Skip to main content

Logging and telemetry

The framework reports what happens inside itself - services activating, failing, focus and pause changes, teardown - through a single function you give it. It does not queue, batch, format or send anything. Where records go is your decision.

That split is the whole design:

  • The framework emits. Only it can see a service that threw during initialize, so only it can report one.
  • Your application collects. Queueing, batching, transport and storage are application concerns, and they differ per app.

With nothing configured, every emission point reaches one shared no-op function. An application that wants no telemetry pays nothing.

Turning it on

import { ServiceManager } from "@realitycollective/service-framework";

const manager = new ServiceManager({
scheduler,
environment,
log: (name, payload, level) => console.log(level, name, payload)
});

That is the whole API. log is a TelemetryLog:

type TelemetryLog = (
name: string,
payload?: Record<string, unknown>,
level?: TelemetryLevel // "debug" | "info" | "warn" | "error", default "info"
) => void;

What the framework reports

RecordWhenLevelPayload
profile_initializeda profile finished activatinginfoname, serviceCount
service_initializeda service initialiseddebugname, token, priority
service_starteda service starteddebugname
service_faileda service threw in initialize or starterrorname, phase, message
service_disposeda service was destroyeddebugname
focus_changeemitFocusChange was calledinfofocused
pause_changeemitPauseChange was calledinfopaused
manager_disposedthe manager was disposedinfoserviceCount

Two things are worth knowing about service_failed:

  • The framework reports it and then rethrows the original error unchanged. Telemetry observes failures, it does not swallow them.
  • Because activation stops at the throw, profile_initialized and every later service_started are simply absent. What is missing is as diagnostic as what is present: no service_started means nothing ever ran.

Logging from your own services

Every service receives the same emitter at construction, so no registration or token lookup is needed:

class InventoryService extends BaseService<Config> {
public equip(sku: string): void {
this.logEvent("item_equipped", { sku });
}
}

Your records and the framework's share one stream, one ordering and one export.

The member is logEvent, not log, because log is the name a service is most likely to have already claimed for its own logging helper, and an inherited member would break it.

Writing a collector

A collector is any object that supplies a TelemetryLog. A useful one adds an envelope, bounds its memory, and writes in batches:

class Collector {
private readonly queue: Record[] = [];
private seq = 0;

public log = (name: string, payload?: Record<string, unknown>, level: TelemetryLevel = "info"): void => {
this.seq += 1;
this.queue.push({ seq: this.seq, timestampMs: Date.now(), name, level, payload });
if (this.queue.length >= 50) { this.flush(); }
};

public bind(manager: ServiceManager): () => void {
// Flush off the scheduler, never setInterval: the host ticks only while
// it is running, so this stops with the application instead of firing
// into a paused runtime, and stays deterministic under a mock scheduler.
return manager.scheduler.subscribe("lateTick", (context) => {
if (context.timestamp - this.lastFlushAt >= 5000) { this.flush(); }
});
}
}

A working version, with a bounded ring, sink isolation and NDJSON export, is in runtime-examples/telemetry-example.

Give each record a monotonic seq. A gap in that sequence is proof of loss, which is the only way to know your collector dropped something.

Rules

Never emit per frame from the framework, and think hard before doing it from a service. Nothing in the framework emits from tick, lateTick, fixedTick or renderTick, and a test enforces that. A record costs roughly 200ns end to end - immaterial at any sane rate, and about 15% of the per-frame service cost if every service emits every frame.

A sink must not throw. A record is a side channel. A failure to report must never become a failure of the thing being reported on. Wrap sink writes and count failures rather than letting them escape.

Flush from the scheduler, not a timer. setInterval keeps running when the host has stopped ticking; the scheduler does not.

Prefer level over name prefixes. Routing on a field keeps the name doing one job and lets a sink filter without parsing.

What this is not

  • Not a logger. There is no info()/warn() API and no formatting.
  • Not a transport. No sinks, no batching and no storage ship in the core package.
  • Not metrics or tracing. There is no aggregation, sampling or span model.

See also