mesh_llm_analytics/event.rs
1//! Typed analytics events and a closed property vocabulary.
2//!
3//! The vocabulary is deliberately closed. Callers cannot hand this crate an
4//! arbitrary `String`, so a prompt, a completion, a filesystem path, a peer
5//! address, or an API key cannot reach the wire by accident: the only way to
6//! attach text is [`Label::sanitize`], which enforces a catalog-shaped grammar
7//! and rejects everything else.
8
9use serde::Serialize;
10use std::collections::BTreeMap;
11
12/// The closed set of events mesh-llm reports.
13///
14/// Adding a variant here is the only way to report a new event, which keeps
15/// the reported surface reviewable in one place.
16#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd)]
17pub enum Event {
18 /// First run of a given install. Emitted once per install identifier.
19 InstallFirstRun,
20 /// An existing install started running a different version than last time.
21 ///
22 /// Emitted by whichever process first observes the change, so it covers
23 /// every upgrade route: `--auto-update`, `mesh-llm update`, a re-run of
24 /// `install.sh`, a package manager, or a hand-swapped binary.
25 InstallUpdated,
26 /// A one-shot CLI command finished. Carries only family and outcome.
27 CliCommand,
28 /// `mesh-llm serve` reached a serving state.
29 ServeStarted,
30 /// A `serve` process shut down.
31 ServeStopped,
32 /// A model became resident on this node.
33 ModelLoaded,
34 /// A model download was attempted.
35 ModelDownload,
36 /// The shape of this machine, reported once per serving process.
37 HardwareProfile,
38}
39
40impl Event {
41 /// The event name as PostHog stores it.
42 pub const fn name(self) -> &'static str {
43 match self {
44 Self::InstallFirstRun => "install_first_run",
45 Self::InstallUpdated => "install_updated",
46 Self::CliCommand => "cli_command",
47 Self::ServeStarted => "serve_started",
48 Self::ServeStopped => "serve_stopped",
49 Self::ModelLoaded => "model_loaded",
50 Self::ModelDownload => "model_download",
51 Self::HardwareProfile => "hardware_profile",
52 }
53 }
54}
55
56/// A grammar-checked string property.
57///
58/// Construction is only possible through [`Label::sanitize`]. The grammar
59/// admits catalog and repository identifiers (`Qwen2.5-7B-Instruct-Q4_K_M`,
60/// `Qwen/Qwen2.5-7B`) and rejects anything path-shaped, whitespace-bearing,
61/// or over-long.
62///
63/// ```
64/// use mesh_llm_analytics::Label;
65///
66/// assert!(Label::sanitize("Qwen/Qwen2.5-7B").is_some());
67/// assert!(Label::sanitize("/Users/dan/models/private.gguf").is_none());
68/// ```
69#[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Serialize)]
70pub struct Label(String);
71
72/// Longest accepted label. Catalog names sit far below this.
73const MAX_LABEL_LEN: usize = 96;
74
75/// Longest accepted slug. Device names sit well below this; prose does not.
76const MAX_SLUG_LEN: usize = 48;
77
78/// Most hyphen-separated segments a slug may have.
79///
80/// `nvidia-geforce-rtx-4090-laptop-gpu` is six, which is about the longest
81/// real device name. A sentence has many more.
82const MAX_SLUG_SEGMENTS: usize = 8;
83
84impl Label {
85 /// Accept `value` only if it matches the label grammar.
86 ///
87 /// The grammar is: ASCII alphanumerics plus `.`, `-`, `_`, `+`, and at
88 /// most one interior `/`; no leading or trailing separator; no `..`; at
89 /// most [`MAX_LABEL_LEN`] characters.
90 ///
91 /// `+` is admitted for semver build metadata (`0.76.0+gABCDEF.dirty`),
92 /// which is the version string this crate reports about itself.
93 pub fn sanitize(value: &str) -> Option<Self> {
94 if value.is_empty() || value.len() > MAX_LABEL_LEN {
95 return None;
96 }
97 if value.contains("..") {
98 return None;
99 }
100 if value.matches('/').count() > 1 {
101 return None;
102 }
103 if value.starts_with(['/', '-', '.']) || value.ends_with('/') {
104 return None;
105 }
106 let permitted = value
107 .chars()
108 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_' | '+' | '/'));
109 permitted.then(|| Self(value.to_owned()))
110 }
111
112 /// Normalize a human-readable device name into a label.
113 ///
114 /// Hardware probes return names like `Apple M4 Max` or
115 /// `NVIDIA GeForce RTX 4090`, which [`Label::sanitize`] rejects for
116 /// containing spaces.
117 ///
118 /// Collapsing separators is dangerous on its own: it would turn
119 /// `/home/dan/my model.gguf` into the perfectly valid-looking
120 /// `home-dan-my-model.gguf`, laundering a path past the grammar. Two
121 /// extra rules prevent that, and they are why this is a distinct
122 /// constructor rather than a pre-pass on `sanitize`:
123 ///
124 /// - Input containing a path separator is refused outright, never
125 /// normalized.
126 /// - The result is capped at [`MAX_SLUG_SEGMENTS`] segments and
127 /// [`MAX_SLUG_LEN`] characters, which every real device name fits and
128 /// prose does not.
129 ///
130 /// ```
131 /// use mesh_llm_analytics::Label;
132 ///
133 /// assert_eq!(Label::slug("Apple M4 Max").unwrap().as_str(), "apple-m4-max");
134 /// assert!(Label::slug("/Users/dan/models/private.gguf").is_none());
135 /// ```
136 pub fn slug(value: &str) -> Option<Self> {
137 // Refuse path-shaped input rather than normalizing the separators
138 // away. This is the rule that stops slugging from laundering a path.
139 if value.contains('/') || value.contains('\\') {
140 return None;
141 }
142
143 let mut slugged = String::with_capacity(value.len());
144 let mut pending_separator = false;
145 for ch in value.trim().chars() {
146 if ch.is_ascii_alphanumeric() || matches!(ch, '.' | '+') {
147 if pending_separator && !slugged.is_empty() {
148 slugged.push('-');
149 }
150 pending_separator = false;
151 slugged.extend(ch.to_lowercase());
152 } else {
153 // Every non-kept character collapses into a single `-`, so
154 // runs of spaces and punctuation do not produce `--`.
155 pending_separator = true;
156 }
157 }
158
159 if slugged.len() > MAX_SLUG_LEN || slugged.split('-').count() > MAX_SLUG_SEGMENTS {
160 return None;
161 }
162 Self::sanitize(&slugged)
163 }
164
165 /// A label for a value that failed sanitization, so counts stay complete.
166 #[must_use]
167 pub fn redacted() -> Self {
168 Self("redacted".to_owned())
169 }
170
171 /// Sanitize `value`, falling back to [`Label::redacted`].
172 ///
173 /// Use this when the count matters more than the identity, so an
174 /// unrecognized model name still contributes to totals without leaking
175 /// whatever the user actually typed.
176 #[must_use]
177 pub fn sanitize_or_redact(value: &str) -> Self {
178 Self::sanitize(value).unwrap_or_else(Self::redacted)
179 }
180
181 /// Slug `value`, falling back to [`Label::redacted`].
182 #[must_use]
183 pub fn slug_or_redact(value: &str) -> Self {
184 Self::slug(value).unwrap_or_else(Self::redacted)
185 }
186
187 #[must_use]
188 pub fn as_str(&self) -> &str {
189 &self.0
190 }
191}
192
193/// A single property value. Deliberately not `From<String>`.
194#[derive(Clone, Debug, PartialEq, Serialize)]
195#[serde(untagged)]
196pub enum Value {
197 Bool(bool),
198 Int(i64),
199 Text(Label),
200 /// A compile-time constant, safe by construction.
201 Static(&'static str),
202}
203
204impl From<bool> for Value {
205 fn from(value: bool) -> Self {
206 Self::Bool(value)
207 }
208}
209
210impl From<i64> for Value {
211 fn from(value: i64) -> Self {
212 Self::Int(value)
213 }
214}
215
216impl From<u64> for Value {
217 fn from(value: u64) -> Self {
218 Self::Int(i64::try_from(value).unwrap_or(i64::MAX))
219 }
220}
221
222impl From<usize> for Value {
223 fn from(value: usize) -> Self {
224 Self::Int(i64::try_from(value).unwrap_or(i64::MAX))
225 }
226}
227
228impl From<Label> for Value {
229 fn from(value: Label) -> Self {
230 Self::Text(value)
231 }
232}
233
234impl From<&'static str> for Value {
235 fn from(value: &'static str) -> Self {
236 Self::Static(value)
237 }
238}
239
240/// Properties attached to one event.
241#[derive(Clone, Debug, Default, PartialEq, Serialize)]
242pub struct Properties(BTreeMap<&'static str, Value>);
243
244impl Properties {
245 #[must_use]
246 pub fn new() -> Self {
247 Self::default()
248 }
249
250 /// Attach one property. Keys are `'static` so they cannot be user data.
251 #[must_use]
252 pub fn with(mut self, key: &'static str, value: impl Into<Value>) -> Self {
253 self.0.insert(key, value.into());
254 self
255 }
256
257 pub(crate) fn entries(&self) -> impl Iterator<Item = (&'static str, &Value)> {
258 self.0.iter().map(|(key, value)| (*key, value))
259 }
260}
261
262/// Bucket a count so a precise fingerprint never leaves the machine.
263///
264/// Exact peer or GPU counts are unusual enough at the tail to be identifying;
265/// the buckets keep the distribution useful without that.
266#[must_use]
267pub fn bucket_count(count: u64) -> &'static str {
268 match count {
269 0 => "0",
270 1 => "1",
271 2 => "2",
272 3..=4 => "3-4",
273 5..=8 => "5-8",
274 9..=16 => "9-16",
275 17..=32 => "17-32",
276 _ => "33+",
277 }
278}
279
280/// Bucket a byte size into human power-of-two ranges.
281#[must_use]
282pub fn bucket_gigabytes(bytes: u64) -> &'static str {
283 const GB: u64 = 1024 * 1024 * 1024;
284 match bytes / GB {
285 0..=7 => "0-8",
286 8..=15 => "8-16",
287 16..=31 => "16-32",
288 32..=63 => "32-64",
289 64..=127 => "64-128",
290 128..=255 => "128-256",
291 _ => "256+",
292 }
293}
294
295/// Bucket a duration in seconds into coarse session lengths.
296#[must_use]
297pub fn bucket_duration_secs(secs: u64) -> &'static str {
298 match secs {
299 0..=59 => "under_1m",
300 60..=899 => "1m-15m",
301 900..=3599 => "15m-1h",
302 3600..=21599 => "1h-6h",
303 21600..=86399 => "6h-24h",
304 _ => "over_24h",
305 }
306}
307
308#[cfg(test)]
309#[path = "event/tests.rs"]
310mod tests;