Skip to content

Commit fb8e4d5

Browse files
committed
feat: handle http errors
1 parent fe42720 commit fb8e4d5

5 files changed

Lines changed: 263 additions & 2 deletions

File tree

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import { captureException, flushIfServerless, getClient, getCurrentScope } from '@sentry/core';
2+
import { HTTPError } from 'h3';
3+
import type { CapturedErrorContext } from 'nitro/types';
4+
5+
/**
6+
* Extracts the relevant context information from the error context (HTTPEvent in Nitro Error)
7+
* and creates a structured context object.
8+
*/
9+
function extractErrorContext(errorContext: CapturedErrorContext | undefined): Record<string, unknown> {
10+
const ctx: Record<string, unknown> = {};
11+
12+
if (!errorContext) {
13+
return ctx;
14+
}
15+
16+
if (errorContext.event) {
17+
ctx.method = errorContext.event.req.method;
18+
19+
try {
20+
const url = new URL(errorContext.event.req.url);
21+
ctx.path = url.pathname;
22+
} catch {
23+
// If URL parsing fails, leave path undefined
24+
}
25+
}
26+
27+
if (Array.isArray(errorContext.tags)) {
28+
ctx.tags = errorContext.tags;
29+
}
30+
31+
return ctx;
32+
}
33+
34+
/**
35+
* Hook that can be added in a Nitro plugin. It captures an error and sends it to Sentry.
36+
*/
37+
export async function captureErrorHook(error: Error, errorContext: CapturedErrorContext): Promise<void> {
38+
const sentryClient = getClient();
39+
const sentryClientOptions = sentryClient?.getOptions();
40+
41+
if (
42+
sentryClientOptions &&
43+
'enableNitroErrorHandler' in sentryClientOptions &&
44+
sentryClientOptions.enableNitroErrorHandler === false
45+
) {
46+
return;
47+
}
48+
49+
// Do not report HTTPErrors with 3xx or 4xx status codes
50+
if (HTTPError.isError(error) && error.status >= 300 && error.status < 500) {
51+
return;
52+
}
53+
54+
const method = errorContext.event?.req.method ?? '';
55+
let path: string | null = null;
56+
57+
try {
58+
if (errorContext.event?.req.url) {
59+
path = new URL(errorContext.event.req.url).pathname;
60+
}
61+
} catch {
62+
// If URL parsing fails, leave path as null
63+
}
64+
65+
if (path) {
66+
getCurrentScope().setTransactionName(`${method} ${path}`);
67+
}
68+
69+
const structuredContext = extractErrorContext(errorContext);
70+
71+
captureException(error, {
72+
captureContext: { contexts: { nitro: structuredContext } },
73+
mechanism: { handled: false, type: 'auto.function.nitro' },
74+
});
75+
76+
await flushIfServerless();
77+
}

packages/nitro/src/runtime/plugins/server.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import type { TracingRequestEvent as H3TracingRequestEvent } from 'h3/tracing';
1515
import { definePlugin } from 'nitro';
1616
import { tracingChannel } from 'otel-tracing-channel';
1717
import type { RequestEvent as SrvxRequestEvent } from 'srvx/tracing';
18+
import { captureErrorHook } from '../hooks/captureErrorHook';
1819

1920
/**
2021
* Global object with the trace channels
@@ -28,11 +29,15 @@ const globalWithTraceChannels = GLOBAL_OBJ as typeof GLOBAL_OBJ & {
2829
*/
2930
const NOOP = (): void => {};
3031

31-
export default definePlugin(() => {
32+
export default definePlugin(nitroApp => {
3233
if (globalWithTraceChannels.__SENTRY_NITRO_HTTP_CHANNELS_INSTRUMENTED__) {
3334
return;
3435
}
3536

37+
// FIXME: Nitro hooks are not typed it seems
38+
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
39+
nitroApp.hooks.hook('error', captureErrorHook);
40+
3641
setupH3TracingChannels();
3742
setupSrvxTracingChannels();
3843
globalWithTraceChannels.__SENTRY_NITRO_HTTP_CHANNELS_INSTRUMENTED__ = true;
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
import * as SentryCore from '@sentry/core';
2+
import { HTTPError } from 'h3';
3+
import { beforeEach, describe, expect, it, vi } from 'vitest';
4+
import { captureErrorHook } from '../../../src/runtime/hooks/captureErrorHook';
5+
6+
vi.mock('@sentry/core', async importOriginal => {
7+
const mod = await importOriginal();
8+
return {
9+
...(mod as any),
10+
captureException: vi.fn(),
11+
flushIfServerless: vi.fn(),
12+
getClient: vi.fn(),
13+
getCurrentScope: vi.fn(() => ({
14+
setTransactionName: vi.fn(),
15+
})),
16+
};
17+
});
18+
19+
describe('captureErrorHook', () => {
20+
const mockErrorContext = {
21+
event: {
22+
req: { method: 'GET', url: 'http://localhost/test-path' },
23+
},
24+
};
25+
26+
beforeEach(() => {
27+
vi.clearAllMocks();
28+
(SentryCore.getClient as any).mockReturnValue({
29+
getOptions: () => ({}),
30+
});
31+
(SentryCore.flushIfServerless as any).mockResolvedValue(undefined);
32+
});
33+
34+
it('should capture regular errors', async () => {
35+
const error = new Error('Test error');
36+
37+
await captureErrorHook(error, mockErrorContext);
38+
39+
expect(SentryCore.captureException).toHaveBeenCalledWith(
40+
error,
41+
expect.objectContaining({
42+
mechanism: { handled: false, type: 'auto.function.nitro' },
43+
}),
44+
);
45+
});
46+
47+
it('should include structured context with method and path', async () => {
48+
const error = new Error('Test error');
49+
50+
await captureErrorHook(error, mockErrorContext);
51+
52+
expect(SentryCore.captureException).toHaveBeenCalledWith(
53+
error,
54+
expect.objectContaining({
55+
captureContext: {
56+
contexts: {
57+
nitro: { method: 'GET', path: '/test-path' },
58+
},
59+
},
60+
}),
61+
);
62+
});
63+
64+
it('should set transaction name from method and path', async () => {
65+
const mockSetTransactionName = vi.fn();
66+
(SentryCore.getCurrentScope as any).mockReturnValue({
67+
setTransactionName: mockSetTransactionName,
68+
});
69+
70+
const error = new Error('Test error');
71+
72+
await captureErrorHook(error, mockErrorContext);
73+
74+
expect(mockSetTransactionName).toHaveBeenCalledWith('GET /test-path');
75+
});
76+
77+
it('should skip HTTPError with 4xx status codes', async () => {
78+
const error = new HTTPError({ status: 404, message: 'Not found' });
79+
80+
await captureErrorHook(error, mockErrorContext);
81+
82+
expect(SentryCore.captureException).not.toHaveBeenCalled();
83+
});
84+
85+
it('should skip HTTPError with 3xx status codes', async () => {
86+
const error = new HTTPError({ status: 302, message: 'Redirect' });
87+
88+
await captureErrorHook(error, mockErrorContext);
89+
90+
expect(SentryCore.captureException).not.toHaveBeenCalled();
91+
});
92+
93+
it('should capture HTTPError with 5xx status codes', async () => {
94+
const error = new HTTPError({ status: 500, message: 'Server error' });
95+
96+
await captureErrorHook(error, mockErrorContext);
97+
98+
expect(SentryCore.captureException).toHaveBeenCalledWith(
99+
error,
100+
expect.objectContaining({
101+
mechanism: { handled: false, type: 'auto.function.nitro' },
102+
}),
103+
);
104+
});
105+
106+
it('should skip when enableNitroErrorHandler is false', async () => {
107+
(SentryCore.getClient as any).mockReturnValue({
108+
getOptions: () => ({ enableNitroErrorHandler: false }),
109+
});
110+
111+
const error = new Error('Test error');
112+
113+
await captureErrorHook(error, mockErrorContext);
114+
115+
expect(SentryCore.captureException).not.toHaveBeenCalled();
116+
});
117+
118+
it('should call flushIfServerless after capturing', async () => {
119+
const error = new Error('Test error');
120+
121+
await captureErrorHook(error, mockErrorContext);
122+
123+
expect(SentryCore.flushIfServerless).toHaveBeenCalled();
124+
});
125+
126+
it('should handle missing event in error context', async () => {
127+
const error = new Error('Test error');
128+
const contextWithoutEvent = {
129+
event: undefined,
130+
};
131+
132+
await captureErrorHook(error, contextWithoutEvent);
133+
134+
expect(SentryCore.captureException).toHaveBeenCalledWith(
135+
error,
136+
expect.objectContaining({
137+
captureContext: {
138+
contexts: {
139+
nitro: {},
140+
},
141+
},
142+
}),
143+
);
144+
});
145+
146+
it('should include tags in structured context when available', async () => {
147+
const error = new Error('Test error');
148+
const contextWithTags = {
149+
event: {
150+
req: { method: 'POST', url: 'http://localhost/api/test' },
151+
} as any,
152+
tags: ['tag1', 'tag2'],
153+
};
154+
155+
await captureErrorHook(error, contextWithTags);
156+
157+
expect(SentryCore.captureException).toHaveBeenCalledWith(
158+
error,
159+
expect.objectContaining({
160+
captureContext: {
161+
contexts: {
162+
nitro: { method: 'POST', path: '/api/test', tags: ['tag1', 'tag2'] },
163+
},
164+
},
165+
}),
166+
);
167+
});
168+
});

packages/nitro/tsconfig.test.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"extends": "./tsconfig.json",
33

4-
"include": ["test/**/*"],
4+
"include": ["test/**/*", "vite.config.ts"],
55

66
"compilerOptions": {
77
// should include all types from `./tsconfig.json` plus types for all test frameworks used

packages/nitro/vite.config.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import baseConfig from '../../vite/vite.config';
2+
3+
export default {
4+
...baseConfig,
5+
test: {
6+
typecheck: {
7+
enabled: true,
8+
tsconfig: './tsconfig.test.json',
9+
},
10+
},
11+
};

0 commit comments

Comments
 (0)