golovanov / traceloom
A lightweight PHP library for recording structured event timelines grouped by trace ID.
Requires
- php: >=8.2
Requires (Dev)
- phpstan/phpstan: ^1.12
- phpunit/phpunit: ^11.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
A lightweight PHP library for recording structured event timelines grouped by trace ID.
Traceloom is for small PHP APIs, backend services, jobs, webhooks, and scripts where you want to reconstruct what happened during one logical process without running a full observability stack.
It is not a replacement for Monolog, OpenTelemetry, or a distributed tracing platform.
Installation
composer require golovanov/traceloom
Quick Start
use Golovanov\Traceloom\Tracer; $tracer = Tracer::fromDirectory(__DIR__ . '/logs'); $trace = $tracer->start(); $trace->event('request_start', [ 'method' => 'POST', 'path' => '/orders', ]); $trace->event('auth_success', [ 'user_id' => 42, ]); $trace->event('request_end', [ 'status' => 201, ]);
All events written through the same Trace object share the same trace_id.
The payload can be any PHP array. It is always written as a JSON object: an empty
payload as {}, a list as {"0": ..., "1": ...}.
JSONL Output
{"timestamp":"2026-07-10T10:41:20.112345Z","trace_id":"9f1a8e7c2d4b4a9f93e2b2b1454f0c0a","event":"request_start","sequence":1,"elapsed_ms":0.121,"data":{"method":"POST","path":"/orders"}}
{"timestamp":"2026-07-10T10:41:20.117381Z","trace_id":"9f1a8e7c2d4b4a9f93e2b2b1454f0c0a","event":"auth_success","sequence":2,"elapsed_ms":5.157,"data":{"user_id":42}}
{"timestamp":"2026-07-10T10:41:20.289842Z","trace_id":"9f1a8e7c2d4b4a9f93e2b2b1454f0c0a","event":"request_end","sequence":3,"elapsed_ms":177.618,"data":{"status":201}}
Fields come in this order: timestamp, trace_id, parent_trace_id (only for a
continued trace), event, sequence, elapsed_ms, data. The format is shared with
the JS and Go implementations: a file written by one is read by the others.
A nested empty array is written as [], even where you meant an empty map: PHP does
not distinguish the two. Only the top-level data is always an object.
Continue An Existing Trace
$trace = $tracer->start($incomingTraceId); $trace->event('webhook_received');
Invalid incoming IDs are replaced with a generated ID. Generated IDs are 32-character random hex strings.
An incoming ID is not trusted by default: it is recorded as parent_trace_id and a
fresh ID is generated. Behind a gateway that sets the header itself, opt in to reusing
it (see Trusting Incoming Trace IDs).
Finding A Trace
After installing Traceloom as a dependency, Composer exposes the bundled CLI through vendor/bin:
vendor/bin/eventtrace show 9f1a8e7c2d4b4a9f93e2b2b1454f0c0a --dir=logs
When working inside the Traceloom source repository itself, run the package binary directly:
php bin/eventtrace show 9f1a8e7c2d4b4a9f93e2b2b1454f0c0a --dir=logs
Example output:
Trace: 9f1a8e7c2d4b4a9f93e2b2b1454f0c0a
10:41:20.112 request_start
10:41:20.117 auth_success +5 ms
10:41:20.289 request_end +172 ms
Total duration: 177 ms
The MVP CLI intentionally includes only show. More commands such as tail or event search belong in a later phase.
Configuration
use Golovanov\Traceloom\Configuration; use Golovanov\Traceloom\Tracer; $config = Configuration::create( logDirectory: __DIR__ . '/logs', maxFileBytes: 50 * 1024 * 1024, // rotate a shard past this size maxStringBytes: 64 * 1024, // truncate longer strings (clamped to maxRecordBytes) maxRecordBytes: 256 * 1024, // degrade an event whose line exceeds this (clamped to maxFileBytes) maxArrayItems: 1000, // limit on EACH array, on its own maxPayloadNodes: 10000, // node budget for the payload as a whole maxKeyBytes: 256, // longer keys are truncated with a digest (min 32) maxDepth: 16, // nesting beyond this becomes [MAX_DEPTH_EXCEEDED] sensitiveKeys: ['payment_token'], strictSensitiveKeys: false, // true => match whole keys only, no fragments directoryMode: 0750, fileMode: 0640, retentionDays: 0, // >0 deletes shards older than the cutoff on rotation trustIncomingTraceId: false, // true reuses an inbound ID instead of quarantining it failOnError: false, onError: static function (\Throwable $e): void { error_log($e->getMessage()); }, ); $tracer = Tracer::fromConfiguration($config);
maxRecordBytes is clamped to maxFileBytes, and maxStringBytes to maxRecordBytes,
so a single event can never overflow a shard.
Traceloom writes files by date and rotates into shards when the active file grows beyond maxFileBytes:
logs/
2026-07-10.jsonl
2026-07-10-1.jsonl
2026-07-10-2.jsonl
The active file handle is reused across events; the directory lock is taken only when a shard has to be selected or rotated.
File Permissions
Log directories are created 0750 and files 0640, so they are not world-readable.
Both are configurable via directoryMode and fileMode.
Trace files hold request bodies, user identifiers, and (despite masking) potentially sensitive values. Keep the log directory outside your web root so it cannot be fetched over HTTP. Traceloom cannot enforce this for you.
Sensitive Data
Sensitive key masking is enabled by default and runs recursively. Keys are compared
after folding case and removing everything but letters and digits, so api_key,
apiKey, API-KEY all become apikey. Built-in keys: password, passwd, pwd,
token, access_token, refresh_token, authorization, proxy_authorization,
auth, bearer, jwt, cookie, set_cookie, api_key, x_api_key, access_key,
secret_key, secret, client_secret, private_key, credentials, signature,
session, session_id, csrf, csrf_token.
By default a key is also masked if it contains a secret fragment: password,
passwd, secret, token, apikey, accesskey, privatekey, credential,
authorization, cookie, session, bearer, jwt, signature. So
user_password, stripe_secret and aws_access_key_id are caught. Set
strictSensitiveKeys: true to require a whole-key match instead.
The JS implementation uses the same two lists.
Masked values are written as:
"[REDACTED]"
Custom keys are merged with the defaults through sensitiveKeys. To see what a
configuration masks:
$config->sensitiveKeys(); // whole keys, canonical form, sorted Configuration::sensitiveKeyFragments(); // fragments
Masking is keyed on names, not values: a secret placed under an innocuous key (note,
body) is not detected. Do not pass raw credentials under arbitrary keys.
Trusting Incoming Trace IDs
An inbound trace ID is not trusted by default. On a public endpoint the header is
attacker-controlled, and a client that guesses or replays another request's ID could
otherwise write its own events into that trace and corrupt an investigation. The
incoming value is stored as parent_trace_id, and a fresh ID is generated:
$trace = $tracer->start($request->header('X-Trace-Id')); $trace->id(); // freshly generated $trace->parentId(); // the value the client sent
Behind a gateway or service mesh that sets the header itself, opt in to reusing it so a single ID spans the whole call chain:
$tracer = Tracer::fromConfiguration(Configuration::create( logDirectory: __DIR__ . '/logs', trustIncomingTraceId: true, ));
Payload Limits
Long strings are replaced with explicit truncation metadata (the preview is cut on a UTF-8 code-point boundary, never mid-character):
{
"_truncated": true,
"size_bytes": 250000,
"preview": "..."
}
Supported payload values are arrays, strings, integers, floats, booleans, null,
JsonSerializable, Throwable, and Stringable.
NAN, INF and -INF have no JSON form; they are written as the strings "NaN",
"Infinity" and "-Infinity".
An exception is written as its type and message, with its previous exception as
cause:
{"type": "RuntimeException", "message": "Payment declined", "cause": {"type": "PDOException", "message": "..."}}
The stack trace, file, line and code are not written: the trace carries server paths
and is large. Pass it explicitly if you need it. Each cause is one level deeper, so a
long chain ends in [MAX_DEPTH_EXCEEDED]. An exception that implements
JsonSerializable is written through jsonSerialize().
Other cases are replaced with an explicit marker rather than being allowed to break the write:
| Marker | Cause |
|---|---|
{"_binary": true, ...} |
String is not valid UTF-8 (preview is hex) |
[CIRCULAR_REFERENCE] |
Value refers back to itself |
[MAX_DEPTH_EXCEEDED] |
Nesting deeper than maxDepth |
[SERIALIZATION_FAILED: Class] |
A jsonSerialize()/__toString() threw |
[UNSUPPORTED_TYPE: type] |
Value is a resource or other unsupported type |
{"_omitted_items": N} |
Entries dropped past maxArrayItems or maxPayloadNodes |
Two independent limits bound a payload. maxArrayItems caps each array on its own,
so one long list cannot push its sibling fields out of the event; maxPayloadNodes caps
the payload as a whole, which is what stops a wide or deeply nested input bomb.
Keys are bounded too. A key longer than maxKeyBytes is cut on a code-point boundary
and given a digest of the original key, so that keys sharing a long prefix stay
distinct instead of collapsing into one and overwriting each other:
"very_long_key_from_untrusted_json…" → "very_long_key_fro…~3f2a9c1e5b7d4088"
The result is an ordinary string key — no JSONL consumer has to change — and the mapping is deterministic across processes, runs, and the PHP, JS and Go implementations alike. Masking is decided on the original key, so truncation cannot smuggle a secret past it.
A key that is not valid UTF-8 (a cp1251 form field, ?%E8%EC%FF=1) gets the same
treatment: each invalid byte becomes U+FFFD, then the key is bounded by maxKeyBytes,
and the digest of the original bytes is appended, so two such keys, or such a key and
a real one spelled with U+FFFD, never overwrite each other:
"\xE8\xEC\xFF" → "���~<16 hex digits>"
Masking is decided on the original key here too. The cross-implementation guarantee above covers valid UTF-8 keys only: for invalid ones the PHP mapping is fixed, but the JS and Go implementations do not yet produce the same key.
If a whole event still cannot be encoded, its data is replaced with
{"_encoding_error": "..."} so the event stays in the timeline instead of vanishing.
That is a real loss of data, so it is counted — see Error Handling.
Reserved Keys
_truncated, _binary, _encoding_error and _omitted_items belong to the
sanitizer. A payload key that spells one of them gains a leading underscore
(_truncated → __truncated), so a record cannot claim to be sanitizer output when it
is not, and a genuine marker can never overwrite a field of yours. The escape is itself
escaped, so the mapping back is unambiguous.
HTTP Integration
For new integrations, prefer X-Trace-Id:
$trace = $tracer->start($request->header('X-Trace-Id'));
The header is quarantined as parent_trace_id unless you opt in (see
Trusting Incoming Trace IDs).
If your system already uses X-Request-Id for request correlation, it is fine to continue that trace:
$trace = $tracer->start($request->header('X-Request-Id'));
Minimal request flow:
$trace->event('request_start', [ 'method' => $request->method(), 'path' => $request->path(), ]); // application processing $trace->event('request_end', [ 'status' => $response->statusCode(), ]);
Framework adapters for PSR-7, Laravel, Symfony, or custom frameworks are intentionally outside the MVP core.
Error Handling
Tracing is fail-safe by default: write errors should not break the main application.
Use failOnError: true when you want tracing failures thrown instead of passed to
onError, usually in tests or strict local development. A failure goes to one of the
two, never both.
Configuration errors always throw. An empty event name always throws too — it is a
programming error, not a runtime failure — regardless of failOnError.
Even in fail-safe mode data can be lost, in two different ways, and neither is silent:
$tracer->droppedEventCount(); // the event never reached the log $tracer->degradedEventCount(); // the event was written, its payload was not
An event is dropped when the write fails (a full disk, a permission error). It is
degraded when its payload could not be encoded or exceeded maxRecordBytes: the
record survives with data replaced by {"_encoding_error": "..."}, because an event
with a placeholder is worth more than a hole in the timeline. A dropped event is passed
to onError, or thrown instead with failOnError. A degraded event is always passed to
onError and never thrown, even with failOnError: it was written.
A dropped event leaves a gap in sequence. That gap is deliberate: the counters
above live in the process's memory and die with it, while the log file outlives both,
so the gap is the only signal to whoever reads the JSONL later that the timeline is
incomplete.
What each call guarantees once it returns:
| Call | Guarantee |
|---|---|
$trace->event() |
the record is handed to the OS: it survives a crash of the process |
$tracer->flush() |
the file is synced (fsync): it survives a crash of the OS or a power loss |
$tracer->close() |
flush(), then the file is released; terminal and idempotent |
After close(), events are rejected and counted as dropped rather than reopening the
file, and flush() does nothing. A failed flush() or close() is passed to
onError, or thrown instead with failOnError; it is not counted as a dropped event.
When the tracer is destroyed, the file handle is released without a sync. Call
close() at the end of a job if its trace must survive a power loss.
Writing To Your Own Destination
Tracer::fromConfiguration() writes JSONL files. To write elsewhere, implement
Golovanov\Traceloom\Writer\WriterInterface and pass it to the constructor:
use Golovanov\Traceloom\Clock\SystemClock; use Golovanov\Traceloom\Tracer; $tracer = new Tracer($config, new MyWriter(), new SystemClock());
write(TraceEvent $event): ?string reports what happened to the event:
- return
nullwhen the event was written as given; - return the reason when it was written with its payload replaced by a marker: the
tracer counts it as degraded and passes the reason to
onError; - throw when it was not written at all: the tracer counts it as dropped and passes the
exception to
onError, or rethrows it withfailOnError.
flush() and close() carry the guarantees from the table above; an exception they
throw is passed to onError, or rethrown with failOnError, without counting a dropped
event. close() is terminal: a write() after it must
throw. The tracer keeps the counters and calls onError or throws, so a writer does
neither.
$event->toArray() gives the record in field order, with data as a \stdClass;
the payload as an array is $event->data. The built-in writer encodes the record with
JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR; a bare
json_encode() escapes / and non-ASCII characters, which is valid JSON but not
byte-for-byte the same line. When the payload cannot be written (it fails to encode,
or is too large for your destination), write $event->toDegradedArray($reason)
instead, which keeps the event with data replaced by {"_encoding_error": $reason},
and return $reason.
Checking That Traces Can Be Written
A health check can ask whether this process can write today's trace, without writing one:
use Golovanov\Traceloom\Configuration; use Golovanov\Traceloom\Writer\JsonlFileWriter; $problem = JsonlFileWriter::writeProblem(Configuration::create(logDirectory: __DIR__ . '/logs')); if ($problem !== null) { // e.g. "Log file is not writable: /app/logs/2026-07-10-1.jsonl" }
writeProblem() returns the first problem it finds, or null if it finds none. It
checks that the log directory is writable (a missing one counts when its nearest existing
parent is, since the writer creates it), and that the lock file and today's current shard
are writable if they exist. It creates no file and changes no permission.
This is a permission check, not a guarantee: null does not promise that the next write
succeeds (a full disk, an ACL the check cannot see), and a reported problem does not
always mean it fails. Treat it as a signal for a health check, and keep watching
droppedEventCount().
Run the check in the process that writes the traces, as the same user. A check from a CLI command or cron job under another user can pass while PHP-FPM drops events.
Pass the configuration your tracer uses: the current shard depends on maxFileBytes.
Tracer::fromDirectory($dir) is the same as
Tracer::fromConfiguration(Configuration::create(logDirectory: $dir)), so for such a
tracer pass Configuration::create(logDirectory: $dir).
"Today" is the UTC date, as for the shard names, taken from the system clock. Pass a
second argument only if you built the tracer with new Tracer(...) and your own clock:
give the same Golovanov\Traceloom\Clock\ClockInterface instance.
When Not To Use Traceloom
Use Monolog if you need a general-purpose logging framework.
Use OpenTelemetry if you need industry-standard distributed tracing.
Use a log platform or observability stack if you need aggregation, dashboards, alerting, retention policy, or cross-service querying.
Traceloom is intentionally small: local structured timeline tracing without external infrastructure.