From 101986f98e17cc98f718a9b45ad8ab94cc166210 Mon Sep 17 00:00:00 2001 From: Daniel Griesser Date: Wed, 11 Mar 2026 13:32:37 +0100 Subject: [PATCH] feat(hono): Add Vercel serverless middleware Add @sentry/hono/vercel entry point for using Hono on Vercel serverless functions (Node.js runtime). Uses @sentry/node for SDK init and wraps each request with isolation scope, distributed tracing, and HTTP spans. - New vercel/middleware.ts with per-request isolation and span creation - Extract sentry-trace/baggage headers for distributed tracing - Update span names with matched Hono route after handler runs - Capture errors from both thrown exceptions and context.error - Patch app.use for middleware span tracing (shared with Cloudflare) - Fix shared middlewareHandlers.ts to import from @sentry/core instead of @sentry/cloudflare for runtime-agnostic usage Co-Authored-By: Claude --- packages/hono/package.json | 13 ++ packages/hono/rollup.npm.config.mjs | 2 +- packages/hono/src/index.vercel.ts | 1 + .../hono/src/shared/middlewareHandlers.ts | 2 +- packages/hono/src/vercel/middleware.ts | 130 +++++++++++++ packages/hono/test/vercel/middleware.test.ts | 181 ++++++++++++++++++ 6 files changed, 327 insertions(+), 2 deletions(-) create mode 100644 packages/hono/src/index.vercel.ts create mode 100644 packages/hono/src/vercel/middleware.ts create mode 100644 packages/hono/test/vercel/middleware.test.ts diff --git a/packages/hono/package.json b/packages/hono/package.json index c371aad129db..dfdc0ebb5d15 100644 --- a/packages/hono/package.json +++ b/packages/hono/package.json @@ -36,6 +36,16 @@ "types": "./build/types/index.cloudflare.d.ts", "default": "./build/cjs/index.cloudflare.js" } + }, + "./vercel": { + "import": { + "types": "./build/types/index.vercel.d.ts", + "default": "./build/esm/index.vercel.js" + }, + "require": { + "types": "./build/types/index.vercel.d.ts", + "default": "./build/cjs/index.vercel.js" + } } }, "typesVersions": { @@ -45,6 +55,9 @@ ], "build/types/index.cloudflare.d.ts": [ "build/types-ts3.8/index.cloudflare.d.ts" + ], + "build/types/index.vercel.d.ts": [ + "build/types-ts3.8/index.vercel.d.ts" ] } }, diff --git a/packages/hono/rollup.npm.config.mjs b/packages/hono/rollup.npm.config.mjs index 6f491584a9d0..6d9244c43ffb 100644 --- a/packages/hono/rollup.npm.config.mjs +++ b/packages/hono/rollup.npm.config.mjs @@ -1,7 +1,7 @@ import { makeBaseNPMConfig, makeNPMConfigVariants } from '@sentry-internal/rollup-utils'; const baseConfig = makeBaseNPMConfig({ - entrypoints: ['src/index.ts', 'src/index.cloudflare.ts'], + entrypoints: ['src/index.ts', 'src/index.cloudflare.ts', 'src/index.vercel.ts'], packageSpecificConfig: { output: { preserveModulesRoot: 'src', diff --git a/packages/hono/src/index.vercel.ts b/packages/hono/src/index.vercel.ts new file mode 100644 index 000000000000..cf70a72a5d5a --- /dev/null +++ b/packages/hono/src/index.vercel.ts @@ -0,0 +1 @@ +export { sentry } from './vercel/middleware'; diff --git a/packages/hono/src/shared/middlewareHandlers.ts b/packages/hono/src/shared/middlewareHandlers.ts index 9745bcfa3988..15ab9d5f6cc3 100644 --- a/packages/hono/src/shared/middlewareHandlers.ts +++ b/packages/hono/src/shared/middlewareHandlers.ts @@ -1,8 +1,8 @@ -import { getIsolationScope } from '@sentry/cloudflare'; import { getActiveSpan, getClient, getDefaultIsolationScope, + getIsolationScope, getRootSpan, updateSpanName, winterCGRequestToRequestData, diff --git a/packages/hono/src/vercel/middleware.ts b/packages/hono/src/vercel/middleware.ts new file mode 100644 index 000000000000..63da628dbe8c --- /dev/null +++ b/packages/hono/src/vercel/middleware.ts @@ -0,0 +1,130 @@ +import { + applySdkMetadata, + type BaseTransportOptions, + captureException, + continueTrace, + debug, + getActiveSpan, + getIsolationScope, + getRootSpan, + type Options, + SEMANTIC_ATTRIBUTE_SENTRY_OP, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, + setHttpStatus, + startSpan, + updateSpanName, + winterCGRequestToRequestData, + withIsolationScope, +} from '@sentry/core'; +import { init as initNode } from '@sentry/node'; +import type { Context, Hono, MiddlewareHandler } from 'hono'; +import { routePath } from 'hono/route'; +import { patchAppUse } from '../shared/patchAppUse'; +import { hasFetchEvent } from '../utils/hono-context'; + +export interface HonoOptions extends Options { + context?: Context; +} + +/** + * Sentry middleware for Hono running on Vercel serverless functions. + * + * Initialises the Sentry Node SDK (if not already initialised) and wraps every + * incoming request in an isolation scope with an HTTP server span. + * + * @example + * ```ts + * import { Hono } from 'hono'; + * import { sentry } from '@sentry/hono/vercel'; + * + * const app = new Hono(); + * + * app.use('*', sentry(app, { + * dsn: '__DSN__', + * tracesSampleRate: 1.0, + * })); + * + * app.get('/', (c) => c.text('Hello!')); + * + * export default app; + * ``` + */ +export const sentry = (app: Hono, options: HonoOptions | undefined = {}): MiddlewareHandler => { + const isDebug = options.debug; + + isDebug && debug.log('Initialized Sentry Hono middleware (Vercel)'); + + applySdkMetadata(options, 'hono'); + + initNode(options); + + patchAppUse(app); + + return async (context, next) => { + const req = hasFetchEvent(context) ? context.event.request : context.req.raw; + const method = context.req.method; + const path = context.req.path; + + return withIsolationScope(isolationScope => { + isolationScope.setSDKProcessingMetadata({ + normalizedRequest: winterCGRequestToRequestData(req), + }); + + const headers: Record = {}; + req.headers.forEach((value: string, key: string) => { + headers[key] = value; + }); + + return continueTrace( + { + sentryTrace: headers['sentry-trace'] || '', + baggage: headers['baggage'], + }, + () => { + return startSpan( + { + name: `${method} ${path}`, + op: 'http.server', + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'http.server', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.hono', + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', + 'http.request.method': method, + 'url.path': path, + }, + }, + async span => { + try { + await next(); + + // After the handler runs, update the span name with the matched route + const route = routePath(context); + const spanName = `${method} ${route}`; + + span.updateName(spanName); + span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'route'); + updateSpanName(getRootSpan(span), spanName); + getIsolationScope().setTransactionName(spanName); + + setHttpStatus(span, context.res.status); + } catch (error) { + captureException(error, { + mechanism: { handled: false, type: 'auto.http.hono' }, + }); + throw error; + } finally { + // Also capture errors stored on the context (e.g. from Hono's onError handler) + if (context.error) { + captureException(context.error, { + mechanism: { handled: false, type: 'auto.faas.hono.error_handler' }, + }); + } + } + }, + ); + }, + ); + }); + }; +}; diff --git a/packages/hono/test/vercel/middleware.test.ts b/packages/hono/test/vercel/middleware.test.ts new file mode 100644 index 000000000000..caabdbfe2ef8 --- /dev/null +++ b/packages/hono/test/vercel/middleware.test.ts @@ -0,0 +1,181 @@ +import * as SentryCore from '@sentry/core'; +import { SDK_VERSION } from '@sentry/core'; +import { Hono } from 'hono'; +import { beforeEach, describe, expect, it, type Mock, vi } from 'vitest'; +import { sentry } from '../../src/vercel/middleware'; + +vi.mock('@sentry/node', () => ({ + init: vi.fn(), +})); + +// eslint-disable-next-line @typescript-eslint/consistent-type-imports +const { init: initNodeMock } = await vi.importMock('@sentry/node'); + +vi.mock('@sentry/core', async () => { + const actual = await vi.importActual('@sentry/core'); + return { + ...actual, + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + applySdkMetadata: vi.fn(actual.applySdkMetadata), + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + withIsolationScope: vi.fn(actual.withIsolationScope), + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + continueTrace: vi.fn((_traceData: unknown, callback: () => unknown) => callback()), + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + startSpan: vi.fn((_options: unknown, callback: (span: unknown) => unknown) => + callback({ + updateName: vi.fn(), + setAttribute: vi.fn(), + }), + ), + }; +}); + +const applySdkMetadataMock = SentryCore.applySdkMetadata as Mock; + +describe('Hono Vercel Middleware', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('sentry middleware', () => { + it('calls applySdkMetadata with "hono"', () => { + const app = new Hono(); + const options = { + dsn: 'https://public@dsn.ingest.sentry.io/1337', + }; + + sentry(app, options); + + expect(applySdkMetadataMock).toHaveBeenCalledTimes(1); + expect(applySdkMetadataMock).toHaveBeenCalledWith(options, 'hono'); + }); + + it('calls init from @sentry/node', () => { + const app = new Hono(); + const options = { + dsn: 'https://public@dsn.ingest.sentry.io/1337', + }; + + sentry(app, options); + + expect(initNodeMock).toHaveBeenCalledTimes(1); + expect(initNodeMock).toHaveBeenCalledWith( + expect.objectContaining({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + }), + ); + }); + + it('sets SDK metadata before calling init', () => { + const app = new Hono(); + const options = { + dsn: 'https://public@dsn.ingest.sentry.io/1337', + }; + + sentry(app, options); + + const applySdkMetadataCallOrder = applySdkMetadataMock.mock.invocationCallOrder[0]; + const initNodeCallOrder = (initNodeMock as Mock).mock.invocationCallOrder[0]; + + expect(applySdkMetadataCallOrder).toBeLessThan(initNodeCallOrder as number); + }); + + it('preserves all user options', () => { + const app = new Hono(); + const options = { + dsn: 'https://public@dsn.ingest.sentry.io/1337', + environment: 'production', + sampleRate: 0.5, + tracesSampleRate: 1.0, + debug: true, + }; + + sentry(app, options); + + expect(initNodeMock).toHaveBeenCalledWith( + expect.objectContaining({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + environment: 'production', + sampleRate: 0.5, + tracesSampleRate: 1.0, + debug: true, + }), + ); + }); + + it('returns a middleware handler function', () => { + const app = new Hono(); + const options = { + dsn: 'https://public@dsn.ingest.sentry.io/1337', + }; + + const middleware = sentry(app, options); + + expect(middleware).toBeDefined(); + expect(typeof middleware).toBe('function'); + expect(middleware).toHaveLength(2); // Hono middleware takes (context, next) + }); + + it('returns an async middleware handler', () => { + const app = new Hono(); + const middleware = sentry(app, {}); + + expect(middleware.constructor.name).toBe('AsyncFunction'); + }); + + it('includes hono SDK metadata', () => { + const app = new Hono(); + const options = { + dsn: 'https://public@dsn.ingest.sentry.io/1337', + }; + + sentry(app, options); + + expect(initNodeMock).toHaveBeenCalledWith( + expect.objectContaining({ + _metadata: expect.objectContaining({ + sdk: expect.objectContaining({ + name: 'sentry.javascript.hono', + version: SDK_VERSION, + packages: [ + { + name: 'npm:@sentry/hono', + version: SDK_VERSION, + }, + ], + }), + }), + }), + ); + }); + }); + + describe('middleware execution', () => { + it('wraps the request in withIsolationScope and startSpan', async () => { + const app = new Hono(); + app.use('*', sentry(app, { dsn: 'https://public@dsn.ingest.sentry.io/1337' })); + app.get('/test', c => c.text('ok')); + + const req = new Request('http://localhost/test'); + await app.request(req); + + expect(SentryCore.withIsolationScope).toHaveBeenCalled(); + expect(SentryCore.continueTrace).toHaveBeenCalled(); + expect(SentryCore.startSpan).toHaveBeenCalledWith( + expect.objectContaining({ + op: 'http.server', + attributes: expect.objectContaining({ + 'http.request.method': 'GET', + 'url.path': '/test', + }), + }), + expect.any(Function), + ); + }); + }); +});