1use crate::{file::FileSpanExporter, trace_file_path, OtlpProtocol};
4use eyre::ensure;
5use opentelemetry::{global, trace::TracerProvider, KeyValue, Value};
6use opentelemetry_otlp::{SpanExporter, WithExportConfig};
7use opentelemetry_sdk::{
8 propagation::TraceContextPropagator,
9 trace::{Sampler, SdkTracer},
10 Resource,
11};
12use opentelemetry_semantic_conventions::{attribute::SERVICE_VERSION, SCHEMA_URL};
13use tracing::Subscriber;
14use tracing_opentelemetry::OpenTelemetryLayer;
15use tracing_subscriber::registry::LookupSpan;
16use url::Url;
17
18use base64::{prelude::BASE64_STANDARD, Engine};
19
20pub use opentelemetry_sdk::trace::SdkTracerProvider;
21
22const OTEL_EXPORTER_OTLP_TRACES_HEADERS: &str = "OTEL_EXPORTER_OTLP_TRACES_HEADERS";
23const OTEL_EXPORTER_OTLP_LOGS_HEADERS: &str = "OTEL_EXPORTER_OTLP_LOGS_HEADERS";
24
25pub fn span_layer<S>(otlp_config: OtlpConfig) -> eyre::Result<OpenTelemetryLayer<S, SdkTracer>>
30where
31 for<'span> S: Subscriber + LookupSpan<'span>,
32{
33 let (layer, provider) = span_layer_with_provider(otlp_config)?;
34 global::set_tracer_provider(provider);
35 Ok(layer)
36}
37
38pub fn span_layer_with_provider<S>(
46 otlp_config: OtlpConfig,
47) -> eyre::Result<(OpenTelemetryLayer<S, SdkTracer>, SdkTracerProvider)>
48where
49 for<'span> S: Subscriber + LookupSpan<'span>,
50{
51 global::set_text_map_propagator(TraceContextPropagator::new());
52
53 let resource =
54 build_resource(otlp_config.service_name.clone(), otlp_config.service_version.as_deref());
55 let builder = SdkTracerProvider::builder()
56 .with_resource(resource)
57 .with_sampler(build_sampler(otlp_config.sample_ratio)?);
58
59 let tracer_provider = if otlp_config.endpoint.scheme() == "file" {
60 builder.with_batch_exporter(FileSpanExporter::new(&otlp_config.endpoint)?).build()
61 } else {
62 let span_builder = SpanExporter::builder();
63 let span_exporter = match otlp_config.protocol {
64 OtlpProtocol::Http => {
65 span_builder.with_http().with_endpoint(otlp_config.endpoint.as_str()).build()?
66 }
67 OtlpProtocol::Grpc => {
68 span_builder.with_tonic().with_endpoint(otlp_config.endpoint.as_str()).build()?
69 }
70 };
71 builder.with_batch_exporter(span_exporter).build()
72 };
73
74 let tracer = tracer_provider.tracer(otlp_config.service_name);
75 let layer = tracing_opentelemetry::layer()
76 .with_tracer(tracer)
77 .with_location(false)
78 .with_tracked_inactivity(false)
79 .with_target(false)
80 .with_threads(false);
81 Ok((layer, tracer_provider))
82}
83
84#[cfg(feature = "otlp-logs")]
88pub fn log_layer(
89 otlp_config: OtlpLogsConfig,
90) -> eyre::Result<
91 opentelemetry_appender_tracing::layer::OpenTelemetryTracingBridge<
92 opentelemetry_sdk::logs::SdkLoggerProvider,
93 opentelemetry_sdk::logs::SdkLogger,
94 >,
95> {
96 use opentelemetry_appender_tracing::layer::TracingSpanAttributes;
97 use opentelemetry_otlp::LogExporter;
98 use opentelemetry_sdk::logs::SdkLoggerProvider;
99
100 let resource =
101 build_resource(otlp_config.service_name.clone(), otlp_config.service_version.as_deref());
102
103 let log_builder = LogExporter::builder();
104
105 let log_exporter = match otlp_config.protocol {
106 OtlpProtocol::Http => {
107 log_builder.with_http().with_endpoint(otlp_config.endpoint.as_str()).build()?
108 }
109 OtlpProtocol::Grpc => {
110 log_builder.with_tonic().with_endpoint(otlp_config.endpoint.as_str()).build()?
111 }
112 };
113
114 let logger_provider = SdkLoggerProvider::builder()
115 .with_resource(resource)
116 .with_batch_exporter(log_exporter)
117 .build();
118
119 Ok(opentelemetry_appender_tracing::layer::OpenTelemetryTracingBridge::builder(&logger_provider)
120 .with_tracing_span_attributes(TracingSpanAttributes::all())
121 .build())
122}
123
124#[derive(Debug, Clone)]
126pub struct OtlpConfig {
127 service_name: String,
129 service_version: Option<String>,
131 endpoint: Url,
133 protocol: OtlpProtocol,
135 sample_ratio: Option<f64>,
137}
138
139impl OtlpConfig {
140 pub fn new(
142 service_name: impl Into<String>,
143 endpoint: Url,
144 protocol: OtlpProtocol,
145 sample_ratio: Option<f64>,
146 ) -> eyre::Result<Self> {
147 if let Some(ratio) = sample_ratio {
148 ensure!(
149 (0.0..=1.0).contains(&ratio),
150 "Sample ratio must be between 0.0 and 1.0, got: {}",
151 ratio
152 );
153 }
154
155 if endpoint.scheme() == "file" {
156 trace_file_path(&endpoint)?;
157 } else {
158 set_otlp_auth_header_from_endpoint(&endpoint, OTEL_EXPORTER_OTLP_TRACES_HEADERS)?;
159 }
160 Ok(Self {
161 service_name: service_name.into(),
162 service_version: None,
163 endpoint: endpoint_without_credentials(endpoint),
164 protocol,
165 sample_ratio,
166 })
167 }
168
169 pub fn with_service_version(mut self, version: impl Into<String>) -> Self {
171 self.service_version = Some(version.into());
172 self
173 }
174
175 pub fn service_name(&self) -> &str {
177 &self.service_name
178 }
179
180 pub const fn endpoint(&self) -> &Url {
182 &self.endpoint
183 }
184
185 pub const fn protocol(&self) -> OtlpProtocol {
187 self.protocol
188 }
189
190 pub const fn sample_ratio(&self) -> Option<f64> {
192 self.sample_ratio
193 }
194}
195
196#[derive(Debug, Clone)]
198pub struct OtlpLogsConfig {
199 service_name: String,
201 service_version: Option<String>,
203 endpoint: Url,
205 protocol: OtlpProtocol,
207}
208
209impl OtlpLogsConfig {
210 pub fn new(
212 service_name: impl Into<String>,
213 endpoint: Url,
214 protocol: OtlpProtocol,
215 ) -> eyre::Result<Self> {
216 ensure!(endpoint.scheme() != "file", "OTLP logs do not support file endpoints");
217 set_otlp_auth_header_from_endpoint(&endpoint, OTEL_EXPORTER_OTLP_LOGS_HEADERS)?;
218 Ok(Self {
219 service_name: service_name.into(),
220 service_version: None,
221 endpoint: endpoint_without_credentials(endpoint),
222 protocol,
223 })
224 }
225
226 pub fn with_service_version(mut self, version: impl Into<String>) -> Self {
228 self.service_version = Some(version.into());
229 self
230 }
231
232 pub fn service_name(&self) -> &str {
234 &self.service_name
235 }
236
237 pub const fn endpoint(&self) -> &Url {
239 &self.endpoint
240 }
241
242 pub const fn protocol(&self) -> OtlpProtocol {
244 self.protocol
245 }
246}
247
248fn build_resource(service_name: impl Into<Value>, service_version: Option<&str>) -> Resource {
250 let version = service_version.unwrap_or(env!("CARGO_PKG_VERSION"));
251 Resource::builder()
252 .with_service_name(service_name)
253 .with_schema_url([KeyValue::new(SERVICE_VERSION, version.to_string())], SCHEMA_URL)
254 .build()
255}
256
257fn endpoint_without_credentials(mut endpoint: Url) -> Url {
259 if !endpoint.username().is_empty() {
260 endpoint.set_username("").ok();
261 }
262 if endpoint.password().is_some() {
263 endpoint.set_password(None).ok();
264 }
265 endpoint
266}
267
268fn otlp_auth_header_from_endpoint(endpoint: &Url) -> eyre::Result<Option<String>> {
270 let username = endpoint.username();
271 if username.is_empty() && endpoint.password().is_none() {
272 return Ok(None);
273 }
274
275 let Some(password) = endpoint.password() else {
276 eyre::bail!("OTLP endpoint credentials must include both username and password");
277 };
278 if username.is_empty() {
279 eyre::bail!("OTLP endpoint credentials must include both username and password");
280 }
281
282 let credentials = format!("{username}:{password}");
283 let encoded = BASE64_STANDARD.encode(credentials.as_bytes());
284 Ok(Some(format!("Authorization=Basic {encoded}")))
285}
286
287fn set_otlp_auth_header_from_endpoint(endpoint: &Url, header_env: &str) -> eyre::Result<()> {
289 if std::env::var_os(header_env).is_some() {
290 return Ok(());
291 }
292
293 let Some(auth_header) = otlp_auth_header_from_endpoint(endpoint)? else {
294 return Ok(());
295 };
296
297 unsafe {
300 std::env::set_var(header_env, auth_header);
301 }
302
303 Ok(())
304}
305
306fn build_sampler(sample_ratio: Option<f64>) -> eyre::Result<Sampler> {
308 match sample_ratio {
309 None | Some(1.0) => Ok(Sampler::ParentBased(Box::new(Sampler::AlwaysOn))),
311 Some(0.0) => Ok(Sampler::ParentBased(Box::new(Sampler::AlwaysOff))),
313 Some(ratio) => Ok(Sampler::ParentBased(Box::new(Sampler::TraceIdRatioBased(ratio)))),
315 }
316}
317
318#[cfg(test)]
319mod tests {
320 use super::*;
321
322 #[test]
323 fn strips_credentials_from_trace_endpoint_and_preserves_auth_header() {
324 let config = OtlpConfig::new(
325 "reth",
326 "https://user:pass@example.com/v1/traces".parse().unwrap(),
327 OtlpProtocol::Http,
328 None,
329 )
330 .unwrap();
331
332 assert_eq!(config.endpoint.as_str(), "https://example.com/v1/traces");
333 assert_eq!(
334 otlp_auth_header_from_endpoint(
335 &"https://user:pass@example.com/v1/traces".parse().unwrap()
336 )
337 .unwrap()
338 .as_deref(),
339 Some("Authorization=Basic dXNlcjpwYXNz")
340 );
341 }
342
343 #[test]
344 fn strips_credentials_from_logs_endpoint_and_preserves_auth_header() {
345 let config = OtlpLogsConfig::new(
346 "reth",
347 "https://logs:secret@example.com/v1/logs".parse().unwrap(),
348 OtlpProtocol::Http,
349 )
350 .unwrap();
351
352 assert_eq!(config.endpoint.as_str(), "https://example.com/v1/logs");
353 assert_eq!(
354 otlp_auth_header_from_endpoint(
355 &"https://logs:secret@example.com/v1/logs".parse().unwrap()
356 )
357 .unwrap()
358 .as_deref(),
359 Some("Authorization=Basic bG9nczpzZWNyZXQ=")
360 );
361 }
362
363 #[test]
364 fn leaves_endpoint_without_credentials_unchanged() {
365 let config = OtlpConfig::new(
366 "reth",
367 "https://example.com/v1/traces".parse().unwrap(),
368 OtlpProtocol::Http,
369 None,
370 )
371 .unwrap();
372
373 assert_eq!(config.endpoint.as_str(), "https://example.com/v1/traces");
374 assert_eq!(otlp_auth_header_from_endpoint(config.endpoint()).unwrap(), None);
375 }
376
377 #[test]
378 fn rejects_partial_credentials() {
379 assert!(OtlpConfig::new(
380 "reth",
381 "https://user@example.com/v1/traces".parse().unwrap(),
382 OtlpProtocol::Http,
383 None,
384 )
385 .is_err());
386 }
387}