Skip to content

Commit 685cf5c

Browse files
authored
feat(hono): Instrument middlewares app.use() (#19611)
Middleware spans are named either after the function name or they are numbered. Middleware in Hono is onion-shaped ([see docs](https://hono.dev/docs/concepts/middleware)) and technically, this shape would create a nested children-based span structure. This however, is not as intuitive and so I decided (after also talking to @andreiborza and @JPeer264) to create a sibiling-like structure: <img width="873" height="152" alt="image" src="https://github.com/user-attachments/assets/484d029b-0887-4d5a-87c4-8eaca9d0081c" /> Closes #19585
1 parent 201eccd commit 685cf5c

File tree

3 files changed

+225
-0
lines changed

3 files changed

+225
-0
lines changed

packages/hono/src/cloudflare/middleware.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
} from '@sentry/core';
1010
import type { Context, Hono, MiddlewareHandler } from 'hono';
1111
import { requestHandler, responseHandler } from '../shared/middlewareHandlers';
12+
import { patchAppUse } from '../shared/patchAppUse';
1213

1314
export interface HonoOptions extends Options<BaseTransportOptions> {
1415
context?: Context;
@@ -42,6 +43,8 @@ export const sentry = (app: Hono, options: HonoOptions | undefined = {}): Middle
4243
app,
4344
);
4445

46+
patchAppUse(app);
47+
4548
return async (context, next) => {
4649
requestHandler(context);
4750

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import {
2+
captureException,
3+
SEMANTIC_ATTRIBUTE_SENTRY_OP,
4+
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
5+
SPAN_STATUS_ERROR,
6+
SPAN_STATUS_OK,
7+
startInactiveSpan,
8+
} from '@sentry/core';
9+
import type { Hono, MiddlewareHandler } from 'hono';
10+
11+
const MIDDLEWARE_ORIGIN = 'auto.middleware.hono';
12+
13+
/**
14+
* Patches `app.use` so that every middleware registered through it is automatically
15+
* wrapped in a Sentry span. Supports both forms: `app.use(...handlers)` and `app.use(path, ...handlers)`.
16+
*/
17+
export function patchAppUse(app: Hono): void {
18+
app.use = new Proxy(app.use, {
19+
apply(target: typeof app.use, thisArg: typeof app, args: Parameters<typeof app.use>): ReturnType<typeof app.use> {
20+
const [first, ...rest] = args as [unknown, ...MiddlewareHandler[]];
21+
22+
if (typeof first === 'string') {
23+
const wrappedHandlers = rest.map(handler => wrapMiddlewareWithSpan(handler));
24+
return Reflect.apply(target, thisArg, [first, ...wrappedHandlers]);
25+
}
26+
27+
const allHandlers = [first as MiddlewareHandler, ...rest].map(handler => wrapMiddlewareWithSpan(handler));
28+
return Reflect.apply(target, thisArg, allHandlers);
29+
},
30+
});
31+
}
32+
33+
/**
34+
* Wraps a Hono middleware handler so that its execution is traced as a Sentry span.
35+
* Uses startInactiveSpan so that all middleware spans are siblings under the request/transaction
36+
* (onion order: A → B → handler → B → A does not nest B under A in the trace).
37+
*/
38+
function wrapMiddlewareWithSpan(handler: MiddlewareHandler): MiddlewareHandler {
39+
return async function sentryTracedMiddleware(context, next) {
40+
const span = startInactiveSpan({
41+
name: handler.name || '<anonymous>',
42+
op: 'middleware.hono',
43+
onlyIfParent: true,
44+
attributes: {
45+
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'middleware.hono',
46+
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: MIDDLEWARE_ORIGIN,
47+
},
48+
});
49+
50+
try {
51+
const result = await handler(context, next);
52+
span.setStatus({ code: SPAN_STATUS_OK });
53+
return result;
54+
} catch (error) {
55+
span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' });
56+
captureException(error, {
57+
mechanism: { handled: false, type: MIDDLEWARE_ORIGIN },
58+
});
59+
throw error;
60+
} finally {
61+
span.end();
62+
}
63+
};
64+
}
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
import * as SentryCore from '@sentry/core';
2+
import { Hono } from 'hono';
3+
import { beforeEach, describe, expect, it, vi } from 'vitest';
4+
import { patchAppUse } from '../../src/shared/patchAppUse';
5+
6+
vi.mock('@sentry/core', async () => {
7+
const actual = await vi.importActual('@sentry/core');
8+
return {
9+
...actual,
10+
startInactiveSpan: vi.fn((_opts: unknown) => ({
11+
setStatus: vi.fn(),
12+
end: vi.fn(),
13+
})),
14+
captureException: vi.fn(),
15+
};
16+
});
17+
18+
const startInactiveSpanMock = SentryCore.startInactiveSpan as ReturnType<typeof vi.fn>;
19+
const captureExceptionMock = SentryCore.captureException as ReturnType<typeof vi.fn>;
20+
21+
describe('patchAppUse (middleware spans)', () => {
22+
beforeEach(() => {
23+
vi.clearAllMocks();
24+
});
25+
26+
it('wraps handlers in app.use(handler) so startInactiveSpan is called when middleware runs', async () => {
27+
const app = new Hono();
28+
patchAppUse(app);
29+
30+
const userHandler = vi.fn(async (_c: unknown, next: () => Promise<void>) => {
31+
await next();
32+
});
33+
app.use(userHandler);
34+
35+
expect(startInactiveSpanMock).not.toHaveBeenCalled();
36+
37+
const fetchHandler = app.fetch;
38+
const req = new Request('http://localhost/');
39+
await fetchHandler(req);
40+
41+
expect(startInactiveSpanMock).toHaveBeenCalledTimes(1);
42+
expect(startInactiveSpanMock).toHaveBeenCalledWith(
43+
expect.objectContaining({
44+
op: 'middleware.hono',
45+
onlyIfParent: true,
46+
attributes: expect.objectContaining({
47+
'sentry.op': 'middleware.hono',
48+
'sentry.origin': 'auto.middleware.hono',
49+
}),
50+
}),
51+
);
52+
expect(userHandler).toHaveBeenCalled();
53+
});
54+
55+
describe('span naming', () => {
56+
it('uses handler.name for span when handler has a name', async () => {
57+
const app = new Hono();
58+
patchAppUse(app);
59+
60+
async function myNamedMiddleware(_c: unknown, next: () => Promise<void>) {
61+
await next();
62+
}
63+
app.use(myNamedMiddleware);
64+
65+
await app.fetch(new Request('http://localhost/'));
66+
67+
expect(startInactiveSpanMock).toHaveBeenCalledWith(expect.objectContaining({ name: 'myNamedMiddleware' }));
68+
});
69+
70+
it('uses <anonymous.index> for span when handler is anonymous', async () => {
71+
const app = new Hono();
72+
patchAppUse(app);
73+
74+
app.use(async (_c: unknown, next: () => Promise<void>) => next());
75+
76+
await app.fetch(new Request('http://localhost/'));
77+
78+
expect(startInactiveSpanMock).toHaveBeenCalledTimes(1);
79+
const name = startInactiveSpanMock.mock.calls[0][0].name;
80+
expect(name).toMatch('<anonymous>');
81+
});
82+
});
83+
84+
it('wraps each handler in app.use(path, ...handlers) and passes path through', async () => {
85+
const app = new Hono();
86+
patchAppUse(app);
87+
88+
const handler = async (_c: unknown, next: () => Promise<void>) => next();
89+
app.use('/api', handler);
90+
app.get('/api', () => new Response('ok'));
91+
92+
await app.fetch(new Request('http://localhost/api'));
93+
94+
expect(startInactiveSpanMock).toHaveBeenCalled();
95+
});
96+
97+
it('calls captureException when middleware throws', async () => {
98+
const app = new Hono();
99+
patchAppUse(app);
100+
101+
const err = new Error('middleware error');
102+
app.use(async () => {
103+
throw err;
104+
});
105+
106+
const res = await app.fetch(new Request('http://localhost/'));
107+
expect(res.status).toBe(500);
108+
109+
expect(captureExceptionMock).toHaveBeenCalledWith(err, {
110+
mechanism: { handled: false, type: 'auto.middleware.hono' },
111+
});
112+
});
113+
114+
it('creates sibling spans for multiple middlewares (onion order, not parent-child)', async () => {
115+
const app = new Hono();
116+
patchAppUse(app);
117+
118+
app.use(
119+
async (_c: unknown, next: () => Promise<void>) => next(),
120+
async function namedMiddleware(_c: unknown, next: () => Promise<void>) {
121+
await next();
122+
},
123+
async (_c: unknown, next: () => Promise<void>) => next(),
124+
);
125+
126+
await app.fetch(new Request('http://localhost/'));
127+
128+
expect(startInactiveSpanMock).toHaveBeenCalledTimes(3);
129+
const [firstCall, secondCall, thirdCall] = startInactiveSpanMock.mock.calls;
130+
expect(firstCall[0]).toMatchObject({ op: 'middleware.hono' });
131+
expect(secondCall[0]).toMatchObject({ op: 'middleware.hono' });
132+
expect(firstCall[0].name).toMatch('<anonymous>');
133+
expect(secondCall[0].name).toBe('namedMiddleware');
134+
expect(thirdCall[0].name).toBe('<anonymous>');
135+
expect(firstCall[0].name).not.toBe(secondCall[0].name);
136+
});
137+
138+
it('preserves this context when calling the original use (Proxy forwards thisArg)', () => {
139+
type FakeApp = {
140+
_capturedThis: unknown;
141+
use: (...args: unknown[]) => FakeApp;
142+
};
143+
const fakeApp: FakeApp = {
144+
_capturedThis: null,
145+
use(this: FakeApp, ..._args: unknown[]) {
146+
this._capturedThis = this;
147+
return this;
148+
},
149+
};
150+
151+
patchAppUse(fakeApp as unknown as Parameters<typeof patchAppUse>[0]);
152+
153+
const noop = async (_c: unknown, next: () => Promise<void>) => next();
154+
fakeApp.use(noop);
155+
156+
expect(fakeApp._capturedThis).toBe(fakeApp);
157+
});
158+
});

0 commit comments

Comments
 (0)