Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ const NODE_EXPORTS_IGNORE = [
'SentryContextManager',
'validateOpenTelemetrySetup',
'preloadOpenTelemetry',
// Internal helper only needed within integrations (e.g. bunRuntimeMetricsIntegration)
'_INTERNAL_normalizeCollectionInterval',
];

const nodeExports = Object.keys(SentryNode).filter(e => !NODE_EXPORTS_IGNORE.includes(e));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ Sentry.init({
transport: loggingTransport,
integrations: [
bunRuntimeMetricsIntegration({
collectionIntervalMs: 100,
collectionIntervalMs: 1000,
collect: {
cpuTime: true,
memExternal: true,
Expand All @@ -19,7 +19,7 @@ Sentry.init({
});

async function run(): Promise<void> {
await new Promise<void>(resolve => setTimeout(resolve, 250));
await new Promise<void>(resolve => setTimeout(resolve, 1100));
await Sentry.flush();
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ Sentry.init({
transport: loggingTransport,
integrations: [
bunRuntimeMetricsIntegration({
collectionIntervalMs: 100,
collectionIntervalMs: 1000,
collect: {
cpuUtilization: false,
cpuTime: false,
Expand All @@ -21,7 +21,7 @@ Sentry.init({
});

async function run(): Promise<void> {
await new Promise<void>(resolve => setTimeout(resolve, 250));
await new Promise<void>(resolve => setTimeout(resolve, 1100));
await Sentry.flush();
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,13 @@ Sentry.init({
transport: loggingTransport,
integrations: [
bunRuntimeMetricsIntegration({
collectionIntervalMs: 100,
collectionIntervalMs: 1000,
}),
],
});

async function run(): Promise<void> {
await new Promise<void>(resolve => setTimeout(resolve, 250));
await new Promise<void>(resolve => setTimeout(resolve, 1100));
await Sentry.flush();
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ Sentry.init({
transport: loggingTransport,
integrations: [
Sentry.nodeRuntimeMetricsIntegration({
collectionIntervalMs: 100,
collectionIntervalMs: 1000,
collect: {
cpuTime: true,
memExternal: true,
Expand All @@ -22,7 +22,7 @@ Sentry.init({
});

async function run(): Promise<void> {
await new Promise<void>(resolve => setTimeout(resolve, 250));
await new Promise<void>(resolve => setTimeout(resolve, 1100));
await Sentry.flush();
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ Sentry.init({
transport: loggingTransport,
integrations: [
Sentry.nodeRuntimeMetricsIntegration({
collectionIntervalMs: 100,
collectionIntervalMs: 1000,
collect: {
cpuUtilization: false,
cpuTime: false,
Expand All @@ -22,7 +22,7 @@ Sentry.init({
});

async function run(): Promise<void> {
await new Promise<void>(resolve => setTimeout(resolve, 250));
await new Promise<void>(resolve => setTimeout(resolve, 1100));
await Sentry.flush();
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,14 @@ Sentry.init({
transport: loggingTransport,
integrations: [
Sentry.nodeRuntimeMetricsIntegration({
collectionIntervalMs: 100,
collectionIntervalMs: 1000,
}),
],
});

async function run(): Promise<void> {
// Wait long enough for the collection interval to fire at least once.
await new Promise<void>(resolve => setTimeout(resolve, 250));
await new Promise<void>(resolve => setTimeout(resolve, 1100));
await Sentry.flush();
}

Expand Down
10 changes: 8 additions & 2 deletions packages/bun/src/integrations/bunRuntimeMetrics.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { performance } from 'perf_hooks';
import { _INTERNAL_safeDateNow, _INTERNAL_safeUnref, defineIntegration, metrics } from '@sentry/core';
import type { NodeRuntimeMetricsOptions } from '@sentry/node';
import { _INTERNAL_normalizeCollectionInterval, type NodeRuntimeMetricsOptions } from '@sentry/node';

const INTEGRATION_NAME = 'BunRuntimeMetrics';
const DEFAULT_INTERVAL_MS = 30_000;
Expand Down Expand Up @@ -44,7 +44,9 @@ export interface BunRuntimeMetricsOptions {
collect?: BunCollectOptions;
/**
* How often to collect metrics, in milliseconds.
* Minimum allowed value is 1000ms.
* @default 30000
* @minimum 1000
*/
collectionIntervalMs?: number;
}
Expand All @@ -62,7 +64,11 @@ export interface BunRuntimeMetricsOptions {
* ```
*/
export const bunRuntimeMetricsIntegration = defineIntegration((options: BunRuntimeMetricsOptions = {}) => {
const collectionIntervalMs = options.collectionIntervalMs ?? DEFAULT_INTERVAL_MS;
const collectionIntervalMs = _INTERNAL_normalizeCollectionInterval(
options.collectionIntervalMs ?? DEFAULT_INTERVAL_MS,
INTEGRATION_NAME,
DEFAULT_INTERVAL_MS,
);
const collect = {
// Default on
cpuUtilization: true,
Expand Down
35 changes: 35 additions & 0 deletions packages/bun/test/integrations/bunRuntimeMetrics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -212,4 +212,39 @@ describe('bunRuntimeMetricsIntegration', () => {
expect(countSpy).not.toHaveBeenCalledWith('bun.runtime.process.uptime', expect.anything(), expect.anything());
});
});

describe('collectionIntervalMs minimum', () => {
it('enforces minimum of 1000ms and warns', () => {
const warnSpy = spyOn(globalThis.console, 'warn').mockImplementation(() => {});

const integration = bunRuntimeMetricsIntegration({ collectionIntervalMs: 100 });
integration.setup();

expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('collectionIntervalMs'));
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('1000'));

// Should fire at minimum 1000ms, not at 100ms
jest.advanceTimersByTime(100);
expect(gaugeSpy).not.toHaveBeenCalled();

jest.advanceTimersByTime(900);
expect(gaugeSpy).toHaveBeenCalled();
});

it('falls back to default when NaN', () => {
const warnSpy = spyOn(globalThis.console, 'warn').mockImplementation(() => {});

const integration = bunRuntimeMetricsIntegration({ collectionIntervalMs: NaN });
integration.setup();

expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('collectionIntervalMs'));

// Should fire at the default 30000ms, not at 1000ms
jest.advanceTimersByTime(1000);
expect(gaugeSpy).not.toHaveBeenCalled();

jest.advanceTimersByTime(29_000);
expect(gaugeSpy).toHaveBeenCalled();
});
});
});
17 changes: 13 additions & 4 deletions packages/deno/src/integrations/denoRuntimeMetrics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ export interface DenoRuntimeMetricsOptions {
};
/**
* How often to collect metrics, in milliseconds.
* Values below 1000ms are clamped to 1000ms.
* Minimum allowed value is 1000ms.
* @default 30000
* @minimum 1000
*/
Expand All @@ -49,13 +49,22 @@ export interface DenoRuntimeMetricsOptions {
*/
export const denoRuntimeMetricsIntegration = defineIntegration((options: DenoRuntimeMetricsOptions = {}) => {
const rawInterval = options.collectionIntervalMs ?? DEFAULT_INTERVAL_MS;
if (!Number.isFinite(rawInterval) || rawInterval < MIN_INTERVAL_MS) {
let collectionIntervalMs: number;
if (!Number.isFinite(rawInterval)) {
// 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.`,
`[Sentry] denoRuntimeMetricsIntegration: collectionIntervalMs (${rawInterval}) is invalid. Using default of ${DEFAULT_INTERVAL_MS}ms.`,
);
collectionIntervalMs = DEFAULT_INTERVAL_MS;
} else 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. Using minimum of ${MIN_INTERVAL_MS}ms.`,
);
collectionIntervalMs = MIN_INTERVAL_MS;
} else {
collectionIntervalMs = rawInterval;
}
const collectionIntervalMs = Number.isFinite(rawInterval) ? Math.max(rawInterval, MIN_INTERVAL_MS) : MIN_INTERVAL_MS;
const collect = {
// Default on
memRss: true,
Expand Down
5 changes: 3 additions & 2 deletions packages/deno/test/deno-runtime-metrics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ Deno.test('attaches correct sentry.origin attribute', async () => {
assertEquals(rss?.attributes?.['sentry.origin']?.value, 'auto.deno.runtime_metrics');
});

Deno.test('warns and clamps collectionIntervalMs below 1000ms', () => {
Deno.test('warns and enforces minimum collectionIntervalMs', () => {
const warnings: string[] = [];
const originalWarn = globalThis.console.warn;
globalThis.console.warn = (msg: string) => warnings.push(msg);
Expand All @@ -135,7 +135,7 @@ Deno.test('warns and clamps collectionIntervalMs below 1000ms', () => {
assertStringIncludes(warnings[0]!, '1000');
});

Deno.test('warns and clamps collectionIntervalMs when NaN', () => {
Deno.test('warns and falls back to default when collectionIntervalMs is NaN', () => {
const warnings: string[] = [];
const originalWarn = globalThis.console.warn;
globalThis.console.warn = (msg: string) => warnings.push(msg);
Expand All @@ -148,4 +148,5 @@ Deno.test('warns and clamps collectionIntervalMs when NaN', () => {

assertEquals(warnings.length, 1);
assertStringIncludes(warnings[0]!, 'collectionIntervalMs');
assertStringIncludes(warnings[0]!, 'invalid');
});
6 changes: 5 additions & 1 deletion packages/node-core/src/common-exports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,11 @@ import * as logger from './logs/exports';

// Node-core integrations (not OTel-dependent)
export { nodeContextIntegration } from './integrations/context';
export { nodeRuntimeMetricsIntegration, type NodeRuntimeMetricsOptions } from './integrations/nodeRuntimeMetrics';
export {
nodeRuntimeMetricsIntegration,
type NodeRuntimeMetricsOptions,
_INTERNAL_normalizeCollectionInterval,
} from './integrations/nodeRuntimeMetrics';
export { contextLinesIntegration } from './integrations/contextlines';
export { localVariablesIntegration } from './integrations/local-variables';
export { modulesIntegration } from './integrations/modules';
Expand Down
37 changes: 36 additions & 1 deletion packages/node-core/src/integrations/nodeRuntimeMetrics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,37 @@ import { _INTERNAL_safeDateNow, _INTERNAL_safeUnref, defineIntegration, metrics

const INTEGRATION_NAME = 'NodeRuntimeMetrics';
const DEFAULT_INTERVAL_MS = 30_000;
const MIN_COLLECTION_INTERVAL_MS = 1_000;
const EVENT_LOOP_DELAY_RESOLUTION_MS = 10;

/**
* Normalizes a `collectionIntervalMs` value, enforcing a minimum of 1000ms.
* - Non-finite values (NaN, Infinity): warns and falls back to `defaultInterval`.
* - Values below the minimum: warns and clamps to 1000ms.
* @internal
*/
export function _INTERNAL_normalizeCollectionInterval(
rawInterval: number,
integrationName: string,
defaultInterval: number,
): number {
if (!Number.isFinite(rawInterval)) {
// eslint-disable-next-line no-console
console.warn(
`[Sentry] ${integrationName}: collectionIntervalMs (${rawInterval}) is invalid. Using default of ${defaultInterval}ms.`,
);
return defaultInterval;
}
if (rawInterval < MIN_COLLECTION_INTERVAL_MS) {
// eslint-disable-next-line no-console
console.warn(
`[Sentry] ${integrationName}: collectionIntervalMs (${rawInterval}) is below the minimum of ${MIN_COLLECTION_INTERVAL_MS}ms. Using minimum of ${MIN_COLLECTION_INTERVAL_MS}ms.`,
);
return MIN_COLLECTION_INTERVAL_MS;
}
return rawInterval;
}

export interface NodeRuntimeMetricsOptions {
/**
* Which metrics to collect.
Expand Down Expand Up @@ -44,7 +73,9 @@ export interface NodeRuntimeMetricsOptions {
};
/**
* How often to collect metrics, in milliseconds.
* Minimum allowed value is 1000ms.
* @default 30000
* @minimum 1000
*/
collectionIntervalMs?: number;
}
Expand All @@ -62,7 +93,11 @@ export interface NodeRuntimeMetricsOptions {
* ```
*/
export const nodeRuntimeMetricsIntegration = defineIntegration((options: NodeRuntimeMetricsOptions = {}) => {
const collectionIntervalMs = options.collectionIntervalMs ?? DEFAULT_INTERVAL_MS;
const collectionIntervalMs = _INTERNAL_normalizeCollectionInterval(
options.collectionIntervalMs ?? DEFAULT_INTERVAL_MS,
INTEGRATION_NAME,
DEFAULT_INTERVAL_MS,
);
const collect = {
// Default on
cpuUtilization: true,
Expand Down
37 changes: 37 additions & 0 deletions packages/node-core/test/integrations/nodeRuntimeMetrics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -329,5 +329,42 @@ describe('nodeRuntimeMetricsIntegration', () => {

expect(countSpy).not.toHaveBeenCalledWith('node.runtime.process.uptime', expect.anything(), expect.anything());
});

it('enforces minimum collectionIntervalMs of 1000ms and warns', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});

const integration = nodeRuntimeMetricsIntegration({ collectionIntervalMs: 100 });
integration.setup();

expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('collectionIntervalMs'));
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('1000'));

// Should fire at the minimum 1000ms, not at 100ms
vi.advanceTimersByTime(100);
expect(gaugeSpy).not.toHaveBeenCalled();

vi.advanceTimersByTime(900);
expect(gaugeSpy).toHaveBeenCalled();

warnSpy.mockRestore();
});

it('falls back to default when collectionIntervalMs is NaN', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});

const integration = nodeRuntimeMetricsIntegration({ collectionIntervalMs: NaN });
integration.setup();

expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('collectionIntervalMs'));

// Should fire at the default 30000ms, not at 1000ms
vi.advanceTimersByTime(1000);
expect(gaugeSpy).not.toHaveBeenCalled();

vi.advanceTimersByTime(29_000);
expect(gaugeSpy).toHaveBeenCalled();

warnSpy.mockRestore();
});
});
});
1 change: 1 addition & 0 deletions packages/node/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -203,4 +203,5 @@ export {
cron,
NODE_VERSION,
validateOpenTelemetrySetup,
_INTERNAL_normalizeCollectionInterval,
} from '@sentry/node-core';
Loading