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}