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-writer | revolution-ai-writer | Cloud Run (Docker) | build-arg + --set-env-vars の 2 経路 |
apps/frontend | revolution-frontend | Vercel | Vercel 環境変数 (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) | runtime | Cloud Run --set-env-vars |
SENTRY_ENVIRONMENT | 環境名 | runtime | Cloud Run --set-env-vars (未設定なら NODE_ENV) |
SENTRY_AUTH_TOKEN | source map アップロード | build 時のみ | GitHub Secrets / Vercel (Sensitive ON) |
SENTRY_RELEASE | release 名 | 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.ts | finally { await flushSentry(2000) }。async 関数の finally は await 完了後に return されるため安全 |
app/api/debug/generate-from-feed-mdx/route.ts | handler は 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 実測):
| 処理 | service | cron |
|---|---|---|
| 会場網羅ゲート (#301) | 2 | 0 |
| Vision ハルシネーション検出 | 8 | 0 |
| テキスト / 画像プレースホルダー置換 | 19 | 0 |
| mdx-assembly | 7 | 0 |
| 未置換検査 / Inertia 展開 / void 要素正規化 (#315) | 6 | 0 |
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」だけをメール通知するよう設定してある。したがって:
| 分類 | level | priority | メール |
|---|---|---|---|
captureException | error | High | ✅ 飛ぶ |
captureMessage(..., 'warning') | warning | Medium | ❌ 飛ばない |
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.1 | Spans 枠は 5M/月 と余裕があるが、機能が固まるまで保守的に運用する |
| Session Replay | 入れない | 無料枠 50/月 で監視に足りず、worker-src の CSP 追加も要る |
| Logs | 入れない | logger 導入は別タスク |
deleteSourcemapsAfterUpload | 両アプリ true を明示 | v10 の既定も true だが、将来の既定変更で .map が公開されるのを防ぐ |
sendDefaultPii | false |
関連
apps/ai-writer/lib/observability/sentry.ts—flushSentry/beforeSendFilterapps/*/instrumentation.ts/sentry.server.config.ts/sentry.edge.config.ts/instrumentation-client.ts- ai-writer-cloud-run.md — Cloud Run デプロイ全体
- 公式: Sentry Next.js SDK