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'],