Skip to content

Commit b29d628

Browse files
chargomeclaude
andauthored
feat(deno): Add denoRuntimeMetricsIntegration (#20023)
Adds `denoRuntimeMetricsIntegration` to `@sentry/deno` that collects runtime health metrics on a configurable interval (default: 30s). --- **Default metrics** (`deno.runtime.*` prefix): - `mem.rss` — Resident Set Size - `mem.heap_used` — V8 heap in use - `mem.heap_total` — total V8 heap - `process.uptime` — process uptime **Opt-in:** `memExternal` enables `mem.external` --- **vs. `nodeRuntimeMetricsIntegration`:** No CPU or event loop metrics — Deno does not expose `process.cpuUsage()`, `performance.eventLoopUtilization()`, or `monitorEventLoopDelay`. Note: Deno's [loadavg()](https://docs.deno.com/api/deno/~/Deno.loadavg) reflects system-wide load across all processes rather than the Deno process itself, which is why we did not include it. closes https://linear.app/getsentry/issue/JS-1957/runtime-metrics-deno-support --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 5b16a5c commit b29d628

File tree

3 files changed

+278
-0
lines changed

3 files changed

+278
-0
lines changed

packages/deno/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,3 +109,4 @@ export { contextLinesIntegration } from './integrations/contextlines';
109109
export { denoCronIntegration } from './integrations/deno-cron';
110110
export { breadcrumbsIntegration } from './integrations/breadcrumbs';
111111
export { vercelAIIntegration } from './integrations/tracing/vercelai';
112+
export { denoRuntimeMetricsIntegration, type DenoRuntimeMetricsOptions } from './integrations/denoRuntimeMetrics';
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
import { _INTERNAL_safeDateNow, defineIntegration, metrics } from '@sentry/core';
2+
3+
const INTEGRATION_NAME = 'DenoRuntimeMetrics';
4+
const DEFAULT_INTERVAL_MS = 30_000;
5+
const MIN_INTERVAL_MS = 1_000;
6+
7+
export interface DenoRuntimeMetricsOptions {
8+
/**
9+
* Which metrics to collect.
10+
*
11+
* Default on (4 metrics):
12+
* - `memRss` — Resident Set Size (actual memory footprint)
13+
* - `memHeapUsed` — V8 heap currently in use
14+
* - `memHeapTotal` — total V8 heap allocated
15+
* - `uptime` — process uptime (detect restarts/crashes)
16+
*
17+
* Default off (opt-in):
18+
* - `memExternal` — external memory (JS objects outside the V8 isolate)
19+
*
20+
* Note: CPU utilization and event loop metrics are not available in Deno.
21+
*/
22+
collect?: {
23+
memRss?: boolean;
24+
memHeapUsed?: boolean;
25+
memHeapTotal?: boolean;
26+
memExternal?: boolean;
27+
uptime?: boolean;
28+
};
29+
/**
30+
* How often to collect metrics, in milliseconds.
31+
* Values below 1000ms are clamped to 1000ms.
32+
* @default 30000
33+
* @minimum 1000
34+
*/
35+
collectionIntervalMs?: number;
36+
}
37+
38+
/**
39+
* Automatically collects Deno runtime metrics and emits them to Sentry.
40+
*
41+
* @example
42+
* ```ts
43+
* Sentry.init({
44+
* integrations: [
45+
* Sentry.denoRuntimeMetricsIntegration(),
46+
* ],
47+
* });
48+
* ```
49+
*/
50+
export const denoRuntimeMetricsIntegration = defineIntegration((options: DenoRuntimeMetricsOptions = {}) => {
51+
const rawInterval = options.collectionIntervalMs ?? DEFAULT_INTERVAL_MS;
52+
if (!Number.isFinite(rawInterval) || rawInterval < MIN_INTERVAL_MS) {
53+
// eslint-disable-next-line no-console
54+
console.warn(
55+
`[Sentry] denoRuntimeMetricsIntegration: collectionIntervalMs (${rawInterval}) is below the minimum of ${MIN_INTERVAL_MS}ms. Clamping to ${MIN_INTERVAL_MS}ms.`,
56+
);
57+
}
58+
const collectionIntervalMs = Number.isFinite(rawInterval) ? Math.max(rawInterval, MIN_INTERVAL_MS) : MIN_INTERVAL_MS;
59+
const collect = {
60+
// Default on
61+
memRss: true,
62+
memHeapUsed: true,
63+
memHeapTotal: true,
64+
uptime: true,
65+
// Default off
66+
memExternal: false,
67+
...options.collect,
68+
};
69+
70+
let intervalId: number | undefined;
71+
let prevFlushTime: number = 0;
72+
73+
const METRIC_ATTRIBUTES_BYTE = { unit: 'byte', attributes: { 'sentry.origin': 'auto.deno.runtime_metrics' } };
74+
const METRIC_ATTRIBUTES_SECOND = { unit: 'second', attributes: { 'sentry.origin': 'auto.deno.runtime_metrics' } };
75+
76+
function collectMetrics(): void {
77+
const now = _INTERNAL_safeDateNow();
78+
const elapsed = now - prevFlushTime;
79+
80+
if (collect.memRss || collect.memHeapUsed || collect.memHeapTotal || collect.memExternal) {
81+
const mem = Deno.memoryUsage();
82+
if (collect.memRss) {
83+
metrics.gauge('deno.runtime.mem.rss', mem.rss, METRIC_ATTRIBUTES_BYTE);
84+
}
85+
if (collect.memHeapUsed) {
86+
metrics.gauge('deno.runtime.mem.heap_used', mem.heapUsed, METRIC_ATTRIBUTES_BYTE);
87+
}
88+
if (collect.memHeapTotal) {
89+
metrics.gauge('deno.runtime.mem.heap_total', mem.heapTotal, METRIC_ATTRIBUTES_BYTE);
90+
}
91+
if (collect.memExternal) {
92+
metrics.gauge('deno.runtime.mem.external', mem.external, METRIC_ATTRIBUTES_BYTE);
93+
}
94+
}
95+
96+
if (collect.uptime && elapsed > 0) {
97+
metrics.count('deno.runtime.process.uptime', elapsed / 1000, METRIC_ATTRIBUTES_SECOND);
98+
}
99+
100+
prevFlushTime = now;
101+
}
102+
103+
return {
104+
name: INTEGRATION_NAME,
105+
106+
setup(): void {
107+
prevFlushTime = _INTERNAL_safeDateNow();
108+
109+
// Guard against double setup (e.g. re-init).
110+
if (intervalId) {
111+
clearInterval(intervalId);
112+
}
113+
// setInterval in Deno returns a number at runtime (global API, not node:timers).
114+
// @types/node in the monorepo overrides the global type to NodeJS.Timeout, so we cast.
115+
intervalId = setInterval(collectMetrics, collectionIntervalMs) as unknown as number;
116+
Deno.unrefTimer(intervalId);
117+
},
118+
119+
teardown(): void {
120+
if (intervalId) {
121+
clearInterval(intervalId);
122+
intervalId = undefined;
123+
}
124+
},
125+
};
126+
});
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
// <reference lib="deno.ns" />
2+
3+
import type { Envelope } from '@sentry/core';
4+
import { createStackParser, forEachEnvelopeItem, nodeStackLineParser } from '@sentry/core';
5+
import { assertEquals, assertNotEquals, assertStringIncludes } from 'https://deno.land/std@0.212.0/assert/mod.ts';
6+
import {
7+
DenoClient,
8+
denoRuntimeMetricsIntegration,
9+
getCurrentScope,
10+
getDefaultIntegrations,
11+
} from '../build/esm/index.js';
12+
import { makeTestTransport } from './transport.ts';
13+
14+
const DSN = 'https://233a45e5efe34c47a3536797ce15dafa@nothing.here/5650507';
15+
16+
function delay(ms: number): Promise<void> {
17+
return new Promise(resolve => setTimeout(resolve, ms));
18+
}
19+
20+
// deno-lint-ignore no-explicit-any
21+
type MetricItem = { name: string; type: string; value: number; unit?: string; attributes?: Record<string, any> };
22+
23+
async function collectMetrics(
24+
integrationOptions: Parameters<typeof denoRuntimeMetricsIntegration>[0] = {},
25+
): Promise<MetricItem[]> {
26+
const envelopes: Envelope[] = [];
27+
28+
// Hold a reference so we can call teardown() to stop the interval before the test ends.
29+
const metricsIntegration = denoRuntimeMetricsIntegration({ collectionIntervalMs: 1000, ...integrationOptions });
30+
31+
const client = new DenoClient({
32+
dsn: DSN,
33+
integrations: [...getDefaultIntegrations({}), metricsIntegration],
34+
stackParser: createStackParser(nodeStackLineParser()),
35+
transport: makeTestTransport(envelope => {
36+
envelopes.push(envelope);
37+
}),
38+
});
39+
40+
client.init();
41+
getCurrentScope().setClient(client);
42+
43+
await delay(2500);
44+
await client.flush(2000);
45+
46+
// Stop the collection interval so Deno's leak detector doesn't flag it.
47+
metricsIntegration.teardown?.();
48+
49+
const items: MetricItem[] = [];
50+
for (const envelope of envelopes) {
51+
forEachEnvelopeItem(envelope, item => {
52+
const [headers, body] = item;
53+
if (headers.type === 'trace_metric') {
54+
// deno-lint-ignore no-explicit-any
55+
items.push(...(body as any).items);
56+
}
57+
});
58+
}
59+
60+
return items;
61+
}
62+
63+
Deno.test('denoRuntimeMetricsIntegration has the correct name', () => {
64+
const integration = denoRuntimeMetricsIntegration();
65+
assertEquals(integration.name, 'DenoRuntimeMetrics');
66+
});
67+
68+
Deno.test('emits default memory metrics with correct shape', async () => {
69+
const items = await collectMetrics();
70+
const names = items.map(i => i.name);
71+
72+
assertEquals(names.includes('deno.runtime.mem.rss'), true);
73+
assertEquals(names.includes('deno.runtime.mem.heap_used'), true);
74+
assertEquals(names.includes('deno.runtime.mem.heap_total'), true);
75+
76+
const rss = items.find(i => i.name === 'deno.runtime.mem.rss');
77+
assertEquals(rss?.type, 'gauge');
78+
assertEquals(rss?.unit, 'byte');
79+
assertEquals(typeof rss?.value, 'number');
80+
});
81+
82+
Deno.test('emits uptime counter', async () => {
83+
const items = await collectMetrics();
84+
const uptime = items.find(i => i.name === 'deno.runtime.process.uptime');
85+
86+
assertNotEquals(uptime, undefined);
87+
assertEquals(uptime?.type, 'counter');
88+
assertEquals(uptime?.unit, 'second');
89+
});
90+
91+
Deno.test('does not emit mem.external by default', async () => {
92+
const items = await collectMetrics();
93+
const names = items.map(i => i.name);
94+
assertEquals(names.includes('deno.runtime.mem.external'), false);
95+
});
96+
97+
Deno.test('emits mem.external when opted in', async () => {
98+
const items = await collectMetrics({ collect: { memExternal: true } });
99+
const external = items.find(i => i.name === 'deno.runtime.mem.external');
100+
101+
assertNotEquals(external, undefined);
102+
assertEquals(external?.type, 'gauge');
103+
assertEquals(external?.unit, 'byte');
104+
});
105+
106+
Deno.test('respects opt-out: skips uptime when disabled', async () => {
107+
const items = await collectMetrics({ collect: { uptime: false } });
108+
const names = items.map(i => i.name);
109+
110+
assertEquals(names.includes('deno.runtime.mem.rss'), true);
111+
assertEquals(names.includes('deno.runtime.process.uptime'), false);
112+
});
113+
114+
Deno.test('attaches correct sentry.origin attribute', async () => {
115+
const items = await collectMetrics();
116+
const rss = items.find(i => i.name === 'deno.runtime.mem.rss');
117+
118+
// Attributes in the serialized envelope are { type, value } objects.
119+
assertEquals(rss?.attributes?.['sentry.origin']?.value, 'auto.deno.runtime_metrics');
120+
});
121+
122+
Deno.test('warns and clamps collectionIntervalMs below 1000ms', () => {
123+
const warnings: string[] = [];
124+
const originalWarn = globalThis.console.warn;
125+
globalThis.console.warn = (msg: string) => warnings.push(msg);
126+
127+
try {
128+
denoRuntimeMetricsIntegration({ collectionIntervalMs: 100 });
129+
} finally {
130+
globalThis.console.warn = originalWarn;
131+
}
132+
133+
assertEquals(warnings.length, 1);
134+
assertStringIncludes(warnings[0]!, 'collectionIntervalMs');
135+
assertStringIncludes(warnings[0]!, '1000');
136+
});
137+
138+
Deno.test('warns and clamps collectionIntervalMs when NaN', () => {
139+
const warnings: string[] = [];
140+
const originalWarn = globalThis.console.warn;
141+
globalThis.console.warn = (msg: string) => warnings.push(msg);
142+
143+
try {
144+
denoRuntimeMetricsIntegration({ collectionIntervalMs: NaN });
145+
} finally {
146+
globalThis.console.warn = originalWarn;
147+
}
148+
149+
assertEquals(warnings.length, 1);
150+
assertStringIncludes(warnings[0]!, 'collectionIntervalMs');
151+
});

0 commit comments

Comments
 (0)