Skip to main content

astrid_telemetry/
context.rs

1//! Request context for correlation and tracing.
2
3use chrono::{DateTime, Utc};
4use serde::{Deserialize, Serialize};
5use std::collections::HashMap;
6use uuid::Uuid;
7
8/// Request context for correlation across operations.
9///
10/// This struct carries context information that should be propagated
11/// through the system for tracing and debugging purposes.
12#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct RequestContext {
14    /// Unique request identifier.
15    pub request_id: Uuid,
16    /// Correlation ID for tracing related requests.
17    pub correlation_id: Uuid,
18    /// Parent request ID if this is a sub-request.
19    pub parent_id: Option<Uuid>,
20    /// Session ID if within a session.
21    pub session_id: Option<Uuid>,
22    /// User ID if authenticated.
23    pub user_id: Option<Uuid>,
24    /// When the request started.
25    pub started_at: DateTime<Utc>,
26    /// Source component that created this context.
27    pub source: String,
28    /// Operation being performed.
29    pub operation: Option<String>,
30    /// Additional metadata.
31    #[serde(default)]
32    pub metadata: HashMap<String, String>,
33}
34
35impl RequestContext {
36    /// Create a new request context.
37    #[must_use]
38    pub fn new(source: impl Into<String>) -> Self {
39        let id = Uuid::new_v4();
40        Self {
41            request_id: id,
42            correlation_id: id,
43            parent_id: None,
44            session_id: None,
45            user_id: None,
46            started_at: Utc::now(),
47            source: source.into(),
48            operation: None,
49            metadata: HashMap::new(),
50        }
51    }
52
53    /// Create a child context that inherits correlation info.
54    #[must_use]
55    pub fn child(&self, source: impl Into<String>) -> Self {
56        Self {
57            request_id: Uuid::new_v4(),
58            correlation_id: self.correlation_id,
59            parent_id: Some(self.request_id),
60            session_id: self.session_id,
61            user_id: self.user_id,
62            started_at: Utc::now(),
63            source: source.into(),
64            operation: None,
65            metadata: self.metadata.clone(),
66        }
67    }
68
69    /// Set the correlation ID.
70    #[must_use]
71    pub fn with_correlation_id(mut self, id: Uuid) -> Self {
72        self.correlation_id = id;
73        self
74    }
75
76    /// Set the session ID.
77    #[must_use]
78    pub fn with_session_id(mut self, id: Uuid) -> Self {
79        self.session_id = Some(id);
80        self
81    }
82
83    /// Set the user ID.
84    #[must_use]
85    pub fn with_user_id(mut self, id: Uuid) -> Self {
86        self.user_id = Some(id);
87        self
88    }
89
90    /// Set the operation name.
91    #[must_use]
92    pub fn with_operation(mut self, operation: impl Into<String>) -> Self {
93        self.operation = Some(operation.into());
94        self
95    }
96
97    /// Add metadata.
98    #[must_use]
99    pub fn with_metadata(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
100        self.metadata.insert(key.into(), value.into());
101        self
102    }
103
104    /// Get elapsed time since the request started.
105    #[must_use]
106    pub fn elapsed(&self) -> chrono::Duration {
107        // Utc::now() >= self.started_at by construction (started_at is set at creation time)
108        #[expect(clippy::arithmetic_side_effects)]
109        let elapsed = Utc::now() - self.started_at;
110        elapsed
111    }
112
113    /// Get elapsed time in milliseconds.
114    #[must_use]
115    pub fn elapsed_ms(&self) -> i64 {
116        self.elapsed().num_milliseconds()
117    }
118
119    /// Create a tracing span with this context.
120    #[must_use]
121    pub fn span(&self) -> tracing::Span {
122        tracing::info_span!(
123            "request",
124            request_id = %self.request_id,
125            correlation_id = %self.correlation_id,
126            source = %self.source,
127            operation = self.operation.as_deref(),
128        )
129    }
130
131    /// Check if this context has a parent.
132    #[must_use]
133    pub fn has_parent(&self) -> bool {
134        self.parent_id.is_some()
135    }
136
137    /// Get a short identifier for logging.
138    #[must_use]
139    pub fn short_id(&self) -> String {
140        self.request_id.to_string()[..8].to_string()
141    }
142}
143
144impl Default for RequestContext {
145    fn default() -> Self {
146        Self::new("unknown")
147    }
148}
149
150#[cfg(test)]
151mod tests {
152    use super::*;
153
154    #[test]
155    fn test_request_context_creation() {
156        let ctx = RequestContext::new("test");
157        assert_eq!(ctx.source, "test");
158        assert_eq!(ctx.request_id, ctx.correlation_id);
159        assert!(ctx.parent_id.is_none());
160        assert!(ctx.session_id.is_none());
161        assert!(ctx.user_id.is_none());
162    }
163
164    #[test]
165    fn test_request_context_builder() {
166        let session = Uuid::new_v4();
167        let user = Uuid::new_v4();
168        let correlation = Uuid::new_v4();
169
170        let ctx = RequestContext::new("test")
171            .with_correlation_id(correlation)
172            .with_session_id(session)
173            .with_user_id(user)
174            .with_operation("test_op")
175            .with_metadata("key", "value");
176
177        assert_eq!(ctx.correlation_id, correlation);
178        assert_eq!(ctx.session_id, Some(session));
179        assert_eq!(ctx.user_id, Some(user));
180        assert_eq!(ctx.operation, Some("test_op".to_string()));
181        assert_eq!(ctx.metadata.get("key"), Some(&"value".to_string()));
182    }
183
184    #[test]
185    fn test_child_context() {
186        let session = Uuid::new_v4();
187        let parent = RequestContext::new("parent")
188            .with_session_id(session)
189            .with_metadata("inherited", "yes");
190
191        let child = parent.child("child");
192
193        // Child should have new request_id
194        assert_ne!(child.request_id, parent.request_id);
195
196        // Child should inherit correlation_id
197        assert_eq!(child.correlation_id, parent.correlation_id);
198
199        // Child should have parent_id
200        assert_eq!(child.parent_id, Some(parent.request_id));
201
202        // Child should inherit session_id
203        assert_eq!(child.session_id, Some(session));
204
205        // Child should inherit metadata
206        assert_eq!(child.metadata.get("inherited"), Some(&"yes".to_string()));
207    }
208
209    #[test]
210    fn test_elapsed() {
211        let ctx = RequestContext::new("test");
212        std::thread::sleep(std::time::Duration::from_millis(10));
213        assert!(ctx.elapsed_ms() >= 10);
214    }
215
216    #[test]
217    fn test_short_id() {
218        let ctx = RequestContext::new("test");
219        let short = ctx.short_id();
220        assert_eq!(short.len(), 8);
221    }
222
223    #[test]
224    fn test_serialization() {
225        let ctx = RequestContext::new("test")
226            .with_operation("test_op")
227            .with_metadata("key", "value");
228
229        let json = serde_json::to_string(&ctx).unwrap();
230        assert!(json.contains("\"source\":\"test\""));
231        assert!(json.contains("\"operation\":\"test_op\""));
232
233        let parsed: RequestContext = serde_json::from_str(&json).unwrap();
234        assert_eq!(parsed.source, "test");
235        assert_eq!(parsed.operation, Some("test_op".to_string()));
236    }
237}