From bba689c6e117e881857dd836dc80917175798765 Mon Sep 17 00:00:00 2001 From: s1gr1d <32902192+s1gr1d@users.noreply.github.com> Date: Thu, 10 Sep 2026 10:07:47 +0200 Subject: [PATCH 1/4] fix(nuxt): Split the Nitro error hook as Nuxt 5 stops importing h3 --- packages/nuxt/src/module.ts | 2 + .../runtime/hooks/captureErrorHook-legacy.ts | 12 +++ .../src/runtime/hooks/captureErrorHook.ts | 65 ++------------ .../plugins/capture-error-legacy.server.ts | 9 ++ .../runtime/plugins/capture-error.server.ts | 10 +++ .../plugins/sentry-cloudflare.server.ts | 2 +- .../nuxt/src/runtime/plugins/sentry.server.ts | 3 - .../plugins/update-route-name.server.ts | 2 +- .../nuxt/src/runtime/utils/captureError.ts | 69 +++++++++++++++ .../runtime/hooks/captureErrorHook.test.ts | 88 ++++++++++++------- 10 files changed, 167 insertions(+), 95 deletions(-) create mode 100644 packages/nuxt/src/runtime/hooks/captureErrorHook-legacy.ts create mode 100644 packages/nuxt/src/runtime/plugins/capture-error-legacy.server.ts create mode 100644 packages/nuxt/src/runtime/plugins/capture-error.server.ts create mode 100644 packages/nuxt/src/runtime/utils/captureError.ts diff --git a/packages/nuxt/src/module.ts b/packages/nuxt/src/module.ts index 0b80077367a0..fb336306c929 100644 --- a/packages/nuxt/src/module.ts +++ b/packages/nuxt/src/module.ts @@ -113,9 +113,11 @@ export default defineNuxtModule({ if (isNitroV3) { addServerPlugin(moduleDirResolver.resolve('./runtime/plugins/handler.server')); addServerPlugin(moduleDirResolver.resolve('./runtime/plugins/update-route-name.server')); + addServerPlugin(moduleDirResolver.resolve('./runtime/plugins/capture-error.server')); } else { addServerPlugin(moduleDirResolver.resolve('./runtime/plugins/handler-legacy.server')); addServerPlugin(moduleDirResolver.resolve('./runtime/plugins/update-route-name-legacy.server')); + addServerPlugin(moduleDirResolver.resolve('./runtime/plugins/capture-error-legacy.server')); } addServerPlugin(moduleDirResolver.resolve('./runtime/plugins/sentry.server')); diff --git a/packages/nuxt/src/runtime/hooks/captureErrorHook-legacy.ts b/packages/nuxt/src/runtime/hooks/captureErrorHook-legacy.ts new file mode 100644 index 000000000000..6482132737ee --- /dev/null +++ b/packages/nuxt/src/runtime/hooks/captureErrorHook-legacy.ts @@ -0,0 +1,12 @@ +// eslint-disable-next-line import/no-extraneous-dependencies +import { H3Error } from 'h3'; +import { createCaptureErrorHook } from '../utils/captureError'; + +/** + * Hook that can be added in a Nitro plugin. It captures an error and sends it to Sentry. + * + * For Nuxt v3/v4 (Nitro v2, h3 v1). + */ +export const sentryCaptureErrorHook = createCaptureErrorHook(error => + error instanceof H3Error ? error.statusCode : undefined, +); diff --git a/packages/nuxt/src/runtime/hooks/captureErrorHook.ts b/packages/nuxt/src/runtime/hooks/captureErrorHook.ts index 50d5a61a2828..8e1f96a1cb52 100644 --- a/packages/nuxt/src/runtime/hooks/captureErrorHook.ts +++ b/packages/nuxt/src/runtime/hooks/captureErrorHook.ts @@ -1,59 +1,12 @@ -import { captureException, getClient, getCurrentScope } from '@sentry/core'; -import { flushIfServerless } from '@sentry/core/server'; -// eslint-disable-next-line import/no-extraneous-dependencies -import { H3Error } from 'h3'; -import type { CapturedErrorContext } from 'nitropack/types'; -import { extractErrorContext } from '../utils'; +import { HTTPError } from 'nitro/h3'; +import { createCaptureErrorHook } from '../utils/captureError'; /** - * Hook that can be added in a Nitro plugin. It captures an error and sends it to Sentry. + * Hook that can be added in a Nitro plugin. It captures an error and sends it to Sentry. + * + * For Nuxt v5+ (Nitro v3+, h3 v2). */ -export async function sentryCaptureErrorHook(error: Error, errorContext: CapturedErrorContext): Promise { - const sentryClient = getClient(); - const sentryClientOptions = sentryClient?.getOptions(); - - if ( - sentryClientOptions && - 'enableNitroErrorHandler' in sentryClientOptions && - sentryClientOptions.enableNitroErrorHandler === false - ) { - return; - } - - // Do not handle 404 and 422 - if (error instanceof H3Error) { - // Do not report if status code is 3xx or 4xx - if (error.statusCode >= 300 && error.statusCode < 500) { - return; - } - - // Check if the cause (original error) was already captured by middleware instrumentation - // H3 wraps errors, so we need to check the cause property - if ( - 'cause' in error && - typeof error.cause === 'object' && - error.cause !== null && - '__sentry_captured__' in error.cause - ) { - return; - } - } - - const { method, path } = { - method: errorContext.event?._method ? errorContext.event._method : '', - path: errorContext.event?._path ? errorContext.event._path : null, - }; - - if (path) { - getCurrentScope().setTransactionName(`${method} ${path}`); - } - - const structuredContext = extractErrorContext(errorContext); - - captureException(error, { - captureContext: { contexts: { nuxt: structuredContext } }, - mechanism: { handled: false, type: 'auto.function.nuxt.nitro' }, - }); - - await flushIfServerless(); -} +export const sentryCaptureErrorHook = createCaptureErrorHook(error => + // `isError` compares constructor names, so it also matches an error thrown by another copy of h3 + HTTPError.isError(error) ? error.status : undefined, +); diff --git a/packages/nuxt/src/runtime/plugins/capture-error-legacy.server.ts b/packages/nuxt/src/runtime/plugins/capture-error-legacy.server.ts new file mode 100644 index 000000000000..c12ccd7ecc42 --- /dev/null +++ b/packages/nuxt/src/runtime/plugins/capture-error-legacy.server.ts @@ -0,0 +1,9 @@ +import type { NitroAppPlugin } from 'nitropack'; +import { sentryCaptureErrorHook } from '../hooks/captureErrorHook-legacy'; + +/** + * Nitro plugin that reports server errors to Sentry for Nuxt v3/v4 (Nitro v2) + */ +export default (nitroApp => { + nitroApp.hooks.hook('error', sentryCaptureErrorHook); +}) satisfies NitroAppPlugin; diff --git a/packages/nuxt/src/runtime/plugins/capture-error.server.ts b/packages/nuxt/src/runtime/plugins/capture-error.server.ts new file mode 100644 index 000000000000..9567ae72d5dc --- /dev/null +++ b/packages/nuxt/src/runtime/plugins/capture-error.server.ts @@ -0,0 +1,10 @@ +import type { NitroAppPlugin } from 'nitro/types'; +import { sentryCaptureErrorHook } from '../hooks/captureErrorHook'; + +/** + * Nitro plugin that reports server errors to Sentry for Nuxt v5+ (Nitro v3+) + */ +export default (nitroApp => { + // @ts-expect-error Nitro v3 hands the `error` hook an `HTTPEvent`, Nitro v2 an `H3Event` + nitroApp.hooks.hook('error', sentryCaptureErrorHook); +}) satisfies NitroAppPlugin; diff --git a/packages/nuxt/src/runtime/plugins/sentry-cloudflare.server.ts b/packages/nuxt/src/runtime/plugins/sentry-cloudflare.server.ts index cc2fcb1c3315..d742f3a11cd0 100644 --- a/packages/nuxt/src/runtime/plugins/sentry-cloudflare.server.ts +++ b/packages/nuxt/src/runtime/plugins/sentry-cloudflare.server.ts @@ -5,7 +5,7 @@ import { debug, getDefaultIsolationScope, getIsolationScope, getTraceData } from import type { H3Event } from 'h3'; import type { NitroApp, NitroAppPlugin } from 'nitropack'; import type { NuxtRenderHTMLContext } from 'nuxt/app'; -import { sentryCaptureErrorHook } from '../hooks/captureErrorHook'; +import { sentryCaptureErrorHook } from '../hooks/captureErrorHook-legacy'; import { updateRouteBeforeResponse } from '../hooks/updateRouteBeforeResponse'; import { addSentryTracingMetaTags } from '../utils'; import { getCfProperties, getCloudflareProperties, hasCfProperty, isEventType } from '../utils/event-type-check'; diff --git a/packages/nuxt/src/runtime/plugins/sentry.server.ts b/packages/nuxt/src/runtime/plugins/sentry.server.ts index fd35d035c077..da2a534af038 100644 --- a/packages/nuxt/src/runtime/plugins/sentry.server.ts +++ b/packages/nuxt/src/runtime/plugins/sentry.server.ts @@ -2,12 +2,9 @@ import { debug } from '@sentry/core'; import type { H3Event } from 'h3'; import type { NitroAppPlugin } from 'nitropack'; import type { NuxtRenderHTMLContext } from 'nuxt/app'; -import { sentryCaptureErrorHook } from '../hooks/captureErrorHook'; import { addSentryTracingMetaTags } from '../utils'; export default (nitroApp => { - nitroApp.hooks.hook('error', sentryCaptureErrorHook); - nitroApp.hooks.hook('render:html', (html: NuxtRenderHTMLContext, { event }: { event: H3Event }) => { // h3 v1 (Nuxt 4): event.node.res.getHeaders(); h3 v2 (Nuxt 5): event.node is undefined const nodeResHeadersH3v1 = event.node?.res?.getHeaders() || {}; diff --git a/packages/nuxt/src/runtime/plugins/update-route-name.server.ts b/packages/nuxt/src/runtime/plugins/update-route-name.server.ts index 72e3d9452e7e..7774e3610ba3 100644 --- a/packages/nuxt/src/runtime/plugins/update-route-name.server.ts +++ b/packages/nuxt/src/runtime/plugins/update-route-name.server.ts @@ -1,6 +1,6 @@ import type { NitroAppPlugin } from 'nitro/types'; import { updateRouteBeforeResponse } from '../hooks/updateRouteBeforeResponse'; -import type { H3Event } from 'h3'; +import type { H3Event } from 'nitro/h3'; export default (nitroApp => { // @ts-expect-error Hook in Nuxt 5 (Nitro 3) is called 'response' https://nitro.build/docs/plugins#available-hooks diff --git a/packages/nuxt/src/runtime/utils/captureError.ts b/packages/nuxt/src/runtime/utils/captureError.ts new file mode 100644 index 000000000000..59cf4e12309f --- /dev/null +++ b/packages/nuxt/src/runtime/utils/captureError.ts @@ -0,0 +1,69 @@ +import { captureException, getClient, getCurrentScope } from '@sentry/core'; +import { flushIfServerless } from '@sentry/core/server'; +import type { CapturedErrorContext } from 'nitropack/types'; +import { extractErrorContext } from '../utils'; + +/** + * Reads the HTTP status off an error thrown by the server framework, or returns `undefined` for + * anything that is not one. h3 v1 (`H3Error.statusCode`) and h3 v2 (`HTTPError.status`) disagree on + * both the class and the field, so each Nitro variant passes in its own. + */ +export type GetHttpErrorStatus = (error: Error) => number | undefined; + +/** + * Builds the hook a Nitro plugin registers on `error`. It captures the error and sends it to Sentry. + */ +export function createCaptureErrorHook( + getHttpErrorStatus: GetHttpErrorStatus, +): (error: Error, errorContext: CapturedErrorContext) => Promise { + return async function sentryCaptureErrorHook(error, errorContext): Promise { + const sentryClient = getClient(); + const sentryClientOptions = sentryClient?.getOptions(); + + if ( + sentryClientOptions && + 'enableNitroErrorHandler' in sentryClientOptions && + sentryClientOptions.enableNitroErrorHandler === false + ) { + return; + } + + const status = getHttpErrorStatus(error); + + if (status !== undefined) { + // Do not report if status code is 3xx or 4xx + if (status >= 300 && status < 500) { + return; + } + + // Check if the cause (original error) was already captured by middleware instrumentation + // H3 wraps errors, so we need to check the cause property + if ( + 'cause' in error && + typeof error.cause === 'object' && + error.cause !== null && + '__sentry_captured__' in error.cause + ) { + return; + } + } + + const { method, path } = { + method: errorContext.event?._method ? errorContext.event._method : '', + path: errorContext.event?._path ? errorContext.event._path : null, + }; + + if (path) { + getCurrentScope().setTransactionName(`${method} ${path}`); + } + + const structuredContext = extractErrorContext(errorContext); + + captureException(error, { + captureContext: { contexts: { nuxt: structuredContext } }, + mechanism: { handled: false, type: 'auto.function.nuxt.nitro' }, + }); + + await flushIfServerless(); + }; +} diff --git a/packages/nuxt/test/runtime/hooks/captureErrorHook.test.ts b/packages/nuxt/test/runtime/hooks/captureErrorHook.test.ts index 8e166a5ff4cc..9f2dccd60808 100644 --- a/packages/nuxt/test/runtime/hooks/captureErrorHook.test.ts +++ b/packages/nuxt/test/runtime/hooks/captureErrorHook.test.ts @@ -1,9 +1,11 @@ import * as SentryCore from '@sentry/core'; import * as SentryCoreServer from '@sentry/core/server'; import { H3Error } from 'h3'; +import { HTTPError } from 'nitro/h3'; import type { CapturedErrorContext } from 'nitropack/types'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { sentryCaptureErrorHook } from '../../../src/runtime/hooks/captureErrorHook'; +import { sentryCaptureErrorHook as sentryCaptureErrorHookLegacy } from '../../../src/runtime/hooks/captureErrorHook-legacy'; vi.mock('@sentry/core', async importOriginal => { const mod = await importOriginal(); @@ -29,7 +31,32 @@ vi.mock('../../../src/runtime/utils', () => ({ extractErrorContext: vi.fn(() => ({ test: 'context' })), })); -describe('sentryCaptureErrorHook', () => { +// Each Nitro major throws its own HTTP error class, with its own status field, so both hooks are +// exercised against the error shape they will actually see. +const variants = [ + { + name: 'Nitro v3 (h3 v2)', + hook: sentryCaptureErrorHook, + httpError: (message: string, status: number): Error => new HTTPError({ message, status }), + }, + { + name: 'Nitro v2 (h3 v1)', + hook: sentryCaptureErrorHookLegacy, + httpError: (message: string, status: number): Error => { + const error = new H3Error(message); + error.statusCode = status; + return error; + }, + }, +]; + +// The two classes disagree on what the constructor puts on `cause` (h3 v2 stores the whole details +// object), so it is set directly: what is under test is how the hook reads `cause`, not h3. +function withCause(error: Error, cause: unknown): Error { + return Object.defineProperty(error, 'cause', { value: cause, configurable: true }); +} + +describe.each(variants)('sentryCaptureErrorHook - $name', ({ hook, httpError }) => { const mockErrorContext: CapturedErrorContext = { event: { _method: 'GET', @@ -48,7 +75,7 @@ describe('sentryCaptureErrorHook', () => { it('should capture regular errors', async () => { const error = new Error('Test error'); - await sentryCaptureErrorHook(error, mockErrorContext); + await hook(error, mockErrorContext); expect(SentryCore.captureException).toHaveBeenCalledWith( error, @@ -58,29 +85,26 @@ describe('sentryCaptureErrorHook', () => { ); }); - it('should skip H3Error with 4xx status codes', async () => { - const error = new H3Error('Not found'); - error.statusCode = 404; + it('should skip HTTP errors with 4xx status codes', async () => { + const error = httpError('Not found', 404); - await sentryCaptureErrorHook(error, mockErrorContext); + await hook(error, mockErrorContext); expect(SentryCore.captureException).not.toHaveBeenCalled(); }); - it('should skip H3Error with 3xx status codes', async () => { - const error = new H3Error('Redirect'); - error.statusCode = 302; + it('should skip HTTP errors with 3xx status codes', async () => { + const error = httpError('Redirect', 302); - await sentryCaptureErrorHook(error, mockErrorContext); + await hook(error, mockErrorContext); expect(SentryCore.captureException).not.toHaveBeenCalled(); }); - it('should capture H3Error with 5xx status codes', async () => { - const error = new H3Error('Server error'); - error.statusCode = 500; + it('should capture HTTP errors with 5xx status codes', async () => { + const error = httpError('Server error', 500); - await sentryCaptureErrorHook(error, mockErrorContext); + await hook(error, mockErrorContext); expect(SentryCore.captureException).toHaveBeenCalledWith( error, @@ -90,7 +114,7 @@ describe('sentryCaptureErrorHook', () => { ); }); - it('should skip H3Error when cause has __sentry_captured__ flag', async () => { + it('should skip HTTP errors when cause has __sentry_captured__ flag', async () => { const originalError = new Error('Original error'); // Mark the original error as already captured by middleware Object.defineProperty(originalError, '__sentry_captured__', { @@ -98,51 +122,47 @@ describe('sentryCaptureErrorHook', () => { enumerable: false, }); - const h3Error = new H3Error('Wrapped error', { cause: originalError }); - h3Error.statusCode = 500; + const error = withCause(httpError('Wrapped error', 500), originalError); - await sentryCaptureErrorHook(h3Error, mockErrorContext); + await hook(error, mockErrorContext); expect(SentryCore.captureException).not.toHaveBeenCalled(); }); - it('should capture H3Error when cause does not have __sentry_captured__ flag', async () => { + it('should capture HTTP errors when cause does not have __sentry_captured__ flag', async () => { const originalError = new Error('Original error'); - const h3Error = new H3Error('Wrapped error', { cause: originalError }); - h3Error.statusCode = 500; + const error = withCause(httpError('Wrapped error', 500), originalError); - await sentryCaptureErrorHook(h3Error, mockErrorContext); + await hook(error, mockErrorContext); expect(SentryCore.captureException).toHaveBeenCalledWith( - h3Error, + error, expect.objectContaining({ mechanism: { handled: false, type: 'auto.function.nuxt.nitro' }, }), ); }); - it('should capture H3Error when cause is not an object', async () => { - const h3Error = new H3Error('Error with string cause', { cause: 'string cause' }); - h3Error.statusCode = 500; + it('should capture HTTP errors when cause is not an object', async () => { + const error = withCause(httpError('Error with string cause', 500), 'string cause'); - await sentryCaptureErrorHook(h3Error, mockErrorContext); + await hook(error, mockErrorContext); expect(SentryCore.captureException).toHaveBeenCalledWith( - h3Error, + error, expect.objectContaining({ mechanism: { handled: false, type: 'auto.function.nuxt.nitro' }, }), ); }); - it('should capture H3Error when there is no cause', async () => { - const h3Error = new H3Error('Error without cause'); - h3Error.statusCode = 500; + it('should capture HTTP errors when there is no cause', async () => { + const error = httpError('Error without cause', 500); - await sentryCaptureErrorHook(h3Error, mockErrorContext); + await hook(error, mockErrorContext); expect(SentryCore.captureException).toHaveBeenCalledWith( - h3Error, + error, expect.objectContaining({ mechanism: { handled: false, type: 'auto.function.nuxt.nitro' }, }), @@ -156,7 +176,7 @@ describe('sentryCaptureErrorHook', () => { const error = new Error('Test error'); - await sentryCaptureErrorHook(error, mockErrorContext); + await hook(error, mockErrorContext); expect(SentryCore.captureException).not.toHaveBeenCalled(); }); From 8c5de5e73e220759efe6499b90467ae5ff9bbf4d Mon Sep 17 00:00:00 2001 From: s1gr1d <32902192+s1gr1d@users.noreply.github.com> Date: Thu, 17 Sep 2026 13:21:33 +0200 Subject: [PATCH 2/4] remove h3 imports --- packages/nuxt/src/module.ts | 2 - .../runtime/hooks/captureErrorHook-legacy.ts | 12 --- .../src/runtime/hooks/captureErrorHook.ts | 75 +++++++++++++-- .../plugins/capture-error-legacy.server.ts | 9 -- .../runtime/plugins/capture-error.server.ts | 10 -- .../plugins/sentry-cloudflare.server.ts | 2 +- .../nuxt/src/runtime/plugins/sentry.server.ts | 3 + packages/nuxt/src/runtime/utils.ts | 28 +++++- .../nuxt/src/runtime/utils/captureError.ts | 69 -------------- .../runtime/hooks/captureErrorHook.test.ts | 93 ++++++++++--------- packages/nuxt/test/runtime/utils.test.ts | 18 +++- 11 files changed, 161 insertions(+), 160 deletions(-) delete mode 100644 packages/nuxt/src/runtime/hooks/captureErrorHook-legacy.ts delete mode 100644 packages/nuxt/src/runtime/plugins/capture-error-legacy.server.ts delete mode 100644 packages/nuxt/src/runtime/plugins/capture-error.server.ts delete mode 100644 packages/nuxt/src/runtime/utils/captureError.ts diff --git a/packages/nuxt/src/module.ts b/packages/nuxt/src/module.ts index fb336306c929..0b80077367a0 100644 --- a/packages/nuxt/src/module.ts +++ b/packages/nuxt/src/module.ts @@ -113,11 +113,9 @@ export default defineNuxtModule({ if (isNitroV3) { addServerPlugin(moduleDirResolver.resolve('./runtime/plugins/handler.server')); addServerPlugin(moduleDirResolver.resolve('./runtime/plugins/update-route-name.server')); - addServerPlugin(moduleDirResolver.resolve('./runtime/plugins/capture-error.server')); } else { addServerPlugin(moduleDirResolver.resolve('./runtime/plugins/handler-legacy.server')); addServerPlugin(moduleDirResolver.resolve('./runtime/plugins/update-route-name-legacy.server')); - addServerPlugin(moduleDirResolver.resolve('./runtime/plugins/capture-error-legacy.server')); } addServerPlugin(moduleDirResolver.resolve('./runtime/plugins/sentry.server')); diff --git a/packages/nuxt/src/runtime/hooks/captureErrorHook-legacy.ts b/packages/nuxt/src/runtime/hooks/captureErrorHook-legacy.ts deleted file mode 100644 index 6482132737ee..000000000000 --- a/packages/nuxt/src/runtime/hooks/captureErrorHook-legacy.ts +++ /dev/null @@ -1,12 +0,0 @@ -// eslint-disable-next-line import/no-extraneous-dependencies -import { H3Error } from 'h3'; -import { createCaptureErrorHook } from '../utils/captureError'; - -/** - * Hook that can be added in a Nitro plugin. It captures an error and sends it to Sentry. - * - * For Nuxt v3/v4 (Nitro v2, h3 v1). - */ -export const sentryCaptureErrorHook = createCaptureErrorHook(error => - error instanceof H3Error ? error.statusCode : undefined, -); diff --git a/packages/nuxt/src/runtime/hooks/captureErrorHook.ts b/packages/nuxt/src/runtime/hooks/captureErrorHook.ts index 8e1f96a1cb52..54747ad8dfea 100644 --- a/packages/nuxt/src/runtime/hooks/captureErrorHook.ts +++ b/packages/nuxt/src/runtime/hooks/captureErrorHook.ts @@ -1,12 +1,71 @@ -import { HTTPError } from 'nitro/h3'; -import { createCaptureErrorHook } from '../utils/captureError'; +import { captureException, getClient, getCurrentScope } from '@sentry/core'; +import { flushIfServerless } from '@sentry/core/server'; +import type { CapturedErrorContext } from 'nitropack/types'; +import { extractErrorContext, getEventRequestInfo } from '../utils'; /** - * Hook that can be added in a Nitro plugin. It captures an error and sends it to Sentry. + * Returns the status code of an error thrown by h3, or `undefined` for any other error. * - * For Nuxt v5+ (Nitro v3+, h3 v2). + * Mirrors each h3 major's own `isError` instead of importing h3: an `h3` import puts this module + * behind Nuxt 5's transitional Nitro v2 compatibility layer (NUXT_B9003), and `nitro/h3` does not + * resolve on Nuxt 3/4. h3 v2 (Nitro v3) recognizes its errors by name, h3 v1 (Nitro v2) by a static + * flag on the class. Both expose `statusCode`. */ -export const sentryCaptureErrorHook = createCaptureErrorHook(error => - // `isError` compares constructor names, so it also matches an error thrown by another copy of h3 - HTTPError.isError(error) ? error.status : undefined, -); +function getH3ErrorStatusCode(error: Error): number | undefined { + const isH3Error = + error.name === 'HTTPError' || (error.constructor as { __h3_error__?: boolean } | undefined)?.__h3_error__ === true; + + return isH3Error ? (error as { statusCode?: number }).statusCode : undefined; +} + +/** + * Hook that can be added in a Nitro plugin. It captures an error and sends it to Sentry. + */ +export async function sentryCaptureErrorHook(error: Error, errorContext: CapturedErrorContext): Promise { + const sentryClient = getClient(); + const sentryClientOptions = sentryClient?.getOptions(); + + if ( + sentryClientOptions && + 'enableNitroErrorHandler' in sentryClientOptions && + sentryClientOptions.enableNitroErrorHandler === false + ) { + return; + } + + const statusCode = getH3ErrorStatusCode(error); + + // Do not handle 404 and 422 + if (statusCode !== undefined) { + // Do not report if status code is 3xx or 4xx + if (statusCode >= 300 && statusCode < 500) { + return; + } + + // Check if the cause (original error) was already captured by middleware instrumentation + // H3 wraps errors, so we need to check the cause property + if ( + 'cause' in error && + typeof error.cause === 'object' && + error.cause !== null && + '__sentry_captured__' in error.cause + ) { + return; + } + } + + const { method = '', path } = getEventRequestInfo(errorContext.event); + + if (path) { + getCurrentScope().setTransactionName(`${method} ${path}`); + } + + const structuredContext = extractErrorContext(errorContext); + + captureException(error, { + captureContext: { contexts: { nuxt: structuredContext } }, + mechanism: { handled: false, type: 'auto.function.nuxt.nitro' }, + }); + + await flushIfServerless(); +} diff --git a/packages/nuxt/src/runtime/plugins/capture-error-legacy.server.ts b/packages/nuxt/src/runtime/plugins/capture-error-legacy.server.ts deleted file mode 100644 index c12ccd7ecc42..000000000000 --- a/packages/nuxt/src/runtime/plugins/capture-error-legacy.server.ts +++ /dev/null @@ -1,9 +0,0 @@ -import type { NitroAppPlugin } from 'nitropack'; -import { sentryCaptureErrorHook } from '../hooks/captureErrorHook-legacy'; - -/** - * Nitro plugin that reports server errors to Sentry for Nuxt v3/v4 (Nitro v2) - */ -export default (nitroApp => { - nitroApp.hooks.hook('error', sentryCaptureErrorHook); -}) satisfies NitroAppPlugin; diff --git a/packages/nuxt/src/runtime/plugins/capture-error.server.ts b/packages/nuxt/src/runtime/plugins/capture-error.server.ts deleted file mode 100644 index 9567ae72d5dc..000000000000 --- a/packages/nuxt/src/runtime/plugins/capture-error.server.ts +++ /dev/null @@ -1,10 +0,0 @@ -import type { NitroAppPlugin } from 'nitro/types'; -import { sentryCaptureErrorHook } from '../hooks/captureErrorHook'; - -/** - * Nitro plugin that reports server errors to Sentry for Nuxt v5+ (Nitro v3+) - */ -export default (nitroApp => { - // @ts-expect-error Nitro v3 hands the `error` hook an `HTTPEvent`, Nitro v2 an `H3Event` - nitroApp.hooks.hook('error', sentryCaptureErrorHook); -}) satisfies NitroAppPlugin; diff --git a/packages/nuxt/src/runtime/plugins/sentry-cloudflare.server.ts b/packages/nuxt/src/runtime/plugins/sentry-cloudflare.server.ts index d742f3a11cd0..cc2fcb1c3315 100644 --- a/packages/nuxt/src/runtime/plugins/sentry-cloudflare.server.ts +++ b/packages/nuxt/src/runtime/plugins/sentry-cloudflare.server.ts @@ -5,7 +5,7 @@ import { debug, getDefaultIsolationScope, getIsolationScope, getTraceData } from import type { H3Event } from 'h3'; import type { NitroApp, NitroAppPlugin } from 'nitropack'; import type { NuxtRenderHTMLContext } from 'nuxt/app'; -import { sentryCaptureErrorHook } from '../hooks/captureErrorHook-legacy'; +import { sentryCaptureErrorHook } from '../hooks/captureErrorHook'; import { updateRouteBeforeResponse } from '../hooks/updateRouteBeforeResponse'; import { addSentryTracingMetaTags } from '../utils'; import { getCfProperties, getCloudflareProperties, hasCfProperty, isEventType } from '../utils/event-type-check'; diff --git a/packages/nuxt/src/runtime/plugins/sentry.server.ts b/packages/nuxt/src/runtime/plugins/sentry.server.ts index da2a534af038..fd35d035c077 100644 --- a/packages/nuxt/src/runtime/plugins/sentry.server.ts +++ b/packages/nuxt/src/runtime/plugins/sentry.server.ts @@ -2,9 +2,12 @@ import { debug } from '@sentry/core'; import type { H3Event } from 'h3'; import type { NitroAppPlugin } from 'nitropack'; import type { NuxtRenderHTMLContext } from 'nuxt/app'; +import { sentryCaptureErrorHook } from '../hooks/captureErrorHook'; import { addSentryTracingMetaTags } from '../utils'; export default (nitroApp => { + nitroApp.hooks.hook('error', sentryCaptureErrorHook); + nitroApp.hooks.hook('render:html', (html: NuxtRenderHTMLContext, { event }: { event: H3Event }) => { // h3 v1 (Nuxt 4): event.node.res.getHeaders(); h3 v2 (Nuxt 5): event.node is undefined const nodeResHeadersH3v1 = event.node?.res?.getHeaders() || {}; diff --git a/packages/nuxt/src/runtime/utils.ts b/packages/nuxt/src/runtime/utils.ts index 5a8e9c3db701..becb6ef55d19 100644 --- a/packages/nuxt/src/runtime/utils.ts +++ b/packages/nuxt/src/runtime/utils.ts @@ -1,9 +1,30 @@ import type { ClientOptions, Context, SerializedTraceData } from '@sentry/core'; -import { captureException, debug, getClient, getTraceMetaTags } from '@sentry/core'; +import { captureException, debug, getClient, getTraceMetaTags, isObjectLike } from '@sentry/core'; import type { CapturedErrorContext } from 'nitropack/types'; import type { NuxtRenderHTMLContext } from 'nuxt/app'; import type { ComponentPublicInstance } from 'vue'; +/** + * Reads the request method and path off the event Nitro passes to its `error` hook. + * + * h3 v1 (Nitro v2) exposes `method` and `path` getters. h3 v2 (Nitro v3) has neither: the method lives + * on the web `Request` in `req`, and the path on the parsed `url`. + */ +export function getEventRequestInfo(event: unknown): { method?: string; path?: string } { + if (!isObjectLike(event)) { + return {}; + } + + const { method, path, req, url } = event as { + method?: string; + path?: string; + req?: { method?: string }; + url?: { pathname?: string }; + }; + + return { method: method ?? req?.method, path: path ?? url?.pathname }; +} + /** * Extracts the relevant context information from the error context (H3Event in Nitro Error) * and created a structured context object. @@ -16,8 +37,9 @@ export function extractErrorContext(errorContext: CapturedErrorContext | undefin } if (errorContext.event) { - ctx.method = errorContext.event._method; - ctx.path = errorContext.event._path; + const { method, path } = getEventRequestInfo(errorContext.event); + ctx.method = method; + ctx.path = path; } if (Array.isArray(errorContext.tags)) { diff --git a/packages/nuxt/src/runtime/utils/captureError.ts b/packages/nuxt/src/runtime/utils/captureError.ts deleted file mode 100644 index 59cf4e12309f..000000000000 --- a/packages/nuxt/src/runtime/utils/captureError.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { captureException, getClient, getCurrentScope } from '@sentry/core'; -import { flushIfServerless } from '@sentry/core/server'; -import type { CapturedErrorContext } from 'nitropack/types'; -import { extractErrorContext } from '../utils'; - -/** - * Reads the HTTP status off an error thrown by the server framework, or returns `undefined` for - * anything that is not one. h3 v1 (`H3Error.statusCode`) and h3 v2 (`HTTPError.status`) disagree on - * both the class and the field, so each Nitro variant passes in its own. - */ -export type GetHttpErrorStatus = (error: Error) => number | undefined; - -/** - * Builds the hook a Nitro plugin registers on `error`. It captures the error and sends it to Sentry. - */ -export function createCaptureErrorHook( - getHttpErrorStatus: GetHttpErrorStatus, -): (error: Error, errorContext: CapturedErrorContext) => Promise { - return async function sentryCaptureErrorHook(error, errorContext): Promise { - const sentryClient = getClient(); - const sentryClientOptions = sentryClient?.getOptions(); - - if ( - sentryClientOptions && - 'enableNitroErrorHandler' in sentryClientOptions && - sentryClientOptions.enableNitroErrorHandler === false - ) { - return; - } - - const status = getHttpErrorStatus(error); - - if (status !== undefined) { - // Do not report if status code is 3xx or 4xx - if (status >= 300 && status < 500) { - return; - } - - // Check if the cause (original error) was already captured by middleware instrumentation - // H3 wraps errors, so we need to check the cause property - if ( - 'cause' in error && - typeof error.cause === 'object' && - error.cause !== null && - '__sentry_captured__' in error.cause - ) { - return; - } - } - - const { method, path } = { - method: errorContext.event?._method ? errorContext.event._method : '', - path: errorContext.event?._path ? errorContext.event._path : null, - }; - - if (path) { - getCurrentScope().setTransactionName(`${method} ${path}`); - } - - const structuredContext = extractErrorContext(errorContext); - - captureException(error, { - captureContext: { contexts: { nuxt: structuredContext } }, - mechanism: { handled: false, type: 'auto.function.nuxt.nitro' }, - }); - - await flushIfServerless(); - }; -} diff --git a/packages/nuxt/test/runtime/hooks/captureErrorHook.test.ts b/packages/nuxt/test/runtime/hooks/captureErrorHook.test.ts index 9f2dccd60808..88a9fa4861fd 100644 --- a/packages/nuxt/test/runtime/hooks/captureErrorHook.test.ts +++ b/packages/nuxt/test/runtime/hooks/captureErrorHook.test.ts @@ -5,7 +5,8 @@ import { HTTPError } from 'nitro/h3'; import type { CapturedErrorContext } from 'nitropack/types'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { sentryCaptureErrorHook } from '../../../src/runtime/hooks/captureErrorHook'; -import { sentryCaptureErrorHook as sentryCaptureErrorHookLegacy } from '../../../src/runtime/hooks/captureErrorHook-legacy'; + +const setTransactionName = vi.fn(); vi.mock('@sentry/core', async importOriginal => { const mod = await importOriginal(); @@ -13,9 +14,7 @@ vi.mock('@sentry/core', async importOriginal => { ...(mod as any), captureException: vi.fn(), getClient: vi.fn(), - getCurrentScope: vi.fn(() => ({ - setTransactionName: vi.fn(), - })), + getCurrentScope: vi.fn(() => ({ setTransactionName })), }; }); @@ -27,26 +26,27 @@ vi.mock('@sentry/core/server', async importOriginal => { }; }); -vi.mock('../../../src/runtime/utils', () => ({ +vi.mock('../../../src/runtime/utils', async importOriginal => ({ + ...(await importOriginal()), extractErrorContext: vi.fn(() => ({ test: 'context' })), })); -// Each Nitro major throws its own HTTP error class, with its own status field, so both hooks are -// exercised against the error shape they will actually see. -const variants = [ - { - name: 'Nitro v3 (h3 v2)', - hook: sentryCaptureErrorHook, - httpError: (message: string, status: number): Error => new HTTPError({ message, status }), - }, +// Nuxt 3/4 run Nitro v2 on h3 v1, Nuxt 5 runs Nitro v3 on h3 v2. The two majors differ in both the +// error class the hook sees and the shape of the event it reads the request from. +const h3Majors = [ { - name: 'Nitro v2 (h3 v1)', - hook: sentryCaptureErrorHookLegacy, - httpError: (message: string, status: number): Error => { + name: 'h3 v1 (Nitro v2)', + httpError: (message: string, statusCode: number): Error => { const error = new H3Error(message); - error.statusCode = status; + error.statusCode = statusCode; return error; }, + event: { method: 'GET', path: '/test-path' }, + }, + { + name: 'h3 v2 (Nitro v3)', + httpError: (message: string, statusCode: number): Error => new HTTPError({ message, status: statusCode }), + event: { req: new Request('http://localhost/test-path'), url: new URL('http://localhost/test-path') }, }, ]; @@ -56,13 +56,8 @@ function withCause(error: Error, cause: unknown): Error { return Object.defineProperty(error, 'cause', { value: cause, configurable: true }); } -describe.each(variants)('sentryCaptureErrorHook - $name', ({ hook, httpError }) => { - const mockErrorContext: CapturedErrorContext = { - event: { - _method: 'GET', - _path: '/test-path', - } as any, - }; +describe.each(h3Majors)('sentryCaptureErrorHook - $name', ({ httpError, event }) => { + const mockErrorContext = { event } as unknown as CapturedErrorContext; beforeEach(() => { vi.clearAllMocks(); @@ -75,7 +70,7 @@ describe.each(variants)('sentryCaptureErrorHook - $name', ({ hook, httpError }) it('should capture regular errors', async () => { const error = new Error('Test error'); - await hook(error, mockErrorContext); + await sentryCaptureErrorHook(error, mockErrorContext); expect(SentryCore.captureException).toHaveBeenCalledWith( error, @@ -85,18 +80,20 @@ describe.each(variants)('sentryCaptureErrorHook - $name', ({ hook, httpError }) ); }); - it('should skip HTTP errors with 4xx status codes', async () => { - const error = httpError('Not found', 404); + it('sets the transaction name from the request method and path', async () => { + await sentryCaptureErrorHook(new Error('Test error'), mockErrorContext); + + expect(setTransactionName).toHaveBeenCalledWith('GET /test-path'); + }); - await hook(error, mockErrorContext); + it('should skip HTTP errors with 4xx status codes', async () => { + await sentryCaptureErrorHook(httpError('Not found', 404), mockErrorContext); expect(SentryCore.captureException).not.toHaveBeenCalled(); }); it('should skip HTTP errors with 3xx status codes', async () => { - const error = httpError('Redirect', 302); - - await hook(error, mockErrorContext); + await sentryCaptureErrorHook(httpError('Redirect', 302), mockErrorContext); expect(SentryCore.captureException).not.toHaveBeenCalled(); }); @@ -104,7 +101,7 @@ describe.each(variants)('sentryCaptureErrorHook - $name', ({ hook, httpError }) it('should capture HTTP errors with 5xx status codes', async () => { const error = httpError('Server error', 500); - await hook(error, mockErrorContext); + await sentryCaptureErrorHook(error, mockErrorContext); expect(SentryCore.captureException).toHaveBeenCalledWith( error, @@ -122,18 +119,15 @@ describe.each(variants)('sentryCaptureErrorHook - $name', ({ hook, httpError }) enumerable: false, }); - const error = withCause(httpError('Wrapped error', 500), originalError); - - await hook(error, mockErrorContext); + await sentryCaptureErrorHook(withCause(httpError('Wrapped error', 500), originalError), mockErrorContext); expect(SentryCore.captureException).not.toHaveBeenCalled(); }); it('should capture HTTP errors when cause does not have __sentry_captured__ flag', async () => { - const originalError = new Error('Original error'); - const error = withCause(httpError('Wrapped error', 500), originalError); + const error = withCause(httpError('Wrapped error', 500), new Error('Original error')); - await hook(error, mockErrorContext); + await sentryCaptureErrorHook(error, mockErrorContext); expect(SentryCore.captureException).toHaveBeenCalledWith( error, @@ -146,7 +140,7 @@ describe.each(variants)('sentryCaptureErrorHook - $name', ({ hook, httpError }) it('should capture HTTP errors when cause is not an object', async () => { const error = withCause(httpError('Error with string cause', 500), 'string cause'); - await hook(error, mockErrorContext); + await sentryCaptureErrorHook(error, mockErrorContext); expect(SentryCore.captureException).toHaveBeenCalledWith( error, @@ -159,7 +153,7 @@ describe.each(variants)('sentryCaptureErrorHook - $name', ({ hook, httpError }) it('should capture HTTP errors when there is no cause', async () => { const error = httpError('Error without cause', 500); - await hook(error, mockErrorContext); + await sentryCaptureErrorHook(error, mockErrorContext); expect(SentryCore.captureException).toHaveBeenCalledWith( error, @@ -174,10 +168,23 @@ describe.each(variants)('sentryCaptureErrorHook - $name', ({ hook, httpError }) getOptions: () => ({ enableNitroErrorHandler: false }), }); - const error = new Error('Test error'); - - await hook(error, mockErrorContext); + await sentryCaptureErrorHook(new Error('Test error'), mockErrorContext); expect(SentryCore.captureException).not.toHaveBeenCalled(); }); }); + +describe('sentryCaptureErrorHook - errors that only look like h3 errors', () => { + beforeEach(() => { + vi.clearAllMocks(); + (SentryCore.getClient as any).mockReturnValue({ getOptions: () => ({}) }); + }); + + it('still reports a plain error that carries a 4xx `statusCode`', async () => { + const error = Object.assign(new Error('Upstream API returned 404'), { statusCode: 404 }); + + await sentryCaptureErrorHook(error, {} as CapturedErrorContext); + + expect(SentryCore.captureException).toHaveBeenCalledWith(error, expect.anything()); + }); +}); diff --git a/packages/nuxt/test/runtime/utils.test.ts b/packages/nuxt/test/runtime/utils.test.ts index fe1ebd94fdf3..e930f63afde0 100644 --- a/packages/nuxt/test/runtime/utils.test.ts +++ b/packages/nuxt/test/runtime/utils.test.ts @@ -14,8 +14,8 @@ describe('extractErrorContext', () => { it('extracts properties from errorContext and drops them if missing', () => { const context = { event: { - _method: 'GET', - _path: '/test', + method: 'GET', + path: '/test', }, tags: ['tag1', 'tag2'], }; @@ -29,7 +29,7 @@ describe('extractErrorContext', () => { const partialContext = { event: { - _path: '/test', + path: '/test', }, }; // eslint-disable-next-line @typescript-eslint/ban-ts-comment @@ -37,6 +37,18 @@ describe('extractErrorContext', () => { expect(extractErrorContext(partialContext)).toEqual({ path: '/test' }); }); + it('reads method and path from an h3 v2 (Nitro v3) event, which has no `method`/`path` getters', () => { + const context = { + event: { + req: new Request('http://localhost/test?query=1', { method: 'POST' }), + url: new URL('http://localhost/test?query=1'), + }, + }; + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + expect(extractErrorContext(context)).toEqual({ method: 'POST', path: '/test' }); + }); + it('handles errorContext.tags correctly, including when absent or of unexpected type', () => { const contextWithTags = { tags: ['tag1', 'tag2'], From de5d1b9a202140feb74c1ddacb27ef7a770a37d3 Mon Sep 17 00:00:00 2001 From: s1gr1d <32902192+s1gr1d@users.noreply.github.com> Date: Thu, 17 Sep 2026 14:16:32 +0200 Subject: [PATCH 3/4] fix review --- .../nuxt/src/runtime/hooks/captureErrorHook.ts | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/packages/nuxt/src/runtime/hooks/captureErrorHook.ts b/packages/nuxt/src/runtime/hooks/captureErrorHook.ts index 54747ad8dfea..c9a6d49cca31 100644 --- a/packages/nuxt/src/runtime/hooks/captureErrorHook.ts +++ b/packages/nuxt/src/runtime/hooks/captureErrorHook.ts @@ -7,17 +7,23 @@ import { extractErrorContext, getEventRequestInfo } from '../utils'; * Returns the status code of an error thrown by h3, or `undefined` for any other error. * * Mirrors each h3 major's own `isError` instead of importing h3: an `h3` import puts this module - * behind Nuxt 5's transitional Nitro v2 compatibility layer (NUXT_B9003), and `nitro/h3` does not - * resolve on Nuxt 3/4. h3 v2 (Nitro v3) recognizes its errors by name, h3 v1 (Nitro v2) by a static - * flag on the class. Both expose `statusCode`. + * behind Nuxt 5's transitional Nitro v2 compatibility layer, and `nitro/h3` does not resolve on Nuxt 3/4. + * h3 v2 (Nitro v3) recognizes its errors by name and stores the code on + * `status`, h3 v1 (Nitro v2) by a static flag on the class and on `statusCode`. */ function getH3ErrorStatusCode(error: Error): number | undefined { const isH3Error = error.name === 'HTTPError' || (error.constructor as { __h3_error__?: boolean } | undefined)?.__h3_error__ === true; - return isH3Error ? (error as { statusCode?: number }).statusCode : undefined; + if (!isH3Error) { + return undefined; + } + + const { status, statusCode } = error as { status?: number; statusCode?: number }; + return status ?? statusCode; } + /** * Hook that can be added in a Nitro plugin. It captures an error and sends it to Sentry. */ From 90f5e99350309b38dd86a2b0d79d9fb4716e47d0 Mon Sep 17 00:00:00 2001 From: s1gr1d <32902192+s1gr1d@users.noreply.github.com> Date: Thu, 17 Sep 2026 15:01:56 +0200 Subject: [PATCH 4/4] add third-party-error test --- .../nuxt-4/app/pages/fetch-server-routes.vue | 5 ++++ .../server/api/third-party-http-error.ts | 16 +++++++++++++ .../nuxt-4/tests/errors.server.test.ts | 22 ++++++++++++++++++ .../nuxt-5/app/pages/fetch-server-routes.vue | 5 ++++ .../server/api/third-party-http-error.ts | 16 +++++++++++++ .../nuxt-5/tests/errors.server.test.ts | 23 +++++++++++++++++++ .../src/runtime/hooks/captureErrorHook.ts | 1 - .../runtime/hooks/captureErrorHook.test.ts | 12 ++++++++++ 8 files changed, 99 insertions(+), 1 deletion(-) create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-4/server/api/third-party-http-error.ts create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-5/server/api/third-party-http-error.ts diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/app/pages/fetch-server-routes.vue b/dev-packages/e2e-tests/test-applications/nuxt-4/app/pages/fetch-server-routes.vue index 089d77a2eee9..3547773a1af9 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/app/pages/fetch-server-routes.vue +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/app/pages/fetch-server-routes.vue @@ -2,6 +2,7 @@
+
@@ -15,4 +16,8 @@ const fetchError = async () => { const fetchNitroFetch = async () => { await useFetch('/api/nitro-fetch'); }; + +const fetchThirdPartyHttpError = async () => { + await useFetch('/api/third-party-http-error'); +}; diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/server/api/third-party-http-error.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/server/api/third-party-http-error.ts new file mode 100644 index 000000000000..1f2d3c2ee90e --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/server/api/third-party-http-error.ts @@ -0,0 +1,16 @@ +import { defineEventHandler } from '#imports'; + +// Mimics ky's and got's `HTTPError`: it shares its `name` with h3's error class, but keeps the +// status on `response` instead of on the error itself. +class ThirdPartyHTTPError extends Error { + public readonly response = { status: 404 }; + + public constructor(message: string) { + super(message); + this.name = 'HTTPError'; + } +} + +export default defineEventHandler(() => { + throw new ThirdPartyHTTPError('Nuxt 4 third-party HTTPError'); +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/errors.server.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/errors.server.test.ts index 8f7bf451a1f6..ea9c78b2d60e 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/errors.server.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/errors.server.test.ts @@ -69,4 +69,26 @@ test.describe('server-side errors', async () => { exception_id: 0, }); }); + + // ky and got name their errors `HTTPError` too. h3 wraps a thrown one in its own error before the + // hook sees it, so this checks it still gets reported. The hook's handling of an unwrapped lookalike + // is covered by the unit tests. + test('captures a thrown third-party `HTTPError`', async ({ page }) => { + const errorPromise = waitForError('nuxt-4', async errorEvent => { + return !!errorEvent?.exception?.values?.some(value => value.value === 'Nuxt 4 third-party HTTPError'); + }); + + await page.goto(`/fetch-server-routes`); + await page.getByText('Fetch Third-Party HTTPError', { exact: true }).click(); + + const error = await errorPromise; + + expect(error.transaction).toEqual('GET /api/third-party-http-error'); + expect(error.exception.values).toContainEqual( + expect.objectContaining({ + value: 'Nuxt 4 third-party HTTPError', + mechanism: expect.objectContaining({ handled: false, type: 'auto.function.nuxt.nitro' }), + }), + ); + }); }); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/app/pages/fetch-server-routes.vue b/dev-packages/e2e-tests/test-applications/nuxt-5/app/pages/fetch-server-routes.vue index 089d77a2eee9..3547773a1af9 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-5/app/pages/fetch-server-routes.vue +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/app/pages/fetch-server-routes.vue @@ -2,6 +2,7 @@
+
@@ -15,4 +16,8 @@ const fetchError = async () => { const fetchNitroFetch = async () => { await useFetch('/api/nitro-fetch'); }; + +const fetchThirdPartyHttpError = async () => { + await useFetch('/api/third-party-http-error'); +}; diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/server/api/third-party-http-error.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/server/api/third-party-http-error.ts new file mode 100644 index 000000000000..b6b6d2aff38e --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/server/api/third-party-http-error.ts @@ -0,0 +1,16 @@ +import { defineHandler } from 'nitro'; + +// Mimics ky's and got's `HTTPError`: it shares its `name` with h3's error class, but keeps the +// status on `response` instead of on the error itself. +class ThirdPartyHTTPError extends Error { + public readonly response = { status: 404 }; + + public constructor(message: string) { + super(message); + this.name = 'HTTPError'; + } +} + +export default defineHandler(() => { + throw new ThirdPartyHTTPError('Nuxt 5 third-party HTTPError'); +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/tests/errors.server.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/tests/errors.server.test.ts index fe17f262b0ae..ebe8b5097d5d 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-5/tests/errors.server.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/tests/errors.server.test.ts @@ -69,4 +69,27 @@ test.describe('server-side errors', async () => { exception_id: 0, }); }); + + // ky and got name their errors `HTTPError` too. h3 wraps a thrown one in its own error before the + // hook sees it, so this checks it still gets reported. The hook's handling of an unwrapped lookalike + // is covered by the unit tests. + test('captures a thrown third-party `HTTPError`', async ({ page }) => { + const errorPromise = waitForError('nuxt-5', async errorEvent => { + return !!errorEvent?.exception?.values?.some(value => value.value === 'Nuxt 5 third-party HTTPError'); + }); + + await page.goto(`/fetch-server-routes`); + await page.getByText('Fetch Third-Party HTTPError', { exact: true }).click(); + + const error = await errorPromise; + + expect(error.transaction).toEqual('GET /api/third-party-http-error'); + expect(error.exception.values).toContainEqual( + expect.objectContaining({ + type: 'HTTPError', + value: 'Nuxt 5 third-party HTTPError', + mechanism: expect.objectContaining({ handled: false, type: 'auto.function.nuxt.nitro' }), + }), + ); + }); }); diff --git a/packages/nuxt/src/runtime/hooks/captureErrorHook.ts b/packages/nuxt/src/runtime/hooks/captureErrorHook.ts index c9a6d49cca31..6d8adaf2ea77 100644 --- a/packages/nuxt/src/runtime/hooks/captureErrorHook.ts +++ b/packages/nuxt/src/runtime/hooks/captureErrorHook.ts @@ -23,7 +23,6 @@ function getH3ErrorStatusCode(error: Error): number | undefined { return status ?? statusCode; } - /** * Hook that can be added in a Nitro plugin. It captures an error and sends it to Sentry. */ diff --git a/packages/nuxt/test/runtime/hooks/captureErrorHook.test.ts b/packages/nuxt/test/runtime/hooks/captureErrorHook.test.ts index 88a9fa4861fd..bcf7f01690be 100644 --- a/packages/nuxt/test/runtime/hooks/captureErrorHook.test.ts +++ b/packages/nuxt/test/runtime/hooks/captureErrorHook.test.ts @@ -187,4 +187,16 @@ describe('sentryCaptureErrorHook - errors that only look like h3 errors', () => expect(SentryCore.captureException).toHaveBeenCalledWith(error, expect.anything()); }); + + it('still reports a third-party `HTTPError` whose status lives on `response`', async () => { + // The packages "ky" and "got" name their errors `HTTPError` but keep the status on `response`, not on the error + const error = Object.assign(new Error('Request failed with status code 404'), { + name: 'HTTPError', + response: { status: 404 }, + }); + + await sentryCaptureErrorHook(error, {} as CapturedErrorContext); + + expect(SentryCore.captureException).toHaveBeenCalledWith(error, expect.anything()); + }); });