dwara_core/lifecycle/journey.rs
1//! API journey recorder (DW-110): records the request flow through the
2//! gateway as a JSON document for debugging.
3//!
4//! A [`Journey`] is the ordered sequence of phases a request passed
5//! through (route match, authn, authz, transforms, upstream pick,
6//! response), each captured as a [`JourneyStep`] (phase, duration,
7//! result, detail). The recorder is a bounded in-memory ring buffer of
8//! recent journeys (for live debugging) plus a JSON serializer that
9//! stores the journey via the existing analytics raw table's custom
10//! dimensions column (a JSON object column -- no schema change needed).
11//!
12//! ## Storage
13//!
14//! The journey JSON document is carried on the
15//! `AccessRecord`(crate::observability::AccessRecord)'s `custom`
16//! dimensions as a `_journey` key (the same JSON object column the
17//! DW-043 custom dimensions ride). This reuses the existing raw
18//! table's fire-and-forget write path -- no schema migration, no new
19//! table, no new writer channel. The in-memory ring buffer is a
20//! bounded `VecDeque` (capped at `JOURNEY_BUFFER_CAP`) for live
21//! debugging queries; the durable copy is the raw row.
22//!
23//! ## Retention
24//!
25//! The journey's durable retention is the raw table's retention (the
26//! `analytics.retention` config, default 24h). The `retention_hours`
27//! config field on [`JourneyConfig`] is an advisory cap that the
28//! recorder honors by dropping journeys older than the window from the
29//! in-memory buffer (the raw table's own retention handles the durable
30//! copy).
31//!
32//! The config schema type ([`JourneyConfig`]) lives in
33//! [`crate::config`] (always present, so configs round-trip without
34//! the `api_lifecycle` feature). This module re-exports it as the
35//! runtime-facing alias the recorder consumes.
36
37use std::collections::VecDeque;
38use std::sync::Mutex;
39
40use serde::{Deserialize, Serialize};
41use serde_json::Value;
42
43pub use crate::config::LifecycleJourneyConfig as JourneyConfig;
44
45/// The result of a journey step.
46#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
47#[serde(rename_all = "lowercase")]
48pub enum JourneyStepResult {
49 Success,
50 Failure,
51}
52
53/// One step in a request's journey through the gateway.
54#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
55#[serde(deny_unknown_fields)]
56pub struct JourneyStep {
57 /// The phase name (e.g. `route_match`, `authn`, `authz`,
58 /// `transforms`, `upstream_pick`, `response`).
59 pub phase: String,
60 /// The duration of this phase in milliseconds.
61 pub duration_ms: f64,
62 /// The outcome of this phase.
63 pub result: JourneyStepResult,
64 /// A free-form detail string (e.g. the matched route name, the
65 /// picked upstream, the response status). Never secrets -- the
66 /// same redaction posture as the access log applies.
67 #[serde(skip_serializing_if = "Option::is_none")]
68 pub detail: Option<String>,
69}
70
71/// A recorded request journey: the request id, the ordered steps, and
72/// the timestamp (wall-clock ms since the Unix epoch).
73#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
74#[serde(deny_unknown_fields)]
75pub struct Journey {
76 pub request_id: String,
77 pub steps: Vec<JourneyStep>,
78 pub timestamp_ms: u64,
79}
80
81impl Journey {
82 /// Create a new empty journey for the given request id.
83 pub fn new(request_id: impl Into<String>) -> Self {
84 Self {
85 request_id: request_id.into(),
86 steps: Vec::new(),
87 timestamp_ms: now_ms(),
88 }
89 }
90
91 /// Record a step in the journey.
92 pub fn add_step(&mut self, step: JourneyStep) {
93 self.steps.push(step);
94 }
95
96 /// Record a step with the given phase, result, and detail. The
97 /// duration is measured from the `started` instant to now.
98 pub fn add_timed_step(
99 &mut self,
100 phase: impl Into<String>,
101 started: std::time::Instant,
102 result: JourneyStepResult,
103 detail: Option<String>,
104 ) {
105 let duration_ms = started.elapsed().as_secs_f64() * 1000.0;
106 self.add_step(JourneyStep {
107 phase: phase.into(),
108 duration_ms,
109 result,
110 detail,
111 });
112 }
113
114 /// Serialize the journey to a JSON string.
115 pub fn to_json(&self) -> Result<String, String> {
116 serde_json::to_string(self).map_err(|e| format!("journey serialize failed: {e}"))
117 }
118
119 /// Serialize the journey to a [`serde_json::Value`].
120 pub fn to_value(&self) -> Result<Value, String> {
121 serde_json::to_value(self).map_err(|e| format!("journey serialize failed: {e}"))
122 }
123
124 /// The total duration of all steps (sum of step durations).
125 pub fn total_duration_ms(&self) -> f64 {
126 self.steps.iter().map(|s| s.duration_ms).sum()
127 }
128
129 /// Whether any step failed.
130 pub fn has_failure(&self) -> bool {
131 self.steps
132 .iter()
133 .any(|s| s.result == JourneyStepResult::Failure)
134 }
135}
136
137/// The in-memory ring buffer cap (recent journeys for live debugging).
138const JOURNEY_BUFFER_CAP: usize = 4096;
139
140/// The journey recorder: a bounded in-memory ring buffer of recent
141/// journeys, plus the retention window for dropping stale entries.
142#[derive(Debug)]
143pub struct JourneyRecorder {
144 config: JourneyConfig,
145 buffer: Mutex<VecDeque<Journey>>,
146}
147
148impl JourneyRecorder {
149 /// Build a recorder from the config.
150 pub fn new(config: JourneyConfig) -> Self {
151 Self {
152 config,
153 buffer: Mutex::new(VecDeque::new()),
154 }
155 }
156
157 /// Whether recording is enabled.
158 pub fn enabled(&self) -> bool {
159 self.config.enabled
160 }
161
162 /// The configured retention window in hours.
163 pub fn retention_hours(&self) -> u64 {
164 self.config.retention_hours
165 }
166
167 /// Record a completed journey into the in-memory ring buffer. The
168 /// caller is responsible for also stamping the journey onto the
169 /// `AccessRecord`'s custom dimensions (via [`journey_dimension`])
170 /// so the durable copy rides the raw table's write path. This
171 /// method never blocks: the buffer is a short mutex over a capped
172 /// `VecDeque`; when full, the oldest entry is evicted.
173 pub fn record(&self, journey: Journey) {
174 if !self.config.enabled {
175 return;
176 }
177 let mut buf = self.buffer.lock().expect("journey buffer mutex poisoned");
178 // Drop stale entries (older than the retention window).
179 let cutoff = now_ms().saturating_sub(self.config.retention_hours * 3600 * 1000);
180 while let Some(front) = buf.front() {
181 if front.timestamp_ms < cutoff {
182 buf.pop_front();
183 } else {
184 break;
185 }
186 }
187 // Evict the oldest when at cap.
188 if buf.len() >= JOURNEY_BUFFER_CAP {
189 buf.pop_front();
190 }
191 buf.push_back(journey);
192 }
193
194 /// Snapshot the in-memory journeys (newest first). Never blocks
195 /// beyond the short mutex hold.
196 pub fn snapshot(&self) -> Vec<Journey> {
197 let buf = self.buffer.lock().expect("journey buffer mutex poisoned");
198 let mut out: Vec<Journey> = buf.iter().rev().cloned().collect();
199 // Drop stale entries on read too (a long-idle buffer can hold
200 // stale entries when no new recording evicted them).
201 let cutoff = now_ms().saturating_sub(self.config.retention_hours * 3600 * 1000);
202 out.retain(|j| j.timestamp_ms >= cutoff);
203 out
204 }
205
206 /// The number of journeys currently in the buffer.
207 pub fn len(&self) -> usize {
208 self.buffer
209 .lock()
210 .expect("journey buffer mutex poisoned")
211 .len()
212 }
213
214 /// Whether the buffer is empty.
215 pub fn is_empty(&self) -> bool {
216 self.len() == 0
217 }
218}
219
220/// Build the custom-dimension entry that carries a journey on an
221/// `AccessRecord` into the raw table's JSON object column. The key
222/// is `_journey` (the leading underscore avoids collisions with
223/// operator-declared dimensions). Returns the `(key, value)` pair to
224/// push onto `AccessRecord::custom`.
225pub fn journey_dimension(journey: &Journey) -> Result<(String, String), String> {
226 let json = journey.to_json()?;
227 Ok(("_journey".to_string(), json))
228}
229
230/// Wall-clock milliseconds since the Unix epoch.
231fn now_ms() -> u64 {
232 std::time::SystemTime::now()
233 .duration_since(std::time::UNIX_EPOCH)
234 .map(|d| d.as_millis() as u64)
235 .unwrap_or(0)
236}