@noego/trace
@noego/trace
Structured tracing for reconstructing what happened inside an application.
@noego/trace is intentionally small: code emits structured events, a process-wide
manager exposes those events as an RxJS stream, writers can persist or forward the
stream, and test helpers can assert the execution path.
Use tracing for facts you need when debugging or testing a runtime flow:
- what triggered
- which route, model, strategy, provider, or implementation was selected
- what input ids, counts, sizes, and config were used
- which branch was taken and why
- what was persisted or handed to the next stage
- whether the flow completed, failed, skipped, aborted, or fell back
Tracing is not a replacement for logs. Logs are for human-readable operational status; traces are structured evidence for replay, debugging, and tests.
Install
npm install @noego/trace rxjs
@noego/ioc is optional and only needed for the IoC helper export.
Basic Usage
import { configureTrace, getTrace } from '@noego/trace';
configureTrace({
process: 'server',
level: 'info',
});
const trace = getTrace('MessageService');
trace.info('message.persist.started', {
conversationId: 42,
messageLength: content.length,
});
trace.info('message.persist.completed', {
conversationId: 42,
messageId: 123,
});
Every emitted event has this core shape:
type TraceEvent = {
timestamp: number;
source: string;
event: string;
level: TraceLevel;
context?: Record<string, unknown>;
message?: string;
process?: string;
};
The source is bound by getTrace(source). The event should be a stable,
dot-separated name such as message.persist.started.
Subscribing
Use getTraceManager().events$ when you need to handle the event stream
directly.
import { getTraceManager } from '@noego/trace';
const subscription = getTraceManager().events$.subscribe((event) => {
console.log(event);
});
subscription.unsubscribe();
The event stream is level-filtered. configureTrace({ level: 'warn' }) filters
out debug and info events from events$.
Writers
JSONL File
import { getTraceManager } from '@noego/trace';
import { FileWriter } from '@noego/trace/writers/file';
const writer = new FileWriter('/tmp/app-trace.jsonl');
const subscription = writer.subscribe(getTraceManager().events$);
Trace Server
import { getTraceManager } from '@noego/trace';
import { TraceServerWriter } from '@noego/trace/writers/trace-server';
const writer = new TraceServerWriter('http://localhost:4000/traces');
const subscription = writer.subscribe(getTraceManager().events$);
Writers are observational. If trace delivery fails, application behavior should not fail because of tracing.
Reactive Trace (Anomaly API)
Reactive Trace lets the trace stream monitor itself. Application code emits ordinary structured traces; Reactive Trace watches those events, applies keyed/temporal rules, and emits a new trace when the observed behavior violates a rule.
Mental model:
ordinary trace event -> rule evaluation -> violation detected -> derived trace event
The current code namespace is @noego/trace/anomaly, and the derived event is
trace.anomaly.violation. Keep those names in code for compatibility; use
Reactive Trace as the concept and documentation name.
import {
createTraceAnomalyInstaller,
createTraceReemitViolationHandler,
getTraceManager,
} from '@noego/trace';
const traceManager = getTraceManager();
const installer = createTraceAnomalyInstaller(traceManager, {
mode: process.env.NODE_ENV === 'test' ? 'test' : 'production',
});
installer.onViolation(createTraceReemitViolationHandler(traceManager));
installer.monitor.defineCountThreshold({
id: 'chat.dom.persisted-message-proceeded-once',
event: 'transcript.dom.upsert-message.proceeding',
keyBy: 'payload.messageId',
max: 1,
windowMs: 1000,
cooldownMs: 10_000,
severity: 'warn',
});
const runtime = installer.startAndSeal();
Use these rules:
- startup rule files use
createTraceAnomalyInstaller()andinstaller.monitor.* - functions and methods use only the sealed
runtime.watch.*API idis static and describes the rule- dynamic values go in
key, never inid - every runtime watch must have a
keyandtimeoutMsorwindowMs - global runtime watches are disabled unless
allowGlobalRuntimeWatches: true
createTraceAnomaly() still exists as a compatibility helper, but it seals the
installer immediately and returns a runtime-only setup. New code should prefer
createTraceAnomalyInstaller() so monitor registration is visibly separated from
hot code paths.
How Levels Work
Rules and runtime watches accept severity: 'warn' | 'error'. If omitted,
severity defaults to warn.
Reactive Trace only becomes a normal trace event when a violation handler re-emits it into the trace manager:
installer.onViolation(createTraceReemitViolationHandler(traceManager));
That handler maps violation severity to the emitted trace level:
severity: 'warn' -> TraceLevel.WARN
severity: 'error' -> TraceLevel.ERROR
The emitted trace event is trace.anomaly.violation, with context that includes
ruleId, key, reason, count, max, windowMs, and cloned
triggeringEvents.
Runtime Watches
Runtime watches are safe to create inside methods because they are bounded and
self-dispose. They dispose on success, timeout, violation, explicit disposal, or
runtime.cleanupKey(key).
const handle = runtime.watch.rateLimitWindow({
id: 'delete-file-overload',
key: filename,
event: 'delete.file',
max: 1,
windowMs: 200,
severity: 'error',
});
if (handle.reused) {
// The same id + key + spec was already active.
}
If delete.file happens twice for the same filename inside 200ms, Reactive
Trace emits one violation. The violation includes cloned triggering trace
snapshots:
type TraceAnomalyViolation = {
kind: 'monitor' | 'watch' | 'diagnostic';
ruleId: string;
key: string;
reason: string;
severity: 'warn' | 'error';
triggeringEvents: TraceEventSnapshot[];
};
Use onceWithin to detect a missing expected event:
const handle = runtime.watch.onceWithin({
id: 'message-persist-observed',
key: ['conversation', conversationId, 'message', messageId],
event: 'message.persisted',
timeoutMs: 500,
where: (event) => event.payload?.messageId === messageId,
});
abortSignal.addEventListener('abort', () => handle.dispose());
Repeated calls with the same id + key reuse the existing watcher only when the
full spec is identical. A changed spec throws in test/development and emits a
diagnostic warning in production.
Memory Safety
Reactive Trace uses one subscription to TraceManager.events$ and keeps state in
bounded maps. It does not expose RxJS operators to application code.
Safety defaults include:
- monitor registration is idempotent by static
idand same function references installer.startAndSeal()rejects late monitor definitions in test/dev mode- production late registration emits a diagnostic instead of silently ignoring it
- runtime watches require a key unless global watches are explicitly enabled
- watcher TTLs are capped by
maxWatcherTtlMsand active timers self-dispose - retained triggering snapshots are cloned and capped by
maxSnapshotCount - anomaly events named
trace.anomaly.*are ignored by monitor ingress to avoid recursive violations
For tests, getAnomalyDebugStats(runtime) exposes active watcher counts and
index sizes so leak-sensitive behavior can be asserted directly.
Testing
@noego/trace/testing provides a recorder plus query/assertion helpers.
import { afterEach, beforeEach, expect, it } from 'vitest';
import { getTrace } from '@noego/trace';
import { TraceRecorder } from '@noego/trace/testing';
let recorder: TraceRecorder;
beforeEach(() => {
recorder = new TraceRecorder();
recorder.enable({ maxEvents: 1000 });
});
afterEach(() => {
recorder.disable();
});
it('records the persistence path', () => {
const trace = getTrace('MessageService');
trace.info('message.persist.started', {
conversationId: 42,
messageLength: 12,
});
trace.info('message.persist.completed', {
conversationId: 42,
messageId: 123,
});
recorder.query().expectSequence([
{
source: 'MessageService',
event: 'message.persist.started',
'context.conversationId': 42,
},
{
source: 'MessageService',
event: 'message.persist.completed',
'context.messageId': 123,
},
]);
});
Useful query/assertion methods:
filter(filter)returns matching events.first(filter)andlast(filter)return one event ornull.count(filter)counts matching events.payloads(filter)returns each matching event'spayloadorcontext.expectOnce(filter)asserts exactly one match.expectNone(filter)asserts no matches.expectSequence(filters)asserts an ordered subsequence.expectUniqueBy(filter, path)asserts matching events have unique values at a path.waitForTrace(filter, { timeoutMs })waits for an async event.
Prefer trace assertions for route selection, branch decisions, persistence ids, handoff sizes, and completion/failure outcomes. Avoid asserting incidental trace volume unless repeated work is the behavior under test.
Event Names and Levels
Use stable dot-separated names:
*.startedfor entry or dispatch*.completedfor success*.failedfor unrecovered failure*.skippedfor intentional no-op paths*.persistedfor durable writes*.hit/*.missfor cache or lookup decisions
Level guidance:
info: lifecycle events, important decisions, external calls, persistencedebug: high-frequency or diagnostic detailwarn: unexpected but recovered stateerror: failed operation or unrecovered exception
When in doubt, make the important path visible at info and move noisy detail
to debug.
Payload Guidance
Keep context small, stable, and queryable. Prefer:
- ids:
conversationId,requestId,messageId - counts:
messageCount,toolCount - sizes:
inputLength,outputBytes - enum or boolean decisions:
selectedRoute,cacheHit,skipped - short error messages or error types
Avoid:
- full prompts
- raw documents
- full command output
- large model responses
- full HTML or large serialized objects
Use lengths, previews, hashes, or persisted identifiers instead.
Core Shape vs Framework Adapters
The core @noego/trace event shape uses event and context.
Some framework adapters, including Wood, normalize trace rows around type and
payload instead:
// Core @noego/trace
trace.info('message.persist.completed', { messageId: 123 });
// Wood-style adapter shape
traceProvider.info({
source: 'MessageService',
type: 'message.persist.completed',
payload: { messageId: 123 },
});
The testing query helpers intentionally understand both conventions:
countByType()readstypefirst and falls back toevent.payloads()readspayloadfirst and falls back tocontext.
When using this package directly, document and assert event and context.
When using a framework adapter, follow that adapter's event shape.
IoC Helper
The optional IoC export registers a scoped trace factory in a container.
import { registerTraceFactory } from '@noego/trace/ioc';
registerTraceFactory(container, Symbol.for('@app:trace'), 'MessageService');
The registered dependency resolves to a ScopedTrace bound to the configured
source.
Development
npm test
npm run typecheck
npm run build