Skip to content
Open
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: Hono<{ Bindings?: { E2E_TEST_DSN: string } }>): v
// 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
46 changes: 46 additions & 0 deletions packages/hono/src/shared/patchAppUse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import { wrapMiddlewareWithSpan } from './wrapMiddlewareSpan';
// oxlint-disable-next-line typescript/no-explicit-any
const patchedInstances = 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 Down Expand Up @@ -34,3 +36,47 @@ 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 {
for (const method of HTTP_METHODS) {
app[method] = new Proxy(app[method], {
apply(target, thisArg, args: unknown[]) {
return Reflect.apply(target, thisArg, wrapInlineMiddleware(args));
},
});
}

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