From b68adc1845489843fa7c1846f15fdd4ec89a630b Mon Sep 17 00:00:00 2001 From: Attila Szegedi Date: Mon, 26 Oct 2026 19:32:04 +0100 Subject: [PATCH 1/2] Add an OpenTelemetry profiles serializer OtelProfilesBuilder collects any number of normalized profiles, such as the time and heap profiles of one collection cycle, into one ExportProfilesServiceRequest of pprof-format/otel. All of them share the request's dictionary, as the OTel format intends. Each profile is added through a ProfileVisitor from visitor(), so whatever drives the visit, the normalizer or a profiler emitting samples itself, needs no knowledge of OTel. serializeTimeProfileOtel() and serializeHeapProfileOtel() are shorthands for a request with a single profile. - An OTel profile has a single sample type, so each added profile becomes one OTel profile per sample type (sample count, wall and CPU time; in-use and allocated objects and space). When every sampling event is a single timestamped observation, as with samplePerContext, the sample count is folded into the profile of the period type (wall time) instead, provided no other sample adds an observation to that profile. It then gets an observation for every sampling event, even one whose value is zero, so the count is its number of observations. Idle samples, which deliberately have zero wall time (see #199), thereby stay in the wall profile as timestamped evidence that sampling happened, while their CPU time stays in the CPU profile. No count profile then has to repeat every sample just to carry 1s. When some sampling events are summed, the count keeps its profile, so no count is lost. - The profiles' time range starts at the start of collection, so sample timestamps fall within [time_unix_nano, time_unix_nano + duration_nano), as the format asks. - Frames become locations, with columns, and the normalizer's stacks become the stack table. Functions are identified by what they encode as, display name and resolved file, so one script mapping to several original files keeps them apart. Functions, locations, stacks, attributes and links are deduplicated across all profiles. Every dictionary table starts with its zero value. - Labels become interned attributes: strings, integers, or doubles for fractional numbers, which pprof labels can't hold. An optional extractLink callback can turn some labels into a link to a span; the builder validates the ID lengths, copies the IDs, and treats a link whose trace ID or span ID is all zeros, which is invalid, as no link. - Samples with the same stack, attributes and link are merged. Timestamped samples accumulate a value and a timestamp per observation; others are summed. Other zero values, such as zero CPU time, are left out of their sample type's profile. - Frames whose source map is declared but missing locally, the candidates for server-side unminification, get a datadog.frame.missing_source_map location attribute. The profiles with such frames get a datadog.profile.has_missing_source_maps attribute, which the builder learns from the visitor's end() callback. pprof carries this as a profile comment, but OTel's pprof.profile.comment is reserved for human-readable text. No semantic convention exists for this, so the keys are provisional until the Datadog backend confirms them. Visitors run synchronously, and build() can be called after any number of completed visits; a visit that throws leaves partial data, so the builder should then be discarded. The builder isn't exported from the package yet, so that the API can settle first. pprof-format/otel first shipped in pprof-format 2.4.0, so that is now the minimum version. --- package-lock.json | 8 +- package.json | 2 +- ts/src/otel-serializer.ts | 498 +++++++++++++++++ ts/test/test-otel-serializer.ts | 959 ++++++++++++++++++++++++++++++++ 4 files changed, 1462 insertions(+), 5 deletions(-) create mode 100644 ts/src/otel-serializer.ts create mode 100644 ts/test/test-otel-serializer.ts diff --git a/package-lock.json b/package-lock.json index edbb9e6e..bd5b74b7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "license": "Apache-2.0", "dependencies": { "node-gyp-build": "^4.8.4", - "pprof-format": "^2.3.2", + "pprof-format": "^2.4.0", "source-map": "^0.8.0" }, "devDependencies": { @@ -5086,9 +5086,9 @@ } }, "node_modules/pprof-format": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/pprof-format/-/pprof-format-2.3.2.tgz", - "integrity": "sha512-fcMM+sgKMOveSurz7i0BMHfy1hHNnI9+VWjd4kdclqQjSTuiHWaP69DGwvkw3zcLkM/F76LhVIKvPyrxmp6iiw==", + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/pprof-format/-/pprof-format-2.4.0.tgz", + "integrity": "sha512-7SDtSvsD996RFtlODgEmCR+GAfC3Dx6zlnef7duB1QNAkOStz+VAV60Ie/OQrkEijmBmcOZlKK0KQ/Oupd1frw==", "license": "MIT" }, "node_modules/prelude-ls": { diff --git a/package.json b/package.json index aaf228fe..fdae89fb 100644 --- a/package.json +++ b/package.json @@ -38,7 +38,7 @@ "license": "Apache-2.0", "dependencies": { "node-gyp-build": "^4.8.4", - "pprof-format": "^2.3.2", + "pprof-format": "^2.4.0", "source-map": "^0.8.0" }, "devDependencies": { diff --git a/ts/src/otel-serializer.ts b/ts/src/otel-serializer.ts new file mode 100644 index 00000000..b2310211 --- /dev/null +++ b/ts/src/otel-serializer.ts @@ -0,0 +1,498 @@ +/* + * Copyright 2026 Datadog, Inc + * + * Licensed 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. + */ + +/** + * Serializes normalized profiles as OpenTelemetry profiles, in an + * ExportProfilesServiceRequest of pprof-format/otel. + * + * The request has one profile per sample type, since an OTel profile has a + * single sample type, and all of them share one dictionary. A sample count + * is the exception when every sampling event is a single timestamped + * observation (time profiles normalized with samplePerContext): it is then + * folded into the profile of the period type, whose observations include + * every sampling event, even zero-valued ones such as idle samples' wall + * time. Otherwise the count keeps a profile of its own, so no count is lost. + * + * Samples with the same stack, attributes and link are merged into one, as + * the OTel format requires: timestamped samples accumulate a value and a + * timestamp per observation, and samples without a timestamp are summed into + * one value. Other zero values are left out of the profile of their sample + * type: e.g. zero CPU time, or the wall time of the synthetic non-JS threads + * sample. + * + * OtelProfilesBuilder collects several profiles, e.g. time and heap, into one + * request; serializeTimeProfileOtel() and serializeHeapProfileOtel() are + * shorthands for a request with a single profile. + * + * Frames whose script declares a source map that can't be found locally, and + * so are candidates for server-side unminification, are marked with a + * location attribute, and their profiles with a profile attribute. (pprof + * carries this as a profile comment, which OTel reserves for human-readable + * text.) + */ + +import { + AnyValueInput, + ExportProfilesServiceRequest, + FunctionInput, + InstrumentationScopeInput, + KeyValueAndUnitInput, + KeyValueInput, + LinkInput, + LocationInput, + ProfileInput, + SampleInput, + StackInput, + StringTable, +} from 'pprof-format/otel'; +import { + Frame, + frameFunctionName, + HeapProfileNormalizerOptions, + ProfileMetadata, + ProfileVisitor, + NormalizedSample, + SampleType, + SampleLabel, + TimeProfileNormalizerOptions, + visitHeapProfile, + visitTimeProfile, +} from './profile-normalizer'; +import { + AllocationProfileNode, + AllocationProfileNodeWithStats, + TimeProfile, +} from './v8-types'; + +/** + * Trace and span IDs that link a sample to a span. The builder copies them, + * and treats a link whose trace ID or span ID is all zeros, which is invalid, + * as no link. + */ +export interface SampleLink { + /** 16 bytes. */ + traceId: Uint8Array; + /** 8 bytes. */ + spanId: Uint8Array; +} + +/** + * Turns some of a sample's labels into a link to a span. Returns the link, if + * any, and the labels to keep as the sample's attributes. + */ +export type LinkExtractor = (labels: SampleLabel[]) => { + link?: SampleLink; + labels: SampleLabel[]; +}; + +export interface OtelSerializerOptions { + /** Attributes of the resource that produced the profile. */ + resourceAttributes?: KeyValueInput[]; + /** The instrumentation scope. Defaults to `@datadog/pprof`. */ + scope?: InstrumentationScopeInput; + /** Without one, all labels become attributes. */ + extractLink?: LinkExtractor; +} + +const DEFAULT_SCOPE: InstrumentationScopeInput = {name: '@datadog/pprof'}; + +const SAMPLE_COUNT: SampleType = {type: 'sample', unit: 'count'}; + +/** The OTel profiles of one normalized profile, by its sample type index. */ +interface ProfileLayout { + profiles: ProfileInput[]; + /** Per profile: samples by identity, for merging. */ + samplesByIdentity: Array>; + /** + * Index of a sample count type that can be folded into the period type's + * profile, or -1. + */ + countIndex: number; + /** Index of the period type. */ + periodIndex: number; + /** + * Whether the count can still be folded: every sampling event so far was a + * single timestamped observation. + */ + foldable: boolean; +} + +// Boolean attributes marking frames with a missing source map, and profiles +// that have such frames. There is no semantic convention for this, so the keys +// are Datadog's own. +// TODO: confirm the keys with the Datadog profiling backend. +export const FRAME_MISSING_SOURCE_MAP = 'datadog.frame.missing_source_map'; +export const PROFILE_HAS_MISSING_SOURCE_MAPS = + 'datadog.profile.has_missing_source_maps'; + +/** + * Builds one ExportProfilesServiceRequest from any number of normalized + * profiles, which share the request's dictionary: e.g. the time and heap + * profiles of one collection cycle. Each profile is added through a visitor + * from visitor(), so that whatever drives the visit (the normalizer, or a + * profiler emitting samples itself) needs no knowledge of the OTel format. + * + * Functions, locations, stacks, attributes and links are deduplicated across + * all profiles, and each dictionary table starts with its zero value. + * + * Visitors run synchronously, and build() can be called after any number of + * completed visits. A visit that throws leaves partial data in the builder, + * so discard the builder after a failed visit. + */ +export class OtelProfilesBuilder { + private readonly strings = new StringTable(); + private readonly functionTable: FunctionInput[] = [{}]; + private readonly locationTable: LocationInput[] = [{}]; + private readonly stackTable: StackInput[] = [{}]; + private readonly attributeTable: KeyValueAndUnitInput[] = [{}]; + // An all-zero link, with IDs of the lengths codecs expect. + private readonly linkTable: LinkInput[] = [ + {traceId: new Uint8Array(16), spanId: new Uint8Array(8)}, + ]; + private readonly functionIndices = new Map(); + private readonly locationIndices = new Map(); + private readonly stackIndices = new Map(); + private readonly attributeIndices = new Map(); + private readonly linkIndices = new Map(); + private readonly profiles: ProfileInput[] = []; + + constructor(private readonly options: OtelSerializerOptions = {}) {} + + /** + * Returns a visitor that adds one normalized profile to the request, as one + * OTel profile per sample type. Use a new visitor for each profile. + */ + visitor(): ProfileVisitor { + let layout: ProfileLayout = { + profiles: [], + samplesByIdentity: [], + countIndex: -1, + periodIndex: -1, + foldable: false, + }; + return { + metadata: metadata => { + layout = this.layout(metadata); + this.profiles.push(...layout.profiles); + }, + frame: frame => this.locationIndex(frame), + stack: frameIds => this.stackIndex(frameIds), + sample: sample => this.addSample(sample, layout), + end: ({hasMissingMapFiles}) => { + let profiles = layout.profiles; + if (layout.countIndex >= 0 && layout.foldable) { + const count = profiles[layout.countIndex]; + this.profiles.splice(this.profiles.indexOf(count), 1); + profiles = profiles.filter(profile => profile !== count); + } + if (hasMissingMapFiles) { + const attribute = this.trueAttribute(PROFILE_HAS_MISSING_SOURCE_MAPS); + for (const profile of profiles) { + profile.attributeIndices = [attribute]; + } + } + }, + }; + } + + /** + * Creates the OTel profiles for a normalized profile's sample types. + * + * A sample count type can be folded into the profile of the period type + * (wall time, for time profiles) instead of keeping a profile of its own: + * that profile gets an observation for every sampling event, even one whose + * value is zero, so the count is its number of observations. That keeps + * idle samples, which have zero wall time, as timestamped evidence that + * sampling happened, without repeating every sample in a count profile. + * The fold is only lossless if every sampling event is a single timestamped + * observation, so the count profile is built anyway, and dropped at the end + * of the visit only if that turned out to be the case. + */ + private layout(metadata: ProfileMetadata): ProfileLayout { + const {sampleTypes, periodType} = metadata; + const sameType = (a: SampleType, b: SampleType) => + a.type === b.type && a.unit === b.unit; + let countIndex = sampleTypes.findIndex(t => sameType(t, SAMPLE_COUNT)); + const periodIndex = sampleTypes.findIndex(t => sameType(t, periodType)); + if (periodIndex < 0 || periodIndex === countIndex) countIndex = -1; + + const otelPeriodType = this.valueType(periodType); + const profiles = sampleTypes.map(sampleType => ({ + sampleType: this.valueType(sampleType), + periodType: otelPeriodType, + period: metadata.period, + // Sample timestamps should lie within the profile's time range, so it + // starts at the start of collection when that is known. + timeUnixNano: metadata.startTimeNanos ?? metadata.timeNanos, + durationNano: metadata.durationNanos ?? 0, + samples: [], + })); + return { + profiles, + samplesByIdentity: profiles.map(() => new Map()), + countIndex, + periodIndex, + foldable: countIndex >= 0, + }; + } + + /** Builds the request from the profiles added so far. */ + build(): ExportProfilesServiceRequest { + return new ExportProfilesServiceRequest({ + resourceProfiles: [ + { + resource: {attributes: this.options.resourceAttributes ?? []}, + scopeProfiles: [ + { + scope: this.options.scope ?? DEFAULT_SCOPE, + profiles: this.profiles, + }, + ], + }, + ], + dictionary: { + functionTable: this.functionTable, + locationTable: this.locationTable, + linkTable: this.linkTable, + stringTable: this.strings, + attributeTable: this.attributeTable, + stackTable: this.stackTable, + }, + }); + } + + private addSample(sample: NormalizedSample, layout: ProfileLayout) { + const {profiles, samplesByIdentity, countIndex, periodIndex} = layout; + // With a foldable count, the period type's profile observes every + // sampling event, even one with a zero value, such as an idle sample's + // wall time. + const count = countIndex >= 0 ? sample.values[countIndex] : 0; + const isSamplingEvent = count > 0; + // Folding is lossless only if the period type's observations are exactly + // the sampling events, one each: every sampling event is a single + // timestamped observation, and no other sample adds an observation. + if ( + isSamplingEvent + ? sample.timestamp === undefined || count !== 1 + : countIndex >= 0 && sample.values[periodIndex] !== 0 + ) { + layout.foldable = false; + } + let labels = sample.labels; + let linkIndex = 0; + if (this.options.extractLink) { + const extracted = this.options.extractLink(labels); + labels = extracted.labels; + if (extracted.link) { + linkIndex = this.linkIndex(extracted.link); + } + } + // Attributes form a set, so order them for the sample's identity. + const attributeIndices = labels + .map(label => this.attributeIndex(label)) + .sort((a, b) => a - b); + const timestamped = sample.timestamp !== undefined; + const identity = `${sample.stackId}|${attributeIndices.join(',')}|${linkIndex}|${timestamped}`; + + sample.values.forEach((value, i) => { + const profile = profiles[i]; + if (value === 0 && !(i === periodIndex && isSamplingEvent)) return; + const samples = samplesByIdentity[i]; + let otelSample = samples.get(identity); + if (otelSample === undefined) { + otelSample = { + stackIndex: sample.stackId, + attributeIndices, + linkIndex, + values: [], + timestampsUnixNano: [], + }; + samples.set(identity, otelSample); + profile.samples!.push(otelSample); + } + if (timestamped) { + otelSample.values!.push(value); + otelSample.timestampsUnixNano!.push(sample.timestamp!); + } else if (otelSample.values!.length === 0) { + otelSample.values!.push(value); + } else { + otelSample.values![0] = Number(otelSample.values![0]) + value; + } + }); + } + + private valueType({type, unit}: {type: string; unit: string}) { + return { + typeStrindex: this.strings.dedup(type), + unitStrindex: this.strings.dedup(unit), + }; + } + + // Functions are identified by what they encode as: their display name and + // resolved file. (After source mapping, one script can map to several + // files, so a script ID doesn't identify a file.) + private functionIndex(frame: Frame): number { + const name = frameFunctionName(frame); + const key = `${frame.file.length}:${frame.file}:${name}`; + let index = this.functionIndices.get(key); + if (index === undefined) { + index = + this.functionTable.push({ + nameStrindex: this.strings.dedup(name), + filenameStrindex: this.strings.dedup(frame.file), + }) - 1; + this.functionIndices.set(key, index); + } + return index; + } + + private locationIndex(frame: Frame): number { + const functionIndex = this.functionIndex(frame); + const line = frame.line ?? 0; + const column = frame.column ?? 0; + const key = `${functionIndex}:${line}:${column}:${frame.missingMapFile}`; + let index = this.locationIndices.get(key); + if (index === undefined) { + index = + this.locationTable.push({ + lines: [{functionIndex, line, column}], + attributeIndices: frame.missingMapFile + ? [this.trueAttribute(FRAME_MISSING_SOURCE_MAP)] + : [], + }) - 1; + this.locationIndices.set(key, index); + } + return index; + } + + private stackIndex(locationIndices: number[]): number { + const key = locationIndices.join(','); + let index = this.stackIndices.get(key); + if (index === undefined) { + index = this.stackTable.push({locationIndices}) - 1; + this.stackIndices.set(key, index); + } + return index; + } + + private attributeIndex([key, value]: SampleLabel): number { + // JSON-encoding the tuple keeps the key unambiguous whatever the strings + // contain. + const internKey = JSON.stringify([key, typeof value, String(value)]); + return this.internAttribute(internKey, () => ({ + keyStrindex: this.strings.dedup(key), + value: anyValue(value), + })); + } + + private trueAttribute(key: string): number { + return this.internAttribute( + JSON.stringify([key, 'boolean', 'true']), + () => ({ + keyStrindex: this.strings.dedup(key), + value: {boolValue: true}, + }), + ); + } + + private internAttribute( + key: string, + create: () => KeyValueAndUnitInput, + ): number { + let index = this.attributeIndices.get(key); + if (index === undefined) { + index = this.attributeTable.push(create()) - 1; + this.attributeIndices.set(key, index); + } + return index; + } + + private linkIndex(link: SampleLink): number { + if (link.traceId.length !== 16 || link.spanId.length !== 8) { + throw new TypeError( + `A link needs a 16-byte trace ID and an 8-byte span ID, not ${link.traceId.length} and ${link.spanId.length} bytes`, + ); + } + // A trace or span ID of all zeros is invalid, so either one means no + // link: index 0 + if (isAllZero(link.traceId) || isAllZero(link.spanId)) return 0; + const key = `${hex(link.traceId)}:${hex(link.spanId)}`; + let index = this.linkIndices.get(key); + if (index === undefined) { + // Copy the IDs, in case the caller reuses its arrays + index = + this.linkTable.push({ + traceId: Uint8Array.from(link.traceId), + spanId: Uint8Array.from(link.spanId), + }) - 1; + this.linkIndices.set(key, index); + } + return index; + } +} + +/** + * Converts a label value to an attribute value. Integers become int64 + * attributes: like pprof's int64 labels, bigints of 2^63 and above (such as + * large unsigned 64-bit span IDs) wrap to negative values, and bits beyond 64 + * are lost. Labels whose exact unsigned value matters should be strings, or + * links. + */ +function anyValue(value: SampleLabel[1]): AnyValueInput { + if (typeof value === 'string') return {stringValue: value}; + if (typeof value === 'number' && !Number.isInteger(value)) { + return {doubleValue: value}; + } + return {intValue: value}; +} + +function isAllZero(bytes: Uint8Array): boolean { + return bytes.every(byte => byte === 0); +} + +function hex(bytes: Uint8Array): string { + return Buffer.from(bytes.buffer, bytes.byteOffset, bytes.length).toString( + 'hex', + ); +} + +/** + * Serializes a V8 time profile as OpenTelemetry profiles: wall time, CPU + * time if the profile has it, and a sample count unless it can be folded into + * the wall time profile. Pass `samplePerContext` to give every sample with a + * context a timestamp, which lets the count be folded. + */ +export function serializeTimeProfileOtel( + prof: TimeProfile, + options: TimeProfileNormalizerOptions & OtelSerializerOptions, +): ExportProfilesServiceRequest { + const builder = new OtelProfilesBuilder(options); + visitTimeProfile(prof, builder.visitor(), options); + return builder.build(); +} + +/** + * Serializes a V8 heap profile as OpenTelemetry profiles: in-use objects and + * space and, in allocation mode, allocated objects and space. + */ +export function serializeHeapProfileOtel( + prof: AllocationProfileNode | AllocationProfileNodeWithStats, + options: HeapProfileNormalizerOptions & OtelSerializerOptions, +): ExportProfilesServiceRequest { + const builder = new OtelProfilesBuilder(options); + visitHeapProfile(prof, builder.visitor(), options); + return builder.build(); +} diff --git a/ts/test/test-otel-serializer.ts b/ts/test/test-otel-serializer.ts new file mode 100644 index 00000000..304c46d7 --- /dev/null +++ b/ts/test/test-otel-serializer.ts @@ -0,0 +1,959 @@ +/* + * Copyright 2026 Datadog, Inc + * + * Licensed 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. + */ + +import * as sinon from 'sinon'; +import * as tmp from 'tmp'; +import {Profile} from 'pprof-format'; +import { + ExportProfilesServiceRequest, + KeyValueAndUnit, + ProfilesDictionary, +} from 'pprof-format/otel'; + +import { + FRAME_MISSING_SOURCE_MAP, + OtelProfilesBuilder, + PROFILE_HAS_MISSING_SOURCE_MAPS, + serializeHeapProfileOtel, + serializeTimeProfileOtel, +} from '../src/otel-serializer'; +import { + serializeHeapProfile, + serializeTimeProfile, +} from '../src/profile-serializer'; +import {visitHeapProfile, visitTimeProfile} from '../src/profile-normalizer'; +import {SourceMapper} from '../src/sourcemapper/sourcemapper'; +import { + AllocationProfileNode, + AllocationProfileNodeWithStats, + TimeProfile, + TimeProfileNode, +} from '../src/v8-types'; +import {v8HeapProfile, v8TimeProfile} from './profiles-for-tests'; + +const assert = require('assert'); + +/** Encodes and decodes the request, so that tests check the wire format. */ +function roundTrip( + request: ExportProfilesServiceRequest, +): ExportProfilesServiceRequest { + return ExportProfilesServiceRequest.decode(request.encode()); +} + +function attributeValue(attribute: KeyValueAndUnit): string { + const value = attribute.value!; + if (value.stringValue !== undefined) return value.stringValue; + if (value.intValue !== undefined) return String(value.intValue); + return String(value.doubleValue); +} + +/** + * Totals of each OTel profile's values, keyed by sample type, then by stack + * (function names, leaf first) and sorted attributes. Zero totals, such as + * idle wall time, are left out. + */ +function otelTotals(request: ExportProfilesServiceRequest) { + const dictionary = request.dictionary!; + const strings = dictionary.stringTable.strings; + const totals = new Map>(); + const [profiles] = request.resourceProfiles[0].scopeProfiles.map( + s => s.profiles, + ); + for (const profile of profiles) { + const type = strings[profile.sampleType!.typeStrindex]; + const byKey = new Map(); + for (const sample of profile.samples) { + const stack = dictionary.stackTable[ + sample.stackIndex + ].locationIndices.map(location => { + const line = dictionary.locationTable[location].lines[0]; + return strings[ + dictionary.functionTable[line.functionIndex].nameStrindex + ]; + }); + const attributes = sample.attributeIndices + .map(i => dictionary.attributeTable[i]) + .map(a => `${strings[a.keyStrindex]}=${attributeValue(a)}`) + .sort(); + const key = JSON.stringify([stack, attributes]); + const total = sample.values.reduce((t, v) => t + Number(v), 0); + if (total !== 0) byKey.set(key, (byKey.get(key) ?? 0) + total); + } + totals.set(type, byKey); + } + return totals; +} + +/** The profiles of the request's only scope. */ +function profilesOf(request: ExportProfilesServiceRequest) { + return request.resourceProfiles[0].scopeProfiles[0].profiles; +} + +/** The sample types of the request's profiles, in order. */ +function profileTypes(request: ExportProfilesServiceRequest): string[] { + const strings = request.dictionary!.stringTable.strings; + return profilesOf(request).map(p => strings[p.sampleType!.typeStrindex]); +} + +function profileOfType(request: ExportProfilesServiceRequest, type: string) { + const index = profileTypes(request).indexOf(type); + assert.ok(index >= 0, `has a ${type} profile`); + return profilesOf(request)[index]; +} + +/** The name of the leaf function of a stack. */ +function leafName( + request: ExportProfilesServiceRequest, + sample: {stackIndex: number}, +): string { + const dictionary = request.dictionary!; + const location = dictionary.stackTable[sample.stackIndex].locationIndices[0]; + const fn = dictionary.locationTable[location].lines[0].functionIndex; + return dictionary.stringTable.strings[ + dictionary.functionTable[fn].nameStrindex + ]; +} + +/** The totals of a pprof profile, as otelTotals(), leaving out zero values. */ +function pprofTotals(profile: Profile) { + const strings = profile.stringTable.strings; + const totals = new Map>(); + profile.sampleType.forEach((sampleType, i) => { + const byKey = new Map(); + for (const sample of profile.sample) { + const value = Number(sample.value[i]); + if (value === 0) continue; + const stack = sample.locationId.map(id => { + const location = profile.location[Number(id) - 1]; + const fn = profile.function[Number(location.line[0].functionId) - 1]; + return strings[Number(fn.name)]; + }); + const attributes = sample.label + .map( + l => + `${strings[Number(l.key)]}=${l.str ? strings[Number(l.str)] : String(l.num)}`, + ) + .sort(); + const key = JSON.stringify([stack, attributes]); + byKey.set(key, (byKey.get(key) ?? 0) + value); + } + totals.set(strings[Number(sampleType.type)], byKey); + }); + return totals; +} + +/** + * Checks that an OTel request has the same totals as a pprof profile. If the + * OTel request folded the sample count into the wall profile, the count must + * equal the number of wall observations. + */ +function assertSameTotals(otel: ExportProfilesServiceRequest, pprof: Profile) { + const expected = pprofTotals(pprof); + if (expected.has('sample') && !profileTypes(otel).includes('sample')) { + expected.delete('sample'); + const sampleCount = pprof.sample.reduce( + (total, sample) => total + Number(sample.value[0]), + 0, + ); + const observations = profileOfType(otel, 'wall').samples.reduce( + (total, sample) => total + sample.values.length, + 0, + ); + assert.strictEqual(observations, sampleCount, 'wall observations'); + } + assert.deepStrictEqual(otelTotals(otel), expected); +} + +function makeLeaf( + name: string, + props: Partial = {}, +): TimeProfileNode { + return { + name, + scriptName: 'script.js', + scriptId: 1, + lineNumber: 1, + columnNumber: 1, + hitCount: 1, + children: [], + ...props, + }; +} + +function makeTimeProfile(children: TimeProfileNode[]): TimeProfile { + return { + startTime: 0, + endTime: 10 * 1000 * 1000, + hasCpuTime: true, + topDownRoot: { + name: '(root)', + scriptName: 'root', + scriptId: 0, + lineNumber: 0, + columnNumber: 0, + hitCount: 0, + children, + }, + }; +} + +/** + * A profile with labelled and unlabelled contexts, idle, GC and CPU time. As + * in profiles from the native profiler with contexts enabled, each node has + * exactly one context per hit. + */ +function richTimeProfile(): TimeProfile { + const prof = makeTimeProfile([ + makeLeaf('handler', { + hitCount: 2, + children: [ + makeLeaf('query', { + scriptId: 2, + hitCount: 3, + contexts: [ + { + context: {endpoint: 'GET /a', span: 1n}, + timestamp: 1n, + cpuTime: 10, + }, + { + context: {endpoint: 'GET /a', span: 1n}, + timestamp: 2n, + cpuTime: 20, + }, + { + context: {endpoint: 'GET /b', span: 2n}, + timestamp: 3n, + cpuTime: 30, + }, + ], + }), + ], + contexts: [ + {context: {}, timestamp: 4n, cpuTime: 40}, + {context: {endpoint: 'GET /a', retries: 3}, timestamp: 5n, cpuTime: 50}, + ], + }), + makeLeaf('(idle)', { + scriptId: 3, + hitCount: 2, + contexts: [ + {context: {endpoint: 'GET /a'}, timestamp: 6n, cpuTime: 0}, + {context: {}, timestamp: 7n, cpuTime: 5}, + ], + }), + makeLeaf('(garbage collector)', { + scriptId: 4, + hitCount: 2, + contexts: [ + {context: {}, timestamp: 8n, cpuTime: 0}, + {context: {}, timestamp: 9n, cpuTime: 60}, + ], + }), + ]); + prof.nonJSThreadsCpuTime = 500; + return prof; +} + +describe('otel-serializer', () => { + let dateStub: sinon.SinonStub<[], number>; + + before(() => { + dateStub = sinon.stub(Date, 'now').returns(0); + }); + after(() => { + dateStub.restore(); + }); + + describe('serializeTimeProfileOtel', () => { + for (const [name, prof, samplePerContext, folded] of [ + // No contexts: every sample is summed, so the count is kept + ['the fixture', v8TimeProfile, false, false], + // Unlabelled contexts are summed without samplePerContext + ['a profile with contexts', richTimeProfile(), false, false], + [ + 'a profile with contexts, with a sample per context', + richTimeProfile(), + true, + true, + ], + ] as Array<[string, TimeProfile, boolean, boolean]>) { + it(`has the same totals as the pprof serializer for ${name}`, () => { + const otel = roundTrip( + serializeTimeProfileOtel(prof, { + intervalMicros: 1000, + samplePerContext, + }), + ); + const pprof = Profile.decode(serializeTimeProfile(prof, 1000).encode()); + assertSameTotals(otel, pprof); + assert.strictEqual( + !profileTypes(otel).includes('sample'), + folded, + 'sample count folded', + ); + }); + } + + it('folds the count into wall time when every sample is an observation', () => { + const request = roundTrip( + serializeTimeProfileOtel(richTimeProfile(), { + intervalMicros: 1000, + samplePerContext: true, + }), + ); + const strings = request.dictionary!.stringTable.strings; + const profiles = profilesOf(request); + assert.deepStrictEqual( + profiles.map(p => [ + strings[p.sampleType!.typeStrindex], + strings[p.sampleType!.unitStrindex], + ]), + [ + ['wall', 'nanoseconds'], + ['cpu', 'nanoseconds'], + ], + ); + for (const profile of profiles) { + assert.strictEqual(strings[profile.periodType!.typeStrindex], 'wall'); + assert.strictEqual(Number(profile.period), 1000 * 1000); + assert.strictEqual( + Number(profile.durationNano), + 10 * 1000 * 1000 * 1000, + ); + } + }); + + it('keeps the count when a sampling event is summed', () => { + // Seven unlabelled idle hits are summed into one sample with zero wall + // time, from which the count couldn't be recovered + const prof = makeTimeProfile([ + makeLeaf('(idle)', { + hitCount: 7, + contexts: Array.from({length: 7}, (_, i) => ({ + context: {}, + timestamp: BigInt(i + 1), + cpuTime: i === 0 ? 1 : 0, + })), + }), + ]); + const request = roundTrip( + serializeTimeProfileOtel(prof, {intervalMicros: 1000}), + ); + assert.deepStrictEqual(profileTypes(request), ['sample', 'wall', 'cpu']); + assert.deepStrictEqual( + profileOfType(request, 'sample').samples.map(s => s.values.map(Number)), + [[7]], + ); + }); + + it('starts the profiles at the start of collection', () => { + // 1.7e18 nanoseconds is beyond the precision of a double + dateStub.returns(1700000000123); + try { + const prof = { + ...makeTimeProfile([ + makeLeaf('foo', { + contexts: [{context: {}, timestamp: 1700000000005_000n}], + }), + ]), + startTime: 5_000_000, + endTime: 15_000_000, + epochOffset: 1700000000000_000 - 5_000_000, + }; + const request = roundTrip( + serializeTimeProfileOtel(prof, { + intervalMicros: 1000, + samplePerContext: true, + }), + ); + const wall = profileOfType(request, 'wall'); + assert.strictEqual(wall.timeUnixNano, 1700000000000_000_000n); + const [timestamp] = wall.samples[0].timestampsUnixNano; + const start = BigInt(wall.timeUnixNano); + assert.ok(timestamp >= start); + assert.ok(timestamp < start + BigInt(wall.durationNano)); + } finally { + dateStub.returns(0); + } + }); + + it('starts each dictionary table with its zero value', () => { + const {dictionary} = roundTrip( + serializeTimeProfileOtel(richTimeProfile(), {intervalMicros: 1000}), + ); + const d = dictionary as ProfilesDictionary; + assert.strictEqual(d.stringTable.strings[0], ''); + assert.strictEqual(d.functionTable[0].nameStrindex, 0); + assert.deepStrictEqual(d.locationTable[0].lines, []); + assert.deepStrictEqual(d.stackTable[0].locationIndices, []); + assert.strictEqual(d.attributeTable[0].value, undefined); + assert.deepStrictEqual(d.linkTable[0].traceId, new Uint8Array(16)); + assert.deepStrictEqual(d.linkTable[0].spanId, new Uint8Array(8)); + assert.strictEqual(d.mappingTable.length, 1); + }); + + it('merges timestamped samples with the same stack and attributes', () => { + const request = roundTrip( + serializeTimeProfileOtel(richTimeProfile(), { + intervalMicros: 1000, + samplePerContext: true, + }), + ); + const dictionary = request.dictionary!; + const query = profileOfType(request, 'wall').samples.find( + sample => + leafName(request, sample) === 'query' && + sample.attributeIndices.some( + i => attributeValue(dictionary.attributeTable[i]) === 'GET /a', + ), + )!; + assert.deepStrictEqual(query.values.map(Number), [1e6, 1e6]); + assert.deepStrictEqual(query.timestampsUnixNano, [1000, 2000]); + }); + + it('sums samples without a timestamp into one value', () => { + const prof = makeTimeProfile([makeLeaf('foo', {hitCount: 5})]); + const request = roundTrip( + serializeTimeProfileOtel(prof, {intervalMicros: 1000}), + ); + assert.deepStrictEqual( + profileOfType(request, 'sample').samples.map(s => s.values.map(Number)), + [[5]], + ); + const wall = profileOfType(request, 'wall'); + assert.deepStrictEqual( + wall.samples.map(s => s.values.map(Number)), + [[5e6]], + ); + assert.deepStrictEqual(wall.samples[0].timestampsUnixNano, []); + }); + + it('keeps idle samples in the wall profile with zero wall time', () => { + const request = roundTrip( + serializeTimeProfileOtel(richTimeProfile(), { + intervalMicros: 1000, + samplePerContext: true, + }), + ); + const wall = profileOfType(request, 'wall'); + const cpu = profileOfType(request, 'cpu'); + // Idle samples are timestamped evidence of sampling, without wall time + const idle = wall.samples.filter(s => leafName(request, s) === '(idle)'); + assert.deepStrictEqual( + idle.flatMap(s => s.values.map(Number)), + [0, 0], + ); + assert.deepStrictEqual( + idle.flatMap(s => s.timestampsUnixNano.map(Number)).sort(), + [6000, 7000], + ); + // Their CPU time is kept; zero CPU time is left out + assert.deepStrictEqual( + cpu.samples + .filter(s => leafName(request, s) === '(idle)') + .flatMap(s => s.values.map(Number)), + [5], + ); + // Non-JS threads aren't sampling events: only CPU time, no wall time + const nonJS = (s: {stackIndex: number}) => + leafName(request, s) === 'Non JS threads activity'; + assert.ok(!wall.samples.some(nonJS)); + assert.ok(cpu.samples.some(nonJS)); + }); + + it('turns labels into links with extractLink', () => { + const traceId = Uint8Array.from({length: 16}, (_, i) => i + 1); + const request = roundTrip( + serializeTimeProfileOtel(richTimeProfile(), { + intervalMicros: 1000, + extractLink: labels => { + const span = labels.find(([key]) => key === 'span'); + return { + link: span && { + traceId, + spanId: Buffer.from( + span[1].toString(16).padStart(16, '0'), + 'hex', + ), + }, + labels: labels.filter(([key]) => key !== 'span'), + }; + }, + }), + ); + const dictionary = request.dictionary!; + const strings = dictionary.stringTable.strings; + assert.strictEqual(dictionary.linkTable.length, 3); + assert.deepStrictEqual(dictionary.linkTable[1].traceId, traceId); + assert.ok( + dictionary.attributeTable.every(a => strings[a.keyStrindex] !== 'span'), + ); + const spanIds = dictionary.linkTable + .slice(1) + .map(link => Number(Buffer.from(link.spanId).readBigUInt64BE())); + assert.deepStrictEqual(spanIds.sort(), [1, 2]); + const linked = profileOfType(request, 'wall').samples.filter( + s => s.linkIndex !== 0, + ); + assert.ok(linked.length > 0); + for (const sample of linked) { + assert.ok(sample.linkIndex < dictionary.linkTable.length); + } + }); + + it('validates and copies link IDs', () => { + const prof = makeTimeProfile([ + makeLeaf('foo', {contexts: [{context: {span: 1}, timestamp: 1n}]}), + ]); + const serialize = (link: {traceId: Uint8Array; spanId: Uint8Array}) => + serializeTimeProfileOtel(prof, { + intervalMicros: 1000, + extractLink: labels => ({link, labels}), + }); + assert.throws( + () => + serialize({traceId: new Uint8Array(8), spanId: new Uint8Array(8)}), + TypeError, + ); + // A trace or span ID of all zeros is invalid, so either means no link + for (const [traceByte, spanByte] of [ + [0, 0], + [0, 1], + [1, 0], + ]) { + const request = roundTrip( + serialize({ + traceId: new Uint8Array(16).fill(traceByte), + spanId: new Uint8Array(8).fill(spanByte), + }), + ); + assert.strictEqual( + request.dictionary!.linkTable.length, + 1, + `trace ${traceByte}, span ${spanByte}`, + ); + assert.strictEqual( + profileOfType(request, 'wall').samples[0].linkIndex, + 0, + ); + } + // A caller reusing its arrays doesn't change links already added + const spanId = new Uint8Array(8).fill(1); + const request = serialize({traceId: new Uint8Array(16).fill(1), spanId}); + spanId.fill(2); + assert.deepStrictEqual( + roundTrip(request).dictionary!.linkTable[1].spanId, + new Uint8Array(8).fill(1), + ); + }); + + it('keeps attributes apart whatever their keys and values contain', () => { + const prof = makeTimeProfile([ + makeLeaf('foo', { + hitCount: 2, + contexts: [ + {context: {a: 'b\0string\0c'}, timestamp: 1n}, + {context: {'a\0string\0b': 'c'}, timestamp: 2n}, + ], + }), + ]); + const request = roundTrip( + serializeTimeProfileOtel(prof, {intervalMicros: 1000}), + ); + assert.strictEqual(request.dictionary!.attributeTable.length, 3); + assert.strictEqual(profileOfType(request, 'wall').samples.length, 2); + }); + + it('sets the resource attributes and scope', () => { + const request = roundTrip( + serializeTimeProfileOtel(v8TimeProfile, { + intervalMicros: 1000, + resourceAttributes: [ + {key: 'service.name', value: {stringValue: 'svc'}}, + ], + scope: {name: 'my-profiler', version: '1.2.3'}, + }), + ); + const [resourceProfiles] = request.resourceProfiles; + assert.strictEqual( + resourceProfiles.resource!.attributes[0].value!.stringValue, + 'svc', + ); + assert.strictEqual( + resourceProfiles.scopeProfiles[0].scope!.name, + 'my-profiler', + ); + assert.strictEqual( + resourceProfiles.scopeProfiles[0].scope!.version, + '1.2.3', + ); + const defaultScope = roundTrip( + serializeTimeProfileOtel(v8TimeProfile, {intervalMicros: 1000}), + ).resourceProfiles[0].scopeProfiles[0].scope!; + assert.strictEqual(defaultScope.name, '@datadog/pprof'); + }); + + it('encodes number labels as int or double attributes', () => { + // pprof can only hold integer labels; OTel attributes keep fractions + const prof = makeTimeProfile([ + makeLeaf('foo', { + contexts: [ + {context: {ratio: 0.5, count: 3, big: 2n ** 60n}, timestamp: 1n}, + ], + }), + ]); + const {dictionary} = roundTrip( + serializeTimeProfileOtel(prof, {intervalMicros: 1000}), + ); + const strings = dictionary!.stringTable.strings; + const byKey = new Map( + dictionary!.attributeTable + .slice(1) + .map(a => [strings[a.keyStrindex], a.value!]), + ); + assert.strictEqual(byKey.get('ratio')!.doubleValue, 0.5); + assert.strictEqual(Number(byKey.get('count')!.intValue), 3); + assert.strictEqual(BigInt(byKey.get('big')!.intValue!), 2n ** 60n); + }); + + it('carries the columns of frames', () => { + const prof = makeTimeProfile([ + makeLeaf('foo', {lineNumber: 3, columnNumber: 7}), + ]); + const {dictionary} = roundTrip( + serializeTimeProfileOtel(prof, {intervalMicros: 1000}), + ); + const line = dictionary!.locationTable[1].lines[0]; + assert.strictEqual(Number(line.line), 3); + assert.strictEqual(Number(line.column), 7); + }); + + it('keeps functions from different original files apart', () => { + // One generated script whose two positions map to different original + // files, with the same function name and position + const sourceMapper = { + mappingInfo: (loc: {line: number}) => ({ + file: loc.line === 1 ? '/app/a.ts' : '/app/b.ts', + line: 1, + column: 1, + name: 'render', + }), + } as unknown as SourceMapper; + const prof = makeTimeProfile([ + makeLeaf('x', {scriptId: 42, lineNumber: 1, columnNumber: 5}), + makeLeaf('y', {scriptId: 42, lineNumber: 2, columnNumber: 5}), + ]); + const request = roundTrip( + serializeTimeProfileOtel(prof, {intervalMicros: 1000, sourceMapper}), + ); + const dictionary = request.dictionary!; + const strings = dictionary.stringTable.strings; + assert.deepStrictEqual( + dictionary.functionTable + .slice(1) + .map(f => strings[f.filenameStrindex]) + .sort(), + ['/app/a.ts', '/app/b.ts'], + ); + assert.strictEqual(dictionary.locationTable.length, 3); + assert.strictEqual(dictionary.stackTable.length, 3); + }); + + it('shares functions that encode the same, across scripts', () => { + const prof = makeTimeProfile([ + makeLeaf('foo', {scriptId: 1, lineNumber: 5}), + makeLeaf('foo', {scriptId: 2, lineNumber: 7}), + ]); + const {dictionary} = roundTrip( + serializeTimeProfileOtel(prof, {intervalMicros: 1000}), + ); + assert.strictEqual(dictionary!.functionTable.length, 2); + assert.strictEqual(dictionary!.locationTable.length, 3); + }); + + it('marks frames with missing source maps and their profiles', async () => { + const fs = await import('fs'); + const path = await import('path'); + const dir = tmp.dirSync().name; + const script = path.join(dir, 'missing-map.js'); + fs.writeFileSync(script, '//# sourceMappingURL=nonexistent.js.map\n'); + const sourceMapper = await SourceMapper.create([dir]); + const prof = makeTimeProfile([ + makeLeaf('minified', {scriptName: script}), + makeLeaf('plain', {scriptId: 2}), + ]); + const request = roundTrip( + serializeTimeProfileOtel(prof, {intervalMicros: 1000, sourceMapper}), + ); + const dictionary = request.dictionary!; + const strings = dictionary.stringTable.strings; + const attributes = (indices: number[]) => + indices.map(i => { + const attribute = dictionary.attributeTable[i]; + return [strings[attribute.keyStrindex], attribute.value!.boolValue]; + }); + + const marked = dictionary.locationTable + .slice(1) + .map(location => [ + strings[ + dictionary.functionTable[location.lines[0].functionIndex] + .nameStrindex + ], + attributes(location.attributeIndices), + ]); + assert.deepStrictEqual(marked.sort(), [ + ['minified', [[FRAME_MISSING_SOURCE_MAP, true]]], + ['plain', []], + ]); + for (const profile of profilesOf(request)) { + assert.deepStrictEqual(attributes(profile.attributeIndices), [ + [PROFILE_HAS_MISSING_SOURCE_MAPS, true], + ]); + } + }); + + it('marks nothing when all source maps are found', () => { + const request = roundTrip( + serializeTimeProfileOtel(richTimeProfile(), {intervalMicros: 1000}), + ); + assert.ok( + request.dictionary!.locationTable.every( + l => l.attributeIndices.length === 0, + ), + ); + for (const profile of profilesOf(request)) { + assert.deepStrictEqual(profile.attributeIndices, []); + } + }); + }); + + describe('serializeHeapProfileOtel', () => { + it('has the same totals as the pprof serializer', () => { + const otel = roundTrip( + serializeHeapProfileOtel(v8HeapProfile, { + startTimeNanos: 0n, + intervalBytes: 512, + }), + ); + const pprof = Profile.decode( + serializeHeapProfile(v8HeapProfile, 0, 512).encode(), + ); + assertSameTotals(otel, pprof); + assert.deepStrictEqual(profileTypes(otel), [ + 'inuse_objects', + 'inuse_space', + ]); + }); + + it('has allocation profiles in allocation mode', () => { + const withStats = ( + node: AllocationProfileNode, + ): AllocationProfileNodeWithStats => ({ + ...node, + allocations: node.allocations.map(a => ({ + inuseObjects: a.count, + inuseSpaceBytes: a.sizeBytes * a.count, + allocObjects: 2 * a.count, + allocSpaceBytes: 2 * a.sizeBytes * a.count, + })), + children: node.children.map(withStats), + }); + const prof = withStats(v8HeapProfile); + const otel = roundTrip( + serializeHeapProfileOtel(prof, { + startTimeNanos: 0n, + intervalBytes: 512, + allocations: true, + }), + ); + const pprof = Profile.decode( + serializeHeapProfile( + prof, + 0, + 512, + undefined, + undefined, + undefined, + true, + ).encode(), + ); + assertSameTotals(otel, pprof); + assert.strictEqual(profilesOf(otel).length, 4); + }); + }); + + describe('OtelProfilesBuilder', () => { + const heapNode = ( + name: string, + props: Partial = {}, + ): AllocationProfileNode => ({ + name, + scriptName: 'script.js', + scriptId: 1, + lineNumber: 1, + columnNumber: 1, + allocations: [{sizeBytes: 64, count: 2}], + children: [], + ...props, + }); + const heapRoot = (children: AllocationProfileNode[]) => + heapNode('(root)', { + scriptName: '', + scriptId: 0, + allocations: [], + children, + }); + const timeOptions = {intervalMicros: 1000, samplePerContext: true}; + + it('puts several profiles in one request, with the same totals', () => { + const builder = new OtelProfilesBuilder(); + visitTimeProfile(richTimeProfile(), builder.visitor(), timeOptions); + visitHeapProfile(v8HeapProfile, builder.visitor(), { + startTimeNanos: 0n, + intervalBytes: 512, + }); + const combined = roundTrip(builder.build()); + assert.deepStrictEqual(profileTypes(combined), [ + 'wall', + 'cpu', + 'inuse_objects', + 'inuse_space', + ]); + const separate = new Map([ + ...otelTotals( + roundTrip(serializeTimeProfileOtel(richTimeProfile(), timeOptions)), + ), + ...otelTotals( + roundTrip( + serializeHeapProfileOtel(v8HeapProfile, { + startTimeNanos: 0n, + intervalBytes: 512, + }), + ), + ), + ]); + assert.deepStrictEqual(otelTotals(combined), separate); + }); + + it('shares dictionary entries between profiles', () => { + const builder = new OtelProfilesBuilder(); + visitTimeProfile( + makeTimeProfile([ + makeLeaf('shared', {children: [makeLeaf('timeOnly', {scriptId: 2})]}), + ]), + builder.visitor(), + timeOptions, + ); + visitHeapProfile( + heapRoot([ + heapNode('shared', {children: [heapNode('heapOnly', {scriptId: 3})]}), + ]), + builder.visitor(), + {startTimeNanos: 0n, intervalBytes: 512}, + ); + const request = roundTrip(builder.build()); + const dictionary = request.dictionary!; + const strings = dictionary.stringTable.strings; + const names = dictionary.functionTable + .slice(1) + .map(f => strings[f.nameStrindex]); + assert.deepStrictEqual(names.sort(), ['heapOnly', 'shared', 'timeOnly']); + // No duplicate locations or stacks + const keys = (items: unknown[]) => + items.map(item => JSON.stringify(item)); + const locations = keys(dictionary.locationTable.map(l => l.lines)); + assert.strictEqual(new Set(locations).size, locations.length); + const stacks = keys(dictionary.stackTable.map(st => st.locationIndices)); + assert.strictEqual(new Set(stacks).size, stacks.length); + // The frame both profiles share is one location, with one stack + const sharedStack = (samples: Array<{stackIndex: number}>) => + samples.find(s => leafName(request, s) === 'shared')!.stackIndex; + assert.strictEqual( + sharedStack(profileOfType(request, 'sample').samples), + sharedStack(profileOfType(request, 'inuse_space').samples), + ); + }); + + it("doesn't fold a count that observations wouldn't match", () => { + // A zero-count sample with a period value would add an observation that + // isn't a sampling event, so the count can't be its number of + // observations + const builder = new OtelProfilesBuilder(); + const visitor = builder.visitor(); + const cpu = {type: 'cpu', unit: 'nanoseconds'}; + visitor.metadata({ + sampleTypes: [{type: 'sample', unit: 'count'}, cpu], + periodType: cpu, + period: 1, + timeNanos: 0n, + }); + const stackId = visitor.stack([ + visitor.frame({name: 'f', file: 'a.js', missingMapFile: false}), + ]); + visitor.sample({stackId, values: [1, 100], labels: [], timestamp: 1n}); + visitor.sample({stackId, values: [0, 50], labels: [], timestamp: 2n}); + visitor.end!({hasMissingMapFiles: false}); + const request = roundTrip(builder.build()); + assert.deepStrictEqual(profileTypes(request), ['sample', 'cpu']); + assert.deepStrictEqual( + profileOfType(request, 'sample').samples.map(s => s.values.map(Number)), + [[1]], + ); + }); + + it('marks only the profiles that have missing source maps', async () => { + const fs = await import('fs'); + const path = await import('path'); + const dir = tmp.dirSync().name; + const script = path.join(dir, 'missing-map.js'); + fs.writeFileSync(script, '//# sourceMappingURL=nonexistent.js.map\n'); + const sourceMapper = await SourceMapper.create([dir]); + const builder = new OtelProfilesBuilder(); + visitTimeProfile( + makeTimeProfile([ + makeLeaf('minified', { + scriptName: script, + contexts: [{context: {}, timestamp: 1n, cpuTime: 1}], + }), + ]), + builder.visitor(), + {...timeOptions, sourceMapper}, + ); + visitHeapProfile(v8HeapProfile, builder.visitor(), { + startTimeNanos: 0n, + intervalBytes: 512, + }); + const request = roundTrip(builder.build()); + const strings = request.dictionary!.stringTable.strings; + const flagged = profilesOf(request).map(p => [ + strings[p.sampleType!.typeStrindex], + p.attributeIndices.length, + ]); + assert.deepStrictEqual(flagged, [ + ['wall', 1], + ['cpu', 1], + ['inuse_objects', 0], + ['inuse_space', 0], + ]); + }); + }); + + after(() => { + tmp.setGracefulCleanup(); + }); +}); From c6ec7871a49d38ecbca96ce7195107bb35082b2d Mon Sep 17 00:00:00 2001 From: Attila Szegedi Date: Tue, 27 Oct 2026 10:07:07 +0100 Subject: [PATCH 2/2] Add time.stopAndVisit() and heap.visitProfile() These pass the normalized profile to a caller-supplied ProfileVisitor, instead of serializing it as pprof. The caller chooses the output, e.g. a visitor of an OtelProfilesBuilder that collects all profiles of a collection cycle into one request. They are the format-neutral counterparts of time.stop() and heap.profile(). time.stopAndVisit() uses the lazy stopAndCollect path, like stopV2(), so it visits the profile while the native profile is still alive, which saves memory. Both return the VisitResult. --- ts/src/heap-profiler.ts | 35 ++++++++++++++++++++++++++- ts/src/index.ts | 2 ++ ts/src/time-profiler.ts | 27 +++++++++++++++++++++ ts/test/test-heap-profiler.ts | 35 +++++++++++++++++++++++++++ ts/test/test-time-profiler.ts | 45 +++++++++++++++++++++++++++++++++++ 5 files changed, 143 insertions(+), 1 deletion(-) diff --git a/ts/src/heap-profiler.ts b/ts/src/heap-profiler.ts index 99e2c0df..c49aa6e4 100644 --- a/ts/src/heap-profiler.ts +++ b/ts/src/heap-profiler.ts @@ -28,7 +28,14 @@ import { DEFAULT_COLUMN_NUMBERS, serializeHeapProfile, } from './profile-serializer'; -import {NormalizedProfile, normalizeHeapProfile} from './profile-normalizer'; +import { + HeapProfileNormalizerOptions, + NormalizedProfile, + normalizeHeapProfile, + ProfileVisitor, + visitHeapProfile, + VisitResult, +} from './profile-normalizer'; import {SourceMapper} from './sourcemapper/sourcemapper'; import { AllocationProfileNode, @@ -129,6 +136,32 @@ export function normalizedProfile( }); } +/** + * Collects a profile and passes it to the visitor, normalized, instead of + * serializing it as pprof, so the caller chooses the output (e.g. a visitor of + * an OtelProfilesBuilder). Throws if heap profiler is not enabled. + */ +export function visitProfile( + visitor: ProfileVisitor, + options: Pick< + HeapProfileNormalizerOptions, + 'ignoreSamplesPath' | 'sourceMapper' | 'generateLabels' + > = {}, +): VisitResult { + return collectProfile(root => + visitHeapProfile( + addExternalMemoryNode(root, startedWithAllocations), + visitor, + { + ...options, + startTimeNanos: BigInt(Date.now()) * 1_000_000n, + intervalBytes: heapIntervalBytes, + allocations: startedWithAllocations, + }, + ), + ); +} + function collectProfile( processProfile: ( root: AllocationProfileNode | AllocationProfileNodeWithStats, diff --git a/ts/src/index.ts b/ts/src/index.ts index 95415114..a3bb10ce 100644 --- a/ts/src/index.ts +++ b/ts/src/index.ts @@ -57,6 +57,7 @@ export const time = { start: timeProfiler.start, stop: timeProfiler.stop, stopNormalized: timeProfiler.stopNormalized, + stopAndVisit: timeProfiler.stopAndVisit, profileV2: timeProfiler.profileV2, getContext: timeProfiler.getContext, setContext: timeProfiler.setContext, @@ -75,6 +76,7 @@ export const heap = { profile: heapProfiler.profile, profileV2: heapProfiler.profileV2, normalizedProfile: heapProfiler.normalizedProfile, + visitProfile: heapProfiler.visitProfile, convertProfile: heapProfiler.convertProfile, v8Profile: heapProfiler.v8Profile, monitorOutOfMemory: heapProfiler.monitorOutOfMemory, diff --git a/ts/src/time-profiler.ts b/ts/src/time-profiler.ts index 40cf3ff2..604ac523 100644 --- a/ts/src/time-profiler.ts +++ b/ts/src/time-profiler.ts @@ -27,7 +27,10 @@ import { import { NormalizedProfile, normalizeTimeProfile, + ProfileVisitor, TimeProfileNormalizerOptions, + visitTimeProfile, + VisitResult, } from './profile-normalizer'; import {SourceMapper} from './sourcemapper/sourcemapper'; import { @@ -302,6 +305,30 @@ export function stopV2( ); } +/** + * Like stopV2(), but passes the normalized profile to the visitor instead of + * serializing it as pprof, so the caller chooses the output (e.g. a visitor + * of an OtelProfilesBuilder). The visit happens while the native profile is + * still alive, which saves memory. + */ +export function stopAndVisit( + restart: boolean, + visitor: ProfileVisitor, + options: Pick< + TimeProfileNormalizerOptions, + 'generateLabels' | 'samplePerContext' + > = {}, +): VisitResult { + return stopAndCollect(restart, (profile: TimeProfile) => + visitTimeProfile(profile, visitor, { + ...options, + intervalMicros: gIntervalMicros, + sourceMapper: gSourceMapper, + recomputeSamplingInterval: true, + }), + ); +} + export function getState() { if (!gProfiler) { throw new Error('Wall profiler is not started'); diff --git a/ts/test/test-heap-profiler.ts b/ts/test/test-heap-profiler.ts index f2d4b2e9..6c8b5421 100644 --- a/ts/test/test-heap-profiler.ts +++ b/ts/test/test-heap-profiler.ts @@ -149,6 +149,41 @@ describe('HeapProfiler', () => { assert.ok(normalized.frames.some(f => f.name === '(external)')); }); + it('should pass the profile to a visitor with visitProfile', async () => { + profileStub = sinon + .stub(v8HeapProfiler, 'getAllocationProfile') + .returns(copy(v8HeapProfile)); + memoryUsageStub = sinon.stub(process, 'memoryUsage').returns({ + external: 1024, + rss: 2048, + heapTotal: 4096, + heapUsed: 2048, + arrayBuffers: 512, + }); + const intervalBytes = 1024 * 512; + heapProfiler.start(intervalBytes, 32); + const values: number[][] = []; + let period = 0; + let frames = 0; + const result = heapProfiler.visitProfile({ + metadata: metadata => { + period = metadata.period; + }, + frame: () => frames++, + stack: () => 0, + sample: sample => { + values.push(sample.values); + }, + }); + assert.deepStrictEqual(result, {hasMissingMapFiles: false}); + assert.strictEqual(period, intervalBytes); + assert.ok(frames > 0); + assert.deepStrictEqual( + values, + heapProfileWithExternal.sample.map(s => s.value), + ); + }); + it('should return a profile equal to the expected profile when including all samples', async () => { profileStub = sinon .stub(v8HeapProfiler, 'getAllocationProfile') diff --git a/ts/test/test-time-profiler.ts b/ts/test/test-time-profiler.ts index 80d7f012..99ac0ae0 100644 --- a/ts/test/test-time-profiler.ts +++ b/ts/test/test-time-profiler.ts @@ -20,6 +20,7 @@ import {time, getNativeThreadId} from '../src'; import {profileV2, stopV2} from '../src/time-profiler'; import * as v8TimeProfiler from '../src/time-profiler-bindings'; import * as profileSerializer from '../src/profile-serializer'; +import {OtelProfilesBuilder} from '../src/otel-serializer'; import {SourceMapper} from '../src/sourcemapper/sourcemapper'; import {timeProfile, v8TimeProfile} from './profiles-for-tests'; import {hrtime} from 'process'; @@ -726,6 +727,50 @@ describe('Time Profiler', () => { sinonStubs.forEach(stub => stub.restore()); }); + it('should stay usable when a stopAndVisit visitor throws', () => { + const throwing = { + metadata: () => { + throw new Error('boom'); + }, + frame: () => 0, + stack: () => 0, + sample: () => {}, + }; + time.start({intervalMicros: PROFILE_OPTIONS.intervalMicros}); + assert.throws(() => time.stopAndVisit(false, throwing), /boom/); + assert.ok(!time.isStarted(), 'stopped despite the error'); + time.start({intervalMicros: PROFILE_OPTIONS.intervalMicros}); + assert.throws(() => time.stopAndVisit(true, throwing), /boom/); + assert.ok(time.isStarted(), 'still running after a restart'); + time.stopAndVisit(false, new OtelProfilesBuilder().visitor()); + assert.ok(!time.isStarted()); + }); + + it('should pass the profile to a visitor with stopAndVisit', () => { + time.start({intervalMicros: PROFILE_OPTIONS.intervalMicros}); + const builder = new OtelProfilesBuilder(); + const result = time.stopAndVisit(true, builder.visitor()); + assert.ok(time.isStarted(), 'still running after a restart'); + assert.deepStrictEqual(result, {hasMissingMapFiles: false}); + time.stopAndVisit(false, builder.visitor()); + assert.ok(!time.isStarted()); + + const request = builder.build(); + const profiles = request.resourceProfiles[0].scopeProfiles[0].profiles; + // Two visits of a profile without contexts (so its samples are summed, + // and the count keeps a profile) or CPU time: sample count and wall + assert.strictEqual(profiles.length, 4); + const expectedCount = timeProfile.sample.reduce( + (total, sample) => total + Number(sample.value[0]), + 0, + ); + const count = profiles[0].samples.reduce( + (total, sample) => total + Number(sample.values[0]), + 0, + ); + assert.strictEqual(count, expectedCount); + }); + it('should profile during duration and finish profiling after duration', async () => { let isProfiling = true; void profileV2(PROFILE_OPTIONS).then(() => {