Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion server/plugins/sentry.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { sentryCloudflareNitroPlugin } from '@sentry/nuxt/module/plugins'
import { createSentryDataCollection } from '../../shared/sentry'
import { createSentryDataCollection, isBestEffortCacheWriteError } from '../../shared/sentry'

export default defineNitroPlugin((nitroApp) => {
const { sentry } = useRuntimeConfig()
Expand All @@ -14,5 +14,15 @@ export default defineNitroPlugin((nitroApp) => {
release: sentry.release || undefined,
tracesSampleRate: sentry.tracesSampleRate,
dataCollection: createSentryDataCollection(),
// Nitro's route cache catches its own KV write failures and forwards the
// caught error here as unhandled (SKILLD-17). That is the same failure
// class `writeCache` records as a wide event, so drop the duplicate and
// keep the wide event as the visibility path.
beforeSend(event, hint) {
const values = event.exception?.values ?? []
if (isBestEffortCacheWriteError(hint.originalException) || values.some(isBestEffortCacheWriteError))
return null
return event
},
})(nitroApp)
})
20 changes: 20 additions & 0 deletions shared/sentry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,26 @@ export function sentryRelease(): string | undefined {
return process.env.SENTRY_RELEASE || process.env.GITHUB_SHA || undefined
}

/**
* The message prefix Workers KV rejects every cache write with
* (`KV PUT failed: 429 Too Many Requests`, Sentry SKILLD-17). Nitro's route
* cache treats the rejection as best-effort: it catches it, logs it, and still
* serves the response, then forwards the caught error to Sentry as unhandled.
* `writeCache` (shared/server/cache.ts) holds the same line for the project's
* own cache writes and records the failure as a wide event instead, so the
* forwarded error is a duplicate of a failure class already handled. The
* server Sentry config uses this predicate to drop exactly that signature in
* `beforeSend`; every other error still reaches Sentry.
*/
export function isBestEffortCacheWriteError(error: unknown): boolean {
const message = error instanceof Error
? error.message
: typeof error === 'object' && error !== null
? (error as { message?: unknown }).message ?? (error as { value?: unknown }).value
: undefined
return typeof message === 'string' && message.startsWith('KV PUT failed:')
}

export function createSentryDataCollection() {
return {
userInfo: false,
Expand Down
76 changes: 76 additions & 0 deletions test/unit/sentry-cache-write-filter.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { isBestEffortCacheWriteError } from '../../shared/sentry'

describe('isBestEffortCacheWriteError', () => {
it('matches the SKILLD-17 rate-limited KV write signature', () => {
expect(isBestEffortCacheWriteError(new Error('KV PUT failed: 429 Too Many Requests'))).toBe(true)
})

it('matches the Sentry-serialized exception, whose message arrives as value', () => {
expect(isBestEffortCacheWriteError({
type: 'Error',
value: 'KV PUT failed: 429 Too Many Requests',
mechanism: { handled: false, type: 'auto.function.nuxt.nitro' },
})).toBe(true)
})

it('ignores KV read failures, which are a different failure class', () => {
expect(isBestEffortCacheWriteError(new Error('KV GET failed: 500 Internal Server Error'))).toBe(false)
})

it('ignores unrelated errors and values that are not errors', () => {
expect(isBestEffortCacheWriteError(new Error('D1_ERROR: D1 DB is overloaded.'))).toBe(false)
expect(isBestEffortCacheWriteError('KV PUT failed: 429 Too Many Requests')).toBe(false)
expect(isBestEffortCacheWriteError(undefined)).toBe(false)
})
})

const { sentryCloudflareNitroPlugin } = vi.hoisted(() => ({
sentryCloudflareNitroPlugin: vi.fn(),
}))

vi.mock('@sentry/nuxt/module/plugins', () => ({ sentryCloudflareNitroPlugin }))

interface SentryEventFixture {
exception?: { values?: Array<{ type?: string, value?: string }> }
}

describe('sentry beforeSend cache-write filter', () => {
let beforeSend: (event: SentryEventFixture, hint: { originalException?: unknown }) => unknown

beforeEach(async () => {
vi.stubGlobal('defineNitroPlugin', (plugin: (nitroApp: unknown) => void) => plugin)
sentryCloudflareNitroPlugin.mockClear()
sentryCloudflareNitroPlugin.mockReturnValue(vi.fn())
const config = (useNuxtApp().$config as { sentry: Record<string, unknown> }).sentry
config.enabled = true
config.dsn = 'https://examplePublicKey@o0.ingest.sentry.io/0'
const registerPlugin = (await import('../../server/plugins/sentry')).default as
(nitroApp: unknown) => void
registerPlugin({})
beforeSend = sentryCloudflareNitroPlugin.mock.calls[0]![0].beforeSend
})

it('drops the route-cache KV write failure Nitro already caught', () => {
const event: SentryEventFixture = {
exception: { values: [{ type: 'Error', value: 'KV PUT failed: 429 Too Many Requests' }] },
}
expect(beforeSend(event, { originalException: new Error('KV PUT failed: 429 Too Many Requests') }))
.toBeNull()
})

it('drops on the serialized exception alone, when the original error is absent', () => {
const event: SentryEventFixture = {
exception: { values: [{ type: 'Error', value: 'KV PUT failed: 429 Too Many Requests' }] },
}
expect(beforeSend(event, {})).toBeNull()
})

it('keeps every other event', () => {
const event: SentryEventFixture = {
exception: { values: [{ type: 'Error', value: 'D1_ERROR: D1 DB is overloaded.' }] },
}
expect(beforeSend(event, { originalException: new Error('D1_ERROR: D1 DB is overloaded.') }))
.toBe(event)
})
})
Loading