Designing an Error Tracking System: Exception Grouping, Stack Trace Fingerprinting, and Intelligent Alerting at Scale
How production error tracking systems work under the hood: event ingestion pipelines, stack trace parsing with source map resolution, fingerprinting algorithms that group thousands of identical exceptions, release-aware regression detection, and intelligent alerting with spike detection.
Every production system generates exceptions. The question is never whether errors will occur but whether your team will learn about the right ones at the right time. A naive approach, logging everything to a file and hoping someone reads it, breaks immediately at scale. When a single bad deploy triggers a million exceptions in two minutes, the noise drowns the signal. Designing an error tracking system that stays useful under that load requires careful thinking across ingestion, deduplication, storage, and alerting.
This article walks through how production-grade error tracking systems work: from the moment an exception fires in a client application to the alert that wakes an engineer at 2am.
Event Ingestion Pipeline
The first design problem is transport. Exceptions happen in browsers, mobile apps, serverless functions, long-running services, and background workers. Each environment has different reliability guarantees and different constraints on payload size and synchronous I/O.
The canonical pattern is a thin SDK that captures the exception, serializes a structured event, and sends it to an ingest endpoint over HTTPS. The SDK should never block the main execution path. In a browser, you queue events and flush asynchronously. In a Node.js service, you capture the exception, enqueue it, and continue handling the request.
interface ErrorEvent {
eventId: string;
timestamp: string; // ISO 8601
platform: "javascript" | "node" | "python" | "java";
environment: string;
release: string;
exception: {
type: string;
value: string;
stacktrace: {
frames: StackFrame[];
};
};
tags: Record<string, string>;
user?: { id: string; ip?: string };
request?: { url: string; method: string; headers: Record<string, string> };
breadcrumbs: Breadcrumb[];
}
interface StackFrame {
filename: string;
function: string;
lineno: number;
colno: number;
in_app: boolean;
context_line?: string;
pre_context?: string[];
post_context?: string[];
}
class ErrorQueue {
private queue: ErrorEvent[] = [];
private flushInterval: ReturnType<typeof setInterval>;
constructor(
private readonly endpoint: string,
private readonly dsn: string,
flushIntervalMs = 2000
) {
this.flushInterval = setInterval(() => this.flush(), flushIntervalMs);
if (typeof window !== "undefined") {
window.addEventListener("beforeunload", () => this.flush());
}
}
enqueue(event: ErrorEvent): void {
// Client-side sampling: drop 90% of identical recent events
if (this.queue.length > 100) return; // local backpressure
this.queue.push(event);
}
private async flush(): Promise<void> {
if (this.queue.length === 0) return;
const batch = this.queue.splice(0, 50); // max 50 per flush
try {
await fetch(this.endpoint, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-DSN": this.dsn,
},
body: JSON.stringify({ events: batch }),
keepalive: true, // survive page unload in browsers
});
} catch {
// Drop on network failure; do not retry indefinitely
// Retrying creates thundering herd on backend recoveries
}
}
}
On the server side, the ingest endpoint needs to handle tens of thousands of requests per second without becoming the bottleneck. The pattern is to accept-and-enqueue: validate the event payload, write it to a durable message queue (Kafka works well here), and return a 200 immediately. Processing happens downstream, decoupled from the HTTP layer.
Client-Side Sampling
Before an event leaves the SDK, apply sampling. If a single function throws in a tight loop, you do not want a million copies. Track a short-lived in-memory count of recently seen error signatures and drop events above a per-signature threshold. The threshold can be configurable per project:
class SamplingFilter {
private counts = new Map<string, { count: number; windowStart: number }>();
private readonly windowMs = 60_000;
private readonly maxPerWindow: number;
constructor(maxPerWindow = 10) {
this.maxPerWindow = maxPerWindow;
}
shouldSample(signature: string): boolean {
const now = Date.now();
const entry = this.counts.get(signature);
if (!entry || now - entry.windowStart > this.windowMs) {
this.counts.set(signature, { count: 1, windowStart: now });
return true;
}
if (entry.count >= this.maxPerWindow) return false;
entry.count++;
return true;
}
}
This client-side gate dramatically reduces ingest volume without losing diagnostic information. You already have one stack trace for the error. The 999th copy adds nothing.
Stack Trace Parsing and Source Map Resolution
Minified JavaScript is the hardest part of browser error tracking. A raw stack frame from a minified bundle looks like bundle.min.js:1:47832. That is not actionable. To get back to the original TypeScript source, you need the corresponding source map.
Source maps are generated at build time. The tracking system needs to store them indexed by release version and apply them on ingest (or lazily on first view). The resolution process:
- Look up the source map for the file and release.
- Parse the base64 VLQ-encoded mappings to build a position index.
- For each minified
(line, column)pair in the stack frame, find the original(source file, line, column, function name).
import { SourceMapConsumer } from "source-map";
interface ResolvedFrame extends StackFrame {
originalFilename?: string;
originalLineno?: number;
originalColno?: number;
originalFunction?: string;
}
async function resolveFrame(
frame: StackFrame,
sourceMapStore: SourceMapStore
): Promise<ResolvedFrame> {
const sourceMap = await sourceMapStore.get(frame.filename);
if (!sourceMap) return frame;
await SourceMapConsumer.with(sourceMap, null, (consumer) => {
const pos = consumer.originalPositionFor({
line: frame.lineno,
column: frame.colno,
});
if (pos.source) {
(frame as ResolvedFrame).originalFilename = pos.source;
(frame as ResolvedFrame).originalLineno = pos.line ?? undefined;
(frame as ResolvedFrame).originalColno = pos.column ?? undefined;
(frame as ResolvedFrame).originalFunction = pos.name ?? undefined;
}
});
return frame as ResolvedFrame;
}
Source map resolution is CPU-intensive. Do it once, cache the result on the processed event, and discard the raw minified frames for display purposes. Store both so you can re-resolve if a source map upload arrives late.
Fingerprinting: Grouping Exceptions into Issues
This is the core deduplication problem. If ten thousand users hit the same null reference in the same function, you want one issue in your dashboard, not ten thousand. Fingerprinting is the algorithm that decides which events represent the same underlying bug.
Stack-Based Fingerprinting
The default strategy uses the normalized stack trace as the fingerprint. Strip memory addresses, line numbers from third-party libraries that vary between builds, and frame counts beyond a depth threshold. Hash what remains.
import { createHash } from "crypto";
function fingerprintFromStack(frames: ResolvedFrame[]): string {
const inAppFrames = frames.filter((f) => f.in_app);
// Use up to 10 most relevant frames
const relevant = inAppFrames.slice(-10);
const normalized = relevant
.map((f) => {
const file = f.originalFilename ?? f.filename;
const fn = f.originalFunction ?? f.function ?? "<anonymous>";
// Normalize dynamic segments: remove UUIDs, numeric IDs from paths
const normalizedFile = file
.replace(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi, "<uuid>")
.replace(/\/\d+\//g, "/<id>/");
return `${normalizedFile}:${fn}`;
})
.join("|");
return createHash("sha256")
.update(normalized)
.digest("hex")
.slice(0, 16);
}
Message-Based Fingerprinting
Some exceptions have identical stack traces but different messages, and you want them grouped separately. A TypeError: Cannot read properties of undefined (reading 'id') and a TypeError: Cannot read properties of undefined (reading 'name') might come from completely different code paths that happen to share a call stack. Include a normalized message component:
function normalizeMessage(message: string): string {
return message
.replace(/\b[0-9a-f]{8,}\b/gi, "<hash>") // hex IDs
.replace(/\b\d{4,}\b/g, "<num>") // large numbers
.replace(/'.+?'/g, "'<val>'") // quoted string values
.replace(/".+?"/g, '"<val>"')
.trim()
.slice(0, 200);
}
function combinedFingerprint(event: ErrorEvent): string {
const stackHash = fingerprintFromStack(event.exception.stacktrace.frames as ResolvedFrame[]);
const messageHash = createHash("sha256")
.update(normalizeMessage(event.exception.value))
.digest("hex")
.slice(0, 8);
return `${stackHash}-${messageHash}`;
}
Custom Fingerprints
For teams with domain-specific grouping needs, allow SDK-level overrides. An HTTP client that wraps every downstream error might want to group by status code and endpoint, not by stack trace. The SDK should expose a beforeSend hook where engineers can set event.fingerprint directly. The server should honor it verbatim when present.
Fingerprinting Approaches: Tradeoffs
| Approach | Grouping Quality | False Merge Risk | False Split Risk | Custom Logic Required |
|---|---|---|---|---|
| Stack-only | High for same-origin bugs | Low | Medium (dynamic paths) | No |
| Message-only | Medium | High (different bugs, same message) | Low | No |
| Stack + message combined | High | Low | Low | No |
| Custom fingerprint | Highest (domain-aware) | Very low | Very low | Yes |
| ML-based clustering | Very high at scale | Low | Very low | No (but expensive) |
The combined approach covers 90%+ of cases without requiring any per-project configuration. Custom fingerprints handle the long tail.
Release-Aware Regression Detection
Not all errors are equal. An error that has existed for six months is a known issue. An error that first appears two minutes after a deploy is a regression that probably needs immediate attention. The system needs to track the first-seen release for every issue and detect when a previously resolved issue reappears.
interface IssueRecord {
fingerprint: string;
firstSeen: Date;
lastSeen: Date;
firstSeenRelease: string;
lastSeenRelease: string;
count: number;
status: "open" | "resolved" | "ignored";
resolvedInRelease?: string;
}
function classifyEvent(
issue: IssueRecord | null,
event: ErrorEvent
): "new" | "regression" | "existing" {
if (!issue) return "new";
if (
issue.status === "resolved" &&
issue.resolvedInRelease &&
isNewerRelease(event.release, issue.resolvedInRelease)
) {
return "regression";
}
return "existing";
}
function isNewerRelease(a: string, b: string): boolean {
// Semver comparison; fall back to timestamp-ordered releases
const partsA = a.split(".").map(Number);
const partsB = b.split(".").map(Number);
for (let i = 0; i < Math.max(partsA.length, partsB.length); i++) {
const diff = (partsA[i] ?? 0) - (partsB[i] ?? 0);
if (diff !== 0) return diff > 0;
}
return false;
}
When an issue is classified as a regression, reopen it immediately and fire an alert regardless of the normal alerting threshold. A regression is always urgent.
Server-Side Rate Limiting
Even with client-side sampling, a single broken deploy can flood ingest. Server-side rate limiting operates per project and per fingerprint. Use a sliding window counter backed by Redis:
async function isRateLimited(
redis: Redis,
projectId: string,
fingerprint: string
): Promise<boolean> {
const key = `rate:${projectId}:${fingerprint}`;
const windowSeconds = 60;
const limit = 1000; // max 1000 events per fingerprint per minute server-side
const count = await redis.incr(key);
if (count === 1) {
await redis.expire(key, windowSeconds);
}
return count > limit;
}
Rate-limited events are counted but not stored in full. You still increment the issue count so the spike is visible in metrics, but you skip storing the full payload. This keeps storage costs bounded without hiding the fact that an error is happening at volume.
Intelligent Alerting
Alerting on every new error is noise. Alerting on nothing means incidents go undetected. The right model combines several signals.
Spike Detection
Track event rate per issue using a time-series counter. Compare the current rate window against a baseline derived from recent history. When the ratio exceeds a threshold, fire an alert:
interface RateWindow {
count: number;
windowStart: number;
}
function detectSpike(
current: RateWindow,
baseline: number, // avg events per window over past 7 days
multiplierThreshold = 5
): boolean {
if (baseline < 10) {
// Low-volume issues: alert on absolute count instead
return current.count > 50;
}
return current.count / baseline > multiplierThreshold;
}
First Occurrence Alerts
Any issue that has never been seen before should fire immediately. This catches new bugs introduced by recent changes before they accumulate.
Escalation Policies
Not every alert needs to wake someone up. Structure alert severity by impact:
type AlertSeverity = "info" | "warning" | "critical";
function classifyAlert(
classification: "new" | "regression" | "existing",
isSpike: boolean,
errorRate: number // errors per minute
): AlertSeverity {
if (classification === "regression") return "critical";
if (classification === "new" && errorRate > 100) return "critical";
if (isSpike && errorRate > 50) return "warning";
if (classification === "new") return "info";
return "info";
}
Route critical alerts to PagerDuty or an on-call rotation. Route warning to Slack. Route info to a low-priority channel that the team reviews asynchronously. Most new errors are info. The ones that matter are critical and warning.
Storage Design for High-Cardinality Exception Data
Error events are write-heavy and read-sparsely. Most events are never viewed individually; only the issue-level aggregates matter day-to-day. The storage layer should reflect this:
- Issue table (relational): one row per fingerprint. Contains counts, status, first/last seen, release metadata. Queried constantly. Fits in PostgreSQL with proper indexing on
(project_id, status, last_seen). - Event store (column-oriented or object storage): full event payloads stored separately. Queried only when an engineer clicks into a specific occurrence. Parquet files on S3 with a metadata index, or ClickHouse for interactive queries, work well here.
- Time-series counters (Redis or TimescaleDB): per-issue event rate over time. Used for spike detection and the volume graph in the UI. Stored at 1-minute resolution, downsampled to 1-hour after 7 days and 1-day after 90 days.
- Source maps (object storage): stored by
(project_id, release, filename)key. Cached in memory on processing nodes for the current release.
For the issue table, avoid storing counts as a single integer column that requires locking. Use a separate counts table with periodic rollup:
// Write: one row per event, aggregated in batch jobs
interface EventCountRow {
fingerprint: string;
projectId: string;
bucketStart: Date; // truncated to 1-minute
count: number;
}
// Read: sum over time range
// SELECT SUM(count) FROM event_counts
// WHERE fingerprint = $1 AND bucket_start > NOW() - INTERVAL '24 hours'
This pattern avoids hot-row contention on high-volume issues and lets you query counts over arbitrary time windows without scanning the full event store.
Tradeoffs: Self-Hosted vs Managed Error Tracking
| Dimension | Self-Hosted | Managed (SaaS) |
|---|---|---|
| Data sovereignty | Full control | Vendor-dependent |
| Setup cost | High (weeks) | Low (hours) |
| Operational burden | High (infra, upgrades) | None |
| Customization | Unlimited | Limited to API |
| Cost at scale (>10M events/mo) | Lower marginal cost | Can become expensive |
| Source map security | Stays internal | Uploaded to vendor |
| Compliance (HIPAA, SOC 2) | Your responsibility | Vendor-certified options |
| ML-based grouping improvements | Manual | Continuous via vendor |
For most teams under 50M events per month, managed wins on total cost of ownership because engineering time spent operating infrastructure is expensive. Above that threshold, self-hosting with a purpose-built stack (Kafka for ingest, ClickHouse for storage, Redis for rate limiting) becomes economically attractive.
Production Considerations
Source map retention: Source maps must outlive their deployments. A crash report from a user still on v1.2.3 three months after v2.0.0 ships needs the v1.2.3 source map to be resolvable. Keep source maps indefinitely or for at least the longest expected client session duration for your platform.
Clock skew: Events from mobile clients with incorrect system clocks will have timestamps in the past or future. Bound accepted timestamps to a configurable window (default: 5 minutes in the future, 24 hours in the past) and use server-side receive time as the authoritative timestamp for ordering when client time is out of range.
Fingerprint stability across refactors: When code is reorganized, file paths change, and existing fingerprints no longer match incoming events for the same underlying bug. This creates duplicate issues. Mitigate by making file path normalization aggressive (strip build tool prefixes, normalize path separators) and by providing a merge-issues API for manual cleanup.
Breadcrumb volume: Breadcrumbs, the sequence of user actions and log events leading to a crash, are invaluable for debugging but can bloat event size. Cap breadcrumbs at 100 entries and enforce a maximum payload size of 1MB per event. Truncate oldest breadcrumbs first; the most recent context is the most useful.
Alert fatigue calibration: Alert thresholds should be per-environment. An error rate of 100/minute in staging is expected noise. The same rate in production is a fire. Model this by scoping all alerting rules to (project, environment) pairs, not just projects.
Release parsing: Semantic versioning is not universal. Teams use git SHAs, timestamps, or custom strings. The regression detection logic needs a configurable comparator, with semver as the default and a fallback to lexicographic ordering for non-semver strings.
In-app frame detection: The in_app flag on stack frames distinguishes your code from framework code. Getting this right matters because fingerprinting uses only in-app frames. Build an allow-list of your project’s source prefixes and a deny-list of known framework prefixes (node_modules/, react-dom, @sentry/). Let teams override this per project.
A well-designed error tracking system does not just collect crashes. It surfaces the right information at the right priority level so engineers can triage confidently. The hard parts, fingerprinting stability, regression detection, and alert calibration, require iteration on real data. Start with conservative thresholds and tune them after watching how your team actually responds to alerts over the first few weeks in production.
More in System Design
How Amazon Aurora Works Internally: The Log-Is-the-Database Architecture, Quorum Writes, and Storage-Compute Separation Behind Cloud-Native SQL
A deep dive into Aurora's storage-compute separation, the log-is-the-database design that pushes redo log processing to storage nodes, quorum-based replication across 6 copies in 3 AZs, protection group architecture, fast cloning, Serverless v2 scaling mechanics, and honest tradeoffs versus RDS, self-managed PostgreSQL, CockroachDB, and Cloud Spanner.
How CockroachDB Works Internally: Distributed SQL, Raft Consensus Per Range, and the Architecture Behind Serializable Transactions at Global Scale
A deep dive into CockroachDB's internals: the 512MB range-based data model with automatic splitting, per-range Raft replication, MVCC timestamp ordering with hybrid logical clocks, DistSQL physical planning, serializable transactions with timestamp refreshes, closed timestamps for follower reads, online schema changes using multi-version schema descriptors, and production considerations for hotspots and write amplification.
How Container Runtimes Work Internally: Namespaces, Cgroups, and the OCI Stack From Docker Run to Process Isolation
Containers are not virtual machines and they are not magic. They are a thin composition of Linux kernel primitives: namespaces, cgroups, and a layered filesystem. This article traces exactly what happens between docker run and a running process, covering the OCI spec, containerd, runc, overlay filesystems, and container networking at the kernel level.
How YugabyteDB Works Internally: DocDB Storage, Tablet Splitting, and the Dual API Architecture That Scales PostgreSQL Horizontally
A deep dive into YugabyteDB internals covering the DocDB storage engine built on RocksDB LSM trees, per-tablet Raft consensus, YSQL and YCQL dual query layers, distributed MVCC with hybrid logical clocks, automatic tablet splitting and rebalancing, xCluster multi-region replication, and production considerations for hotspots, connection pooling, and schema design.