Skip to content

Commit 34538c8

Browse files
authored
feat(cloudflare): Add honoIntegration with error-filtering function (#17743)
This adds a `honoIntegration` that exposes a `shouldHandleError` function that lets users define a custom handling of capturing errors. By default, we capture exceptions with `error.status >= 500 || error.status <= 299`. It's possible to modify this behavior like this: ```js integrations: [ honoIntegration({ shouldHandleError: (err) => true; // always capture exceptions in onError }) ] ``` **Up for discussion**: The function name is the same like in express/fastify to keep it consistent. But I was also thinking about other names like `shouldHandleInOnError` or `shouldCaptureOnError` 🤔 Because it's specifically about the `onError` function. Let me know what you think. closes #17717
1 parent e419420 commit 34538c8

File tree

9 files changed

+203
-9
lines changed

9 files changed

+203
-9
lines changed

CHANGELOG.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,21 @@
44

55
- "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott
66

7+
- **feat(cloudflare): Add honoIntegration with error-filtering function ([#17743](https://github.com/getsentry/sentry-javascript/pull/17743))**
8+
9+
This release adds a `honoIntegration` to `@sentry/cloudflare`, which exposes a `shouldHandleError` function that lets you define which errors in `onError` should be captured.
10+
By default, Sentry captures exceptions with `error.status >= 500 || error.status <= 299`.
11+
12+
The integration is added by default, and it's possible to modify this behavior like this:
13+
14+
```js
15+
integrations: [
16+
honoIntegration({
17+
shouldHandleError: (err) => true; // always capture exceptions in onError
18+
})
19+
]
20+
```
21+
722
Work in this release was contributed by @Karibash. Thank you for your contribution!
823

924
## 10.14.0

packages/cloudflare/src/handler.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
import { setAsyncLocalStorageAsyncContextStrategy } from './async';
1111
import type { CloudflareOptions } from './client';
1212
import { isInstrumented, markAsInstrumented } from './instrument';
13+
import { getHonoIntegration } from './integrations/hono';
1314
import { getFinalOptions } from './options';
1415
import { wrapRequestHandler } from './request';
1516
import { addCloudResourceContext } from './scope-utils';
@@ -48,7 +49,7 @@ export function withSentry<Env = unknown, QueueHandlerMessage = unknown, CfHostM
4849
markAsInstrumented(handler.fetch);
4950
}
5051

51-
/* hono does not reach the catch block of the fetch handler and captureException needs to be called in the hono errorHandler */
52+
/* Hono does not reach the catch block of the fetch handler and captureException needs to be called in the hono errorHandler */
5253
if (
5354
'onError' in handler &&
5455
'errorHandler' in handler &&
@@ -59,7 +60,7 @@ export function withSentry<Env = unknown, QueueHandlerMessage = unknown, CfHostM
5960
apply(target, thisArg, args) {
6061
const [err] = args;
6162

62-
captureException(err, { mechanism: { handled: false, type: 'auto.faas.cloudflare.error_handler' } });
63+
getHonoIntegration()?.handleHonoException(err);
6364

6465
return Reflect.apply(target, thisArg, args);
6566
},

packages/cloudflare/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,7 @@ export { getDefaultIntegrations } from './sdk';
111111

112112
export { fetchIntegration } from './integrations/fetch';
113113
export { vercelAIIntegration } from './integrations/tracing/vercelai';
114+
export { honoIntegration } from './integrations/hono';
114115

115116
export { instrumentD1WithSentry } from './d1';
116117

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import type { IntegrationFn } from '@sentry/core';
2+
import { captureException, debug, defineIntegration, getClient } from '@sentry/core';
3+
import { DEBUG_BUILD } from '../debug-build';
4+
5+
const INTEGRATION_NAME = 'Hono';
6+
7+
interface HonoError extends Error {
8+
status?: number;
9+
}
10+
11+
export interface Options {
12+
/**
13+
* Callback method deciding whether error should be captured and sent to Sentry
14+
* @param error Captured middleware error
15+
*/
16+
shouldHandleError?(this: void, error: HonoError): boolean;
17+
}
18+
19+
/** Only exported for internal use */
20+
export function getHonoIntegration(): ReturnType<typeof _honoIntegration> | undefined {
21+
return getClient()?.getIntegrationByName(INTEGRATION_NAME);
22+
}
23+
24+
function isHonoError(err: unknown): err is HonoError {
25+
if (err instanceof Error) {
26+
return true;
27+
}
28+
return typeof err === 'object' && err !== null && 'status' in (err as Record<string, unknown>);
29+
}
30+
31+
const _honoIntegration = ((options: Partial<Options> = {}) => {
32+
return {
33+
name: INTEGRATION_NAME,
34+
handleHonoException(err: HonoError): void {
35+
const shouldHandleError = options.shouldHandleError || defaultShouldHandleError;
36+
37+
if (!isHonoError(err)) {
38+
DEBUG_BUILD && debug.log("[Hono] Won't capture exception in `onError` because it's not a Hono error.", err);
39+
return;
40+
}
41+
42+
if (shouldHandleError(err)) {
43+
captureException(err, { mechanism: { handled: false, type: 'auto.faas.hono.error_handler' } });
44+
} else {
45+
DEBUG_BUILD && debug.log('[Hono] Not capturing exception because `shouldHandleError` returned `false`.', err);
46+
}
47+
},
48+
};
49+
}) satisfies IntegrationFn;
50+
51+
/**
52+
* Automatically captures exceptions caught with the `onError` handler in Hono.
53+
*
54+
* The integration is enabled by default.
55+
*
56+
* @example
57+
* integrations: [
58+
* honoIntegration({
59+
* shouldHandleError: (err) => true; // always capture exceptions in onError
60+
* })
61+
* ]
62+
*/
63+
export const honoIntegration = defineIntegration(_honoIntegration);
64+
65+
/**
66+
* Default function to determine if an error should be sent to Sentry
67+
*
68+
* 3xx and 4xx errors are not sent by default.
69+
*/
70+
function defaultShouldHandleError(error: HonoError): boolean {
71+
const statusCode = error?.status;
72+
// 3xx and 4xx errors are not sent by default.
73+
return statusCode ? statusCode >= 500 || statusCode <= 299 : true;
74+
}

packages/cloudflare/src/sdk.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import type { CloudflareClientOptions, CloudflareOptions } from './client';
1414
import { CloudflareClient } from './client';
1515
import { makeFlushLock } from './flush';
1616
import { fetchIntegration } from './integrations/fetch';
17+
import { honoIntegration } from './integrations/hono';
1718
import { setupOpenTelemetryTracer } from './opentelemetry/tracer';
1819
import { makeCloudflareTransport } from './transport';
1920
import { defaultStackParser } from './vendor/stacktrace';
@@ -31,6 +32,7 @@ export function getDefaultIntegrations(options: CloudflareOptions): Integration[
3132
functionToStringIntegration(),
3233
linkedErrorsIntegration(),
3334
fetchIntegration(),
35+
honoIntegration(),
3436
// TODO(v11): the `include` object should be defined directly in the integration based on `sendDefaultPii`
3537
requestDataIntegration(sendDefaultPii ? undefined : { include: { cookies: false } }),
3638
consoleIntegration(),

packages/cloudflare/test/handler.test.ts

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { beforeEach, describe, expect, onTestFinished, test, vi } from 'vitest';
1414
import { CloudflareClient } from '../src/client';
1515
import { withSentry } from '../src/handler';
1616
import { markAsInstrumented } from '../src/instrument';
17+
import * as HonoIntegration from '../src/integrations/hono';
1718

1819
// Custom type for hono-like apps (cloudflare handlers) that include errorHandler and onError
1920
type HonoLikeApp<Env = unknown, QueueHandlerMessage = unknown, CfHostMetadata = unknown> = ExportedHandler<
@@ -1081,10 +1082,12 @@ describe('withSentry', () => {
10811082
});
10821083

10831084
describe('hono errorHandler', () => {
1084-
test('captures errors handled by the errorHandler', async () => {
1085-
const captureExceptionSpy = vi.spyOn(SentryCore, 'captureException');
1085+
test('calls Hono Integration to handle error captured by the errorHandler', async () => {
10861086
const error = new Error('test hono error');
10871087

1088+
const handleHonoException = vi.fn();
1089+
vi.spyOn(HonoIntegration, 'getHonoIntegration').mockReturnValue({ handleHonoException } as any);
1090+
10881091
const honoApp = {
10891092
fetch(_request, _env, _context) {
10901093
return new Response('test');
@@ -1100,10 +1103,8 @@ describe('withSentry', () => {
11001103
// simulates hono's error handling
11011104
const errorHandlerResponse = honoApp.errorHandler?.(error);
11021105

1103-
expect(captureExceptionSpy).toHaveBeenCalledTimes(1);
1104-
expect(captureExceptionSpy).toHaveBeenLastCalledWith(error, {
1105-
mechanism: { handled: false, type: 'auto.faas.cloudflare.error_handler' },
1106-
});
1106+
expect(handleHonoException).toHaveBeenCalledTimes(1);
1107+
expect(handleHonoException).toHaveBeenLastCalledWith(error);
11071108
expect(errorHandlerResponse?.status).toBe(500);
11081109
});
11091110

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import * as sentryCore from '@sentry/core';
2+
import { type Client, createStackParser } from '@sentry/core';
3+
import { beforeEach, describe, expect, it, vi } from 'vitest';
4+
import { CloudflareClient } from '../../src/client';
5+
import { honoIntegration } from '../../src/integrations/hono';
6+
7+
class FakeClient extends CloudflareClient {
8+
public getIntegrationByName(name: string) {
9+
return name === 'Hono' ? (honoIntegration() as any) : undefined;
10+
}
11+
}
12+
13+
type MockHonoIntegrationType = { handleHonoException: (err: Error) => void };
14+
15+
describe('Hono integration', () => {
16+
let client: FakeClient;
17+
18+
beforeEach(() => {
19+
vi.clearAllMocks();
20+
client = new FakeClient({
21+
dsn: 'https://[email protected]/1337',
22+
integrations: [],
23+
transport: () => ({ send: () => Promise.resolve({}), flush: () => Promise.resolve(true) }),
24+
stackParser: createStackParser(),
25+
});
26+
27+
vi.spyOn(sentryCore, 'getClient').mockImplementation(() => client as Client);
28+
});
29+
30+
it('captures in errorHandler when onError exists', () => {
31+
const captureExceptionSpy = vi.spyOn(sentryCore, 'captureException');
32+
const integration = honoIntegration();
33+
integration.setupOnce?.();
34+
35+
const error = new Error('hono boom');
36+
// simulate withSentry wrapping of errorHandler calling back into integration
37+
(integration as unknown as MockHonoIntegrationType).handleHonoException(error);
38+
39+
expect(captureExceptionSpy).toHaveBeenCalledTimes(1);
40+
expect(captureExceptionSpy).toHaveBeenLastCalledWith(error, {
41+
mechanism: { handled: false, type: 'auto.faas.hono.error_handler' },
42+
});
43+
});
44+
45+
it('does not capture for 4xx status', () => {
46+
const captureExceptionSpy = vi.spyOn(sentryCore, 'captureException');
47+
const integration = honoIntegration();
48+
integration.setupOnce?.();
49+
50+
(integration as unknown as MockHonoIntegrationType).handleHonoException(
51+
Object.assign(new Error('client err'), { status: 404 }),
52+
);
53+
expect(captureExceptionSpy).not.toHaveBeenCalled();
54+
});
55+
56+
it('does not capture for 3xx status', () => {
57+
const captureExceptionSpy = vi.spyOn(sentryCore, 'captureException');
58+
const integration = honoIntegration();
59+
integration.setupOnce?.();
60+
61+
(integration as unknown as MockHonoIntegrationType).handleHonoException(
62+
Object.assign(new Error('redirect'), { status: 302 }),
63+
);
64+
expect(captureExceptionSpy).not.toHaveBeenCalled();
65+
});
66+
67+
it('captures for 5xx status', () => {
68+
const captureExceptionSpy = vi.spyOn(sentryCore, 'captureException');
69+
const integration = honoIntegration();
70+
integration.setupOnce?.();
71+
72+
const err = Object.assign(new Error('server err'), { status: 500 });
73+
(integration as unknown as MockHonoIntegrationType).handleHonoException(err);
74+
expect(captureExceptionSpy).toHaveBeenCalledTimes(1);
75+
});
76+
77+
it('captures if no status is present on Error', () => {
78+
const captureExceptionSpy = vi.spyOn(sentryCore, 'captureException');
79+
const integration = honoIntegration();
80+
integration.setupOnce?.();
81+
82+
(integration as unknown as MockHonoIntegrationType).handleHonoException(new Error('no status'));
83+
expect(captureExceptionSpy).toHaveBeenCalledTimes(1);
84+
});
85+
86+
it('supports custom shouldHandleError option', () => {
87+
const captureExceptionSpy = vi.spyOn(sentryCore, 'captureException');
88+
const integration = honoIntegration({ shouldHandleError: () => false });
89+
integration.setupOnce?.();
90+
91+
(integration as unknown as MockHonoIntegrationType).handleHonoException(new Error('blocked'));
92+
expect(captureExceptionSpy).not.toHaveBeenCalled();
93+
});
94+
});

packages/cloudflare/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
// other package-specific, test-specific options

packages/cloudflare/vite.config.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import { defineConfig } from 'vitest/config';
2+
import baseConfig from '../../vite/vite.config';
3+
4+
export default defineConfig({
5+
...baseConfig,
6+
});

0 commit comments

Comments
 (0)