[data-reveal]{opacity:1!important;transform:none!important}
Concepts

Diagnostics

Module lifecycle operations published as node:diagnostics_channel tracing channels, for tracing, metrics, and profiling tools.

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:

ChannelOperation
antelopejs.module.loadLoading a module's entry point and obtaining its callbacks.
antelopejs.module.constructThe module's construct transition.
antelopejs.module.startThe module's start transition.
antelopejs.module.stopThe module's stop transition.
antelopejs.module.destroyThe 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:

FieldTypeMeaning
moduleIdstringThe module name from its manifest.
moduleVersionstring | undefinedThe module version at the moment the span opened.
resultunknownAdded by Node on asyncStart and asyncEnd when the operation resolved.
errorunknownAdded 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.