Skip to content

Commit 6e73205

Browse files
committed
test(hono): Add E2E and unit tests for middleware tracing and route patterns
Add comprehensive test coverage for the Hono middleware instrumentation: - E2E route-patterns tests for HTTP methods, registration styles, error capture - E2E inline middleware tests for direct method, .all(), .on() with inline and separately registered middleware across all HTTP methods - E2E negative test asserting .all() handlers don't create middleware spans - Unit tests for responseHandler error capture and isAlreadyCaptured guard - Unit tests for patchRoute arity heuristic (inline mw, separate mw, .on()) - Unit tests for sentry middleware without options (external init pattern) Made-with: Cursor
1 parent 8a9a99a commit 6e73205

8 files changed

Lines changed: 600 additions & 30 deletions

File tree

dev-packages/e2e-tests/test-applications/hono-4/src/route-groups/test-middleware.ts

Lines changed: 55 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ middlewareRoutes.get('/anonymous', c => c.json({ middleware: 'anonymous' }));
88
middlewareRoutes.get('/multi', c => c.json({ middleware: 'multi' }));
99
middlewareRoutes.get('/error', c => c.text('should not reach'));
1010

11-
// Self-contained sub-app registering its own middleware
11+
// Self-contained sub-app registering its own middleware via .use()
1212
const subAppWithMiddleware = new Hono();
1313

1414
subAppWithMiddleware.use('/named/*', middlewareA);
@@ -19,12 +19,63 @@ subAppWithMiddleware.use('/anonymous/*', async (c, next) => {
1919
subAppWithMiddleware.use('/multi/*', middlewareA, middlewareB);
2020
subAppWithMiddleware.use('/error/*', failingMiddleware);
2121

22-
// .all() produces the same method:'ALL' as .use() in Hono's route record.
23-
// Wrapping it is harmless (onlyIfParent:true) — this route exists to prove that.
22+
// .all() handler (1 parameter) — should NOT be wrapped as middleware by patchRoute.
2423
subAppWithMiddleware.all('/all-handler', async function allCatchAll(c) {
2524
return c.json({ handler: 'all' });
2625
});
2726

2827
subAppWithMiddleware.route('/', middlewareRoutes);
2928

30-
export { middlewareRoutes, subAppWithMiddleware };
29+
// Sub-app with inline middleware for different registration styles.
30+
// patchRoute wraps non-last handlers per method+path group as middleware.
31+
const subAppWithInlineMiddleware = new Hono();
32+
33+
const METHODS = ['get', 'post', 'put', 'delete', 'patch'] as const;
34+
35+
// Direct method registration for each HTTP method
36+
METHODS.forEach(method => {
37+
subAppWithInlineMiddleware[method](
38+
'/direct',
39+
async function inlineMiddleware(_c, next) {
40+
await next();
41+
},
42+
c => c.text(`${method} direct response`),
43+
);
44+
45+
subAppWithInlineMiddleware[method]('/direct/separately', async function inlineSeparateMiddleware(_c, next) {
46+
await next();
47+
});
48+
subAppWithInlineMiddleware[method]('/direct/separately', c => c.text(`${method} direct separate response`));
49+
});
50+
51+
// .all(): .all('/path', mw, handler)
52+
subAppWithInlineMiddleware.all(
53+
'/all',
54+
async function inlineMiddlewareAll(_c, next) {
55+
await next();
56+
},
57+
c => c.text('all response'),
58+
);
59+
subAppWithInlineMiddleware.all('/all/separately', async function inlineSeparateMiddlewareAll(_c, next) {
60+
await next();
61+
});
62+
subAppWithInlineMiddleware.all('/all/separately', c => c.text('all separate response'));
63+
64+
// .on() registration for each HTTP method
65+
METHODS.forEach(method => {
66+
subAppWithInlineMiddleware.on(
67+
method,
68+
'/on',
69+
async function inlineMiddlewareOn(_c, next) {
70+
await next();
71+
},
72+
c => c.text(`${method} on response`),
73+
);
74+
75+
subAppWithInlineMiddleware.on(method, '/on/separately', async function inlineSeparateMiddlewareOn(_c, next) {
76+
await next();
77+
});
78+
subAppWithInlineMiddleware.on(method, '/on/separately', c => c.text(`${method} on separate response`));
79+
});
80+
81+
export { middlewareRoutes, subAppWithMiddleware, subAppWithInlineMiddleware };
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { Hono } from 'hono';
2+
import { HTTPException } from 'hono/http-exception';
3+
4+
const routePatterns = new Hono();
5+
6+
const METHODS = ['get', 'post', 'put', 'delete', 'patch'] as const;
7+
8+
// Direct method registration for each HTTP method (sync handlers)
9+
METHODS.forEach(method => {
10+
routePatterns[method]('/', c => c.text(`${method} response`));
11+
});
12+
13+
// Async handler
14+
routePatterns.get('/async', async c => {
15+
await new Promise(resolve => setTimeout(resolve, 10));
16+
return c.text('async response');
17+
});
18+
19+
// .all() registration
20+
routePatterns.all('/all', c => c.text('all handler response'));
21+
22+
// .on() registration
23+
METHODS.forEach(method => {
24+
routePatterns.on(method, '/on', c => c.text(`${method} on response`));
25+
});
26+
27+
// Error routes for direct method registration
28+
METHODS.forEach(method => {
29+
routePatterns[method]('/500', () => {
30+
throw new HTTPException(500, { message: 'response 500' });
31+
});
32+
routePatterns[method]('/401', () => {
33+
throw new HTTPException(401, { message: 'response 401' });
34+
});
35+
routePatterns[method]('/402', () => {
36+
throw new HTTPException(402, { message: 'response 402' });
37+
});
38+
routePatterns[method]('/403', () => {
39+
throw new HTTPException(403, { message: 'response 403' });
40+
});
41+
});
42+
43+
// Error routes for .all()
44+
routePatterns.all('/all/500', () => {
45+
throw new HTTPException(500, { message: 'response 500' });
46+
});
47+
48+
// Error routes for .on()
49+
METHODS.forEach(method => {
50+
routePatterns.on(method, '/on/500', () => {
51+
throw new HTTPException(500, { message: 'response 500' });
52+
});
53+
});
54+
55+
export { routePatterns };

dev-packages/e2e-tests/test-applications/hono-4/src/routes.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import type { Hono } from 'hono';
22
import { HTTPException } from 'hono/http-exception';
33
import { failingMiddleware, middlewareA, middlewareB } from './middleware';
4-
import { middlewareRoutes, subAppWithMiddleware } from './route-groups/test-middleware';
4+
import { middlewareRoutes, subAppWithInlineMiddleware, subAppWithMiddleware } from './route-groups/test-middleware';
5+
import { routePatterns } from './route-groups/test-route-patterns';
56

67
export function addRoutes(app: Hono<{ Bindings?: { E2E_TEST_DSN: string } }>): void {
78
app.get('/', c => {
@@ -36,4 +37,10 @@ export function addRoutes(app: Hono<{ Bindings?: { E2E_TEST_DSN: string } }>): v
3637

3738
// Sub-app middleware: registered on the sub-app, wrapped at mount time by route() patching
3839
app.route('/test-subapp-middleware', subAppWithMiddleware);
40+
41+
// Inline middleware patterns: direct method, .all(), .on() with inline/separate middleware
42+
app.route('/test-inline-middleware', subAppWithInlineMiddleware);
43+
44+
// Route patterns: HTTP methods, .all(), .on(), sync/async, errors
45+
app.route('/test-routes', routePatterns);
3946
}

dev-packages/e2e-tests/test-applications/hono-4/tests/middleware.test.ts

Lines changed: 52 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -97,10 +97,7 @@ for (const { name, prefix } of SCENARIOS) {
9797

9898
test('captures error thrown in middleware', async ({ baseURL }) => {
9999
const errorPromise = waitForError(APP_NAME, event => {
100-
return (
101-
event.exception?.values?.[0]?.value === 'Middleware error' &&
102-
event.exception?.values?.[0]?.mechanism?.type === 'auto.middleware.hono'
103-
);
100+
return event.exception?.values?.[0]?.value === 'Middleware error';
104101
});
105102

106103
const response = await fetch(`${baseURL}${prefix}/error`);
@@ -152,8 +149,8 @@ for (const { name, prefix } of SCENARIOS) {
152149
});
153150
}
154151

155-
test.describe('patchRoute wraps .all() as middleware span (in sub-app)', () => {
156-
test('patchRoute wraps .all() as middleware span', async ({ baseURL }) => {
152+
test.describe('.all() handler in sub-app', () => {
153+
test('does not create middleware span for .all() route handler', async ({ baseURL }) => {
157154
const transactionPromise = waitForTransaction(APP_NAME, event => {
158155
return (
159156
event.contexts?.trace?.op === 'http.server' && event.transaction === 'GET /test-subapp-middleware/all-handler'
@@ -169,20 +166,57 @@ test.describe('patchRoute wraps .all() as middleware span (in sub-app)', () => {
169166
const transaction = await transactionPromise;
170167
const spans = transaction.spans || [];
171168

172-
// On Bun/Cloudflare, patchRoute is the sole wrapper and sees the original
173-
// function name. It wraps .all() handlers identically to .use() middleware
174-
// because both produce method:'ALL' in Hono's route record.
175169
const allHandlerSpan = spans.find(
176170
(span: SpanJSON) => span.op === 'middleware.hono' && span.description === 'allCatchAll',
177171
);
178-
179-
expect(allHandlerSpan).toEqual(
180-
expect.objectContaining({
181-
description: 'allCatchAll',
182-
op: 'middleware.hono',
183-
origin: 'auto.middleware.hono',
184-
status: 'ok',
185-
}),
186-
);
172+
expect(allHandlerSpan).toBeUndefined();
187173
});
188174
});
175+
176+
const INLINE_PREFIX = '/test-inline-middleware';
177+
178+
const REGISTRATION_STYLES = [
179+
{ name: 'direct method (.get())', path: '/direct' },
180+
{ name: '.all()', path: '/all' },
181+
{ name: '.on()', path: '/on' },
182+
] as const;
183+
184+
const MIDDLEWARE_STYLES = [
185+
{ name: 'inline', path: '' },
186+
{ name: 'separately registered', path: '/separately' },
187+
] as const;
188+
189+
test.describe('inline middleware spans (sub-app)', () => {
190+
for (const { name: regName, path: regPath } of REGISTRATION_STYLES) {
191+
for (const { name: mwName, path: mwPath } of MIDDLEWARE_STYLES) {
192+
test(`creates middleware span for ${mwName} middleware via ${regName}`, async ({ baseURL }) => {
193+
const fullPath = `${INLINE_PREFIX}${regPath}${mwPath}`;
194+
195+
const transactionPromise = waitForTransaction(APP_NAME, event => {
196+
return event.contexts?.trace?.op === 'http.server' && event.transaction === `GET ${fullPath}`;
197+
});
198+
199+
const response = await fetch(`${baseURL}${fullPath}`);
200+
expect(response.status).toBe(200);
201+
202+
const transaction = await transactionPromise;
203+
204+
const EXPECTED_DESCRIPTIONS: Record<string, Record<string, string>> = {
205+
'/direct': { '': 'inlineMiddleware', '/separately': 'inlineSeparateMiddleware' },
206+
'/all': { '': 'inlineMiddlewareAll', '/separately': 'inlineSeparateMiddlewareAll' },
207+
'/on': { '': 'inlineMiddlewareOn', '/separately': 'inlineSeparateMiddlewareOn' },
208+
};
209+
const expectedDescription = EXPECTED_DESCRIPTIONS[regPath]![mwPath]!;
210+
211+
expect(transaction.spans).toContainEqual(
212+
expect.objectContaining({
213+
description: expectedDescription,
214+
op: 'middleware.hono',
215+
origin: 'auto.middleware.hono',
216+
status: 'ok',
217+
}),
218+
);
219+
});
220+
}
221+
}
222+
});
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
import { expect, test } from '@playwright/test';
2+
import { waitForError, waitForTransaction } from '@sentry-internal/test-utils';
3+
import { APP_NAME } from './constants';
4+
5+
const PREFIX = '/test-routes';
6+
7+
const REGISTRATION_STYLES = [
8+
{ name: 'direct method', path: '' },
9+
{ name: '.all()', path: '/all' },
10+
{ name: '.on()', path: '/on' },
11+
] as const;
12+
13+
test.describe('HTTP methods', () => {
14+
for (const method of ['POST', 'PUT', 'DELETE', 'PATCH']) {
15+
test(`sends transaction for ${method}`, async ({ baseURL }) => {
16+
const transactionPromise = waitForTransaction(APP_NAME, event => {
17+
return event.contexts?.trace?.op === 'http.server' && event.transaction === `${method} ${PREFIX}`;
18+
});
19+
20+
const response = await fetch(`${baseURL}${PREFIX}`, { method });
21+
expect(response.status).toBe(200);
22+
23+
const transaction = await transactionPromise;
24+
expect(transaction.contexts?.trace?.op).toBe('http.server');
25+
expect(transaction.transaction).toBe(`${method} ${PREFIX}`);
26+
});
27+
}
28+
});
29+
30+
test.describe('route registration styles', () => {
31+
for (const { name, path } of REGISTRATION_STYLES) {
32+
test(`${name} sends transaction`, async ({ baseURL }) => {
33+
const transactionPromise = waitForTransaction(APP_NAME, event => {
34+
return event.contexts?.trace?.op === 'http.server' && event.transaction === `GET ${PREFIX}${path}`;
35+
});
36+
37+
const response = await fetch(`${baseURL}${PREFIX}${path}`);
38+
expect(response.status).toBe(200);
39+
40+
const transaction = await transactionPromise;
41+
expect(transaction.contexts?.trace?.op).toBe('http.server');
42+
expect(transaction.transaction).toBe(`GET ${PREFIX}${path}`);
43+
});
44+
}
45+
46+
for (const { name, path } of [
47+
{ name: '.all()', path: '/all' },
48+
{ name: '.on()', path: '/on' },
49+
]) {
50+
test(`${name} responds to POST`, async ({ baseURL }) => {
51+
const transactionPromise = waitForTransaction(APP_NAME, event => {
52+
return event.contexts?.trace?.op === 'http.server' && event.transaction === `POST ${PREFIX}${path}`;
53+
});
54+
55+
const response = await fetch(`${baseURL}${PREFIX}${path}`, { method: 'POST' });
56+
expect(response.status).toBe(200);
57+
58+
const transaction = await transactionPromise;
59+
expect(transaction.transaction).toBe(`POST ${PREFIX}${path}`);
60+
});
61+
}
62+
});
63+
64+
test('async handler sends transaction', async ({ baseURL }) => {
65+
const transactionPromise = waitForTransaction(APP_NAME, event => {
66+
return event.contexts?.trace?.op === 'http.server' && event.transaction === `GET ${PREFIX}/async`;
67+
});
68+
69+
const response = await fetch(`${baseURL}${PREFIX}/async`);
70+
expect(response.status).toBe(200);
71+
72+
const transaction = await transactionPromise;
73+
expect(transaction.contexts?.trace?.op).toBe('http.server');
74+
});
75+
76+
test.describe('500 HTTPException capture', () => {
77+
for (const { name, path } of REGISTRATION_STYLES) {
78+
test(`captures 500 from ${name} route with correct mechanism`, async ({ baseURL }) => {
79+
const fullPath = `${PREFIX}${path}/500`;
80+
81+
const errorPromise = waitForError(APP_NAME, event => {
82+
return event.exception?.values?.[0]?.value === 'response 500' && !!event.request?.url?.includes(fullPath);
83+
});
84+
85+
const response = await fetch(`${baseURL}${fullPath}`);
86+
expect(response.status).toBe(500);
87+
88+
const errorEvent = await errorPromise;
89+
expect(errorEvent.exception?.values?.[0]?.value).toBe('response 500');
90+
expect(errorEvent.exception?.values?.[0]?.mechanism).toEqual(
91+
expect.objectContaining({
92+
handled: false,
93+
type: 'auto.http.hono.context_error',
94+
}),
95+
);
96+
});
97+
}
98+
99+
test('captures 500 error with POST method', async ({ baseURL }) => {
100+
const errorPromise = waitForError(APP_NAME, event => {
101+
return (
102+
event.exception?.values?.[0]?.value === 'response 500' &&
103+
!!event.request?.url?.includes(`${PREFIX}/500`) &&
104+
event.request?.method === 'POST'
105+
);
106+
});
107+
108+
const response = await fetch(`${baseURL}${PREFIX}/500`, { method: 'POST' });
109+
expect(response.status).toBe(500);
110+
111+
const errorEvent = await errorPromise;
112+
expect(errorEvent.exception?.values?.[0]?.value).toBe('response 500');
113+
expect(errorEvent.exception?.values?.[0]?.mechanism).toEqual(
114+
expect.objectContaining({
115+
handled: false,
116+
type: 'auto.http.hono.context_error',
117+
}),
118+
);
119+
});
120+
});
121+
122+
test.describe('4xx HTTPException capture', () => {
123+
for (const code of [401, 402, 403]) {
124+
test(`captures ${code} HTTPException`, async ({ baseURL }) => {
125+
const fullPath = `${PREFIX}/${code}`;
126+
127+
const errorPromise = waitForError(APP_NAME, event => {
128+
return event.exception?.values?.[0]?.value === `response ${code}` && !!event.request?.url?.includes(fullPath);
129+
});
130+
131+
const response = await fetch(`${baseURL}${fullPath}`);
132+
expect(response.status).toBe(code);
133+
134+
const errorEvent = await errorPromise;
135+
expect(errorEvent.exception?.values?.[0]?.value).toBe(`response ${code}`);
136+
expect(errorEvent.exception?.values?.[0]?.mechanism).toEqual(
137+
expect.objectContaining({
138+
handled: false,
139+
type: 'auto.http.hono.context_error',
140+
}),
141+
);
142+
});
143+
}
144+
});

0 commit comments

Comments
 (0)