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