OpenTelemetry in Laravel 13: From 'the App Is Slow' to 'It's This Query,' Step by Step
Your endpoint takes 800 ms and the log just says "request handled". OpenTelemetry in Laravel gives you the full trace —request, query, external call— plus a trace_id you can paste into your logs so both sides tell the same story.
What a Trace Gives You That a Log Doesn't
When something is slow in production you have three tools and each answers a different question. Mixing them up is why people spend hours reading logs and come up empty.
Logs, Metrics and Traces Answer Three Different Questions
A log says what happened: a message, an error, a warning. A metric says how much: average latency, requests per second, errors per minute. A trace says where: which segment ate the 800 ms. If you already have logging and still can't find the problem, you're missing the third one.
What a Span Is and What a trace_id Is
A trace is the full journey of one request. Every segment of that journey is a span: the HTTP request, the database query, the call to the payment gateway, the queued job. They all share one identifier, the trace_id, and that is what lets you say "these five events are the same story". If that trace_id also rides along in your log lines, the error and the trace stop being two systems you correlate by eye.
Read also
Step 1: Install the PHP Extension
OpenTelemetry's automatic instrumentation for PHP lives in a native extension, not a Composer package. You install it once on the server with PECL and enable it in an ini file.
pecl install opentelemetry
echo "extension=opentelemetry" | sudo tee /etc/php/8.4/mods-available/opentelemetry.ini
sudo phpenmod opentelemetry
php --ri opentelemetryAdjust the path to your app's PHP version. On a VPS with Nginx and PHP-FPM the change affects the web process and the queue workers, so restart both. The last command must print the extension version; if it answers "Extension 'opentelemetry' not present", the ini wasn't read for that SAPI: check with php --ini which files the CLI loads and which the FPM loads.
Step 2: The Composer Packages
The extension instruments at a low level, but it needs the SDK and the exporter to talk to a backend. The Laravel package is the one that adds request, database and cache spans without a line of code.
composer require open-telemetry/sdk open-telemetry/exporter-otlp \
open-telemetry/opentelemetry-auto-laravel php-http/guzzle7-adapterWhat Gets Instrumented Automatically and What Doesn't
With that installed you get spans for the HTTP request, SQL queries and cache operations for free. What you don't get is your business logic: a billing job or a payment gateway call has no span until you add one. Start with the automatic part and add manual spans only where it hurts.
The PSR-18 Client and Protobuf: The Two Everyone Forgets
The OTLP exporter needs a PSR-18 HTTP client it can discover at runtime; if it's missing there is no visible error, just nothing arriving at the backend. And in production you want the C Protobuf extension rather than the native google/protobuf implementation: same payload, far less CPU.
Step 3: Configure the OTLP Exporter
Almost everything is configured through environment variables. That is exactly why switching backends tomorrow is a one-line .env change and not a rewrite.
OTEL_PHP_AUTOLOAD_ENABLED=true
OTEL_SERVICE_NAME=mi-app
OTEL_EXPORTER_OTLP_ENDPOINT=http://tempo:4318
OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf
OTEL_TRACES_SAMPLER=parentbased_traceidratio
OTEL_TRACES_SAMPLER_ARG=0.1OTEL_PHP_AUTOLOAD_ENABLED=true is what hooks automatic instrumentation into PHP at boot. OTEL_SERVICE_NAME is the name you'll search for in the backend.
http/protobuf on 4318 vs gRPC on 4317
http/protobuf uses port 4318 and is the exporter's default transport: it goes through proxies and load balancers without drama. gRPC uses 4317 and is more efficient at high volume, but it needs HTTP/2 end to end. If you don't know what sits in the middle, start with http/protobuf.
Testing Against a Local Backend in a Container
Before touching production, run the backend on your machine. Jaeger all-in-one accepts OTLP and ships a web UI:
docker run --rm -p 16686:16686 -p 4318:4318 jaegertracing/all-in-one:latest
# UI: http://localhost:16686Point the endpoint at http://localhost:4318, make a request and look for the service in the UI. If nothing shows up, the problem is on the sending side, not the backend.
Step 4: See Your First Real Trace
Hit a route that queries the database. You should see a root span named after the route, one span per query inside, and the time for each. There is the answer: if the request takes 800 ms and 620 of them sit in a single query, you know where to look. When reading it, look for the longest span, not the most numerous, and compare the root total with the sum of its children — the difference is time spent in your own code.
Sampling: Don't Export 100%
Exporting all your traffic is the fastest way to fill the backend's disk and to add latency to every request. The ratio sampler handles it with two variables: parentbased_traceidratio and an argument between 0 and 1. At 0.1 you export one trace in ten and respect the parent's decision when the request comes from another service.
What Sampling Costs You and How to Keep Errors
What you lose is the weird error that happens once in a thousand requests. The fix is not to sample blindly: always mark errors and export those traces separately, with a composite sampler (ratio for normal traffic, always for anything ending in an error). Volume falls in the same proportion as the ratio either way: from 1.0 to 0.1 cuts exported traffic to a tenth. Measure a week at 0.1 and tune, because it depends on how many spans each request produces.
Your Own Spans: Queues and External Services
Where automatic instrumentation falls short is your own business logic. A billing job that takes 4 seconds shows up as a queue span with no detail; with a manual span you see what happened inside.
use OpenTelemetry\API\Globals;
public function handle(): void
{
$tracer = Globals::tracerProvider()->getTracer('mi-app/facturacion');
$span = $tracer->spanBuilder('factura.generar')
->setAttribute('pedido.id', $this->pedido->id)
->startSpan();
$scope = $span->activate();
try {
// real work
} catch (\Throwable $e) {
$span->recordException($e);
throw $e;
} finally {
$scope->detach();
$span->end();
}
}The pattern is always the same: open the span, activate it, do the work, record the exception if it fails, close it in finally. If your SDK version exposes a different builder signature, check the API docs before copying: names change between major versions.
Useful Attributes and the Ones You Never Add
Good attributes are the ones you can filter on: order id, gateway, retry count, contract version. Bad attributes turn the tracing backend into a leak: emails, phone numbers, tokens, authorization headers, full request bodies. A tracing backend is not secure by default and more people read it than read your database.
The Three Places It Breaks in Production
PHP-FPM: Short-Lived Processes and Batch Export
Under PHP-FPM the process dies when the request ends. If the export is batched and the process exits before flushing, traces vanish silently. With fastcgi_finish_request or processes killed on timeout the batch can be left half sent. If you only see incomplete traces in production, look here first.
Octane and Long-Lived Workers: Memory and State
With Octane or with queue workers the process lives for hours: SDK state survives between requests, spans must not pile up and exporter memory grows if it never flushes. Watch worker memory and don't assume the same sampling profile fits web and queues.
The Silent Exporter: No Error, No Traces Either
The most frustrating failure is the one that doesn't fail. The endpoint is wrong, the PSR-18 client isn't discovered or the protocol doesn't match — and there is no exception, just nothing arriving. Check in this order that the endpoint is reachable from the server (not from your laptop), that the configured protocol matches the receiver's, and that the HTTP client is installed. The SDK's internal logs are enabled by environment variable and are the fastest way out.
Choosing a Backend Without Lock-In: Tempo, SigNoz or Jaeger
All three accept OTLP, so the decision is operational, not technical: Jaeger is the easiest to spin up, Tempo fits your existing Grafana and stores cheap, SigNoz brings traces, metrics and logs into one UI. Migrating later means changing OTEL_EXPORTER_OTLP_ENDPOINT and redeploying.
Conclusion
Instrument once and stay free to swap backends — that's the difference between observing your app and renting a black box. Setup is four steps, sampling is one line, and the production failures are three and well known. Start with automatic instrumentation and 10% sampling: that already answers "where did the 800 ms go". The blog keeps going with the pieces that make a Laravel app hold up in production.

