Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions bindings/otel-thread-ctx.cc
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,7 @@ class CtxWrap : public ObjectWrap {
static void New(const FunctionCallbackInfo<Value>& args);
static void DebugBytes(const FunctionCallbackInfo<Value>& args);
static void Append(const FunctionCallbackInfo<Value>& args);
static void Invalidate(const FunctionCallbackInfo<Value>& args);
static void IsTruncated(const FunctionCallbackInfo<Value>& args);

// Encode the JS array at `attrs_val` into `out` as packed (key, len, value)
Expand Down Expand Up @@ -543,6 +544,25 @@ void CtxWrap::Append(const FunctionCallbackInfo<Value>& 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<Value>& args) {
CtxWrap* self = ObjectWrap::Unwrap<CtxWrap>(args.This());
if (!self) {
args.GetIsolate()->ThrowError("not a ThreadContext");
return;
}
std::atomic_signal_fence(std::memory_order_release);
*reinterpret_cast<volatile uint8_t*>(&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
Expand Down Expand Up @@ -587,6 +607,9 @@ void CtxWrap::Init(Local<Object> 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));
Expand Down
15 changes: 15 additions & 0 deletions ts/src/otel-thread-ctx.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,20 @@ export interface ThreadContext {
appendAttributes(
attributes: Array<string | null | undefined> | 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;
Expand Down Expand Up @@ -224,6 +238,7 @@ if (process.platform === 'linux') {
// AsyncLocalStorage.
class NoopThreadContext implements ThreadContext {
appendAttributes(): void {}
invalidate(): void {}
isTruncated(): boolean {
return false;
}
Expand Down
37 changes: 37 additions & 0 deletions ts/test/test-otel-thread-ctx.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Loading