メインコンテンツへスキップ
kt-tech.blog

【実装】Cloudflare Workers + Next.js でエラーをDiscord Webhookに自動通知する仕組みを作った

実装10分で読めます

この記事でわかること

  • Discord Webhook だけでエラー通知を成立させる実装(外部サービス不要)
  • worker.ts と API ルートで環境変数の取り方が違う問題と、共通化の切り方
  • void で fire-and-forget にして、通知がレスポンスを遅くしないようにする方法
  • 通知の連打を止める工夫と、メールアドレスをそのまま送らないためのマスク

Next.js(App Router)を Cloudflare Workers(OpenNext)で運用していること。Discord サーバーと Webhook URL が作れること。

ポイント
Cloudflare Workers + Next.js 環境で、全APIルートのエラーをDiscord Webhookに自動通知する仕組みを実装しました。3段階の重要度分類、fire-and-forget による遅延防止、PIIマスクなど、実運用を見据えた設計を解説します。

はじめに

個人開発のプロダクトを Cloudflare Workers にデプロイして運用していたところ、本番でエラーが起きても気づけないという問題がありました。console.error でログは出しているものの、Cloudflare のダッシュボードを能動的に見に行かないと気づけません。

そこで、Discord Webhook を使ってエラーを自動通知する仕組みを作りました。


1. 全体設計

アーキテクチャ

APIルート catch → notifyError() → Discord Webhook POST
worker.ts catch → notifyError(env, ...) → Discord Webhook POST
                        ↓
              buildDiscordPayload() ← 共通関数

ポイントは2つあります。

  • Next.js APIルートでは process.env から環境変数を取得
  • worker.ts(Cloudflare Worker エントリポイント)では env オブジェクトから取得

この違いがあるため、notifyError 関数は2箇所に定義しつつ、Discord embed のペイロード構築は buildDiscordPayload() として共通化しています。

3段階の重要度

レベル アイコン 基準
critical 🚨 プロダクトが機能しなくなる
warning ⚠️ 一部機能に影響するが動く
notice 💬 情報として把握

2. notifyError の実装

コア:ペイロード構築(共通関数)

TypeScript
// src/lib/error-notify.ts
export function buildDiscordPayload(
  options: ErrorNotifyOptions,
  appEnv: string
): object {
  const config = SEVERITY_CONFIG[options.severity];
  const envEmoji = ENV_EMOJI[appEnv] ?? "⚪";
  const fields = options.metadata
    ? Object.entries(options.metadata).map(([name, value]) => ({
        name: `🔹 ${name}`,
        value: `\`${value}\``,
        inline: true,
      }))
    : [];

  return {
    embeds: [{
      author: { name: `${config.emoji} ${config.label}` },
      title: options.title,
      description: `\`\`\`\n${options.description}\n\`\`\``,
      color: config.color,
      fields,
      footer: {
        text: `${envEmoji} ${appEnv.toUpperCase()}  •  anoni`,
      },
      timestamp: new Date().toISOString(),
    }],
  };
}

この関数は webhook URL や環境変数の取得方法に依存しないため、どこからでも使えます。

Next.js APIルート用

TypeScript
// src/lib/error-notify.ts
export async function notifyError(options: ErrorNotifyOptions): Promise<void> {
  console.error(
    `[${options.severity.toUpperCase()}] ${options.title}:`,
    options.description
  );

  const webhookUrl = process.env.DISCORD_WEBHOOK_URL;
  if (!webhookUrl) return;

  const env = process.env.APP_ENV
    || (process.env.NODE_ENV === "production" ? "prod" : "local");

  await fetch(webhookUrl, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(buildDiscordPayload(options, env)),
  }).catch(() => {});
}

console.error を内蔵しているため、呼び出し側は void notifyError(...) だけで済みます。

worker.ts 用(Cloudflare Worker)

TypeScript
// worker.ts
import { buildDiscordPayload, type ErrorNotifyOptions } from "./src/lib/error-notify";

async function notifyError(
  env: CloudflareEnv,
  options: ErrorNotifyOptions
): Promise<void> {
  console.error(...);
  const webhookUrl = env.DISCORD_WEBHOOK_URL;
  if (!webhookUrl) return;
  await fetch(webhookUrl, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(buildDiscordPayload(options, env.APP_ENV || "local")),
  }).catch(() => {});
}

worker.ts では process.env が使えないため、env オブジェクトを引数で受け取る薄いラッパーになっています。

3. 呼び出し側のパターン

fire-and-forget で呼ぶ

TypeScript
} catch (error) {
  void notifyError({
    severity: "critical",
    title: "Stripe Webhook 処理エラー",
    description: `イベント処理に失敗: ${error instanceof Error ? error.message : String(error)}`,
    metadata: { eventType: event.type, eventId: event.id },
  });
  return NextResponse.json({ error: "Webhook processing failed" }, { status: 500 });
}

void をつけることで Promise を await せず、Discord への POST(100-500ms)をブロックしません。notifyError 内部の .catch(() => {}) で通知失敗も握りつぶすため、エラーレスポンスの遅延もなく安全です。

4. レートリミット通知の工夫

レートリミット超過は notice レベルですが、2つの工夫が必要でした。

スパム防止

同じユーザーが100回叩いたら100回通知が飛ぶのは困ります。Set で同一キーの通知を1回に制限しています。

TypeScript
const notifiedNamespaces = new Set<string>();

if (isOverLimit && !notifiedNamespaces.has(key)) {
  notifiedNamespaces.add(key);
  void notifyError({ ... });
}
注意

この Set はモジュールスコープなので、効き方が環境依存です。Workers の isolate は複数リクエストで使い回されるので、生きている間はずっと「通知済み」扱いになります(スパムは防げますが、時間を置いて再発しても通知が飛びません)。逆に isolate が入れ替わればリセットされるので、いつリセットされるかは制御できません

「1 時間に 1 回だけ通知」のようにきちんと制御したいなら、KV など外部のストアに TTL 付きで置く必要があります。個人開発で「とりあえず連打を止めたい」だけなら、このままでも実用上困りません。

PIIマスク

identifier にはメールアドレスが入ることがあるため、マスクしてからDiscordに送ります。

TypeScript
function maskIdentifier(identifier: string): string {
  if (identifier.includes("@")) {
    const [local, domain] = identifier.split("@");
    return `${local[0]}***@${domain}`;
  }
  return identifier.length > 4
    ? `${identifier.slice(0, 2)}***${identifier.slice(-2)}`
    : "***";
}

5. 環境変数と CI/CD

GitHub environment secrets で管理

dev / prod の2環境があるため、GitHub の environment secrets で分けて管理しています。

変数名 dev prod local
DISCORD_WEBHOOK_URL 設定済み 設定済み .env に設定
APP_ENV dev prod 未設定→local

デプロイ時に wrangler secret bulk で Worker secrets に自動同期されます。

通知の見分け方

Discord embed の footer に環境アイコンが表示されるため、一目で判別できます。

  • 🔴 PROD — 本番エラー、即対応
  • 🟡 DEV — 開発環境、確認用
  • 🟢 LOCAL — ローカル開発、テスト用

Tips

Discord Webhook は完全無料

Webhook は何本作っても、何回送っても無料です。レート制限(30リクエスト/分)はありますが、エラー通知程度なら問題ありません。

バッククォートのエスケープ

エラーメッセージにバッククォート()が含まれると Discord の fenced code block が壊れます。replaceAll` でエスケープしています。

TypeScript
description: `\`\`\`\n${options.description.replaceAll("\`", "ˋ")}\n\`\`\``,

テストの書き方

vi.spyOn(global, "fetch") で fetch をモックし、process.env を操作してテストできます。

TypeScript
it("DISCORD_WEBHOOK_URL 設定時に fetch を呼ぶ", async () => {
  process.env.DISCORD_WEBHOOK_URL = "https://discord.com/api/webhooks/test";
  await notifyError({ severity: "warning", title: "テスト", description: "テスト" });
  expect(fetchSpy).toHaveBeenCalledOnce();
});

まとめ

  • Discord Webhook でエラー通知する notifyError を全APIルート(38ファイル、66箇所)に導入
  • buildDiscordPayload を共通関数として切り出し、worker.ts と APIルートで共有
  • fire-and-forget(void notifyError(...))でレスポンス遅延を防止
  • console.error を内蔵し、呼び出し側のコードを最小化
  • レートリミット通知はスパム防止 + PIIマスク付き
  • 追加ライブラリ不要、Cloudflare Workers 完全互換

更新履歴

  1. 通知のスパム防止に使っている Set がモジュールスコープであるため、isolate の使い回しで効き方が変わる(再発しても通知されない / いつリセットされるか制御できない)ことを警告として追加。コードブロックの言語指定を plain text から text に修正。callout の色指定を他記事と揃えた。「この記事でわかること」「対象読者」「前提条件」を概要プロパティへ移動。

この記事のタグ