From 13cbc55151e5878263a8ab4b11bd5abeecf74ba5 Mon Sep 17 00:00:00 2001 From: s1gr1d Date: Tue, 23 Sep 2025 11:23:03 +0200 Subject: [PATCH 1/8] add Hono integration --- packages/cloudflare/src/handler.ts | 5 +- packages/cloudflare/src/index.ts | 1 + packages/cloudflare/src/integrations/hono.ts | 72 +++++++++++++++++++ packages/cloudflare/src/sdk.ts | 2 + .../cloudflare/test/integrations/hono.test.ts | 0 5 files changed, 78 insertions(+), 2 deletions(-) create mode 100644 packages/cloudflare/src/integrations/hono.ts create mode 100644 packages/cloudflare/test/integrations/hono.test.ts diff --git a/packages/cloudflare/src/handler.ts b/packages/cloudflare/src/handler.ts index a6e5983902c6..969cb6be72ee 100644 --- a/packages/cloudflare/src/handler.ts +++ b/packages/cloudflare/src/handler.ts @@ -10,6 +10,7 @@ import { import { setAsyncLocalStorageAsyncContextStrategy } from './async'; import type { CloudflareOptions } from './client'; import { isInstrumented, markAsInstrumented } from './instrument'; +import { getHonoIntegration } from './integrations/hono'; import { getFinalOptions } from './options'; import { wrapRequestHandler } from './request'; import { addCloudResourceContext } from './scope-utils'; @@ -48,7 +49,7 @@ export function withSentry | undefined { + const client = getClient(); + if (!client) { + return undefined; + } else { + return client.getIntegrationByName(_honoIntegration.name); + } +} + +// todo: implement this +function isHonoError(err: unknown): err is HonoError { + // @ts-ignore + return 'status' in err; +} + +const _honoIntegration = ((options: Partial = {}) => { + let _shouldHandleError: (error: Error) => boolean; + + return { + name: INTEGRATION_NAME, + setupOnce() { + _shouldHandleError = options.shouldHandleError || defaultShouldHandleError; + }, + handleHonoException(err: HonoError): void { + if (!isHonoError) { + DEBUG_BUILD && debug.log('Hono integration could not detect a Hono error'); + return; + } + if (_shouldHandleError(err)) { + captureException(err, { mechanism: { handled: false, type: 'auto.faas.cloudflare.error_handler' } }); + } + }, + }; +}) satisfies IntegrationFn; + +/** + * Automatically captures exceptions caught with the `onError` handler in Hono. + * + * The integration is added by default. + */ +export const honoIntegration = defineIntegration(_honoIntegration); + +/** + * Default function to determine if an error should be sent to Sentry + * + * 3xx and 4xx errors are not sent by default. + */ +function defaultShouldHandleError(error: HonoError): boolean { + // todo: add test for checking error without status + const statusCode = error?.status; + // 3xx and 4xx errors are not sent by default. + return statusCode ? statusCode >= 500 || statusCode <= 299 : true; +} diff --git a/packages/cloudflare/src/sdk.ts b/packages/cloudflare/src/sdk.ts index 9d4fb8d749ae..238cc13253a5 100644 --- a/packages/cloudflare/src/sdk.ts +++ b/packages/cloudflare/src/sdk.ts @@ -14,6 +14,7 @@ import type { CloudflareClientOptions, CloudflareOptions } from './client'; import { CloudflareClient } from './client'; import { makeFlushLock } from './flush'; import { fetchIntegration } from './integrations/fetch'; +import { honoIntegration } from './integrations/hono'; import { setupOpenTelemetryTracer } from './opentelemetry/tracer'; import { makeCloudflareTransport } from './transport'; import { defaultStackParser } from './vendor/stacktrace'; @@ -31,6 +32,7 @@ export function getDefaultIntegrations(options: CloudflareOptions): Integration[ functionToStringIntegration(), linkedErrorsIntegration(), fetchIntegration(), + honoIntegration(), // TODO(v11): the `include` object should be defined directly in the integration based on `sendDefaultPii` requestDataIntegration(sendDefaultPii ? undefined : { include: { cookies: false } }), consoleIntegration(), diff --git a/packages/cloudflare/test/integrations/hono.test.ts b/packages/cloudflare/test/integrations/hono.test.ts new file mode 100644 index 000000000000..e69de29bb2d1 From 26628c7105eb90eff10b3d347ea86a7ebddec1b5 Mon Sep 17 00:00:00 2001 From: s1gr1d Date: Tue, 23 Sep 2025 15:18:54 +0200 Subject: [PATCH 2/8] add tests --- packages/cloudflare/src/integrations/hono.ts | 30 ++++-- .../cloudflare/test/integrations/hono.test.ts | 97 +++++++++++++++++++ 2 files changed, 117 insertions(+), 10 deletions(-) diff --git a/packages/cloudflare/src/integrations/hono.ts b/packages/cloudflare/src/integrations/hono.ts index b1080896c140..89a63ad2bddf 100644 --- a/packages/cloudflare/src/integrations/hono.ts +++ b/packages/cloudflare/src/integrations/hono.ts @@ -22,31 +22,35 @@ export function getHonoIntegration(): ReturnType | unde if (!client) { return undefined; } else { - return client.getIntegrationByName(_honoIntegration.name); + return client.getIntegrationByName(INTEGRATION_NAME) as ReturnType | undefined; } } -// todo: implement this function isHonoError(err: unknown): err is HonoError { - // @ts-ignore - return 'status' in err; + if (err instanceof Error) { + return true; + } + return typeof err === 'object' && err !== null && 'status' in (err as Record); } -const _honoIntegration = ((options: Partial = {}) => { - let _shouldHandleError: (error: Error) => boolean; +// outside of integration to prevent resetting the variable +let _shouldHandleError: (error: Error) => boolean; +const _honoIntegration = ((options: Partial = {}) => { return { name: INTEGRATION_NAME, setupOnce() { _shouldHandleError = options.shouldHandleError || defaultShouldHandleError; }, handleHonoException(err: HonoError): void { - if (!isHonoError) { - DEBUG_BUILD && debug.log('Hono integration could not detect a Hono error'); + if (!isHonoError(err)) { + DEBUG_BUILD && debug.log("[Hono] Won't capture exception in `onError` because it's not a Hono error.", err); return; } if (_shouldHandleError(err)) { captureException(err, { mechanism: { handled: false, type: 'auto.faas.cloudflare.error_handler' } }); + } else { + DEBUG_BUILD && debug.log('[Hono] Not capturing exception because `shouldHandleError` returned `false`.', err); } }, }; @@ -55,7 +59,14 @@ const _honoIntegration = ((options: Partial = {}) => { /** * Automatically captures exceptions caught with the `onError` handler in Hono. * - * The integration is added by default. + * The integration is enabled by default. + * + * @example + * integrations: [ + * honoIntegration({ + * shouldHandleError: (err) => true; // always capture exceptions in onError + * }) + * ] */ export const honoIntegration = defineIntegration(_honoIntegration); @@ -65,7 +76,6 @@ export const honoIntegration = defineIntegration(_honoIntegration); * 3xx and 4xx errors are not sent by default. */ function defaultShouldHandleError(error: HonoError): boolean { - // todo: add test for checking error without status const statusCode = error?.status; // 3xx and 4xx errors are not sent by default. return statusCode ? statusCode >= 500 || statusCode <= 299 : true; diff --git a/packages/cloudflare/test/integrations/hono.test.ts b/packages/cloudflare/test/integrations/hono.test.ts index e69de29bb2d1..ae985dcad226 100644 --- a/packages/cloudflare/test/integrations/hono.test.ts +++ b/packages/cloudflare/test/integrations/hono.test.ts @@ -0,0 +1,97 @@ +import * as sentryCore from '@sentry/core'; +import { type Client, createStackParser } from '@sentry/core'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { CloudflareClient } from '../../src/client'; +import { honoIntegration } from '../../src/integrations/hono'; + +class FakeClient extends CloudflareClient { + public getIntegrationByName(name: string) { + return name === 'Hono' ? (honoIntegration() as any) : undefined; + } +} + +vi.mock('../../src/debug-build', () => ({ + DEBUG_BUILD: true, +})); + +type MockHonoIntegrationType = { handleHonoException: (err: Error) => void }; + +describe('Hono integration', () => { + let client: FakeClient; + + beforeEach(() => { + vi.clearAllMocks(); + client = new FakeClient({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [], + transport: () => ({ send: () => Promise.resolve({}), flush: () => Promise.resolve(true) }), + stackParser: createStackParser(), + }); + vi.spyOn(sentryCore, 'getClient').mockImplementation(() => client as Client); + }); + + it('captures in errorHandler when onError exists', () => { + const captureExceptionSpy = vi.spyOn(sentryCore, 'captureException'); + const integration = honoIntegration(); + integration.setupOnce?.(); + + const error = new Error('hono boom'); + // simulate withSentry wrapping of errorHandler calling back into integration + (integration as unknown as MockHonoIntegrationType).handleHonoException(error); + + expect(captureExceptionSpy).toHaveBeenCalledTimes(1); + expect(captureExceptionSpy).toHaveBeenLastCalledWith(error, { + mechanism: { handled: false, type: 'auto.faas.cloudflare.error_handler' }, + }); + }); + + it('does not capture for 4xx status', () => { + const captureExceptionSpy = vi.spyOn(sentryCore, 'captureException'); + const integration = honoIntegration(); + integration.setupOnce?.(); + + (integration as unknown as MockHonoIntegrationType).handleHonoException( + Object.assign(new Error('client err'), { status: 404 }), + ); + expect(captureExceptionSpy).not.toHaveBeenCalled(); + }); + + it('does not capture for 3xx status', () => { + const captureExceptionSpy = vi.spyOn(sentryCore, 'captureException'); + const integration = honoIntegration(); + integration.setupOnce?.(); + + (integration as unknown as MockHonoIntegrationType).handleHonoException( + Object.assign(new Error('redirect'), { status: 302 }), + ); + expect(captureExceptionSpy).not.toHaveBeenCalled(); + }); + + it('captures for 5xx status', () => { + const captureExceptionSpy = vi.spyOn(sentryCore, 'captureException'); + const integration = honoIntegration(); + integration.setupOnce?.(); + + const err = Object.assign(new Error('server err'), { status: 500 }); + (integration as unknown as MockHonoIntegrationType).handleHonoException(err); + expect(captureExceptionSpy).toHaveBeenCalledTimes(1); + }); + + it('captures if no status is present on Error', () => { + const captureExceptionSpy = vi.spyOn(sentryCore, 'captureException'); + const integration = honoIntegration(); + integration.setupOnce?.(); + + (integration as unknown as MockHonoIntegrationType).handleHonoException(new Error('no status')); + expect(captureExceptionSpy).toHaveBeenCalledTimes(1); + }); + + it('supports custom shouldHandleError option', () => { + const captureExceptionSpy = vi.spyOn(sentryCore, 'captureException'); + const integration = honoIntegration({ shouldHandleError: () => false }); + integration.setupOnce?.(); + + (integration as unknown as MockHonoIntegrationType).handleHonoException(new Error('blocked')); + expect(captureExceptionSpy).not.toHaveBeenCalled(); + }); +}); From a9d5e79c8e42a2303802c0f7737c63e3735fc6fd Mon Sep 17 00:00:00 2001 From: s1gr1d Date: Tue, 23 Sep 2025 15:29:15 +0200 Subject: [PATCH 3/8] add changelog entry --- CHANGELOG.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6350f087f454..62e1359e60c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,21 @@ - "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott +- **feat(cloudflare): Add honoIntegration with error-filtering function ([#17743](https://github.com/getsentry/sentry-javascript/pull/17743))** + + This release adds a `honoIntegration` to `@sentry/cloudflare`, which exposes a `shouldHandleError` function that lets you define which errors in `onError` should be captured. + By default, Sentry captures exceptions with `error.status >= 500 || error.status <= 299`. + + The integration is added by default, and it's possible to modify this behavior like this: + + ```js + integrations: [ + honoIntegration({ + shouldHandleError: (err) => true; // always capture exceptions in onError + }) + ] + ``` + Work in this release was contributed by @Karibash. Thank you for your contribution! ## 10.14.0 From 28789bb75a7d429edd5e1bf26416aeb8adbc238d Mon Sep 17 00:00:00 2001 From: s1gr1d Date: Tue, 23 Sep 2025 15:52:18 +0200 Subject: [PATCH 4/8] remove code in setupOnce --- packages/cloudflare/src/integrations/hono.ts | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/packages/cloudflare/src/integrations/hono.ts b/packages/cloudflare/src/integrations/hono.ts index 89a63ad2bddf..1449c06eb297 100644 --- a/packages/cloudflare/src/integrations/hono.ts +++ b/packages/cloudflare/src/integrations/hono.ts @@ -22,7 +22,7 @@ export function getHonoIntegration(): ReturnType | unde if (!client) { return undefined; } else { - return client.getIntegrationByName(INTEGRATION_NAME) as ReturnType | undefined; + return client.getIntegrationByName(INTEGRATION_NAME); } } @@ -33,21 +33,19 @@ function isHonoError(err: unknown): err is HonoError { return typeof err === 'object' && err !== null && 'status' in (err as Record); } -// outside of integration to prevent resetting the variable -let _shouldHandleError: (error: Error) => boolean; - const _honoIntegration = ((options: Partial = {}) => { return { name: INTEGRATION_NAME, - setupOnce() { - _shouldHandleError = options.shouldHandleError || defaultShouldHandleError; - }, + setupOnce() {}, handleHonoException(err: HonoError): void { + const shouldHandleError = options.shouldHandleError || defaultShouldHandleError; + if (!isHonoError(err)) { DEBUG_BUILD && debug.log("[Hono] Won't capture exception in `onError` because it's not a Hono error.", err); return; } - if (_shouldHandleError(err)) { + + if (shouldHandleError(err)) { captureException(err, { mechanism: { handled: false, type: 'auto.faas.cloudflare.error_handler' } }); } else { DEBUG_BUILD && debug.log('[Hono] Not capturing exception because `shouldHandleError` returned `false`.', err); From 07d8e671b7240bacf0db4ad3e4ca9216b5aec8d6 Mon Sep 17 00:00:00 2001 From: s1gr1d Date: Tue, 23 Sep 2025 16:09:23 +0200 Subject: [PATCH 5/8] add __DEBUG_BUILD__ mock --- packages/cloudflare/test/integrations/hono.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/cloudflare/test/integrations/hono.test.ts b/packages/cloudflare/test/integrations/hono.test.ts index ae985dcad226..291b20b12df3 100644 --- a/packages/cloudflare/test/integrations/hono.test.ts +++ b/packages/cloudflare/test/integrations/hono.test.ts @@ -12,6 +12,7 @@ class FakeClient extends CloudflareClient { vi.mock('../../src/debug-build', () => ({ DEBUG_BUILD: true, + __DEBUG_BUILD__: true, })); type MockHonoIntegrationType = { handleHonoException: (err: Error) => void }; @@ -27,6 +28,7 @@ describe('Hono integration', () => { transport: () => ({ send: () => Promise.resolve({}), flush: () => Promise.resolve(true) }), stackParser: createStackParser(), }); + vi.spyOn(sentryCore, 'getClient').mockImplementation(() => client as Client); }); From 05fdea11836e2b67d23072499aabaa6e73a6c3d1 Mon Sep 17 00:00:00 2001 From: s1gr1d Date: Tue, 23 Sep 2025 17:12:02 +0200 Subject: [PATCH 6/8] add vite config --- packages/cloudflare/test/handler.test.ts | 13 +++++++------ packages/cloudflare/test/integrations/hono.test.ts | 5 ----- packages/cloudflare/vite.config.ts | 6 ++++++ 3 files changed, 13 insertions(+), 11 deletions(-) create mode 100644 packages/cloudflare/vite.config.ts diff --git a/packages/cloudflare/test/handler.test.ts b/packages/cloudflare/test/handler.test.ts index 97e93199ea31..7768689ffc48 100644 --- a/packages/cloudflare/test/handler.test.ts +++ b/packages/cloudflare/test/handler.test.ts @@ -14,6 +14,7 @@ import { beforeEach, describe, expect, onTestFinished, test, vi } from 'vitest'; import { CloudflareClient } from '../src/client'; import { withSentry } from '../src/handler'; import { markAsInstrumented } from '../src/instrument'; +import * as HonoIntegration from '../src/integrations/hono'; // Custom type for hono-like apps (cloudflare handlers) that include errorHandler and onError type HonoLikeApp = ExportedHandler< @@ -1081,10 +1082,12 @@ describe('withSentry', () => { }); describe('hono errorHandler', () => { - test('captures errors handled by the errorHandler', async () => { - const captureExceptionSpy = vi.spyOn(SentryCore, 'captureException'); + test('calls Hono Integration to handle error captured by the errorHandler', async () => { const error = new Error('test hono error'); + const handleHonoException = vi.fn(); + vi.spyOn(HonoIntegration, 'getHonoIntegration').mockReturnValue({ handleHonoException } as any); + const honoApp = { fetch(_request, _env, _context) { return new Response('test'); @@ -1100,10 +1103,8 @@ describe('withSentry', () => { // simulates hono's error handling const errorHandlerResponse = honoApp.errorHandler?.(error); - expect(captureExceptionSpy).toHaveBeenCalledTimes(1); - expect(captureExceptionSpy).toHaveBeenLastCalledWith(error, { - mechanism: { handled: false, type: 'auto.faas.cloudflare.error_handler' }, - }); + expect(handleHonoException).toHaveBeenCalledTimes(1); + expect(handleHonoException).toHaveBeenLastCalledWith(error); expect(errorHandlerResponse?.status).toBe(500); }); diff --git a/packages/cloudflare/test/integrations/hono.test.ts b/packages/cloudflare/test/integrations/hono.test.ts index 291b20b12df3..609f4ca183e4 100644 --- a/packages/cloudflare/test/integrations/hono.test.ts +++ b/packages/cloudflare/test/integrations/hono.test.ts @@ -10,11 +10,6 @@ class FakeClient extends CloudflareClient { } } -vi.mock('../../src/debug-build', () => ({ - DEBUG_BUILD: true, - __DEBUG_BUILD__: true, -})); - type MockHonoIntegrationType = { handleHonoException: (err: Error) => void }; describe('Hono integration', () => { diff --git a/packages/cloudflare/vite.config.ts b/packages/cloudflare/vite.config.ts new file mode 100644 index 000000000000..b2150cd225a4 --- /dev/null +++ b/packages/cloudflare/vite.config.ts @@ -0,0 +1,6 @@ +import { defineConfig } from 'vitest/config'; +import baseConfig from '../../vite/vite.config'; + +export default defineConfig({ + ...baseConfig, +}); From e910c66c81c2e5b8239fc0b01f8bdd609905a404 Mon Sep 17 00:00:00 2001 From: s1gr1d Date: Wed, 24 Sep 2025 09:34:13 +0200 Subject: [PATCH 7/8] review comments --- packages/cloudflare/src/integrations/hono.ts | 10 ++-------- packages/cloudflare/test/integrations/hono.test.ts | 2 +- 2 files changed, 3 insertions(+), 9 deletions(-) diff --git a/packages/cloudflare/src/integrations/hono.ts b/packages/cloudflare/src/integrations/hono.ts index 1449c06eb297..7cb7b9efa42f 100644 --- a/packages/cloudflare/src/integrations/hono.ts +++ b/packages/cloudflare/src/integrations/hono.ts @@ -18,12 +18,7 @@ export interface Options { /** Only exported for internal use */ export function getHonoIntegration(): ReturnType | undefined { - const client = getClient(); - if (!client) { - return undefined; - } else { - return client.getIntegrationByName(INTEGRATION_NAME); - } + return getClient()?.getIntegrationByName(INTEGRATION_NAME); } function isHonoError(err: unknown): err is HonoError { @@ -36,7 +31,6 @@ function isHonoError(err: unknown): err is HonoError { const _honoIntegration = ((options: Partial = {}) => { return { name: INTEGRATION_NAME, - setupOnce() {}, handleHonoException(err: HonoError): void { const shouldHandleError = options.shouldHandleError || defaultShouldHandleError; @@ -46,7 +40,7 @@ const _honoIntegration = ((options: Partial = {}) => { } if (shouldHandleError(err)) { - captureException(err, { mechanism: { handled: false, type: 'auto.faas.cloudflare.error_handler' } }); + captureException(err, { mechanism: { handled: false, type: 'auto.faas.hono.error_handler' } }); } else { DEBUG_BUILD && debug.log('[Hono] Not capturing exception because `shouldHandleError` returned `false`.', err); } diff --git a/packages/cloudflare/test/integrations/hono.test.ts b/packages/cloudflare/test/integrations/hono.test.ts index 609f4ca183e4..f1b273b3ed2b 100644 --- a/packages/cloudflare/test/integrations/hono.test.ts +++ b/packages/cloudflare/test/integrations/hono.test.ts @@ -38,7 +38,7 @@ describe('Hono integration', () => { expect(captureExceptionSpy).toHaveBeenCalledTimes(1); expect(captureExceptionSpy).toHaveBeenLastCalledWith(error, { - mechanism: { handled: false, type: 'auto.faas.cloudflare.error_handler' }, + mechanism: { handled: false, type: 'auto.faas.hono.error_handler' }, }); }); From 40c50df4600e6cb0f265f7d5fd7fb431e9f314ad Mon Sep 17 00:00:00 2001 From: s1gr1d Date: Wed, 24 Sep 2025 10:53:43 +0200 Subject: [PATCH 8/8] fix lint error --- packages/cloudflare/tsconfig.test.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cloudflare/tsconfig.test.json b/packages/cloudflare/tsconfig.test.json index 42d9d0df227e..00cada2d8bcf 100644 --- a/packages/cloudflare/tsconfig.test.json +++ b/packages/cloudflare/tsconfig.test.json @@ -1,7 +1,7 @@ { "extends": "./tsconfig.json", - "include": ["test/**/*"], + "include": ["test/**/*", "vite.config.ts"], "compilerOptions": { // other package-specific, test-specific options