diff --git a/crates/trapfall-ingest/src/envelope.rs b/crates/trapfall-ingest/src/envelope.rs index 780c172..85bc62c 100644 --- a/crates/trapfall-ingest/src/envelope.rs +++ b/crates/trapfall-ingest/src/envelope.rs @@ -313,6 +313,20 @@ mod tests { assert_eq!(result.events[0].message.as_deref(), Some("Null check operator used on a null value")); } + #[test] + fn parse_envelope_with_numeric_timestamps_ingests() { + // Real JS/Python SDKs send epoch numbers for event and breadcrumb timestamps. + let envelope = r#"{"event_id":"abc123","sent_at":"2026-08-27T00:00:00Z"} +{"type":"event"} +{"event_id":"abc123","timestamp":1756278600.5,"message":"boom","level":"error","breadcrumbs":{"values":[{"timestamp":1756278599,"category":"console","message":"hi"}]}}"#; + + let result = parse_envelope_text(envelope).unwrap(); + assert_eq!(result.events.len(), 1, "numeric timestamps must not drop the event"); + let ev = &result.events[0]; + assert_eq!(ev.timestamp.as_deref(), Some("2025-08-27T07:10:00.500Z")); + assert_eq!(ev.breadcrumbs.values[0].timestamp.as_deref(), Some("2025-08-27T07:09:59Z")); + } + #[test] fn parse_event_message_object_falls_back_to_message_key() { // When `formatted` is absent, fall back to the `message` template. diff --git a/crates/trapfall-proto/src/lib.rs b/crates/trapfall-proto/src/lib.rs index 9a88de8..32f148a 100644 --- a/crates/trapfall-proto/src/lib.rs +++ b/crates/trapfall-proto/src/lib.rs @@ -97,7 +97,7 @@ pub struct Stacktrace { /// A breadcrumb event leading up to an error. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Breadcrumb { - #[serde(skip_serializing_if = "Option::is_none")] + #[serde(default, deserialize_with = "deserialize_timestamp", skip_serializing_if = "Option::is_none")] pub timestamp: Option, #[serde(rename = "type", skip_serializing_if = "Option::is_none")] pub bc_type: Option, @@ -133,9 +133,37 @@ pub struct Event { pub extra: serde_json::Value, #[serde(rename = "contexts", default)] pub contexts: serde_json::Value, + #[serde(default, deserialize_with = "deserialize_timestamp")] pub timestamp: Option, } +/// Deserialize a Sentry `timestamp` that may arrive as an RFC3339 string +/// (Rust SDK) or as epoch seconds, integer or float (JavaScript/Python SDKs). +/// +/// Numeric values are normalized to an RFC3339 string (UTC) so storage is +/// unchanged. Null yields `None`; unparseable or out-of-range numbers also +/// yield `None` rather than dropping the whole event. +pub fn deserialize_timestamp<'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::Number(n) => if let Some(i) = n.as_i64() { + chrono::DateTime::from_timestamp(i, 0) + } else { + n.as_f64().filter(|f| f.is_finite()).and_then(|f| { + let secs = f.floor(); + let nanos = ((f - secs) * 1e9).round().clamp(0.0, 999_999_999.0) as u32; + chrono::DateTime::from_timestamp(secs as i64, nanos) + }) + } + .map(|dt| dt.to_rfc3339_opts(chrono::SecondsFormat::AutoSi, true)), + _ => None, + })) +} + /// 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). @@ -479,6 +507,44 @@ pub struct ParsedEnvelope { mod tests { use super::*; + fn event_ts(ts: &str) -> Event { + serde_json::from_str(&format!(r#"{{"event_id":"e1"{ts}}}"#)).unwrap() + } + + #[test] + fn timestamp_accepts_float_epoch() { + let e = event_ts(r#","timestamp":1756278600.5"#); + assert_eq!(e.timestamp.as_deref(), Some("2025-08-27T07:10:00.500Z")); + } + + #[test] + fn timestamp_accepts_int_epoch() { + let e = event_ts(r#","timestamp":1756278600"#); + assert_eq!(e.timestamp.as_deref(), Some("2025-08-27T07:10:00Z")); + } + + #[test] + fn timestamp_accepts_rfc3339_string() { + let e = event_ts(r#","timestamp":"2026-08-27T07:52:30Z""#); + assert_eq!(e.timestamp.as_deref(), Some("2026-08-27T07:52:30Z")); + } + + #[test] + fn timestamp_null_or_missing_is_none() { + assert!(event_ts(r#","timestamp":null"#).timestamp.is_none()); + assert!(event_ts("").timestamp.is_none()); + } + + #[test] + fn breadcrumb_timestamp_accepts_numeric_and_missing() { + let b: Breadcrumb = serde_json::from_str(r#"{"timestamp":1756278600.25,"message":"x"}"#).unwrap(); + assert_eq!(b.timestamp.as_deref(), Some("2025-08-27T07:10:00.250Z")); + let b: Breadcrumb = serde_json::from_str(r#"{"timestamp":1756278600,"message":"x"}"#).unwrap(); + assert_eq!(b.timestamp.as_deref(), Some("2025-08-27T07:10:00Z")); + let b: Breadcrumb = serde_json::from_str(r#"{"message":"x"}"#).unwrap(); + assert!(b.timestamp.is_none()); + } + #[test] fn issue_status_default_is_unresolved() { assert_eq!(IssueStatus::default(), IssueStatus::Unresolved);