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
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ middlewareRoutes.get('/anonymous', c => c.json({ middleware: 'anonymous' }));
middlewareRoutes.get('/multi', c => c.json({ middleware: 'multi' }));
middlewareRoutes.get('/error', c => c.text('should not reach'));

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

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

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

subAppWithMiddleware.route('/', middlewareRoutes);

export { middlewareRoutes, subAppWithMiddleware };
// Sub-app with inline middleware for different registration styles.
// patchRoute wraps non-last handlers per method+path group as middleware.
const subAppWithInlineMiddleware = new Hono();

const METHODS = ['get', 'post', 'put', 'delete', 'patch'] as const;

// Direct method registration for each HTTP method
METHODS.forEach(method => {
subAppWithInlineMiddleware[method](
'/direct',
async function inlineMiddleware(_c, next) {
await next();
},
c => c.text(`${method} direct response`),
);

subAppWithInlineMiddleware[method]('/direct/separately', async function inlineSeparateMiddleware(_c, next) {
await next();
});
subAppWithInlineMiddleware[method]('/direct/separately', c => c.text(`${method} direct separate response`));
});

// .all(): .all('/path', mw, handler)
subAppWithInlineMiddleware.all(
'/all',
async function inlineMiddlewareAll(_c, next) {
await next();
},
c => c.text('all response'),
);
subAppWithInlineMiddleware.all('/all/separately', async function inlineSeparateMiddlewareAll(_c, next) {
await next();
});
subAppWithInlineMiddleware.all('/all/separately', c => c.text('all separate response'));

// .on() registration for each HTTP method
METHODS.forEach(method => {
subAppWithInlineMiddleware.on(
method,
'/on',
async function inlineMiddlewareOn(_c, next) {
await next();
},
c => c.text(`${method} on response`),
);

subAppWithInlineMiddleware.on(method, '/on/separately', async function inlineSeparateMiddlewareOn(_c, next) {
await next();
});
subAppWithInlineMiddleware.on(method, '/on/separately', c => c.text(`${method} on separate response`));
});

export { middlewareRoutes, subAppWithMiddleware, subAppWithInlineMiddleware };
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { Hono } from 'hono';
import { HTTPException } from 'hono/http-exception';

const routePatterns = new Hono();

const METHODS = ['get', 'post', 'put', 'delete', 'patch'] as const;

// Direct method registration for each HTTP method (sync handlers)
METHODS.forEach(method => {
routePatterns[method]('/', c => c.text(`${method} response`));
});

// Async handler
routePatterns.get('/async', async c => {
await new Promise(resolve => setTimeout(resolve, 10));
return c.text('async response');
});

// .all() registration
routePatterns.all('/all', c => c.text('all handler response'));

// .on() registration
METHODS.forEach(method => {
routePatterns.on(method, '/on', c => c.text(`${method} on response`));
});

// Error routes for direct method registration
METHODS.forEach(method => {
routePatterns[method]('/500', () => {
throw new HTTPException(500, { message: 'response 500' });
});
routePatterns[method]('/401', () => {
throw new HTTPException(401, { message: 'response 401' });
});
routePatterns[method]('/402', () => {
throw new HTTPException(402, { message: 'response 402' });
});
routePatterns[method]('/403', () => {
throw new HTTPException(403, { message: 'response 403' });
});
});

// Error routes for .all()
routePatterns.all('/all/500', () => {
throw new HTTPException(500, { message: 'response 500' });
});

// Error routes for .on()
METHODS.forEach(method => {
routePatterns.on(method, '/on/500', () => {
throw new HTTPException(500, { message: 'response 500' });
});
});

export { routePatterns };
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import type { Hono } from 'hono';
import { HTTPException } from 'hono/http-exception';
import { failingMiddleware, middlewareA, middlewareB } from './middleware';
import { middlewareRoutes, subAppWithMiddleware } from './route-groups/test-middleware';
import { middlewareRoutes, subAppWithInlineMiddleware, subAppWithMiddleware } from './route-groups/test-middleware';
import { routePatterns } from './route-groups/test-route-patterns';

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

// Sub-app middleware: registered on the sub-app, wrapped at mount time by route() patching
app.route('/test-subapp-middleware', subAppWithMiddleware);

// Inline middleware patterns: direct method, .all(), .on() with inline/separate middleware
app.route('/test-inline-middleware', subAppWithInlineMiddleware);

// Route patterns: HTTP methods, .all(), .on(), sync/async, errors
app.route('/test-routes', routePatterns);
}
Original file line number Diff line number Diff line change
Expand Up @@ -97,10 +97,7 @@ for (const { name, prefix } of SCENARIOS) {

test('captures error thrown in middleware', async ({ baseURL }) => {
const errorPromise = waitForError(APP_NAME, event => {
return (
event.exception?.values?.[0]?.value === 'Middleware error' &&
event.exception?.values?.[0]?.mechanism?.type === 'auto.middleware.hono'
);
return event.exception?.values?.[0]?.value === 'Middleware error';
});

const response = await fetch(`${baseURL}${prefix}/error`);
Expand Down Expand Up @@ -152,8 +149,8 @@ for (const { name, prefix } of SCENARIOS) {
});
}

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

// On Bun/Cloudflare, patchRoute is the sole wrapper and sees the original
// function name. It wraps .all() handlers identically to .use() middleware
// because both produce method:'ALL' in Hono's route record.
const allHandlerSpan = spans.find(
(span: SpanJSON) => span.op === 'middleware.hono' && span.description === 'allCatchAll',
);

expect(allHandlerSpan).toEqual(
expect.objectContaining({
description: 'allCatchAll',
op: 'middleware.hono',
origin: 'auto.middleware.hono',
status: 'ok',
}),
);
// No middleware is called for this route, so there should be no spans.
expect(spans).toEqual([]);
});
});

const INLINE_PREFIX = '/test-inline-middleware';

const REGISTRATION_STYLES = [
{ name: 'direct method (.get())', path: '/direct' },
{ name: '.all()', path: '/all' },
{ name: '.on()', path: '/on' },
] as const;

const MIDDLEWARE_STYLES = [
{ name: 'inline', path: '' },
{ name: 'separately registered', path: '/separately' },
] as const;

test.describe('inline middleware spans (sub-app)', () => {
for (const { name: regName, path: regPath } of REGISTRATION_STYLES) {
for (const { name: mwName, path: mwPath } of MIDDLEWARE_STYLES) {
test(`creates middleware span for ${mwName} middleware via ${regName}`, async ({ baseURL }) => {
const fullPath = `${INLINE_PREFIX}${regPath}${mwPath}`;

const transactionPromise = waitForTransaction(APP_NAME, event => {
return event.contexts?.trace?.op === 'http.server' && event.transaction === `GET ${fullPath}`;
});

const response = await fetch(`${baseURL}${fullPath}`);
expect(response.status).toBe(200);

const transaction = await transactionPromise;

const EXPECTED_DESCRIPTIONS: Record<string, Record<string, string>> = {
'/direct': { '': 'inlineMiddleware', '/separately': 'inlineSeparateMiddleware' },
'/all': { '': 'inlineMiddlewareAll', '/separately': 'inlineSeparateMiddlewareAll' },
'/on': { '': 'inlineMiddlewareOn', '/separately': 'inlineSeparateMiddlewareOn' },
};
const expectedDescription = EXPECTED_DESCRIPTIONS[regPath]![mwPath]!;

expect(transaction.spans).toContainEqual(
expect.objectContaining({
description: expectedDescription,
op: 'middleware.hono',
origin: 'auto.middleware.hono',
status: 'ok',
}),
);
});
}
}
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import { expect, test } from '@playwright/test';
import { waitForError, waitForTransaction } from '@sentry-internal/test-utils';
import { APP_NAME } from './constants';

const PREFIX = '/test-routes';

const REGISTRATION_STYLES = [
{ name: 'direct method', path: '' },
{ name: '.all()', path: '/all' },
{ name: '.on()', path: '/on' },
] as const;

test.describe('HTTP methods', () => {
for (const method of ['POST', 'PUT', 'DELETE', 'PATCH']) {
test(`sends transaction for ${method}`, async ({ baseURL }) => {
const transactionPromise = waitForTransaction(APP_NAME, event => {
return event.contexts?.trace?.op === 'http.server' && event.transaction === `${method} ${PREFIX}`;
});

const response = await fetch(`${baseURL}${PREFIX}`, { method });
expect(response.status).toBe(200);

const transaction = await transactionPromise;
expect(transaction.contexts?.trace?.op).toBe('http.server');
expect(transaction.transaction).toBe(`${method} ${PREFIX}`);
});
}
});

test.describe('route registration styles', () => {
for (const { name, path } of REGISTRATION_STYLES) {
test(`${name} sends transaction`, async ({ baseURL }) => {
const transactionPromise = waitForTransaction(APP_NAME, event => {
return event.contexts?.trace?.op === 'http.server' && event.transaction === `GET ${PREFIX}${path}`;
});

const response = await fetch(`${baseURL}${PREFIX}${path}`);
expect(response.status).toBe(200);

const transaction = await transactionPromise;
expect(transaction.contexts?.trace?.op).toBe('http.server');
expect(transaction.transaction).toBe(`GET ${PREFIX}${path}`);
});
}

for (const { name, path } of [
{ name: '.all()', path: '/all' },
{ name: '.on()', path: '/on' },
]) {
test(`${name} responds to POST`, async ({ baseURL }) => {
const transactionPromise = waitForTransaction(APP_NAME, event => {
return event.contexts?.trace?.op === 'http.server' && event.transaction === `POST ${PREFIX}${path}`;
});

const response = await fetch(`${baseURL}${PREFIX}${path}`, { method: 'POST' });
expect(response.status).toBe(200);

const transaction = await transactionPromise;
expect(transaction.transaction).toBe(`POST ${PREFIX}${path}`);
});
}
});

test('async handler sends transaction', async ({ baseURL }) => {
const transactionPromise = waitForTransaction(APP_NAME, event => {
return event.contexts?.trace?.op === 'http.server' && event.transaction === `GET ${PREFIX}/async`;
});

const response = await fetch(`${baseURL}${PREFIX}/async`);
expect(response.status).toBe(200);

const transaction = await transactionPromise;
expect(transaction.contexts?.trace?.op).toBe('http.server');
});

test.describe('500 HTTPException capture', () => {
for (const { name, path } of REGISTRATION_STYLES) {
test(`captures 500 from ${name} route with correct mechanism`, async ({ baseURL }) => {
const fullPath = `${PREFIX}${path}/500`;

const errorPromise = waitForError(APP_NAME, event => {
return event.exception?.values?.[0]?.value === 'response 500' && !!event.request?.url?.includes(fullPath);
});

const response = await fetch(`${baseURL}${fullPath}`);
expect(response.status).toBe(500);

const errorEvent = await errorPromise;
expect(errorEvent.exception?.values?.[0]?.value).toBe('response 500');
expect(errorEvent.exception?.values?.[0]?.mechanism).toEqual(
expect.objectContaining({
handled: false,
type: 'auto.http.hono.context_error',
}),
);
});
}

test('captures 500 error with POST method', async ({ baseURL }) => {
const errorPromise = waitForError(APP_NAME, event => {
return (
event.exception?.values?.[0]?.value === 'response 500' &&
!!event.request?.url?.includes(`${PREFIX}/500`) &&
event.request?.method === 'POST'
);
});

const response = await fetch(`${baseURL}${PREFIX}/500`, { method: 'POST' });
expect(response.status).toBe(500);

const errorEvent = await errorPromise;
expect(errorEvent.exception?.values?.[0]?.value).toBe('response 500');
expect(errorEvent.exception?.values?.[0]?.mechanism).toEqual(
expect.objectContaining({
handled: false,
type: 'auto.http.hono.context_error',
}),
);
});
});

test.describe('4xx HTTPException capture', () => {
for (const code of [401, 402, 403]) {
test(`captures ${code} HTTPException`, async ({ baseURL }) => {
const fullPath = `${PREFIX}/${code}`;

const errorPromise = waitForError(APP_NAME, event => {
return event.exception?.values?.[0]?.value === `response ${code}` && !!event.request?.url?.includes(fullPath);
});

const response = await fetch(`${baseURL}${fullPath}`);
expect(response.status).toBe(code);

const errorEvent = await errorPromise;
expect(errorEvent.exception?.values?.[0]?.value).toBe(`response ${code}`);
expect(errorEvent.exception?.values?.[0]?.mechanism).toEqual(
expect.objectContaining({
handled: false,
type: 'auto.http.hono.context_error',
}),
);
});
}
});
Loading
Loading