Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
e2e00c5
fix(hono): Make the Hono middleware types compile on Hono 5
s1gr1d Oct 16, 2026
a93c25c
build: Deduplicate hono in yarn.lock
s1gr1d Oct 16, 2026
2a39959
ref(hono): Shorten comments
s1gr1d Oct 16, 2026
2980a08
ref(hono): Share the middleware classification
s1gr1d Oct 16, 2026
1de41db
ref(hono): Shorten comments
s1gr1d Oct 16, 2026
a23bb7a
fix(hono): Name routes without c.req.matchedRoutes
s1gr1d Oct 16, 2026
4a75cd3
ref(hono): Shorten comments
s1gr1d Oct 16, 2026
4543984
fix(hono): Name the request after the route handler that responds
s1gr1d Oct 19, 2026
f3a97f2
Merge remote-tracking branch 'origin/develop' into sig/hono-5--1-types
s1gr1d Oct 23, 2026
03e9663
build: Deduplicate hono in pnpm-lock.yaml
s1gr1d Oct 23, 2026
9ae2d2c
Merge branch 'sig/hono-5--1-types' into sig/hono-5--1-classifier
s1gr1d Oct 23, 2026
668df69
fix(hono): Name every tuple member for TypeScript 5.0
s1gr1d Oct 23, 2026
5b4000c
ref(hono): Shorten comments
s1gr1d Oct 27, 2026
b1bd58f
Merge branch 'sig/hono-5--1-classifier' into sig/hono-5--1
s1gr1d Oct 27, 2026
147ce2e
Merge remote-tracking branch 'origin/develop' into sig/hono-5--1
s1gr1d Oct 27, 2026
0f0f801
test(hono): Pass the runtime to the request middleware
s1gr1d Oct 27, 2026
27242ec
Merge remote-tracking branch 'origin/develop' into sig/hono-5--1
s1gr1d Nov 9, 2026
2fc7fa7
fix(hono): Let FunctionToString unwrap the route handler wrapper
s1gr1d Nov 9, 2026
51d9121
fix(hono): Wrap the route handlers when more than one matched
s1gr1d Nov 9, 2026
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 @@ -16,6 +16,7 @@
"dependencies": {
"@sentry/bun": "latest || *",
"@sentry/cloudflare": "latest || *",
"@sentry/core": "latest || *",
"@sentry/deno": "latest || *",
"@sentry/hono": "latest || *",
"@sentry/node": "latest || *",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { captureMessage } from '@sentry/core';
import { Hono } from 'hono';

const routePatterns = new Hono();
Expand Down Expand Up @@ -30,4 +31,24 @@ METHODS.forEach(method => {
routePatterns.on(method, '/on', c => c.text(`${method} on response`));
});

// The catch-all below also matches. An event from inside this handler must still get this route.
routePatterns.get('/capture/:id', c => {
captureMessage('Message from a route handler');
return c.text('captured');
});
routePatterns.get('/capture/*', c => c.text('capture catch-all'));

// Passes `draft` on to the catch-all below, which then names the request.
routePatterns.get('/posts/:id', async (c, next) => {
if (c.req.param('id') === 'draft') {
await next();
return;
}
return c.text('post');
});
routePatterns.get('/posts/*', c => {
captureMessage('Message from a route handler after next()');
return c.text('fallback');
});

export { routePatterns };
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import { expect, test } from '@playwright/test';
import { waitForStreamedSpan, getSpanOp, collectStreamedSpansUntilSegment } from '@sentry-internal/test-utils';
import {
waitForError,
waitForStreamedSpan,
getSpanOp,
collectStreamedSpansUntilSegment,
} from '@sentry-internal/test-utils';
import { APP_NAME } from './constants';

const PREFIX = '/test-routes';
Expand Down Expand Up @@ -176,3 +181,23 @@ test('async handler sends span', async ({ baseURL }) => {
const middlewareSpans = spans.filter(s => getSpanOp(s) === 'middleware');
expect(middlewareSpans).toEqual([]);
});

test('names an event captured in a route handler after that route', async ({ baseURL }) => {
const eventPromise = waitForError(APP_NAME, event => event.message === 'Message from a route handler');

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

const event = await eventPromise;
expect(event.transaction).toBe(`GET ${PREFIX}/capture/:id`);
});

test('names an event from the route handler that runs after next() after that route', async ({ baseURL }) => {
const eventPromise = waitForError(APP_NAME, event => event.message === 'Message from a route handler after next()');

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

const event = await eventPromise;
expect(event.transaction).toBe(`GET ${PREFIX}/posts/*`);
});
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
"dependencies": {
"@sentry/bun": "latest || *",
"@sentry/cloudflare": "latest || *",
"@sentry/core": "latest || *",
"@sentry/deno": "latest || *",
"@sentry/node": "latest || *",
"@hono/node-server": "^2.0.5",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { captureMessage } from '@sentry/core';
import { Hono } from 'hono';

const routePatterns = new Hono();
Expand Down Expand Up @@ -30,4 +31,24 @@ METHODS.forEach(method => {
routePatterns.on(method, '/on', c => c.text(`${method} on response`));
});

// The catch-all below also matches. An event from inside this handler must still get this route.
routePatterns.get('/capture/:id', c => {
captureMessage('Message from a route handler');
return c.text('captured');
});
routePatterns.get('/capture/*', c => c.text('capture catch-all'));

// Passes `draft` on to the catch-all below, which then names the request.
routePatterns.get('/posts/:id', async (c, next) => {
if (c.req.param('id') === 'draft') {
await next();
return;
}
return c.text('post');
});
routePatterns.get('/posts/*', c => {
captureMessage('Message from a route handler after next()');
return c.text('fallback');
});

export { routePatterns };
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import { expect, test } from '@playwright/test';
import { waitForStreamedSpan, getSpanOp, collectStreamedSpansUntilSegment } from '@sentry-internal/test-utils';
import {
waitForError,
waitForStreamedSpan,
getSpanOp,
collectStreamedSpansUntilSegment,
} from '@sentry-internal/test-utils';
import { APP_NAME } from './constants';

const PREFIX = '/test-routes';
Expand Down Expand Up @@ -176,3 +181,23 @@ test('async handler sends span', async ({ baseURL }) => {
const middlewareSpans = spans.filter(s => getSpanOp(s) === 'middleware');
expect(middlewareSpans).toEqual([]);
});

test('names an event captured in a route handler after that route', async ({ baseURL }) => {
const eventPromise = waitForError(APP_NAME, event => event.message === 'Message from a route handler');

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

const event = await eventPromise;
expect(event.transaction).toBe(`GET ${PREFIX}/capture/:id`);
});

test('names an event from the route handler that runs after next() after that route', async ({ baseURL }) => {
const eventPromise = waitForError(APP_NAME, event => event.message === 'Message from a route handler after next()');

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

const event = await eventPromise;
expect(event.transaction).toBe(`GET ${PREFIX}/posts/*`);
});
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { serve } from '@hono/node-server';
import { sentry } from '@sentry/hono/node';
import { captureMessage, sentry } from '@sentry/hono/node';
import { sendPortToRunner } from '@sentry-internal/node-integration-tests';
import { Hono } from 'hono';

Expand All @@ -20,6 +20,61 @@ app.get('/error/:param', () => {
throw new Error('Test error from Hono app');
});

// Both routes match `/users/me`. The name must come from the handler that responded.
app.get('/users/me', c => c.text('me'));
app.get('/users/:id', c => c.text(`user ${c.req.param('id')}`));

// The catch-all below also matches. An event from inside this handler must still get this route.
app.get('/users/:id/capture', c => {
captureMessage('Message from a route handler');
return c.text('captured');
});

// A middleware that returns early. The request gets the route that would have run.
app.use('/early/*', async (c, _next) => c.text('blocked by middleware'));
app.get('/early/:id', c => c.text('never reached'));

// Only a middleware matches this path.
app.use('/mw-only/*', async (_c, _next) => new Response('handled by middleware'));

const subApp = new Hono();
subApp.get('/hello', c => c.text('sub hello'));
app.route('/sub', subApp);

app.basePath('/base').get('/hello', c => c.text('base hello'));

app.all('/any-method', c => c.text(`any method: ${c.req.method}`));

// Hono answers `HEAD` with the `GET` route. The SDK records no server span for `HEAD`, so the test
// checks the name on the error event.
app.get('/head-error/:id', () => {
throw new Error('Test error from a HEAD request');
});

const innerApp = new Hono();
innerApp.get('/item/:itemId', c => c.json({ itemId: c.req.param('itemId') }));
app.get('/outer/:itemId', async c => {
const res = await innerApp.request(`/item/${c.req.param('itemId')}`);
return c.json({ outer: c.req.param('itemId'), inner: await res.json() });
});

// Passes `draft` on to the catch-all below, which then names the request.
app.get('/posts/:id', async (c, next) => {
if (c.req.param('id') === 'draft') {
await next();
return;
}
return c.text('post');
});
app.get('/posts/*', c => {
captureMessage('Message from a route handler after next()');
return c.text('fallback');
});

// A catch-all registered last. It also matches `/users/me` and `/users/42`, but must not name them.
app.get('/users/*', c => c.text('users catch-all'));
app.get('/early/*', c => c.text('never reached'));

serve({ fetch: app.fetch, port: 0 }, info => {
sendPortToRunner(info.port);
});
85 changes: 85 additions & 0 deletions dev-packages/node-integration-tests/suites/hono-sdk/test.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,97 @@
import { afterAll, describe, expect } from 'vitest';
import { cleanupChildProcesses, createEsmAndCjsTests } from '../../utils/runner';

const ROUTE_NAME_CASES: Array<{ method: 'get' | 'post'; path: string; name: string; route: string }> = [
{ method: 'get', path: '/users/me', name: 'GET /users/me', route: '/users/me' },
{ method: 'get', path: '/users/42', name: 'GET /users/:id', route: '/users/:id' },
{ method: 'get', path: '/users/42/posts', name: 'GET /users/*', route: '/users/*' },
{ method: 'get', path: '/early/1', name: 'GET /early/:id', route: '/early/:id' },

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

in case the middleware returns early, we still get a parametrized route.

{ method: 'get', path: '/posts/1', name: 'GET /posts/:id', route: '/posts/:id' },
{ method: 'get', path: '/posts/draft', name: 'GET /posts/*', route: '/posts/*' },
{ method: 'get', path: '/mw-only/anything', name: 'GET /mw-only/*', route: '/mw-only/*' },
{ method: 'get', path: '/sub/hello', name: 'GET /sub/hello', route: '/sub/hello' },
{ method: 'get', path: '/base/hello', name: 'GET /base/hello', route: '/base/hello' },
{ method: 'post', path: '/any-method', name: 'POST /any-method', route: '/any-method' },
{ method: 'get', path: '/outer/self-watering-plant', name: 'GET /outer/:itemId', route: '/outer/:itemId' },
];

describe('hono-sdk (Node)', () => {
afterAll(() => {
cleanupChildProcesses();
});

createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => {
// All cases in one test, because each `test()` starts its own Node process.
test('names each request after the route of the handler that responded', async () => {
const runner = createRunner().unordered();
for (const { name, route } of ROUTE_NAME_CASES) {
runner.expect({
span: container => {
const segment = container.items.find(item => item.is_segment && item.name === name);
if (!segment) {
throw new Error(`segment for \`${name}\` not in this container`);
}
expect(segment.attributes?.['http.route']).toEqual({ type: 'string', value: route });
expect(segment.attributes?.['sentry.segment.name.source']).toEqual({ type: 'string', value: 'route' });
},
});
}

const started = runner.start();
for (const { method, path } of ROUTE_NAME_CASES) {
await started.makeRequest(method, path);
}
await started.completed();
});

test('names an event captured in a route handler after that route', async () => {
const runner = createRunner()
.ignore('span')
.expect({ event: { message: 'Message from a route handler', transaction: 'GET /users/:id/capture' } })
.start();
runner.makeRequest('get', '/users/42/capture');
await runner.completed();
});

test('keeps the runtime name for a request without a route', async () => {
const runner = createRunner()
.expect({
span: container => {
const segment = container.items.find(item => item.is_segment);
expect(segment?.name).toBe('GET');
expect(segment?.attributes?.['sentry.segment.name.source']).toEqual({ type: 'string', value: 'url' });
expect(segment?.attributes?.['url.path']).toEqual({ type: 'string', value: '/does-not-exist' });
expect(segment?.attributes?.['http.route']).toBeUndefined();
},
})
.start();
runner.makeRequest('get', '/does-not-exist', { expectError: true });
await runner.completed();
});

test('names an event from the route handler that runs after next() after that route', async () => {
const runner = createRunner()
.ignore('span')
.expect({ event: { message: 'Message from a route handler after next()', transaction: 'GET /posts/*' } })
.start();
runner.makeRequest('get', '/posts/draft');
await runner.completed();
});

test('names a HEAD request after the GET route', async () => {
const runner = createRunner()
.ignore('span')
.expect({
event: {
exception: { values: [{ value: 'Test error from a HEAD request' }] },
transaction: 'HEAD /head-error/:id',
},
})
.start();
runner.makeRequest('head', '/head-error/1', { expectError: true });
await runner.completed();
});

test('creates a segment span for a basic GET request', async () => {
const runner = createRunner()
.expect({
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { serve } from '@hono/node-server';
import * as Sentry from '@sentry/node';
import { sendPortToRunner } from '@sentry-internal/node-integration-tests';
import { Hono } from 'hono';

// The auto-instrumentation and the manual `honoMiddleware(app)` are both active.
const app = new Hono();
app.use(Sentry.honoMiddleware(app));

// Counts the Sentry wrappers around the handler that runs. Both setups wrap, and the router reuses the
// handler list for each request.
function wrapperCount(c) {
const [matchResultKey] = Object.getOwnPropertySymbols(Object.getPrototypeOf(c.req));
let handler = c.req[matchResultKey][0][c.req.routeIndex][0][0];
let count = 0;
while (handler.__sentry_original__) {
handler = handler.__sentry_original__;
count++;
}
return count;
}

// Only one route handler matches, so the SDK does not wrap.
app.get('/users/me', c => c.json({ wrappers: wrapperCount(c) }));

// Two route handlers match, so the SDK wraps them.
app.get('/posts/:id', async (c, next) => {
if (c.req.param('id') === 'draft') {
await next();
return;
}
return c.json({ wrappers: wrapperCount(c) });
});
app.get('/posts/*', c => c.json({ wrappers: wrapperCount(c) }));

serve({ fetch: app.fetch, port: 0 }, info => {
sendPortToRunner(info.port);
});
Loading
Loading