Skip to content
Draft
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 @@ -9,12 +9,14 @@
"dev:bun": "bun src/entry.bun.ts",
"dev:deno": "deno run --allow-net --allow-env --allow-read src/entry.deno.ts",
"build": "wrangler deploy --dry-run",
"test:build": "pnpm install && pnpm build",
"test:build": "pnpm install && pnpm typecheck && pnpm build",
"typecheck": "tsc --noEmit -p tsconfig.typecheck.json",
"test:assert": "TEST_ENV=production playwright test"
},
"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 with `next()`. The catch-all responds, so it 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
@@ -0,0 +1,19 @@
// Only `tsc` in `test:build` reads this file. Nothing runs it.
import { sentry as sentryBun } from '@sentry/hono/bun';
import { sentry as sentryCloudflare } from '@sentry/hono/cloudflare';
import { sentry as sentryDeno } from '@sentry/hono/deno';
import { sentry as sentryNode } from '@sentry/hono/node';
import { Hono } from 'hono';

type AppEnv = { Bindings: { E2E_TEST_DSN: string }; Variables: { user: string } };

const dsn = 'https://public@dsn.ingest.sentry.io/1337';

const app = new Hono<AppEnv>();
app.use(sentryNode(app));
app.use(sentryBun(app, { dsn }));
app.use(sentryDeno(app, { dsn }));
app.use(sentryCloudflare(app, env => ({ dsn: env.E2E_TEST_DSN })));

const api = app.basePath('/api');
api.use(sentryNode(api));
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
@@ -0,0 +1,5 @@
{
// The rest of `src` needs Node, Bun and Deno types, which this app does not install.
"extends": "./tsconfig.json",
"include": ["src/*.typecheck.ts"]
}
4 changes: 3 additions & 1 deletion dev-packages/e2e-tests/test-applications/hono-4/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@
"dev:bun": "bun run dist/entry.bun.js",
"dev:deno": "deno run --allow-net --allow-env --allow-read --preload=npm:@sentry/deno/import src/entry.deno.ts",
"dev:cloudflare": "wrangler dev --config ./dist/hono_4/wrangler.json --var \"E2E_TEST_DSN:$E2E_TEST_DSN\" --log-level=$(test $CI && echo 'none' || echo 'log')",
"test:build": "pnpm install",
"test:build": "pnpm install && pnpm typecheck",
"typecheck": "tsc --noEmit -p tsconfig.typecheck.json",
"test": "TEST_ENV=production playwright test",
"test:assert": "pnpm test:assert:node",
"test:assert:node": "RUNTIME=node pnpm test",
Expand All @@ -19,6 +20,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
@@ -0,0 +1,12 @@
// Only `tsc` in `test:build` reads this file. Nothing runs it.
import * as Sentry from '@sentry/node';
import { Hono } from 'hono';

type AppEnv = { Bindings: { E2E_TEST_DSN: string }; Variables: { user: string } };

const app = new Hono<AppEnv>();
app.use(Sentry.honoMiddleware(app));
app.use('*', Sentry.honoMiddleware(app));

const api = app.basePath('/api');
api.use(Sentry.honoMiddleware(api));
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 with `next()`. The catch-all responds, so it 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
@@ -0,0 +1,5 @@
{
// The rest of `src` needs Node, Bun and Deno types, which this app does not install.
"extends": "./tsconfig.json",
"include": ["src/*.typecheck.ts"]
}
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,62 @@ 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. No route handler runs, so the name falls back to the last matched
// route handler.
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 with `next()`. The catch-all responds, so it 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' },
{ 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
Loading