@rgolovanov/traceloom
v0.1.1
Published
A lightweight dependency-free Node.js library for recording structured event timelines grouped by trace ID.
Downloads
25
Maintainers
Readme
Traceloom for Node.js
A lightweight, dependency-free TypeScript library for recording structured event timelines grouped by trace ID.
Traceloom is intended for small Node.js APIs, backend services, jobs, webhooks, and scripts where you want to reconstruct one logical process without running a full observability stack. Its JSONL wire format is compatible with the PHP Traceloom package.
It is not a replacement for Pino, OpenTelemetry, or a distributed tracing platform.
Requirements
- Node.js 20 or newer
- Linux or another POSIX-like system for the primary production target
The package ships both ESM and CommonJS builds and has no runtime dependencies.
Installation
npm install @rgolovanov/traceloomQuick start
import { Tracer } from '@rgolovanov/traceloom';
const tracer = Tracer.fromDirectory('./logs');
const trace = tracer.start();
await trace.event('request_start', {
method: 'POST',
path: '/orders',
});
await trace.event('auth_success', { user_id: 42 });
await trace.event('request_end', { status: 201 });
await tracer.close();Every event written through the same Trace shares a trace_id. Calls on one trace are serialized in invocation order, even when several returned promises are awaited together.
CommonJS is supported as well:
const { Tracer } = require('@rgolovanov/traceloom');JSONL output
{"timestamp":"2026-07-10T10:41:20.112000Z","trace_id":"9f1a8e7c2d4b4a9f93e2b2b1454f0c0a","event":"request_start","sequence":1,"elapsed_ms":0.121,"data":{"method":"POST","path":"/orders"}}
{"timestamp":"2026-07-10T10:41:20.117000Z","trace_id":"9f1a8e7c2d4b4a9f93e2b2b1454f0c0a","event":"auth_success","sequence":2,"elapsed_ms":5.157,"data":{"user_id":42}}Node.js wall-clock timestamps have millisecond precision. Traceloom emits six fractional digits for PHP wire compatibility; the final three digits are zero. elapsed_ms uses process.hrtime.bigint(), so duration measurement is monotonic.
Continue an existing trace
const trace = tracer.start(incomingTraceId);
await trace.event('webhook_received');Valid incoming IDs contain 8–128 characters from A-Z, a-z, 0-9, ., _, -, and :. Invalid IDs are ignored.
By default an incoming ID is not trusted: Traceloom generates a fresh trace_id and keeps the accepted incoming value as parent_trace_id, so a caller cannot inject events into somebody else's timeline. Set trustIncomingTraceId: true to adopt the incoming ID as-is — appropriate only for internal traffic behind a gateway that validates the header itself.
Configuration
import { Configuration, Tracer } from '@rgolovanov/traceloom';
const configuration = Configuration.create({
logDirectory: './logs',
maxFileBytes: 50 * 1024 * 1024,
maxStringBytes: 64 * 1024,
maxRecordBytes: 256 * 1024,
maxArrayItems: 1_000,
maxPayloadNodes: 10_000,
maxKeyBytes: 256,
maxDepth: 16,
maxQueuedEvents: 10_000,
overflowPolicy: 'drop-newest',
sensitiveKeys: ['payment_token'],
strictSensitiveKeys: false,
directoryMode: 0o750,
fileMode: 0o640,
retentionDays: 0,
trustIncomingTraceId: false,
failOnError: false,
onError: async (error) => {
console.error(error);
},
});
const tracer = Tracer.fromConfiguration(configuration);maxRecordBytes is clamped to maxFileBytes, and maxStringBytes is clamped to maxRecordBytes. One event therefore cannot overflow a shard.
Async lifecycle
Trace.event(), Trace.flush(), Tracer.flush(), and Tracer.close() return promises. Await individual events when strict-mode errors matter:
await trace.event('payment_authorized', { payment_id: 'pay_123' });An event records the time it was invoked, not the time it reached the disk. Timestamp, elapsed_ms, and sequence are captured synchronously inside event(), so a queued event still reports what the application actually did, and awaiting the returned promise is never required for correct timings.
tracer.close() waits for every event already registered with the tracer, then releases the file handle. Close is terminal: an event that arrives afterwards is rejected and counted as dropped rather than silently reopening the file.
flush() waits for pending events and calls fsync() on the active file. Ordinary event writes are handed to the operating system without an fsync per line.
Throughput and backpressure
Events are queued and appended in batches, so the cost of the cross-process lock is amortised over every event that is already waiting instead of being paid per line. Roughly 69,000 events/sec when events are not awaited individually, against roughly 1,100 events/sec when each event() is awaited — awaiting forces a batch of one and a full lock round-trip per event. Await when you need to know that a specific event reached the file; otherwise let the queue do its job.
The queue is bounded by maxQueuedEvents (default 10_000). When a producer outruns the disk and the queue is full, overflowPolicy decides what gives:
| Policy | Behaviour |
| --- | --- |
| drop-newest (default) | The incoming event is rejected; events already accepted are kept. |
| drop-oldest | The oldest queued event is discarded in favour of the newest. |
Either way the lost event increments droppedEventCount() and reaches onError — the timeline may lose events, but never silently.
Files, rotation, and concurrency
Files are selected by UTC date and rotated into numbered shards:
logs/
2026-07-10.jsonl
2026-07-10-1.jsonl
2026-07-10-2.jsonlWriters in separate processes coordinate through .traceloom-js.lock, acquired atomically with O_CREAT | O_EXCL. The lock carries its PID, hostname, and a unique token; it has a bounded wait and can recover after a dead owner. Shard selection and one complete batch of appends happen inside the critical section, and a writer only ever deletes a lock it still owns — so a process whose critical section outlived the staleness window cannot release somebody else's lock.
Expired shards are collected on the first write of each UTC date, so retentionDays keeps working in a process that runs for weeks. Retention only ever deletes files this writer creates — <date>.jsonl and <date>-<n>.jsonl. A neighbouring 2020-01-01-backup.jsonl is not ours and is left alone.
If a write is interrupted mid-buffer (ENOSPC, EIO), the log is rolled back to the last complete record, so a later event is never appended onto a half-written line.
The JavaScript lock is not the same primitive as PHP flock. PHP and Node.js processes may read each other's JSONL files, but they must not concurrently write into the same directory.
Directories are created as 0750 and files as 0640 on POSIX systems. Keep trace files outside the web root: payloads can still contain sensitive information after masking.
Sensitive data
Masking is recursive and enabled by default. Keys are folded by case and punctuation, so api_key, apiKey, API-KEY, and X-Api-Key are recognized. Common secret fragments such as password, secret, token, apikey, accesskey, credential, authorization, cookie, session, bearer, jwt, and signature are also matched, which covers suffixed and plural spellings like cookies, authorization_header, jwt_value, or access_key_id. No fragment is a substring of an ordinary English word, so author, keyboard, and monkey stay visible.
Masked values become:
"[REDACTED]"Set strictSensitiveKeys: true to disable fragment matching. Custom keys are merged with the defaults.
Two limits are deliberate and worth knowing before you log a payload:
- Only key names are inspected, never values. A credential stored under an innocent key —
{ "note": "Bearer eyJhbGciOi..." }or a DSN with a password inside aurlfield — is written verbatim. Scan or strip such values yourself before passing them toevent(). - Personal data is out of scope. Keys like
email,phone,ssn, orcard_numberare not masked by default. Add them throughsensitiveKeysif your logs must not carry them.
Payload limits and JavaScript values
Event data must be a plain object. Supported nested values include plain objects, arrays, strings, numbers, booleans, null, Date and objects with toJSON(). Buffer, ArrayBuffer, and typed arrays are recorded as binary metadata.
| Marker | Cause |
| --- | --- |
| { "_truncated": true, ... } | UTF-8 string exceeds maxStringBytes |
| { "_binary": true, ... } | Buffer, ArrayBuffer, or typed array |
| [CIRCULAR_REFERENCE] | Object or array refers back to itself |
| [MAX_DEPTH_EXCEEDED] | Nesting is deeper than maxDepth |
| [SERIALIZATION_FAILED: Class] | toJSON() or a property getter throws |
| [UNSUPPORTED_TYPE: type] | BigInt, function, symbol, or unsupported class |
| { "_omitted_items": N } | Entries dropped by maxArrayItems or maxPayloadNodes |
Two independent budgets shape a payload. maxArrayItems caps each array on its own, so a long array cannot starve the ones beside it. maxPayloadNodes caps the payload as a whole — every key and every array element spends one node — which is what stops a wide or deeply nested input from ballooning a record. Whichever limit bites first leaves an _omitted_items count at that level.
Keys are bounded too. A key longer than maxKeyBytes (256) is cut on a code-point boundary and given a digest of the full key, so two keys sharing a long prefix stay distinct instead of silently overwriting each other:
"user_metadata_zzzz…zzz" (514 bytes)
→ "user_metadata_zzz…zzz~953c7479324377a7" (exactly 256 bytes)The digest is the first 16 hex characters of sha256(key), so the same key maps to the same name across processes, runs, and the PHP and Go implementations. Masking is judged on the key as it arrived, so a long secret key is still redacted.
A payload cannot forge these markers. A key that spells one — _truncated, _binary, _encoding_error, _omitted_items — is written one underscore deeper (__truncated), so a marker in the log is always one Traceloom wrote.
Non-finite numbers are stored as the strings NaN, Infinity, or -Infinity. If a complete record is still too large or cannot be encoded, its data becomes { "_encoding_error": "..." }, preserving the event in the timeline. The event survives but its payload does not, so that loss increments degradedEventCount() and reaches onError.
Trusting incoming trace IDs
A client-controlled ID is quarantined by default: Traceloom creates a fresh trace_id and stores the accepted incoming value as parent_trace_id.
Inside a trusted perimeter — a gateway that already validated the header — you can adopt the incoming ID instead, which keeps one trace_id across services:
const tracer = Tracer.fromConfiguration(Configuration.create({
logDirectory: './logs',
trustIncomingTraceId: true,
}));
const trace = tracer.start(request.headers['x-trace-id'] ?? null);Do not enable this on a public endpoint: any caller could then pick the trace_id and mix its events into an existing timeline.
HTTP integration
const incoming = typeof request.headers['x-trace-id'] === 'string'
? request.headers['x-trace-id']
: null;
const trace = tracer.start(incoming);
await trace.event('request_start', {
method: request.method,
path: request.url,
});
// application processing
await trace.event('request_end', { status: response.statusCode });
response.setHeader('X-Trace-Id', trace.id());Framework-specific adapters are outside the core package.
Error handling
Runtime tracing failures are fail-safe by default. They increment droppedEventCount() and optionally call onError, but do not reject the event promise. Configuration errors and invalid event names always surface.
Set failOnError: true in tests or strict development environments to reject on I/O and payload-processing failures.
Two counters describe what the timeline lost:
tracer.droppedEventCount(); // events never written: I/O failure, full queue, closed writer
tracer.degradedEventCount(); // events written with their payload replaced by _encoding_errorCLI
npx eventtrace show 9f1a8e7c2d4b4a9f93e2b2b1454f0c0a --dir=logsExample:
Trace: 9f1a8e7c2d4b4a9f93e2b2b1454f0c0a
10:41:20.112 request_start
10:41:20.117 auth_success +5.036 ms
Total duration: 5.157 msThe CLI escapes control characters from untrusted log records and aggregates malformed-line warnings.
Development
npm install
npm run check
npm run benchmarkTests use Node.js's built-in test runner and include concurrent multi-process append and rotation scenarios.
When not to use Traceloom
Use a general-purpose logger when you need levels, transports, and broad logging integrations. Use OpenTelemetry for standard distributed tracing. Use an observability platform for aggregation, dashboards, alerting, or cross-service querying.
Traceloom intentionally remains small: local structured timeline tracing without external infrastructure.
