Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 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
1 change: 1 addition & 0 deletions packages/deno/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
124 changes: 124 additions & 0 deletions packages/deno/src/integrations/denoRuntimeMetrics.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import { _INTERNAL_safeDateNow, defineIntegration, metrics } from '@sentry/core';

const INTEGRATION_NAME = 'DenoRuntimeMetrics';
const DEFAULT_INTERVAL_MS = 30_000;
const MIN_INTERVAL_MS = 1_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;
Comment thread
chargome marked this conversation as resolved.
}

/**
* Automatically collects Deno runtime metrics and emits them to Sentry.
*
* @example
* ```ts
* Sentry.init({
* integrations: [
* Sentry.denoRuntimeMetricsIntegration(),
* ],
* });
* ```
*/
export const denoRuntimeMetricsIntegration = defineIntegration((options: DenoRuntimeMetricsOptions = {}) => {
const rawInterval = options.collectionIntervalMs ?? DEFAULT_INTERVAL_MS;
if (rawInterval < MIN_INTERVAL_MS) {
// eslint-disable-next-line no-console
console.warn(
`[Sentry] denoRuntimeMetricsIntegration: collectionIntervalMs (${rawInterval}) is below the minimum of ${MIN_INTERVAL_MS}ms. Clamping to ${MIN_INTERVAL_MS}ms.`,
);
}
const collectionIntervalMs = Math.max(rawInterval, MIN_INTERVAL_MS);
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated
const collect = {
// Default on
memRss: true,
memHeapUsed: true,
memHeapTotal: true,
uptime: true,
// Default off
memExternal: false,
...options.collect,
};

let intervalId: number | 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);
}
// setInterval in Deno returns a number at runtime (global API, not node:timers).
// @types/node in the monorepo overrides the global type to NodeJS.Timeout, so we cast.
intervalId = setInterval(collectMetrics, collectionIntervalMs) as unknown as number;
Deno.unrefTimer(intervalId);
},

teardown(): void {
if (intervalId) {
clearInterval(intervalId);
intervalId = undefined;
}
},
};
});
136 changes: 136 additions & 0 deletions packages/deno/test/deno-runtime-metrics.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
// <reference lib="deno.ns" />

import type { Envelope } from '@sentry/core';
import { createStackParser, forEachEnvelopeItem, nodeStackLineParser } from '@sentry/core';
import { assertEquals, assertNotEquals, assertStringIncludes } from 'https://deno.land/std@0.212.0/assert/mod.ts';
import {
DenoClient,
denoRuntimeMetricsIntegration,
getCurrentScope,
getDefaultIntegrations,
} from '../build/esm/index.js';
import { makeTestTransport } from './transport.ts';

const DSN = 'https://233a45e5efe34c47a3536797ce15dafa@nothing.here/5650507';

function delay(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}

// deno-lint-ignore no-explicit-any
type MetricItem = { name: string; type: string; value: number; unit?: string; attributes?: Record<string, any> };

async function collectMetrics(
integrationOptions: Parameters<typeof denoRuntimeMetricsIntegration>[0] = {},
): Promise<MetricItem[]> {
const envelopes: Envelope[] = [];

// Hold a reference so we can call teardown() to stop the interval before the test ends.
const metricsIntegration = denoRuntimeMetricsIntegration({ collectionIntervalMs: 1000, ...integrationOptions });

const client = new DenoClient({
dsn: DSN,
integrations: [...getDefaultIntegrations({}), metricsIntegration],
stackParser: createStackParser(nodeStackLineParser()),
transport: makeTestTransport(envelope => {
envelopes.push(envelope);
}),
});

client.init();
getCurrentScope().setClient(client);

await delay(2500);
await client.flush(2000);

// Stop the collection interval so Deno's leak detector doesn't flag it.
metricsIntegration.teardown?.();

const items: MetricItem[] = [];
for (const envelope of envelopes) {
forEachEnvelopeItem(envelope, item => {
const [headers, body] = item;
if (headers.type === 'trace_metric') {
// deno-lint-ignore no-explicit-any
items.push(...(body as any).items);
}
});
}

return items;
}

Deno.test('denoRuntimeMetricsIntegration has the correct name', () => {
const integration = denoRuntimeMetricsIntegration();
assertEquals(integration.name, 'DenoRuntimeMetrics');
});

Deno.test('emits default memory metrics with correct shape', async () => {
const items = await collectMetrics();
const names = items.map(i => i.name);

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);

const rss = items.find(i => i.name === 'deno.runtime.mem.rss');
assertEquals(rss?.type, 'gauge');
assertEquals(rss?.unit, 'byte');
assertEquals(typeof rss?.value, 'number');
});

Deno.test('emits uptime counter', async () => {
const items = await collectMetrics();
const uptime = items.find(i => i.name === 'deno.runtime.process.uptime');

assertNotEquals(uptime, undefined);
assertEquals(uptime?.type, 'counter');
assertEquals(uptime?.unit, 'second');
});

Deno.test('does not emit mem.external by default', async () => {
const items = await collectMetrics();
const names = items.map(i => i.name);
assertEquals(names.includes('deno.runtime.mem.external'), false);
});

Deno.test('emits mem.external when opted in', async () => {
const items = await collectMetrics({ collect: { memExternal: true } });
const external = items.find(i => i.name === 'deno.runtime.mem.external');

assertNotEquals(external, undefined);
assertEquals(external?.type, 'gauge');
assertEquals(external?.unit, 'byte');
});

Deno.test('respects opt-out: skips uptime when disabled', async () => {
const items = await collectMetrics({ collect: { uptime: false } });
const names = items.map(i => i.name);

assertEquals(names.includes('deno.runtime.mem.rss'), true);
assertEquals(names.includes('deno.runtime.process.uptime'), false);
});

Deno.test('attaches correct sentry.origin attribute', async () => {
const items = await collectMetrics();
const rss = items.find(i => i.name === 'deno.runtime.mem.rss');

// Attributes in the serialized envelope are { type, value } objects.
assertEquals(rss?.attributes?.['sentry.origin']?.value, 'auto.deno.runtime_metrics');
});

Deno.test('warns and clamps collectionIntervalMs below 1000ms', () => {
const warnings: string[] = [];
const originalWarn = console.warn;

Check failure on line 124 in packages/deno/test/deno-runtime-metrics.test.ts

View workflow job for this annotation

GitHub Actions / Lint

eslint(no-console)

Unexpected console statement.
console.warn = (msg: string) => warnings.push(msg);

Check failure on line 125 in packages/deno/test/deno-runtime-metrics.test.ts

View workflow job for this annotation

GitHub Actions / Lint

eslint(no-console)

Unexpected console statement.

try {
denoRuntimeMetricsIntegration({ collectionIntervalMs: 100 });
} finally {
console.warn = originalWarn;

Check failure on line 130 in packages/deno/test/deno-runtime-metrics.test.ts

View workflow job for this annotation

GitHub Actions / Lint

eslint(no-console)

Unexpected console statement.
}

assertEquals(warnings.length, 1);
assertStringIncludes(warnings[0]!, 'collectionIntervalMs');
assertStringIncludes(warnings[0]!, '1000');
});
Loading