●FORK — Claude Code 2.1.212 changes what /fork does: it copies your conversation into a new background session with its own row in claude agents, so you can keep working. The old in-session subagent is now /subtask●LIMITS — WebSearch calls are now capped at 200 per session by default, and subagent spawns get the same 200 ceiling, so a runaway search or delegation loop stops on its own●MCPBG — MCP tool calls running past two minutes now move to the background automatically, keeping the session usable. Tune the threshold with CLAUDE_CODE_MCP_AUTO_BACKGROUND_MS●PLANFIX — Fixed plan mode auto-running file-modifying Bash commands such as touch and rm without a permission prompt or an SDK canUseTool callback●SONNET5 — Claude Sonnet 5 is running on introductory pricing of $2 per million input tokens and $10 per million output. After August 31 it moves to $3 and $15●IPO — Bankers are reportedly lining up investor meetings for Anthropic ahead of a possible public listing as soon as October●FORK — Claude Code 2.1.212 changes what /fork does: it copies your conversation into a new background session with its own row in claude agents, so you can keep working. The old in-session subagent is now /subtask●LIMITS — WebSearch calls are now capped at 200 per session by default, and subagent spawns get the same 200 ceiling, so a runaway search or delegation loop stops on its own●MCPBG — MCP tool calls running past two minutes now move to the background automatically, keeping the session usable. Tune the threshold with CLAUDE_CODE_MCP_AUTO_BACKGROUND_MS●PLANFIX — Fixed plan mode auto-running file-modifying Bash commands such as touch and rm without a permission prompt or an SDK canUseTool callback●SONNET5 — Claude Sonnet 5 is running on introductory pricing of $2 per million input tokens and $10 per million output. After August 31 it moves to $3 and $15●IPO — Bankers are reportedly lining up investor meetings for Anthropic ahead of a possible public listing as soon as October
Grouping Crashes by Root Cause: A Triage Design Built on the Claude API
Crashlytics 'Issues' often scatter the same root cause across separate entries. After years of running apps with 50M+ cumulative downloads, here is how I use the Claude API to regroup crashes by actual root cause and rank them, with working code and real numbers.
The more apps I shipped, the more the Crashlytics dashboard turned from something I read into something I merely glanced at.
I have been building apps on my own since 2014, and the catalog now adds up to more than 50 million cumulative downloads. As the number of apps, OS versions, and devices grew, Crashlytics started listing Issues by the hundreds. The hard part was never the sheer count. It was that the same root cause kept scattering across separate Issues.
For example, one image-decoding bug was split into three Issues simply because the calling thread differed. Conversely, obfuscated traces whose symbols had not resolved sometimes packed two genuinely different causes into a single Issue. Because Crashlytics grouping leans heavily on the top frame of the stack trace, this over-splitting and under-splitting happen at the same time.
Standing in front of that list every morning, the time I spent deciding what to tackle first quietly grew longer than the development itself. So I built a system on the Claude API that regroups crashes by root cause and produces a priority order mechanically. This article shares the design decisions, the working code, and the numbers I measured in practice.
Why "just let the LLM classify everything" fails
My first attempt was the naive one: hand raw stack traces to Claude and ask it to "group these." That breaks down for two reasons.
The first is cost. At a scale where thousands of crash events arrive per day, sending every one through an LLM is not financially realistic. The second is stability. Even for identical traces, the grouping drifts day to day depending on input ordering or slight wording changes. Once clusters change shape every day, you lose the ability to ask "is the cause I fixed yesterday still near the top today?"
To make this usable in real development, you need a clear split: process deterministically whatever can be processed deterministically, and use the LLM only at the 'boundary' where even a human would hesitate. I came to think of this design the way my grandfather, a temple carpenter, switched tools: ink-line marking by jig, but the cutting by hand. Dimensions that must not drift get fixed by a jig; only the parts that call for judgment get the hand. Crash analysis is the same — fingerprints that must not drift are fixed deterministically, and only the judgment of meaning is delegated to Claude.
That led to the following three stages.
Normalize and fingerprint (deterministic) — strip the variable parts of a trace and build a stable fingerprint
Pre-cluster by fingerprint (deterministic) — mechanically group identical fingerprints
Label and merge with Claude (LLM) — bundle entries that share a cause despite different fingerprints, then assign a root-cause name and priority
Normalizing a stack trace into a fingerprint
Before anything reaches the LLM, I normalize each trace so that "same cause produces same string." The goal is to remove variable elements — memory addresses, line numbers, user IDs — and keep only a stable sequence of frames.
import reimport hashlib# Rules to erase variable elementsNOISE_PATTERNS = [ (re.compile(r"0x[0-9a-fA-F]+"), "0xADDR"), # memory addresses (re.compile(r":\d+\)"), ":N)"), # line numbers (re.compile(r"\b\d{4,}\b"), "NUM"), # long numeric IDs (re.compile(r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}"), "UUID"),]# Keep only the app's own frames (drop OS/SDK noise)APP_FRAME_HINT = re.compile(r"(MyWallpaperApp|com\.dolice\.)")def normalize_frame(frame: str) -> str: for pattern, repl in NOISE_PATTERNS: frame = pattern.sub(repl, frame) return frame.strip()def fingerprint(stacktrace: str, top_n: int = 5) -> str: """Build a fingerprint from normalized, app-originated top frames.""" frames = [normalize_frame(f) for f in stacktrace.splitlines() if f.strip()] app_frames = [f for f in frames if APP_FRAME_HINT.search(f)] # Fall back to top frames if no app frame is found key_frames = (app_frames or frames)[:top_n] digest = hashlib.sha256("\n".join(key_frames).encode()).hexdigest() return digest[:16]
The trick that pays off here is "prefer app-originated frames for the fingerprint." If you pick UIKit or libsystem frames from the top, crashes with different causes collapse into the same fingerprint. Anchoring on your own app's frames, by contrast, lets things split cleanly along cause boundaries. The official docs do not mention this, but in my experience this single move noticeably changed pre-cluster accuracy.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦A three-stage design that normalizes and fingerprints stack traces deterministically, confining LLM calls to the 'boundary' to balance cost and stability
✦A complete, runnable Python implementation that labels root causes and merges clusters using the Claude API's structured output (tool use)
✦A priority-scoring formula based on affected users and crash-free-rate regression, plus operational notes on Batch API and prompt caching to keep costs low
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
After fingerprinting, the crashes shrink from hundreds to dozens of clusters. This is where the boundary judgment begins: merge clusters that have different fingerprints but the same cause (for example, the same null reference triggered from two entry points), and assign a human-readable root-cause name.
Using the Claude API's structured output (tool use) lets you fix the shape of the return value, which keeps downstream processing stable.
import osimport jsonimport anthropicclient = anthropic.Anthropic(api_key=os.environ["ANTHROPIC_API_KEY"])CLUSTER_TOOL = { "name": "report_clusters", "description": "Merge crash clusters by root cause and return labels and types", "input_schema": { "type": "object", "properties": { "clusters": { "type": "array", "items": { "type": "object", "properties": { "root_cause": {"type": "string", "description": "Concise root-cause name"}, "category": {"type": "string", "enum": ["null-deref", "oom", "concurrency", "io", "decode", "other"]}, "member_fingerprints": {"type": "array", "items": {"type": "string"}}, "confidence": {"type": "number"} }, "required": ["root_cause", "category", "member_fingerprints", "confidence"] } } }, "required": ["clusters"] }}def merge_clusters(cluster_samples: list[dict]) -> list[dict]: """cluster_samples: [{fingerprint, sample_trace, count}, ...]""" payload = json.dumps(cluster_samples, ensure_ascii=False, indent=2) resp = client.messages.create( model="claude-sonnet-4-6", max_tokens=2000, tools=[CLUSTER_TOOL], tool_choice={"type": "tool", "name": "report_clusters"}, messages=[{ "role": "user", "content": ( "Merge the following crash clusters by root cause. " "Even when fingerprints differ, group entries you judge to share " "the same cause into the same cluster's member_fingerprints.\n\n" + payload ), }], ) for block in resp.content: if block.type == "tool_use": return block.input["clusters"] return []
Forcing the tool call with tool_choice is the key. Omit it and Claude may return a prose explanation, which makes downstream parsing fragile. When you want structured output, define the tool and force its invocation — that is the form I settled on in production.
Scoring priority by "impact," not "count"
A common mistake is sorting by crash count. A cause with a high count but whose crash-free rate has already recovered to nearly 100% (i.e., already fixed in a newer build) is a waste of effort.
I score priority by affected users and the size of the crash-free-rate regression.
def priority_score(cluster: dict, metrics: dict) -> float: """metrics: {affected_users, crash_free_delta, is_recent} affected_users: affected users in the last 7 days crash_free_delta: crash-free-rate regression in the latest release (0..1) is_recent: whether it newly appeared after the latest release """ base = metrics["affected_users"] * (1 + metrics["crash_free_delta"] * 5) if metrics["is_recent"]: base *= 2.0 # squash new regressions first if cluster["confidence"] < 0.5: base *= 0.7 # discount low-confidence merges slightly return round(base, 1)
The heavy weight on crash_free_delta reflects that, for an indie developer, "did the latest release make it worse?" is the signal that drives decisions the most. Anything whose crash-free rate suddenly dropped in a new version gets squashed first, before users churn. Causes that have persisted at a steady, low rate, by contrast, can be fixed at a calmer pace.
Keeping costs low: Batch API and prompt caching
The merge phase only needs to run once a day. With Claude's Message Batches API, this latency-tolerant work runs at half price.
On top of that, the tool definition and category descriptions are identical every run, so I make them the target of prompt caching. Only the cluster samples change day to day, so placing the fixed portion before the cache boundary means most input tokens are cache hits.
In practice, across 4 apps and roughly 3,200 crash events per day, the merge-phase Claude call fit into a single daily run of about 8,000 input tokens. Combined with the Batch API and caching, the monthly API cost is a few hundred yen. What changed most was time: the ~40 minutes of morning triage shrank to about 5 minutes of reviewing the top 5 prioritized clusters.
Wrapping up: what to do next
If you are facing the same flood of crashes, start by implementing only the normalization and fingerprinting. That deterministic part alone, before any LLM, resolves much of the Crashlytics over-splitting. Then, if entries with different fingerprints but the same cause still remain, that is the moment to add Claude's merge phase — and the return on cost becomes clearly visible.
Left alone, a crash list becomes "something you only glance at." But raise the abstraction by one level so you can see it in units of root cause, and it turns back into something you can act on. I hope this design is useful to anyone working on the same problem.
Share
Thank You for Reading
Claude Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.