diff --git a/packages/deno/src/index.ts b/packages/deno/src/index.ts index e6fdde530c81..9d04cdb6d663 100644 --- a/packages/deno/src/index.ts +++ b/packages/deno/src/index.ts @@ -109,3 +109,4 @@ export { contextLinesIntegration } from './integrations/contextlines'; export { denoCronIntegration } from './integrations/deno-cron'; export { breadcrumbsIntegration } from './integrations/breadcrumbs'; export { vercelAIIntegration } from './integrations/tracing/vercelai'; +export { denoRuntimeMetricsIntegration, type DenoRuntimeMetricsOptions } from './integrations/denoRuntimeMetrics'; diff --git a/packages/deno/src/integrations/denoRuntimeMetrics.ts b/packages/deno/src/integrations/denoRuntimeMetrics.ts new file mode 100644 index 000000000000..6633134b6b93 --- /dev/null +++ b/packages/deno/src/integrations/denoRuntimeMetrics.ts @@ -0,0 +1,106 @@ +import { _INTERNAL_safeDateNow, _INTERNAL_safeUnref, defineIntegration, metrics } from '@sentry/core'; + +const INTEGRATION_NAME = 'DenoRuntimeMetrics'; +const DEFAULT_INTERVAL_MS = 30_000; + +export interface DenoRuntimeMetricsOptions { + /** + * Which metrics to collect. + * + * Default on (4 metrics): + * - `memRss` — Resident Set Size (actual memory footprint) + * - `memHeapUsed` — V8 heap currently in use + * - `memHeapTotal` — total V8 heap allocated + * - `uptime` — process uptime (detect restarts/crashes) + * + * Default off (opt-in): + * - `memExternal` — external memory (JS objects outside the V8 isolate) + * + * Note: CPU utilization and event loop metrics are not available in Deno. + */ + collect?: { + memRss?: boolean; + memHeapUsed?: boolean; + memHeapTotal?: boolean; + memExternal?: boolean; + uptime?: boolean; + }; + /** + * How often to collect metrics, in milliseconds. + * @default 30000 + */ + collectionIntervalMs?: number; +} + +/** + * Automatically collects Deno runtime metrics and emits them to Sentry. + * + * @example + * ```ts + * Sentry.init({ + * integrations: [ + * Sentry.denoRuntimeMetricsIntegration(), + * ], + * }); + * ``` + */ +export const denoRuntimeMetricsIntegration = defineIntegration((options: DenoRuntimeMetricsOptions = {}) => { + const collectionIntervalMs = options.collectionIntervalMs ?? DEFAULT_INTERVAL_MS; + const collect = { + // Default on + memRss: true, + memHeapUsed: true, + memHeapTotal: true, + uptime: true, + // Default off + memExternal: false, + ...options.collect, + }; + + let intervalId: ReturnType | undefined; + let prevFlushTime: number = 0; + + const METRIC_ATTRIBUTES_BYTE = { unit: 'byte', attributes: { 'sentry.origin': 'auto.deno.runtime_metrics' } }; + const METRIC_ATTRIBUTES_SECOND = { unit: 'second', attributes: { 'sentry.origin': 'auto.deno.runtime_metrics' } }; + + function collectMetrics(): void { + const now = _INTERNAL_safeDateNow(); + const elapsed = now - prevFlushTime; + + if (collect.memRss || collect.memHeapUsed || collect.memHeapTotal || collect.memExternal) { + const mem = Deno.memoryUsage(); + if (collect.memRss) { + metrics.gauge('deno.runtime.mem.rss', mem.rss, METRIC_ATTRIBUTES_BYTE); + } + if (collect.memHeapUsed) { + metrics.gauge('deno.runtime.mem.heap_used', mem.heapUsed, METRIC_ATTRIBUTES_BYTE); + } + if (collect.memHeapTotal) { + metrics.gauge('deno.runtime.mem.heap_total', mem.heapTotal, METRIC_ATTRIBUTES_BYTE); + } + if (collect.memExternal) { + metrics.gauge('deno.runtime.mem.external', mem.external, METRIC_ATTRIBUTES_BYTE); + } + } + + if (collect.uptime && elapsed > 0) { + metrics.count('deno.runtime.process.uptime', elapsed / 1000, METRIC_ATTRIBUTES_SECOND); + } + + prevFlushTime = now; + } + + return { + name: INTEGRATION_NAME, + + setup(): void { + prevFlushTime = _INTERNAL_safeDateNow(); + + // Guard against double setup (e.g. re-init). + if (intervalId) { + clearInterval(intervalId); + } + intervalId = _INTERNAL_safeUnref(setInterval(collectMetrics, collectionIntervalMs)); + }, + }; +}); diff --git a/packages/deno/test/deno-runtime-metrics.test.ts b/packages/deno/test/deno-runtime-metrics.test.ts new file mode 100644 index 000000000000..85ea8b7d4651 --- /dev/null +++ b/packages/deno/test/deno-runtime-metrics.test.ts @@ -0,0 +1,193 @@ +// + +import { assertEquals, assertNotEquals } from 'https://deno.land/std@0.212.0/assert/mod.ts'; +import { spy, stub } from 'https://deno.land/std@0.212.0/testing/mock.ts'; +import { FakeTime } from 'https://deno.land/std@0.212.0/testing/time.ts'; +import { denoRuntimeMetricsIntegration, metrics } from '../build/esm/index.js'; + +const MOCK_MEMORY: Deno.MemoryUsage = { + rss: 50_000_000, + heapTotal: 30_000_000, + heapUsed: 20_000_000, + external: 1_000_000, +}; + +// deno-lint-ignore no-explicit-any +type AnyCall = { args: any[] }; + +Deno.test('denoRuntimeMetricsIntegration has the correct name', () => { + const integration = denoRuntimeMetricsIntegration(); + assertEquals(integration.name, 'DenoRuntimeMetrics'); +}); + +Deno.test('starts a collection interval', () => { + using time = new FakeTime(); + using _memStub = stub(Deno, 'memoryUsage', () => MOCK_MEMORY); + const gaugeSpy = spy(metrics, 'gauge'); + + try { + const integration = denoRuntimeMetricsIntegration({ collectionIntervalMs: 1_000 }); + integration.setup!({} as never); + + assertEquals(gaugeSpy.calls.length, 0); + time.tick(1_000); + assertNotEquals(gaugeSpy.calls.length, 0); + } finally { + gaugeSpy.restore(); + } +}); + +Deno.test('emits default memory metrics', () => { + using time = new FakeTime(); + using _memStub = stub(Deno, 'memoryUsage', () => MOCK_MEMORY); + const gaugeSpy = spy(metrics, 'gauge'); + + try { + const integration = denoRuntimeMetricsIntegration({ collectionIntervalMs: 1_000 }); + integration.setup!({} as never); + time.tick(1_000); + + const names = (gaugeSpy.calls as AnyCall[]).map(c => c.args[0]); + assertEquals(names.includes('deno.runtime.mem.rss'), true); + assertEquals(names.includes('deno.runtime.mem.heap_used'), true); + assertEquals(names.includes('deno.runtime.mem.heap_total'), true); + } finally { + gaugeSpy.restore(); + } +}); + +Deno.test('emits correct memory values', () => { + using time = new FakeTime(); + using _memStub = stub(Deno, 'memoryUsage', () => MOCK_MEMORY); + const gaugeSpy = spy(metrics, 'gauge'); + + try { + const integration = denoRuntimeMetricsIntegration({ collectionIntervalMs: 1_000 }); + integration.setup!({} as never); + time.tick(1_000); + + const calls = gaugeSpy.calls as AnyCall[]; + const rssCall = calls.find(c => c.args[0] === 'deno.runtime.mem.rss'); + const heapUsedCall = calls.find(c => c.args[0] === 'deno.runtime.mem.heap_used'); + const heapTotalCall = calls.find(c => c.args[0] === 'deno.runtime.mem.heap_total'); + + assertEquals(rssCall?.args[1], 50_000_000); + assertEquals(heapUsedCall?.args[1], 20_000_000); + assertEquals(heapTotalCall?.args[1], 30_000_000); + } finally { + gaugeSpy.restore(); + } +}); + +Deno.test('does not emit mem.external by default', () => { + using time = new FakeTime(); + using _memStub = stub(Deno, 'memoryUsage', () => MOCK_MEMORY); + const gaugeSpy = spy(metrics, 'gauge'); + + try { + const integration = denoRuntimeMetricsIntegration({ collectionIntervalMs: 1_000 }); + integration.setup!({} as never); + time.tick(1_000); + + const names = (gaugeSpy.calls as AnyCall[]).map(c => c.args[0]); + assertEquals(names.includes('deno.runtime.mem.external'), false); + } finally { + gaugeSpy.restore(); + } +}); + +Deno.test('emits mem.external when opted in', () => { + using time = new FakeTime(); + using _memStub = stub(Deno, 'memoryUsage', () => MOCK_MEMORY); + const gaugeSpy = spy(metrics, 'gauge'); + + try { + const integration = denoRuntimeMetricsIntegration({ + collectionIntervalMs: 1_000, + collect: { memExternal: true }, + }); + integration.setup!({} as never); + time.tick(1_000); + + const calls = gaugeSpy.calls as AnyCall[]; + const externalCall = calls.find(c => c.args[0] === 'deno.runtime.mem.external'); + assertEquals(externalCall?.args[1], 1_000_000); + } finally { + gaugeSpy.restore(); + } +}); + +Deno.test('emits uptime counter', () => { + using time = new FakeTime(); + using _memStub = stub(Deno, 'memoryUsage', () => MOCK_MEMORY); + const countSpy = spy(metrics, 'count'); + + try { + const integration = denoRuntimeMetricsIntegration({ collectionIntervalMs: 1_000 }); + integration.setup!({} as never); + time.tick(1_000); + + const uptimeCall = (countSpy.calls as AnyCall[]).find(c => c.args[0] === 'deno.runtime.process.uptime'); + assertNotEquals(uptimeCall, undefined); + } finally { + countSpy.restore(); + } +}); + +Deno.test('respects opt-out: skips mem.rss when memRss is false', () => { + using time = new FakeTime(); + using _memStub = stub(Deno, 'memoryUsage', () => MOCK_MEMORY); + const gaugeSpy = spy(metrics, 'gauge'); + + try { + const integration = denoRuntimeMetricsIntegration({ + collectionIntervalMs: 1_000, + collect: { memRss: false }, + }); + integration.setup!({} as never); + time.tick(1_000); + + const names = (gaugeSpy.calls as AnyCall[]).map(c => c.args[0]); + assertEquals(names.includes('deno.runtime.mem.rss'), false); + } finally { + gaugeSpy.restore(); + } +}); + +Deno.test('skips uptime when uptime is false', () => { + using time = new FakeTime(); + using _memStub = stub(Deno, 'memoryUsage', () => MOCK_MEMORY); + const countSpy = spy(metrics, 'count'); + + try { + const integration = denoRuntimeMetricsIntegration({ + collectionIntervalMs: 1_000, + collect: { uptime: false }, + }); + integration.setup!({} as never); + time.tick(1_000); + + const uptimeCall = (countSpy.calls as AnyCall[]).find(c => c.args[0] === 'deno.runtime.process.uptime'); + assertEquals(uptimeCall, undefined); + } finally { + countSpy.restore(); + } +}); + +Deno.test('attaches correct sentry.origin attribute', () => { + using time = new FakeTime(); + using _memStub = stub(Deno, 'memoryUsage', () => MOCK_MEMORY); + const gaugeSpy = spy(metrics, 'gauge'); + + try { + const integration = denoRuntimeMetricsIntegration({ collectionIntervalMs: 1_000 }); + integration.setup!({} as never); + time.tick(1_000); + + const calls = gaugeSpy.calls as AnyCall[]; + const rssCall = calls.find(c => c.args[0] === 'deno.runtime.mem.rss'); + assertEquals(rssCall?.args[2]?.attributes?.['sentry.origin'], 'auto.deno.runtime_metrics'); + } finally { + gaugeSpy.restore(); + } +});