feat(observability): break the heap sample down by V8 space

The heap sample named old_space alone, so a heap that stood well above
it could not be explained from the log: a buffer past V8's large-object
threshold lives in large_object_space, compiled code and bytecode in the
code and trusted spaces. The sampled and pressure lines now carry the
used size of every space, with any space the runtime reports that is
not named summed into other_spaces_mb, so the fields account for the
whole used heap whatever the Node version.
This commit is contained in:
Marc-André Bombeck committed 2026-10-20 20:27:24 +02:00
1 parent c6c51be280
commit 73f2004c54
3 files changed
+112 -7

No files matched your search

@@ -10,6 +10,7 @@ import {
} from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { getHeapSpaceStatistics } from "node:v8";
import { describe, expect, it, vi } from "vitest";
@@ -34,6 +35,7 @@ import {
type HeapFigures,
type HeapMonitorDeps,
emitHeapLine,
heapSpaceFigures,
isPrivateDirectory,
writeDiagnosticHeapSnapshot,
} from "../heap-monitor";
@@ -46,7 +48,15 @@ const FIGURE_KEYS: Array<keyof HeapFigures> = [
"heap_total_mb",
"heap_limit_mb",
"heap_used_ratio",
"new_space_mb",
"old_space_mb",
"code_space_mb",
"large_object_space_mb",
"new_large_object_space_mb",
"code_large_object_space_mb",
"trusted_space_mb",
"read_only_space_mb",
"other_spaces_mb",
"rss_mb",
"external_mb",
"array_buffers_mb",
@@ -125,6 +135,50 @@ describe("heap monitor sampling", () => {
});
});
describe("heap space breakdown", () => {
const MB = 1024 * 1024;
it("reports each named space and sums the rest into other_spaces_mb", () => {
const figures = heapSpaceFigures([
{ space_name: "old_space", space_used_size: 300 * MB },
{ space_name: "large_object_space", space_used_size: 120 * MB },
{ space_name: "trusted_space", space_used_size: 40 * MB },
{ space_name: "shared_space", space_used_size: 2 * MB },
{ space_name: "a_space_v8_adds_later", space_used_size: 3 * MB },
]);
expect(figures).toEqual({
new_space_mb: 0,
old_space_mb: 300,
code_space_mb: 0,
large_object_space_mb: 120,
new_large_object_space_mb: 0,
code_large_object_space_mb: 0,
trusted_space_mb: 40,
read_only_space_mb: 0,
other_spaces_mb: 5,
});
});
it("accounts for every space this runtime reports", () => {
const spaces = getHeapSpaceStatistics();
expect(spaces.length).toBeGreaterThan(0);
const figures = heapSpaceFigures(spaces);
expect(Object.keys(figures).sort()).toEqual(
FIGURE_KEYS.filter(
(k) => k.endsWith("space_mb") || k === "other_spaces_mb",
)
.map(String)
.sort(),
);
const total = spaces.reduce((sum, s) => sum + s.space_used_size, 0);
const reported = Object.values(figures).reduce((a, b) => a + b, 0);
// Each space is rounded to the megabyte on its own.
expect(Math.abs(reported - total / MB)).toBeLessThanOrEqual(
Object.keys(figures).length,
);
});
});
describe("heap pressure report", () => {
it("fires once at 85 % with the work in flight, at warn", () => {
const h = harness();
+57 -6
View File
@@ -72,12 +72,66 @@ export const HEAP_CRITICAL_RATIO = 0.95;
export const HEAP_PRESSURE_COOLDOWN_MS = 30 * 60_000;
export const HEAP_PRESSURE_LIST_MAX = 50;
export interface HeapFigures {
/**
* The used size of each V8 heap space, by the field it is reported under.
*
* `old_space` alone left most of a large heap unexplained: a buffer or string
* past V8's large-object threshold (a profile picture read into memory, a
* long JSON payload) lives in `large_object_space`, compiled code in the code
* spaces, and since V8 12 bytecode and other sandboxed objects in
* `trusted_space`. Every space a runtime reports that is not named here
* (the shared spaces, a space a later V8 adds) is summed into
* `other_spaces_mb`, so the fields account for the whole used heap and the
* line keeps one shape whatever the Node version.
*/
const HEAP_SPACE_FIELDS = {
new_space: "new_space_mb",
old_space: "old_space_mb",
code_space: "code_space_mb",
large_object_space: "large_object_space_mb",
new_large_object_space: "new_large_object_space_mb",
code_large_object_space: "code_large_object_space_mb",
trusted_space: "trusted_space_mb",
read_only_space: "read_only_space_mb",
} as const;
type HeapSpaceField =
| (typeof HEAP_SPACE_FIELDS)[keyof typeof HEAP_SPACE_FIELDS]
| "other_spaces_mb";
export type HeapSpaceFigures = Record<HeapSpaceField, number>;
/** The per-space breakdown of `getHeapSpaceStatistics()`, in megabytes. */
export function heapSpaceFigures(
spaces: ReadonlyArray<{ space_name: string; space_used_size: number }>,
): HeapSpaceFigures {
const bytes: Record<HeapSpaceField, number> = {
new_space_mb: 0,
old_space_mb: 0,
code_space_mb: 0,
large_object_space_mb: 0,
new_large_object_space_mb: 0,
code_large_object_space_mb: 0,
trusted_space_mb: 0,
read_only_space_mb: 0,
other_spaces_mb: 0,
};
for (const space of spaces) {
const field =
HEAP_SPACE_FIELDS[space.space_name as keyof typeof HEAP_SPACE_FIELDS] ??
"other_spaces_mb";
bytes[field] += space.space_used_size;
}
return Object.fromEntries(
Object.entries(bytes).map(([field, size]) => [field, megabytes(size)]),
) as HeapSpaceFigures;
}
export interface HeapFigures extends HeapSpaceFigures {
heap_used_mb: number;
heap_total_mb: number;
heap_limit_mb: number;
heap_used_ratio: number;
old_space_mb: number;
rss_mb: number;
external_mb: number;
array_buffers_mb: number;
@@ -115,9 +169,6 @@ function defaultReadHeap(): { used: number; limit: number } {
function defaultReadFigures(registry: InFlightRegistry): HeapFigures {
const memory = process.memoryUsage();
const limit = getHeapStatistics().heap_size_limit;
const oldSpace = getHeapSpaceStatistics().find(
(space) => space.space_name === "old_space",
);
const counts = registry.counts();
return {
heap_used_mb: megabytes(memory.heapUsed),
@@ -125,7 +176,7 @@ function defaultReadFigures(registry: InFlightRegistry): HeapFigures {
heap_limit_mb: megabytes(limit),
heap_used_ratio:
limit > 0 ? Math.round((memory.heapUsed / limit) * 1000) / 1000 : 0,
old_space_mb: megabytes(oldSpace?.space_used_size ?? 0),
...heapSpaceFigures(getHeapSpaceStatistics()),
rss_mb: megabytes(memory.rss),
external_mb: megabytes(memory.external),
array_buffers_mb: megabytes(memory.arrayBuffers),