OpenTelemetry en Laravel 13: de 'la app va lenta' a 'es esta query', paso a paso
Tu endpoint tarda 800 ms y el log solo dice "request handled". OpenTelemetry en Laravel te da la traza completa —petición, query, llamada externa— y un trace_id que también puedes pegar en el log para unir ambas historias.
Qué te da una traza que un log no te da
Cuando algo va lento en producción tienes tres herramientas y cada una responde una pregunta distinta. Confundirlas es la razón por la que se pasan horas mirando logs sin encontrar nada.
Log, métrica y traza responden a tres preguntas distintas
El log dice qué pasó: un mensaje, un error, un aviso. La métrica dice cuánto: latencia media, peticiones por segundo, errores por minuto. La traza dice dónde: en qué tramo concreto se fueron los 800 ms. Si tienes logging montado y aun así no encuentras el problema, te falta la tercera.
Qué es un span y qué es un trace_id
Una traza es el recorrido completo de una petición. Cada tramo de ese recorrido es un span: la petición HTTP, la query a la base de datos, la llamada a la pasarela de pago, el job en cola. Todos comparten un identificador, el trace_id, que es lo que te permite decir "estos cinco eventos son la misma historia". Si ese trace_id también va en las líneas del log, el error y la traza dejan de ser dos sistemas que correlacionas a ojo.
Lee también
Paso 1: instalar la extensión de PHP
La instrumentación automática de OpenTelemetry para PHP vive en una extensión nativa, no en un paquete de Composer. Se instala una vez en el servidor con PECL y se activa en un ini.
pecl install opentelemetry
echo "extension=opentelemetry" | sudo tee /etc/php/8.4/mods-available/opentelemetry.ini
sudo phpenmod opentelemetry
php --ri opentelemetryAjusta la ruta a la versión de PHP de tu app. En un VPS con Nginx y PHP-FPM el cambio afecta al proceso web y a los workers de cola, así que reinicia ambos. El último comando debe imprimir la versión de la extensión; si responde "Extension 'opentelemetry' not present", el ini no se leyó para ese SAPI: comprueba con php --ini qué archivos carga la CLI y cuáles el FPM.
Paso 2: los paquetes de Composer
La extensión instrumenta a bajo nivel, pero necesita el SDK y el exportador para hablar con un backend. El paquete de Laravel es el que añade los spans de petición, de base de datos y de caché sin escribir una línea.
composer require open-telemetry/sdk open-telemetry/exporter-otlp \
open-telemetry/opentelemetry-auto-laravel php-http/guzzle7-adapterQué instrumenta automáticamente y qué no
Con eso aparecen solos los spans de la petición HTTP, de las consultas SQL y de la caché. Lo que no aparece es tu lógica de negocio: un job de facturación o una llamada a la pasarela de pago no tienen span hasta que se lo pones tú. Empieza por lo automático y añade spans propios solo donde duele.
El cliente PSR-18 y el protobuf: los dos que se olvidan
El exportador OTLP necesita un cliente HTTP PSR-18 que pueda descubrir en runtime; si falta, no hay error visible, simplemente no llega nada al backend. Y en producción conviene la extensión C de Protobuf en lugar de la implementación nativa de google/protobuf: mismo payload, mucho menos CPU.
Paso 3: configurar el exportador OTLP
Casi todo se configura con variables de entorno. Ese es el motivo por el que cambiar de backend mañana es cambiar una línea del .env y no reescribir código.
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 es lo que engancha la instrumentación automática al arrancar PHP. OTEL_SERVICE_NAME es el nombre con el que buscarás tu app en el backend.
http/protobuf en el 4318 frente a gRPC en el 4317
http/protobuf usa el puerto 4318 y es el transporte por defecto del exportador: atraviesa proxies y balanceadores sin complicaciones. gRPC usa el 4317 y es más eficiente en volumen alto, pero exige HTTP/2 de punta a punta. Si no sabes qué hay en medio, empieza por http/protobuf.
Probar con un backend local en un contenedor
Antes de tocar producción, monta el backend en tu máquina. Jaeger todo en uno acepta OTLP y trae interfaz web:
docker run --rm -p 16686:16686 -p 4318:4318 jaegertracing/all-in-one:latest
# UI: http://localhost:16686Apunta el endpoint a http://localhost:4318, haz una petición y busca el servicio en la interfaz. Si no aparece nada, el problema está en el envío, no en el backend.
Paso 4: ver la primera traza de verdad
Haz una petición a una ruta que consulte la base de datos. Deberías ver un span raíz con el nombre de la ruta, un span por query dentro y el tiempo de cada uno. Ahí está la respuesta: si la petición tarda 800 ms y 620 están en una sola consulta, ya sabes dónde mirar. Al leerla, busca el span más largo, no el más numeroso, y compara el total del span raíz con la suma de sus hijos: la diferencia es el tiempo que se fue en código tuyo.
Sampling: no exportes el 100%
Exportar todo el tráfico es la forma más rápida de llenar el disco del backend y de añadir latencia a cada petición. El sampler por ratio lo resuelve con dos variables: parentbased_traceidratio y un argumento entre 0 y 1. Con 0,1 exportas una de cada diez trazas y respetas la decisión del padre cuando la petición viene de otro servicio.
Qué se pierde al muestrear y cómo conservar los errores
Lo que se pierde es el error raro que ocurre una vez cada mil peticiones. La solución no es muestrear a ciegas: marca siempre los errores y expórtalos aparte, con un sampler compuesto (ratio para el tráfico normal, siempre para lo que acaba en error). El volumen, en cualquier caso, cae en la misma proporción que el ratio: de 1,0 a 0,1 reduces el tráfico exportado a una décima parte. Mide una semana con 0,1 y ajusta, porque depende de cuántos spans genere cada petición.
Spans propios: colas y servicios externos
Donde la instrumentación automática se queda corta es en tu lógica de negocio. Un job de facturación que tarda 4 segundos aparece como un span de cola sin detalle; con un span manual ves qué pasó dentro.
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 {
// trabajo real
} catch (\Throwable $e) {
$span->recordException($e);
throw $e;
} finally {
$scope->detach();
$span->end();
}
}El patrón es siempre el mismo: abrir span, activarlo, hacer el trabajo, registrar la excepción si falla y cerrar en el finally. Si tu versión del SDK expone otra firma para el builder, revisa la documentación de la API antes de copiar: los nombres cambian entre versiones mayores.
Atributos útiles y los que nunca se ponen
Buenos atributos son los que te dejan filtrar: id del pedido, pasarela, reintento, versión del contrato. Malos atributos convierten el backend de trazas en una filtración: emails, teléfonos, tokens, cabeceras de autorización, cuerpos de petición completos. Un backend de trazas no es seguro por defecto y lo lee más gente que la base de datos.
Los tres sitios donde se rompe en producción
PHP-FPM: procesos cortos y exportación por lotes
En PHP-FPM el proceso muere al acabar la petición. Si el export va por lotes y el proceso termina antes de vaciarlos, las trazas se pierden en silencio. Con fastcgi_finish_request o con procesos que se matan por timeout el lote se puede quedar a medias. Si ves trazas incompletas solo en producción, mira aquí primero.
Octane y workers de larga vida: memoria y estado
Con Octane o con workers de cola el proceso vive horas: el estado del SDK sobrevive entre peticiones, los spans no deben acumularse y la memoria del exportador crece si no se vacía. Vigila el consumo del worker y no des por hecho que el mismo perfil de sampling sirve para web y para colas.
El exportador mudo: no hay error pero tampoco trazas
El fallo más frustrante es el que no falla. El endpoint está mal, el cliente PSR-18 no se descubre o el protocolo no coincide, y no hay excepción: simplemente no llega nada. Revisa en este orden que el endpoint sea alcanzable desde el servidor (no desde tu portátil), que el protocolo configurado coincida con el del receptor y que el cliente HTTP esté instalado. Los logs internos del SDK se activan por variable de entorno y son la salida más rápida.
Elegir backend sin atarse: Tempo, SigNoz o Jaeger
Los tres aceptan OTLP, así que la decisión es de operación y no técnica: Jaeger es el más fácil de levantar, Tempo se integra con tu Grafana y almacena barato, SigNoz trae trazas, métricas y logs en una sola interfaz. Migrar después es cambiar OTEL_EXPORTER_OTLP_ENDPOINT y desplegar.
Conclusión
Instrumentar una vez y poder cambiar de backend es la diferencia entre observar tu app y alquilar una caja negra. La instalación son cuatro pasos, el muestreo es una línea y los fallos de producción son tres y conocidos. Empieza con la instrumentación automática y un 10% de muestreo: con eso ya respondes "dónde se fueron los 800 ms". En el blog seguimos con las piezas que hacen que una app de Laravel aguante en producción.

