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}