From 594c6f2235831f3fa20eb7d80f96f965f7de0b08 Mon Sep 17 00:00:00 2001 From: Attila Szegedi Date: Thu, 25 Jun 2026 11:32:54 +0200 Subject: [PATCH 1/7] Update CI actions to Node.js 24 versions (#356) GitHub deprecated the Node.js 20 runtime; bump the actions/* to versions that run on Node.js 24, matching DataDog/action-prebuildify: - actions/checkout -> v7 - actions/setup-node -> v6 - actions/download-artifact -> v7 (release.yml) - the release download-artifact steps now fetch the prebuilds artifact by name into ./prebuilds, since v5+ no longer nests a single nameless artifact under its own directory --- .github/workflows/build.yml | 8 ++++---- .github/workflows/lint.yml | 4 ++-- .github/workflows/package-size.yml | 4 ++-- .github/workflows/release.yml | 18 ++++++++++++------ 4 files changed, 20 insertions(+), 14 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 324ae78c..dcba8935 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -13,8 +13,8 @@ jobs: version: [18, 20, 22, 24, 25, 26] runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 - - uses: actions/setup-node@v3 + - uses: actions/checkout@v7 + - uses: actions/setup-node@v6 with: node-version: ${{ matrix.version }} - run: npm install @@ -26,8 +26,8 @@ jobs: version: [18, 20, 22, 24, 25, 26] runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 - - uses: actions/setup-node@v3 + - uses: actions/checkout@v7 + - uses: actions/setup-node@v6 with: node-version: ${{ matrix.version }} - run: sudo apt-get update && sudo apt-get install valgrind diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 0b14a365..f4dad2a6 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -8,7 +8,7 @@ jobs: lint: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 - - uses: actions/setup-node@v2 + - uses: actions/checkout@v7 + - uses: actions/setup-node@v6 - run: yarn - run: yarn lint diff --git a/.github/workflows/package-size.yml b/.github/workflows/package-size.yml index df44eb5e..93a53267 100644 --- a/.github/workflows/package-size.yml +++ b/.github/workflows/package-size.yml @@ -15,9 +15,9 @@ jobs: permissions: pull-requests: write steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v7 - name: Setup Node.js - uses: actions/setup-node@v2 + uses: actions/setup-node@v6 with: node-version: '22' - run: yarn diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ddd4e082..d31dccb3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -32,11 +32,14 @@ jobs: with: scope: DataDog/pprof-nodejs policy: self.github.release.push-tags - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false # drop GITHUB_TOKEN so the dd-octo-sts token is used for the tag push - - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 - - uses: actions/setup-node@3235b876344d2a9aa001b8d1453c930bba69e610 # v3.9.1 + - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 + with: + name: prebuilds + path: prebuilds + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: '24' registry-url: 'https://registry.npmjs.org' @@ -59,9 +62,12 @@ jobs: id-token: write # Required for OIDC contents: read steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 - - uses: actions/setup-node@3235b876344d2a9aa001b8d1453c930bba69e610 # v3.9.1 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 + with: + name: prebuilds + path: prebuilds + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: '24' registry-url: 'https://registry.npmjs.org' From bc8e75f5b629b548a123103170475172bde45e98 Mon Sep 17 00:00:00 2001 From: Ruben Bridgewater Date: Mon, 29 Jun 2026 11:58:47 +0200 Subject: [PATCH 2/7] fix: drop no-op install script that triggers Yarn build warning (#363) Yarn Berry (Plug'n'Play) prints "YN0007: @datadog/pprof must be built" for any dependency that declares an install/preinstall/postinstall script, even the no-op "exit 0". The package ships prebuilt binaries and excludes binding.gyp from the published tarball, so a consumer has nothing to build; the script only suppressed npm's implicit node-gyp rebuild in the dev tree, which CI and the prepare script do explicitly. Removing it stops the spurious warning for Yarn Berry consumers. Refs: https://github.com/DataDog/dd-trace-js/issues/5432 --- package.json | 1 - ts/test/test-no-build-scripts.ts | 14 ++++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) create mode 100644 ts/test/test-no-build-scripts.ts diff --git a/package.json b/package.json index 975f288a..1933cd78 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,6 @@ "compile": "tsc -p .", "fix": "gts fix", "format": "clang-format --style file -i --glob='bindings/**/*.{h,hh,cpp,cc}'", - "install": "exit 0", "lint": "jsgl --local . && gts check && clang-format --style file -n -Werror --glob='bindings/**/*.{h,hh,cpp,cc}'", "prepare": "npm run compile && npm run rebuild", "pretest:js-asan": "npm run compile && npm run build:asan", diff --git a/ts/test/test-no-build-scripts.ts b/ts/test/test-no-build-scripts.ts new file mode 100644 index 00000000..4aee2b12 --- /dev/null +++ b/ts/test/test-no-build-scripts.ts @@ -0,0 +1,14 @@ +import * as assert from 'assert'; +import * as fs from 'fs'; +import * as path from 'path'; + +describe('package manifest', () => { + it('declares no npm build lifecycle scripts (Yarn Berry YN0007)', () => { + const manifest = path.join(__dirname, '..', '..', 'package.json'); + const pkg = JSON.parse(fs.readFileSync(manifest, 'utf8')); + const scripts = pkg.scripts || {}; + const hooks = ['preinstall', 'install', 'postinstall']; + const present = hooks.filter(name => scripts[name] !== undefined); + assert.deepStrictEqual(present, []); + }); +}); From 855b6f4bd5bd949be2a5213075e1e6c174cd3535 Mon Sep 17 00:00:00 2001 From: Attila Szegedi Date: Thu, 2 Jul 2026 17:15:21 +0200 Subject: [PATCH 3/7] PROF-14694: Port OTEP-4947 thread-context writer from polarsignals/custom-labels (#347) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port OTEP-4947 thread-context writer from polarsignals/custom-labels Adds a Node.js writer for the OpenTelemetry Thread Local Context Record (OTEP-4947), ported from the in-development upstream at polarsignals/custom-labels (#16, #17). Native addon (bindings/otel-thread-ctx.cc/.hh): the writer, namespaced as dd::OtelThreadCtx::Init(exports) and called from binding.cc. The thread_local discovery symbol otel_thread_ctx_nodejs_v1 stays in extern "C" at file scope so a reader can find it by name in the dd_pprof.node dynsym table. Records use a flexible-array tail, are right-sized to the encoded payload (36-byte attrs_data floor, ×2 growth on append, 612-byte cap), and realloc-on-append updates the wrapper's record_ pointer in place so every async-context frame holding the same reference observes the new buffer. binding.gyp adds -mtls-dialect=gnu2 on x86_64 Linux (TLSDESC; arm64 needs no flag). Compiles across Node 18–26 (V8 ABI guards) and MSVC. TS layer (ts/src/otel-thread-ctx.ts), Linux-only with no-op stubs elsewhere: a ThreadContext class constructed with (traceId, spanId, attributes?) and re-installed per async-context fire. Instance methods enter()/run(fn) route through the prototype so only a real ThreadContext can enter the AsyncLocalStorage; appendAttributes(), isTruncated(), debugBytes(). Module-level getContext(), clearContext(), and getProcessContextAttributes(keys) — the frozen OTEP-4719 snapshot (schema version, uint8-key→name map, V8 layout constants) a reader needs. Surfaced on the package root as require('@datadog/pprof'). otelThreadCtx. Tests: a mocha suite (skipped on non-Linux) covering construction, wire encoding incl. multibyte UTF-8 truncation, the cap and isTruncated, in-place append vs realloc, async propagation, enter/run/clearContext lifecycle, getProcessContextAttributes shape, and a readelf check that the TLS symbol is exported correctly. A scripts/docker/ harness + `npm run test:docker` runs the full suite in a Linux container from any host. Verified 158 passing. --- binding.gyp | 15 +- bindings/binding.cc | 2 + bindings/otel-thread-ctx.cc | 668 ++++++++++++++++++++++++++ bindings/otel-thread-ctx.hh | 26 + package.json | 3 +- scripts/docker/Dockerfile | 14 + scripts/docker/run-in-docker.sh | 37 ++ ts/src/index.ts | 12 + ts/src/otel-thread-ctx.ts | 270 +++++++++++ ts/test/test-otel-thread-ctx.ts | 810 ++++++++++++++++++++++++++++++++ 10 files changed, 1854 insertions(+), 3 deletions(-) create mode 100644 bindings/otel-thread-ctx.cc create mode 100644 bindings/otel-thread-ctx.hh create mode 100644 scripts/docker/Dockerfile create mode 100755 scripts/docker/run-in-docker.sh create mode 100644 ts/src/otel-thread-ctx.ts create mode 100644 ts/test/test-otel-thread-ctx.ts diff --git a/binding.gyp b/binding.gyp index 3b650daf..c35d8e66 100644 --- a/binding.gyp +++ b/binding.gyp @@ -21,7 +21,8 @@ "bindings/binding.cc", "bindings/map-get.cc", "bindings/allocation-profile.cc", - "bindings/allocation-profile-node.cc" + "bindings/allocation-profile-node.cc", + "bindings/otel-thread-ctx.cc" ], "include_dirs": [ "bindings", @@ -46,7 +47,8 @@ "bindings/translate-time-profile.cc", "bindings/test/binding.cc", "bindings/allocation-profile.cc", - "bindings/allocation-profile-node.cc" + "bindings/allocation-profile-node.cc", + "bindings/otel-thread-ctx.cc" ], "include_dirs": [ "bindings", @@ -81,6 +83,15 @@ ["-Wno-deprecated-declarations"], "cflags_cc!": ["-std=gnu++14", "-std=gnu++1y", "-std=gnu++20" ], "cflags_cc": ["-std=gnu++2a"], + "conditions": [ + # -mtls-dialect=gnu2 forces TLSDESC on x86_64 so the + # otel_thread_ctx_nodejs_v1 symbol is reachable per the + # OTEP-4947 spec. On arm64 TLSDESC is the only dynamic + # model, so no flag is needed there. + ['target_arch == "x64"', { + "cflags": ["-mtls-dialect=gnu2"], + }], + ], } ], ["OS == 'mac'", diff --git a/bindings/binding.cc b/bindings/binding.cc index acbf99a0..68741dd8 100644 --- a/bindings/binding.cc +++ b/bindings/binding.cc @@ -19,6 +19,7 @@ #include #include "allocation-profile-node.hh" +#include "otel-thread-ctx.hh" #include "profilers/heap.hh" #include "profilers/wall.hh" #include "translate-time-profile.hh" @@ -53,5 +54,6 @@ NODE_MODULE_INIT(/* exports, module, context */) { dd::TimeProfileNodeView::Init(exports); dd::HeapProfiler::Init(exports); dd::WallProfiler::Init(exports); + dd::OtelThreadCtx::Init(exports); Nan::SetMethod(exports, "getNativeThreadId", GetNativeThreadId); } diff --git a/bindings/otel-thread-ctx.cc b/bindings/otel-thread-ctx.cc new file mode 100644 index 00000000..db76c16b --- /dev/null +++ b/bindings/otel-thread-ctx.cc @@ -0,0 +1,668 @@ +/* + * 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. + */ + +// Vendored from +// https://github.com/polarsignals/custom-labels/tree/otel-thread-ctx-wip/js/ +// (originally js/addon.cpp). Kept as a near-verbatim copy: edits should +// ideally land upstream first and be ported here, so the two stay in +// sync. We plan to drop this vendored copy once the upstream package is +// suitable to depend on directly. + +// Node.js writer for the OTEP-4947 Thread Local Context Record, adapted for +// the Node.js asynchronous context model. The record is wrapped in a JS +// object (CtxWrap) and stored in an AsyncLocalStorage instance; an +// out-of-process reader discovers it by walking the V8 isolate's +// ContinuationPreservedEmbedderData to the AsyncContextFrame (a JS Map), +// looking up the ALS instance as the key, reading the resulting CtxWrap, +// and finally the record it owns. + +#include "otel-thread-ctx.hh" + +#include +#include +#include + +#include +#include +#include +#include + +#include +#include +#include + +// Single thread-local read from outside the process via TLSDESC. It +// identifies, for the current V8 isolate's thread: +// +// - the address of the isolate's ContinuationPreservedEmbedderData slot +// (`cped_slot`), whose value V8 swaps as it switches between +// continuations. Reading `*cped_slot` yields the active +// AsyncContextFrame; no V8 internal symbol lookup is required on the +// reader side. +// - the AsyncLocalStorage instance the reader must look up inside that +// AsyncContextFrame map (`als_handle`), +// - that instance's JS identity hash (`als_identity_hash`), so the +// reader can restrict the lookup to a single hash bucket. +// - the (per-isolate) tagged address of the `undefined` singleton +// (`undefined_addr`). After looking up the value for our ALS key in +// the ACF map, the reader can compare against this to skip the +// JSObject / internal-field-0 dereference when no CtxWrap is +// currently attached; without it, a reader walking through undefined +// would have to rely on structural validation of the bytes at +// undefined+wrapped_object_offset to detect the absence. +// +// Layout is part of the reader ABI: see the README "Discovery contract" +// section and the static_asserts below. +extern "C" { +using v8::Global; +using v8::Object; + +struct otel_thread_ctx_nodejs_v1_t { + v8::internal::Address* cped_slot; // offset 0 + Global als_handle; // offset sizeof(void*); 1 V8 ptr + int als_identity_hash; // offset 2 * sizeof(void*); 4 + 4 pad + v8::internal::Address undefined_addr; // offset 3 * sizeof(void*); tagged +}; + +// MSVC doesn't understand __attribute__; visibility is irrelevant on +// Windows anyway since the OTEP-4947 reader contract is ELF-TLSDESC and +// only meaningful on Linux. +#if defined(__GNUC__) || defined(__clang__) +__attribute__((visibility("default"))) +#endif +thread_local otel_thread_ctx_nodejs_v1_t otel_thread_ctx_nodejs_v1; +} + +static_assert(sizeof(v8::Global) == sizeof(void*), + "Global must be exactly one pointer wide"); +static_assert(offsetof(otel_thread_ctx_nodejs_v1_t, cped_slot) == 0, + "cped_slot must be at offset 0"); +static_assert(offsetof(otel_thread_ctx_nodejs_v1_t, als_handle) == + sizeof(void*), + "als_handle must immediately follow cped_slot"); +static_assert(offsetof(otel_thread_ctx_nodejs_v1_t, als_identity_hash) == + 2 * sizeof(void*), + "als_identity_hash must immediately follow als_handle"); +static_assert(offsetof(otel_thread_ctx_nodejs_v1_t, undefined_addr) == + 3 * sizeof(void*), + "undefined_addr must follow als_identity_hash + padding"); + +namespace dd { +namespace { + +using node::ObjectWrap; +using v8::Array; +using v8::Context; +using v8::Function; +using v8::FunctionCallbackInfo; +using v8::FunctionTemplate; +using v8::Global; +using v8::Integer; +using v8::Isolate; +using v8::Local; +using v8::Object; +using v8::String; +using v8::Uint8Array; +using v8::Value; + +// OTEP-4947 record. The trailing `attrs_data` is a C99 flexible array +// member: the writer allocates one contiguous block of size +// `sizeof(OtelThreadCtxRecord) + attrs_data_size`, and the FAM gives the +// reader of this struct definition the right intuition — "there's +// variable-length data after the header" — while sizeof / offsetof still +// see only the 28-byte header. Field offsets are statically verified +// below. +struct OtelThreadCtxRecord { + uint8_t trace_id[16]; // offset 0 + uint8_t span_id[8]; // offset 16 + uint8_t valid; // offset 24 + uint8_t reserved; // offset 25 + uint16_t attrs_data_size; // offset 26 + uint8_t attrs_data[]; // offset 28; length is attrs_data_size +}; +static_assert(sizeof(OtelThreadCtxRecord) == 28, + "OTEP thread-ctx header must be exactly 28 bytes"); +static_assert(offsetof(OtelThreadCtxRecord, trace_id) == 0, "trace_id offset"); +static_assert(offsetof(OtelThreadCtxRecord, span_id) == 16, "span_id offset"); +static_assert(offsetof(OtelThreadCtxRecord, valid) == 24, "valid offset"); +static_assert(offsetof(OtelThreadCtxRecord, reserved) == 25, "reserved offset"); +static_assert(offsetof(OtelThreadCtxRecord, attrs_data_size) == 26, + "attrs_data_size offset"); +static_assert(offsetof(OtelThreadCtxRecord, attrs_data) == 28, + "attrs_data offset"); + +struct OtelThreadCtxRecordDeleter { + void operator()(OtelThreadCtxRecord* p) const noexcept { free(p); } +}; +using OwnedRecord = + std::unique_ptr; + +// Floor on the attrs_data capacity of a freshly allocated record. Sized so +// the total allocation is one 64-byte cache line — matching the OTEP-4947 +// "frugal writer" guidance ("a frugal writer may aim to keep the entire +// record under 64 bytes") — and giving small records some slack so the +// first few appends (if any) can be in-place. +constexpr size_t MIN_INITIAL_CAPACITY = 64 - sizeof(OtelThreadCtxRecord); + +// Upper bound on the attribute payload. Sized so the total record (28-byte +// header + attrs_data) stays under the OTEP-4947 recommended 640 bytes, +// which is the read-buffer ceiling for typical eBPF readers. Attributes +// that would push past this are silently dropped (with `truncated_` set on +// the wrapper) rather than the writer throwing — the OTEP treats the cap +// as best-effort. +constexpr size_t MAX_ATTRS_DATA_SIZE = 640 - sizeof(OtelThreadCtxRecord); + +// Wraps a heap-allocated OtelThreadCtxRecord. Lifetime is managed by V8 +// GC: when no JS code (or AsyncLocalStorage entry) holds a reference, the +// record is freed. +// +// Layout note for the reader: `record_` is private to C++ but its byte +// position within CtxWrap is part of the reader contract. It is the first +// field after the node::ObjectWrap base subobject. `capacity_` and +// `truncated_` sit after `record_` purely for the writer's own +// bookkeeping — the reader never touches them. +class CtxWrap : public ObjectWrap { + public: + ~CtxWrap() override; + static void Init(Local exports); + + CtxWrap(const CtxWrap&) = delete; + CtxWrap& operator=(const CtxWrap&) = delete; + CtxWrap(CtxWrap&&) = delete; + CtxWrap& operator=(CtxWrap&&) noexcept = delete; + + private: + static void New(const FunctionCallbackInfo& args); + static void DebugBytes(const FunctionCallbackInfo& args); + static void Append(const FunctionCallbackInfo& args); + static void IsTruncated(const FunctionCallbackInfo& args); + + // Encode the JS array at `attrs_val` into `out` as packed (key, len, value) + // entries. Same shape used by both New() and Append(). On a parse error + // (non-array, etc.) throws via `isolate` and returns false. On per-entry + // overflow against the 612-byte attrs_data cap, the entry is dropped, + // `*out_truncated` is set to true, and processing continues with the + // next entry (a smaller subsequent entry may still fit). + static bool EncodeAttrs(Isolate* isolate, + Local context, + Local attrs_val, + size_t existing_size, + std::vector* out, + bool* out_truncated); + + CtxWrap(OtelThreadCtxRecord* record, size_t capacity, bool truncated); + + // The three fields are kept in one access section because C++ leaves + // the relative layout of fields in different access controls + // implementation-defined. `record_` must come first — its offset + // within CtxWrap is part of the reader contract (see the + // static_assert below) — and is therefore `public`. The bookkeeping + // fields after it would normally be private, but the access change + // would let a conforming compiler reorder them in front of `record_`; + // exposing them publicly keeps everything in one ordering-stable + // block. Readers never touch them. + public: + OtelThreadCtxRecord* record_; + // attrs_data capacity in bytes of the record_ allocation. The total + // allocation is `sizeof(OtelThreadCtxRecord) + capacity_`. Always + // `record_->attrs_data_size <= capacity_ <= MAX_ATTRS_DATA_SIZE`. + size_t capacity_; + // Set to true (once, never cleared) if at any point in this record's + // lifetime — during New() or any subsequent Append() — at least one + // attribute had to be dropped because it would have pushed attrs_data + // past MAX_ATTRS_DATA_SIZE. + bool truncated_; +}; + +// Pin the offset of `record_` — the field the reader walks to from the +// JSObject's internal field 0. We document it as "the first field after +// the node::ObjectWrap base subobject", so equality with +// sizeof(node::ObjectWrap) is the invariant. `offsetof` on a non- +// standard-layout type (CtxWrap has private fields and inherits from +// ObjectWrap) is conditionally supported per the standard but accepted +// by every compiler this addon targets; suppress -Winvalid-offsetof so +// the static_assert compiles cleanly under strict warning flags. +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Winvalid-offsetof" +static_assert(offsetof(CtxWrap, record_) == sizeof(node::ObjectWrap), + "record_ must be the first field after the ObjectWrap base " + "subobject"); +#pragma GCC diagnostic pop + +CtxWrap::~CtxWrap() { + free(record_); +} + +CtxWrap::CtxWrap(OtelThreadCtxRecord* record, size_t capacity, bool truncated) + : record_(record), capacity_(capacity), truncated_(truncated) {} + +// Copy exactly `expected_bytes` bytes out of a JS Uint8Array (or subclass +// such as Buffer) into `out`. Returns false if the value isn't a +// Uint8Array or its length doesn't match. +bool CopyBytes(Local value, size_t expected_bytes, uint8_t* out) { + if (!value->IsUint8Array()) return false; + Local arr = value.As(); + if (arr->ByteLength() != expected_bytes) return false; + uint8_t* base = + static_cast(arr->Buffer()->GetBackingStore()->Data()) + + arr->ByteOffset(); + memcpy(out, base, expected_bytes); + return true; +} + +// Encode the JS array `attrs_val` (positional, index N = uint8 key N) into +// `*out` as packed `(key:u8, len:u8, value:u8[len])` entries. +// `existing_size` is the number of bytes already in any pre-existing +// record's attrs_data — used so the cap is enforced across the combined +// result. On a parse error (wrong type, etc.) throws and returns false. An +// entry whose encoding would push the combined size past MAX_ATTRS_DATA_SIZE +// is dropped (not encoded into `*out`), `*out_truncated` is set, and +// processing continues so a smaller subsequent entry may still fit. +bool CtxWrap::EncodeAttrs(Isolate* isolate, + Local context, + Local attrs_val, + size_t existing_size, + std::vector* out, + bool* out_truncated) { + if (attrs_val->IsUndefined() || attrs_val->IsNull()) return true; + if (!attrs_val->IsArray()) { + isolate->ThrowError( + "attributes must be an array indexed by key, or undefined"); + return false; + } + Local attrs = attrs_val.As(); + uint32_t n = attrs->Length(); + if (n > 256) { + isolate->ThrowError("attributes array length must not exceed 256"); + return false; + } + // Reserve a conservative upper bound; reallocations are cheap but + // unnecessary for the typical small attribute set. + out->reserve(out->size() + n * 4); + for (uint32_t i = 0; i < n; ++i) { + Local val_val; + if (!attrs->Get(context, i).ToLocal(&val_val)) return false; + // null / undefined / array holes mean "no value at this key index". + if (val_val->IsUndefined() || val_val->IsNull()) continue; + + Local v; + if (!val_val->ToString(context).ToLocal(&v)) return false; +#if NODE_MAJOR_VERSION >= 24 + int v_utf8_len = static_cast(v->Utf8LengthV2(isolate)); +#else + int v_utf8_len = v->Utf8Length(isolate); +#endif + // The on-the-wire val_len prefix is a uint8, so individual values + // longer than 255 UTF-8 bytes are silently truncated to 255. + int v_budget = v_utf8_len > 255 ? 255 : v_utf8_len; + + const size_t needed = 2u + static_cast(v_budget); + if (existing_size + out->size() + needed > MAX_ATTRS_DATA_SIZE) { + // Doesn't fit in the remaining budget; drop this entry and set the + // truncated flag. Smaller subsequent entries may still fit, so we + // continue rather than break. + *out_truncated = true; + continue; + } + + const size_t entry_off = out->size(); + out->resize(entry_off + needed); + (*out)[entry_off] = static_cast(i); + // WriteUtf8 returns the actual number of bytes written, which can be + // less than v_budget when the cap lands inside a multibyte codepoint + // — WriteUtf8 stops before writing a partial sequence. Use that count + // as the length prefix, and shrink the buffer back so the next entry + // starts at exactly the right offset. +#if NODE_MAJOR_VERSION >= 24 + int v_written = static_cast( + v->WriteUtf8V2(isolate, + reinterpret_cast(&(*out)[entry_off + 2]), + static_cast(v_budget), + String::WriteFlags::kNone)); +#else + int v_written = + v->WriteUtf8(isolate, + reinterpret_cast(&(*out)[entry_off + 2]), + v_budget, + nullptr, + String::NO_NULL_TERMINATION); +#endif + (*out)[entry_off + 1] = static_cast(v_written); + if (v_written < v_budget) { + out->resize(entry_off + 2u + static_cast(v_written)); + } + } + return true; +} + +void CtxWrap::New(const FunctionCallbackInfo& args) { + Isolate* isolate = args.GetIsolate(); + Local context = isolate->GetCurrentContext(); + + if (!args.IsConstructCall()) [[unlikely]] { + isolate->ThrowError("ThreadContext must be called with `new`"); + return; + } + if (args.Length() != 3) { + isolate->ThrowError( + "ThreadContext expects 3 arguments: traceId, spanId, attributes"); + return; + } + + // Validate IDs into a scratch header first; we copy into the final + // allocation once we know how much room the attrs payload needs. + uint8_t trace_id[16]; + uint8_t span_id[8]; + if (!CopyBytes(args[0], 16, trace_id)) { + isolate->ThrowError("traceId must be a 16-byte Uint8Array"); + return; + } + if (!CopyBytes(args[1], 8, span_id)) { + isolate->ThrowError("spanId must be an 8-byte Uint8Array"); + return; + } + + // Encode attributes into a transient buffer first so we can size the + // record allocation correctly. The 612-byte attrs_data cap mirrors the + // OTEP-recommended 640-byte total-record ceiling (which exists for + // eBPF readers that copy the record into a fixed-size kernel buffer); + // entries that wouldn't fit are silently dropped and recorded via the + // truncated flag below. + std::vector attrs_buf; + bool truncated = false; + if (!EncodeAttrs(isolate, context, args[2], 0, &attrs_buf, &truncated)) { + return; + } + + // Pick the initial attrs_data capacity. Small records get a 64-byte + // floor so the first append is likely to fit in-place; larger records + // are sized exactly to what's needed (the extra slack a doubling + // strategy would buy is dwarfed by the existing memory footprint and + // doesn't change the geometric-growth amortized cost of subsequent + // appends). + size_t capacity = std::max(attrs_buf.size(), MIN_INITIAL_CAPACITY); + const size_t total = sizeof(OtelThreadCtxRecord) + capacity; + OwnedRecord record(static_cast(calloc(1, total))); + if (!record) { + isolate->ThrowError("allocation failed"); + return; + } + memcpy(record->trace_id, trace_id, sizeof(trace_id)); + memcpy(record->span_id, span_id, sizeof(span_id)); + record->attrs_data_size = static_cast(attrs_buf.size()); + if (!attrs_buf.empty()) { + memcpy(record->attrs_data, attrs_buf.data(), attrs_buf.size()); + } + + // OTEP-4947 publication protocol: order the `valid = 1` store after every + // other field write, with an `atomic_signal_fence` to pin that ordering at + // compile time and a volatile store so the compiler can't fold or hoist + // the write. The signal fence + volatile store is also the protocol used + // by Append() in its in-place path. + std::atomic_signal_fence(std::memory_order_release); + *reinterpret_cast(&record->valid) = 1; + + CtxWrap* self = new CtxWrap(record.release(), capacity, truncated); + self->Wrap(args.This()); + args.GetReturnValue().Set(args.This()); +} + +// Append entries to the active record. Either modifies the record in place +// (if the appended bytes fit in the current allocation's slack) or +// reallocates to a larger one (geometrically), keeping invariant +// `record_->attrs_data_size <= capacity_`. +void CtxWrap::Append(const FunctionCallbackInfo& args) { + Isolate* isolate = args.GetIsolate(); + Local context = isolate->GetCurrentContext(); + + CtxWrap* self = ObjectWrap::Unwrap(args.This()); + if (!self) { + isolate->ThrowError("not a ThreadContext"); + return; + } + if (args.Length() != 1) { + isolate->ThrowError("append expects 1 argument: attributes"); + return; + } + + const size_t current_used = self->record_->attrs_data_size; + std::vector appended; + bool truncated = false; + if (!EncodeAttrs( + isolate, context, args[0], current_used, &appended, &truncated)) { + return; + } + if (truncated) self->truncated_ = true; + + // Nothing to append — either the input array was empty, every slot was + // null/undefined, or every entry was dropped because the record is + // already at the cap. + if (appended.empty()) return; + + const size_t new_used = current_used + appended.size(); + // EncodeAttrs already enforced the cap; new_used <= MAX_ATTRS_DATA_SIZE. + + if (new_used <= self->capacity_) { + // In-place: write the new entries past the current attrs_data_size, + // then bump attrs_data_size with a release fence + volatile store so + // the content writes are visible before the size store from the + // compiler's perspective. + // + // No valid=0/valid=1 dance: this is an append-only operation. Bytes + // past attrs_data_size aren't observable by the reader, and + // attrs_data_size *is* the publication boundary. A reader firing + // mid-append sees either the old size (old extent, ignores the + // half-written tail) or the new size (full new extent, all bytes + // written). Either is consistent. + memcpy(&self->record_->attrs_data[current_used], + appended.data(), + appended.size()); + std::atomic_signal_fence(std::memory_order_release); + *reinterpret_cast(&self->record_->attrs_data_size) = + static_cast(new_used); + return; + } + + // Doesn't fit. Reallocate with geometric growth with cap. + size_t new_cap = + std::min(std::max(self->capacity_ * 2, new_used), MAX_ATTRS_DATA_SIZE); + + const size_t total = sizeof(OtelThreadCtxRecord) + new_cap; + OwnedRecord new_rec(static_cast(calloc(1, total))); + if (!new_rec) { + isolate->ThrowError("allocation failed"); + return; + } + // Copy the existing record (header + already-written attrs_data). + memcpy( + new_rec.get(), self->record_, sizeof(OtelThreadCtxRecord) + current_used); + // Append the new entries and update attrs_data_size. + memcpy(&new_rec->attrs_data[current_used], appended.data(), appended.size()); + new_rec->attrs_data_size = static_cast(new_used); + // The copy should've preserved valid=1 from the source record. + assert(new_rec->valid == 1); + + // Publish: the pointer swap is the atomic boundary the reader sees. The + // first fence keeps the new_rec content writes ordered before the pointer + // store from the compiler's perspective. The second fence prevents free() + // from being hoisted above the pointer swap — without it, a reader stopped + // between a reordered free() and the not-yet-completed swap would follow + // self->record_ into freed memory. OTEP signal-handler semantics (the + // writer is stopped during reads) take care of CPU-side ordering and make + // immediate freeing of the old record safe. + std::atomic_signal_fence(std::memory_order_release); + OtelThreadCtxRecord* old_rec = self->record_; + self->record_ = new_rec.release(); + self->capacity_ = new_cap; + std::atomic_signal_fence(std::memory_order_acq_rel); + free(old_rec); +} + +// Returns true if any attribute was ever dropped from this wrapper's +// record because it would have pushed attrs_data past the cap — set during +// CtxWrap::New() if the initial set didn't fit, or by any subsequent +// CtxWrap::Append() call. +void CtxWrap::IsTruncated(const FunctionCallbackInfo& args) { + CtxWrap* self = ObjectWrap::Unwrap(args.This()); + if (!self) { + args.GetIsolate()->ThrowError("not a ThreadContext"); + return; + } + args.GetReturnValue().Set(self->truncated_); +} + +// Debug accessor: returns the record (header + attrs_data) as a fresh +// Uint8Array sized to the actual on-the-wire length. Not part of the stable +// API; intended for tests and out-of-process-reader development. +void CtxWrap::DebugBytes(const FunctionCallbackInfo& args) { + Isolate* isolate = args.GetIsolate(); + CtxWrap* self = ObjectWrap::Unwrap(args.This()); + if (!self) { + isolate->ThrowError("not a ThreadContext"); + return; + } + const size_t total = + sizeof(OtelThreadCtxRecord) + self->record_->attrs_data_size; + Local buf = v8::ArrayBuffer::New(isolate, total); + memcpy(buf->GetBackingStore()->Data(), self->record_, total); + args.GetReturnValue().Set(Uint8Array::New(buf, 0, total)); +} + +void CtxWrap::Init(Local exports) { + Isolate* isolate = Isolate::GetCurrent(); + Local context = isolate->GetCurrentContext(); + + Local tpl = FunctionTemplate::New(isolate, New); + tpl->SetClassName(String::NewFromUtf8Literal(isolate, "ThreadContext")); + tpl->InstanceTemplate()->SetInternalFieldCount(1); + + tpl->PrototypeTemplate()->Set( + String::NewFromUtf8Literal(isolate, "debugBytes"), + FunctionTemplate::New(isolate, DebugBytes)); + tpl->PrototypeTemplate()->Set( + String::NewFromUtf8Literal(isolate, "appendAttributes"), + FunctionTemplate::New(isolate, Append)); + tpl->PrototypeTemplate()->Set( + String::NewFromUtf8Literal(isolate, "isTruncated"), + FunctionTemplate::New(isolate, IsTruncated)); + + Local constructor = tpl->GetFunction(context).ToLocalChecked(); + exports + ->Set(context, + String::NewFromUtf8Literal(isolate, "threadContext"), + constructor) + .FromJust(); +} + +// Reset the Global and the cped_slot pointer before the isolate +// is torn down. The Global lives in thread-local storage and its +// destructor only runs at thread exit, which on the main thread happens +// after the isolate is already gone — causing a segfault. Registering +// this as a per-isolate cleanup hook the first time StoreAls is called +// keeps the handle safely scoped to the isolate. +void ResetDiscoveryStruct(void* /*arg*/) { + otel_thread_ctx_nodejs_v1.cped_slot = nullptr; + otel_thread_ctx_nodejs_v1.als_handle.Reset(); + otel_thread_ctx_nodejs_v1.als_identity_hash = 0; + otel_thread_ctx_nodejs_v1.undefined_addr = 0; +} + +void StoreAls(const FunctionCallbackInfo& args) { + static thread_local bool cleanup_registered = false; + + Isolate* isolate = args.GetIsolate(); + if (!args[0]->IsObject()) { + isolate->ThrowError("First argument must be the AsyncLocalStorage object."); + return; + } + Local obj = args[0].As(); + otel_thread_ctx_nodejs_v1.als_identity_hash = obj->GetIdentityHash(); + otel_thread_ctx_nodejs_v1.als_handle = Global(isolate, obj); +#if NODE_MAJOR_VERSION >= 22 + otel_thread_ctx_nodejs_v1.cped_slot = + reinterpret_cast( + reinterpret_cast(isolate) + + v8::internal::Internals::kContinuationPreservedEmbedderDataOffset); +#else + // Node < 22 lacks ContinuationPreservedEmbedderData entirely (and the + // associated V8 internal offset). The TS layer refuses to install the + // hook on these versions via asyncContextFrameError, so StoreAls is + // never called from JS — this null assignment is just here so the + // addon compiles on the older Node versions the package supports. + otel_thread_ctx_nodejs_v1.cped_slot = nullptr; +#endif + // Cache the per-isolate undefined singleton's tagged address. Undefined + // is a read-only-roots heap object, never moves, so a cached numeric + // address is fine — no Global<> tracking needed. + otel_thread_ctx_nodejs_v1.undefined_addr = + reinterpret_cast(*v8::Undefined(isolate)); + if (!cleanup_registered) { + node::AddEnvironmentCleanupHook(isolate, ResetDiscoveryStruct, nullptr); + cleanup_registered = true; + } +} + +// Without a function that explicitly reads the TLS variable, on x86 the +// linker may strip the symbol from the dynamic symbol table even though +// `nm` still reports it, breaking out-of-process discovery. +void GetStoredAlsHash(const FunctionCallbackInfo& args) { + Isolate* isolate = args.GetIsolate(); + args.GetReturnValue().Set( + Integer::New(isolate, otel_thread_ctx_nodejs_v1.als_identity_hash)); +} + +// V8 layout constants captured at addon-compile time from the same V8 +// headers Node bundles. Published via the discovery contract so an +// out-of-process reader can decode our wrapper / V8's internal hashmap +// layout without doing its own V8-internal-symbol lookups for the +// pointer-compression / sandbox state. +#if NODE_MAJOR_VERSION >= 22 +constexpr int WRAPPED_OBJECT_OFFSET = + v8::internal::Internals::kJSObjectHeaderSize + + v8::internal::Internals::kEmbedderDataSlotExternalPointerOffset; +#else +// Node < 22 lacks kEmbedderDataSlotExternalPointerOffset. The discovery +// contract isn't usable on these versions (no ContinuationPreservedEmbedderData +// either — see StoreAls), so this value is published only to keep the +// addon's exported surface consistent across Node majors. A would-be +// reader cannot reach a live record through it. +constexpr int WRAPPED_OBJECT_OFFSET = 0; +#endif +constexpr int TAGGED_SIZE = v8::internal::kApiTaggedSize; + +} // namespace + +void OtelThreadCtx::Init(Local exports) { + CtxWrap::Init(exports); + NODE_SET_METHOD(exports, "otelThreadCtxStoreAls", StoreAls); + NODE_SET_METHOD(exports, "otelThreadCtxGetStoredAlsHash", GetStoredAlsHash); + + Isolate* isolate = Isolate::GetCurrent(); + Local ctx = isolate->GetCurrentContext(); + exports + ->Set(ctx, + String::NewFromUtf8Literal(isolate, + "otelThreadCtxWrappedObjectOffset"), + Integer::New(isolate, WRAPPED_OBJECT_OFFSET)) + .FromJust(); + exports + ->Set(ctx, + String::NewFromUtf8Literal(isolate, "otelThreadCtxTaggedSize"), + Integer::New(isolate, TAGGED_SIZE)) + .FromJust(); +} + +} // namespace dd diff --git a/bindings/otel-thread-ctx.hh b/bindings/otel-thread-ctx.hh new file mode 100644 index 00000000..c8e7470b --- /dev/null +++ b/bindings/otel-thread-ctx.hh @@ -0,0 +1,26 @@ +/* + * 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. + */ + +#pragma once + +#include + +namespace dd { +class OtelThreadCtx { + public: + static void Init(v8::Local exports); +}; +} // namespace dd diff --git a/package.json b/package.json index 1933cd78..35affbce 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,8 @@ "test:js-tsan": "LD_PRELOAD=`gcc -print-file-name=libtsan.so` mocha out/test/test-*.js", "test:js-valgrind": "valgrind --leak-check=full mocha out/test/test-*.js", "test:js": "nyc mocha -r source-map-support/register out/test/test-*.js", - "test": "npm run test:js" + "test": "npm run test:js", + "test:docker": "./scripts/docker/run-in-docker.sh" }, "author": { "name": "Google Inc." diff --git a/scripts/docker/Dockerfile b/scripts/docker/Dockerfile new file mode 100644 index 00000000..b130849c --- /dev/null +++ b/scripts/docker/Dockerfile @@ -0,0 +1,14 @@ +# Image for running this project's test suite on Linux from a non-Linux dev +# machine. The native addon is built per-architecture inside the container; +# node-gyp needs python3 and a C++ toolchain. Node 24 is used so all of the +# OTEP-4947 thread-context tests run without needing to pass +# --experimental-async-context-frame. +FROM node:24-bookworm + +RUN apt-get update -qq \ + && apt-get install -y -qq --no-install-recommends \ + python3 \ + build-essential \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /tmp/work diff --git a/scripts/docker/run-in-docker.sh b/scripts/docker/run-in-docker.sh new file mode 100755 index 00000000..099549da --- /dev/null +++ b/scripts/docker/run-in-docker.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +# Build the test image (idempotent; cached after the first run) and run the +# project's test suite against the working tree inside it. The tree is mounted +# read-only and copied to a writable scratch dir inside the container, so the +# host repo is never modified (no stray node_modules/, build/, out/). + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)" +IMAGE_TAG="pprof-nodejs-test:latest" + +if ! command -v docker >/dev/null 2>&1; then + echo "docker not found in PATH; install Docker Desktop / colima / podman-with-docker-alias" >&2 + exit 1 +fi + +if ! docker info >/dev/null 2>&1; then + echo "docker daemon not reachable; is it running?" >&2 + exit 1 +fi + +echo "==> building $IMAGE_TAG (cached after first run)" +docker build -q -t "$IMAGE_TAG" "$SCRIPT_DIR" >/dev/null + +echo "==> running tests" +exec docker run --rm \ + -v "$REPO_DIR":/work:ro \ + "$IMAGE_TAG" \ + bash -c ' + set -euo pipefail + cp -R /work/. /tmp/work/ + # Drop any host-built artifacts so we get a clean build inside. + rm -rf /tmp/work/node_modules /tmp/work/build /tmp/work/out + npm install --no-audit --no-fund + npm test + ' diff --git a/ts/src/index.ts b/ts/src/index.ts index 73e85779..b4d35efe 100644 --- a/ts/src/index.ts +++ b/ts/src/index.ts @@ -16,6 +16,7 @@ import {writeFileSync} from 'fs'; import * as heapProfiler from './heap-profiler'; +import * as otelThreadCtxModule from './otel-thread-ctx'; import {encodeSync} from './profile-encoder'; import * as timeProfiler from './time-profiler'; export { @@ -57,6 +58,17 @@ export const heap = { CallbackMode: heapProfiler.CallbackMode, }; +// Writer for the OpenTelemetry Thread Local Context Record (OTEP-4947). +// Linux + AsyncContextFrame (Node 22 with --experimental-async-context-frame, +// Node 24+ by default) only; degrades to no-ops on other platforms / Node +// versions. +export const otelThreadCtx = { + ThreadContext: otelThreadCtxModule.ThreadContext, + getContext: otelThreadCtxModule.getContext, + clearContext: otelThreadCtxModule.clearContext, + getProcessContextAttributes: otelThreadCtxModule.getProcessContextAttributes, +}; + // If loaded with --require, start profiling. if (module.parent && module.parent.id === 'internal/preload') { time.start({}); diff --git a/ts/src/otel-thread-ctx.ts b/ts/src/otel-thread-ctx.ts new file mode 100644 index 00000000..316e1505 --- /dev/null +++ b/ts/src/otel-thread-ctx.ts @@ -0,0 +1,270 @@ +/* + * 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. + */ + +// Vendored from https://github.com/polarsignals/custom-labels/tree/otel-thread-ctx-wip/js/ +// (originally js/index.js + js/index.d.ts, merged into TypeScript). Kept +// as a near-verbatim copy: edits should ideally land upstream first and +// be ported here, so the two stay in sync. We plan to drop this vendored +// copy once the upstream package is suitable to depend on directly. + +// Node.js writer for the OpenTelemetry Thread Local Context Record +// (OTEP-4947), discoverable from an out-of-process reader via the +// `otel_thread_ctx_nodejs_v1` thread-local symbol exported by +// `dd_pprof.node`. +// +// Linux only; on other platforms the exported functions degrade to no-ops. + +import {join} from 'path'; +import {AsyncLocalStorage} from 'node:async_hooks'; + +/** + * OTEP-4719 process-context attributes corresponding to a particular + * key list. Spread this into whatever attribute map the application + * hands to its OTEP-4719 process-context publisher. + */ +export interface ProcessContextAttributes { + readonly 'threadlocal.schema_version': 'nodejs_v1_dev'; + readonly 'threadlocal.attribute_key_map': readonly string[]; + readonly 'threadlocal.wrapped_object_offset': number; + readonly 'threadlocal.tagged_size': number; +} + +/** + * A thread-context record. Construct with `new ThreadContext(...)`; install + * via the {@link enter} or {@link run} instance methods. The underlying + * native record is GC-owned: when no JS or async-context-frame reference + * survives, it's freed. + * + * `appendAttributes` mutates the context's record in place. Because every + * async-context frame that holds the same `ThreadContext` reference observes + * the same native record buffer, an append is visible across all those + * frames even when the reallocate path runs (the context's internal + * pointer is updated, the JS object is not replaced). + */ +export interface ThreadContext { + appendAttributes( + attributes: Array | undefined, + ): void; + isTruncated(): boolean; + /** Debug-only: returns the on-the-wire record bytes. Not stable. */ + debugBytes(): Uint8Array; + + /** + * Attach this context to the current async-context frame (and every + * frame derived from it until the frame ends or {@link clearContext} + * detaches it). Re-installing the same context reference is cheap (no + * allocation); per-span caching of the context on the caller side is + * the intended usage pattern. + * + * On non-Linux platforms this is a no-op. + */ + enter(): void; + + /** + * Attach this context for the duration of `fn`. Equivalent to + * `als.run(this, fn)` — after `fn` returns, the previous context is + * restored. Returns whatever `fn` returns; if `fn` returns a Promise, + * the same Promise is propagated. On non-Linux platforms simply + * invokes `fn`. + */ + run(fn: () => T): T; +} + +/** + * Constructor for {@link ThreadContext}. On non-Linux platforms, returns a + * no-op instance whose methods do nothing — the OTEP-4947 reader + * contract is ELF-TLSDESC, only meaningful on Linux. + */ +export interface ThreadContextCtor { + new ( + traceId: Uint8Array, + spanId: Uint8Array, + attributes?: Array, + ): ThreadContext; + readonly prototype: ThreadContext; +} + +interface Addon { + threadContext: ThreadContextCtor; + otelThreadCtxStoreAls(als: AsyncLocalStorage): void; + otelThreadCtxGetStoredAlsHash(): number; + otelThreadCtxWrappedObjectOffset: number; + otelThreadCtxTaggedSize: number; +} + +const SCHEMA_VERSION = 'nodejs_v1_dev'; + +// V8 layout constants the addon captured from the V8 headers Node bundles. +// On non-Linux these fall back to values matching Node's standard build +// (no V8 pointer compression, no sandbox); the reader is Linux-only per +// the OTEP anyway, so the fallbacks just keep processContextAttributes +// consistent in shape. +let WRAPPED_OBJECT_OFFSET = 24; +let TAGGED_SIZE = 8; + +/** {@inheritDoc ThreadContextCtor} */ +export let ThreadContext: ThreadContextCtor; + +/** + * Returns the {@link ThreadContext} currently attached to the active + * async-context frame, or `undefined` if none is. + */ +export let getContext: () => ThreadContext | undefined; + +/** + * Detach any {@link ThreadContext} from the current async-context frame. + * Idempotent when no context is attached. On non-Linux platforms this is + * a no-op. + */ +export let clearContext: () => void; + +// Debug accessor (not part of the stable API; for tests / reader dev). +export let _currentRecordBytes: () => Uint8Array | undefined = () => undefined; + +if (process.platform === 'linux') { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const findBinding = require('node-gyp-build'); + const addon: Addon = findBinding(join(__dirname, '..', '..')); + WRAPPED_OBJECT_OFFSET = addon.otelThreadCtxWrappedObjectOffset; + TAGGED_SIZE = addon.otelThreadCtxTaggedSize; + + ThreadContext = addon.threadContext; + + let als: AsyncLocalStorage | undefined; + + function asyncContextFrameError(): string | undefined { + const [major] = process.versions.node.split('.').map(Number); + if (process.execArgv.includes('--no-async-context-frame')) { + return 'Node explicitly launched with --no-async-context-frame'; + } + if (major >= 24) return undefined; + if (process.execArgv.includes('--experimental-async-context-frame')) { + return undefined; + } + if (major >= 22) { + return 'Node versions prior to v24 must be launched with --experimental-async-context-frame'; + } + return 'Node major versions prior to v22 do not support the feature at all'; + } + + function ensureHook(): AsyncLocalStorage { + if (als) return als; + const err = asyncContextFrameError(); + if (err) { + throw new Error( + `otel thread-ctx writer requires async_context_frame support, which is unavailable: ${err}.`, + ); + } + als = new AsyncLocalStorage(); + addon.otelThreadCtxStoreAls(als); + return als; + } + + getContext = function (): ThreadContext | undefined { + return als ? als.getStore() : undefined; + }; + + // Idempotent: clearing when the hook hasn't been installed (no prior + // enter / run on a ThreadContext) is a no-op. + clearContext = function (): void { + if (!als) return; + als.enterWith(undefined as unknown as ThreadContext); + }; + + // Install the active-context channel on the ThreadContext prototype so + // the only way to push a ThreadContext into our AsyncLocalStorage is + // via the context itself — callers can't poison the ALS with an + // arbitrary object. + ThreadContext.prototype.enter = function (this: ThreadContext): void { + ensureHook().enterWith(this); + }; + ThreadContext.prototype.run = function ( + this: ThreadContext, + fn: () => T, + ): T { + return ensureHook().run(this, fn); + }; + + _currentRecordBytes = function (): Uint8Array | undefined { + if (!als) return undefined; + const context = als.getStore(); + return context ? context.debugBytes() : undefined; + }; +} else { + // Non-Linux degradation. The writer's reader contract is ELF-TLSDESC, + // meaningful only on Linux; on other platforms we still want the API + // to be callable so consumers don't have to gate every call site — + // construction succeeds but produces an inert context, and the + // enter/run/clearContext entry points don't wire anything into + // AsyncLocalStorage. + class NoopThreadContext implements ThreadContext { + appendAttributes(): void {} + isTruncated(): boolean { + return false; + } + debugBytes(): Uint8Array { + return new Uint8Array(0); + } + enter(): void {} + run(fn: () => T): T { + return fn(); + } + } + ThreadContext = NoopThreadContext as ThreadContextCtor; + getContext = function (): undefined { + return undefined; + }; + clearContext = function (): void {}; +} + +/** + * Returns the OTEP-4719 process-context attributes the caller should + * publish so an out-of-process reader can decode the on-the-wire uint8 + * key indexes back to attribute names. The supplied `keys` array is the + * same string list the caller writes into the positional `attributes` + * argument of {@link ThreadContext}: index N here is the uint8 key index + * N in each record. + * + * `keys` is validated: must be a string array of length ≤ 256 with no + * duplicates. + */ +export function getProcessContextAttributes( + keys: string[], +): ProcessContextAttributes { + if (!Array.isArray(keys)) { + throw new TypeError('keys must be an array of attribute names'); + } + if (keys.length > 256) { + throw new RangeError('keys array exceeds 256 entries'); + } + const seen = new Set(); + for (let i = 0; i < keys.length; ++i) { + const name = keys[i]; + if (typeof name !== 'string') { + throw new TypeError('every key must be a string'); + } + if (seen.has(name)) { + throw new Error(`duplicate key name at index ${i}: ${name}`); + } + seen.add(name); + } + return Object.freeze({ + 'threadlocal.schema_version': SCHEMA_VERSION, + 'threadlocal.attribute_key_map': Object.freeze(keys.slice()), + 'threadlocal.wrapped_object_offset': WRAPPED_OBJECT_OFFSET, + 'threadlocal.tagged_size': TAGGED_SIZE, + }) as ProcessContextAttributes; +} diff --git a/ts/test/test-otel-thread-ctx.ts b/ts/test/test-otel-thread-ctx.ts new file mode 100644 index 00000000..adad1841 --- /dev/null +++ b/ts/test/test-otel-thread-ctx.ts @@ -0,0 +1,810 @@ +/* + * 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. + */ + +// Vendored from https://github.com/polarsignals/custom-labels/tree/otel-thread-ctx-wip/js/test/ +// (originally js/test/test.js, ported to TypeScript). Kept as a +// near-verbatim copy: edits should ideally land upstream first and be +// ported here, so the two stay in sync. We plan to drop this vendored +// copy once the upstream package is suitable to depend on directly. + +// Tests intentionally use array holes to verify the writer's positional +// attribute encoding (where a hole means "no value at this key index"). +/* eslint-disable no-sparse-arrays */ + +import assert from 'assert'; +import {strict as strictAssert} from 'assert'; +import {spawnSync} from 'node:child_process'; +import {existsSync} from 'node:fs'; +import {join} from 'node:path'; + +import { + ThreadContext, + getContext, + clearContext, + getProcessContextAttributes, + _currentRecordBytes, +} from '../src/otel-thread-ctx'; + +// Helpers bridging the old positional-attrs test shape to the new +// ThreadContext-first API. +interface PosOpts { + traceId: Uint8Array; + spanId: Uint8Array; + attributes?: Array; +} +function tcRun(fn: () => T, opts: PosOpts): T { + return new ThreadContext(opts.traceId, opts.spanId, opts.attributes).run(fn); +} +function tcEnter(opts: PosOpts): void { + new ThreadContext(opts.traceId, opts.spanId, opts.attributes).enter(); +} +function tcAppend( + attributes: Array | undefined, +): void { + getContext()!.appendAttributes(attributes); +} +function tcIsTruncated(): boolean { + return getContext()?.isTruncated() ?? false; +} + +const isLinux = process.platform === 'linux'; +// AsyncContextFrame (the writer's discovery substrate) is opt-in on Node +// 22/23 (via --experimental-async-context-frame) and on by default in +// Node 24+ (disable-able via --no-async-context-frame). The TS layer +// refuses to install the hook when ACF isn't available, so the entire +// describe block is skipped in that case. Mirrors the source-side +// asyncContextFrameError logic. +const isAsyncContextFrameAvailable = (() => { + if (process.execArgv.includes('--no-async-context-frame')) return false; + const major = Number(process.versions.node.split('.')[0]); + if (major >= 24) return true; + if (major >= 22) { + return process.execArgv.includes('--experimental-async-context-frame'); + } + return false; +})(); + +// Returns a plain Uint8Array (not a Buffer) so assert.deepStrictEqual against +// other Uint8Arrays — including the one the addon returns — succeeds. +function bytesFromHex(hex: string): Uint8Array { + return Uint8Array.from(Buffer.from(hex, 'hex')); +} + +const TRACE_ID_BYTES = bytesFromHex('0102030405060708090a0b0c0d0e0f10'); +const SPAN_ID_BYTES = bytesFromHex('1112131415161718'); + +interface Header { + traceId: Uint8Array; + spanId: Uint8Array; + valid: number; + reserved: number; + attrsDataSize: number; +} + +function decodeHeader(bytes: Uint8Array): Header { + strictAssert.ok( + bytes.length >= 28, + `record must be at least 28 bytes, got ${bytes.length}`, + ); + const attrsDataSize = bytes[26] | (bytes[27] << 8); + strictAssert.equal( + bytes.length, + 28 + attrsDataSize, + `record length (${bytes.length}) must equal 28 + attrs_data_size (${attrsDataSize})`, + ); + return { + traceId: bytes.slice(0, 16), + spanId: bytes.slice(16, 24), + valid: bytes[24], + reserved: bytes[25], + attrsDataSize, + }; +} + +// Returns the attribute payload as a positional sparse array, mirroring the +// writer's input shape: index N is the value for uint8 key index N on the +// wire; unset slots are array holes. +function decodeAttrs(bytes: Uint8Array): Array { + const hdr = decodeHeader(bytes); + const out: Array = []; + let i = 28; + const end = i + hdr.attrsDataSize; + while (i < end) { + const idx = bytes[i++]; + const len = bytes[i++]; + out[idx] = Buffer.from(bytes.slice(i, i + len)).toString('utf8'); + i += len; + } + strictAssert.equal( + i, + end, + 'attrs payload must be exactly attrsDataSize bytes', + ); + return out; +} + +function captureBytes(opts: { + traceId: Uint8Array; + spanId: Uint8Array; + attributes?: Array; +}): Uint8Array { + let bytes: Uint8Array | undefined; + tcRun(() => { + bytes = _currentRecordBytes(); + }, opts); + return bytes as Uint8Array; +} + +(isLinux && isAsyncContextFrameAvailable ? describe : describe.skip)( + 'OTEP-4947 thread context (Linux-only)', + () => { + describe('ThreadContext construction', () => { + it('accepts Uint8Array trace and span IDs', () => { + const bytes = captureBytes({ + traceId: TRACE_ID_BYTES, + spanId: SPAN_ID_BYTES, + }); + const hdr = decodeHeader(bytes); + strictAssert.deepEqual(hdr.traceId, TRACE_ID_BYTES); + strictAssert.deepEqual(hdr.spanId, SPAN_ID_BYTES); + strictAssert.equal(hdr.valid, 1); + strictAssert.equal(hdr.reserved, 0); + strictAssert.equal(hdr.attrsDataSize, 0); + }); + + it('accepts Buffer (Uint8Array subclass) trace and span IDs', () => { + const bytes = captureBytes({ + traceId: Buffer.from(TRACE_ID_BYTES), + spanId: Buffer.from(SPAN_ID_BYTES), + }); + const hdr = decodeHeader(bytes); + strictAssert.deepEqual(hdr.traceId, TRACE_ID_BYTES); + strictAssert.deepEqual(hdr.spanId, SPAN_ID_BYTES); + }); + + it('rejects wrong-length traceId', () => { + strictAssert.throws( + () => + captureBytes({traceId: new Uint8Array(8), spanId: SPAN_ID_BYTES}), + /traceId must be/, + ); + }); + + it('rejects wrong-length spanId', () => { + strictAssert.throws( + () => + captureBytes({traceId: TRACE_ID_BYTES, spanId: new Uint8Array(4)}), + /spanId must be/, + ); + }); + + it('rejects non-Uint8Array traceId', () => { + strictAssert.throws( + () => + captureBytes({ + traceId: 'a'.repeat(32) as unknown as Uint8Array, + spanId: SPAN_ID_BYTES, + }), + /traceId must be/, + ); + }); + }); + + describe('attribute encoding', () => { + it('leaves attrs_data empty when no attributes are provided', () => { + const bytes = captureBytes({ + traceId: TRACE_ID_BYTES, + spanId: SPAN_ID_BYTES, + }); + strictAssert.equal(decodeHeader(bytes).attrsDataSize, 0); + }); + + it('encodes attributes by position', () => { + const bytes = captureBytes({ + traceId: TRACE_ID_BYTES, + spanId: SPAN_ID_BYTES, + attributes: ['GET', '/api/v1/widgets'], + }); + strictAssert.deepEqual(decodeAttrs(bytes), ['GET', '/api/v1/widgets']); + }); + + it('skips null and undefined slots', () => { + const bytes = captureBytes({ + traceId: TRACE_ID_BYTES, + spanId: SPAN_ID_BYTES, + attributes: ['zero', null, undefined, 'three'], + }); + strictAssert.deepEqual(decodeAttrs(bytes), ['zero', , , 'three']); + }); + + it('skips trailing array holes', () => { + const attributes: Array = []; + attributes[5] = 'five'; + const bytes = captureBytes({ + traceId: TRACE_ID_BYTES, + spanId: SPAN_ID_BYTES, + attributes, + }); + strictAssert.deepEqual(decodeAttrs(bytes), [, , , , , 'five']); + }); + + it('coerces non-string values via toString', () => { + const bytes = captureBytes({ + traceId: TRACE_ID_BYTES, + spanId: SPAN_ID_BYTES, + attributes: [42 as unknown as string, true as unknown as string], + }); + strictAssert.deepEqual(decodeAttrs(bytes), ['42', 'true']); + }); + + it('truncates values longer than 255 bytes to 255', () => { + const long = 'x'.repeat(300); + const bytes = captureBytes({ + traceId: TRACE_ID_BYTES, + spanId: SPAN_ID_BYTES, + attributes: [long], + }); + strictAssert.deepEqual(decodeAttrs(bytes), ['x'.repeat(255)]); + }); + + it('does not split a multibyte UTF-8 codepoint at the truncation boundary', () => { + const euro = '€'; + const bytes = captureBytes({ + traceId: TRACE_ID_BYTES, + spanId: SPAN_ID_BYTES, + attributes: [euro.repeat(86)], + }); + strictAssert.deepEqual(decodeAttrs(bytes), [euro.repeat(85)]); + strictAssert.equal(decodeHeader(bytes).attrsDataSize, 2 + 255); + + const bytes2 = captureBytes({ + traceId: TRACE_ID_BYTES, + spanId: SPAN_ID_BYTES, + attributes: [euro.repeat(84) + 'éé'], + }); + strictAssert.deepEqual(decodeAttrs(bytes2), [euro.repeat(84) + 'é']); + strictAssert.equal(decodeHeader(bytes2).attrsDataSize, 2 + 254); + }); + + it('right-sizes an empty record to 28 bytes', () => { + const bytes = captureBytes({ + traceId: TRACE_ID_BYTES, + spanId: SPAN_ID_BYTES, + }); + strictAssert.equal(bytes.length, 28); + }); + + it('right-sizes a one-short-attribute record to 28 + 2 + len bytes', () => { + const bytes = captureBytes({ + traceId: TRACE_ID_BYTES, + spanId: SPAN_ID_BYTES, + attributes: ['GET'], + }); + strictAssert.equal(bytes.length, 28 + 2 + 3); + }); + + it('skip-and-continue truncates past the 612-byte cap', () => { + const a = 'a'.repeat(255); + const b = 'b'.repeat(255); + const c = 'c'.repeat(255); + const d = 'd'.repeat(30); + let bytes: Uint8Array | undefined; + let truncated = false; + tcRun( + () => { + bytes = _currentRecordBytes(); + truncated = tcIsTruncated(); + }, + { + traceId: TRACE_ID_BYTES, + spanId: SPAN_ID_BYTES, + attributes: [a, b, c, d], + }, + ); + strictAssert.deepEqual(decodeAttrs(bytes!), [a, b, , d]); + strictAssert.equal(decodeHeader(bytes!).attrsDataSize, 514 + 32); + strictAssert.equal(truncated, true); + }); + + it('rejects attributes array longer than 256', () => { + const tooLong: Array = new Array(257); + strictAssert.throws( + () => + captureBytes({ + traceId: TRACE_ID_BYTES, + spanId: SPAN_ID_BYTES, + attributes: tooLong, + }), + /must not exceed 256/, + ); + }); + + it('rejects non-array attributes argument', () => { + strictAssert.throws( + () => + captureBytes({ + traceId: TRACE_ID_BYTES, + spanId: SPAN_ID_BYTES, + attributes: {not: 'an array'} as unknown as Array, + }), + /attributes must be an array/, + ); + }); + }); + + describe('runWithContext lifecycle', () => { + it('returns the callback result', () => { + const result = tcRun(() => 'ok', { + traceId: TRACE_ID_BYTES, + spanId: SPAN_ID_BYTES, + }); + strictAssert.equal(result, 'ok'); + }); + + it('has no active record outside the call', () => { + strictAssert.equal(_currentRecordBytes(), undefined); + }); + + it('has no active record after the call returns', () => { + tcRun(() => undefined, { + traceId: TRACE_ID_BYTES, + spanId: SPAN_ID_BYTES, + }); + strictAssert.equal(_currentRecordBytes(), undefined); + }); + + it('restores the parent context after a nested call returns', () => { + const outerOpts = {traceId: TRACE_ID_BYTES, spanId: SPAN_ID_BYTES}; + const innerSpanBytes = bytesFromHex('aabbccddeeff0011'); + const innerOpts = {traceId: TRACE_ID_BYTES, spanId: innerSpanBytes}; + + tcRun(() => { + const outerBefore = decodeHeader(_currentRecordBytes()!).spanId; + tcRun(() => { + const inner = decodeHeader(_currentRecordBytes()!).spanId; + strictAssert.deepEqual(inner, innerSpanBytes); + }, innerOpts); + const outerAfter = decodeHeader(_currentRecordBytes()!).spanId; + strictAssert.deepEqual(outerBefore, outerAfter); + strictAssert.deepEqual(outerAfter, SPAN_ID_BYTES); + }, outerOpts); + }); + + it('keeps the same record after awaits', async () => { + await tcRun( + async () => { + const before = decodeHeader(_currentRecordBytes()!).spanId; + await Promise.resolve(); + const afterMicro = decodeHeader(_currentRecordBytes()!).spanId; + await new Promise(setImmediate); + const afterMacro = decodeHeader(_currentRecordBytes()!).spanId; + strictAssert.deepEqual(before, SPAN_ID_BYTES); + strictAssert.deepEqual(afterMicro, SPAN_ID_BYTES); + strictAssert.deepEqual(afterMacro, SPAN_ID_BYTES); + }, + {traceId: TRACE_ID_BYTES, spanId: SPAN_ID_BYTES}, + ); + }); + + it('keeps concurrent async calls isolated', async () => { + const aSpan = bytesFromHex('1111111111111111'); + const bSpan = bytesFromHex('2222222222222222'); + + async function run(spanBytes: Uint8Array) { + return tcRun( + async () => { + const observed: Uint8Array[] = []; + for (let i = 0; i < 4; i++) { + observed.push(decodeHeader(_currentRecordBytes()!).spanId); + await Promise.resolve(); + } + return observed; + }, + {traceId: TRACE_ID_BYTES, spanId: spanBytes}, + ); + } + + const [aObs, bObs] = await Promise.all([run(aSpan), run(bSpan)]); + for (const s of aObs) strictAssert.deepEqual(s, aSpan); + for (const s of bObs) strictAssert.deepEqual(s, bSpan); + }); + }); + + describe('enterWithContext', () => { + it('attaches the record to the current async scope', () => { + void tcRun( + () => { + strictAssert.deepEqual( + decodeHeader(_currentRecordBytes()!).spanId, + SPAN_ID_BYTES, + ); + + const newSpan = bytesFromHex('aabbccddeeff0011'); + tcEnter({traceId: TRACE_ID_BYTES, spanId: newSpan}); + strictAssert.deepEqual( + decodeHeader(_currentRecordBytes()!).spanId, + newSpan, + ); + + return Promise.resolve().then(() => { + strictAssert.deepEqual( + decodeHeader(_currentRecordBytes()!).spanId, + newSpan, + ); + }); + }, + {traceId: TRACE_ID_BYTES, spanId: SPAN_ID_BYTES}, + ); + + strictAssert.equal(_currentRecordBytes(), undefined); + }); + }); + + describe('clearContext', () => { + it('detaches the active record within a scope', () => { + tcRun( + () => { + strictAssert.ok(_currentRecordBytes()); + clearContext(); + strictAssert.equal(_currentRecordBytes(), undefined); + }, + {traceId: TRACE_ID_BYTES, spanId: SPAN_ID_BYTES}, + ); + }); + + it('drops the active record so getContext returns undefined', () => { + tcRun( + () => { + strictAssert.ok(getContext() !== undefined); + clearContext(); + strictAssert.equal(getContext(), undefined); + strictAssert.equal(tcIsTruncated(), false); + }, + {traceId: TRACE_ID_BYTES, spanId: SPAN_ID_BYTES}, + ); + }); + + it('is idempotent (calling with no context or twice is a no-op)', () => { + clearContext(); + strictAssert.equal(_currentRecordBytes(), undefined); + tcRun( + () => { + clearContext(); + clearContext(); + strictAssert.equal(_currentRecordBytes(), undefined); + }, + {traceId: TRACE_ID_BYTES, spanId: SPAN_ID_BYTES}, + ); + }); + + it('lets a nested runWithContext re-establish a record', () => { + tcRun( + () => { + clearContext(); + const innerSpan = bytesFromHex('aabbccddeeff0011'); + tcRun( + () => { + strictAssert.deepEqual( + decodeHeader(_currentRecordBytes()!).spanId, + innerSpan, + ); + }, + {traceId: TRACE_ID_BYTES, spanId: innerSpan}, + ); + // After the inner runWithContext returns, we're back to the + // post-clear state in the outer scope. + strictAssert.equal(_currentRecordBytes(), undefined); + }, + {traceId: TRACE_ID_BYTES, spanId: SPAN_ID_BYTES}, + ); + }); + + it('lets enterWithContext re-establish a record', () => { + tcRun( + () => { + clearContext(); + const newSpan = bytesFromHex('aabbccddeeff0011'); + tcEnter({traceId: TRACE_ID_BYTES, spanId: newSpan}); + strictAssert.deepEqual( + decodeHeader(_currentRecordBytes()!).spanId, + newSpan, + ); + }, + {traceId: TRACE_ID_BYTES, spanId: SPAN_ID_BYTES}, + ); + }); + }); + + describe('appendAttributes', () => { + it('adds entries to the current record', () => { + tcRun( + () => { + strictAssert.deepEqual(decodeAttrs(_currentRecordBytes()!), [ + 'GET', + ]); + tcAppend([, , '200']); + strictAssert.deepEqual(decodeAttrs(_currentRecordBytes()!), [ + 'GET', + , + '200', + ]); + }, + {traceId: TRACE_ID_BYTES, spanId: SPAN_ID_BYTES, attributes: ['GET']}, + ); + }); + + it('writes in-place when bytes fit in the slack', () => { + tcRun( + () => { + const before = _currentRecordBytes()!; + tcAppend([, 'ab']); + const after = _currentRecordBytes()!; + strictAssert.deepEqual(decodeAttrs(after), ['xxx', 'ab']); + strictAssert.equal(after.length, before.length + 2 + 2); + strictAssert.deepEqual(after.slice(0, 26), before.slice(0, 26)); + strictAssert.deepEqual(after.slice(28, 33), before.slice(28, 33)); + strictAssert.equal(after[24], 1); + }, + {traceId: TRACE_ID_BYTES, spanId: SPAN_ID_BYTES, attributes: ['xxx']}, + ); + }); + + it('grows the record geometrically when slack runs out', () => { + tcRun( + () => { + const v = 'y'.repeat(60); + for (let i = 0; i < 8; i++) { + const append: Array = []; + append[i] = v; + tcAppend(append); + } + const decoded = decodeAttrs(_currentRecordBytes()!); + for (let i = 0; i < 8; i++) { + strictAssert.equal(decoded[i], v, `slot ${i}`); + } + strictAssert.equal( + decodeHeader(_currentRecordBytes()!).attrsDataSize, + 8 * 62, + ); + }, + {traceId: TRACE_ID_BYTES, spanId: SPAN_ID_BYTES}, + ); + }); + + it('is a no-op when given an empty array', () => { + tcRun( + () => { + const before = _currentRecordBytes(); + tcAppend([]); + const after = _currentRecordBytes(); + strictAssert.deepEqual(after, before); + }, + {traceId: TRACE_ID_BYTES, spanId: SPAN_ID_BYTES}, + ); + }); + + it('is a no-op when all slots are null/undefined', () => { + tcRun( + () => { + const before = _currentRecordBytes(); + tcAppend([null, undefined, , null]); + const after = _currentRecordBytes(); + strictAssert.deepEqual(after, before); + }, + {traceId: TRACE_ID_BYTES, spanId: SPAN_ID_BYTES}, + ); + }); + + it('silently drops entries past the 612-byte cap and sets the truncated flag', () => { + const big = 'a'.repeat(255); + tcRun( + () => { + tcAppend([big, big]); + strictAssert.equal(tcIsTruncated(), false); + tcAppend([, , big]); + strictAssert.equal(tcIsTruncated(), true); + strictAssert.equal( + decodeHeader(_currentRecordBytes()!).attrsDataSize, + 514, + ); + const small = 'x'.repeat(30); + tcAppend([, , , small]); + const decoded = decodeAttrs(_currentRecordBytes()!); + strictAssert.equal(decoded[0], big); + strictAssert.equal(decoded[1], big); + strictAssert.equal(decoded[2], undefined); + strictAssert.equal(decoded[3], small); + strictAssert.equal(tcIsTruncated(), true); + }, + {traceId: TRACE_ID_BYTES, spanId: SPAN_ID_BYTES}, + ); + }); + + it('propagates through async continuations', async () => { + await tcRun( + async () => { + tcAppend([, 'after-await']); + await Promise.resolve(); + strictAssert.deepEqual(decodeAttrs(_currentRecordBytes()!), [ + 'before', + 'after-await', + ]); + }, + { + traceId: TRACE_ID_BYTES, + spanId: SPAN_ID_BYTES, + attributes: ['before'], + }, + ); + }); + }); + + describe('isContextTruncated', () => { + it('returns false outside a context', () => { + strictAssert.equal(tcIsTruncated(), false); + }); + + it('returns false for a non-truncated record', () => { + tcRun( + () => { + strictAssert.equal(tcIsTruncated(), false); + }, + { + traceId: TRACE_ID_BYTES, + spanId: SPAN_ID_BYTES, + attributes: ['GET', '/x'], + }, + ); + }); + + it('reflects appended-then-overflowed entries', () => { + tcRun( + () => { + strictAssert.equal(tcIsTruncated(), false); + tcAppend([ + , + , + 'c'.repeat(255), + , + , + 'd'.repeat(255), + , + , + 'e'.repeat(255), + ]); + strictAssert.equal(tcIsTruncated(), true); + }, + { + traceId: TRACE_ID_BYTES, + spanId: SPAN_ID_BYTES, + attributes: ['a', 'b'], + }, + ); + }); + }); + + describe('getProcessContextAttributes', () => { + it('rejects non-array keys', () => { + strictAssert.throws( + () => getProcessContextAttributes({} as unknown as string[]), + /must be an array/, + ); + }); + + it('rejects more than 256 keys', () => { + const tooMany = Array.from({length: 257}, (_, i) => `k${i}`); + strictAssert.throws( + () => getProcessContextAttributes(tooMany), + /exceeds 256/, + ); + }); + + it('rejects duplicate names', () => { + strictAssert.throws( + () => getProcessContextAttributes(['x', 'y', 'x']), + /duplicate key name/, + ); + }); + + it('rejects non-string entries', () => { + strictAssert.throws( + () => getProcessContextAttributes(['ok', 42 as unknown as string]), + /must be a string/, + ); + }); + + it('returns the expected shape', () => { + const keys = ['http.method', 'http.route', 'user.id']; + const pca = getProcessContextAttributes(keys); + strictAssert.equal(pca['threadlocal.schema_version'], 'nodejs_v1_dev'); + strictAssert.deepEqual(pca['threadlocal.attribute_key_map'], keys); + strictAssert.equal(pca['threadlocal.wrapped_object_offset'], 24); + strictAssert.equal(pca['threadlocal.tagged_size'], 8); + strictAssert.deepEqual(Object.keys(pca).sort(), [ + 'threadlocal.attribute_key_map', + 'threadlocal.schema_version', + 'threadlocal.tagged_size', + 'threadlocal.wrapped_object_offset', + ]); + }); + + it('is frozen and a defensive copy', () => { + const keys = ['http.method', 'http.route']; + const pca = getProcessContextAttributes(keys); + strictAssert.ok(Object.isFrozen(pca)); + strictAssert.ok(Object.isFrozen(pca['threadlocal.attribute_key_map'])); + keys.push('mutated.after'); + strictAssert.deepEqual(pca['threadlocal.attribute_key_map'], [ + 'http.method', + 'http.route', + ]); + strictAssert.throws(() => { + (pca as unknown as Record)[ + 'threadlocal.schema_version' + ] = 'tampered'; + }, /read-only|read only|TypeError/i); + }); + }); + + describe('discovery contract', () => { + it('exports otel_thread_ctx_nodejs_v1 as a TLS dynsym', function () { + const addon = join( + __dirname, + '..', + '..', + 'build', + 'Release', + 'dd_pprof.node', + ); + // The prebuild-install / node-gyp-build CI matrix runs against a + // prebuilt binary that lives outside build/Release; only the + // build-from-source path produces this exact file. + if (!existsSync(addon)) { + this.skip(); + } + const r = spawnSync('readelf', ['--dyn-syms', '--wide', addon], { + encoding: 'utf8', + }); + if (r.error && (r.error as NodeJS.ErrnoException).code === 'ENOENT') { + this.skip(); + } + strictAssert.equal(r.status, 0, `readelf failed: ${r.stderr}`); + const line = r.stdout + .split('\n') + .find(l => /\sotel_thread_ctx_nodejs_v1$/.test(l)); + assert.ok( + line, + 'otel_thread_ctx_nodejs_v1 not present in dynamic symbol table', + ); + assert.match( + line!, + /\bTLS\b/, + `expected TLS type, got: ${line!.trim()}`, + ); + assert.match( + line!, + /\bGLOBAL\b/, + `expected GLOBAL binding, got: ${line!.trim()}`, + ); + assert.match( + line!, + /\bDEFAULT\b/, + `expected DEFAULT visibility, got: ${line!.trim()}`, + ); + }); + }); + }, +); From 002b5d718c0972dd5357d7bbe8e19932306210c4 Mon Sep 17 00:00:00 2001 From: Attila Szegedi Date: Thu, 2 Jul 2026 18:07:10 +0200 Subject: [PATCH 4/7] Fix lint CI: install from lockfile (npm ci) instead of lockless yarn (#365) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The lint job ran `yarn` with no `yarn.lock` in the repo, so yarn ignored package-lock.json and resolved every dependency fresh from the package.json ranges. prettier isn't a direct dependency — it comes in transitively via gts (^3.6.2) — so CI floated to the newest prettier (3.9.4), while local development pins 3.8.1 through package-lock.json. prettier 3.9.x changed how it wraps union return types (collapsing the leading-`|` multiline form 3.8.1 produces), so `gts check` failed in CI on heap-profiler.ts / heap-profiler-bindings.ts even though the code was correctly formatted for the pinned prettier — and the failure never reproduced locally. Switch install to `npm ci` (and the script to `npm run lint`) so CI uses the exact package-lock.json versions the repo is developed against. This fixes the prettier drift and prevents any future lockless-yarn float for every other tool in the lint chain. --- .github/workflows/lint.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index f4dad2a6..68b06640 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -10,5 +10,5 @@ jobs: steps: - uses: actions/checkout@v7 - uses: actions/setup-node@v6 - - run: yarn - - run: yarn lint + - run: npm ci + - run: npm run lint From cd31eee1472c4a13b8bd352dd2b85be313194a32 Mon Sep 17 00:00:00 2001 From: Attila Szegedi Date: Thu, 2 Jul 2026 17:59:36 +0200 Subject: [PATCH 5/7] Simplify map-get.cc to only handle the regular OrderedHashMap (#361) A JS Map always uses the regular OrderedHashMap as its backing table: V8's Map constructor hardcodes AllocateOrderedHashMap(), and the only path that could install a SmallOrderedHashMap (OrderedHashMapHandler / AdjustRepresentation) is test-only, never used by the JSMap/JSSet builtins. We only ever read AsyncContextFrame (CPED) maps, which are ordinary JS Maps, so the SmallOrderedHashMap handling was dead code. Drop the SmallOrderedHashMapLayout struct, IsSmallOrderedHashMap header sniffing, and the now-unnecessary templating on FindEntryByHash / FindValueByHash. The public GetValueFromMap signature is unchanged. Co-authored-by: Claude Opus 4.8 (1M context) --- bindings/map-get.cc | 170 +++++++------------------------------------- 1 file changed, 24 insertions(+), 146 deletions(-) diff --git a/bindings/map-get.cc b/bindings/map-get.cc index 7d3c3f50..3c4ae5c7 100644 --- a/bindings/map-get.cc +++ b/bindings/map-get.cc @@ -16,24 +16,17 @@ #include "map-get.hh" -// Find a value in JavaScript map by directly reading the underlying V8 hash +// Find a value in a JavaScript map by directly reading the underlying V8 hash // map. // -// V8 uses TWO internal hash map representations: -// 1. SmallOrderedHashMap: For small maps (capacity 4-254) -// - Metadata stored as uint8_t bytes -// - Entry size: 2 (key, value) -// - Chain table separate from entries -// -// 2. OrderedHashMap: For larger maps (capacity >254) -// - Metadata stored as Smis in FixedArray -// - Entry size: 3 (key, value, chain) -// - Chain stored inline with entries -// -// This code handles both types by detecting the table format at runtime. -// Practical testing shows that at least the AsyncContextFrame maps use the -// large map format even for small cardinality maps, but just in case we handle -// both. +// V8 defines two internal hash map representations: a compact +// SmallOrderedHashMap (for low-cardinality maps) and the regular +// OrderedHashMap. However, a JS Map always uses the regular OrderedHashMap: +// V8's Map constructor hardcodes AllocateOrderedHashMap() and the "start small, +// then promote" path that could ever install a SmallOrderedHashMap +// (OrderedHashMapHandler) is test-only, never used by the JSMap/JSSet builtins. +// We only ever read AsyncContextFrame maps (the CPED map), which are ordinary +// JS Maps, so we only handle the OrderedHashMap layout here. #include @@ -49,7 +42,7 @@ using Address = uintptr_t; // Heap object tagging constexpr int kHeapObjectTag = 1; -// OrderedHashMap/SmallOrderedHashMap shared constants +// OrderedHashMap constants constexpr int kNotFound = -1; constexpr int kLoadFactor = 2; @@ -89,7 +82,7 @@ struct JSMapLayout { HeapObjectLayout header_; // Map is a HeapObject Address properties_or_hash_; // not used by us Address elements_; // not used by us - // Tagged pointer to a [Small]OrderedHashMapLayout + // Tagged pointer to an OrderedHashMapLayout Address table_; }; @@ -100,11 +93,7 @@ struct FixedArrayLayout { Address elements_[0]; }; -// NOTE: both OrderedHashMap and SmallOrderedHashMap have compatible method -// definitions so FindEntryByHash and FindValueByHash can be defined as -// templated function working on both. - -// OrderedHashMap layout (for large maps, capacity >254) +// OrderedHashMap layout // From v8/src/objects/ordered-hash-table.h struct OrderedHashMapLayout { FixedArrayLayout fixedArray_; // OrderedHashMap is a FixedArray @@ -173,90 +162,14 @@ struct OrderedHashMapLayout { } }; -// SmallOrderedHashMap layout (for small maps, capacity 4-254) -// Memory layout (stores metadata as uint8_t, not Smis): -// [0]: map pointer (HeapObject) -// [kHeaderSize + 0]: number_of_elements (uint8) -// [kHeaderSize + 1]: number_of_deleted_elements (uint8) -// [kHeaderSize + 2]: number_of_buckets (uint8) -// [kHeaderSize + 3...]: padding (5 bytes on 64-bit, 1 byte on 32-bit) -// [DataTableStartOffset...]: data table (key-value pairs as Tagged) -// [...]: hash table (uint8 bucket indices) -// [...]: chain table (uint8 next entry indices) -// -// Each entry is 2 Tagged elements (kEntrySize = 2): -// [0]: key (Tagged Object) -// [1]: value (Tagged Object) -// -// From v8/src/objects/ordered-hash-table.h -struct SmallOrderedHashMapLayout { - HeapObjectLayout header_; - uint8_t number_of_elements_; - uint8_t number_of_deleted_elements_; - uint8_t number_of_buckets_; - uint8_t padding_[5]; // 5 bytes on 64-bit - // Variable length: - // - Address data_table_[capacity * kEntrySize] // Keys and values - // - uint8_t hash_table_[number_of_buckets_] // Bucket -> first entry - // - uint8_t chain_table_[capacity] // Entry -> next entry - Address data_table_[0]; - - // Constants for entry structure - static constexpr int kEntrySize = 2; - static constexpr int kKeyOffset = 0; - static constexpr int kValueOffset = 1; - static constexpr int kNotFoundValue = 255; - - // Get capacity from number of buckets - int Capacity() const { return number_of_buckets_ * kLoadFactor; } - - int NumberOfBuckets() const { return number_of_buckets_; } - - int GetEntryCount() const { - return number_of_elements_ + number_of_deleted_elements_; - } - - const uint8_t* GetHashTable() const { - return reinterpret_cast(data_table_ + - Capacity() * kEntrySize); - } - - const uint8_t* GetChainTable() const { - return GetHashTable() + number_of_buckets_; - } - - // Get key at entry index - Address GetKey(int entry) const { - return data_table_[entry * kEntrySize + kKeyOffset]; - } - - // Get value at entry index - Address GetValue(int entry) const { - return data_table_[entry * kEntrySize + kValueOffset]; - } - - // Get first entry in bucket - uint8_t GetFirstEntry(int bucket) const { - const uint8_t* hash_table = GetHashTable(); - return hash_table[bucket]; - } - - // Get next entry in chain - uint8_t GetNextChainEntry(int entry) const { - const uint8_t* chain_table = GetChainTable(); - return chain_table[entry]; - } -}; - // ============================================================================ -// Templated Hash Table Lookup +// Hash Table Lookup // ============================================================================ -// Find an entry by a key and its hash in any hash table layout -// Template parameter LayoutT should be either OrderedHashMapLayout or -// SmallOrderedHashMapLayout -template -int FindEntryByHash(const LayoutT* layout, int hash, Address key_to_find) { +// Find an entry by a key and its hash in an OrderedHashMap. +int FindEntryByHash(const OrderedHashMapLayout* layout, + int hash, + Address key_to_find) { const int entry_count = layout->GetEntryCount(); const int bucket = hash & (layout->NumberOfBuckets() - 1); int entry = layout->GetFirstEntry(bucket); @@ -266,8 +179,8 @@ int FindEntryByHash(const LayoutT* layout, int hash, Address key_to_find) { // reason the chain is cyclical. Also, every entry value must be between // [0, GetEntryCount). for (int max_entries_left = entry_count; - entry != LayoutT::kNotFoundValue && entry >= 0 && entry < entry_count && - max_entries_left > 0; + entry != OrderedHashMapLayout::kNotFoundValue && entry >= 0 && + entry < entry_count && max_entries_left > 0; max_entries_left--) { Address key_at_entry = layout->GetKey(entry); if (key_at_entry == key_to_find) { @@ -279,46 +192,15 @@ int FindEntryByHash(const LayoutT* layout, int hash, Address key_to_find) { return kNotFound; } -// Find an entry by a key and its hash in any hash table layout, and return its +// Find an entry by a key and its hash in an OrderedHashMap, and return its // value or the zero address if it is not found. -// Template parameter LayoutT should be either OrderedHashMapLayout or -// SmallOrderedHashMapLayout -template -Address FindValueByHash(const LayoutT* layout, int hash, Address key_to_find) { +Address FindValueByHash(const OrderedHashMapLayout* layout, + int hash, + Address key_to_find) { auto entry = FindEntryByHash(layout, hash, key_to_find); return entry == kNotFound ? 0 : layout->GetValue(entry); } -static bool IsSmallOrderedHashMap(Address table_untagged) { - const SmallOrderedHashMapLayout* potential_small = - reinterpret_cast(table_untagged); - - // Read the header as one 64-bit value for validation - uint64_t smallHeader = - *reinterpret_cast(&potential_small->number_of_elements_); - - static_assert(__BYTE_ORDER__ == __ORDER_LITTLE_ENDIAN__, - "Little-endian required"); - // Small map will have some bits in bytes 0-2 be nonzero, and all bits in - // bytes 3-7 zero. That effectively limits the value range of smallHeader to - // [0x1-0xFFFFFF]. - if (smallHeader == 0 || smallHeader >= 0x1000000) return false; - - auto num_elements = potential_small->number_of_elements_; - auto num_deleted = potential_small->number_of_deleted_elements_; - auto num_buckets = potential_small->number_of_buckets_; - - // num_buckets must be between 2 and 127 - if (num_buckets < 2 || num_buckets > 127) return false; - - // num_buckets must be a power of 2 - if ((num_buckets & (num_buckets - 1)) != 0) return false; - - auto capacity = num_buckets * kLoadFactor; - // Sum of elements and deleted elements can't exceed capacity - return num_elements + num_deleted <= capacity; -} - static bool IsOrderedHashMap(Address table_untagged) { const OrderedHashMapLayout* layout = reinterpret_cast(table_untagged); @@ -368,11 +250,7 @@ Address GetValueFromMap(Address map_addr, int hash, Address key) { reinterpret_cast(UntagPointer(map_addr)); Address table_untagged = UntagPointer(map_untagged->table_); - if (IsSmallOrderedHashMap(table_untagged)) { - const SmallOrderedHashMapLayout* layout = - reinterpret_cast(table_untagged); - return FindValueByHash(layout, hash, key); - } else if (IsOrderedHashMap(table_untagged)) { + if (IsOrderedHashMap(table_untagged)) { const OrderedHashMapLayout* layout = reinterpret_cast(table_untagged); return FindValueByHash(layout, hash, key); From 4d5ff186bfdda315af2fdeeb4760f26e5282bdd2 Mon Sep 17 00:00:00 2001 From: Attila Szegedi Date: Thu, 2 Jul 2026 18:15:59 +0200 Subject: [PATCH 6/7] Reconcile package.json with package-lock.json for npm ci MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The v5.x lockfile carried newer versions than package.json declared — a desync that lockless `yarn` never validated but `npm ci` (now used by the lint job) rejects: - @types/node: package.json 25.9.1 vs lock 25.9.2 - tmp: package.json 0.2.6 vs lock 0.2.7 Align package.json up to the already-locked versions rather than down: tmp 0.2.6 carries a High-severity advisory (GHSA-7c78-jf6q-g5cm) and the lock was already advanced to the fixed 0.2.7. This changes nothing about what gets installed; it only makes the manifest honest. Regenerating the lockfile also drops the stale root `hasInstallScript` flag (the install script was removed in #363) and realigns the eslint-plugin-n / semver range mirrors with package.json. Co-Authored-By: Claude Opus 4.8 (1M context) --- package-lock.json | 5 ++--- package.json | 4 ++-- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index f3ff87d9..fe2b3626 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,7 +7,6 @@ "": { "name": "@datadog/pprof", "version": "5.15.1", - "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { "node-gyp-build": "^4.8.4", @@ -23,13 +22,13 @@ "clang-format": "^1.8.0", "codecov": "^3.8.3", "deep-copy": "^1.4.2", - "eslint-plugin-n": "^18.1.0", + "eslint-plugin-n": "^18.0.1", "gts": "^7.0.0", "js-green-licenses": "^4.0.0", "mocha": "^11.7.6", "nan": "^2.27.0", "nyc": "^18.0.0", - "semver": "^7.8.3", + "semver": "^7.8.1", "sinon": "^22.0.0", "source-map-support": "^0.5.21", "tmp": "0.2.7", diff --git a/package.json b/package.json index 35affbce..7c7eb23e 100644 --- a/package.json +++ b/package.json @@ -42,7 +42,7 @@ }, "devDependencies": { "@types/mocha": "^10.0.1", - "@types/node": "25.9.1", + "@types/node": "25.9.2", "@types/semver": "^7.5.8", "@types/sinon": "^21.0.1", "@types/tmp": "^0.2.3", @@ -58,7 +58,7 @@ "semver": "^7.8.1", "sinon": "^22.0.0", "source-map-support": "^0.5.21", - "tmp": "0.2.6", + "tmp": "0.2.7", "typescript": "^6.0.3" }, "files": [ From 0d4e0fb00558f37e1adbda9d9643f7315d91138b Mon Sep 17 00:00:00 2001 From: Attila Szegedi Date: Thu, 2 Jul 2026 18:16:00 +0200 Subject: [PATCH 7/7] v5.16.0 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index fe2b3626..84fbda1a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@datadog/pprof", - "version": "5.15.1", + "version": "5.16.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@datadog/pprof", - "version": "5.15.1", + "version": "5.16.0", "license": "Apache-2.0", "dependencies": { "node-gyp-build": "^4.8.4", diff --git a/package.json b/package.json index 7c7eb23e..c0f35521 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@datadog/pprof", - "version": "5.15.1", + "version": "5.16.0", "description": "pprof support for Node.js", "repository": { "type": "git",