From 76a3fefcc22d4cfb34abd99eb3ff671e56a5a65f Mon Sep 17 00:00:00 2001 From: "Anaz S. Aji" Date: Wed, 1 Jul 2026 13:36:36 +0700 Subject: [PATCH] fix: drop events when message is an object (sentry-dart/Flutter SDK) Root cause: the Event struct declared message as Option, but modern Sentry SDKs (Dart/Flutter, JS, etc.) send the Message interface as an object {"formatted":"...","message":"..."} rather than a plain string. serde failed with "invalid type: map, expected a string" and, because the envelope parser used `if let Ok(...)` and silently discarded the Err, the ENTIRE event was dropped. Reproduced against a real sentry-dart envelope: "Parsed 0 events" even though requests reached the server. This matched production logs showing repeated "Parsed 0 events, 0 transactions" with no error. Fix: - proto: custom deserialize_message() accepts both a plain string and the Message object, collapsing to the formatted display string (prefers "formatted", falls back to "message"). - ingest: replace silent `if let Ok` drops with explicit match arms that tracing::warn! the deserialize error (item type + message), so future field mismatches surface in logs instead of vanishing. Unknown item types now log at debug instead of being swallowed. Tests: - parse_event_message_as_object_sentry_dart_format - parse_event_message_object_falls_back_to_message_key --- crates/trapfall-ingest/src/envelope.rs | 65 ++++++++++++++++++-------- crates/trapfall-proto/src/lib.rs | 25 ++++++++++ 2 files changed, 71 insertions(+), 19 deletions(-) diff --git a/crates/trapfall-ingest/src/envelope.rs b/crates/trapfall-ingest/src/envelope.rs index c8dc158..780c172 100644 --- a/crates/trapfall-ingest/src/envelope.rs +++ b/crates/trapfall-ingest/src/envelope.rs @@ -143,27 +143,27 @@ fn parse_envelope_binary(data: &[u8]) -> Result { }; match hdr.item_type.as_str() { - "event" => { - if let Ok(event) = serde_json::from_str::(body_text) { - result.events.push(event); + "event" => match serde_json::from_str::(body_text) { + Ok(event) => result.events.push(event), + Err(e) => tracing::warn!(item = "event", error = %e, "dropping event: deserialize failed"), + }, + "transaction" => match serde_json::from_str::(body_text) { + Ok(txn) => result.transactions.push(txn), + Err(e) => { + tracing::warn!(item = "transaction", error = %e, "dropping transaction: deserialize failed") } - } - "transaction" => { - if let Ok(txn) = serde_json::from_str::(body_text) { - result.transactions.push(txn); - } - } - "session" => { - if let Ok(session) = serde_json::from_str::(body_text) { - result.session_updates.push(session); - } - } - "sessions" => { - if let Ok(aggregates) = serde_json::from_str::(body_text) { - result.session_aggregates.push(aggregates); + }, + "session" => match serde_json::from_str::(body_text) { + Ok(session) => result.session_updates.push(session), + Err(e) => tracing::warn!(item = "session", error = %e, "dropping session: deserialize failed"), + }, + "sessions" => match serde_json::from_str::(body_text) { + Ok(aggregates) => result.session_aggregates.push(aggregates), + Err(e) => { + tracing::warn!(item = "sessions", error = %e, "dropping session aggregates: deserialize failed") } - } - _ => {} // Ignore unknown item types. + }, + _ => tracing::debug!(item_type = %hdr.item_type, "ignoring unknown envelope item type"), } } } @@ -298,6 +298,33 @@ mod tests { assert_eq!(result.events[0].message.as_deref(), Some("hello")); } + #[test] + fn parse_event_message_as_object_sentry_dart_format() { + // Modern SDKs (Dart/Flutter, JS, etc.) send `message` as the + // Sentry Message interface object, not a plain string. Without the + // custom deserializer this whole event is silently dropped. + let envelope = r#"{"event_id":"abc123","sent_at":"2026-07-01T00:00:00Z"} +{"type":"event"} +{"event_id":"abc123","message":{"formatted":"Null check operator used on a null value","message":"%s"},"level":"error","platform":"dart"}"#; + + let result = parse_envelope_text(envelope).unwrap(); + assert_eq!(result.events.len(), 1, "message object must not drop the event"); + assert_eq!(result.events[0].event_id, "abc123"); + assert_eq!(result.events[0].message.as_deref(), Some("Null check operator used on a null value")); + } + + #[test] + fn parse_event_message_object_falls_back_to_message_key() { + // When `formatted` is absent, fall back to the `message` template. + let envelope = r#"{"event_id":"abc123","sent_at":"2026-07-01T00:00:00Z"} +{"type":"event"} +{"event_id":"abc123","message":{"message":"template only"},"level":"error"}"#; + + let result = parse_envelope_text(envelope).unwrap(); + assert_eq!(result.events.len(), 1); + assert_eq!(result.events[0].message.as_deref(), Some("template only")); + } + #[test] fn parse_envelope_session_and_event_together() { let envelope = r#"{"event_id":"abc123","sent_at":"2026-01-01T00:00:00Z"} diff --git a/crates/trapfall-proto/src/lib.rs b/crates/trapfall-proto/src/lib.rs index 38cf6cb..9a88de8 100644 --- a/crates/trapfall-proto/src/lib.rs +++ b/crates/trapfall-proto/src/lib.rs @@ -125,6 +125,7 @@ pub struct Event { #[serde(default)] pub breadcrumbs: Breadcrumbs, pub exception: Option, + #[serde(default, deserialize_with = "deserialize_message", alias = "Message")] pub message: Option, #[serde(default)] pub tags: serde_json::Value, @@ -135,6 +136,30 @@ pub struct Event { pub timestamp: Option, } +/// Deserialize a Sentry event `message` field that may arrive either as a +/// plain string (minimal SDKs) or as the `Message` interface object +/// `{ "formatted": "...", "message": "..." }` (modern SDKs incl. Dart/Flutter). +/// +/// We collapse both forms into the formatted display string, preferring +/// `formatted`, then `message`, then falling back to `None`. +/// Without this, a `message` object silently fails whole-event +/// deserialization (serde "invalid type: map, expected a string") and the +/// event is dropped. +pub fn deserialize_message<'de, D>(deserializer: D) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + let value = Option::::deserialize(deserializer)?; + Ok(value.and_then(|v| match v { + serde_json::Value::String(s) => Some(s), + serde_json::Value::Object(map) => map + .get("formatted") + .and_then(|f| f.as_str().map(str::to_string)) + .or_else(|| map.get("message").and_then(|m| m.as_str().map(str::to_string))), + _ => None, + })) +} + /// Wrapper for exception values (Sentry format). #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ExceptionValues {