Skip to main content

kerniq_observability/
stats.rs

1//! Export-pipeline health counters.
2//!
3//! The SDK's batch processors drop items when their queue is full rather
4//! than block, but do not expose how many. Kerniq wraps each pipeline's
5//! processor and exporter to count items *offered* to the pipeline, items
6//! *exported*, and items whose export *failed*. While the application runs,
7//! `offered − exported − failed` is the number queued plus the number
8//! dropped; queued is bounded by the queue capacity, so a gap that keeps
9//! growing past it means the backend cannot keep up. After
10//! [`Observability::shutdown`](crate::Observability::shutdown) has drained
11//! the queues, the gap is exactly the number dropped.
12
13use std::fmt;
14#[allow(unused_imports)]
15use std::sync::Arc;
16use std::sync::atomic::{AtomicU64, Ordering};
17
18/// Counters for one signal's export pipelines.
19#[derive(Debug, Default)]
20pub(crate) struct SignalCounters {
21    pub(crate) offered: AtomicU64,
22    pub(crate) exported: AtomicU64,
23    pub(crate) failed: AtomicU64,
24    pub(crate) export_calls: AtomicU64,
25    pub(crate) export_failures: AtomicU64,
26}
27
28impl SignalCounters {
29    fn snapshot(&self) -> SignalStats {
30        // Read `offered` last so the derived gap is never negative.
31        let exported = self.exported.load(Ordering::Relaxed);
32        let failed = self.failed.load(Ordering::Relaxed);
33        let export_calls = self.export_calls.load(Ordering::Relaxed);
34        let export_failures = self.export_failures.load(Ordering::Relaxed);
35        let offered = self.offered.load(Ordering::Relaxed);
36        SignalStats {
37            offered,
38            exported,
39            failed,
40            unexported: offered.saturating_sub(exported + failed),
41            export_calls,
42            export_failures,
43        }
44    }
45
46    #[cfg_attr(not(feature = "otlp"), allow(dead_code))]
47    pub(crate) fn record_export(&self, items: u64, ok: bool) {
48        self.export_calls.fetch_add(1, Ordering::Relaxed);
49        if ok {
50            self.exported.fetch_add(items, Ordering::Relaxed);
51        } else {
52            self.failed.fetch_add(items, Ordering::Relaxed);
53            self.export_failures.fetch_add(1, Ordering::Relaxed);
54        }
55    }
56}
57
58/// Shared counters for every signal.
59#[derive(Debug, Default)]
60pub(crate) struct Counters {
61    pub(crate) traces: SignalCounters,
62    pub(crate) logs: SignalCounters,
63    pub(crate) metrics: SignalCounters,
64    /// Console log lines dropped by the non-blocking writer.
65    #[cfg(feature = "logs")]
66    pub(crate) console_dropped: std::sync::OnceLock<tracing_appender::non_blocking::ErrorCounter>,
67}
68
69impl Counters {
70    pub(crate) fn snapshot(&self) -> TelemetryStats {
71        TelemetryStats {
72            traces: self.traces.snapshot(),
73            logs: self.logs.snapshot(),
74            metrics: self.metrics.snapshot(),
75            #[cfg(feature = "logs")]
76            console_lines_dropped: self
77                .console_dropped
78                .get()
79                .map_or(0, |c| c.dropped_lines() as u64),
80            #[cfg(not(feature = "logs"))]
81            console_lines_dropped: 0,
82        }
83    }
84}
85
86/// Health of one signal's export pipelines.
87#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
88pub struct SignalStats {
89    /// Items handed to the pipelines (spans ended while sampled, log
90    /// records emitted). For metrics: data points in export calls.
91    pub offered: u64,
92    /// Items the exporter accepted.
93    pub exported: u64,
94    /// Items in export calls that failed (after retries).
95    pub failed: u64,
96    /// `offered − exported − failed`: queued plus dropped. After shutdown,
97    /// the number dropped. Always 0 for metrics, which aggregate in memory
98    /// instead of queuing.
99    pub unexported: u64,
100    /// Export calls made.
101    pub export_calls: u64,
102    /// Export calls that failed.
103    pub export_failures: u64,
104}
105
106/// A point-in-time view of every export pipeline. See the module docs for
107/// how to read it.
108#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
109pub struct TelemetryStats {
110    /// Span pipelines.
111    pub traces: SignalStats,
112    /// Log pipelines (OTLP and custom processors).
113    pub logs: SignalStats,
114    /// Push-based metric exporters (OTLP). Prometheus scrapes are not
115    /// counted.
116    pub metrics: SignalStats,
117    /// Console log lines dropped because the non-blocking buffer was full.
118    pub console_lines_dropped: u64,
119}
120
121impl fmt::Display for TelemetryStats {
122    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
123        for (name, s) in [
124            ("traces", self.traces),
125            ("logs", self.logs),
126            ("metrics", self.metrics),
127        ] {
128            writeln!(
129                f,
130                "{name}: offered={} exported={} failed={} unexported={} export_failures={}",
131                s.offered, s.exported, s.failed, s.unexported, s.export_failures
132            )?;
133        }
134        write!(f, "console lines dropped={}", self.console_lines_dropped)
135    }
136}
137
138// ---------------------------------------------------------------------------
139// Wrappers
140// ---------------------------------------------------------------------------
141
142#[cfg(all(feature = "tracing", feature = "otlp"))]
143pub(crate) mod traces {
144    use super::*;
145    use opentelemetry::Context;
146    use opentelemetry_sdk::Resource;
147    use opentelemetry_sdk::error::OTelSdkResult;
148    use opentelemetry_sdk::trace::{Span, SpanData, SpanExporter, SpanProcessor};
149    use std::time::Duration;
150
151    /// Counts spans offered to the wrapped processor.
152    #[derive(Debug)]
153    pub(crate) struct CountingProcessor<P> {
154        pub(crate) inner: P,
155        pub(crate) counters: Arc<Counters>,
156    }
157
158    impl<P: SpanProcessor> SpanProcessor for CountingProcessor<P> {
159        fn on_start(&self, span: &mut Span, cx: &Context) {
160            self.inner.on_start(span, cx);
161        }
162        fn on_end(&self, span: SpanData) {
163            if span.span_context.is_sampled() {
164                self.counters.traces.offered.fetch_add(1, Ordering::Relaxed);
165            }
166            self.inner.on_end(span);
167        }
168        fn force_flush(&self) -> OTelSdkResult {
169            self.inner.force_flush()
170        }
171        fn shutdown_with_timeout(&self, timeout: Duration) -> OTelSdkResult {
172            self.inner.shutdown_with_timeout(timeout)
173        }
174        fn set_resource(&mut self, resource: &Resource) {
175            self.inner.set_resource(resource);
176        }
177    }
178
179    /// Counts exported / failed spans.
180    #[derive(Debug)]
181    pub(crate) struct CountingExporter<E> {
182        pub(crate) inner: E,
183        pub(crate) counters: Arc<Counters>,
184    }
185
186    impl<E: SpanExporter> SpanExporter for CountingExporter<E> {
187        async fn export(&self, batch: Vec<SpanData>) -> OTelSdkResult {
188            let n = batch.len() as u64;
189            let result = self.inner.export(batch).await;
190            self.counters.traces.record_export(n, result.is_ok());
191            result
192        }
193        fn shutdown_with_timeout(&self, timeout: Duration) -> OTelSdkResult {
194            self.inner.shutdown_with_timeout(timeout)
195        }
196        fn force_flush(&self) -> OTelSdkResult {
197            self.inner.force_flush()
198        }
199        fn set_resource(&mut self, resource: &Resource) {
200            self.inner.set_resource(resource);
201        }
202    }
203}
204
205#[cfg(all(feature = "logs", feature = "otlp"))]
206pub(crate) mod logs {
207    use super::*;
208    use opentelemetry::InstrumentationScope;
209    use opentelemetry::logs::Severity;
210    use opentelemetry_sdk::Resource;
211    use opentelemetry_sdk::error::OTelSdkResult;
212    use opentelemetry_sdk::logs::{LogBatch, LogExporter, LogProcessor, SdkLogRecord};
213    use std::time::Duration;
214
215    #[derive(Debug)]
216    pub(crate) struct CountingProcessor<P> {
217        pub(crate) inner: P,
218        pub(crate) counters: Arc<Counters>,
219    }
220
221    impl<P: LogProcessor> LogProcessor for CountingProcessor<P> {
222        fn emit(&self, data: &mut SdkLogRecord, instrumentation: &InstrumentationScope) {
223            self.counters.logs.offered.fetch_add(1, Ordering::Relaxed);
224            self.inner.emit(data, instrumentation);
225        }
226        fn force_flush(&self) -> OTelSdkResult {
227            self.inner.force_flush()
228        }
229        fn shutdown_with_timeout(&self, timeout: Duration) -> OTelSdkResult {
230            self.inner.shutdown_with_timeout(timeout)
231        }
232        fn event_enabled(&self, level: Severity, target: &str, name: Option<&str>) -> bool {
233            self.inner.event_enabled(level, target, name)
234        }
235        fn set_resource(&mut self, resource: &Resource) {
236            self.inner.set_resource(resource);
237        }
238    }
239
240    #[derive(Debug)]
241    pub(crate) struct CountingExporter<E> {
242        pub(crate) inner: E,
243        pub(crate) counters: Arc<Counters>,
244    }
245
246    impl<E: LogExporter> LogExporter for CountingExporter<E> {
247        async fn export(&self, batch: LogBatch<'_>) -> OTelSdkResult {
248            let n = batch.iter().count() as u64;
249            let result = self.inner.export(batch).await;
250            self.counters.logs.record_export(n, result.is_ok());
251            result
252        }
253        fn shutdown_with_timeout(&self, timeout: Duration) -> OTelSdkResult {
254            self.inner.shutdown_with_timeout(timeout)
255        }
256        fn event_enabled(&self, level: Severity, target: &str, name: Option<&str>) -> bool {
257            self.inner.event_enabled(level, target, name)
258        }
259        fn set_resource(&mut self, resource: &Resource) {
260            self.inner.set_resource(resource);
261        }
262    }
263}
264
265#[cfg(feature = "metrics")]
266pub(crate) mod metrics {
267    use super::*;
268    use opentelemetry_sdk::error::OTelSdkResult;
269    use opentelemetry_sdk::metrics::Temporality;
270    use opentelemetry_sdk::metrics::data::{AggregatedMetrics, MetricData, ResourceMetrics};
271    use opentelemetry_sdk::metrics::exporter::PushMetricExporter;
272    use std::time::Duration;
273
274    #[derive(Debug)]
275    #[cfg_attr(not(feature = "otlp"), allow(dead_code))]
276    pub(crate) struct CountingExporter<E> {
277        pub(crate) inner: E,
278        pub(crate) counters: Arc<Counters>,
279    }
280
281    #[cfg_attr(not(feature = "otlp"), allow(dead_code))]
282    fn points(metrics: &ResourceMetrics) -> u64 {
283        let mut n = 0u64;
284        for scope in metrics.scope_metrics() {
285            for metric in scope.metrics() {
286                macro_rules! count {
287                    ($d:expr) => {
288                        match $d {
289                            MetricData::Sum(s) => s.data_points().count(),
290                            MetricData::Gauge(g) => g.data_points().count(),
291                            MetricData::Histogram(h) => h.data_points().count(),
292                            MetricData::ExponentialHistogram(h) => h.data_points().count(),
293                        }
294                    };
295                }
296                n += match metric.data() {
297                    AggregatedMetrics::F64(d) => count!(d),
298                    AggregatedMetrics::U64(d) => count!(d),
299                    AggregatedMetrics::I64(d) => count!(d),
300                } as u64;
301            }
302        }
303        n
304    }
305
306    impl<E: PushMetricExporter> PushMetricExporter for CountingExporter<E> {
307        async fn export(&self, metrics: &ResourceMetrics) -> OTelSdkResult {
308            let n = points(metrics);
309            self.counters
310                .metrics
311                .offered
312                .fetch_add(n, Ordering::Relaxed);
313            let result = self.inner.export(metrics).await;
314            self.counters.metrics.record_export(n, result.is_ok());
315            result
316        }
317        fn force_flush(&self) -> OTelSdkResult {
318            self.inner.force_flush()
319        }
320        fn shutdown_with_timeout(&self, timeout: Duration) -> OTelSdkResult {
321            self.inner.shutdown_with_timeout(timeout)
322        }
323        fn temporality(&self) -> Temporality {
324            self.inner.temporality()
325        }
326    }
327
328    /// Publishes the counters as `kerniq.telemetry.*` observable counters.
329    pub(crate) fn register(meter: &opentelemetry::metrics::Meter, counters: &Arc<Counters>) {
330        use kerniq_telemetry::semconv::kerniq as k;
331        use opentelemetry::KeyValue;
332
333        let c = counters.clone();
334        meter
335            .u64_observable_counter(k::TELEMETRY_ITEMS)
336            .with_unit("{item}")
337            .with_description("Telemetry items by signal and outcome (offered, exported, failed).")
338            .with_callback(move |observer| {
339                let s = c.snapshot();
340                for (signal, stats) in [("traces", s.traces), ("logs", s.logs)] {
341                    for (outcome, value) in [
342                        ("offered", stats.offered),
343                        ("exported", stats.exported),
344                        ("failed", stats.failed),
345                    ] {
346                        observer.observe(
347                            value,
348                            &[
349                                KeyValue::new(k::SIGNAL, signal),
350                                KeyValue::new(k::OUTCOME, outcome),
351                            ],
352                        );
353                    }
354                }
355                observer.observe(
356                    s.console_lines_dropped,
357                    &[
358                        KeyValue::new(k::SIGNAL, "console_logs"),
359                        KeyValue::new(k::OUTCOME, "dropped"),
360                    ],
361                );
362            })
363            .build();
364        let c = counters.clone();
365        meter
366            .u64_observable_counter(k::TELEMETRY_EXPORTS)
367            .with_unit("{export}")
368            .with_description("Export calls by signal and outcome (success, failure).")
369            .with_callback(move |observer| {
370                let s = c.snapshot();
371                for (signal, stats) in [
372                    ("traces", s.traces),
373                    ("logs", s.logs),
374                    ("metrics", s.metrics),
375                ] {
376                    observer.observe(
377                        stats.export_calls - stats.export_failures,
378                        &[
379                            KeyValue::new(k::SIGNAL, signal),
380                            KeyValue::new(k::OUTCOME, "success"),
381                        ],
382                    );
383                    observer.observe(
384                        stats.export_failures,
385                        &[
386                            KeyValue::new(k::SIGNAL, signal),
387                            KeyValue::new(k::OUTCOME, "failure"),
388                        ],
389                    );
390                }
391            })
392            .build();
393    }
394}
395
396#[cfg(test)]
397mod tests {
398    use super::*;
399
400    #[test]
401    fn unexported_is_never_negative() {
402        let c = SignalCounters::default();
403        c.offered.fetch_add(10, Ordering::Relaxed);
404        c.record_export(6, true);
405        c.record_export(2, false);
406        let s = c.snapshot();
407        assert_eq!(s.unexported, 2);
408        assert_eq!(s.export_calls, 2);
409        assert_eq!(s.export_failures, 1);
410    }
411}