Skip to main content

dynamo_runtime/
telemetry.rs

1// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Native OpenTelemetry request-lifecycle spans.
5//!
6//! Coarse runtime timing, causal parentage, request identity, and terminal outcomes.
7//! Engine-internal metrics and invariants are intentionally not instrumented here.
8
9use std::sync::{
10    Arc, OnceLock,
11    atomic::{AtomicBool, Ordering},
12};
13
14use std::time::{SystemTime, UNIX_EPOCH};
15use tracing::Span;
16
17use crate::config::environment_names::lifecycle_tracing::{
18    DYN_LIFECYCLE_TRACE_ENABLED, DYN_LIFECYCLE_TRACE_MODE,
19};
20
21/// Static tracing target used exclusively by lifecycle spans.
22pub const LIFECYCLE_TARGET: &str = "dynamo.request_lifecycle";
23
24/// Context-registry key used to preserve lifecycle identity through frontend stages.
25pub const LIFECYCLE_TRACE_CONTEXT_KEY: &str = "dynamo.request_lifecycle.trace";
26
27/// Internal wire metadata marking requests with a frontend lifecycle root.
28/// Absent on legacy or uninstrumented frontends; those requests keep ordinary tracing.
29pub const LIFECYCLE_ROOT_METADATA_KEY: &str = "dynamo.lifecycle.root";
30
31const LIFECYCLE_SCHEMA: &str = "v1";
32const DEFAULT_PROFILE: &str = "generic.v1";
33const DEFAULT_MODE: &str = "core";
34
35static PROCESS_EPOCH: OnceLock<String> = OnceLock::new();
36static INSTANCE_ID: OnceLock<String> = OnceLock::new();
37static LIFECYCLE_ENABLED: OnceLock<bool> = OnceLock::new();
38static LIFECYCLE_MODE: OnceLock<&'static str> = OnceLock::new();
39
40/// Operation owner used to distinguish the frontend and worker stages.
41#[derive(Clone, Copy, Debug, Eq, PartialEq)]
42pub enum LifecycleOperationRole {
43    Frontend,
44    Encode,
45    Prefill,
46    Decode,
47    Worker,
48}
49
50impl LifecycleOperationRole {
51    const fn as_str(self) -> &'static str {
52        match self {
53            Self::Frontend => "frontend",
54            Self::Encode => "encode",
55            Self::Prefill => "prefill",
56            Self::Decode => "decode",
57            Self::Worker => "worker",
58        }
59    }
60}
61
62/// One-shot outcome recorded on the request root.
63#[derive(Clone, Copy, Debug, Eq, PartialEq)]
64pub enum TerminalOutcome {
65    Success,
66    Rejected,
67    Cancelled,
68    TimedOut,
69    Failed,
70    Unknown,
71}
72
73impl TerminalOutcome {
74    const fn as_str(self) -> &'static str {
75        match self {
76            Self::Success => "success",
77            Self::Rejected => "rejected",
78            Self::Cancelled => "cancelled",
79            Self::TimedOut => "timed_out",
80            Self::Failed => "failed",
81            Self::Unknown => "unknown",
82        }
83    }
84
85    const fn is_error(self) -> bool {
86        !matches!(self, Self::Success)
87    }
88}
89
90#[derive(Clone, Debug, Eq, PartialEq)]
91struct LifecycleIdentity {
92    request_id: String,
93    /// Identifies one lifecycle wave within a request, which may span retries
94    /// or prefill/decode operations.
95    operation_id: String,
96    role: LifecycleOperationRole,
97    profile: &'static str,
98    mode: &'static str,
99    identity_state: &'static str,
100}
101
102impl LifecycleIdentity {
103    fn new(request_id: Option<String>, role: LifecycleOperationRole) -> Self {
104        let (request_id, identity_state) = match request_id.filter(|id| !id.is_empty()) {
105            Some(id) => (id, "complete"),
106            None => ("unknown".to_string(), "missing_request_id"),
107        };
108        Self {
109            request_id,
110            operation_id: uuid::Uuid::new_v4().to_string(),
111            role,
112            profile: DEFAULT_PROFILE,
113            mode: lifecycle_mode(),
114            identity_state,
115        }
116    }
117}
118
119/// A duration-bearing boundary in the request-lifecycle convention.
120///
121/// `WorkerOperationPrefill` and `WorkerOperationDecode` are coarse Dynamo
122/// runtime boundaries around the worker operation; they are not direct engine
123/// execution measurements.
124///
125/// Router queue and selection stages are intentionally deferred. A later
126/// instrumentation milestone will add spans at the actual scheduling
127/// boundaries together with the router-specific metrics and invariants needed
128/// to interpret them.
129#[derive(Clone, Copy, Debug, Eq, PartialEq)]
130pub enum LifecycleStage {
131    RequestLifecycle,
132    RequestPreprocessing,
133    WorkerAdmission,
134    RequestDispatch,
135    WorkerOperation,
136    WorkerOperationEncode,
137    WorkerOperationPrefill,
138    WorkerOperationDecode,
139    ResponseStreaming,
140    ResponseStreamingEncode,
141    ResponseStreamingPrefill,
142    ResponseStreamingDecode,
143    ResponseStreamingWorker,
144}
145
146impl LifecycleStage {
147    const fn component(self) -> &'static str {
148        match self {
149            Self::RequestLifecycle | Self::RequestPreprocessing | Self::ResponseStreaming => {
150                "frontend"
151            }
152            Self::WorkerAdmission
153            | Self::RequestDispatch
154            | Self::WorkerOperation
155            | Self::WorkerOperationEncode
156            | Self::WorkerOperationPrefill
157            | Self::WorkerOperationDecode
158            | Self::ResponseStreamingPrefill
159            | Self::ResponseStreamingEncode
160            | Self::ResponseStreamingDecode
161            | Self::ResponseStreamingWorker => "worker",
162        }
163    }
164
165    fn span(self, identity: &LifecycleIdentity) -> Span {
166        macro_rules! common_span {
167            ($name:literal) => {
168                tracing::info_span!(
169                    target: LIFECYCLE_TARGET, $name,
170                    "dynamo.request.id" = %identity.request_id,
171                    "dynamo.operation.id" = %identity.operation_id,
172                    "dynamo.operation.role" = identity.role.as_str(),
173                    "dynamo.lifecycle.schema" = LIFECYCLE_SCHEMA,
174                    "dynamo.lifecycle.profile" = %identity.profile,
175                    "dynamo.lifecycle.mode" = %identity.mode,
176                    "dynamo.component" = self.component(),
177                    "dynamo.instance.id" = instance_id(),
178                    "dynamo.process.epoch" = process_epoch(),
179                    "dynamo.lifecycle.identity.state" = identity.identity_state,
180                )
181            };
182        }
183        // Each branch is a static callsite, allowing inexpensive target filtering.
184        match self {
185            Self::RequestLifecycle => tracing::info_span!(
186                target: LIFECYCLE_TARGET, "request.lifecycle",
187                "dynamo.request.id" = %identity.request_id,
188                "dynamo.operation.id" = %identity.operation_id,
189                "dynamo.operation.role" = identity.role.as_str(),
190                "dynamo.lifecycle.schema" = LIFECYCLE_SCHEMA,
191                "dynamo.lifecycle.profile" = %identity.profile,
192                "dynamo.lifecycle.mode" = %identity.mode,
193                "dynamo.component" = self.component(),
194                "dynamo.instance.id" = instance_id(),
195                "dynamo.process.epoch" = process_epoch(),
196                "dynamo.lifecycle.identity.state" = identity.identity_state,
197                "dynamo.session.id" = tracing::field::Empty,
198                "dynamo.session.source" = tracing::field::Empty,
199                "dynamo.request.terminal.outcome" = tracing::field::Empty,
200                "dynamo.request.terminal.error" = tracing::field::Empty,
201                "dynamo.request.terminal.timestamp_unix_ns" = tracing::field::Empty,
202            ),
203            Self::RequestPreprocessing => common_span!("request.preprocessing"),
204            Self::WorkerAdmission => common_span!("worker.admission"),
205            Self::RequestDispatch => common_span!("request.dispatch"),
206            Self::WorkerOperation => common_span!("worker.operation"),
207            Self::WorkerOperationEncode => common_span!("worker.operation.encode"),
208            Self::WorkerOperationPrefill => common_span!("worker.operation.prefill"),
209            Self::WorkerOperationDecode => common_span!("worker.operation.decode"),
210            Self::ResponseStreaming => common_span!("response.streaming"),
211            Self::ResponseStreamingEncode => common_span!("response.streaming.encode"),
212            Self::ResponseStreamingPrefill => common_span!("response.streaming.prefill"),
213            Self::ResponseStreamingDecode => common_span!("response.streaming.decode"),
214            Self::ResponseStreamingWorker => common_span!("response.streaming.worker"),
215        }
216    }
217}
218
219/// Request-scoped lifecycle capture state.
220///
221/// Construct this once when request state is created, freezing the feature gate
222/// for the lifetime of that request.
223#[derive(Clone, Debug, Eq, PartialEq)]
224pub struct LifecycleTrace {
225    enabled: bool,
226    identity: Option<LifecycleIdentity>,
227}
228
229impl LifecycleTrace {
230    /// Construct capture state explicitly, primarily for integrations and tests.
231    pub fn new(enabled: bool) -> Self {
232        if enabled {
233            Self::enabled(LifecycleIdentity::new(None, LifecycleOperationRole::Worker))
234        } else {
235            Self::disabled()
236        }
237    }
238
239    /// Construct worker capture state with its statically configured role.
240    pub fn from_request_id_with_role(
241        request_id: impl Into<String>,
242        role: LifecycleOperationRole,
243    ) -> Self {
244        if !lifecycle_tracing_enabled() {
245            return Self::disabled();
246        }
247        Self::enabled(LifecycleIdentity::new(Some(request_id.into()), role))
248    }
249
250    /// Construct a frontend trace before request parsing has made a session ID available.
251    pub fn frontend_request_without_session(request_id: impl Into<String>) -> Self {
252        Self::with_role(request_id, LifecycleOperationRole::Frontend)
253    }
254
255    fn with_role(request_id: impl Into<String>, role: LifecycleOperationRole) -> Self {
256        if lifecycle_tracing_enabled() {
257            Self::enabled(LifecycleIdentity::new(Some(request_id.into()), role))
258        } else {
259            Self::disabled()
260        }
261    }
262
263    fn enabled(identity: LifecycleIdentity) -> Self {
264        Self {
265            enabled: true,
266            identity: Some(identity),
267        }
268    }
269
270    const fn disabled() -> Self {
271        Self {
272            enabled: false,
273            identity: None,
274        }
275    }
276
277    /// Whether lifecycle spans are emitted for this request.
278    pub const fn is_enabled(&self) -> bool {
279        self.enabled
280    }
281
282    /// Start the request root and return a recorder shared with all terminal paths.
283    #[must_use]
284    pub fn start_request(&self) -> LifecycleRequest {
285        let span = self.start(LifecycleStage::RequestLifecycle);
286        LifecycleRequest {
287            span: span.clone(),
288            terminal: LifecycleTerminal(self.enabled.then(|| {
289                Arc::new(TerminalState {
290                    span,
291                    finished: AtomicBool::new(false),
292                })
293            })),
294        }
295    }
296
297    /// Start a duration-only lifecycle span.
298    #[must_use]
299    pub fn start(&self, stage: LifecycleStage) -> Span {
300        if let Some(identity) = self.identity.as_ref() {
301            stage.span(identity)
302        } else {
303            Span::none()
304        }
305    }
306
307    /// Start the worker response-streaming boundary with its configured
308    /// disaggregation role encoded in the timing span name.
309    #[must_use]
310    pub fn start_worker_response_streaming(&self) -> Span {
311        if !self.enabled {
312            return Span::none();
313        }
314        let role = self.identity.as_ref().map(|identity| identity.role);
315        self.start(worker_response_streaming_stage(role))
316    }
317
318    /// Start the worker operation boundary for a disaggregated role.
319    ///
320    /// This bounds the Dynamo runtime's worker-side operation. It intentionally
321    /// includes any backend-internal queueing or decode-side KV wait.
322    #[must_use]
323    pub fn start_worker_operation(&self) -> Span {
324        if !self.enabled {
325            return Span::none();
326        }
327        let role = self.identity.as_ref().map(|identity| identity.role);
328        self.start(worker_operation_stage(role))
329    }
330}
331
332/// Request root span plus the shared terminal recorder.
333///
334/// The span stays open while detached request work cleans up. Its duration may
335/// therefore extend past client termination; `dynamo.request.terminal.timestamp_unix_ns`
336/// records the first terminal observation, independently of that cleanup.
337pub struct LifecycleRequest {
338    span: Span,
339    terminal: LifecycleTerminal,
340}
341
342impl LifecycleRequest {
343    #[must_use]
344    pub fn span(&self) -> Span {
345        self.span.clone()
346    }
347
348    #[must_use]
349    pub fn terminal(&self) -> LifecycleTerminal {
350        self.terminal.clone()
351    }
352
353    /// Record session identity after request parsing, or the request-ID fallback on early errors.
354    pub fn record_session(&self, request_id: &str, session_id: Option<&str>) {
355        let (session_id, source) = session_id
356            .filter(|id| !id.is_empty())
357            .map(|id| (id, "agent_context"))
358            .unwrap_or((request_id, "request_id_fallback"));
359        self.span.record("dynamo.session.id", session_id);
360        self.span.record("dynamo.session.source", source);
361    }
362}
363
364/// A terminal recorder that is safe to clone across completion and cancellation paths.
365#[derive(Clone)]
366pub struct LifecycleTerminal(Option<Arc<TerminalState>>);
367
368struct TerminalState {
369    span: Span,
370    finished: AtomicBool,
371}
372
373impl LifecycleTerminal {
374    /// Record the first observed terminal result. Later races are ignored.
375    pub fn finish(&self, outcome: TerminalOutcome) {
376        if let Some(state) = &self.0
377            && !state.finished.swap(true, Ordering::AcqRel)
378        {
379            state.record(outcome);
380        }
381    }
382}
383
384impl TerminalState {
385    fn record(&self, outcome: TerminalOutcome) {
386        let timestamp = SystemTime::now()
387            .duration_since(UNIX_EPOCH)
388            .unwrap_or_default();
389        self.span.record(
390            "dynamo.request.terminal.timestamp_unix_ns",
391            u64::try_from(timestamp.as_nanos()).unwrap_or(u64::MAX),
392        );
393        self.span
394            .record("dynamo.request.terminal.outcome", outcome.as_str());
395        self.span
396            .record("dynamo.request.terminal.error", outcome.is_error());
397    }
398}
399
400impl Drop for TerminalState {
401    fn drop(&mut self) {
402        if !self.finished.swap(true, Ordering::AcqRel) {
403            self.record(TerminalOutcome::Unknown);
404        }
405    }
406}
407
408fn lifecycle_mode() -> &'static str {
409    LIFECYCLE_MODE.get_or_init(|| match std::env::var(DYN_LIFECYCLE_TRACE_MODE) {
410        Ok(mode) if mode.trim().eq_ignore_ascii_case("investigation") => "investigation",
411        _ => DEFAULT_MODE,
412    })
413}
414
415fn process_epoch() -> &'static str {
416    PROCESS_EPOCH
417        .get_or_init(|| format!("{}:{}", std::process::id(), uuid::Uuid::new_v4()))
418        .as_str()
419}
420
421fn instance_id() -> &'static str {
422    INSTANCE_ID
423        .get_or_init(|| {
424            std::env::var("HOSTNAME")
425                .ok()
426                .filter(|value| !value.is_empty())
427                .unwrap_or_else(|| format!("pid-{}", std::process::id()))
428        })
429        .as_str()
430}
431
432fn worker_operation_stage(role: Option<LifecycleOperationRole>) -> LifecycleStage {
433    match role {
434        Some(LifecycleOperationRole::Encode) => LifecycleStage::WorkerOperationEncode,
435        Some(LifecycleOperationRole::Prefill) => LifecycleStage::WorkerOperationPrefill,
436        Some(LifecycleOperationRole::Decode) => LifecycleStage::WorkerOperationDecode,
437        _ => LifecycleStage::WorkerOperation,
438    }
439}
440
441fn worker_response_streaming_stage(role: Option<LifecycleOperationRole>) -> LifecycleStage {
442    match role {
443        Some(LifecycleOperationRole::Encode) => LifecycleStage::ResponseStreamingEncode,
444        Some(LifecycleOperationRole::Prefill) => LifecycleStage::ResponseStreamingPrefill,
445        Some(LifecycleOperationRole::Decode) => LifecycleStage::ResponseStreamingDecode,
446        _ => LifecycleStage::ResponseStreamingWorker,
447    }
448}
449
450pub(crate) fn lifecycle_tracing_enabled() -> bool {
451    *LIFECYCLE_ENABLED.get_or_init(|| crate::config::env_is_truthy(DYN_LIFECYCLE_TRACE_ENABLED))
452}
453
454#[cfg(test)]
455mod tests {
456    use std::sync::{Arc, Mutex};
457
458    use tracing::Subscriber;
459    use tracing_subscriber::{Layer, layer::Context, prelude::*};
460
461    use super::*;
462
463    #[derive(Debug, Eq, PartialEq)]
464    struct CapturedSpan {
465        name: &'static str,
466        target: &'static str,
467        role: Option<String>,
468    }
469
470    #[derive(Default)]
471    struct RoleCapture(Option<String>);
472
473    impl tracing::field::Visit for RoleCapture {
474        fn record_str(&mut self, field: &tracing::field::Field, value: &str) {
475            if field.name() == "dynamo.operation.role" {
476                self.0 = Some(value.to_owned());
477            }
478        }
479
480        fn record_debug(&mut self, _field: &tracing::field::Field, _value: &dyn std::fmt::Debug) {}
481    }
482
483    struct CaptureLayer(Arc<Mutex<Vec<CapturedSpan>>>);
484
485    impl<S: Subscriber> Layer<S> for CaptureLayer {
486        fn on_new_span(
487            &self,
488            attrs: &tracing::span::Attributes<'_>,
489            _id: &tracing::Id,
490            _ctx: Context<'_, S>,
491        ) {
492            let metadata = attrs.metadata();
493            let mut role = RoleCapture::default();
494            attrs.record(&mut role);
495            self.0.lock().unwrap().push(CapturedSpan {
496                name: metadata.name(),
497                target: metadata.target(),
498                role: role.0,
499            });
500        }
501    }
502
503    #[test]
504    fn enabled_trace_creates_a_registered_span() {
505        let captured = Arc::new(Mutex::new(Vec::new()));
506        let subscriber = tracing_subscriber::registry().with(CaptureLayer(captured.clone()));
507        let _guard = tracing::subscriber::set_default(subscriber);
508        let _span = LifecycleTrace::new(true).start(LifecycleStage::RequestPreprocessing);
509
510        assert_eq!(
511            captured.lock().unwrap().as_slice(),
512            [CapturedSpan {
513                name: "request.preprocessing",
514                target: LIFECYCLE_TARGET,
515                role: Some("worker".to_string()),
516            }]
517        );
518    }
519
520    #[test]
521    fn disabled_trace_is_a_noop() {
522        let captured = Arc::new(Mutex::new(Vec::new()));
523        let subscriber = tracing_subscriber::registry().with(CaptureLayer(captured.clone()));
524        let _guard = tracing::subscriber::set_default(subscriber);
525        let trace = LifecycleTrace::new(false);
526        assert!(trace.identity.is_none());
527        let request = trace.start_request();
528        assert!(
529            request.terminal.0.is_none(),
530            "disabled requests must not allocate terminal state"
531        );
532        let terminal = request.terminal();
533        assert!(terminal.0.is_none());
534        terminal.finish(TerminalOutcome::Success);
535        request.record_session("request-id", None);
536        let _span = trace.start(LifecycleStage::RequestPreprocessing);
537
538        assert!(captured.lock().unwrap().is_empty());
539    }
540
541    #[test]
542    fn worker_stage_selection_emits_expected_spans() {
543        let captured = Arc::new(Mutex::new(Vec::new()));
544        let subscriber = tracing_subscriber::registry().with(CaptureLayer(captured.clone()));
545        let _guard = tracing::subscriber::set_default(subscriber);
546        for role in [
547            LifecycleOperationRole::Worker,
548            LifecycleOperationRole::Encode,
549            LifecycleOperationRole::Prefill,
550            LifecycleOperationRole::Decode,
551        ] {
552            let trace = LifecycleTrace::enabled(LifecycleIdentity::new(
553                Some("request-id".to_string()),
554                role,
555            ));
556            let _operation = trace.start_worker_operation();
557            let _streaming = trace.start_worker_response_streaming();
558        }
559
560        let captured = captured.lock().unwrap();
561        assert!(captured.iter().all(|span| span.target == LIFECYCLE_TARGET));
562        assert_eq!(
563            captured
564                .iter()
565                .map(|span| span.role.as_deref())
566                .collect::<Vec<_>>(),
567            [
568                Some("worker"),
569                Some("worker"),
570                Some("encode"),
571                Some("encode"),
572                Some("prefill"),
573                Some("prefill"),
574                Some("decode"),
575                Some("decode")
576            ]
577        );
578        assert_eq!(
579            captured.iter().map(|span| span.name).collect::<Vec<_>>(),
580            [
581                "worker.operation",
582                "response.streaming.worker",
583                "worker.operation.encode",
584                "response.streaming.encode",
585                "worker.operation.prefill",
586                "response.streaming.prefill",
587                "worker.operation.decode",
588                "response.streaming.decode",
589            ]
590        );
591    }
592}