Skip to content

feat: add an OpenTelemetry profiles serializer - #437

Draft
szegedi wants to merge 2 commits into
szegedi/profile-normalizerfrom
szegedi/otel-serializer
Draft

szegedi wants to merge 2 commits into
szegedi/profile-normalizerfrom
szegedi/otel-serializer

Conversation

@szegedi

@szegedi szegedi commented Nov 9, 2026

Copy link
Copy Markdown
Collaborator

What does this PR do?:

Adds an OpenTelemetry profiles serializer, built as a ProfileVisitor on the format-neutral normalizer from #430, and encoding with pprof-format/otel (pprof-format 2.4.0).

  • OtelProfilesBuilder (ts/src/otel-serializer.ts) collects any number of normalized profiles, e.g. the time and heap profiles of one collection cycle, into one ExportProfilesServiceRequest. All of them share the request's dictionary, as the OTel format intends. Each profile is added through builder.visitor(), so whatever drives the visit needs no knowledge of OTel. serializeTimeProfileOtel() and serializeHeapProfileOtel() are shorthands for a single-profile request.
  • time.stopAndVisit() and heap.visitProfile() pass the normalized profile to a caller-supplied visitor instead of serializing it as pprof. They're the format-neutral counterparts of time.stop() and heap.profile(). stopAndVisit() uses the lazy stopAndCollect path, like stopV2(), so it visits while the native profile is still alive.

Mapping highlights:

  • One OTel profile per sample type (sample count, wall and CPU time; in-use and allocated objects and space).
  • With samplePerContext, the sample count is folded into the wall-time profile, which gets an observation per sampling event. Idle samples, which deliberately have zero wall time (see Preserve idle samples with zero wall time and no CPU time rollup #199), thereby stay as timestamped evidence that sampling happened, and no count profile has to repeat every sample just to carry 1s.
  • Sample timestamps fall within [time_unix_nano, time_unix_nano + duration_nano), since the time range starts at the start of collection.
  • Functions, locations (with columns), 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. An optional extractLink callback can turn labels into a span link; all-zero trace or span IDs are treated as no link.
  • Samples with the same stack, attributes and link are merged: timestamped ones accumulate a value and timestamp per observation, others are summed.

Motivation:

Step 2 of emitting the OpenTelemetry profiles signal from the Node.js profiler: a second encoder that reuses the profile semantics from #430 instead of duplicating them.

Additional Notes:

  • Stacked on refactor: extract a format-neutral profile normalizer from the pprof serializer #430; only the last two commits are new here.
  • The builder isn't exported from the package yet, so that the API can settle first, in particular around how dd-trace-js will drive a collection cycle.
  • Frames whose source map is declared but missing locally get a datadog.frame.missing_source_map location attribute, and their profiles a datadog.profile.has_missing_source_maps attribute. pprof carries this as a profile comment, but OTel's pprof.profile.comment is reserved for human-readable text. No semantic convention exists, so these keys are provisional until the Datadog backend confirms them.
  • Visitors run synchronously. A visit that throws leaves partial data, so the builder should then be discarded.

How to test the change?:

Unit tests in ts/test/test-otel-serializer.ts cover the mapping, deduplication across profiles, sample merging, links and the missing-source-map attributes, and decode the encoded request to check it round-trips. ts/test/test-time-profiler.ts and ts/test/test-heap-profiler.ts cover stopAndVisit() and visitProfile(), including state after a throwing visitor. npm test.

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.
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.
@github-actions

github-actions Bot commented Nov 9, 2026

Copy link
Copy Markdown

Overall package size

Self size: 2.94 MB
Deduped: 4.05 MB
No deduping: 4.05 MB

Dependency sizes | name | version | self size | total size | |------|---------|-----------|------------| | pprof-format | 2.4.0 | 912.36 kB | 912.36 kB | | source-map | 0.8.0 | 185.66 kB | 185.66 kB | | node-gyp-build | 4.8.4 | 13.86 kB | 13.86 kB |

🤖 This report was automatically generated by heaviest-objects-in-the-universe

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant