アプリが増えるほど、Crashlyticsのダッシュボードは「読むもの」から「眺めるだけのもの」に変わっていきました。
2014年から個人でアプリを作り続けてきて、いまは累計5,000万ダウンロードほどになりました。本数が増え、OSのバージョンも端末も多様になると、Crashlyticsの「Issue(問題)」は数百単位で並びます。やっかいなのは、件数の多さそのものではありません。同じ根本原因が、別々のIssueとして散らばってしまうことでした。
たとえば、ある画像デコード処理の不具合は、呼び出し元のスレッドが違うだけで3つのIssueに分かれていました。逆に、シンボルが解決できていない難読化済みのトレースは、まったく別の原因がひとつのIssueに同居していることもありました。Crashlyticsのグルーピングはスタックトレースの先頭フレームに強く依存するため、こうした「分かれすぎ」と「混ざりすぎ」が同時に起きます。
毎朝この一覧を前に、どれから手をつけるべきかを判断する時間が、いつのまにか開発そのものより長くなっていました。そこでClaude APIを使い、クラッシュを「根本原因」という単位で束ね直し、優先順位を機械的に出す仕組みを組みました。以下では、その設計判断と動くコード、そして運用して分かった実測値を共有します。
なぜ「LLMで全部分類」ではうまくいかないのか
最初に試したのは、クラッシュのスタックトレースをそのままClaudeに渡して「これらをグループ分けして」と頼む素朴なやり方でした。これは二つの理由で破綻します。
ひとつはコストです。1日に数千件のクラッシュイベントが届く規模だと、毎件をLLMに通すのは費用的に現実的ではありません。もうひとつは安定性です。同じトレースでも、入力の並び順やわずかな文言差で、束ね方が日によって揺れてしまいます。クラスタが毎日違う形になると、「昨日対応した原因が今日も上位にいるのか」を追えなくなります。
開発現場で実際に使えるものにするには、決定論的に処理できる部分は決定論的に処理し、LLMは人間でも判断に迷う「境界」だけに使うという割り切りが要ります。私はこの設計を、宮大工だった祖父が「墨付けは機械、刻みは手」と道具を使い分けていた話と重ねて考えるようになりました。揺れてはいけない寸法は治具で固定し、判断が要る部分にだけ手をかける。クラッシュ解析も同じで、揺れてはいけない指紋は決定論で固定し、意味の判断だけをClaudeに委ねます。
そこで採ったのが次の三段構成です。
- 正規化と指紋化(決定論)— トレースから可変要素を削り、安定した指紋を作る
- 指紋による事前クラスタリング(決定論)— 同一指紋を機械的にまとめる
- Claudeによるラベル付けとクラスタ統合(LLM)— 指紋が違っても原因が同じものを束ね、根本原因名と優先度を付ける
スタックトレースを正規化して指紋にする
LLMに渡す前に、まずトレースを「同じ原因なら同じ文字列になる」形へ正規化します。メモリアドレス、行番号、ユーザーIDのような可変要素を取り除き、安定したフレーム列だけを残すのが狙いです。
import re
import hashlib
# 可変要素を消すための置換ルール
NOISE_PATTERNS = [
(re.compile(r"0x[0-9a-fA-F]+"), "0xADDR"), # メモリアドレス
(re.compile(r":\d+\)"), ":N)"), # 行番号
(re.compile(r"\b\d{4,}\b"), "NUM"), # 長い数値ID
(re.compile(r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}"), "UUID"),
]
# アプリ自身のフレームだけ残す(OS/SDKのノイズを落とす)
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:
"""上位フレームのうちアプリ由来のものを正規化して指紋を作る"""
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)]
# アプリフレームが取れなければ上位フレームで代替
key_frames = (app_frames or frames)[:top_n]
digest = hashlib.sha256("\n".join(key_frames).encode()).hexdigest()
return digest[:16]
ここで効くのが「アプリ由来のフレームを優先して指紋にする」工夫です。UIKit や libsystem のような共通フレームを先頭から拾うと、原因の違うクラッシュが同じ指紋に潰れてしまいます。逆に、自分のアプリのフレームを軸にすると、原因の単位で素直に分かれてくれます。公式ドキュメントには書かれていませんが、私の経験ではこの一手で事前クラスタの精度が体感で大きく変わりました。
Claudeに「クラスタの統合とラベル付け」だけを任せる
指紋でまとめた段階で、クラッシュは数百から数十のクラスタに減ります。ここからが境界の判断です。指紋は違うが原因は同じ(例:同じNull参照が二つの入口から起きている)クラスタを統合し、人が読める根本原因名を付けます。
Claude APIの構造化出力(tool use)を使うと、戻り値の形を固定できるので後段の処理が安定します。
import os
import json
import anthropic
client = anthropic.Anthropic(api_key=os.environ["ANTHROPIC_API_KEY"])
CLUSTER_TOOL = {
"name": "report_clusters",
"description": "クラッシュクラスタを根本原因ごとに統合し、ラベルと種別を返す",
"input_schema": {
"type": "object",
"properties": {
"clusters": {
"type": "array",
"items": {
"type": "object",
"properties": {
"root_cause": {"type": "string", "description": "簡潔な根本原因名(日本語)"},
"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": (
"次のクラッシュクラスタを根本原因ごとに統合してください。"
"指紋が異なっても、同じ原因で発生していると判断できるものは"
"同じclusterのmember_fingerprintsにまとめてください。\n\n" + payload
),
}],
)
for block in resp.content:
if block.type == "tool_use":
return block.input["clusters"]
return []
tool_choice でこのツールの呼び出しを強制している点が要です。これを省くと、Claudeが自然文で説明を返してくることがあり、後段のパースが不安定になります。構造化したい出力では、ツールを定義したうえで呼び出しを強制するのが、私が実運用で落ち着いた書き方です。
優先度を「件数」ではなく「影響」で採点する
ありがちな失敗は、クラッシュ件数の多い順に並べてしまうことです。件数が多くても、すでにクラッシュフリー率がほぼ100%に戻っている(=新バージョンで解決済み)原因に手をかけるのは無駄になります。
私は優先度を、影響ユーザー数とクラッシュフリー率の悪化幅で採点しています。
def priority_score(cluster: dict, metrics: dict) -> float:
"""metrics: {affected_users, crash_free_delta, is_recent}
affected_users: 直近7日の影響ユーザー数
crash_free_delta: 直近リリースでのクラッシュフリー率の悪化幅(0〜1)
is_recent: 直近リリース後に新出したか
"""
base = metrics["affected_users"] * (1 + metrics["crash_free_delta"] * 5)
if metrics["is_recent"]:
base *= 2.0 # 新規退行は最優先で潰す
if cluster["confidence"] < 0.5:
base *= 0.7 # 統合の確信度が低いものは少し割り引く
return round(base, 1)
crash_free_delta に重みを大きく載せているのは、「直近のリリースで悪化したかどうか」が、個人開発では最も意思決定に効くシグナルだからです。新しいバージョンで急にクラッシュフリー率が落ちたものは、ユーザーが離れる前に最優先で潰します。逆に、長く一定数出続けているが率が安定している原因は、腰を据えて直せます。
コストを抑える運用:Batch APIとプロンプトキャッシュ
統合フェーズは1日1回のバッチで十分です。Claude の Message Batches APIを使うと、即時応答が不要なこの処理を半額で回せます。
加えて、ツール定義と分類カテゴリの説明文は毎回同じなので、プロンプトキャッシュの対象にします。クラスタのサンプルだけが日々変わる入力なので、固定部分をキャッシュ境界の前に置くと、入力トークンのほとんどがキャッシュヒットします。
実測では、4本のアプリ・1日あたり約3,200件のクラッシュイベントに対して、統合フェーズのClaude呼び出しは1日1回・入力約8,000トークンに収まりました。Batch APIとキャッシュ併用で、月のAPI費用は数百円程度です。何より変わったのは時間で、毎朝のトリアージにかけていた約40分が、優先度つきの上位5クラスタを確認する5分前後に縮みました。
まとめ:次に手を動かすなら
もし同じようにクラッシュの洪水に向き合っているなら、まずは正規化と指紋化だけを実装してみてください。LLMを入れる前のこの決定論パートだけで、Crashlyticsの「分かれすぎ」はかなり解消します。そのうえで、指紋が違うのに原因が同じものが残っているなら、そこで初めてClaudeの統合フェーズを足すと、費用対効果がはっきり見えます。
クラッシュ一覧は、放っておくと「眺めるだけのもの」になります。けれど一段だけ抽象度を上げて「根本原因の単位」で見られるようにすると、また手を動かせる対象に戻ってきます。同じ課題に取り組んでいる方の設計の参考になれば幸いです。