-
Notifications
You must be signed in to change notification settings - Fork 0
feat(api): add email-only notifier for high/medium API errors (#809) #818
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 1 commit
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -25,6 +25,7 @@ import { | |
| type UpdateAiAnalysisInput, | ||
| } from "../../services/apiErrorService.js"; | ||
| import { publishApiErrorUpdate } from "../../services/apiErrorBroadcaster.js"; | ||
| import { notifyApiErrorAlert } from "../../services/notifier.js"; | ||
| import { verifyInstallationToken } from "../../lib/githubAppAuth.js"; | ||
| import type { AppEnv } from "../../types/index.js"; | ||
|
|
||
|
|
@@ -161,6 +162,21 @@ app.put("/:id", async (c) => { | |
| // SSE 購読者へ AI 解析結果を配信 (Phase 2 / issue #807)。 | ||
| // Notify SSE subscribers so the admin UI updates without a page reload. | ||
| publishApiErrorUpdate(updated); | ||
| // 重要エラーのメール通知 (Phase 3 / issue #809)。severity が `high` / | ||
| // `medium` 以外、もしくは `MONITORING_NOTIFY_EMAIL` 未設定の場合は no-op。 | ||
| // ここを唯一の呼び出し元にして二重通知を防ぐ。fire-and-forget で | ||
| // webhook 応答を遅らせない(notifier 側でエラーは swallow 済み)。 | ||
| // | ||
| // Email alert for high-impact errors (Phase 3 / #809). The notifier is a | ||
| // no-op when severity is `low`/`unknown` or `MONITORING_NOTIFY_EMAIL` is | ||
| // unset. This is the single call site to prevent duplicate alerts; we | ||
| // fire-and-forget so the webhook response doesn't await Resend. | ||
| void notifyApiErrorAlert({ | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Useful? React with 👍 / 👎.
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Good catch. Fixed in 96da9b6. The route now reads the row via
Tests added in Generated by Claude Code |
||
| apiErrorId: updated.id, | ||
| sentryIssueId: updated.sentryIssueId, | ||
| severity: updated.severity, | ||
| title: updated.title, | ||
| }); | ||
| // 外部 (GitHub Actions) 向けの webhook なので、`error` キーを成功時に流用する | ||
| // 内部 admin API の慣習ではなく、`data` キーで返して "error 有無で失敗判定" | ||
| // できる素直な形にする(admin/src/api/admin.ts と異なり消費者がまだ存在しない)。 | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,193 @@ | ||
| /** | ||
| * `notifier.ts` の単体テスト (Epic #616 Phase 3 / sub-issue #809)。 | ||
| * | ||
| * 本プロジェクトでは外部通知をメール経由のみに限定する(Slack は使わない)。 | ||
| * テストは `emailService` を mock して、severity と環境変数の組み合わせで | ||
| * 1 通だけ送られる / 送られないことを担保する。PII (Authorization / Cookie / | ||
| * raw email) が本文に混入しないことも検証する。 | ||
| * | ||
| * Unit tests for `notifier.ts` (Epic #616 Phase 3 / sub-issue #809). External | ||
| * alerting is intentionally email-only (no Slack). Tests mock `emailService` | ||
| * and assert send count for each (severity, env) combination, plus PII-safe | ||
| * body content (no Authorization / Cookie / raw email leakage). | ||
| */ | ||
| import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; | ||
|
|
||
| const mockSendEmail = vi.fn(); | ||
|
|
||
| vi.mock("./emailService.js", () => ({ | ||
| sendEmail: mockSendEmail, | ||
| })); | ||
|
|
||
| // モック設定後にインポート / Import after mock setup | ||
| const { notifyApiErrorAlert } = await import("./notifier.js"); | ||
|
|
||
| describe("notifyApiErrorAlert", () => { | ||
| const originalEnv = process.env; | ||
|
|
||
| beforeEach(() => { | ||
| process.env = { ...originalEnv }; | ||
| vi.clearAllMocks(); | ||
| mockSendEmail.mockResolvedValue({ success: true, id: "email_id_1" }); | ||
| vi.spyOn(console, "warn").mockImplementation(() => undefined); | ||
| vi.spyOn(console, "error").mockImplementation(() => undefined); | ||
| vi.spyOn(console, "log").mockImplementation(() => undefined); | ||
| }); | ||
|
|
||
| afterEach(() => { | ||
| process.env = originalEnv; | ||
| vi.restoreAllMocks(); | ||
| }); | ||
|
|
||
| it("MONITORING_NOTIFY_EMAIL 未設定なら送信しない (no-op)", async () => { | ||
| delete process.env.MONITORING_NOTIFY_EMAIL; | ||
|
|
||
| const result = await notifyApiErrorAlert({ | ||
| apiErrorId: "00000000-0000-0000-0000-000000000001", | ||
| sentryIssueId: "sentry-abc", | ||
| severity: "high", | ||
| title: "TypeError: cannot read property", | ||
| }); | ||
|
|
||
| expect(result.email.sent).toBe(false); | ||
| expect(mockSendEmail).not.toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| it("severity が low / unknown のときは送信しない", async () => { | ||
| process.env.MONITORING_NOTIFY_EMAIL = "ops@example.com"; | ||
|
|
||
| const lowResult = await notifyApiErrorAlert({ | ||
| apiErrorId: "00000000-0000-0000-0000-000000000001", | ||
| sentryIssueId: "sentry-low", | ||
| severity: "low", | ||
| title: "minor", | ||
| }); | ||
| const unknownResult = await notifyApiErrorAlert({ | ||
| apiErrorId: "00000000-0000-0000-0000-000000000002", | ||
| sentryIssueId: "sentry-unknown", | ||
| severity: "unknown", | ||
| title: "unclassified", | ||
| }); | ||
|
|
||
| expect(lowResult.email.sent).toBe(false); | ||
| expect(unknownResult.email.sent).toBe(false); | ||
| expect(mockSendEmail).not.toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| it("severity=high かつ MONITORING_NOTIFY_EMAIL 設定済みで 1 通だけ送る", async () => { | ||
| process.env.MONITORING_NOTIFY_EMAIL = "ops@example.com"; | ||
|
|
||
| const result = await notifyApiErrorAlert({ | ||
| apiErrorId: "00000000-0000-0000-0000-000000000001", | ||
| sentryIssueId: "sentry-abc", | ||
| severity: "high", | ||
| title: "TypeError: cannot read property", | ||
| }); | ||
|
|
||
| expect(result.email.sent).toBe(true); | ||
| expect(mockSendEmail).toHaveBeenCalledTimes(1); | ||
| const args = mockSendEmail.mock.calls[0]?.[0]; | ||
| expect(args.to).toBe("ops@example.com"); | ||
| expect(args.subject).toContain("high"); | ||
| expect(args.subject).toContain("sentry-abc"); | ||
| }); | ||
|
|
||
| it("severity=medium でも送る", async () => { | ||
| process.env.MONITORING_NOTIFY_EMAIL = "ops@example.com"; | ||
|
|
||
| const result = await notifyApiErrorAlert({ | ||
| apiErrorId: "00000000-0000-0000-0000-000000000003", | ||
| sentryIssueId: "sentry-med", | ||
| severity: "medium", | ||
| title: "Latency spike", | ||
| }); | ||
|
|
||
| expect(result.email.sent).toBe(true); | ||
| expect(mockSendEmail).toHaveBeenCalledTimes(1); | ||
| }); | ||
|
|
||
| it("ADMIN_BASE_URL 設定時は管理画面 URL を本文に含める", async () => { | ||
| process.env.MONITORING_NOTIFY_EMAIL = "ops@example.com"; | ||
| process.env.ADMIN_BASE_URL = "https://admin.zedi-note.app"; | ||
|
|
||
| await notifyApiErrorAlert({ | ||
| apiErrorId: "00000000-0000-0000-0000-000000000001", | ||
| sentryIssueId: "sentry-abc", | ||
| severity: "high", | ||
| title: "TypeError", | ||
| }); | ||
|
|
||
| const args = mockSendEmail.mock.calls[0]?.[0]; | ||
| expect(args.html).toContain( | ||
| "https://admin.zedi-note.app/errors/00000000-0000-0000-0000-000000000001", | ||
| ); | ||
| }); | ||
|
|
||
| it("ADMIN_BASE_URL 未設定時は URL を本文に含めない(no-op フォールバック)", async () => { | ||
| process.env.MONITORING_NOTIFY_EMAIL = "ops@example.com"; | ||
| delete process.env.ADMIN_BASE_URL; | ||
|
|
||
| await notifyApiErrorAlert({ | ||
| apiErrorId: "00000000-0000-0000-0000-000000000001", | ||
| sentryIssueId: "sentry-abc", | ||
| severity: "high", | ||
| title: "TypeError", | ||
| }); | ||
|
|
||
| const args = mockSendEmail.mock.calls[0]?.[0]; | ||
| // 管理画面 URL は載せないが、最低限 sentry_issue_id と severity は本文にある。 | ||
| // No admin URL is rendered, but sentry_issue_id and severity are still | ||
| // present so an on-call can pivot to the Sentry issue directly. | ||
| expect(args.html).not.toContain("http"); | ||
| expect(args.html).toContain("sentry-abc"); | ||
| expect(args.html).toContain("high"); | ||
| }); | ||
|
|
||
| it("PII (Authorization / Cookie / raw email) を本文に含めない", async () => { | ||
| process.env.MONITORING_NOTIFY_EMAIL = "ops@example.com"; | ||
| process.env.ADMIN_BASE_URL = "https://admin.zedi-note.app"; | ||
|
|
||
| await notifyApiErrorAlert({ | ||
| apiErrorId: "00000000-0000-0000-0000-000000000001", | ||
| sentryIssueId: "sentry-abc", | ||
| severity: "high", | ||
| title: "TypeError", | ||
| }); | ||
|
|
||
| const args = mockSendEmail.mock.calls[0]?.[0]; | ||
| const haystack = `${args.subject}\n${args.html}`.toLowerCase(); | ||
| expect(haystack).not.toContain("authorization"); | ||
| expect(haystack).not.toContain("cookie"); | ||
| expect(haystack).not.toContain("bearer "); | ||
| }); | ||
|
|
||
| it("emailService が失敗しても throw せず success=false を返す", async () => { | ||
| process.env.MONITORING_NOTIFY_EMAIL = "ops@example.com"; | ||
| mockSendEmail.mockResolvedValueOnce({ success: false, error: "Rate limit" }); | ||
|
|
||
| const result = await notifyApiErrorAlert({ | ||
| apiErrorId: "00000000-0000-0000-0000-000000000001", | ||
| sentryIssueId: "sentry-abc", | ||
| severity: "high", | ||
| title: "TypeError", | ||
| }); | ||
|
|
||
| expect(result.email.sent).toBe(false); | ||
| expect(result.email.error).toBe("Rate limit"); | ||
| }); | ||
|
|
||
| it("emailService が例外を投げても throw せず success=false を返す", async () => { | ||
| process.env.MONITORING_NOTIFY_EMAIL = "ops@example.com"; | ||
| mockSendEmail.mockRejectedValueOnce(new Error("Network down")); | ||
|
|
||
| const result = await notifyApiErrorAlert({ | ||
| apiErrorId: "00000000-0000-0000-0000-000000000001", | ||
| sentryIssueId: "sentry-abc", | ||
| severity: "high", | ||
| title: "TypeError", | ||
| }); | ||
|
|
||
| expect(result.email.sent).toBe(false); | ||
| expect(result.email.error).toContain("Network down"); | ||
| }); | ||
| }); |
Uh oh!
There was an error while loading. Please reload this page.