Skip to main content

privatus_analytics/
attrs.rs

1//! Data attributes the tracker reads for custom events and click maps.
2
3use crate::html::{escape_html, truncate_chars, SafeHtml};
4use crate::value::Value;
5
6/// Event names are cut to this many characters.
7pub const MAX_NAME: usize = 120;
8/// Prop keys are cut to this many characters.
9pub const MAX_KEY: usize = 64;
10/// At most this many props are kept.
11pub const MAX_PROPS: usize = 30;
12/// Prop values are cut to this many characters.
13pub const MAX_VALUE: usize = 500;
14/// Click map names may be at most this many characters.
15pub const MAX_CLICK_NAME: usize = 100;
16
17/// The attributes for a custom event, as safe HTML to put inside a tag you write.
18///
19/// ```
20/// use privatus_analytics::{event_attrs, props};
21///
22/// let attrs = event_attrs("Export CSV", props! { "format" => "csv", "source" => "toolbar" });
23/// assert_eq!(
24///     attrs.as_str(),
25///     r#"data-privatus-event="Export CSV" data-privatus-prop-format="csv" data-privatus-prop-source="toolbar""#
26/// );
27/// ```
28///
29/// On a `<form>` the event is sent when the form is submitted. The name is
30/// trimmed and cut to 120 characters, and an empty name prints nothing. See
31/// [`event_data`] for the rules on props.
32///
33/// The attributes print even when the tag is off. They do nothing without the
34/// tracker, so your pages look the same in development and production.
35pub fn event_attrs<I, K, V>(name: &str, props: I) -> SafeHtml
36where
37    I: IntoIterator<Item = (K, V)>,
38    K: AsRef<str>,
39    V: Into<Value>,
40{
41    attrs_to_html(&event_data(name, props))
42}
43
44/// The same attributes as [`event_attrs`], as `(name, value)` pairs that are
45/// not escaped yet. Handy for template engines and builders that set
46/// attributes one by one and escape them for you.
47///
48/// * Prop keys are made lowercase. Anything that is not `a-z`, `0-9` or `_`
49///   becomes `_`, so `"Report Type"` becomes `report_type`. Keys are cut to 64
50///   characters.
51/// * At most 30 props. Values become text: `true`, `false`, numbers as
52///   written, lists joined with commas. `None` and [`Value::Null`] are left
53///   out. Values are cut to 500 characters.
54///
55/// Returns an empty list for an empty name.
56pub fn event_data<I, K, V>(name: &str, props: I) -> Vec<(String, String)>
57where
58    I: IntoIterator<Item = (K, V)>,
59    K: AsRef<str>,
60    V: Into<Value>,
61{
62    let name = truncate_chars(name.trim(), MAX_NAME).trim();
63    if name.is_empty() {
64        return Vec::new();
65    }
66    let mut attrs = vec![("data-privatus-event".to_owned(), name.to_owned())];
67    for (key, value) in normalize(props) {
68        let text = string_value(&value);
69        attrs.push((
70            format!("data-privatus-prop-{key}"),
71            truncate_chars(&text, MAX_VALUE).to_owned(),
72        ));
73    }
74    attrs
75}
76
77/// The click map name attribute, as safe HTML.
78///
79/// ```
80/// use privatus_analytics::click_attr;
81///
82/// assert_eq!(click_attr("pricing.cta").as_str(), r#"data-privatus-click="pricing.cta""#);
83/// assert!(click_attr("not valid!").is_empty());
84/// ```
85///
86/// A name may use letters, digits and `_ . : -`, up to 100 characters.
87/// Anything else prints nothing, and in debug builds a warning goes to
88/// standard error.
89pub fn click_attr(name: &str) -> SafeHtml {
90    attrs_to_html(&click_data(name))
91}
92
93/// The click map name as a `(name, value)` pair, or an empty list when the
94/// name is not valid.
95pub fn click_data(name: &str) -> Vec<(String, String)> {
96    if valid_click(name) {
97        vec![("data-privatus-click".to_owned(), name.to_owned())]
98    } else {
99        warn_invalid_click(name);
100        Vec::new()
101    }
102}
103
104/// Whether a click map name is valid: letters, digits and `_ . : -`, from 1 to
105/// 100 characters.
106pub fn valid_click(name: &str) -> bool {
107    !name.is_empty()
108        && name.len() <= MAX_CLICK_NAME
109        && name
110            .bytes()
111            .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'.' | b':' | b'-'))
112}
113
114/// Turns `(name, value)` pairs into HTML attributes, with every value escaped.
115pub fn attrs_to_html(attrs: &[(String, String)]) -> SafeHtml {
116    let parts: Vec<String> = attrs
117        .iter()
118        .map(|(name, value)| format!("{name}=\"{}\"", escape_html(value)))
119        .collect();
120    SafeHtml::from_trusted(parts.join(" "))
121}
122
123#[cfg(debug_assertions)]
124fn warn_invalid_click(name: &str) {
125    eprintln!(
126        "[privatus-analytics] click map name {name:?} is not valid. Use letters, digits and \
127         _ . : - only, at most 100 characters. Nothing was printed."
128    );
129}
130
131#[cfg(not(debug_assertions))]
132fn warn_invalid_click(_name: &str) {}
133
134/// Cleans a prop key: lowercase, anything outside `a-z 0-9 _` becomes `_`,
135/// cut to 64 characters.
136pub fn normalize_key(key: &str) -> String {
137    key.to_lowercase()
138        .chars()
139        .map(|c| {
140            if c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' {
141                c
142            } else {
143                '_'
144            }
145        })
146        .take(MAX_KEY)
147        .collect()
148}
149
150/// Cleans keys, drops empty keys and null values, and keeps the first 30 props.
151/// A key seen twice keeps its first place and gets the later value.
152pub(crate) fn normalize<I, K, V>(props: I) -> Vec<(String, Value)>
153where
154    I: IntoIterator<Item = (K, V)>,
155    K: AsRef<str>,
156    V: Into<Value>,
157{
158    let mut clean: Vec<(String, Value)> = Vec::new();
159    for (key, value) in props {
160        let key = normalize_key(key.as_ref());
161        let value = value.into();
162        if key.is_empty() || value == Value::Null {
163            continue;
164        }
165        match clean.iter_mut().find(|(k, _)| *k == key) {
166            Some(slot) => slot.1 = value,
167            None => clean.push((key, value)),
168        }
169    }
170    clean.truncate(MAX_PROPS);
171    clean
172}
173
174/// A value as attribute text. Lists are joined with commas, nulls skipped.
175pub(crate) fn string_value(value: &Value) -> String {
176    match value {
177        Value::Null => String::new(),
178        Value::Bool(b) => b.to_string(),
179        Value::Int(n) => n.to_string(),
180        Value::UInt(n) => n.to_string(),
181        Value::Float(n) => n.to_string(),
182        Value::Str(s) => s.clone(),
183        Value::List(items) => items
184            .iter()
185            .filter(|item| **item != Value::Null)
186            .map(string_value)
187            .collect::<Vec<_>>()
188            .join(","),
189    }
190}