Diagnostics
Overview
The core publishes each of the module lifecycle transitions listed below on
node:diagnostics_channel as a
TracingChannel.
Reloading a module is not itself a span. A hot reload destroys the running module and builds a
replacement, so it publishes the old module's destroy, then the replacement's load,
construct and start — all under the same moduleId. Subscribing costs nothing while nobody
is listening, and it needs no configuration: an
observability tool subscribes to the channel names below and receives an event for each
operation the core performs.
This is a diagnostics surface, not a logging one. Application logs still go through Logging; the two are independent and neither feeds the other.
Channels
Five operations are published, each as a tracing channel:
| Channel | Operation |
|---|---|
antelopejs.module.load | Loading a module's entry point and obtaining its callbacks. |
antelopejs.module.construct | The module's construct transition. |
antelopejs.module.start | The module's start transition. |
antelopejs.module.stop | The module's stop transition. |
antelopejs.module.destroy | The module's destroy transition. |
A tracing channel is not a single channel on the wire. Each of the five names expands into five real channels, one per event:
tracing:antelopejs.module.load:start
tracing:antelopejs.module.load:end
tracing:antelopejs.module.load:asyncStart
tracing:antelopejs.module.load:asyncEnd
tracing:antelopejs.module.load:error
Use diagnostics_channel.tracingChannel(name) and subscribe() rather than binding those
expanded names yourself.
Payload
Every event carries the same object for the duration of one span:
| Field | Type | Meaning |
|---|---|---|
moduleId | string | The module name from its manifest. |
moduleVersion | string | undefined | The module version at the moment the span opened. |
result | unknown | Added by Node on asyncStart and asyncEnd when the operation resolved. |
error | unknown | Added by Node on error, and present on asyncStart and asyncEnd when the operation rejected. |
moduleVersion is the version the module's package.json declared, and that file is not
validated on the way in. A module that declares no version publishes undefined, on every
event of every span. Do not call string methods on it unguarded — see the note on subscriber
errors below for what that costs.
result is the operation's return value, and on antelopejs.module.load that is the
module's entire export namespace, exactly as the loader resolved it. Node assigns it inside
tracePromise and the core cannot suppress it. A module's exports routinely include a
configured client holding the credentials that configured it, so treat a load span's
result as sensitive, and do not serialize it wholesale into a trace backend. The four
transition spans resolve to nothing, so their result is always undefined.
The module configuration passed to construct is never published on any channel. It may
hold credentials, and a diagnostics subscriber is not an authorized reader of them.
Subscribing
import diagnostics_channel from "node:diagnostics_channel";
const construct = diagnostics_channel.tracingChannel(
"antelopejs.module.construct",
);
construct.subscribe({
start(message) {
console.log("constructing", message.moduleId, message.moduleVersion);
},
end() {},
asyncStart() {},
asyncEnd(message) {
console.log("constructed", message.moduleId);
},
error(message) {
console.log("construct failed", message.moduleId, message.error);
},
});
Each span publishes start and end around the synchronous part of the operation, then
asyncStart and asyncEnd around its asynchronous completion. A failing operation also
publishes error, between end and asyncStart.
Behaviour a subscriber must expect
Lifecycle transitions are serialized, and the wait is outside the span. The core queues
transitions per module, so a stop requested while a start is in flight waits for it. That
wait belongs to no span: a span opens once the operation actually begins. Span durations
therefore measure work, not queueing.
A transition that changes nothing publishes nothing. Starting an already active module, stopping a module that is not active, or constructing one that is already constructed is a no-op, and no span is published for it. Do not count spans expecting one per call.
Destroying an active module nests a stop span inside the destroy span. The core stops the
module before destroying it, and that stop is published as an ordinary
antelopejs.module.stop span opened inside the enclosing antelopejs.module.destroy span.
The nesting is by containment of the whole span, not of every event: the destroy span's end
lands between the stop span's end and its asyncStart, so the two spans interleave as
destroy:start, stop:start, stop:end, destroy:end, stop:asyncStart, stop:asyncEnd,
destroy:asyncStart, destroy:asyncEnd. Build a span tree from start and asyncEnd, not
from end.
A destroy span can report an error for a module that was in fact destroyed. When the
nested stop fails but the destroy callback succeeds, the module reaches the loaded state and
the destroy span still reports the stop error. Treat error on a destroy span as "something
in the teardown failed", not as "the module is still alive".
A failed span does not always mean the state is unchanged. construct marks the module
constructed before invoking the callback, while start and stop update the state after
theirs returns. So a construct span reporting an error leaves a module that counts as
constructed and must still be destroyed, whereas a start span reporting an error leaves a
module that never became active.
A start span can follow that module's own failed destroy. When a reload cannot destroy the
running module, the core leaves it in place and starts it again to recover. A subscriber sees
destroy reporting an error and then an ordinary, clean start span for the same moduleId,
with nothing in either payload marking the second as a recovery.
A reload that fails to activate leaves the module id with nothing running. The core
destroys the replacement, and the module it was replacing is already gone. What a subscriber
sees depends on where the replacement failed. If its construct failed, that moduleId
publishes load, construct reporting the error, then a destroy; if its start failed, a
successful construct sits between them. If its load failed, that moduleId publishes the failed load span and nothing else at all: the
replacement never left the loaded state, so the destroy that follows is a no-op and publishes
nothing, by the rule above. Either way nothing runs on that id until something reloads it.
A failed load also emits the legacy ModuleDestroyed event. When the loader throws, the
antelopejs.module.load span reports the error and the core additionally emits its
Events.ModuleDestroyed event for that module, for consumers predating this surface.
Subscribers run synchronously, in the lifecycle path. Node invokes subscribers on the publishing call stack, so slow work in a subscriber slows module startup. A subscriber that throws does not disturb the transition: Node catches the throw and re-raises it on a later tick as an uncaught exception, which by default terminates the process, carrying no attribution to the span it came from. The core does not guard against this. Keep subscribers short, and catch your own errors.
Relation to OpenTelemetry
This is not an OpenTelemetry integration. The core publishes plain diagnostics channel events and depends on no tracing SDK. Mapping these spans onto an OpenTelemetry tracer — creating spans, propagating context, naming attributes — is the consumer's job, and is a small adapter over the events documented here.