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