Skip to main content

proton_sdk/
telemetry.rs

1//! Pluggable telemetry / structured-observability hooks.
2//!
3//! The SDK records a [`TelemetryEvent`] for each instrumented operation
4//! (transfers, navigation, API calls) and hands it to a consumer-supplied
5//! [`Telemetry`] observer. This mirrors the C# `Proton.Sdk` telemetry layer's
6//! `ITelemetry`-style sink: the SDK measures, the host decides what to do with
7//! the measurements (export to metrics, log, drop).
8//!
9//! Observers are **fire-and-forget and must not block**: [`Telemetry::record`]
10//! is called on the SDK's own task, synchronously, often inside a hot path. Do
11//! cheap work (increment a counter, enqueue) and return; offload anything
12//! heavy.
13//!
14//! Two built-ins are provided: [`NoopTelemetry`] (the default — records
15//! nothing) and [`TracingTelemetry`] (forwards every event to the `tracing`
16//! crate, tying telemetry into the SDK's existing structured logging).
17
18use std::sync::Arc;
19use std::time::Duration;
20
21/// The outcome of an instrumented operation.
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum Outcome {
24    /// The operation completed successfully.
25    Success,
26    /// The operation returned an error (or was dropped before being marked
27    /// successful — see [`OpTimer`]).
28    Failure,
29}
30
31impl Outcome {
32    /// `"success"` / `"failure"` — the label form used by [`TracingTelemetry`]
33    /// and convenient for metric dimensions.
34    pub fn as_str(self) -> &'static str {
35        match self {
36            Outcome::Success => "success",
37            Outcome::Failure => "failure",
38        }
39    }
40}
41
42/// A single measurement emitted by the SDK for one instrumented operation.
43///
44/// `operation` is a stable, low-cardinality identifier (e.g. `"download_file"`,
45/// `"upload_file"`, `"enumerate_folder_children"`) safe to use as a metric name
46/// or dimension. `attributes` carry extra low-cardinality context (e.g.
47/// `("block_count", "12")`); avoid per-request unique values (ids, names) that
48/// would explode a metrics backend's cardinality.
49#[derive(Debug, Clone)]
50pub struct TelemetryEvent {
51    /// Stable operation identifier.
52    pub operation: &'static str,
53    /// Whether the operation succeeded.
54    pub outcome: Outcome,
55    /// Wall-clock time the operation took.
56    pub duration: Duration,
57    /// Extra low-cardinality key/value context.
58    pub attributes: Vec<(&'static str, String)>,
59}
60
61/// A consumer-supplied sink for [`TelemetryEvent`]s.
62///
63/// Mirrors the C# telemetry sink. Implementations must be cheap to share across
64/// tasks (hence [`Send`] + [`Sync`]); the SDK holds them as
65/// `Arc<dyn Telemetry>`. [`record`](Telemetry::record) must not block.
66pub trait Telemetry: Send + Sync {
67    /// Receive one measurement. Called synchronously on the SDK's task.
68    fn record(&self, event: &TelemetryEvent);
69}
70
71/// The default observer: records nothing.
72#[derive(Debug, Clone, Copy, Default)]
73pub struct NoopTelemetry;
74
75impl Telemetry for NoopTelemetry {
76    fn record(&self, _event: &TelemetryEvent) {}
77}
78
79impl NoopTelemetry {
80    /// A shared no-op observer, for use as the default telemetry sink.
81    pub fn shared() -> Arc<dyn Telemetry> {
82        Arc::new(NoopTelemetry)
83    }
84}
85
86/// An observer that forwards every event to the `tracing` crate.
87///
88/// Successful operations emit at `debug`, failures at `warn`, both on the
89/// `proton_sdk::telemetry` target with structured `operation`, `outcome`,
90/// `duration_ms` and the event's attributes flattened into the message. This
91/// bridges telemetry into the SDK's existing `tracing`-based logging without a
92/// separate metrics backend.
93#[derive(Debug, Clone, Copy, Default)]
94pub struct TracingTelemetry;
95
96impl TracingTelemetry {
97    /// A shared `tracing`-forwarding observer.
98    pub fn shared() -> Arc<dyn Telemetry> {
99        Arc::new(TracingTelemetry)
100    }
101}
102
103impl Telemetry for TracingTelemetry {
104    fn record(&self, event: &TelemetryEvent) {
105        let duration_ms = event.duration.as_secs_f64() * 1000.0;
106        // Attributes are flattened into a single string so the log line stays
107        // readable regardless of how many there are.
108        let attrs = event
109            .attributes
110            .iter()
111            .map(|(k, v)| format!("{k}={v}"))
112            .collect::<Vec<_>>()
113            .join(" ");
114        match event.outcome {
115            Outcome::Success => tracing::debug!(
116                target: "proton_sdk::telemetry",
117                operation = event.operation,
118                outcome = event.outcome.as_str(),
119                duration_ms,
120                attributes = %attrs,
121                "operation completed",
122            ),
123            Outcome::Failure => tracing::warn!(
124                target: "proton_sdk::telemetry",
125                operation = event.operation,
126                outcome = event.outcome.as_str(),
127                duration_ms,
128                attributes = %attrs,
129                "operation failed",
130            ),
131        }
132    }
133}
134
135/// A scoped timer that records a [`TelemetryEvent`] when dropped.
136///
137/// Construct one at the start of an operation via [`Telemetry`]'s
138/// [`start`](TelemetryExt::start) helper. It defaults to [`Outcome::Failure`]
139/// so an early `?`-return is recorded as a failure automatically; call
140/// [`success`](OpTimer::success) on the happy path before returning. Attach
141/// extra context with [`attr`](OpTimer::attr).
142///
143/// ```ignore
144/// let mut timer = telemetry.start("download_file");
145/// let blocks = fetch_blocks().await?;        // early return => Failure
146/// timer.attr("block_count", blocks.len());
147/// timer.success();                            // mark before Ok
148/// Ok(data)
149/// ```
150pub struct OpTimer {
151    telemetry: Arc<dyn Telemetry>,
152    operation: &'static str,
153    start: std::time::Instant,
154    outcome: Outcome,
155    attributes: Vec<(&'static str, String)>,
156}
157
158impl OpTimer {
159    /// Mark the operation successful. Without this, drop records a failure.
160    pub fn success(&mut self) {
161        self.outcome = Outcome::Success;
162    }
163
164    /// Explicitly set the outcome (rarely needed; prefer [`success`](Self::success)).
165    pub fn set_outcome(&mut self, outcome: Outcome) {
166        self.outcome = outcome;
167    }
168
169    /// Attach a low-cardinality attribute to the event recorded on drop.
170    pub fn attr(&mut self, key: &'static str, value: impl ToString) {
171        self.attributes.push((key, value.to_string()));
172    }
173}
174
175impl Drop for OpTimer {
176    fn drop(&mut self) {
177        let event = TelemetryEvent {
178            operation: self.operation,
179            outcome: self.outcome,
180            duration: self.start.elapsed(),
181            // Take the attributes out so we don't clone; `self` is being dropped.
182            attributes: std::mem::take(&mut self.attributes),
183        };
184        self.telemetry.record(&event);
185    }
186}
187
188/// Ergonomic entry point for instrumenting an operation against a shared
189/// telemetry sink.
190pub trait TelemetryExt {
191    /// Begin timing `operation`, returning an [`OpTimer`] that records on drop.
192    fn start(&self, operation: &'static str) -> OpTimer;
193}
194
195impl TelemetryExt for Arc<dyn Telemetry> {
196    fn start(&self, operation: &'static str) -> OpTimer {
197        OpTimer {
198            telemetry: Arc::clone(self),
199            operation,
200            start: std::time::Instant::now(),
201            outcome: Outcome::Failure,
202            attributes: Vec::new(),
203        }
204    }
205}
206
207#[cfg(test)]
208mod tests {
209    use super::*;
210    use std::sync::Mutex;
211
212    /// A test sink that captures every recorded event.
213    #[derive(Default)]
214    struct Capture(Mutex<Vec<TelemetryEvent>>);
215
216    impl Telemetry for Capture {
217        fn record(&self, event: &TelemetryEvent) {
218            self.0.lock().unwrap().push(event.clone());
219        }
220    }
221
222    fn shared_capture() -> (Arc<dyn Telemetry>, Arc<Capture>) {
223        let capture = Arc::new(Capture::default());
224        (capture.clone() as Arc<dyn Telemetry>, capture)
225    }
226
227    #[test]
228    fn timer_records_failure_by_default() {
229        let (sink, capture) = shared_capture();
230        {
231            let _timer = sink.start("op_a");
232            // dropped without success()
233        }
234        let events = capture.0.lock().unwrap();
235        assert_eq!(events.len(), 1);
236        assert_eq!(events[0].operation, "op_a");
237        assert_eq!(events[0].outcome, Outcome::Failure);
238    }
239
240    #[test]
241    fn timer_records_success_and_attributes() {
242        let (sink, capture) = shared_capture();
243        {
244            let mut timer = sink.start("op_b");
245            timer.attr("block_count", 3usize);
246            timer.success();
247        }
248        let events = capture.0.lock().unwrap();
249        assert_eq!(events.len(), 1);
250        assert_eq!(events[0].outcome, Outcome::Success);
251        assert_eq!(events[0].attributes, vec![("block_count", "3".to_string())]);
252    }
253
254    #[test]
255    fn noop_records_nothing() {
256        let sink = NoopTelemetry::shared();
257        let mut timer = sink.start("op_c");
258        timer.success();
259        // No panic, nothing to assert — the point is it compiles and runs.
260    }
261
262    #[test]
263    fn outcome_label_form() {
264        assert_eq!(Outcome::Success.as_str(), "success");
265        assert_eq!(Outcome::Failure.as_str(), "failure");
266    }
267}