Skip to content

feat: implement SSE streaming for real-time API error updates - #816

Merged
otomatty merged 3 commits into
developfrom
claude/fix-issue-807-DXt75
May 5, 2026
Merged

feat: implement SSE streaming for real-time API error updates#816
otomatty merged 3 commits into
developfrom
claude/fix-issue-807-DXt75

Conversation

@otomatty

@otomatty otomatty commented May 5, 2026

Copy link
Copy Markdown
Owner

概要

管理画面の API エラー一覧をサーバー送信イベント (SSE) でリアルタイム更新する機能を実装しました (Epic #616 Phase 2 / issue #807)。REST API で初期取得した後、/api/admin/errors/stream エンドポイントを EventSource で購読し、Sentry Webhook・GitHub AI コールバック・管理画面の PATCH 操作による更新をリアルタイムで受信します。

変更点

クライアント側 (admin/src/pages/errors/)

  • useApiErrors.ts: SSE 購読機能を追加

    • EventSource/api/admin/errors/stream を購読
    • update イベントで受信した行を既存リストにマージ(同 ID なら置換、新規なら先頭に追加)
    • フィルタ条件に合致しない行は無視(UI の意味的整合を保つ)
    • 接続確立中はフォールバックポーリングを抑制
    • 切断時は exponential backoff で再接続(可視タブのみ)
    • アンマウント時に EventSource を必ず close(ファイル記述子リーク防止)
    • enableStream パラメータで SSE を無効化可能(テスト用)
  • useApiErrors.test.ts (新規): 単体テスト

    • REST 初期取得と SSE マージの動作確認
    • フィルタ非該当行の無視
    • EventSource のライフサイクル管理
    • enableStream: false での SSE 無効化

サーバー側 (server/api/src/)

  • services/apiErrorBroadcaster.ts (新規): in-memory pub/sub

    • subscribeApiErrorUpdates(): SSE 購読者を登録(上限 64 接続)
    • publishApiErrorUpdate(): 全購読者へ行を配信
    • 購読者の例外は隔離(1 つの接続エラーが他に影響しない)
    • 上限超過時は ApiErrorStreamCapacityExceededError を投げ、SSE ルートで 503 にマップ
  • services/apiErrorBroadcaster.test.ts (新規): 単体テスト

    • subscribe/publish/unsubscribe の動作
    • 容量制限の検証
    • 例外隔離の確認
  • routes/admin/errors.ts: SSE エンドポイント追加

    • GET /api/admin/errors/stream: text/event-stream で更新を配信
    • 接続時に ready イベントと retry: 30000 ヒントを送信
    • キープアライブ(25 秒ごと)でプロキシ切断を防止
    • PATCH 操作後に publishApiErrorUpdate() を呼び出し
  • routes/webhooks/sentry.ts: Sentry Webhook で publishApiErrorUpdate() を呼び出し

  • routes/webhooks/githubAiCallback.ts: GitHub AI コールバックで publishApiErrorUpdate() を呼び出し

  • routes/admin/errors.test.ts: SSE エンドポイントのテスト追加

    • 初期 ready イベントの確認
    • update イベントの配信確認
    • 認証・認可の検証

https://claude.ai/code/session_01BKJQ9SDjcRmHdt7uW7LcY5

Summary by CodeRabbit

  • New Features

    • Admin Errors page receives real-time updates; new errors are prepended and existing rows update in-place.
    • UI exposes stream connection state so connectivity is visible.
  • Reliability

    • Automatic reconnection with exponential backoff; server enforces a subscriber cap and returns 503 when full.
    • Polling is paused while the stream is active.
  • Behavior

    • Streamed updates respect active status/severity filters.
  • Tests

    • End-to-end and unit tests cover streaming and broadcaster behavior.

…2 / #807)

- add in-memory `apiErrorBroadcaster` for fan-out of `api_errors` updates
- expose `GET /api/admin/errors/stream` (text/event-stream, adminRequired) with
  initial `ready` event, periodic keep-alive comments, and subscriber cap
- publish from Sentry webhook, GitHub AI callback, and admin PATCH so the UI
  receives push updates from every mutation path
- switch `useApiErrors` to subscribe via `EventSource` with reconnect/cleanup;
  fallback polling kicks in only when the stream is down
- tests: broadcaster unit, SSE route headers + push delivery, hook merge/filter

Closes #807
@coderabbitai

coderabbitai Bot commented May 5, 2026

Copy link
Copy Markdown

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: aa50e7b3-377a-4c9e-9010-4c006ce6e70d

📥 Commits

Reviewing files that changed from the base of the PR and between 428f31f and 2c06ab6.

📒 Files selected for processing (1)
  • server/api/src/routes/admin/errors.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • server/api/src/routes/admin/errors.ts

📝 Walkthrough

Walkthrough

Adds an in-memory server-side SSE broadcaster, an admin SSE endpoint that streams ApiError updates (with subscriber cap and keepalive), server publishers that call the broadcaster from webhooks and a PATCH handler, a client hook that consumes SSE with reconnection/filtering and polling suspension, tests, and exports getApiUrl for EventSource usage.

Changes

API Errors Real-Time Streaming

Layer / File(s) Summary
Broadcaster Service
server/api/src/services/apiErrorBroadcaster.ts
New in-memory pub/sub: API_ERROR_STREAM_MAX_SUBSCRIBERS, ApiErrorUpdateListener, ApiErrorStreamCapacityExceededError, subscribeApiErrorUpdates, publishApiErrorUpdate, apiErrorSubscriberCount, clearApiErrorSubscribers.
SSE Route Implementation
server/api/src/routes/admin/errors.ts
Adds GET /api/admin/errors/stream: pre-checks subscriber cap (503), emits ready (retry: 30000), subscribes to broadcaster, forwards update events (JSON rows), serializes writes, sends : ping keepalives, handles errors and cleanup; also invokes publishApiErrorUpdate after PATCH updates.
Server Producers (publish wiring)
server/api/src/routes/webhooks/sentry.ts, server/api/src/routes/webhooks/githubAiCallback.ts
Call publishApiErrorUpdate(row) after successful upsert/update to notify SSE subscribers.
Client URL Helper Export
admin/src/api/client.ts
getApiUrl(path) made exported and documented for non-fetch consumers (e.g., EventSource) to resolve against VITE_API_BASE_URL.
Client Hook Implementation
admin/src/pages/errors/useApiErrors.ts
Adds enableStream?: boolean param and streamConnected return field; conditionally opens EventSource via getApiUrl with credentials, handles ready/update events, client-side filters by status/severity, merges/prepends rows (mergeRow), manages exponential backoff reconnect gated by document.hidden, and suspends REST polling while SSE is connected.
Tests
admin/src/pages/errors/useApiErrors.test.ts, server/api/src/__tests__/routes/admin/errors.test.ts, server/api/src/__tests__/services/apiErrorBroadcaster.test.ts
Client tests: REST bootstrap, SSE lifecycle, merge/prepend/ignore semantics, enableStream toggle; Server tests: SSE ready/update semantics, auth, 503 on subscriber cap, broadcaster pub/sub behavior, error isolation, and capacity enforcement.

Sequence Diagram

sequenceDiagram
    participant AC as Admin Client
    participant SE as SSE Endpoint
    participant BC as Broadcaster
    participant WH as Webhook / Patch Handler
    participant DB as Database

    AC->>SE: GET /api/admin/errors/stream
    SE->>AC: event: ready (retry: 30000)
    SE->>BC: subscribe listener

    rect rgba(100, 200, 100, 0.5)
    WH->>DB: update / upsert ApiError
    DB-->>WH: updated row
    WH->>BC: publishApiErrorUpdate(row)
    end

    rect rgba(100, 150, 200, 0.5)
    BC->>SE: invoke listener (row)
    SE->>AC: event: update {row}
    end

    rect rgba(200, 150, 100, 0.5)
    AC->>AC: parse update, apply filter, merge/prepend
    AC->>AC: set streamConnected, suspend polling
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Possibly related PRs

  • otomatty/zedi#812: Touches Sentry webhook and admin errors paths; related to integrating SSE publishing into those handlers.
  • otomatty/zedi#811: Implements foundational api_errors data/model that this PR broadcasts and consumes.
  • otomatty/zedi#814: Modifies GitHub AI callback flow; related to publishing API error updates from that handler.

"I nibble logs beneath the moonlight bright,
Streams hum, I twitch and tune each tiny bite,
Subscribers listen, pings keep beats in time,
Updates hop forward—front of list, sublime,
A rabbit cheers: live errors, carrot-fine!"

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 66.67% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat: implement SSE streaming for real-time API error updates' accurately and concisely summarizes the main change across the entire pull request, which implements Server-Sent Events for real-time updates to the API error list.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/fix-issue-807-DXt75

Comment @coderabbitai help to get the list of available commands and usage tips.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: bf380fab13

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread admin/src/pages/errors/useApiErrors.ts Outdated

const open = () => {
if (cancelled) return;
es = new EventSource("/api/admin/errors/stream", { withCredentials: true });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Use API base URL for the SSE stream

In the production admin build, REST calls are sent to VITE_API_BASE_URL (admin/README.md:41, .github/workflows/deploy-prod.yml:169), but this relative EventSource URL connects to the Cloudflare Pages admin origin instead of https://api.zedi-note.app. In that split-origin environment /api/admin/errors/stream will not reach the Hono API, so the new real-time stream never receives updates and the page falls back to polling; build the stream URL with the same API base as adminFetch.

Useful? React with 👍 / 👎.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request implements real-time updates for the API errors dashboard using Server-Sent Events (SSE). It introduces an in-memory broadcaster service on the server to fan out updates from Sentry webhooks, AI analysis callbacks, and manual status changes. The client-side useApiErrors hook was updated to subscribe to the SSE stream, handle automatic reconnections with exponential backoff, and merge incoming updates into the local state. Review feedback identifies a potential resource leak where timers are not cleared during reconnection, a timing issue with HTTP status codes when the subscriber limit is reached, and a suggestion to improve the user experience by moving updated rows to the top of the list.

Comment on lines +239 to +248
const onVisible = () => {
if (typeof document !== "undefined" && document.hidden) return;
// 可視化されたタイミングで未接続なら即時再接続を試みる。
// When the tab becomes visible again, eagerly reconnect if we lost the
// stream while hidden.
if (!es) {
backoff = SSE_RECONNECT_INITIAL_MS;
open();
}
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

タブが可視化された際に open() を呼び出していますが、既存の reconnectTimer がクリアされていないため、タイマーが生きている場合に EventSource が重複して作成される可能性があります。これにより古い接続がリークし、リソースを無駄に消費する原因となります。open() を呼ぶ前に clearTimeout を行うべきです。

Suggested change
const onVisible = () => {
if (typeof document !== "undefined" && document.hidden) return;
// 可視化されたタイミングで未接続なら即時再接続を試みる。
// When the tab becomes visible again, eagerly reconnect if we lost the
// stream while hidden.
if (!es) {
backoff = SSE_RECONNECT_INITIAL_MS;
open();
}
};
const onVisible = () => {
if (typeof document !== "undefined" && document.hidden) return;
// 可視化されたタイミングで未接続なら即時再接続を試みる。
// When the tab becomes visible again, eagerly reconnect if we lost the
// stream while hidden.
if (!es) {
if (reconnectTimer != null) {
window.clearTimeout(reconnectTimer);
reconnectTimer = null;
}
backoff = SSE_RECONNECT_INITIAL_MS;
open();
}
};

Comment on lines +172 to +175
app.get("/stream", (c) => {
return streamSSE(
c,
async (stream) => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

購読者数の上限チェックを streamSSE の内部で行っていますが、この時点では既に HTTP ステータス 200 OK でレスポンスが開始されています。上限に達している場合にクライアントが再試行(バックオフ)の判断を適切に行えるよう、streamSSE を呼び出す前に apiErrorSubscriberCount() を確認し、上限を超えている場合は 503 Service Unavailable を返すべきです。なお、apiErrorSubscriberCount../../services/apiErrorBroadcaster.js からインポートする必要があります。

Suggested change
app.get("/stream", (c) => {
return streamSSE(
c,
async (stream) => {
app.get("/stream", (c) => {
if (apiErrorSubscriberCount() >= API_ERROR_STREAM_MAX_SUBSCRIBERS) {
c.status(503);
return c.json({ error: "subscriber cap reached" });
}
return streamSSE(
c,
async (stream) => {

Comment thread admin/src/pages/errors/useApiErrors.ts Outdated
Comment on lines +84 to +90
const idx = prev.errors.findIndex((r) => r.id === row.id);
if (idx >= 0) {
const next = prev.errors.slice();
next[idx] = row;
return { ...prev, errors: next };
}
return { ...prev, errors: [row, ...prev.errors], total: prev.total + 1 };

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

既存の ID が見つかった場合に元のインデックスで置換していますが、エラー一覧は通常 lastSeenAt の降順でソートされているため、更新された行(最新の発生)はリストの先頭に移動させるのが適切です。現在の実装では、更新された行がリストの下方に埋もれたままになり、リアルタイム更新の利便性が損なわれます。また、ページネーション(offset > 0)利用時の整合性についても考慮が必要です。

  const filtered = prev.errors.filter((r) => r.id !== row.id);
  const isUpdate = filtered.length < prev.errors.length;
  // 常に先頭に配置することで、最新の更新をユーザーに提示する。
  // ただし、offset > 0 の場合はページ間の重複や欠落が発生しうる点に注意。
  return { ...prev, errors: [row, ...filtered], total: isUpdate ? prev.total : prev.total + 1 };

- route SSE URL through `getApiUrl` so split-origin admin builds reach
  the API host instead of the Cloudflare Pages origin
- pre-check subscriber cap before `streamSSE` so capacity exhaustion
  returns a real 503 instead of 200 + SSE error event
- clear pending `reconnectTimer` in the visibilitychange handler to
  avoid spawning a duplicate `EventSource`
- move SSE-updated rows to the front of the list (matches the server's
  `last_seen_at DESC` ordering on a fresh REST fetch)

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
server/api/src/routes/admin/errors.ts (1)

239-246: 💤 Low value

Ping event is not an SSE comment line; it will be delivered to clients.

Lines 242-244 comment that "SSE comment lines (:) are ignored by EventSource", but the code sends { data: "", event: "ping" } which is a named event, not a comment. Clients will receive this as an event: ping message.

If the intent is truly a keep-alive that's invisible to the client, use a raw comment line instead. If clients should handle ping events, update the comment accordingly.

Option A: Use an actual SSE comment (invisible to EventSource)

The writeSSE method may not support raw comments directly. If true invisible keep-alives are needed, you'd need to write directly to the underlying stream (e.g., stream.write(": ping\n\n")). Verify Hono's streaming API supports this.

Option B: Update the comment to reflect actual behavior
-        // SSE コメント行(`:` で始まる)はクライアントには配信されない。
-        // SSE comment lines (leading `:`) are ignored by the EventSource API
-        // but keep the underlying connection alive.
+        // Named `ping` event keeps the connection alive. The client can ignore
+        // it or use it for debugging; what matters is that the TCP stays open.
         await stream.writeSSE({ data: "", event: "ping" });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server/api/src/routes/admin/errors.ts` around lines 239 - 246, The loop
currently uses stream.writeSSE({ data: "", event: "ping" }) which emits a named
SSE "ping" event (clients will receive it) but the comment claims it's an
invisible SSE comment; change this by either: (A) sending a true SSE comment
line to the underlying stream (write a line that begins with ':' and ends with
the SSE record terminator) instead of using writeSSE so it stays invisible, or
(B) update the comment to accurately state that writeSSE is emitting a named
"ping" event that clients will receive; update references in the while loop that
uses SSE_KEEPALIVE_MS and stream.writeSSE accordingly.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@server/api/src/routes/admin/errors.ts`:
- Around line 239-246: The loop currently uses stream.writeSSE({ data: "",
event: "ping" }) which emits a named SSE "ping" event (clients will receive it)
but the comment claims it's an invisible SSE comment; change this by either: (A)
sending a true SSE comment line to the underlying stream (write a line that
begins with ':' and ends with the SSE record terminator) instead of using
writeSSE so it stays invisible, or (B) update the comment to accurately state
that writeSSE is emitting a named "ping" event that clients will receive; update
references in the while loop that uses SSE_KEEPALIVE_MS and stream.writeSSE
accordingly.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 73878cf9-99bf-4319-ae49-3bc490141f46

📥 Commits

Reviewing files that changed from the base of the PR and between bf380fa and 428f31f.

📒 Files selected for processing (5)
  • admin/src/api/client.ts
  • admin/src/pages/errors/useApiErrors.test.ts
  • admin/src/pages/errors/useApiErrors.ts
  • server/api/src/__tests__/routes/admin/errors.test.ts
  • server/api/src/routes/admin/errors.ts
✅ Files skipped from review due to trivial changes (1)
  • admin/src/pages/errors/useApiErrors.test.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • admin/src/pages/errors/useApiErrors.ts

`stream.writeSSE({ event: "ping" })` always emits an `event:` field, so the
client receives a named ping event — not the invisible keep-alive the
comment claimed. Switch to `stream.write(": ping\n\n")` so the heartbeat
is a true SSE comment line (ignored by EventSource per the spec) while
still keeping idle proxies from tearing down the TCP connection.

PR #816 review feedback.
@otomatty otomatty self-assigned this May 5, 2026
@otomatty
otomatty merged commit 4252952 into develop May 5, 2026
16 checks passed
@otomatty
otomatty deleted the claude/fix-issue-807-DXt75 branch May 5, 2026 05:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants