Skip to main content

traverse_contracts/
usage_telemetry.rs

1//! Provider-neutral usage-telemetry port (spec `088-runtime-usage-telemetry`
2//! FR-001). No caller of [`UsageTelemetrySink`] takes on a network or
3//! configuration dependency merely by calling it: [`NoOpUsageTelemetrySink`]
4//! is the default and performs no I/O of any kind.
5
6/// Which kind of usage event occurred (spec 088 FR-001).
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8pub enum UsageEventKind {
9    /// A registry version lookup resolved successfully.
10    Resolve,
11    /// A capability was executed via a real WASM invocation.
12    Execute,
13}
14
15/// One usage-telemetry event: exactly the capability-identifying fields
16/// spec 088 FR-004 permits (the install ID, also required by FR-004, is
17/// attached by the caller's concrete sink implementation, not this port).
18#[derive(Debug, Clone, PartialEq, Eq)]
19pub struct UsageEvent {
20    /// Which kind of event occurred.
21    pub kind: UsageEventKind,
22    /// The capability reference, formatted as `namespace/id@version`.
23    pub capability_ref: String,
24    /// Event timestamp (ISO 8601).
25    pub timestamp: String,
26}
27
28/// Provider-neutral port for recording usage-telemetry events.
29///
30/// Implementations MUST NOT block or fail the caller on downstream failure:
31/// per FR-005, any collector failure (timeout, DNS, non-2xx) must be
32/// swallowed entirely by the concrete sink, never surfaced through `record`.
33pub trait UsageTelemetrySink: Send + Sync {
34    /// Records one usage event.
35    fn record(&self, event: UsageEvent);
36}
37
38/// Default [`UsageTelemetrySink`]: performs no I/O of any kind (FR-001,
39/// FR-007). Wired whenever telemetry is disabled or unconfigured, so no
40/// caller (including `crates/traverse-registry`) takes on a network or
41/// configuration dependency merely by holding a sink.
42#[derive(Debug, Clone, Copy, Default)]
43pub struct NoOpUsageTelemetrySink;
44
45impl UsageTelemetrySink for NoOpUsageTelemetrySink {
46    fn record(&self, _event: UsageEvent) {}
47}
48
49#[cfg(test)]
50mod tests {
51    use super::*;
52
53    #[test]
54    fn no_op_sink_records_without_side_effects() {
55        let sink = NoOpUsageTelemetrySink;
56        sink.record(UsageEvent {
57            kind: UsageEventKind::Resolve,
58            capability_ref: "hello.world/say-hello@1.0.0".to_string(),
59            timestamp: "2026-08-04T00:00:00Z".to_string(),
60        });
61        sink.record(UsageEvent {
62            kind: UsageEventKind::Execute,
63            capability_ref: "hello.world/say-hello@1.0.0".to_string(),
64            timestamp: "2026-08-04T00:00:00Z".to_string(),
65        });
66    }
67
68    #[test]
69    #[allow(clippy::clone_on_copy, clippy::default_constructed_unit_structs)]
70    fn no_op_sink_default_clone_and_debug_are_stable() {
71        let sink = NoOpUsageTelemetrySink::default();
72        let cloned = sink.clone();
73        assert_eq!(format!("{sink:?}"), format!("{cloned:?}"));
74        assert_eq!(format!("{sink:?}"), "NoOpUsageTelemetrySink");
75    }
76
77    #[test]
78    fn no_op_sink_is_usable_as_a_trait_object() {
79        let sink: Box<dyn UsageTelemetrySink> = Box::new(NoOpUsageTelemetrySink);
80        sink.record(UsageEvent {
81            kind: UsageEventKind::Resolve,
82            capability_ref: "a.b/c@1.0.0".to_string(),
83            timestamp: "t".to_string(),
84        });
85    }
86
87    #[test]
88    fn usage_event_kind_equality_and_debug() {
89        assert_eq!(UsageEventKind::Resolve, UsageEventKind::Resolve);
90        assert_ne!(UsageEventKind::Resolve, UsageEventKind::Execute);
91        assert_eq!(format!("{:?}", UsageEventKind::Resolve), "Resolve");
92        assert_eq!(format!("{:?}", UsageEventKind::Execute), "Execute");
93        assert_eq!(UsageEventKind::Execute.clone(), UsageEventKind::Execute);
94    }
95
96    #[test]
97    fn usage_event_equality_clone_and_debug() {
98        let event = UsageEvent {
99            kind: UsageEventKind::Resolve,
100            capability_ref: "a.b/c@1.0.0".to_string(),
101            timestamp: "t".to_string(),
102        };
103        let cloned = event.clone();
104        assert_eq!(event, cloned);
105        assert!(format!("{event:?}").contains("Resolve"));
106
107        let different = UsageEvent {
108            kind: UsageEventKind::Execute,
109            ..event.clone()
110        };
111        assert_ne!(event, different);
112    }
113}