npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@rgolovanov/traceloom

v0.1.1

Published

A lightweight dependency-free Node.js library for recording structured event timelines grouped by trace ID.

Downloads

25

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/traceloom

Quick 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.jsonl

Writers 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 a url field — is written verbatim. Scan or strip such values yourself before passing them to event().
  • Personal data is out of scope. Keys like email, phone, ssn, or card_number are not masked by default. Add them through sensitiveKeys if 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_error

CLI

npx eventtrace show 9f1a8e7c2d4b4a9f93e2b2b1454f0c0a --dir=logs

Example:

Trace: 9f1a8e7c2d4b4a9f93e2b2b1454f0c0a
10:41:20.112 request_start
10:41:20.117 auth_success +5.036 ms
Total duration: 5.157 ms

The CLI escapes control characters from untrusted log records and aggregates malformed-line warnings.

Development

npm install
npm run check
npm run benchmark

Tests 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.