Skip to main content

sofie_model/
telemetry.rs

1use std::path::{Path, PathBuf};
2
3use syn::punctuated::Punctuated;
4use syn::visit::{self, Visit};
5use syn::{Expr, Item, ItemStruct, Macro, Token};
6
7use crate::code::derives;
8use crate::syntax::{has_attribute, line_of, path_ends_with, segments};
9
10/// `product_event!(order.placed, order.total)`: its name and the values it records, as written.
11#[derive(Clone, Debug, PartialEq, Eq)]
12pub struct ProductEvent {
13    pub name: String,
14    pub values: Vec<String>,
15    pub file: PathBuf,
16    pub line: usize,
17}
18
19#[derive(Clone, Debug, Default, PartialEq, Eq)]
20pub struct TelemetryCode {
21    pub product_events: Vec<ProductEvent>,
22    /// Functions marked `#[span]`, each in a `step` span named after its path.
23    pub steps: Vec<Vec<String>>,
24    /// `#[derive(Value)]` types marked `#[sensitive]`, which log only their mask.
25    pub sensitive_values: Vec<String>,
26    /// Aggregate fields marked `#[sensitive]`, as (aggregate, field), which log only `***`.
27    pub sensitive_fields: Vec<(String, String)>,
28}
29
30impl TelemetryCode {
31    pub(crate) fn collect(&mut self, item: &Item, file: &Path, module_path: &[String]) {
32        let mut calls = ProductEventCalls {
33            file,
34            found: Vec::new(),
35        };
36        calls.visit_item(item);
37        self.product_events.extend(calls.found);
38        match item {
39            Item::Fn(function) if has_attribute(&function.attrs, "span") => {
40                self.steps
41                    .push([module_path, &[function.sig.ident.to_string()]].concat());
42            }
43            Item::Struct(structure) => self.collect_sensitive(structure),
44            _ => {}
45        }
46    }
47
48    fn collect_sensitive(&mut self, structure: &ItemStruct) {
49        let owner = structure.ident.to_string();
50        if derives(&structure.attrs, "Value") && has_attribute(&structure.attrs, "sensitive") {
51            self.sensitive_values.push(owner);
52            return;
53        }
54        if !derives(&structure.attrs, "Aggregate") {
55            return;
56        }
57        let fields = structure
58            .fields
59            .iter()
60            .filter(|field| has_attribute(&field.attrs, "sensitive"))
61            .filter_map(|field| field.ident.as_ref());
62        for field in fields {
63            self.sensitive_fields
64                .push((owner.clone(), field.to_string()));
65        }
66    }
67}
68
69struct ProductEventCalls<'file> {
70    file: &'file Path,
71    found: Vec<ProductEvent>,
72}
73
74impl Visit<'_> for ProductEventCalls<'_> {
75    fn visit_macro(&mut self, call: &Macro) {
76        if !path_ends_with(&call.path, &["product_event"]) {
77            visit::visit_macro(self, call);
78            return;
79        }
80        let arguments = call
81            .parse_body_with(Punctuated::<Expr, Token![,]>::parse_terminated)
82            .unwrap_or_default();
83        let mut written = arguments.iter().filter_map(dotted);
84        let Some(name) = written.next() else { return };
85        self.found.push(ProductEvent {
86            name,
87            values: written.collect(),
88            file: self.file.to_path_buf(),
89            line: line_of(&call.path),
90        });
91    }
92}
93
94fn dotted(expression: &Expr) -> Option<String> {
95    match expression {
96        Expr::Path(path) => Some(segments(&path.path).join(".")),
97        Expr::Field(field) => {
98            let member = match &field.member {
99                syn::Member::Named(ident) => ident.to_string(),
100                syn::Member::Unnamed(index) => index.index.to_string(),
101            };
102            Some(format!("{}.{member}", dotted(&field.base)?))
103        }
104        _ => None,
105    }
106}
107
108#[cfg(test)]
109mod tests {
110    use std::path::Path;
111
112    use super::TelemetryCode;
113
114    #[test]
115    fn reads_product_events_steps_and_sensitive_values_and_fields() {
116        let file: syn::File = syn::parse_str(
117            r"
118            #[derive(Value)]
119            #[sensitive]
120            pub struct TaxNumber(String);
121
122            #[derive(Value)]
123            pub struct Note(String);
124
125            #[derive(Aggregate)]
126            pub struct Payer {
127                meta: Meta,
128                name: String,
129                #[sensitive]
130                iban: String,
131            }
132
133            #[span]
134            async fn reserve_stock() {}
135
136            pub async fn place_order(order: Order) {
137                sofie::telemetry::product_event!(order.placed, order.total, count);
138            }
139            ",
140        )
141        .unwrap();
142        let mut code = TelemetryCode::default();
143        for item in &file.items {
144            code.collect(item, Path::new("actions.rs"), &["actions".to_owned()]);
145        }
146
147        let events: Vec<(&str, &[String], usize)> = code
148            .product_events
149            .iter()
150            .map(|event| (event.name.as_str(), event.values.as_slice(), event.line))
151            .collect();
152        assert_eq!(
153            events,
154            [(
155                "order.placed",
156                &["order.total".to_owned(), "count".to_owned()][..],
157                21
158            )]
159        );
160        assert_eq!(code.steps, [["actions", "reserve_stock"]]);
161        assert_eq!(code.sensitive_values, ["TaxNumber"]);
162        assert_eq!(
163            code.sensitive_fields,
164            [("Payer".to_owned(), "iban".to_owned())]
165        );
166    }
167}