Skip to content
Open
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
2 changes: 2 additions & 0 deletions packages/nuxt/src/module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,9 +113,11 @@ export default defineNuxtModule<ModuleOptions>({
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'));
Expand Down
12 changes: 12 additions & 0 deletions packages/nuxt/src/runtime/hooks/captureErrorHook-legacy.ts
Original file line number Diff line number Diff line change
@@ -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,
);
65 changes: 9 additions & 56 deletions packages/nuxt/src/runtime/hooks/captureErrorHook.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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,
);
Original file line number Diff line number Diff line change
@@ -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;
10 changes: 10 additions & 0 deletions packages/nuxt/src/runtime/plugins/capture-error.server.ts
Original file line number Diff line number Diff line change
@@ -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;
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
3 changes: 0 additions & 3 deletions packages/nuxt/src/runtime/plugins/sentry.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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() || {};
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
69 changes: 69 additions & 0 deletions packages/nuxt/src/runtime/utils/captureError.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
return async function sentryCaptureErrorHook(error, errorContext): Promise<void> {
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}`);
}
Comment on lines +51 to +58

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: The createCaptureErrorHook utility accesses event._method and event._path, which are internal to h3 v1. These fields do not exist in h3 v2 (used by Nitro v3/Nuxt 5+).
Severity: MEDIUM

Suggested Fix

Update the shared utility to handle both h3 v1 and h3 v2 event shapes. Check for the standard event.path before falling back to event._path, and use event.req.method or event.method to get the request method, ensuring compatibility with both Nitro v2 and v3.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent. Verify if this is a real issue. If it is, propose a fix; if not, explain why it's
not valid.

Location: packages/nuxt/src/runtime/utils/captureError.ts#L51-L58

Potential issue: The shared utility `createCaptureErrorHook` attempts to set the Sentry
transaction name by accessing `errorContext.event._method` and
`errorContext.event._path`. These properties are internal to the `H3Event` from `h3 v1`
(used in Nuxt 3/4). However, in Nuxt 5+ which uses Nitro v3 and `h3 v2`, the event
object is an `HTTPEvent` and lacks these underscore-prefixed fields. Consequently, for
Nuxt 5+ applications, the method and path will be `undefined`, the
`setTransactionName()` function will not be called, and transaction names for captured
server errors will be missing, degrading observability.

Did we get this right? 👍 / 👎 to inform future reviews.


const structuredContext = extractErrorContext(errorContext);

captureException(error, {
captureContext: { contexts: { nuxt: structuredContext } },
mechanism: { handled: false, type: 'auto.function.nuxt.nitro' },
});

await flushIfServerless();
};
}
88 changes: 54 additions & 34 deletions packages/nuxt/test/runtime/hooks/captureErrorHook.test.ts
Original file line number Diff line number Diff line change
@@ -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();
Expand All @@ -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',
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -90,59 +114,55 @@ 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__', {
value: true,
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' },
}),
Expand All @@ -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();
});
Expand Down
Loading