feat(sentry): add web and worker observability#374
Conversation
- 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
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
Caution Review failedPull request was closed or merged during review 📝 WalkthroughWalkthroughIntegrates 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
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)
Estimated code review effort🎯 4 (Complex) | ⏱️ ~75 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
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. Comment |
There was a problem hiding this comment.
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 | 🟡 MinorLost log context for Cloudflare KV failures.
Unlike the other three capture sites in this file, the
cfErrorbranch no longer emits aconsole.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 aconsole.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-Errorinputs lose structure viaString(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:actiontag value disagrees with the server route for the same endpoint.The client reports
action: 'load'/action: 'save'on/api/admin/ng-list, butapp/api/admin/ng-list/route.tstags the server-side captures withaction: 'get'/action: 'post'for the exact same handlers. That split makes Sentry filtering/grouping byactioneffectively require OR-ing both names for every query. Pick one convention and apply it on both sides (I'd suggest the HTTP-verb styleget/post, or the semanticload/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
⛔ Files ignored due to path filters (1)
package-lock.jsonis excluded by!**/package-lock.json
📒 Files selected for processing (31)
.env.example.github/workflows/deploy-worker.ymlapp/admin/ng-settings/page.tsxapp/api/admin/ng-list/route.tsapp/api/admin/video-info/route.tsapp/api/cron/fetch/route.tsapp/api/ranking/route.tsapp/client-page.tsxapp/global-error.tsxapp/mylists/[id]/mylist-detail-client.tsxapp/page.tsxcomponents/error-boundary.tsxdocs/sentry-observability.mdinstrumentation-client.tsinstrumentation.tslib/sentry/capture.tslib/sentry/shared.tsnext.config.mjspackage.jsonsentry.edge.config.tssentry.server.config.tstsconfig.jsontsconfig.workers.jsonworkers/api-gateway-green-20250726.tsworkers/sentry.jsworkers/smart-router-20250706.tsworkers/types.d.tsworkers/video-stats-updater/src/index.jsworkers/video-stats-updater/wrangler.tomlwrangler-green.tomlwrangler.toml
| export default function GlobalError({ error, reset }: GlobalErrorProps) { | ||
| useEffect(() => { | ||
| captureWebException(error, { | ||
| tags: { | ||
| runtime: 'browser', | ||
| surface: 'global-error', | ||
| endpoint_family: 'app-router', | ||
| }, | ||
| }) | ||
| }, [error]) |
There was a problem hiding this comment.
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.
| 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), | ||
| }, | ||
| }, | ||
| }) |
There was a problem hiding this comment.
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.
| 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), |
There was a problem hiding this comment.
🧩 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.mdRepository: 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:
- 1: https://nextjs.org/docs/15/pages/guides/environment-variables
- 2: https://nextjs.org/docs/pages/guides/environment-variables
- 3: https://nextjs.org/docs/15/app/guides/environment-variables
- 4: https://nextjs.org/docs/app/building-your-application/configuring/environment-variables
- 5: https://nextjs.org/docs/app/guides/environment-variables
- 6: https://vercel.com/docs/environment-variables/framework-environment-variables
- 7: Non-public env var exposed client-side vercel/next.js#83089
🏁 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 -60Repository: 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:
- 1: https://nextjs.org/docs/pages/guides/environment-variables
- 2: https://nextjs.org/docs/app/building-your-application/configuring/environment-variables
- 3: https://stackoverflow.com/questions/53266814/next-js-pass-node-env-to-client
- 4: https://stackoverflow.com/questions/64792787/easiest-way-to-detect-production-or-dev-environment-in-nextjs
- 5: https://www.codestudy.net/blog/easiest-way-to-detect-production-or-dev-environment-in-nextjs/
- 6: How can I make process.env.NODE_ENV correspond to the actual NODE_ENV value when app is run? vercel/next.js#3605
🏁 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 -50Repository: 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.
| const SENSITIVE_QUERY_KEYS = new Set([ | ||
| 'authorization', | ||
| 'cookie', | ||
| 'key', | ||
| 'memo', | ||
| 'password', | ||
| 'q', | ||
| 'query', | ||
| 'tag', | ||
| 'title', | ||
| 'token', | ||
| 'username', | ||
| ]) |
There was a problem hiding this comment.
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.
| 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 |
There was a problem hiding this comment.
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.
| "include": [ | ||
| "workers/smart-router-20250706.ts", | ||
| "workers/api-gateway-green-20250726.ts", | ||
| "workers/utils/**/*.ts", | ||
| "workers/types.d.ts" | ||
| ], |
There was a problem hiding this comment.
🧩 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/nullRepository: 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.
| 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 | ||
| } |
There was a problem hiding this comment.
🧩 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:
- 1: https://docs.sentry.io/platforms/javascript/guides/cloudflare/configuration/integrations/console
- 2: https://docs.sentry.io/platforms/javascript/guides/cloudflare/configuration/integrations/
- 3: https://docs.sentry.io/platforms/javascript/guides/cloudflare/configuration/integrations/fetchIntegration/
- 4: https://docs.sentry.io/platforms/javascript/guides/cloudflare/configuration/integrations
- 5: https://docs.sentry.io/platforms/javascript/guides/cloudflare/configuration/integrations/captureconsole
- 6: https://develop.sentry.dev/sdk/data-model/event-payloads/breadcrumbs
- 7: https://develop.sentry.dev/sdk/foundations/state-management/scopes/breadcrumbs/
- 8: Span descriptions/Transaction names unhelpful in javascript/cloudflare getsentry/sentry-javascript#16022
- 9: feat(cloudflare): Improve http span data getsentry/sentry-javascript#16232
- 10: https://docs.sentry.io/platforms/javascript/guides/cloudflare/tracing/instrumentation/
🏁 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 fRepository: YJSN180/nico-ranking-custom
Length of output: 90
🏁 Script executed:
cat -n ./workers/sentry.jsRepository: 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:
breadcrumb.message— filter query parameters, tag values, and path-like patterns using a shared text sanitizerevent.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.
| 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.
| captureWorkerException(error, { | ||
| tags: { | ||
| runtime: 'cloudflare-worker', | ||
| surface: 'video-stats-updater', | ||
| endpoint_family: 'scheduled', | ||
| upstream_kind: 'stats-update', | ||
| worker_version: 'video-stats-updater', | ||
| }, | ||
| }); |
There was a problem hiding this comment.
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.
| # 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 |
There was a problem hiding this comment.
🧩 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:
- 1: https://developers.cloudflare.com/workers/wrangler/configuration/
- 2: https://developers.cloudflare.com/workers/wrangler/environments/index.md
- 3: https://developers.cloudflare.com/workers/tooling/wrangler/configuration/environments
- 4: https://developers.cloudflare.com/workers/wrangler/environments/
- 5: https://developers.cloudflare.com/kv/reference/environments/index.md
- 6: https://developers.cloudflare.com/workers/configuration/environment-variables/
- 7: https://developers.cloudflare.com/workers/tooling/wrangler/configuration/
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_KVbinding,R2_BUCKETbinding,WORKER_AUTH_KEYandENVIRONMENTvars.
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.
| # Worker auth key for manual triggers | ||
| [vars] | ||
| WORKER_AUTH_KEY = "c8d0aeead3a77d1a88438d5275398fda20efd5db8f98186c524d478362ffa493" | ||
| ENVIRONMENT = "production" |
There was a problem hiding this comment.
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:
- Treat the current key as compromised and rotate it immediately everywhere it's accepted.
- Store the new key with
wrangler secret put WORKER_AUTH_KEY(per environment) and remove it fromwrangler.toml. - 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.
| # 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>
Summary
video-stats-updater, including Crons check-ins and sourcemap upload settingsbuild:worker/check:allcover the current deploy targets instead of an old unused worker entryVerification
npm run buildnpm run typechecknpm run build:workergit diff --checkset -a; source .dev.vars; set +a; npx wrangler deploy --dry-run -c wrangler.tomlset -a; source .dev.vars; set +a; npx wrangler deploy --dry-run -c wrangler-green.tomlset -a; source workers/video-stats-updater/.dev.vars; set +a; npx wrangler deploy --dry-run -c workers/video-stats-updater/wrangler.tomlNotes
docs/CODECOV_SETUP.md,.eslintignore,__tests__/unit/run-update.test.tsnext.config.mjs(turbopack: false) and ESLint option handling duringnext build; these were not expanded in this PRSummary by CodeRabbit
New Features
Documentation