1use std::sync::{
43 Arc,
44 atomic::{AtomicU32, AtomicUsize, Ordering},
45};
46use std::time::{Duration, SystemTime, UNIX_EPOCH};
47
48use serde::Serialize;
49use tokio::io::AsyncWriteExt;
50use tokio::sync::broadcast;
51
52pub const TRACE_CHANNEL_CAPACITY: usize = 1_024;
54pub const MAX_SUBSCRIBERS: usize = 4;
56
57#[derive(Clone, Debug, Serialize)]
61pub struct MatchTraceEvent {
62 pub event_id: u64,
64 pub schema_version: u8,
66 pub received_at_ms: u64,
68 pub duration_ms: u32,
70 pub request: RequestSummary,
72 pub outcome: Outcome,
74 pub dropped_count: u32,
76}
77
78#[derive(Clone, Debug, Serialize)]
80pub struct RequestSummary {
81 pub method: String,
82 pub url_path: String,
83 pub headers: Vec<(String, String)>,
85 #[serde(skip_serializing_if = "Option::is_none")]
88 pub body_json: Option<serde_json::Value>,
89 #[serde(skip_serializing_if = "std::ops::Not::not")]
91 pub body_truncated: bool,
92}
93
94impl RequestSummary {
95 pub fn without_body(method: String, url_path: String, headers: Vec<(String, String)>) -> Self {
97 Self { method, url_path, headers, body_json: None, body_truncated: false }
98 }
99}
100
101#[derive(Clone, Debug)]
103pub struct TraceConfig {
104 pub capture_body: bool,
106 pub max_body_bytes: usize,
109}
110
111impl Default for TraceConfig {
112 fn default() -> Self {
113 Self { capture_body: false, max_body_bytes: 8_192 }
114 }
115}
116
117#[derive(Clone, Debug, Serialize)]
119#[serde(tag = "type", rename_all = "snake_case")]
120pub enum Outcome {
121 Matched { rule_set_index: usize, rule_index: usize },
122 Fallback { file_path: String, status: u16 },
123 Miss { status: u16 },
124 Error { kind: String, message: String },
125}
126
127#[derive(Clone)]
133pub struct TraceEmitter {
134 sender: broadcast::Sender<MatchTraceEvent>,
135 event_counter: Arc<AtomicU32>,
136 dropped_counter: Arc<AtomicU32>,
137 pub config: Arc<TraceConfig>,
139}
140
141impl TraceEmitter {
142 pub fn new() -> Self {
143 Self::with_config(TraceConfig::default())
144 }
145
146 pub fn with_config(config: TraceConfig) -> Self {
147 let (sender, _) = broadcast::channel(TRACE_CHANNEL_CAPACITY);
148 Self {
149 sender,
150 event_counter: Arc::new(AtomicU32::new(0)),
151 dropped_counter: Arc::new(AtomicU32::new(0)),
152 config: Arc::new(config),
153 }
154 }
155
156 pub fn subscribe(&self) -> broadcast::Receiver<MatchTraceEvent> {
158 self.sender.subscribe()
159 }
160
161 pub fn enrich_with_body(
164 &self,
165 summary: &mut RequestSummary,
166 body_json: Option<&serde_json::Value>,
167 ) {
168 if !self.config.capture_body {
169 return;
170 }
171 match body_json {
172 None => {} Some(v) => {
174 match serde_json::to_string(v) {
176 Ok(s) if s.len() <= self.config.max_body_bytes => {
177 summary.body_json = Some(v.clone());
178 }
179 Ok(_) => {
180 summary.body_truncated = true;
181 }
182 Err(_) => {} }
184 }
185 }
186 }
187
188 pub fn emit(
191 &self,
192 received_at_ms: u64,
193 duration_ms: u32,
194 request: RequestSummary,
195 outcome: Outcome,
196 ) {
197 let event_id = self.event_counter.fetch_add(1, Ordering::Relaxed) as u64;
198 let dropped_count = self.dropped_counter.swap(0, Ordering::Relaxed);
199
200 let event = MatchTraceEvent {
201 event_id,
202 schema_version: 1,
203 received_at_ms,
204 duration_ms,
205 request,
206 outcome,
207 dropped_count,
208 };
209
210 if self.sender.send(event).is_err() {
211 self.dropped_counter.fetch_add(1, Ordering::Relaxed);
212 }
213 }
214
215 pub fn has_subscribers(&self) -> bool {
217 self.sender.receiver_count() > 0
218 }
219}
220
221impl Default for TraceEmitter { fn default() -> Self { Self::new() } }
222
223#[derive(Clone, Debug, Default)]
227pub enum TraceTransportConfig {
228 #[cfg(unix)]
230 Uds { path: String },
231 Tcp { addr: String },
233 #[default]
235 Disabled,
236}
237
238pub struct TraceTransport;
241
242impl TraceTransport {
243 pub async fn accept_loop(config: TraceTransportConfig, emitter: TraceEmitter) {
255 match config {
256 #[cfg(unix)]
257 TraceTransportConfig::Uds { path } => {
258 Self::uds_accept_loop(path, emitter).await
259 }
260 TraceTransportConfig::Tcp { addr } => {
261 Self::tcp_accept_loop(addr, emitter).await
262 }
263 TraceTransportConfig::Disabled => {
264 }
266 }
267 }
268
269 async fn tcp_accept_loop(addr: String, emitter: TraceEmitter) {
272 let listener = match tokio::net::TcpListener::bind(&addr).await {
273 Ok(l) => {
274 let bound = l.local_addr().map(|a| a.to_string())
275 .unwrap_or_else(|_| addr.clone());
276 log::info!("trace transport: TCP listening on {}", bound);
277 l
278 }
279 Err(e) => {
280 log::error!("trace transport: failed to bind TCP {}: {}", addr, e);
281 return;
282 }
283 };
284
285 let active = Arc::new(AtomicUsize::new(0));
286 loop {
287 match listener.accept().await {
288 Ok((stream, peer)) => {
289 let count = active.fetch_add(1, Ordering::Relaxed) + 1;
290 if count > MAX_SUBSCRIBERS {
291 active.fetch_sub(1, Ordering::Relaxed);
292 let active_clone = active.clone();
293 tokio::spawn(async move {
294 let (_, mut writer) = tokio::io::split(stream);
295 let _ = writer
296 .write_all(b"{\"error\":\"max_subscribers_reached\"}\n")
297 .await;
298 drop(active_clone);
299 });
300 continue;
301 }
302 log::debug!("trace: TCP subscriber connected from {}", peer);
303 let rx = emitter.subscribe();
304 let active_clone = active.clone();
305 tokio::spawn(async move {
306 let (_, writer) = tokio::io::split(stream);
307 Self::forward_events(writer, rx).await;
308 active_clone.fetch_sub(1, Ordering::Relaxed);
309 log::debug!("trace: TCP subscriber {} disconnected", peer);
310 });
311 }
312 Err(e) => {
313 log::error!("trace: TCP accept error: {}", e);
314 tokio::time::sleep(Duration::from_millis(100)).await;
315 }
316 }
317 }
318 }
319
320 #[cfg(unix)]
323 async fn uds_accept_loop(path: String, emitter: TraceEmitter) {
324 let _ = std::fs::remove_file(&path);
326
327 let listener = match tokio::net::UnixListener::bind(&path) {
328 Ok(l) => {
329 log::info!("trace transport: UDS listening at {}", path);
330 l
331 }
332 Err(e) => {
333 log::error!("trace transport: failed to bind UDS {}: {}", path, e);
334 return;
335 }
336 };
337
338 let active = Arc::new(AtomicUsize::new(0));
339 loop {
340 match listener.accept().await {
341 Ok((stream, _)) => {
342 let count = active.fetch_add(1, Ordering::Relaxed) + 1;
343 if count > MAX_SUBSCRIBERS {
344 active.fetch_sub(1, Ordering::Relaxed);
345 tokio::spawn(async move {
346 let (_, mut writer) = tokio::io::split(stream);
347 let _ = writer
348 .write_all(b"{\"error\":\"max_subscribers_reached\"}\n")
349 .await;
350 });
351 continue;
352 }
353 log::debug!("trace: UDS subscriber connected");
354 let rx = emitter.subscribe();
355 let active_clone = active.clone();
356 tokio::spawn(async move {
357 let (_, writer) = tokio::io::split(stream);
358 Self::forward_events(writer, rx).await;
359 active_clone.fetch_sub(1, Ordering::Relaxed);
360 log::debug!("trace: UDS subscriber disconnected");
361 });
362 }
363 Err(e) => {
364 log::error!("trace: UDS accept error: {}", e);
365 tokio::time::sleep(Duration::from_millis(100)).await;
366 }
367 }
368 }
369 }
370
371 async fn forward_events<W>(mut writer: W, mut rx: broadcast::Receiver<MatchTraceEvent>)
376 where
377 W: tokio::io::AsyncWrite + Unpin,
378 {
379 loop {
380 let event = match rx.recv().await {
381 Ok(e) => e,
382 Err(broadcast::error::RecvError::Lagged(n)) => {
383 log::debug!("trace: subscriber lagged, {} events dropped", n);
387 continue;
388 }
389 Err(broadcast::error::RecvError::Closed) => break,
390 };
391
392 let mut line = match serde_json::to_string(&event) {
393 Ok(s) => s,
394 Err(e) => {
395 log::error!("trace: serialise error: {}", e);
396 continue;
397 }
398 };
399 line.push('\n');
400
401 if writer.write_all(line.as_bytes()).await.is_err() {
402 break; }
404 }
405 }
406}
407
408pub fn now_ms() -> u64 {
412 SystemTime::now()
413 .duration_since(UNIX_EPOCH)
414 .unwrap_or(Duration::ZERO)
415 .as_millis() as u64
416}
417
418#[cfg(test)]
421mod tests {
422 use super::*;
423
424 #[tokio::test]
425 async fn emit_received_by_subscriber() {
426 let emitter = TraceEmitter::new();
427 let mut rx = emitter.subscribe();
428
429 emitter.emit(
430 1_000_000, 5,
431 RequestSummary { method: "GET".into(), url_path: "/api/test".into(), headers: vec![], body_json: None, body_truncated: false },
432 Outcome::Miss { status: 404 },
433 );
434
435 let event = rx.try_recv().expect("event in channel");
436 assert_eq!(event.event_id, 0);
437 assert_eq!(event.schema_version, 1);
438 assert_eq!(event.request.method, "GET");
439 assert_eq!(event.duration_ms, 5);
440 assert_eq!(event.dropped_count, 0);
441 assert!(matches!(event.outcome, Outcome::Miss { status: 404 }));
442 }
443
444 #[tokio::test]
445 async fn emit_no_subscriber_increments_dropped() {
446 let emitter = TraceEmitter::new();
447 emitter.emit(0, 0,
448 RequestSummary { method: "GET".into(), url_path: "/".into(), headers: vec![], body_json: None, body_truncated: false },
449 Outcome::Miss { status: 404 },
450 );
451 let mut rx = emitter.subscribe();
452 emitter.emit(0, 0,
453 RequestSummary { method: "GET".into(), url_path: "/".into(), headers: vec![], body_json: None, body_truncated: false },
454 Outcome::Miss { status: 200 },
455 );
456 let event = rx.try_recv().expect("second event visible");
457 assert_eq!(event.dropped_count, 1, "first event should be counted dropped");
458 }
459
460 #[test]
461 fn has_subscribers_reflects_state() {
462 let emitter = TraceEmitter::new();
463 assert!(!emitter.has_subscribers());
464 let _rx = emitter.subscribe();
465 assert!(emitter.has_subscribers());
466 }
467
468 #[tokio::test]
469 async fn outcome_serialises_correctly() {
470 let event = MatchTraceEvent {
471 event_id: 7, schema_version: 1, received_at_ms: 0, duration_ms: 0,
472 request: RequestSummary { method: "POST".into(), url_path: "/x".into(), headers: vec![], body_json: None, body_truncated: false },
473 outcome: Outcome::Matched { rule_set_index: 0, rule_index: 2 },
474 dropped_count: 0,
475 };
476 let json = serde_json::to_string(&event).unwrap();
477 assert!(json.contains("\"type\":\"matched\""));
478 assert!(json.contains("\"rule_index\":2"));
479 assert!(json.contains("\"schema_version\":1"));
480 }
481
482 #[tokio::test]
483 async fn tcp_transport_delivers_events() {
484 let emitter = TraceEmitter::new();
485 let emitter_clone = emitter.clone();
486
487 let config = TraceTransportConfig::Tcp { addr: "127.0.0.1:0".to_owned() };
489
490 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
494 let bound_addr = listener.local_addr().unwrap();
495
496 tokio::spawn(async move {
498 let (stream, _) = listener.accept().await.unwrap();
499 let rx = emitter_clone.subscribe();
500 let (_, writer) = tokio::io::split(stream);
501 TraceTransport::forward_events(writer, rx).await;
502 });
503
504 let mut client = tokio::net::TcpStream::connect(bound_addr).await.unwrap();
506
507 tokio::time::sleep(std::time::Duration::from_millis(10)).await;
509
510 emitter.emit(
511 42, 3,
512 RequestSummary { method: "GET".into(), url_path: "/ping".into(), headers: vec![], body_json: None, body_truncated: false },
513 Outcome::Miss { status: 404 },
514 );
515
516 use tokio::io::AsyncBufReadExt;
518 let mut reader = tokio::io::BufReader::new(&mut client);
519 let mut line = String::new();
520 tokio::time::timeout(
521 std::time::Duration::from_secs(2),
522 reader.read_line(&mut line),
523 )
524 .await
525 .expect("timeout")
526 .expect("read ok");
527
528 let value: serde_json::Value = serde_json::from_str(line.trim()).expect("valid JSON");
529 assert_eq!(value["request"]["url_path"], "/ping");
530 assert_eq!(value["outcome"]["type"], "miss");
531 assert_eq!(value["schema_version"], 1);
532 }
533
534 #[test]
537 fn enrich_with_body_disabled_by_default() {
538 let emitter = TraceEmitter::new(); let mut summary = RequestSummary {
540 method: "POST".into(), url_path: "/".into(), headers: vec![],
541 body_json: None, body_truncated: false,
542 };
543 let body = serde_json::json!({"action": "create"});
544 emitter.enrich_with_body(&mut summary, Some(&body));
545 assert!(summary.body_json.is_none(), "body should not be captured when disabled");
546 assert!(!summary.body_truncated);
547 }
548
549 #[test]
550 fn enrich_with_body_enabled_captures_small_body() {
551 let emitter = TraceEmitter::with_config(TraceConfig { capture_body: true, max_body_bytes: 8_192 });
552 let mut summary = RequestSummary {
553 method: "POST".into(), url_path: "/".into(), headers: vec![],
554 body_json: None, body_truncated: false,
555 };
556 let body = serde_json::json!({"action": "create", "user_id": 42});
557 emitter.enrich_with_body(&mut summary, Some(&body));
558 assert!(summary.body_json.is_some(), "body should be captured when enabled");
559 assert_eq!(summary.body_json.unwrap()["action"], "create");
560 assert!(!summary.body_truncated);
561 }
562
563 #[test]
564 fn enrich_with_body_truncates_oversized_body() {
565 let emitter = TraceEmitter::with_config(TraceConfig { capture_body: true, max_body_bytes: 10 });
566 let mut summary = RequestSummary {
567 method: "POST".into(), url_path: "/".into(), headers: vec![],
568 body_json: None, body_truncated: false,
569 };
570 let body = serde_json::json!({"data": "this is longer than 10 bytes"});
571 emitter.enrich_with_body(&mut summary, Some(&body));
572 assert!(summary.body_json.is_none(), "oversized body should be omitted");
573 assert!(summary.body_truncated, "body_truncated flag should be set");
574 }
575
576 #[test]
577 fn request_summary_body_json_not_in_serialised_output_when_none() {
578 let summary = RequestSummary {
579 method: "GET".into(), url_path: "/api".into(), headers: vec![],
580 body_json: None, body_truncated: false,
581 };
582 let json = serde_json::to_string(&summary).unwrap();
583 assert!(!json.contains("body_json"), "absent body_json must be skipped");
584 assert!(!json.contains("body_truncated"), "false body_truncated must be skipped");
585 }
586}