1use 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;
13pub 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)]
135pub 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 TrialStarted(OccurrenceMetrics),
152 TrialEnded(OutcomeMetrics),
154 UpgradeViewed(OccurrenceMetrics),
156 CheckoutStarted(OccurrenceMetrics),
157 SubscriptionActivated(OccurrenceMetrics),
159 SubscriptionCancelled(OccurrenceMetrics),
161 TeamCreated(OccurrenceMetrics),
163 TeamMemberInvited(OccurrenceMetrics),
165 TeamContextPromoted(OutcomeMetrics),
167 ErrorCategoryAggregate(ErrorMetrics),
168 VersionUpgrade(VersionUpgradeMetrics),
169 OrchestrationAggregate(Box<OrchestrationMetrics>),
171 UsageHistory(UsageHistoryMetrics),
172 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 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
237pub const MAX_TOKENS: u64 = 1_000_000_000_000_000;
240pub const MAX_HISTORY_DAYS: usize = 90;
242
243#[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 pub commands: u64,
259 pub original_tokens: u64,
261 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 #[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
315fn 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 #[serde(default, skip_serializing_if = "Option::is_none")]
332 pub install_age: Option<InstallAge>,
333 #[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#[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#[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#[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 Unsupported,
439 Disabled,
441 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 #[serde(default, skip_serializing_if = "Option::is_none")]
494 pub tokens: Option<TokenMetrics>,
495}
496
497#[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#[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 #[serde(default, skip_serializing_if = "Option::is_none")]
538 pub latency_milliseconds_total: Option<u64>,
539 #[serde(default, skip_serializing_if = "Option::is_none")]
541 pub failure_kinds:
542 Option<std::collections::BTreeMap<crate::core::telemetry_failure::FailureKind, u64>>,
543 #[serde(default, skip_serializing_if = "Option::is_none")]
546 pub failure_messages: Option<Vec<FailureMessage>>,
547}
548
549#[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 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
624pub 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
635pub const MAX_FEATURE_ENTRIES: usize = 96;
637
638#[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#[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 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 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 None,
873 Other,
875}
876
877impl ClientFamily {
878 #[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 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 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 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}