Skip to main content

sentinel_core/normalize/
mod.rs

1//! Normalization stage: canonicalizes SQL queries and HTTP URLs.
2
3pub mod http;
4#[cfg(test)]
5mod metamorphic;
6pub mod sql;
7
8use std::sync::Arc;
9
10use crate::event::{EventType, MAX_ID_LENGTH, SpanEvent, sanitize_id};
11
12/// A span event enriched with its normalized template and extracted parameters.
13///
14/// `template` is `Arc<str>` so findings of the same pattern share the
15/// canonical string buffer when cloned across the detect / report stages.
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub struct NormalizedEvent {
18    pub event: SpanEvent,
19    pub template: Arc<str>,
20    pub params: Vec<String>,
21}
22
23/// Normalize a single event by dispatching on its type.
24///
25/// Also sanitizes `trace_id` and `span_id` to enforce maximum length.
26#[must_use]
27pub fn normalize(mut event: SpanEvent) -> NormalizedEvent {
28    // Enforce ID length limits at the normalization boundary
29    if event.trace_id.len() > MAX_ID_LENGTH {
30        event.trace_id = sanitize_id(&event.trace_id);
31    }
32    if event.span_id.len() > MAX_ID_LENGTH {
33        event.span_id = sanitize_id(&event.span_id);
34    }
35    if let Some(ref pid) = event.parent_span_id
36        && pid.len() > MAX_ID_LENGTH
37    {
38        event.parent_span_id = Some(sanitize_id(pid));
39    }
40    match event.event_type {
41        EventType::Sql => {
42            let result = sql::normalize_sql(&event.target);
43            NormalizedEvent {
44                event,
45                template: Arc::from(result.template),
46                params: result.params,
47            }
48        }
49        EventType::HttpOut => {
50            let result = http::normalize_http(&event.operation, &event.target);
51            NormalizedEvent {
52                event,
53                template: Arc::from(result.template),
54                params: result.params,
55            }
56        }
57    }
58}
59
60/// Normalize a batch of events.
61#[must_use]
62pub fn normalize_all(events: Vec<SpanEvent>) -> Vec<NormalizedEvent> {
63    events.into_iter().map(normalize).collect()
64}
65
66#[cfg(test)]
67mod tests {
68    use super::*;
69    use crate::event::{EventSource, EventType, SpanEvent};
70
71    fn make_sql_event(target: &str) -> SpanEvent {
72        SpanEvent {
73            timestamp: "2025-07-10T14:32:01.123Z".to_string(),
74            trace_id: "trace-1".to_string(),
75            span_id: "span-1".to_string(),
76            parent_span_id: None,
77            service: Arc::from("test"),
78            cloud_region: None,
79            event_type: EventType::Sql,
80            operation: "SELECT".to_string(),
81            target: target.to_string(),
82            duration_us: 100,
83            source: EventSource {
84                endpoint: "GET /test".to_string(),
85                method: "Test::test".to_string(),
86            },
87            status_code: None,
88            response_size_bytes: None,
89            code_function: None,
90            code_filepath: None,
91            code_lineno: None,
92            code_namespace: None,
93            instrumentation_scopes: Vec::new(),
94        }
95    }
96
97    fn make_http_event(method: &str, target: &str) -> SpanEvent {
98        SpanEvent {
99            timestamp: "2025-07-10T14:32:01.123Z".to_string(),
100            trace_id: "trace-1".to_string(),
101            span_id: "span-1".to_string(),
102            parent_span_id: None,
103            service: Arc::from("test"),
104            cloud_region: None,
105            event_type: EventType::HttpOut,
106            operation: method.to_string(),
107            target: target.to_string(),
108            duration_us: 100,
109            source: EventSource {
110                endpoint: "GET /test".to_string(),
111                method: "Test::test".to_string(),
112            },
113            status_code: Some(200),
114            response_size_bytes: None,
115            code_function: None,
116            code_filepath: None,
117            code_lineno: None,
118            code_namespace: None,
119            instrumentation_scopes: Vec::new(),
120        }
121    }
122
123    #[test]
124    fn normalize_dispatches_sql() {
125        let event = make_sql_event("SELECT * FROM users WHERE id = 42");
126        let normalized = normalize(event);
127        assert_eq!(&*normalized.template, "SELECT * FROM users WHERE id = ?");
128        assert_eq!(normalized.params, vec!["42"]);
129    }
130
131    #[test]
132    fn normalize_dispatches_http() {
133        let event = make_http_event("GET", "/api/users/42");
134        let normalized = normalize(event);
135        assert_eq!(&*normalized.template, "GET /api/users/{id}");
136    }
137
138    #[test]
139    fn normalize_all_processes_batch() {
140        let events = vec![
141            make_sql_event("SELECT 1"),
142            make_http_event("POST", "/api/orders/99/submit"),
143        ];
144        let normalized = normalize_all(events);
145        assert_eq!(normalized.len(), 2);
146        assert_eq!(&*normalized[0].template, "SELECT ?");
147        assert_eq!(&*normalized[1].template, "POST /api/orders/{id}/submit");
148    }
149
150    #[test]
151    fn normalize_truncates_oversized_trace_id() {
152        let mut event = make_sql_event("SELECT 1");
153        event.trace_id = "x".repeat(200);
154        event.span_id = "y".repeat(200);
155        event.parent_span_id = Some("z".repeat(200));
156        let normalized = normalize(event);
157        assert_eq!(normalized.event.trace_id.len(), MAX_ID_LENGTH);
158        assert_eq!(normalized.event.span_id.len(), MAX_ID_LENGTH);
159        assert_eq!(
160            normalized.event.parent_span_id.unwrap().len(),
161            MAX_ID_LENGTH
162        );
163    }
164
165    #[test]
166    fn normalize_preserves_normal_ids() {
167        let event = make_sql_event("SELECT 1");
168        let original_trace = event.trace_id.clone();
169        let normalized = normalize(event);
170        assert_eq!(normalized.event.trace_id, original_trace);
171    }
172}