Skip to content

Commit 16fa689

Browse files
committed
feat(hono): Add basic instrumentation for Node runtime
1 parent 3df0bc6 commit 16fa689

7 files changed

Lines changed: 239 additions & 5 deletions

File tree

packages/hono/README.md

Lines changed: 45 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,9 +54,8 @@ const app = new Hono();
5454

5555
// Initialize Sentry middleware right after creating the app
5656
app.use(
57-
'*',
5857
sentry(app, {
59-
dsn: 'your-sentry-dsn',
58+
dsn: '__DSN__',
6059
// ...other Sentry options
6160
}),
6261
);
@@ -65,3 +64,47 @@ app.use(
6564

6665
export default app;
6766
```
67+
68+
## Setup (Node)
69+
70+
### 1. Initialize Sentry in your Hono app
71+
72+
Initialize the Sentry Hono middleware as early as possible in your app:
73+
74+
```ts
75+
import { Hono } from 'hono';
76+
import { serve } from '@hono/node-server';
77+
import { sentry } from '@sentry/hono/node';
78+
79+
const app = new Hono();
80+
81+
// Initialize Sentry middleware right after creating the app
82+
app.use(
83+
sentry(app, {
84+
dsn: '__DSN__',
85+
tracesSampleRate: 1.0,
86+
}),
87+
);
88+
89+
// ... your routes and other middleware
90+
91+
serve(app);
92+
```
93+
94+
### 2. Add `preload` script to start command
95+
96+
To ensure that Sentry can capture spans from third-party libraries (e.g. database clients) used in your Hono app, Sentry needs to wrap these libraries as early as possible.
97+
98+
When starting the Hono Node application, use the `@sentry/node/preload` hook with the `--import` CLI option to ensure modules are wrapped before the application code runs:
99+
100+
```bash
101+
node --import @sentry/node/preload index.js
102+
```
103+
104+
This can also be added to the `NODE_OPTIONS` environment variable:
105+
106+
```bash
107+
NODE_OPTIONS="--import @sentry/node/preload"
108+
```
109+
110+
Read more about this preload script in the docs: https://docs.sentry.io/platforms/javascript/guides/hono/install/late-initialization/#late-initialization-with-esm

packages/hono/package.json

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,16 @@
3636
"types": "./build/types/index.cloudflare.d.ts",
3737
"default": "./build/cjs/index.cloudflare.js"
3838
}
39+
},
40+
"./node": {
41+
"import": {
42+
"types": "./build/types/index.node.d.ts",
43+
"default": "./build/esm/index.node.js"
44+
},
45+
"require": {
46+
"types": "./build/types/index.node.d.ts",
47+
"default": "./build/cjs/index.node.js"
48+
}
3949
}
4050
},
4151
"typesVersions": {
@@ -45,6 +55,9 @@
4555
],
4656
"build/types/index.cloudflare.d.ts": [
4757
"build/types-ts3.8/index.cloudflare.d.ts"
58+
],
59+
"build/types/index.node.d.ts": [
60+
"build/types-ts3.8/index.node.d.ts"
4861
]
4962
}
5063
},

packages/hono/rollup.npm.config.mjs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { makeBaseNPMConfig, makeNPMConfigVariants } from '@sentry-internal/rollup-utils';
22

33
const baseConfig = makeBaseNPMConfig({
4-
entrypoints: ['src/index.ts', 'src/index.cloudflare.ts'],
4+
entrypoints: ['src/index.ts', 'src/index.cloudflare.ts', 'src/index.node.ts'],
55
packageSpecificConfig: {
66
output: {
77
preserveModulesRoot: 'src',

packages/hono/src/index.node.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export { sentry } from './node/middleware';
2+
3+
export * from '@sentry/node';
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { applySdkMetadata, type BaseTransportOptions, debug, type Options } from '@sentry/core';
2+
import { init as initNode } from '@sentry/node';
3+
import type { Context, Hono, MiddlewareHandler } from 'hono';
4+
import { patchAppUse } from '../shared/patchAppUse';
5+
import { requestHandler, responseHandler } from '../shared/middlewareHandlers';
6+
7+
export interface HonoOptions extends Options<BaseTransportOptions> {
8+
context?: Context;
9+
}
10+
11+
/**
12+
* Sentry middleware for Hono running in a Node runtime environment.
13+
*/
14+
export const sentry = (app: Hono, options: HonoOptions | undefined = {}): MiddlewareHandler => {
15+
const isDebug = options.debug;
16+
17+
isDebug && debug.log('Initialized Sentry Hono middleware (Node)');
18+
19+
applySdkMetadata(options, 'hono');
20+
21+
initNode(options);
22+
23+
patchAppUse(app);
24+
25+
return async (context, next) => {
26+
requestHandler(context);
27+
28+
await next(); // Handler runs in between Request above ⤴ and Response below ⤵
29+
30+
responseHandler(context);
31+
};
32+
};

packages/hono/src/shared/middlewareHandlers.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
1-
import { getIsolationScope } from '@sentry/cloudflare';
21
import {
32
getActiveSpan,
43
getClient,
54
getDefaultIsolationScope,
5+
getIsolationScope,
66
getRootSpan,
7+
SEMANTIC_ATTRIBUTE_SENTRY_SOURCE,
78
updateSpanName,
89
winterCGRequestToRequestData,
910
} from '@sentry/core';
@@ -32,7 +33,11 @@ export function responseHandler(context: Context): void {
3233
const activeSpan = getActiveSpan();
3334
if (activeSpan) {
3435
activeSpan.updateName(`${context.req.method} ${routePath(context)}`);
35-
updateSpanName(getRootSpan(activeSpan), `${context.req.method} ${routePath(context)}`);
36+
activeSpan.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'route');
37+
38+
const rootSpan = getRootSpan(activeSpan);
39+
updateSpanName(rootSpan, `${context.req.method} ${routePath(context)}`);
40+
rootSpan.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'route');
3641
}
3742

3843
getIsolationScope().setTransactionName(`${context.req.method} ${routePath(context)}`);
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
import * as SentryCore from '@sentry/core';
2+
import { SDK_VERSION } from '@sentry/core';
3+
import { Hono } from 'hono';
4+
import { beforeEach, describe, expect, it, type Mock, vi } from 'vitest';
5+
import { sentry } from '../../src/node/middleware';
6+
7+
vi.mock('@sentry/node', () => ({
8+
init: vi.fn(),
9+
}));
10+
11+
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
12+
const { init: initNodeMock } = await vi.importMock<typeof import('@sentry/node')>('@sentry/node');
13+
14+
vi.mock('@sentry/core', async () => {
15+
const actual = await vi.importActual('@sentry/core');
16+
return {
17+
...actual,
18+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
19+
// @ts-ignore
20+
applySdkMetadata: vi.fn(actual.applySdkMetadata),
21+
};
22+
});
23+
24+
const applySdkMetadataMock = SentryCore.applySdkMetadata as Mock;
25+
26+
describe('Hono Vercel Middleware', () => {
27+
beforeEach(() => {
28+
vi.clearAllMocks();
29+
});
30+
31+
describe('sentry middleware', () => {
32+
it('calls applySdkMetadata with "hono"', () => {
33+
const app = new Hono();
34+
const options = {
35+
dsn: 'https://public@dsn.ingest.sentry.io/1337',
36+
};
37+
38+
sentry(app, options);
39+
40+
expect(applySdkMetadataMock).toHaveBeenCalledTimes(1);
41+
expect(applySdkMetadataMock).toHaveBeenCalledWith(options, 'hono');
42+
});
43+
44+
it('calls init from @sentry/node', () => {
45+
const app = new Hono();
46+
const options = {
47+
dsn: 'https://public@dsn.ingest.sentry.io/1337',
48+
};
49+
50+
sentry(app, options);
51+
52+
expect(initNodeMock).toHaveBeenCalledTimes(1);
53+
expect(initNodeMock).toHaveBeenCalledWith(
54+
expect.objectContaining({
55+
dsn: 'https://public@dsn.ingest.sentry.io/1337',
56+
}),
57+
);
58+
});
59+
60+
it('sets SDK metadata before calling Node init', () => {
61+
const app = new Hono();
62+
const options = {
63+
dsn: 'https://public@dsn.ingest.sentry.io/1337',
64+
};
65+
66+
sentry(app, options);
67+
68+
const applySdkMetadataCallOrder = applySdkMetadataMock.mock.invocationCallOrder[0];
69+
const initNodeCallOrder = (initNodeMock as Mock).mock.invocationCallOrder[0];
70+
71+
expect(applySdkMetadataCallOrder).toBeLessThan(initNodeCallOrder as number);
72+
});
73+
74+
it('preserves all user options', () => {
75+
const app = new Hono();
76+
const options = {
77+
dsn: 'https://public@dsn.ingest.sentry.io/1337',
78+
environment: 'production',
79+
sampleRate: 0.5,
80+
tracesSampleRate: 1.0,
81+
debug: true,
82+
};
83+
84+
sentry(app, options);
85+
86+
expect(initNodeMock).toHaveBeenCalledWith(
87+
expect.objectContaining({
88+
dsn: 'https://public@dsn.ingest.sentry.io/1337',
89+
environment: 'production',
90+
sampleRate: 0.5,
91+
tracesSampleRate: 1.0,
92+
debug: true,
93+
}),
94+
);
95+
});
96+
97+
it('returns a middleware handler function', () => {
98+
const app = new Hono();
99+
const options = {
100+
dsn: 'https://public@dsn.ingest.sentry.io/1337',
101+
};
102+
103+
const middleware = sentry(app, options);
104+
105+
expect(middleware).toBeDefined();
106+
expect(typeof middleware).toBe('function');
107+
expect(middleware).toHaveLength(2); // Hono middleware takes (context, next)
108+
});
109+
110+
it('returns an async middleware handler', () => {
111+
const app = new Hono();
112+
const middleware = sentry(app, {});
113+
114+
expect(middleware.constructor.name).toBe('AsyncFunction');
115+
});
116+
117+
it('includes hono SDK metadata', () => {
118+
const app = new Hono();
119+
const options = {
120+
dsn: 'https://public@dsn.ingest.sentry.io/1337',
121+
};
122+
123+
sentry(app, options);
124+
125+
expect(initNodeMock).toHaveBeenCalledWith(
126+
expect.objectContaining({
127+
_metadata: expect.objectContaining({
128+
sdk: expect.objectContaining({
129+
name: 'sentry.javascript.hono',
130+
version: SDK_VERSION,
131+
packages: [{ name: 'npm:@sentry/hono', version: SDK_VERSION }],
132+
}),
133+
}),
134+
}),
135+
);
136+
});
137+
});
138+
});

0 commit comments

Comments
 (0)