npm.io
0.0.5 • Published 3d ago

@noego/trace

Licence
ISC
Version
0.0.5
Deps
0
Size
326 kB
Vulns
0
Weekly
0

@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() and installer.monitor.*
  • functions and methods use only the sealed runtime.watch.* API
  • id is static and describes the rule
  • dynamic values go in key, never in id
  • every runtime watch must have a key and timeoutMs or windowMs
  • 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 id and 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 maxWatcherTtlMs and 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) and last(filter) return one event or null.
  • count(filter) counts matching events.
  • payloads(filter) returns each matching event's payload or context.
  • 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:

  • *.started for entry or dispatch
  • *.completed for success
  • *.failed for unrecovered failure
  • *.skipped for intentional no-op paths
  • *.persisted for durable writes
  • *.hit / *.miss for cache or lookup decisions

Level guidance:

  • info: lifecycle events, important decisions, external calls, persistence
  • debug: high-frequency or diagnostic detail
  • warn: unexpected but recovered state
  • error: 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() reads type first and falls back to event.
  • payloads() reads payload first and falls back to context.

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