Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
<div>
<button @click="fetchError">Fetch Server API Error</button>
<button @click="fetchNitroFetch">Fetch Nitro $fetch</button>
<button @click="fetchThirdPartyHttpError">Fetch Third-Party HTTPError</button>
</div>
</template>

Expand All @@ -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');
};
</script>
Original file line number Diff line number Diff line change
@@ -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');
});
Original file line number Diff line number Diff line change
Expand Up @@ -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' }),
}),
);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
<div>
<button @click="fetchError">Fetch Server API Error</button>
<button @click="fetchNitroFetch">Fetch Nitro $fetch</button>
<button @click="fetchThirdPartyHttpError">Fetch Third-Party HTTPError</button>
</div>
</template>

Expand All @@ -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');
};
</script>
Original file line number Diff line number Diff line change
@@ -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');
});
Original file line number Diff line number Diff line change
Expand Up @@ -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' }),
}),
);
});
});
35 changes: 26 additions & 9 deletions packages/nuxt/src/runtime/hooks/captureErrorHook.ts
Original file line number Diff line number Diff line change
@@ -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;
Comment thread
sentry[bot] marked this conversation as resolved.

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.
Expand All @@ -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;
}

Expand All @@ -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}`);
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down
28 changes: 25 additions & 3 deletions packages/nuxt/src/runtime/utils.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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)) {
Expand Down
Loading
Loading