Skip to main content

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    /// ```ignore
140    /// .salt(concat!(env!("CARGO_PKG_NAME"), ".telemetry.v1"))
141    /// ```
142    ///
143    /// Rotating the version tag (`.v1` → `.v2`) invalidates every
144    /// previously-recorded machine identity — the intended path for
145    /// "reset my telemetry identity" flows.
146    pub fn salt(mut self, salt: impl Into<String>) -> Self {
147        self.salt = Some(salt.into());
148        self
149    }
150
151    /// Set the backing sink. Defaults to [`NoopSink`].
152    pub fn sink(mut self, sink: Arc<dyn TelemetrySink>) -> Self {
153        self.sink = Some(sink);
154        self
155    }
156
157    /// Set the collection policy. Default is
158    /// [`CollectionPolicy::Disabled`] — opt-in.
159    pub const fn policy(mut self, policy: CollectionPolicy) -> Self {
160        self.policy = policy;
161        self
162    }
163
164    /// Finalise the context.
165    ///
166    /// # Panics
167    ///
168    /// Panics if `tool`, `tool_version`, or (when `policy == Enabled`)
169    /// `salt` were not supplied. Panic message names the missing
170    /// field.
171    #[must_use]
172    pub fn build(self) -> TelemetryContext {
173        let tool = self.tool.expect("TelemetryContextBuilder: tool is required");
174        let tool_version =
175            self.tool_version.expect("TelemetryContextBuilder: tool_version is required");
176        let sink = self.sink.unwrap_or_else(|| Arc::new(NoopSink));
177        let policy = self.policy;
178
179        // Derive the machine ID only when enabled — Disabled
180        // contexts never touch the host.
181        let machine_id = match policy {
182            CollectionPolicy::Disabled => String::new(),
183            CollectionPolicy::Enabled => {
184                let salt = self
185                    .salt
186                    .expect("TelemetryContextBuilder: salt is required when policy is Enabled");
187                MachineId::derive(&salt)
188            }
189        };
190
191        TelemetryContext {
192            tool: Arc::new(tool),
193            tool_version: Arc::new(tool_version),
194            machine_id: Arc::new(machine_id),
195            sink,
196            policy,
197        }
198    }
199}