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 50d5a61a2828..6d8adaf2ea77 100644 --- a/packages/nuxt/src/runtime/hooks/captureErrorHook.ts +++ b/packages/nuxt/src/runtime/hooks/captureErrorHook.ts @@ -1,9 +1,27 @@ 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 { 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, 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; + + 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. @@ -20,10 +38,12 @@ export async function sentryCaptureErrorHook(error: Error, errorContext: Capture return; } + const statusCode = getH3ErrorStatusCode(error); + // Do not handle 404 and 422 - if (error instanceof H3Error) { + if (statusCode !== undefined) { // Do not report if status code is 3xx or 4xx - if (error.statusCode >= 300 && error.statusCode < 500) { + if (statusCode >= 300 && statusCode < 500) { return; } @@ -39,10 +59,7 @@ export async function sentryCaptureErrorHook(error: Error, errorContext: Capture } } - const { method, path } = { - method: errorContext.event?._method ? errorContext.event._method : '', - path: errorContext.event?._path ? errorContext.event._path : null, - }; + const { method = '', path } = getEventRequestInfo(errorContext.event); if (path) { getCurrentScope().setTransactionName(`${method} ${path}`); 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.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/test/runtime/hooks/captureErrorHook.test.ts b/packages/nuxt/test/runtime/hooks/captureErrorHook.test.ts index 8e166a5ff4cc..bcf7f01690be 100644 --- a/packages/nuxt/test/runtime/hooks/captureErrorHook.test.ts +++ b/packages/nuxt/test/runtime/hooks/captureErrorHook.test.ts @@ -1,19 +1,20 @@ 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'; +const setTransactionName = vi.fn(); + vi.mock('@sentry/core', async importOriginal => { const mod = await importOriginal(); return { ...(mod as any), captureException: vi.fn(), getClient: vi.fn(), - getCurrentScope: vi.fn(() => ({ - setTransactionName: vi.fn(), - })), + getCurrentScope: vi.fn(() => ({ setTransactionName })), }; }); @@ -25,17 +26,38 @@ 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' })), })); -describe('sentryCaptureErrorHook', () => { - const mockErrorContext: CapturedErrorContext = { - event: { - _method: 'GET', - _path: '/test-path', - } as any, - }; +// 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: 'h3 v1 (Nitro v2)', + httpError: (message: string, statusCode: number): Error => { + const error = new H3Error(message); + 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') }, + }, +]; + +// 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(h3Majors)('sentryCaptureErrorHook - $name', ({ httpError, event }) => { + const mockErrorContext = { event } as unknown as CapturedErrorContext; beforeEach(() => { vi.clearAllMocks(); @@ -58,27 +80,26 @@ describe('sentryCaptureErrorHook', () => { ); }); - it('should skip H3Error with 4xx status codes', async () => { - const error = new H3Error('Not found'); - error.statusCode = 404; + it('sets the transaction name from the request method and path', async () => { + await sentryCaptureErrorHook(new Error('Test error'), mockErrorContext); - await sentryCaptureErrorHook(error, mockErrorContext); + expect(setTransactionName).toHaveBeenCalledWith('GET /test-path'); + }); + + 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 H3Error with 3xx status codes', async () => { - const error = new H3Error('Redirect'); - error.statusCode = 302; - - await sentryCaptureErrorHook(error, mockErrorContext); + it('should skip HTTP errors with 3xx status codes', async () => { + await sentryCaptureErrorHook(httpError('Redirect', 302), 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); @@ -90,7 +111,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 +119,44 @@ describe('sentryCaptureErrorHook', () => { enumerable: false, }); - const h3Error = new H3Error('Wrapped error', { cause: originalError }); - h3Error.statusCode = 500; - - await sentryCaptureErrorHook(h3Error, mockErrorContext); + await sentryCaptureErrorHook(withCause(httpError('Wrapped error', 500), originalError), mockErrorContext); expect(SentryCore.captureException).not.toHaveBeenCalled(); }); - it('should capture H3Error 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; + it('should capture HTTP errors when cause does not have __sentry_captured__ flag', async () => { + const error = withCause(httpError('Wrapped error', 500), new Error('Original error')); - await sentryCaptureErrorHook(h3Error, mockErrorContext); + await sentryCaptureErrorHook(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 sentryCaptureErrorHook(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 sentryCaptureErrorHook(error, mockErrorContext); expect(SentryCore.captureException).toHaveBeenCalledWith( - h3Error, + error, expect.objectContaining({ mechanism: { handled: false, type: 'auto.function.nuxt.nitro' }, }), @@ -154,10 +168,35 @@ describe('sentryCaptureErrorHook', () => { getOptions: () => ({ enableNitroErrorHandler: false }), }); - const error = new Error('Test error'); - - await sentryCaptureErrorHook(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()); + }); + + 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()); + }); +}); 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'],