Skip to content

Guide Home | Previous: Location | Next: Operations — metrics · drain · readiness

11. Monitoring — Status Observation And Diagnostics

The document that owns this chapter's contract — covered by the Node.js location and observability public contract. This chapter explains the four observation surfaces that contract exposes, focused on usage.

Handler calls alone can't show you all of operations. Whether a connection is ready, which peer dropped, where a message failed — you also have to read this from the framework surface. The Node framework exposes this through two channels — a status snapshot and async iterable, and message flow records.

There's no surface that receives a runtime event as a provider handler. Observation always goes through one of the two channels below.

1. Observation Surfaces

What you're watching Injection token Where it's covered
Host lifecycle (relocate / drain / readiness) ZLINK_FRAMEWORK_RUNTIME 12. Operations §6.1
A MeshNode's node / peer / channel readiness ZLINK_ROUTE_MESH_RUNTIME 12. Operations §5
A ClientServer channel's target status ZLINK_CLIENT_SERVER_RUNTIME This chapter §2
A pub/sub channel's publisher status ZLINK_FANOUT_RUNTIME This chapter §2
Location store status and topology ZLINK_LOCATION_RUNTIME_QUERY 10. Location §4
Message receive / dispatch / failure and flow The message flow in configureDispatch() This chapter §3

All of these are received via @Inject(token). The type alone doesn't tell Nest what to inject.

2. Status Snapshot And Status Stream

Every status surface has the same shape — snapshot(...) gives the current value, and observe(...) gives every change after that as an async iterable.

@Injectable()
export class MeshWatcher {
  constructor(
    @Inject(ZLINK_ROUTE_MESH_RUNTIME) private readonly meshRuntime: ZLinkRouteMeshRuntime
  ) {}

  ready(): boolean {
    const snapshot = this.meshRuntime.snapshot('game.room');  // One current value.
    return this.meshRuntime.isReady('game.room');
  }

  async watch(signal: AbortSignal): Promise<void> {
    // A slow consumer that exceeds capacity skips intermediate values.
    for await (const observed of this.meshRuntime.observe('game.room', 64, signal)) {
      // observed.status is the complete status after the change -- not an event with
      // only the changed fields.
      // observed.loss is how many this subscription has missed.
      this.record(observed.status);
    }
  }
}

To end the subscription, abort the AbortSignal. You can break out of the for await loop, but the signal is the only way to stop it from outside.

snapshot(...) observe(...)
What it gives One value, at call time The complete value on every change
When to use it An operations endpoint response, a one-off check Recording or reacting to state transitions
Can it miss anything N/A Skips intermediate values past capacity

Host status has the same shape.

for await (const observed of runtime.observe(signal)) {
  const status = observed.status;
  logger.log(`host lifecycle: ${status.state} ${status.relocationResult}`);

  // How many this subscription missed. Splits out what was skipped by coalescing versus
  // what's gone for good.
  if (observed.loss.discardedTerminalCount > 0n) {
    logger.warn(`lost terminal statuses: ${observed.loss.discardedTerminalCount}`);
  }
}

Peer status carries only the Node RID, its current state, and why it's unavailable. Connection intent, discovery source, and lifecycle generation are framework-internal state and aren't exposed.

3. Message Flow Tracing

Where and how an individual message ended is seen through message flow. configureDispatch() sets the level.

builder.configureDispatch()
  .messageFlow("errors"); // Default -- failures and backpressure only.
Level Recording scope
"off" Records nothing
"errors" (default) Dispatch failures and backpressure
"normal" The above + major transitions like receive/dispatch/complete
"detailed" The above + detailed diagnostics

The values are the lowercase string union defined by the exact interface.

Keep operations at "errors" and raise it only when needed. "detailed" records more diagnostics, so check its cost on high-throughput paths.

The Framework exposes no message-flow callback observer, runtime error sink, file path, or raw event DTO. Configure a standard logger/telemetry provider and file backend in the application; the Framework records the formal structured records through that provider. Provider failure does not change the message-operation result.

4. Metrics

What the Node runtime actually emits is only part of the contract. The contract defines 47 instruments; runtime-metrics.ts declares 44 names, and fewer than that actually get recorded. The emission points are scattered across several files, so the exact count isn't pinned down. Check that the instrument you need is actually emitted before building a dashboard.

The contract for instrument names, kinds, units, and labels is owned by Runtime Metrics And Aggregation Rules.

5. Readiness And Liveness

Node has no separate health-check surface. You judge it from runtime status.

@Controller('healthz')
export class HealthController {
  constructor(
    @Inject(ZLINK_FRAMEWORK_RUNTIME) private readonly runtime: ZLinkFrameworkRuntime
  ) {}

  @Get('ready')
  ready(@Res() res: Response): void {
    res.status(this.runtime.status.isReady ? 200 : 503).send();
  }
}

status is a property — not a call. Its shape differs from status() in the other languages.

Reflect a dependency that can drop out briefly, like the store connection, only in readiness. Put it in liveness and the orchestrator kills the process the moment the store blips.

6. Common Problems

  • The observe(...) loop never ends → pass an AbortSignal and abort it. You can break inside the loop, but the signal is needed to stop it from outside.
  • Some state transitions are missingobserve(...)'s capacity was exceeded and they got skipped. Raise the capacity and consume faster.
  • Calling status() throws an error → it's a property. Drop the parentheses.
  • The level value doesn't match → Node uses a lowercase string ("errors").
  • The flow record is empty → the default level is "errors", so normal flow isn't recorded.
  • No metrics show up at all → that's expected. The Node runtime doesn't emit instruments yet (§4).
  • The store blipped briefly but the process restarted → the store status is in liveness.