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}