Skip to content

feat(sentry): add web and worker observability#374

Merged
YJSN180 merged 3 commits into
mainfrom
codex/feat/sentry-observability
Apr 20, 2026
Merged

feat(sentry): add web and worker observability#374
YJSN180 merged 3 commits into
mainfrom
codex/feat/sentry-observability

Conversation

@YJSN180
Copy link
Copy Markdown
Owner

@YJSN180 YJSN180 commented Apr 20, 2026

Summary

  • add Sentry to Next.js browser/server/edge with strict scrub + targeted tracing
  • add Sentry to the active Cloudflare workers and video-stats-updater, including Crons check-ins and sourcemap upload settings
  • add worker-specific validation so build:worker / check:all cover the current deploy targets instead of an old unused worker entry

Verification

  • npm run build
  • npm run typecheck
  • npm run build:worker
  • git diff --check
  • set -a; source .dev.vars; set +a; npx wrangler deploy --dry-run -c wrangler.toml
  • set -a; source .dev.vars; set +a; npx wrangler deploy --dry-run -c wrangler-green.toml
  • set -a; source workers/video-stats-updater/.dev.vars; set +a; npx wrangler deploy --dry-run -c workers/video-stats-updater/wrangler.toml

Notes

  • left unrelated local changes out of this PR: docs/CODECOV_SETUP.md, .eslintignore, __tests__/unit/run-update.test.ts
  • current build still emits pre-existing warnings for next.config.mjs (turbopack: false) and ESLint option handling during next build; these were not expanded in this PR

Summary by CodeRabbit

  • New Features

    • Global error UI added to surface application errors with a localized message.
    • “再試行” (Retry) button on error screens to recover from failures.
  • Documentation

    • Added a Sentry observability guide detailing configuration, privacy controls, and verification checklists.

- wire Sentry into Next.js browser/server/edge and Cloudflare workers

- add strict scrubbing, targeted tracing, crons monitoring, and sourcemap upload config

- strengthen worker validation so check:all covers active deploy targets
@vercel
Copy link
Copy Markdown

vercel Bot commented Apr 20, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
nico-ranking-custom Ready Ready Preview, Comment Apr 20, 2026 11:45pm
rss-news-summarizer Ready Ready Preview, Comment Apr 20, 2026 11:45pm

Request Review

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 20, 2026

Caution

Review failed

Pull request was closed or merged during review

📝 Walkthrough

Walkthrough

Integrates Sentry across runtimes: adds SDK init/config, instrumentation utilities, data-scrubbing helpers, worker integrations, CI/build config for sourcemaps, and inserts error-capture calls across client, server, edge, and Cloudflare Worker code paths.

Changes

Cohort / File(s) Summary
Environment & Build
\.env\.example, next\.config\.mjs, package\.json, tsconfig\.json, tsconfig\.workers\.json
Added Sentry env placeholders, integrated withSentryConfig in Next config, added Sentry deps and worker typecheck, expanded TS includes and worker tsconfig.
Docs & CI
docs/sentry-observability.md, .github/workflows/deploy-worker.yml
New Sentry observability docs and added SENTRY_* secrets to worker deployment job.
Client Instrumentation
instrumentation-client\.ts, app/global-error\.tsx, components/error-boundary\.tsx, app/client-page\.tsx, app/mylists/.../mylist-detail-client\.tsx, app/admin/ng-settings/page\.tsx, app/page\.tsx
Added browser init, router hook export, GlobalError component, and inserted captureWebException calls in client pages/components with structured tags/contexts.
Server / Edge Init
instrumentation\.ts, sentry\.server\.config\.ts, sentry\.edge\.config\.ts
Added runtime register, dynamic server/edge Sentry init modules with sampling and scrubbing hooks.
Server API Instrumentation
app/api/admin/ng-list/route\.ts, app/api/admin/video-info/route\.ts, app/api/cron/fetch/route\.ts, app/api/ranking/route\.ts
Inserted captureWebException calls in GET/POST/cron/ranking handlers' catch blocks with contextual tags/contexts.
Sentry Core Helpers
lib/sentry/capture\.ts, lib/sentry/shared\.ts
New captureWebException and comprehensive scrubbing/normalization helpers (URL normalization, scrubEvent/scrubBreadcrumb, buildSafeTags, env helpers).
Cloudflare Workers: SDK & Helpers
workers/sentry\.js, workers/types\.d\.ts
Worker-side Sentry factory, scrubbers, sampler, captureWorkerException, and module boundary adjustment.
Cloudflare Workers: Wrapped Handlers
workers/api-gateway-green-20250726\.ts, workers/smart-router-20250706\.ts, workers/video-stats-updater/src/index\.js
Wrapped handlers with Sentry.withSentry, added Env SENTRY bindings, added captureWorkerException calls across multiple error paths, restructured handler exports and monitoring.
Worker Deployment
workers/video-stats-updater/wrangler\.toml, wrangler\.toml, wrangler-green\.toml
Enabled upload_source_maps, added ENVIRONMENT/CF config, account_id, and worker-specific wrangler config including cron and KV/R2 bindings.
Typecheck & Tests
tsconfig\.typecheck\.json, __tests__/unit/sentry-shared\.test\.ts
New typecheck config and unit tests for shared Sentry helper behavior (sanitization/normalization).
Removed Worker Type Definitions
workers/cloudflare\.d\.ts
Deleted Cloudflare Workers ambient type-definition module (removes previously declared KV/ExecutionContext interfaces).

Sequence Diagram(s)

sequenceDiagram
    autonumber
    participant Browser as Browser (Client)
    participant NextServer as Next.js Server (SSR/API)
    participant Worker as Cloudflare Worker
    participant Sentry as Sentry Ingest

    Browser->>NextServer: API request / page action
    Note right of NextServer: server/edge code executes\n(error may occur)
    NextServer-->>Sentry: captureWebException(error, tags/contexts)
    NextServer->>Browser: 500 / error response

    Browser->>Sentry: captureWebException(error, tags/contexts) [client-side]
    Browser-->>Sentry: breadcrumb/transaction (beforeSend scrubbing)

    Browser->>Worker: Request proxied to Worker (or Worker scheduled task)
    Worker-->>Sentry: captureWorkerException(error, tags/contexts)
    Worker->>Sentry: sourcemap-enabled upload (build/CI)
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~75 minutes

Possibly related PRs

Poem

🐰
I hopped through code both near and far,
Caught stray errors like a little star,
I scrubbed their trails, gave warnings a home,
From browser, server, worker — no more roam.
Hooray — observability carrots for all! 🥕

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 26.67% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat(sentry): add web and worker observability' accurately reflects the main objective: integrating Sentry observability across Next.js (web) and Cloudflare Workers (worker) runtimes.

✏️ 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 codex/feat/sentry-observability

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 11

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
app/api/cron/fetch/route.ts (1)

231-245: ⚠️ Potential issue | 🟡 Minor

Lost log context for Cloudflare KV failures.

Unlike the other three capture sites in this file, the cfError branch no longer emits a console.error, so the only remaining signal in Vercel logs for a CF KV write failure is the Sentry capture. If that's intentional (since Sentry is now the source of truth), ignore; otherwise, consider retaining a console.error('[Cloudflare KV] write failed:', cfError) alongside the capture for parity with the kill-switch / KV monitor handlers.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/api/cron/fetch/route.ts` around lines 231 - 245, The Cloudflare KV error
handler for setRankingToKV currently only calls captureWebException(cfError,
{...}) and omits a console error, losing local log parity with other handlers;
add a console.error('[Cloudflare KV] write failed:', cfError) immediately before
or after the captureWebException call in the catch block handling cfError so
local Vercel logs retain the failure signal while Sentry still receives the
event.
🧹 Nitpick comments (3)
lib/sentry/capture.ts (1)

12-13: Non-Error inputs lose structure via String(error).

For thrown objects (common from fetch/third-party libs), String(error) yields "[object Object]", which then becomes the Sentry message and defeats grouping. Consider preserving the raw value as an extra and producing a slightly more informative normalized error.

♻️ Suggested tweak
-export function captureWebException(error: unknown, options: CaptureOptions = {}) {
-  const normalizedError = error instanceof Error ? error : new Error(String(error))
+export function captureWebException(error: unknown, options: CaptureOptions = {}) {
+  const normalizedError =
+    error instanceof Error
+      ? error
+      : new Error(
+          typeof error === 'string'
+            ? error
+            : (() => {
+                try { return JSON.stringify(error) } catch { return String(error) }
+              })(),
+        )
@@
   Sentry.withScope((scope) => {
+    if (!(error instanceof Error)) {
+      scope.setExtra('originalThrownValue', error)
+    }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/sentry/capture.ts` around lines 12 - 13, captureWebException currently
converts non-Error throws to new Error(String(error)), which loses structure for
thrown objects; update the normalization inside captureWebException to (1)
attempt to serialize the original non-Error value (e.g., try JSON.stringify with
a try/catch and fallback to String) to produce a more informative Error message,
and (2) attach the original raw value to the Sentry extras (e.g.,
options.extra['rawError'] or similar) before sending so the original object is
preserved for grouping and debugging; keep the normalized Error instance for
Sentry but ensure the raw payload is carried along in options/extra when calling
the final capture/submit routine.
app/admin/ng-settings/page.tsx (1)

67-103: action tag value disagrees with the server route for the same endpoint.

The client reports action: 'load' / action: 'save' on /api/admin/ng-list, but app/api/admin/ng-list/route.ts tags the server-side captures with action: 'get' / action: 'post' for the exact same handlers. That split makes Sentry filtering/grouping by action effectively require OR-ing both names for every query. Pick one convention and apply it on both sides (I'd suggest the HTTP-verb style get/post, or the semantic load/save, consistently).

Same issue applies to the save path below (L160-178, L195-210).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/admin/ng-settings/page.tsx` around lines 67 - 103, The client Sentry tags
for captureWebException on the /api/admin/ng-list endpoint use action:
'load'/'save' but the server route uses 'get'/'post', so unify them by changing
the client action tags to the HTTP-verb convention ('get' for the load/fetch
code path and 'post' for the save code path) wherever captureWebException is
called for '/api/admin/ng-list' (e.g., in the load block that calls
migrateLegacyNGList and in the save block), so filtering/grouping matches the
server-side route tagging.
workers/video-stats-updater/src/index.js (1)

257-274: Throttle Snapshot API batch failures to one summarized event per run.

If the Snapshot API is degraded, this emits one Sentry event per failed batch every 5-minute cron run. Aggregate the failures and capture a single summary to avoid noisy incidents and quota burn.

Sketch of an aggregate capture approach
   const allStats = {};
   const batches = batchArray(videoIds, BATCH_SIZE);
+  const batchFailures = [];
@@
     } catch (error) {
       console.error(`Failed to fetch batch ${index + 1}:`, error);
-      captureWorkerException(error, {
-        tags: {
-          runtime: 'cloudflare-worker',
-          surface: 'video-stats-updater',
-          endpoint_family: 'scheduled',
-          upstream_kind: 'snapshot-api',
-          worker_version: 'video-stats-updater',
-        },
-        contexts: {
-          batch: {
-            batchIndex: index + 1,
-            batchSize: batch.length,
-          },
-        },
-      });
+      batchFailures.push({ batchIndex: index + 1, batchSize: batch.length });
       return {};
     }
   });
@@
   const batchResults = await Promise.all(batchPromises);
+  if (batchFailures.length > 0) {
+    captureWorkerException(new Error(`Snapshot API failed for ${batchFailures.length}/${batches.length} batches`), {
+      tags: {
+        runtime: 'cloudflare-worker',
+        surface: 'video-stats-updater',
+        endpoint_family: 'scheduled',
+        upstream_kind: 'snapshot-api',
+        worker_version: 'video-stats-updater',
+      },
+      contexts: {
+        batchFailures: {
+          failedBatchCount: batchFailures.length,
+          totalBatchCount: batches.length,
+          failures: batchFailures.slice(0, 10),
+        },
+      },
+    });
+  }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@workers/video-stats-updater/src/index.js` around lines 257 - 274, The current
catch in the batch loop calls captureWorkerException for every failed batch;
change this to aggregate failures and emit a single summarized event after the
loop. Specifically: inside the catch that currently references index and batch,
stop calling captureWorkerException there and instead push a small error summary
object (e.g., { batchIndex: index+1, batchSize: batch.length, message:
error.message, code: error.code }) onto a run-scoped array (suggest name
snapshotBatchErrors). Keep the console.error for immediate logs if you want.
After the batch-processing loop completes, if snapshotBatchErrors.length > 0
call captureWorkerException once with the same tags used now but a contexts
payload that summarizes failures (failedCount, failedBatches: array of indices,
batchSizes, and a few errorSamples or top messages) so a single Sentry event
represents the run’s aggregated failures.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@app/global-error.tsx`:
- Around line 11-20: The GlobalError component calls captureWebException but
doesn't surface Next.js's server error correlation key; update the
captureWebException invocation in GlobalError (the function and useEffect block)
to include error.digest (when present) as either a tag e.g. tags: { ..., digest:
error.digest } or as extra data e.g. extra: { digest: error.digest } so Sentry
events can be correlated with server logs.

In `@app/page.tsx`:
- Around line 186-205: The Sentry tags are being set directly from unvalidated
URL params (genre, period) in the captureWebException call, which risks high
cardinality and long values; validate and normalize these before tagging (e.g.,
check genre against RANKING_GENRES and period against your known periods/enum,
and if not found set to a sentinel like "unknown"), use the normalized values in
tags passed to captureWebException, and move the original raw values (if you
want to record them) into contexts or extras (e.g., ranking_request or
extras.rawParams) instead of tags; keep has_tag as a boolean but ensure any raw
tag string is not used as a tag.

In `@instrumentation-client.ts`:
- Around line 13-36: The client is using getSentryEnvironment() which reads
server-only VERCEL_ENV, causing previews to be labeled production; add a
client-side variant that checks the public env var (NEXT_PUBLIC_VERCEL_ENV)
first (e.g., create getSentryEnvironmentClient or update getSentryEnvironment to
read process.env.NEXT_PUBLIC_VERCEL_ENV when in browser) and update
instrumentation-client to call that client helper (and any dependent predicate
like isProductionSentryEnvironment if it relies on the environment) so preview
builds in the browser pick up NEXT_PUBLIC_VERCEL_ENV and are sampled correctly.

In `@lib/sentry/shared.ts`:
- Around line 1-13: SENSITIVE_QUERY_KEYS currently only exact-matches literal
keys and misses common variants like access_token, refreshToken, api_key,
client_secret, secret, session, etc.; update the redaction logic that uses
SENSITIVE_QUERY_KEYS to normalize incoming query parameter names (to lowercase),
strip common punctuation/spacing, and then check both exact matches against
SENSITIVE_QUERY_KEYS and pattern matches (e.g., endsWith('token'),
endsWith('secret'), contains('key'), contains('session'), or matches
/(^|_)(key|token|secret|session|password|api|access|refresh)($|_)/) to catch
snake_case/camelCase variants—keep SENSITIVE_QUERY_KEYS as core literals but add
these additional normalized/pattern checks where query params are filtered so
all common secret variants are redacted.
- Around line 135-189: The scrubEvent/scrubBreadcrumb flow currently misses
scrubbing top-level message, logentry.message, exception.values[*].value, and
extra payloads; update scrubEvent (and reuse helpers like sanitizeUrlForSentry)
to: inspect nextEvent.message and nextEvent.logentry?.message and replace/redact
any strings matching secrets/URL/token/password patterns; iterate
nextEvent.exception?.values and sanitize each .value (replace URLs/tokens or set
to '[redacted]'); and deep-sanitize nextEvent.extra (remove headers/inputs and
sanitize any string fields containing URLs or tokens). Keep using existing
helpers (sanitizeUrlForSentry, scrubBreadcrumb, scrubRequest, scrubContexts,
normalizeTransactionName) and ensure scrubEvent returns the sanitized nextEvent.

In `@sentry.server.config.ts`:
- Around line 19-29: The fallback in tracesSampler uses
samplingContext.attributes?.['sentry.source'] which is a source type (e.g.,
'url'|'route') and not the transaction name, so passing it to
normalizeTransactionName makes the "/api/" check dead; update the tracesSampler
logic in the tracesSampler function to use samplingContext.name || '' (or simply
remove the attribute fallback) before calling normalizeTransactionName (i.e.,
call normalizeTransactionName(samplingContext.name || '') ) so the
transactionName can correctly be checked for '/api/'.

In `@tsconfig.workers.json`:
- Around line 13-18: The tsconfig.workers.json include array currently lists
specific worker filenames which can silently miss files when names change;
update the "include" entry to use a glob for worker files (e.g. replace the
individual entries like "workers/smart-router-20250706.ts" and
"workers/api-gateway-green-20250726.ts" with a pattern such as
"workers/*-[0-9]*.ts") while keeping "workers/utils/**/*.ts" and
"workers/types.d.ts" so that the compiler always picks up dated/rotated worker
files; edit the include array in tsconfig.workers.json accordingly.

In `@workers/sentry.js`:
- Around line 43-99: Update scrubBreadcrumb to sanitize breadcrumb.message via
the shared text sanitizer (use sanitizeTextForSentry or equivalent) in addition
to existing data.url handling, and ensure message is stripped of
query/tag/path-like sensitive content; also extend scrubEvent to process
nextEvent.spans: map over nextEvent.spans and for each span sanitize
span.description with the same text sanitizer, sanitize any URL-like fields in
span.data with sanitizeUrlForSentry, and delete body-like or header-like keys
(e.g., headers, request_body, response_body, url query params) from span.data;
keep references to scrubBreadcrumb and scrubEvent so you update those functions
only.

In `@workers/video-stats-updater/src/index.js`:
- Around line 349-357: The failure is being reported twice because
processVideoStatsUpdate currently captures and rethrows errors with
endpoint_family: 'scheduled' while the /trigger handler also captures the same
error as endpoint_family: '/trigger'; modify processVideoStatsUpdate to accept
an endpointFamily (string) parameter and use that for its captureWorkerException
call (or skip internal capture when a caller-provided flag indicates outer
handling), and update the manual trigger handler (the /trigger route) to pass
endpointFamily: '/trigger' into processVideoStatsUpdate (or set the flag) so
only the outer handler logs the manual-trigger failure; apply the same change to
the other similar block that uses captureWorkerException for this flow.

In `@workers/video-stats-updater/wrangler.toml`:
- Around line 22-25: The committed WORKER_AUTH_KEY in wrangler.toml (the [vars]
section) must be rotated and removed: immediately revoke/rotate the exposed
64-hex key wherever accepted, delete WORKER_AUTH_KEY from the [vars] block in
wrangler.toml, and store the new secret per-environment using the Wrangler CLI
(wrangler secret put WORKER_AUTH_KEY) instead of version-controlled vars; also
ensure ENVIRONMENT is not leaking into staging by overriding it in
[env.staging.vars] if needed, and purge the old secret from git history (e.g.,
git filter-repo) so the key is fully removed from repository history.
- Around line 8-40: The wrangler config places STATS_KV, R2_BUCKET,
WORKER_AUTH_KEY and ENVIRONMENT at top-level but named environments do not
inherit vars, kv_namespaces, r2_buckets or secrets; update the file so each
named env that you deploy (env.production and env.staging) explicitly declares
the [[kv_namespaces]] binding for STATS_KV, the [[r2_buckets]] binding for
R2_BUCKET, and the WORKER_AUTH_KEY and ENVIRONMENT entries (or move
WORKER_AUTH_KEY into secrets per environment), or remove the [env.*] blocks and
deploy from the top-level; ensure references to STATS_KV, R2_BUCKET,
WORKER_AUTH_KEY and ENVIRONMENT exist under whichever environment block you
intend to use.

---

Outside diff comments:
In `@app/api/cron/fetch/route.ts`:
- Around line 231-245: The Cloudflare KV error handler for setRankingToKV
currently only calls captureWebException(cfError, {...}) and omits a console
error, losing local log parity with other handlers; add a
console.error('[Cloudflare KV] write failed:', cfError) immediately before or
after the captureWebException call in the catch block handling cfError so local
Vercel logs retain the failure signal while Sentry still receives the event.

---

Nitpick comments:
In `@app/admin/ng-settings/page.tsx`:
- Around line 67-103: The client Sentry tags for captureWebException on the
/api/admin/ng-list endpoint use action: 'load'/'save' but the server route uses
'get'/'post', so unify them by changing the client action tags to the HTTP-verb
convention ('get' for the load/fetch code path and 'post' for the save code
path) wherever captureWebException is called for '/api/admin/ng-list' (e.g., in
the load block that calls migrateLegacyNGList and in the save block), so
filtering/grouping matches the server-side route tagging.

In `@lib/sentry/capture.ts`:
- Around line 12-13: captureWebException currently converts non-Error throws to
new Error(String(error)), which loses structure for thrown objects; update the
normalization inside captureWebException to (1) attempt to serialize the
original non-Error value (e.g., try JSON.stringify with a try/catch and fallback
to String) to produce a more informative Error message, and (2) attach the
original raw value to the Sentry extras (e.g., options.extra['rawError'] or
similar) before sending so the original object is preserved for grouping and
debugging; keep the normalized Error instance for Sentry but ensure the raw
payload is carried along in options/extra when calling the final capture/submit
routine.

In `@workers/video-stats-updater/src/index.js`:
- Around line 257-274: The current catch in the batch loop calls
captureWorkerException for every failed batch; change this to aggregate failures
and emit a single summarized event after the loop. Specifically: inside the
catch that currently references index and batch, stop calling
captureWorkerException there and instead push a small error summary object
(e.g., { batchIndex: index+1, batchSize: batch.length, message: error.message,
code: error.code }) onto a run-scoped array (suggest name snapshotBatchErrors).
Keep the console.error for immediate logs if you want. After the
batch-processing loop completes, if snapshotBatchErrors.length > 0 call
captureWorkerException once with the same tags used now but a contexts payload
that summarizes failures (failedCount, failedBatches: array of indices,
batchSizes, and a few errorSamples or top messages) so a single Sentry event
represents the run’s aggregated failures.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 2a9905e0-03c4-462c-b16b-7c7dd5299aba

📥 Commits

Reviewing files that changed from the base of the PR and between 68172a7 and cc1c90d.

⛔ Files ignored due to path filters (1)
  • package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (31)
  • .env.example
  • .github/workflows/deploy-worker.yml
  • app/admin/ng-settings/page.tsx
  • app/api/admin/ng-list/route.ts
  • app/api/admin/video-info/route.ts
  • app/api/cron/fetch/route.ts
  • app/api/ranking/route.ts
  • app/client-page.tsx
  • app/global-error.tsx
  • app/mylists/[id]/mylist-detail-client.tsx
  • app/page.tsx
  • components/error-boundary.tsx
  • docs/sentry-observability.md
  • instrumentation-client.ts
  • instrumentation.ts
  • lib/sentry/capture.ts
  • lib/sentry/shared.ts
  • next.config.mjs
  • package.json
  • sentry.edge.config.ts
  • sentry.server.config.ts
  • tsconfig.json
  • tsconfig.workers.json
  • workers/api-gateway-green-20250726.ts
  • workers/sentry.js
  • workers/smart-router-20250706.ts
  • workers/types.d.ts
  • workers/video-stats-updater/src/index.js
  • workers/video-stats-updater/wrangler.toml
  • wrangler-green.toml
  • wrangler.toml

Comment thread app/global-error.tsx
Comment on lines +11 to +20
export default function GlobalError({ error, reset }: GlobalErrorProps) {
useEffect(() => {
captureWebException(error, {
tags: {
runtime: 'browser',
surface: 'global-error',
endpoint_family: 'app-router',
},
})
}, [error])
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Consider surfacing error.digest to Sentry.

Next.js attaches a digest to server-rendered errors that's the primary correlation key between server logs and the client-side global-error boundary. Without it, you can't link the Sentry event to the originating server error. Consider adding it as a tag or extra.

✏️ Suggested addition
     captureWebException(error, {
       tags: {
         runtime: 'browser',
         surface: 'global-error',
         endpoint_family: 'app-router',
+        digest: error.digest,
       },
     })
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/global-error.tsx` around lines 11 - 20, The GlobalError component calls
captureWebException but doesn't surface Next.js's server error correlation key;
update the captureWebException invocation in GlobalError (the function and
useEffect block) to include error.digest (when present) as either a tag e.g.
tags: { ..., digest: error.digest } or as extra data e.g. extra: { digest:
error.digest } so Sentry events can be correlated with server logs.

Comment thread app/page.tsx
Comment on lines +186 to +205
captureWebException(error, {
tags: {
runtime: 'next-node',
surface: 'ssr-ranking',
endpoint_family: '/api/ranking',
genre,
period,
has_tag: Boolean(tag),
is_preview: process.env.VERCEL_ENV === 'preview',
upstream_kind: 'next-api',
cache_source: 'no-store',
},
contexts: {
ranking_request: {
genre,
period,
hasTag: Boolean(tag),
},
},
})
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Unvalidated URL params sent as Sentry tags (cardinality + length risk).

genre, period, and (transitively) has_tag flow straight from searchParams into Sentry tags without validation against RANKING_GENRES / known periods. Any crawler, fuzzer, or bad link can inject arbitrary strings, which:

  • Inflates tag-value cardinality on your Sentry project (these values are indexed).
  • Can exceed Sentry's tag value length cap (~200 chars), getting the event silently dropped/truncated.

Consider normalizing to a known enum (or routing unknowns to a sentinel like "unknown") before tagging, and/or moving the raw values into contexts/extras instead of tags. Same applies to the Home catch at lines 314-332.

✏️ Example normalization
+    const safeGenre = RANKING_GENRES.some(g => g.value === genre) ? genre : 'unknown'
+    const safePeriod = period === '24h' || period === 'hour' ? period : 'unknown'
     captureWebException(error, {
       tags: {
         runtime: 'next-node',
         surface: 'ssr-ranking',
         endpoint_family: '/api/ranking',
-        genre,
-        period,
+        genre: safeGenre,
+        period: safePeriod,
         has_tag: Boolean(tag),

Also applies to: 314-332

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/page.tsx` around lines 186 - 205, The Sentry tags are being set directly
from unvalidated URL params (genre, period) in the captureWebException call,
which risks high cardinality and long values; validate and normalize these
before tagging (e.g., check genre against RANKING_GENRES and period against your
known periods/enum, and if not found set to a sentinel like "unknown"), use the
normalized values in tags passed to captureWebException, and move the original
raw values (if you want to record them) into contexts or extras (e.g.,
ranking_request or extras.rawParams) instead of tags; keep has_tag as a boolean
but ensure any raw tag string is not used as a tag.

Comment thread instrumentation-client.ts
Comment on lines +13 to +36
const environment = getSentryEnvironment()
const dsn = process.env.NEXT_PUBLIC_SENTRY_DSN

Sentry.init({
dsn,
enabled: Boolean(dsn),
environment,
sendDefaultPii: false,
replaysOnErrorSampleRate: 0,
replaysSessionSampleRate: 0,
integrations: [
Sentry.browserTracingIntegration({
beforeStartSpan: (options) => ({
...options,
name: normalizeTransactionName(options.name) || options.name,
}),
}),
],
tracePropagationTargets: [
/^\/api\//,
/^https:\/\/nico-rank\.com\/api\//,
/^https?:\/\/localhost:3000\/api\//,
],
tracesSampler: () => (isProductionSentryEnvironment(environment) ? 0.05 : 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.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify the client-visible environment source used by instrumentation-client.ts.
# Expected: getSentryEnvironment includes a browser-safe value such as NEXT_PUBLIC_VERCEL_ENV/NEXT_PUBLIC_SENTRY_ENVIRONMENT,
# or the build explicitly injects VERCEL_ENV for client code.

rg -n -C3 'getSentryEnvironment|NEXT_PUBLIC_VERCEL_ENV|NEXT_PUBLIC_SENTRY_ENVIRONMENT|VERCEL_ENV|NODE_ENV' \
  instrumentation-client.ts sentry.edge.config.ts sentry.server.config.ts lib/sentry docs/sentry-observability.md

Repository: YJSN180/nico-ranking-custom

Length of output: 3004


🌐 Web query:

Does Next.js expose non-NEXT_PUBLIC environment variables such as VERCEL_ENV to Client Components or browser bundles?

💡 Result:

No, Next.js does not expose non-NEXT_PUBLIC_ environment variables like VERCEL_ENV to Client Components or browser bundles. These variables are only available server-side in the Node.js environment. In Client Components ('use client'), process.env for non-NEXT_PUBLIC_ vars is undefined, preventing access in the browser. Official Next.js documentation consistently states: "Non-NEXT_PUBLIC_ environment variables are only available in the Node.js environment, meaning they aren't accessible to the browser (the client runs in a different environment)." To expose to client, prefix with NEXT_PUBLIC_, which inlines the value at build time into JS bundles. VERCEL_ENV specifically is a Vercel system variable available server-side. Vercel auto-prefixes it to NEXT_PUBLIC_VERCEL_ENV for client exposure if needed, but plain VERCEL_ENV remains server-only. Caveats from issues/discussions: - Server-rendered HTML may initially show server env values (e.g., in static/SSR output), but client JS cannot access them (undefined), often causing hydration mismatches if used directly. - Accidentally passing server env as props to Client Components exposes the value in props/JSON payload to browser, but this is misuse, not direct exposure via process.env. - Use 'server-only' package to prevent accidental client imports. As of Next.js 15+ (including canary/preview up to 2026 mentions), behavior unchanged.

Citations:


🏁 Script executed:

# Check if instrumentation-client.ts has 'use client' directive
head -20 instrumentation-client.ts

# Look for any NEXT_PUBLIC environment variable definitions
rg -n 'NEXT_PUBLIC_' instrumentation-client.ts sentry.client.config.ts sentry.*.ts lib/sentry/

# Check for NODE_ENV handling in shared.ts
cat -n lib/sentry/shared.ts | head -60

Repository: YJSN180/nico-ranking-custom

Length of output: 2690


🌐 Web query:

In Next.js, is process.env.NODE_ENV exposed to Client Components / browser bundles, or is it also restricted like other non-NEXT_PUBLIC variables?

💡 Result:

No, process.env.NODE_ENV is exposed to Client Components and browser bundles in Next.js. Unlike other non-NEXT_PUBLIC_ environment variables, which are restricted to the server-side (Node.js environment) and not accessible in the browser, Next.js automatically inlines process.env.NODE_ENV into client bundles via webpack's DefinePlugin. This replaces references to process.env.NODE_ENV with the literal string value ('development' or 'production') at build time, making it available in client-side code such as "use client" components. This behavior is a standard webpack convention adopted by Next.js for optimization (e.g., dead code elimination), and official docs treat NODE_ENV as a special case by referencing it extensively without requiring the NEXT_PUBLIC_ prefix. Secondary sources confirm it works directly in client components without additional configuration, though the docs emphasize the general rule for non-prefixed vars. To verify empirically: In a client component, console.log(process.env.NODE_ENV) will output the build-time environment ('development' in next dev, 'production' in next build/start).

Citations:


🏁 Script executed:

# Check if server-side configs are different (they should read VERCEL_ENV correctly)
cat -n sentry.server.config.ts | head -50
cat -n sentry.edge.config.ts | head -50

Repository: YJSN180/nico-ranking-custom

Length of output: 2324


Preview deployments are mislabeled as production in client-side Sentry events.

This client module uses getSentryEnvironment() which calls process.env.VERCEL_ENV || process.env.NODE_ENV || 'development'. On Vercel, VERCEL_ENV is server-only and undefined in the browser, so the fallback to NODE_ENV occurs. Since NODE_ENV=production for both preview and production builds, preview deployments in the browser are tagged as environment='production' and sampled at 5% instead of 100%, mixing preview traces into production telemetry.

Add a NEXT_PUBLIC_VERCEL_ENV environment variable and update getSentryEnvironment() to check it first in client contexts, or create a separate client-side helper that uses the public variant.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@instrumentation-client.ts` around lines 13 - 36, The client is using
getSentryEnvironment() which reads server-only VERCEL_ENV, causing previews to
be labeled production; add a client-side variant that checks the public env var
(NEXT_PUBLIC_VERCEL_ENV) first (e.g., create getSentryEnvironmentClient or
update getSentryEnvironment to read process.env.NEXT_PUBLIC_VERCEL_ENV when in
browser) and update instrumentation-client to call that client helper (and any
dependent predicate like isProductionSentryEnvironment if it relies on the
environment) so preview builds in the browser pick up NEXT_PUBLIC_VERCEL_ENV and
are sampled correctly.

Comment thread lib/sentry/shared.ts
Comment on lines +1 to +13
const SENSITIVE_QUERY_KEYS = new Set([
'authorization',
'cookie',
'key',
'memo',
'password',
'q',
'query',
'tag',
'title',
'token',
'username',
])
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Redact common secret query-key variants too.

SENSITIVE_QUERY_KEYS is exact-match only, so URLs with common credential names like access_token, refresh_token, api_key, client_secret, secret, session, or camelCase variants can be sent to Sentry unredacted.

🛡️ Proposed hardening
 const SENSITIVE_QUERY_KEYS = new Set([
+  'access_token',
+  'api_key',
+  'apikey',
   'authorization',
+  'client_secret',
   'cookie',
   'key',
   'memo',
   'password',
   'q',
   'query',
+  'refresh_token',
+  'secret',
+  'session',
+  'sessionid',
   'tag',
   'title',
   'token',
   'username',
 ])
+
+const SENSITIVE_QUERY_KEY_PATTERN = /(?:^|[_-])(auth|authorization|cookie|key|password|secret|session|token)(?:[_-]|$)/i
+
+function isSensitiveQueryKey(key: string) {
+  const normalizedKey = key.toLowerCase()
+  return SENSITIVE_QUERY_KEYS.has(normalizedKey) || SENSITIVE_QUERY_KEY_PATTERN.test(key)
+}
 
 function scrubSearchParams(searchParams: URLSearchParams) {
   const nextParams = new URLSearchParams()
 
   for (const [key, value] of searchParams.entries()) {
-    if (SENSITIVE_QUERY_KEYS.has(key.toLowerCase())) {
+    if (isSensitiveQueryKey(key)) {
       nextParams.set(key, '[redacted]')
       continue
     }

Also applies to: 23-33

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/sentry/shared.ts` around lines 1 - 13, SENSITIVE_QUERY_KEYS currently
only exact-matches literal keys and misses common variants like access_token,
refreshToken, api_key, client_secret, secret, session, etc.; update the
redaction logic that uses SENSITIVE_QUERY_KEYS to normalize incoming query
parameter names (to lowercase), strip common punctuation/spacing, and then check
both exact matches against SENSITIVE_QUERY_KEYS and pattern matches (e.g.,
endsWith('token'), endsWith('secret'), contains('key'), contains('session'), or
matches /(^|_)(key|token|secret|session|password|api|access|refresh)($|_)/) to
catch snake_case/camelCase variants—keep SENSITIVE_QUERY_KEYS as core literals
but add these additional normalized/pattern checks where query params are
filtered so all common secret variants are redacted.

Comment thread lib/sentry/shared.ts
Comment on lines +135 to +189
export function scrubBreadcrumb<T extends Record<string, any> | null>(breadcrumb: T): T {
if (!breadcrumb) return breadcrumb

const nextBreadcrumb: Record<string, any> = { ...breadcrumb }

if (typeof nextBreadcrumb.message === 'string' && /authorization|cookie|password|token/i.test(nextBreadcrumb.message)) {
nextBreadcrumb.message = '[redacted]'
}

if (nextBreadcrumb.data && typeof nextBreadcrumb.data === 'object') {
const nextData = { ...nextBreadcrumb.data }

if (typeof nextData.url === 'string') {
nextData.url = sanitizeUrlForSentry(nextData.url)
}

if (typeof nextData.to === 'string') {
nextData.to = sanitizeUrlForSentry(nextData.to)
}

if (typeof nextData.from === 'string') {
nextData.from = sanitizeUrlForSentry(nextData.from)
}

delete nextData.headers
delete nextData.input
delete nextData.response

nextBreadcrumb.data = nextData
}

return nextBreadcrumb as T
}

export function scrubEvent<T extends Record<string, any>>(event: T): T {
const nextEvent: Record<string, any> = { ...event }

nextEvent.request = scrubRequest(nextEvent.request)
nextEvent.contexts = scrubContexts(nextEvent.contexts)

if (Array.isArray(nextEvent.breadcrumbs)) {
nextEvent.breadcrumbs = nextEvent.breadcrumbs
.map((breadcrumb: Record<string, any>) => scrubBreadcrumb(breadcrumb))
.filter(Boolean)
}

if (nextEvent.user) {
delete nextEvent.user
}

if (nextEvent.transaction) {
nextEvent.transaction = normalizeTransactionName(nextEvent.transaction)
}

return nextEvent as T
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Scrub event messages and exception values before sending.

request.url and breadcrumbs are partially scrubbed, but top-level message, logentry.message, exception.values[*].value, and extra can still carry raw URLs, tokens, or header text from thrown errors.

🛡️ Proposed scrubbing extension
+function scrubString(value: string) {
+  if (/authorization|cookie|password|secret|token/i.test(value)) {
+    return '[redacted]'
+  }
+
+  return value.replace(/https?:\/\/[^\s"'<>]+/g, match => sanitizeUrlForSentry(match) || '[redacted]')
+}
+
 export function scrubBreadcrumb<T extends Record<string, any> | null>(breadcrumb: T): T {
   if (!breadcrumb) return breadcrumb
 
   const nextBreadcrumb: Record<string, any> = { ...breadcrumb }
 
-  if (typeof nextBreadcrumb.message === 'string' && /authorization|cookie|password|token/i.test(nextBreadcrumb.message)) {
-    nextBreadcrumb.message = '[redacted]'
+  if (typeof nextBreadcrumb.message === 'string') {
+    nextBreadcrumb.message = scrubString(nextBreadcrumb.message)
   }
 
   if (nextBreadcrumb.data && typeof nextBreadcrumb.data === 'object') {
     const nextData = { ...nextBreadcrumb.data }
@@
 
+    delete nextData.body
+    delete nextData.cookies
     delete nextData.headers
     delete nextData.input
+    delete nextData.query
+    delete nextData.request
     delete nextData.response
 
     nextBreadcrumb.data = nextData
   }
@@
 export function scrubEvent<T extends Record<string, any>>(event: T): T {
   const nextEvent: Record<string, any> = { ...event }
 
+  if (typeof nextEvent.message === 'string') {
+    nextEvent.message = scrubString(nextEvent.message)
+  }
+
+  if (typeof nextEvent.logentry?.message === 'string') {
+    nextEvent.logentry = {
+      ...nextEvent.logentry,
+      message: scrubString(nextEvent.logentry.message),
+    }
+  }
+
+  if (Array.isArray(nextEvent.exception?.values)) {
+    nextEvent.exception = {
+      ...nextEvent.exception,
+      values: nextEvent.exception.values.map((value: Record<string, any>) => ({
+        ...value,
+        value: typeof value.value === 'string' ? scrubString(value.value) : value.value,
+      })),
+    }
+  }
+
   nextEvent.request = scrubRequest(nextEvent.request)
   nextEvent.contexts = scrubContexts(nextEvent.contexts)
@@
   if (nextEvent.user) {
     delete nextEvent.user
   }
+
+  if (nextEvent.extra) {
+    delete nextEvent.extra
+  }
 
   if (nextEvent.transaction) {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/sentry/shared.ts` around lines 135 - 189, The scrubEvent/scrubBreadcrumb
flow currently misses scrubbing top-level message, logentry.message,
exception.values[*].value, and extra payloads; update scrubEvent (and reuse
helpers like sanitizeUrlForSentry) to: inspect nextEvent.message and
nextEvent.logentry?.message and replace/redact any strings matching
secrets/URL/token/password patterns; iterate nextEvent.exception?.values and
sanitize each .value (replace URLs/tokens or set to '[redacted]'); and
deep-sanitize nextEvent.extra (remove headers/inputs and sanitize any string
fields containing URLs or tokens). Keep using existing helpers
(sanitizeUrlForSentry, scrubBreadcrumb, scrubRequest, scrubContexts,
normalizeTransactionName) and ensure scrubEvent returns the sanitized nextEvent.

Comment thread tsconfig.workers.json
Comment on lines +13 to +18
"include": [
"workers/smart-router-20250706.ts",
"workers/api-gateway-green-20250726.ts",
"workers/utils/**/*.ts",
"workers/types.d.ts"
],
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify the explicit entries exist and there are no sibling stale copies
fd -t f 'smart-router.*\.ts$' workers/ 2>/dev/null
fd -t f 'api-gateway.*\.ts$' workers/ 2>/dev/null
fd -t f 'types.d.ts$' workers/ 2>/dev/null

Repository: YJSN180/nico-ranking-custom

Length of output: 606


Consider using glob patterns for worker includes to prevent silent typecheck failures on file renaming.

The hardcoded paths (smart-router-20250706.ts, api-gateway-green-20250726.ts, types.d.ts) currently exist, but the presence of api-gateway-green-20250726.backup.ts and multiple dated variants confirms this codebase actively rotates/replaces worker files. Hardcoded paths will silently match zero files if workers are renamed, allowing typecheck:workers and build:worker to succeed with no actual type-checking.

Consider replacing the explicit list with a glob pattern such as "workers/*-[0-9]*.ts" plus "workers/types.d.ts", or establish a process to update the config when worker files change.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tsconfig.workers.json` around lines 13 - 18, The tsconfig.workers.json
include array currently lists specific worker filenames which can silently miss
files when names change; update the "include" entry to use a glob for worker
files (e.g. replace the individual entries like
"workers/smart-router-20250706.ts" and "workers/api-gateway-green-20250726.ts"
with a pattern such as "workers/*-[0-9]*.ts") while keeping
"workers/utils/**/*.ts" and "workers/types.d.ts" so that the compiler always
picks up dated/rotated worker files; edit the include array in
tsconfig.workers.json accordingly.

Comment thread workers/sentry.js
Comment on lines +43 to +99
function scrubBreadcrumb(breadcrumb) {
if (!breadcrumb) return breadcrumb

const nextBreadcrumb = { ...breadcrumb }

if (nextBreadcrumb.data && typeof nextBreadcrumb.data === 'object') {
const nextData = { ...nextBreadcrumb.data }

if (typeof nextData.url === 'string') {
nextData.url = sanitizeUrlForSentry(nextData.url)
}

delete nextData.headers
delete nextData.request_body
delete nextData.response_body

nextBreadcrumb.data = nextData
}

return nextBreadcrumb
}

function scrubEvent(event) {
const nextEvent = { ...event }

if (nextEvent.request) {
const nextRequest = { ...nextEvent.request }
delete nextRequest.cookies
delete nextRequest.data
delete nextRequest.headers
delete nextRequest.query_string

if (typeof nextRequest.url === 'string') {
nextRequest.url = sanitizeUrlForSentry(nextRequest.url)
}

nextEvent.request = nextRequest
}

if (nextEvent.transaction) {
nextEvent.transaction = normalizeTransactionName(nextEvent.transaction)
}

if (Array.isArray(nextEvent.breadcrumbs)) {
nextEvent.breadcrumbs = nextEvent.breadcrumbs.map(scrubBreadcrumb)
}

if (nextEvent.user) {
delete nextEvent.user
}

if (nextEvent.contexts?.response) {
delete nextEvent.contexts.response
}

return nextEvent
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

For @sentry/cloudflare JavaScript SDK, do default integrations record console breadcrumbs and fetch/HTTP spans on events, and which event fields contain breadcrumb messages, span descriptions, and span URL data?

💡 Result:

Yes, the default integrations for @sentry/cloudflare JavaScript SDK record console breadcrumbs via the Console integration (Sentry.consoleIntegration, enabled by default) and fetch/HTTP spans via the FetchIntegration (Sentry.fetchIntegration, auto-enabled with ✓ for Errors and Tracing). The FetchIntegration also captures breadcrumbs for fetch requests (configurable via breadcrumbs option, defaults to true). Breadcrumb messages (from console logs and fetch breadcrumbs) are contained in the event's "breadcrumbs.values[].message" field. Span descriptions are in "spans[].description", span URL data (for http spans) is in span data/attributes like "data.url" or "url.full" / "url" tags (per Sentry conventions and Cloudflare-specific improvements), and other details like method, status in span data.

Citations:


🏁 Script executed:

fd -t f "sentry.js" --path "*workers*"

Repository: YJSN180/nico-ranking-custom

Length of output: 304


🏁 Script executed:

find . -name "sentry.js" -path "*/workers/*" -type f

Repository: YJSN180/nico-ranking-custom

Length of output: 90


🏁 Script executed:

cat -n ./workers/sentry.js

Repository: YJSN180/nico-ranking-custom

Length of output: 5740


Add breadcrumb message and span scrubbing to the Sentry event processor.

The worker scrubber sanitizes request.url and breadcrumb.data.url, but leaves breadcrumb.message and event.spans untouched. The Sentry SDK's default integrations capture console breadcrumbs (which contain raw message text) and fetch spans (which include descriptions and URLs). These fields can leak sensitive data like tag values, query parameters, and upstream URLs that violate the documented privacy policy.

Add scrubbing for:

  1. breadcrumb.message — filter query parameters, tag values, and path-like patterns using a shared text sanitizer
  2. event.spans — scrub span descriptions and data fields (especially URL-like and body-like fields)
Proposed implementation
 const DYNAMIC_PATH_PATTERNS = [
   [/\/api\/thumbnail\/[^/]+/g, '/api/thumbnail/:videoId'],
   [/\/api\/hd-thumbnail\/[^/]+/g, '/api/hd-thumbnail/:videoId'],
   [/\/trigger\/[^/]+/g, '/trigger/:id'],
 ]
+
+function scrubTextForSentry(value) {
+  if (typeof value !== 'string') return value
+
+  const normalizedUrl = /^(https?:\/\/|\/)/.test(value) ? sanitizeUrlForSentry(value) : undefined
+  if (normalizedUrl) return normalizedUrl
+
+  return value
+    .replace(/([?&][^=&#\s]+)=([^&#\s]+)/g, '$1=[Filtered]')
+    .replace(/(Tag:\s*)[^,\]]+/gi, '$1[Filtered]')
+    .replace(/(tags\/)[^/\s]+(\.json)/gi, '$1:tag$2')
+}
+
+function scrubSpan(span) {
+  const nextSpan = { ...span }
+
+  if (typeof nextSpan.description === 'string') {
+    nextSpan.description = scrubTextForSentry(nextSpan.description)
+  }
+
+  if (nextSpan.data && typeof nextSpan.data === 'object') {
+    const nextData = { ...nextSpan.data }
+    for (const key of Object.keys(nextData)) {
+      if (/headers|cookie|body|query/i.test(key)) {
+        delete nextData[key]
+      } else if (typeof nextData[key] === 'string') {
+        nextData[key] = scrubTextForSentry(nextData[key])
+      }
+    }
+    nextSpan.data = nextData
+  }
+
+  return nextSpan
+}
 
 function scrubBreadcrumb(breadcrumb) {
   if (!breadcrumb) return breadcrumb
 
   const nextBreadcrumb = { ...breadcrumb }
+
+  if (typeof nextBreadcrumb.message === 'string') {
+    nextBreadcrumb.message = scrubTextForSentry(nextBreadcrumb.message)
+  }
 
   if (nextBreadcrumb.data && typeof nextBreadcrumb.data === 'object') {
     const nextData = { ...nextBreadcrumb.data }
 
     if (typeof nextData.url === 'string') {
       nextData.url = sanitizeUrlForSentry(nextData.url)
     }
 
     delete nextData.headers
     delete nextData.request_body
     delete nextData.response_body
 
     nextBreadcrumb.data = nextData
   }
 
   return nextBreadcrumb
 }
 
 function scrubEvent(event) {
   const nextEvent = { ...event }
 
   if (nextEvent.request) {
     const nextRequest = { ...nextEvent.request }
     delete nextRequest.cookies
     delete nextRequest.data
     delete nextRequest.headers
     delete nextRequest.query_string
 
     if (typeof nextRequest.url === 'string') {
       nextRequest.url = sanitizeUrlForSentry(nextRequest.url)
     }
 
     nextEvent.request = nextRequest
   }
 
   if (nextEvent.transaction) {
     nextEvent.transaction = normalizeTransactionName(nextEvent.transaction)
   }
 
   if (Array.isArray(nextEvent.breadcrumbs)) {
     nextEvent.breadcrumbs = nextEvent.breadcrumbs.map(scrubBreadcrumb)
   }
+
+  if (Array.isArray(nextEvent.spans)) {
+    nextEvent.spans = nextEvent.spans.map(scrubSpan)
+  }
 
   if (nextEvent.user) {
     delete nextEvent.user
   }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
function scrubBreadcrumb(breadcrumb) {
if (!breadcrumb) return breadcrumb
const nextBreadcrumb = { ...breadcrumb }
if (nextBreadcrumb.data && typeof nextBreadcrumb.data === 'object') {
const nextData = { ...nextBreadcrumb.data }
if (typeof nextData.url === 'string') {
nextData.url = sanitizeUrlForSentry(nextData.url)
}
delete nextData.headers
delete nextData.request_body
delete nextData.response_body
nextBreadcrumb.data = nextData
}
return nextBreadcrumb
}
function scrubEvent(event) {
const nextEvent = { ...event }
if (nextEvent.request) {
const nextRequest = { ...nextEvent.request }
delete nextRequest.cookies
delete nextRequest.data
delete nextRequest.headers
delete nextRequest.query_string
if (typeof nextRequest.url === 'string') {
nextRequest.url = sanitizeUrlForSentry(nextRequest.url)
}
nextEvent.request = nextRequest
}
if (nextEvent.transaction) {
nextEvent.transaction = normalizeTransactionName(nextEvent.transaction)
}
if (Array.isArray(nextEvent.breadcrumbs)) {
nextEvent.breadcrumbs = nextEvent.breadcrumbs.map(scrubBreadcrumb)
}
if (nextEvent.user) {
delete nextEvent.user
}
if (nextEvent.contexts?.response) {
delete nextEvent.contexts.response
}
return nextEvent
}
function scrubTextForSentry(value) {
if (typeof value !== 'string') return value
const normalizedUrl = /^(https?:\/\/|\/)/.test(value) ? sanitizeUrlForSentry(value) : undefined
if (normalizedUrl) return normalizedUrl
return value
.replace(/([?&][^=&#\s]+)=([^&#\s]+)/g, '$1=[Filtered]')
.replace(/(Tag:\s*)[^,\]]+/gi, '$1[Filtered]')
.replace(/(tags\/)[^/\s]+(\.json)/gi, '$1:tag$2')
}
function scrubSpan(span) {
const nextSpan = { ...span }
if (typeof nextSpan.description === 'string') {
nextSpan.description = scrubTextForSentry(nextSpan.description)
}
if (nextSpan.data && typeof nextSpan.data === 'object') {
const nextData = { ...nextSpan.data }
for (const key of Object.keys(nextData)) {
if (/headers|cookie|body|query/i.test(key)) {
delete nextData[key]
} else if (typeof nextData[key] === 'string') {
nextData[key] = scrubTextForSentry(nextData[key])
}
}
nextSpan.data = nextData
}
return nextSpan
}
function scrubBreadcrumb(breadcrumb) {
if (!breadcrumb) return breadcrumb
const nextBreadcrumb = { ...breadcrumb }
if (typeof nextBreadcrumb.message === 'string') {
nextBreadcrumb.message = scrubTextForSentry(nextBreadcrumb.message)
}
if (nextBreadcrumb.data && typeof nextBreadcrumb.data === 'object') {
const nextData = { ...nextBreadcrumb.data }
if (typeof nextData.url === 'string') {
nextData.url = sanitizeUrlForSentry(nextData.url)
}
delete nextData.headers
delete nextData.request_body
delete nextData.response_body
nextBreadcrumb.data = nextData
}
return nextBreadcrumb
}
function scrubEvent(event) {
const nextEvent = { ...event }
if (nextEvent.request) {
const nextRequest = { ...nextEvent.request }
delete nextRequest.cookies
delete nextRequest.data
delete nextRequest.headers
delete nextRequest.query_string
if (typeof nextRequest.url === 'string') {
nextRequest.url = sanitizeUrlForSentry(nextRequest.url)
}
nextEvent.request = nextRequest
}
if (nextEvent.transaction) {
nextEvent.transaction = normalizeTransactionName(nextEvent.transaction)
}
if (Array.isArray(nextEvent.breadcrumbs)) {
nextEvent.breadcrumbs = nextEvent.breadcrumbs.map(scrubBreadcrumb)
}
if (Array.isArray(nextEvent.spans)) {
nextEvent.spans = nextEvent.spans.map(scrubSpan)
}
if (nextEvent.user) {
delete nextEvent.user
}
if (nextEvent.contexts?.response) {
delete nextEvent.contexts.response
}
return nextEvent
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@workers/sentry.js` around lines 43 - 99, Update scrubBreadcrumb to sanitize
breadcrumb.message via the shared text sanitizer (use sanitizeTextForSentry or
equivalent) in addition to existing data.url handling, and ensure message is
stripped of query/tag/path-like sensitive content; also extend scrubEvent to
process nextEvent.spans: map over nextEvent.spans and for each span sanitize
span.description with the same text sanitizer, sanitize any URL-like fields in
span.data with sanitizeUrlForSentry, and delete body-like or header-like keys
(e.g., headers, request_body, response_body, url query params) from span.data;
keep references to scrubBreadcrumb and scrubEvent so you update those functions
only.

Comment on lines +349 to +357
captureWorkerException(error, {
tags: {
runtime: 'cloudflare-worker',
surface: 'video-stats-updater',
endpoint_family: 'scheduled',
upstream_kind: 'stats-update',
worker_version: 'video-stats-updater',
},
});
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Avoid double-reporting manual trigger failures as scheduled runs.

processVideoStatsUpdate() captures and rethrows with endpoint_family: 'scheduled'; /trigger then captures the same failure again with endpoint_family: '/trigger'. Pass the endpoint family into the update flow, or let only the outer handler capture manual-trigger failures.

Proposed direction
-async function processVideoStatsUpdate(env) {
+async function processVideoStatsUpdate(env, endpointFamily = 'scheduled') {
@@
       captureWorkerException(error, {
         tags: {
           runtime: 'cloudflare-worker',
           surface: 'video-stats-updater',
-          endpoint_family: 'scheduled',
+          endpoint_family: endpointFamily,
           upstream_kind: 'stats-update',
           worker_version: 'video-stats-updater',
         },
       });
@@
-        const result = await processVideoStatsUpdate(env);
+        const result = await processVideoStatsUpdate(env, '/trigger');
         return Response.json(result);
       } catch (error) {
-        captureWorkerException(error, {
-          tags: {
-            runtime: 'cloudflare-worker',
-            surface: 'video-stats-updater',
-            endpoint_family: '/trigger',
-            upstream_kind: 'manual-trigger',
-            worker_version: 'video-stats-updater',
-          },
-        });
         return Response.json({ error: error.message }, { status: 500 });
       }

Also applies to: 429-442

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@workers/video-stats-updater/src/index.js` around lines 349 - 357, The failure
is being reported twice because processVideoStatsUpdate currently captures and
rethrows errors with endpoint_family: 'scheduled' while the /trigger handler
also captures the same error as endpoint_family: '/trigger'; modify
processVideoStatsUpdate to accept an endpointFamily (string) parameter and use
that for its captureWorkerException call (or skip internal capture when a
caller-provided flag indicates outer handling), and update the manual trigger
handler (the /trigger route) to pass endpointFamily: '/trigger' into
processVideoStatsUpdate (or set the flag) so only the outer handler logs the
manual-trigger failure; apply the same change to the other similar block that
uses captureWorkerException for this flow.

Comment on lines +8 to +40
# Cron trigger - runs every 5 minutes
[triggers]
crons = ["*/5 * * * *"]

# KV namespace binding for storing video stats
[[kv_namespaces]]
binding = "STATS_KV"
id = "80f4535c379b4e8cb89ce6dbdb7d2dc9"

# R2 bucket binding for reading ranking data
[[r2_buckets]]
binding = "R2_BUCKET"
bucket_name = "nico-ranking"

# Worker auth key for manual triggers
[vars]
WORKER_AUTH_KEY = "c8d0aeead3a77d1a88438d5275398fda20efd5db8f98186c524d478362ffa493"
ENVIRONMENT = "production"

# Environment-specific settings
[env.production]
name = "video-stats-updater"

[env.staging]
name = "video-stats-updater-staging"

# Development settings
[dev]
port = 8788

# Observability settings for debugging
[observability]
enabled = 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.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
cd workers/video-stats-updater
npx wrangler deploy --dry-run 2>&1 | sed -n '1,80p'
npx wrangler deploy --dry-run --env production 2>&1 | sed -n '1,80p'
npx wrangler deploy --dry-run --env staging 2>&1 | sed -n '1,80p'

Repository: YJSN180/nico-ranking-custom

Length of output: 4552


🌐 Web query:

Cloudflare Wrangler toml environments inheritance vars kv_namespaces triggers

💡 Result:

In Cloudflare Wrangler's wrangler.toml configuration file, environments are defined under [env.] sections (e.g., [env.staging]). There is a default top-level environment, and named environments inherit most configuration keys from the top level but override or add environment-specific settings. Key points on inheritance: - Most keys are inheritable: Top-level settings like compatibility_date, main, route (triggers), etc., apply to all environments unless overridden in [env.]. - Non-inheritable keys (bindings) like vars, kv_namespaces, secrets, etc., are NOT inherited. They must be explicitly defined in each environment section if needed. For example: TOML example: name = "my-worker" compatibility_date = "2026-04-20" # Inheritable [vars] API_HOST = "example.com" # Top-level vars (non-inherited) [[kv_namespaces]] binding = "MY_KV" id = "<DEV_KV_ID>" # Top-level KV (non-inherited) [env.staging] route = "staging.example.com/*" # Overrides inheritable route trigger [env.staging.vars] API_HOST = "staging.example.com" # Must redefine vars [[env.staging.kv_namespaces]] binding = "MY_KV" id = "<STAGING_KV_ID>" # Must redefine KV namespaces Vars: Environment variables defined in [vars]. Non-inheritable; specify per environment. Available in Worker code via global env.VAR_NAME or import { env } from 'cloudflare:worker'. KV Namespaces: Defined as [[kv_namespaces]] arrays with binding and id. Non-inheritable; different IDs per environment for staging/prod separation. Triggers: Configured via [triggers] (e.g., crons = ["0 * * * "]) or [route]. These are inheritable, so top-level triggers apply unless overridden per env. Routes are patterns like "example.com/".

Citations:


Named environments won't inherit top-level vars, kv_namespaces, r2_buckets, or secrets.

In Wrangler, vars, kv_namespaces, r2_buckets, and secrets are explicitly non-inheritable by named environments. With the current file, deploying via wrangler deploy --env production or --env staging will produce a worker missing:

  • STATS_KV binding,
  • R2_BUCKET binding,
  • WORKER_AUTH_KEY and ENVIRONMENT vars.

Crons (triggers) ARE inherited, so the 5-minute schedule will remain active.

Also, WORKER_AUTH_KEY should not be stored as plaintext in vars; move it to secrets (or environment-specific secret management).

Either deploy from the top-level config (no --env) and drop [env.production]/[env.staging], or redeclare the bindings/vars under each [env.*] block:

♻️ Minimal redeclaration example for named envs
 [env.production]
 name = "video-stats-updater"
+[env.production.vars]
+ENVIRONMENT = "production"
+[[env.production.kv_namespaces]]
+binding = "STATS_KV"
+id = "80f4535c379b4e8cb89ce6dbdb7d2dc9"
+[[env.production.r2_buckets]]
+binding = "R2_BUCKET"
+bucket_name = "nico-ranking"

 [env.staging]
 name = "video-stats-updater-staging"
+[env.staging.vars]
+ENVIRONMENT = "staging"
+[[env.staging.kv_namespaces]]
+binding = "STATS_KV"
+id = "<STAGING_KV_ID>"
+[[env.staging.r2_buckets]]
+binding = "R2_BUCKET"
+bucket_name = "nico-ranking-staging"
🧰 Tools
🪛 Betterleaks (1.1.2)

[high] 24-24: Detected a Generic API Key, potentially exposing access to various services and sensitive operations.

(generic-api-key)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@workers/video-stats-updater/wrangler.toml` around lines 8 - 40, The wrangler
config places STATS_KV, R2_BUCKET, WORKER_AUTH_KEY and ENVIRONMENT at top-level
but named environments do not inherit vars, kv_namespaces, r2_buckets or
secrets; update the file so each named env that you deploy (env.production and
env.staging) explicitly declares the [[kv_namespaces]] binding for STATS_KV, the
[[r2_buckets]] binding for R2_BUCKET, and the WORKER_AUTH_KEY and ENVIRONMENT
entries (or move WORKER_AUTH_KEY into secrets per environment), or remove the
[env.*] blocks and deploy from the top-level; ensure references to STATS_KV,
R2_BUCKET, WORKER_AUTH_KEY and ENVIRONMENT exist under whichever environment
block you intend to use.

Comment on lines +22 to +25
# Worker auth key for manual triggers
[vars]
WORKER_AUTH_KEY = "c8d0aeead3a77d1a88438d5275398fda20efd5db8f98186c524d478362ffa493"
ENVIRONMENT = "production"
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

Critical: hardcoded WORKER_AUTH_KEY committed to the repo — rotate and move to a secret.

[vars] is a plain-text, version-controlled section. The 64-hex WORKER_AUTH_KEY on line 24 is now effectively public in anything that ships this file (git history, PR diffs, forks, build artifacts, Sentry-uploaded sourcemaps, etc.). This matches the Betterleaks finding.

Required actions:

  1. Treat the current key as compromised and rotate it immediately everywhere it's accepted.
  2. Store the new key with wrangler secret put WORKER_AUTH_KEY (per environment) and remove it from wrangler.toml.
  3. Purge from git history (e.g., git filter-repo) since rewriting the file alone doesn't erase history.
🔒 Proposed config change
 # Worker auth key for manual triggers
 [vars]
-WORKER_AUTH_KEY = "c8d0aeead3a77d1a88438d5275398fda20efd5db8f98186c524d478362ffa493"
 ENVIRONMENT = "production"

Note: ENVIRONMENT = "production" at the top level will also leak into the staging deploy unless you override it in [env.staging.vars] — see the separate comment on env inheritance.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
# Worker auth key for manual triggers
[vars]
WORKER_AUTH_KEY = "c8d0aeead3a77d1a88438d5275398fda20efd5db8f98186c524d478362ffa493"
ENVIRONMENT = "production"
# Worker auth key for manual triggers
[vars]
ENVIRONMENT = "production"
🧰 Tools
🪛 Betterleaks (1.1.2)

[high] 24-24: Detected a Generic API Key, potentially exposing access to various services and sensitive operations.

(generic-api-key)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@workers/video-stats-updater/wrangler.toml` around lines 22 - 25, The
committed WORKER_AUTH_KEY in wrangler.toml (the [vars] section) must be rotated
and removed: immediately revoke/rotate the exposed 64-hex key wherever accepted,
delete WORKER_AUTH_KEY from the [vars] block in wrangler.toml, and store the new
secret per-environment using the Wrangler CLI (wrangler secret put
WORKER_AUTH_KEY) instead of version-controlled vars; also ensure ENVIRONMENT is
not leaking into staging by overriding it in [env.staging.vars] if needed, and
purge the old secret from git history (e.g., git filter-repo) so the key is
fully removed from repository history.

- delete unused worker type file and remove redundant scrubTransaction wrapper

- add unit coverage for sentry scrubbing helpers

- stabilize typecheck by separating it from stale .next/types artifacts
Pin Rollup to a non-vulnerable 4.x release so GitHub dependency review passes on the Sentry PR.

Keep the change scoped to package metadata and preserve the existing Next.js and worker build behavior.

Co-Authored-By: OpenAI Codex <noreply@openai.com>
@YJSN180 YJSN180 merged commit 6188ca3 into main Apr 20, 2026
11 of 12 checks passed
@YJSN180 YJSN180 deleted the codex/feat/sentry-observability branch April 20, 2026 23:46
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.

1 participant