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 @@ -9,7 +9,8 @@
"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": {
Expand Down
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
@@ -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"]
}
3 changes: 2 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 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
@@ -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"]
}
3 changes: 2 additions & 1 deletion packages/hono/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,8 @@
"@sentry/deno": "11.9.0",
"@sentry/node": "11.9.0",
"@types/node": "^20.19.0",
"hono": "^4.13.7",
"hono": "4.13.9",
"hono-5": "npm:hono@5.0.0-rc.1",
"wrangler": "4.62.0"
},
"scripts": {
Expand Down
57 changes: 57 additions & 0 deletions packages/hono/test/types.test-d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { honoMiddleware } from '@sentry/server-utils';
import { Hono } from 'hono';
import type { Hono as Hono5 } from 'hono-5';
import { describe, it } from 'vitest';
import { sentry as sentryBun } from '../src/bun/middleware';
import { sentry as sentryCloudflare } from '../src/cloudflare/middleware';
import { sentry as sentryDeno } from '../src/deno/middleware';
import { sentry as sentryNode } from '../src/node/middleware';

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

declare const app5: Hono5<AppEnv>;

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

describe('Hono 4', () => {
it('accepts honoMiddleware(app)', () => {
const app = new Hono<AppEnv>();
app.use(honoMiddleware(app));

const api = app.basePath('/api');
api.use(honoMiddleware(api));
});

it('accepts sentry(app) from each runtime entry', () => {
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.SENTRY_DSN })));

const api = app.basePath('/api');
api.use(sentryNode(api));
});
});

describe('Hono 5', () => {
it('accepts honoMiddleware(app)', () => {
app5.use(honoMiddleware(app5));

const api = app5.basePath('/api');
api.use(honoMiddleware(api));
});

// In this package, `sentry()` gets its `Hono` type from Hono 4. That class has private members, so a
// Hono 5 app cannot match it. In a real Hono 5 app, `sentry()` gets the Hono 5 types.
it('rejects sentry(app) here, because `hono` is Hono 4 in this package', () => {
// @ts-expect-error -- Hono 5 app, Hono 4 types
app5.use(sentryNode(app5));
// @ts-expect-error -- Hono 5 app, Hono 4 types
app5.use(sentryBun(app5, { dsn }));
// @ts-expect-error -- Hono 5 app, Hono 4 types
app5.use(sentryDeno(app5, { dsn }));
// @ts-expect-error -- Hono 5 app, Hono 4 types
app5.use(sentryCloudflare(app5, env => ({ dsn: env.SENTRY_DSN })));
});
});
8 changes: 8 additions & 0 deletions packages/hono/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,12 @@ import baseConfig from '../../vite/vite.config';

export default defineConfig({
...baseConfig,
test: {
...baseConfig.test,
typecheck: {
enabled: true,
tsconfig: './tsconfig.test.json',
ignoreSourceErrors: true,
},
},
});
10 changes: 10 additions & 0 deletions packages/server-utils/.oxlintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,16 @@
"name": "diagnostics_channel",
"allowTypeImports": true,
"message": "Bun garbage-collects a channel that no code references, together with its subscribers. Import the wrappers from `src/utils/diagnosticsChannel.ts` instead. See https://github.com/oven-sh/bun/issues/43086"
},
{
"name": "hono",
"message": "`@sentry/server-utils` has no `hono` dependency and must work on every Hono major. Use the vendored types in `src/integrations/hono/honoTypes.ts`."
}
],
"patterns": [
{
"group": ["hono/*"],
"message": "`@sentry/server-utils` has no `hono` dependency and must work on every Hono major. Use the vendored types in `src/integrations/hono/honoTypes.ts`."
}
]
}
Expand Down
55 changes: 16 additions & 39 deletions packages/server-utils/src/integrations/hono/honoIntegration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { HTTP_SERVER } from '@sentry/conventions/op';
import type { IntegrationFn } from '@sentry/core';
import { defineIntegration, startInactiveSpan } from '@sentry/core';
import type { HonoRuntime } from './connInfo';
import type { Env, Hono, MiddlewareHandler } from './honoTypes';
import type { Env, Hono, MatchedHandler, MiddlewareHandler } from './honoTypes';
import type { OrchestrionChannelContext } from '../../orchestrion/types';
import { CHANNELS } from '../../orchestrion/channels';
import { honoModuleNames } from '../../orchestrion/config/hono';
Expand All @@ -13,7 +13,8 @@ import { bindTracingChannelToSpan, safeChannelCallback } from '../../tracing-cha
import { applyPatches } from './applyPatches';
import { INTERNAL_REQUEST_ORIGIN } from './constants';
import { createHonoRequestMiddleware } from './createHonoMiddleware';
import { isMiddleware } from './isMiddleware';
import { getMiddlewareFlags } from './isMiddleware';
import { isMatchedHandlerList } from './matchResult';
import { extractPathname, isInternalRequestSpanActive } from './patchAppRequest';
import { wrapMiddlewareWithSpan } from './wrapMiddlewareSpan';
import type { SentryHonoMiddlewareOptions } from './types';
Expand Down Expand Up @@ -49,10 +50,14 @@ export function honoMiddleware<E extends Env>(app: Hono<E>, options: HonoIntegra
});
}

// A Hono `matchResult[0]` entry: `[[handler, routeMeta], paramIndexMap]` — `compose` runs the
// handler (`entry[0][0]`) and the `matchedRoutes` getter reads `routeMeta` (`entry[0][1]`).
// oxlint-disable-next-line typescript/no-explicit-any
type MatchedHandlerEntry = [[any, any], any];
function wrapMatchedMiddleware(handlers: MatchedHandler[]): void {
const middlewareFlags = getMiddlewareFlags(handlers.map(([[, route]]) => route));
for (const [i, [pair]] of handlers.entries()) {
if (middlewareFlags[i]) {
pair[0] = wrapMiddlewareWithSpan(pair[0]);
}
}
}

/**
* Per-request Context hook. `#dispatch` builds `new Context(req, { matchResult })` before its
Expand All @@ -69,47 +74,19 @@ function injectHonoInstrumentation(
requestMiddleware: MiddlewareHandler,
injectedHandlerLists: WeakSet<object>,
): void {
const ctorOptions = message.arguments?.[1] as { matchResult?: [MatchedHandlerEntry[], unknown] } | undefined;
const ctorOptions = message.arguments?.[1] as { matchResult?: [unknown[], unknown] } | undefined;
const handlers = ctorOptions?.matchResult?.[0];
if (!Array.isArray(handlers) || injectedHandlerLists.has(handlers)) {
return;
}
injectedHandlerLists.add(handlers);

// Classify matched handlers with the same positional heuristic as `wrapSubAppMiddleware`: within a
// method+path group the last handler is the route handler and earlier ones are middleware; `.use()`
// registers as method 'ALL' with a lone genuine-middleware entry, so fall back to arity there.
// Position is needed because a route handler declared with an unused `next` param has middleware arity.
const lastIndexByKey = new Map<string, number>();
for (const [i, entry] of handlers.entries()) {
const routeMeta = entry?.[0]?.[1] as { method?: string; path?: string } | undefined;
if (routeMeta?.method != null && routeMeta.path != null) {
// \0 is a collision-free delimiter: it cannot appear in a valid HTTP method or URL path.
lastIndexByKey.set(`${routeMeta.method}\0${routeMeta.path}`, i);
}
}

for (const [i, entry] of handlers.entries()) {
const pair = entry?.[0];
const handler = pair?.[0];
if (typeof handler !== 'function') {
continue;
}

const routeMeta = pair?.[1] as { method?: string; path?: string } | undefined;
const isMW =
routeMeta?.method != null && routeMeta.path != null
? lastIndexByKey.get(`${routeMeta.method}\0${routeMeta.path}`) !== i ||
(routeMeta.method === 'ALL' && isMiddleware(handler))
: isMiddleware(handler);

if (isMW) {
pair[0] = wrapMiddlewareWithSpan(handler as MiddlewareHandler);
}
if (isMatchedHandlerList(handlers)) {
wrapMatchedMiddleware(handlers);
}

// Prepend the Sentry request/response middleware. `routeMeta` is what the `matchedRoutes` getter
// exposes; its middleware arity keeps route-name resolution from picking it.
// Prepend the Sentry request/response middleware. Its arity marks it as a middleware, so its `/*`
// never becomes the route name.
const routeMeta = { basePath: '/', path: '/*', method: 'ALL', handler: requestMiddleware };
handlers.unshift([[requestMiddleware, routeMeta], {}]);
}
Expand Down
9 changes: 5 additions & 4 deletions packages/server-utils/src/integrations/hono/honoTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,15 +29,16 @@ export interface HonoRoute {
handler: (...args: any[]) => any;
}

// One entry of Hono's `matchResult[0]`.
export type MatchedHandler = [pair: [handler: HonoRoute['handler'], route: HonoRoute], paramIndexMap: unknown];

export interface HonoRequest {
raw: Request;
method: string;
path: string;
routeIndex: number;
// These are public (though deprecated) getters on Hono's request. Reading them avoids a runtime
// import of the `hono/route` helpers, which are their non-deprecated replacements.
matchedRoutes: HonoRoute[];
routePath: string;
// Deprecated getter that Hono 5 removes.
matchedRoutes?: HonoRoute[];
[key: string]: any;
}

Expand Down
20 changes: 19 additions & 1 deletion packages/server-utils/src/integrations/hono/isMiddleware.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import type { HonoRoute } from './honoTypes';

// Hono stores the original (unwrapped) handler under this key when it wraps a sub-app's handlers
// for a custom `onError`. Unwrapping it lets us inspect the real handler instead of the wrapper.
// See https://github.com/honojs/hono/blob/9f0dadf141a3242a6c3b77462c7d33c6ce0f599d/src/hono-base.ts#L224-L226
Expand All @@ -12,7 +14,7 @@ const COMPOSED_HANDLER = '__COMPOSED_HANDLER';
* https://github.com/honojs/hono/blob/97c6fe1f12298c715eb7b2da65b4b6e0d81682bb/src/utils/handler.ts#L8
*
* This is only one signal — callers that must classify inline middleware sharing a method+path with
* their handler (e.g. `wrapSubAppMiddleware`) additionally need positional information.
* their handler additionally need positional information (`getMiddlewareFlags`).
*/
export function isMiddleware(handler: unknown): boolean {
if (typeof handler !== 'function') {
Expand All @@ -24,3 +26,19 @@ export function isMiddleware(handler: unknown): boolean {

return (original as (...args: unknown[]) => unknown).length >= 2;
}

/**
* Tells for each route if it is a middleware. Hono has no flag for this. In a method+path group, the
* last handler is the route handler (`app.get('/path', mw, handler)`). `.use()` adds middleware with
* method `ALL` and no route handler, so for `ALL` routes the arity also counts. Arity alone is not
* enough, because a route handler can declare an unused `next`.
*/
export function getMiddlewareFlags(routes: HonoRoute[]): boolean[] {
// `\0` cannot appear in an HTTP method or a URL path.
const groupKey = (route: HonoRoute): string => `${route.method}\0${route.path}`;
const lastIndexByKey = new Map(routes.map((route, i) => [groupKey(route), i]));

return routes.map(
(route, i) => lastIndexByKey.get(groupKey(route)) !== i || (route.method === 'ALL' && isMiddleware(route.handler)),
);
}
26 changes: 26 additions & 0 deletions packages/server-utils/src/integrations/hono/matchResult.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { isPlainObject } from '@sentry/core';
import type { MatchedHandler } from './honoTypes';

function isMatchedHandler(entry: unknown): entry is MatchedHandler {
const pair: unknown = Array.isArray(entry) ? entry[0] : undefined;
if (!Array.isArray(pair)) {
return false;
}

const [handler, route]: unknown[] = pair;
return (
typeof handler === 'function' &&
isPlainObject(route) &&
typeof route.method === 'string' &&
typeof route.path === 'string' &&
typeof route.handler === 'function'
);
}

/**
* Checks the entry shape that `compose` and `hono/route` read. The match result is a Hono internal, so
* the SDK leaves other shapes alone.
*/
export function isMatchedHandlerList(handlers: unknown): handlers is MatchedHandler[] {
return Array.isArray(handlers) && handlers.every(isMatchedHandler);
}
27 changes: 4 additions & 23 deletions packages/server-utils/src/integrations/hono/patchRoute.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { debug, getOriginalFunction } from '@sentry/core';
import type { WrappedFunction } from '@sentry/core';
import { DEBUG_BUILD } from '../../debug-build';
import type { Hono, HonoRoute } from './honoTypes';
import { isMiddleware } from './isMiddleware';
import { getMiddlewareFlags } from './isMiddleware';
import { patchAppRequest } from './patchAppRequest';
import { wrapMiddlewareWithSpan } from './wrapMiddlewareSpan';

Expand Down Expand Up @@ -103,31 +103,12 @@ export function installRouteHookOnPrototype(honoBaseProto: HonoBaseProto): Route
}

/**
* Identifies middleware handlers in a sub-app's flat routes array and wraps them in spans.
*
* Heuristics (since Hono has no "isMiddleware" flag):
* 1. Position: `app.get('/path', mw, handler)` produces entries with the same method+path.
* All but the LAST are middleware (they call `next()`).
* 2. Arity (# of params) for method 'ALL': `.use()` handlers always have 2+ params (context, next),
* while `.all()` route handlers typically have 1 (`context` only).
* See: https://github.com/honojs/hono/blob/18fe604c8cefc2628240651b1af219692e1918c1/src/hono-base.ts#L156-L168
* Wraps the middleware handlers of a sub-app's routes in spans.
*/
export function wrapSubAppMiddleware(routes: HonoRoute[]): void {
const lastIndexByKey = new Map<string, number>();
for (const [i, route] of routes.entries()) {
// \0 (null byte) is a collision-free delimiter: it cannot appear in a valid HTTP method name or URL path
lastIndexByKey.set(`${route.method}\0${route.path}`, i);
}

const middlewareFlags = getMiddlewareFlags(routes);
for (const [i, route] of routes.entries()) {
if (typeof route.handler !== 'function') {
continue;
}

const isLastForGroup = lastIndexByKey.get(`${route.method}\0${route.path}`) === i;

const isMW = !isLastForGroup || (route.method === 'ALL' && isMiddleware(route.handler));
if (isMW) {
if (middlewareFlags[i] && typeof route.handler === 'function') {
route.handler = wrapMiddlewareWithSpan(route.handler);
}
}
Expand Down
2 changes: 2 additions & 0 deletions packages/server-utils/src/orchestrion/config/hono.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ const honoInstrumentationConfig: InstrumentationConfig[] = [
{
// `new Context()` runs once per request: where the Sentry middleware is injected and matched
// middleware handlers are wrapped for spans.
// TODO(v12): Raise the minimum Hono version to 4.4.9, here and in the `@sentry/hono` peer range.
// Before 4.4.9, Hono passes `matchResult` to `new HonoRequest()`, so this hook does nothing.
channelName: 'context',
module: { name: 'hono', versionRange: '>=4.0.0 <5', filePath: /^dist\/(?:cjs\/)?context\.js$/ },
functionQuery: { className: 'Context' },
Expand Down
Loading
Loading