rtb_telemetry/context.rs
1//! [`TelemetryContext`] — the main user-facing entry point.
2
3use std::collections::HashMap;
4use std::sync::Arc;
5
6use crate::error::TelemetryError;
7use crate::event::Event;
8use crate::machine::MachineId;
9use crate::sink::{NoopSink, TelemetrySink};
10
11/// Runtime opt-in switch. `Disabled` suppresses every `record` call
12/// without allocating an event.
13///
14/// Default is [`CollectionPolicy::Disabled`] — collection is
15/// opt-in, per the two-level policy in CLAUDE.md (tool authors
16/// compile-enable the telemetry feature; users runtime-enable
17/// collection).
18#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
19pub enum CollectionPolicy {
20 /// No events emitted; `record` is a cheap short-circuit.
21 #[default]
22 Disabled,
23 /// Events emit through the configured sink.
24 Enabled,
25}
26
27/// Cheap-to-clone telemetry handle threaded through tools that opt
28/// into telemetry. Clones share the same sink, machine ID, and
29/// policy.
30#[derive(Clone)]
31pub struct TelemetryContext {
32 tool: Arc<String>,
33 tool_version: Arc<String>,
34 machine_id: Arc<String>,
35 sink: Arc<dyn TelemetrySink>,
36 policy: CollectionPolicy,
37}
38
39impl std::fmt::Debug for TelemetryContext {
40 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
41 f.debug_struct("TelemetryContext")
42 .field("tool", &self.tool)
43 .field("tool_version", &self.tool_version)
44 .field("machine_id_len", &self.machine_id.len())
45 .field("policy", &self.policy)
46 .field("sink", &"<dyn TelemetrySink>")
47 .finish()
48 }
49}
50
51impl TelemetryContext {
52 /// Start a builder. `tool`, `tool_version`, and a per-tool `salt`
53 /// are required; sink and policy default to noop/disabled.
54 pub fn builder() -> TelemetryContextBuilder {
55 TelemetryContextBuilder::default()
56 }
57
58 /// The current collection policy.
59 #[must_use]
60 pub const fn policy(&self) -> CollectionPolicy {
61 self.policy
62 }
63
64 /// Emit an event with no custom attributes.
65 ///
66 /// Short-circuits to `Ok(())` when
67 /// [`CollectionPolicy::Disabled`] — no event is constructed and
68 /// the sink is not touched.
69 pub async fn record(&self, event_name: &str) -> Result<(), TelemetryError> {
70 if self.policy == CollectionPolicy::Disabled {
71 return Ok(());
72 }
73 let event = Event::now(event_name, &*self.tool, &*self.tool_version, &*self.machine_id);
74 self.sink.emit(&event).await
75 }
76
77 /// Emit an event with custom attributes. Disabled-policy
78 /// short-circuit as above.
79 pub async fn record_with_attrs(
80 &self,
81 event_name: &str,
82 attrs: HashMap<String, String>,
83 ) -> Result<(), TelemetryError> {
84 if self.policy == CollectionPolicy::Disabled {
85 return Ok(());
86 }
87 let mut event = Event::now(event_name, &*self.tool, &*self.tool_version, &*self.machine_id);
88 event.attrs = attrs;
89 self.sink.emit(&event).await
90 }
91
92 /// Flush the underlying sink. No-op when disabled.
93 pub async fn flush(&self) -> Result<(), TelemetryError> {
94 if self.policy == CollectionPolicy::Disabled {
95 return Ok(());
96 }
97 self.sink.flush().await
98 }
99}
100
101/// Builder for [`TelemetryContext`].
102#[must_use]
103#[derive(Default)]
104pub struct TelemetryContextBuilder {
105 tool: Option<String>,
106 tool_version: Option<String>,
107 salt: Option<String>,
108 sink: Option<Arc<dyn TelemetrySink>>,
109 policy: CollectionPolicy,
110}
111
112impl TelemetryContextBuilder {
113 /// Set the owning tool's name. Required.
114 pub fn tool(mut self, tool: impl Into<String>) -> Self {
115 self.tool = Some(tool.into());
116 self
117 }
118
119 /// Set the tool's version string. Required.
120 pub fn tool_version(mut self, version: impl Into<String>) -> Self {
121 self.tool_version = Some(version.into());
122 self
123 }
124
125 /// Set the per-tool salt used in [`MachineId::derive`]. Required
126 /// when `policy == Enabled`; ignored otherwise.
127 ///
128 /// # Correctness
129 ///
130 /// The salt **must** be unique and stable per tool, otherwise
131 /// two tools running on the same host will emit identical
132 /// machine IDs and become indistinguishable in the telemetry
133 /// backend. A failing pattern is passing a literal like
134 /// `"default"` from every tool's codebase — don't do that.
135 ///
136 /// The recommended pattern is to derive the salt from the tool's
137 /// name and a fixed version tag, e.g.:
138 ///
139 /// ```no_run
140 /// # use rtb_telemetry::TelemetryContext;
141 /// let ctx = TelemetryContext::builder()
142 /// .tool("mytool")
143 /// .tool_version("1.0.0")
144 /// .salt(concat!(env!("CARGO_PKG_NAME"), ".telemetry.v1"))
145 /// .build();
146 /// ```
147 ///
148 /// Rotating the version tag (`.v1` → `.v2`) invalidates every
149 /// previously-recorded machine identity — the intended path for
150 /// "reset my telemetry identity" flows.
151 pub fn salt(mut self, salt: impl Into<String>) -> Self {
152 self.salt = Some(salt.into());
153 self
154 }
155
156 /// Set the backing sink. Defaults to [`NoopSink`].
157 pub fn sink(mut self, sink: Arc<dyn TelemetrySink>) -> Self {
158 self.sink = Some(sink);
159 self
160 }
161
162 /// Set the collection policy. Default is
163 /// [`CollectionPolicy::Disabled`] — opt-in.
164 pub const fn policy(mut self, policy: CollectionPolicy) -> Self {
165 self.policy = policy;
166 self
167 }
168
169 /// Finalise the context.
170 ///
171 /// # Panics
172 ///
173 /// Panics if `tool`, `tool_version`, or (when `policy == Enabled`)
174 /// `salt` were not supplied. Panic message names the missing
175 /// field.
176 #[must_use]
177 pub fn build(self) -> TelemetryContext {
178 let tool = self.tool.expect("TelemetryContextBuilder: tool is required");
179 let tool_version =
180 self.tool_version.expect("TelemetryContextBuilder: tool_version is required");
181 let sink = self.sink.unwrap_or_else(|| Arc::new(NoopSink));
182 let policy = self.policy;
183
184 // Derive the machine ID only when enabled — Disabled
185 // contexts never touch the host.
186 let machine_id = match policy {
187 CollectionPolicy::Disabled => String::new(),
188 CollectionPolicy::Enabled => {
189 let salt = self
190 .salt
191 .expect("TelemetryContextBuilder: salt is required when policy is Enabled");
192 MachineId::derive(&salt)
193 }
194 };
195
196 TelemetryContext {
197 tool: Arc::new(tool),
198 tool_version: Arc::new(tool_version),
199 machine_id: Arc::new(machine_id),
200 sink,
201 policy,
202 }
203 }
204}