Build Notes ·Published 18 July 2026 ·5 min ·Traceloom

Building one library across PHP, JavaScript and Go

Traceloom exists in PHP, JavaScript and Go: three repositories, three version lines, one JSONL record format. The decision that made the port work is a hard line: the file on disk is the product, everything above it is a local dialect.

Traceloom is a small tracing library: it records event timelines grouped by a trace ID into plain JSONL files, so you can reconstruct one request, job, or webhook without running an observability stack. It exists three times (in PHP, in Node.js/TypeScript, and in Go) as three separate repositories with three separate version lines.

Porting a library across three languages sounds like a translation job. It is not. Each language has its own concurrency story, its own error-handling culture, its own idea of what a pleasant API looks like. Translate any one of those literally and you get a library that feels foreign everywhere. The decision that made the port work was drawing one hard line: the JSONL record on disk is the product. Everything above that line is negotiable per language. Nothing below it is negotiable at all.

Below the line: the record

A Traceloom log is one JSON object per line. The contract says exactly what that object looks like, and it is identical in all three implementations: the same field names, and the same marker names for everything the sanitizer did to your data: _truncated, _binary, _encoding_error, _omitted_items. When a payload exceeds a budget, the record says so explicitly instead of losing data silently, and it says so in the same vocabulary regardless of which language wrote the line. A file written by the Go implementation is readable by the PHP tooling, and the other way around.

The contract goes further down than field names. Take key truncation: a payload key longer than maxKeyBytes is cut and suffixed with a digest of the original key, so two distinct long keys can never collapse into the same truncated name. That digest scheme is deterministic across processes and across all three languages: the same input key produces the same stored key whether PHP, JS, or Go handled it. Masking of sensitive values is decided on the original key, before truncation, so cutting a key cannot un-redact its value. This is the level a wire format has to be specified at once you accept that files from different implementations may end up side by side.

Versioning sits above the line, deliberately. Each repository has its own changelog, tags, and pace: PHP is at 0.4.0, the JS and Go implementations at 0.1.0. Those numbers are not drift to be fixed: synchronizing them would imply the APIs move in lockstep, which is exactly what I did not want to promise. The thing that is kept aligned is the record contract, and only that.

Above the line: three dialects

Because the record is fixed, each library is free to be normal in its own language. The PHP API is Tracer::fromDirectory() / start() / event(): synchronous writes, concurrent processes coordinated with flock. The TypeScript API is async: await trace.event(...), appends batched in the background, a bounded queue (10,000 events by default) with a configurable overflow policy, drop-newest or drop-oldest, and a counter for whatever was dropped. The Go API is traceloom.New with functional options and a traceloom.Data map, thread-safe, taking kernel locks through flock on Unix-likes and LockFileEx on Windows.

None of these surfaces is a port of another. A Go developer gets functional options because that is what a Go library does; a Node developer gets promises and backpressure because a synchronous file append in a request handler is not something you ship in Node. The wire format is what makes this freedom affordable: however different the code looks, the files are interchangeable.

Where the seam shows

One place the line could not hold cleanly: writer coordination. PHP and Go both take kernel locks on the same .traceloom.lock file and on the active shard, so their writers can safely append to one shared log directory. Node.js is the odd one out. Keeping the JS implementation dependency-free means no native add-ons, and without them Node has no portable kernel file lock, so the JS writer coordinates its own processes through an atomic lock file instead, created with O_CREAT | O_EXCL.

Two locking mechanisms that do not see each other means one hard restriction: a Node.js writer must not share a write directory with concurrent PHP or Go writers. Reading is unaffected: any implementation can read any file. I could have hidden this behind a native dependency or a staleness heuristic; instead the restriction is documented as a rule. When idiomatic implementations genuinely diverge, recording the boundary beats pretending it is not there.

Porting as review

The unexpected return on writing the same sanitizer three times: each rewrite audited the previous ones. The PHP release that aligned its records with the JS and Go implementations was also a hardening release: broader sensitive-key masking, protection against forged sanitizer markers, and incoming trace IDs no longer trusted by default. The release after that closed the last unbounded dimension of the sanitizer, payload keys, with the digest scheme described above, shipped into all three implementations at once, because by then a contract change in one place was a contract change everywhere.

That is also why the claim "one format, three languages" is tested rather than asserted: each implementation ships unit, integration, CLI, and multi-process concurrency tests, running in CI.

Status

All three implementations are MIT: golovanov/traceloom on Packagist, @rgolovanov/traceloom on npm, github.com/golovanov-dev/traceloom-go on pkg.go.dev. Everything is pre-1.0, and Traceloom is deliberately not an APM, not distributed tracing, and not a logging framework: it is small local timeline tracing, in whichever of the three languages your backend happens to speak. The format underneath does not care.

Traceloom

Structured application traces in PHP, JavaScript and Go.

View project

Read next

Building something similar? Tell me about it.

If this note matches a problem you have right now, describe the situation — a few sentences are enough to tell whether I can help.