Skip to main content

kerniq_observability/
lib.rs

1//! # Kerniq Observe
2//!
3//! Vendor-neutral metrics, structured logs and distributed tracing for
4//! Kerniq Server, built on OpenTelemetry, `tracing` and Prometheus.
5//!
6//!
7//! ## What Kerniq adds, and what it reuses
8//!
9//! Aggregation, sampling, batching, export and retries are the OpenTelemetry
10//! SDK's. Log formatting and filtering are `tracing-subscriber`'s. Prometheus
11//! encoding is `opentelemetry-prometheus`'s. Kerniq contributes:
12//!
13//! * one builder that configures every signal independently,
14//! * explicit provider ownership: no global OpenTelemetry provider is set
15//!   unless you call [`Observability::install_global_providers`],
16//! * bounded pipelines with drop accounting ([`Observability::stats`]),
17//! * a bounded [`shutdown`](Observability::shutdown),
18//! * HTTP instrumentation with streaming-aware measurements
19//!   (`kerniq-observability-axum`),
20//! * a JSON log format that carries `trace_id`/`span_id`,
21//! * safeguards against telemetry feedback loops (exporter and HTTP-client
22//!   crates are excluded from the OpenTelemetry log and trace bridges).
23//!
24//! ## Crate features
25//!
26//! | Feature | Default | Enables |
27//! |---|---|---|
28//! | `metrics` | ✓ | metrics SDK, HTTP metrics |
29//! | `logs` | ✓ | console logs (JSON / pretty / compact), log bridge |
30//! | `tracing` | ✓ | spans → OpenTelemetry, W3C propagation |
31//! | `otlp` | | OTLP gRPC + HTTP exporters for the enabled signals |
32//! | `otlp-tls` | | TLS for OTLP |
33//! | `prometheus` | | Prometheus scrape endpoint |
34//! | `runtime` | | Tokio and process metrics |
35//! | `full` | | all of the above except `otlp-tls` |
36
37#![cfg_attr(
38    all(feature = "metrics", feature = "logs", feature = "tracing"),
39    doc = r####"
40## Example
41
42```no_run
43use kerniq_observability::prelude::*;
44
45# async fn demo() -> Result<(), kerniq_observability::Error> {
46let observability = Observability::builder()
47    .service_name("payment-api")
48    .service_version("1.0.0")
49    .environment("production")
50    // With the `prometheus` feature: MetricsConfig::prometheus().endpoint("/metrics")
51    .metrics(MetricsConfig::new())
52    .logs(LogsConfig::json())
53    // With the `otlp` feature: TracesConfig::otlp().sampling_ratio(0.10)
54    .traces(TracesConfig::new().sampling_ratio(0.10))
55    .build()
56    .await?;
57
58// With kerniq-server (feature `observability`):
59//     App::new().observability(observability.clone()).get(...).run(..).await
60
61// Application metrics use the OpenTelemetry API directly:
62let payments = observability
63    .meter("payment-service")
64    .u64_counter("payments.processed")
65    .build();
66payments.add(1, &[KeyValue::new("payment.type", "transfer")]);
67
68// Flush and stop exporters (Kerniq's `App` does this after draining).
69let report = observability.shutdown().await;
70# Ok(()) }
71```
72"####
73)]
74#![warn(missing_docs)]
75#![cfg_attr(docsrs, feature(doc_cfg))]
76
77mod config;
78#[cfg(feature = "logs")]
79pub mod logging;
80#[cfg(feature = "otlp")]
81mod otlp;
82#[cfg(feature = "runtime")]
83mod runtime;
84mod stats;
85
86use std::sync::Arc;
87use std::sync::atomic::{AtomicBool, Ordering};
88use std::time::{Duration, Instant};
89
90#[cfg(feature = "logs")]
91pub use config::LogsConfig;
92#[cfg(feature = "metrics")]
93pub use config::MetricsConfig;
94#[cfg(feature = "tracing")]
95pub use config::TracesConfig;
96pub use config::{BatchConfig, ConsoleFormat, ConsoleTarget, Sampling};
97#[cfg(feature = "otlp-tls")]
98pub use otlp::TlsConfig;
99#[cfg(feature = "otlp")]
100pub use otlp::{OtlpExporter, Protocol, Retry};
101pub use stats::{SignalStats, TelemetryStats};
102
103pub use kerniq_observability_axum::HttpInstrumentation;
104/// W3C trace-context helpers for outbound calls.
105#[cfg(feature = "tracing")]
106pub use kerniq_observability_axum::propagation;
107#[cfg(feature = "prometheus")]
108pub use kerniq_observability_prometheus::{PrometheusConfig, PrometheusExporter};
109pub use kerniq_telemetry::{self as telemetry, BoundedLabel, ServiceInfo};
110
111/// Re-export of the OpenTelemetry API.
112pub use opentelemetry;
113pub use opentelemetry::KeyValue;
114/// Re-export of the OpenTelemetry SDK.
115#[cfg(any(feature = "metrics", feature = "tracing", feature = "logs"))]
116pub use opentelemetry_sdk;
117
118use stats::Counters;
119
120/// Everything most applications need.
121pub mod prelude {
122    #[cfg(feature = "logs")]
123    pub use crate::LogsConfig;
124    #[cfg(feature = "metrics")]
125    pub use crate::MetricsConfig;
126    #[cfg(feature = "prometheus")]
127    pub use crate::PrometheusConfig;
128    #[cfg(feature = "tracing")]
129    pub use crate::TracesConfig;
130    pub use crate::{
131        BatchConfig, BoundedLabel, ConsoleFormat, Observability, ObservabilityBuilder, Sampling,
132    };
133    #[cfg(feature = "otlp")]
134    pub use crate::{OtlpExporter, Protocol};
135    pub use opentelemetry::KeyValue;
136    #[cfg(feature = "metrics")]
137    pub use opentelemetry::metrics::{Counter, Gauge, Histogram, Meter, UpDownCounter};
138}
139
140/// Observability setup errors.
141#[derive(Debug, thiserror::Error)]
142#[non_exhaustive]
143pub enum Error {
144    /// The configuration is invalid.
145    #[error("invalid observability configuration: {0}")]
146    Config(String),
147    /// An exporter could not be built.
148    #[error("failed to build the {signal} exporter: {source}")]
149    Exporter {
150        /// `traces`, `logs` or `metrics`.
151        signal: &'static str,
152        /// The underlying error.
153        #[source]
154        source: Box<dyn std::error::Error + Send + Sync>,
155    },
156    /// A global `tracing` subscriber was already installed.
157    #[error(
158        "a global tracing subscriber is already installed; call \
159         `.install_subscriber(false)` and add `observability.tracing_layer()` to your own subscriber"
160    )]
161    SubscriberAlreadySet,
162    /// The Prometheus exporter could not be built.
163    #[cfg(feature = "prometheus")]
164    #[error(transparent)]
165    Prometheus(#[from] kerniq_observability_prometheus::Error),
166}
167
168impl From<Error> for kerniq_core::Error {
169    fn from(error: Error) -> Self {
170        kerniq_core::Error::internal(error)
171    }
172}
173
174/// Targets whose events and spans are never bridged to OpenTelemetry: the
175/// exporters' own HTTP/gRPC stacks and the SDK's diagnostics. Bridging them
176/// would make every export produce more telemetry to export.
177#[cfg(any(feature = "tracing", feature = "logs"))]
178const BRIDGE_EXCLUSIONS: &str = "opentelemetry=off,opentelemetry_sdk=off,opentelemetry_otlp=off,\
179                                 opentelemetry_prometheus=off,tonic=off,h2=off,hyper=off,\
180                                 hyper_util=off,reqwest=off,tower=off,tower_http=off";
181
182// ---------------------------------------------------------------------------
183// Builder
184// ---------------------------------------------------------------------------
185
186/// Builds an [`Observability`]. Every signal is optional.
187#[must_use]
188pub struct ObservabilityBuilder {
189    service: ServiceInfo,
190    #[cfg(feature = "metrics")]
191    metrics: Option<MetricsConfig>,
192    #[cfg(feature = "tracing")]
193    traces: Option<TracesConfig>,
194    #[cfg(feature = "logs")]
195    logs: Option<LogsConfig>,
196    install_subscriber: bool,
197    shutdown_timeout: Duration,
198    http_exclude: Vec<String>,
199    http_scheme: &'static str,
200}
201
202impl std::fmt::Debug for ObservabilityBuilder {
203    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
204        let mut d = f.debug_struct("ObservabilityBuilder");
205        d.field("service", &self.service);
206        #[cfg(feature = "metrics")]
207        d.field("metrics", &self.metrics);
208        #[cfg(feature = "tracing")]
209        d.field("traces", &self.traces);
210        #[cfg(feature = "logs")]
211        d.field("logs", &self.logs);
212        d.field("install_subscriber", &self.install_subscriber)
213            .field("shutdown_timeout", &self.shutdown_timeout)
214            .field("http_exclude", &self.http_exclude)
215            .finish()
216    }
217}
218
219impl Default for ObservabilityBuilder {
220    fn default() -> Self {
221        Self {
222            service: ServiceInfo::default(),
223            #[cfg(feature = "metrics")]
224            metrics: None,
225            #[cfg(feature = "tracing")]
226            traces: None,
227            #[cfg(feature = "logs")]
228            logs: None,
229            install_subscriber: true,
230            shutdown_timeout: Duration::from_secs(5),
231            http_exclude: Vec::new(),
232            http_scheme: "http",
233        }
234    }
235}
236
237impl ObservabilityBuilder {
238    /// `service.name`. Falls back to `OTEL_SERVICE_NAME`, then
239    /// `unknown_service`.
240    pub fn service_name(mut self, name: impl Into<String>) -> Self {
241        self.service.name = Some(name.into());
242        self
243    }
244
245    /// `service.version`.
246    pub fn service_version(mut self, version: impl Into<String>) -> Self {
247        self.service.version = Some(version.into());
248        self
249    }
250
251    /// `service.namespace`.
252    pub fn service_namespace(mut self, namespace: impl Into<String>) -> Self {
253        self.service.namespace = Some(namespace.into());
254        self
255    }
256
257    /// `deployment.environment.name` (`production`, `staging`, ...).
258    pub fn environment(mut self, environment: impl Into<String>) -> Self {
259        self.service.environment = Some(environment.into());
260        self
261    }
262
263    /// `service.instance.id`. Default: `<hostname>-<pid>-<start time>`.
264    pub fn instance_id(mut self, id: impl Into<String>) -> Self {
265        self.service.instance_id = Some(id.into());
266        self
267    }
268
269    /// Adds a resource attribute (`cloud.region`, `k8s.pod.name`, ...).
270    /// `OTEL_RESOURCE_ATTRIBUTES` is also honored.
271    pub fn resource_attribute(
272        mut self,
273        key: impl Into<opentelemetry::Key>,
274        value: impl Into<opentelemetry::Value>,
275    ) -> Self {
276        self.service.attributes.push(KeyValue::new(key, value));
277        self
278    }
279
280    /// Enables metrics.
281    #[cfg(feature = "metrics")]
282    pub fn metrics(mut self, config: MetricsConfig) -> Self {
283        self.metrics = Some(config);
284        self
285    }
286
287    /// Enables tracing.
288    #[cfg(feature = "tracing")]
289    pub fn traces(mut self, config: TracesConfig) -> Self {
290        self.traces = Some(config);
291        self
292    }
293
294    /// Enables logging.
295    #[cfg(feature = "logs")]
296    pub fn logs(mut self, config: LogsConfig) -> Self {
297        self.logs = Some(config);
298        self
299    }
300
301    /// Whether [`build`](Self::build) installs the global `tracing`
302    /// subscriber (default `true`). Building fails with
303    /// [`Error::SubscriberAlreadySet`] instead of silently replacing one.
304    /// Set `false` to compose [`Observability::tracing_layer`] into your
305    /// own subscriber.
306    pub fn install_subscriber(mut self, install: bool) -> Self {
307        self.install_subscriber = install;
308        self
309    }
310
311    /// Upper bound on [`Observability::shutdown`]. Default 5 s.
312    pub fn shutdown_timeout(mut self, timeout: Duration) -> Self {
313        self.shutdown_timeout = timeout;
314        self
315    }
316
317    /// Excludes a route template (or, for unmatched requests, a path) from
318    /// HTTP metrics and traces, e.g. `/health`.
319    pub fn exclude_http_route(mut self, route: impl Into<String>) -> Self {
320        self.http_exclude.push(route.into());
321        self
322    }
323
324    /// The `url.scheme` to report for HTTP/1 requests (`"https"` when TLS
325    /// terminates in front of the service). Default `"http"`.
326    pub fn http_scheme(mut self, scheme: &'static str) -> Self {
327        self.http_scheme = scheme;
328        self
329    }
330
331    /// Builds the providers and exporters and, unless disabled, installs the
332    /// `tracing` subscriber.
333    ///
334    /// Must be called inside a Tokio runtime: the OTLP/gRPC exporter and the
335    /// runtime metrics bind to the current runtime. No runtime is created.
336    /// Exporters connect lazily, so an unreachable Collector does not fail
337    /// the build.
338    pub async fn build(self) -> Result<Observability, Error> {
339        self.build_sync()
340    }
341
342    fn build_sync(self) -> Result<Observability, Error> {
343        let ObservabilityBuilder {
344            mut service,
345            #[cfg(feature = "metrics")]
346            metrics,
347            #[cfg(feature = "tracing")]
348            traces,
349            #[cfg(feature = "logs")]
350            logs,
351            install_subscriber,
352            shutdown_timeout,
353            http_exclude,
354            http_scheme,
355        } = self;
356
357        if service.instance_id.is_none() {
358            service.instance_id = Some(kerniq_telemetry::resource::default_instance_id());
359        }
360        #[cfg(any(feature = "metrics", feature = "tracing", feature = "logs"))]
361        let resource = {
362            let mut builder = opentelemetry_sdk::Resource::builder();
363            if let Some(name) = &service.name {
364                builder = builder.with_service_name(name.clone());
365            }
366            builder.with_attributes(service.to_attributes()).build()
367        };
368
369        let counters = Arc::new(Counters::default());
370
371        // --- metrics ------------------------------------------------------
372        #[cfg(feature = "metrics")]
373        let mut http_metrics = false;
374        #[cfg(feature = "prometheus")]
375        let mut prometheus_handle = None;
376        #[cfg(feature = "metrics")]
377        let meter_provider = match metrics {
378            None => None,
379            Some(config) => {
380                http_metrics = config.http;
381                let (provider, _prometheus) = build_metrics(config, &resource, &counters)?;
382                #[cfg(feature = "prometheus")]
383                {
384                    prometheus_handle = _prometheus;
385                }
386                Some(provider)
387            }
388        };
389
390        // --- traces -------------------------------------------------------
391        #[cfg(feature = "tracing")]
392        let (tracer_provider, traces_filter, activate_context) = match traces {
393            None => (None, None, false),
394            Some(config) => {
395                let filter = config.filter.clone();
396                let activate = config.activate_context;
397                (
398                    Some(build_traces(config, &resource, &counters)?),
399                    filter,
400                    activate,
401                )
402            }
403        };
404
405        // --- logs ---------------------------------------------------------
406        #[cfg(feature = "logs")]
407        let mut console = None;
408        #[cfg(feature = "logs")]
409        let mut console_guard = None;
410        #[cfg(feature = "logs")]
411        let (logger_provider, logs_filter, bridge_filter) = match logs {
412            None => (None, None, None),
413            Some(config) => {
414                let filter = config.filter.clone();
415                #[cfg(feature = "otlp")]
416                let bridge_filter = config.otlp_filter.clone().or_else(|| filter.clone());
417                #[cfg(not(feature = "otlp"))]
418                let bridge_filter = filter.clone();
419                if let Some((format, target)) = config.console {
420                    let (writer, guard) = console_writer(target, config.non_blocking, &counters);
421                    console = Some(ConsoleSetup {
422                        format,
423                        writer,
424                        target,
425                    });
426                    console_guard = guard;
427                }
428                let provider = if config.has_bridge() {
429                    Some(build_logs(config, &resource, &counters)?)
430                } else {
431                    None
432                };
433                (provider, filter, bridge_filter)
434            }
435        };
436
437        // --- HTTP instrumentation -----------------------------------------
438        #[cfg(feature = "tracing")]
439        let propagator: Arc<
440            dyn opentelemetry::propagation::TextMapPropagator + Send + Sync,
441        > = Arc::new(opentelemetry::propagation::TextMapCompositePropagator::new(
442            vec![
443                Box::new(opentelemetry_sdk::propagation::TraceContextPropagator::new()),
444                Box::new(opentelemetry_sdk::propagation::BaggagePropagator::new()),
445            ],
446        ));
447
448        #[cfg_attr(not(feature = "prometheus"), allow(unused_mut))]
449        let mut exclude = http_exclude;
450        #[cfg(feature = "prometheus")]
451        if let Some(prom) = &prometheus_handle {
452            exclude.push(prom.config().scrape_path().to_owned());
453        }
454
455        let inner = Inner {
456            #[cfg(any(feature = "metrics", feature = "tracing", feature = "logs"))]
457            resource,
458            #[cfg(feature = "metrics")]
459            meter_provider,
460            #[cfg(feature = "metrics")]
461            http_metrics,
462            #[cfg(feature = "prometheus")]
463            prometheus: prometheus_handle,
464            #[cfg(feature = "tracing")]
465            tracer_provider,
466            #[cfg(feature = "tracing")]
467            traces_filter,
468            #[cfg(feature = "tracing")]
469            activate_context,
470            #[cfg(feature = "tracing")]
471            propagator,
472            #[cfg(feature = "logs")]
473            logger_provider,
474            #[cfg(feature = "logs")]
475            console,
476            #[cfg(feature = "logs")]
477            console_guard: std::sync::Mutex::new(console_guard),
478            #[cfg(feature = "logs")]
479            logs_filter,
480            #[cfg(feature = "logs")]
481            bridge_filter,
482            service,
483            counters,
484            http_exclude: exclude,
485            http_scheme,
486            shutdown_timeout,
487            shut_down: AtomicBool::new(false),
488        };
489        let observability = Observability {
490            inner: Arc::new(inner),
491        };
492
493        if install_subscriber && observability.has_subscriber_layers() {
494            observability.install_subscriber()?;
495        }
496        Ok(observability)
497    }
498}
499
500#[cfg(feature = "metrics")]
501fn build_metrics(
502    config: MetricsConfig,
503    resource: &opentelemetry_sdk::Resource,
504    counters: &Arc<Counters>,
505) -> Result<
506    (
507        opentelemetry_sdk::metrics::SdkMeterProvider,
508        Option<PrometheusHandle>,
509    ),
510    Error,
511> {
512    use opentelemetry::metrics::MeterProvider as _;
513    use opentelemetry_sdk::metrics::SdkMeterProvider;
514
515    let mut builder = SdkMeterProvider::builder().with_resource(resource.clone());
516    if let Some(limit) = config.cardinality_limit {
517        if limit == 0 {
518            return Err(Error::Config(
519                "cardinality_limit must be greater than zero".into(),
520            ));
521        }
522        builder = builder.with_view(move |_: &opentelemetry_sdk::metrics::Instrument| {
523            opentelemetry_sdk::metrics::Stream::builder()
524                .with_cardinality_limit(limit)
525                .build()
526                .ok()
527        });
528    }
529
530    #[cfg(feature = "otlp")]
531    for exporter in &config.otlp {
532        let exporter = stats::metrics::CountingExporter {
533            inner: otlp::metric_exporter(exporter, config.delta_temporality)?,
534            counters: counters.clone(),
535        };
536        let reader = opentelemetry_sdk::metrics::PeriodicReader::builder(exporter)
537            .with_interval(config.export_interval)
538            .build();
539        builder = builder.with_reader(reader);
540    }
541
542    #[cfg(feature = "prometheus")]
543    let prometheus = match &config.prometheus {
544        Some(prom) => {
545            let (reader, handle) = PrometheusExporter::build(prom.clone())?;
546            builder = builder.with_reader(reader);
547            Some(handle)
548        }
549        None => None,
550    };
551    #[cfg(not(feature = "prometheus"))]
552    let prometheus = None;
553
554    for hook in config.readers {
555        builder = hook(builder, config.export_interval);
556    }
557    let provider = builder.build();
558
559    let internal = provider.meter("kerniq.observability");
560    stats::metrics::register(&internal, counters);
561    #[cfg(feature = "runtime")]
562    if config.runtime {
563        runtime::register(&internal);
564    }
565    Ok((provider, prometheus))
566}
567
568#[cfg(feature = "tracing")]
569fn build_traces(
570    config: TracesConfig,
571    resource: &opentelemetry_sdk::Resource,
572    counters: &Arc<Counters>,
573) -> Result<opentelemetry_sdk::trace::SdkTracerProvider, Error> {
574    use opentelemetry_sdk::trace::{Sampler, SdkTracerProvider};
575
576    config.batch.validate("traces").map_err(Error::Config)?;
577    let sampler = match config.sampling {
578        Sampling::AlwaysOn => Sampler::AlwaysOn,
579        Sampling::AlwaysOff => Sampler::AlwaysOff,
580        Sampling::Ratio(ratio) => {
581            if !(0.0..=1.0).contains(&ratio) {
582                return Err(Error::Config(format!(
583                    "sampling ratio {ratio} is outside 0.0..=1.0"
584                )));
585            }
586            Sampler::TraceIdRatioBased(ratio)
587        }
588    };
589    let sampler = if config.parent_based {
590        Sampler::ParentBased(Box::new(sampler))
591    } else {
592        sampler
593    };
594    let mut builder = SdkTracerProvider::builder()
595        .with_resource(resource.clone())
596        .with_sampler(sampler);
597
598    #[cfg(feature = "otlp")]
599    for exporter in &config.otlp {
600        let batch = opentelemetry_sdk::trace::BatchConfigBuilder::default()
601            .with_max_queue_size(config.batch.max_queue_size)
602            .with_max_export_batch_size(config.batch.max_export_batch_size)
603            .with_scheduled_delay(config.batch.scheduled_delay)
604            .build();
605        let exporter = stats::traces::CountingExporter {
606            inner: otlp::span_exporter(
607                &exporter.with_default_timeout(config.batch.export_timeout),
608            )?,
609            counters: counters.clone(),
610        };
611        let processor = opentelemetry_sdk::trace::BatchSpanProcessor::builder(exporter)
612            .with_batch_config(batch)
613            .build();
614        builder = builder.with_span_processor(stats::traces::CountingProcessor {
615            inner: processor,
616            counters: counters.clone(),
617        });
618    }
619    #[cfg(not(feature = "otlp"))]
620    let _ = counters;
621
622    for hook in config.processors {
623        builder = hook(builder);
624    }
625    Ok(builder.build())
626}
627
628#[cfg(feature = "logs")]
629fn build_logs(
630    config: LogsConfig,
631    resource: &opentelemetry_sdk::Resource,
632    counters: &Arc<Counters>,
633) -> Result<opentelemetry_sdk::logs::SdkLoggerProvider, Error> {
634    use opentelemetry_sdk::logs::SdkLoggerProvider;
635
636    config.batch.validate("logs").map_err(Error::Config)?;
637    let mut builder = SdkLoggerProvider::builder().with_resource(resource.clone());
638
639    #[cfg(feature = "otlp")]
640    for exporter in &config.otlp {
641        let batch = opentelemetry_sdk::logs::BatchConfigBuilder::default()
642            .with_max_queue_size(config.batch.max_queue_size)
643            .with_max_export_batch_size(config.batch.max_export_batch_size)
644            .with_scheduled_delay(config.batch.scheduled_delay)
645            .build();
646        let exporter = stats::logs::CountingExporter {
647            inner: otlp::log_exporter(&exporter.with_default_timeout(config.batch.export_timeout))?,
648            counters: counters.clone(),
649        };
650        let processor = opentelemetry_sdk::logs::BatchLogProcessor::builder(exporter)
651            .with_batch_config(batch)
652            .build();
653        builder = builder.with_log_processor(stats::logs::CountingProcessor {
654            inner: processor,
655            counters: counters.clone(),
656        });
657    }
658    #[cfg(not(feature = "otlp"))]
659    let _ = counters;
660
661    for hook in config.processors {
662        builder = hook(builder);
663    }
664    Ok(builder.build())
665}
666
667#[cfg(feature = "logs")]
668#[derive(Clone)]
669enum ConsoleWriter {
670    NonBlocking(tracing_appender::non_blocking::NonBlocking),
671    Stdout,
672    Stderr,
673}
674
675#[cfg(feature = "logs")]
676struct ConsoleSetup {
677    format: ConsoleFormat,
678    writer: ConsoleWriter,
679    target: ConsoleTarget,
680}
681
682#[cfg(feature = "logs")]
683fn console_writer(
684    target: ConsoleTarget,
685    buffered_lines: Option<usize>,
686    counters: &Arc<Counters>,
687) -> (
688    ConsoleWriter,
689    Option<tracing_appender::non_blocking::WorkerGuard>,
690) {
691    let Some(lines) = buffered_lines else {
692        return (
693            match target {
694                ConsoleTarget::Stdout => ConsoleWriter::Stdout,
695                ConsoleTarget::Stderr => ConsoleWriter::Stderr,
696            },
697            None,
698        );
699    };
700    let builder = tracing_appender::non_blocking::NonBlockingBuilder::default()
701        .buffered_lines_limit(lines.max(1))
702        .lossy(true)
703        .thread_name("kerniq-log-writer");
704    let (writer, guard) = match target {
705        ConsoleTarget::Stdout => builder.finish(std::io::stdout()),
706        ConsoleTarget::Stderr => builder.finish(std::io::stderr()),
707    };
708    let _ = counters.console_dropped.set(writer.error_counter());
709    (ConsoleWriter::NonBlocking(writer), Some(guard))
710}
711
712#[cfg(feature = "prometheus")]
713type PrometheusHandle = PrometheusExporter;
714#[cfg(all(feature = "metrics", not(feature = "prometheus")))]
715type PrometheusHandle = ();
716
717// ---------------------------------------------------------------------------
718// Observability
719// ---------------------------------------------------------------------------
720
721struct Inner {
722    #[cfg(any(feature = "metrics", feature = "tracing", feature = "logs"))]
723    resource: opentelemetry_sdk::Resource,
724    #[cfg(feature = "metrics")]
725    meter_provider: Option<opentelemetry_sdk::metrics::SdkMeterProvider>,
726    #[cfg(feature = "metrics")]
727    http_metrics: bool,
728    #[cfg(feature = "prometheus")]
729    prometheus: Option<PrometheusExporter>,
730    #[cfg(feature = "tracing")]
731    tracer_provider: Option<opentelemetry_sdk::trace::SdkTracerProvider>,
732    #[cfg(feature = "tracing")]
733    traces_filter: Option<String>,
734    #[cfg(feature = "tracing")]
735    activate_context: bool,
736    #[cfg(feature = "tracing")]
737    propagator: Arc<dyn opentelemetry::propagation::TextMapPropagator + Send + Sync>,
738    #[cfg(feature = "logs")]
739    logger_provider: Option<opentelemetry_sdk::logs::SdkLoggerProvider>,
740    #[cfg(feature = "logs")]
741    console: Option<ConsoleSetup>,
742    #[cfg(feature = "logs")]
743    console_guard: std::sync::Mutex<Option<tracing_appender::non_blocking::WorkerGuard>>,
744    #[cfg(feature = "logs")]
745    logs_filter: Option<String>,
746    #[cfg(feature = "logs")]
747    bridge_filter: Option<String>,
748    service: ServiceInfo,
749    counters: Arc<Counters>,
750    http_exclude: Vec<String>,
751    http_scheme: &'static str,
752    shutdown_timeout: Duration,
753    shut_down: AtomicBool,
754}
755
756/// The configured telemetry providers. Cheap to clone; every clone refers
757/// to the same providers.
758///
759/// Dropping the last clone without calling [`shutdown`](Self::shutdown)
760/// lets the SDK flush on drop, which may block; call `shutdown` explicitly
761/// (Kerniq's `App` does so after draining connections).
762#[derive(Clone)]
763pub struct Observability {
764    inner: Arc<Inner>,
765}
766
767impl std::fmt::Debug for Observability {
768    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
769        let i = &*self.inner;
770        let mut d = f.debug_struct("Observability");
771        d.field("service", &i.service.name);
772        #[cfg(feature = "metrics")]
773        d.field("metrics", &i.meter_provider.is_some());
774        #[cfg(feature = "prometheus")]
775        d.field(
776            "prometheus",
777            &i.prometheus
778                .as_ref()
779                .map(|p| p.config().scrape_path().to_owned()),
780        );
781        #[cfg(feature = "tracing")]
782        d.field("traces", &i.tracer_provider.is_some());
783        #[cfg(feature = "logs")]
784        d.field("console_logs", &i.console.as_ref().map(|c| c.format))
785            .field("log_export", &i.logger_provider.is_some());
786        d.finish_non_exhaustive()
787    }
788}
789
790/// The result of [`Observability::shutdown`].
791#[derive(Debug, Clone)]
792pub struct ShutdownReport {
793    /// How long shutdown took.
794    pub duration: Duration,
795    /// Pipeline counters after the final flush. `unexported` is now the
796    /// number of items dropped.
797    pub stats: TelemetryStats,
798    /// Errors reported by providers (export failures during the final flush,
799    /// timeouts).
800    pub errors: Vec<String>,
801    /// Whether the shutdown timeout elapsed before every provider finished.
802    pub timed_out: bool,
803}
804
805impl Observability {
806    /// Starts configuring observability.
807    pub fn builder() -> ObservabilityBuilder {
808        ObservabilityBuilder::default()
809    }
810
811    /// A handle with every signal disabled.
812    pub fn disabled() -> Self {
813        ObservabilityBuilder::default()
814            .install_subscriber(false)
815            .build_sync()
816            .expect("an empty configuration is always valid")
817    }
818
819    /// The service identity.
820    pub fn service(&self) -> &ServiceInfo {
821        &self.inner.service
822    }
823
824    /// The OpenTelemetry resource attached to every signal.
825    #[cfg(any(feature = "metrics", feature = "tracing", feature = "logs"))]
826    pub fn resource(&self) -> &opentelemetry_sdk::Resource {
827        &self.inner.resource
828    }
829
830    /// A meter for application instruments. Create instruments once and
831    /// reuse them; recording is lock-free in the common case. Returns a
832    /// no-op meter when metrics are disabled, so instrumentation code never
833    /// needs to check.
834    #[cfg(feature = "metrics")]
835    pub fn meter(&self, name: &'static str) -> opentelemetry::metrics::Meter {
836        use opentelemetry::metrics::MeterProvider as _;
837        match &self.inner.meter_provider {
838            Some(provider) => provider.meter(name),
839            None => opentelemetry::metrics::NoopMeterProvider::new().meter(name),
840        }
841    }
842
843    /// The SDK meter provider, when metrics are enabled.
844    #[cfg(feature = "metrics")]
845    pub fn meter_provider(&self) -> Option<&opentelemetry_sdk::metrics::SdkMeterProvider> {
846        self.inner.meter_provider.as_ref()
847    }
848
849    /// A tracer for manual spans through the OpenTelemetry API. Most code
850    /// should use `tracing` spans instead, which are exported through the
851    /// same provider.
852    #[cfg(feature = "tracing")]
853    pub fn tracer(&self, name: &'static str) -> opentelemetry_sdk::trace::Tracer {
854        use opentelemetry::trace::TracerProvider as _;
855        match &self.inner.tracer_provider {
856            Some(provider) => provider.tracer(name),
857            None => opentelemetry_sdk::trace::SdkTracerProvider::builder()
858                .with_sampler(opentelemetry_sdk::trace::Sampler::AlwaysOff)
859                .build()
860                .tracer(name),
861        }
862    }
863
864    /// The SDK tracer provider, when tracing is enabled.
865    #[cfg(feature = "tracing")]
866    pub fn tracer_provider(&self) -> Option<&opentelemetry_sdk::trace::SdkTracerProvider> {
867        self.inner.tracer_provider.as_ref()
868    }
869
870    /// The W3C TraceContext + Baggage propagator, for outbound calls
871    /// (see `kerniq_observability_axum::propagation`).
872    #[cfg(feature = "tracing")]
873    pub fn propagator(
874        &self,
875    ) -> Arc<dyn opentelemetry::propagation::TextMapPropagator + Send + Sync> {
876        self.inner.propagator.clone()
877    }
878
879    /// The SDK logger provider, when log export is enabled.
880    #[cfg(feature = "logs")]
881    pub fn logger_provider(&self) -> Option<&opentelemetry_sdk::logs::SdkLoggerProvider> {
882        self.inner.logger_provider.as_ref()
883    }
884
885    /// The Prometheus endpoint, when configured.
886    #[cfg(feature = "prometheus")]
887    pub fn prometheus(&self) -> Option<&PrometheusExporter> {
888        self.inner.prometheus.as_ref()
889    }
890
891    /// HTTP instrumentation configured from this handle: metrics when
892    /// metrics are enabled, spans and W3C propagation when tracing is
893    /// enabled, and the configured exclusions (the Prometheus path is
894    /// always excluded).
895    pub fn http_instrumentation(&self) -> HttpInstrumentation {
896        let i = &*self.inner;
897        let mut http = HttpInstrumentation::new().scheme(i.http_scheme);
898        #[cfg(feature = "metrics")]
899        if i.http_metrics
900            && let Some(provider) = &i.meter_provider
901        {
902            use opentelemetry::metrics::MeterProvider as _;
903            let meter = provider.meter_with_scope(
904                opentelemetry::InstrumentationScope::builder("kerniq.http")
905                    .with_version(env!("CARGO_PKG_VERSION"))
906                    .build(),
907            );
908            http = http.meter(&meter);
909        }
910        #[cfg(feature = "tracing")]
911        {
912            http = http.tracing(i.tracer_provider.is_some() || self.has_console_logs());
913            if i.tracer_provider.is_some() {
914                http = http.shared_propagator(i.propagator.clone());
915            }
916        }
917        for route in &i.http_exclude {
918            http = http.exclude(route.clone());
919        }
920        http
921    }
922
923    /// The OpenTelemetry log bridge reads the trace context from
924    /// `opentelemetry::Context::current()`, so exported log records only
925    /// carry trace ids when spans activate their context.
926    #[cfg(feature = "tracing")]
927    fn needs_context_activation(&self) -> bool {
928        #[cfg(feature = "logs")]
929        if self.inner.logger_provider.is_some() {
930            return true;
931        }
932        self.inner.activate_context
933    }
934
935    #[cfg(feature = "tracing")]
936    fn has_console_logs(&self) -> bool {
937        #[cfg(feature = "logs")]
938        return self.inner.console.is_some();
939        #[cfg(not(feature = "logs"))]
940        false
941    }
942
943    /// Export-pipeline counters (see [`TelemetryStats`]). Also published as
944    /// the `kerniq.telemetry.items` and `kerniq.telemetry.exports` metrics.
945    pub fn stats(&self) -> TelemetryStats {
946        self.inner.counters.snapshot()
947    }
948
949    /// Exports everything buffered now. Blocks the calling thread until the
950    /// exporters return; use from `spawn_blocking` in async code.
951    pub fn force_flush(&self) -> Vec<String> {
952        #[allow(unused_mut)]
953        let mut errors = Vec::new();
954        #[cfg(feature = "tracing")]
955        if let Some(p) = &self.inner.tracer_provider
956            && let Err(e) = p.force_flush()
957        {
958            errors.push(format!("traces: {e}"));
959        }
960        #[cfg(feature = "logs")]
961        if let Some(p) = &self.inner.logger_provider
962            && let Err(e) = p.force_flush()
963        {
964            errors.push(format!("logs: {e}"));
965        }
966        #[cfg(feature = "metrics")]
967        if let Some(p) = &self.inner.meter_provider
968            && let Err(e) = p.force_flush()
969        {
970            errors.push(format!("metrics: {e}"));
971        }
972        errors
973    }
974
975    /// Flushes and shuts down every provider, in at most the configured
976    /// shutdown timeout. Idempotent; later calls return immediately.
977    ///
978    /// Call it after the server has stopped accepting requests and drained
979    /// (Kerniq's `App` does this automatically). Providers are shut down on
980    /// Tokio's blocking pool, so the async runtime stays responsive even if
981    /// a Collector is unreachable; the caller waits at most the timeout.
982    pub async fn shutdown(&self) -> ShutdownReport {
983        let start = Instant::now();
984        if self.inner.shut_down.swap(true, Ordering::AcqRel) {
985            return ShutdownReport {
986                duration: Duration::ZERO,
987                stats: self.stats(),
988                errors: Vec::new(),
989                timed_out: false,
990            };
991        }
992        let timeout = self.inner.shutdown_timeout;
993        let inner = self.inner.clone();
994        let task = tokio::task::spawn_blocking(move || shutdown_providers(&inner, timeout));
995        #[allow(unused_mut)]
996        let (mut errors, timed_out) = match tokio::time::timeout(timeout, task).await {
997            Ok(Ok(errors)) => (errors, false),
998            Ok(Err(join)) => (vec![format!("shutdown task failed: {join}")], false),
999            Err(_) => (
1000                vec![format!(
1001                    "telemetry shutdown did not finish within {timeout:?}"
1002                )],
1003                true,
1004            ),
1005        };
1006        // Console logs last, so shutdown messages are written.
1007        #[cfg(feature = "logs")]
1008        {
1009            let guard = self
1010                .inner
1011                .console_guard
1012                .lock()
1013                .unwrap_or_else(|p| p.into_inner())
1014                .take();
1015            if let Some(guard) = guard
1016                && tokio::task::spawn_blocking(move || drop(guard))
1017                    .await
1018                    .is_err()
1019            {
1020                errors.push("console log writer did not flush".to_owned());
1021            }
1022        }
1023        let report = ShutdownReport {
1024            duration: start.elapsed(),
1025            stats: self.stats(),
1026            errors,
1027            timed_out,
1028        };
1029        tracing::debug!(duration = ?report.duration, timed_out, "telemetry shut down");
1030        report
1031    }
1032
1033    /// Sets this handle's providers and propagator as the OpenTelemetry
1034    /// globals (`opentelemetry::global`), for libraries that instrument
1035    /// through the global API. Never done implicitly.
1036    pub fn install_global_providers(&self) {
1037        #[cfg(feature = "metrics")]
1038        if let Some(p) = &self.inner.meter_provider {
1039            opentelemetry::global::set_meter_provider(p.clone());
1040        }
1041        #[cfg(feature = "tracing")]
1042        {
1043            if let Some(p) = &self.inner.tracer_provider {
1044                opentelemetry::global::set_tracer_provider(p.clone());
1045            }
1046            opentelemetry::global::set_text_map_propagator(
1047                opentelemetry::propagation::TextMapCompositePropagator::new(vec![
1048                    Box::new(opentelemetry_sdk::propagation::TraceContextPropagator::new()),
1049                    Box::new(opentelemetry_sdk::propagation::BaggagePropagator::new()),
1050                ]),
1051            );
1052        }
1053    }
1054
1055    fn has_subscriber_layers(&self) -> bool {
1056        #[allow(unused_mut)]
1057        let mut any = false;
1058        #[cfg(feature = "tracing")]
1059        {
1060            any |= self.inner.tracer_provider.is_some();
1061        }
1062        #[cfg(feature = "logs")]
1063        {
1064            any |= self.inner.console.is_some() || self.inner.logger_provider.is_some();
1065        }
1066        any
1067    }
1068
1069    fn install_subscriber(&self) -> Result<(), Error> {
1070        #[cfg(any(feature = "tracing", feature = "logs"))]
1071        {
1072            use tracing_subscriber::layer::SubscriberExt as _;
1073            let subscriber = tracing_subscriber::registry().with(self.tracing_layer());
1074            tracing::subscriber::set_global_default(subscriber)
1075                .map_err(|_| Error::SubscriberAlreadySet)?;
1076        }
1077        Ok(())
1078    }
1079
1080    /// The `tracing` layers for this configuration (console output, span
1081    /// export, log export), each with its own filter, for composing into an
1082    /// application-owned subscriber (with
1083    /// [`install_subscriber(false)`](ObservabilityBuilder::install_subscriber)).
1084    ///
1085    /// ```no_run
1086    /// use tracing_subscriber::layer::SubscriberExt;
1087    /// # async fn demo() -> Result<(), kerniq_observability::Error> {
1088    /// use kerniq_observability::prelude::*;
1089    ///
1090    /// let observability = Observability::builder()
1091    ///     .logs(LogsConfig::json())
1092    ///     .install_subscriber(false)
1093    ///     .build()
1094    ///     .await?;
1095    /// let subscriber = tracing_subscriber::registry()
1096    ///     .with(observability.tracing_layer())
1097    ///     // .with(your_other_layer)
1098    ///     ;
1099    /// tracing::subscriber::set_global_default(subscriber).unwrap();
1100    /// # Ok(()) }
1101    /// ```
1102    #[cfg(any(feature = "tracing", feature = "logs"))]
1103    pub fn tracing_layer<S>(&self) -> Box<dyn tracing_subscriber::Layer<S> + Send + Sync>
1104    where
1105        S: tracing::Subscriber + for<'a> tracing_subscriber::registry::LookupSpan<'a> + Send + Sync,
1106    {
1107        use tracing_subscriber::Layer as _;
1108        type Boxed<S> = Box<dyn tracing_subscriber::Layer<S> + Send + Sync>;
1109        // Chained with `and_then` rather than collected in a `Vec`: a `Vec`
1110        // of layers does not forward `on_register_dispatch`, which the JSON
1111        // format needs for trace correlation.
1112        #[allow(unused_mut)]
1113        let mut layers: Vec<Boxed<S>> = Vec::new();
1114
1115        #[cfg(feature = "logs")]
1116        if let Some(console) = &self.inner.console {
1117            let filter = env_filter(self.inner.logs_filter.as_deref(), None);
1118            layers.push(console_layer::<S>(console).with_filter(filter).boxed());
1119        }
1120
1121        #[cfg(feature = "tracing")]
1122        if let Some(provider) = &self.inner.tracer_provider {
1123            use opentelemetry::trace::TracerProvider as _;
1124            let tracer = provider.tracer("kerniq");
1125            let filter = env_filter(
1126                Some(self.inner.traces_filter.as_deref().unwrap_or("info")),
1127                Some(BRIDGE_EXCLUSIONS),
1128            );
1129            layers.push(
1130                tracing_opentelemetry::layer()
1131                    .with_tracer(tracer)
1132                    .with_error_records_to_exceptions(true)
1133                    // Busy/idle time reads the clock on every enter/exit, which
1134                    // for a streamed response is every frame.
1135                    .with_tracked_inactivity(false)
1136                    .with_context_activation(self.needs_context_activation())
1137                    .with_filter(filter)
1138                    .boxed(),
1139            );
1140        }
1141
1142        #[cfg(feature = "logs")]
1143        if let Some(provider) = &self.inner.logger_provider {
1144            let filter = env_filter(self.inner.bridge_filter.as_deref(), Some(BRIDGE_EXCLUSIONS));
1145            layers.push(
1146                opentelemetry_appender_tracing::layer::OpenTelemetryTracingBridge::new(provider)
1147                    .with_filter(filter)
1148                    .boxed(),
1149            );
1150        }
1151
1152        layers
1153            .into_iter()
1154            .reduce(|chain, layer| chain.and_then(layer).boxed())
1155            .unwrap_or_else(|| tracing_subscriber::layer::Identity::new().boxed())
1156    }
1157}
1158
1159/// The OpenTelemetry context of the current `tracing` span (empty when the
1160/// span is not exported).
1161#[cfg(feature = "tracing")]
1162pub fn current_context() -> opentelemetry::Context {
1163    use tracing_opentelemetry::OpenTelemetrySpanExt as _;
1164    tracing::Span::current().context()
1165}
1166
1167/// The trace id of the current `tracing` span, as 32 lowercase hex digits,
1168/// when it belongs to an exported trace. Useful to return in error
1169/// responses so users can quote it in support requests.
1170#[cfg(feature = "tracing")]
1171pub fn current_trace_id() -> Option<String> {
1172    use opentelemetry::trace::TraceContextExt as _;
1173    let cx = current_context();
1174    let span = cx.span();
1175    let sc = span.span_context();
1176    sc.is_valid().then(|| sc.trace_id().to_string())
1177}
1178
1179#[cfg(any(feature = "tracing", feature = "logs"))]
1180fn env_filter(directives: Option<&str>, extra: Option<&str>) -> tracing_subscriber::EnvFilter {
1181    let base = directives
1182        .map(str::to_owned)
1183        .or_else(|| {
1184            std::env::var("RUST_LOG")
1185                .ok()
1186                .filter(|s| !s.trim().is_empty())
1187        })
1188        .unwrap_or_else(|| "info".to_owned());
1189    let full = match extra {
1190        Some(extra) => format!("{base},{extra}"),
1191        None => base,
1192    };
1193    tracing_subscriber::EnvFilter::builder()
1194        .parse(&full)
1195        .unwrap_or_else(|err| {
1196            eprintln!("kerniq-observability: invalid log filter `{full}` ({err}); using `info`");
1197            tracing_subscriber::EnvFilter::new("info")
1198        })
1199}
1200
1201#[cfg(feature = "logs")]
1202fn console_layer<S>(console: &ConsoleSetup) -> Box<dyn tracing_subscriber::Layer<S> + Send + Sync>
1203where
1204    S: tracing::Subscriber + for<'a> tracing_subscriber::registry::LookupSpan<'a> + Send + Sync,
1205{
1206    use std::io::IsTerminal as _;
1207    use tracing_subscriber::Layer as _;
1208    use tracing_subscriber::fmt::writer::BoxMakeWriter;
1209
1210    let writer = match console.writer.clone() {
1211        ConsoleWriter::NonBlocking(w) => BoxMakeWriter::new(w),
1212        ConsoleWriter::Stdout => BoxMakeWriter::new(std::io::stdout),
1213        ConsoleWriter::Stderr => BoxMakeWriter::new(std::io::stderr),
1214    };
1215    let ansi = match console.target {
1216        ConsoleTarget::Stdout => std::io::stdout().is_terminal(),
1217        ConsoleTarget::Stderr => std::io::stderr().is_terminal(),
1218    };
1219    match console.format {
1220        ConsoleFormat::Json => logging::json_layer(writer),
1221        ConsoleFormat::Pretty => tracing_subscriber::fmt::layer()
1222            .pretty()
1223            .with_ansi(ansi)
1224            .with_writer(writer)
1225            .boxed(),
1226        ConsoleFormat::Compact => tracing_subscriber::fmt::layer()
1227            .compact()
1228            .with_ansi(ansi)
1229            .with_writer(writer)
1230            .boxed(),
1231    }
1232}
1233
1234fn shutdown_providers(inner: &Inner, timeout: Duration) -> Vec<String> {
1235    let deadline = Instant::now() + timeout;
1236    let remaining = || deadline.saturating_duration_since(Instant::now());
1237    #[allow(unused_mut)]
1238    let mut errors = Vec::new();
1239    // Traces and logs first: their final export may still be recorded in
1240    // metrics (`kerniq.telemetry.*`) by the metrics shutdown that follows.
1241    #[cfg(feature = "tracing")]
1242    if let Some(p) = &inner.tracer_provider
1243        && let Err(e) = p.shutdown_with_timeout(remaining())
1244    {
1245        errors.push(format!("traces: {e}"));
1246    }
1247    #[cfg(feature = "logs")]
1248    if let Some(p) = &inner.logger_provider
1249        && let Err(e) = p.shutdown_with_timeout(remaining())
1250    {
1251        errors.push(format!("logs: {e}"));
1252    }
1253    #[cfg(feature = "metrics")]
1254    if let Some(p) = &inner.meter_provider
1255        && let Err(e) = p.shutdown_with_timeout(remaining())
1256    {
1257        errors.push(format!("metrics: {e}"));
1258    }
1259    let _ = (remaining, inner);
1260    errors
1261}