diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index e8f2c79..dc60c1c 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -47,6 +47,34 @@ jobs: npm i npm run lint + TestUnitRemote: + runs-on: ubuntu-latest + timeout-minutes: 10 + strategy: + matrix: + node-version: [ 20, 22, 24 ] + steps: + - uses: actions/checkout@v4 + with: + submodules: true + + - uses: actions/cache@v4 + with: + path: ~/.npm + key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} + restore-keys: | + ${{ runner.os }}-node- + + - name: Set Up NodeJS ${{ matrix.node-version }} + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + + - name: Unit tests on Node@${{ matrix.node-version }} + run: | + npm i + npx jest tests/remote/ tests/config/ tests/runtime/ --runInBand + build-matrix: runs-on: ubuntu-latest timeout-minutes: 5 diff --git a/README.md b/README.md index 50586c5..bfda9d2 100644 --- a/README.md +++ b/README.md @@ -84,7 +84,7 @@ Note that the various ignore options like `SW_IGNORE_SUFFIX`, `SW_TRACE_IGNORE_P ## Node.js Runtime Metrics -The agent reports six process-level meters (`instance_nodejs_*`) via `MeterReportService` by default (collect 1s, report 1s). Set `SW_AGENT_NODEJS_RUNTIME_METRICS_REPORTER_ACTIVE=false` to disable. Process CPU combines `process.cpuUsage()` user + system, normalized by logical CPU count (0–100%). +The agent reports twelve process-level meters (`instance_nodejs_*`) via `MeterReportService` by default (collect 1s, report 1s). Set `SW_AGENT_NODEJS_RUNTIME_METRICS_REPORTER_ACTIVE=false` to disable. Process CPU combines `process.cpuUsage()` user + system, normalized by logical CPU count (0–100%). | Node.js source | Meter name | Notes | | :--- | :--- | :--- | @@ -94,6 +94,12 @@ The agent reports six process-level meters (`instance_nodejs_*`) via `MeterRepor | `v8.getHeapStatistics().heap_size_limit` | `instance_nodejs_heap_limit` | bytes | | `process.memoryUsage().rss` | `instance_nodejs_rss` | bytes | | `process.memoryUsage().external` | `instance_nodejs_external_memory` | bytes | +| `process.memoryUsage().arrayBuffers` | `instance_nodejs_array_buffers` | bytes | +| `process.uptime()` | `instance_nodejs_uptime` | seconds | +| `v8.getHeapStatistics().peak_malloced_memory` | `instance_nodejs_peak_malloced_memory` | bytes | +| `v8.getHeapStatistics().malloced_memory` | `instance_nodejs_malloced_memory` | bytes | +| `v8.getHeapSpaceStatistics()` old_space | `instance_nodejs_old_space_used` | bytes | +| `v8.getHeapSpaceStatistics()` new_space | `instance_nodejs_new_space_used` | bytes | Custom business metrics are not available through a public API; use [OpenTelemetry metrics](https://skywalking.apache.org/docs/main/latest/en/setup/backend/opentelemetry-receiver/) if you need those. diff --git a/src/agent/core/meter/RuntimeMetricsCollector.ts b/src/agent/core/meter/RuntimeMetricsCollector.ts index 0d59a29..af288d1 100644 --- a/src/agent/core/meter/RuntimeMetricsCollector.ts +++ b/src/agent/core/meter/RuntimeMetricsCollector.ts @@ -36,6 +36,12 @@ export default class RuntimeMetricsCollector { ['instance_nodejs_heap_limit', snapshot.heapSizeLimit], ['instance_nodejs_rss', snapshot.rss], ['instance_nodejs_external_memory', snapshot.external], + ['instance_nodejs_array_buffers', snapshot.arrayBuffers], + ['instance_nodejs_uptime', snapshot.uptime], + ['instance_nodejs_peak_malloced_memory', snapshot.peakMallocedMemory], + ['instance_nodejs_malloced_memory', snapshot.mallocedMemory], + ['instance_nodejs_old_space_used', snapshot.oldSpaceUsed], + ['instance_nodejs_new_space_used', snapshot.newSpaceUsed], ]; return gauges.map(([name, value]) => diff --git a/src/agent/core/meter/RuntimeSampler.ts b/src/agent/core/meter/RuntimeSampler.ts index c7ac80e..4654e3b 100644 --- a/src/agent/core/meter/RuntimeSampler.ts +++ b/src/agent/core/meter/RuntimeSampler.ts @@ -19,8 +19,10 @@ import os from 'os'; import v8 from 'v8'; +import config from '../../../config/AgentConfig'; export type RuntimeSnapshot = { + collectedAt: number; heapUsed: number; heapTotal: number; heapSizeLimit: number; @@ -28,6 +30,12 @@ export type RuntimeSnapshot = { external: number; cpuUserPercent: number; cpuSystemPercent: number; + arrayBuffers: number; + uptime: number; + peakMallocedMemory: number; + mallocedMemory: number; + oldSpaceUsed: number; + newSpaceUsed: number; }; export default class RuntimeSampler { @@ -38,17 +46,30 @@ export default class RuntimeSampler { sample(): RuntimeSnapshot { const memory = process.memoryUsage(); const heapStats = v8.getHeapStatistics(); - const cpuUsage = process.cpuUsage(this.lastCpuUsage); + const cpuNow = process.cpuUsage(); + const cpuUsage = { + user: cpuNow.user - this.lastCpuUsage.user, + system: cpuNow.system - this.lastCpuUsage.system, + }; const now = process.hrtime.bigint(); const elapsedMicros = Number(now - this.lastCpuTimestamp) / 1000; - this.lastCpuUsage = process.cpuUsage(); + this.lastCpuUsage = cpuNow; this.lastCpuTimestamp = now; const cpuScale = elapsedMicros > 0 ? 100 / elapsedMicros / this.logicalCpuCount : 0; const cpuUserPercent = cpuUsage.user * cpuScale; const cpuSystemPercent = cpuUsage.system * cpuScale; + const heapSpaceDetail = config.runtimeMetricsHeapSpaceDetail !== false; + let oldSpaceUsed = 0; + let newSpaceUsed = 0; + if (heapSpaceDetail) { + const heapSpaces = v8.getHeapSpaceStatistics(); + oldSpaceUsed = heapSpaces.find((entry) => entry.space_name === 'old_space')?.space_used_size ?? 0; + newSpaceUsed = heapSpaces.find((entry) => entry.space_name === 'new_space')?.space_used_size ?? 0; + } return { + collectedAt: Date.now(), heapUsed: memory.heapUsed, heapTotal: memory.heapTotal, heapSizeLimit: heapStats.heap_size_limit, @@ -56,6 +77,12 @@ export default class RuntimeSampler { external: memory.external, cpuUserPercent, cpuSystemPercent, + arrayBuffers: memory.arrayBuffers ?? 0, + uptime: process.uptime(), + peakMallocedMemory: heapStats.peak_malloced_memory, + mallocedMemory: heapStats.malloced_memory, + oldSpaceUsed, + newSpaceUsed, }; } diff --git a/src/config/AgentConfig.ts b/src/config/AgentConfig.ts index 54d8a34..6153025 100644 --- a/src/config/AgentConfig.ts +++ b/src/config/AgentConfig.ts @@ -47,6 +47,7 @@ export type AgentConfig = { runtimeMetricsCollectPeriod?: number; runtimeMetricsReportPeriod?: number; runtimeMetricsBufferSize?: number; + runtimeMetricsHeapSpaceDetail?: boolean; /** @deprecated use runtimeMetricsReporterActive */ nvmMetricsReporterActive?: boolean; /** @deprecated use runtimeMetricsCollectPeriod */ @@ -279,6 +280,10 @@ const _config = { 10, ), ), + runtimeMetricsHeapSpaceDetail: ((): boolean => { + const configured = process.env.SW_AGENT_RUNTIME_METRICS_HEAP_SPACE_DETAIL; + return configured?.toLowerCase() !== 'false'; + })(), runtimeMetricsBufferSize: ((n) => (Number.isSafeInteger(n) && n > 0 ? n : 600))( Number.parseInt( process.env.SW_AGENT_NODEJS_RUNTIME_METRICS_BUFFER_SIZE ?? diff --git a/tests/plugins/express/expected.data.yaml b/tests/plugins/express/expected.data.yaml index afddb26..a3235b3 100644 --- a/tests/plugins/express/expected.data.yaml +++ b/tests/plugins/express/expected.data.yaml @@ -122,7 +122,7 @@ segmentItems: meterItems: - serviceName: server - meterSize: 6 + meterSize: 12 meters: - meterId: name: instance_nodejs_process_cpu @@ -148,8 +148,32 @@ meterItems: name: instance_nodejs_external_memory tags: [] singleValue: gt 0 + - meterId: + name: instance_nodejs_array_buffers + tags: [] + singleValue: ge 0 + - meterId: + name: instance_nodejs_uptime + tags: [] + singleValue: gt 0 + - meterId: + name: instance_nodejs_peak_malloced_memory + tags: [] + singleValue: gt 0 + - meterId: + name: instance_nodejs_malloced_memory + tags: [] + singleValue: gt 0 + - meterId: + name: instance_nodejs_old_space_used + tags: [] + singleValue: gt 0 + - meterId: + name: instance_nodejs_new_space_used + tags: [] + singleValue: gt 0 - serviceName: client - meterSize: 6 + meterSize: 12 meters: - meterId: name: instance_nodejs_process_cpu @@ -175,3 +199,27 @@ meterItems: name: instance_nodejs_external_memory tags: [] singleValue: gt 0 + - meterId: + name: instance_nodejs_array_buffers + tags: [] + singleValue: ge 0 + - meterId: + name: instance_nodejs_uptime + tags: [] + singleValue: gt 0 + - meterId: + name: instance_nodejs_peak_malloced_memory + tags: [] + singleValue: gt 0 + - meterId: + name: instance_nodejs_malloced_memory + tags: [] + singleValue: gt 0 + - meterId: + name: instance_nodejs_old_space_used + tags: [] + singleValue: gt 0 + - meterId: + name: instance_nodejs_new_space_used + tags: [] + singleValue: gt 0 diff --git a/tests/runtime/RuntimeMetricsCollector.test.ts b/tests/runtime/RuntimeMetricsCollector.test.ts index 35b7833..f9e437a 100644 --- a/tests/runtime/RuntimeMetricsCollector.test.ts +++ b/tests/runtime/RuntimeMetricsCollector.test.ts @@ -20,6 +20,22 @@ /* eslint-env jest */ import RuntimeMetricsCollector from '../../src/agent/core/meter/RuntimeMetricsCollector'; +import { RuntimeSnapshot } from '../../src/agent/core/meter/RuntimeSampler'; + +const EXPECTED_METER_NAMES = [ + 'instance_nodejs_process_cpu', + 'instance_nodejs_heap_used', + 'instance_nodejs_heap_total', + 'instance_nodejs_heap_limit', + 'instance_nodejs_rss', + 'instance_nodejs_external_memory', + 'instance_nodejs_array_buffers', + 'instance_nodejs_uptime', + 'instance_nodejs_peak_malloced_memory', + 'instance_nodejs_malloced_memory', + 'instance_nodejs_old_space_used', + 'instance_nodejs_new_space_used', +]; describe('RuntimeMetricsCollector', () => { let collector: RuntimeMetricsCollector; @@ -37,21 +53,57 @@ describe('RuntimeMetricsCollector', () => { const meters = collector.toMeterData(snapshot); const names = meters.map((meter) => meter.getSinglevalue()?.getName()); - expect(names).toEqual( - expect.arrayContaining([ - 'instance_nodejs_process_cpu', - 'instance_nodejs_heap_used', - 'instance_nodejs_heap_total', - 'instance_nodejs_heap_limit', - 'instance_nodejs_rss', - 'instance_nodejs_external_memory', - ]), - ); - - expect(names).toHaveLength(6); + expect(names).toEqual(EXPECTED_METER_NAMES); for (const meter of meters) { expect(meter.getSinglevalue()?.getValue()).toBeGreaterThanOrEqual(0); } + + expect(snapshot.uptime).toBeGreaterThanOrEqual(0); + expect(snapshot.oldSpaceUsed).toBeGreaterThanOrEqual(0); + expect(snapshot.newSpaceUsed).toBeGreaterThanOrEqual(0); + }); + + it('maps extended runtime snapshot values into meter single values', () => { + const snapshot: RuntimeSnapshot = { + collectedAt: 1_700_000_000_000, + heapUsed: 100, + heapTotal: 200, + heapSizeLimit: 300, + rss: 400, + external: 50, + cpuUserPercent: 1.2, + cpuSystemPercent: 0.8, + arrayBuffers: 16, + uptime: 42.5, + peakMallocedMemory: 2048, + mallocedMemory: 3072, + oldSpaceUsed: 88, + newSpaceUsed: 12, + }; + + const meters = collector.toMeterData(snapshot); + const values: Record = {}; + for (const meter of meters) { + const name = meter.getSinglevalue()?.getName(); + if (name) { + values[name] = meter.getSinglevalue()?.getValue(); + } + } + + expect(values).toEqual({ + instance_nodejs_process_cpu: 2, + instance_nodejs_heap_used: 100, + instance_nodejs_heap_total: 200, + instance_nodejs_heap_limit: 300, + instance_nodejs_rss: 400, + instance_nodejs_external_memory: 50, + instance_nodejs_array_buffers: 16, + instance_nodejs_uptime: 42.5, + instance_nodejs_peak_malloced_memory: 2048, + instance_nodejs_malloced_memory: 3072, + instance_nodejs_old_space_used: 88, + instance_nodejs_new_space_used: 12, + }); }); }); diff --git a/tests/runtime/RuntimeSampler.test.ts b/tests/runtime/RuntimeSampler.test.ts new file mode 100644 index 0000000..44e5015 --- /dev/null +++ b/tests/runtime/RuntimeSampler.test.ts @@ -0,0 +1,124 @@ +/*! + * + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +/* eslint-env jest */ +/* global BigInt */ + +import os from 'os'; +import v8 from 'v8'; +import RuntimeSampler from '../../src/agent/core/meter/RuntimeSampler'; + +describe('RuntimeSampler', () => { + let sampler: RuntimeSampler; + + beforeEach(() => { + sampler = new RuntimeSampler(); + }); + + afterEach(() => { + sampler.destroy(); + }); + + it('records collectedAt at sample time', () => { + jest.spyOn(Date, 'now').mockReturnValueOnce(1_700_000_000_000); + expect(sampler.sample().collectedAt).toBe(1_700_000_000_000); + }); + + it('samples array buffers, uptime, heap stats, and heap spaces', () => { + const memoryUsageSpy = jest.spyOn(process, 'memoryUsage').mockReturnValue({ + rss: 1, + heapTotal: 2, + heapUsed: 3, + external: 4, + arrayBuffers: 5, + }); + const uptimeSpy = jest.spyOn(process, 'uptime').mockReturnValue(99); + const heapStatsSpy = jest.spyOn(v8, 'getHeapStatistics').mockReturnValue({ + heap_size_limit: 1000, + peak_malloced_memory: 2000, + malloced_memory: 4096, + } as ReturnType); + const heapSpaceSpy = jest + .spyOn(v8, 'getHeapSpaceStatistics') + .mockReturnValue([ + { space_name: 'old_space', space_used_size: 80 } as v8.HeapSpaceInfo, + { space_name: 'new_space', space_used_size: 20 } as v8.HeapSpaceInfo, + ]); + + const snapshot = sampler.sample(); + + expect(snapshot.arrayBuffers).toBe(5); + expect(snapshot.uptime).toBe(99); + expect(snapshot.peakMallocedMemory).toBe(2000); + expect(snapshot.mallocedMemory).toBe(4096); + expect(snapshot.oldSpaceUsed).toBe(80); + expect(snapshot.newSpaceUsed).toBe(20); + + memoryUsageSpy.mockRestore(); + uptimeSpy.mockRestore(); + heapStatsSpy.mockRestore(); + heapSpaceSpy.mockRestore(); + }); + + it('normalizes process CPU by logical core count', () => { + jest.spyOn(os, 'cpus').mockReturnValue([{}, {}, {}, {}] as os.CpuInfo[]); + + let cpuCall = 0; + const cpuUsageSpy = jest.spyOn(process, 'cpuUsage').mockImplementation(() => { + cpuCall += 1; + if (cpuCall === 1) { + return { user: 0, system: 0 }; + } + return { user: 1_000_000, system: 500_000 }; + }); + + let hrtimeCall = 0; + const hrtimeSpy = jest.spyOn(process.hrtime, 'bigint').mockImplementation(() => { + hrtimeCall += 1; + if (hrtimeCall === 1) { + return BigInt(0); + } + return BigInt(1_000_000_000); + }); + + const cpuSampler = new RuntimeSampler(); + const snapshot = cpuSampler.sample(); + + // 1s wall, 1 core-second user + 0.5 core-second system on a 4-logical-CPU host => 25% + 12.5% + expect(snapshot.cpuUserPercent).toBeCloseTo(25); + expect(snapshot.cpuSystemPercent).toBeCloseTo(12.5); + expect(snapshot.cpuUserPercent + snapshot.cpuSystemPercent).toBeCloseTo(37.5); + + cpuSampler.destroy(); + cpuUsageSpy.mockRestore(); + hrtimeSpy.mockRestore(); + jest.restoreAllMocks(); + }); + + it('defaults missing heap spaces to zero', () => { + jest.spyOn(v8, 'getHeapSpaceStatistics').mockReturnValue([]); + + const snapshot = sampler.sample(); + + expect(snapshot.oldSpaceUsed).toBe(0); + expect(snapshot.newSpaceUsed).toBe(0); + + jest.restoreAllMocks(); + }); +});