Skip to main content

uni_plugin_host/
observability.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2024-2026 Dragonscale Team
3
4//! OpenTelemetry tracing-subscriber layer for `uni-db`.
5//!
6//! Per proposal §12.1.1, plugin spans should propagate alongside the
7//! host's `tracing` events so a query trace shows up as one continuous
8//! span tree in Jaeger / Tempo / Datadog. This module exposes a single
9//! initialization helper that constructs a [`tracing_subscriber::Registry`]
10//! with the [`tracing-opentelemetry`](https://docs.rs/tracing-opentelemetry)
11//! layer wrapping the standard `fmt` layer.
12//!
13//! ## Why a helper, not auto-install?
14//!
15//! Embedders frequently bring their own `tracing` subscriber (server
16//! frameworks like axum/tower-http, test harnesses, Python bindings).
17//! Auto-installing a global subscriber from `Uni::open` would conflict
18//! with those setups and produce the runtime panic
19//! "a global default trace dispatcher has already been set". The
20//! conservative shape: ship the helper, let embedders opt in.
21//!
22//! Inside the host, the [`uni_plugin::observability::record_invocation`]
23//! function emits `tracing::debug!` events tagged with `kind` / `qname` /
24//! plugin id. With the OTel layer installed those events become OTLP
25//! spans automatically.
26//!
27//! ## Usage
28//!
29//! ```no_run
30//! use uni_plugin_host::observability::OtelConfig;
31//!
32//! let cfg = OtelConfig {
33//!     service_name: "my-app".into(),
34//!     otlp_endpoint: "http://localhost:4317".into(),
35//! };
36//! let _guard = uni_plugin_host::observability::init_otel_subscriber(cfg)
37//!     .expect("OTel subscriber must initialize once");
38//! // ... use Uni normally; events become OTLP spans.
39//! ```
40
41// Rust guideline compliant
42
43use std::error::Error as StdError;
44
45use opentelemetry::trace::TracerProvider as _;
46use opentelemetry_otlp::WithExportConfig;
47use opentelemetry_sdk::Resource;
48use opentelemetry_sdk::trace::SdkTracerProvider;
49use tracing_subscriber::layer::SubscriberExt;
50use tracing_subscriber::util::SubscriberInitExt;
51
52/// Configuration for the OTel tracing subscriber.
53///
54/// Construct directly with literal fields; no builder is needed at this
55/// shape.
56#[derive(Clone, Debug)]
57pub struct OtelConfig {
58    /// `service.name` resource attribute reported to the collector.
59    pub service_name: String,
60    /// OTLP-gRPC endpoint, e.g. `"http://localhost:4317"`.
61    pub otlp_endpoint: String,
62}
63
64/// Initialize a `tracing-subscriber::Registry` with an OTel layer
65/// pointing at the OTLP endpoint described by `cfg`.
66///
67/// Returns a [`OtelGuard`] whose `Drop` impl shuts the tracer provider
68/// down cleanly. Calls
69/// [`tracing_subscriber::util::SubscriberInitExt::try_init`] under the
70/// hood — passing the global default subscriber lock through to
71/// `tracing-subscriber` semantics.
72///
73/// # Errors
74///
75/// Returns an error if the tracer provider cannot be constructed
76/// (bad endpoint, missing TLS material) or if a global subscriber has
77/// already been installed.
78pub fn init_otel_subscriber(cfg: OtelConfig) -> Result<OtelGuard, Box<dyn StdError>> {
79    let exporter = opentelemetry_otlp::SpanExporter::builder()
80        .with_tonic()
81        .with_endpoint(&cfg.otlp_endpoint)
82        .build()?;
83    let resource = Resource::builder()
84        .with_service_name(cfg.service_name.clone())
85        .build();
86    let provider = SdkTracerProvider::builder()
87        .with_resource(resource)
88        .with_batch_exporter(exporter)
89        .build();
90    let tracer = provider.tracer("uni-db");
91    let otel_layer = tracing_opentelemetry::layer().with_tracer(tracer);
92
93    tracing_subscriber::registry()
94        .with(
95            tracing_subscriber::EnvFilter::try_from_default_env()
96                .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
97        )
98        .with(tracing_subscriber::fmt::layer())
99        .with(otel_layer)
100        .try_init()?;
101
102    Ok(OtelGuard { provider })
103}
104
105/// RAII guard that flushes and shuts down the OTel tracer provider on
106/// drop. Keep the returned value alive for the lifetime of the
107/// process; dropping it tears down the OTel pipeline.
108#[derive(Debug)]
109pub struct OtelGuard {
110    provider: SdkTracerProvider,
111}
112
113impl Drop for OtelGuard {
114    fn drop(&mut self) {
115        // Best-effort: the SDK's shutdown is idempotent and never
116        // panics; if the collector is unreachable, the shutdown just
117        // times out internally.
118        let _ = self.provider.shutdown();
119    }
120}
121
122// ── FU-3: trace context extraction + outbound HTTP injection ──────
123
124/// W3C `traceparent` header value extracted from the current
125/// `tracing` span, formatted as `00-<trace_id>-<span_id>-<flags>`.
126///
127/// Returns `None` when no `tracing-opentelemetry` layer is installed
128/// (the current span has no associated `SpanContext`). Used by
129/// outbound HTTP request paths to propagate the trace across a
130/// process boundary — e.g., when a plugin invokes `http-get-with-trace`
131/// via the host-net WIT import.
132#[must_use]
133pub fn current_traceparent() -> Option<String> {
134    // Delegates to the single source of truth in `uni-plugin` (built with the
135    // `otel` feature) so the host-side outbound-HTTP path and the plugin ABI
136    // share one extraction + formatting implementation.
137    uni_plugin::observability::current_trace_context().to_traceparent()
138}
139
140/// Perform an HTTP GET against `url` with the current span's
141/// `traceparent` header injected (FU-3).
142///
143/// Used by the host's outbound-HTTP request path (and by the
144/// `examples/otel_demo` binary) to demonstrate end-to-end trace
145/// propagation: `Session::query → plugin span → outbound HTTP`. The
146/// receiving server sees a `traceparent` header whose `trace_id`
147/// matches the outer query span.
148///
149/// # Errors
150///
151/// Returns an error string on any HTTP transport / status failure.
152pub async fn http_get_with_traceparent(url: &str) -> Result<Vec<u8>, String> {
153    let client = reqwest::Client::new();
154    let mut req = client.get(url);
155    if let Some(tp) = current_traceparent() {
156        req = req.header("traceparent", tp);
157    }
158    let resp = req.send().await.map_err(|e| format!("send: {e}"))?;
159    let status = resp.status();
160    let bytes = resp
161        .bytes()
162        .await
163        .map_err(|e| format!("read body: {e}"))?
164        .to_vec();
165    if !status.is_success() {
166        return Err(format!("HTTP {status}: {} bytes", bytes.len()));
167    }
168    Ok(bytes)
169}