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 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 { + return getClient()?.getIntegrationByName(INTEGRATION_NAME); +} + +function isHonoError(err: unknown): err is HonoError { + if (err instanceof Error) { + return true; + } + return typeof err === 'object' && err !== null && 'status' in (err as Record); +} + +const _honoIntegration = ((options: Partial = {}) => { + return { + name: INTEGRATION_NAME, + 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)) { + 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); + } + }, + }; +}) satisfies IntegrationFn; + +/** + * Automatically captures exceptions caught with the `onError` handler in Hono. + * + * The integration is enabled by default. + * + * @example + * integrations: [ + * honoIntegration({ + * shouldHandleError: (err) => true; // always capture exceptions in onError + * }) + * ] + */ +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 { + 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/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 new file mode 100644 index 000000000000..f1b273b3ed2b --- /dev/null +++ b/packages/cloudflare/test/integrations/hono.test.ts @@ -0,0 +1,94 @@ +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; + } +} + +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.hono.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(); + }); +}); 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 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, +});