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
13 changes: 13 additions & 0 deletions packages/hono/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand All @@ -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"
]
}
},
Expand Down
2 changes: 1 addition & 1 deletion packages/hono/rollup.npm.config.mjs
Original file line number Diff line number Diff line change
@@ -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',
Expand Down
1 change: 1 addition & 0 deletions packages/hono/src/index.vercel.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { sentry } from './vercel/middleware';
2 changes: 1 addition & 1 deletion packages/hono/src/shared/middlewareHandlers.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { getIsolationScope } from '@sentry/cloudflare';
import {
getActiveSpan,
getClient,
getDefaultIsolationScope,
getIsolationScope,
getRootSpan,
updateSpanName,
winterCGRequestToRequestData,
Expand Down
130 changes: 130 additions & 0 deletions packages/hono/src/vercel/middleware.ts
Original file line number Diff line number Diff line change
@@ -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<BaseTransportOptions> {
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<string, string> = {};
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' },
});
}
}
},
);
},
);
});
};
};
181 changes: 181 additions & 0 deletions packages/hono/test/vercel/middleware.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof import('@sentry/node')>('@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),
);
});
});
});
Loading