1. Crash Report Ingestion and Normalization
Why a Normalization Layer Is Required
Crash data rarely arrives in one shape. A desktop client may upload a Windows minidump plus a sidecar .meta file; a mobile SDK may post a JSON payload containing a raw stack trace and device properties; a backend service may ship a tarball of rotating log files. Each source names its fields differently, uses different timestamp formats, and repeats the same crash many times because every user session that hits the bug produces its own report. Downstream analytics — frequency counts, trend detection, symbolication — cannot operate on this heterogeneity directly. The ingestion and normalization pipeline is the boundary that converts all of it into one canonical record.
The pipeline in this case has three responsibilities that must be solved together, because they interact:
- Field extraction — pull a stable set of attributes out of each source format, including a crash identity, a build/release identifier, an environment, and a stack representation.
- Timestamp alignment — convert every source's time representation into a single UTC instant, preserving the original value and the conversion applied so that later analysis can audit it.
- Deduplication keys — derive a deterministic key so that the same crash reported by many sessions collapses to one logical event, while genuinely distinct crashes stay separate.
Canonical Schema
Define the target record before writing any parser. The schema below is deliberately small; every field is either needed by downstream analytics or needed to audit how the record was produced.
| Field | Type | Source / rule |
|---|---|---|
report_id | string | Source-native ID if present, otherwise a content hash of the raw payload. |
source_type | enum | minidump | stacktrace_json | log_bundle |
occurred_at_utc | ISO-8601 UTC | Normalized from source timestamp; always ends in Z. |
occurred_at_raw | string | Original timestamp text, kept verbatim for audit. |
tz_offset_minutes | integer | Offset applied during conversion; 0 when the source was already UTC. |
app_version | string | Semantic version, e.g. 4.12.0. |
platform | enum | windows | macos | linux | ios | android |
signal | string | Exception class or fault type, e.g. EXCEPTION_ACCESS_VIOLATION, SIGSEGV. |
top_frame | string | First non-runtime frame, normalized to module!function. |
stack_digest | string | SHA-256 of the normalized frame list (see below). |
dedup_key | string | Deterministic composite key; primary grouping identifier. |
Extraction per Source Type
Each adapter is a pure function from raw bytes to a partial canonical record. Keeping adapters pure makes them individually testable and lets you add a new source without touching the dedup or timestamp logic.
Minidump adapter. A minidump is a binary container. Parse the header to confirm the signature, then read the exception stream for the fault code and the thread list for the crashing thread's stack. The module list gives you the loaded module names and their base addresses, which you need to map instruction pointers to module!function form. The sidecar .meta file supplies app_version, platform, and the client-side timestamp.
Stack trace JSON adapter. Mobile SDKs typically send a JSON object with a timestamp (often epoch milliseconds), a frames array of {module, symbol, offset}, and a device block. Extract the frames in order, drop frames whose module is a known runtime (e.g. libc, libsystem) when computing top_frame, and keep the full list for the digest.
Log bundle adapter. A tarball of rotating logs has no structured crash record. Scan for the first line matching a crash marker (e.g. FATAL EXCEPTION or Segmentation fault), then read the following indented block as the stack. Timestamps come from the log line prefix, which may be local time with an offset — this is where timestamp alignment matters most.
Timestamp Alignment
Three rules keep timestamps trustworthy. First, never discard the raw string; store it in occurred_at_raw. Second, record the offset you applied in tz_offset_minutes so a reviewer can reconstruct the conversion. Third, reject records whose timestamp cannot be parsed rather than silently substituting ingestion time — a substituted timestamp corrupts trend analysis. When a source provides only local time with no offset, treat the record as unalignable and route it to a quarantine queue with the reason recorded.
from datetime import datetime, timezone
def align_timestamp(raw: str, tz_offset_minutes: int) -> tuple[str, int]:
"""Return (iso_utc, offset_applied). Raises ValueError on unparseable input."""
# Accept ISO-8601 with or without offset, and epoch milliseconds as digits.
if raw.isdigit():
dt = datetime.fromtimestamp(int(raw) / 1000, tz=timezone.utc)
return dt.strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z", 0
dt = datetime.fromisoformat(raw.replace("Z", "+00:00"))
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone(timedelta(minutes=tz_offset_minutes)))
utc = dt.astimezone(timezone.utc)
return utc.strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z", tz_offset_minutes
Deduplication Key Design
The dedup key must be stable across sessions and machines but sensitive to real differences. A workable composite is:
dedup_key = sha256(
f"{app_version}|{platform}|{signal}|{top_frame}|{stack_digest}"
).hexdigest()
Including app_version keeps crashes from different releases separate, which matters because a fix in 4.12.1 should not be merged with the 4.12.0 cluster. Including stack_digest rather than the raw stack text means two reports that differ only in memory addresses or thread IDs collapse correctly. Excluding user ID and session ID from the key is deliberate: the same crash from a thousand users is one logical event, and user counts are computed downstream from the raw records, not from the key.
The stack_digest itself is computed over a normalized frame list: strip addresses, lowercase module names, and keep only module!function pairs in call order. This normalization is what makes the digest stable; it is also the input that the symbolication stage will later refine.
Verification and Failure Symptoms
Run the pipeline against a small fixture set that contains one report of each source type plus two reports that should collapse to the same dedup_key. The expected outcome is three canonical records from four inputs, with the two duplicates sharing a key and all occurred_at_utc values ending in Z. If the duplicate pair produces different keys, inspect stack_digest first — the usual cause is an unnormalized frame list where addresses or thread IDs leaked into the digest input. If timestamps drift by a whole number of hours, the offset was applied twice or not at all; check tz_offset_minutes against occurred_at_raw. If a log-bundle record lands in quarantine, the log prefix format did not match the parser's expected pattern — extend the pattern rather than falling back to ingestion time.