Skip to main content

rivetkit_core/telemetry/
export.rs

1//! Native OTLP export of the runtime's spans.
2//!
3//! Configuration comes entirely from the standard OpenTelemetry environment
4//! variables, so every host that embeds core gets the same behavior by adding
5//! [`layer`] to its subscriber and calling [`shutdown_best_effort`] after the
6//! runtime has drained.
7//! Core never installs a subscriber itself; which log layers surround the span
8//! layer is the host's decision.
9
10use std::time::Duration;
11
12use anyhow::{Context, Result};
13use opentelemetry::KeyValue;
14use opentelemetry::trace::TracerProvider as _;
15use opentelemetry_otlp::{Protocol, SpanExporter, WithExportConfig as _};
16use opentelemetry_sdk::Resource;
17use opentelemetry_sdk::trace::{SdkTracer, SdkTracerProvider};
18use parking_lot::Mutex;
19use tracing_subscriber::registry::LookupSpan;
20use tracing_subscriber::{EnvFilter, Layer};
21
22enum ProviderState {
23	Uninitialized,
24	Active(SdkTracerProvider),
25	ShutDown,
26}
27
28static PROVIDER: Mutex<ProviderState> = Mutex::new(ProviderState::Uninitialized);
29
30/// Longest a collector that stops answering may hold up process shutdown.
31const SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(5);
32
33/// Builds the span layer when standard OTel environment variables opt in, or
34/// nothing when they do not. The layer only sees the runtime's own spans, so a
35/// host's log filters do not decide what gets exported.
36pub fn layer<S>() -> Result<Option<impl Layer<S>>>
37where
38	S: tracing::Subscriber + for<'a> LookupSpan<'a>,
39{
40	let Some(tracer) = initialize_if_configured()? else {
41		return Ok(None);
42	};
43	Ok(Some(
44		tracing_opentelemetry::layer()
45			.with_tracer(tracer)
46			.with_location(false)
47			.with_threads(false)
48			.with_tracked_inactivity(false)
49			.with_filter(EnvFilter::new("rivetkit::telemetry=info")),
50	))
51}
52
53/// Builds the OTLP exporter once. A second call reuses the provider so that a
54/// host initializing tracing more than once does not open a second pipeline.
55fn initialize_if_configured() -> Result<Option<SdkTracer>> {
56	if !export_is_configured() {
57		return Ok(None);
58	}
59	let mut stored_provider = PROVIDER.lock();
60	match &*stored_provider {
61		ProviderState::Active(provider) => return Ok(Some(provider.tracer("rivetkit"))),
62		ProviderState::ShutDown => return Ok(None),
63		ProviderState::Uninitialized => {}
64	}
65
66	let exporter = match configured_protocol()? {
67		Protocol::Grpc => SpanExporter::builder()
68			.with_tonic()
69			.build()
70			.context("build otlp span exporter")?,
71		protocol @ (Protocol::HttpBinary | Protocol::HttpJson) => SpanExporter::builder()
72			.with_http()
73			.with_protocol(protocol)
74			.build()
75			.context("build otlp span exporter")?,
76	};
77	let resource = Resource::builder()
78		.with_attribute(KeyValue::new("rivetkit.version", env!("CARGO_PKG_VERSION")))
79		.build();
80	let provider = SdkTracerProvider::builder()
81		.with_resource(resource)
82		.with_batch_exporter(exporter)
83		.build();
84	let tracer = provider.tracer("rivetkit");
85	*stored_provider = ProviderState::Active(provider);
86	Ok(Some(tracer))
87}
88
89/// Reads the standard OTLP protocol variables.
90///
91/// The exporter's own default comes from a compile-time constant chosen by the
92/// enabled cargo features, and neither of its builders reads these variables,
93/// so selecting the protocol has to happen here. Enabling `http-json` would
94/// otherwise make JSON that compile-time default, which the OTLP specification
95/// does not list among the usual defaults.
96fn configured_protocol() -> Result<Protocol> {
97	let configured = std::env::var("OTEL_EXPORTER_OTLP_TRACES_PROTOCOL")
98		.or_else(|_| std::env::var("OTEL_EXPORTER_OTLP_PROTOCOL"))
99		.unwrap_or_else(|_| "http/protobuf".to_owned());
100	match configured.as_str() {
101		"grpc" => Ok(Protocol::Grpc),
102		"http/protobuf" => Ok(Protocol::HttpBinary),
103		"http/json" => Ok(Protocol::HttpJson),
104		other => anyhow::bail!(
105			"native trace export supports grpc, http/protobuf and http/json, got {other:?}"
106		),
107	}
108}
109
110fn export_is_configured() -> bool {
111	if std::env::var("OTEL_SDK_DISABLED").is_ok_and(|value| value.eq_ignore_ascii_case("true"))
112		|| std::env::var("OTEL_TRACES_EXPORTER")
113			.is_ok_and(|value| value.eq_ignore_ascii_case("none"))
114	{
115		return false;
116	}
117
118	[
119		"OTEL_EXPORTER_OTLP_TRACES_ENDPOINT",
120		"OTEL_EXPORTER_OTLP_ENDPOINT",
121	]
122	.into_iter()
123	.any(|name| std::env::var_os(name).is_some_and(|value| !value.is_empty()))
124}
125
126/// Terminates the process-global trace provider after runtime work has drained.
127/// Export failures are logged because telemetry must not affect runtime shutdown.
128pub async fn shutdown_best_effort() {
129	let provider = {
130		let mut state = PROVIDER.lock();
131		match std::mem::replace(&mut *state, ProviderState::ShutDown) {
132			ProviderState::Active(provider) => Some(provider),
133			ProviderState::Uninitialized | ProviderState::ShutDown => None,
134		}
135	};
136	let Some(provider) = provider else {
137		return;
138	};
139	let shutdown = tokio::task::spawn_blocking(move || provider.shutdown());
140	match tokio::time::timeout(SHUTDOWN_TIMEOUT, shutdown).await {
141		Ok(Ok(Ok(()))) => {}
142		Ok(Ok(Err(error))) => tracing::warn!(
143			?error,
144			"OpenTelemetry trace provider shutdown failed; queued spans may be lost"
145		),
146		Ok(Err(error)) => tracing::warn!(
147			?error,
148			"OpenTelemetry trace provider shutdown task failed; queued spans may be lost"
149		),
150		Err(_) => tracing::warn!(
151			"OpenTelemetry trace provider shutdown timed out; queued spans may be lost"
152		),
153	}
154}