Skip to main content

lean_ctx/core/
telemetry_v2.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Privacy-bounded, versioned telemetry event contract.
4
5use chrono::NaiveDate;
6use serde::{Deserialize, Serialize};
7use uuid::Uuid;
8
9pub const SCHEMA_VERSION: u16 = 2;
10pub const MAX_COUNT: u64 = 1_000_000_000;
11pub const MAX_HISTOGRAM_BUCKETS: usize = 32;
12pub const MAX_BATCH_EVENTS: usize = 64;
13/// Upper bound on distinct tools in one `tool_call_aggregate` event. The
14/// built-in registry holds fewer than 100 tools.
15pub const MAX_TOOL_ENTRIES: usize = 128;
16pub const MAX_TOOL_NAME_LEN: usize = 64;
17
18#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
19#[serde(deny_unknown_fields)]
20pub struct TelemetryBatchV2 {
21    pub schema_version: u16,
22    pub deletion_token_hash: String,
23    pub events: Vec<TelemetryEnvelopeV2>,
24}
25
26impl TelemetryBatchV2 {
27    pub fn validate(&self) -> Result<(), TelemetryValidationError> {
28        if self.schema_version != SCHEMA_VERSION {
29            return Err(TelemetryValidationError::SchemaVersion);
30        }
31        if !valid_digest(&self.deletion_token_hash) {
32            return Err(TelemetryValidationError::DeletionTokenHash);
33        }
34        if self.events.is_empty() || self.events.len() > MAX_BATCH_EVENTS {
35            return Err(TelemetryValidationError::BatchSize);
36        }
37        self.events
38            .iter()
39            .try_for_each(TelemetryEnvelopeV2::validate)
40    }
41}
42
43fn valid_digest(value: &str) -> bool {
44    value.len() == 64
45        && value
46            .bytes()
47            .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())
48}
49
50#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
51#[serde(deny_unknown_fields)]
52pub struct TelemetryEnvelopeV2 {
53    pub schema_version: u16,
54    pub timestamp_bucket: String,
55    pub installation_id: String,
56    #[serde(skip_serializing_if = "Option::is_none")]
57    pub account_id: Option<PseudonymousId>,
58    #[serde(skip_serializing_if = "Option::is_none")]
59    pub organization_id: Option<PseudonymousId>,
60    pub app_version: String,
61    pub event: TelemetryEventV2,
62}
63
64impl TelemetryEnvelopeV2 {
65    pub fn validate(&self) -> Result<(), TelemetryValidationError> {
66        if self.schema_version != SCHEMA_VERSION {
67            return Err(TelemetryValidationError::SchemaVersion);
68        }
69        NaiveDate::parse_from_str(&self.timestamp_bucket, "%Y-%m-%d")
70            .map_err(|_| TelemetryValidationError::TimestampBucket)?;
71        Uuid::parse_str(&self.installation_id)
72            .map_err(|_| TelemetryValidationError::InstallationId)?;
73        if self.app_version.is_empty()
74            || self.app_version.len() > 64
75            || !self
76                .app_version
77                .bytes()
78                .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'-' | b'+'))
79        {
80            return Err(TelemetryValidationError::AppVersion);
81        }
82        if self.account_id.as_ref().is_some_and(|id| !id.is_valid())
83            || self
84                .organization_id
85                .as_ref()
86                .is_some_and(|id| !id.is_valid())
87        {
88            return Err(TelemetryValidationError::PseudonymousId);
89        }
90        self.event.validate()
91    }
92}
93
94#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
95#[serde(transparent)]
96pub struct PseudonymousId(String);
97
98impl<'de> Deserialize<'de> for PseudonymousId {
99    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
100    where
101        D: serde::Deserializer<'de>,
102    {
103        Self::new(String::deserialize(deserializer)?)
104            .map_err(|_| serde::de::Error::custom("invalid pseudonymous ID"))
105    }
106}
107
108impl PseudonymousId {
109    pub fn new(value: impl Into<String>) -> Result<Self, TelemetryValidationError> {
110        let value = Self(value.into());
111        if value.is_valid() {
112            Ok(value)
113        } else {
114            Err(TelemetryValidationError::PseudonymousId)
115        }
116    }
117
118    fn is_valid(&self) -> bool {
119        self.0.strip_prefix("hmac-sha256:").is_some_and(|tail| {
120            tail.len() == 64
121                && tail
122                    .bytes()
123                    .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())
124        })
125    }
126}
127
128#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
129#[serde(
130    tag = "name",
131    content = "metrics",
132    rename_all = "snake_case",
133    deny_unknown_fields
134)]
135/// One telemetry event. The contract is shared with the hosted service, which
136/// deserializes the same type: variants marked *hosted plane* are never built
137/// by the OSS engine and stay only so both sides read one schema
138/// (`docs/privacy/TELEMETRY-DATA-DICTIONARY.md` ยง3).
139pub enum TelemetryEventV2 {
140    Heartbeat(HeartbeatMetrics),
141    SetupProfile(SetupProfileMetrics),
142    SetupCompleted(OccurrenceMetrics),
143    IntegrationDetected(OccurrenceMetrics),
144    SessionAggregate(SessionMetrics),
145    ToolUsageAggregate(ToolUsageMetrics),
146    ToolCallAggregate(ToolCallMetrics),
147    AutopilotAggregate(DecisionMetrics),
148    AutopilotFallbackAggregate(DecisionMetrics),
149    SyncAggregate(SyncMetrics),
150    /// Hosted plane: never emitted by the OSS engine.
151    TrialStarted(OccurrenceMetrics),
152    /// Hosted plane: never emitted by the OSS engine.
153    TrialEnded(OutcomeMetrics),
154    /// Hosted plane: never emitted by the OSS engine.
155    UpgradeViewed(OccurrenceMetrics),
156    CheckoutStarted(OccurrenceMetrics),
157    /// Hosted plane: never emitted by the OSS engine.
158    SubscriptionActivated(OccurrenceMetrics),
159    /// Hosted plane: never emitted by the OSS engine.
160    SubscriptionCancelled(OccurrenceMetrics),
161    /// Hosted plane: never emitted by the OSS engine.
162    TeamCreated(OccurrenceMetrics),
163    /// Hosted plane: never emitted by the OSS engine.
164    TeamMemberInvited(OccurrenceMetrics),
165    /// Hosted plane: never emitted by the OSS engine.
166    TeamContextPromoted(OutcomeMetrics),
167    ErrorCategoryAggregate(ErrorMetrics),
168    VersionUpgrade(VersionUpgradeMetrics),
169    /// Hosted plane: never emitted by the OSS engine.
170    OrchestrationAggregate(Box<OrchestrationMetrics>),
171    UsageHistory(UsageHistoryMetrics),
172    /// Daily use of CLI commands and background features (3.11.1+).
173    FeatureAggregate(FeatureMetrics),
174}
175
176impl TelemetryEventV2 {
177    pub fn name(&self) -> &'static str {
178        match self {
179            Self::Heartbeat(_) => "heartbeat",
180            Self::SetupProfile(_) => "setup_profile",
181            Self::SetupCompleted(_) => "setup_completed",
182            Self::IntegrationDetected(_) => "integration_detected",
183            Self::SessionAggregate(_) => "session_aggregate",
184            Self::ToolUsageAggregate(_) => "tool_usage_aggregate",
185            Self::ToolCallAggregate(_) => "tool_call_aggregate",
186            Self::AutopilotAggregate(_) => "autopilot_aggregate",
187            Self::AutopilotFallbackAggregate(_) => "autopilot_fallback_aggregate",
188            Self::SyncAggregate(_) => "sync_aggregate",
189            Self::TrialStarted(_) => "trial_started",
190            Self::TrialEnded(_) => "trial_ended",
191            Self::UpgradeViewed(_) => "upgrade_viewed",
192            Self::CheckoutStarted(_) => "checkout_started",
193            Self::SubscriptionActivated(_) => "subscription_activated",
194            Self::SubscriptionCancelled(_) => "subscription_cancelled",
195            Self::TeamCreated(_) => "team_created",
196            Self::TeamMemberInvited(_) => "team_member_invited",
197            Self::TeamContextPromoted(_) => "team_context_promoted",
198            Self::ErrorCategoryAggregate(_) => "error_category_aggregate",
199            Self::VersionUpgrade(_) => "version_upgrade",
200            Self::OrchestrationAggregate(_) => "orchestration_aggregate",
201            Self::UsageHistory(_) => "usage_history",
202            Self::FeatureAggregate(_) => "feature_aggregate",
203        }
204    }
205
206    fn validate(&self) -> Result<(), TelemetryValidationError> {
207        match self {
208            Self::Heartbeat(metrics) => metrics.validate(),
209            // Both fields are closed enums: deserialization is the validation.
210            Self::SetupProfile(_) => Ok(()),
211            Self::SetupCompleted(metrics)
212            | Self::IntegrationDetected(metrics)
213            | Self::TrialStarted(metrics)
214            | Self::UpgradeViewed(metrics)
215            | Self::CheckoutStarted(metrics)
216            | Self::SubscriptionActivated(metrics)
217            | Self::SubscriptionCancelled(metrics)
218            | Self::TeamCreated(metrics)
219            | Self::TeamMemberInvited(metrics) => metrics.validate(),
220            Self::SessionAggregate(metrics) => metrics.validate(),
221            Self::ToolUsageAggregate(metrics) => metrics.validate(),
222            Self::ToolCallAggregate(metrics) => metrics.validate(),
223            Self::AutopilotAggregate(metrics) | Self::AutopilotFallbackAggregate(metrics) => {
224                metrics.validate()
225            }
226            Self::SyncAggregate(metrics) => metrics.validate(),
227            Self::TrialEnded(metrics) | Self::TeamContextPromoted(metrics) => metrics.validate(),
228            Self::ErrorCategoryAggregate(metrics) => metrics.validate(),
229            Self::VersionUpgrade(metrics) => metrics.validate(),
230            Self::OrchestrationAggregate(metrics) => metrics.validate(),
231            Self::UsageHistory(metrics) => metrics.validate(),
232            Self::FeatureAggregate(metrics) => metrics.validate(),
233        }
234    }
235}
236
237/// Upper bound for token totals: a heavy installation passes [`MAX_COUNT`]
238/// tokens within weeks, so tokens get their own, still finite, bound.
239pub const MAX_TOKENS: u64 = 1_000_000_000_000_000;
240/// Days of local history sent; matches the server's retention window.
241pub const MAX_HISTORY_DAYS: usize = 90;
242
243/// The installation's own daily usage record (`lean-ctx gain`), so history
244/// before telemetry and usage outside MCP (shell hooks, CLI) is counted.
245/// Days are `YYYY-MM-DD` in the client's calendar, strictly ascending.
246#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
247#[serde(deny_unknown_fields)]
248pub struct UsageHistoryMetrics {
249    pub days: Vec<UsageDay>,
250    pub lifetime: LifetimeUsage,
251}
252
253#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
254#[serde(deny_unknown_fields)]
255pub struct UsageDay {
256    pub date: String,
257    /// Compressed operations (tool calls, hook-wrapped commands, reads).
258    pub commands: u64,
259    /// Tokens before compression.
260    pub original_tokens: u64,
261    /// Tokens that reached the model.
262    pub delivered_tokens: u64,
263}
264
265#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
266#[serde(deny_unknown_fields)]
267pub struct LifetimeUsage {
268    pub commands: u64,
269    pub original_tokens: u64,
270    pub delivered_tokens: u64,
271    /// `YYYY-MM` of first use; month precision only.
272    #[serde(default, skip_serializing_if = "Option::is_none")]
273    pub first_use_month: Option<String>,
274}
275
276impl UsageHistoryMetrics {
277    fn validate(&self) -> Result<(), TelemetryValidationError> {
278        if self.days.len() > MAX_HISTORY_DAYS
279            || !self.days.windows(2).all(|pair| pair[0].date < pair[1].date)
280        {
281            return Err(TelemetryValidationError::History);
282        }
283        for day in &self.days {
284            if !valid_day(&day.date) {
285                return Err(TelemetryValidationError::History);
286            }
287            bounded(day.commands)?;
288            tokens_bounded(&[day.original_tokens, day.delivered_tokens])?;
289        }
290        bounded(self.lifetime.commands)?;
291        tokens_bounded(&[
292            self.lifetime.original_tokens,
293            self.lifetime.delivered_tokens,
294        ])?;
295        if self
296            .lifetime
297            .first_use_month
298            .as_deref()
299            .is_some_and(|month| !valid_day(&format!("{month}-01")))
300        {
301            return Err(TelemetryValidationError::History);
302        }
303        Ok(())
304    }
305}
306
307fn tokens_bounded(values: &[u64]) -> Result<(), TelemetryValidationError> {
308    if values.iter().all(|value| *value <= MAX_TOKENS) {
309        Ok(())
310    } else {
311        Err(TelemetryValidationError::CountBound)
312    }
313}
314
315/// A real calendar day written exactly as `YYYY-MM-DD`.
316fn valid_day(value: &str) -> bool {
317    value.len() == 10
318        && chrono::NaiveDate::parse_from_str(value, "%Y-%m-%d")
319            .is_ok_and(|date| date.format("%Y-%m-%d").to_string() == value)
320}
321
322#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
323#[serde(deny_unknown_fields)]
324pub struct HeartbeatMetrics {
325    pub distribution_channel: DistributionChannel,
326    pub client_family: ClientFamily,
327    pub operating_system: OperatingSystem,
328    pub architecture: Architecture,
329    /// Age of the local installation identity, bucketed. Closed enums only;
330    /// skipped when unknown so older receivers keep accepting the heartbeat.
331    #[serde(default, skip_serializing_if = "Option::is_none")]
332    pub install_age: Option<InstallAge>,
333    /// Distinct UTC days with a successful send in the trailing 30, bucketed.
334    #[serde(default, skip_serializing_if = "Option::is_none")]
335    pub active_days: Option<ActiveDays>,
336    #[serde(default, skip_serializing_if = "Option::is_none")]
337    pub runtime_environment: Option<RuntimeEnvironment>,
338}
339
340#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
341pub enum InstallAge {
342    #[serde(rename = "lt_1h")]
343    Lt1h,
344    #[serde(rename = "lt_1d")]
345    Lt1d,
346    #[serde(rename = "lt_7d")]
347    Lt7d,
348    #[serde(rename = "lt_30d")]
349    Lt30d,
350    #[serde(rename = "gte_30d")]
351    Gte30d,
352}
353
354impl InstallAge {
355    #[must_use]
356    pub fn from_seconds(seconds: u64) -> Self {
357        match seconds {
358            0..3_600 => Self::Lt1h,
359            3_600..86_400 => Self::Lt1d,
360            86_400..604_800 => Self::Lt7d,
361            604_800..2_592_000 => Self::Lt30d,
362            _ => Self::Gte30d,
363        }
364    }
365}
366
367#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
368pub enum ActiveDays {
369    #[serde(rename = "d1")]
370    D1,
371    #[serde(rename = "d2_3")]
372    D2To3,
373    #[serde(rename = "d4_7")]
374    D4To7,
375    #[serde(rename = "d8_14")]
376    D8To14,
377    #[serde(rename = "d15_plus")]
378    D15Plus,
379}
380
381impl ActiveDays {
382    #[must_use]
383    pub fn from_count(days: usize) -> Self {
384        match days {
385            0 | 1 => Self::D1,
386            2 | 3 => Self::D2To3,
387            4..=7 => Self::D4To7,
388            8..=14 => Self::D8To14,
389            _ => Self::D15Plus,
390        }
391    }
392}
393
394/// Where the process runs. Separates people from short-lived agent sandboxes
395/// without any machine, account or network identifier.
396#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
397#[serde(rename_all = "snake_case")]
398pub enum RuntimeEnvironment {
399    Local,
400    Container,
401    Codespaces,
402    Gitpod,
403    Replit,
404    CloudAgent,
405    Ci,
406    Unknown,
407}
408
409impl HeartbeatMetrics {
410    fn validate(&self) -> Result<(), TelemetryValidationError> {
411        Ok(())
412    }
413}
414
415/// How this installation is wired up, as closed enums only: no paths,
416/// project names, model names or config values leave the machine.
417#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
418#[serde(deny_unknown_fields)]
419pub struct SetupProfileMetrics {
420    pub integration_mode: IntegrationMode,
421    pub embeddings: EmbeddingsState,
422}
423
424/// Configured `hook_mode`; `Default` means the user never set one.
425#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
426#[serde(rename_all = "snake_case")]
427pub enum IntegrationMode {
428    Default,
429    Mcp,
430    Hybrid,
431    Replace,
432}
433
434#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
435#[serde(rename_all = "snake_case")]
436pub enum EmbeddingsState {
437    /// Built without the `embeddings` feature.
438    Unsupported,
439    /// Auto-download is turned off and no model is on disk.
440    Disabled,
441    /// Allowed but the model has not been downloaded yet.
442    NotInstalled,
443    Installed,
444}
445
446#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
447#[serde(deny_unknown_fields)]
448pub struct OccurrenceMetrics {
449    pub count: u64,
450}
451
452impl OccurrenceMetrics {
453    fn validate(&self) -> Result<(), TelemetryValidationError> {
454        bounded(self.count)
455    }
456}
457
458#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
459#[serde(deny_unknown_fields)]
460pub struct OutcomeMetrics {
461    pub accepted: u64,
462    pub rejected: u64,
463    pub unknown: u64,
464}
465
466impl OutcomeMetrics {
467    fn validate(&self) -> Result<(), TelemetryValidationError> {
468        bounded_many(&[self.accepted, self.rejected, self.unknown])
469    }
470}
471
472#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
473#[serde(deny_unknown_fields)]
474pub struct SessionMetrics {
475    pub sessions: u64,
476    pub duration_seconds: Histogram,
477}
478
479impl SessionMetrics {
480    fn validate(&self) -> Result<(), TelemetryValidationError> {
481        bounded(self.sessions)?;
482        self.duration_seconds.validate()
483    }
484}
485
486#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
487#[serde(deny_unknown_fields)]
488pub struct ToolUsageMetrics {
489    pub calls: u64,
490    pub failures: u64,
491    pub latency_milliseconds: Histogram,
492    /// Absent from clients before 3.11.1.
493    #[serde(default, skip_serializing_if = "Option::is_none")]
494    pub tokens: Option<TokenMetrics>,
495}
496
497/// Tokens of tool output for one day: what it would have cost uncompressed
498/// (`original`) and what reached the model (`delivered`). Counts only.
499#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
500#[serde(deny_unknown_fields)]
501pub struct TokenMetrics {
502    pub original: u64,
503    pub delivered: u64,
504}
505
506impl ToolUsageMetrics {
507    fn validate(&self) -> Result<(), TelemetryValidationError> {
508        bounded_many(&[self.calls, self.failures])?;
509        if self.failures > self.calls {
510            return Err(TelemetryValidationError::InconsistentCounts);
511        }
512        if let Some(tokens) = self.tokens {
513            bounded_many(&[tokens.original, tokens.delivered])?;
514        }
515        self.latency_milliseconds.validate()
516    }
517}
518
519/// Per-tool call counts for one day.
520///
521/// Tool names are the static keys of the built-in tool registry, never
522/// caller-supplied strings: a name is a short `[a-z0-9_]` identifier, entries
523/// are sorted and unique, and every entry has at least one call.
524#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
525#[serde(deny_unknown_fields)]
526pub struct ToolCallMetrics {
527    pub tools: Vec<ToolCallCount>,
528}
529
530#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
531#[serde(deny_unknown_fields)]
532pub struct ToolCallCount {
533    pub tool: String,
534    pub calls: u64,
535    pub failures: u64,
536    /// Summed latency of these calls; absent from clients before 3.11.1.
537    #[serde(default, skip_serializing_if = "Option::is_none")]
538    pub latency_milliseconds_total: Option<u64>,
539    /// Failures by class (never the message); absent before 3.11.1.
540    #[serde(default, skip_serializing_if = "Option::is_none")]
541    pub failure_kinds:
542        Option<std::collections::BTreeMap<crate::core::telemetry_failure::FailureKind, u64>>,
543    /// The most frequent scrubbed failure templates (see
544    /// `core::failure_template`); absent before 3.11.1.
545    #[serde(default, skip_serializing_if = "Option::is_none")]
546    pub failure_messages: Option<Vec<FailureMessage>>,
547}
548
549/// One scrubbed failure template and how often it occurred that day.
550#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
551#[serde(deny_unknown_fields)]
552pub struct FailureMessage {
553    pub template: String,
554    pub count: u64,
555}
556
557impl FailureMessage {
558    /// Shape of a template the scrubber can produce: bounded, a closed
559    /// character set and no digits.
560    fn valid(&self) -> bool {
561        let length = self.template.chars().count();
562        (1..=crate::core::failure_template::MAX_TEMPLATE_CHARS).contains(&length)
563            && self.count > 0
564            && self.template.chars().all(|c| {
565                (c.is_ascii_alphanumeric() && !c.is_ascii_digit())
566                    || " .,:;()[]{}<>!?'-_/โ€นโ€บโ€ฆ".contains(c)
567            })
568    }
569}
570
571impl ToolCallMetrics {
572    pub(crate) fn validate(&self) -> Result<(), TelemetryValidationError> {
573        if self.tools.is_empty() || self.tools.len() > MAX_TOOL_ENTRIES {
574            return Err(TelemetryValidationError::ToolEntries);
575        }
576        if !self
577            .tools
578            .windows(2)
579            .all(|pair| pair[0].tool < pair[1].tool)
580        {
581            return Err(TelemetryValidationError::ToolEntries);
582        }
583        self.tools.iter().try_for_each(ToolCallCount::validate)
584    }
585}
586
587impl ToolCallCount {
588    fn validate(&self) -> Result<(), TelemetryValidationError> {
589        if !valid_tool_name(&self.tool) {
590            return Err(TelemetryValidationError::ToolName);
591        }
592        bounded_many(&[self.calls, self.failures])?;
593        bounded(self.latency_milliseconds_total.unwrap_or_default())?;
594        let classified: u64 = self
595            .failure_kinds
596            .iter()
597            .flat_map(|kinds| kinds.values())
598            .sum();
599        if classified > self.failures
600            || self
601                .failure_kinds
602                .as_ref()
603                .is_some_and(|kinds| kinds.values().any(|count| *count == 0))
604        {
605            return Err(TelemetryValidationError::InconsistentCounts);
606        }
607        if self.calls == 0 || self.failures > self.calls {
608            return Err(TelemetryValidationError::InconsistentCounts);
609        }
610        if let Some(messages) = &self.failure_messages {
611            let sum: u64 = messages.iter().map(|message| message.count).sum();
612            if messages.is_empty()
613                || messages.len() > crate::core::telemetry_failure::MAX_FAILURE_MESSAGES
614                || sum > self.failures
615                || !messages.iter().all(FailureMessage::valid)
616            {
617                return Err(TelemetryValidationError::InconsistentCounts);
618            }
619        }
620        Ok(())
621    }
622}
623
624/// `[a-z][a-z0-9_]*`, at most [`MAX_TOOL_NAME_LEN`] bytes.
625pub fn valid_tool_name(name: &str) -> bool {
626    let bytes = name.as_bytes();
627    !bytes.is_empty()
628        && bytes.len() <= MAX_TOOL_NAME_LEN
629        && bytes[0].is_ascii_lowercase()
630        && bytes
631            .iter()
632            .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || *byte == b'_')
633}
634
635/// Most distinct feature codes per day; extra codes are dropped on the client.
636pub const MAX_FEATURE_ENTRIES: usize = 96;
637
638/// Daily counts per feature code from the closed registry in
639/// `core::telemetry_features` (e.g. `cli.pack.export`, `index.graph`).
640/// Sorted by code; a code never carries arguments, paths or user input.
641#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
642#[serde(deny_unknown_fields)]
643pub struct FeatureMetrics {
644    pub features: Vec<FeatureCount>,
645}
646
647#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
648#[serde(deny_unknown_fields)]
649pub struct FeatureCount {
650    pub feature: String,
651    pub count: u64,
652    #[serde(default, skip_serializing_if = "is_zero")]
653    pub failures: u64,
654}
655
656fn is_zero(value: &u64) -> bool {
657    *value == 0
658}
659
660/// `segment(.segment){0,2}`, each `[a-z][a-z0-9_]{0,23}`.
661#[must_use]
662pub fn valid_feature_code(code: &str) -> bool {
663    let segments: Vec<&str> = code.split('.').collect();
664    (1..=3).contains(&segments.len())
665        && segments.iter().all(|segment| {
666            let mut bytes = segment.bytes();
667            segment.len() <= 24
668                && bytes.next().is_some_and(|byte| byte.is_ascii_lowercase())
669                && bytes
670                    .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_')
671        })
672}
673
674impl FeatureMetrics {
675    fn validate(&self) -> Result<(), TelemetryValidationError> {
676        if self.features.is_empty()
677            || self.features.len() > MAX_FEATURE_ENTRIES
678            || !self
679                .features
680                .windows(2)
681                .all(|pair| pair[0].feature < pair[1].feature)
682        {
683            return Err(TelemetryValidationError::FeatureEntries);
684        }
685        for entry in &self.features {
686            bounded_many(&[entry.count, entry.failures])?;
687            if !valid_feature_code(&entry.feature) {
688                return Err(TelemetryValidationError::FeatureCode);
689            }
690            if entry.count == 0 || entry.failures > entry.count {
691                return Err(TelemetryValidationError::InconsistentCounts);
692            }
693        }
694        Ok(())
695    }
696}
697
698#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
699#[serde(deny_unknown_fields)]
700pub struct DecisionMetrics {
701    pub admitted: u64,
702    pub denied: u64,
703    pub fallback: u64,
704}
705
706impl DecisionMetrics {
707    fn validate(&self) -> Result<(), TelemetryValidationError> {
708        bounded_many(&[self.admitted, self.denied, self.fallback])
709    }
710}
711
712#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
713#[serde(deny_unknown_fields)]
714pub struct SyncMetrics {
715    pub attempts: u64,
716    pub successes: u64,
717    pub failures: u64,
718}
719
720impl SyncMetrics {
721    fn validate(&self) -> Result<(), TelemetryValidationError> {
722        bounded_many(&[self.attempts, self.successes, self.failures])?;
723        if self.successes.saturating_add(self.failures) > self.attempts {
724            return Err(TelemetryValidationError::InconsistentCounts);
725        }
726        Ok(())
727    }
728}
729
730#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
731#[serde(deny_unknown_fields)]
732pub struct ErrorMetrics {
733    pub category: ErrorCategory,
734    pub count: u64,
735}
736
737impl ErrorMetrics {
738    fn validate(&self) -> Result<(), TelemetryValidationError> {
739        bounded(self.count)
740    }
741}
742
743#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
744#[serde(deny_unknown_fields)]
745pub struct VersionUpgradeMetrics {
746    pub from_major: u16,
747    pub to_major: u16,
748}
749
750impl VersionUpgradeMetrics {
751    fn validate(&self) -> Result<(), TelemetryValidationError> {
752        if self.to_major < self.from_major {
753            return Err(TelemetryValidationError::VersionDirection);
754        }
755        Ok(())
756    }
757}
758
759#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
760#[serde(deny_unknown_fields)]
761pub struct OrchestrationMetrics {
762    pub admitted_tasks: u64,
763    pub execution_plans: u64,
764    pub retries: u64,
765    pub fallbacks: u64,
766    pub cancellations: u64,
767    pub lease_conflicts: u64,
768    pub receipts: u64,
769    pub outcomes: OutcomeMetrics,
770    pub node_count: Histogram,
771    pub fan_out: Histogram,
772    pub depth: Histogram,
773    pub parallel_nodes: Histogram,
774}
775
776impl OrchestrationMetrics {
777    fn validate(&self) -> Result<(), TelemetryValidationError> {
778        bounded_many(&[
779            self.admitted_tasks,
780            self.execution_plans,
781            self.retries,
782            self.fallbacks,
783            self.cancellations,
784            self.lease_conflicts,
785            self.receipts,
786        ])?;
787        self.outcomes.validate()?;
788        self.node_count.validate()?;
789        self.fan_out.validate()?;
790        self.depth.validate()?;
791        self.parallel_nodes.validate()
792    }
793}
794
795#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
796#[serde(deny_unknown_fields)]
797pub struct Histogram {
798    pub upper_bounds: Vec<u64>,
799    pub counts: Vec<u64>,
800}
801
802impl Histogram {
803    fn validate(&self) -> Result<(), TelemetryValidationError> {
804        if self.upper_bounds.is_empty()
805            || self.upper_bounds.len() > MAX_HISTOGRAM_BUCKETS
806            || self.upper_bounds.len() != self.counts.len()
807            || !self
808                .upper_bounds
809                .windows(2)
810                .all(|window| window[0] < window[1])
811        {
812            return Err(TelemetryValidationError::Histogram);
813        }
814        bounded_many(&self.counts)
815    }
816}
817
818#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
819#[serde(rename_all = "snake_case")]
820pub enum DistributionChannel {
821    Cargo,
822    Homebrew,
823    Npm,
824    Docker,
825    Source,
826    Aur,
827    Pypi,
828    /// A release binary installed by the install script or by hand.
829    Binary,
830    Unknown,
831}
832
833#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
834#[serde(rename_all = "snake_case")]
835pub enum ClientFamily {
836    Claude,
837    Codex,
838    Cursor,
839    Gemini,
840    Windsurf,
841    Zed,
842    VscodeCopilot,
843    Kiro,
844    Antigravity,
845    Codebuddy,
846    Codewhale,
847    // 3.11.1: more MCP hosts. The server accepts these from 3.11.1 on.
848    Cline,
849    RooCode,
850    KiloCode,
851    ContinueDev,
852    Opencode,
853    Goose,
854    Amp,
855    Augment,
856    Jetbrains,
857    Warp,
858    Trae,
859    QwenCode,
860    Crush,
861    ClaudeDesktop,
862    Chatgpt,
863    LmStudio,
864    CopilotCli,
865    VisualStudio,
866    Neovim,
867    Emacs,
868    Factory,
869    Pi,
870    OhMyPi,
871    /// No MCP client was seen: LeanCTX runs only through the CLI or shell hooks.
872    None,
873    /// An MCP client LeanCTX does not recognise.
874    Other,
875}
876
877impl ClientFamily {
878    /// Maps a `client_capabilities` client id (from the MCP initialize
879    /// handshake) onto the fixed wire enum; unknown ids never leak as text.
880    #[must_use]
881    pub fn from_client_id(id: &str) -> Option<Self> {
882        Some(match id {
883            "claude-code" => Self::Claude,
884            "codex" => Self::Codex,
885            "cursor" => Self::Cursor,
886            "gemini-cli" => Self::Gemini,
887            "windsurf" => Self::Windsurf,
888            "zed" => Self::Zed,
889            "vscode-copilot" => Self::VscodeCopilot,
890            "kiro" => Self::Kiro,
891            "antigravity" => Self::Antigravity,
892            "codebuddy" => Self::Codebuddy,
893            "codewhale" => Self::Codewhale,
894            "cline" => Self::Cline,
895            "roo-code" => Self::RooCode,
896            "kilo-code" => Self::KiloCode,
897            "continue" => Self::ContinueDev,
898            "opencode" => Self::Opencode,
899            "goose" => Self::Goose,
900            "amp" => Self::Amp,
901            "augment" => Self::Augment,
902            "jetbrains" => Self::Jetbrains,
903            "warp" => Self::Warp,
904            "trae" => Self::Trae,
905            "qwen-code" => Self::QwenCode,
906            "crush" => Self::Crush,
907            "claude-desktop" => Self::ClaudeDesktop,
908            "chatgpt" => Self::Chatgpt,
909            "lm-studio" => Self::LmStudio,
910            "copilot-cli" => Self::CopilotCli,
911            "visual-studio" => Self::VisualStudio,
912            "neovim" => Self::Neovim,
913            "emacs" => Self::Emacs,
914            "factory" => Self::Factory,
915            "pi" => Self::Pi,
916            "oh-my-pi" => Self::OhMyPi,
917            _ => return None,
918        })
919    }
920}
921
922#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
923#[serde(rename_all = "snake_case")]
924pub enum OperatingSystem {
925    Macos,
926    Linux,
927    Windows,
928    Other,
929}
930
931impl OperatingSystem {
932    #[must_use]
933    pub fn current() -> Self {
934        match std::env::consts::OS {
935            "macos" => Self::Macos,
936            "linux" => Self::Linux,
937            "windows" => Self::Windows,
938            _ => Self::Other,
939        }
940    }
941}
942
943#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
944#[serde(rename_all = "snake_case")]
945pub enum Architecture {
946    X86_64,
947    Aarch64,
948    Other,
949}
950
951impl Architecture {
952    #[must_use]
953    pub fn current() -> Self {
954        match std::env::consts::ARCH {
955            "x86_64" => Self::X86_64,
956            "aarch64" => Self::Aarch64,
957            _ => Self::Other,
958        }
959    }
960}
961
962#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
963#[serde(rename_all = "snake_case")]
964pub enum ErrorCategory {
965    Authentication,
966    Authorization,
967    Configuration,
968    Network,
969    Provider,
970    Timeout,
971    Validation,
972    Internal,
973    /// A command run through a shell tool exited non-zero: the user's
974    /// command failed, not LeanCTX. Sent from 3.11.1 on.
975    Command,
976}
977
978#[derive(Debug, Clone, Copy, PartialEq, Eq)]
979pub enum TelemetryValidationError {
980    SchemaVersion,
981    DeletionTokenHash,
982    TimestampBucket,
983    InstallationId,
984    PseudonymousId,
985    AppVersion,
986    CountBound,
987    InconsistentCounts,
988    Histogram,
989    VersionDirection,
990    BatchSize,
991    ToolEntries,
992    ToolName,
993    History,
994    FeatureEntries,
995    FeatureCode,
996}
997
998fn bounded(value: u64) -> Result<(), TelemetryValidationError> {
999    if value <= MAX_COUNT {
1000        Ok(())
1001    } else {
1002        Err(TelemetryValidationError::CountBound)
1003    }
1004}
1005
1006fn bounded_many(values: &[u64]) -> Result<(), TelemetryValidationError> {
1007    values.iter().try_for_each(|value| bounded(*value))
1008}
1009
1010#[cfg(test)]
1011mod tests {
1012    use super::*;
1013
1014    fn envelope(event: TelemetryEventV2) -> TelemetryEnvelopeV2 {
1015        TelemetryEnvelopeV2 {
1016            schema_version: SCHEMA_VERSION,
1017            timestamp_bucket: "2026-09-08".into(),
1018            installation_id: "550e8400-e29b-41d4-a716-446655440000".into(),
1019            account_id: None,
1020            organization_id: None,
1021            app_version: "4.0.0".into(),
1022            event,
1023        }
1024    }
1025
1026    #[test]
1027    fn valid_typed_event_roundtrips() {
1028        let event = envelope(TelemetryEventV2::ToolUsageAggregate(ToolUsageMetrics {
1029            calls: 4,
1030            failures: 1,
1031            latency_milliseconds: Histogram {
1032                upper_bounds: vec![10, 100],
1033                counts: vec![1, 3],
1034            },
1035            tokens: Some(TokenMetrics {
1036                original: 12_000,
1037                delivered: 3_000,
1038            }),
1039        }));
1040        event.validate().unwrap();
1041        let encoded = serde_json::to_vec(&event).unwrap();
1042        let decoded: TelemetryEnvelopeV2 = serde_json::from_slice(&encoded).unwrap();
1043        assert_eq!(decoded, event);
1044    }
1045
1046    #[test]
1047    fn batch_is_bounded_and_validates_every_event() {
1048        let event = envelope(TelemetryEventV2::Heartbeat(HeartbeatMetrics {
1049            distribution_channel: DistributionChannel::Cargo,
1050            client_family: ClientFamily::Codex,
1051            operating_system: OperatingSystem::Linux,
1052            architecture: Architecture::X86_64,
1053            install_age: None,
1054            active_days: None,
1055            runtime_environment: None,
1056        }));
1057        let batch = TelemetryBatchV2 {
1058            schema_version: SCHEMA_VERSION,
1059            deletion_token_hash: "a".repeat(64),
1060            events: vec![event],
1061        };
1062        assert!(batch.validate().is_ok());
1063        let mut invalid_credential = batch.clone();
1064        invalid_credential.deletion_token_hash = "raw-secret".into();
1065        assert_eq!(
1066            invalid_credential.validate(),
1067            Err(TelemetryValidationError::DeletionTokenHash)
1068        );
1069        let empty = TelemetryBatchV2 {
1070            schema_version: SCHEMA_VERSION,
1071            deletion_token_hash: "a".repeat(64),
1072            events: Vec::new(),
1073        };
1074        assert_eq!(empty.validate(), Err(TelemetryValidationError::BatchSize));
1075    }
1076
1077    #[test]
1078    fn ops_context_fields_use_closed_wire_names_and_are_omitted_when_unset() {
1079        let mut metrics = HeartbeatMetrics {
1080            distribution_channel: DistributionChannel::Aur,
1081            client_family: ClientFamily::Codex,
1082            operating_system: OperatingSystem::Linux,
1083            architecture: Architecture::X86_64,
1084            install_age: None,
1085            active_days: None,
1086            runtime_environment: None,
1087        };
1088        let bare = serde_json::to_value(&metrics).unwrap();
1089        assert_eq!(
1090            bare.as_object().unwrap().len(),
1091            4,
1092            "unset fields stay off the wire: {bare}"
1093        );
1094        metrics.install_age = Some(InstallAge::Lt1h);
1095        metrics.active_days = Some(ActiveDays::D2To3);
1096        metrics.runtime_environment = Some(RuntimeEnvironment::CloudAgent);
1097        let full = serde_json::to_value(&metrics).unwrap();
1098        assert_eq!(full["distribution_channel"], "aur");
1099        assert_eq!(full["install_age"], "lt_1h");
1100        assert_eq!(full["active_days"], "d2_3");
1101        assert_eq!(full["runtime_environment"], "cloud_agent");
1102        let decoded: HeartbeatMetrics = serde_json::from_value(full).unwrap();
1103        assert_eq!(decoded, metrics);
1104        assert!(
1105            serde_json::from_value::<HeartbeatMetrics>(serde_json::json!({
1106                "distribution_channel": "cargo", "client_family": "codex",
1107                "operating_system": "linux", "architecture": "x86_64", "install_age": "3 days"
1108            }))
1109            .is_err()
1110        );
1111    }
1112
1113    #[test]
1114    fn ops_context_buckets_have_exact_boundaries() {
1115        assert_eq!(InstallAge::from_seconds(0), InstallAge::Lt1h);
1116        assert_eq!(InstallAge::from_seconds(3_599), InstallAge::Lt1h);
1117        assert_eq!(InstallAge::from_seconds(3_600), InstallAge::Lt1d);
1118        assert_eq!(InstallAge::from_seconds(86_400), InstallAge::Lt7d);
1119        assert_eq!(InstallAge::from_seconds(604_800), InstallAge::Lt30d);
1120        assert_eq!(InstallAge::from_seconds(2_592_000), InstallAge::Gte30d);
1121        assert_eq!(ActiveDays::from_count(0), ActiveDays::D1);
1122        assert_eq!(ActiveDays::from_count(1), ActiveDays::D1);
1123        assert_eq!(ActiveDays::from_count(3), ActiveDays::D2To3);
1124        assert_eq!(ActiveDays::from_count(7), ActiveDays::D4To7);
1125        assert_eq!(ActiveDays::from_count(14), ActiveDays::D8To14);
1126        assert_eq!(ActiveDays::from_count(15), ActiveDays::D15Plus);
1127    }
1128
1129    #[test]
1130    fn unknown_fields_are_rejected() {
1131        let raw = r#"{"schema_version":2,"timestamp_bucket":"2026-09-08","installation_id":"550e8400-e29b-41d4-a716-446655440000","app_version":"4.0.0","raw_task":"secret","event":{"name":"heartbeat","metrics":{"distribution_channel":"cargo","client_family":"codex","operating_system":"linux","architecture":"x86_64"}}}"#;
1132        assert!(serde_json::from_str::<TelemetryEnvelopeV2>(raw).is_err());
1133    }
1134
1135    #[test]
1136    fn event_wrapper_preserves_valid_v2_wire_bytes() {
1137        let raw = r#"{"name":"setup_completed","metrics":{"count":1}}"#;
1138        let decoded: TelemetryEventV2 = serde_json::from_str(raw).unwrap();
1139        assert_eq!(decoded.name(), "setup_completed");
1140        decoded.validate().unwrap();
1141        assert_eq!(serde_json::to_string(&decoded).unwrap(), raw);
1142        let event = envelope(decoded);
1143        let encoded = serde_json::to_vec(&event).unwrap();
1144        let decoded: TelemetryEnvelopeV2 = serde_json::from_slice(&encoded).unwrap();
1145        assert_eq!(decoded, event);
1146        assert_eq!(serde_json::to_vec(&decoded).unwrap(), encoded);
1147    }
1148
1149    #[test]
1150    fn event_wrapper_rejects_forbidden_fields_in_every_key_position() {
1151        // Synthetic privacy fixtures only; no captured content or credentials.
1152        let forbidden = [
1153            ("source_code", "fn synthetic_fixture() {}"),
1154            ("absolute_path", "/synthetic/private/example.rs"),
1155            ("prompt", "synthetic private task instructions"),
1156            ("secret", "SYNTHETIC_NOT_A_CREDENTIAL"),
1157            (
1158                "repository_url",
1159                "https://example.invalid/private/project.git",
1160            ),
1161        ];
1162        for base in [
1163            r#"{"name":"setup_completed","metrics":{"count":1}}"#,
1164            r#"{"metrics":{"count":1},"name":"setup_completed"}"#,
1165        ] {
1166            let valid: TelemetryEventV2 = serde_json::from_str(base).unwrap();
1167            valid.validate().unwrap();
1168            for (field, value) in forbidden {
1169                let property = format!(
1170                    "{}:{}",
1171                    serde_json::to_string(field).unwrap(),
1172                    serde_json::to_string(value).unwrap()
1173                );
1174                let middle = base.find(',').unwrap();
1175                for injected in [
1176                    format!("{{{property},{}", &base[1..]),
1177                    format!("{},{property}{}", &base[..middle], &base[middle..]),
1178                    format!("{},{property}}}", &base[..base.len() - 1]),
1179                ] {
1180                    // Establish valid JSON independently: parse errors must be
1181                    // caused by the forbidden event field, not broken fixtures.
1182                    let parsed: serde_json::Value = serde_json::from_str(&injected).unwrap();
1183                    assert_eq!(parsed[field], value);
1184                    assert!(
1185                        serde_json::from_str::<TelemetryEventV2>(&injected).is_err(),
1186                        "unknown event field accepted: {field}"
1187                    );
1188                    let mut wrapped = serde_json::to_value(envelope(valid.clone())).unwrap();
1189                    wrapped["event"] = parsed;
1190                    assert!(serde_json::from_value::<TelemetryEnvelopeV2>(wrapped).is_err());
1191                }
1192            }
1193        }
1194    }
1195
1196    #[test]
1197    fn malformed_identity_date_and_version_are_rejected() {
1198        let mut event = envelope(TelemetryEventV2::Heartbeat(HeartbeatMetrics {
1199            distribution_channel: DistributionChannel::Cargo,
1200            client_family: ClientFamily::Codex,
1201            operating_system: OperatingSystem::Linux,
1202            architecture: Architecture::X86_64,
1203            install_age: None,
1204            active_days: None,
1205            runtime_environment: None,
1206        }));
1207        event.installation_id = "not-a-uuid".into();
1208        assert_eq!(
1209            event.validate(),
1210            Err(TelemetryValidationError::InstallationId)
1211        );
1212        event.installation_id = "550e8400-e29b-41d4-a716-446655440000".into();
1213        event.timestamp_bucket = "2026-02-30".into();
1214        assert_eq!(
1215            event.validate(),
1216            Err(TelemetryValidationError::TimestampBucket)
1217        );
1218        event.timestamp_bucket = "2026-09-08".into();
1219        event.schema_version = 3;
1220        assert_eq!(
1221            event.validate(),
1222            Err(TelemetryValidationError::SchemaVersion)
1223        );
1224    }
1225
1226    #[test]
1227    fn bounds_and_cross_field_counts_fail_closed() {
1228        let event = envelope(TelemetryEventV2::SyncAggregate(SyncMetrics {
1229            attempts: 1,
1230            successes: 1,
1231            failures: 1,
1232        }));
1233        assert_eq!(
1234            event.validate(),
1235            Err(TelemetryValidationError::InconsistentCounts)
1236        );
1237        let event = envelope(TelemetryEventV2::SetupCompleted(OccurrenceMetrics {
1238            count: MAX_COUNT + 1,
1239        }));
1240        assert_eq!(event.validate(), Err(TelemetryValidationError::CountBound));
1241    }
1242
1243    #[test]
1244    fn histogram_shape_and_order_are_bounded() {
1245        let histogram = Histogram {
1246            upper_bounds: vec![100, 10],
1247            counts: vec![1, 1],
1248        };
1249        assert_eq!(
1250            histogram.validate(),
1251            Err(TelemetryValidationError::Histogram)
1252        );
1253    }
1254
1255    #[test]
1256    fn pseudonymous_ids_never_accept_raw_identifiers() {
1257        assert!(PseudonymousId::new("customer@example.com").is_err());
1258        assert!(PseudonymousId::new(format!("hmac-sha256:{}", "a".repeat(64))).is_ok());
1259    }
1260
1261    #[test]
1262    fn pseudonymous_id_deserialization_preserves_constructor_invariant() {
1263        for raw in [
1264            "synthetic@example.invalid".to_owned(),
1265            "/synthetic/private/identity".to_owned(),
1266            String::new(),
1267            format!("hmac-sha256:{}", "a".repeat(63)),
1268            format!("hmac-sha256:{}", "A".repeat(64)),
1269            format!("hmac-sha256:{}", "g".repeat(64)),
1270        ] {
1271            assert!(PseudonymousId::new(raw.clone()).is_err());
1272            let encoded = serde_json::to_string(&raw).unwrap();
1273            let result = serde_json::from_str::<PseudonymousId>(&encoded);
1274            assert!(result.is_err(), "constructor invariant bypassed on wire");
1275            let message = result.unwrap_err().to_string();
1276            assert!(message.starts_with("invalid pseudonymous ID"));
1277            if !raw.is_empty() {
1278                assert!(!message.contains(&raw));
1279            }
1280        }
1281        let raw = format!("hmac-sha256:{}", "a".repeat(64));
1282        let encoded = serde_json::to_string(&raw).unwrap();
1283        let decoded: PseudonymousId = serde_json::from_str(&encoded).unwrap();
1284        assert_eq!(decoded, PseudonymousId::new(raw).unwrap());
1285        assert_eq!(serde_json::to_string(&decoded).unwrap(), encoded);
1286    }
1287
1288    #[test]
1289    fn envelope_rejects_raw_pseudonymous_ids_before_semantic_validation() {
1290        let event = envelope(TelemetryEventV2::SetupCompleted(OccurrenceMetrics {
1291            count: 1,
1292        }));
1293        for field in ["account_id", "organization_id"] {
1294            let mut wire = serde_json::to_value(&event).unwrap();
1295            wire[field] = serde_json::json!("synthetic@example.invalid");
1296            assert!(serde_json::from_value::<TelemetryEnvelopeV2>(wire).is_err());
1297        }
1298        let mut event_with_ids = event;
1299        event_with_ids.account_id =
1300            Some(PseudonymousId::new(format!("hmac-sha256:{}", "a".repeat(64))).unwrap());
1301        event_with_ids.organization_id =
1302            Some(PseudonymousId::new(format!("hmac-sha256:{}", "b".repeat(64))).unwrap());
1303        let encoded = serde_json::to_vec(&event_with_ids).unwrap();
1304        let decoded: TelemetryEnvelopeV2 = serde_json::from_slice(&encoded).unwrap();
1305        decoded.validate().unwrap();
1306        assert_eq!(decoded, event_with_ids);
1307        assert_eq!(serde_json::to_vec(&decoded).unwrap(), encoded);
1308    }
1309
1310    fn tool_call(tool: &str, calls: u64, failures: u64) -> ToolCallCount {
1311        ToolCallCount {
1312            tool: tool.into(),
1313            calls,
1314            failures,
1315            latency_milliseconds_total: None,
1316            failure_kinds: None,
1317            failure_messages: None,
1318        }
1319    }
1320
1321    fn tool_call_event(tools: Vec<ToolCallCount>) -> TelemetryEnvelopeV2 {
1322        envelope(TelemetryEventV2::ToolCallAggregate(ToolCallMetrics {
1323            tools,
1324        }))
1325    }
1326
1327    #[test]
1328    fn setup_profile_is_closed_enums_on_the_wire() {
1329        let event = TelemetryEventV2::SetupProfile(SetupProfileMetrics {
1330            integration_mode: IntegrationMode::Replace,
1331            embeddings: EmbeddingsState::NotInstalled,
1332        });
1333        let json = serde_json::to_value(&event).unwrap();
1334        assert_eq!(
1335            json,
1336            serde_json::json!({
1337                "name": "setup_profile",
1338                "metrics": {"integration_mode": "replace", "embeddings": "not_installed"}
1339            })
1340        );
1341        assert_eq!(
1342            serde_json::from_value::<TelemetryEventV2>(json).unwrap(),
1343            event
1344        );
1345        for bad in [
1346            serde_json::json!({"name": "setup_profile", "metrics": {"integration_mode": "/home/me", "embeddings": "installed"}}),
1347            serde_json::json!({"name": "setup_profile", "metrics": {"integration_mode": "mcp", "embeddings": "installed", "model": "x"}}),
1348        ] {
1349            assert!(serde_json::from_value::<TelemetryEventV2>(bad).is_err());
1350        }
1351    }
1352
1353    #[test]
1354    fn every_detected_client_id_maps_to_a_stable_wire_family() {
1355        let cases = [
1356            ("claude-code", "claude"),
1357            ("codex", "codex"),
1358            ("cursor", "cursor"),
1359            ("gemini-cli", "gemini"),
1360            ("windsurf", "windsurf"),
1361            ("zed", "zed"),
1362            ("vscode-copilot", "vscode_copilot"),
1363            ("kiro", "kiro"),
1364            ("antigravity", "antigravity"),
1365            ("codebuddy", "codebuddy"),
1366            ("codewhale", "codewhale"),
1367            ("pi", "pi"),
1368            ("oh-my-pi", "oh_my_pi"),
1369        ];
1370        for (id, wire) in cases {
1371            let family = ClientFamily::from_client_id(id).expect(id);
1372            assert_eq!(serde_json::to_value(family).unwrap(), wire);
1373        }
1374        assert_eq!(ClientFamily::from_client_id("unknown"), None);
1375        assert_eq!(ClientFamily::from_client_id("my-private-fork"), None);
1376    }
1377
1378    #[test]
1379    fn tool_call_aggregate_roundtrips_on_the_wire() {
1380        let event = tool_call_event(vec![tool_call("ctx_read", 5, 1), tool_call("shell", 2, 0)]);
1381        event.validate().unwrap();
1382        let encoded = serde_json::to_string(&event.event).unwrap();
1383        assert_eq!(
1384            encoded,
1385            r#"{"name":"tool_call_aggregate","metrics":{"tools":[{"tool":"ctx_read","calls":5,"failures":1},{"tool":"shell","calls":2,"failures":0}]}}"#
1386        );
1387        let decoded: TelemetryEnvelopeV2 =
1388            serde_json::from_slice(&serde_json::to_vec(&event).unwrap()).unwrap();
1389        assert_eq!(decoded, event);
1390    }
1391
1392    #[test]
1393    fn tool_call_aggregate_rejects_free_text_and_inconsistent_entries() {
1394        let reject = |tools: Vec<ToolCallCount>, expected| {
1395            assert_eq!(tool_call_event(tools).validate(), Err(expected));
1396        };
1397        use TelemetryValidationError::{InconsistentCounts, ToolEntries, ToolName};
1398        reject(Vec::new(), ToolEntries);
1399        reject(
1400            vec![tool_call("ctx_shell", 1, 0), tool_call("ctx_read", 1, 0)],
1401            ToolEntries,
1402        );
1403        reject(
1404            vec![tool_call("ctx_read", 1, 0), tool_call("ctx_read", 1, 0)],
1405            ToolEntries,
1406        );
1407        for name in [
1408            "",
1409            "Ctx_read",
1410            "ctx-read",
1411            "/etc/passwd",
1412            "ctx read",
1413            "_ctx",
1414        ] {
1415            reject(vec![tool_call(name, 1, 0)], ToolName);
1416        }
1417        reject(
1418            vec![tool_call(&"a".repeat(MAX_TOOL_NAME_LEN + 1), 1, 0)],
1419            ToolName,
1420        );
1421        reject(vec![tool_call("ctx_read", 0, 0)], InconsistentCounts);
1422        reject(vec![tool_call("ctx_read", 1, 2)], InconsistentCounts);
1423        let too_many = (0..=MAX_TOOL_ENTRIES)
1424            .map(|index| tool_call(&format!("tool_{index:04}"), 1, 0))
1425            .collect();
1426        reject(too_many, ToolEntries);
1427        let unknown_field = r#"{"name":"tool_call_aggregate","metrics":{"tools":[{"tool":"ctx_read","calls":1,"failures":0,"path":"/secret"}]}}"#;
1428        assert!(serde_json::from_str::<TelemetryEventV2>(unknown_field).is_err());
1429    }
1430}