Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions crates/trapfall-ingest/src/envelope.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
68 changes: 67 additions & 1 deletion crates/trapfall-proto/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
#[serde(rename = "type", skip_serializing_if = "Option::is_none")]
pub bc_type: Option<String>,
Expand Down Expand Up @@ -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<String>,
}

/// 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<Option<String>, D::Error>
where
D: serde::Deserializer<'de>,
{
let value = Option::<serde_json::Value>::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).
Expand Down Expand Up @@ -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);
Expand Down
Loading