diff --git a/bindings/otel-thread-ctx.cc b/bindings/otel-thread-ctx.cc index c98bb209..2aaea7ba 100644 --- a/bindings/otel-thread-ctx.cc +++ b/bindings/otel-thread-ctx.cc @@ -190,6 +190,7 @@ class CtxWrap : public ObjectWrap { static void New(const FunctionCallbackInfo& args); static void DebugBytes(const FunctionCallbackInfo& args); static void Append(const FunctionCallbackInfo& args); + static void Invalidate(const FunctionCallbackInfo& args); static void IsTruncated(const FunctionCallbackInfo& args); // Encode the JS array at `attrs_val` into `out` as packed (key, len, value) @@ -543,6 +544,25 @@ void CtxWrap::Append(const FunctionCallbackInfo& args) { free(old_rec); } +// Mark this record's `valid` byte as 0 in place. Every async-context +// frame that holds this ThreadContext reference — including those that +// merely inherited it verbatim from a parent frame — will subsequently +// present the same shared record to a reader, so this one write drops +// the record out of scope for every such frame at once. Intended for +// span-finish, where clearing the current frame's context via +// `clearContext()` alone leaves sibling / detached-continuation frames +// still exposing the finished span. Idempotent; safe to call multiple +// times. +void CtxWrap::Invalidate(const FunctionCallbackInfo& args) { + CtxWrap* self = ObjectWrap::Unwrap(args.This()); + if (!self) { + args.GetIsolate()->ThrowError("not a ThreadContext"); + return; + } + std::atomic_signal_fence(std::memory_order_release); + *reinterpret_cast(&self->record_->valid) = 0; +} + // 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 @@ -587,6 +607,9 @@ void CtxWrap::Init(Local exports) { tpl->PrototypeTemplate()->Set( String::NewFromUtf8Literal(isolate, "appendAttributes"), FunctionTemplate::New(isolate, Append)); + tpl->PrototypeTemplate()->Set( + String::NewFromUtf8Literal(isolate, "invalidate"), + FunctionTemplate::New(isolate, Invalidate)); tpl->PrototypeTemplate()->Set( String::NewFromUtf8Literal(isolate, "isTruncated"), FunctionTemplate::New(isolate, IsTruncated)); diff --git a/ts/src/otel-thread-ctx.ts b/ts/src/otel-thread-ctx.ts index cbc91c93..b5d9e75b 100644 --- a/ts/src/otel-thread-ctx.ts +++ b/ts/src/otel-thread-ctx.ts @@ -61,6 +61,20 @@ export interface ThreadContext { appendAttributes( attributes: Array | undefined, ): void; + + /** + * Mark this context's underlying record `valid` byte as 0 in place. + * Every async-context frame that still holds this `ThreadContext` + * reference (including those that inherited it verbatim from a + * parent frame) will subsequently present a record with `valid = 0` + * to a reader, so this one call drops the record out of scope for + * every such frame at once. Intended for the span-finish path, where + * clearing only the current frame's context via {@link clearContext} + * would leave sibling and detached-continuation frames still exposing + * the finished span's trace / span IDs. Idempotent. + */ + invalidate(): void; + isTruncated(): boolean; /** Debug-only: returns the on-the-wire record bytes. Not stable. */ debugBytes(): Uint8Array; @@ -224,6 +238,7 @@ if (process.platform === 'linux') { // AsyncLocalStorage. class NoopThreadContext implements ThreadContext { appendAttributes(): void {} + invalidate(): void {} isTruncated(): boolean { return false; } diff --git a/ts/test/test-otel-thread-ctx.ts b/ts/test/test-otel-thread-ctx.ts index f6229f2c..d19167d1 100644 --- a/ts/test/test-otel-thread-ctx.ts +++ b/ts/test/test-otel-thread-ctx.ts @@ -696,6 +696,43 @@ function captureBytes(opts: { }); }); + describe('invalidate', () => { + it('flips the record valid byte to 0 in place', () => { + // Verified through the shared record: same ThreadContext reference + // observed by any async-context frame that inherits it sees the + // new valid=0 the moment we call invalidate() on any of them. + const ctx = new ThreadContext(TRACE_ID_BYTES, SPAN_ID_BYTES); + ctx.run(() => { + strictAssert.equal(decodeHeader(_currentRecordBytes()!).valid, 1); + ctx.invalidate(); + strictAssert.equal(decodeHeader(_currentRecordBytes()!).valid, 0); + }); + }); + + it('is idempotent', () => { + const ctx = new ThreadContext(TRACE_ID_BYTES, SPAN_ID_BYTES); + ctx.run(() => { + ctx.invalidate(); + ctx.invalidate(); + strictAssert.equal(decodeHeader(_currentRecordBytes()!).valid, 0); + }); + }); + + it('appendAttributes after invalidate mutates attrs_data but leaves valid=0', () => { + // valid is a separate byte from attrs_data — an invalidated record + // can still grow via appendAttributes; readers MUST honor + // valid==0 and ignore the record regardless. + const ctx = new ThreadContext(TRACE_ID_BYTES, SPAN_ID_BYTES); + ctx.run(() => { + ctx.invalidate(); + ctx.appendAttributes([, 'late']); + const hdr = decodeHeader(_currentRecordBytes()!); + strictAssert.equal(hdr.valid, 0); + strictAssert.equal(hdr.attrsDataSize, 6); // key(1) + len(1) + 'late'(4) + }); + }); + }); + describe('getProcessContextAttributes', () => { it('rejects non-array keys', () => { strictAssert.throws(