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] 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(); });