Skip to main content

sampler_config/assembly/
mod.rs

1// Copyright 2025 The Fuchsia Authors. All rights reserved.
2// Use of this source code is governed by a BSD-style license that can be
3// found in the LICENSE file.
4
5use crate::common::{EventCode, MetricId, MetricType, ProjectId};
6use crate::utils::OneOrMany;
7use anyhow::bail;
8use component_id_index::InstanceId;
9use moniker::ExtendedMoniker;
10use serde::{Deserialize, Deserializer, Serialize, Serializer, de};
11use std::marker::PhantomData;
12use std::str::FromStr;
13
14pub use crate::runtime::{DataSetConfig, MetricConfig, ProjectConfig};
15
16/// Configuration for a single FIRE project template to map Inspect data to its Cobalt metrics
17/// for all components in the ComponentIdInfo.
18///
19/// This type will get converted to an individual `ProjectConfig` and piped to Sampler.
20#[derive(Serialize, Deserialize, Debug, PartialEq, Eq)]
21pub struct ProjectTemplate {
22    /// Project ID that metrics are being sampled and forwarded on behalf of.
23    pub project_id: ProjectId,
24
25    /// The frequency with which metrics are sampled, in seconds.
26    #[serde(deserialize_with = "crate::utils::greater_than_zero")]
27    pub poll_rate_sec: i64,
28
29    /// The collection of mappings from Inspect to Cobalt.
30    pub metrics: Vec<MetricTemplate>,
31}
32
33impl ProjectTemplate {
34    pub fn expand(self, components: &ComponentIdInfoList) -> Result<ProjectConfig, anyhow::Error> {
35        let ProjectTemplate { project_id, poll_rate_sec, metrics } = self;
36        let mut metric_configs = Vec::with_capacity(metrics.len() * components.len());
37        for component in components.iter() {
38            for metric in metrics.iter() {
39                if let Some(metric_config) = metric.expand(component)? {
40                    metric_configs.push(metric_config);
41                }
42            }
43        }
44
45        let data_sets = vec![DataSetConfig { poll_rate_sec, metrics: metric_configs }];
46
47        Ok(ProjectConfig { project_id, data_sets })
48    }
49}
50
51/// Configuration for a single FIRE metric template to map from an Inspect property
52/// to a cobalt metric. Unlike MetricConfig, selectors aren't parsed.
53#[derive(Clone, Serialize, Deserialize, Debug, PartialEq, Eq)]
54pub struct MetricTemplate {
55    /// Selector identifying the metric to
56    /// sample via the diagnostics platform.
57    #[serde(rename = "selector", deserialize_with = "one_or_many_strings")]
58    pub selectors: Vec<String>,
59
60    /// Cobalt metric id to map the selector to.
61    pub metric_id: MetricId,
62
63    /// Data type to transform the metric to.
64    pub metric_type: MetricType,
65
66    /// Event codes defining the dimensions of the
67    /// cobalt metric.
68    /// Notes:
69    /// - Order matters, and must match the order of the defined dimensions
70    ///   in the cobalt metric file.
71    /// - The FIRE component-ID will be inserted as the first element of event_codes.
72    /// - The event_codes field may be omitted from the config file if component-ID is the only
73    ///   event code.
74    #[serde(default)]
75    pub event_codes: Vec<EventCode>,
76
77    /// Optional boolean specifying whether to upload the specified metric only once, the first time
78    /// it becomes available to the sampler. Defaults to false.
79    #[serde(default)]
80    pub upload_once: bool,
81}
82
83#[derive(Deserialize, Serialize, Debug, PartialEq, Eq)]
84pub struct ComponentIdInfo {
85    /// The component's moniker
86    #[serde(deserialize_with = "moniker_deserialize", serialize_with = "moniker_serialize")]
87    pub moniker: ExtendedMoniker,
88
89    /// The Component Instance ID - may not be available
90    #[serde(
91        default,
92        deserialize_with = "instance_id_deserialize",
93        serialize_with = "instance_id_serialize"
94    )]
95    pub instance_id: Option<InstanceId>,
96
97    /// The ID sent to Cobalt as an event code
98    #[serde(alias = "id")]
99    pub event_id: EventCode,
100
101    /// Human-readable label, not used by Sampler, only for human configs.
102    pub label: String,
103}
104
105#[derive(Serialize, Deserialize, Default, Debug, PartialEq, Eq)]
106pub struct ComponentIdInfoList(Vec<ComponentIdInfo>);
107
108impl ComponentIdInfoList {
109    pub fn new(infos: Vec<ComponentIdInfo>) -> Self {
110        Self(infos)
111    }
112
113    pub fn add_instance_ids(&mut self, index: &component_id_index::Index) {
114        for component in self.0.iter_mut() {
115            if let ExtendedMoniker::ComponentInstance(moniker) = &component.moniker {
116                component.instance_id = index.id_for_moniker(moniker).cloned();
117            }
118        }
119    }
120
121    pub fn iter(&self) -> impl Iterator<Item = &ComponentIdInfo> {
122        self.0.iter()
123    }
124}
125
126impl std::ops::Deref for ComponentIdInfoList {
127    type Target = Vec<ComponentIdInfo>;
128
129    fn deref(&self) -> &Self::Target {
130        &self.0
131    }
132}
133
134impl std::ops::DerefMut for ComponentIdInfoList {
135    fn deref_mut(&mut self) -> &mut Self::Target {
136        &mut self.0
137    }
138}
139
140impl IntoIterator for ComponentIdInfoList {
141    type Item = ComponentIdInfo;
142    type IntoIter = std::vec::IntoIter<Self::Item>;
143
144    fn into_iter(self) -> Self::IntoIter {
145        self.0.into_iter()
146    }
147}
148
149impl MetricTemplate {
150    fn expand(&self, component: &ComponentIdInfo) -> Result<Option<MetricConfig>, anyhow::Error> {
151        let MetricTemplate { selectors, metric_id, metric_type, event_codes, upload_once } = self;
152        let mut result_selectors = Vec::with_capacity(selectors.len());
153        for selector in selectors {
154            if let Some(selector_string) = interpolate_template(selector, component)? {
155                let selector = crate::utils::parse_selector(&selector_string)?;
156                result_selectors.push(selector);
157            }
158        }
159        if result_selectors.is_empty() {
160            return Ok(None);
161        }
162        let mut event_codes = event_codes.to_vec();
163        event_codes.insert(0, component.event_id);
164        Ok(Some(MetricConfig {
165            event_codes,
166            selectors: result_selectors,
167            metric_id: *metric_id,
168            metric_type: *metric_type,
169            upload_once: *upload_once,
170        }))
171    }
172}
173
174const MONIKER_INTERPOLATION: &str = "{MONIKER}";
175const INSTANCE_ID_INTERPOLATION: &str = "{INSTANCE_ID}";
176
177/// Returns Ok(None) if the template needs a component ID and there's none available for
178/// the component. This is not an error and should be handled silently.
179fn interpolate_template(
180    template: &str,
181    component_info: &ComponentIdInfo,
182) -> Result<Option<String>, anyhow::Error> {
183    let moniker_position = template.find(MONIKER_INTERPOLATION);
184    let instance_id_position = template.find(INSTANCE_ID_INTERPOLATION);
185    let separator_position = template.find(":");
186    // If the insert position is before the first colon, it's the selector's moniker and
187    // slashes should not be escaped.
188    // Otherwise, treat the moniker string as a single Node or Property name,
189    // and escape the appropriate characters.
190    // Instance IDs have no special characters and don't need escaping.
191    match (moniker_position, separator_position, instance_id_position, &component_info.instance_id)
192    {
193        (Some(i), Some(s), _, _) if i < s => {
194            Ok(Some(template.replace(MONIKER_INTERPOLATION, component_info.moniker.as_ref())))
195        }
196        (Some(_), Some(_), _, _) => Ok(Some(template.replace(
197            MONIKER_INTERPOLATION,
198            &selectors::sanitize_string_for_selectors(component_info.moniker.as_ref()),
199        ))),
200        (_, _, Some(_), Some(id)) => {
201            Ok(Some(template.replace(INSTANCE_ID_INTERPOLATION, &id.to_string())))
202        }
203        (_, _, Some(_), None) => Ok(None),
204        (None, _, None, _) => {
205            bail!(
206                "{} and {} not found in selector template {}",
207                MONIKER_INTERPOLATION,
208                INSTANCE_ID_INTERPOLATION,
209                template
210            )
211        }
212        (Some(_), None, _, _) => {
213            bail!("Separator ':' not found in selector template {}", template)
214        }
215    }
216}
217
218fn moniker_deserialize<'de, D>(deserializer: D) -> Result<ExtendedMoniker, D::Error>
219where
220    D: Deserializer<'de>,
221{
222    let moniker_str = String::deserialize(deserializer)?;
223    ExtendedMoniker::parse_str(&moniker_str).map_err(de::Error::custom)
224}
225
226fn instance_id_deserialize<'de, D>(deserializer: D) -> Result<Option<InstanceId>, D::Error>
227where
228    D: Deserializer<'de>,
229{
230    match Option::<String>::deserialize(deserializer)? {
231        None => Ok(None),
232        Some(instance_id) => {
233            let instance_id = InstanceId::from_str(&instance_id).map_err(de::Error::custom)?;
234            Ok(Some(instance_id))
235        }
236    }
237}
238
239pub fn moniker_serialize<S>(moniker: &ExtendedMoniker, serializer: S) -> Result<S::Ok, S::Error>
240where
241    S: Serializer,
242{
243    serializer.serialize_str(moniker.as_ref())
244}
245
246pub fn instance_id_serialize<S>(
247    instance_id: &Option<InstanceId>,
248    serializer: S,
249) -> Result<S::Ok, S::Error>
250where
251    S: Serializer,
252{
253    match instance_id.as_ref() {
254        Some(instance_id) => serializer.serialize_some(&instance_id.to_string()),
255        None => serializer.serialize_none(),
256    }
257}
258
259fn one_or_many_strings<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
260where
261    D: Deserializer<'de>,
262{
263    deserializer.deserialize_any(OneOrMany(PhantomData::<String>))
264}
265
266#[derive(Serialize, Deserialize, Default)]
267pub struct MergedSamplerConfig {
268    pub fire_project_templates: Vec<ProjectTemplate>,
269    pub fire_component_configs: Vec<ComponentIdInfoList>,
270    pub project_configs: Vec<ProjectConfig>,
271}
272
273#[cfg(test)]
274mod tests {
275    use super::*;
276    use component_id_index::IndexEntry;
277    use moniker::Moniker;
278    use selectors::FastError;
279    use std::str::FromStr;
280
281    #[fuchsia::test]
282    fn parse_project_template() {
283        let template = r#"{
284            "project_id": 13,
285            "poll_rate_sec": 60,
286            "metrics": [
287                {
288                "selector": [
289                    "{MONIKER}:root/path2:leaf2",
290                    "foo/bar:root/{MONIKER}:leaf3",
291                    "asdf/qwer:root/path4:pre-{MONIKER}-post",
292                ],
293                "metric_id": 2,
294                "metric_type": "Occurrence",
295                }
296            ]
297        }"#;
298        let config: ProjectTemplate = serde_json5::from_str(template).expect("deserialize");
299        assert_eq!(
300            config,
301            ProjectTemplate {
302                project_id: ProjectId(13),
303                poll_rate_sec: 60,
304                metrics: vec![MetricTemplate {
305                    selectors: vec![
306                        "{MONIKER}:root/path2:leaf2".into(),
307                        "foo/bar:root/{MONIKER}:leaf3".into(),
308                        "asdf/qwer:root/path4:pre-{MONIKER}-post".into(),
309                    ],
310                    event_codes: vec![],
311                    upload_once: false,
312                    metric_id: MetricId(2),
313                    metric_type: MetricType::Occurrence,
314                }],
315            }
316        );
317    }
318
319    #[fuchsia::test]
320    fn parse_component_config() {
321        let components_json = r#"[
322            {
323                "id": 42,
324                "label": "Foo_42",
325                "moniker": "core/foo42"
326            },
327            {
328                "id": 10,
329                "label": "Hello",
330                "instance_id": "8775ff0afe12ca578135014a5d36a7733b0f9982bcb62a888b007cb2c31a7046",
331                "moniker": "bootstrap/hello"
332            }
333        ]"#;
334        let config: ComponentIdInfoList =
335            serde_json5::from_str(components_json).expect("deserialize");
336        assert_eq!(
337            config,
338            ComponentIdInfoList(vec![
339                ComponentIdInfo {
340                    moniker: ExtendedMoniker::parse_str("core/foo42").unwrap(),
341                    instance_id: None,
342                    event_id: EventCode(42),
343                    label: "Foo_42".into()
344                },
345                ComponentIdInfo {
346                    moniker: ExtendedMoniker::parse_str("bootstrap/hello").unwrap(),
347                    instance_id: Some(
348                        InstanceId::from_str(
349                            "8775ff0afe12ca578135014a5d36a7733b0f9982bcb62a888b007cb2c31a7046"
350                        )
351                        .unwrap()
352                    ),
353                    event_id: EventCode(10),
354                    label: "Hello".into()
355                },
356            ])
357        );
358    }
359
360    #[fuchsia::test]
361    fn template_expansion_basic() {
362        let project_template = ProjectTemplate {
363            project_id: ProjectId(13),
364            poll_rate_sec: 60,
365            metrics: vec![MetricTemplate {
366                selectors: vec!["{MONIKER}:root/path:leaf".into()],
367                metric_id: MetricId(1),
368                metric_type: MetricType::Occurrence,
369                event_codes: vec![EventCode(1), EventCode(2)],
370                upload_once: true,
371            }],
372        };
373        let component_info = ComponentIdInfoList(vec![
374            ComponentIdInfo {
375                moniker: ExtendedMoniker::parse_str("core/foo42").unwrap(),
376                instance_id: None,
377                event_id: EventCode(42),
378                label: "Foo_42".into(),
379            },
380            ComponentIdInfo {
381                moniker: ExtendedMoniker::parse_str("bootstrap/hello").unwrap(),
382                instance_id: Some(
383                    InstanceId::from_str(
384                        "8775ff0afe12ca578135014a5d36a7733b0f9982bcb62a888b007cb2c31a7046",
385                    )
386                    .unwrap(),
387                ),
388                event_id: EventCode(43),
389                label: "Hello".into(),
390            },
391        ]);
392        let config = project_template.expand(&component_info).expect("expanded template");
393        assert_eq!(
394            config,
395            ProjectConfig {
396                project_id: ProjectId(13),
397                data_sets: vec![DataSetConfig {
398                    poll_rate_sec: 60,
399                    metrics: vec![
400                        MetricConfig {
401                            selectors: vec![
402                                selectors::parse_selector::<FastError>("core/foo42:root/path:leaf")
403                                    .unwrap()
404                            ],
405                            metric_id: MetricId(1),
406                            metric_type: MetricType::Occurrence,
407                            event_codes: vec![EventCode(42), EventCode(1), EventCode(2)],
408                            upload_once: true,
409                        },
410                        MetricConfig {
411                            selectors: vec![
412                                selectors::parse_selector::<FastError>(
413                                    "bootstrap/hello:root/path:leaf"
414                                )
415                                .unwrap()
416                            ],
417                            metric_id: MetricId(1),
418                            metric_type: MetricType::Occurrence,
419                            event_codes: vec![EventCode(43), EventCode(1), EventCode(2)],
420                            upload_once: true,
421                        },
422                    ],
423                }],
424            }
425        );
426    }
427
428    #[fuchsia::test]
429    fn template_expansion_many_selectors() {
430        let project_template = ProjectTemplate {
431            project_id: ProjectId(13),
432            poll_rate_sec: 60,
433            metrics: vec![MetricTemplate {
434                selectors: vec![
435                    "{MONIKER}:root/path2:leaf2".into(),
436                    "foo/bar:root/{MONIKER}:leaf3".into(),
437                    "asdf/qwer:root/path4:pre-{MONIKER}-post".into(),
438                ],
439                metric_id: MetricId(2),
440                metric_type: MetricType::Occurrence,
441                event_codes: vec![],
442                upload_once: false,
443            }],
444        };
445        let component_info = ComponentIdInfoList(vec![
446            ComponentIdInfo {
447                moniker: ExtendedMoniker::parse_str("core/foo42").unwrap(),
448                instance_id: None,
449                event_id: EventCode(42),
450                label: "Foo_42".into(),
451            },
452            ComponentIdInfo {
453                moniker: ExtendedMoniker::parse_str("bootstrap/hello").unwrap(),
454                instance_id: Some(
455                    InstanceId::from_str(
456                        "8775ff0afe12ca578135014a5d36a7733b0f9982bcb62a888b007cb2c31a7046",
457                    )
458                    .unwrap(),
459                ),
460                event_id: EventCode(43),
461                label: "Hello".into(),
462            },
463        ]);
464        let config = project_template.expand(&component_info).expect("expanded template");
465        assert_eq!(
466            config,
467            ProjectConfig {
468                project_id: ProjectId(13),
469                data_sets: vec![DataSetConfig {
470                    poll_rate_sec: 60,
471                    metrics: vec![
472                        MetricConfig {
473                            selectors: vec![
474                                selectors::parse_selector::<FastError>(
475                                    "core/foo42:root/path2:leaf2"
476                                )
477                                .unwrap(),
478                                selectors::parse_selector::<FastError>(
479                                    "foo/bar:root/core\\/foo42:leaf3"
480                                )
481                                .unwrap(),
482                                selectors::parse_selector::<FastError>(
483                                    "asdf/qwer:root/path4:pre-core\\/foo42-post"
484                                )
485                                .unwrap()
486                            ],
487                            metric_id: MetricId(2),
488                            metric_type: MetricType::Occurrence,
489                            event_codes: vec![EventCode(42)],
490                            upload_once: false,
491                        },
492                        MetricConfig {
493                            selectors: vec![
494                                selectors::parse_selector::<FastError>(
495                                    "bootstrap/hello:root/path2:leaf2"
496                                )
497                                .unwrap(),
498                                selectors::parse_selector::<FastError>(
499                                    "foo/bar:root/bootstrap\\/hello:leaf3"
500                                )
501                                .unwrap(),
502                                selectors::parse_selector::<FastError>(
503                                    "asdf/qwer:root/path4:pre-bootstrap\\/hello-post"
504                                )
505                                .unwrap()
506                            ],
507                            metric_id: MetricId(2),
508                            metric_type: MetricType::Occurrence,
509                            event_codes: vec![EventCode(43)],
510                            upload_once: false,
511                        },
512                    ],
513                }],
514            }
515        );
516    }
517
518    #[fuchsia::test]
519    fn index_substitution_works() {
520        let mut ids = component_id_index::Index::default();
521        let foo_bar_moniker = Moniker::parse_str("foo/bar").unwrap();
522        let qwer_asdf_moniker = Moniker::parse_str("qwer/asdf").unwrap();
523        ids.insert(IndexEntry {
524            moniker: foo_bar_moniker,
525            instance_id: "1234123412341234123412341234123412341234123412341234123412341234"
526                .parse::<InstanceId>()
527                .unwrap(),
528            ignore_duplicate_id: false,
529        })
530        .unwrap();
531        ids.insert(IndexEntry {
532            moniker: qwer_asdf_moniker,
533            instance_id: "1234abcd1234abcd123412341234123412341234123412341234123412341234"
534                .parse::<InstanceId>()
535                .unwrap(),
536            ignore_duplicate_id: false,
537        })
538        .unwrap();
539        let mut components = ComponentIdInfoList(vec![
540            ComponentIdInfo {
541                moniker: "baz/quux".try_into().unwrap(),
542                event_id: EventCode(101),
543                label: "bq".into(),
544                instance_id: None,
545            },
546            ComponentIdInfo {
547                moniker: "foo/bar".try_into().unwrap(),
548                event_id: EventCode(102),
549                label: "fb".into(),
550                instance_id: None,
551            },
552        ]);
553        components.add_instance_ids(&ids);
554        let moniker_template = "fizz/buzz:root/info/{MONIKER}/data";
555        let id_template = "fizz/buzz:root/info/{INSTANCE_ID}/data";
556        assert_eq!(
557            interpolate_template(moniker_template, &components[0]).unwrap().unwrap(),
558            "fizz/buzz:root/info/baz\\/quux/data".to_string()
559        );
560        assert_eq!(
561            interpolate_template(moniker_template, &components[1]).unwrap().unwrap(),
562            "fizz/buzz:root/info/foo\\/bar/data".to_string()
563        );
564        assert_eq!(interpolate_template(id_template, &components[0]).unwrap(), None);
565        assert_eq!(
566            interpolate_template(id_template, &components[1]).unwrap().unwrap(),
567            "fizz/buzz:root/info/1234123412341234123412341234123412341234123412341234123412341234/data"
568        );
569    }
570
571    #[derive(Debug, Deserialize, Eq, PartialEq)]
572    struct TestString(#[serde(deserialize_with = "super::one_or_many_strings")] Vec<String>);
573
574    #[fuchsia::test]
575    fn parse_valid_single_string() {
576        let json = "\"whatever-1982035*()$*H\"";
577        let data: TestString = serde_json5::from_str(json).expect("deserialize");
578        assert_eq!(data, TestString(vec!["whatever-1982035*()$*H".into()]));
579    }
580
581    #[fuchsia::test]
582    fn parse_valid_multiple_strings() {
583        let json = "[ \"core/foo:not:a:selector:root/branch:leaf\", \"core/bar:root/twig:leaf\"]";
584        let data: TestString = serde_json5::from_str(json).expect("deserialize");
585        assert_eq!(
586            data,
587            TestString(vec![
588                "core/foo:not:a:selector:root/branch:leaf".into(),
589                "core/bar:root/twig:leaf".into()
590            ])
591        );
592    }
593
594    #[fuchsia::test]
595    fn refuse_invalid_strings() {
596        let not_string = "42";
597        let bad_list = "[ 42, \"core/bar:not:a:selector:root/twig:leaf\"]";
598        serde_json5::from_str::<TestString>(not_string).expect_err("should fail");
599        serde_json5::from_str::<TestString>(bad_list).expect_err("should fail");
600    }
601}