Vortos
Tracing

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 miss

In 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 OpenTelemetry

When 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-tracing

Package registration

bootstrap/app.php
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 tracing
config/tracing.php
use 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

EnvironmentDefault samplerDefault rate
devAlwaysOn100%
Any otherRatio10%

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.

AttributeValueExample
http.methodRequest methodPOST
http.urlScheme + host + path only (never query string)https://api.example.com/orders
http.routeNamed routeorders.place
http.status_codeResponse status201

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.

AttributeValue
command.classFully 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 nameTriggered by
db.queryConnection::executeQuery() — SELECT, returns result set
db.executeConnection::executeStatement() — INSERT / UPDATE / DELETE
db.prepare.executeStatement::execute() — prepared statement execution
AttributeValue
db.statementThe SQL string
db.rows_affectedRow 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.

AttributeValue
db.collectionCollection name
db.operationMethod name

Cache

Each get, set, and delete on the cache adapter creates a cache.{operation} span.

AttributeValue
cache.keyThe cache key
cache.hittrue / 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 client

Kafka 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: false

Enable 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 -5

Check W3C header injection:

# Make a request and inspect the response headers sent to the client
curl -i https://localhost/orders | grep traceparent

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


Module overview

On this page