diff --git a/packages/hono/src/cloudflare/middleware.ts b/packages/hono/src/cloudflare/middleware.ts index c1d6a80335a7..ffcdf5e40346 100644 --- a/packages/hono/src/cloudflare/middleware.ts +++ b/packages/hono/src/cloudflare/middleware.ts @@ -9,6 +9,7 @@ import { } from '@sentry/core'; import type { Context, Hono, MiddlewareHandler } from 'hono'; import { requestHandler, responseHandler } from '../shared/middlewareHandlers'; +import { patchAppUse } from '../shared/patchAppUse'; export interface HonoOptions extends Options { context?: Context; @@ -42,6 +43,8 @@ export const sentry = (app: Hono, options: HonoOptions | undefined = {}): Middle app, ); + patchAppUse(app); + return async (context, next) => { requestHandler(context); diff --git a/packages/hono/src/shared/patchAppUse.ts b/packages/hono/src/shared/patchAppUse.ts new file mode 100644 index 000000000000..dfcd186dc38a --- /dev/null +++ b/packages/hono/src/shared/patchAppUse.ts @@ -0,0 +1,64 @@ +import { + captureException, + SEMANTIC_ATTRIBUTE_SENTRY_OP, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + SPAN_STATUS_ERROR, + SPAN_STATUS_OK, + startInactiveSpan, +} from '@sentry/core'; +import type { Hono, MiddlewareHandler } from 'hono'; + +const MIDDLEWARE_ORIGIN = 'auto.middleware.hono'; + +/** + * Patches `app.use` so that every middleware registered through it is automatically + * wrapped in a Sentry span. Supports both forms: `app.use(...handlers)` and `app.use(path, ...handlers)`. + */ +export function patchAppUse(app: Hono): void { + app.use = new Proxy(app.use, { + apply(target: typeof app.use, thisArg: typeof app, args: Parameters): ReturnType { + const [first, ...rest] = args as [unknown, ...MiddlewareHandler[]]; + + if (typeof first === 'string') { + const wrappedHandlers = rest.map(handler => wrapMiddlewareWithSpan(handler)); + return Reflect.apply(target, thisArg, [first, ...wrappedHandlers]); + } + + const allHandlers = [first as MiddlewareHandler, ...rest].map(handler => wrapMiddlewareWithSpan(handler)); + return Reflect.apply(target, thisArg, allHandlers); + }, + }); +} + +/** + * Wraps a Hono middleware handler so that its execution is traced as a Sentry span. + * Uses startInactiveSpan so that all middleware spans are siblings under the request/transaction + * (onion order: A → B → handler → B → A does not nest B under A in the trace). + */ +function wrapMiddlewareWithSpan(handler: MiddlewareHandler): MiddlewareHandler { + return async function sentryTracedMiddleware(context, next) { + const span = startInactiveSpan({ + name: handler.name || '', + op: 'middleware.hono', + onlyIfParent: true, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'middleware.hono', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: MIDDLEWARE_ORIGIN, + }, + }); + + try { + const result = await handler(context, next); + span.setStatus({ code: SPAN_STATUS_OK }); + return result; + } catch (error) { + span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); + captureException(error, { + mechanism: { handled: false, type: MIDDLEWARE_ORIGIN }, + }); + throw error; + } finally { + span.end(); + } + }; +} diff --git a/packages/hono/test/shared/patchAppUse.test.ts b/packages/hono/test/shared/patchAppUse.test.ts new file mode 100644 index 000000000000..8f4e3bc0cc6c --- /dev/null +++ b/packages/hono/test/shared/patchAppUse.test.ts @@ -0,0 +1,158 @@ +import * as SentryCore from '@sentry/core'; +import { Hono } from 'hono'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { patchAppUse } from '../../src/shared/patchAppUse'; + +vi.mock('@sentry/core', async () => { + const actual = await vi.importActual('@sentry/core'); + return { + ...actual, + startInactiveSpan: vi.fn((_opts: unknown) => ({ + setStatus: vi.fn(), + end: vi.fn(), + })), + captureException: vi.fn(), + }; +}); + +const startInactiveSpanMock = SentryCore.startInactiveSpan as ReturnType; +const captureExceptionMock = SentryCore.captureException as ReturnType; + +describe('patchAppUse (middleware spans)', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('wraps handlers in app.use(handler) so startInactiveSpan is called when middleware runs', async () => { + const app = new Hono(); + patchAppUse(app); + + const userHandler = vi.fn(async (_c: unknown, next: () => Promise) => { + await next(); + }); + app.use(userHandler); + + expect(startInactiveSpanMock).not.toHaveBeenCalled(); + + const fetchHandler = app.fetch; + const req = new Request('http://localhost/'); + await fetchHandler(req); + + expect(startInactiveSpanMock).toHaveBeenCalledTimes(1); + expect(startInactiveSpanMock).toHaveBeenCalledWith( + expect.objectContaining({ + op: 'middleware.hono', + onlyIfParent: true, + attributes: expect.objectContaining({ + 'sentry.op': 'middleware.hono', + 'sentry.origin': 'auto.middleware.hono', + }), + }), + ); + expect(userHandler).toHaveBeenCalled(); + }); + + describe('span naming', () => { + it('uses handler.name for span when handler has a name', async () => { + const app = new Hono(); + patchAppUse(app); + + async function myNamedMiddleware(_c: unknown, next: () => Promise) { + await next(); + } + app.use(myNamedMiddleware); + + await app.fetch(new Request('http://localhost/')); + + expect(startInactiveSpanMock).toHaveBeenCalledWith(expect.objectContaining({ name: 'myNamedMiddleware' })); + }); + + it('uses for span when handler is anonymous', async () => { + const app = new Hono(); + patchAppUse(app); + + app.use(async (_c: unknown, next: () => Promise) => next()); + + await app.fetch(new Request('http://localhost/')); + + expect(startInactiveSpanMock).toHaveBeenCalledTimes(1); + const name = startInactiveSpanMock.mock.calls[0][0].name; + expect(name).toMatch(''); + }); + }); + + it('wraps each handler in app.use(path, ...handlers) and passes path through', async () => { + const app = new Hono(); + patchAppUse(app); + + const handler = async (_c: unknown, next: () => Promise) => next(); + app.use('/api', handler); + app.get('/api', () => new Response('ok')); + + await app.fetch(new Request('http://localhost/api')); + + expect(startInactiveSpanMock).toHaveBeenCalled(); + }); + + it('calls captureException when middleware throws', async () => { + const app = new Hono(); + patchAppUse(app); + + const err = new Error('middleware error'); + app.use(async () => { + throw err; + }); + + const res = await app.fetch(new Request('http://localhost/')); + expect(res.status).toBe(500); + + expect(captureExceptionMock).toHaveBeenCalledWith(err, { + mechanism: { handled: false, type: 'auto.middleware.hono' }, + }); + }); + + it('creates sibling spans for multiple middlewares (onion order, not parent-child)', async () => { + const app = new Hono(); + patchAppUse(app); + + app.use( + async (_c: unknown, next: () => Promise) => next(), + async function namedMiddleware(_c: unknown, next: () => Promise) { + await next(); + }, + async (_c: unknown, next: () => Promise) => next(), + ); + + await app.fetch(new Request('http://localhost/')); + + expect(startInactiveSpanMock).toHaveBeenCalledTimes(3); + const [firstCall, secondCall, thirdCall] = startInactiveSpanMock.mock.calls; + expect(firstCall[0]).toMatchObject({ op: 'middleware.hono' }); + expect(secondCall[0]).toMatchObject({ op: 'middleware.hono' }); + expect(firstCall[0].name).toMatch(''); + expect(secondCall[0].name).toBe('namedMiddleware'); + expect(thirdCall[0].name).toBe(''); + expect(firstCall[0].name).not.toBe(secondCall[0].name); + }); + + it('preserves this context when calling the original use (Proxy forwards thisArg)', () => { + type FakeApp = { + _capturedThis: unknown; + use: (...args: unknown[]) => FakeApp; + }; + const fakeApp: FakeApp = { + _capturedThis: null, + use(this: FakeApp, ..._args: unknown[]) { + this._capturedThis = this; + return this; + }, + }; + + patchAppUse(fakeApp as unknown as Parameters[0]); + + const noop = async (_c: unknown, next: () => Promise) => next(); + fakeApp.use(noop); + + expect(fakeApp._capturedThis).toBe(fakeApp); + }); +});