You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Companion to #788 (AsyncLocalStorage real propagation). Once ALS lands, the remaining gap in node:async_hooks is the observability half of the API: createHook callbacks, real executionAsyncId() / triggerAsyncId(), and the full AsyncResource surface. These are what APM tools (Datadog, NewRelic, custom tracers) use to instrument every async resource in the process; ALS alone doesn't cover them.
This is a separate effort from #788 because the work is essentially disjoint: ALS needs a per-thread context stack at suspend/resume points; createHook needs a global async-resource registry with init/before/after/destroy callbacks fired at every resource creation and execution site. They share zero code beyond living in the same runtime.
Design surface
AsyncId allocation. Monotonically increasing u64 per-process, assigned at resource creation time. Need a global counter (atomic) and a per-thread "current execution id" + "trigger id" pair maintained as a stack.
Resource registry. Every async resource (Promise, timer handle, immediate handle, nextTick entry, I/O handle, user AsyncResource) gets an asyncId at construction. The registry maps asyncId → resource metadata (type string, triggerAsyncId, optional user resource ref). Lifecycle: alloc on construction, free on destroy callback.
Hook registration.createHook({ init, before, after, destroy, promiseResolve }) returns an AsyncHook with .enable() / .disable(). Registered hooks live in a global list; enable/disable toggles per-hook bits. Must be cheap when zero hooks are enabled (the common case) — if (HOOKS_ACTIVE.load(Relaxed) == 0) skip at every callsite.
Callback emission points. Every place that creates an async resource fires init(asyncId, type, triggerAsyncId, resource). Every place that executes a callback for that resource fires before(asyncId) → callback → after(asyncId). Promise resolution fires promiseResolve(asyncId). Destruction fires destroy(asyncId).
executionAsyncId() / triggerAsyncId() real values. Per-thread "current execution id" updated on every before (push) / after (pop). triggerAsyncId is the execution id active at the moment the resource was created — captured at init time.
AsyncResource full surface.asyncId(), triggerAsyncId(), emitDestroy(), bind(fn, type?) with the resource captured. Subclassing path: class MyRes extends AsyncResource works without extra runtime support once the constructor and methods are real.
Eager destroy for settled / fired resources. Fire destroy when a Promise settles, a timer fires, an immediate runs, or AsyncResource.emitDestroy() is called. Simpler than GC-driven destroy and matches what most APM consumers actually observe.
Performance. Every async-resource call site gets a check. The hot path when no hooks are enabled must be a single relaxed atomic load + branch. Behind a hook, the cost is unavoidable — that's what users opt into when they register a hook.
GC-driven destroy for leaked resources. Resources that are never explicitly destroyed (Promises that never settle, lost timer handles, etc.) must still fire destroy when collected — otherwise APM "active handles" counts and leak detectors observably diverge from Node. Requires adding a finalization mechanism to the gen-GC in crates/perry-runtime/src/gc.rs: a per-allocation "has finalizer" bit, a finalizer queue drained after sweep, and a decision on order / resurrection semantics (Node's behavior: finalizers run on a separate turn, no resurrection). This is its own substantial subsystem and may warrant a sub-issue, but it must land before this issue closes — otherwise the [perry] note: from fix: #775 — emit shim note when async_hooks is imported #787 can't be retired.
Acceptance tests
createHook({ init }).enable() observes init callbacks for: new Promise, setTimeout, setImmediate, process.nextTick, new AsyncResource('Custom').
Each init callback receives a unique asyncId, the correct type string, and a triggerAsyncId matching the creating context.
before / after bracket every callback execution, with executionAsyncId() returning the resource's id inside the callback.
triggerAsyncId() inside a callback returns the id of the resource that scheduled it.
promiseResolve fires on Promise settlement.
destroy fires eagerly on Promise settle / timer fire / AsyncResource.emitDestroy().
destroy fires on GC for a Promise that is dropped without settling (leak path).
Multiple registered hooks fire in registration order.
.disable() stops a hook from firing without affecting others.
Zero-hook microbenchmark: overhead on a Promise-heavy workload stays within noise of the current stub.
Legacy / deprecated paths from older Node versions: process.binding('async_wrap'), pre-stabilization Node 8 async_hooks shape, and other API forms superseded before LTS.
Background
Companion to #788 (AsyncLocalStorage real propagation). Once ALS lands, the remaining gap in
node:async_hooksis the observability half of the API:createHookcallbacks, realexecutionAsyncId()/triggerAsyncId(), and the fullAsyncResourcesurface. These are what APM tools (Datadog, NewRelic, custom tracers) use to instrument every async resource in the process; ALS alone doesn't cover them.This is a separate effort from #788 because the work is essentially disjoint: ALS needs a per-thread context stack at suspend/resume points;
createHookneeds a global async-resource registry with init/before/after/destroy callbacks fired at every resource creation and execution site. They share zero code beyond living in the same runtime.Design surface
AsyncId allocation. Monotonically increasing
u64per-process, assigned at resource creation time. Need a global counter (atomic) and a per-thread "current execution id" + "trigger id" pair maintained as a stack.Resource registry. Every async resource (Promise, timer handle, immediate handle, nextTick entry, I/O handle, user
AsyncResource) gets anasyncIdat construction. The registry mapsasyncId → resource metadata(type string, triggerAsyncId, optional user resource ref). Lifecycle: alloc on construction, free ondestroycallback.Hook registration.
createHook({ init, before, after, destroy, promiseResolve })returns anAsyncHookwith.enable()/.disable(). Registered hooks live in a global list; enable/disable toggles per-hook bits. Must be cheap when zero hooks are enabled (the common case) —if (HOOKS_ACTIVE.load(Relaxed) == 0) skipat every callsite.Callback emission points. Every place that creates an async resource fires
init(asyncId, type, triggerAsyncId, resource). Every place that executes a callback for that resource firesbefore(asyncId)→ callback →after(asyncId). Promise resolution firespromiseResolve(asyncId). Destruction firesdestroy(asyncId).promise.rs(Promise construction + resolution +.thenchaining),js_promise_run_microtasks(),setTimeout/setImmediate/process.nextTickschedulers,AsyncResourceconstructor +runInAsyncScope/emitDestroy.executionAsyncId()/triggerAsyncId()real values. Per-thread "current execution id" updated on everybefore(push) /after(pop).triggerAsyncIdis the execution id active at the moment the resource was created — captured atinittime.AsyncResourcefull surface.asyncId(),triggerAsyncId(),emitDestroy(),bind(fn, type?)with the resource captured. Subclassing path:class MyRes extends AsyncResourceworks without extra runtime support once the constructor and methods are real.Eager
destroyfor settled / fired resources. Firedestroywhen a Promise settles, a timer fires, an immediate runs, orAsyncResource.emitDestroy()is called. Simpler than GC-driven destroy and matches what most APM consumers actually observe.Performance. Every async-resource call site gets a check. The hot path when no hooks are enabled must be a single relaxed atomic load + branch. Behind a hook, the cost is unavoidable — that's what users opt into when they register a hook.
GC-driven
destroyfor leaked resources. Resources that are never explicitly destroyed (Promises that never settle, lost timer handles, etc.) must still firedestroywhen collected — otherwise APM "active handles" counts and leak detectors observably diverge from Node. Requires adding a finalization mechanism to the gen-GC incrates/perry-runtime/src/gc.rs: a per-allocation "has finalizer" bit, a finalizer queue drained after sweep, and a decision on order / resurrection semantics (Node's behavior: finalizers run on a separate turn, no resurrection). This is its own substantial subsystem and may warrant a sub-issue, but it must land before this issue closes — otherwise the[perry] note:from fix: #775 — emit shim note when async_hooks is imported #787 can't be retired.Acceptance tests
createHook({ init }).enable()observes init callbacks for: new Promise, setTimeout, setImmediate, process.nextTick, new AsyncResource('Custom').initcallback receives a uniqueasyncId, the correcttypestring, and atriggerAsyncIdmatching the creating context.before/afterbracket every callback execution, withexecutionAsyncId()returning the resource's id inside the callback.triggerAsyncId()inside a callback returns the id of the resource that scheduled it.promiseResolvefires on Promise settlement.destroyfires eagerly on Promise settle / timer fire /AsyncResource.emitDestroy().destroyfires on GC for a Promise that is dropped without settling (leak path)..disable()stops a hook from firing without affecting others.[perry] note:from fix: #775 — emit shim note when async_hooks is imported #787 once AsyncLocalStorage: real async-context tracking across await / microtasks / timers #788 and this issue both land.Out of scope
process.binding('async_wrap'), pre-stabilization Node 8async_hooksshape, and other API forms superseded before LTS.Related