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
33 changes: 33 additions & 0 deletions dev-packages/e2e-tests/test-applications/hono-4/src/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,39 @@ export function addRoutes(app: HonoType<{ Bindings?: { E2E_TEST_DSN: string } }>
// Inline middleware patterns: direct method, .all(), .on() with inline/separate middleware
app.route('/test-inline-middleware', subAppWithInlineMiddleware);

// Inline middleware on the main app via HTTP method registration (not .use()).
app.get(
'/test-main-inline/get',
async function mainInlineGet(_c, next) {
await next();
},
c => c.text('main inline get'),
);
app.post(
'/test-main-inline/post',
async function mainInlinePost(_c, next) {
await next();
},
c => c.text('main inline post'),
);
app.all(
'/test-main-inline/all',
async function mainInlineAll(_c, next) {
await next();
},
c => c.text('main inline all'),
);

// Combined: .use() middleware + inline middleware via .get() on the same path.
app.use('/test-main-inline/combined/*', middlewareA);
app.get(
'/test-main-inline/combined/resource',
async function combinedInlineMw(_c, next) {
await next();
},
c => c.text('combined response'),
);

// Route patterns: HTTP methods, .all(), .on(), sync/async, errors
app.route('/test-routes', routePatterns);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ test.describe('route handler errors', () => {
expect(errorEvent.transaction).toBe('GET /error/:cause');
expect(errorEvent.request?.method).toBe('GET');
expect(errorEvent.request?.url).toContain('/error/test-cause');
expect(errorEvent.request?.headers).toBeDefined();

expect(errorEvent.contexts?.trace?.trace_id).toBe(transactionEvent.contexts?.trace?.trace_id);
});
Expand Down Expand Up @@ -217,7 +218,9 @@ test.describe('nested sub-app errors', () => {
handled: false,
type: 'auto.http.hono.context_error',
});
expect(errorEvent.request?.method).toBe('GET');
expect(errorEvent.request?.url).toContain('/test-errors/nested/child/error');
expect(errorEvent.request?.headers).toBeDefined();
});
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -248,3 +248,70 @@ test.describe('inline middleware spans (sub-app)', () => {
}
}
});

const MAIN_INLINE_PREFIX = '/test-main-inline';

const MAIN_INLINE_CASES = [
{ name: '.get()', path: '/get', method: 'GET', expectedMiddlewareName: 'mainInlineGet' },
{ name: '.post()', path: '/post', method: 'POST', expectedMiddlewareName: 'mainInlinePost' },
{ name: '.all()', path: '/all', method: 'GET', expectedMiddlewareName: 'mainInlineAll' },
] as const;

test.describe('inline middleware spans (main app)', () => {
MAIN_INLINE_CASES.forEach(({ name, path, method, expectedMiddlewareName }) => {
test(`creates middleware span for inline middleware via ${name}`, async ({ baseURL }) => {
const fullPath = `${MAIN_INLINE_PREFIX}${path}`;

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

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

const transaction = await transactionPromise;
expect(transaction.transaction).toBe(`${method} ${fullPath}`);

const spans = transaction.spans || [];
const inlineSpan = spans.find(s => s.description === expectedMiddlewareName);

expect(inlineSpan).toBeDefined();
expect(inlineSpan?.op).toBe('middleware.hono');
expect(inlineSpan?.origin).toBe('auto.middleware.hono');
expect(inlineSpan?.status).not.toBe('internal_error');

const middlewareSpans = spans.filter(s => s.op === 'middleware.hono');
expect(middlewareSpans).toHaveLength(1);
});
});

test('creates spans for both .use() middleware and inline middleware via .get()', async ({ baseURL }) => {
const fullPath = `${MAIN_INLINE_PREFIX}/combined/resource`;

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;
expect(transaction.transaction).toBe(`GET ${fullPath}`);

const spans = transaction.spans || [];
const middlewareSpans = spans.filter(s => s.op === 'middleware.hono');

expect(middlewareSpans).toHaveLength(2);

const [spanA, spanB] = middlewareSpans.sort((a, b) => (a.description ?? '').localeCompare(b.description ?? ''));
expect(spanA?.description).toBe('combinedInlineMw');
expect(spanA?.op).toBe('middleware.hono');
expect(spanA?.origin).toBe('auto.middleware.hono');
expect(spanA?.status).not.toBe('internal_error');

expect(spanB?.description).toBe('middlewareA');
expect(spanB?.op).toBe('middleware.hono');
expect(spanB?.origin).toBe('auto.middleware.hono');
expect(spanB?.status).not.toBe('internal_error');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,10 @@ const REGISTRATION_STYLES = [
] as const;

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

const response = await fetch(`${baseURL}${PREFIX}`, { method });
Expand All @@ -23,15 +23,20 @@ test.describe('HTTP methods', () => {
const transaction = await transactionPromise;
expect(transaction.transaction).toBe(`${method} ${PREFIX}`);
expect(transaction.contexts?.trace?.op).toBe('http.server');
expect(transaction.contexts?.trace?.data?.['sentry.source']).toBe('route');

const spans = transaction.spans || [];
const middlewareSpans = spans.filter(s => s.op === 'middleware.hono');
expect(middlewareSpans).toEqual([]);
});
});
});

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

const response = await fetch(`${baseURL}${PREFIX}${path}`);
Expand All @@ -40,6 +45,11 @@ test.describe('route registration styles', () => {
const transaction = await transactionPromise;
expect(transaction.transaction).toBe(`GET ${PREFIX}${path}`);
expect(transaction.contexts?.trace?.op).toBe('http.server');
expect(transaction.contexts?.trace?.data?.['sentry.source']).toBe('route');

const spans = transaction.spans || [];
const middlewareSpans = spans.filter(s => s.op === 'middleware.hono');
expect(middlewareSpans).toEqual([]);
});
});

Expand All @@ -49,21 +59,74 @@ test.describe('route registration styles', () => {
].forEach(({ name, path }) => {
test(`${name} responds to POST`, async ({ baseURL }) => {
const transactionPromise = waitForTransaction(APP_NAME, event => {
return event.contexts?.trace?.op === 'http.server' && !!event.transaction?.includes(`${PREFIX}${path}`);
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}`);
expect(transaction.contexts?.trace?.data?.['sentry.source']).toBe('route');

const spans = transaction.spans || [];
const middlewareSpans = spans.filter(s => s.op === 'middleware.hono');
expect(middlewareSpans).toEqual([]);
});
});
});

test.describe('request data extraction', () => {
test('includes method, url, and headers on transaction', async ({ baseURL }) => {
const transactionPromise = waitForTransaction(APP_NAME, event => {
return event.contexts?.trace?.op === 'http.server' && event.transaction === `GET ${PREFIX}`;
});

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

const transaction = await transactionPromise;
expect(transaction.request?.method).toBe('GET');
expect(transaction.request?.url).toContain(PREFIX);
expect(transaction.request?.headers).toBeDefined();
});

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

const response = await fetch(`${baseURL}${PREFIX}?foo=bar&baz=42`);
expect(response.status).toBe(200);

const transaction = await transactionPromise;

expect(transaction.request?.method).toBe('GET');
expect(transaction.request?.url).toContain(PREFIX);
expect(transaction.request?.query_string).toBe('foo=bar&baz=42');
});

test('includes request data for POST with headers', async ({ baseURL }) => {
const transactionPromise = waitForTransaction(APP_NAME, event => {
return event.contexts?.trace?.op === 'http.server' && event.transaction === `POST ${PREFIX}`;
});

const response = await fetch(`${baseURL}${PREFIX}`, {
method: 'POST',
headers: { 'X-Custom-Header': 'test-value' },
});
expect(response.status).toBe(200);

const transaction = await transactionPromise;
expect(transaction.request?.method).toBe('POST');
expect(transaction.request?.url).toContain(PREFIX);
expect(transaction.request?.headers?.['x-custom-header']).toBe('test-value');
});
});

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

const response = await fetch(`${baseURL}${PREFIX}/async`);
Expand All @@ -72,4 +135,9 @@ test('async handler sends transaction', async ({ baseURL }) => {
const transaction = await transactionPromise;
expect(transaction.transaction).toBe(`GET ${PREFIX}/async`);
expect(transaction.contexts?.trace?.op).toBe('http.server');
expect(transaction.contexts?.trace?.data?.['sentry.source']).toBe('route');

const spans = transaction.spans || [];
const middlewareSpans = spans.filter(s => s.op === 'middleware.hono');
expect(middlewareSpans).toEqual([]);
});
5 changes: 4 additions & 1 deletion packages/hono/src/shared/applyPatches.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { debug } from '@sentry/core';
import type { Env, Hono } from 'hono';
import { DEBUG_BUILD } from '../debug-build';
import { patchAppRequest } from './patchAppRequest';
import { patchAppUse } from './patchAppUse';
import { patchAppUse, patchHttpMethodHandlers } from './patchAppUse';
import { type HonoRoute, type RouteHookHandle, installRouteHookOnPrototype, wrapSubAppMiddleware } from './patchRoute';

// Lazily set by the first call to earlyPatchHono or applyPatches.
Expand Down Expand Up @@ -31,6 +31,9 @@ export function applyPatches<E extends Env>(app: Hono<E>): void {
// `app.use` (instance own property) — wraps middleware at registration time on this instance.
patchAppUse(app);

// `app.get`, `app.post`, … (instance own properties) — wraps inline middleware (all-but-last handler).
patchHttpMethodHandlers(app);

patchAppRequest(app);

_routeHook.activate();
Expand Down
61 changes: 58 additions & 3 deletions packages/hono/src/shared/patchAppUse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,11 @@ import { DEBUG_BUILD } from '../debug-build';
import { wrapMiddlewareWithSpan } from './wrapMiddlewareSpan';

// oxlint-disable-next-line typescript/no-explicit-any
const patchedInstances = new WeakSet<Hono<any>>();
const patchedUseInstances = new WeakSet<Hono<any>>();
// oxlint-disable-next-line typescript/no-explicit-any
const patchedMethodInstances = new WeakSet<Hono<any>>();

const HTTP_METHODS = ['get', 'post', 'put', 'delete', 'options', 'patch', 'all'] as const;

/**
* Patches `app.use` (instance own property) on a Hono instance to instrument middleware at registration time.
Expand All @@ -13,12 +17,12 @@ const patchedInstances = new WeakSet<Hono<any>>();
* Idempotent.
*/
export function patchAppUse<E extends Env>(app: Hono<E>): void {
if (patchedInstances.has(app)) {
if (patchedUseInstances.has(app)) {
DEBUG_BUILD && debug.log('[hono] app.use already patched — skipping.');
return;
}

patchedInstances.add(app);
patchedUseInstances.add(app);

app.use = new Proxy(app.use, {
apply(target: typeof app.use, thisArg: typeof app, args: Parameters<typeof app.use>): ReturnType<typeof app.use> {
Expand All @@ -34,3 +38,54 @@ export function patchAppUse<E extends Env>(app: Hono<E>): void {
},
});
}

/**
* Patches HTTP method class fields (get, post, put, delete, options, patch, all) to instrument inline middleware at registration time.
*
* For `app.get('/path', mw1, mw2, handler)`, all handlers except the last are middleware and get wrapped with spans.
* The final handler (the route handler) is already covered by the root http.server transaction.
*/
export function patchHttpMethodHandlers<E extends Env>(app: Hono<E>): void {
if (patchedMethodInstances.has(app)) {
DEBUG_BUILD && debug.log('[hono] HTTP method handlers already patched - skipping.');
return;
}

patchedMethodInstances.add(app);

for (const method of HTTP_METHODS) {
app[method] = new Proxy(app[method], {
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: For patchAppUse you prevented double wrapping with a check at this point

Copy link
Copy Markdown
Member Author

@s1gr1d s1gr1d May 26, 2026

Choose a reason for hiding this comment

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

We check for double wrapping in wrapMiddlewareWithSpan in line 24. The function is called by patchHttpMethodHandlers. But I'll add a test so we can be sure it produces only one span.

But for tidiness it's probably worth monitoring what was already patched, so we don't accumulate multiple proxy layers (even though those would still just create one span).

apply(target, thisArg, args: unknown[]) {
return Reflect.apply(target, thisArg, wrapInlineMiddleware(args));
},
});
}
Comment thread
s1gr1d marked this conversation as resolved.

app.on = new Proxy(app.on, {
apply(target, thisArg, args: unknown[]) {
// .on(method, path, ...handlers) — first two args are method and path
const [method, path, ...handlers] = args;
return Reflect.apply(target, thisArg, [method, path, ...wrapInlineMiddleware(handlers)]);
},
});
}

/**
* Given `[path?, ...handlers]` or `[...handlers]`, wraps all handlers except the last one with spans.
* The last handler is the route handler and is left as-is.
*/
function wrapInlineMiddleware(args: unknown[]): unknown[] {
const hasPathPrefix = typeof args[0] === 'string';
const handlersStart = hasPathPrefix ? 1 : 0;
const handlers = args.slice(handlersStart) as MiddlewareHandler[];

if (handlers.length <= 1) {
return args;
}

const wrapped = [...args];
for (let i = handlersStart; i < wrapped.length - 1; i++) {
wrapped[i] = wrapMiddlewareWithSpan(wrapped[i] as MiddlewareHandler);
}
return wrapped;
}
Loading
Loading