anime-events.com Docs

Sentry (エラー監視 + トレーシング)

apps/ai-writer と apps/frontend の両方に @sentry/nextjs を導入している。本ドキュメントは 構成の正本であり、計装を追加する人が最初に読むもの。

なぜ入れたか

apps/ai-writer は Cloud Run 上に用意してあるが、現在 Cloud Scheduler は未設定で、定期実行していない (2026-08-16 時点)。AI ライターの品質を先に上げる方針のため、無人稼働はまだ始めていない。

それでも先に入れたのは、導入前は本番の観測手段が実質ゼロだったため:

  • logger 不在 (pino は依存にあるが import 0 件)
  • AI 呼び出しの観測ログ lib/ai/observability/ai-call-recorder.ts は NODE_ENV === 'production' で無効
  • lib/services/article-generation-mdx.service.ts はパイプライン全体を catch して { success: false } を 返すだけで、どの step で落ちても例外が消える

この状態のまま無人稼働を始めると「動いていない」ことにすら気づけない。 観測手段を先に用意してから Scheduler を有効化する、という順序のための導入。

2 project 構成

アプリSentry project実行環境DSN の渡し方
apps/ai-writerrevolution-ai-writerCloud Run (Docker)build-arg + --set-env-vars の 2 経路
apps/frontendrevolution-frontendVercelVercel 環境変数 (Production + Preview)

org は we-are-all-one。project を分けているので DSN の取り違えに注意する — 誤った DSN でも イベントは正常に飛ぶため、間違いに気づけない。DSN 末尾の projectId を Client Keys 画面と照合すること。

DSN は秘密ではない。公開前提の値でブラウザバンドルに含まれる。ただしコードに平文で書かない — 環境ごとに差し替えられなくなるため、必ず env 経由にする。 一方 SENTRY_AUTH_TOKEN (sntrys_ で始まる) は真の秘密で、絶対にコミットしない。

環境変数

変数用途必要なタイミング置き場所
NEXT_PUBLIC_SENTRY_DSNクライアント + ビルド時build 時のみGitHub Variables (SENTRY_DSN_AI_WRITER) / Vercel
SENTRY_DSNサーバ runtime (ai-writer)runtimeCloud Run --set-env-vars
SENTRY_ENVIRONMENT環境名runtimeCloud Run --set-env-vars (未設定なら NODE_ENV)
SENTRY_AUTH_TOKENsource map アップロードbuild 時のみGitHub Secrets / Vercel (Sensitive ON)
SENTRY_RELEASErelease 名build 時CI が github.sha を注入

DSN が 2 本立てになる理由 (冗長ではない)

Next.js は NEXT_PUBLIC_* をバンドル時にリテラル置換する。したがって:

  • NEXT_PUBLIC_SENTRY_DSN は runtime に同名で注入しても読まれない → build-arg で渡す必要がある
  • SENTRY_DSN は prefix が無いので inline されず runtime に読める → 同一イメージのまま環境を切り替えられる

sentry.server.config.ts は SENTRY_DSN || NEXT_PUBLIC_SENTRY_DSN の順で解決するため、 ローカル (.env.local に NEXT_PUBLIC_ だけ書く) でも動く。

?? ではなく || を使う。?? は null / undefined でしかフォールバックしないため、 SENTRY_DSN を空文字で設定すると(Cloud Run / Vercel の env で起こりうる) フォールバックが働かず SDK が無言で no-op になる。4 つの設定ファイルすべてで || に揃えてある。

GitHub 側だけ変数名が違う理由

GitHub Variables に入る DSN は ai-writer の 1 本だけ (frontend は GitHub Actions からデプロイされない)。 汎用名 NEXT_PUBLIC_SENTRY_DSN で置くと、将来 ci.yml の frontend build に DSN を渡したくなったときに ai-writer の値が無警告で frontend に混入する。SENTRY_DSN_AI_WRITER と名前を分けることで この事故が構造的に起きなくなる。

Cloud Run では flush が必須

Cloud Run は --min-instances 0 かつレスポンス送出後に CPU が throttle される。 「返してから背後で送る」型の非同期処理は完走しない。

SDK v10 の flushIfServerless は K_SERVICE で Cloud Run を検出し App Router の route handler を 自動 flush するが、handler が return した後に走る処理は救えない。

場所対応
app/api/cron/rss/route.tsfinally { await flushSentry(2000) }。async 関数の finally は await 完了後に return されるため安全
app/api/debug/generate-from-feed-mdx/route.tshandler は stream を返した時点で return し実処理は start() 内で走る = 自動 flush の射程外。stream を閉じる 3 経路すべてで controller.close() の直前に await する
その他の API routes不要 (自動 flush で足りる)

flushSentry() は lib/observability/sentry.ts にあり、失敗しても業務処理を落とさない (監視の失敗で本番が止まるのは本末転倒)。

⚠️ 計装ポイントは 2 系統ある

app/api/cron/rss/route.ts は独自の runMdxPipeline() を内包しており、 パイプライン本体の ArticleGenerationMdxService.generateMdxFromRSS() を呼んでいない。

経路カバーする計装
cron (Cloud Scheduler)route の catch (tags: entrypoint=cron)
SSE (管理 UI)service の catch (tags: pipeline=mdx)
CLI (pnpm debug:mdx)⚠️ 実質なし。tsx で動かすと @sentry/nextjs の captureException などが undefined になり、service の catch で TypeError が起きて本当のエラーが隠れる (2026-10-03 確認、未修正)。Jest は手動 mock に解決されるので、テストでは検出できない

片方にだけ入れると、もう片方が観測ゼロになる。 計装を足すときは必ず両方を確認すること。

⚠️ cron 経路は品質ゲートを持たない (有効化の前に寄せること)

この 2 実装は対等ではない。cron 側は機能変更の最終コミットが PR #116 で、以降に積んだ 品質ゲートを 1 つも持っていない (2026-08-16 実測):

処理servicecron
会場網羅ゲート (#301)20
Vision ハルシネーション検出80
テキスト / 画像プレースホルダー置換190
mdx-assembly70
未置換検査 / Inertia 展開 / void 要素正規化 (#315)60

cron が使う lib/mdx/template-generator.ts は import が path / schema / types / date のみの 純粋な整形関数で、ゲートを持たない。つまり #291 (多開催) / #292 (会場表現の決定表) / #301 / #315 のすべてを欠いた経路。

Sentry が可視化するのは例外であって、品質の劣化ではない。 計装が入ったことを理由に Cloud Scheduler を有効化しないこと。有効化の前に cron 経路を service へ寄せる。

計装の判断基準 (3 分類)

新しく計装を足すときは、この 3 つのどれかに割り当てる。

captureException = 誰かが起きて対応すべきもの addBreadcrumb = 対応すべき事象が起きたときに原因を知りたいもの 何もしない = ユーザー入力起因で日常的に起きるもの

無料枠 (Developer plan) は Errors 5K / 月。日常的に起きるものを拾うと即座に溶ける。

通知との対応

Developer plan の priority 判定は log level のみで決まり、Alert Rule は 「High priority issue」だけをメール通知するよう設定してある。したがって:

分類levelpriorityメール
captureExceptionerrorHigh✅ 飛ぶ
captureMessage(..., 'warning')warningMedium❌ 飛ばない

3 分類がそのまま通知要否になるので、追加設定なしで「対応すべきものだけが飛ぶ」。

Slack へのまとめ通知 (sentry-digest.yml)

メールとは別に、GitHub Actions が 30 分ごと (毎時 7 分・37 分) に Sentry の API を読み、 新しく出た issue と回帰した issue を Slack へまとめて送る。線引きはメールと同じく priority で決める。

対象送り先メンション
本番 (environment=production) の priority High本番系あり
本番の Medium / Low、本番以外の環境開発系なし
  • token は Internal Integration (Revolution Slack Digest、権限は Issue & Event: Read だけ) の SENTRY_API_TOKEN。source map 用の SENTRY_AUTH_TOKEN (organization token) は権限を変えられず、issue を読めない
  • 新規の判定は検索条件の firstSeen: で行う。**レスポンスの firstSeen は「問い合わせた期間の中で最初に起きた日時」**で、 本当の初回とは限らない (2026-10-04 実測)。environment を付けた検索の firstSeen: は環境ごとの初回 (GroupEnvironment.first_seen) なので、本番の新規は「本番で初めて起きた issue」になる
  • issue のタイトルは Slack の blocks にだけ載せ、Actions のログには出さない (エラー文に何が入るか分からないため)。 issue へのリンクは API の応答の URL ではなく、org の URL と数字の issue id から組み立てる
  • 毎回、直近 24 時間に初めて起きた issue と、いま回帰中の issue を見直す。送ったかどうかは artifact sentry-digest-state の「知らせ済み」(キーは <new|reg>:<prod|dev>:<issue id>) で判断する
    • 「知らせ済み」は送り先ごとに持つ (開発系で知らせた issue が後で本番の High になったら、本番系へ届く)
    • 届いた送り先の分だけ記録するので、届かなかった分は次回に送り直す
    • 回帰は、知らせた issue が回帰中のあいだ知らせない。回帰が終わったら記録を消すので、再び回帰したらまた知らせる
    • priority が High から下がった issue は、24 時間以内なら開発系にも 1 度届く (記録が本番系の分しか無いため)
    • state が無い時 (初回・2 日を超える停止) は、前に届いたものと重複することがある旨を文面に書く
    • 24 時間を超えて止まると、それより前に起きた issue は見直さない (本番の High はメールでも届く)。 追いつく時は workflow_dispatch で dry_run を外し、window_hours (最大 720) を広げる
  • 1 回の取得は 100 件まで。上限に達した時は、一部しか確かめていないことを文面に書く
  • digest 自体の失敗 (token の失効・API の障害など) は notify-main-failures.yml が知らせる。定期実行なので、 成功から失敗に変わった最初の 1 回だけ本番系へ送る。Sentry の token の失効 (取得が 401 / 403) もこれで気づく (以降は復旧まで知らせないので、最初の 1 通を見落とさないこと)
  • ⚠️ public repo の schedule は、60 日間リポジトリに動き (commit など) が無いと GitHub に止められる。止まっても 失敗にはならないので、どこにも知らせが来ない
  • 確認は workflow_dispatch (dry_run の既定は true = テストモードで開発系へ 1 通にまとめて送り、state を保存しない)。 直近 24 時間に issue が無い時は window_hours を広げる

beforeSend による選別

lib/observability/sentry.ts の beforeSendFilter (named export、単体テスト済み):

  • DuplicateSlugError → drop。同一記事の再投入で日常的に起き、呼び出し側は 409 を返して正常処理している
  • retryable === true の GitHub エラー → level: 'warning' へ降格

⚠️ DuplicateSlugError の除外方法は経路によって違う (意図的)。

経路方法なぜ
cron/rss/route.tsinstanceof で分岐し captureException を呼ばない同じ catch で 409 を返す分岐がすでにあり、そこに 1 行足すだけで済む。SDK まで往復させる必要がない
article-generation-mdx.service.ts の全体 catch常に captureException し beforeSendFilter で dropパイプライン全体を包む catch で、ここに来る例外の種類を列挙できない。個別に除外を書くと除外リストの保守が必要になるため、選別は beforeSendFilter に一元化する

つまり 「例外の種類が特定できる箇所は呼ばない / 特定できない箇所は送って filter に任せる」 が方針。 新しく計装を足すときもこの基準で選ぶ。結果はどちらも「送信されない」で同じ。

Server Actions は throw しない

frontend の Server Actions は Result パターン ({ ok: false }) を返すため、 instrumentation.ts の onRequestError に一切乗らない。個別に captureException しない限り 永久に観測できない。ただし全部拾うと枠が溶けるので、判定は:

ユーザー入力に帰責するものは捨て、インフラ / DB に帰責するものだけ拾う

捨てる代表例: zod parse 失敗 / PG_UNIQUE_VIOLATION (username 重複・二重 like) / verifyOtp 失敗 (コードの打ち間違い) / 未ログイン。 捨てる側も __tests__/actions/sentry-instrumentation.test.ts でコントラクト化してある (「念のため拾う」への揺り戻しを防ぐため)。

CSP (frontend)

next.config.mjs の headers() が NEXT_PUBLIC_SENTRY_DSN から URL.origin を導出し、 connect-src に追加する。*.sentry.io のワイルドカードは使わない (リージョン変更に追従し許可範囲が最小)。 URL.origin は userinfo (DSN の public key) を含まないため CSP に載せて安全。

⚠️ headers() は build 時に評価される。NEXT_PUBLIC_SENTRY_DSN が Vercel の Production / Preview 両方に無いとエントリが黙って抜け、クライアントのイベントが全滅するのに 何のエラーも出ない。

テスト

@sentry/nextjs は jest.config.mjs の moduleNameMapper で常に手動 mock (__mocks__/@sentry/nextjs.ts) へ解決される。

transformIgnorePatterns を緩める案は却下した — 実体は @sentry/node / @opentelemetry/* の 巨大依存を引き、遅く、OTel は Node 固有 API 依存で jsdom 環境 (ai-writer) では壊れるため。 resolver 層で差し替えれば transform 自体が走らない。

各テストで jest.mock() を書く方式も却下 (書き忘れ 1 箇所で CI が謎に落ちる)。

計装テストを書くときの落とし穴 2 つ

1. jest.resetModules() + 動的 import の suite では Sentry を取り直す

テストファイル冒頭で静的 import した @sentry/nextjs は、resetModules() 後に 対象モジュールが掴むものと別インスタンスになり、呼び出しを観測できない (assertion が「0 回」で空振りする)。対象を import した後に同じレジストリから取り直すこと。

const sentry = (await import('@sentry/nextjs')) as unknown as { captureMessage: jest.Mock };

2. 「呼ばれないこと」の検証は gitignored な YAML に依存させない

apps/ai-writer/templates/ と config/ は private リポジトリから pnpm sync:templates で 同期される gitignored ディレクトリで、CI ランナーには存在しない。 getMediaTypeMapperService() / getMediaFormResolverService() 等はこれを読むため、 正常系を実体で通そうとすると CI でだけ catch へ落ちて「captureMessage が呼ばれないはず」が反転する。

ci.yml は YAML 依存の suite を testPathIgnorePatterns で除外しているが、 その一覧に無いファイルは CI で実行される。正常系を検証したい場合は 依存サービスを mock し、必要なら別ファイルに分離すること (例: __tests__/unit/lib/services/content-generation-sentry.test.ts)。

現在の設定

項目値備考
tracesSampleRate両アプリ 0.1Spans 枠は 5M/月 と余裕があるが、機能が固まるまで保守的に運用する
Session Replay入れない無料枠 50/月 で監視に足りず、worker-src の CSP 追加も要る
Logs入れないlogger 導入は別タスク
deleteSourcemapsAfterUpload両アプリ true を明示v10 の既定も true だが、将来の既定変更で .map が公開されるのを防ぐ
sendDefaultPiifalse

関連

  • apps/ai-writer/lib/observability/sentry.ts — flushSentry / beforeSendFilter
  • apps/*/instrumentation.ts / sentry.server.config.ts / sentry.edge.config.ts / instrumentation-client.ts
  • ai-writer-cloud-run.md — Cloud Run デプロイ全体
  • 公式: Sentry Next.js SDK

目次