Skip to content
Merged
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
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,23 @@
[Sentry TanStack Start SDK docs](https://docs.sentry.io/platforms/javascript/guides/tanstackstart-react/). Please reach out on
[GitHub](https://github.com/getsentry/sentry-javascript/issues/new/choose) if you have any feedback or concerns.

- **feat(hono): Add `shouldHandleError` option to `sentry()` middleware**

The `sentry()` middleware now accepts a `shouldHandleError` callback to control which errors are captured and sent to Sentry. By default, 3xx/4xx HTTP errors are ignored and 5xx errors and plain `Error` objects are captured. Return `true` from the callback to capture an error, `false` to suppress it.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

l: maybe an example would help to understand how this is to be used?


```ts
app.use(
sentry(app, {
dsn: '__DSN__',
shouldHandleError(error) {
const status = (error as { status?: number })?.status;
// Capture 401/403 in addition to the default 5xx errors
return status === 401 || status === 403 || typeof status !== 'number' || status >= 500;
},
}),
);
```

- **test(tanstackstart-react): Move initialization to client entry point ([#21161](https://github.com/getsentry/sentry-javascript/pull/21161))**

Change the recommended setup for the SDK to do `Sentry.init()` in the client entry file to capture telemetry that is emitted ahead of page hydration.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,7 @@ test.describe('middleware errors', () => {

const errorEvent = await errorPromise;
expect(errorEvent.exception?.values?.[0]?.value).toBe('Service Unavailable from middleware');
expect(errorEvent.exception?.values?.[0]?.mechanism?.type).toBe('auto.middleware.hono');
expect(errorEvent.exception?.values?.[0]?.mechanism?.type).toBe('auto.http.hono.context_error');
expect(errorEvent.exception?.values?.[0]?.mechanism?.handled).toBe(false);
expect(errorEvent.transaction).toBe('GET /test-errors/middleware-http-exception');

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ for (const { name, prefix } of SCENARIOS) {
expect(errorEvent.exception?.values?.[0]?.mechanism).toEqual(
expect.objectContaining({
handled: false,
type: 'auto.middleware.hono',
type: 'auto.http.hono.context_error',
}),
);

Expand Down
21 changes: 21 additions & 0 deletions packages/hono/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -189,3 +189,24 @@ app.use(

serve(app);
```

## Filtering errors

By default, `@sentry/hono` captures 5xx errors and plain `Error` objects, and ignores 3xx/4xx HTTP errors (redirects, not-found, bad request, etc.).

Use `shouldHandleError` to override this on a per-error basis:

```ts
app.use(
sentry(app, {
dsn: '__DSN__',
shouldHandleError(error) {
const status = (error as { status?: number })?.status;
// Capture 401/403 in addition to the default 5xx errors
return status === 401 || status === 403 || typeof status !== 'number' || status >= 500;
},
}),
);
```

Return `true` to capture the error, `false` to suppress it.
9 changes: 4 additions & 5 deletions packages/hono/src/bun/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,15 @@ import { init } from './sdk';
import type { Env, Hono, MiddlewareHandler } from 'hono';
import { requestHandler, responseHandler } from '../shared/middlewareHandlers';
import { applyPatches } from '../shared/applyPatches';
import type { SentryHonoMiddlewareOptions } from '../shared/types';

export interface HonoBunOptions extends Options<BaseTransportOptions> {}
export interface HonoBunOptions extends Options<BaseTransportOptions>, SentryHonoMiddlewareOptions {}

/**
* Sentry middleware for Hono running in a Bun runtime environment.
*/
export const sentry = <E extends Env>(app: Hono<E>, options: HonoBunOptions): MiddlewareHandler => {
const isDebug = options.debug;

isDebug && debug.log('Initialized Sentry Hono middleware (Bun)');
options.debug && debug.log('Initialized Sentry Hono middleware (Bun)');

init(options);

Expand All @@ -23,6 +22,6 @@ export const sentry = <E extends Env>(app: Hono<E>, options: HonoBunOptions): Mi

await next(); // Handler runs in between Request above ⤴ and Response below ⤵

responseHandler(context);
responseHandler(context, options.shouldHandleError);
};
};
10 changes: 8 additions & 2 deletions packages/hono/src/cloudflare/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@ import type { Env, Hono, MiddlewareHandler } from 'hono';
import { buildFilteredIntegrations } from '../shared/buildFilteredIntegrations';
import { requestHandler, responseHandler } from '../shared/middlewareHandlers';
import { applyPatches } from '../shared/applyPatches';
import type { SentryHonoMiddlewareOptions } from '../shared/types';

export interface HonoCloudflareOptions extends Options<BaseTransportOptions> {}
export interface HonoCloudflareOptions extends Options<BaseTransportOptions>, SentryHonoMiddlewareOptions {}

/**
* Sentry middleware for Hono on Cloudflare Workers.
Expand Down Expand Up @@ -36,10 +37,15 @@ export function sentry<E extends Env>(
applyPatches(app);

return async (context, next) => {
const shouldHandleError =
typeof options === 'function'
? options(context.env as E['Bindings']).shouldHandleError
: options.shouldHandleError;

requestHandler(context);

await next(); // Handler runs in between Request above ⤴ and Response below ⤵

responseHandler(context);
responseHandler(context, shouldHandleError);
};
}
5 changes: 3 additions & 2 deletions packages/hono/src/node/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { type BaseTransportOptions, debug, type Options, getClient } from '@sent
import type { Env, Hono, MiddlewareHandler } from 'hono';
import { requestHandler, responseHandler } from '../shared/middlewareHandlers';
import { applyPatches } from '../shared/applyPatches';
import type { SentryHonoMiddlewareOptions } from '../shared/types';

export interface HonoNodeOptions extends Options<BaseTransportOptions> {}

Expand All @@ -13,7 +14,7 @@ export interface HonoNodeOptions extends Options<BaseTransportOptions> {}
*
* **Note:** You must initialize Sentry separately before using this middleware. Typically, this is done by calling `Sentry.init()` in an `instrument.ts` file and loading it via the Node `--import` flag.
*/
export const sentry = <E extends Env>(app: Hono<E>): MiddlewareHandler => {
export const sentry = <E extends Env>(app: Hono<E>, options?: SentryHonoMiddlewareOptions): MiddlewareHandler => {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

m: should this take HonoNodeOptions (which then I think would need to extend SentryHonoMiddlewareOptions) like in the other middlewares?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

No, because in the Node runtime the options are passed to the init in the external instrumentation file.

const sentryClient = getClient();
if (sentryClient === undefined) {
debug.warn(
Expand All @@ -31,6 +32,6 @@ export const sentry = <E extends Env>(app: Hono<E>): MiddlewareHandler => {

await next(); // Handler runs in between Request above ⤴ and Response below ⤵

responseHandler(context);
responseHandler(context, options?.shouldHandleError);
};
};
19 changes: 19 additions & 0 deletions packages/hono/src/shared/defaultShouldHandleError.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
/**
* Default implementation of the `shouldHandleError` callback.
*
* Returns `true` (capture) for 5xx errors and any error without a `status` property
*
* Returns `false` (skip) for 3xx and 4xx errors (they still generate spans and transactions for tracing)
*
* Checks any error-like value that carries a numeric `status` property. This covers
* Hono's `HTTPException`, third-party middleware errors, and custom error subclasses.
*/
export function defaultShouldHandleError(error: unknown): boolean {
if (typeof error !== 'object' || error === null) {
return true;
}

const status = (error as { status?: unknown }).status;

return !(typeof status === 'number' && status >= 300 && status < 500);
}
17 changes: 0 additions & 17 deletions packages/hono/src/shared/isExpectedError.ts

This file was deleted.

18 changes: 12 additions & 6 deletions packages/hono/src/shared/middlewareHandlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ import {
import type { Context } from 'hono';
import { routePath } from 'hono/route';
import { hasFetchEvent } from '../utils/hono-context';
import { isExpectedError } from './isExpectedError';
import { defaultShouldHandleError } from './defaultShouldHandleError';
import { type SentryHonoMiddlewareOptions } from '../shared/types';

/**
* Request handler for Hono framework
Expand All @@ -33,11 +34,16 @@ export function requestHandler(context: Context): void {
/**
* Response handler for Hono framework
*/
export function responseHandler(context: Context): void {
if (context.error && !isExpectedError(context.error)) {
getClient()?.captureException(context.error, {
mechanism: { handled: false, type: 'auto.http.hono.context_error' },
});
export function responseHandler(
context: Context,
shouldHandleError?: SentryHonoMiddlewareOptions['shouldHandleError'],
): void {
if (context.error) {
if ((shouldHandleError ?? defaultShouldHandleError)(context.error)) {
getClient()?.captureException(context.error, {
mechanism: { handled: false, type: 'auto.http.hono.context_error' },
});
}
}
}

Expand Down
24 changes: 24 additions & 0 deletions packages/hono/src/shared/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
/**
* Middleware-specific options shared across all Hono runtime adapters.
* These options are distinct from the SDK initialization options (DSN, sample rates, etc.).
*/
export interface SentryHonoMiddlewareOptions {
/**
* Determines whether a given Hono error thrown in the response should be captured and sent to Sentry.
*
* When not provided, the default behavior applies: 3xx and 4xx HTTP errors are
* considered expected and are not captured. All other errors are captured.
*
* @example
* // Capture everything, including 4xx errors:
* shouldHandleError: () => true
*
* @example
* // Capture only 5xx errors and suppress everything else:
* shouldHandleError: (err) => {
* const status = (err as { status?: number })?.status;
* return typeof status === 'number' ? status >= 500 : true;
* }
*/
shouldHandleError?: (error: unknown) => boolean;
}
11 changes: 4 additions & 7 deletions packages/hono/src/shared/wrapMiddlewareSpan.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import {
captureException,
getActiveSpan,
getOriginalFunction,
getRootSpan,
Expand All @@ -10,7 +9,7 @@ import {
type WrappedFunction,
} from '@sentry/core';
import { type MiddlewareHandler } from 'hono';
import { isExpectedError } from './isExpectedError';
import { defaultShouldHandleError } from './defaultShouldHandleError';

const MIDDLEWARE_ORIGIN = 'auto.middleware.hono';

Expand Down Expand Up @@ -44,13 +43,11 @@ export function wrapMiddlewareWithSpan(handler: MiddlewareHandler): MiddlewareHa
try {
return await handler(context, next);
} catch (error) {
if (!isExpectedError(error)) {
// Error capture is handled by `responseHandler` via `context.error`, so this wrapper only sets
// span status (based on our default "error" conditions) and rethrows (no `captureException`).
if (defaultShouldHandleError(error)) {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

m: is this intentionally defaultShouldHandleError and not shouldCaptureError?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Just confirming that here we do not use the user-provided shouldHandleError, was that intentional? I think it is fine tbh. Just e.g. if a user wants to catch 400s as errors then span status would not be set to error here.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

We'll only set the span as "errored" when our default conditions apply.
Users could potentially want to report 200 as error and it would be wrong to set the span as "errored".

span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' });
captureException(error, {
mechanism: { handled: false, type: MIDDLEWARE_ORIGIN },
});
}

throw error;
} finally {
span.end();
Expand Down
78 changes: 78 additions & 0 deletions packages/hono/test/shared/defaultShouldHandleError.test.ts
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

same file as before

Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { HTTPException } from 'hono/http-exception';
import { describe, expect, it } from 'vitest';
import { defaultShouldHandleError } from '../../src/shared/defaultShouldHandleError';

describe('defaultShouldHandleError', () => {
describe('HTTPException', () => {
it('returns false for 4xx HTTPException (skip)', () => {
expect(defaultShouldHandleError(new HTTPException(400, { message: 'Bad Request' }))).toBe(false);
expect(defaultShouldHandleError(new HTTPException(401, { message: 'Unauthorized' }))).toBe(false);
expect(defaultShouldHandleError(new HTTPException(403, { message: 'Forbidden' }))).toBe(false);
expect(defaultShouldHandleError(new HTTPException(404, { message: 'Not Found' }))).toBe(false);
expect(defaultShouldHandleError(new HTTPException(422, { message: 'Unprocessable Entity' }))).toBe(false);
expect(defaultShouldHandleError(new HTTPException(499))).toBe(false);
});

it('returns true for 5xx HTTPException (capture)', () => {
expect(defaultShouldHandleError(new HTTPException(500, { message: 'Internal Server Error' }))).toBe(true);
expect(defaultShouldHandleError(new HTTPException(502, { message: 'Bad Gateway' }))).toBe(true);
expect(defaultShouldHandleError(new HTTPException(503, { message: 'Service Unavailable' }))).toBe(true);
});
});

describe('custom error classes with status property', () => {
it('returns false for custom Error subclass with 4xx status (skip)', () => {
class AuthError extends Error {
status = 401;
}
expect(defaultShouldHandleError(new AuthError('unauthorized'))).toBe(false);
});

it('returns true for custom Error subclass with 5xx status (capture)', () => {
class DbError extends Error {
status = 500;
}
expect(defaultShouldHandleError(new DbError('connection lost'))).toBe(true);
});

it('returns false for plain object with 4xx status (skip)', () => {
expect(defaultShouldHandleError({ status: 404, message: 'Not Found' })).toBe(false);
expect(defaultShouldHandleError({ status: 400 })).toBe(false);
});

it('returns true for plain object with 5xx status (capture)', () => {
expect(defaultShouldHandleError({ status: 500, message: 'Internal Server Error' })).toBe(true);
});
});

describe('non-HTTP errors', () => {
it('returns true for plain Error without status (capture)', () => {
expect(defaultShouldHandleError(new Error('something broke'))).toBe(true);
});

it('returns true for non-object values (capture)', () => {
expect(defaultShouldHandleError('string error')).toBe(true);
expect(defaultShouldHandleError(42)).toBe(true);
expect(defaultShouldHandleError(null)).toBe(true);
expect(defaultShouldHandleError(undefined)).toBe(true);
expect(defaultShouldHandleError(true)).toBe(true);
});

it('returns true when status is not a number (capture)', () => {
expect(defaultShouldHandleError({ status: '404' })).toBe(true);
expect(defaultShouldHandleError({ status: null })).toBe(true);
expect(defaultShouldHandleError({ status: undefined })).toBe(true);
});

it('returns false for 3xx status (skip)', () => {
expect(defaultShouldHandleError({ status: 301 })).toBe(false);
expect(defaultShouldHandleError({ status: 302 })).toBe(false);
expect(defaultShouldHandleError({ status: 399 })).toBe(false);
});

it('returns true for 2xx status (capture)', () => {
expect(defaultShouldHandleError({ status: 200 })).toBe(true);
expect(defaultShouldHandleError({ status: 299 })).toBe(true);
});
});
});
Loading
Loading