diff --git a/CHANGELOG.md b/CHANGELOG.md index c4f1d7232266..fc2bb454f0dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,30 @@ ## Unreleased +### Important Changes + +- **feat(node, bun): Add runtime metrics integrations for Node.js and Bun ([#19923](https://github.com/getsentry/sentry-javascript/pull/19923), [#19979](https://github.com/getsentry/sentry-javascript/pull/19979))** + + New `nodeRuntimeMetricsIntegration` and `bunRuntimeMetricsIntegration` automatically collect runtime health metrics and send them to Sentry on a configurable interval (default: 30s). Collected metrics include memory (RSS, heap used/total), CPU utilization, event loop utilization, and process uptime. Node additionally collects event loop delay percentiles (p50, p99). Extra metrics like CPU time and external memory are available as opt-in. + + ```ts + // Node.js + import * as Sentry from '@sentry/node'; + + Sentry.init({ + dsn: '...', + integrations: [Sentry.nodeRuntimeMetricsIntegration()], + }); + + // Bun + import * as Sentry from '@sentry/bun'; + + Sentry.init({ + dsn: '...', + integrations: [Sentry.bunRuntimeMetricsIntegration()], + }); + ``` + - "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott ## 10.46.0 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 new file mode 100644 index 000000000000..f82385c4c16e --- /dev/null +++ b/dev-packages/node-integration-tests/suites/bun-runtime-metrics/scenario-all.ts @@ -0,0 +1,27 @@ +import * as Sentry from '@sentry/node'; +import { bunRuntimeMetricsIntegration } from '@sentry/bun'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0.0', + environment: 'test', + transport: loggingTransport, + integrations: [ + bunRuntimeMetricsIntegration({ + collectionIntervalMs: 100, + collect: { + cpuTime: true, + memExternal: true, + }, + }), + ], +}); + +async function run(): Promise { + await new Promise(resolve => setTimeout(resolve, 250)); + await Sentry.flush(); +} + +// eslint-disable-next-line @typescript-eslint/no-floating-promises +run(); 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 new file mode 100644 index 000000000000..d3aa0f309893 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/bun-runtime-metrics/scenario-opt-out.ts @@ -0,0 +1,29 @@ +import * as Sentry from '@sentry/node'; +import { bunRuntimeMetricsIntegration } from '@sentry/bun'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0.0', + environment: 'test', + transport: loggingTransport, + integrations: [ + bunRuntimeMetricsIntegration({ + collectionIntervalMs: 100, + collect: { + cpuUtilization: false, + cpuTime: false, + eventLoopUtilization: false, + uptime: false, + }, + }), + ], +}); + +async function run(): Promise { + await new Promise(resolve => setTimeout(resolve, 250)); + await Sentry.flush(); +} + +// eslint-disable-next-line @typescript-eslint/no-floating-promises +run(); 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 new file mode 100644 index 000000000000..1948ddfa6c23 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/bun-runtime-metrics/scenario.ts @@ -0,0 +1,23 @@ +import * as Sentry from '@sentry/node'; +import { bunRuntimeMetricsIntegration } from '@sentry/bun'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0.0', + environment: 'test', + transport: loggingTransport, + integrations: [ + bunRuntimeMetricsIntegration({ + collectionIntervalMs: 100, + }), + ], +}); + +async function run(): Promise { + await new Promise(resolve => setTimeout(resolve, 250)); + await Sentry.flush(); +} + +// eslint-disable-next-line @typescript-eslint/no-floating-promises +run(); diff --git a/dev-packages/node-integration-tests/suites/bun-runtime-metrics/test.ts b/dev-packages/node-integration-tests/suites/bun-runtime-metrics/test.ts new file mode 100644 index 000000000000..78638b8b02cb --- /dev/null +++ b/dev-packages/node-integration-tests/suites/bun-runtime-metrics/test.ts @@ -0,0 +1,116 @@ +import { afterAll, describe, expect, test } from 'vitest'; +import { cleanupChildProcesses, createRunner } from '../../utils/runner'; + +const SENTRY_ATTRIBUTES = { + 'sentry.release': { value: '1.0.0', type: 'string' }, + 'sentry.environment': { value: 'test', type: 'string' }, + 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, + 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, + 'sentry.origin': { value: 'auto.bun.runtime_metrics', type: 'string' }, +}; + +const gauge = (name: string, unit?: string) => ({ + timestamp: expect.any(Number), + trace_id: expect.any(String), + name, + type: 'gauge', + ...(unit ? { unit } : {}), + value: expect.any(Number), + attributes: expect.objectContaining(SENTRY_ATTRIBUTES), +}); + +const counter = (name: string, unit?: string) => ({ + timestamp: expect.any(Number), + trace_id: expect.any(String), + name, + type: 'counter', + ...(unit ? { unit } : {}), + value: expect.any(Number), + attributes: expect.objectContaining(SENTRY_ATTRIBUTES), +}); + +describe('bunRuntimeMetricsIntegration', () => { + afterAll(() => { + cleanupChildProcesses(); + }); + + test('emits default runtime metrics with correct shape', async () => { + const runner = createRunner(__dirname, 'scenario.ts') + .expect({ + trace_metric: { + items: expect.arrayContaining([ + gauge('bun.runtime.mem.rss', 'byte'), + gauge('bun.runtime.mem.heap_used', 'byte'), + gauge('bun.runtime.mem.heap_total', 'byte'), + gauge('bun.runtime.cpu.utilization'), + gauge('bun.runtime.event_loop.utilization'), + counter('bun.runtime.process.uptime', 'second'), + ]), + }, + }) + .start(); + + await runner.completed(); + }); + + test('does not emit opt-in metrics by default', async () => { + const runner = createRunner(__dirname, 'scenario.ts') + .expect({ + trace_metric: (container: { items: Array<{ name: string }> }) => { + const names = container.items.map(item => item.name); + expect(names).not.toContain('bun.runtime.cpu.user'); + expect(names).not.toContain('bun.runtime.cpu.system'); + expect(names).not.toContain('bun.runtime.mem.external'); + expect(names).not.toContain('bun.runtime.mem.array_buffers'); + }, + }) + .start(); + + await runner.completed(); + }); + + test('emits all metrics when fully opted in', async () => { + const runner = createRunner(__dirname, 'scenario-all.ts') + .expect({ + trace_metric: { + items: expect.arrayContaining([ + gauge('bun.runtime.mem.rss', 'byte'), + gauge('bun.runtime.mem.heap_used', 'byte'), + gauge('bun.runtime.mem.heap_total', 'byte'), + gauge('bun.runtime.mem.external', 'byte'), + gauge('bun.runtime.mem.array_buffers', 'byte'), + gauge('bun.runtime.cpu.user', 'second'), + gauge('bun.runtime.cpu.system', 'second'), + gauge('bun.runtime.cpu.utilization'), + gauge('bun.runtime.event_loop.utilization'), + counter('bun.runtime.process.uptime', 'second'), + ]), + }, + }) + .start(); + + await runner.completed(); + }); + + test('respects opt-out: only memory metrics remain when cpu/event loop/uptime are disabled', async () => { + const runner = createRunner(__dirname, 'scenario-opt-out.ts') + .expect({ + trace_metric: (container: { items: Array<{ name: string }> }) => { + const names = container.items.map(item => item.name); + + // Memory metrics should still be present + expect(names).toContain('bun.runtime.mem.rss'); + expect(names).toContain('bun.runtime.mem.heap_used'); + expect(names).toContain('bun.runtime.mem.heap_total'); + + // Everything else should be absent + expect(names).not.toContain('bun.runtime.cpu.utilization'); + expect(names).not.toContain('bun.runtime.event_loop.utilization'); + expect(names).not.toContain('bun.runtime.process.uptime'); + }, + }) + .start(); + + await runner.completed(); + }); +}); diff --git a/packages/bun/src/index.ts b/packages/bun/src/index.ts index c2990e6262a7..41f5b3cf5c52 100644 --- a/packages/bun/src/index.ts +++ b/packages/bun/src/index.ts @@ -192,4 +192,5 @@ export type { BunOptions } from './types'; export { BunClient } from './client'; export { getDefaultIntegrations, init } from './sdk'; export { bunServerIntegration } from './integrations/bunserver'; +export { bunRuntimeMetricsIntegration, type BunRuntimeMetricsOptions } from './integrations/bunRuntimeMetrics'; export { makeFetchTransport } from './transports'; diff --git a/packages/bun/src/integrations/bunRuntimeMetrics.ts b/packages/bun/src/integrations/bunRuntimeMetrics.ts new file mode 100644 index 000000000000..7646eb23568b --- /dev/null +++ b/packages/bun/src/integrations/bunRuntimeMetrics.ts @@ -0,0 +1,166 @@ +import { performance } from 'perf_hooks'; +import { _INTERNAL_safeDateNow, _INTERNAL_safeUnref, defineIntegration, metrics } from '@sentry/core'; +import type { NodeRuntimeMetricsOptions } from '@sentry/node'; + +const INTEGRATION_NAME = 'BunRuntimeMetrics'; +const DEFAULT_INTERVAL_MS = 30_000; + +/** + * Which metrics to collect in the Bun runtime metrics integration. + * Explicitly picks the metrics available in Bun from `NodeRuntimeMetricsOptions['collect']`. + * Event loop delay percentiles are excluded because `monitorEventLoopDelay` is unavailable in Bun. + */ +type BunCollectOptions = Pick< + NonNullable, + | 'cpuUtilization' + | 'cpuTime' + | 'memHeapUsed' + | 'memHeapTotal' + | 'memRss' + | 'memExternal' + | 'eventLoopUtilization' + | 'uptime' +>; + +export interface BunRuntimeMetricsOptions { + /** + * Which metrics to collect. + * + * Default on (6 metrics): + * - `cpuUtilization` — CPU utilization ratio + * - `memRss` — Resident Set Size (actual memory footprint) + * - `memHeapUsed` — V8 heap currently in use + * - `memHeapTotal` — total V8 heap allocated + * - `eventLoopUtilization` — fraction of time the event loop was active + * - `uptime` — process uptime (detect restarts/crashes) + * + * Default off (opt-in): + * - `cpuTime` — raw user/system CPU time in seconds + * - `memExternal` — external/ArrayBuffer memory (relevant for native addons) + * + * Note: event loop delay percentiles (p50, p99, etc.) are not available in Bun + * because `monitorEventLoopDelay` from `perf_hooks` is not implemented. + */ + collect?: BunCollectOptions; + /** + * How often to collect metrics, in milliseconds. + * @default 30000 + */ + collectionIntervalMs?: number; +} + +/** + * Automatically collects Bun runtime metrics and emits them to Sentry. + * + * @example + * ```ts + * Sentry.init({ + * integrations: [ + * Sentry.bunRuntimeMetricsIntegration(), + * ], + * }); + * ``` + */ +export const bunRuntimeMetricsIntegration = defineIntegration((options: BunRuntimeMetricsOptions = {}) => { + const collectionIntervalMs = options.collectionIntervalMs ?? DEFAULT_INTERVAL_MS; + const collect = { + // Default on + cpuUtilization: true, + memHeapUsed: true, + memHeapTotal: true, + memRss: true, + eventLoopUtilization: true, + uptime: true, + // Default off + cpuTime: false, + memExternal: false, + ...options.collect, + }; + + const needsCpu = collect.cpuUtilization || collect.cpuTime; + + let intervalId: ReturnType | undefined; + let prevCpuUsage: NodeJS.CpuUsage | undefined; + let prevElu: ReturnType | undefined; + let prevFlushTime: number = 0; + let eluAvailable = false; + + const METRIC_ATTRIBUTES = { attributes: { 'sentry.origin': 'auto.bun.runtime_metrics' } }; + const METRIC_ATTRIBUTES_BYTE = { unit: 'byte', attributes: { 'sentry.origin': 'auto.bun.runtime_metrics' } }; + const METRIC_ATTRIBUTES_SECOND = { unit: 'second', attributes: { 'sentry.origin': 'auto.bun.runtime_metrics' } }; + + function collectMetrics(): void { + const now = _INTERNAL_safeDateNow(); + const elapsed = now - prevFlushTime; + + if (needsCpu && prevCpuUsage !== undefined) { + const delta = process.cpuUsage(prevCpuUsage); + + if (collect.cpuTime) { + metrics.gauge('bun.runtime.cpu.user', delta.user / 1e6, METRIC_ATTRIBUTES_SECOND); + metrics.gauge('bun.runtime.cpu.system', delta.system / 1e6, METRIC_ATTRIBUTES_SECOND); + } + if (collect.cpuUtilization && elapsed > 0) { + metrics.gauge('bun.runtime.cpu.utilization', (delta.user + delta.system) / (elapsed * 1000), METRIC_ATTRIBUTES); + } + + prevCpuUsage = process.cpuUsage(); + } + + if (collect.memRss || collect.memHeapUsed || collect.memHeapTotal || collect.memExternal) { + const mem = process.memoryUsage(); + if (collect.memRss) { + metrics.gauge('bun.runtime.mem.rss', mem.rss, METRIC_ATTRIBUTES_BYTE); + } + if (collect.memHeapUsed) { + metrics.gauge('bun.runtime.mem.heap_used', mem.heapUsed, METRIC_ATTRIBUTES_BYTE); + } + if (collect.memHeapTotal) { + metrics.gauge('bun.runtime.mem.heap_total', mem.heapTotal, METRIC_ATTRIBUTES_BYTE); + } + if (collect.memExternal) { + metrics.gauge('bun.runtime.mem.external', mem.external, METRIC_ATTRIBUTES_BYTE); + metrics.gauge('bun.runtime.mem.array_buffers', mem.arrayBuffers, METRIC_ATTRIBUTES_BYTE); + } + } + + if (collect.eventLoopUtilization && eluAvailable && prevElu !== undefined) { + const currentElu = performance.eventLoopUtilization(); + const delta = performance.eventLoopUtilization(currentElu, prevElu); + metrics.gauge('bun.runtime.event_loop.utilization', delta.utilization, METRIC_ATTRIBUTES); + prevElu = currentElu; + } + + if (collect.uptime && elapsed > 0) { + metrics.count('bun.runtime.process.uptime', elapsed / 1000, METRIC_ATTRIBUTES_SECOND); + } + + prevFlushTime = now; + } + + return { + name: INTEGRATION_NAME, + + setup(): void { + // Prime baselines before the first collection interval. + if (needsCpu) { + prevCpuUsage = process.cpuUsage(); + } + if (collect.eventLoopUtilization) { + try { + prevElu = performance.eventLoopUtilization(); + eluAvailable = true; + } catch { + // Not available in all Bun versions. + } + } + 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/bun/test/integrations/bunRuntimeMetrics.test.ts b/packages/bun/test/integrations/bunRuntimeMetrics.test.ts new file mode 100644 index 000000000000..6264905db41e --- /dev/null +++ b/packages/bun/test/integrations/bunRuntimeMetrics.test.ts @@ -0,0 +1,215 @@ +import { afterEach, beforeEach, describe, expect, it, jest, mock, spyOn } from 'bun:test'; +import { metrics } from '@sentry/core'; + +const mockElu = { idle: 700, active: 300, utilization: 0.3 }; +const mockEluDelta = { idle: 700, active: 300, utilization: 0.3 }; +const mockEventLoopUtilization = jest.fn((curr?: object, _prev?: object) => { + if (curr) return mockEluDelta; + return mockElu; +}); + +mock.module('perf_hooks', () => ({ + performance: { eventLoopUtilization: mockEventLoopUtilization }, +})); + +const { bunRuntimeMetricsIntegration } = await import('../../src/integrations/bunRuntimeMetrics'); + +describe('bunRuntimeMetricsIntegration', () => { + let gaugeSpy: ReturnType; + let countSpy: ReturnType; + + beforeEach(() => { + jest.useFakeTimers(); + gaugeSpy = spyOn(metrics, 'gauge').mockImplementation(() => undefined); + countSpy = spyOn(metrics, 'count').mockImplementation(() => undefined); + + spyOn(process, 'cpuUsage').mockReturnValue({ user: 500_000, system: 200_000 }); + spyOn(process, 'memoryUsage').mockReturnValue({ + rss: 50_000_000, + heapTotal: 30_000_000, + heapUsed: 20_000_000, + external: 1_000_000, + arrayBuffers: 500_000, + }); + + mockEventLoopUtilization.mockClear(); + }); + + afterEach(() => { + jest.useRealTimers(); + jest.restoreAllMocks(); + }); + + it('has the correct name', () => { + const integration = bunRuntimeMetricsIntegration(); + expect(integration.name).toBe('BunRuntimeMetrics'); + }); + + describe('setup', () => { + it('starts a collection interval', () => { + const integration = bunRuntimeMetricsIntegration({ collectionIntervalMs: 1_000 }); + integration.setup(); + + expect(gaugeSpy).not.toHaveBeenCalled(); + jest.advanceTimersByTime(1_000); + expect(gaugeSpy).toHaveBeenCalled(); + }); + + it('does not throw if performance.eventLoopUtilization is unavailable', () => { + mockEventLoopUtilization.mockImplementationOnce(() => { + throw new Error('Not implemented'); + }); + + const integration = bunRuntimeMetricsIntegration({ collectionIntervalMs: 1_000 }); + expect(() => integration.setup()).not.toThrow(); + }); + }); + + const ORIGIN = { attributes: { 'sentry.origin': 'auto.bun.runtime_metrics' } }; + const BYTE = { unit: 'byte', attributes: { 'sentry.origin': 'auto.bun.runtime_metrics' } }; + const SECOND = { unit: 'second', attributes: { 'sentry.origin': 'auto.bun.runtime_metrics' } }; + + describe('metric collection — defaults', () => { + it('emits cpu utilization (default on)', () => { + const integration = bunRuntimeMetricsIntegration({ collectionIntervalMs: 1_000 }); + integration.setup(); + jest.advanceTimersByTime(1_000); + + expect(gaugeSpy).toHaveBeenCalledWith('bun.runtime.cpu.utilization', expect.any(Number), ORIGIN); + }); + + it('does not emit cpu.user / cpu.system by default (opt-in)', () => { + const integration = bunRuntimeMetricsIntegration({ collectionIntervalMs: 1_000 }); + integration.setup(); + jest.advanceTimersByTime(1_000); + + expect(gaugeSpy).not.toHaveBeenCalledWith('bun.runtime.cpu.user', expect.anything(), expect.anything()); + expect(gaugeSpy).not.toHaveBeenCalledWith('bun.runtime.cpu.system', expect.anything(), expect.anything()); + }); + + it('emits cpu.user / cpu.system when cpuTime is opted in', () => { + const integration = bunRuntimeMetricsIntegration({ + collectionIntervalMs: 1_000, + collect: { cpuTime: true }, + }); + integration.setup(); + jest.advanceTimersByTime(1_000); + + expect(gaugeSpy).toHaveBeenCalledWith('bun.runtime.cpu.user', expect.any(Number), SECOND); + expect(gaugeSpy).toHaveBeenCalledWith('bun.runtime.cpu.system', expect.any(Number), SECOND); + }); + + it('emits mem.rss, mem.heap_used, mem.heap_total (default on)', () => { + const integration = bunRuntimeMetricsIntegration({ collectionIntervalMs: 1_000 }); + integration.setup(); + jest.advanceTimersByTime(1_000); + + expect(gaugeSpy).toHaveBeenCalledWith('bun.runtime.mem.rss', 50_000_000, BYTE); + expect(gaugeSpy).toHaveBeenCalledWith('bun.runtime.mem.heap_used', 20_000_000, BYTE); + expect(gaugeSpy).toHaveBeenCalledWith('bun.runtime.mem.heap_total', 30_000_000, BYTE); + }); + + it('does not emit mem.external / mem.array_buffers by default (opt-in)', () => { + const integration = bunRuntimeMetricsIntegration({ collectionIntervalMs: 1_000 }); + integration.setup(); + jest.advanceTimersByTime(1_000); + + expect(gaugeSpy).not.toHaveBeenCalledWith('bun.runtime.mem.external', expect.anything(), expect.anything()); + expect(gaugeSpy).not.toHaveBeenCalledWith('bun.runtime.mem.array_buffers', expect.anything(), expect.anything()); + }); + + it('emits mem.external / mem.array_buffers when opted in', () => { + const integration = bunRuntimeMetricsIntegration({ + collectionIntervalMs: 1_000, + collect: { memExternal: true }, + }); + integration.setup(); + jest.advanceTimersByTime(1_000); + + expect(gaugeSpy).toHaveBeenCalledWith('bun.runtime.mem.external', 1_000_000, BYTE); + expect(gaugeSpy).toHaveBeenCalledWith('bun.runtime.mem.array_buffers', 500_000, BYTE); + }); + + it('emits event loop utilization metric', () => { + const integration = bunRuntimeMetricsIntegration({ collectionIntervalMs: 1_000 }); + integration.setup(); + jest.advanceTimersByTime(1_000); + + expect(gaugeSpy).toHaveBeenCalledWith('bun.runtime.event_loop.utilization', 0.3, ORIGIN); + }); + + it('does not emit event loop utilization if performance.eventLoopUtilization threw during setup', () => { + mockEventLoopUtilization.mockImplementationOnce(() => { + throw new Error('Not implemented'); + }); + + const integration = bunRuntimeMetricsIntegration({ collectionIntervalMs: 1_000 }); + integration.setup(); + jest.advanceTimersByTime(1_000); + + expect(gaugeSpy).not.toHaveBeenCalledWith( + 'bun.runtime.event_loop.utilization', + expect.anything(), + expect.anything(), + ); + }); + + it('emits uptime counter', () => { + const integration = bunRuntimeMetricsIntegration({ collectionIntervalMs: 1_000 }); + integration.setup(); + jest.advanceTimersByTime(1_000); + + expect(countSpy).toHaveBeenCalledWith('bun.runtime.process.uptime', expect.any(Number), SECOND); + }); + }); + + describe('opt-out', () => { + it('skips cpu.utilization when cpuUtilization is false', () => { + const integration = bunRuntimeMetricsIntegration({ + collectionIntervalMs: 1_000, + collect: { cpuUtilization: false }, + }); + integration.setup(); + jest.advanceTimersByTime(1_000); + + expect(gaugeSpy).not.toHaveBeenCalledWith('bun.runtime.cpu.utilization', expect.anything(), expect.anything()); + }); + + it('skips mem.rss when memRss is false', () => { + const integration = bunRuntimeMetricsIntegration({ + collectionIntervalMs: 1_000, + collect: { memRss: false }, + }); + integration.setup(); + jest.advanceTimersByTime(1_000); + + expect(gaugeSpy).not.toHaveBeenCalledWith('bun.runtime.mem.rss', expect.anything(), expect.anything()); + }); + + it('skips event loop utilization when eventLoopUtilization is false', () => { + const integration = bunRuntimeMetricsIntegration({ + collectionIntervalMs: 1_000, + collect: { eventLoopUtilization: false }, + }); + integration.setup(); + jest.advanceTimersByTime(1_000); + + expect(gaugeSpy).not.toHaveBeenCalledWith( + 'bun.runtime.event_loop.utilization', + expect.anything(), + expect.anything(), + ); + }); + + it('skips uptime when uptime is false', () => { + const integration = bunRuntimeMetricsIntegration({ + collectionIntervalMs: 1_000, + collect: { uptime: false }, + }); + integration.setup(); + jest.advanceTimersByTime(1_000); + + expect(countSpy).not.toHaveBeenCalledWith('bun.runtime.process.uptime', expect.anything(), expect.anything()); + }); + }); +});