phpdot / logs
Tracer, span, and pending-log core implementing the contracts observability boundary.
Requires
- php: >=8.5
- phpdot/contracts: ^0.3
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.94
- phpdot/container: ^0.3
- phpstan/phpstan: ^2.0
- phpstan/phpstan-strict-rules: ^2.0
- phpunit/phpunit: ^13.0
Suggests
- phpdot/container: Autowires the container-attribute services declared in src (the attributes stay inert until reflected, so standalone consumers do not need it installed).
Provides
None
Conflicts
None
Replaces
None
README
The observability engine for the PHPdot framework — distributed tracing and structured logging behind one interface.
A package holds one object — TracerInterface — and logs and opens spans against it. It never knows or cares where those records go. The application binds a backend (a Writer) once, and that single binding decides whether the same code writes rich encrypted files, forwards to Monolog, or is discarded. One trace_id per request ties every log line and span together — across packages and across separate channel files.
Table of Contents
Requirements
| Requirement | Constraint |
|---|---|
| PHP | >= 8.5 |
phpdot/contracts |
^0.3 |
phpdot/container is require-dev only (and a suggest entry) — the #[Singleton] and #[Binds]
attributes in src stay inert until a phpdot application reflects them, so standalone consumers
don't need it installed.
Installation
composer require phpdot/logs
The engine ships with a NullWriter bound by default, so it works with nothing else installed (records are discarded). Add phpdot/tracelog or phpdot/psr3-bridge and bind it to actually persist them.
Usage
Logging
Inject TracerInterface and log — anywhere, no setup required:
use PHPdot\Contracts\Logs\TracerInterface; final class OrderService { public function __construct(private readonly TracerInterface $tracer) {} public function place(int $id): void { $this->tracer->info('order placed', ['id' => $id]); $this->tracer->warning('low stock', ['sku' => 'A-1', 'left' => 2]); } }
Every line is correlated to the current span's trace_id / span_id. If no span is active yet, the tracer mints a trace context on first use, so logging works in any context — services, jobs, a one-off script.
Encrypting a single line — ->secure()
A log method returns a pending handle; call secure() on it to mark that one record sensitive. A backend that supports encryption (tracelog) then encrypts its message and context together, fail-closed — dropped, never written in plaintext, if it cannot be protected:
$tracer->error('Password reset for ' . $email, ['email' => $email])->secure(); // encrypted $tracer->info('GET /orders', ['status' => 200]); // plaintext
The record is written when the statement ends (the handle is released), so ->secure() takes effect as long as it is on the same line. trace_id / span_id stay in plaintext so an encrypted line is still correlatable.
Logging an exception
The global _e() helper (autoloaded with this package, like env()) encodes a Throwable
into the canonical exception context shape — class, message, code, file, line —
so the structure never drifts between packages:
try { $dispatcher->send($message); } catch (\Throwable $e) { $tracer->error('Message dispatch failed', ['queue' => $queue, 'exception' => _e($e)]); }
Spans
A span is a timed unit of work with attributes, events, and a status.
// trace() — preferred: the span is ALWAYS ended, even if the callback throws, // and the callback's return value is returned straight back. $rows = $tracer->trace('db.query', 'client', function ($span): array { $span->setAttribute('db.statement', 'SELECT * FROM users'); $span->addEvent('cache.miss', ['key' => 'users']); $result = $db->run(...); $span->setAttribute('db.rows', count($result)); return $result; }); // manual span — you own end(); use when the work doesn't fit a callback $span = $tracer->span('upload', 'internal'); $span->setAttribute('bytes', $size); try { $this->store($file); $span->setStatus('ok'); } finally { $span->end(); }
Spans nest automatically — a span opened while another is active becomes its child, sharing the trace:
$tracer->trace('http.request', 'server', function () use ($tracer) { $tracer->trace('db.query', 'client', fn () => $this->query()); // child of http.request });
span kinds: internal | server | client | producer | consumer
status: unset | ok | error
Logging on a span correlates the line to that span's id: $span->info('processing', ['step' => 2]).
Channels
Scope a package to its own stream with channel(); a backend routes each channel separately (tracelog writes {channel}.log):
$log = $tracer->channel('http'); $log->info('GET /orders', ['status' => 200]); // channel "http", same request trace_id
channel() returns a clone — the trace identity is unchanged, so lines on different channels in one request still share one trace_id.
The request boundary
At the server entry point, the kernel opens one root span per request — continuing an inbound W3C traceparent when present — runs your work, marks the root error if it throws, and flushes on the way out. Your packages never call it:
$kernel = $container->get(PHPdot\Logs\TraceKernel::class); $response = $kernel->handle( $request->method() . ' ' . $request->path(), // root span name fn () => $dispatcher->handle($request), // your request body $request->header('traceparent'), // optional: continue a distributed trace $request->header('tracestate'), );
There is no startRequest/endRequest/close in your code. The per-coroutine scope also auto-drains at coroutine end as a backstop, so a span left open is still exported. A malformed inbound traceparent never breaks the request — the kernel falls back to a fresh root.
The kernel is transport-agnostic — name the root after the unit of work for CLI commands or queue messages, too:
$kernel->handle('orders:reconcile', fn () => $command->run($input, $output)); $kernel->handle('queue:SendInvoice', fn () => $handler->handle($message), $message->header('traceparent'));
Distributed tracing
// inbound — continue an upstream trace (handle() does this for you) $kernel->handle('GET /orders', $work, $req->header('traceparent')); // outbound — propagate to a downstream service $client->request('GET', $url, ['headers' => [ 'traceparent' => $tracer->context()->toTraceparent(), // 00-<trace>-<span>-01 ]]);
Choosing a backend
The app binds WriterInterface once — the only line that changes between "rich files", "Monolog", and "off":
- phpdot/tracelog — encrypted, per-channel files
- phpdot/psr3-bridge — any PSR-3 logger (Monolog)
NullWriter(built in, the default) — discard
No sampling, ever: if logging is enabled, every log and span is stored — production included.
Coroutine safety
The tracer and scope manager are process-wide #[Singleton]s, but trace identity is not on the instance: each coroutine's active-span stack lives in its own context, looked up on every call. Two coroutines get two stacks; neither can see the other's current span. This is what makes one shared tracer safe under Swoole without leaking state between concurrent requests.
Architecture
- A package depends on
phpdot/contractsonly and injectsTracerInterface. - The engine (
phpdot/logs) builds the spans, the per-coroutine scope, and the request kernel. - The backend is chosen by the app in one line. Packages never change.
graph TD
PKG["Your package<br/><br/>injects TracerInterface —<br/>info / warning / error, span, channel"]
subgraph Engine["phpdot/logs"]
direction TB
TRACER["Tracer + Span<br/><br/>builds a correlated record per call;<br/>one trace_id per request"]
SCOPE["ScopeManager<br/><br/>per-coroutine active-span stack<br/>(Swoole-safe), auto-drains at end"]
KERNEL["TraceKernel<br/><br/>one root span per request,<br/>continues inbound traceparent, flushes"]
end
WRITER["WriterInterface<br/><br/>the seam — the app binds one backend"]
BACKEND["Backend<br/><br/>tracelog (encrypted files) /<br/>psr3-bridge (Monolog) /<br/>NullWriter (discard, default)"]
PKG --> Engine
Engine --> WRITER
WRITER --> BACKEND
Loading
Testing
The package is standalone-testable:
composer install composer test # PHPUnit composer analyse # PHPStan, level max + strict rules composer cs-check # PHP-CS-Fixer (@PER-CS2.0) composer check # all three
License
MIT
This repository is a read-only mirror, generated by CI from phpdot/monorepo. Pull requests and issues belong in the monorepo.