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
6 changes: 3 additions & 3 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -1028,8 +1028,8 @@ jobs:
node-version-file: 'dev-packages/e2e-tests/test-applications/${{ matrix.test-application }}/package.json'
- name: Set up Bun
if:
contains(fromJSON('["node-exports-test-app","nextjs-16-bun", "elysia-bun", "elysia-bun-static", "hono-4",
"bun-bytecode", "bun-mysql"]'), matrix.test-application)
matrix.test-application == 'node-exports-test-app' || contains(matrix.test-application, 'bun') ||
contains(matrix.label, 'bun')
uses: oven-sh/setup-bun@v2
with:
bun-version: '1.3.14'
Expand All @@ -1040,7 +1040,7 @@ jobs:
use-installer: true
token: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Deno
if: matrix.test-application == 'deno' || matrix.test-application == 'hono-4'
if: contains(matrix.test-application, 'deno') || contains(matrix.label, 'deno')
uses: denoland/setup-deno@v2.0.5
with:
deno-version: ${{ matrix.deno-version || 'v2.8.3' }}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"name": "hono-4",
"name": "hono-4-legacy",
"type": "module",
"version": "0.0.0",
"private": true,
Expand Down Expand Up @@ -37,15 +37,15 @@
"variants": [
{
"assert-command": "RUNTIME=node pnpm test:assert",
"label": "hono-4 (node)"
"label": "hono-4-legacy (node)"
},
{
"assert-command": "RUNTIME=bun pnpm test:assert",
"label": "hono-4 (bun)"
"label": "hono-4-legacy (bun)"
},
{
"assert-command": "RUNTIME=deno pnpm test:assert",
"label": "hono-4 (deno)"
"label": "hono-4-legacy (deno)"
}
]
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import type { MiddlewareHandler } from 'hono';

// Defined as anonymous function expressions so the function name is inferred from the `const`
// binding. A named expression (`const middlewareA = async function middlewareA() {}`) collides with
// the binding when bundled, and Bun renames the inner function (→ `middlewareA2`), which would then
// surface as the middleware span name. The inferred name stays stable across all runtimes.
export const middlewareA: MiddlewareHandler = async function (c, next) {
// Add some delay
await new Promise(resolve => setTimeout(resolve, 50));
await next();
};

export const middlewareB: MiddlewareHandler = async function (_c, next) {
// Add some delay
await new Promise(resolve => setTimeout(resolve, 60));
await next();
};

let failingMiddlewareCount = 0;
export const failingMiddleware: MiddlewareHandler = async function (_c, _next) {
// Each throw gets a unique suffix so the Dedupe integration doesn't collapse the identical errors
// that several tests (and their retries) trigger through this shared middleware — otherwise only the
// first would be reported and the other tests' `waitForError` would time out. Tests match on the
// stable `Middleware error` prefix.
throw new Error(`Middleware error #${(failingMiddlewareCount += 1)}`);
};

// Intentionally a NAMED function expression (unlike the anonymous ones above) so the named-function
// path stays covered: the span name is taken from the function's own name. Under bundled Bun the inner
// name collides with the `const` binding and is suffixed (→ `namedMiddleware2`), so the test matches on
// the `namedMiddleware` prefix rather than an exact name.
export const namedMiddleware: MiddlewareHandler = async function namedMiddleware(_c, next) {
await next();
};
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Hono } from 'hono';
import { failingMiddleware, middlewareA, middlewareB } from '../middleware';
import { failingMiddleware, middlewareA, middlewareB, namedMiddleware } from '../middleware';

const middlewareRoutes = new Hono();

Expand All @@ -8,11 +8,13 @@ middlewareRoutes.get('/anonymous', c => c.json({ middleware: 'anonymous' }));
middlewareRoutes.get('/multi', c => c.json({ middleware: 'multi' }));
middlewareRoutes.get('/error', c => c.text('should not reach'));
middlewareRoutes.get('/param/:id', c => c.json({ paramId: c.req.param('id') }));
middlewareRoutes.get('/declared', c => c.json({ middleware: 'declared' }));

// Self-contained sub-app registering its own middleware via .use()
const subAppWithMiddleware = new Hono();

subAppWithMiddleware.use('/named/*', middlewareA);
subAppWithMiddleware.use('/declared/*', namedMiddleware);
subAppWithMiddleware.use('/anonymous/*', async (c, next) => {
c.header('X-Custom', 'anonymous');
await next();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,14 @@ inventoryApp.get('/item/:productId/stock', c => {
return c.json({ productId, inStock: item.stock > 0, quantity: item.stock });
});

// Simulates an inner-route failure (a plain 5xx Error, not an HTTPException) reached only via an
// internal .request() — used by the storefront's `/degraded` route below. Each throw gets a unique
// suffix so the Dedupe integration doesn't collapse repeats across test retries.
let dbErrorCount = 0;
inventoryApp.get('/item/:productId/db-error', () => {
throw new Error(`inventory db is down #${(dbErrorCount += 1)}`);
});

// Storefront service — orchestrates internal .request() calls to inventoryApp.
const storefrontApp = new Hono();

Expand Down Expand Up @@ -83,6 +91,17 @@ storefrontApp.get('/product/:productId/availability', async c => {
});
});

// Degraded response: the inner route throws, but the outer handler swallows the failed internal
// response and returns a 200 instead of propagating. The inner error should still reach Sentry (auto
// instrumentation); the outer request stays healthy.
storefrontApp.get('/product/:productId/degraded', async c => {
const res = await inventoryApp.request(`/item/${c.req.param('productId')}/db-error`);
if (!res.ok) {
return c.json({ product: null, degraded: true }, 200);
}
return c.json({ product: await res.json() });
});

// Error propagation: internal 404 causes the handler to throw a plain Error
storefrontApp.get('/product-or-throw/:productId', async c => {
const res = await inventoryApp.request(`/item/${c.req.param('productId')}`);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { type Hono as HonoType, Hono } from 'hono';
import { HTTPException } from 'hono/http-exception';
import { failingMiddleware, middlewareA, middlewareB } from './middleware';
import { failingMiddleware, middlewareA, middlewareB, namedMiddleware } from './middleware';
import { errorRoutes } from './route-groups/test-errors';
import { middlewareRoutes, subAppWithInlineMiddleware, subAppWithMiddleware } from './route-groups/test-middleware';
import { multiFetchRoutes } from './route-groups/test-multi-fetch';
Expand Down Expand Up @@ -42,6 +42,7 @@ export function addRoutes(app: HonoType<{ Bindings?: { E2E_TEST_DSN: string } }>
app.use('/test-middleware/multi/*', middlewareA, middlewareB);
app.use('/test-middleware/error/*', failingMiddleware);
app.use('/test-middleware/param/*', middlewareA);
app.use('/test-middleware/declared/*', namedMiddleware);
app.route('/test-middleware', middlewareRoutes);

// Sub-app middleware: registered on the sub-app, wrapped at mount time by route() patching
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,5 @@ import { startEventProxyServer } from '@sentry-internal/test-utils';

startEventProxyServer({
port: 3031,
proxyServerName: 'hono-4',
proxyServerName: 'hono-4-legacy',
});
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@ export type Runtime = 'cloudflare' | 'node' | 'bun' | 'deno';

export const RUNTIME = (process.env.RUNTIME || 'node') as Runtime;

export const APP_NAME = 'hono-4';
export const APP_NAME = 'hono-4-legacy';
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,38 @@ for (const { name, prefix } of SCENARIOS) {
expect(anonymousSpan?.status).not.toBe('error');
});

test('creates a span for a named-function middleware (name from the function binding)', async ({ baseURL }) => {
const segmentPromise = collectStreamedSpansUntilSegment(
APP_NAME,
segment => getSpanOp(segment) === 'http.server' && !!segment.name?.includes(`${prefix}/declared`),
);

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

const segmentSpans = await segmentPromise;
const segment = segmentSpans.find(
segment =>
segment.is_segment && getSpanOp(segment) === 'http.server' && !!segment.name?.includes(`${prefix}/declared`),
)!;
expect(segment.name).toBe(`GET ${prefix}/declared`);

const spans = segmentSpans.filter(
span => !span.is_segment && span.attributes['sentry.segment.id']?.value === segment.span_id,
);

// Named function expression: the span name comes from the function's own name. A bundler (Bun)
// may append a numeric suffix when the inner name collides with the `const` binding, so match on
// the `namedMiddleware` prefix rather than an exact name.
const middlewareSpan = spans.find(
span => getSpanOp(span) === 'middleware' && !!span.name?.match(/^namedMiddleware\d*$/),
);

expect(middlewareSpan).toBeDefined();
expect(middlewareSpan?.attributes['sentry.origin']?.value).toBe('auto.middleware.hono');
expect(middlewareSpan?.status).not.toBe('error');
});

test('multiple middleware are sibling spans under the same parent', async ({ baseURL }) => {
const segmentPromise = collectStreamedSpansUntilSegment(
APP_NAME,
Expand Down Expand Up @@ -115,14 +147,17 @@ for (const { name, prefix } of SCENARIOS) {

test('captures error thrown in middleware', async ({ baseURL }) => {
const errorPromise = waitForError(APP_NAME, event => {
return event.exception?.values?.[0]?.value === 'Middleware error';
return (
!!event.exception?.values?.[0]?.value?.startsWith('Middleware error') &&
!!event.request?.url?.includes(prefix)
);
});

const response = await fetch(`${baseURL}${prefix}/error`);
expect(response.status).toBe(500);

const errorEvent = await errorPromise;
expect(errorEvent.exception?.values?.[0]?.value).toBe('Middleware error');
expect(errorEvent.exception?.values?.[0]?.value).toMatch(/^Middleware error/);
expect(errorEvent.exception?.values?.[0]?.mechanism).toEqual(
expect.objectContaining({
handled: false,
Expand Down Expand Up @@ -184,7 +219,10 @@ for (const { name, prefix } of SCENARIOS) {

test('includes request data on error events from middleware', async ({ baseURL }) => {
const errorPromise = waitForError(APP_NAME, event => {
return event.exception?.values?.[0]?.value === 'Middleware error' && !!event.request?.url?.includes(prefix);
return (
!!event.exception?.values?.[0]?.value?.startsWith('Middleware error') &&
!!event.request?.url?.includes(prefix)
);
});

await fetch(`${baseURL}${prefix}/error`);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,19 @@ test.describe('multi-fetch: internal .request() calls between sub-apps', () => {
});
});

test.describe('error inside internal fetch (degraded response)', () => {
// The manual `sentry()` middleware only instruments the main app's request lifecycle. An error
// thrown solely inside an internal sub-app `.request()` — whose failed response the outer handler
// swallows — is therefore NOT captured here, unlike the orchestrion auto-instrumentation in the
// `hono-4` app, which instruments every dispatched context. We assert only that the outer request
// stays healthy.
test('degrades to a 200 when the inner route fails', async ({ baseURL }) => {
const response = await fetch(`${baseURL}${STOREFRONT}/product/self-watering-plant/degraded`);
expect(response.status).toBe(200);
await expect(response.json()).resolves.toEqual({ product: null, degraded: true });
});
});
Comment on lines +186 to +197

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.

This should be a follow-up fix.


test.describe('inventory sub-app direct access', () => {
test('creates its own span when accessed directly via HTTP', async ({ baseURL }) => {
const segmentPromise = waitForStreamedSpan(
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"$schema": "node_modules/wrangler/config-schema.json",
"name": "hono-4",
"name": "hono-4-legacy",
"main": "src/entry.cloudflare.ts",
"compatibility_date": "2026-04-20",
"compatibility_flags": ["nodejs_compat"],
Expand Down
17 changes: 0 additions & 17 deletions dev-packages/e2e-tests/test-applications/hono-4/src/middleware.ts

This file was deleted.

3 changes: 2 additions & 1 deletion packages/hono/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,8 @@
"dependencies": {
"@opentelemetry/api": "^1.9.1",
"@sentry/core": "10.67.0",
"@sentry/conventions": "^0.23.0"
"@sentry/conventions": "^0.23.0",
"@sentry/server-utils": "10.67.0"
},
"peerDependencies": {
"@cloudflare/workers-types": "^4.x || ^5.x",
Expand Down
14 changes: 3 additions & 11 deletions packages/hono/src/bun/middleware.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,8 @@
import { type BaseTransportOptions, debug, type Options } from '@sentry/core';
import { init } from './sdk';
import { getConnInfo } from 'hono/bun';
import { applyHonoPatches, createHonoRequestMiddleware, type SentryHonoMiddlewareOptions } from '@sentry/server-utils';
import type { Env, Hono, MiddlewareHandler } from 'hono';
import { requestHandler, responseHandler } from '../shared/middlewareHandlers';
import { applyPatches } from '../shared/applyPatches';
import type { SentryHonoMiddlewareOptions } from '../shared/types';

export interface HonoBunOptions extends Options<BaseTransportOptions>, SentryHonoMiddlewareOptions {}

Expand All @@ -16,13 +14,7 @@ export const sentry = <E extends Env>(app: Hono<E>, options: HonoBunOptions): Mi

init(options);

applyPatches(app);
applyHonoPatches(app);

return async (context, next) => {
requestHandler(context, getConnInfo);

await next(); // Handler runs in between Request above ⤴ and Response below ⤵

responseHandler(context, options.shouldHandleError);
};
return createHonoRequestMiddleware({ getConnInfo, shouldHandleError: options.shouldHandleError });
};
27 changes: 13 additions & 14 deletions packages/hono/src/cloudflare/middleware.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import { withSentry } from '@sentry/cloudflare';
import { applySdkMetadata, type BaseTransportOptions, debug, type Options } from '@sentry/core';
import { getConnInfo } from 'hono/cloudflare-workers';
import {
applyHonoPatches,
createHonoRequestMiddleware,
type SentryHonoMiddlewareOptions,
} from '@sentry/server-utils/no-diagnostic-channels';
import type { Env, Hono, MiddlewareHandler } from 'hono';
import { buildFilteredIntegrations } from '../shared/buildFilteredIntegrations';
import { LOW_QUALITY_TRANSACTION_PATTERNS } from '../shared/lowQualityTransactionPatterns';
import { requestHandler, responseHandler } from '../shared/middlewareHandlers';
import { applyPatches } from '../shared/applyPatches';
import type { SentryHonoMiddlewareOptions } from '../shared/types';

export interface HonoCloudflareOptions extends Options<BaseTransportOptions>, SentryHonoMiddlewareOptions {}

Expand Down Expand Up @@ -41,18 +43,15 @@ export function sentry<E extends Env>(
app as unknown as ExportedHandler<unknown>,
);

applyPatches(app);
applyHonoPatches(app);

return async (context, next) => {
const shouldHandleError =
return createHonoRequestMiddleware({
getConnInfo,
// Cloudflare accepts middleware options as a function of `env`, so `shouldHandleError` is only
// known per request.
resolveShouldHandleError: context =>
typeof options === 'function'
? options(context.env as E['Bindings']).shouldHandleError
: options.shouldHandleError;

requestHandler(context, getConnInfo);

await next(); // Handler runs in between Request above ⤴ and Response below ⤵

responseHandler(context, shouldHandleError);
};
: options.shouldHandleError,
});
}
8 changes: 0 additions & 8 deletions packages/hono/src/debug-build.ts

This file was deleted.

Loading
Loading