From 67b647f9e9ea5517e34844a8269b69c0ef3fca48 Mon Sep 17 00:00:00 2001 From: Attila Szegedi Date: Wed, 27 May 2026 17:34:29 +0200 Subject: [PATCH 01/15] Port OTEP-4947 thread-context writer from custom-labels/js Ports the in-development OpenTelemetry thread-context writer that lives on the otel-thread-ctx-node branch of polarsignals/custom-labels (szegedi fork) into this project. The two codebases will likely diverge again later; for now this is a snapshot of the current state. Structurally: - bindings/otel-thread-ctx.cc/.hh: the native addon code, namespaced in `dd::` and exposed via OtelThreadCtx::Init(exports) called from binding.cc. The thread_local otel_thread_ctx_nodejs_v1 discovery symbol stays in extern "C" at file scope so it's exported by name through the dd_pprof.node dynsym table. - ts/src/otel-thread-ctx.ts: the runWithContext / enterWithContext / makeNamedContext API, loading the native addon via node-gyp-build like the rest of this project. - ts/test/test-otel-thread-ctx.ts: mocha port of the node:test suite. Skipped wholesale on non-Linux. - binding.gyp: adds bindings/otel-thread-ctx.cc to both target source lists and the -mtls-dialect=gnu2 cflag on x86_64 Linux (required by the OTEP-4947 spec; on arm64 TLSDESC is the only dynamic TLS model so no flag is needed). Verified by mocha against the built dd_pprof.node in a Linux container (Node 22 with --experimental-async-context-frame): 35 passing. --- binding.gyp | 15 +- bindings/binding.cc | 2 + bindings/otel-thread-ctx.cc | 509 +++++++++++++++++++ bindings/otel-thread-ctx.hh | 26 + ts/src/otel-thread-ctx.ts | 347 +++++++++++++ ts/test/test-otel-thread-ctx.ts | 856 ++++++++++++++++++++++++++++++++ 6 files changed, 1753 insertions(+), 2 deletions(-) create mode 100644 bindings/otel-thread-ctx.cc create mode 100644 bindings/otel-thread-ctx.hh 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..99f867b4 --- /dev/null +++ b/bindings/otel-thread-ctx.cc @@ -0,0 +1,509 @@ +/* + * 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. + */ + +// 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. +// +// Layout is part of the reader ABI: see 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 +}; + +__attribute__((visibility("default"))) +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. +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 — 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 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 Bytes(const FunctionCallbackInfo &args); + static void Append(const FunctionCallbackInfo &args); + static void IsTruncated(const FunctionCallbackInfo &args); + + static bool EncodeAttrs(Isolate *isolate, Local context, + Local attrs_val, size_t existing_size, + std::vector *out, bool *out_truncated); + + OtelThreadCtxRecord *record_; + size_t capacity_; + bool truncated_; + + CtxWrap(OtelThreadCtxRecord *record, size_t capacity, bool truncated); +}; + +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()->Data()) + arr->ByteOffset(); + memcpy(out, base, expected_bytes); + return true; +} + +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; + } + 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; + if (val_val->IsUndefined() || val_val->IsNull()) continue; + + Local v; + if (!val_val->ToString(context).ToLocal(&v)) { + isolate->ThrowError("failed to coerce attribute value to string"); + return false; + } + int v_utf8_len = v->Utf8Length(isolate); + 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) { + *out_truncated = true; + continue; + } + + const size_t entry_off = out->size(); + out->resize(entry_off + needed); + (*out)[entry_off] = static_cast(i); + int v_written = v->WriteUtf8( + isolate, reinterpret_cast(&(*out)[entry_off + 2]), v_budget, + nullptr, String::NO_NULL_TERMINATION); + (*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()) { + isolate->ThrowError("OtelThreadCtxWrap must be called with `new`"); + return; + } + if (args.Length() != 3) { + isolate->ThrowError( + "OtelThreadCtxWrap expects 3 arguments: traceId, spanId, attributes"); + return; + } + + 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; + } + + std::vector attrs_buf; + bool truncated = false; + if (!EncodeAttrs(isolate, context, args[2], 0, &attrs_buf, &truncated)) { + return; + } + + size_t capacity = attrs_buf.size() < MIN_INITIAL_CAPACITY + ? MIN_INITIAL_CAPACITY + : attrs_buf.size(); + 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 + volatile store. + 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 the 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 an OtelThreadCtxWrap"); + 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; + + if (appended.empty()) return; + + const size_t new_used = current_used + appended.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. + // attrs_data_size is the publication boundary — bytes past it are + // not observable by the reader, so a reader firing mid-append sees + // either the old or new size, never a torn state. + 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, capped. + size_t new_cap = self->capacity_ * 2; + if (new_cap < new_used) new_cap = new_used; + if (new_cap > MAX_ATTRS_DATA_SIZE) new_cap = 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; + } + memcpy(new_rec.get(), self->record_, + sizeof(OtelThreadCtxRecord) + current_used); + memcpy(&new_rec->attrs_data[current_used], appended.data(), appended.size()); + new_rec->attrs_data_size = static_cast(new_used); + new_rec->valid = 1; + + std::atomic_signal_fence(std::memory_order_release); + OtelThreadCtxRecord *old_rec = self->record_; + self->record_ = new_rec.release(); + self->capacity_ = new_cap; + free(old_rec); +} + +void CtxWrap::IsTruncated(const FunctionCallbackInfo &args) { + CtxWrap *self = ObjectWrap::Unwrap(args.This()); + if (!self) { + args.GetIsolate()->ThrowError("not an OtelThreadCtxWrap"); + return; + } + args.GetReturnValue().Set(self->truncated_); +} + +void CtxWrap::Bytes(const FunctionCallbackInfo &args) { + Isolate *isolate = args.GetIsolate(); + CtxWrap *self = ObjectWrap::Unwrap(args.This()); + if (!self) { + isolate->ThrowError("not an OtelThreadCtxWrap"); + return; + } + const size_t total = + sizeof(OtelThreadCtxRecord) + self->record_->attrs_data_size; + Local buf = v8::ArrayBuffer::New(isolate, total); + memcpy(buf->Data(), self->record_, total); + args.GetReturnValue().Set(Uint8Array::New(buf, 0, total)); +} + +void CtxWrap::Init(Local exports) { +#if NODE_MAJOR_VERSION >= 26 + Isolate *isolate = Isolate::GetCurrent(); +#else + Isolate *isolate = exports->GetIsolate(); +#endif + Local context = isolate->GetCurrentContext(); + + Local tpl = FunctionTemplate::New(isolate, New); + tpl->SetClassName(String::NewFromUtf8Literal(isolate, "OtelThreadCtxWrap")); + tpl->InstanceTemplate()->SetInternalFieldCount(1); + + tpl->PrototypeTemplate()->Set( + String::NewFromUtf8Literal(isolate, "bytes"), + FunctionTemplate::New(isolate, Bytes)); + tpl->PrototypeTemplate()->Set( + String::NewFromUtf8Literal(isolate, "append"), + 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, "otelThreadCtxWrap"), + 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); + otel_thread_ctx_nodejs_v1.cped_slot = reinterpret_cast( + reinterpret_cast(isolate) + + v8::internal::Internals::kContinuationPreservedEmbedderDataOffset); + // 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. +constexpr int WRAPPED_OBJECT_OFFSET = + v8::internal::Internals::kJSObjectHeaderSize + + v8::internal::Internals::kEmbedderDataSlotExternalPointerOffset; +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 = exports->GetIsolate(); + 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/ts/src/otel-thread-ctx.ts b/ts/src/otel-thread-ctx.ts new file mode 100644 index 00000000..ff453ecb --- /dev/null +++ b/ts/src/otel-thread-ctx.ts @@ -0,0 +1,347 @@ +/* + * 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. + */ + +// 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'; + +/** + * Inputs to {@link runWithContext} and {@link enterWithContext}. + * + * `traceId` and `spanId` are passed as raw bytes (a `Uint8Array` of length + * 16 and 8 respectively; `Buffer` is acceptable as a subclass). + * + * `attributes`, if present, is positional: index N in the array is the value + * for uint8 key index N on the wire. Slots that are `null`, `undefined`, or + * absent (array holes) are skipped. Non-string values are coerced via + * `toString`. Values longer than 255 UTF-8 bytes are silently truncated and + * attributes that would overflow the 612-byte payload budget are silently + * dropped — see {@link isContextTruncated} for how to detect that. Array + * length must not exceed 256. + */ +export interface ContextOptions { + traceId: Uint8Array; + spanId: Uint8Array; + attributes?: Array; +} + +/** + * Inputs to the methods returned by {@link makeNamedContext}. Same as + * {@link ContextOptions} but attributes are addressed by name; names are + * resolved to uint8 key indexes using the array passed to + * {@link makeNamedContext}. + */ +export interface NamedContextOptions { + traceId: Uint8Array; + spanId: Uint8Array; + namedAttributes?: + | Record + | Map + | Array<[string, unknown]>; +} + +/** + * OTEP-4719 process-context attributes corresponding to a particular + * {@link NamedContext}. 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'; + readonly 'threadlocal.attribute_key_map': readonly string[]; + readonly 'threadlocal.nodejs_v1.wrapped_object_offset': number; + readonly 'threadlocal.nodejs_v1.tagged_size': number; +} + +/** + * Object returned by {@link makeNamedContext}. + */ +export interface NamedContext { + runWithContext(fn: () => T, opts: NamedContextOptions): T; + enterWithContext(opts: NamedContextOptions): void; + clearContext(): void; + appendAttributes( + namedAttributes: + | Record + | Map + | Array<[string, unknown]>, + ): void; + isContextTruncated(): boolean; + readonly processContextAttributes: ProcessContextAttributes; +} + +interface CtxWrap { + bytes(): Uint8Array; + append(attributes: Array | undefined): void; + isTruncated(): boolean; +} + +interface Addon { + otelThreadCtxWrap: new ( + traceId: Uint8Array, + spanId: Uint8Array, + attributes: Array | undefined, + ) => CtxWrap; + otelThreadCtxStoreAls(als: AsyncLocalStorage): void; + otelThreadCtxGetStoredAlsHash(): number; + otelThreadCtxWrappedObjectOffset: number; + otelThreadCtxTaggedSize: number; +} + +const SCHEMA_VERSION = 'nodejs_v1'; + +// 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; + +export let runWithContext: (fn: () => T, opts: ContextOptions) => T; +export let enterWithContext: (opts: ContextOptions) => void; +/** + * Detach any thread-context record from the current asynchronous scope. + * Subsequent reads in the same scope (until a new + * {@link runWithContext}/{@link enterWithContext} attaches one) see no + * active context. Idempotent. On non-Linux platforms this is a no-op. + */ +export let clearContext: () => void; +export let appendAttributes: ( + attributes: Array, +) => void; +export let isContextTruncated: () => boolean; + +// 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; + + 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; + } + + function buildWrap(opts: ContextOptions): CtxWrap { + if (!opts || typeof opts !== 'object') { + throw new TypeError('options object required'); + } + ensureHook(); + return new addon.otelThreadCtxWrap( + opts.traceId, + opts.spanId, + opts.attributes, + ); + } + + runWithContext = function (fn: () => T, opts: ContextOptions): T { + const wrap = buildWrap(opts); + return ensureHook().run(wrap, fn); + }; + + enterWithContext = function (opts: ContextOptions): void { + const wrap = buildWrap(opts); + ensureHook().enterWith(wrap); + }; + + clearContext = function (): void { + // Idempotent: clearing when no hook has been installed yet (and + // therefore no context can be active) is a no-op. + if (!als) return; + als.enterWith(undefined as unknown as CtxWrap); + }; + + appendAttributes = function ( + attributes: Array, + ): void { + if (!als) { + throw new Error( + 'no active thread context; call runWithContext or enterWithContext first', + ); + } + const wrap = als.getStore(); + if (!wrap) { + throw new Error( + 'no active thread context; call runWithContext or enterWithContext first', + ); + } + wrap.append(attributes); + }; + + isContextTruncated = function (): boolean { + if (!als) return false; + const wrap = als.getStore(); + if (!wrap) return false; + return wrap.isTruncated(); + }; + + _currentRecordBytes = function (): Uint8Array | undefined { + if (!als) return undefined; + const wrap = als.getStore(); + if (!wrap) return undefined; + return wrap.bytes(); + }; +} else { + runWithContext = function (fn: () => T, _opts: ContextOptions): T { + return fn(); + }; + enterWithContext = function (_opts: ContextOptions): void {}; + clearContext = function (): void {}; + appendAttributes = function ( + _attributes: Array, + ): void {}; + isContextTruncated = function (): boolean { + return false; + }; +} + +/** + * Build name-addressed wrappers around {@link runWithContext}, + * {@link enterWithContext}, and {@link appendAttributes}. The supplied + * `keys` array is the same string list the caller publishes (or has + * published) as the `threadlocal.attribute_key_map` resource attribute in + * the OTEP-4719 process context: index N in this array is the uint8 key + * index N in the on-the-wire record. The mapping is captured once at + * factory time. + */ +export function makeNamedContext(keys: string[]): NamedContext { + 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 indexByName = new Map(); + keys.forEach((name, i) => { + if (typeof name !== 'string') { + throw new TypeError('every key must be a string'); + } + if (indexByName.has(name)) { + throw new Error( + `duplicate key name at indexes ${indexByName.get(name)} and ${i}: ${name}`, + ); + } + indexByName.set(name, i); + }); + + function resolveAttributes( + named: + | Record + | Map + | Array<[string, unknown]> + | undefined, + ): Array | undefined { + if (named == null) return undefined; + const attributes: Array = []; + const set = (name: string, value: unknown) => { + const idx = indexByName.get(name); + if (idx === undefined) { + throw new Error(`unknown attribute name: ${name}`); + } + attributes[idx] = String(value); + }; + if (Array.isArray(named)) { + for (const [n, v] of named) set(n, v); + } else if (named instanceof Map) { + for (const [n, v] of named) set(n, v); + } else if (typeof named === 'object') { + for (const n of Object.keys(named)) + set(n, (named as Record)[n]); + } else { + throw new TypeError( + 'namedAttributes must be an object, Map, or array of pairs', + ); + } + return attributes; + } + + function toBaseOpts(opts: NamedContextOptions): ContextOptions { + if (!opts || typeof opts !== 'object') { + throw new TypeError('options object required'); + } + return { + traceId: opts.traceId, + spanId: opts.spanId, + attributes: resolveAttributes(opts.namedAttributes), + }; + } + + const processContextAttributes = Object.freeze({ + 'threadlocal.schema_version': SCHEMA_VERSION, + 'threadlocal.attribute_key_map': Object.freeze(keys.slice()), + 'threadlocal.nodejs_v1.wrapped_object_offset': WRAPPED_OBJECT_OFFSET, + 'threadlocal.nodejs_v1.tagged_size': TAGGED_SIZE, + }) as ProcessContextAttributes; + + return { + runWithContext(fn: () => T, opts: NamedContextOptions): T { + return runWithContext(fn, toBaseOpts(opts)); + }, + enterWithContext(opts: NamedContextOptions): void { + enterWithContext(toBaseOpts(opts)); + }, + clearContext(): void { + clearContext(); + }, + appendAttributes( + namedAttributes: + | Record + | Map + | Array<[string, unknown]>, + ): void { + appendAttributes(resolveAttributes(namedAttributes)!); + }, + isContextTruncated(): boolean { + return isContextTruncated(); + }, + 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..3cc140af --- /dev/null +++ b/ts/test/test-otel-thread-ctx.ts @@ -0,0 +1,856 @@ +/* + * Copyright 2026 Datadog, Inc + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import assert from 'assert'; +import {strict as strictAssert} from 'assert'; +import {spawnSync} from 'node:child_process'; +import {join} from 'node:path'; + +import { + ContextOptions, + appendAttributes, + clearContext, + enterWithContext, + isContextTruncated, + makeNamedContext, + runWithContext, + _currentRecordBytes, +} from '../src/otel-thread-ctx'; + +const isLinux = process.platform === 'linux'; + +// 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; + runWithContext(() => { + bytes = _currentRecordBytes(); + }, opts); + return bytes as Uint8Array; +} + +(isLinux ? describe : describe.skip)('OTEP-4947 thread context (Linux-only)', () => { + describe('CtxWrap 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; + runWithContext( + () => { + bytes = _currentRecordBytes(); + truncated = isContextTruncated(); + }, + {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 = runWithContext(() => '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', () => { + runWithContext(() => 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}; + + runWithContext(() => { + const outerBefore = decodeHeader(_currentRecordBytes()!).spanId; + runWithContext(() => { + 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 runWithContext(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 runWithContext(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', () => { + runWithContext(() => { + strictAssert.deepEqual( + decodeHeader(_currentRecordBytes()!).spanId, + SPAN_ID_BYTES, + ); + + const newSpan = bytesFromHex('aabbccddeeff0011'); + enterWithContext({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); + }); + + it('requires an options object', () => { + strictAssert.throws( + () => enterWithContext(undefined as unknown as ContextOptions), + /options object required/, + ); + }); + }); + + describe('clearContext', () => { + it('detaches the active record within a scope', () => { + runWithContext( + () => { + strictAssert.ok(_currentRecordBytes()); + clearContext(); + strictAssert.equal(_currentRecordBytes(), undefined); + }, + {traceId: TRACE_ID_BYTES, spanId: SPAN_ID_BYTES}, + ); + }); + + it('makes appendAttributes throw and isContextTruncated return false', () => { + runWithContext( + () => { + clearContext(); + strictAssert.throws( + () => appendAttributes(['v']), + /no active thread context/, + ); + strictAssert.equal(isContextTruncated(), 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); + runWithContext( + () => { + clearContext(); + clearContext(); + strictAssert.equal(_currentRecordBytes(), undefined); + }, + {traceId: TRACE_ID_BYTES, spanId: SPAN_ID_BYTES}, + ); + }); + + it('lets a nested runWithContext re-establish a record', () => { + runWithContext( + () => { + clearContext(); + const innerSpan = bytesFromHex('aabbccddeeff0011'); + runWithContext( + () => { + 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', () => { + runWithContext( + () => { + clearContext(); + const newSpan = bytesFromHex('aabbccddeeff0011'); + enterWithContext({traceId: TRACE_ID_BYTES, spanId: newSpan}); + strictAssert.deepEqual( + decodeHeader(_currentRecordBytes()!).spanId, + newSpan, + ); + }, + {traceId: TRACE_ID_BYTES, spanId: SPAN_ID_BYTES}, + ); + }); + + it('named.clearContext detaches the active record', () => { + const named = makeNamedContext(['route']); + named.runWithContext( + () => { + strictAssert.ok(_currentRecordBytes()); + named.clearContext(); + strictAssert.equal(_currentRecordBytes(), undefined); + }, + { + traceId: TRACE_ID_BYTES, + spanId: SPAN_ID_BYTES, + namedAttributes: {route: '/x'}, + }, + ); + }); + }); + + describe('appendAttributes', () => { + it('adds entries to the current record', () => { + runWithContext( + () => { + strictAssert.deepEqual(decodeAttrs(_currentRecordBytes()!), ['GET']); + appendAttributes([, , '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', () => { + runWithContext( + () => { + const before = _currentRecordBytes()!; + appendAttributes([, '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', () => { + runWithContext(() => { + const v = 'y'.repeat(60); + for (let i = 0; i < 8; i++) { + const append: Array = []; + append[i] = v; + appendAttributes(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('throws when there is no current context', () => { + strictAssert.throws(() => appendAttributes(['v']), /no active thread context/); + }); + + it('is a no-op when given an empty array', () => { + runWithContext(() => { + const before = _currentRecordBytes(); + appendAttributes([]); + 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', () => { + runWithContext(() => { + const before = _currentRecordBytes(); + appendAttributes([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); + runWithContext(() => { + appendAttributes([big, big]); + strictAssert.equal(isContextTruncated(), false); + appendAttributes([, , big]); + strictAssert.equal(isContextTruncated(), true); + strictAssert.equal(decodeHeader(_currentRecordBytes()!).attrsDataSize, 514); + const small = 'x'.repeat(30); + appendAttributes([, , , 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(isContextTruncated(), true); + }, {traceId: TRACE_ID_BYTES, spanId: SPAN_ID_BYTES}); + }); + + it('propagates through async continuations', async () => { + await runWithContext( + async () => { + appendAttributes([, '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(isContextTruncated(), false); + }); + + it('returns false for a non-truncated record', () => { + runWithContext( + () => { + strictAssert.equal(isContextTruncated(), false); + }, + { + traceId: TRACE_ID_BYTES, + spanId: SPAN_ID_BYTES, + attributes: ['GET', '/x'], + }, + ); + }); + }); + + describe('makeNamedContext', () => { + it('rejects non-array keys', () => { + strictAssert.throws( + () => makeNamedContext({} 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(() => makeNamedContext(tooMany), /exceeds 256/); + }); + + it('rejects duplicate names', () => { + strictAssert.throws( + () => makeNamedContext(['x', 'y', 'x']), + /duplicate key name/, + ); + }); + + it('rejects non-string entries', () => { + strictAssert.throws( + () => makeNamedContext(['ok', 42 as unknown as string]), + /must be a string/, + ); + }); + + it('returns an object exposing all five NamedContext methods', () => { + const named = makeNamedContext(['a']); + strictAssert.equal(typeof named.runWithContext, 'function'); + strictAssert.equal(typeof named.enterWithContext, 'function'); + strictAssert.equal(typeof named.clearContext, 'function'); + strictAssert.equal(typeof named.appendAttributes, 'function'); + strictAssert.equal(typeof named.isContextTruncated, 'function'); + }); + + it('resolves namedAttributes given as an object', () => { + const named = makeNamedContext(['http.method', 'http.route']); + let bytes: Uint8Array | undefined; + named.runWithContext( + () => { + bytes = _currentRecordBytes(); + }, + { + traceId: TRACE_ID_BYTES, + spanId: SPAN_ID_BYTES, + namedAttributes: {'http.method': 'GET', 'http.route': '/x'}, + }, + ); + strictAssert.deepEqual(decodeAttrs(bytes!), ['GET', '/x']); + }); + + it('resolves namedAttributes given as a Map', () => { + const named = makeNamedContext(['a', 'b']); + let bytes: Uint8Array | undefined; + named.runWithContext( + () => { + bytes = _currentRecordBytes(); + }, + { + traceId: TRACE_ID_BYTES, + spanId: SPAN_ID_BYTES, + namedAttributes: new Map([ + ['a', 'A'], + ['b', 'B'], + ]), + }, + ); + strictAssert.deepEqual(decodeAttrs(bytes!), ['A', 'B']); + }); + + it('resolves namedAttributes given as an array of pairs', () => { + const named = makeNamedContext(['a', 'b']); + let bytes: Uint8Array | undefined; + named.runWithContext( + () => { + bytes = _currentRecordBytes(); + }, + { + traceId: TRACE_ID_BYTES, + spanId: SPAN_ID_BYTES, + namedAttributes: [ + ['a', 'A'], + ['b', 'B'], + ], + }, + ); + strictAssert.deepEqual(decodeAttrs(bytes!), ['A', 'B']); + }); + + it('rejects unknown names', () => { + const named = makeNamedContext(['a']); + strictAssert.throws( + () => + named.runWithContext(() => undefined, { + traceId: TRACE_ID_BYTES, + spanId: SPAN_ID_BYTES, + namedAttributes: {unknown: 'v'}, + }), + /unknown attribute name: unknown/, + ); + }); + + it('coerces non-string values', () => { + const named = makeNamedContext(['n']); + let bytes: Uint8Array | undefined; + named.runWithContext( + () => { + bytes = _currentRecordBytes(); + }, + { + traceId: TRACE_ID_BYTES, + spanId: SPAN_ID_BYTES, + namedAttributes: {n: 7}, + }, + ); + strictAssert.deepEqual(decodeAttrs(bytes!), ['7']); + }); + + it('enterWithContext attaches a name-addressed record', () => { + const named = makeNamedContext(['route']); + runWithContext( + () => { + named.enterWithContext({ + traceId: TRACE_ID_BYTES, + spanId: SPAN_ID_BYTES, + namedAttributes: {route: '/x'}, + }); + strictAssert.deepEqual(decodeAttrs(_currentRecordBytes()!), ['/x']); + }, + {traceId: TRACE_ID_BYTES, spanId: SPAN_ID_BYTES}, + ); + }); + + it('appendAttributes appends by name', () => { + const named = makeNamedContext(['http.method', 'http.route', 'http.status']); + named.runWithContext( + () => { + named.appendAttributes({'http.status': '500'}); + strictAssert.deepEqual(decodeAttrs(_currentRecordBytes()!), [ + 'GET', + '/x', + '500', + ]); + }, + { + traceId: TRACE_ID_BYTES, + spanId: SPAN_ID_BYTES, + namedAttributes: {'http.method': 'GET', 'http.route': '/x'}, + }, + ); + }); + + it('appendAttributes rejects unknown names', () => { + const named = makeNamedContext(['known']); + named.runWithContext( + () => { + strictAssert.throws( + () => named.appendAttributes({unknown: 'v'}), + /unknown attribute name: unknown/, + ); + }, + { + traceId: TRACE_ID_BYTES, + spanId: SPAN_ID_BYTES, + namedAttributes: {known: 'k'}, + }, + ); + }); + + it('isContextTruncated mirrors the top-level function', () => { + const named = makeNamedContext(['a', 'b', 'c']); + named.runWithContext( + () => { + strictAssert.equal(named.isContextTruncated(), false); + appendAttributes([ + , + , + 'c'.repeat(255), + , + , + 'd'.repeat(255), + , + , + 'e'.repeat(255), + ]); + strictAssert.equal(named.isContextTruncated(), true); + }, + { + traceId: TRACE_ID_BYTES, + spanId: SPAN_ID_BYTES, + namedAttributes: {a: 'a', b: 'b'}, + }, + ); + }); + + describe('processContextAttributes', () => { + it('matches the input keys plus the V8 layout constants', () => { + const keys = ['http.method', 'http.route', 'user.id']; + const named = makeNamedContext(keys); + const pca = named.processContextAttributes; + strictAssert.equal(pca['threadlocal.schema_version'], 'nodejs_v1'); + strictAssert.deepEqual(pca['threadlocal.attribute_key_map'], keys); + strictAssert.equal(pca['threadlocal.nodejs_v1.wrapped_object_offset'], 24); + strictAssert.equal(pca['threadlocal.nodejs_v1.tagged_size'], 8); + strictAssert.deepEqual(Object.keys(pca).sort(), [ + 'threadlocal.attribute_key_map', + 'threadlocal.nodejs_v1.tagged_size', + 'threadlocal.nodejs_v1.wrapped_object_offset', + 'threadlocal.schema_version', + ]); + }); + + it('is frozen and a defensive copy', () => { + const keys = ['http.method', 'http.route']; + const named = makeNamedContext(keys); + const pca = named.processContextAttributes; + 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'); + 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 9b207e6b5e8df46df815984e9e2e5dc6c42d7e63 Mon Sep 17 00:00:00 2001 From: Attila Szegedi Date: Wed, 27 May 2026 17:56:24 +0200 Subject: [PATCH 02/15] Add test:docker harness for running tests on Linux from any host MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors the test:docker mechanism in custom-labels/js: a Dockerfile under scripts/docker/ extending node:24-bookworm with python3 and build-essential, plus a launcher script that builds the image (cached), mounts the repo read-only, copies it into /tmp/work inside the container, and runs `npm install && npm test`. The host tree is never modified (no stray node_modules/, build/, out/). Node 24 is used so the full test suite — including the new OTEP-4947 thread-context tests, which need AsyncContextFrame — runs without extra Node flags. Run via `npm run test:docker`. --- package.json | 3 ++- scripts/docker/Dockerfile | 14 +++++++++++++ scripts/docker/run-in-docker.sh | 37 +++++++++++++++++++++++++++++++++ 3 files changed, 53 insertions(+), 1 deletion(-) create mode 100644 scripts/docker/Dockerfile create mode 100755 scripts/docker/run-in-docker.sh diff --git a/package.json b/package.json index 166d33ea..9bd678cc 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,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 + ' From f07c4ce202d2f782e29546b4c9c51bccba99f885 Mon Sep 17 00:00:00 2001 From: Attila Szegedi Date: Wed, 10 Jun 2026 15:44:24 +0200 Subject: [PATCH 03/15] Address review feedback - Add a static_assert that offsetof(CtxWrap, record_) == sizeof(node::ObjectWrap), since that offset is part of the reader ABI. Restructure CtxWrap so record_, capacity_, and truncated_ live in a single public access section: C++ leaves cross-access-control field ordering implementation-defined, so splitting them would allow a conforming compiler to reorder the bookkeeping fields ahead of record_. - Add an acq_rel signal fence between the pointer swap and free() in the reallocate path. The pre-existing release fence only constrains prior writes; nothing was stopping the compiler from hoisting free() above the publication store, which would let a stopped reader follow self->record_ into freed memory. - Restore the [[unlikely]] annotation on the IsConstructCall() check. - Misc local cleanups (std::min/max in two spots, assert valid==1 after the memcpy instead of redundantly setting it). --- bindings/otel-thread-ctx.cc | 60 ++++++++++++++++++++++++++++++------- 1 file changed, 49 insertions(+), 11 deletions(-) diff --git a/bindings/otel-thread-ctx.cc b/bindings/otel-thread-ctx.cc index 99f867b4..fda41ce8 100644 --- a/bindings/otel-thread-ctx.cc +++ b/bindings/otel-thread-ctx.cc @@ -176,13 +176,45 @@ class CtxWrap : public ObjectWrap { 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_; - - CtxWrap(OtelThreadCtxRecord *record, size_t capacity, 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) @@ -254,7 +286,7 @@ void CtxWrap::New(const FunctionCallbackInfo &args) { Isolate *isolate = args.GetIsolate(); Local context = isolate->GetCurrentContext(); - if (!args.IsConstructCall()) { + if (!args.IsConstructCall()) [[unlikely]] { isolate->ThrowError("OtelThreadCtxWrap must be called with `new`"); return; } @@ -281,9 +313,7 @@ void CtxWrap::New(const FunctionCallbackInfo &args) { return; } - size_t capacity = attrs_buf.size() < MIN_INITIAL_CAPACITY - ? MIN_INITIAL_CAPACITY - : attrs_buf.size(); + 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) { @@ -353,9 +383,7 @@ void CtxWrap::Append(const FunctionCallbackInfo &args) { } // Doesn't fit. Reallocate with geometric growth, capped. - size_t new_cap = self->capacity_ * 2; - if (new_cap < new_used) new_cap = new_used; - if (new_cap > MAX_ATTRS_DATA_SIZE) new_cap = MAX_ATTRS_DATA_SIZE; + 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))); @@ -367,12 +395,22 @@ void CtxWrap::Append(const FunctionCallbackInfo &args) { sizeof(OtelThreadCtxRecord) + current_used); memcpy(&new_rec->attrs_data[current_used], appended.data(), appended.size()); new_rec->attrs_data_size = static_cast(new_used); - new_rec->valid = 1; - + // 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); } From 64d4d68fea6f18d66291b28c4378d6adfeacc4cf Mon Sep 17 00:00:00 2001 From: Attila Szegedi Date: Wed, 10 Jun 2026 15:47:31 +0200 Subject: [PATCH 04/15] Rename CtxWrap::Bytes to DebugBytes The CtxWrap accessor that returns the raw record as a Uint8Array is only intended for tests and out-of-process-reader development. Naming it DebugBytes (and exposing it as wrap.debugBytes() on the JS prototype) makes that explicit at every call site. --- bindings/otel-thread-ctx.cc | 8 ++++---- ts/src/otel-thread-ctx.ts | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/bindings/otel-thread-ctx.cc b/bindings/otel-thread-ctx.cc index fda41ce8..a5ab6844 100644 --- a/bindings/otel-thread-ctx.cc +++ b/bindings/otel-thread-ctx.cc @@ -168,7 +168,7 @@ class CtxWrap : public ObjectWrap { private: static void New(const FunctionCallbackInfo &args); - static void Bytes(const FunctionCallbackInfo &args); + static void DebugBytes(const FunctionCallbackInfo &args); static void Append(const FunctionCallbackInfo &args); static void IsTruncated(const FunctionCallbackInfo &args); @@ -423,7 +423,7 @@ void CtxWrap::IsTruncated(const FunctionCallbackInfo &args) { args.GetReturnValue().Set(self->truncated_); } -void CtxWrap::Bytes(const FunctionCallbackInfo &args) { +void CtxWrap::DebugBytes(const FunctionCallbackInfo &args) { Isolate *isolate = args.GetIsolate(); CtxWrap *self = ObjectWrap::Unwrap(args.This()); if (!self) { @@ -450,8 +450,8 @@ void CtxWrap::Init(Local exports) { tpl->InstanceTemplate()->SetInternalFieldCount(1); tpl->PrototypeTemplate()->Set( - String::NewFromUtf8Literal(isolate, "bytes"), - FunctionTemplate::New(isolate, Bytes)); + String::NewFromUtf8Literal(isolate, "debugBytes"), + FunctionTemplate::New(isolate, DebugBytes)); tpl->PrototypeTemplate()->Set( String::NewFromUtf8Literal(isolate, "append"), FunctionTemplate::New(isolate, Append)); diff --git a/ts/src/otel-thread-ctx.ts b/ts/src/otel-thread-ctx.ts index ff453ecb..c6b516d8 100644 --- a/ts/src/otel-thread-ctx.ts +++ b/ts/src/otel-thread-ctx.ts @@ -89,7 +89,7 @@ export interface NamedContext { } interface CtxWrap { - bytes(): Uint8Array; + debugBytes(): Uint8Array; append(attributes: Array | undefined): void; isTruncated(): boolean; } @@ -227,7 +227,7 @@ if (process.platform === 'linux') { if (!als) return undefined; const wrap = als.getStore(); if (!wrap) return undefined; - return wrap.bytes(); + return wrap.debugBytes(); }; } else { runWithContext = function (fn: () => T, _opts: ContextOptions): T { From d32acc58824da2659ef663ab4f8c39673ac3830c Mon Sep 17 00:00:00 2001 From: Attila Szegedi Date: Thu, 11 Jun 2026 17:06:52 +0200 Subject: [PATCH 05/15] Propagate V8 pending exception on ToString failure instead of overwriting it --- bindings/otel-thread-ctx.cc | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/bindings/otel-thread-ctx.cc b/bindings/otel-thread-ctx.cc index a5ab6844..bfb10625 100644 --- a/bindings/otel-thread-ctx.cc +++ b/bindings/otel-thread-ctx.cc @@ -255,10 +255,7 @@ bool CtxWrap::EncodeAttrs(Isolate *isolate, Local context, if (val_val->IsUndefined() || val_val->IsNull()) continue; Local v; - if (!val_val->ToString(context).ToLocal(&v)) { - isolate->ThrowError("failed to coerce attribute value to string"); - return false; - } + if (!val_val->ToString(context).ToLocal(&v)) return false; int v_utf8_len = v->Utf8Length(isolate); int v_budget = v_utf8_len > 255 ? 255 : v_utf8_len; From 937b86b010f2394ef1437272292ea47d3a95982c Mon Sep 17 00:00:00 2001 From: Attila Szegedi Date: Thu, 11 Jun 2026 17:45:51 +0200 Subject: [PATCH 06/15] Compile addon on Node < 22 by guarding the V8 CPED offset lookup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit v8::internal::Internals::kContinuationPreservedEmbedderDataOffset was introduced in Node 22. Older versions don't have ContinuationPreservedEmbedderData at all, and the TS layer already refuses to install the hook there via asyncContextFrameError, so StoreAls is never actually invoked on Node < 22 — we just need the addon to compile so the package installs. --- bindings/otel-thread-ctx.cc | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/bindings/otel-thread-ctx.cc b/bindings/otel-thread-ctx.cc index bfb10625..22684b14 100644 --- a/bindings/otel-thread-ctx.cc +++ b/bindings/otel-thread-ctx.cc @@ -487,9 +487,18 @@ void StoreAls(const FunctionCallbackInfo &args) { 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. From d4ef05069374bbcb1fe74e92fe760712a67bebef Mon Sep 17 00:00:00 2001 From: Attila Szegedi Date: Thu, 11 Jun 2026 18:06:40 +0200 Subject: [PATCH 07/15] Compile addon against older V8 ABIs (Node 18 prebuild targets) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two more Node-version-sensitive spots blocking the prebuild against Node 18.0.0 headers: - ArrayBuffer::Data() wasn't exposed in V8 10.1 (Node 18.0). Switch to GetBackingStore()->Data(), which has been available since V8 7.4 / Node 12. The shared_ptr atomic is a per-call cost in CopyBytes (twice per CtxWrap::New) and DebugBytes — neither is a hot path. - kEmbedderDataSlotExternalPointerOffset is Node 22+. Same shape as the earlier kContinuationPreservedEmbedderDataOffset guard: publish a sentinel 0 on older Node so the addon's exported surface stays consistent across majors. A would-be reader can't reach a live record through it anyway (no CPED on Node < 22). --- bindings/otel-thread-ctx.cc | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/bindings/otel-thread-ctx.cc b/bindings/otel-thread-ctx.cc index 22684b14..538facf7 100644 --- a/bindings/otel-thread-ctx.cc +++ b/bindings/otel-thread-ctx.cc @@ -228,7 +228,8 @@ bool CopyBytes(Local value, size_t expected_bytes, uint8_t *out) { Local arr = value.As(); if (arr->ByteLength() != expected_bytes) return false; uint8_t *base = - static_cast(arr->Buffer()->Data()) + arr->ByteOffset(); + static_cast(arr->Buffer()->GetBackingStore()->Data()) + + arr->ByteOffset(); memcpy(out, base, expected_bytes); return true; } @@ -430,7 +431,7 @@ void CtxWrap::DebugBytes(const FunctionCallbackInfo &args) { const size_t total = sizeof(OtelThreadCtxRecord) + self->record_->attrs_data_size; Local buf = v8::ArrayBuffer::New(isolate, total); - memcpy(buf->Data(), self->record_, total); + memcpy(buf->GetBackingStore()->Data(), self->record_, total); args.GetReturnValue().Set(Uint8Array::New(buf, 0, total)); } @@ -524,9 +525,18 @@ void GetStoredAlsHash(const FunctionCallbackInfo &args) { // 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 From 9e7e52c55ea65759d3c2c40ffbbf5654d95d0716 Mon Sep 17 00:00:00 2001 From: Attila Szegedi Date: Thu, 11 Jun 2026 18:31:32 +0200 Subject: [PATCH 08/15] Compile addon on Node 26 (V8 14.x V2 string API + Object::GetIsolate removal) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Node 26 ships V8 14.x, which removes the old String::Utf8Length / WriteUtf8 / NO_NULL_TERMINATION trio in favor of the V2 versions, and removes Object::GetIsolate() entirely. Switch the encode loop to the V2 forms on Node >= 24 (Node 22 ships V8 12.4 which never gets V2; Node 24's V8 13.6 has both, Node 26's V8 14.x has only V2). Replace exports->GetIsolate() with Isolate::GetCurrent() unconditionally — they're equivalent during module init and the latter is the only version that survives Node 26. --- bindings/otel-thread-ctx.cc | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/bindings/otel-thread-ctx.cc b/bindings/otel-thread-ctx.cc index 538facf7..ee172e36 100644 --- a/bindings/otel-thread-ctx.cc +++ b/bindings/otel-thread-ctx.cc @@ -257,7 +257,11 @@ bool CtxWrap::EncodeAttrs(Isolate *isolate, Local context, 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 int v_budget = v_utf8_len > 255 ? 255 : v_utf8_len; const size_t needed = 2u + static_cast(v_budget); @@ -269,9 +273,15 @@ bool CtxWrap::EncodeAttrs(Isolate *isolate, Local context, const size_t entry_off = out->size(); out->resize(entry_off + needed); (*out)[entry_off] = static_cast(i); +#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)); @@ -436,11 +446,7 @@ void CtxWrap::DebugBytes(const FunctionCallbackInfo &args) { } void CtxWrap::Init(Local exports) { -#if NODE_MAJOR_VERSION >= 26 Isolate *isolate = Isolate::GetCurrent(); -#else - Isolate *isolate = exports->GetIsolate(); -#endif Local context = isolate->GetCurrentContext(); Local tpl = FunctionTemplate::New(isolate, New); @@ -546,7 +552,7 @@ void OtelThreadCtx::Init(Local exports) { NODE_SET_METHOD(exports, "otelThreadCtxStoreAls", StoreAls); NODE_SET_METHOD(exports, "otelThreadCtxGetStoredAlsHash", GetStoredAlsHash); - Isolate *isolate = exports->GetIsolate(); + Isolate *isolate = Isolate::GetCurrent(); Local ctx = isolate->GetCurrentContext(); exports ->Set(ctx, From b3f0812998e76dd362af2e9aebbe583929fa79db Mon Sep 17 00:00:00 2001 From: Attila Szegedi Date: Thu, 11 Jun 2026 18:31:44 +0200 Subject: [PATCH 09/15] Fix TS lint: prettier formatting, strict equality, unused-vars - Reformat ts/test/test-otel-thread-ctx.ts via gts (prettier). - Drop unused parameter declarations from the non-Linux stubs in ts/src/otel-thread-ctx.ts (they were carrying _-prefix names that gts's eslint still flags); TS allows fewer params on the assigned function than the declared variable's signature requires. - Use strict ==/!= equality instead of loose null-check. - Disable no-sparse-arrays in the test file: holes in attribute arrays are part of the wire format we're verifying. - Use `void` prefix on the runWithContext() call inside the sync test whose return type confuses no-floating-promises. --- bindings/otel-thread-ctx.cc | 161 ++-- ts/src/otel-thread-ctx.ts | 11 +- ts/test/test-otel-thread-ctx.ts | 1431 ++++++++++++++++--------------- 3 files changed, 855 insertions(+), 748 deletions(-) diff --git a/bindings/otel-thread-ctx.cc b/bindings/otel-thread-ctx.cc index ee172e36..54d529e4 100644 --- a/bindings/otel-thread-ctx.cc +++ b/bindings/otel-thread-ctx.cc @@ -61,28 +61,28 @@ using v8::Global; using v8::Object; struct otel_thread_ctx_nodejs_v1_t { - v8::internal::Address *cped_slot; // offset 0 + 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 }; -__attribute__((visibility("default"))) -thread_local otel_thread_ctx_nodejs_v1_t otel_thread_ctx_nodejs_v1; +__attribute__((visibility("default"))) thread_local otel_thread_ctx_nodejs_v1_t + otel_thread_ctx_nodejs_v1; } -static_assert(sizeof(v8::Global) == sizeof(void *), +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 *), + sizeof(void*), "als_handle must immediately follow cped_slot"); static_assert(offsetof(otel_thread_ctx_nodejs_v1_t, als_identity_hash) == - 2 * sizeof(void *), + 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 *), + 3 * sizeof(void*), "undefined_addr must follow als_identity_hash + padding"); namespace dd { @@ -129,7 +129,7 @@ static_assert(offsetof(OtelThreadCtxRecord, attrs_data) == 28, "attrs_data offset"); struct OtelThreadCtxRecordDeleter { - void operator()(OtelThreadCtxRecord *p) const noexcept { free(p); } + void operator()(OtelThreadCtxRecord* p) const noexcept { free(p); } }; using OwnedRecord = std::unique_ptr; @@ -161,22 +161,25 @@ class CtxWrap : public ObjectWrap { ~CtxWrap() override; static void Init(Local exports); - CtxWrap(const CtxWrap &) = delete; - CtxWrap &operator=(const CtxWrap &) = delete; - CtxWrap(CtxWrap &&) = delete; - CtxWrap &operator=(CtxWrap &&) noexcept = delete; + 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); + static void New(const FunctionCallbackInfo& args); + static void DebugBytes(const FunctionCallbackInfo& args); + static void Append(const FunctionCallbackInfo& args); + static void IsTruncated(const FunctionCallbackInfo& args); - static bool EncodeAttrs(Isolate *isolate, Local context, - Local attrs_val, size_t existing_size, - std::vector *out, bool *out_truncated); + 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); + 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 @@ -188,7 +191,7 @@ class CtxWrap : public ObjectWrap { // exposing them publicly keeps everything in one ordering-stable // block. Readers never touch them. public: - OtelThreadCtxRecord *record_; + 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`. @@ -215,28 +218,33 @@ static_assert(offsetof(CtxWrap, record_) == sizeof(node::ObjectWrap), "subobject"); #pragma GCC diagnostic pop -CtxWrap::~CtxWrap() { free(record_); } +CtxWrap::~CtxWrap() { + free(record_); +} -CtxWrap::CtxWrap(OtelThreadCtxRecord *record, size_t capacity, bool truncated) +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) { +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()) + + uint8_t* base = + static_cast(arr->Buffer()->GetBackingStore()->Data()) + arr->ByteOffset(); memcpy(out, base, expected_bytes); return true; } -bool CtxWrap::EncodeAttrs(Isolate *isolate, Local context, - Local attrs_val, size_t existing_size, - std::vector *out, bool *out_truncated) { +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( @@ -274,13 +282,18 @@ bool CtxWrap::EncodeAttrs(Isolate *isolate, Local context, out->resize(entry_off + needed); (*out)[entry_off] = static_cast(i); #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)); + 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); + 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) { @@ -290,8 +303,8 @@ bool CtxWrap::EncodeAttrs(Isolate *isolate, Local context, return true; } -void CtxWrap::New(const FunctionCallbackInfo &args) { - Isolate *isolate = args.GetIsolate(); +void CtxWrap::New(const FunctionCallbackInfo& args) { + Isolate* isolate = args.GetIsolate(); Local context = isolate->GetCurrentContext(); if (!args.IsConstructCall()) [[unlikely]] { @@ -323,7 +336,7 @@ void CtxWrap::New(const FunctionCallbackInfo &args) { 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))); + OwnedRecord record(static_cast(calloc(1, total))); if (!record) { isolate->ThrowError("allocation failed"); return; @@ -338,9 +351,9 @@ void CtxWrap::New(const FunctionCallbackInfo &args) { // OTEP-4947 publication protocol: order the `valid = 1` store after every // other field write, with an atomic_signal_fence + volatile store. std::atomic_signal_fence(std::memory_order_release); - *reinterpret_cast(&record->valid) = 1; + *reinterpret_cast(&record->valid) = 1; - CtxWrap *self = new CtxWrap(record.release(), capacity, truncated); + CtxWrap* self = new CtxWrap(record.release(), capacity, truncated); self->Wrap(args.This()); args.GetReturnValue().Set(args.This()); } @@ -349,11 +362,11 @@ void CtxWrap::New(const FunctionCallbackInfo &args) { // place (if the appended bytes fit in the current allocation's slack) or // reallocates to a larger one (geometrically), keeping the invariant // `record_->attrs_data_size <= capacity_`. -void CtxWrap::Append(const FunctionCallbackInfo &args) { - Isolate *isolate = args.GetIsolate(); +void CtxWrap::Append(const FunctionCallbackInfo& args) { + Isolate* isolate = args.GetIsolate(); Local context = isolate->GetCurrentContext(); - CtxWrap *self = ObjectWrap::Unwrap(args.This()); + CtxWrap* self = ObjectWrap::Unwrap(args.This()); if (!self) { isolate->ThrowError("not an OtelThreadCtxWrap"); return; @@ -366,8 +379,8 @@ void CtxWrap::Append(const FunctionCallbackInfo &args) { 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)) { + if (!EncodeAttrs( + isolate, context, args[0], current_used, &appended, &truncated)) { return; } if (truncated) self->truncated_ = true; @@ -382,25 +395,27 @@ void CtxWrap::Append(const FunctionCallbackInfo &args) { // attrs_data_size is the publication boundary — bytes past it are // not observable by the reader, so a reader firing mid-append sees // either the old or new size, never a torn state. - memcpy(&self->record_->attrs_data[current_used], appended.data(), + 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) = + *reinterpret_cast(&self->record_->attrs_data_size) = static_cast(new_used); return; } // Doesn't fit. Reallocate with geometric growth, capped. - size_t new_cap = std::min(std::max(self->capacity_ * 2, new_used), MAX_ATTRS_DATA_SIZE); + 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))); + OwnedRecord new_rec(static_cast(calloc(1, total))); if (!new_rec) { isolate->ThrowError("allocation failed"); return; } - memcpy(new_rec.get(), self->record_, - sizeof(OtelThreadCtxRecord) + current_used); + memcpy( + new_rec.get(), self->record_, sizeof(OtelThreadCtxRecord) + current_used); 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. @@ -415,15 +430,15 @@ void CtxWrap::Append(const FunctionCallbackInfo &args) { // 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_; + 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); } -void CtxWrap::IsTruncated(const FunctionCallbackInfo &args) { - CtxWrap *self = ObjectWrap::Unwrap(args.This()); +void CtxWrap::IsTruncated(const FunctionCallbackInfo& args) { + CtxWrap* self = ObjectWrap::Unwrap(args.This()); if (!self) { args.GetIsolate()->ThrowError("not an OtelThreadCtxWrap"); return; @@ -431,9 +446,9 @@ void CtxWrap::IsTruncated(const FunctionCallbackInfo &args) { args.GetReturnValue().Set(self->truncated_); } -void CtxWrap::DebugBytes(const FunctionCallbackInfo &args) { - Isolate *isolate = args.GetIsolate(); - CtxWrap *self = ObjectWrap::Unwrap(args.This()); +void CtxWrap::DebugBytes(const FunctionCallbackInfo& args) { + Isolate* isolate = args.GetIsolate(); + CtxWrap* self = ObjectWrap::Unwrap(args.This()); if (!self) { isolate->ThrowError("not an OtelThreadCtxWrap"); return; @@ -446,7 +461,7 @@ void CtxWrap::DebugBytes(const FunctionCallbackInfo &args) { } void CtxWrap::Init(Local exports) { - Isolate *isolate = Isolate::GetCurrent(); + Isolate* isolate = Isolate::GetCurrent(); Local context = isolate->GetCurrentContext(); Local tpl = FunctionTemplate::New(isolate, New); @@ -456,16 +471,16 @@ void CtxWrap::Init(Local exports) { tpl->PrototypeTemplate()->Set( String::NewFromUtf8Literal(isolate, "debugBytes"), FunctionTemplate::New(isolate, DebugBytes)); - tpl->PrototypeTemplate()->Set( - String::NewFromUtf8Literal(isolate, "append"), - FunctionTemplate::New(isolate, Append)); + tpl->PrototypeTemplate()->Set(String::NewFromUtf8Literal(isolate, "append"), + 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, "otelThreadCtxWrap"), + ->Set(context, + String::NewFromUtf8Literal(isolate, "otelThreadCtxWrap"), constructor) .FromJust(); } @@ -476,17 +491,17 @@ void CtxWrap::Init(Local exports) { // 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*/) { +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) { +void StoreAls(const FunctionCallbackInfo& args) { static thread_local bool cleanup_registered = false; - Isolate *isolate = args.GetIsolate(); + Isolate* isolate = args.GetIsolate(); if (!args[0]->IsObject()) { isolate->ThrowError("First argument must be the AsyncLocalStorage object."); return; @@ -495,9 +510,10 @@ void StoreAls(const FunctionCallbackInfo &args) { 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); + 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 @@ -520,8 +536,8 @@ void StoreAls(const FunctionCallbackInfo &args) { // 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(); +void GetStoredAlsHash(const FunctionCallbackInfo& args) { + Isolate* isolate = args.GetIsolate(); args.GetReturnValue().Set( Integer::New(isolate, otel_thread_ctx_nodejs_v1.als_identity_hash)); } @@ -552,11 +568,12 @@ void OtelThreadCtx::Init(Local exports) { NODE_SET_METHOD(exports, "otelThreadCtxStoreAls", StoreAls); NODE_SET_METHOD(exports, "otelThreadCtxGetStoredAlsHash", GetStoredAlsHash); - Isolate *isolate = Isolate::GetCurrent(); + Isolate* isolate = Isolate::GetCurrent(); Local ctx = isolate->GetCurrentContext(); exports ->Set(ctx, - String::NewFromUtf8Literal(isolate, "otelThreadCtxWrappedObjectOffset"), + String::NewFromUtf8Literal(isolate, + "otelThreadCtxWrappedObjectOffset"), Integer::New(isolate, WRAPPED_OBJECT_OFFSET)) .FromJust(); exports diff --git a/ts/src/otel-thread-ctx.ts b/ts/src/otel-thread-ctx.ts index c6b516d8..c2f1eb4b 100644 --- a/ts/src/otel-thread-ctx.ts +++ b/ts/src/otel-thread-ctx.ts @@ -134,7 +134,6 @@ export let isContextTruncated: () => boolean; 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; @@ -230,14 +229,12 @@ if (process.platform === 'linux') { return wrap.debugBytes(); }; } else { - runWithContext = function (fn: () => T, _opts: ContextOptions): T { + runWithContext = function (fn: () => T): T { return fn(); }; - enterWithContext = function (_opts: ContextOptions): void {}; + enterWithContext = function (): void {}; clearContext = function (): void {}; - appendAttributes = function ( - _attributes: Array, - ): void {}; + appendAttributes = function (): void {}; isContextTruncated = function (): boolean { return false; }; @@ -279,7 +276,7 @@ export function makeNamedContext(keys: string[]): NamedContext { | Array<[string, unknown]> | undefined, ): Array | undefined { - if (named == null) return undefined; + if (named === null || named === undefined) return undefined; const attributes: Array = []; const set = (name: string, value: unknown) => { const idx = indexByName.get(name); diff --git a/ts/test/test-otel-thread-ctx.ts b/ts/test/test-otel-thread-ctx.ts index 3cc140af..3979a816 100644 --- a/ts/test/test-otel-thread-ctx.ts +++ b/ts/test/test-otel-thread-ctx.ts @@ -14,6 +14,10 @@ * limitations under the License. */ +// 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'; @@ -50,7 +54,10 @@ interface Header { } function decodeHeader(bytes: Uint8Array): Header { - strictAssert.ok(bytes.length >= 28, `record must be at least 28 bytes, got ${bytes.length}`); + 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, @@ -80,7 +87,11 @@ function decodeAttrs(bytes: Uint8Array): Array { out[idx] = Buffer.from(bytes.slice(i, i + len)).toString('utf8'); i += len; } - strictAssert.equal(i, end, 'attrs payload must be exactly attrsDataSize bytes'); + strictAssert.equal( + i, + end, + 'attrs payload must be exactly attrsDataSize bytes', + ); return out; } @@ -96,761 +107,843 @@ function captureBytes(opts: { return bytes as Uint8Array; } -(isLinux ? describe : describe.skip)('OTEP-4947 thread context (Linux-only)', () => { - describe('CtxWrap 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), +(isLinux ? describe : describe.skip)( + 'OTEP-4947 thread context (Linux-only)', + () => { + describe('CtxWrap 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); }); - 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('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 non-Uint8Array traceId', () => { - strictAssert.throws( - () => - captureBytes({ - traceId: 'a'.repeat(32) as unknown as Uint8Array, - spanId: SPAN_ID_BYTES, - }), - /traceId must be/, - ); - }); - }); + it('rejects wrong-length traceId', () => { + strictAssert.throws( + () => + captureBytes({traceId: new Uint8Array(8), 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('rejects wrong-length spanId', () => { + strictAssert.throws( + () => + captureBytes({traceId: TRACE_ID_BYTES, spanId: new Uint8Array(4)}), + /spanId must be/, + ); + }); - it('encodes attributes by position', () => { - const bytes = captureBytes({ - traceId: TRACE_ID_BYTES, - spanId: SPAN_ID_BYTES, - attributes: ['GET', '/api/v1/widgets'], + it('rejects non-Uint8Array traceId', () => { + strictAssert.throws( + () => + captureBytes({ + traceId: 'a'.repeat(32) as unknown as Uint8Array, + spanId: SPAN_ID_BYTES, + }), + /traceId must be/, + ); }); - 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'], + 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); }); - 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, + 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']); }); - 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], + 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']); }); - 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], + 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']); }); - 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)], + 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']); }); - 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) + 'éé'], + 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)]); }); - 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('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); - 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'], + 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); }); - 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; - runWithContext( - () => { - bytes = _currentRecordBytes(); - truncated = isContextTruncated(); - }, - {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('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('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('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('rejects non-array attributes argument', () => { - strictAssert.throws( - () => - captureBytes({ + 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; + runWithContext( + () => { + bytes = _currentRecordBytes(); + truncated = isContextTruncated(); + }, + { traceId: TRACE_ID_BYTES, spanId: SPAN_ID_BYTES, - attributes: {not: 'an array'} as unknown as Array, - }), - /attributes must be an array/, - ); - }); - }); + attributes: [a, b, c, d], + }, + ); + strictAssert.deepEqual(decodeAttrs(bytes!), [a, b, , d]); + strictAssert.equal(decodeHeader(bytes!).attrsDataSize, 514 + 32); + strictAssert.equal(truncated, true); + }); - describe('runWithContext lifecycle', () => { - it('returns the callback result', () => { - const result = runWithContext(() => 'ok', { - traceId: TRACE_ID_BYTES, - spanId: SPAN_ID_BYTES, + 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/, + ); }); - strictAssert.equal(result, 'ok'); - }); - it('has no active record outside the call', () => { - strictAssert.equal(_currentRecordBytes(), undefined); + 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/, + ); + }); }); - it('has no active record after the call returns', () => { - runWithContext(() => undefined, { - traceId: TRACE_ID_BYTES, - spanId: SPAN_ID_BYTES, + describe('runWithContext lifecycle', () => { + it('returns the callback result', () => { + const result = runWithContext(() => 'ok', { + traceId: TRACE_ID_BYTES, + spanId: SPAN_ID_BYTES, + }); + strictAssert.equal(result, 'ok'); }); - 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}; + it('has no active record outside the call', () => { + strictAssert.equal(_currentRecordBytes(), undefined); + }); - runWithContext(() => { - const outerBefore = decodeHeader(_currentRecordBytes()!).spanId; - runWithContext(() => { - 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('has no active record after the call returns', () => { + runWithContext(() => undefined, { + traceId: TRACE_ID_BYTES, + spanId: SPAN_ID_BYTES, + }); + strictAssert.equal(_currentRecordBytes(), undefined); + }); - it('keeps the same record after awaits', async () => { - await runWithContext(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('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}; - it('keeps concurrent async calls isolated', async () => { - const aSpan = bytesFromHex('1111111111111111'); - const bSpan = bytesFromHex('2222222222222222'); + runWithContext(() => { + const outerBefore = decodeHeader(_currentRecordBytes()!).spanId; + runWithContext(() => { + 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); + }); - async function run(spanBytes: Uint8Array) { - return runWithContext(async () => { - const observed: Uint8Array[] = []; - for (let i = 0; i < 4; i++) { - observed.push(decodeHeader(_currentRecordBytes()!).spanId); + it('keeps the same record after awaits', async () => { + await runWithContext( + async () => { + const before = 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', () => { - runWithContext(() => { - strictAssert.deepEqual( - decodeHeader(_currentRecordBytes()!).spanId, - SPAN_ID_BYTES, + 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}, ); + }); - const newSpan = bytesFromHex('aabbccddeeff0011'); - enterWithContext({traceId: TRACE_ID_BYTES, spanId: newSpan}); - strictAssert.deepEqual(decodeHeader(_currentRecordBytes()!).spanId, newSpan); - - return Promise.resolve().then(() => { - strictAssert.deepEqual( - decodeHeader(_currentRecordBytes()!).spanId, - newSpan, + it('keeps concurrent async calls isolated', async () => { + const aSpan = bytesFromHex('1111111111111111'); + const bSpan = bytesFromHex('2222222222222222'); + + async function run(spanBytes: Uint8Array) { + return runWithContext( + 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}, ); - }); - }, {traceId: TRACE_ID_BYTES, spanId: SPAN_ID_BYTES}); + } - strictAssert.equal(_currentRecordBytes(), undefined); + 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); + }); }); - it('requires an options object', () => { - strictAssert.throws( - () => enterWithContext(undefined as unknown as ContextOptions), - /options object required/, - ); - }); - }); - - describe('clearContext', () => { - it('detaches the active record within a scope', () => { - runWithContext( - () => { - strictAssert.ok(_currentRecordBytes()); - clearContext(); - strictAssert.equal(_currentRecordBytes(), undefined); - }, - {traceId: TRACE_ID_BYTES, spanId: SPAN_ID_BYTES}, - ); - }); + describe('enterWithContext', () => { + it('attaches the record to the current async scope', () => { + void runWithContext( + () => { + strictAssert.deepEqual( + decodeHeader(_currentRecordBytes()!).spanId, + SPAN_ID_BYTES, + ); - it('makes appendAttributes throw and isContextTruncated return false', () => { - runWithContext( - () => { - clearContext(); - strictAssert.throws( - () => appendAttributes(['v']), - /no active thread context/, - ); - strictAssert.equal(isContextTruncated(), 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); - runWithContext( - () => { - clearContext(); - clearContext(); - strictAssert.equal(_currentRecordBytes(), undefined); - }, - {traceId: TRACE_ID_BYTES, spanId: SPAN_ID_BYTES}, - ); - }); + const newSpan = bytesFromHex('aabbccddeeff0011'); + enterWithContext({traceId: TRACE_ID_BYTES, spanId: newSpan}); + strictAssert.deepEqual( + decodeHeader(_currentRecordBytes()!).spanId, + newSpan, + ); - it('lets a nested runWithContext re-establish a record', () => { - runWithContext( - () => { - clearContext(); - const innerSpan = bytesFromHex('aabbccddeeff0011'); - runWithContext( - () => { + return Promise.resolve().then(() => { strictAssert.deepEqual( decodeHeader(_currentRecordBytes()!).spanId, - innerSpan, + newSpan, ); - }, - {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', () => { - runWithContext( - () => { - clearContext(); - const newSpan = bytesFromHex('aabbccddeeff0011'); - enterWithContext({traceId: TRACE_ID_BYTES, spanId: newSpan}); - strictAssert.deepEqual( - decodeHeader(_currentRecordBytes()!).spanId, - newSpan, - ); - }, - {traceId: TRACE_ID_BYTES, spanId: SPAN_ID_BYTES}, - ); - }); + }); + }, + {traceId: TRACE_ID_BYTES, spanId: SPAN_ID_BYTES}, + ); - it('named.clearContext detaches the active record', () => { - const named = makeNamedContext(['route']); - named.runWithContext( - () => { - strictAssert.ok(_currentRecordBytes()); - named.clearContext(); - strictAssert.equal(_currentRecordBytes(), undefined); - }, - { - traceId: TRACE_ID_BYTES, - spanId: SPAN_ID_BYTES, - namedAttributes: {route: '/x'}, - }, - ); - }); - }); - - describe('appendAttributes', () => { - it('adds entries to the current record', () => { - runWithContext( - () => { - strictAssert.deepEqual(decodeAttrs(_currentRecordBytes()!), ['GET']); - appendAttributes([, , '200']); - strictAssert.deepEqual(decodeAttrs(_currentRecordBytes()!), [ - 'GET', - , - '200', - ]); - }, - {traceId: TRACE_ID_BYTES, spanId: SPAN_ID_BYTES, attributes: ['GET']}, - ); - }); + strictAssert.equal(_currentRecordBytes(), undefined); + }); - it('writes in-place when bytes fit in the slack', () => { - runWithContext( - () => { - const before = _currentRecordBytes()!; - appendAttributes([, '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('requires an options object', () => { + strictAssert.throws( + () => enterWithContext(undefined as unknown as ContextOptions), + /options object required/, + ); + }); }); - it('grows the record geometrically when slack runs out', () => { - runWithContext(() => { - const v = 'y'.repeat(60); - for (let i = 0; i < 8; i++) { - const append: Array = []; - append[i] = v; - appendAttributes(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, + describe('clearContext', () => { + it('detaches the active record within a scope', () => { + runWithContext( + () => { + strictAssert.ok(_currentRecordBytes()); + clearContext(); + strictAssert.equal(_currentRecordBytes(), undefined); + }, + {traceId: TRACE_ID_BYTES, spanId: SPAN_ID_BYTES}, ); - }, {traceId: TRACE_ID_BYTES, spanId: SPAN_ID_BYTES}); - }); + }); - it('throws when there is no current context', () => { - strictAssert.throws(() => appendAttributes(['v']), /no active thread context/); - }); + it('makes appendAttributes throw and isContextTruncated return false', () => { + runWithContext( + () => { + clearContext(); + strictAssert.throws( + () => appendAttributes(['v']), + /no active thread context/, + ); + strictAssert.equal(isContextTruncated(), false); + }, + {traceId: TRACE_ID_BYTES, spanId: SPAN_ID_BYTES}, + ); + }); - it('is a no-op when given an empty array', () => { - runWithContext(() => { - const before = _currentRecordBytes(); - appendAttributes([]); - const after = _currentRecordBytes(); - strictAssert.deepEqual(after, before); - }, {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); + runWithContext( + () => { + clearContext(); + clearContext(); + strictAssert.equal(_currentRecordBytes(), undefined); + }, + {traceId: TRACE_ID_BYTES, spanId: SPAN_ID_BYTES}, + ); + }); - it('is a no-op when all slots are null/undefined', () => { - runWithContext(() => { - const before = _currentRecordBytes(); - appendAttributes([null, undefined, , null]); - const after = _currentRecordBytes(); - strictAssert.deepEqual(after, before); - }, {traceId: TRACE_ID_BYTES, spanId: SPAN_ID_BYTES}); - }); + it('lets a nested runWithContext re-establish a record', () => { + runWithContext( + () => { + clearContext(); + const innerSpan = bytesFromHex('aabbccddeeff0011'); + runWithContext( + () => { + 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('silently drops entries past the 612-byte cap and sets the truncated flag', () => { - const big = 'a'.repeat(255); - runWithContext(() => { - appendAttributes([big, big]); - strictAssert.equal(isContextTruncated(), false); - appendAttributes([, , big]); - strictAssert.equal(isContextTruncated(), true); - strictAssert.equal(decodeHeader(_currentRecordBytes()!).attrsDataSize, 514); - const small = 'x'.repeat(30); - appendAttributes([, , , 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(isContextTruncated(), true); - }, {traceId: TRACE_ID_BYTES, spanId: SPAN_ID_BYTES}); - }); + it('lets enterWithContext re-establish a record', () => { + runWithContext( + () => { + clearContext(); + const newSpan = bytesFromHex('aabbccddeeff0011'); + enterWithContext({traceId: TRACE_ID_BYTES, spanId: newSpan}); + strictAssert.deepEqual( + decodeHeader(_currentRecordBytes()!).spanId, + newSpan, + ); + }, + {traceId: TRACE_ID_BYTES, spanId: SPAN_ID_BYTES}, + ); + }); - it('propagates through async continuations', async () => { - await runWithContext( - async () => { - appendAttributes([, 'after-await']); - await Promise.resolve(); - strictAssert.deepEqual(decodeAttrs(_currentRecordBytes()!), [ - 'before', - 'after-await', - ]); - }, - { - traceId: TRACE_ID_BYTES, - spanId: SPAN_ID_BYTES, - attributes: ['before'], - }, - ); + it('named.clearContext detaches the active record', () => { + const named = makeNamedContext(['route']); + named.runWithContext( + () => { + strictAssert.ok(_currentRecordBytes()); + named.clearContext(); + strictAssert.equal(_currentRecordBytes(), undefined); + }, + { + traceId: TRACE_ID_BYTES, + spanId: SPAN_ID_BYTES, + namedAttributes: {route: '/x'}, + }, + ); + }); }); - }); - describe('isContextTruncated', () => { - it('returns false outside a context', () => { - strictAssert.equal(isContextTruncated(), false); - }); + describe('appendAttributes', () => { + it('adds entries to the current record', () => { + runWithContext( + () => { + strictAssert.deepEqual(decodeAttrs(_currentRecordBytes()!), [ + 'GET', + ]); + appendAttributes([, , '200']); + strictAssert.deepEqual(decodeAttrs(_currentRecordBytes()!), [ + 'GET', + , + '200', + ]); + }, + {traceId: TRACE_ID_BYTES, spanId: SPAN_ID_BYTES, attributes: ['GET']}, + ); + }); - it('returns false for a non-truncated record', () => { - runWithContext( - () => { - strictAssert.equal(isContextTruncated(), false); - }, - { - traceId: TRACE_ID_BYTES, - spanId: SPAN_ID_BYTES, - attributes: ['GET', '/x'], - }, - ); - }); - }); - - describe('makeNamedContext', () => { - it('rejects non-array keys', () => { - strictAssert.throws( - () => makeNamedContext({} as unknown as string[]), - /must be an array/, - ); - }); + it('writes in-place when bytes fit in the slack', () => { + runWithContext( + () => { + const before = _currentRecordBytes()!; + appendAttributes([, '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('rejects more than 256 keys', () => { - const tooMany = Array.from({length: 257}, (_, i) => `k${i}`); - strictAssert.throws(() => makeNamedContext(tooMany), /exceeds 256/); - }); + it('grows the record geometrically when slack runs out', () => { + runWithContext( + () => { + const v = 'y'.repeat(60); + for (let i = 0; i < 8; i++) { + const append: Array = []; + append[i] = v; + appendAttributes(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('rejects duplicate names', () => { - strictAssert.throws( - () => makeNamedContext(['x', 'y', 'x']), - /duplicate key name/, - ); - }); + it('throws when there is no current context', () => { + strictAssert.throws( + () => appendAttributes(['v']), + /no active thread context/, + ); + }); - it('rejects non-string entries', () => { - strictAssert.throws( - () => makeNamedContext(['ok', 42 as unknown as string]), - /must be a string/, - ); - }); + it('is a no-op when given an empty array', () => { + runWithContext( + () => { + const before = _currentRecordBytes(); + appendAttributes([]); + const after = _currentRecordBytes(); + strictAssert.deepEqual(after, before); + }, + {traceId: TRACE_ID_BYTES, spanId: SPAN_ID_BYTES}, + ); + }); - it('returns an object exposing all five NamedContext methods', () => { - const named = makeNamedContext(['a']); - strictAssert.equal(typeof named.runWithContext, 'function'); - strictAssert.equal(typeof named.enterWithContext, 'function'); - strictAssert.equal(typeof named.clearContext, 'function'); - strictAssert.equal(typeof named.appendAttributes, 'function'); - strictAssert.equal(typeof named.isContextTruncated, 'function'); - }); + it('is a no-op when all slots are null/undefined', () => { + runWithContext( + () => { + const before = _currentRecordBytes(); + appendAttributes([null, undefined, , null]); + const after = _currentRecordBytes(); + strictAssert.deepEqual(after, before); + }, + {traceId: TRACE_ID_BYTES, spanId: SPAN_ID_BYTES}, + ); + }); - it('resolves namedAttributes given as an object', () => { - const named = makeNamedContext(['http.method', 'http.route']); - let bytes: Uint8Array | undefined; - named.runWithContext( - () => { - bytes = _currentRecordBytes(); - }, - { - traceId: TRACE_ID_BYTES, - spanId: SPAN_ID_BYTES, - namedAttributes: {'http.method': 'GET', 'http.route': '/x'}, - }, - ); - strictAssert.deepEqual(decodeAttrs(bytes!), ['GET', '/x']); - }); + it('silently drops entries past the 612-byte cap and sets the truncated flag', () => { + const big = 'a'.repeat(255); + runWithContext( + () => { + appendAttributes([big, big]); + strictAssert.equal(isContextTruncated(), false); + appendAttributes([, , big]); + strictAssert.equal(isContextTruncated(), true); + strictAssert.equal( + decodeHeader(_currentRecordBytes()!).attrsDataSize, + 514, + ); + const small = 'x'.repeat(30); + appendAttributes([, , , 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(isContextTruncated(), true); + }, + {traceId: TRACE_ID_BYTES, spanId: SPAN_ID_BYTES}, + ); + }); - it('resolves namedAttributes given as a Map', () => { - const named = makeNamedContext(['a', 'b']); - let bytes: Uint8Array | undefined; - named.runWithContext( - () => { - bytes = _currentRecordBytes(); - }, - { - traceId: TRACE_ID_BYTES, - spanId: SPAN_ID_BYTES, - namedAttributes: new Map([ - ['a', 'A'], - ['b', 'B'], - ]), - }, - ); - strictAssert.deepEqual(decodeAttrs(bytes!), ['A', 'B']); + it('propagates through async continuations', async () => { + await runWithContext( + async () => { + appendAttributes([, 'after-await']); + await Promise.resolve(); + strictAssert.deepEqual(decodeAttrs(_currentRecordBytes()!), [ + 'before', + 'after-await', + ]); + }, + { + traceId: TRACE_ID_BYTES, + spanId: SPAN_ID_BYTES, + attributes: ['before'], + }, + ); + }); }); - it('resolves namedAttributes given as an array of pairs', () => { - const named = makeNamedContext(['a', 'b']); - let bytes: Uint8Array | undefined; - named.runWithContext( - () => { - bytes = _currentRecordBytes(); - }, - { - traceId: TRACE_ID_BYTES, - spanId: SPAN_ID_BYTES, - namedAttributes: [ - ['a', 'A'], - ['b', 'B'], - ], - }, - ); - strictAssert.deepEqual(decodeAttrs(bytes!), ['A', 'B']); - }); + describe('isContextTruncated', () => { + it('returns false outside a context', () => { + strictAssert.equal(isContextTruncated(), false); + }); - it('rejects unknown names', () => { - const named = makeNamedContext(['a']); - strictAssert.throws( - () => - named.runWithContext(() => undefined, { + it('returns false for a non-truncated record', () => { + runWithContext( + () => { + strictAssert.equal(isContextTruncated(), false); + }, + { traceId: TRACE_ID_BYTES, spanId: SPAN_ID_BYTES, - namedAttributes: {unknown: 'v'}, - }), - /unknown attribute name: unknown/, - ); + attributes: ['GET', '/x'], + }, + ); + }); }); - it('coerces non-string values', () => { - const named = makeNamedContext(['n']); - let bytes: Uint8Array | undefined; - named.runWithContext( - () => { - bytes = _currentRecordBytes(); - }, - { - traceId: TRACE_ID_BYTES, - spanId: SPAN_ID_BYTES, - namedAttributes: {n: 7}, - }, - ); - strictAssert.deepEqual(decodeAttrs(bytes!), ['7']); - }); + describe('makeNamedContext', () => { + it('rejects non-array keys', () => { + strictAssert.throws( + () => makeNamedContext({} 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(() => makeNamedContext(tooMany), /exceeds 256/); + }); + + it('rejects duplicate names', () => { + strictAssert.throws( + () => makeNamedContext(['x', 'y', 'x']), + /duplicate key name/, + ); + }); + + it('rejects non-string entries', () => { + strictAssert.throws( + () => makeNamedContext(['ok', 42 as unknown as string]), + /must be a string/, + ); + }); + + it('returns an object exposing all five NamedContext methods', () => { + const named = makeNamedContext(['a']); + strictAssert.equal(typeof named.runWithContext, 'function'); + strictAssert.equal(typeof named.enterWithContext, 'function'); + strictAssert.equal(typeof named.clearContext, 'function'); + strictAssert.equal(typeof named.appendAttributes, 'function'); + strictAssert.equal(typeof named.isContextTruncated, 'function'); + }); - it('enterWithContext attaches a name-addressed record', () => { - const named = makeNamedContext(['route']); - runWithContext( - () => { - named.enterWithContext({ + it('resolves namedAttributes given as an object', () => { + const named = makeNamedContext(['http.method', 'http.route']); + let bytes: Uint8Array | undefined; + named.runWithContext( + () => { + bytes = _currentRecordBytes(); + }, + { traceId: TRACE_ID_BYTES, spanId: SPAN_ID_BYTES, - namedAttributes: {route: '/x'}, - }); - strictAssert.deepEqual(decodeAttrs(_currentRecordBytes()!), ['/x']); - }, - {traceId: TRACE_ID_BYTES, spanId: SPAN_ID_BYTES}, - ); - }); + namedAttributes: {'http.method': 'GET', 'http.route': '/x'}, + }, + ); + strictAssert.deepEqual(decodeAttrs(bytes!), ['GET', '/x']); + }); - it('appendAttributes appends by name', () => { - const named = makeNamedContext(['http.method', 'http.route', 'http.status']); - named.runWithContext( - () => { - named.appendAttributes({'http.status': '500'}); - strictAssert.deepEqual(decodeAttrs(_currentRecordBytes()!), [ - 'GET', - '/x', - '500', - ]); - }, - { - traceId: TRACE_ID_BYTES, - spanId: SPAN_ID_BYTES, - namedAttributes: {'http.method': 'GET', 'http.route': '/x'}, - }, - ); - }); + it('resolves namedAttributes given as a Map', () => { + const named = makeNamedContext(['a', 'b']); + let bytes: Uint8Array | undefined; + named.runWithContext( + () => { + bytes = _currentRecordBytes(); + }, + { + traceId: TRACE_ID_BYTES, + spanId: SPAN_ID_BYTES, + namedAttributes: new Map([ + ['a', 'A'], + ['b', 'B'], + ]), + }, + ); + strictAssert.deepEqual(decodeAttrs(bytes!), ['A', 'B']); + }); - it('appendAttributes rejects unknown names', () => { - const named = makeNamedContext(['known']); - named.runWithContext( - () => { - strictAssert.throws( - () => named.appendAttributes({unknown: 'v'}), - /unknown attribute name: unknown/, - ); - }, - { - traceId: TRACE_ID_BYTES, - spanId: SPAN_ID_BYTES, - namedAttributes: {known: 'k'}, - }, - ); - }); + it('resolves namedAttributes given as an array of pairs', () => { + const named = makeNamedContext(['a', 'b']); + let bytes: Uint8Array | undefined; + named.runWithContext( + () => { + bytes = _currentRecordBytes(); + }, + { + traceId: TRACE_ID_BYTES, + spanId: SPAN_ID_BYTES, + namedAttributes: [ + ['a', 'A'], + ['b', 'B'], + ], + }, + ); + strictAssert.deepEqual(decodeAttrs(bytes!), ['A', 'B']); + }); - it('isContextTruncated mirrors the top-level function', () => { - const named = makeNamedContext(['a', 'b', 'c']); - named.runWithContext( - () => { - strictAssert.equal(named.isContextTruncated(), false); - appendAttributes([ - , - , - 'c'.repeat(255), - , - , - 'd'.repeat(255), - , - , - 'e'.repeat(255), - ]); - strictAssert.equal(named.isContextTruncated(), true); - }, - { - traceId: TRACE_ID_BYTES, - spanId: SPAN_ID_BYTES, - namedAttributes: {a: 'a', b: 'b'}, - }, - ); - }); + it('rejects unknown names', () => { + const named = makeNamedContext(['a']); + strictAssert.throws( + () => + named.runWithContext(() => undefined, { + traceId: TRACE_ID_BYTES, + spanId: SPAN_ID_BYTES, + namedAttributes: {unknown: 'v'}, + }), + /unknown attribute name: unknown/, + ); + }); - describe('processContextAttributes', () => { - it('matches the input keys plus the V8 layout constants', () => { - const keys = ['http.method', 'http.route', 'user.id']; - const named = makeNamedContext(keys); - const pca = named.processContextAttributes; - strictAssert.equal(pca['threadlocal.schema_version'], 'nodejs_v1'); - strictAssert.deepEqual(pca['threadlocal.attribute_key_map'], keys); - strictAssert.equal(pca['threadlocal.nodejs_v1.wrapped_object_offset'], 24); - strictAssert.equal(pca['threadlocal.nodejs_v1.tagged_size'], 8); - strictAssert.deepEqual(Object.keys(pca).sort(), [ - 'threadlocal.attribute_key_map', - 'threadlocal.nodejs_v1.tagged_size', - 'threadlocal.nodejs_v1.wrapped_object_offset', - 'threadlocal.schema_version', - ]); + it('coerces non-string values', () => { + const named = makeNamedContext(['n']); + let bytes: Uint8Array | undefined; + named.runWithContext( + () => { + bytes = _currentRecordBytes(); + }, + { + traceId: TRACE_ID_BYTES, + spanId: SPAN_ID_BYTES, + namedAttributes: {n: 7}, + }, + ); + strictAssert.deepEqual(decodeAttrs(bytes!), ['7']); }); - it('is frozen and a defensive copy', () => { - const keys = ['http.method', 'http.route']; - const named = makeNamedContext(keys); - const pca = named.processContextAttributes; - 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'], [ + it('enterWithContext attaches a name-addressed record', () => { + const named = makeNamedContext(['route']); + runWithContext( + () => { + named.enterWithContext({ + traceId: TRACE_ID_BYTES, + spanId: SPAN_ID_BYTES, + namedAttributes: {route: '/x'}, + }); + strictAssert.deepEqual(decodeAttrs(_currentRecordBytes()!), ['/x']); + }, + {traceId: TRACE_ID_BYTES, spanId: SPAN_ID_BYTES}, + ); + }); + + it('appendAttributes appends by name', () => { + const named = makeNamedContext([ 'http.method', 'http.route', + 'http.status', ]); - strictAssert.throws(() => { - (pca as unknown as Record)['threadlocal.schema_version'] = - 'tampered'; - }, /read-only|read only|TypeError/i); + named.runWithContext( + () => { + named.appendAttributes({'http.status': '500'}); + strictAssert.deepEqual(decodeAttrs(_currentRecordBytes()!), [ + 'GET', + '/x', + '500', + ]); + }, + { + traceId: TRACE_ID_BYTES, + spanId: SPAN_ID_BYTES, + namedAttributes: {'http.method': 'GET', 'http.route': '/x'}, + }, + ); + }); + + it('appendAttributes rejects unknown names', () => { + const named = makeNamedContext(['known']); + named.runWithContext( + () => { + strictAssert.throws( + () => named.appendAttributes({unknown: 'v'}), + /unknown attribute name: unknown/, + ); + }, + { + traceId: TRACE_ID_BYTES, + spanId: SPAN_ID_BYTES, + namedAttributes: {known: 'k'}, + }, + ); + }); + + it('isContextTruncated mirrors the top-level function', () => { + const named = makeNamedContext(['a', 'b', 'c']); + named.runWithContext( + () => { + strictAssert.equal(named.isContextTruncated(), false); + appendAttributes([ + , + , + 'c'.repeat(255), + , + , + 'd'.repeat(255), + , + , + 'e'.repeat(255), + ]); + strictAssert.equal(named.isContextTruncated(), true); + }, + { + traceId: TRACE_ID_BYTES, + spanId: SPAN_ID_BYTES, + namedAttributes: {a: 'a', b: 'b'}, + }, + ); + }); + + describe('processContextAttributes', () => { + it('matches the input keys plus the V8 layout constants', () => { + const keys = ['http.method', 'http.route', 'user.id']; + const named = makeNamedContext(keys); + const pca = named.processContextAttributes; + strictAssert.equal(pca['threadlocal.schema_version'], 'nodejs_v1'); + strictAssert.deepEqual(pca['threadlocal.attribute_key_map'], keys); + strictAssert.equal( + pca['threadlocal.nodejs_v1.wrapped_object_offset'], + 24, + ); + strictAssert.equal(pca['threadlocal.nodejs_v1.tagged_size'], 8); + strictAssert.deepEqual(Object.keys(pca).sort(), [ + 'threadlocal.attribute_key_map', + 'threadlocal.nodejs_v1.tagged_size', + 'threadlocal.nodejs_v1.wrapped_object_offset', + 'threadlocal.schema_version', + ]); + }); + + it('is frozen and a defensive copy', () => { + const keys = ['http.method', 'http.route']; + const named = makeNamedContext(keys); + const pca = named.processContextAttributes; + 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'); - 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()}`); + + describe('discovery contract', () => { + it('exports otel_thread_ctx_nodejs_v1 as a TLS dynsym', function () { + const addon = join( + __dirname, + '..', + '..', + 'build', + 'Release', + 'dd_pprof.node', + ); + 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 eb9b18e0b5ccefeb497f8bf50687b7d92291e4c9 Mon Sep 17 00:00:00 2001 From: Attila Szegedi Date: Thu, 11 Jun 2026 19:13:11 +0200 Subject: [PATCH 10/15] Make the addon compile on MSVC __attribute__((visibility("default"))) is a GCC/Clang extension that MSVC doesn't recognize, breaking the Windows prebuild. Guard it behind __GNUC__/__clang__. Visibility is irrelevant on Windows anyway since the OTEP-4947 reader contract is ELF-TLSDESC and only meaningful on Linux. --- bindings/otel-thread-ctx.cc | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/bindings/otel-thread-ctx.cc b/bindings/otel-thread-ctx.cc index 54d529e4..2e7e0972 100644 --- a/bindings/otel-thread-ctx.cc +++ b/bindings/otel-thread-ctx.cc @@ -67,8 +67,13 @@ struct otel_thread_ctx_nodejs_v1_t { v8::internal::Address undefined_addr; // offset 3 * sizeof(void*); tagged }; -__attribute__((visibility("default"))) thread_local otel_thread_ctx_nodejs_v1_t - otel_thread_ctx_nodejs_v1; +// 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*), From 3e5239441065756ef1f22d3aab83559874c83bdd Mon Sep 17 00:00:00 2001 From: Attila Szegedi Date: Thu, 11 Jun 2026 19:13:14 +0200 Subject: [PATCH 11/15] Skip OTEP-4947 thread context tests where the feature is unavailable Two cases the prior unconditional describe didn't cover: - AsyncContextFrame (the writer's discovery substrate) is opt-in on Node 22/23 (requires --experimental-async-context-frame), on by default in Node 24+ (disable-able via --no-async-context-frame), and absent on Node < 22. The TS layer's asyncContextFrameError refuses to install the hook in each of those cases; the test now mirrors the same predicate so the suite is skipped instead of failing every test with "feature unavailable". - The discovery-contract test reads the addon binary from build/Release/dd_pprof.node, which exists only on the build-from-source path. The prebuild-install / node-gyp-build CI matrix uses a prebuilt binary from prebuilds/, so the path doesn't exist there. Skip that one test when the file isn't present. --- ts/test/test-otel-thread-ctx.ts | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/ts/test/test-otel-thread-ctx.ts b/ts/test/test-otel-thread-ctx.ts index 3979a816..0de8342c 100644 --- a/ts/test/test-otel-thread-ctx.ts +++ b/ts/test/test-otel-thread-ctx.ts @@ -21,6 +21,7 @@ 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 { @@ -35,6 +36,21 @@ import { } from '../src/otel-thread-ctx'; 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. @@ -107,7 +123,7 @@ function captureBytes(opts: { return bytes as Uint8Array; } -(isLinux ? describe : describe.skip)( +(isLinux && isAsyncContextFrameAvailable ? describe : describe.skip)( 'OTEP-4947 thread context (Linux-only)', () => { describe('CtxWrap construction', () => { @@ -914,6 +930,12 @@ function captureBytes(opts: { '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', }); From 6e2806070458f6d469162a1f0544591afd20c586 Mon Sep 17 00:00:00 2001 From: Attila Szegedi Date: Tue, 16 Jun 2026 10:27:39 +0200 Subject: [PATCH 12/15] Surface the OTEP-4947 writer API on the package root Add an otelThreadCtx namespace alongside time/heap so consumers can reach the writer (runWithContext, enterWithContext, clearContext, appendAttributes, isContextTruncated, makeNamedContext) via require('@datadog/pprof').otelThreadCtx without importing internal paths. The debug-only _currentRecordBytes accessor stays unexposed. --- ts/src/index.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/ts/src/index.ts b/ts/src/index.ts index 73e85779..3bdac01c 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,19 @@ 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 = { + runWithContext: otelThreadCtxModule.runWithContext, + enterWithContext: otelThreadCtxModule.enterWithContext, + clearContext: otelThreadCtxModule.clearContext, + appendAttributes: otelThreadCtxModule.appendAttributes, + isContextTruncated: otelThreadCtxModule.isContextTruncated, + makeNamedContext: otelThreadCtxModule.makeNamedContext, +}; + // If loaded with --require, start profiling. if (module.parent && module.parent.id === 'internal/preload') { time.start({}); From 279821032067cc560be14597208d34c999187cf9 Mon Sep 17 00:00:00 2001 From: Attila Szegedi Date: Tue, 16 Jun 2026 12:52:00 +0200 Subject: [PATCH 13/15] Add currentSpanIdMatches to the OTEP-4947 writer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Native CtxWrap.spanIdMatches(buf) memcmps the 8-byte argument against record_->span_id and returns a boolean — no allocation on the JS side when the caller passes a stable Uint8Array (typically cached on the span object). Exposed at the JS layer as: - top-level currentSpanIdMatches(spanIdBytes), returns false outside a context, on non-Linux platforms, and for arguments that aren't 8-byte Uint8Arrays. - NamedContext.currentSpanIdMatches(spanIdBytes), a passthrough. Motivation: dd-trace-js's storage:enter channel fires on every async-context resume. Without a way to ask "is this span already the active context?", the writer ends up allocating a fresh CtxWrap on each enter, even when re-entering the same span — the same allocation-churn pattern the wall profiler fixed in dd-trace-js#8638. --- bindings/otel-thread-ctx.cc | 30 ++++++++++++++++++++++ ts/src/index.ts | 1 + ts/src/otel-thread-ctx.ts | 23 +++++++++++++++++ ts/test/test-otel-thread-ctx.ts | 44 +++++++++++++++++++++++++++++++++ 4 files changed, 98 insertions(+) diff --git a/bindings/otel-thread-ctx.cc b/bindings/otel-thread-ctx.cc index 2e7e0972..62a5e778 100644 --- a/bindings/otel-thread-ctx.cc +++ b/bindings/otel-thread-ctx.cc @@ -176,6 +176,7 @@ class CtxWrap : public ObjectWrap { static void DebugBytes(const FunctionCallbackInfo& args); static void Append(const FunctionCallbackInfo& args); static void IsTruncated(const FunctionCallbackInfo& args); + static void SpanIdMatches(const FunctionCallbackInfo& args); static bool EncodeAttrs(Isolate* isolate, Local context, @@ -451,6 +452,32 @@ void CtxWrap::IsTruncated(const FunctionCallbackInfo& args) { args.GetReturnValue().Set(self->truncated_); } +// Compares the 8-byte argument against record_->span_id. Returns true on +// match, false otherwise (including wrong-shape arguments). No allocation +// on the JS side; the caller passes in a stable Uint8Array (typically +// cached on the span object) and we memcmp against the record bytes. +void CtxWrap::SpanIdMatches(const FunctionCallbackInfo& args) { + CtxWrap* self = ObjectWrap::Unwrap(args.This()); + if (!self) { + args.GetIsolate()->ThrowError("not an OtelThreadCtxWrap"); + return; + } + if (args.Length() != 1 || !args[0]->IsUint8Array()) { + args.GetReturnValue().Set(false); + return; + } + Local arr = args[0].As(); + if (arr->ByteLength() != sizeof(self->record_->span_id)) { + args.GetReturnValue().Set(false); + return; + } + const uint8_t* base = + static_cast(arr->Buffer()->GetBackingStore()->Data()) + + arr->ByteOffset(); + args.GetReturnValue().Set( + memcmp(base, self->record_->span_id, sizeof(self->record_->span_id)) == 0); +} + void CtxWrap::DebugBytes(const FunctionCallbackInfo& args) { Isolate* isolate = args.GetIsolate(); CtxWrap* self = ObjectWrap::Unwrap(args.This()); @@ -481,6 +508,9 @@ void CtxWrap::Init(Local exports) { tpl->PrototypeTemplate()->Set( String::NewFromUtf8Literal(isolate, "isTruncated"), FunctionTemplate::New(isolate, IsTruncated)); + tpl->PrototypeTemplate()->Set( + String::NewFromUtf8Literal(isolate, "spanIdMatches"), + FunctionTemplate::New(isolate, SpanIdMatches)); Local constructor = tpl->GetFunction(context).ToLocalChecked(); exports diff --git a/ts/src/index.ts b/ts/src/index.ts index 3bdac01c..7b591d30 100644 --- a/ts/src/index.ts +++ b/ts/src/index.ts @@ -68,6 +68,7 @@ export const otelThreadCtx = { clearContext: otelThreadCtxModule.clearContext, appendAttributes: otelThreadCtxModule.appendAttributes, isContextTruncated: otelThreadCtxModule.isContextTruncated, + currentSpanIdMatches: otelThreadCtxModule.currentSpanIdMatches, makeNamedContext: otelThreadCtxModule.makeNamedContext, }; diff --git a/ts/src/otel-thread-ctx.ts b/ts/src/otel-thread-ctx.ts index c2f1eb4b..1e5521d4 100644 --- a/ts/src/otel-thread-ctx.ts +++ b/ts/src/otel-thread-ctx.ts @@ -85,6 +85,7 @@ export interface NamedContext { | Array<[string, unknown]>, ): void; isContextTruncated(): boolean; + currentSpanIdMatches(spanIdBytes: Uint8Array): boolean; readonly processContextAttributes: ProcessContextAttributes; } @@ -92,6 +93,7 @@ interface CtxWrap { debugBytes(): Uint8Array; append(attributes: Array | undefined): void; isTruncated(): boolean; + spanIdMatches(spanIdBytes: Uint8Array): boolean; } interface Addon { @@ -129,6 +131,14 @@ export let appendAttributes: ( attributes: Array, ) => void; export let isContextTruncated: () => boolean; +/** + * Returns true iff a context is currently attached to the active + * asynchronous scope and its span_id bytes match the 8-byte + * `spanIdBytes` argument. Allocation-free on the JS side (a memcmp on + * the native side against the stable buffer the caller passes in). + * Returns false on non-Linux platforms. + */ +export let currentSpanIdMatches: (spanIdBytes: Uint8Array) => boolean; // Debug accessor (not part of the stable API; for tests / reader dev). export let _currentRecordBytes: () => Uint8Array | undefined = () => undefined; @@ -222,6 +232,13 @@ if (process.platform === 'linux') { return wrap.isTruncated(); }; + currentSpanIdMatches = function (spanIdBytes: Uint8Array): boolean { + if (!als) return false; + const wrap = als.getStore(); + if (!wrap) return false; + return wrap.spanIdMatches(spanIdBytes); + }; + _currentRecordBytes = function (): Uint8Array | undefined { if (!als) return undefined; const wrap = als.getStore(); @@ -238,6 +255,9 @@ if (process.platform === 'linux') { isContextTruncated = function (): boolean { return false; }; + currentSpanIdMatches = function (): boolean { + return false; + }; } /** @@ -339,6 +359,9 @@ export function makeNamedContext(keys: string[]): NamedContext { isContextTruncated(): boolean { return isContextTruncated(); }, + currentSpanIdMatches(spanIdBytes: Uint8Array): boolean { + return currentSpanIdMatches(spanIdBytes); + }, processContextAttributes, }; } diff --git a/ts/test/test-otel-thread-ctx.ts b/ts/test/test-otel-thread-ctx.ts index 0de8342c..6772d3a7 100644 --- a/ts/test/test-otel-thread-ctx.ts +++ b/ts/test/test-otel-thread-ctx.ts @@ -28,6 +28,7 @@ import { ContextOptions, appendAttributes, clearContext, + currentSpanIdMatches, enterWithContext, isContextTruncated, makeNamedContext, @@ -678,6 +679,49 @@ function captureBytes(opts: { }); }); + describe('currentSpanIdMatches', () => { + it('returns false outside a context', () => { + strictAssert.equal(currentSpanIdMatches(SPAN_ID_BYTES), false); + }); + + it('returns true when the active record has the matching span id', () => { + runWithContext( + () => { + strictAssert.equal(currentSpanIdMatches(SPAN_ID_BYTES), true); + }, + {traceId: TRACE_ID_BYTES, spanId: SPAN_ID_BYTES}, + ); + }); + + it('returns false when the active record has a different span id', () => { + const otherSpan = bytesFromHex('1112131415161719'); + runWithContext( + () => { + strictAssert.equal(currentSpanIdMatches(otherSpan), false); + }, + {traceId: TRACE_ID_BYTES, spanId: SPAN_ID_BYTES}, + ); + }); + + it('returns false for malformed arguments', () => { + runWithContext( + () => { + strictAssert.equal( + currentSpanIdMatches('11121314' as unknown as Uint8Array), + false, + ); + // Wrong-length Uint8Array (Buffer is a Uint8Array subclass): the + // native side rejects anything that isn't exactly 8 bytes. + strictAssert.equal( + currentSpanIdMatches(Buffer.from('aabbccdd', 'hex')), + false, + ); + }, + {traceId: TRACE_ID_BYTES, spanId: SPAN_ID_BYTES}, + ); + }); + }); + describe('makeNamedContext', () => { it('rejects non-array keys', () => { strictAssert.throws( From c3cb13a517e73d2b3fe95c7a0b51e6eb06c7dc79 Mon Sep 17 00:00:00 2001 From: Attila Szegedi Date: Tue, 16 Jun 2026 16:43:20 +0200 Subject: [PATCH 14/15] Expose CtxWrap as a first-class JS class; drop the implicit-storage helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reshape the otelThreadCtx namespace around an explicitly-allocated CtxWrap object so consumers can cache one record per span and re-install it without allocating churn: - The native CtxWrap class is now constructable from JS via `new pprof.otelThreadCtx.CtxWrap(traceId, spanId, attributes?)`. - New top-level functions get/set/runWithContext take a wrap reference (or undefined). `getContext() === wrap` becomes the cheap identity check that replaces the previous currentSpanIdMatches dance. - The opts-form helpers (`enterWithContext`, `appendAttributes`, `clearContext`, `isContextTruncated`, `currentSpanIdMatches`) are removed at the top level. Callers go through the wrap directly: `wrap.appendAttributes(...)`, `wrap.isTruncated()`, `setContext(undefined)`. - NamedContext stays as a name→index resolver: `buildContext(opts)` returns a CtxWrap with attributes resolved positionally; `runWithContext` / `enterWithContext` / `clearContext` are kept as one-liner sugar that compose with the new top-level functions. Native: the CtxWrap class is registered with its new name (was "OtelThreadCtxWrap") and the per-instance "append" method is now "appendAttributes" for parity with the JS-side phrasing. The SpanIdMatches binding is dropped. Reasoning: under AsyncContextFrame each fork inherits the wrap by reference, so once dd-trace-js caches one CtxWrap per span and re-installs it on every storage:enter, both the allocation churn we saw in the wall profiler (PR dd-trace-js#8638) and the "different wraps in different CPEDs for the same span" stale-record edge case go away — there's exactly one record per span across the whole lifetime, and mutations via wrap.appendAttributes propagate naturally because the native realloc-on-append path updates the wrap's record_ pointer in place, never the JS wrapper. --- bindings/otel-thread-ctx.cc | 49 ++---- ts/src/index.ts | 8 +- ts/src/otel-thread-ctx.ts | 283 +++++++++++++++----------------- ts/test/test-otel-thread-ctx.ts | 258 +++++++++++------------------ 4 files changed, 240 insertions(+), 358 deletions(-) diff --git a/bindings/otel-thread-ctx.cc b/bindings/otel-thread-ctx.cc index 62a5e778..a04671b4 100644 --- a/bindings/otel-thread-ctx.cc +++ b/bindings/otel-thread-ctx.cc @@ -176,7 +176,6 @@ class CtxWrap : public ObjectWrap { static void DebugBytes(const FunctionCallbackInfo& args); static void Append(const FunctionCallbackInfo& args); static void IsTruncated(const FunctionCallbackInfo& args); - static void SpanIdMatches(const FunctionCallbackInfo& args); static bool EncodeAttrs(Isolate* isolate, Local context, @@ -314,12 +313,12 @@ void CtxWrap::New(const FunctionCallbackInfo& args) { Local context = isolate->GetCurrentContext(); if (!args.IsConstructCall()) [[unlikely]] { - isolate->ThrowError("OtelThreadCtxWrap must be called with `new`"); + isolate->ThrowError("CtxWrap must be called with `new`"); return; } if (args.Length() != 3) { isolate->ThrowError( - "OtelThreadCtxWrap expects 3 arguments: traceId, spanId, attributes"); + "CtxWrap expects 3 arguments: traceId, spanId, attributes"); return; } @@ -374,7 +373,7 @@ void CtxWrap::Append(const FunctionCallbackInfo& args) { CtxWrap* self = ObjectWrap::Unwrap(args.This()); if (!self) { - isolate->ThrowError("not an OtelThreadCtxWrap"); + isolate->ThrowError("not a CtxWrap"); return; } if (args.Length() != 1) { @@ -446,43 +445,17 @@ void CtxWrap::Append(const FunctionCallbackInfo& args) { void CtxWrap::IsTruncated(const FunctionCallbackInfo& args) { CtxWrap* self = ObjectWrap::Unwrap(args.This()); if (!self) { - args.GetIsolate()->ThrowError("not an OtelThreadCtxWrap"); + args.GetIsolate()->ThrowError("not a CtxWrap"); return; } args.GetReturnValue().Set(self->truncated_); } -// Compares the 8-byte argument against record_->span_id. Returns true on -// match, false otherwise (including wrong-shape arguments). No allocation -// on the JS side; the caller passes in a stable Uint8Array (typically -// cached on the span object) and we memcmp against the record bytes. -void CtxWrap::SpanIdMatches(const FunctionCallbackInfo& args) { - CtxWrap* self = ObjectWrap::Unwrap(args.This()); - if (!self) { - args.GetIsolate()->ThrowError("not an OtelThreadCtxWrap"); - return; - } - if (args.Length() != 1 || !args[0]->IsUint8Array()) { - args.GetReturnValue().Set(false); - return; - } - Local arr = args[0].As(); - if (arr->ByteLength() != sizeof(self->record_->span_id)) { - args.GetReturnValue().Set(false); - return; - } - const uint8_t* base = - static_cast(arr->Buffer()->GetBackingStore()->Data()) + - arr->ByteOffset(); - args.GetReturnValue().Set( - memcmp(base, self->record_->span_id, sizeof(self->record_->span_id)) == 0); -} - void CtxWrap::DebugBytes(const FunctionCallbackInfo& args) { Isolate* isolate = args.GetIsolate(); CtxWrap* self = ObjectWrap::Unwrap(args.This()); if (!self) { - isolate->ThrowError("not an OtelThreadCtxWrap"); + isolate->ThrowError("not a CtxWrap"); return; } const size_t total = @@ -497,25 +470,23 @@ void CtxWrap::Init(Local exports) { Local context = isolate->GetCurrentContext(); Local tpl = FunctionTemplate::New(isolate, New); - tpl->SetClassName(String::NewFromUtf8Literal(isolate, "OtelThreadCtxWrap")); + tpl->SetClassName(String::NewFromUtf8Literal(isolate, "CtxWrap")); tpl->InstanceTemplate()->SetInternalFieldCount(1); tpl->PrototypeTemplate()->Set( String::NewFromUtf8Literal(isolate, "debugBytes"), FunctionTemplate::New(isolate, DebugBytes)); - tpl->PrototypeTemplate()->Set(String::NewFromUtf8Literal(isolate, "append"), - FunctionTemplate::New(isolate, Append)); + tpl->PrototypeTemplate()->Set( + String::NewFromUtf8Literal(isolate, "appendAttributes"), + FunctionTemplate::New(isolate, Append)); tpl->PrototypeTemplate()->Set( String::NewFromUtf8Literal(isolate, "isTruncated"), FunctionTemplate::New(isolate, IsTruncated)); - tpl->PrototypeTemplate()->Set( - String::NewFromUtf8Literal(isolate, "spanIdMatches"), - FunctionTemplate::New(isolate, SpanIdMatches)); Local constructor = tpl->GetFunction(context).ToLocalChecked(); exports ->Set(context, - String::NewFromUtf8Literal(isolate, "otelThreadCtxWrap"), + String::NewFromUtf8Literal(isolate, "ctxWrap"), constructor) .FromJust(); } diff --git a/ts/src/index.ts b/ts/src/index.ts index 7b591d30..2d928644 100644 --- a/ts/src/index.ts +++ b/ts/src/index.ts @@ -63,12 +63,10 @@ export const heap = { // Node 24+ by default) only; degrades to no-ops on other platforms / Node // versions. export const otelThreadCtx = { + CtxWrap: otelThreadCtxModule.CtxWrap, + getContext: otelThreadCtxModule.getContext, + setContext: otelThreadCtxModule.setContext, runWithContext: otelThreadCtxModule.runWithContext, - enterWithContext: otelThreadCtxModule.enterWithContext, - clearContext: otelThreadCtxModule.clearContext, - appendAttributes: otelThreadCtxModule.appendAttributes, - isContextTruncated: otelThreadCtxModule.isContextTruncated, - currentSpanIdMatches: otelThreadCtxModule.currentSpanIdMatches, makeNamedContext: otelThreadCtxModule.makeNamedContext, }; diff --git a/ts/src/otel-thread-ctx.ts b/ts/src/otel-thread-ctx.ts index 1e5521d4..69926f88 100644 --- a/ts/src/otel-thread-ctx.ts +++ b/ts/src/otel-thread-ctx.ts @@ -25,30 +25,18 @@ import {join} from 'path'; import {AsyncLocalStorage} from 'node:async_hooks'; /** - * Inputs to {@link runWithContext} and {@link enterWithContext}. + * Inputs to {@link NamedContext.buildContext} (and the convenience + * methods that delegate to it). * * `traceId` and `spanId` are passed as raw bytes (a `Uint8Array` of length * 16 and 8 respectively; `Buffer` is acceptable as a subclass). * - * `attributes`, if present, is positional: index N in the array is the value - * for uint8 key index N on the wire. Slots that are `null`, `undefined`, or - * absent (array holes) are skipped. Non-string values are coerced via - * `toString`. Values longer than 255 UTF-8 bytes are silently truncated and - * attributes that would overflow the 612-byte payload budget are silently - * dropped — see {@link isContextTruncated} for how to detect that. Array - * length must not exceed 256. - */ -export interface ContextOptions { - traceId: Uint8Array; - spanId: Uint8Array; - attributes?: Array; -} - -/** - * Inputs to the methods returned by {@link makeNamedContext}. Same as - * {@link ContextOptions} but attributes are addressed by name; names are - * resolved to uint8 key indexes using the array passed to - * {@link makeNamedContext}. + * `namedAttributes` are resolved to positional uint8 key indexes via the + * `keys` array passed to {@link makeNamedContext}. Values are coerced to + * strings via `toString`. Values longer than 255 UTF-8 bytes are silently + * truncated, and attributes that would overflow the 612-byte payload cap + * are silently dropped (see {@link CtxWrap.isTruncated}). Names that + * aren't in the key map throw. */ export interface NamedContextOptions { traceId: Uint8Array; @@ -72,42 +60,65 @@ export interface ProcessContextAttributes { } /** - * Object returned by {@link makeNamedContext}. + * A thread-context record. Construct with `new CtxWrap(...)`; install + * with {@link setContext} or {@link runWithContext}. The underlying + * native record is GC-owned: when no JS or async-context-frame + * reference survives, it's freed. + * + * `appendAttributes` mutates the wrap's record in place. Because every + * async-context frame that holds the same `CtxWrap` reference observes + * the same native record buffer, an append is visible across all those + * frames even when the reallocate path runs (the wrap's internal + * pointer is updated, the JS object is not replaced). */ -export interface NamedContext { - runWithContext(fn: () => T, opts: NamedContextOptions): T; - enterWithContext(opts: NamedContextOptions): void; - clearContext(): void; +export interface CtxWrap { appendAttributes( - namedAttributes: - | Record - | Map - | Array<[string, unknown]>, + attributes: Array | undefined, ): void; - isContextTruncated(): boolean; - currentSpanIdMatches(spanIdBytes: Uint8Array): boolean; - readonly processContextAttributes: ProcessContextAttributes; -} - -interface CtxWrap { - debugBytes(): Uint8Array; - append(attributes: Array | undefined): void; isTruncated(): boolean; - spanIdMatches(spanIdBytes: Uint8Array): boolean; + /** Debug-only: returns the on-the-wire record bytes. Not stable. */ + debugBytes(): Uint8Array; } -interface Addon { - otelThreadCtxWrap: new ( +/** + * Constructor for {@link CtxWrap}. 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 CtxWrapCtor { + new ( traceId: Uint8Array, spanId: Uint8Array, - attributes: Array | undefined, - ) => CtxWrap; + attributes?: Array, + ): CtxWrap; +} + +interface Addon { + ctxWrap: CtxWrapCtor; otelThreadCtxStoreAls(als: AsyncLocalStorage): void; otelThreadCtxGetStoredAlsHash(): number; otelThreadCtxWrappedObjectOffset: number; otelThreadCtxTaggedSize: number; } +/** + * Object returned by {@link makeNamedContext}. Resolves the + * `namedAttributes` map to a positional array against the key list + * captured at factory time and builds a {@link CtxWrap}; convenience + * methods compose with {@link setContext} / {@link runWithContext}. + */ +export interface NamedContext { + /** Allocate a CtxWrap with attributes resolved positionally by name. */ + buildContext(opts: NamedContextOptions): CtxWrap; + /** Sugar: `setContext(buildContext(opts))`. */ + enterWithContext(opts: NamedContextOptions): void; + /** Sugar: `runWithContext(buildContext(opts), fn)`. */ + runWithContext(fn: () => T, opts: NamedContextOptions): T; + /** Sugar: `setContext(undefined)`. */ + clearContext(): void; + readonly processContextAttributes: ProcessContextAttributes; +} + const SCHEMA_VERSION = 'nodejs_v1'; // V8 layout constants the addon captured from the V8 headers Node bundles. @@ -118,37 +129,42 @@ const SCHEMA_VERSION = 'nodejs_v1'; let WRAPPED_OBJECT_OFFSET = 24; let TAGGED_SIZE = 8; -export let runWithContext: (fn: () => T, opts: ContextOptions) => T; -export let enterWithContext: (opts: ContextOptions) => void; +/** {@inheritDoc CtxWrapCtor} */ +export let CtxWrap: CtxWrapCtor; + +/** + * Returns the {@link CtxWrap} currently attached to the active + * async-context frame, or `undefined` if none is. + */ +export let getContext: () => CtxWrap | undefined; + /** - * Detach any thread-context record from the current asynchronous scope. - * Subsequent reads in the same scope (until a new - * {@link runWithContext}/{@link enterWithContext} attaches one) see no - * active context. Idempotent. On non-Linux platforms this is a no-op. + * Attach a {@link CtxWrap} (or `undefined` to detach) to the current + * async-context frame. Idempotent for `setContext(undefined)` when no + * frame has been installed. Re-installing the same wrap reference is + * cheap (no allocation); per-span caching of the wrap on the caller + * side is the intended usage pattern. */ -export let clearContext: () => void; -export let appendAttributes: ( - attributes: Array, -) => void; -export let isContextTruncated: () => boolean; +export let setContext: (wrap: CtxWrap | undefined) => void; + /** - * Returns true iff a context is currently attached to the active - * asynchronous scope and its span_id bytes match the 8-byte - * `spanIdBytes` argument. Allocation-free on the JS side (a memcmp on - * the native side against the stable buffer the caller passes in). - * Returns false on non-Linux platforms. + * As {@link setContext}, but scoped to the callback's execution. After + * `fn` returns, the previous context is restored. */ -export let currentSpanIdMatches: (spanIdBytes: Uint8Array) => boolean; +export let runWithContext: (wrap: CtxWrap | undefined, fn: () => T) => T; // 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; + CtxWrap = addon.ctxWrap; + let als: AsyncLocalStorage | undefined; function asyncContextFrameError(): string | undefined { @@ -179,95 +195,67 @@ if (process.platform === 'linux') { return als; } - function buildWrap(opts: ContextOptions): CtxWrap { - if (!opts || typeof opts !== 'object') { - throw new TypeError('options object required'); - } - ensureHook(); - return new addon.otelThreadCtxWrap( - opts.traceId, - opts.spanId, - opts.attributes, - ); - } - - runWithContext = function (fn: () => T, opts: ContextOptions): T { - const wrap = buildWrap(opts); - return ensureHook().run(wrap, fn); + getContext = function (): CtxWrap | undefined { + return als ? als.getStore() : undefined; }; - enterWithContext = function (opts: ContextOptions): void { - const wrap = buildWrap(opts); + setContext = function (wrap: CtxWrap | undefined): void { + if (wrap === undefined) { + // Idempotent: clearing when the hook hasn't been installed (no + // prior setContext call) is a no-op. + if (!als) return; + als.enterWith(undefined as unknown as CtxWrap); + return; + } ensureHook().enterWith(wrap); }; - clearContext = function (): void { - // Idempotent: clearing when no hook has been installed yet (and - // therefore no context can be active) is a no-op. - if (!als) return; - als.enterWith(undefined as unknown as CtxWrap); - }; - - appendAttributes = function ( - attributes: Array, - ): void { - if (!als) { - throw new Error( - 'no active thread context; call runWithContext or enterWithContext first', - ); - } - const wrap = als.getStore(); - if (!wrap) { - throw new Error( - 'no active thread context; call runWithContext or enterWithContext first', - ); + runWithContext = function (wrap: CtxWrap | undefined, fn: () => T): T { + if (wrap === undefined) { + if (!als) return fn(); + return als.run(undefined as unknown as CtxWrap, fn); } - wrap.append(attributes); - }; - - isContextTruncated = function (): boolean { - if (!als) return false; - const wrap = als.getStore(); - if (!wrap) return false; - return wrap.isTruncated(); - }; - - currentSpanIdMatches = function (spanIdBytes: Uint8Array): boolean { - if (!als) return false; - const wrap = als.getStore(); - if (!wrap) return false; - return wrap.spanIdMatches(spanIdBytes); + return ensureHook().run(wrap, fn); }; _currentRecordBytes = function (): Uint8Array | undefined { if (!als) return undefined; const wrap = als.getStore(); - if (!wrap) return undefined; - return wrap.debugBytes(); + return wrap ? wrap.debugBytes() : undefined; }; } else { - runWithContext = function (fn: () => T): T { - return fn(); - }; - enterWithContext = function (): void {}; - clearContext = function (): void {}; - appendAttributes = function (): void {}; - isContextTruncated = function (): boolean { - return false; + // 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 wrap, and setContext / + // runWithContext don't actually wire anything into AsyncLocalStorage. + class NoopCtxWrap implements CtxWrap { + appendAttributes(): void {} + isTruncated(): boolean { + return false; + } + debugBytes(): Uint8Array { + return new Uint8Array(0); + } + } + CtxWrap = NoopCtxWrap as CtxWrapCtor; + getContext = function (): undefined { + return undefined; }; - currentSpanIdMatches = function (): boolean { - return false; + setContext = function (): void {}; + runWithContext = function (_wrap: CtxWrap | undefined, fn: () => T): T { + return fn(); }; } /** - * Build name-addressed wrappers around {@link runWithContext}, - * {@link enterWithContext}, and {@link appendAttributes}. The supplied + * Build name-addressed wrappers around {@link CtxWrap}, + * {@link setContext}, and {@link runWithContext}. The supplied * `keys` array is the same string list the caller publishes (or has - * published) as the `threadlocal.attribute_key_map` resource attribute in - * the OTEP-4719 process context: index N in this array is the uint8 key - * index N in the on-the-wire record. The mapping is captured once at - * factory time. + * published) as the `threadlocal.attribute_key_map` resource attribute + * in the OTEP-4719 process context: index N in this array is the uint8 + * key index N in the on-the-wire record. The mapping is captured once + * at factory time. */ export function makeNamedContext(keys: string[]): NamedContext { if (!Array.isArray(keys)) { @@ -320,15 +308,15 @@ export function makeNamedContext(keys: string[]): NamedContext { return attributes; } - function toBaseOpts(opts: NamedContextOptions): ContextOptions { + function buildContext(opts: NamedContextOptions): CtxWrap { if (!opts || typeof opts !== 'object') { throw new TypeError('options object required'); } - return { - traceId: opts.traceId, - spanId: opts.spanId, - attributes: resolveAttributes(opts.namedAttributes), - }; + return new CtxWrap( + opts.traceId, + opts.spanId, + resolveAttributes(opts.namedAttributes), + ); } const processContextAttributes = Object.freeze({ @@ -339,28 +327,15 @@ export function makeNamedContext(keys: string[]): NamedContext { }) as ProcessContextAttributes; return { - runWithContext(fn: () => T, opts: NamedContextOptions): T { - return runWithContext(fn, toBaseOpts(opts)); - }, + buildContext, enterWithContext(opts: NamedContextOptions): void { - enterWithContext(toBaseOpts(opts)); + setContext(buildContext(opts)); }, - clearContext(): void { - clearContext(); - }, - appendAttributes( - namedAttributes: - | Record - | Map - | Array<[string, unknown]>, - ): void { - appendAttributes(resolveAttributes(namedAttributes)!); - }, - isContextTruncated(): boolean { - return isContextTruncated(); + runWithContext(fn: () => T, opts: NamedContextOptions): T { + return runWithContext(buildContext(opts), fn); }, - currentSpanIdMatches(spanIdBytes: Uint8Array): boolean { - return currentSpanIdMatches(spanIdBytes); + clearContext(): void { + setContext(undefined); }, processContextAttributes, }; diff --git a/ts/test/test-otel-thread-ctx.ts b/ts/test/test-otel-thread-ctx.ts index 6772d3a7..29cd29d5 100644 --- a/ts/test/test-otel-thread-ctx.ts +++ b/ts/test/test-otel-thread-ctx.ts @@ -25,17 +25,42 @@ import {existsSync} from 'node:fs'; import {join} from 'node:path'; import { - ContextOptions, - appendAttributes, - clearContext, - currentSpanIdMatches, - enterWithContext, - isContextTruncated, + CtxWrap, + getContext, makeNamedContext, runWithContext, + setContext, _currentRecordBytes, } from '../src/otel-thread-ctx'; +// Helpers bridging the old positional-attrs test shape to the new +// CtxWrap-first API. +interface PosOpts { + traceId: Uint8Array; + spanId: Uint8Array; + attributes?: Array; +} +function tcRun(fn: () => T, opts: PosOpts): T { + return runWithContext( + new CtxWrap(opts.traceId, opts.spanId, opts.attributes), + fn, + ); +} +function tcEnter(opts: PosOpts): void { + setContext(new CtxWrap(opts.traceId, opts.spanId, opts.attributes)); +} +function tcClear(): void { + setContext(undefined); +} +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 @@ -118,7 +143,7 @@ function captureBytes(opts: { attributes?: Array; }): Uint8Array { let bytes: Uint8Array | undefined; - runWithContext(() => { + tcRun(() => { bytes = _currentRecordBytes(); }, opts); return bytes as Uint8Array; @@ -279,10 +304,10 @@ function captureBytes(opts: { const d = 'd'.repeat(30); let bytes: Uint8Array | undefined; let truncated = false; - runWithContext( + tcRun( () => { bytes = _currentRecordBytes(); - truncated = isContextTruncated(); + truncated = tcIsTruncated(); }, { traceId: TRACE_ID_BYTES, @@ -323,7 +348,7 @@ function captureBytes(opts: { describe('runWithContext lifecycle', () => { it('returns the callback result', () => { - const result = runWithContext(() => 'ok', { + const result = tcRun(() => 'ok', { traceId: TRACE_ID_BYTES, spanId: SPAN_ID_BYTES, }); @@ -335,7 +360,7 @@ function captureBytes(opts: { }); it('has no active record after the call returns', () => { - runWithContext(() => undefined, { + tcRun(() => undefined, { traceId: TRACE_ID_BYTES, spanId: SPAN_ID_BYTES, }); @@ -347,9 +372,9 @@ function captureBytes(opts: { const innerSpanBytes = bytesFromHex('aabbccddeeff0011'); const innerOpts = {traceId: TRACE_ID_BYTES, spanId: innerSpanBytes}; - runWithContext(() => { + tcRun(() => { const outerBefore = decodeHeader(_currentRecordBytes()!).spanId; - runWithContext(() => { + tcRun(() => { const inner = decodeHeader(_currentRecordBytes()!).spanId; strictAssert.deepEqual(inner, innerSpanBytes); }, innerOpts); @@ -360,7 +385,7 @@ function captureBytes(opts: { }); it('keeps the same record after awaits', async () => { - await runWithContext( + await tcRun( async () => { const before = decodeHeader(_currentRecordBytes()!).spanId; await Promise.resolve(); @@ -380,7 +405,7 @@ function captureBytes(opts: { const bSpan = bytesFromHex('2222222222222222'); async function run(spanBytes: Uint8Array) { - return runWithContext( + return tcRun( async () => { const observed: Uint8Array[] = []; for (let i = 0; i < 4; i++) { @@ -401,7 +426,7 @@ function captureBytes(opts: { describe('enterWithContext', () => { it('attaches the record to the current async scope', () => { - void runWithContext( + void tcRun( () => { strictAssert.deepEqual( decodeHeader(_currentRecordBytes()!).spanId, @@ -409,7 +434,7 @@ function captureBytes(opts: { ); const newSpan = bytesFromHex('aabbccddeeff0011'); - enterWithContext({traceId: TRACE_ID_BYTES, spanId: newSpan}); + tcEnter({traceId: TRACE_ID_BYTES, spanId: newSpan}); strictAssert.deepEqual( decodeHeader(_currentRecordBytes()!).spanId, newSpan, @@ -427,48 +452,39 @@ function captureBytes(opts: { strictAssert.equal(_currentRecordBytes(), undefined); }); - - it('requires an options object', () => { - strictAssert.throws( - () => enterWithContext(undefined as unknown as ContextOptions), - /options object required/, - ); - }); }); describe('clearContext', () => { it('detaches the active record within a scope', () => { - runWithContext( + tcRun( () => { strictAssert.ok(_currentRecordBytes()); - clearContext(); + tcClear(); strictAssert.equal(_currentRecordBytes(), undefined); }, {traceId: TRACE_ID_BYTES, spanId: SPAN_ID_BYTES}, ); }); - it('makes appendAttributes throw and isContextTruncated return false', () => { - runWithContext( + it('drops the active record so getContext returns undefined', () => { + tcRun( () => { - clearContext(); - strictAssert.throws( - () => appendAttributes(['v']), - /no active thread context/, - ); - strictAssert.equal(isContextTruncated(), false); + strictAssert.ok(getContext() !== undefined); + tcClear(); + 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(); + tcClear(); strictAssert.equal(_currentRecordBytes(), undefined); - runWithContext( + tcRun( () => { - clearContext(); - clearContext(); + tcClear(); + tcClear(); strictAssert.equal(_currentRecordBytes(), undefined); }, {traceId: TRACE_ID_BYTES, spanId: SPAN_ID_BYTES}, @@ -476,11 +492,11 @@ function captureBytes(opts: { }); it('lets a nested runWithContext re-establish a record', () => { - runWithContext( + tcRun( () => { - clearContext(); + tcClear(); const innerSpan = bytesFromHex('aabbccddeeff0011'); - runWithContext( + tcRun( () => { strictAssert.deepEqual( decodeHeader(_currentRecordBytes()!).spanId, @@ -498,11 +514,11 @@ function captureBytes(opts: { }); it('lets enterWithContext re-establish a record', () => { - runWithContext( + tcRun( () => { - clearContext(); + tcClear(); const newSpan = bytesFromHex('aabbccddeeff0011'); - enterWithContext({traceId: TRACE_ID_BYTES, spanId: newSpan}); + tcEnter({traceId: TRACE_ID_BYTES, spanId: newSpan}); strictAssert.deepEqual( decodeHeader(_currentRecordBytes()!).spanId, newSpan, @@ -531,12 +547,12 @@ function captureBytes(opts: { describe('appendAttributes', () => { it('adds entries to the current record', () => { - runWithContext( + tcRun( () => { strictAssert.deepEqual(decodeAttrs(_currentRecordBytes()!), [ 'GET', ]); - appendAttributes([, , '200']); + tcAppend([, , '200']); strictAssert.deepEqual(decodeAttrs(_currentRecordBytes()!), [ 'GET', , @@ -548,10 +564,10 @@ function captureBytes(opts: { }); it('writes in-place when bytes fit in the slack', () => { - runWithContext( + tcRun( () => { const before = _currentRecordBytes()!; - appendAttributes([, 'ab']); + tcAppend([, 'ab']); const after = _currentRecordBytes()!; strictAssert.deepEqual(decodeAttrs(after), ['xxx', 'ab']); strictAssert.equal(after.length, before.length + 2 + 2); @@ -564,13 +580,13 @@ function captureBytes(opts: { }); it('grows the record geometrically when slack runs out', () => { - runWithContext( + tcRun( () => { const v = 'y'.repeat(60); for (let i = 0; i < 8; i++) { const append: Array = []; append[i] = v; - appendAttributes(append); + tcAppend(append); } const decoded = decodeAttrs(_currentRecordBytes()!); for (let i = 0; i < 8; i++) { @@ -585,18 +601,11 @@ function captureBytes(opts: { ); }); - it('throws when there is no current context', () => { - strictAssert.throws( - () => appendAttributes(['v']), - /no active thread context/, - ); - }); - it('is a no-op when given an empty array', () => { - runWithContext( + tcRun( () => { const before = _currentRecordBytes(); - appendAttributes([]); + tcAppend([]); const after = _currentRecordBytes(); strictAssert.deepEqual(after, before); }, @@ -605,10 +614,10 @@ function captureBytes(opts: { }); it('is a no-op when all slots are null/undefined', () => { - runWithContext( + tcRun( () => { const before = _currentRecordBytes(); - appendAttributes([null, undefined, , null]); + tcAppend([null, undefined, , null]); const after = _currentRecordBytes(); strictAssert.deepEqual(after, before); }, @@ -618,33 +627,33 @@ function captureBytes(opts: { it('silently drops entries past the 612-byte cap and sets the truncated flag', () => { const big = 'a'.repeat(255); - runWithContext( + tcRun( () => { - appendAttributes([big, big]); - strictAssert.equal(isContextTruncated(), false); - appendAttributes([, , big]); - strictAssert.equal(isContextTruncated(), true); + tcAppend([big, big]); + strictAssert.equal(tcIsTruncated(), false); + tcAppend([, , big]); + strictAssert.equal(tcIsTruncated(), true); strictAssert.equal( decodeHeader(_currentRecordBytes()!).attrsDataSize, 514, ); const small = 'x'.repeat(30); - appendAttributes([, , , small]); + 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(isContextTruncated(), true); + strictAssert.equal(tcIsTruncated(), true); }, {traceId: TRACE_ID_BYTES, spanId: SPAN_ID_BYTES}, ); }); it('propagates through async continuations', async () => { - await runWithContext( + await tcRun( async () => { - appendAttributes([, 'after-await']); + tcAppend([, 'after-await']); await Promise.resolve(); strictAssert.deepEqual(decodeAttrs(_currentRecordBytes()!), [ 'before', @@ -662,13 +671,13 @@ function captureBytes(opts: { describe('isContextTruncated', () => { it('returns false outside a context', () => { - strictAssert.equal(isContextTruncated(), false); + strictAssert.equal(tcIsTruncated(), false); }); it('returns false for a non-truncated record', () => { - runWithContext( + tcRun( () => { - strictAssert.equal(isContextTruncated(), false); + strictAssert.equal(tcIsTruncated(), false); }, { traceId: TRACE_ID_BYTES, @@ -679,49 +688,6 @@ function captureBytes(opts: { }); }); - describe('currentSpanIdMatches', () => { - it('returns false outside a context', () => { - strictAssert.equal(currentSpanIdMatches(SPAN_ID_BYTES), false); - }); - - it('returns true when the active record has the matching span id', () => { - runWithContext( - () => { - strictAssert.equal(currentSpanIdMatches(SPAN_ID_BYTES), true); - }, - {traceId: TRACE_ID_BYTES, spanId: SPAN_ID_BYTES}, - ); - }); - - it('returns false when the active record has a different span id', () => { - const otherSpan = bytesFromHex('1112131415161719'); - runWithContext( - () => { - strictAssert.equal(currentSpanIdMatches(otherSpan), false); - }, - {traceId: TRACE_ID_BYTES, spanId: SPAN_ID_BYTES}, - ); - }); - - it('returns false for malformed arguments', () => { - runWithContext( - () => { - strictAssert.equal( - currentSpanIdMatches('11121314' as unknown as Uint8Array), - false, - ); - // Wrong-length Uint8Array (Buffer is a Uint8Array subclass): the - // native side rejects anything that isn't exactly 8 bytes. - strictAssert.equal( - currentSpanIdMatches(Buffer.from('aabbccdd', 'hex')), - false, - ); - }, - {traceId: TRACE_ID_BYTES, spanId: SPAN_ID_BYTES}, - ); - }); - }); - describe('makeNamedContext', () => { it('rejects non-array keys', () => { strictAssert.throws( @@ -749,13 +715,12 @@ function captureBytes(opts: { ); }); - it('returns an object exposing all five NamedContext methods', () => { + it('returns an object exposing the NamedContext methods', () => { const named = makeNamedContext(['a']); + strictAssert.equal(typeof named.buildContext, 'function'); strictAssert.equal(typeof named.runWithContext, 'function'); strictAssert.equal(typeof named.enterWithContext, 'function'); strictAssert.equal(typeof named.clearContext, 'function'); - strictAssert.equal(typeof named.appendAttributes, 'function'); - strictAssert.equal(typeof named.isContextTruncated, 'function'); }); it('resolves namedAttributes given as an object', () => { @@ -843,7 +808,7 @@ function captureBytes(opts: { it('enterWithContext attaches a name-addressed record', () => { const named = makeNamedContext(['route']); - runWithContext( + tcRun( () => { named.enterWithContext({ traceId: TRACE_ID_BYTES, @@ -856,52 +821,25 @@ function captureBytes(opts: { ); }); - it('appendAttributes appends by name', () => { - const named = makeNamedContext([ - 'http.method', - 'http.route', - 'http.status', - ]); - named.runWithContext( - () => { - named.appendAttributes({'http.status': '500'}); - strictAssert.deepEqual(decodeAttrs(_currentRecordBytes()!), [ - 'GET', - '/x', - '500', - ]); - }, - { - traceId: TRACE_ID_BYTES, - spanId: SPAN_ID_BYTES, - namedAttributes: {'http.method': 'GET', 'http.route': '/x'}, - }, - ); - }); - - it('appendAttributes rejects unknown names', () => { + it('buildContext rejects unknown names', () => { const named = makeNamedContext(['known']); - named.runWithContext( - () => { - strictAssert.throws( - () => named.appendAttributes({unknown: 'v'}), - /unknown attribute name: unknown/, - ); - }, - { - traceId: TRACE_ID_BYTES, - spanId: SPAN_ID_BYTES, - namedAttributes: {known: 'k'}, - }, + strictAssert.throws( + () => + named.buildContext({ + traceId: TRACE_ID_BYTES, + spanId: SPAN_ID_BYTES, + namedAttributes: {unknown: 'v'}, + }), + /unknown attribute name: unknown/, ); }); - it('isContextTruncated mirrors the top-level function', () => { + it('isTruncated reflects appended-then-overflowed entries', () => { const named = makeNamedContext(['a', 'b', 'c']); named.runWithContext( () => { - strictAssert.equal(named.isContextTruncated(), false); - appendAttributes([ + strictAssert.equal(tcIsTruncated(), false); + tcAppend([ , , 'c'.repeat(255), @@ -912,7 +850,7 @@ function captureBytes(opts: { , 'e'.repeat(255), ]); - strictAssert.equal(named.isContextTruncated(), true); + strictAssert.equal(tcIsTruncated(), true); }, { traceId: TRACE_ID_BYTES, From daefb51bdb887ba263d6146306090271b3b5830a Mon Sep 17 00:00:00 2001 From: Attila Szegedi Date: Tue, 16 Jun 2026 17:16:26 +0200 Subject: [PATCH 15/15] Rename CtxWrap to ThreadContext in the JS API surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `CtxWrap` leaks the C++ ObjectWrap implementation detail — as far as the JS API is concerned, the object IS the thread context. Rename: - The JS-visible class name (SetClassName) and TS interface: `CtxWrap` → `ThreadContext`. - The TS constructor interface: `CtxWrapCtor` → `ThreadContextCtor`. - The addon constructor export: `addon.ctxWrap` → `addon.threadContext`. - The non-Linux fallback class: `NoopCtxWrap` → `NoopThreadContext`. - Parameter/variable names previously named `wrap` (or `_wrap`) → `context` (or `_context`). The native C++ class itself stays `CtxWrap` — that's the conventional ObjectWrap-ish naming on the C++ side and isn't user-visible. Error messages updated to refer to "ThreadContext" too ("not a ThreadContext", "ThreadContext must be called with `new`", etc.). --- bindings/otel-thread-ctx.cc | 14 ++--- ts/src/index.ts | 2 +- ts/src/otel-thread-ctx.ts | 99 ++++++++++++++++++--------------- ts/test/test-otel-thread-ctx.ts | 10 ++-- 4 files changed, 67 insertions(+), 58 deletions(-) diff --git a/bindings/otel-thread-ctx.cc b/bindings/otel-thread-ctx.cc index a04671b4..790ed105 100644 --- a/bindings/otel-thread-ctx.cc +++ b/bindings/otel-thread-ctx.cc @@ -313,12 +313,12 @@ void CtxWrap::New(const FunctionCallbackInfo& args) { Local context = isolate->GetCurrentContext(); if (!args.IsConstructCall()) [[unlikely]] { - isolate->ThrowError("CtxWrap must be called with `new`"); + isolate->ThrowError("ThreadContext must be called with `new`"); return; } if (args.Length() != 3) { isolate->ThrowError( - "CtxWrap expects 3 arguments: traceId, spanId, attributes"); + "ThreadContext expects 3 arguments: traceId, spanId, attributes"); return; } @@ -373,7 +373,7 @@ void CtxWrap::Append(const FunctionCallbackInfo& args) { CtxWrap* self = ObjectWrap::Unwrap(args.This()); if (!self) { - isolate->ThrowError("not a CtxWrap"); + isolate->ThrowError("not a ThreadContext"); return; } if (args.Length() != 1) { @@ -445,7 +445,7 @@ void CtxWrap::Append(const FunctionCallbackInfo& args) { void CtxWrap::IsTruncated(const FunctionCallbackInfo& args) { CtxWrap* self = ObjectWrap::Unwrap(args.This()); if (!self) { - args.GetIsolate()->ThrowError("not a CtxWrap"); + args.GetIsolate()->ThrowError("not a ThreadContext"); return; } args.GetReturnValue().Set(self->truncated_); @@ -455,7 +455,7 @@ void CtxWrap::DebugBytes(const FunctionCallbackInfo& args) { Isolate* isolate = args.GetIsolate(); CtxWrap* self = ObjectWrap::Unwrap(args.This()); if (!self) { - isolate->ThrowError("not a CtxWrap"); + isolate->ThrowError("not a ThreadContext"); return; } const size_t total = @@ -470,7 +470,7 @@ void CtxWrap::Init(Local exports) { Local context = isolate->GetCurrentContext(); Local tpl = FunctionTemplate::New(isolate, New); - tpl->SetClassName(String::NewFromUtf8Literal(isolate, "CtxWrap")); + tpl->SetClassName(String::NewFromUtf8Literal(isolate, "ThreadContext")); tpl->InstanceTemplate()->SetInternalFieldCount(1); tpl->PrototypeTemplate()->Set( @@ -486,7 +486,7 @@ void CtxWrap::Init(Local exports) { Local constructor = tpl->GetFunction(context).ToLocalChecked(); exports ->Set(context, - String::NewFromUtf8Literal(isolate, "ctxWrap"), + String::NewFromUtf8Literal(isolate, "threadContext"), constructor) .FromJust(); } diff --git a/ts/src/index.ts b/ts/src/index.ts index 2d928644..77af07bb 100644 --- a/ts/src/index.ts +++ b/ts/src/index.ts @@ -63,7 +63,7 @@ export const heap = { // Node 24+ by default) only; degrades to no-ops on other platforms / Node // versions. export const otelThreadCtx = { - CtxWrap: otelThreadCtxModule.CtxWrap, + ThreadContext: otelThreadCtxModule.ThreadContext, getContext: otelThreadCtxModule.getContext, setContext: otelThreadCtxModule.setContext, runWithContext: otelThreadCtxModule.runWithContext, diff --git a/ts/src/otel-thread-ctx.ts b/ts/src/otel-thread-ctx.ts index 69926f88..e0f3f101 100644 --- a/ts/src/otel-thread-ctx.ts +++ b/ts/src/otel-thread-ctx.ts @@ -35,7 +35,7 @@ import {AsyncLocalStorage} from 'node:async_hooks'; * `keys` array passed to {@link makeNamedContext}. Values are coerced to * strings via `toString`. Values longer than 255 UTF-8 bytes are silently * truncated, and attributes that would overflow the 612-byte payload cap - * are silently dropped (see {@link CtxWrap.isTruncated}). Names that + * are silently dropped (see {@link ThreadContext.isTruncated}). Names that * aren't in the key map throw. */ export interface NamedContextOptions { @@ -60,18 +60,18 @@ export interface ProcessContextAttributes { } /** - * A thread-context record. Construct with `new CtxWrap(...)`; install + * A thread-context record. Construct with `new ThreadContext(...)`; install * with {@link setContext} or {@link runWithContext}. The underlying * native record is GC-owned: when no JS or async-context-frame * reference survives, it's freed. * - * `appendAttributes` mutates the wrap's record in place. Because every - * async-context frame that holds the same `CtxWrap` reference observes + * `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 wrap's internal + * frames even when the reallocate path runs (the context's internal * pointer is updated, the JS object is not replaced). */ -export interface CtxWrap { +export interface ThreadContext { appendAttributes( attributes: Array | undefined, ): void; @@ -81,21 +81,21 @@ export interface CtxWrap { } /** - * Constructor for {@link CtxWrap}. On non-Linux platforms, returns a + * 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 CtxWrapCtor { +export interface ThreadContextCtor { new ( traceId: Uint8Array, spanId: Uint8Array, attributes?: Array, - ): CtxWrap; + ): ThreadContext; } interface Addon { - ctxWrap: CtxWrapCtor; - otelThreadCtxStoreAls(als: AsyncLocalStorage): void; + threadContext: ThreadContextCtor; + otelThreadCtxStoreAls(als: AsyncLocalStorage): void; otelThreadCtxGetStoredAlsHash(): number; otelThreadCtxWrappedObjectOffset: number; otelThreadCtxTaggedSize: number; @@ -104,12 +104,12 @@ interface Addon { /** * Object returned by {@link makeNamedContext}. Resolves the * `namedAttributes` map to a positional array against the key list - * captured at factory time and builds a {@link CtxWrap}; convenience + * captured at factory time and builds a {@link ThreadContext}; convenience * methods compose with {@link setContext} / {@link runWithContext}. */ export interface NamedContext { - /** Allocate a CtxWrap with attributes resolved positionally by name. */ - buildContext(opts: NamedContextOptions): CtxWrap; + /** Allocate a ThreadContext with attributes resolved positionally by name. */ + buildContext(opts: NamedContextOptions): ThreadContext; /** Sugar: `setContext(buildContext(opts))`. */ enterWithContext(opts: NamedContextOptions): void; /** Sugar: `runWithContext(buildContext(opts), fn)`. */ @@ -129,29 +129,32 @@ const SCHEMA_VERSION = 'nodejs_v1'; let WRAPPED_OBJECT_OFFSET = 24; let TAGGED_SIZE = 8; -/** {@inheritDoc CtxWrapCtor} */ -export let CtxWrap: CtxWrapCtor; +/** {@inheritDoc ThreadContextCtor} */ +export let ThreadContext: ThreadContextCtor; /** - * Returns the {@link CtxWrap} currently attached to the active + * Returns the {@link ThreadContext} currently attached to the active * async-context frame, or `undefined` if none is. */ -export let getContext: () => CtxWrap | undefined; +export let getContext: () => ThreadContext | undefined; /** - * Attach a {@link CtxWrap} (or `undefined` to detach) to the current + * Attach a {@link ThreadContext} (or `undefined` to detach) to the current * async-context frame. Idempotent for `setContext(undefined)` when no - * frame has been installed. Re-installing the same wrap reference is - * cheap (no allocation); per-span caching of the wrap on the caller + * frame has been installed. 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. */ -export let setContext: (wrap: CtxWrap | undefined) => void; +export let setContext: (context: ThreadContext | undefined) => void; /** * As {@link setContext}, but scoped to the callback's execution. After * `fn` returns, the previous context is restored. */ -export let runWithContext: (wrap: CtxWrap | undefined, fn: () => T) => T; +export let runWithContext: ( + context: ThreadContext | undefined, + fn: () => T, +) => T; // Debug accessor (not part of the stable API; for tests / reader dev). export let _currentRecordBytes: () => Uint8Array | undefined = () => undefined; @@ -163,9 +166,9 @@ if (process.platform === 'linux') { WRAPPED_OBJECT_OFFSET = addon.otelThreadCtxWrappedObjectOffset; TAGGED_SIZE = addon.otelThreadCtxTaggedSize; - CtxWrap = addon.ctxWrap; + ThreadContext = addon.threadContext; - let als: AsyncLocalStorage | undefined; + let als: AsyncLocalStorage | undefined; function asyncContextFrameError(): string | undefined { const [major] = process.versions.node.split('.').map(Number); @@ -182,7 +185,7 @@ if (process.platform === 'linux') { return 'Node major versions prior to v22 do not support the feature at all'; } - function ensureHook(): AsyncLocalStorage { + function ensureHook(): AsyncLocalStorage { if (als) return als; const err = asyncContextFrameError(); if (err) { @@ -190,46 +193,49 @@ if (process.platform === 'linux') { `otel thread-ctx writer requires async_context_frame support, which is unavailable: ${err}.`, ); } - als = new AsyncLocalStorage(); + als = new AsyncLocalStorage(); addon.otelThreadCtxStoreAls(als); return als; } - getContext = function (): CtxWrap | undefined { + getContext = function (): ThreadContext | undefined { return als ? als.getStore() : undefined; }; - setContext = function (wrap: CtxWrap | undefined): void { - if (wrap === undefined) { + setContext = function (context: ThreadContext | undefined): void { + if (context === undefined) { // Idempotent: clearing when the hook hasn't been installed (no // prior setContext call) is a no-op. if (!als) return; - als.enterWith(undefined as unknown as CtxWrap); + als.enterWith(undefined as unknown as ThreadContext); return; } - ensureHook().enterWith(wrap); + ensureHook().enterWith(context); }; - runWithContext = function (wrap: CtxWrap | undefined, fn: () => T): T { - if (wrap === undefined) { + runWithContext = function ( + context: ThreadContext | undefined, + fn: () => T, + ): T { + if (context === undefined) { if (!als) return fn(); - return als.run(undefined as unknown as CtxWrap, fn); + return als.run(undefined as unknown as ThreadContext, fn); } - return ensureHook().run(wrap, fn); + return ensureHook().run(context, fn); }; _currentRecordBytes = function (): Uint8Array | undefined { if (!als) return undefined; - const wrap = als.getStore(); - return wrap ? wrap.debugBytes() : 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 wrap, and setContext / + // construction succeeds but produces an inert context, and setContext / // runWithContext don't actually wire anything into AsyncLocalStorage. - class NoopCtxWrap implements CtxWrap { + class NoopThreadContext implements ThreadContext { appendAttributes(): void {} isTruncated(): boolean { return false; @@ -238,18 +244,21 @@ if (process.platform === 'linux') { return new Uint8Array(0); } } - CtxWrap = NoopCtxWrap as CtxWrapCtor; + ThreadContext = NoopThreadContext as ThreadContextCtor; getContext = function (): undefined { return undefined; }; setContext = function (): void {}; - runWithContext = function (_wrap: CtxWrap | undefined, fn: () => T): T { + runWithContext = function ( + _context: ThreadContext | undefined, + fn: () => T, + ): T { return fn(); }; } /** - * Build name-addressed wrappers around {@link CtxWrap}, + * Build name-addressed wrappers around {@link ThreadContext}, * {@link setContext}, and {@link runWithContext}. The supplied * `keys` array is the same string list the caller publishes (or has * published) as the `threadlocal.attribute_key_map` resource attribute @@ -308,11 +317,11 @@ export function makeNamedContext(keys: string[]): NamedContext { return attributes; } - function buildContext(opts: NamedContextOptions): CtxWrap { + function buildContext(opts: NamedContextOptions): ThreadContext { if (!opts || typeof opts !== 'object') { throw new TypeError('options object required'); } - return new CtxWrap( + return new ThreadContext( opts.traceId, opts.spanId, resolveAttributes(opts.namedAttributes), diff --git a/ts/test/test-otel-thread-ctx.ts b/ts/test/test-otel-thread-ctx.ts index 29cd29d5..3c86f5d1 100644 --- a/ts/test/test-otel-thread-ctx.ts +++ b/ts/test/test-otel-thread-ctx.ts @@ -25,7 +25,7 @@ import {existsSync} from 'node:fs'; import {join} from 'node:path'; import { - CtxWrap, + ThreadContext, getContext, makeNamedContext, runWithContext, @@ -34,7 +34,7 @@ import { } from '../src/otel-thread-ctx'; // Helpers bridging the old positional-attrs test shape to the new -// CtxWrap-first API. +// ThreadContext-first API. interface PosOpts { traceId: Uint8Array; spanId: Uint8Array; @@ -42,12 +42,12 @@ interface PosOpts { } function tcRun(fn: () => T, opts: PosOpts): T { return runWithContext( - new CtxWrap(opts.traceId, opts.spanId, opts.attributes), + new ThreadContext(opts.traceId, opts.spanId, opts.attributes), fn, ); } function tcEnter(opts: PosOpts): void { - setContext(new CtxWrap(opts.traceId, opts.spanId, opts.attributes)); + setContext(new ThreadContext(opts.traceId, opts.spanId, opts.attributes)); } function tcClear(): void { setContext(undefined); @@ -152,7 +152,7 @@ function captureBytes(opts: { (isLinux && isAsyncContextFrameAvailable ? describe : describe.skip)( 'OTEP-4947 thread context (Linux-only)', () => { - describe('CtxWrap construction', () => { + describe('ThreadContext construction', () => { it('accepts Uint8Array trace and span IDs', () => { const bytes = captureBytes({ traceId: TRACE_ID_BYTES,