Skip to main content

re_perf_telemetry/
telemetry.rs

1use std::sync::Arc;
2
3use opentelemetry::trace::TracerProvider as _;
4use opentelemetry_otlp::WithTonicConfig as _;
5use opentelemetry_sdk::logs::SdkLoggerProvider;
6use opentelemetry_sdk::metrics::{Aggregation, SdkMeterProvider};
7use opentelemetry_sdk::trace::{BatchConfigBuilder, BatchSpanProcessor, SdkTracerProvider};
8use tracing_subscriber::layer::SubscriberExt as _;
9use tracing_subscriber::util::SubscriberInitExt as _;
10use tracing_subscriber::{EnvFilter, Layer as _};
11
12use crate::shared_reader::SharedManualReader;
13use crate::trace_id_format::TraceIdFormat;
14use crate::{LogFormat, SpanMetadataCleanupLayer, TelemetryArgs};
15
16const OTLP_EXPORTER_ENV_VAR: &str = "OTEL_EXPORTER_OTLP_ENDPOINT";
17
18/// Resolved trace destinations for `Telemetry::init`. Each field is
19/// `Some(url)` iff the corresponding exporter should be built. The two
20/// fields are independent — both, either, or neither can be active.
21///
22/// When both are set, every root span is fanned out through *both*
23/// exporters. Dual-publishing (Hub + a local collector like Jaeger/Tempo)
24/// is the reason this struct exists; if you want a single destination,
25/// set only one env var.
26#[derive(Debug, Clone, PartialEq, Eq)]
27struct ResolvedTraceEndpoints {
28    /// Rerun-authed exporter routing through the Hub frontend. Set when
29    /// the SDK-side `RERUN_TELEMETRY_ENDPOINT` env var is non-empty.
30    ///
31    /// The value is the `http(s)://` transport URL the exporter dials —
32    /// the input rewritten to its underlying transport (`rerun://` and
33    /// `rerun+https://` → `https://`, `rerun+http://` → `http://`) or
34    /// passed through verbatim for plain `http(s)://` schemes. Any other
35    /// scheme is a config error returned as `Err` by `resolve`.
36    ///
37    /// Never mirrored into `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT` — keeping
38    /// values set via the SDK-side knob out of the standard env var is
39    /// the entire point of having a dedicated knob.
40    rerun_authed: Option<String>,
41
42    /// Plain OTLP gRPC exporter driven by the standard
43    /// `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT` (or its
44    /// `OTEL_EXPORTER_OTLP_ENDPOINT` umbrella fallback). The URL is
45    /// passed through verbatim — we never inspect it for `rerun://`
46    /// schemes; that's the SDK-side knob's job.
47    ///
48    /// `Telemetry::init` mirrors this URL back into
49    /// `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT` so the `OTel` SDK's exporter
50    /// builder reads it from there.
51    standard: Option<String>,
52}
53
54impl ResolvedTraceEndpoints {
55    /// Resolve which exporters (if any) to build from the two trace-endpoint
56    /// inputs.
57    ///
58    /// * `rerun_telemetry_endpoint`: raw value of the SDK-side
59    ///   `RERUN_TELEMETRY_ENDPOINT` env var (empty when unset).
60    /// * `standard_otel_endpoint`: value of
61    ///   `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT` already merged with the
62    ///   `OTEL_EXPORTER_OTLP_ENDPOINT` umbrella fallback.
63    ///
64    /// The two inputs are independent — both, either, or neither may
65    /// produce a destination. The standard endpoint is *never* parsed for
66    /// `rerun://` schemes; server-side configurations point at plain
67    /// Alloy / Jaeger / Tempo collectors through this knob.
68    ///
69    /// Accepted schemes for `RERUN_TELEMETRY_ENDPOINT`: `rerun`,
70    /// `rerun+http`, `rerun+https`, `http`, `https`. Anything else is a
71    /// config error returned as `Err` even when `standard_otel_endpoint`
72    /// is valid — a typo in the dedicated knob surfaces at init time
73    /// instead of silently dropping the Hub destination.
74    fn resolve(
75        rerun_telemetry_endpoint: &str,
76        standard_otel_endpoint: &str,
77    ) -> anyhow::Result<Self> {
78        let rerun_authed = if rerun_telemetry_endpoint.is_empty() {
79            None
80        } else {
81            let reject = || {
82                anyhow::anyhow!(
83                    "RERUN_TELEMETRY_ENDPOINT={rerun_telemetry_endpoint:?} is not a supported endpoint URL — \
84                     accepted schemes are rerun://, rerun+http://, rerun+https://, http://, https://"
85                )
86            };
87            let (scheme, rest) = rerun_telemetry_endpoint
88                .split_once("://")
89                .ok_or_else(reject)?;
90            let transport_scheme = match scheme {
91                "rerun" | "rerun+https" | "https" => "https",
92                "rerun+http" | "http" => "http",
93                _ => return Err(reject()),
94            };
95            Some(format!("{transport_scheme}://{rest}"))
96        };
97
98        let standard =
99            (!standard_otel_endpoint.is_empty()).then(|| standard_otel_endpoint.to_owned());
100
101        Ok(Self {
102            rerun_authed,
103            standard,
104        })
105    }
106
107    fn any(&self) -> bool {
108        self.rerun_authed.is_some() || self.standard.is_some()
109    }
110
111    /// Short tag used in the `Telemetry initialized` log line and the
112    /// init-failure stderr fallback. Keep the strings stable — operators
113    /// grep these out of logs.
114    fn trace_mode(&self) -> &'static str {
115        match (self.rerun_authed.is_some(), self.standard.is_some()) {
116            (true, true) => "rerun-authed+otlp",
117            (true, false) => "rerun-authed",
118            (false, true) => "otlp",
119            (false, false) => "off",
120        }
121    }
122
123    /// Human-readable destination(s) for the same log lines. Renders the
124    /// dual-publish case as `"<rerun_url> + <std_url>"` so both URLs are
125    /// visible in one grep.
126    fn summary(&self) -> String {
127        match (&self.rerun_authed, &self.standard) {
128            (Some(rerun), Some(std)) => format!("{rerun} + {std}"),
129            (Some(url), None) | (None, Some(url)) => url.clone(),
130            (None, None) => "off".to_owned(),
131        }
132    }
133}
134
135/// `SpanExporter` decorator that refreshes the Rerun SDK auth token just-in-time
136/// before each export, delegating the actual gRPC send to the inner OTLP
137/// exporter.
138///
139/// `SpanExporter::export` is async, and per its contract is never called
140/// concurrently for the same instance. Before delegating, this wrapper awaits
141/// `provider.get_token()` and writes the result into the shared `token_cache`
142/// that the inner exporter's synchronous tonic interceptor reads from. The
143/// credentials provider has its own internal cache and short-circuits on a
144/// still-valid JWT, so the steady-state cost is a single async lock read;
145/// real network refresh only fires near token expiry.
146///
147/// On refresh failure the cache is left untouched — a stale but still-valid
148/// JWT continues to be used, and the inner exporter's own error handling
149/// applies if the server rejects. A single `warn!` fires on the *rising edge*
150/// of a failure run, re-arming on the next success, so sustained outages
151/// don't spam the log.
152#[derive(Debug)]
153struct AuthRefreshingSpanExporter<P: re_auth::credentials::CredentialsProvider> {
154    inner: opentelemetry_otlp::SpanExporter,
155    provider: Arc<P>,
156    token_cache: Arc<parking_lot::RwLock<String>>,
157    refresh_failing: std::sync::atomic::AtomicBool,
158}
159
160impl<P> opentelemetry_sdk::trace::SpanExporter for AuthRefreshingSpanExporter<P>
161where
162    P: re_auth::credentials::CredentialsProvider + Send + Sync + std::fmt::Debug + 'static,
163{
164    async fn export(
165        &self,
166        batch: Vec<opentelemetry_sdk::trace::SpanData>,
167    ) -> opentelemetry_sdk::error::OTelSdkResult {
168        use std::sync::atomic::Ordering;
169
170        match self.provider.get_token().await {
171            Ok(Some(jwt)) => {
172                *self.token_cache.write() = jwt.to_string();
173                self.refresh_failing.store(false, Ordering::Relaxed);
174            }
175            Ok(None) => {
176                self.token_cache.write().clear();
177                self.refresh_failing.store(false, Ordering::Relaxed);
178            }
179            Err(err) => {
180                // Leave the cached token in place — if it's still inside its
181                // validity window, the server will accept it.
182                if !self.refresh_failing.swap(true, Ordering::Relaxed) {
183                    tracing::warn!(
184                        "Hub auth token refresh failed, continuing with cached token: {err}"
185                    );
186                }
187            }
188        }
189
190        self.inner.export(batch).await
191    }
192
193    fn shutdown_with_timeout(
194        &self,
195        timeout: std::time::Duration,
196    ) -> opentelemetry_sdk::error::OTelSdkResult {
197        self.inner.shutdown_with_timeout(timeout)
198    }
199
200    fn force_flush(&self) -> opentelemetry_sdk::error::OTelSdkResult {
201        self.inner.force_flush()
202    }
203
204    fn set_resource(&mut self, resource: &opentelemetry_sdk::Resource) {
205        self.inner.set_resource(resource);
206    }
207}
208
209/// Build an OTLP `SpanExporter` that pushes through a tonic Channel whose
210/// outbound requests carry a Rerun SDK Bearer token in the `authorization`
211/// metadata. The token comes from
212/// [`re_auth::credentials::CliCredentialsProvider`] — same global credentials
213/// store the rest of the SDK uses (populated by `rerun auth login`) — and is
214/// refreshed just before each export by [`AuthRefreshingSpanExporter`].
215///
216/// The actual TCP/TLS handshake is deferred to first use via
217/// `Endpoint::connect_lazy()` so init stays sync.
218fn build_rerun_authed_span_exporter(
219    transport_url: &str,
220) -> anyhow::Result<AuthRefreshingSpanExporter<re_auth::credentials::CliCredentialsProvider>> {
221    use re_auth::credentials::CliCredentialsProvider;
222
223    build_rerun_authed_span_exporter_with_provider(
224        transport_url,
225        Arc::new(CliCredentialsProvider::new()),
226    )
227}
228
229/// Inner constructor parameterized on the [`re_auth::credentials::CredentialsProvider`].
230/// The public [`build_rerun_authed_span_exporter`] wires up `CliCredentialsProvider`;
231/// tests inject [`re_auth::credentials::StaticCredentialsProvider`] with a known JWT.
232fn build_rerun_authed_span_exporter_with_provider<P>(
233    transport_url: &str,
234    provider: Arc<P>,
235) -> anyhow::Result<AuthRefreshingSpanExporter<P>>
236where
237    P: re_auth::credentials::CredentialsProvider + Send + Sync + std::fmt::Debug + 'static,
238{
239    let token_cache: Arc<parking_lot::RwLock<String>> =
240        Arc::new(parking_lot::RwLock::new(String::new()));
241
242    // Build the tonic Channel by hand so we can attach our auth interceptor
243    // and so the TLS config matches `re_redap_client` (rustls + system roots
244    // via `tonic/tls-native-roots`).
245    let mut endpoint: tonic::transport::Endpoint = transport_url.parse()?;
246    if transport_url.starts_with("https://") {
247        endpoint = endpoint.tls_config(
248            tonic::transport::ClientTlsConfig::new()
249                .with_enabled_roots()
250                .assume_http2(true),
251        )?;
252    }
253    let channel = endpoint.connect_lazy();
254
255    // Single combined interceptor that both injects the Bearer token AND
256    // delegates to `RerunVersionInterceptor` to set `x-rerun-client-version`.
257    // Each call to `TonicExporterBuilder::with_interceptor` only accepts one
258    // interceptor, so we compose them here. The standard SDK setup uses
259    // `new_rerun_client_headers_layer()` but that's a tower::Layer and we'd
260    // need to pass a layered service via `with_channel`, which the OTLP
261    // builder doesn't allow.
262    let token_for_interceptor: Arc<parking_lot::RwLock<String>> = Arc::clone(&token_cache);
263    let mut version_interceptor = re_grpc_headers::RerunVersionInterceptor::new_client(None, None);
264    // Rising-edge gate so a malformed cached token warns once per failure run,
265    // not on every export. Mirrors `refresh_failing` on the wrapping struct.
266    // Arc because the interceptor closure has to be `Clone` for `with_interceptor`.
267    let parse_failing: Arc<std::sync::atomic::AtomicBool> =
268        Arc::new(std::sync::atomic::AtomicBool::new(false));
269    let interceptor = move |mut req: tonic::Request<()>| -> tonic::Result<tonic::Request<()>> {
270        use std::sync::atomic::Ordering;
271        let token = token_for_interceptor.read().clone();
272        if !token.is_empty() {
273            match format!("Bearer {token}").parse() {
274                Ok(value) => {
275                    req.metadata_mut().insert("authorization", value);
276                    parse_failing.store(false, Ordering::Relaxed);
277                }
278                Err(err) => {
279                    if !parse_failing.swap(true, Ordering::Relaxed) {
280                        tracing::warn!(
281                            "Cached Hub auth token failed to parse as an HTTP header value; aborting send: {err}",
282                        );
283                    }
284                    return Err(tonic::Status::internal(
285                        "cached Hub auth token is not a valid HTTP header value",
286                    ));
287                }
288            }
289        }
290        tonic::service::Interceptor::call(&mut version_interceptor, req)
291    };
292
293    let inner = opentelemetry_otlp::SpanExporter::builder()
294        .with_tonic()
295        .with_channel(channel)
296        .with_interceptor(interceptor)
297        .with_compression(opentelemetry_otlp::Compression::Gzip)
298        .build()?;
299
300    Ok(AuthRefreshingSpanExporter {
301        inner,
302        provider,
303        token_cache,
304        refresh_failing: std::sync::atomic::AtomicBool::new(false),
305    })
306}
307
308// ---
309
310/// The Redap telemetry pipeline.
311///
312/// Keep this alive for as long as you need to log, trace and/or measure.
313///
314/// Will flush everything on drop.
315#[derive(Debug, Clone)]
316pub struct Telemetry {
317    logs: Option<SdkLoggerProvider>,
318    traces: Option<SdkTracerProvider>,
319    metrics: Option<SdkMeterProvider>,
320
321    /// The shared manual reader for pull-based metrics collection
322    metrics_reader: Option<Arc<opentelemetry_sdk::metrics::ManualReader>>,
323
324    drop_behavior: TelemetryDropBehavior,
325}
326
327#[derive(Debug, Clone, Copy, Default)]
328pub enum TelemetryDropBehavior {
329    /// The telemetry pipeline will be flushed everytime a [`Telemetry`] is dropped.
330    ///
331    /// This is particularly useful to use in conjunction with the fact that [`Telemetry`]
332    /// is `Clone`: lazy initialize a [`Telemetry`] into a static `LazyCell`/`LazyLock`, and keep
333    /// returning clones of that value.
334    /// You are guaranteed that the pipeline will get flushed everytime one of these clone goes out
335    /// of scope.
336    Flush,
337
338    /// The telemetry pipeline will be flushed and shutdown the first time a [`Telemetry`] is dropped.
339    ///
340    /// The pipeline is then inactive, and all logs, traces and metrics are dropped.
341    #[default]
342    Shutdown,
343}
344
345/// Set to `true` by [`Telemetry::init`] once it has successfully wired up the
346/// `tracing` subscriber, OTLP exporters, and global propagator. Read by
347/// [`is_telemetry_active`] (and through it, by [`crate::with_tracing_session`]
348/// and the Python `tracing_session()` bridge) to detect the case where a
349/// caller is trying to use telemetry features before initializing the stack.
350///
351/// Stays `true` for the rest of the process lifetime; not cleared on
352/// `Telemetry` drop (matches Python's `_is_telemetry_active` semantics).
353static TELEMETRY_ACTIVE: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
354
355/// Returns `true` once [`Telemetry::init`] has run with telemetry enabled
356/// (i.e. the `tracing` subscriber, OTLP exporters, and global propagator are
357/// installed).
358///
359/// Used by [`crate::with_tracing_session`] to no-op with a warning when a
360/// caller attempts session scoping before initializing telemetry. Process-
361/// wide single source of truth for this question — the Python
362/// `_is_telemetry_active()` binding also reads it via this function.
363pub fn is_telemetry_active() -> bool {
364    TELEMETRY_ACTIVE.load(std::sync::atomic::Ordering::Acquire)
365}
366
367/// Test-only: flip the [`TELEMETRY_ACTIVE`] flag without running the full
368/// [`Telemetry::init`] pipeline. Lets in-crate tests exercise APIs that
369/// gate on `is_telemetry_active` (notably `with_tracing_session`) without
370/// having to stand up the `OTel` stack.
371///
372/// **Concurrency:** mutates a process-global atomic. Tests that call this
373/// (or assert on `TELEMETRY_ACTIVE` / `ACTIVE_TRACING_SESSION_COUNT`) are
374/// race-free only when each test runs in its own process. Use `cargo
375/// nextest` (the project's standard, per `rerun/CLAUDE.md`) — it spawns
376/// a subprocess per test. Plain `cargo test` runs tests as threads inside
377/// one process and will be flaky against these tests.
378#[cfg(test)]
379pub(crate) fn set_telemetry_active_for_test(active: bool) {
380    TELEMETRY_ACTIVE.store(active, std::sync::atomic::Ordering::Release);
381}
382
383impl Telemetry {
384    pub fn flush(&self) {
385        let Self {
386            logs,
387            traces,
388            metrics,
389            metrics_reader: _,
390            drop_behavior: _,
391        } = self;
392
393        if let Some(logs) = logs
394            && let Err(err) = logs.force_flush()
395        {
396            tracing::error!(%err, "failed to flush otel log provider");
397        }
398
399        if let Some(traces) = traces
400            && let Err(err) = traces.force_flush()
401        {
402            tracing::error!(%err, "failed to flush otel trace provider");
403        }
404
405        if let Some(metrics) = metrics
406            && let Err(err) = metrics.force_flush()
407        {
408            tracing::error!(%err, "failed to flush otel metric provider");
409        }
410    }
411
412    pub fn shutdown(&self) {
413        // NOTE: We do both `force_flush` and `shutdown` because, even though they both flush the
414        // pipeline, sometimes one has better error messages than the other (although, more often
415        // than not, they both provide useless errors and you should make sure to look into the
416        // DEBUG logs: this is generally where they end up).
417        self.flush();
418
419        let Self {
420            logs,
421            traces,
422            metrics,
423            metrics_reader: _,
424            drop_behavior: _,
425        } = self;
426
427        if let Some(logs) = logs
428            && let Err(err) = logs.shutdown()
429        {
430            tracing::error!(%err, "failed to shutdown otel log provider");
431        }
432
433        if let Some(traces) = traces
434            && let Err(err) = traces.shutdown()
435        {
436            tracing::error!(%err, "failed to shutdown otel trace provider");
437        }
438
439        if let Some(metrics) = metrics
440            && let Err(err) = metrics.shutdown()
441        {
442            tracing::error!(%err, "failed to shutdown otel metric provider");
443        }
444    }
445}
446
447impl Drop for Telemetry {
448    fn drop(&mut self) {
449        match self.drop_behavior {
450            TelemetryDropBehavior::Flush => self.flush(),
451            TelemetryDropBehavior::Shutdown => self.shutdown(),
452        }
453    }
454}
455
456impl Telemetry {
457    /// Same as [`Self::init`], plus registers `reader` as the host-language
458    /// callback that [`crate::current_rerun_session_id`] consults on its slow
459    /// path (and that [`crate::with_current_tracing_session`] invokes once at
460    /// the boundary).
461    ///
462    /// Intended for SDK bindings (today: `rerun_py`) that hold the active
463    /// session id in a host-language-specific store this crate has no way to
464    /// reach. First-call-wins: the registration happens once, atomically,
465    /// before `init` returns, and any subsequent registration attempt is a
466    /// silent no-op.
467    ///
468    /// Gated behind the `session_id_reader` feature so end customers of
469    /// `re_perf_telemetry` never see the extra public API.
470    #[cfg(feature = "session_id_reader")]
471    #[must_use = "dropping this will flush and shutdown all telemetry systems"]
472    pub fn init_with_session_id_reader(
473        args: TelemetryArgs,
474        drop_behavior: TelemetryDropBehavior,
475        reader: crate::SessionIdReader,
476    ) -> anyhow::Result<Self> {
477        crate::tracing_session::set_session_id_reader(reader);
478        Self::init(args, drop_behavior)
479    }
480
481    #[must_use = "dropping this will flush and shutdown all telemetry systems"]
482    pub fn init(args: TelemetryArgs, drop_behavior: TelemetryDropBehavior) -> anyhow::Result<Self> {
483        let TelemetryArgs {
484            tracy_enabled,
485            enabled,
486            service_name,
487            attributes,
488            log_filter,
489            log_test_output,
490            log_format,
491            log_closed_spans,
492            log_otlp_enabled,
493            log_endpoint,
494            trace_filter,
495            trace_endpoint,
496            trace_sampler,
497            trace_sampler_args,
498            metric_endpoint,
499            metric_interval,
500            metrics_listen_address: _, // TelemetryArgs only, used at the caller site
501        } = args;
502
503        // Resolve the umbrella `OTEL_EXPORTER_OTLP_ENDPOINT` as a fallback for any
504        // signal-specific endpoint that wasn't set. Mirrors the OTel SDK convention.
505        let umbrella_endpoint = std::env::var(OTLP_EXPORTER_ENV_VAR)
506            .ok()
507            .filter(|s| !s.is_empty());
508        let resolve_endpoint = |signal: String| -> String {
509            if signal.is_empty() {
510                umbrella_endpoint.clone().unwrap_or_default()
511            } else {
512                signal
513            }
514        };
515        let log_endpoint = resolve_endpoint(log_endpoint);
516        let trace_endpoint = resolve_endpoint(trace_endpoint);
517        let metric_endpoint = resolve_endpoint(metric_endpoint);
518
519        // Dedicated SDK-side trace endpoint, kept distinct from the standard
520        // `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT` so its value doesn't leak into
521        // other OTel-aware libraries (e.g. Python's
522        // `opentelemetry-exporter-otlp-proto-grpc`) sharing the same process.
523        // Env-only; no clap arg, no CLI flag.
524        let rerun_telemetry_endpoint =
525            std::env::var("RERUN_TELEMETRY_ENDPOINT").unwrap_or_default();
526
527        // Decide which OTLP exporters the SDK should build. See
528        // [`ResolvedTraceEndpoints`] for the rules — the two endpoints are
529        // independent, so it's valid for both to be active at once
530        // (dual-publish to Hub and a local collector). When neither is set,
531        // spans still flow through the in-process pipeline but nothing
532        // leaves the process. Gated on `enabled` so a malformed
533        // `RERUN_TELEMETRY_ENDPOINT` doesn't break a `TELEMETRY_ENABLED=false`
534        // process (nor a `TRACY_ENABLED=true`-only one).
535        let trace_endpoints = if enabled {
536            ResolvedTraceEndpoints::resolve(&rerun_telemetry_endpoint, &trace_endpoint)?
537        } else {
538            ResolvedTraceEndpoints {
539                rerun_authed: None,
540                standard: None,
541            }
542        };
543
544        // Pipeline summary fields. Computed once here so the success (`info!` once
545        // the subscriber is up) and failure (`eprintln!`, subscriber may not be up)
546        // paths can emit the same set of decision details.
547        let trace_mode: &'static str = trace_endpoints.trace_mode();
548        let traces_summary = trace_endpoints.summary();
549        let logs_summary: String = if log_otlp_enabled && !log_endpoint.is_empty() {
550            log_endpoint.clone()
551        } else {
552            "off".to_owned()
553        };
554        let metrics_summary: String = if metric_endpoint.is_empty() {
555            "off".to_owned()
556        } else {
557            metric_endpoint.clone()
558        };
559        let service_name_summary: String = service_name.as_deref().unwrap_or("<unset>").to_owned();
560
561        let result: anyhow::Result<Self> = (move || -> anyhow::Result<Self> {
562            if !enabled {
563                if tracy_enabled {
564                    cfg_select! {
565                        feature = "tracy" => {
566                            tracing_subscriber::registry()
567                                .with(self::tracy::tracy_layer())
568                                .try_init()?;
569                        }
570                        _ => {
571                            anyhow::bail!(
572                                "`TRACY_ENABLED=true` but the 'tracy' feature flag is not toggled"
573                            );
574                        }
575                    }
576                }
577
578                return Ok(Self {
579                    logs: None,
580                    metrics: None,
581                    traces: None,
582                    metrics_reader: None,
583                    drop_behavior,
584                });
585            }
586
587            let Some(service_name) = service_name else {
588                anyhow::bail!(
589                    "either `OTEL_SERVICE_NAME` or `TelemetryArgs::service_name` must be set in order to initialize telemetry"
590                );
591            };
592
593            // For these things, all we need to do is make sure that the right OTEL env var is set.
594            // All the downstream libraries will do the right thing if they are.
595            //
596            // Endpoint env vars are only set when we actually have an endpoint to point at;
597            // overwriting them with empty strings would prevent the OTLP SDK builders from
598            // reading values that may have been set externally.
599            //
600            // Safety: anything touching the env is unsafe, tis what it is.
601            #[expect(unsafe_code)]
602            unsafe {
603                if !log_endpoint.is_empty() {
604                    std::env::set_var("OTEL_EXPORTER_OTLP_LOGS_ENDPOINT", &log_endpoint);
605                }
606                if !metric_endpoint.is_empty() {
607                    std::env::set_var("OTEL_EXPORTER_OTLP_METRICS_ENDPOINT", &metric_endpoint);
608                }
609                // Mirror the `OTEL_*`-sourced trace endpoint back into its
610                // env var so the OTel SDK's exporter builder reads it from
611                // there — origin/main behavior. `RERUN_TELEMETRY_ENDPOINT`
612                // values live in `trace_endpoints.rerun_authed` and are
613                // never mirrored here regardless of their URL scheme;
614                // keeping them out of `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT`
615                // is the entire point of having a dedicated knob.
616                if let Some(url) = &trace_endpoints.standard {
617                    std::env::set_var("OTEL_EXPORTER_OTLP_TRACES_ENDPOINT", url);
618                }
619                std::env::set_var("OTEL_METRIC_EXPORT_INTERVAL", metric_interval);
620                std::env::set_var("OTEL_RESOURCE_ATTRIBUTES", attributes);
621                std::env::set_var("OTEL_SERVICE_NAME", &service_name);
622                std::env::set_var("OTEL_TRACES_SAMPLER", trace_sampler);
623                std::env::set_var("OTEL_TRACES_SAMPLER_ARG", trace_sampler_args);
624            }
625
626            let create_filter = |base: &str, forced: &str| {
627                use crate::EnvFilterExt as _;
628
629                EnvFilter::new(base)
630                    .add_directive_if_absent(base, "aws_smithy_runtime", forced)?
631                    .add_directive_if_absent(base, "datafusion", forced)?
632                    .add_directive_if_absent(base, "datafusion_optimizer", forced)?
633                    .add_directive_if_absent(base, "h2", forced)?
634                    .add_directive_if_absent(base, "hyper", forced)?
635                    .add_directive_if_absent(base, "hyper_util", forced)?
636                    .add_directive_if_absent(base, "lance", forced)?
637                    .add_directive_if_absent(base, "lance-arrow", forced)?
638                    .add_directive_if_absent(base, "lance-core", forced)?
639                    .add_directive_if_absent(base, "lance-datafusion", forced)?
640                    .add_directive_if_absent(base, "lance-encoding", forced)?
641                    .add_directive_if_absent(base, "lance-file", forced)?
642                    .add_directive_if_absent(base, "lance-index", forced)?
643                    .add_directive_if_absent(base, "lance-io", forced)?
644                    .add_directive_if_absent(base, "lance-linalg", forced)?
645                    .add_directive_if_absent(base, "lance-table", forced)?
646                    .add_directive_if_absent(base, "lance", forced)?
647                    .add_directive_if_absent(base, "opentelemetry-otlp", forced)?
648                    .add_directive_if_absent(base, "opentelemetry", forced)?
649                    .add_directive_if_absent(base, "opentelemetry_sdk", forced)?
650                    .add_directive_if_absent(base, "rustls", forced)?
651                    .add_directive_if_absent(base, "sqlparser", forced)?
652                    .add_directive_if_absent(base, "tonic", forced)?
653                    .add_directive_if_absent(base, "tonic_web", forced)?
654                    .add_directive_if_absent(base, "tower", forced)?
655                    .add_directive_if_absent(base, "tower_http", forced)?
656                    .add_directive_if_absent(base, "tower_web", forced)?
657                    .add_directive_if_absent(base, "typespec_client_core", forced)?
658                    //
659                    .add_directive_if_absent(base, "lance::index", "off")?
660                    .add_directive_if_absent(base, "lance::io::exec", "off")?
661                    .add_directive_if_absent(base, "lance::execution", "warn")?
662                    .add_directive_if_absent(base, "lance::dataset::scanner", "off")?
663                    .add_directive_if_absent(base, "lance_index", "off")?
664                    .add_directive_if_absent(base, "lance::dataset::builder", "off")?
665                    .add_directive_if_absent(base, "lance_encoding", "off")
666            };
667
668            // Logging strategy
669            // ================
670            //
671            // * All our logs go through the structured `tracing` macros.
672            //
673            // * We always log from `tracing` directly into stdio: we never involve the OpenTelemetry
674            //   logging API. Production is expected to read the logs from the pod's output.
675            //   There is never any internal buffering going on, besides the buffering of stdio itself.
676            //
677            // * All logs that happen as part of the larger trace/span will automatically be uploaded
678            //   with that trace/span.
679            //   This makes our traces a very powerful debugging tool, in addition to a profiler.
680            //
681            // * If `OTEL_EXPORTER_OTLP_LOGS_ENABLED=true`, all logs will be forwarded to an OpenTelemetry
682            //   collector in addition to standard IO.
683
684            let layer_logs_and_traces_stdio = {
685                let layer = tracing_subscriber::fmt::layer()
686                    .with_writer(std::io::stderr)
687                    .with_file(true)
688                    .with_line_number(true)
689                    .with_target(false)
690                    .with_thread_ids(true)
691                    .with_thread_names(true)
692                    .with_span_events(if log_closed_spans {
693                        tracing_subscriber::fmt::format::FmtSpan::CLOSE
694                    } else {
695                        tracing_subscriber::fmt::format::FmtSpan::NONE
696                    });
697
698                // Everything is generically typed, which is why this is such a nightmare to do.
699                macro_rules! handle_format {
700                    ($format:ident, $is_json:expr) => {{
701                        let layer = layer
702                            .$format()
703                            .map_event_format(|f| TraceIdFormat::new(f, $is_json));
704                        if log_test_output {
705                            layer.with_test_writer().boxed()
706                        } else {
707                            layer.boxed()
708                        }
709                    }};
710                }
711                let layer = match log_format {
712                    LogFormat::Pretty => handle_format!(pretty, false),
713                    LogFormat::Compact => handle_format!(compact, false),
714                    LogFormat::Json => handle_format!(json, true),
715                };
716
717                layer.with_filter(create_filter(&log_filter, "warn")?)
718            };
719
720            let (logger_provider, layer_logs_otlp) = if log_otlp_enabled && !log_endpoint.is_empty()
721            {
722                use opentelemetry_appender_tracing::layer::OpenTelemetryTracingBridge;
723
724                let exporter = opentelemetry_otlp::LogExporter::builder()
725                    .with_tonic() // There's no good reason to use HTTP for logs (at the moment, that is)
726                    .build()?;
727
728                let provider = SdkLoggerProvider::builder()
729                    .with_batch_exporter(exporter)
730                    .build();
731
732                let layer = OpenTelemetryTracingBridge::new(&provider).boxed();
733
734                (
735                    Some(provider),
736                    Some(layer.with_filter(create_filter(&log_filter, "warn")?)),
737                )
738            } else {
739                (None, None)
740            };
741
742            // Tracing strategy
743            // ================
744            //
745            // * All our traces go through the structured `tracing` macros. We *never* use the
746            //   OpenTelemetry macros.
747            //
748            // * The traces go through a first layer of filtering based on the value of `RUST_TRACE`, which
749            //   functions similarly to a `RUST_LOG` filter.
750            //
751            // * The traces are then sent to the OpenTelemetry SDK, where they will go through a pass of
752            //   sampling before being sent to the OTLP endpoint.
753            //   The sampling mechanism is controlled by the official OTEL environment variables.
754            //
755            // * Spans that contains error logs will properly be marked as failed, and easily findable.
756
757            // The `TracerProvider` is always built when telemetry is enabled, so propagators
758            // and `current_trace_id()` keep working. Up to two `BatchSpanProcessor`s are
759            // attached — one per active trace endpoint, see [`ResolvedTraceEndpoints`].
760            // With neither endpoint set, spans flow through the in-process pipeline and
761            // are dropped at the end — no exporter chatter.
762            let (tracer_provider, layer_traces_otlp) = {
763                let mut builder = SdkTracerProvider::builder();
764                if trace_endpoints.any() {
765                    // Build a fresh batch config per processor — the OTel
766                    // builder consumes it, and we may attach two processors
767                    // when both endpoints are active.
768                    let make_batch_config = || {
769                        BatchConfigBuilder::default()
770                            // increase max queue size from default 2048 to ensure we don't drop spans during high throughput
771                            .with_max_queue_size(8192)
772                            // export more spans per batch to reduce number of requests (default is 512)
773                            // together with queue size this help ensure more robust exporting under high throughput
774                            .with_max_export_batch_size(2048)
775                            .build()
776                    };
777
778                    // Tag root spans with `rerun_session_id` whenever any
779                    // exporter is active, so Tempo can find client-side
780                    // traces by `{ .rerun_session_id = "rs_…" }`. When a
781                    // vanilla OTLP destination is configured alongside Hub,
782                    // it also receives the attribute on root spans —
783                    // downstream tools that don't know about it ignore it.
784                    builder = builder
785                        .with_span_processor(crate::tracestate::RerunSessionRootSpanProcessor);
786
787                    if let Some(transport_url) = &trace_endpoints.rerun_authed {
788                        // `RERUN_TELEMETRY_ENDPOINT` exporter, already
789                        // normalized to its `http(s)://` transport form by
790                        // `ResolvedTraceEndpoints::resolve`. Injects the
791                        // SDK's auth token on every export — the dedicated
792                        // knob always opts into the Rerun auth path
793                        // regardless of scheme.
794                        let exporter = build_rerun_authed_span_exporter(transport_url)?;
795                        builder = builder.with_span_processor(
796                            BatchSpanProcessor::builder(exporter)
797                                .with_batch_config(make_batch_config())
798                                .build(),
799                        );
800                    }
801
802                    if trace_endpoints.standard.is_some() {
803                        // Standard OTLP exporter — reads the endpoint from
804                        // `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT` (mirrored
805                        // above), so no explicit URL passed here.
806                        let exporter = opentelemetry_otlp::SpanExporter::builder()
807                            .with_tonic() // There's no good reason to use HTTP for traces (at the moment, that is)
808                            .with_compression(opentelemetry_otlp::Compression::Gzip) // use gzip compression to reduce bandwidth
809                            .build()?;
810                        builder = builder.with_span_processor(
811                            BatchSpanProcessor::builder(exporter)
812                                .with_batch_config(make_batch_config())
813                                .build(),
814                        );
815                    }
816                }
817
818                let provider = builder.build();
819
820                // Used by `TracingInjectorInterceptor` to encode the trace information into the
821                // outbound request headers. `TraceStateEnricher` runs after the W3C propagator
822                // and merges `rerun_session_id=<id>` into `tracestate` whenever a tracing
823                // session is active (Rust `with_tracing_session` or Python `tracing_session()`).
824                // With no active scope it is a no-op.
825                let propagators: Vec<
826                    Box<dyn opentelemetry::propagation::TextMapPropagator + Send + Sync>,
827                > = vec![
828                    Box::new(opentelemetry_sdk::propagation::TraceContextPropagator::new()),
829                    Box::new(crate::tracestate::TraceStateEnricher),
830                ];
831
832                opentelemetry::global::set_text_map_propagator(
833                    opentelemetry::propagation::TextMapCompositePropagator::new(propagators),
834                );
835
836                // This is to make sure that if some third-party system is logging raw OpenTelemetry
837                // spans (as opposed to `tracing` spans), we will catch them and forward them
838                // appropriately.
839                opentelemetry::global::set_tracer_provider(provider.clone());
840
841                let layer = tracing_opentelemetry::layer()
842                    .with_tracer(provider.tracer(service_name.clone()))
843                    .with_filter(create_filter(&trace_filter, "info")?)
844                    .boxed();
845
846                (Some(provider), Some(layer))
847            };
848
849            // Metric strategy
850            // ===============
851            //
852            // * Metrics can be pushed to an OTLP endpoint as defined by OTEL SDK variables.
853            //   OTEL_METRIC_EXPORT_INTERVAL environment variable applies for push interval.
854            //   This is enabled by setting OTEL_EXPORTER_OTLP_METRICS_ENDPOINT
855            //
856            // * Additionally a prometheus-style scraping endpoint can be enabled by calling
857            //   start_metrics_listener() on the returned Telemetry instance.
858            //
859            // Both ways use the same data for actual metrics.
860            //
861            // The `MeterProvider` is always built so the `start_metrics_listener()` Prometheus
862            // path keeps working; the OTLP push exporter is only attached when an endpoint is
863            // configured (per-signal or via the umbrella).
864            let (metric_provider, metrics_reader) = {
865                let mut builder = SdkMeterProvider::builder();
866
867                // Use base-2 exponential histograms (OTel equivalent of Prometheus native
868                // histograms) instead of explicit bucket histograms. This avoids hardcoding
869                // bucket boundaries and lets the SDK auto-scale resolution.
870                builder =
871                    builder.with_view(|instrument: &opentelemetry_sdk::metrics::Instrument| {
872                        if instrument.kind()
873                            == opentelemetry_sdk::metrics::InstrumentKind::Histogram
874                        {
875                            opentelemetry_sdk::metrics::Stream::builder()
876                                .with_aggregation(Aggregation::Base2ExponentialHistogram {
877                                    // Max buckets per positive/negative range. Negative buckets
878                                    // stay empty for duration/size metrics. Comparable to the
879                                    // ~10 explicit buckets we had before, but with auto-scaling
880                                    // boundaries.
881                                    max_size: 20,
882                                    // Starting resolution scale. The base of each bucket is
883                                    // 2^(2^(-scale)). At scale 20 (the maximum), buckets are
884                                    // extremely fine-grained; the SDK automatically downscales
885                                    // when observations exceed max_size buckets.
886                                    max_scale: 20,
887                                    record_min_max: true,
888                                })
889                                .build()
890                                .ok()
891                        } else {
892                            None
893                        }
894                    });
895
896                // Drive the periodic export on the Tokio runtime rather than via
897                // `with_periodic_exporter`. That convenience method installs the
898                // thread-based `PeriodicReader`, which spawns a bare std thread and drives
899                // each export with `futures_executor::block_on`. The OTLP HTTP exporter uses
900                // a hyper client whose `tokio::time::timeout` panics ("there is no reactor
901                // running") when polled off a Tokio runtime, and with `panic = "abort"` that
902                // takes down the whole process. The async-runtime reader instead spawns its
903                // ticker via `tokio::spawn` (when the meter provider is built below), so
904                // exports run on the runtime's workers, which have a reactor. The interval
905                // still honors `OTEL_METRIC_EXPORT_INTERVAL` (read by the builder).
906                //
907                // This means the reader requires an ambient Tokio runtime at init time.
908                // Every caller initializes telemetry within one (services via
909                // `#[tokio::main]`, the Python SDK via `runtime.block_on`), but we guard
910                // explicitly: telemetry must never abort the host process. With no runtime
911                // we skip OTLP push metrics and fall back to the always-installed
912                // `SharedManualReader` (the Prometheus scrape path is unaffected).
913                if !metric_endpoint.is_empty() {
914                    if tokio::runtime::Handle::try_current().is_ok() {
915                        let otlp_exporter = opentelemetry_otlp::MetricExporter::builder()
916                            .with_temporality(opentelemetry_sdk::metrics::Temporality::Cumulative)
917                            .with_http()
918                            .build()?;
919
920                        let reader = opentelemetry_sdk::metrics::periodic_reader_with_async_runtime::PeriodicReader::builder(
921                            otlp_exporter,
922                            opentelemetry_sdk::runtime::Tokio,
923                        )
924                        .build();
925                        builder = builder.with_reader(reader);
926                    } else {
927                        tracing::warn!(
928                            "OTLP metrics endpoint is set but telemetry was initialized outside a Tokio runtime; \
929                             skipping push-based metric export. Metrics are still available via the Prometheus \
930                             scrape listener if one is configured."
931                        );
932                    }
933                }
934
935                // Always add a ManualReader for potential metrics listener
936                // We use SharedManualReader to share the same reader instance between
937                // the MeterProvider (for registration) and the metrics server (for collection)
938                let shared_reader =
939                    SharedManualReader::new(opentelemetry_sdk::metrics::Temporality::Cumulative);
940
941                let reader_for_telemetry = shared_reader.inner();
942                builder = builder.with_reader(shared_reader);
943
944                let provider = builder.build();
945
946                // Set as global provider - this makes all metrics created via opentelemetry::global::meter()
947                // available to all registered readers: OTLP push and ManualReader
948                opentelemetry::global::set_meter_provider(provider.clone());
949
950                (Some(provider), Some(reader_for_telemetry))
951            };
952
953            // Without the `tracy` feature the `if` branch always bails, but the `else` is not
954            // redundant in a build that has it:
955            #[cfg_attr(not(feature = "tracy"), expect(clippy::redundant_else))]
956            if tracy_enabled {
957                cfg_select! {
958                    feature = "tracy" => {
959                        tracing_subscriber::registry()
960                            .with(layer_logs_otlp)
961                            .with(layer_logs_and_traces_stdio)
962                            .with(layer_traces_otlp)
963                            .with(SpanMetadataCleanupLayer::default())
964                            .with(self::tracy::tracy_layer())
965                            .try_init()?;
966                    }
967                    _ => {
968                        anyhow::bail!(
969                            "`TRACY_ENABLED=true` but the 'tracy' feature flag is not toggled"
970                        );
971                    }
972                }
973            } else {
974                tracing_subscriber::registry()
975                    .with(layer_logs_otlp)
976                    .with(layer_logs_and_traces_stdio)
977                    .with(layer_traces_otlp)
978                    .with(SpanMetadataCleanupLayer::default())
979                    .try_init()?;
980            }
981
982            crate::memory_telemetry::install_memory_use_meters();
983
984            // Reached only on the enabled-true success path (subscriber +
985            // OTLP layers installed). Flips the process-wide flag that
986            // [`is_telemetry_active`] exposes; consumers like
987            // [`crate::with_tracing_session`] and the Python
988            // `tracing_session()` bridge gate on it.
989            TELEMETRY_ACTIVE.store(true, std::sync::atomic::Ordering::Release);
990
991            Ok(Self {
992                drop_behavior,
993                logs: logger_provider,
994                traces: tracer_provider,
995                metrics: metric_provider,
996                metrics_reader,
997            })
998        })();
999
1000        match result {
1001            Ok(self_) => {
1002                // Emitted through the subscriber installed by `try_init` above
1003                // (when `enabled` or `tracy_enabled`). Drops silently in the
1004                // no-subscriber case — but that case has nothing else running
1005                // either, so silence is appropriate.
1006                tracing::info!(
1007                    enabled,
1008                    service = %service_name_summary,
1009                    trace_mode,
1010                    traces = %traces_summary,
1011                    logs = %logs_summary,
1012                    metrics = %metrics_summary,
1013                    tracy = tracy_enabled,
1014                    "Telemetry initialized"
1015                );
1016                #[cfg(feature = "tracy")]
1017                if tracy_enabled && enabled {
1018                    tracing::warn!(
1019                        "using tracy in addition to standard telemetry stack, consider `TELEMETRY_ENABLED=false`"
1020                    );
1021                }
1022                Ok(self_)
1023            }
1024            Err(err) => {
1025                // The subscriber is not guaranteed to be installed on the
1026                // failure path (most error sites are pre-`try_init`), so fall
1027                // back to stderr to ensure the diagnosis is visible.
1028                eprintln!(
1029                    "Telemetry init failed (enabled={enabled} service={service_name_summary} trace_mode={trace_mode} traces={traces_summary} logs={logs_summary} metrics={metrics_summary} tracy={tracy_enabled}): {err:#}"
1030                );
1031                Err(err)
1032            }
1033        }
1034    }
1035
1036    /// Start a dedicated HTTP server for metrics collection at the given address.
1037    ///
1038    /// This binds to the specified address and spawns an HTTP server that exposes a
1039    /// `/metrics` endpoint for Prometheus-style scraping. The metrics are collected
1040    /// on-demand when the endpoint is accessed.
1041    ///
1042    /// # Arguments
1043    ///
1044    /// * `addr` - The address to listen on (e.g., ":9091", "0.0.0.0:9091", or "127.0.0.1:9091")
1045    ///
1046    /// # Returns
1047    ///
1048    /// Returns an error if:
1049    /// - Telemetry was not initialized with metrics support
1050    /// - The address is invalid or cannot be parsed
1051    /// - The server fails to bind to the address (e.g., port already in use)
1052    ///
1053    /// # Example
1054    ///
1055    /// ```ignore
1056    /// use re_perf_telemetry::{Telemetry, TelemetryArgs, TelemetryDropBehavior};
1057    ///
1058    /// let args = TelemetryArgs { /* ... */ };
1059    /// let telemetry = Telemetry::init(args, TelemetryDropBehavior::Shutdown)?;
1060    ///
1061    /// // This will return an error if the port is already in use
1062    /// telemetry.start_metrics_listener(":9091").await?;
1063    /// ```
1064    pub async fn start_metrics_listener(&self, addr: &str) -> anyhow::Result<()> {
1065        let reader = self.metrics_reader.as_ref()
1066            .ok_or_else(|| anyhow::anyhow!(
1067                "Cannot start metrics listener: telemetry was not initialized with metrics support. \
1068                Ensure TELEMETRY_ENABLED=true"
1069            ))?;
1070
1071        // Clone the Arc to pass to the server
1072        let reader_for_server = Arc::clone(reader);
1073
1074        // Start the metrics server - this will bind synchronously and return an error
1075        // if binding fails (e.g., port already in use), but the actual serving happens
1076        // asynchronously in a spawned task
1077        crate::metrics_server::start_metrics_server(addr, reader_for_server).await?;
1078
1079        Ok(())
1080    }
1081}
1082
1083// ---
1084
1085/// Tracy integration
1086/// =================
1087///
1088/// * Use `TRACY_ENABLED=true` in combination with `tracy` feature flag.
1089/// * The Tracy Viewer version must match the client's: we use 0.12 for both (latest as of this writing).
1090///
1091/// See <https://github.com/wolfpld/tracy>.
1092///
1093/// ⚠️Tracy will start monitoring OS performance as soon as the client library is loaded in!
1094/// This is very cheap, but make sure to disable the `tracy` feature flag if that turns out to be a
1095/// problem for whatever reason (`TRACY_ENABLED=false`) won't cut it.
1096///
1097/// ⚠️Keep in mind that the `Counts` that are displayed in Tracy account for every yields!
1098/// E.g. an async function that yields 50 times will be counted as 51 (the first call + 50 yields).
1099#[cfg(feature = "tracy")]
1100mod tracy {
1101    #[derive(Default)]
1102    pub struct TracyConfig(tracing_subscriber::fmt::format::DefaultFields);
1103
1104    impl tracing_tracy::Config for TracyConfig {
1105        type Formatter = tracing_subscriber::fmt::format::DefaultFields;
1106
1107        fn formatter(&self) -> &Self::Formatter {
1108            &self.0
1109        }
1110
1111        fn format_fields_in_zone_name(&self) -> bool {
1112            false
1113        }
1114    }
1115
1116    pub fn tracy_layer() -> tracing_tracy::TracyLayer<TracyConfig> {
1117        tracing_tracy::TracyLayer::new(TracyConfig::default())
1118    }
1119}
1120
1121#[cfg(test)]
1122mod tests {
1123    use super::ResolvedTraceEndpoints;
1124
1125    /// Compact projection of `resolve`'s return for table-driven assertions.
1126    /// `Endpoints` keeps both URLs so we can match the dual-publish cases
1127    /// directly; `Err` collapses all malformed-input cases together — we
1128    /// only assert "resolve rejects this", not the particular error type.
1129    #[derive(Debug)]
1130    enum Want {
1131        Endpoints {
1132            rerun_authed: Option<&'static str>,
1133            standard: Option<&'static str>,
1134        },
1135        Err,
1136    }
1137
1138    /// Shorthand constructors for the `Want::Endpoints` rows.
1139    const fn rerun_only(url: &'static str) -> Want {
1140        Want::Endpoints {
1141            rerun_authed: Some(url),
1142            standard: None,
1143        }
1144    }
1145    const fn standard_only(url: &'static str) -> Want {
1146        Want::Endpoints {
1147            rerun_authed: None,
1148            standard: Some(url),
1149        }
1150    }
1151    const fn both(rerun_authed: &'static str, standard: &'static str) -> Want {
1152        Want::Endpoints {
1153            rerun_authed: Some(rerun_authed),
1154            standard: Some(standard),
1155        }
1156    }
1157    const NONE: Want = Want::Endpoints {
1158        rerun_authed: None,
1159        standard: None,
1160    };
1161
1162    /// `ResolvedTraceEndpoints::resolve` behavior, table-driven.
1163    ///
1164    /// Each row: `(rerun_telemetry_endpoint, standard_otel_endpoint, expected)`.
1165    #[test]
1166    fn resolve_behavior() {
1167        let cases: &[(&str, &str, Want)] = &[
1168            // -- No exporter ---------------------------------------------
1169            ("", "", NONE),
1170            // -- Only OTEL_*: standard, verbatim (never parsed for `rerun://`) -
1171            (
1172                "",
1173                "https://collector:4317",
1174                standard_only("https://collector:4317"),
1175            ),
1176            (
1177                "",
1178                "http://localhost:4317",
1179                standard_only("http://localhost:4317"),
1180            ),
1181            ("", "grpc://collector", standard_only("grpc://collector")),
1182            (
1183                "",
1184                "rerun://api.example.com",
1185                standard_only("rerun://api.example.com"),
1186            ),
1187            // -- Only RERUN_*, `rerun*` schemes: authed (normalized) -----
1188            (
1189                "rerun://api.example.com",
1190                "",
1191                rerun_only("https://api.example.com"),
1192            ),
1193            (
1194                "rerun+https://api.example.com:4317",
1195                "",
1196                rerun_only("https://api.example.com:4317"),
1197            ),
1198            (
1199                "rerun+http://localhost:4317",
1200                "",
1201                rerun_only("http://localhost:4317"),
1202            ),
1203            (
1204                "rerun://host/foo/bar?x=1",
1205                "",
1206                rerun_only("https://host/foo/bar?x=1"),
1207            ),
1208            // -- Only RERUN_*, plain `http(s)://`: still authed, URL unchanged ---
1209            (
1210                "https://api.example.com:4317",
1211                "",
1212                rerun_only("https://api.example.com:4317"),
1213            ),
1214            (
1215                "http://localhost:4317",
1216                "",
1217                rerun_only("http://localhost:4317"),
1218            ),
1219            // -- Invalid RERUN_*: Err (no silent fallback to OTEL_*) -----
1220            ("ftp://collector", "", Want::Err),
1221            ("grpc://collector", "", Want::Err),
1222            ("garbage", "", Want::Err),
1223            ("api.example.com", "", Want::Err),
1224            ("RERUN://host", "", Want::Err), // Case-sensitive: `re_uri::Scheme` convention.
1225            ("Rerun+Https://host", "", Want::Err),
1226            ("HTTPS://host", "", Want::Err),
1227            ("rerun:/host", "", Want::Err),
1228            ("rerun", "", Want::Err),
1229            ("ftp://bad", "https://otel:4317", Want::Err), // Malformed RERUN_* does NOT fall back to OTEL_*.
1230            // -- Both set: dual-publish (both exporters active) ----------
1231            (
1232                "rerun://hub",
1233                "https://collector",
1234                both("https://hub", "https://collector"),
1235            ),
1236            (
1237                "http://hub",
1238                "https://collector",
1239                both("http://hub", "https://collector"),
1240            ),
1241            (
1242                "rerun+http://hub:4317",
1243                "https://collector:4317",
1244                both("http://hub:4317", "https://collector:4317"),
1245            ),
1246        ];
1247
1248        for (rerun, otel, want) in cases {
1249            let got = ResolvedTraceEndpoints::resolve(rerun, otel);
1250            let matches = match (&got, want) {
1251                (Err(_), Want::Err) => true,
1252                (
1253                    Ok(endpoints),
1254                    Want::Endpoints {
1255                        rerun_authed,
1256                        standard,
1257                    },
1258                ) => {
1259                    endpoints.rerun_authed.as_deref() == *rerun_authed
1260                        && endpoints.standard.as_deref() == *standard
1261                }
1262                _ => false,
1263            };
1264            assert!(
1265                matches,
1266                "resolve({rerun:?}, {otel:?})\n  got:      {got:?}\n  expected: {want:?}",
1267            );
1268        }
1269    }
1270
1271    /// Minimal structurally-valid JWT: `{"alg":"HS256","typ":"JWT"}` base64url
1272    /// then `{}` then a stub signature. `re_auth::Jwt::try_from` only checks
1273    /// that the header decodes, so this is enough.
1274    const TEST_JWT: &str = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.e30.sig";
1275
1276    #[test]
1277    fn build_rejects_invalid_transport_url() {
1278        use re_auth::credentials::StaticCredentialsProvider;
1279
1280        let jwt = re_auth::Jwt::try_from(TEST_JWT.to_owned()).unwrap();
1281        let provider = super::Arc::new(StaticCredentialsProvider::new(jwt));
1282        let result =
1283            super::build_rerun_authed_span_exporter_with_provider("not a url at all", provider);
1284        assert!(result.is_err(), "expected Err for malformed URL");
1285    }
1286
1287    /// End-to-end: build the authed exporter with a known JWT, send a span
1288    /// through it, and confirm the `MockOtlpCollector` receives an export
1289    /// whose `authorization` metadata is `Bearer <jwt>`. Exercises the
1290    /// full wrapper → tonic interceptor → gRPC metadata pipeline.
1291    #[tokio::test(flavor = "multi_thread")]
1292    async fn authed_exporter_sends_bearer_metadata() {
1293        use std::time::Duration;
1294
1295        use opentelemetry::trace::{Tracer as _, TracerProvider as _};
1296        use opentelemetry_sdk::trace::{BatchSpanProcessor, SdkTracerProvider};
1297        use re_auth::credentials::StaticCredentialsProvider;
1298        use re_test_mocks::otlp::MockOtlpCollector;
1299
1300        let collector = MockOtlpCollector::spawn().await;
1301        let jwt = re_auth::Jwt::try_from(TEST_JWT.to_owned()).unwrap();
1302        let provider = super::Arc::new(StaticCredentialsProvider::new(jwt));
1303
1304        let exporter =
1305            super::build_rerun_authed_span_exporter_with_provider(&collector.endpoint(), provider)
1306                .unwrap();
1307
1308        let tracer_provider = SdkTracerProvider::builder()
1309            .with_span_processor(BatchSpanProcessor::builder(exporter).build())
1310            .build();
1311        let tracer = tracer_provider.tracer("test");
1312
1313        // Emit one span, then force a flush so we don't wait for the default
1314        // 5-second scheduled-delay tick.
1315        {
1316            let span = tracer.start("authed_test_span");
1317            drop(span);
1318        }
1319        tracer_provider.force_flush().ok();
1320
1321        let received = collector
1322            .wait_for(|_| true, Duration::from_secs(10))
1323            .await
1324            .expect("collector should receive at least one span");
1325
1326        let auth = received
1327            .metadata
1328            .get("authorization")
1329            .expect("authorization metadata missing")
1330            .to_str()
1331            .expect("authorization should be ASCII");
1332        assert_eq!(auth, format!("Bearer {TEST_JWT}"));
1333    }
1334}