1use 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
21pub const LIFECYCLE_TARGET: &str = "dynamo.request_lifecycle";
23
24pub const LIFECYCLE_TRACE_CONTEXT_KEY: &str = "dynamo.request_lifecycle.trace";
26
27pub 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#[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#[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 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#[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 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#[derive(Clone, Debug, Eq, PartialEq)]
224pub struct LifecycleTrace {
225 enabled: bool,
226 identity: Option<LifecycleIdentity>,
227}
228
229impl LifecycleTrace {
230 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 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 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 pub const fn is_enabled(&self) -> bool {
279 self.enabled
280 }
281
282 #[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 #[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 #[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 #[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
332pub 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 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#[derive(Clone)]
366pub struct LifecycleTerminal(Option<Arc<TerminalState>>);
367
368struct TerminalState {
369 span: Span,
370 finished: AtomicBool,
371}
372
373impl LifecycleTerminal {
374 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}