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
10 changes: 10 additions & 0 deletions js/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,16 @@ Instance methods:
internal record pointer in place — the JS object stays the same — so no
per-frame divergence.

- **`invalidate()`** — mark this record's `valid` byte as 0 in place.
Every async-context frame that still 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 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 `clearContext()` would leave
sibling and detached-continuation frames still exposing the finished
span. Idempotent.

- **`isTruncated()`** — returns `true` if at any point in this record's
lifetime — either at construction or in a subsequent `appendAttributes`
call — at least one attribute had to be dropped because it would have
Expand Down
23 changes: 23 additions & 0 deletions js/addon.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,7 @@ class CtxWrap : public ObjectWrap {
static void New(const FunctionCallbackInfo<Value>& args);
static void DebugBytes(const FunctionCallbackInfo<Value>& args);
static void AppendAttributes(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 @@ -516,6 +517,25 @@ void CtxWrap::AppendAttributes(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 @@ -560,6 +580,9 @@ void CtxWrap::Init(Local<Object> exports) {
tpl->PrototypeTemplate()->Set(
String::NewFromUtf8Literal(isolate, "appendAttributes"),
FunctionTemplate::New(isolate, AppendAttributes));
tpl->PrototypeTemplate()->Set(
String::NewFromUtf8Literal(isolate, "invalidate"),
FunctionTemplate::New(isolate, Invalidate));
tpl->PrototypeTemplate()->Set(
String::NewFromUtf8Literal(isolate, "isTruncated"),
FunctionTemplate::New(isolate, IsTruncated));
Expand Down
13 changes: 13 additions & 0 deletions js/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,19 @@ export interface ThreadContext {
*/
appendAttributes(attributes: Array<string | null | undefined> | undefined): void;

/**
* Mark this record's underlying `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 / detached-continuation frames still exposing the finished
* span's trace / span IDs. Idempotent.
*/
invalidate(): void;

/**
* True if at any point in this context's lifetime — either at
* construction or in a subsequent {@link appendAttributes} call — at
Expand Down
1 change: 1 addition & 0 deletions js/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ if (process.platform === 'linux') {
// AsyncLocalStorage.
class NoopThreadContext {
appendAttributes() {}
invalidate() {}
isTruncated() { return false; }
debugBytes() { return new Uint8Array(0); }
enter() {}
Expand Down
35 changes: 35 additions & 0 deletions js/test/test.js
Original file line number Diff line number Diff line change
Expand Up @@ -560,6 +560,41 @@ test('appendAttributes propagates through async continuations', async () => {
});
});

test('invalidate flips the record\'s valid byte to 0 in place', () => {
// The invalidation is visible across every async-context frame that
// holds the same ThreadContext reference — nothing about the async
// scope changes, only the shared record's `valid` header byte.
const ctx = new ThreadContext(TRACE_ID_BYTES, SPAN_ID_BYTES);
ctx.run(() => {
assert.equal(decodeHeader(_currentRecordBytes()).valid, 1);
ctx.invalidate();
assert.equal(decodeHeader(_currentRecordBytes()).valid, 0);
});
});

test('invalidate is idempotent', () => {
const ctx = new ThreadContext(TRACE_ID_BYTES, SPAN_ID_BYTES);
ctx.run(() => {
ctx.invalidate();
ctx.invalidate();
assert.equal(decodeHeader(_currentRecordBytes()).valid, 0);
});
});

test('appendAttributes after invalidate mutates attrs_data but leaves valid=0', () => {
// The addon separates `valid` from the attrs-append path: an
// invalidated record's `attrs_data_size` can still grow, but readers
// MUST honor `valid == 0` and ignore the record anyway.
const ctx = new ThreadContext(TRACE_ID_BYTES, SPAN_ID_BYTES);
ctx.run(() => {
ctx.invalidate();
ctx.appendAttributes([, 'late']);
const hdr = decodeHeader(_currentRecordBytes());
assert.equal(hdr.valid, 0);
assert.equal(hdr.attrsDataSize, 6); // key(1) + len(1) + 'late'(4)
});
});

test('otel_thread_ctx_nodejs_v1 is exported as a TLS dynsym', (t) => {
const addon = path.join(__dirname, '..', 'build', 'Release', 'customlabels.node');
if (!require('node:fs').existsSync(addon)) {
Expand Down