Tracing
OpenTelemetry-compatible distributed tracing — HTTP, CQRS, DB, cache, and messaging spans wired automatically. Zero overhead until you plug in OpenTelemetry.
Tracing
vortos-tracing provides distributed tracing via a layered decorator chain. NoOp by default — zero overhead until you plug in OpenTelemetry. Sampling and per-module disable are configured at compile time.
What is distributed tracing?
When a request arrives at your application it typically triggers many operations — a database query, a cache lookup, an event dispatched to Kafka, a downstream HTTP call. Each operation takes time. Without tracing you know the total request duration but not which operation caused the slowdown.
Tracing records every meaningful operation as a span. A span has a name, a start time, a duration, and a set of attributes (key-value metadata). Spans form a tree: one root span per request, with child spans for each sub-operation. The whole tree is called a trace.
HTTP POST /orders [root span, 342ms]
├─ cqrs.command.PlaceOrder [child span, 318ms]
│ ├─ db.query [child span, 12ms] ← SELECT stock
│ ├─ db.execute [child span, 8ms] ← INSERT order
│ └─ messaging.event.dispatch [child span, 295ms]
│ └─ kafka.produce [child span, 291ms]
└─ cache.get [child span, 2ms] ← cache missIn a tracing UI (Jaeger, Grafana Tempo, Honeycomb) you can click any trace and see this waterfall. Slow spans jump out immediately.
Distributed means the trace can cross service boundaries. When your API calls another service, the trace ID travels in HTTP headers — the downstream service creates its own spans as children of your span. You get a single trace that spans your entire system.
Decorator chain
TracingInterface (injected everywhere)
│
▼
ModuleAwareTracer ← disables specific modules (e.g. cache too noisy)
│
▼
SamplingTracer ← applies sampling rate (e.g. trace 10% of requests)
│
▼
NoOpTracer ← default inner (zero overhead)
or
OpenTelemetryTracer ← when you plug in OpenTelemetryWhen ModuleAwareTracer detects a disabled module, it returns a NoOpSpan immediately — SamplingTracer is never called. When SamplingTracer decides not to sample, it returns NoOpSpan — the inner tracer is never called. This means disabling or sampling-out is always O(1) and allocation-free.
Installation
composer require vortos/vortos-tracingPackage registration
use Vortos\Tracing\DependencyInjection\TracingPackage;
$packages = [
new LoggerPackage(), // order 10
new TracingPackage(), // order 50
new HttpPackage(),
// ...
];Register TracingPackage after LoggerPackage — the logger's CorrelationIdProcessor depends on TracingInterface being available.
Configuration
php vortos make:config tracinguse Vortos\Tracing\Config\TracingModule;
use Vortos\Tracing\Config\TracingSampler;
use Vortos\Tracing\DependencyInjection\VortosTracingConfig;
return static function (VortosTracingConfig $config): void {
// Sampling — choose one
$config->sampler(TracingSampler::AlwaysOn); // trace everything (dev)
$config->sampler(TracingSampler::AlwaysOff); // disable completely
$config->sampler(TracingSampler::Ratio, rate: 0.1); // trace 10% of requests (prod)
$config->sampler(TracingSampler::Ratio, rate: 0.01); // trace 1% (very high volume)
// Silence noisy modules
$config->disable(TracingModule::Cache);
$config->disable(TracingModule::Persistence);
// Trust incoming trace context from upstream services
// Only enable when your load balancer / API gateway is the sole entry point
$config->trustRemoteContext(true);
};No config file required — sensible defaults apply per environment.
Default behaviour per environment
| Environment | Default sampler | Default rate |
|---|---|---|
dev | AlwaysOn | 100% |
| Any other | Ratio | 10% |
Auto-instrumented spans
The framework automatically creates spans for every major operation. No code changes are required — these activate the moment TracingPackage is registered.
HTTP requests
TracingMiddleware (registered as a kernel event subscriber) creates the root span for every HTTP request.
| Attribute | Value | Example |
|---|---|---|
http.method | Request method | POST |
http.url | Scheme + host + path only (never query string) | https://api.example.com/orders |
http.route | Named route | orders.place |
http.status_code | Response status | 201 |
Why path only — no query string?
Query strings often contain tokens, search terms, user IDs, or API keys. Recording them in traces (which may be shipped to a third-party collector) creates a PII / credential leakage risk. http.url always stores scheme + host + path — never ?token=....
On exception, the span records the exception and sets status=error before ending.
CQRS — command bus
Each CommandBus::dispatch() call creates a child span named cqrs.command.{ShortName} — for example cqrs.command.PlaceOrder.
| Attribute | Value |
|---|---|
command.class | Fully qualified class name |
The span status is ok on success, error on exception (with the exception recorded on the span).
Database — DBAL
When vortos-persistence is active with a DBAL connection, every query execution creates a span.
| Span name | Triggered by |
|---|---|
db.query | Connection::executeQuery() — SELECT, returns result set |
db.execute | Connection::executeStatement() — INSERT / UPDATE / DELETE |
db.prepare.execute | Statement::execute() — prepared statement execution |
| Attribute | Value |
|---|---|
db.statement | The SQL string |
db.rows_affected | Row count (execute/prepare only) |
SQL may contain sensitive values
If you build queries by interpolating values directly into SQL strings rather than using bound parameters, those values appear in db.statement. Always use prepared statements — executeQuery('SELECT … WHERE id = ?', [$id]) — not string interpolation.
Database — MongoDB
MongoDB operations are wrapped in db.mongo.{operation} spans — for example db.mongo.find, db.mongo.insertOne.
| Attribute | Value |
|---|---|
db.collection | Collection name |
db.operation | Method name |
Cache
Each get, set, and delete on the cache adapter creates a cache.{operation} span.
| Attribute | Value |
|---|---|
cache.key | The cache key |
cache.hit | true / false (get only) |
Cache spans can be noisy at high request volume. Disable them with $config->disable(TracingModule::Cache) if they dominate your trace view.
Messaging
The messaging module is fully instrumented — spans are created at every stage of the message pipeline. See the Messaging tracing section for details.
Trace context propagation
Distributed tracing requires a shared trace ID to flow through every service in your system. This is called context propagation — the trace ID travels as an HTTP header from service A to service B, and service B's spans become children of service A's span.
Vortos uses the W3C traceparent header — the industry standard, supported by all major tracing systems.
Outbound context (always active)
When your code makes an outbound HTTP call, inject the current trace context into the request headers:
$headers = [];
$this->tracer->injectHeaders($headers);
// headers now contains: ['traceparent' => '00-<trace-id>-<span-id>-01']
// pass these headers to your HTTP clientKafka producers automatically inject headers — no manual wiring needed.
Inbound context — trustRemoteContext
By default, TracingMiddleware does not read the traceparent header from incoming requests. This is a security default: if your service is publicly accessible, an attacker could send a forged traceparent header and inject their own trace ID into your traces.
// config/tracing.php
$config->trustRemoteContext(true); // default: falseEnable trustRemoteContext only when:
- Your service sits behind an API gateway or load balancer that is the sole public entry point
- That gateway strips or validates incoming trace headers before forwarding them
- All callers are internal services you control
When enabled, the incoming traceparent header is used to link your spans as children of the upstream trace — enabling end-to-end traces across services.
Inject the tracer
use Vortos\Tracing\Contract\TracingInterface;
final class PaymentService
{
public function __construct(private readonly TracingInterface $tracer) {}
public function charge(Payment $payment): void
{
$span = $this->tracer->startSpan('payment.charge', [
'payment.id' => $payment->getId(),
'payment.currency' => $payment->getCurrency(),
]);
try {
// ... charge
$span->setStatus('ok');
} catch (\Throwable $e) {
$span->recordException($e);
$span->setStatus('error', $e->getMessage());
throw $e;
} finally {
$span->end(); // always end, even on exception
}
}
}Always call $span->end() in a finally block. A span that never ends is never exported to the collector.
Silence a module
High-volume modules (cache, persistence) can generate thousands of spans per request. Use disable() to stop recording spans for a module without affecting the rest:
$config->disable(TracingModule::Cache);
$config->disable(TracingModule::Persistence);
$config->disable(TracingModule::Auth);Disabled modules return a NoOpSpan immediately — zero allocation, no performance impact.
Connecting logs to traces
When vortos-logger is active alongside vortos-tracing, every log record automatically receives a trace_id field:
{"message":"Order created","channel":"app","extra":{"trace_id":"4bf92f3577b34da6a3ce929d0e0e4736"}}
{"message":"Payment charged","channel":"app","extra":{"trace_id":"4bf92f3577b34da6a3ce929d0e0e4736"}}All log records produced during the same request share the same trace_id. In your log aggregator (Datadog, Loki, CloudWatch) you can filter on this value to see every log line from a single request.
How to verify it's working
Check spans are created (dev environment):
Add a simple OTel collector or use Jaeger all-in-one in Docker. Every request should appear as a trace with child spans.
Check trace_id in logs:
grep '"trace_id"' var/log/app-$(date +%Y-%m-%d).log | head -5Check W3C header injection:
# Make a request and inspect the response headers sent to the client
curl -i https://localhost/orders | grep traceparentCheck context propagation works:
Make a request to Service A that calls Service B internally. In Jaeger/Tempo, the trace for that request should show spans from both services in a single tree.
Disable a module and verify silence:
$config->disable(TracingModule::Cache);After container rebuild, cache operations should produce no spans.