diff --git a/Cargo.lock b/Cargo.lock index dff4e7c..2de5df9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2411,7 +2411,7 @@ dependencies = [ [[package]] name = "trapfall_alert" -version = "0.1.3" +version = "0.1.4" dependencies = [ "chrono", "serde", @@ -2424,7 +2424,7 @@ dependencies = [ [[package]] name = "trapfall_core" -version = "0.1.3" +version = "0.1.4" dependencies = [ "anyhow", "argon2", @@ -2444,7 +2444,7 @@ dependencies = [ [[package]] name = "trapfall_dashboard" -version = "0.1.3" +version = "0.1.4" dependencies = [ "axum", "rust-embed", @@ -2459,7 +2459,7 @@ dependencies = [ [[package]] name = "trapfall_db" -version = "0.1.3" +version = "0.1.4" dependencies = [ "anyhow", "async-trait", @@ -2477,7 +2477,7 @@ dependencies = [ [[package]] name = "trapfall_ingest" -version = "0.1.3" +version = "0.1.4" dependencies = [ "anyhow", "axum", @@ -2493,7 +2493,7 @@ dependencies = [ [[package]] name = "trapfall_mcp" -version = "0.1.3" +version = "0.1.4" dependencies = [ "anyhow", "serde", @@ -2508,7 +2508,7 @@ dependencies = [ [[package]] name = "trapfall_proto" -version = "0.1.3" +version = "0.1.4" dependencies = [ "chrono", "serde", @@ -2519,7 +2519,7 @@ dependencies = [ [[package]] name = "trapfall_search" -version = "0.1.3" +version = "0.1.4" dependencies = [ "anyhow", "sqlx", @@ -2531,7 +2531,7 @@ dependencies = [ [[package]] name = "trapfalld" -version = "0.1.3" +version = "0.1.4" dependencies = [ "anyhow", "axum", diff --git a/crates/trapfall-proto/src/lib.rs b/crates/trapfall-proto/src/lib.rs index 2a5a48b..e1acb36 100644 --- a/crates/trapfall-proto/src/lib.rs +++ b/crates/trapfall-proto/src/lib.rs @@ -264,6 +264,104 @@ pub struct StoredEvent { pub received_at: String, } +// ── Transaction / Span (Sentry Protocol) ────────────────────────────────── + +/// Span status in a distributed trace. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SpanStatus { + Ok, + DeadlineExceeded, + Unauthenticated, + PermissionDenied, + NotFound, + AlreadyExists, + ResourceExhausted, + Cancelled, + DataLoss, + Unknown, + Aborted, + OutOfRange, + Unimplemented, + Internal, + Unavailable, + #[serde(other)] + Other, +} + +impl Default for SpanStatus { + fn default() -> Self { + Self::Unknown + } +} + +/// A single span within a transaction trace. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Span { + #[serde(skip_serializing_if = "Option::is_none")] + pub span_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub trace_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub parent_span_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub op: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub status: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub start_timestamp: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub timestamp: Option, + #[serde(default)] + pub tags: serde_json::Value, + #[serde(default)] + pub data: serde_json::Value, +} + +/// A transaction envelope item (performance trace). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Transaction { + pub event_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub transaction: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub start_timestamp: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub timestamp: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub release: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub environment: Option, + #[serde(default)] + pub spans: Vec, + #[serde(default)] + pub contexts: serde_json::Value, + #[serde(default)] + pub tags: serde_json::Value, + #[serde(default)] + pub extra: serde_json::Value, + #[serde(skip_serializing_if = "Option::is_none")] + pub platform: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub server_name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub user: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub request: Option, +} + +/// Aggregated result of parsing a single Sentry envelope. +/// +/// Replaces the old `parse_envelope_text() -> Vec` return type. +/// Each new envelope type (session, attachment) adds a field here. +#[derive(Debug, Clone, Default)] +pub struct ParsedEnvelope { + pub events: Vec, + pub transactions: Vec, +} + // ── Alert Rule Types ─────────────────────────────────────────────────── #[derive(Debug, Clone, Serialize, Deserialize)] @@ -502,4 +600,176 @@ mod tests { assert!(masked.ends_with(suffix), "masked must preserve host/path, got {masked} for {dsn}"); } } + + // ── Transaction / Span tests ─────────────────────────────────────── + + #[test] + fn span_status_default_is_unknown() { + assert_eq!(SpanStatus::default(), SpanStatus::Unknown); + } + + #[test] + fn serde_span_status_snake_case() { + let status = SpanStatus::DeadlineExceeded; + let json = serde_json::to_string(&status).unwrap(); + assert_eq!(json, r#""deadline_exceeded""#); + let back: SpanStatus = serde_json::from_str(&json).unwrap(); + assert_eq!(back, status); + } + + #[test] + fn serde_span_status_other_deserialize() { + let back: SpanStatus = serde_json::from_str(r#""custom_value""#).unwrap(); + assert_eq!(back, SpanStatus::Other); + } + + #[test] + fn serde_span_minimal() { + let span = Span { + span_id: Some("abc123".into()), + trace_id: None, + parent_span_id: None, + op: Some("db.sql.select".into()), + description: None, + status: Some(SpanStatus::Ok), + start_timestamp: Some(1.0), + timestamp: Some(2.5), + tags: serde_json::Value::Null, + data: serde_json::Value::Null, + }; + let json = serde_json::to_string(&span).unwrap(); + let back: Span = serde_json::from_str(&json).unwrap(); + assert_eq!(back.span_id.unwrap(), "abc123"); + assert_eq!(back.op.unwrap(), "db.sql.select"); + assert_eq!(back.status.unwrap(), SpanStatus::Ok); + assert_eq!(back.start_timestamp.unwrap(), 1.0); + } + + #[test] + fn serde_span_omits_none_fields() { + let span = Span { + span_id: None, + trace_id: None, + parent_span_id: None, + op: None, + description: None, + status: None, + start_timestamp: None, + timestamp: None, + tags: serde_json::Value::Null, + data: serde_json::Value::Null, + }; + let json = serde_json::to_string(&span).unwrap(); + assert_eq!(json, r#"{"tags":null,"data":null}"#); + } + + #[test] + fn serde_transaction_with_spans() { + let tx = Transaction { + event_id: "tx-1".into(), + transaction: Some("GET /api/users".into()), + start_timestamp: Some(1.0), + timestamp: Some(3.5), + release: Some("app@1.0.0".into()), + environment: Some("production".into()), + spans: vec![Span { + span_id: Some("sp-1".into()), + trace_id: Some("tr-1".into()), + parent_span_id: None, + op: Some("http.server".into()), + description: None, + status: Some(SpanStatus::Ok), + start_timestamp: Some(1.0), + timestamp: Some(3.5), + tags: serde_json::Value::Null, + data: serde_json::Value::Null, + }], + contexts: serde_json::Value::Null, + tags: serde_json::Value::Null, + extra: serde_json::Value::Null, + platform: None, + server_name: None, + user: None, + request: None, + }; + let json = serde_json::to_string(&tx).unwrap(); + let back: Transaction = serde_json::from_str(&json).unwrap(); + assert_eq!(back.event_id, "tx-1"); + assert_eq!(back.transaction.unwrap(), "GET /api/users"); + assert_eq!(back.release.unwrap(), "app@1.0.0"); + assert_eq!(back.spans.len(), 1); + assert_eq!(back.spans[0].op.as_deref(), Some("http.server")); + } + + #[test] + fn serde_transaction_minimal() { + let tx = Transaction { + event_id: "minimal".into(), + transaction: None, + start_timestamp: None, + timestamp: None, + release: None, + environment: None, + spans: vec![], + contexts: serde_json::Value::Null, + tags: serde_json::Value::Null, + extra: serde_json::Value::Null, + platform: None, + server_name: None, + user: None, + request: None, + }; + let json = serde_json::to_string(&tx).unwrap(); + let back: Transaction = serde_json::from_str(&json).unwrap(); + assert_eq!(back.event_id, "minimal"); + assert!(back.spans.is_empty()); + assert!(back.transaction.is_none()); + } + + #[test] + fn parsed_envelope_default_is_empty() { + let env = ParsedEnvelope::default(); + assert!(env.events.is_empty()); + assert!(env.transactions.is_empty()); + } + + #[test] + fn parsed_envelope_holds_mixed_types() { + let mut env = ParsedEnvelope::default(); + env.events.push(Event { + event_id: "e1".into(), + level: Level::Error, + platform: Some("rust".into()), + release: None, + environment: None, + server_name: None, + breadcrumbs: Breadcrumbs::default(), + exception: None, + message: Some("err".into()), + tags: serde_json::Value::Null, + extra: serde_json::Value::Null, + contexts: serde_json::Value::Null, + timestamp: None, + }); + env.transactions.push(Transaction { + event_id: "t1".into(), + transaction: Some("GET /".into()), + start_timestamp: None, + timestamp: None, + release: None, + environment: None, + spans: vec![], + contexts: serde_json::Value::Null, + tags: serde_json::Value::Null, + extra: serde_json::Value::Null, + platform: None, + server_name: None, + user: None, + request: None, + }); + assert_eq!(env.events.len(), 1); + assert_eq!(env.transactions.len(), 1); + assert_eq!(env.events[0].event_id, "e1"); + assert_eq!(env.transactions[0].event_id, "t1"); + } }