Skip to main content

sentinel_core/
event.rs

1//! Core event types for the perf-sentinel pipeline.
2
3use std::sync::Arc;
4
5use serde::{Deserialize, Serialize};
6
7/// The type of I/O operation a span represents.
8#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
9#[serde(rename_all = "snake_case")]
10pub enum EventType {
11    Sql,
12    HttpOut,
13    /// Message published to a broker (Kafka, `RabbitMQ`, Pulsar, SQS, ...).
14    /// Producer side only, see `ingest::otlp::classify_io_event`.
15    Messaging,
16}
17
18/// Source context for the span (which endpoint/method triggered it).
19#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
20pub struct EventSource {
21    pub endpoint: String,
22    pub method: String,
23}
24
25/// Maximum allowed length for a `trace_id` or `span_id`.
26///
27/// OpenTelemetry specifies 32 hex chars for trace IDs and 16 for span IDs.
28/// We allow up to 128 chars to accommodate non-standard formats.
29pub const MAX_ID_LENGTH: usize = 128;
30
31/// Truncate an ID field (`trace_id`, `span_id`) to [`MAX_ID_LENGTH`].
32///
33/// Uses char-boundary-aware truncation to avoid panicking on multi-byte UTF-8.
34/// Delegates to [`truncate_field`] after a one-time clone to keep the
35/// char-boundary walk in a single place.
36#[must_use]
37pub fn sanitize_id(id: &str) -> String {
38    let mut s = id.to_string();
39    truncate_field(&mut s, MAX_ID_LENGTH);
40    s
41}
42
43/// Maximum length for the `service` field (bytes).
44pub const MAX_SERVICE_LENGTH: usize = 256;
45
46/// Maximum length for the `operation` field (bytes).
47pub const MAX_OPERATION_LENGTH: usize = 256;
48
49/// Maximum length for the `target` field (bytes).
50/// The SQL normalizer has its own 64 KB limit; this provides
51/// defense-in-depth at the ingestion boundary.
52pub const MAX_TARGET_LENGTH: usize = 65_536;
53
54/// Maximum length for `source.endpoint` and `source.method` (bytes).
55pub const MAX_SOURCE_LENGTH: usize = 512;
56
57/// Maximum length for `code_function` and `code_namespace` (bytes).
58pub const MAX_CODE_FUNCTION_LENGTH: usize = 512;
59
60/// Maximum length for `code_filepath` (bytes).
61pub const MAX_CODE_FILEPATH_LENGTH: usize = 1024;
62
63/// Maximum length for `code_namespace` (bytes).
64pub const MAX_CODE_NAMESPACE_LENGTH: usize = 512;
65
66/// Maximum length for a single instrumentation scope name (bytes).
67/// Real OpenTelemetry scope names are short (`io.opentelemetry.spring-data-3.0`
68/// is 33 bytes), so 256 leaves comfortable headroom while bounding the
69/// memory amplification of the per-finding Vec clone.
70pub const MAX_SCOPE_NAME_LENGTH: usize = 256;
71
72/// Maximum number of instrumentation scopes captured per span. Matches
73/// the OTLP parent-walk depth bound (`CODE_ATTRS_MAX_DEPTH = 8`). The
74/// JSON ingest path has no such structural bound, so the cap fires there.
75pub const MAX_INSTRUMENTATION_SCOPES: usize = 8;
76
77/// Truncate a string to `max_len` bytes on a char boundary.
78///
79/// Shared between span-event sanitization and the daemon ack store
80/// (`crate::daemon::ack`). Keep behavior strictly bytes-and-char-boundary,
81/// do not add domain-specific normalization here.
82pub(crate) fn truncate_field(s: &mut String, max_len: usize) {
83    if s.len() <= max_len {
84        return;
85    }
86    let mut end = max_len;
87    while end > 0 && !s.is_char_boundary(end) {
88        end -= 1;
89    }
90    s.truncate(end);
91}
92
93/// Strip URL query string, fragment, and userinfo from an endpoint in place.
94///
95/// `source.endpoint` falls back to the raw request URL when `http.route` is
96/// absent, so a `?token=...`, `#...`, or `user:pass@` can carry a secret into
97/// stored findings, the query API, the NDJSON archive, and the ack signature.
98/// Drop them at the ingestion boundary. Route templates and query-less URLs
99/// (the common case) are left untouched.
100fn strip_endpoint_secrets(endpoint: &mut String) {
101    // Drop query/fragment first, so a '?', '#', '://' or '@' living inside them
102    // cannot be mistaken for authority structure below.
103    if let Some(cut) = endpoint.find(['?', '#']) {
104        endpoint.truncate(cut);
105    }
106    // Authority begins after a scheme `://` (only when that `://` precedes the
107    // first path '/'), after a leading `//`, or at 0 for a scheme-less
108    // `user:pass@host`. Strip its userinfo up to the last '@' before the path.
109    let authority_start = if endpoint.starts_with("//") {
110        2
111    } else if let Some(i) = endpoint
112        .find("://")
113        .filter(|&i| !endpoint[..i].contains('/'))
114    {
115        i + 3
116    } else {
117        0
118    };
119    let authority_end = endpoint[authority_start..]
120        .find('/')
121        .map_or(endpoint.len(), |i| authority_start + i);
122    if let Some(at) = endpoint[authority_start..authority_end].rfind('@') {
123        endpoint.replace_range(authority_start..=authority_start + at, "");
124    }
125}
126
127pub(crate) fn sanitize_source_endpoint(endpoint: &mut String) {
128    strip_endpoint_secrets(endpoint);
129    truncate_field(endpoint, MAX_SOURCE_LENGTH);
130}
131
132/// Drop the field if it contains any ASCII control character, otherwise truncate.
133///
134/// Mirrors the silent-drop posture used for `cloud.region` invalid values.
135/// Control characters in `code.*` would render badly in TUI/CLI output and
136/// could enable log-injection if any future log site emitted them raw.
137fn sanitize_optional_arc_str(field: &mut Option<Arc<str>>, max_len: usize) {
138    if field
139        .as_deref()
140        .is_some_and(crate::config::has_control_char)
141    {
142        *field = None;
143        return;
144    }
145    if let Some(s) = field.as_ref()
146        && s.len() > max_len
147    {
148        let mut tmp = s.to_string();
149        truncate_field(&mut tmp, max_len);
150        *field = Some(Arc::from(tmp));
151    }
152}
153
154/// Truncate an `Arc<str>` field in place via alloc-and-replace.
155///
156/// `Arc<str>` is immutable, so we materialize a `String`, truncate it
157/// on a char boundary, and rebuild a fresh `Arc::<str>::from(String)`
158/// that reuses the buffer (no double allocation).
159fn truncate_arc_str(field: &mut Arc<str>, max_len: usize) {
160    if field.len() <= max_len {
161        return;
162    }
163    let mut tmp = field.to_string();
164    truncate_field(&mut tmp, max_len);
165    *field = Arc::from(tmp);
166}
167
168/// Drop entries with control characters, truncate the remainder to
169/// `max_len` and cap the Vec at `max_count`.
170///
171/// Used for `instrumentation_scopes` (OpenTelemetry scope names from
172/// arbitrary agents, including the JSON ingest path which has no
173/// structural depth bound). Bounds both the per-element and per-event
174/// memory amplification when those scope names propagate into the
175/// per-finding clone.
176fn sanitize_arc_str_vec(field: &mut Vec<Arc<str>>, max_len: usize, max_count: usize) {
177    field.retain(|s| !crate::config::has_control_char(s));
178    if field.len() > max_count {
179        field.truncate(max_count);
180    }
181    for s in field.iter_mut() {
182        truncate_arc_str(s, max_len);
183    }
184}
185
186/// Sanitize all string fields on a [`SpanEvent`] to enforce length limits.
187///
188/// Maximum length for the `timestamp` field (bytes).
189/// ISO 8601 with microseconds and timezone is at most ~30 chars.
190const MAX_TIMESTAMP_LENGTH: usize = 64;
191
192/// Called at every ingestion boundary (OTLP, JSON, Jaeger, Zipkin) to
193/// prevent unbounded memory growth from oversized attribute values.
194/// Also truncates IDs (`trace_id`, `span_id`, `parent_span_id`) that
195/// are not already sanitized at the ingestion boundary for some formats
196/// (Jaeger, Zipkin, native JSON).
197pub fn sanitize_span_event(event: &mut SpanEvent) {
198    truncate_field(&mut event.timestamp, MAX_TIMESTAMP_LENGTH);
199    truncate_field(&mut event.trace_id, MAX_ID_LENGTH);
200    truncate_field(&mut event.span_id, MAX_ID_LENGTH);
201    if let Some(ref mut pid) = event.parent_span_id {
202        truncate_field(pid, MAX_ID_LENGTH);
203    }
204    // is_valid_region_id at OTLP/JSON ingest already caps cloud_region at
205    // 64 chars, but Jaeger/Zipkin paths leave the field None. Funnel
206    // through the same control-char + truncate helper as code_* for
207    // defense-in-depth on hand-crafted inputs.
208    sanitize_optional_arc_str(&mut event.cloud_region, MAX_ID_LENGTH);
209    // A grouping value reaches the terminal and the dashboard, and its key
210    // is the label shown beside it. Both go through the same guard as the
211    // service name, and either one rejected or blank drops the whole pair:
212    // a half-pair renders as an unlabelled identity. Native JSON ingest
213    // deserializes `grouping` verbatim, so both sides are untrusted here.
214    event.grouping.retain_mut(|attr| {
215        let mut key = Some(Arc::clone(&attr.key));
216        let mut value = Some(Arc::clone(&attr.value));
217        sanitize_optional_arc_str(&mut key, MAX_SERVICE_LENGTH);
218        sanitize_optional_arc_str(&mut value, MAX_SERVICE_LENGTH);
219        match (key, value) {
220            (Some(k), Some(v)) if !k.is_empty() && !v.is_empty() => {
221                attr.key = k;
222                attr.value = v;
223                true
224            }
225            _ => false,
226        }
227    });
228    event
229        .grouping
230        .truncate(crate::config::MAX_GROUPING_ATTRIBUTES);
231    sanitize_optional_arc_str(&mut event.link_trace_id, MAX_ID_LENGTH);
232    truncate_arc_str(&mut event.service, MAX_SERVICE_LENGTH);
233    truncate_field(&mut event.operation, MAX_OPERATION_LENGTH);
234    // A messaging destination reaches the finding template verbatim, with
235    // no tokenizer or URL parser in between. Scheme-gated on purpose, see
236    // `docs/design/02-NORMALIZATION.md`.
237    if event.event_type == EventType::Messaging && event.target.contains("://") {
238        strip_endpoint_secrets(&mut event.target);
239    }
240    truncate_field(&mut event.target, MAX_TARGET_LENGTH);
241    sanitize_source_endpoint(&mut event.source.endpoint);
242    truncate_field(&mut event.source.method, MAX_SOURCE_LENGTH);
243    sanitize_optional_arc_str(&mut event.code_function, MAX_CODE_FUNCTION_LENGTH);
244    sanitize_optional_arc_str(&mut event.code_filepath, MAX_CODE_FILEPATH_LENGTH);
245    sanitize_optional_arc_str(&mut event.code_namespace, MAX_CODE_NAMESPACE_LENGTH);
246    sanitize_arc_str_vec(
247        &mut event.instrumentation_scopes,
248        MAX_SCOPE_NAME_LENGTH,
249        MAX_INSTRUMENTATION_SCOPES,
250    );
251}
252
253/// Source code location extracted from `OTel` `code.*` span attributes.
254///
255/// Not all instrumentation agents emit these attributes. When present,
256/// they allow findings to point to the exact function and file where the
257/// anti-pattern originates.
258#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
259pub struct CodeLocation {
260    #[serde(default, skip_serializing_if = "Option::is_none")]
261    pub function: Option<String>,
262    #[serde(default, skip_serializing_if = "Option::is_none")]
263    pub filepath: Option<String>,
264    #[serde(default, skip_serializing_if = "Option::is_none")]
265    pub lineno: Option<u32>,
266    #[serde(default, skip_serializing_if = "Option::is_none")]
267    pub namespace: Option<String>,
268}
269
270impl CodeLocation {
271    /// Returns `true` when all fields are `None`.
272    #[must_use]
273    pub fn is_empty(&self) -> bool {
274        self.function.is_none()
275            && self.filepath.is_none()
276            && self.lineno.is_none()
277            && self.namespace.is_none()
278    }
279
280    /// Render the location as `namespace.function (filepath:lineno)`,
281    /// omitting absent parts. Returns an empty string when the location
282    /// has nothing displayable, so callers can skip the line entirely
283    /// rather than printing a bare `Source:` label.
284    ///
285    /// Single source of truth for the CLI text output, the SARIF
286    /// `physicalLocation` message, and the TUI detail panel.
287    #[must_use]
288    pub fn display_string(&self) -> String {
289        let mut src = String::new();
290        if let Some(ref ns) = self.namespace {
291            src.push_str(ns);
292            src.push('.');
293        }
294        if let Some(ref func) = self.function {
295            src.push_str(func);
296        }
297        let has_name = !src.is_empty();
298        if let Some(ref fp) = self.filepath {
299            if has_name {
300                src.push_str(" (");
301            }
302            src.push_str(fp);
303            if let Some(ln) = self.lineno {
304                src.push(':');
305                src.push_str(&ln.to_string());
306            }
307            if has_name {
308                src.push(')');
309            }
310        }
311        src
312    }
313}
314
315/// One attribute captured for grouping, with the attribute name it came
316/// from so an operator can tell `tenant.id=acme` from `k8s.namespace.name=acme`.
317#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
318pub struct GroupingAttribute {
319    pub key: Arc<str>,
320    pub value: Arc<str>,
321}
322
323impl GroupingAttribute {
324    /// Borrowed `(key, value)`. Both halves, never the value alone:
325    /// `tenant.id=prod` and `k8s.namespace.name=prod` are two deployments,
326    /// not one.
327    #[must_use]
328    pub fn identity(&self) -> (&str, &str) {
329        (self.key.as_ref(), self.value.as_ref())
330    }
331}
332
333/// A single span event representing an I/O operation (SQL query, HTTP call).
334///
335/// Strings repeated across events of the same workload are stored as
336/// `Arc<str>` for cheap clones, per-event-unique strings stay as `String`.
337#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
338pub struct SpanEvent {
339    pub timestamp: String,
340    pub trace_id: String,
341    pub span_id: String,
342    #[serde(default, skip_serializing_if = "Option::is_none")]
343    pub parent_span_id: Option<String>,
344    /// Trace of the producer whose message triggered this span, from the `OTel`
345    /// span link of the nearest CONSUMER ancestor. The two traces stay
346    /// separate: this only makes the chain navigable across the broker.
347    #[serde(default, skip_serializing_if = "Option::is_none")]
348    pub link_trace_id: Option<Arc<str>>,
349    pub service: Arc<str>,
350    /// Attributes captured for grouping, in `[detection] grouping_attributes`
351    /// order, absent ones skipped. The first entry is the dimension identity
352    /// keys use, the rest are kept because a report must show the telemetry
353    /// it received, not only the value that won.
354    #[serde(default, skip_serializing_if = "Vec::is_empty")]
355    pub grouping: Vec<GroupingAttribute>,
356    /// Cloud region this span was emitted from, sourced from the `OTel`
357    /// `cloud.region` resource attribute (or span attribute as fallback).
358    ///
359    /// Used by the carbon scoring stage to apply per-region carbon
360    /// intensity coefficients in multi-region deployments. `None` when
361    /// the attribute is absent or when ingesting from formats that don't
362    /// carry it (`Jaeger`, `Zipkin`, raw `JSON` without explicit field).
363    #[serde(default, skip_serializing_if = "Option::is_none")]
364    pub cloud_region: Option<Arc<str>>,
365    #[serde(rename = "type")]
366    pub event_type: EventType,
367    /// SQL: `db.system` for OTLP (e.g. "postgresql"), verb for native JSON.
368    /// HTTP: request method (e.g. "GET").
369    pub operation: String,
370    pub target: String,
371    pub duration_us: u64,
372    pub source: EventSource,
373    #[serde(skip_serializing_if = "Option::is_none")]
374    pub status_code: Option<u16>,
375    /// Payload size in bytes: the HTTP response body
376    /// (`http.response.body.size`, legacy `http.response_content_length`) or
377    /// the published message body (`messaging.message.body.size`). Drives the
378    /// HTTP carbon size tiers and network transport estimation. `None` for SQL
379    /// spans or when the attribute is absent.
380    #[serde(default, skip_serializing_if = "Option::is_none")]
381    pub response_size_bytes: Option<u64>,
382    /// `OTel` `code.function` attribute.
383    #[serde(default, skip_serializing_if = "Option::is_none")]
384    pub code_function: Option<Arc<str>>,
385    /// `OTel` `code.filepath` attribute.
386    #[serde(default, skip_serializing_if = "Option::is_none")]
387    pub code_filepath: Option<Arc<str>>,
388    /// `OTel` `code.lineno` attribute.
389    #[serde(default, skip_serializing_if = "Option::is_none")]
390    pub code_lineno: Option<u32>,
391    /// `OTel` `code.namespace` attribute (e.g. Java package).
392    #[serde(default, skip_serializing_if = "Option::is_none")]
393    pub code_namespace: Option<Arc<str>>,
394    /// OpenTelemetry instrumentation scope names captured at ingest time:
395    /// the leaf span's scope at index 0, then each unique ancestor scope
396    /// up to a bounded depth. Lets framework detection identify Spring
397    /// Data, Hibernate, Quarkus, Helidon and friends from the
398    /// `io.opentelemetry.<module>` strings emitted by the agent, without
399    /// relying on user-code naming conventions.
400    #[serde(default, skip_serializing_if = "Vec::is_empty")]
401    pub instrumentation_scopes: Vec<Arc<str>>,
402}
403
404impl SpanEvent {
405    /// Value that separates deployments in reports and recurrence views.
406    #[must_use]
407    pub fn effective_grouping(&self) -> Option<&GroupingAttribute> {
408        self.grouping.first()
409    }
410
411    /// Just the value, for the identity keys that do not display it.
412    #[must_use]
413    pub fn grouping_value(&self) -> Option<&str> {
414        self.grouping.first().map(|g| g.value.as_ref())
415    }
416
417    /// Borrowed `(key, value)` for a `HashMap` partition key.
418    #[must_use]
419    pub fn grouping_identity(&self) -> Option<(&str, &str)> {
420        self.grouping.first().map(GroupingAttribute::identity)
421    }
422
423    /// Build a [`CodeLocation`] from this span's `code_*` fields.
424    ///
425    /// Returns `None` when all four fields are absent.
426    #[must_use]
427    pub fn code_location(&self) -> Option<CodeLocation> {
428        if self.code_function.is_none()
429            && self.code_filepath.is_none()
430            && self.code_lineno.is_none()
431            && self.code_namespace.is_none()
432        {
433            return None;
434        }
435        Some(CodeLocation {
436            function: self.code_function.as_deref().map(String::from),
437            filepath: self.code_filepath.as_deref().map(String::from),
438            lineno: self.code_lineno,
439            namespace: self.code_namespace.as_deref().map(String::from),
440        })
441    }
442}
443
444#[cfg(test)]
445mod tests {
446    use super::*;
447
448    #[test]
449    fn code_location_display_string_full() {
450        let loc = CodeLocation {
451            function: Some("OrderItemRepository.findByOrderId".to_string()),
452            filepath: Some("order-service/src/main/java/OrderItemRepository.java".to_string()),
453            lineno: Some(42),
454            namespace: Some("com.example.order.repository".to_string()),
455        };
456        assert_eq!(
457            loc.display_string(),
458            "com.example.order.repository.OrderItemRepository.findByOrderId \
459             (order-service/src/main/java/OrderItemRepository.java:42)"
460        );
461    }
462
463    #[test]
464    fn code_location_display_string_function_only() {
465        let loc = CodeLocation {
466            function: Some("fetchUser".to_string()),
467            filepath: None,
468            lineno: None,
469            namespace: None,
470        };
471        assert_eq!(loc.display_string(), "fetchUser");
472    }
473
474    #[test]
475    fn code_location_display_string_filepath_only() {
476        let loc = CodeLocation {
477            function: None,
478            filepath: Some("src/main.rs".to_string()),
479            lineno: Some(7),
480            namespace: None,
481        };
482        // No function or namespace, so no parentheses wrap; filepath
483        // still emits with its line number.
484        assert_eq!(loc.display_string(), "src/main.rs:7");
485    }
486
487    #[test]
488    fn code_location_display_string_empty_when_all_none() {
489        let loc = CodeLocation {
490            function: None,
491            filepath: None,
492            lineno: None,
493            namespace: None,
494        };
495        assert_eq!(loc.display_string(), "");
496        assert!(loc.is_empty());
497    }
498
499    fn sample_sql_json() -> &'static str {
500        r#"{
501            "timestamp": "2025-07-10T14:32:01.123Z",
502            "trace_id": "abc123-def456",
503            "span_id": "span-789",
504            "service": "order-svc",
505            "type": "sql",
506            "operation": "SELECT",
507            "target": "SELECT * FROM order_item WHERE order_id = 42",
508            "duration_us": 1200,
509            "source": {
510                "endpoint": "POST /api/orders/42/submit",
511                "method": "OrderService::create_order"
512            }
513        }"#
514    }
515
516    fn sample_http_json() -> &'static str {
517        r#"{
518            "timestamp": "2025-07-10T14:32:01.456Z",
519            "trace_id": "abc123-def456",
520            "span_id": "span-790",
521            "service": "order-svc",
522            "type": "http_out",
523            "operation": "GET",
524            "target": "http://user-svc:5000/api/users/user-123",
525            "duration_us": 15000,
526            "status_code": 200,
527            "source": {
528                "endpoint": "POST /api/orders/42/submit",
529                "method": "OrderService::create_order"
530            }
531        }"#
532    }
533
534    #[test]
535    fn deserialize_sql_event() {
536        let event: SpanEvent = serde_json::from_str(sample_sql_json()).unwrap();
537        assert_eq!(event.event_type, EventType::Sql);
538        assert_eq!(event.trace_id, "abc123-def456");
539        assert_eq!(&*event.service, "order-svc");
540        assert_eq!(event.target, "SELECT * FROM order_item WHERE order_id = 42");
541        assert_eq!(event.duration_us, 1200);
542        assert!(event.status_code.is_none());
543    }
544
545    #[test]
546    fn deserialize_http_event() {
547        let event: SpanEvent = serde_json::from_str(sample_http_json()).unwrap();
548        assert_eq!(event.event_type, EventType::HttpOut);
549        assert_eq!(event.status_code, Some(200));
550        assert_eq!(event.source.endpoint, "POST /api/orders/42/submit");
551    }
552
553    #[test]
554    fn serde_roundtrip_sql() {
555        let event: SpanEvent = serde_json::from_str(sample_sql_json()).unwrap();
556        let json = serde_json::to_string(&event).unwrap();
557        let back: SpanEvent = serde_json::from_str(&json).unwrap();
558        assert_eq!(event, back);
559    }
560
561    #[test]
562    fn serde_roundtrip_http() {
563        let event: SpanEvent = serde_json::from_str(sample_http_json()).unwrap();
564        let json = serde_json::to_string(&event).unwrap();
565        let back: SpanEvent = serde_json::from_str(&json).unwrap();
566        assert_eq!(event, back);
567    }
568
569    #[test]
570    fn sql_event_omits_status_code_in_json() {
571        let event: SpanEvent = serde_json::from_str(sample_sql_json()).unwrap();
572        let json = serde_json::to_string(&event).unwrap();
573        assert!(!json.contains("status_code"));
574    }
575
576    #[test]
577    fn deserialize_event_without_cloud_region_defaults_to_none() {
578        let event: SpanEvent = serde_json::from_str(sample_sql_json()).unwrap();
579        assert!(event.cloud_region.is_none());
580    }
581
582    #[test]
583    fn deserialize_event_without_grouping_defaults_to_empty() {
584        let event: SpanEvent = serde_json::from_str(sample_sql_json()).unwrap();
585        assert!(event.grouping.is_empty());
586        assert_eq!(event.grouping_value(), None);
587    }
588
589    /// The identity value is the FIRST captured attribute, config order, and
590    /// the others stay readable rather than being dropped.
591    #[test]
592    fn grouping_value_is_the_first_captured_attribute() {
593        let mut event = make_event_with_field("service", "svc");
594        event.grouping = vec![
595            GroupingAttribute {
596                key: Arc::from("k8s.namespace.name"),
597                value: Arc::from("prod-eu"),
598            },
599            GroupingAttribute {
600                key: Arc::from("service.namespace"),
601                value: Arc::from("payments"),
602            },
603        ];
604        assert_eq!(event.grouping_value(), Some("prod-eu"));
605        assert_eq!(
606            event.effective_grouping().map(|g| g.key.as_ref()),
607            Some("k8s.namespace.name"),
608            "the operator must be able to tell which attribute won"
609        );
610        assert_eq!(event.grouping[1].value.as_ref(), "payments");
611
612        event.grouping.clear();
613        assert_eq!(event.grouping_value(), None);
614    }
615
616    #[test]
617    fn serde_roundtrip_with_grouping() {
618        let mut value: serde_json::Value = serde_json::from_str(sample_sql_json()).unwrap();
619        value["grouping"] = serde_json::json!([
620            {"key": "tenant.id", "value": "acme"},
621            {"key": "k8s.namespace.name", "value": "shared-cluster"},
622        ]);
623
624        let event: SpanEvent = serde_json::from_value(value).unwrap();
625        assert_eq!(event.grouping_value(), Some("acme"));
626        assert_eq!(event.grouping.len(), 2);
627
628        let serialized = serde_json::to_string(&event).unwrap();
629        let back: SpanEvent = serde_json::from_str(&serialized).unwrap();
630        assert_eq!(back, event);
631    }
632
633    #[test]
634    fn serde_roundtrip_with_cloud_region() {
635        let json = r#"{
636            "timestamp": "2025-07-10T14:32:01.123Z",
637            "trace_id": "abc123-def456",
638            "span_id": "span-789",
639            "service": "order-svc",
640            "cloud_region": "eu-west-3",
641            "type": "sql",
642            "operation": "SELECT",
643            "target": "SELECT 1",
644            "duration_us": 1200,
645            "source": {
646                "endpoint": "POST /api/orders/42/submit",
647                "method": "OrderService::create_order"
648            }
649        }"#;
650        let event: SpanEvent = serde_json::from_str(json).unwrap();
651        assert_eq!(event.cloud_region.as_deref(), Some("eu-west-3"));
652        let serialized = serde_json::to_string(&event).unwrap();
653        assert!(serialized.contains("\"cloud_region\":\"eu-west-3\""));
654        let back: SpanEvent = serde_json::from_str(&serialized).unwrap();
655        assert_eq!(event, back);
656    }
657
658    #[test]
659    fn cloud_region_omitted_when_none() {
660        let event: SpanEvent = serde_json::from_str(sample_sql_json()).unwrap();
661        let json = serde_json::to_string(&event).unwrap();
662        assert!(!json.contains("cloud_region"));
663    }
664
665    #[test]
666    fn sanitize_id_short_unchanged() {
667        assert_eq!(sanitize_id("abc-123"), "abc-123");
668    }
669
670    #[test]
671    fn sanitize_id_truncates_long() {
672        let long = "a".repeat(200);
673        let result = sanitize_id(&long);
674        assert_eq!(result.len(), MAX_ID_LENGTH);
675    }
676
677    #[test]
678    fn sanitize_id_exact_length_unchanged() {
679        let exact = "b".repeat(MAX_ID_LENGTH);
680        assert_eq!(sanitize_id(&exact), exact);
681    }
682
683    #[test]
684    fn sanitize_id_multibyte_no_panic() {
685        // 4-byte emoji repeated to exceed MAX_ID_LENGTH (200 bytes total)
686        let id = "\u{1F600}".repeat(50);
687        assert!(id.len() > MAX_ID_LENGTH);
688        let result = sanitize_id(&id);
689        assert!(result.len() <= MAX_ID_LENGTH);
690        // Must be valid UTF-8 (would panic in .to_string() if not)
691        assert!(result.is_char_boundary(result.len()));
692    }
693
694    #[test]
695    fn sanitize_id_two_byte_chars_no_panic() {
696        // 2-byte UTF-8 chars: é is 2 bytes
697        let id = "é".repeat(100); // 200 bytes
698        let result = sanitize_id(&id);
699        assert!(result.len() <= MAX_ID_LENGTH);
700        // Result should contain whole chars only (even byte count for 2-byte chars)
701        assert_eq!(result.len() % 2, 0);
702    }
703
704    // ------------------------------------------------------------------
705    // sanitize_span_event
706    // ------------------------------------------------------------------
707
708    fn make_event_with_field(field: &str, value: &str) -> SpanEvent {
709        let mut event: SpanEvent = serde_json::from_str(sample_sql_json()).unwrap();
710        match field {
711            "service" => event.service = Arc::from(value),
712            "operation" => event.operation = value.to_string(),
713            "target" => event.target = value.to_string(),
714            "endpoint" => event.source.endpoint = value.to_string(),
715            "method" => event.source.method = value.to_string(),
716            _ => panic!("unknown field: {field}"),
717        }
718        event
719    }
720
721    #[test]
722    fn sanitize_bounds_link_trace_id() {
723        let mut event = make_event_with_field("service", "svc");
724        event.link_trace_id = Some(Arc::from("x".repeat(500)));
725        sanitize_span_event(&mut event);
726        assert_eq!(
727            event.link_trace_id.as_deref().map(str::len),
728            Some(MAX_ID_LENGTH)
729        );
730
731        event.link_trace_id = Some(Arc::from("trace\u{7}id"));
732        sanitize_span_event(&mut event);
733        assert_eq!(event.link_trace_id, None);
734    }
735
736    #[test]
737    fn sanitize_strips_credentials_from_a_messaging_destination() {
738        // A destination lands verbatim in the finding template, which
739        // reaches the archive, the dashboard and the terminal.
740        let mut event = make_event_with_field("target", "amqp://svc:hunter2@rabbit:5672/orders");
741        event.event_type = EventType::Messaging;
742        sanitize_span_event(&mut event);
743        assert!(
744            !event.target.contains("hunter2"),
745            "credential survived: {}",
746            event.target
747        );
748        assert!(event.target.contains("rabbit"), "host must survive");
749    }
750
751    #[test]
752    fn sanitize_keeps_an_sqs_arn_intact() {
753        // The account id is deliberately preserved: it is what keeps two
754        // AWS accounts from merging into one template.
755        let arn = "arn:aws:sqs:eu-west-3:123456789012:orders";
756        let mut event = make_event_with_field("target", arn);
757        event.event_type = EventType::Messaging;
758        sanitize_span_event(&mut event);
759        assert_eq!(event.target, arn);
760    }
761
762    #[test]
763    fn sanitize_keeps_scheme_less_destinations_verbatim() {
764        // These are names, not URLs. Running the authority parser over
765        // them merges distinct destinations into one finding target.
766        for name in [
767            "ORDERS@QM1",   // IBM MQ queue@qmgr
768            "logs.#",       // RabbitMQ routing key
769            "my.topic?v=2", // a literal '?' in a queue name
770            "orders",
771        ] {
772            let mut event = make_event_with_field("target", name);
773            event.event_type = EventType::Messaging;
774            sanitize_span_event(&mut event);
775            assert_eq!(event.target, name, "rewrote a legitimate destination");
776        }
777    }
778
779    #[test]
780    fn sanitize_truncates_long_service() {
781        let mut event = make_event_with_field("service", &"x".repeat(500));
782        sanitize_span_event(&mut event);
783        assert!(event.service.len() <= MAX_SERVICE_LENGTH);
784    }
785
786    /// A grouping value reaches the terminal and the dashboard, and its key
787    /// comes from operator config: both are bounded, and a control character
788    /// drops the whole pair rather than half of it.
789    #[test]
790    fn sanitize_bounds_and_rejects_grouping_values() {
791        let mut event = make_event_with_field("service", "svc");
792        event.grouping = vec![
793            GroupingAttribute {
794                key: Arc::from("k8s.namespace.name"),
795                value: Arc::from("s".repeat(500)),
796            },
797            GroupingAttribute {
798                key: Arc::from("tenant.id"),
799                value: Arc::from("prod\u{7}hidden"),
800            },
801            GroupingAttribute {
802                key: Arc::from("service.namespace"),
803                value: Arc::from(""),
804            },
805        ];
806
807        sanitize_span_event(&mut event);
808
809        assert_eq!(event.grouping.len(), 1, "{:?}", event.grouping);
810        assert_eq!(
811            event.grouping_value().map(str::len),
812            Some(MAX_SERVICE_LENGTH),
813            "an over-long value is truncated, not dropped"
814        );
815    }
816
817    #[test]
818    fn sanitize_caps_oversize_grouping_vec() {
819        let mut event = make_event_with_field("service", "svc");
820        event.grouping = (0..32)
821            .map(|i| GroupingAttribute {
822                key: Arc::from(format!("tenant.dimension.{i}")),
823                value: Arc::from(format!("value-{i}")),
824            })
825            .collect();
826
827        sanitize_span_event(&mut event);
828
829        assert_eq!(event.grouping.len(), crate::config::MAX_GROUPING_ATTRIBUTES);
830        assert_eq!(event.grouping[0].key.as_ref(), "tenant.dimension.0");
831    }
832
833    #[test]
834    fn sanitize_truncates_long_operation() {
835        let mut event = make_event_with_field("operation", &"x".repeat(500));
836        sanitize_span_event(&mut event);
837        assert!(event.operation.len() <= MAX_OPERATION_LENGTH);
838    }
839
840    #[test]
841    fn sanitize_truncates_long_target() {
842        let mut event = make_event_with_field("target", &"x".repeat(100_000));
843        sanitize_span_event(&mut event);
844        assert!(event.target.len() <= MAX_TARGET_LENGTH);
845    }
846
847    #[test]
848    fn sanitize_truncates_long_endpoint() {
849        let mut event = make_event_with_field("endpoint", &"x".repeat(1000));
850        sanitize_span_event(&mut event);
851        assert!(event.source.endpoint.len() <= MAX_SOURCE_LENGTH);
852    }
853
854    #[test]
855    fn sanitize_truncates_long_method() {
856        let mut event = make_event_with_field("method", &"x".repeat(1000));
857        sanitize_span_event(&mut event);
858        assert!(event.source.method.len() <= MAX_SOURCE_LENGTH);
859    }
860
861    #[test]
862    fn sanitize_strips_endpoint_query_and_userinfo() {
863        for (raw, expected) in [
864            (
865                "https://svc/api/users?token=SECRET",
866                "https://svc/api/users",
867            ),
868            ("http://user:pass@host/cb?code=abc#frag", "http://host/cb"),
869            ("/api/reset?token=SECRET", "/api/reset"),
870            // '@' or '://' in the query is not userinfo (path-less URLs).
871            ("http://svc?filter=a@b&token=SECRET", "http://svc"),
872            ("http://host?a=x@y", "http://host"),
873            ("//user:tok@bmc/cb?redirect=https://app", "//bmc/cb"),
874            ("user:pass@host/cb?next=https://x", "host/cb"),
875            // Scheme-less and protocol-relative userinfo still stripped.
876            ("user:pass@host/path", "host/path"),
877            ("//svc-user:tok@order-svc/cb", "//order-svc/cb"),
878            // '@' outside an authority (path) is not userinfo.
879            ("/users/a@b.example/orders", "/users/a@b.example/orders"),
880            // Common cases: route templates and query-less URLs are untouched.
881            ("POST /api/orders/{id}", "POST /api/orders/{id}"),
882            (
883                "http://order-svc/api/orders/42",
884                "http://order-svc/api/orders/42",
885            ),
886        ] {
887            let mut event = make_event_with_field("endpoint", raw);
888            sanitize_span_event(&mut event);
889            assert_eq!(event.source.endpoint, expected, "input: {raw}");
890        }
891    }
892
893    #[test]
894    fn sanitize_short_fields_unchanged() {
895        let mut event: SpanEvent = serde_json::from_str(sample_sql_json()).unwrap();
896        let before = event.clone();
897        sanitize_span_event(&mut event);
898        assert_eq!(event, before);
899    }
900
901    #[test]
902    fn sanitize_multibyte_char_boundary() {
903        // Service with 4-byte emojis that would split mid-char at MAX_SERVICE_LENGTH
904        let mut event = make_event_with_field("service", &"\u{1F600}".repeat(100));
905        sanitize_span_event(&mut event);
906        assert!(event.service.len() <= MAX_SERVICE_LENGTH);
907        // Must be valid UTF-8 (String invariant guarantees this, but verify)
908        assert!(event.service.is_char_boundary(event.service.len()));
909    }
910
911    // ------------------------------------------------------------------
912    // CodeLocation and code_* fields
913    // ------------------------------------------------------------------
914
915    #[test]
916    fn code_location_is_empty_when_all_none() {
917        let loc = CodeLocation {
918            function: None,
919            filepath: None,
920            lineno: None,
921            namespace: None,
922        };
923        assert!(loc.is_empty());
924    }
925
926    #[test]
927    fn code_location_not_empty_with_function() {
928        let loc = CodeLocation {
929            function: Some("processItems".to_string()),
930            filepath: None,
931            lineno: None,
932            namespace: None,
933        };
934        assert!(!loc.is_empty());
935    }
936
937    #[test]
938    fn span_event_code_location_none_when_all_absent() {
939        let event: SpanEvent = serde_json::from_str(sample_sql_json()).unwrap();
940        assert!(event.code_location().is_none());
941    }
942
943    #[test]
944    fn span_event_code_location_some_when_present() {
945        let mut event: SpanEvent = serde_json::from_str(sample_sql_json()).unwrap();
946        event.code_function = Some(Arc::from("processItems"));
947        event.code_filepath = Some(Arc::from("src/OrderService.java"));
948        event.code_lineno = Some(42);
949        event.code_namespace = Some(Arc::from("com.example"));
950        let loc = event.code_location().unwrap();
951        assert_eq!(loc.function.as_deref(), Some("processItems"));
952        assert_eq!(loc.filepath.as_deref(), Some("src/OrderService.java"));
953        assert_eq!(loc.lineno, Some(42));
954        assert_eq!(loc.namespace.as_deref(), Some("com.example"));
955    }
956
957    #[test]
958    fn serde_roundtrip_with_code_fields() {
959        let json = r#"{
960            "timestamp": "2025-07-10T14:32:01.123Z",
961            "trace_id": "abc123",
962            "span_id": "span-1",
963            "service": "svc",
964            "type": "sql",
965            "operation": "SELECT",
966            "target": "SELECT 1",
967            "duration_us": 100,
968            "source": { "endpoint": "GET /test", "method": "test" },
969            "code_function": "processItems",
970            "code_filepath": "src/OrderService.java",
971            "code_lineno": 42,
972            "code_namespace": "com.example"
973        }"#;
974        let event: SpanEvent = serde_json::from_str(json).unwrap();
975        assert_eq!(event.code_function.as_deref(), Some("processItems"));
976        assert_eq!(event.code_lineno, Some(42));
977        let serialized = serde_json::to_string(&event).unwrap();
978        let back: SpanEvent = serde_json::from_str(&serialized).unwrap();
979        assert_eq!(event, back);
980    }
981
982    #[test]
983    fn code_fields_omitted_when_none() {
984        let event: SpanEvent = serde_json::from_str(sample_sql_json()).unwrap();
985        let json = serde_json::to_string(&event).unwrap();
986        assert!(!json.contains("code_function"));
987        assert!(!json.contains("code_filepath"));
988        assert!(!json.contains("code_lineno"));
989        assert!(!json.contains("code_namespace"));
990    }
991
992    #[test]
993    fn sanitize_truncates_long_code_function() {
994        let mut event: SpanEvent = serde_json::from_str(sample_sql_json()).unwrap();
995        event.code_function = Some(Arc::from("x".repeat(1000)));
996        sanitize_span_event(&mut event);
997        assert!(event.code_function.as_ref().unwrap().len() <= MAX_CODE_FUNCTION_LENGTH);
998    }
999
1000    #[test]
1001    fn sanitize_truncates_long_code_filepath() {
1002        let mut event: SpanEvent = serde_json::from_str(sample_sql_json()).unwrap();
1003        event.code_filepath = Some(Arc::from("x".repeat(2000)));
1004        sanitize_span_event(&mut event);
1005        assert!(event.code_filepath.as_ref().unwrap().len() <= MAX_CODE_FILEPATH_LENGTH);
1006    }
1007
1008    #[test]
1009    fn sanitize_drops_code_function_with_control_char() {
1010        let mut event: SpanEvent = serde_json::from_str(sample_sql_json()).unwrap();
1011        event.code_function = Some(Arc::from("findItems\x1b[31m"));
1012        sanitize_span_event(&mut event);
1013        assert!(event.code_function.is_none());
1014    }
1015
1016    #[test]
1017    fn sanitize_drops_code_filepath_with_newline() {
1018        let mut event: SpanEvent = serde_json::from_str(sample_sql_json()).unwrap();
1019        event.code_filepath = Some(Arc::from("src/main.rs\nINJECT"));
1020        sanitize_span_event(&mut event);
1021        assert!(event.code_filepath.is_none());
1022    }
1023
1024    #[test]
1025    fn sanitize_drops_code_namespace_with_del() {
1026        let mut event: SpanEvent = serde_json::from_str(sample_sql_json()).unwrap();
1027        event.code_namespace = Some(Arc::from("com.foo\x7fX"));
1028        sanitize_span_event(&mut event);
1029        assert!(event.code_namespace.is_none());
1030    }
1031
1032    #[test]
1033    fn sanitize_keeps_clean_code_fields() {
1034        let mut event: SpanEvent = serde_json::from_str(sample_sql_json()).unwrap();
1035        event.code_function = Some(Arc::from("findItems"));
1036        event.code_filepath = Some(Arc::from("src/main/java/com/foo/Repo.java"));
1037        event.code_namespace = Some(Arc::from("com.foo.Repo"));
1038        sanitize_span_event(&mut event);
1039        assert_eq!(event.code_function.as_deref(), Some("findItems"));
1040        assert_eq!(
1041            event.code_filepath.as_deref(),
1042            Some("src/main/java/com/foo/Repo.java")
1043        );
1044        assert_eq!(event.code_namespace.as_deref(), Some("com.foo.Repo"));
1045    }
1046
1047    // ── instrumentation_scopes sanitization ─────────────────────
1048
1049    #[test]
1050    fn sanitize_truncates_long_instrumentation_scope() {
1051        let mut event: SpanEvent = serde_json::from_str(sample_sql_json()).unwrap();
1052        event.instrumentation_scopes = vec![Arc::from("x".repeat(1024))];
1053        sanitize_span_event(&mut event);
1054        assert_eq!(event.instrumentation_scopes.len(), 1);
1055        assert!(event.instrumentation_scopes[0].len() <= MAX_SCOPE_NAME_LENGTH);
1056    }
1057
1058    #[test]
1059    fn sanitize_drops_instrumentation_scope_with_control_char() {
1060        let mut event: SpanEvent = serde_json::from_str(sample_sql_json()).unwrap();
1061        event.instrumentation_scopes = vec![
1062            Arc::from("io.opentelemetry.spring-data"),
1063            Arc::from("\x1b[31mio.opentelemetry.evil\x1b[0m"),
1064            Arc::from("io.opentelemetry.hibernate"),
1065        ];
1066        sanitize_span_event(&mut event);
1067        let scopes: Vec<&str> = event
1068            .instrumentation_scopes
1069            .iter()
1070            .map(AsRef::as_ref)
1071            .collect();
1072        assert_eq!(
1073            scopes,
1074            vec!["io.opentelemetry.spring-data", "io.opentelemetry.hibernate"]
1075        );
1076    }
1077
1078    #[test]
1079    fn sanitize_caps_oversize_instrumentation_scopes_vec() {
1080        let mut event: SpanEvent = serde_json::from_str(sample_sql_json()).unwrap();
1081        event.instrumentation_scopes = (0..32)
1082            .map(|i| Arc::from(format!("io.opentelemetry.scope-{i}")))
1083            .collect();
1084        sanitize_span_event(&mut event);
1085        assert_eq!(
1086            event.instrumentation_scopes.len(),
1087            MAX_INSTRUMENTATION_SCOPES
1088        );
1089        assert_eq!(
1090            &*event.instrumentation_scopes[0],
1091            "io.opentelemetry.scope-0"
1092        );
1093    }
1094}