diff --git a/dev-packages/e2e-tests/test-applications/node-exports-test-app/scripts/consistentExports.ts b/dev-packages/e2e-tests/test-applications/node-exports-test-app/scripts/consistentExports.ts index e762909c9173..6ae689b80da3 100644 --- a/dev-packages/e2e-tests/test-applications/node-exports-test-app/scripts/consistentExports.ts +++ b/dev-packages/e2e-tests/test-applications/node-exports-test-app/scripts/consistentExports.ts @@ -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)); diff --git a/dev-packages/node-integration-tests/suites/bun-runtime-metrics/scenario-all.ts b/dev-packages/node-integration-tests/suites/bun-runtime-metrics/scenario-all.ts index f82385c4c16e..6ec4ca0c1244 100644 --- a/dev-packages/node-integration-tests/suites/bun-runtime-metrics/scenario-all.ts +++ b/dev-packages/node-integration-tests/suites/bun-runtime-metrics/scenario-all.ts @@ -9,7 +9,7 @@ Sentry.init({ transport: loggingTransport, integrations: [ bunRuntimeMetricsIntegration({ - collectionIntervalMs: 100, + collectionIntervalMs: 1000, collect: { cpuTime: true, memExternal: true, @@ -19,7 +19,7 @@ Sentry.init({ }); async function run(): Promise { - await new Promise(resolve => setTimeout(resolve, 250)); + await new Promise(resolve => setTimeout(resolve, 1100)); await Sentry.flush(); } diff --git a/dev-packages/node-integration-tests/suites/bun-runtime-metrics/scenario-opt-out.ts b/dev-packages/node-integration-tests/suites/bun-runtime-metrics/scenario-opt-out.ts index d3aa0f309893..8987865e277d 100644 --- a/dev-packages/node-integration-tests/suites/bun-runtime-metrics/scenario-opt-out.ts +++ b/dev-packages/node-integration-tests/suites/bun-runtime-metrics/scenario-opt-out.ts @@ -9,7 +9,7 @@ Sentry.init({ transport: loggingTransport, integrations: [ bunRuntimeMetricsIntegration({ - collectionIntervalMs: 100, + collectionIntervalMs: 1000, collect: { cpuUtilization: false, cpuTime: false, @@ -21,7 +21,7 @@ Sentry.init({ }); async function run(): Promise { - await new Promise(resolve => setTimeout(resolve, 250)); + await new Promise(resolve => setTimeout(resolve, 1100)); await Sentry.flush(); } diff --git a/dev-packages/node-integration-tests/suites/bun-runtime-metrics/scenario.ts b/dev-packages/node-integration-tests/suites/bun-runtime-metrics/scenario.ts index 1948ddfa6c23..92e248cccc6e 100644 --- a/dev-packages/node-integration-tests/suites/bun-runtime-metrics/scenario.ts +++ b/dev-packages/node-integration-tests/suites/bun-runtime-metrics/scenario.ts @@ -9,13 +9,13 @@ Sentry.init({ transport: loggingTransport, integrations: [ bunRuntimeMetricsIntegration({ - collectionIntervalMs: 100, + collectionIntervalMs: 1000, }), ], }); async function run(): Promise { - await new Promise(resolve => setTimeout(resolve, 250)); + await new Promise(resolve => setTimeout(resolve, 1100)); await Sentry.flush(); } diff --git a/dev-packages/node-integration-tests/suites/node-runtime-metrics/scenario-all.ts b/dev-packages/node-integration-tests/suites/node-runtime-metrics/scenario-all.ts index e995482fafbf..94a7161fe00c 100644 --- a/dev-packages/node-integration-tests/suites/node-runtime-metrics/scenario-all.ts +++ b/dev-packages/node-integration-tests/suites/node-runtime-metrics/scenario-all.ts @@ -8,7 +8,7 @@ Sentry.init({ transport: loggingTransport, integrations: [ Sentry.nodeRuntimeMetricsIntegration({ - collectionIntervalMs: 100, + collectionIntervalMs: 1000, collect: { cpuTime: true, memExternal: true, @@ -22,7 +22,7 @@ Sentry.init({ }); async function run(): Promise { - await new Promise(resolve => setTimeout(resolve, 250)); + await new Promise(resolve => setTimeout(resolve, 1100)); await Sentry.flush(); } diff --git a/dev-packages/node-integration-tests/suites/node-runtime-metrics/scenario-opt-out.ts b/dev-packages/node-integration-tests/suites/node-runtime-metrics/scenario-opt-out.ts index 423e478ed1f8..e838df6e9408 100644 --- a/dev-packages/node-integration-tests/suites/node-runtime-metrics/scenario-opt-out.ts +++ b/dev-packages/node-integration-tests/suites/node-runtime-metrics/scenario-opt-out.ts @@ -8,7 +8,7 @@ Sentry.init({ transport: loggingTransport, integrations: [ Sentry.nodeRuntimeMetricsIntegration({ - collectionIntervalMs: 100, + collectionIntervalMs: 1000, collect: { cpuUtilization: false, cpuTime: false, @@ -22,7 +22,7 @@ Sentry.init({ }); async function run(): Promise { - await new Promise(resolve => setTimeout(resolve, 250)); + await new Promise(resolve => setTimeout(resolve, 1100)); await Sentry.flush(); } diff --git a/dev-packages/node-integration-tests/suites/node-runtime-metrics/scenario.ts b/dev-packages/node-integration-tests/suites/node-runtime-metrics/scenario.ts index b862634c719a..1e174e5c29cc 100644 --- a/dev-packages/node-integration-tests/suites/node-runtime-metrics/scenario.ts +++ b/dev-packages/node-integration-tests/suites/node-runtime-metrics/scenario.ts @@ -8,14 +8,14 @@ Sentry.init({ transport: loggingTransport, integrations: [ Sentry.nodeRuntimeMetricsIntegration({ - collectionIntervalMs: 100, + collectionIntervalMs: 1000, }), ], }); async function run(): Promise { // Wait long enough for the collection interval to fire at least once. - await new Promise(resolve => setTimeout(resolve, 250)); + await new Promise(resolve => setTimeout(resolve, 1100)); await Sentry.flush(); } diff --git a/packages/bun/src/integrations/bunRuntimeMetrics.ts b/packages/bun/src/integrations/bunRuntimeMetrics.ts index 7646eb23568b..5bd4e87adbf4 100644 --- a/packages/bun/src/integrations/bunRuntimeMetrics.ts +++ b/packages/bun/src/integrations/bunRuntimeMetrics.ts @@ -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; @@ -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; } @@ -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, diff --git a/packages/bun/test/integrations/bunRuntimeMetrics.test.ts b/packages/bun/test/integrations/bunRuntimeMetrics.test.ts index 6264905db41e..a03b07ffe760 100644 --- a/packages/bun/test/integrations/bunRuntimeMetrics.test.ts +++ b/packages/bun/test/integrations/bunRuntimeMetrics.test.ts @@ -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(); + }); + }); }); diff --git a/packages/deno/src/integrations/denoRuntimeMetrics.ts b/packages/deno/src/integrations/denoRuntimeMetrics.ts index 077e920bd5a4..54d162132db8 100644 --- a/packages/deno/src/integrations/denoRuntimeMetrics.ts +++ b/packages/deno/src/integrations/denoRuntimeMetrics.ts @@ -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 */ @@ -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, diff --git a/packages/deno/test/deno-runtime-metrics.test.ts b/packages/deno/test/deno-runtime-metrics.test.ts index 12b1c72fd985..48435279a954 100644 --- a/packages/deno/test/deno-runtime-metrics.test.ts +++ b/packages/deno/test/deno-runtime-metrics.test.ts @@ -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); @@ -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); @@ -148,4 +148,5 @@ Deno.test('warns and clamps collectionIntervalMs when NaN', () => { assertEquals(warnings.length, 1); assertStringIncludes(warnings[0]!, 'collectionIntervalMs'); + assertStringIncludes(warnings[0]!, 'invalid'); }); diff --git a/packages/node-core/src/common-exports.ts b/packages/node-core/src/common-exports.ts index d6d1e070ef85..03cf6addbc13 100644 --- a/packages/node-core/src/common-exports.ts +++ b/packages/node-core/src/common-exports.ts @@ -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'; diff --git a/packages/node-core/src/integrations/nodeRuntimeMetrics.ts b/packages/node-core/src/integrations/nodeRuntimeMetrics.ts index c2ae72f04f77..5d6ab5ea7ca0 100644 --- a/packages/node-core/src/integrations/nodeRuntimeMetrics.ts +++ b/packages/node-core/src/integrations/nodeRuntimeMetrics.ts @@ -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. @@ -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; } @@ -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, diff --git a/packages/node-core/test/integrations/nodeRuntimeMetrics.test.ts b/packages/node-core/test/integrations/nodeRuntimeMetrics.test.ts index fe1de568304a..f5dd76edf779 100644 --- a/packages/node-core/test/integrations/nodeRuntimeMetrics.test.ts +++ b/packages/node-core/test/integrations/nodeRuntimeMetrics.test.ts @@ -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(); + }); }); }); diff --git a/packages/node/src/index.ts b/packages/node/src/index.ts index 67fe97e59300..ac74638130b9 100644 --- a/packages/node/src/index.ts +++ b/packages/node/src/index.ts @@ -203,4 +203,5 @@ export { cron, NODE_VERSION, validateOpenTelemetrySetup, + _INTERNAL_normalizeCollectionInterval, } from '@sentry/node-core';