1#![cfg(feature = "otlp")]
2
3use clap::ValueEnum;
12use eyre::ensure;
13use opentelemetry::{global, trace::TracerProvider, KeyValue, Value};
14use opentelemetry_otlp::{SpanExporter, WithExportConfig};
15use opentelemetry_sdk::{
16 propagation::TraceContextPropagator,
17 trace::{Sampler, SdkTracer, SdkTracerProvider},
18 Resource,
19};
20use opentelemetry_semantic_conventions::{attribute::SERVICE_VERSION, SCHEMA_URL};
21use tracing::Subscriber;
22use tracing_opentelemetry::OpenTelemetryLayer;
23use tracing_subscriber::registry::LookupSpan;
24use url::Url;
25
26use base64::{prelude::BASE64_STANDARD, Engine};
27
28const HTTP_TRACE_ENDPOINT: &str = "/v1/traces";
31const HTTP_LOGS_ENDPOINT: &str = "/v1/logs";
32const OTEL_EXPORTER_OTLP_TRACES_HEADERS: &str = "OTEL_EXPORTER_OTLP_TRACES_HEADERS";
33const OTEL_EXPORTER_OTLP_LOGS_HEADERS: &str = "OTEL_EXPORTER_OTLP_LOGS_HEADERS";
34
35pub fn span_layer<S>(otlp_config: OtlpConfig) -> eyre::Result<OpenTelemetryLayer<S, SdkTracer>>
40where
41 for<'span> S: Subscriber + LookupSpan<'span>,
42{
43 global::set_text_map_propagator(TraceContextPropagator::new());
44
45 let resource =
46 build_resource(otlp_config.service_name.clone(), otlp_config.service_version.as_deref());
47
48 let span_builder = SpanExporter::builder();
49
50 let span_exporter = match otlp_config.protocol {
51 OtlpProtocol::Http => {
52 span_builder.with_http().with_endpoint(otlp_config.endpoint.as_str()).build()?
53 }
54 OtlpProtocol::Grpc => {
55 span_builder.with_tonic().with_endpoint(otlp_config.endpoint.as_str()).build()?
56 }
57 };
58
59 let sampler = build_sampler(otlp_config.sample_ratio)?;
60
61 let tracer_provider = SdkTracerProvider::builder()
62 .with_resource(resource)
63 .with_sampler(sampler)
64 .with_batch_exporter(span_exporter)
65 .build();
66
67 global::set_tracer_provider(tracer_provider.clone());
68
69 let tracer = tracer_provider.tracer(otlp_config.service_name);
70 Ok(tracing_opentelemetry::layer()
71 .with_tracer(tracer)
72 .with_location(false)
73 .with_tracked_inactivity(false)
74 .with_target(false)
75 .with_threads(false))
76}
77
78#[cfg(feature = "otlp-logs")]
82pub fn log_layer(
83 otlp_config: OtlpLogsConfig,
84) -> eyre::Result<
85 opentelemetry_appender_tracing::layer::OpenTelemetryTracingBridge<
86 opentelemetry_sdk::logs::SdkLoggerProvider,
87 opentelemetry_sdk::logs::SdkLogger,
88 >,
89> {
90 use opentelemetry_appender_tracing::layer::TracingSpanAttributes;
91 use opentelemetry_otlp::LogExporter;
92 use opentelemetry_sdk::logs::SdkLoggerProvider;
93
94 let resource =
95 build_resource(otlp_config.service_name.clone(), otlp_config.service_version.as_deref());
96
97 let log_builder = LogExporter::builder();
98
99 let log_exporter = match otlp_config.protocol {
100 OtlpProtocol::Http => {
101 log_builder.with_http().with_endpoint(otlp_config.endpoint.as_str()).build()?
102 }
103 OtlpProtocol::Grpc => {
104 log_builder.with_tonic().with_endpoint(otlp_config.endpoint.as_str()).build()?
105 }
106 };
107
108 let logger_provider = SdkLoggerProvider::builder()
109 .with_resource(resource)
110 .with_batch_exporter(log_exporter)
111 .build();
112
113 Ok(opentelemetry_appender_tracing::layer::OpenTelemetryTracingBridge::builder(&logger_provider)
114 .with_tracing_span_attributes(TracingSpanAttributes::all())
115 .build())
116}
117
118#[derive(Debug, Clone)]
120pub struct OtlpConfig {
121 service_name: String,
123 service_version: Option<String>,
125 endpoint: Url,
127 protocol: OtlpProtocol,
129 sample_ratio: Option<f64>,
131}
132
133impl OtlpConfig {
134 pub fn new(
136 service_name: impl Into<String>,
137 endpoint: Url,
138 protocol: OtlpProtocol,
139 sample_ratio: Option<f64>,
140 ) -> eyre::Result<Self> {
141 if let Some(ratio) = sample_ratio {
142 ensure!(
143 (0.0..=1.0).contains(&ratio),
144 "Sample ratio must be between 0.0 and 1.0, got: {}",
145 ratio
146 );
147 }
148
149 set_otlp_auth_header_from_endpoint(&endpoint, OTEL_EXPORTER_OTLP_TRACES_HEADERS)?;
150 Ok(Self {
151 service_name: service_name.into(),
152 service_version: None,
153 endpoint: endpoint_without_credentials(endpoint),
154 protocol,
155 sample_ratio,
156 })
157 }
158
159 pub fn with_service_version(mut self, version: impl Into<String>) -> Self {
161 self.service_version = Some(version.into());
162 self
163 }
164
165 pub fn service_name(&self) -> &str {
167 &self.service_name
168 }
169
170 pub const fn endpoint(&self) -> &Url {
172 &self.endpoint
173 }
174
175 pub const fn protocol(&self) -> OtlpProtocol {
177 self.protocol
178 }
179
180 pub const fn sample_ratio(&self) -> Option<f64> {
182 self.sample_ratio
183 }
184}
185
186#[derive(Debug, Clone)]
188pub struct OtlpLogsConfig {
189 service_name: String,
191 service_version: Option<String>,
193 endpoint: Url,
195 protocol: OtlpProtocol,
197}
198
199impl OtlpLogsConfig {
200 pub fn new(
202 service_name: impl Into<String>,
203 endpoint: Url,
204 protocol: OtlpProtocol,
205 ) -> eyre::Result<Self> {
206 set_otlp_auth_header_from_endpoint(&endpoint, OTEL_EXPORTER_OTLP_LOGS_HEADERS)?;
207 Ok(Self {
208 service_name: service_name.into(),
209 service_version: None,
210 endpoint: endpoint_without_credentials(endpoint),
211 protocol,
212 })
213 }
214
215 pub fn with_service_version(mut self, version: impl Into<String>) -> Self {
217 self.service_version = Some(version.into());
218 self
219 }
220
221 pub fn service_name(&self) -> &str {
223 &self.service_name
224 }
225
226 pub const fn endpoint(&self) -> &Url {
228 &self.endpoint
229 }
230
231 pub const fn protocol(&self) -> OtlpProtocol {
233 self.protocol
234 }
235}
236
237fn build_resource(service_name: impl Into<Value>, service_version: Option<&str>) -> Resource {
239 let version = service_version.unwrap_or(env!("CARGO_PKG_VERSION"));
240 Resource::builder()
241 .with_service_name(service_name)
242 .with_schema_url([KeyValue::new(SERVICE_VERSION, version.to_string())], SCHEMA_URL)
243 .build()
244}
245
246fn endpoint_without_credentials(mut endpoint: Url) -> Url {
248 if !endpoint.username().is_empty() {
249 endpoint.set_username("").ok();
250 }
251 if endpoint.password().is_some() {
252 endpoint.set_password(None).ok();
253 }
254 endpoint
255}
256
257fn otlp_auth_header_from_endpoint(endpoint: &Url) -> eyre::Result<Option<String>> {
259 let username = endpoint.username();
260 if username.is_empty() && endpoint.password().is_none() {
261 return Ok(None);
262 }
263
264 let Some(password) = endpoint.password() else {
265 eyre::bail!("OTLP endpoint credentials must include both username and password");
266 };
267 if username.is_empty() {
268 eyre::bail!("OTLP endpoint credentials must include both username and password");
269 }
270
271 let credentials = format!("{username}:{password}");
272 let encoded = BASE64_STANDARD.encode(credentials.as_bytes());
273 Ok(Some(format!("Authorization=Basic {encoded}")))
274}
275
276fn set_otlp_auth_header_from_endpoint(endpoint: &Url, header_env: &str) -> eyre::Result<()> {
278 if std::env::var_os(header_env).is_some() {
279 return Ok(());
280 }
281
282 let Some(auth_header) = otlp_auth_header_from_endpoint(endpoint)? else {
283 return Ok(());
284 };
285
286 unsafe {
289 std::env::set_var(header_env, auth_header);
290 }
291
292 Ok(())
293}
294
295fn build_sampler(sample_ratio: Option<f64>) -> eyre::Result<Sampler> {
297 match sample_ratio {
298 None | Some(1.0) => Ok(Sampler::ParentBased(Box::new(Sampler::AlwaysOn))),
300 Some(0.0) => Ok(Sampler::ParentBased(Box::new(Sampler::AlwaysOff))),
302 Some(ratio) => Ok(Sampler::ParentBased(Box::new(Sampler::TraceIdRatioBased(ratio)))),
304 }
305}
306
307#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
309pub enum OtlpProtocol {
310 Http,
312 Grpc,
314}
315
316impl OtlpProtocol {
317 pub fn validate_endpoint(&self, url: &mut Url) -> eyre::Result<()> {
322 self.validate_endpoint_with_path(url, HTTP_TRACE_ENDPOINT)
323 }
324
325 pub fn validate_logs_endpoint(&self, url: &mut Url) -> eyre::Result<()> {
330 self.validate_endpoint_with_path(url, HTTP_LOGS_ENDPOINT)
331 }
332
333 fn validate_endpoint_with_path(&self, url: &mut Url, http_path: &str) -> eyre::Result<()> {
334 match self {
335 Self::Http => {
336 if !url.path().ends_with(http_path) {
337 let path = url.path().trim_end_matches('/');
338 url.set_path(&format!("{}{}", path, http_path));
339 }
340 }
341 Self::Grpc => {
342 ensure!(
343 !url.path().ends_with(http_path),
344 "OTLP gRPC endpoint should not include {} path, got: {}",
345 http_path,
346 url
347 );
348 }
349 }
350 Ok(())
351 }
352}
353
354#[cfg(test)]
355mod tests {
356 use super::*;
357
358 #[test]
359 fn strips_credentials_from_trace_endpoint_and_preserves_auth_header() {
360 let config = OtlpConfig::new(
361 "reth",
362 "https://user:pass@example.com/v1/traces".parse().unwrap(),
363 OtlpProtocol::Http,
364 None,
365 )
366 .unwrap();
367
368 assert_eq!(config.endpoint.as_str(), "https://example.com/v1/traces");
369 assert_eq!(
370 otlp_auth_header_from_endpoint(
371 &"https://user:pass@example.com/v1/traces".parse().unwrap()
372 )
373 .unwrap()
374 .as_deref(),
375 Some("Authorization=Basic dXNlcjpwYXNz")
376 );
377 }
378
379 #[test]
380 fn strips_credentials_from_logs_endpoint_and_preserves_auth_header() {
381 let config = OtlpLogsConfig::new(
382 "reth",
383 "https://logs:secret@example.com/v1/logs".parse().unwrap(),
384 OtlpProtocol::Http,
385 )
386 .unwrap();
387
388 assert_eq!(config.endpoint.as_str(), "https://example.com/v1/logs");
389 assert_eq!(
390 otlp_auth_header_from_endpoint(
391 &"https://logs:secret@example.com/v1/logs".parse().unwrap()
392 )
393 .unwrap()
394 .as_deref(),
395 Some("Authorization=Basic bG9nczpzZWNyZXQ=")
396 );
397 }
398
399 #[test]
400 fn leaves_endpoint_without_credentials_unchanged() {
401 let config = OtlpConfig::new(
402 "reth",
403 "https://example.com/v1/traces".parse().unwrap(),
404 OtlpProtocol::Http,
405 None,
406 )
407 .unwrap();
408
409 assert_eq!(config.endpoint.as_str(), "https://example.com/v1/traces");
410 assert_eq!(otlp_auth_header_from_endpoint(config.endpoint()).unwrap(), None);
411 }
412
413 #[test]
414 fn rejects_partial_credentials() {
415 assert!(OtlpConfig::new(
416 "reth",
417 "https://user@example.com/v1/traces".parse().unwrap(),
418 OtlpProtocol::Http,
419 None,
420 )
421 .is_err());
422 }
423}