Skip to main content

o_sfu_telemetry/
setup.rs

1use std::fmt;
2
3use anyhow::Result;
4use serde::{
5    Serialize, Serializer,
6    ser::{Error as _, SerializeSeq},
7};
8use serde_json::value::RawValue;
9use time::format_description::well_known::Rfc3339;
10use tracing::{Span, Subscriber, field};
11use tracing_serde::fields::AsMap;
12use tracing_subscriber::{
13    EnvFilter, Registry,
14    fmt::{
15        FmtContext, FormattedFields,
16        format::{FormatEvent, JsonFields, Writer},
17        layer as fmt_layer,
18    },
19    layer::SubscriberExt,
20    registry::{LookupSpan, Scope},
21    util::SubscriberInitExt,
22};
23#[cfg(feature = "otel-tracing")]
24use {
25    opentelemetry::{
26        KeyValue, global,
27        trace::{TraceContextExt, TracerProvider as _},
28    },
29    opentelemetry_otlp::{Protocol, WithExportConfig},
30    opentelemetry_sdk::{
31        Resource,
32        trace::{RandomIdGenerator, Sampler, SdkTracerProvider},
33    },
34    std::sync::{Arc, OnceLock},
35    tracing::dispatcher::WeakDispatch,
36    tracing_opentelemetry::{OpenTelemetrySpanExt, get_otel_context},
37    tracing_subscriber::Layer,
38};
39
40use crate::{TelemetryConfig, TelemetryLogFormat, schema};
41
42const DEFAULT_ENV_FILTER: &str = "o_sfu=info,o_sfu_core=info,o_sfu_router=info";
43#[cfg(feature = "otel-tracing")]
44const PRODUCTION_ENVIRONMENT_NAME: &str = "production";
45#[cfg(feature = "otel-tracing")]
46const PRODUCTION_TRACE_SAMPLE_RATIO: f64 = 0.05;
47#[cfg(feature = "otel-tracing")]
48const TRACE_EXPORTER_NAME: &str = "o-sfu.runtime";
49
50#[derive(Debug, Default)]
51pub struct TelemetryHandle {
52    #[cfg(feature = "otel-tracing")]
53    tracer_provider: Option<SdkTracerProvider>,
54}
55
56#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
57struct TelemetryResourceFields {
58    #[serde(rename = "service.name")]
59    service_name: String,
60    #[serde(rename = "service.version")]
61    service_version: String,
62    #[serde(rename = "service.instance.id")]
63    service_instance_id: String,
64    #[serde(rename = "deployment.environment")]
65    deployment_environment: String,
66}
67
68#[derive(Debug, Clone)]
69struct RuntimeJsonFormatter {
70    resource: TelemetryResourceFields,
71    #[cfg(feature = "otel-tracing")]
72    dispatch: Arc<OnceLock<WeakDispatch>>,
73}
74
75#[derive(Serialize)]
76struct JsonEvent<'a, E, S> {
77    timestamp: String,
78    level: &'static str,
79    target: &'static str,
80    #[serde(flatten)]
81    resource: &'a TelemetryResourceFields,
82    #[serde(skip_serializing_if = "Option::is_none")]
83    trace_id: Option<String>,
84    fields: E,
85    spans: S,
86}
87
88#[derive(Serialize)]
89struct JsonSpan<'a> {
90    name: &'static str,
91    fields: &'a RawValue,
92}
93
94struct JsonSpans<'a, 'context, S>(&'a FmtContext<'context, S, JsonFields>);
95
96#[cfg(feature = "otel-tracing")]
97impl Drop for TelemetryHandle {
98    fn drop(&mut self) {
99        if let Some(tracer_provider) = self.tracer_provider.take()
100            && let Err(_error) = tracer_provider.shutdown()
101        {
102            // Drop cannot surface shutdown failures to a caller, and logging here would
103            // recurse through the subscriber that is being torn down.
104        }
105    }
106}
107
108impl RuntimeJsonFormatter {
109    fn new(resource: TelemetryResourceFields) -> Self {
110        Self {
111            resource,
112            #[cfg(feature = "otel-tracing")]
113            dispatch: Arc::default(),
114        }
115    }
116
117    #[cfg(feature = "otel-tracing")]
118    fn trace_id<S>(&self, ctx: &FmtContext<'_, S, JsonFields>) -> Option<String>
119    where
120        S: Subscriber + for<'lookup> LookupSpan<'lookup>,
121    {
122        let dispatch = self.dispatch.get()?.upgrade()?;
123        let parent = ctx.parent_span()?;
124        let context = get_otel_context(&parent.id(), &dispatch)?;
125        let span = context.span();
126        let span_context = span.span_context();
127        span_context
128            .is_valid()
129            .then(|| span_context.trace_id().to_string())
130    }
131}
132
133#[cfg(feature = "otel-tracing")]
134impl<S: Subscriber> Layer<S> for RuntimeJsonFormatter {
135    fn on_register_dispatch(&self, dispatch: &tracing::Dispatch) {
136        // Nested get_default calls hide the subscriber during event dispatch.
137        // The formatter clone in this layer shares a weak reference with the fmt layer.
138        let _ = self.dispatch.set(dispatch.downgrade());
139    }
140}
141
142impl<S> FormatEvent<S, JsonFields> for RuntimeJsonFormatter
143where
144    S: Subscriber + for<'lookup> LookupSpan<'lookup>,
145{
146    fn format_event(
147        &self,
148        ctx: &FmtContext<'_, S, JsonFields>,
149        mut writer: Writer<'_>,
150        event: &tracing::Event<'_>,
151    ) -> fmt::Result {
152        let payload = JsonEvent {
153            timestamp: time::OffsetDateTime::now_utc()
154                .format(&Rfc3339)
155                .map_err(|_error| fmt::Error)?,
156            level: event.metadata().level().as_str(),
157            target: event.metadata().target(),
158            resource: &self.resource,
159            // Only OTel trace lookup needs formatter state.
160            #[cfg(feature = "otel-tracing")]
161            trace_id: self.trace_id(ctx),
162            #[cfg(not(feature = "otel-tracing"))]
163            trace_id: None,
164            fields: event.field_map(),
165            spans: JsonSpans(ctx),
166        };
167        let encoded = serde_json::to_string(&payload).map_err(|_error| fmt::Error)?;
168        writeln!(writer, "{encoded}")
169    }
170}
171
172impl<S> Serialize for JsonSpans<'_, '_, S>
173where
174    S: Subscriber + for<'lookup> LookupSpan<'lookup>,
175{
176    fn serialize<T: Serializer>(&self, serializer: T) -> Result<T::Ok, T::Error> {
177        let mut sequence = serializer.serialize_seq(None)?;
178        // Explicit-parent events can belong to a different scope than the entered span.
179        for span in self.0.event_scope().into_iter().flat_map(Scope::from_root) {
180            let extensions = span.extensions();
181            if let Some(fields) = extensions.get::<FormattedFields<JsonFields>>() {
182                let fields = serde_json::from_str::<&RawValue>(fields.fields.as_str())
183                    .map_err(T::Error::custom)?;
184                sequence.serialize_element(&JsonSpan {
185                    name: span.name(),
186                    fields,
187                })?;
188            }
189        }
190        sequence.end()
191    }
192}
193
194/// Installs the configured tracing subscriber and retains its exporter until
195/// the returned handle is dropped.
196///
197/// # Errors
198///
199/// Returns an [`anyhow::Error`] when subscriber initialization fails or when the
200/// `otel-tracing` feature is enabled and OTLP exporter construction fails.
201pub fn init_tracing(config: &TelemetryConfig, process_id: u32) -> Result<TelemetryHandle> {
202    let env_filter = default_env_filter();
203    let resource = telemetry_resource_fields(config, process_id);
204    #[cfg(feature = "otel-tracing")]
205    let tracer_provider = build_tracer_provider(config, &resource)?;
206    #[cfg(feature = "otel-tracing")]
207    let tracer = tracer_provider
208        .as_ref()
209        .map(|provider| provider.tracer(TRACE_EXPORTER_NAME));
210    match config.log_format {
211        TelemetryLogFormat::Compact => {
212            let subscriber = Registry::default()
213                .with(env_filter)
214                .with(fmt_layer().with_target(false).compact());
215            #[cfg(feature = "otel-tracing")]
216            let subscriber = subscriber.with(
217                tracer
218                    .as_ref()
219                    .map(|tracer| tracing_opentelemetry::layer().with_tracer(tracer.clone())),
220            );
221            subscriber.try_init()?;
222        }
223        TelemetryLogFormat::Json => {
224            let formatter = RuntimeJsonFormatter::new(resource.clone());
225            let subscriber = Registry::default().with(env_filter);
226            #[cfg(feature = "otel-tracing")]
227            let subscriber = subscriber.with(formatter.clone());
228            let subscriber = subscriber.with(
229                fmt_layer()
230                    .fmt_fields(JsonFields::new())
231                    .event_format(formatter)
232                    .with_ansi(false),
233            );
234            #[cfg(feature = "otel-tracing")]
235            let subscriber = subscriber.with(
236                tracer
237                    .as_ref()
238                    .map(|tracer| tracing_opentelemetry::layer().with_tracer(tracer.clone())),
239            );
240            subscriber.try_init()?;
241        }
242    }
243    #[cfg(feature = "otel-tracing")]
244    let trace_export_otlp_endpoint = config
245        .trace_export
246        .otlp_endpoint
247        .as_deref()
248        .unwrap_or("disabled");
249    #[cfg(not(feature = "otel-tracing"))]
250    let trace_export_otlp_endpoint = "feature_disabled";
251    tracing::info!(
252        event = schema::event::RUNTIME_TELEMETRY_INITIALIZED,
253        service_name = resource.service_name.as_str(),
254        service_version = resource.service_version.as_str(),
255        deployment_environment = resource.deployment_environment.as_str(),
256        service_instance_id = resource.service_instance_id.as_str(),
257        log_format = config.log_format.as_str(),
258        common_fields = ?schema::COMMON_FIELD_NAMES,
259        correlation_fields = ?schema::CORRELATION_FIELD_NAMES,
260        trace_export_otlp_endpoint,
261        "initialized runtime telemetry"
262    );
263    Ok(TelemetryHandle {
264        #[cfg(feature = "otel-tracing")]
265        tracer_provider,
266    })
267}
268
269#[must_use]
270pub fn http_request_span(route: &'static str) -> Span {
271    activated_span(tracing::info_span!(
272        "http.request",
273        "otel.kind" = "server",
274        route,
275        room_id = field::Empty,
276        user_id = field::Empty,
277        connection_id = field::Empty,
278        remote_address = field::Empty
279    ))
280}
281
282#[must_use]
283pub fn ws_upgrade_span() -> Span {
284    activated_span(tracing::info_span!(
285        "ws.upgrade",
286        room_id = field::Empty,
287        user_id = field::Empty,
288        connection_id = field::Empty,
289        remote_address = field::Empty
290    ))
291}
292
293#[must_use]
294pub fn ws_handshake_span() -> Span {
295    activated_span(tracing::info_span!(
296        "ws.handshake",
297        room_id = field::Empty,
298        user_id = field::Empty,
299        connection_id = field::Empty,
300        remote_address = field::Empty
301    ))
302}
303
304#[cfg(feature = "otel-tracing")]
305#[must_use]
306pub fn activated_span(span: Span) -> Span {
307    let _span_context = span.context();
308    span
309}
310
311#[cfg(not(feature = "otel-tracing"))]
312#[must_use]
313pub fn activated_span(span: Span) -> Span {
314    span
315}
316
317fn default_env_filter() -> EnvFilter {
318    EnvFilter::try_from_default_env().unwrap_or_else(|_error| EnvFilter::new(DEFAULT_ENV_FILTER))
319}
320
321fn telemetry_resource_fields(config: &TelemetryConfig, process_id: u32) -> TelemetryResourceFields {
322    TelemetryResourceFields {
323        service_name: config.resource.service_name.clone(),
324        service_version: env!("CARGO_PKG_VERSION").to_owned(),
325        service_instance_id: config.resource.resolved_instance_id(process_id),
326        deployment_environment: config.resource.deployment_environment.clone(),
327    }
328}
329
330#[cfg(feature = "otel-tracing")]
331fn build_tracer_provider(
332    config: &TelemetryConfig,
333    resource: &TelemetryResourceFields,
334) -> Result<Option<SdkTracerProvider>> {
335    let Some(endpoint) = config.trace_export.otlp_endpoint.as_deref() else {
336        return Ok(None);
337    };
338    let exporter = opentelemetry_otlp::SpanExporter::builder()
339        .with_http()
340        .with_protocol(Protocol::HttpBinary)
341        .with_endpoint(normalize_trace_export_endpoint(endpoint))
342        .build()?;
343    let tracer_provider = SdkTracerProvider::builder()
344        .with_batch_exporter(exporter)
345        .with_sampler(default_trace_sampler(
346            resource.deployment_environment.as_str(),
347        ))
348        .with_id_generator(RandomIdGenerator::default())
349        .with_resource(
350            Resource::builder_empty()
351                .with_attributes([
352                    KeyValue::new(schema::field::SERVICE_NAME, resource.service_name.clone()),
353                    KeyValue::new(
354                        schema::field::SERVICE_VERSION,
355                        resource.service_version.clone(),
356                    ),
357                    KeyValue::new(
358                        schema::field::SERVICE_INSTANCE_ID,
359                        resource.service_instance_id.clone(),
360                    ),
361                    KeyValue::new(
362                        schema::field::DEPLOYMENT_ENVIRONMENT,
363                        resource.deployment_environment.clone(),
364                    ),
365                ])
366                .build(),
367        )
368        .build();
369    global::set_tracer_provider(tracer_provider.clone());
370    Ok(Some(tracer_provider))
371}
372
373#[cfg(feature = "otel-tracing")]
374fn default_trace_sampler(deployment_environment: &str) -> Sampler {
375    if deployment_environment == PRODUCTION_ENVIRONMENT_NAME {
376        Sampler::ParentBased(Box::new(Sampler::TraceIdRatioBased(
377            PRODUCTION_TRACE_SAMPLE_RATIO,
378        )))
379    } else {
380        Sampler::AlwaysOn
381    }
382}
383
384#[cfg(feature = "otel-tracing")]
385fn normalize_trace_export_endpoint(endpoint: &str) -> String {
386    if endpoint.ends_with("/v1/traces") {
387        endpoint.to_owned()
388    } else {
389        format!("{}/v1/traces", endpoint.trim_end_matches('/'))
390    }
391}
392
393#[cfg(test)]
394#[path = "TESTS/setup.rs"]
395mod tests;