From 069c05eb4b769578f05b77dd557d97625dbf4c63 Mon Sep 17 00:00:00 2001 From: Attila Szegedi Date: Wed, 27 May 2026 17:34:29 +0200 Subject: [PATCH 01/17] 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 | 547 ++++++++++++++++++++ bindings/otel-thread-ctx.hh | 26 + ts/src/otel-thread-ctx.ts | 347 +++++++++++++ ts/test/test-otel-thread-ctx.ts | 856 ++++++++++++++++++++++++++++++++ 6 files changed, 1791 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..fda41ce8 --- /dev/null +++ b/bindings/otel-thread-ctx.cc @@ -0,0 +1,547 @@ +/* + * 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); + + CtxWrap(OtelThreadCtxRecord *record, size_t capacity, bool truncated); + + // The three fields are kept in one access section because C++ leaves + // the relative layout of fields in different access controls + // implementation-defined. `record_` must come first — its offset + // within CtxWrap is part of the reader contract (see the + // static_assert below) — and is therefore `public`. The bookkeeping + // fields after it would normally be private, but the access change + // would let a conforming compiler reorder them in front of `record_`; + // exposing them publicly keeps everything in one ordering-stable + // block. Readers never touch them. + public: + OtelThreadCtxRecord *record_; + // attrs_data capacity in bytes of the record_ allocation. The total + // allocation is `sizeof(OtelThreadCtxRecord) + capacity_`. Always + // `record_->attrs_data_size <= capacity_ <= MAX_ATTRS_DATA_SIZE`. + size_t capacity_; + // Set to true (once, never cleared) if at any point in this record's + // lifetime — during New() or any subsequent Append() — at least one + // attribute had to be dropped because it would have pushed attrs_data + // past MAX_ATTRS_DATA_SIZE. + bool truncated_; +}; + +// Pin the offset of `record_` — the field the reader walks to from the +// JSObject's internal field 0. We document it as "the first field after +// the node::ObjectWrap base subobject", so equality with +// sizeof(node::ObjectWrap) is the invariant. `offsetof` on a non- +// standard-layout type (CtxWrap has private fields and inherits from +// ObjectWrap) is conditionally supported per the standard but accepted +// by every compiler this addon targets; suppress -Winvalid-offsetof so +// the static_assert compiles cleanly under strict warning flags. +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Winvalid-offsetof" +static_assert(offsetof(CtxWrap, record_) == sizeof(node::ObjectWrap), + "record_ must be the first field after the ObjectWrap base " + "subobject"); +#pragma GCC diagnostic pop + +CtxWrap::~CtxWrap() { free(record_); } + +CtxWrap::CtxWrap(OtelThreadCtxRecord *record, size_t capacity, bool truncated) + : record_(record), capacity_(capacity), truncated_(truncated) {} + +// Copy exactly `expected_bytes` bytes out of a JS Uint8Array (or subclass +// such as Buffer) into `out`. Returns false if the value isn't a +// Uint8Array or its length doesn't match. +bool CopyBytes(Local value, size_t expected_bytes, uint8_t *out) { + if (!value->IsUint8Array()) return false; + Local arr = value.As(); + if (arr->ByteLength() != expected_bytes) return false; + uint8_t *base = + static_cast(arr->Buffer()->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()) [[unlikely]] { + 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 = std::max(attrs_buf.size(), MIN_INITIAL_CAPACITY); + const size_t total = sizeof(OtelThreadCtxRecord) + capacity; + OwnedRecord record(static_cast(calloc(1, total))); + if (!record) { + isolate->ThrowError("allocation failed"); + return; + } + memcpy(record->trace_id, trace_id, sizeof(trace_id)); + memcpy(record->span_id, span_id, sizeof(span_id)); + record->attrs_data_size = static_cast(attrs_buf.size()); + if (!attrs_buf.empty()) { + memcpy(record->attrs_data, attrs_buf.data(), attrs_buf.size()); + } + + // OTEP-4947 publication protocol: order the `valid = 1` store after every + // other field write, with an atomic_signal_fence + 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 = std::min(std::max(self->capacity_ * 2, new_used), MAX_ATTRS_DATA_SIZE); + + const size_t total = sizeof(OtelThreadCtxRecord) + new_cap; + OwnedRecord new_rec(static_cast(calloc(1, total))); + if (!new_rec) { + isolate->ThrowError("allocation failed"); + return; + } + 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. + 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); +} + +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 d6be1bc9d507a6ad9df3b1b5999feb4b5dd6633d Mon Sep 17 00:00:00 2001 From: Attila Szegedi Date: Wed, 27 May 2026 17:56:24 +0200 Subject: [PATCH 02/17] 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 be4823bc..9527112d 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 a991075df81fd6d6ede730a70701c5aada052819 Mon Sep 17 00:00:00 2001 From: Attila Szegedi Date: Wed, 10 Jun 2026 15:47:31 +0200 Subject: [PATCH 03/17] 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 be20640c8005538c9bfa5d8efdaee4ed0d10e22e Mon Sep 17 00:00:00 2001 From: Attila Szegedi Date: Thu, 11 Jun 2026 17:06:52 +0200 Subject: [PATCH 04/17] 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 1802a083f1f880274081c2b6f9b790b7c4bb38c2 Mon Sep 17 00:00:00 2001 From: Attila Szegedi Date: Thu, 11 Jun 2026 17:45:51 +0200 Subject: [PATCH 05/17] 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 e6b9d44d207b113bf96f1bd2c0b8930dbae84714 Mon Sep 17 00:00:00 2001 From: Attila Szegedi Date: Thu, 11 Jun 2026 18:06:40 +0200 Subject: [PATCH 06/17] 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 5adbdf5bb2e0d17a6069ae84f4b31e5586bd5086 Mon Sep 17 00:00:00 2001 From: Attila Szegedi Date: Thu, 11 Jun 2026 18:31:32 +0200 Subject: [PATCH 07/17] 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 b3724e6b7f7e6713269458d99c573fa1ede356b1 Mon Sep 17 00:00:00 2001 From: Attila Szegedi Date: Thu, 11 Jun 2026 18:31:44 +0200 Subject: [PATCH 08/17] 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 36808356a76d2b2df3cb512b3f3f1f2bd93ea7d7 Mon Sep 17 00:00:00 2001 From: Attila Szegedi Date: Thu, 11 Jun 2026 19:13:11 +0200 Subject: [PATCH 09/17] 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 ba65497a10ccdbdaac68bdfddc62dcfa0cc69d14 Mon Sep 17 00:00:00 2001 From: Attila Szegedi Date: Thu, 11 Jun 2026 19:13:14 +0200 Subject: [PATCH 10/17] 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 3306ca88436ad11ef76b993055c794b864c38acd Mon Sep 17 00:00:00 2001 From: Attila Szegedi Date: Tue, 16 Jun 2026 10:27:39 +0200 Subject: [PATCH 11/17] 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 e74fe19e894b7d9d9e4b44c41add3c0a7c348059 Mon Sep 17 00:00:00 2001 From: Attila Szegedi Date: Tue, 16 Jun 2026 12:52:00 +0200 Subject: [PATCH 12/17] 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 | 19 +-- ts/src/index.ts | 7 +- ts/src/otel-thread-ctx.ts | 264 ++++++++++++++++---------------- ts/test/test-otel-thread-ctx.ts | 214 ++++++++++++-------------- 4 files changed, 242 insertions(+), 262 deletions(-) diff --git a/bindings/otel-thread-ctx.cc b/bindings/otel-thread-ctx.cc index 2e7e0972..a04671b4 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("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; } @@ -373,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) { @@ -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 an OtelThreadCtxWrap"); + args.GetIsolate()->ThrowError("not a CtxWrap"); 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 an OtelThreadCtxWrap"); + isolate->ThrowError("not a CtxWrap"); return; } const size_t total = @@ -470,14 +470,15 @@ 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)); @@ -485,7 +486,7 @@ void CtxWrap::Init(Local exports) { 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 3bdac01c..2d928644 100644 --- a/ts/src/index.ts +++ b/ts/src/index.ts @@ -63,11 +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, makeNamedContext: otelThreadCtxModule.makeNamedContext, }; diff --git a/ts/src/otel-thread-ctx.ts b/ts/src/otel-thread-ctx.ts index c2f1eb4b..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,40 +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; - readonly processContextAttributes: ProcessContextAttributes; -} - -interface CtxWrap { - debugBytes(): Uint8Array; - append(attributes: Array | undefined): void; isTruncated(): 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. @@ -116,29 +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; + +/** + * 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 setContext: (wrap: CtxWrap | undefined) => 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. + * As {@link setContext}, but scoped to the callback's execution. After + * `fn` returns, the previous context is restored. */ -export let clearContext: () => void; -export let appendAttributes: ( - attributes: Array, -) => void; -export let isContextTruncated: () => 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 { @@ -169,85 +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(); + 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(); + // 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; }; - enterWithContext = function (): void {}; - clearContext = function (): void {}; - appendAttributes = function (): void {}; - isContextTruncated = 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)) { @@ -300,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({ @@ -319,25 +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)); - }, - clearContext(): void { - clearContext(); + setContext(buildContext(opts)); }, - appendAttributes( - namedAttributes: - | Record - | Map - | Array<[string, unknown]>, - ): void { - appendAttributes(resolveAttributes(namedAttributes)!); + runWithContext(fn: () => T, opts: NamedContextOptions): T { + return runWithContext(buildContext(opts), fn); }, - isContextTruncated(): boolean { - return isContextTruncated(); + clearContext(): void { + setContext(undefined); }, processContextAttributes, }; diff --git a/ts/test/test-otel-thread-ctx.ts b/ts/test/test-otel-thread-ctx.ts index 0de8342c..29cd29d5 100644 --- a/ts/test/test-otel-thread-ctx.ts +++ b/ts/test/test-otel-thread-ctx.ts @@ -25,16 +25,42 @@ import {existsSync} from 'node:fs'; import {join} from 'node:path'; import { - ContextOptions, - appendAttributes, - clearContext, - 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 @@ -117,7 +143,7 @@ function captureBytes(opts: { attributes?: Array; }): Uint8Array { let bytes: Uint8Array | undefined; - runWithContext(() => { + tcRun(() => { bytes = _currentRecordBytes(); }, opts); return bytes as Uint8Array; @@ -278,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, @@ -322,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, }); @@ -334,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, }); @@ -346,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); @@ -359,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(); @@ -379,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++) { @@ -400,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, @@ -408,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, @@ -426,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}, @@ -475,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, @@ -497,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, @@ -530,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', , @@ -547,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); @@ -563,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++) { @@ -584,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); }, @@ -604,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); }, @@ -617,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', @@ -661,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, @@ -705,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', () => { @@ -799,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, @@ -812,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), @@ -868,7 +850,7 @@ function captureBytes(opts: { , 'e'.repeat(255), ]); - strictAssert.equal(named.isContextTruncated(), true); + strictAssert.equal(tcIsTruncated(), true); }, { traceId: TRACE_ID_BYTES, From 33db17beafd6ba8dd0e4bb0e7d965b386fbeff18 Mon Sep 17 00:00:00 2001 From: Attila Szegedi Date: Tue, 16 Jun 2026 17:16:26 +0200 Subject: [PATCH 13/17] 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, From 04557a6c25c43dc08d4d4993cd2df24cefc23606 Mon Sep 17 00:00:00 2001 From: Attila Szegedi Date: Tue, 23 Jun 2026 12:48:51 +0200 Subject: [PATCH 14/17] Update process-context schema-version and layout-key names --- ts/src/otel-thread-ctx.ts | 12 ++++++------ ts/test/test-otel-thread-ctx.ts | 14 +++++++------- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/ts/src/otel-thread-ctx.ts b/ts/src/otel-thread-ctx.ts index e0f3f101..54f74abe 100644 --- a/ts/src/otel-thread-ctx.ts +++ b/ts/src/otel-thread-ctx.ts @@ -53,10 +53,10 @@ export interface NamedContextOptions { * application hands to its OTEP-4719 process-context publisher. */ export interface ProcessContextAttributes { - readonly 'threadlocal.schema_version': 'nodejs_v1'; + readonly 'threadlocal.schema_version': 'nodejs_v1_dev'; readonly 'threadlocal.attribute_key_map': readonly string[]; - readonly 'threadlocal.nodejs_v1.wrapped_object_offset': number; - readonly 'threadlocal.nodejs_v1.tagged_size': number; + readonly 'threadlocal.wrapped_object_offset': number; + readonly 'threadlocal.tagged_size': number; } /** @@ -119,7 +119,7 @@ export interface NamedContext { readonly processContextAttributes: ProcessContextAttributes; } -const SCHEMA_VERSION = 'nodejs_v1'; +const SCHEMA_VERSION = 'nodejs_v1_dev'; // V8 layout constants the addon captured from the V8 headers Node bundles. // On non-Linux these fall back to values matching Node's standard build @@ -331,8 +331,8 @@ export function makeNamedContext(keys: string[]): NamedContext { 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, + 'threadlocal.wrapped_object_offset': WRAPPED_OBJECT_OFFSET, + 'threadlocal.tagged_size': TAGGED_SIZE, }) as ProcessContextAttributes; return { diff --git a/ts/test/test-otel-thread-ctx.ts b/ts/test/test-otel-thread-ctx.ts index 3c86f5d1..59b3b00f 100644 --- a/ts/test/test-otel-thread-ctx.ts +++ b/ts/test/test-otel-thread-ctx.ts @@ -865,18 +865,18 @@ function captureBytes(opts: { 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, + pca['threadlocal.schema_version'], + 'nodejs_v1_dev', ); - strictAssert.equal(pca['threadlocal.nodejs_v1.tagged_size'], 8); + strictAssert.deepEqual(pca['threadlocal.attribute_key_map'], keys); + strictAssert.equal(pca['threadlocal.wrapped_object_offset'], 24); + strictAssert.equal(pca['threadlocal.tagged_size'], 8); strictAssert.deepEqual(Object.keys(pca).sort(), [ 'threadlocal.attribute_key_map', - 'threadlocal.nodejs_v1.tagged_size', - 'threadlocal.nodejs_v1.wrapped_object_offset', 'threadlocal.schema_version', + 'threadlocal.tagged_size', + 'threadlocal.wrapped_object_offset', ]); }); From 7870f9fa242ffeb1b19d53e8e2089f0a78a03960 Mon Sep 17 00:00:00 2001 From: Attila Szegedi Date: Tue, 23 Jun 2026 13:19:23 +0200 Subject: [PATCH 15/17] Note vendored-from-polarsignals provenance on OTEP-4947 files --- bindings/otel-thread-ctx.cc | 6 ++++++ ts/src/otel-thread-ctx.ts | 6 ++++++ ts/test/test-otel-thread-ctx.ts | 6 ++++++ 3 files changed, 18 insertions(+) diff --git a/bindings/otel-thread-ctx.cc b/bindings/otel-thread-ctx.cc index 790ed105..927965fb 100644 --- a/bindings/otel-thread-ctx.cc +++ b/bindings/otel-thread-ctx.cc @@ -14,6 +14,12 @@ * limitations under the License. */ +// Vendored from https://github.com/polarsignals/custom-labels/tree/otel-thread-ctx-wip/js/ +// (originally js/addon.cpp). Kept as a near-verbatim copy: edits should +// ideally land upstream first and be ported here, so the two stay in +// sync. We plan to drop this vendored copy once the upstream package is +// suitable to depend on directly. + // Node.js writer for the OTEP-4947 Thread Local Context Record, adapted for // the Node.js asynchronous context model. The record is wrapped in a JS // object (CtxWrap) and stored in an AsyncLocalStorage instance; an diff --git a/ts/src/otel-thread-ctx.ts b/ts/src/otel-thread-ctx.ts index 54f74abe..794fddb6 100644 --- a/ts/src/otel-thread-ctx.ts +++ b/ts/src/otel-thread-ctx.ts @@ -14,6 +14,12 @@ * limitations under the License. */ +// Vendored from https://github.com/polarsignals/custom-labels/tree/otel-thread-ctx-wip/js/ +// (originally js/index.js + js/index.d.ts, merged into TypeScript). Kept +// as a near-verbatim copy: edits should ideally land upstream first and +// be ported here, so the two stay in sync. We plan to drop this vendored +// copy once the upstream package is suitable to depend on directly. + // Node.js writer for the OpenTelemetry Thread Local Context Record // (OTEP-4947), discoverable from an out-of-process reader via the // `otel_thread_ctx_nodejs_v1` thread-local symbol exported by diff --git a/ts/test/test-otel-thread-ctx.ts b/ts/test/test-otel-thread-ctx.ts index 59b3b00f..cc05f0d7 100644 --- a/ts/test/test-otel-thread-ctx.ts +++ b/ts/test/test-otel-thread-ctx.ts @@ -14,6 +14,12 @@ * limitations under the License. */ +// Vendored from https://github.com/polarsignals/custom-labels/tree/otel-thread-ctx-wip/js/test/ +// (originally js/test/test.js, ported to TypeScript). Kept as a +// near-verbatim copy: edits should ideally land upstream first and be +// ported here, so the two stay in sync. We plan to drop this vendored +// copy once the upstream package is suitable to depend on directly. + // Tests intentionally use array holes to verify the writer's positional // attribute encoding (where a hole means "no value at this key index"). /* eslint-disable no-sparse-arrays */ From e97824378f14493260b2c2b3011a6bdbf3761607 Mon Sep 17 00:00:00 2001 From: Attila Szegedi Date: Tue, 23 Jun 2026 13:23:46 +0200 Subject: [PATCH 16/17] Restore upstream comment wording in vendored otel-thread-ctx.cc --- bindings/otel-thread-ctx.cc | 105 +++++++++++++++++++++++++++++------- 1 file changed, 87 insertions(+), 18 deletions(-) diff --git a/bindings/otel-thread-ctx.cc b/bindings/otel-thread-ctx.cc index 927965fb..b10f8ff3 100644 --- a/bindings/otel-thread-ctx.cc +++ b/bindings/otel-thread-ctx.cc @@ -59,9 +59,12 @@ // (`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. +// currently attached; without it, a reader walking through undefined +// would have to rely on structural validation of the bytes at +// undefined+wrapped_object_offset to detect the absence. // -// Layout is part of the reader ABI: see static_asserts below. +// Layout is part of the reader ABI: see the README "Discovery contract" +// section and the static_asserts below. extern "C" { using v8::Global; using v8::Object; @@ -119,7 +122,8 @@ using v8::Value; // `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. +// see only the 28-byte header. Field offsets are statically verified +// below. struct OtelThreadCtxRecord { uint8_t trace_id[16]; // offset 0 uint8_t span_id[8]; // offset 16 @@ -147,15 +151,17 @@ using OwnedRecord = // 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 +// "frugal writer" guidance ("a frugal writer may aim to keep the entire +// record under 64 bytes") — and giving small records some slack so the // first few appends (if any) can be in-place. constexpr size_t MIN_INITIAL_CAPACITY = 64 - sizeof(OtelThreadCtxRecord); -// Upper bound on the attribute payload. Sized so the total record 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. +// Upper bound on the attribute payload. Sized so the total record (28-byte +// header + attrs_data) stays under the OTEP-4947 recommended 640 bytes, +// which is the read-buffer ceiling for typical eBPF readers. Attributes +// that would push past this are silently dropped (with `truncated_` set on +// the wrapper) rather than the writer throwing — the OTEP treats the cap +// as best-effort. constexpr size_t MAX_ATTRS_DATA_SIZE = 640 - sizeof(OtelThreadCtxRecord); // Wraps a heap-allocated OtelThreadCtxRecord. Lifetime is managed by V8 @@ -183,6 +189,12 @@ class CtxWrap : public ObjectWrap { static void Append(const FunctionCallbackInfo& args); static void IsTruncated(const FunctionCallbackInfo& args); + // Encode the JS array at `attrs_val` into `out` as packed (key, len, value) + // entries. Same shape used by both New() and Append(). On a parse error + // (non-array, etc.) throws via `isolate` and returns false. On per-entry + // overflow against the 612-byte attrs_data cap, the entry is dropped, + // `*out_truncated` is set to true, and processing continues with the + // next entry (a smaller subsequent entry may still fit). static bool EncodeAttrs(Isolate* isolate, Local context, Local attrs_val, @@ -250,6 +262,14 @@ bool CopyBytes(Local value, size_t expected_bytes, uint8_t* out) { return true; } +// Encode the JS array `attrs_val` (positional, index N = uint8 key N) into +// `*out` as packed `(key:u8, len:u8, value:u8[len])` entries. +// `existing_size` is the number of bytes already in any pre-existing +// record's attrs_data — used so the cap is enforced across the combined +// result. On a parse error (wrong type, etc.) throws and returns false. An +// entry whose encoding would push the combined size past MAX_ATTRS_DATA_SIZE +// is dropped (not encoded into `*out`), `*out_truncated` is set, and +// processing continues so a smaller subsequent entry may still fit. bool CtxWrap::EncodeAttrs(Isolate* isolate, Local context, Local attrs_val, @@ -268,10 +288,13 @@ bool CtxWrap::EncodeAttrs(Isolate* isolate, isolate->ThrowError("attributes array length must not exceed 256"); return false; } + // Reserve a conservative upper bound; reallocations are cheap but + // unnecessary for the typical small attribute set. out->reserve(out->size() + n * 4); for (uint32_t i = 0; i < n; ++i) { Local val_val; if (!attrs->Get(context, i).ToLocal(&val_val)) return false; + // null / undefined / array holes mean "no value at this key index". if (val_val->IsUndefined() || val_val->IsNull()) continue; Local v; @@ -281,10 +304,15 @@ bool CtxWrap::EncodeAttrs(Isolate* isolate, #else int v_utf8_len = v->Utf8Length(isolate); #endif + // The on-the-wire val_len prefix is a uint8, so individual values + // longer than 255 UTF-8 bytes are silently truncated to 255. int v_budget = v_utf8_len > 255 ? 255 : v_utf8_len; const size_t needed = 2u + static_cast(v_budget); if (existing_size + out->size() + needed > MAX_ATTRS_DATA_SIZE) { + // Doesn't fit in the remaining budget; drop this entry and set the + // truncated flag. Smaller subsequent entries may still fit, so we + // continue rather than break. *out_truncated = true; continue; } @@ -292,6 +320,11 @@ bool CtxWrap::EncodeAttrs(Isolate* isolate, const size_t entry_off = out->size(); out->resize(entry_off + needed); (*out)[entry_off] = static_cast(i); + // WriteUtf8 returns the actual number of bytes written, which can be + // less than v_budget when the cap lands inside a multibyte codepoint + // — WriteUtf8 stops before writing a partial sequence. Use that count + // as the length prefix, and shrink the buffer back so the next entry + // starts at exactly the right offset. #if NODE_MAJOR_VERSION >= 24 int v_written = static_cast( v->WriteUtf8V2(isolate, @@ -328,6 +361,8 @@ void CtxWrap::New(const FunctionCallbackInfo& args) { return; } + // Validate IDs into a scratch header first; we copy into the final + // allocation once we know how much room the attrs payload needs. uint8_t trace_id[16]; uint8_t span_id[8]; if (!CopyBytes(args[0], 16, trace_id)) { @@ -339,12 +374,24 @@ void CtxWrap::New(const FunctionCallbackInfo& args) { return; } + // Encode attributes into a transient buffer first so we can size the + // record allocation correctly. The 612-byte attrs_data cap mirrors the + // OTEP-recommended 640-byte total-record ceiling (which exists for + // eBPF readers that copy the record into a fixed-size kernel buffer); + // entries that wouldn't fit are silently dropped and recorded via the + // truncated flag below. std::vector attrs_buf; bool truncated = false; if (!EncodeAttrs(isolate, context, args[2], 0, &attrs_buf, &truncated)) { return; } + // Pick the initial attrs_data capacity. Small records get a 64-byte + // floor so the first append is likely to fit in-place; larger records + // are sized exactly to what's needed (the extra slack a doubling + // strategy would buy is dwarfed by the existing memory footprint and + // doesn't change the geometric-growth amortized cost of subsequent + // appends). size_t capacity = std::max(attrs_buf.size(), MIN_INITIAL_CAPACITY); const size_t total = sizeof(OtelThreadCtxRecord) + capacity; OwnedRecord record(static_cast(calloc(1, total))); @@ -360,7 +407,10 @@ 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. + // other field write, with an `atomic_signal_fence` to pin that ordering at + // compile time and a volatile store so the compiler can't fold or hoist + // the write. The signal fence + volatile store is also the protocol used + // by Append() in its in-place path. std::atomic_signal_fence(std::memory_order_release); *reinterpret_cast(&record->valid) = 1; @@ -369,9 +419,9 @@ void CtxWrap::New(const FunctionCallbackInfo& args) { 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 +// Append entries to the active record. Either modifies the record in place +// (if the appended bytes fit in the current allocation's slack) or +// reallocates to a larger one (geometrically), keeping invariant // `record_->attrs_data_size <= capacity_`. void CtxWrap::Append(const FunctionCallbackInfo& args) { Isolate* isolate = args.GetIsolate(); @@ -396,16 +446,26 @@ void CtxWrap::Append(const FunctionCallbackInfo& args) { } if (truncated) self->truncated_ = true; + // Nothing to append — either the input array was empty, every slot was + // null/undefined, or every entry was dropped because the record is + // already at the cap. if (appended.empty()) return; const size_t new_used = current_used + appended.size(); + // EncodeAttrs already enforced the cap; new_used <= MAX_ATTRS_DATA_SIZE. if (new_used <= self->capacity_) { // In-place: write the new entries past the current attrs_data_size, - // then bump attrs_data_size with a release fence + volatile store. - // 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. + // then bump attrs_data_size with a release fence + volatile store so + // the content writes are visible before the size store from the + // compiler's perspective. + // + // No valid=0/valid=1 dance: this is an append-only operation. Bytes + // past attrs_data_size aren't observable by the reader, and + // attrs_data_size *is* the publication boundary. A reader firing + // mid-append sees either the old size (old extent, ignores the + // half-written tail) or the new size (full new extent, all bytes + // written). Either is consistent. memcpy(&self->record_->attrs_data[current_used], appended.data(), appended.size()); @@ -415,7 +475,7 @@ void CtxWrap::Append(const FunctionCallbackInfo& args) { return; } - // Doesn't fit. Reallocate with geometric growth, capped. + // Doesn't fit. Reallocate with geometric growth with cap. size_t new_cap = std::min(std::max(self->capacity_ * 2, new_used), MAX_ATTRS_DATA_SIZE); @@ -425,8 +485,10 @@ void CtxWrap::Append(const FunctionCallbackInfo& args) { isolate->ThrowError("allocation failed"); return; } + // Copy the existing record (header + already-written attrs_data). memcpy( new_rec.get(), self->record_, sizeof(OtelThreadCtxRecord) + current_used); + // Append the new entries and update attrs_data_size. memcpy(&new_rec->attrs_data[current_used], appended.data(), appended.size()); new_rec->attrs_data_size = static_cast(new_used); // The copy should've preserved valid=1 from the source record. @@ -448,6 +510,10 @@ void CtxWrap::Append(const FunctionCallbackInfo& args) { free(old_rec); } +// Returns true if any attribute was ever dropped from this wrapper's +// record because it would have pushed attrs_data past the cap — set during +// CtxWrap::New() if the initial set didn't fit, or by any subsequent +// CtxWrap::Append() call. void CtxWrap::IsTruncated(const FunctionCallbackInfo& args) { CtxWrap* self = ObjectWrap::Unwrap(args.This()); if (!self) { @@ -457,6 +523,9 @@ void CtxWrap::IsTruncated(const FunctionCallbackInfo& args) { args.GetReturnValue().Set(self->truncated_); } +// Debug accessor: returns the record (header + attrs_data) as a fresh +// Uint8Array sized to the actual on-the-wire length. Not part of the stable +// API; intended for tests and out-of-process-reader development. void CtxWrap::DebugBytes(const FunctionCallbackInfo& args) { Isolate* isolate = args.GetIsolate(); CtxWrap* self = ObjectWrap::Unwrap(args.This()); From 9451063494a637ad60e7332eeba6f344f5642488 Mon Sep 17 00:00:00 2001 From: Attila Szegedi Date: Wed, 24 Jun 2026 18:29:56 +0200 Subject: [PATCH 17/17] Move setContext/runWithContext to ThreadContext.enter/run methods setContext and runWithContext accepted arbitrary objects, so callers could poison the AsyncLocalStorage with values that aren't a ThreadContext. Move the active-context channel onto the ThreadContext prototype itself (enter and run methods); restore a module-level clearContext for detaching, since setContext(undefined) is no longer the way. --- ts/src/index.ts | 3 +- ts/src/otel-thread-ctx.ts | 112 +++++++++++++++++--------------- ts/test/test-otel-thread-ctx.ts | 27 +++----- 3 files changed, 71 insertions(+), 71 deletions(-) diff --git a/ts/src/index.ts b/ts/src/index.ts index 77af07bb..1768b68d 100644 --- a/ts/src/index.ts +++ b/ts/src/index.ts @@ -65,8 +65,7 @@ export const heap = { export const otelThreadCtx = { ThreadContext: otelThreadCtxModule.ThreadContext, getContext: otelThreadCtxModule.getContext, - setContext: otelThreadCtxModule.setContext, - runWithContext: otelThreadCtxModule.runWithContext, + clearContext: otelThreadCtxModule.clearContext, makeNamedContext: otelThreadCtxModule.makeNamedContext, }; diff --git a/ts/src/otel-thread-ctx.ts b/ts/src/otel-thread-ctx.ts index 794fddb6..d63e98be 100644 --- a/ts/src/otel-thread-ctx.ts +++ b/ts/src/otel-thread-ctx.ts @@ -67,9 +67,9 @@ export interface ProcessContextAttributes { /** * 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. + * via the {@link enter} or {@link run} instance methods. The underlying + * native record is GC-owned: when no JS or async-context-frame reference + * survives, it's freed. * * `appendAttributes` mutates the context's record in place. Because every * async-context frame that holds the same `ThreadContext` reference observes @@ -84,6 +84,26 @@ export interface ThreadContext { isTruncated(): boolean; /** Debug-only: returns the on-the-wire record bytes. Not stable. */ debugBytes(): Uint8Array; + + /** + * Attach this context to the current async-context frame (and every + * frame derived from it until the frame ends or {@link clearContext} + * detaches it). Re-installing the same context reference is cheap (no + * allocation); per-span caching of the context on the caller side is + * the intended usage pattern. + * + * On non-Linux platforms this is a no-op. + */ + enter(): void; + + /** + * Attach this context for the duration of `fn`. Equivalent to + * `als.run(this, fn)` — after `fn` returns, the previous context is + * restored. Returns whatever `fn` returns; if `fn` returns a Promise, + * the same Promise is propagated. On non-Linux platforms simply + * invokes `fn`. + */ + run(fn: () => T): T; } /** @@ -97,6 +117,7 @@ export interface ThreadContextCtor { spanId: Uint8Array, attributes?: Array, ): ThreadContext; + readonly prototype: ThreadContext; } interface Addon { @@ -111,16 +132,17 @@ 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 ThreadContext}; convenience - * methods compose with {@link setContext} / {@link runWithContext}. + * methods compose with {@link ThreadContext.enter} / + * {@link ThreadContext.run}. */ export interface NamedContext { /** Allocate a ThreadContext with attributes resolved positionally by name. */ buildContext(opts: NamedContextOptions): ThreadContext; - /** Sugar: `setContext(buildContext(opts))`. */ + /** Sugar: `buildContext(opts).enter()`. */ enterWithContext(opts: NamedContextOptions): void; - /** Sugar: `runWithContext(buildContext(opts), fn)`. */ + /** Sugar: `buildContext(opts).run(fn)`. */ runWithContext(fn: () => T, opts: NamedContextOptions): T; - /** Sugar: `setContext(undefined)`. */ + /** Sugar: re-export of the module-level {@link clearContext}. */ clearContext(): void; readonly processContextAttributes: ProcessContextAttributes; } @@ -145,22 +167,11 @@ export let ThreadContext: ThreadContextCtor; export let getContext: () => ThreadContext | undefined; /** - * 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 context reference is - * cheap (no allocation); per-span caching of the context on the caller - * side is the intended usage pattern. - */ -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. + * Detach any {@link ThreadContext} from the current async-context frame. + * Idempotent when no context is attached. On non-Linux platforms this is + * a no-op. */ -export let runWithContext: ( - context: ThreadContext | undefined, - fn: () => T, -) => T; +export let clearContext: () => void; // Debug accessor (not part of the stable API; for tests / reader dev). export let _currentRecordBytes: () => Uint8Array | undefined = () => undefined; @@ -208,26 +219,25 @@ if (process.platform === 'linux') { return als ? als.getStore() : 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 ThreadContext); - return; - } - ensureHook().enterWith(context); + // Idempotent: clearing when the hook hasn't been installed (no prior + // enter / run on a ThreadContext) is a no-op. + clearContext = function (): void { + if (!als) return; + als.enterWith(undefined as unknown as ThreadContext); }; - runWithContext = function ( - context: ThreadContext | undefined, + // Install the active-context channel on the ThreadContext prototype so + // the only way to push a ThreadContext into our AsyncLocalStorage is + // via the context itself — callers can't poison the ALS with an + // arbitrary object. + ThreadContext.prototype.enter = function (this: ThreadContext): void { + ensureHook().enterWith(this); + }; + ThreadContext.prototype.run = function ( + this: ThreadContext, fn: () => T, ): T { - if (context === undefined) { - if (!als) return fn(); - return als.run(undefined as unknown as ThreadContext, fn); - } - return ensureHook().run(context, fn); + return ensureHook().run(this, fn); }; _currentRecordBytes = function (): Uint8Array | undefined { @@ -239,8 +249,9 @@ if (process.platform === 'linux') { // Non-Linux degradation. The writer's reader contract is ELF-TLSDESC, // meaningful only on Linux; on other platforms we still want the API // to be callable so consumers don't have to gate every call site — - // construction succeeds but produces an inert context, and setContext / - // runWithContext don't actually wire anything into AsyncLocalStorage. + // construction succeeds but produces an inert context, and the + // enter/run/clearContext entry points don't wire anything into + // AsyncLocalStorage. class NoopThreadContext implements ThreadContext { appendAttributes(): void {} isTruncated(): boolean { @@ -249,23 +260,20 @@ if (process.platform === 'linux') { debugBytes(): Uint8Array { return new Uint8Array(0); } + enter(): void {} + run(fn: () => T): T { + return fn(); + } } ThreadContext = NoopThreadContext as ThreadContextCtor; getContext = function (): undefined { return undefined; }; - setContext = function (): void {}; - runWithContext = function ( - _context: ThreadContext | undefined, - fn: () => T, - ): T { - return fn(); - }; + clearContext = function (): void {}; } /** - * Build name-addressed wrappers around {@link ThreadContext}, - * {@link setContext}, and {@link runWithContext}. The supplied + * Build a name-addressed factory for {@link ThreadContext}. 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 @@ -344,13 +352,13 @@ export function makeNamedContext(keys: string[]): NamedContext { return { buildContext, enterWithContext(opts: NamedContextOptions): void { - setContext(buildContext(opts)); + buildContext(opts).enter(); }, runWithContext(fn: () => T, opts: NamedContextOptions): T { - return runWithContext(buildContext(opts), fn); + return buildContext(opts).run(fn); }, clearContext(): void { - setContext(undefined); + clearContext(); }, processContextAttributes, }; diff --git a/ts/test/test-otel-thread-ctx.ts b/ts/test/test-otel-thread-ctx.ts index cc05f0d7..4ed764bc 100644 --- a/ts/test/test-otel-thread-ctx.ts +++ b/ts/test/test-otel-thread-ctx.ts @@ -33,9 +33,8 @@ import {join} from 'node:path'; import { ThreadContext, getContext, + clearContext, makeNamedContext, - runWithContext, - setContext, _currentRecordBytes, } from '../src/otel-thread-ctx'; @@ -47,16 +46,10 @@ interface PosOpts { attributes?: Array; } function tcRun(fn: () => T, opts: PosOpts): T { - return runWithContext( - new ThreadContext(opts.traceId, opts.spanId, opts.attributes), - fn, - ); + return new ThreadContext(opts.traceId, opts.spanId, opts.attributes).run(fn); } function tcEnter(opts: PosOpts): void { - setContext(new ThreadContext(opts.traceId, opts.spanId, opts.attributes)); -} -function tcClear(): void { - setContext(undefined); + new ThreadContext(opts.traceId, opts.spanId, opts.attributes).enter(); } function tcAppend( attributes: Array | undefined, @@ -465,7 +458,7 @@ function captureBytes(opts: { tcRun( () => { strictAssert.ok(_currentRecordBytes()); - tcClear(); + clearContext(); strictAssert.equal(_currentRecordBytes(), undefined); }, {traceId: TRACE_ID_BYTES, spanId: SPAN_ID_BYTES}, @@ -476,7 +469,7 @@ function captureBytes(opts: { tcRun( () => { strictAssert.ok(getContext() !== undefined); - tcClear(); + clearContext(); strictAssert.equal(getContext(), undefined); strictAssert.equal(tcIsTruncated(), false); }, @@ -485,12 +478,12 @@ function captureBytes(opts: { }); it('is idempotent (calling with no context or twice is a no-op)', () => { - tcClear(); + clearContext(); strictAssert.equal(_currentRecordBytes(), undefined); tcRun( () => { - tcClear(); - tcClear(); + clearContext(); + clearContext(); strictAssert.equal(_currentRecordBytes(), undefined); }, {traceId: TRACE_ID_BYTES, spanId: SPAN_ID_BYTES}, @@ -500,7 +493,7 @@ function captureBytes(opts: { it('lets a nested runWithContext re-establish a record', () => { tcRun( () => { - tcClear(); + clearContext(); const innerSpan = bytesFromHex('aabbccddeeff0011'); tcRun( () => { @@ -522,7 +515,7 @@ function captureBytes(opts: { it('lets enterWithContext re-establish a record', () => { tcRun( () => { - tcClear(); + clearContext(); const newSpan = bytesFromHex('aabbccddeeff0011'); tcEnter({traceId: TRACE_ID_BYTES, spanId: newSpan}); strictAssert.deepEqual(