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