Skip to content

Commit c94fd11

Browse files
authored
fix(nuxt): Stop importing h3 in the Nitro error hook (#24283)
Nuxt 5 switched on its Nitro v2 compatibility layer for any module whose server code imports `h3`: nuxt/nuxt#36318 The hook now recognizes h3 errors the way h3 itself does, without importing it: h3 v2 by the error's `name`, h3 v1 by the static `__h3_error__` flag on the class. Both expose `statusCode`. Our CI tests currently show this warning: ``` [WebServer] [warn] [NUXT_B9003] Nitro v2 compatibility was applied to server code from 1 module, because of what it imports: [WebServer] - `@sentry/nuxt/module` (imports `h3`) [WebServer] ╰▶ fix: This layer is transitional and will be removed in Nuxt 6. Update the module to import from `nuxt/server`, registering the portable file alongside the one it ships today, or report it to the module author if the module is not your own. ```
1 parent 08fef6d commit c94fd11

11 files changed

Lines changed: 240 additions & 63 deletions

File tree

‎dev-packages/e2e-tests/test-applications/nuxt-4/app/pages/fetch-server-routes.vue‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
<div>
33
<button @click="fetchError">Fetch Server API Error</button>
44
<button @click="fetchNitroFetch">Fetch Nitro $fetch</button>
5+
<button @click="fetchThirdPartyHttpError">Fetch Third-Party HTTPError</button>
56
</div>
67
</template>
78

@@ -15,4 +16,8 @@ const fetchError = async () => {
1516
const fetchNitroFetch = async () => {
1617
await useFetch('/api/nitro-fetch');
1718
};
19+
20+
const fetchThirdPartyHttpError = async () => {
21+
await useFetch('/api/third-party-http-error');
22+
};
1823
</script>
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { defineEventHandler } from '#imports';
2+
3+
// Mimics ky's and got's `HTTPError`: it shares its `name` with h3's error class, but keeps the
4+
// status on `response` instead of on the error itself.
5+
class ThirdPartyHTTPError extends Error {
6+
public readonly response = { status: 404 };
7+
8+
public constructor(message: string) {
9+
super(message);
10+
this.name = 'HTTPError';
11+
}
12+
}
13+
14+
export default defineEventHandler(() => {
15+
throw new ThirdPartyHTTPError('Nuxt 4 third-party HTTPError');
16+
});

‎dev-packages/e2e-tests/test-applications/nuxt-4/tests/errors.server.test.ts‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,4 +69,26 @@ test.describe('server-side errors', async () => {
6969
exception_id: 0,
7070
});
7171
});
72+
73+
// ky and got name their errors `HTTPError` too. h3 wraps a thrown one in its own error before the
74+
// hook sees it, so this checks it still gets reported. The hook's handling of an unwrapped lookalike
75+
// is covered by the unit tests.
76+
test('captures a thrown third-party `HTTPError`', async ({ page }) => {
77+
const errorPromise = waitForError('nuxt-4', async errorEvent => {
78+
return !!errorEvent?.exception?.values?.some(value => value.value === 'Nuxt 4 third-party HTTPError');
79+
});
80+
81+
await page.goto(`/fetch-server-routes`);
82+
await page.getByText('Fetch Third-Party HTTPError', { exact: true }).click();
83+
84+
const error = await errorPromise;
85+
86+
expect(error.transaction).toEqual('GET /api/third-party-http-error');
87+
expect(error.exception.values).toContainEqual(
88+
expect.objectContaining({
89+
value: 'Nuxt 4 third-party HTTPError',
90+
mechanism: expect.objectContaining({ handled: false, type: 'auto.function.nuxt.nitro' }),
91+
}),
92+
);
93+
});
7294
});

‎dev-packages/e2e-tests/test-applications/nuxt-5/app/pages/fetch-server-routes.vue‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
<div>
33
<button @click="fetchError">Fetch Server API Error</button>
44
<button @click="fetchNitroFetch">Fetch Nitro $fetch</button>
5+
<button @click="fetchThirdPartyHttpError">Fetch Third-Party HTTPError</button>
56
</div>
67
</template>
78

@@ -15,4 +16,8 @@ const fetchError = async () => {
1516
const fetchNitroFetch = async () => {
1617
await useFetch('/api/nitro-fetch');
1718
};
19+
20+
const fetchThirdPartyHttpError = async () => {
21+
await useFetch('/api/third-party-http-error');
22+
};
1823
</script>
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { defineHandler } from 'nitro';
2+
3+
// Mimics ky's and got's `HTTPError`: it shares its `name` with h3's error class, but keeps the
4+
// status on `response` instead of on the error itself.
5+
class ThirdPartyHTTPError extends Error {
6+
public readonly response = { status: 404 };
7+
8+
public constructor(message: string) {
9+
super(message);
10+
this.name = 'HTTPError';
11+
}
12+
}
13+
14+
export default defineHandler(() => {
15+
throw new ThirdPartyHTTPError('Nuxt 5 third-party HTTPError');
16+
});

‎dev-packages/e2e-tests/test-applications/nuxt-5/tests/errors.server.test.ts‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,4 +69,27 @@ test.describe('server-side errors', async () => {
6969
exception_id: 0,
7070
});
7171
});
72+
73+
// ky and got name their errors `HTTPError` too. h3 wraps a thrown one in its own error before the
74+
// hook sees it, so this checks it still gets reported. The hook's handling of an unwrapped lookalike
75+
// is covered by the unit tests.
76+
test('captures a thrown third-party `HTTPError`', async ({ page }) => {
77+
const errorPromise = waitForError('nuxt-5', async errorEvent => {
78+
return !!errorEvent?.exception?.values?.some(value => value.value === 'Nuxt 5 third-party HTTPError');
79+
});
80+
81+
await page.goto(`/fetch-server-routes`);
82+
await page.getByText('Fetch Third-Party HTTPError', { exact: true }).click();
83+
84+
const error = await errorPromise;
85+
86+
expect(error.transaction).toEqual('GET /api/third-party-http-error');
87+
expect(error.exception.values).toContainEqual(
88+
expect.objectContaining({
89+
type: 'HTTPError',
90+
value: 'Nuxt 5 third-party HTTPError',
91+
mechanism: expect.objectContaining({ handled: false, type: 'auto.function.nuxt.nitro' }),
92+
}),
93+
);
94+
});
7295
});

‎packages/nuxt/src/runtime/hooks/captureErrorHook.ts‎

Lines changed: 26 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,27 @@
11
import { captureException, getClient, getCurrentScope } from '@sentry/core';
22
import { flushIfServerless } from '@sentry/core/server';
3-
// eslint-disable-next-line import/no-extraneous-dependencies
4-
import { H3Error } from 'h3';
53
import type { CapturedErrorContext } from 'nitropack/types';
6-
import { extractErrorContext } from '../utils';
4+
import { extractErrorContext, getEventRequestInfo } from '../utils';
5+
6+
/**
7+
* Returns the status code of an error thrown by h3, or `undefined` for any other error.
8+
*
9+
* Mirrors each h3 major's own `isError` instead of importing h3: an `h3` import puts this module
10+
* behind Nuxt 5's transitional Nitro v2 compatibility layer, and `nitro/h3` does not resolve on Nuxt 3/4.
11+
* h3 v2 (Nitro v3) recognizes its errors by name and stores the code on
12+
* `status`, h3 v1 (Nitro v2) by a static flag on the class and on `statusCode`.
13+
*/
14+
function getH3ErrorStatusCode(error: Error): number | undefined {
15+
const isH3Error =
16+
error.name === 'HTTPError' || (error.constructor as { __h3_error__?: boolean } | undefined)?.__h3_error__ === true;
17+
18+
if (!isH3Error) {
19+
return undefined;
20+
}
21+
22+
const { status, statusCode } = error as { status?: number; statusCode?: number };
23+
return status ?? statusCode;
24+
}
725

826
/**
927
* 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
2038
return;
2139
}
2240

41+
const statusCode = getH3ErrorStatusCode(error);
42+
2343
// Do not handle 404 and 422
24-
if (error instanceof H3Error) {
44+
if (statusCode !== undefined) {
2545
// Do not report if status code is 3xx or 4xx
26-
if (error.statusCode >= 300 && error.statusCode < 500) {
46+
if (statusCode >= 300 && statusCode < 500) {
2747
return;
2848
}
2949

@@ -39,10 +59,7 @@ export async function sentryCaptureErrorHook(error: Error, errorContext: Capture
3959
}
4060
}
4161

42-
const { method, path } = {
43-
method: errorContext.event?._method ? errorContext.event._method : '',
44-
path: errorContext.event?._path ? errorContext.event._path : null,
45-
};
62+
const { method = '', path } = getEventRequestInfo(errorContext.event);
4663

4764
if (path) {
4865
getCurrentScope().setTransactionName(`${method} ${path}`);

‎packages/nuxt/src/runtime/plugins/update-route-name.server.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { NitroAppPlugin } from 'nitro/types';
22
import { updateRouteBeforeResponse } from '../hooks/updateRouteBeforeResponse';
3-
import type { H3Event } from 'h3';
3+
import type { H3Event } from 'nitro/h3';
44

55
export default (nitroApp => {
66
// @ts-expect-error Hook in Nuxt 5 (Nitro 3) is called 'response' https://nitro.build/docs/plugins#available-hooks

‎packages/nuxt/src/runtime/utils.ts‎

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,30 @@
11
import type { ClientOptions, Context, SerializedTraceData } from '@sentry/core';
2-
import { captureException, debug, getClient, getTraceMetaTags } from '@sentry/core';
2+
import { captureException, debug, getClient, getTraceMetaTags, isObjectLike } from '@sentry/core';
33
import type { CapturedErrorContext } from 'nitropack/types';
44
import type { NuxtRenderHTMLContext } from 'nuxt/app';
55
import type { ComponentPublicInstance } from 'vue';
66

7+
/**
8+
* Reads the request method and path off the event Nitro passes to its `error` hook.
9+
*
10+
* h3 v1 (Nitro v2) exposes `method` and `path` getters. h3 v2 (Nitro v3) has neither: the method lives
11+
* on the web `Request` in `req`, and the path on the parsed `url`.
12+
*/
13+
export function getEventRequestInfo(event: unknown): { method?: string; path?: string } {
14+
if (!isObjectLike(event)) {
15+
return {};
16+
}
17+
18+
const { method, path, req, url } = event as {
19+
method?: string;
20+
path?: string;
21+
req?: { method?: string };
22+
url?: { pathname?: string };
23+
};
24+
25+
return { method: method ?? req?.method, path: path ?? url?.pathname };
26+
}
27+
728
/**
829
* Extracts the relevant context information from the error context (H3Event in Nitro Error)
930
* and created a structured context object.
@@ -16,8 +37,9 @@ export function extractErrorContext(errorContext: CapturedErrorContext | undefin
1637
}
1738

1839
if (errorContext.event) {
19-
ctx.method = errorContext.event._method;
20-
ctx.path = errorContext.event._path;
40+
const { method, path } = getEventRequestInfo(errorContext.event);
41+
ctx.method = method;
42+
ctx.path = path;
2143
}
2244

2345
if (Array.isArray(errorContext.tags)) {

0 commit comments

Comments
 (0)