
はじめに
エラーログを集約して GitHub Issue に自動起票する仕組みを運用していると、「同じ原因なのに Issue が大量に立つ」問題に遭遇することがあります。
実運用しているサービスでは、1 日で 14 件の Issue と 14 回のアラート起動が発生しました。
原因は、エラーメッセージに requestId や cfRayId などリクエストごとに変わる値が含まれ、fingerprint(SHA-256)が毎回変わっていたことでした。
重複排除も「既存 Issue への追記」も効かない状態です。
この記事では、次の 3 層の防御で解決した方法を紹介します。
- fingerprint の入力から変動値を取り除く(正規化)
- 直近にクローズされた同じ fingerprint の Issue があれば、新規を作らずコメントする
- すり抜けた場合に備え、新規起票に時間あたりの上限をかける
こんな人におすすめ
- エラー監視から GitHub Issue を自動起票していて、重複に悩んでいる方
- Cloudflare Workers + D1 で小さな監視基盤を作っている方
- 並列リクエスト下での「件数上限」を安全に実装したい方
- アラート疲れを避ける通知設計を考えている方
第1層: 変動値を正規化して fingerprint を安定させる
fingerprint の入力は「同じ原因なら同じ文字列」になるまで削ります。
取り除く対象は key=value 形式の ID 類、UUID、ISO 8601 時刻です。
const KEY_VALUE = new RegExp(
`\\b(${VOLATILE_KEYS.join('|')})\\s{0,3}[=:]\\s{0,3}[^\\s,;)\\]}"']{1,256}`,
'gi',
);
const UUID = /\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/gi;
const ISO_TIMESTAMP = /\b\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?(?:Z|[+-]\d{2}:?\d{2})?/g;
export function normalizeForFingerprint(text: string): string {
return text
.replace(KEY_VALUE, (_m, key: string) => `${key.toLowerCase()}=*`)
.replace(UUID, '<uuid>')
.replace(ISO_TIMESTAMP, '<timestamp>');
}
変動するキー名をリスト(VOLATILE_KEYS)で管理し、値だけを * に置き換えます。
キー名は残るので、どのフィールドを丸めたかが追いやすくなります。
ポイントは「消しすぎない」ことです。
status=403 と status=409 のような通常の数字まで消すと、別の障害を同一視してしまいます。
変動値だけを狙って消す方針にしました。
もう一点、正規表現には量指定子の上限を必ず置きます。
認証なしで届く経路があると ReDoS の入口になるためです。
上のコードの {1,256} や \s{0,3} のように、すべて有限にしています。
第2層: 閉じた直後の再発は「新規起票」ではなく「コメント」にする
クローズ直後に同じエラーが再発したとき、新しい Issue を立てると履歴が分断されます。
そこで、open → closed の順で既存 Issue を探します。
const res = await ghFetch(`${base}&state=open&per_page=1`, { method: 'GET' }, ctx);
// ... open が見つかればそれを返す
if (closedLookbackMs <= 0) return null;
const closedRes = await ghFetch(
`${base}&state=closed&sort=updated&direction=desc&per_page=5`,
{ method: 'GET' }, ctx,
);
const recent = closedItems.find((i) => {
const closedAt = i.closed_at ? Date.parse(i.closed_at) : NaN;
return Number.isFinite(closedAt) && now - closedAt <= closedLookbackMs;
});
open を優先し、無ければ期間内にクローズされたものを探します。
ラベル検索には REST の list API を使うため、作成直後の Issue でも整合して見つかります。
期間(既定 7 日)は設定可能にし、0 で無効化できるようにしました。
再開するかどうかは人に委ね、コメントにも「クローズ済み」と明記します。
第3層: アトミックな起票上限を最後の防波堤にする
正規化をすり抜けるケースに備えて、新規起票に時間あたりの上限をかけます。
SELECT COUNT してから INSERT する書き方だと、並列リクエストで上限を超えてしまいます。
そこで、判定と記録を 1 文にまとめます。
const created = await env.DB.prepare(
`INSERT INTO github_issue_budget (kind, created_at)
SELECT 'created', ?1
WHERE (SELECT COUNT(*) FROM github_issue_budget WHERE kind = 'created' AND created_at > ?2) < ?3`,
).bind(now, since, limit).run();
if ((created.meta?.changes ?? 0) === 1) return { allowed: true };
「数える」と「記録する」を分けず、changes が 1 かどうかで枠を取れたかを判定します。
D1 は文を直列に実行するため、並列でも超過しません。
直近 1 時間のスライディングウィンドウなので、境界で一斉に解放されることもありません。
超過通知は「最初の 1 回だけ」にする
上限を超えるたびに通知すると、今度はアラート疲れが起きます。
超過を記録する行を NOT EXISTS 付きの INSERT で 1 時間に 1 件だけ作り、成功した呼び出しだけが要約を送ります。
以降は件数だけ記録し、通知は止めます。
ストレージ障害時は「起票しない」側に倒す
上限の記録が使えないまま起票すると、重複の再発につながります。
そのため、記録に失敗したときは起票を見送ります。
通知経路(Slack など)は止めないので、見逃しにはなりません。
記録テーブルは種別と時刻だけにしておくと、利用者の値を持たず保持の心配が要りません。
古い行は日次の scheduled handler で削除すれば足ります。
つまづきやすいポイント
- 正規化で数字を一律に消すと、
403と409のような別障害が同じ fingerprint になります。キー名を指定して消す設計にしましょう - 正規表現の量指定子を
+や*のままにすると、外部から届く入力で ReDoS の入口になります。必ず{1,256}のように上限を置きます - 上限チェックを
SELECTとINSERTの 2 文に分けると、並列時に超過します。1 文のアトミックな INSERT にします - 超過のたびに通知すると、アラート疲れで本当の障害を見逃しやすくなります。「最初の 1 回だけ」に絞ります
まとめ
重複 Issue の根本原因は、fingerprint に変動値が混ざっていたことでした。
正規化で原因を断ち、クローズ済み Issue への追記で履歴をつなぎ、アトミックな起票上限で最後の防波堤を作る、という 3 層構成にしました。
どの層も単体で完結する小さな仕組みなので、既存の自動起票基盤にも段階的に導入できます。

