Skip to content

Commit 4ffea2d

Browse files
chargomeclaude
andauthored
feat(node): Add nodeRuntimeMetricsIntegration (#19923)
Adds `nodeRuntimeMetricsIntegration` to `@sentry/node` and `@sentry/node-core`. When enabled, the integration periodically collects Node.js runtime health metrics and emits them to Sentry via the metrics pipeline. ### Usage ```ts import * as Sentry from '@sentry/node'; Sentry.init({ dsn: '...', integrations: [ Sentry.nodeRuntimeMetricsIntegration(), ], }); ``` ### Default metrics (8) Emitted every 30 seconds out of the box: | Metric | Type | Unit | Description | |---|---|---|---| | `node.runtime.mem.rss` | gauge | byte | Resident Set Size — actual process memory footprint | | `node.runtime.mem.heap_used` | gauge | byte | V8 heap currently in use — tracks GC pressure and leaks | | `node.runtime.mem.heap_total` | gauge | byte | Total V8 heap allocated — paired with `heap_used` to see headroom | | `node.runtime.cpu.utilization` | gauge | — | CPU time / wall-clock time ratio (can exceed 1.0 on multi-core) | | `node.runtime.event_loop.delay.p50` | gauge | second | Median event loop delay — baseline latency | | `node.runtime.event_loop.delay.p99` | gauge | second | 99th percentile event loop delay — tail latency / spikes | | `node.runtime.event_loop.utilization` | gauge | — | Fraction of time the event loop was active | | `node.runtime.process.uptime` | counter | second | Cumulative uptime — useful for detecting restarts / crashes | ### Opt-in metrics (off by default) ```ts Sentry.nodeRuntimeMetricsIntegration({ collect: { cpuTime: true, // node.runtime.cpu.user + node.runtime.cpu.system (raw seconds) memExternal: true, // node.runtime.mem.external + node.runtime.mem.array_buffers eventLoopDelayMin: true, eventLoopDelayMax: true, eventLoopDelayMean: true, eventLoopDelayP90: true, }, }) ``` Any default metric can also be turned off: ```ts Sentry.nodeRuntimeMetricsIntegration({ collect: { uptime: false, eventLoopDelayP50: false, }, }) ``` ### Collection interval ```ts Sentry.nodeRuntimeMetricsIntegration({ collectionIntervalMs: 60_000, // default: 30_000 }) ``` ### Serverless (Next.js on Vercel, AWS Lambda, etc.) Works out of the box — no special configuration needed. Metrics are sent by the periodic collection interval and flushed by the existing SDK flush infrastructure (framework wrappers like SvelteKit, TanStack Start, and `@sentry/aws-serverless` already call `flushIfServerless` after each request handler). The interval is `unref()`-ed so it never prevents the process from exiting. ### Runtime compatibility This integration is Node.js only. Bun and Deno will be addressed in separate integrations that use their respective native APIs. Closes #19967 (added automatically) --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 3e4ac69 commit 4ffea2d

File tree

14 files changed

+817
-0
lines changed

14 files changed

+817
-0
lines changed

CHANGELOG.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,21 @@
66

77
Work in this release was contributed by @roli-lpci. Thank you for your contributions!
88

9+
### Important Changes
10+
11+
- **feat(node): Add `nodeRuntimeMetricsIntegration` for automatic Node.js runtime metrics ([#19923](https://github.com/getsentry/sentry-javascript/pull/19923))**
12+
13+
The new `nodeRuntimeMetricsIntegration` automatically collects Node.js runtime health metrics and sends them to Sentry. Eight metrics are emitted by default every 30 seconds: memory (RSS, heap used/total), CPU utilization, event loop delay (p50, p99), event loop utilization, and process uptime. Additional metrics are available as opt-in.
14+
15+
```ts
16+
import * as Sentry from '@sentry/node';
17+
18+
Sentry.init({
19+
dsn: '...',
20+
integrations: [Sentry.nodeRuntimeMetricsIntegration()],
21+
});
22+
```
23+
924
## 10.45.0
1025

1126
### Important Changes

dev-packages/e2e-tests/test-applications/node-exports-test-app/scripts/consistentExports.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,9 @@ const DEPENDENTS: Dependent[] = [
5555
'childProcessIntegration',
5656
'systemErrorIntegration',
5757
'pinoIntegration',
58+
// Bun will get its own runtime metrics integration
59+
'nodeRuntimeMetricsIntegration',
60+
'NodeRuntimeMetricsOptions',
5861
],
5962
},
6063
{
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import * as Sentry from '@sentry/node';
2+
import { loggingTransport } from '@sentry-internal/node-integration-tests';
3+
4+
Sentry.init({
5+
dsn: 'https://public@dsn.ingest.sentry.io/1337',
6+
release: '1.0.0',
7+
environment: 'test',
8+
transport: loggingTransport,
9+
integrations: [
10+
Sentry.nodeRuntimeMetricsIntegration({
11+
collectionIntervalMs: 100,
12+
collect: {
13+
cpuTime: true,
14+
memExternal: true,
15+
eventLoopDelayMin: true,
16+
eventLoopDelayMax: true,
17+
eventLoopDelayMean: true,
18+
eventLoopDelayP90: true,
19+
},
20+
}),
21+
],
22+
});
23+
24+
async function run(): Promise<void> {
25+
await new Promise<void>(resolve => setTimeout(resolve, 250));
26+
await Sentry.flush();
27+
}
28+
29+
// eslint-disable-next-line @typescript-eslint/no-floating-promises
30+
run();
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import * as Sentry from '@sentry/node';
2+
import { loggingTransport } from '@sentry-internal/node-integration-tests';
3+
4+
Sentry.init({
5+
dsn: 'https://public@dsn.ingest.sentry.io/1337',
6+
release: '1.0.0',
7+
environment: 'test',
8+
transport: loggingTransport,
9+
integrations: [
10+
Sentry.nodeRuntimeMetricsIntegration({
11+
collectionIntervalMs: 100,
12+
collect: {
13+
cpuUtilization: false,
14+
cpuTime: false,
15+
eventLoopDelayP50: false,
16+
eventLoopDelayP99: false,
17+
eventLoopUtilization: false,
18+
uptime: false,
19+
},
20+
}),
21+
],
22+
});
23+
24+
async function run(): Promise<void> {
25+
await new Promise<void>(resolve => setTimeout(resolve, 250));
26+
await Sentry.flush();
27+
}
28+
29+
// eslint-disable-next-line @typescript-eslint/no-floating-promises
30+
run();
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import * as Sentry from '@sentry/node';
2+
import { loggingTransport } from '@sentry-internal/node-integration-tests';
3+
4+
Sentry.init({
5+
dsn: 'https://public@dsn.ingest.sentry.io/1337',
6+
release: '1.0.0',
7+
environment: 'test',
8+
transport: loggingTransport,
9+
integrations: [
10+
Sentry.nodeRuntimeMetricsIntegration({
11+
collectionIntervalMs: 100,
12+
}),
13+
],
14+
});
15+
16+
async function run(): Promise<void> {
17+
// Wait long enough for the collection interval to fire at least once.
18+
await new Promise<void>(resolve => setTimeout(resolve, 250));
19+
await Sentry.flush();
20+
}
21+
22+
// eslint-disable-next-line @typescript-eslint/no-floating-promises
23+
run();
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
import { afterAll, describe, expect, test } from 'vitest';
2+
import { cleanupChildProcesses, createRunner } from '../../utils/runner';
3+
4+
const SENTRY_ATTRIBUTES = {
5+
'sentry.release': { value: '1.0.0', type: 'string' },
6+
'sentry.environment': { value: 'test', type: 'string' },
7+
'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' },
8+
'sentry.sdk.version': { value: expect.any(String), type: 'string' },
9+
'sentry.origin': { value: 'auto.node.runtime_metrics', type: 'string' },
10+
};
11+
12+
const gauge = (name: string, unit?: string) => ({
13+
timestamp: expect.any(Number),
14+
trace_id: expect.any(String),
15+
name,
16+
type: 'gauge',
17+
...(unit ? { unit } : {}),
18+
value: expect.any(Number),
19+
attributes: expect.objectContaining(SENTRY_ATTRIBUTES),
20+
});
21+
22+
const counter = (name: string, unit?: string) => ({
23+
timestamp: expect.any(Number),
24+
trace_id: expect.any(String),
25+
name,
26+
type: 'counter',
27+
...(unit ? { unit } : {}),
28+
value: expect.any(Number),
29+
attributes: expect.objectContaining(SENTRY_ATTRIBUTES),
30+
});
31+
32+
describe('nodeRuntimeMetricsIntegration', () => {
33+
afterAll(() => {
34+
cleanupChildProcesses();
35+
});
36+
37+
test('emits default runtime metrics with correct shape', async () => {
38+
const runner = createRunner(__dirname, 'scenario.ts')
39+
.expect({
40+
trace_metric: {
41+
items: expect.arrayContaining([
42+
gauge('node.runtime.mem.rss', 'byte'),
43+
gauge('node.runtime.mem.heap_used', 'byte'),
44+
gauge('node.runtime.mem.heap_total', 'byte'),
45+
gauge('node.runtime.cpu.utilization'),
46+
gauge('node.runtime.event_loop.delay.p50', 'second'),
47+
gauge('node.runtime.event_loop.delay.p99', 'second'),
48+
gauge('node.runtime.event_loop.utilization'),
49+
counter('node.runtime.process.uptime', 'second'),
50+
]),
51+
},
52+
})
53+
.start();
54+
55+
await runner.completed();
56+
});
57+
58+
test('does not emit opt-in metrics by default', async () => {
59+
const runner = createRunner(__dirname, 'scenario.ts')
60+
.expect({
61+
trace_metric: (container: { items: Array<{ name: string }> }) => {
62+
const names = container.items.map(item => item.name);
63+
expect(names).not.toContain('node.runtime.cpu.user');
64+
expect(names).not.toContain('node.runtime.cpu.system');
65+
expect(names).not.toContain('node.runtime.mem.external');
66+
expect(names).not.toContain('node.runtime.mem.array_buffers');
67+
expect(names).not.toContain('node.runtime.event_loop.delay.min');
68+
expect(names).not.toContain('node.runtime.event_loop.delay.max');
69+
expect(names).not.toContain('node.runtime.event_loop.delay.mean');
70+
expect(names).not.toContain('node.runtime.event_loop.delay.p90');
71+
},
72+
})
73+
.start();
74+
75+
await runner.completed();
76+
});
77+
78+
test('emits all metrics when fully opted in', async () => {
79+
const runner = createRunner(__dirname, 'scenario-all.ts')
80+
.expect({
81+
trace_metric: {
82+
items: expect.arrayContaining([
83+
gauge('node.runtime.mem.rss', 'byte'),
84+
gauge('node.runtime.mem.heap_used', 'byte'),
85+
gauge('node.runtime.mem.heap_total', 'byte'),
86+
gauge('node.runtime.mem.external', 'byte'),
87+
gauge('node.runtime.mem.array_buffers', 'byte'),
88+
gauge('node.runtime.cpu.user', 'second'),
89+
gauge('node.runtime.cpu.system', 'second'),
90+
gauge('node.runtime.cpu.utilization'),
91+
gauge('node.runtime.event_loop.delay.min', 'second'),
92+
gauge('node.runtime.event_loop.delay.max', 'second'),
93+
gauge('node.runtime.event_loop.delay.mean', 'second'),
94+
gauge('node.runtime.event_loop.delay.p50', 'second'),
95+
gauge('node.runtime.event_loop.delay.p90', 'second'),
96+
gauge('node.runtime.event_loop.delay.p99', 'second'),
97+
gauge('node.runtime.event_loop.utilization'),
98+
counter('node.runtime.process.uptime', 'second'),
99+
]),
100+
},
101+
})
102+
.start();
103+
104+
await runner.completed();
105+
});
106+
107+
test('respects opt-out: only memory metrics remain when cpu/event loop/uptime are disabled', async () => {
108+
const runner = createRunner(__dirname, 'scenario-opt-out.ts')
109+
.expect({
110+
trace_metric: (container: { items: Array<{ name: string }> }) => {
111+
const names = container.items.map(item => item.name);
112+
113+
// Memory metrics should still be present
114+
expect(names).toContain('node.runtime.mem.rss');
115+
expect(names).toContain('node.runtime.mem.heap_used');
116+
expect(names).toContain('node.runtime.mem.heap_total');
117+
118+
// Everything else should be absent
119+
expect(names).not.toContain('node.runtime.cpu.utilization');
120+
expect(names).not.toContain('node.runtime.event_loop.delay.p50');
121+
expect(names).not.toContain('node.runtime.event_loop.delay.p99');
122+
expect(names).not.toContain('node.runtime.event_loop.utilization');
123+
expect(names).not.toContain('node.runtime.process.uptime');
124+
},
125+
})
126+
.start();
127+
128+
await runner.completed();
129+
});
130+
});

packages/astro/src/index.server.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,8 @@ export {
8484
lruMemoizerIntegration,
8585
makeNodeTransport,
8686
modulesIntegration,
87+
nodeRuntimeMetricsIntegration,
88+
type NodeRuntimeMetricsOptions,
8789
mongoIntegration,
8890
mongooseIntegration,
8991
mysql2Integration,

packages/aws-serverless/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,8 @@ export {
6161
langChainIntegration,
6262
langGraphIntegration,
6363
modulesIntegration,
64+
nodeRuntimeMetricsIntegration,
65+
type NodeRuntimeMetricsOptions,
6466
contextLinesIntegration,
6567
nodeContextIntegration,
6668
localVariablesIntegration,

packages/core/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -537,3 +537,4 @@ export {
537537
safeMathRandom as _INTERNAL_safeMathRandom,
538538
safeDateNow as _INTERNAL_safeDateNow,
539539
} from './utils/randomSafeContext';
540+
export { safeUnref as _INTERNAL_safeUnref } from './utils/timer';

packages/google-cloud-serverless/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,8 @@ export {
6161
langChainIntegration,
6262
langGraphIntegration,
6363
modulesIntegration,
64+
nodeRuntimeMetricsIntegration,
65+
type NodeRuntimeMetricsOptions,
6466
contextLinesIntegration,
6567
nodeContextIntegration,
6668
localVariablesIntegration,

0 commit comments

Comments
 (0)