DevOps

Stack de observabilidad propio para microservicios PHP: OpenTelemetry, Prometheus y Loki sin dependencias en la nube

Ruslan Ismailov Publicado 14 min de lectura
S

Introducción: los tres pilares de la observabilidad y por qué el enfoque self-hosted es relevante en 2026

Los sistemas distribuidos modernos no pueden depurarse ni escalarse a ciegas. La observabilidad es la capacidad de comprender el estado interno de un sistema a partir de sus señales externas. Los tres pilares fundamentales de la observabilidad son las métricas, el tracing y los logs. Las métricas ofrecen una visión agregada del rendimiento, las trazas permiten seguir el recorrido de una solicitud a través de la cadena de servicios, y los logs estructurados revelan los detalles de cada evento.

En 2026, el enfoque self-hosted cobra especial relevancia: soluciones en la nube como Datadog o New Relic pueden costar decenas de miles de dólares al año con grandes volúmenes de datos, y los requisitos de soberanía de datos y cumplimiento del GDPR obligan a las empresas a mantener la telemetría dentro de su perímetro. Un stack propio basado en OpenTelemetry, Prometheus y Loki ofrece control total, costes predecibles y ausencia de vendor lock-in.

Este artículo es una guía práctica para ingenieros DevOps y desarrolladores backend en PHP que desean desplegar un stack de observabilidad completo en su propia infraestructura.

Visión general de la arquitectura del stack

El stack está compuesto por los siguientes componentes, cada uno con una función específica:

  • OpenTelemetry PHP SDK — instrumentación de la aplicación, generación de trazas, métricas y logs.
  • OpenTelemetry Collector — recepción, procesamiento y enrutamiento de telemetría. Acepta datos mediante los protocolos OTLP/gRPC y OTLP/HTTP.
  • Prometheus — almacenamiento de métricas en formato de series temporales, scraping de endpoints.
  • Loki — almacenamiento de logs con indexación por etiquetas, compatible con Grafana.
  • Tempo — almacenamiento de trazas distribuidas, integración con Grafana para consultas TraceQL.
  • Grafana — interfaz unificada para métricas (Prometheus), logs (Loki) y trazas (Tempo) con correlación entre ellos.
  • Alertmanager — enrutamiento de alertas desde Prometheus hacia Slack, PagerDuty o email.

El flujo de datos es el siguiente: el servicio PHP envía trazas y logs al Collector mediante OTLP; el Collector exporta las trazas a Tempo, los logs a Loki a través del exportador Loki, y las métricas son recopiladas directamente por Prometheus desde el endpoint /metrics de la aplicación. Grafana lee de las tres fuentes y permite pasar de una métrica a una traza, y de una traza a los logs con un solo clic.

Instrumentación de la aplicación PHP con OpenTelemetry PHP SDK

OpenTelemetry proporciona un SDK oficial para PHP. Instalamos los paquetes necesarios mediante Composer:

composer require open-telemetry/sdk \
  open-telemetry/opentelemetry-auto-laravel \
  open-telemetry/exporter-otlp \
  open-telemetry/transport-grpc \
  php-http/guzzle7-adapter

Para la instrumentación automática de Laravel, instalamos la extensión mediante PECL:

pecl install opentelemetry
# Añadimos en php.ini:
extension=opentelemetry.so

A continuación, el paquete opentelemetry-auto-laravel crea automáticamente spans para las solicitudes HTTP entrantes, las consultas de Eloquent y las colas, sin necesidad de modificar el código de la aplicación.

Para la instrumentación manual de secciones críticas del código:

<?php

use OpenTelemetry\API\Globals;
use OpenTelemetry\API\Trace\SpanKind;
use OpenTelemetry\API\Trace\StatusCode;

class OrderService
{
    public function processOrder(int $orderId): void
    {
        $tracer = Globals::tracerProvider()->getTracer('order-service');

        $span = $tracer->spanBuilder('processOrder')
            ->setSpanKind(SpanKind::KIND_INTERNAL)
            ->startSpan();

        $scope = $span->activate();

        try {
            $span->setAttribute('order.id', $orderId);
            $span->setAttribute('order.source', 'api');

            // lógica de negocio
            $this->chargePayment($orderId);
            $this->sendNotification($orderId);

            $span->setStatus(StatusCode::STATUS_OK);
        } catch (\Throwable $e) {
            $span->recordException($e);
            $span->setStatus(StatusCode::STATUS_ERROR, $e->getMessage());
            throw $e;
        } finally {
            $scope->detach();
            $span->end();
        }
    }
}

Recopilación de trazas: integración con Laravel y propagación entre servicios

Configurar el SDK mediante variables de entorno es el enfoque recomendado para aplicaciones Laravel, ya que no requiere cambios en el código al cambiar de entorno:

# .env
OTEL_SERVICE_NAME=order-service
OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4318
OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf
OTEL_PROPAGATORS=tracecontext,baggage
OTEL_TRACES_SAMPLER=parentbased_traceidratio
OTEL_TRACES_SAMPLER_ARG=0.1

Para la propagación del contexto entre servicios en solicitudes HTTP salientes mediante Guzzle, añadimos un middleware:

<?php

use GuzzleHttp\Client;
use OpenTelemetry\API\Globals;
use OpenTelemetry\Context\Propagation\ArrayAccessGetterSetter;
use OpenTelemetry\SDK\Common\Export\Http\PsrTransportFactory;

class TracingHttpClient
{
    private Client $client;

    public function __construct()
    {
        $this->client = new Client();
    }

    public function get(string $url): string
    {
        $headers = [];
        $propagator = Globals::propagator();
        $propagator->inject($headers, ArrayAccessGetterSetter::getInstance());

        $response = $this->client->get($url, ['headers' => $headers]);
        return $response->getBody()->getContents();
    }
}

Las cabeceras traceparent y tracestate (estándar W3C TraceContext) se transmiten automáticamente a los servicios downstream, lo que permite construir un árbol de trazas unificado a través de varios microservicios PHP.

Métricas desde PHP: contadores personalizados e histogramas

Las métricas de Prometheus se exportan mediante la librería promphp/prometheus_client_php o a través de la API de Métricas de OpenTelemetry. Mostramos el enfoque con el cliente nativo de Prometheus para máxima flexibilidad:

composer require promphp/prometheus_client_php
<?php

use Prometheus\CollectorRegistry;
use Prometheus\Storage\APCu;
use Prometheus\RenderTextFormat;

class MetricsService
{
    private CollectorRegistry $registry;

    public function __construct()
    {
        $this->registry = new CollectorRegistry(new APCu());
    }

    public function recordHttpRequest(
        string $method,
        string $route,
        int $statusCode,
        float $durationSeconds
    ): void {
        // Contador de solicitudes
        $counter = $this->registry->getOrRegisterCounter(
            'app',
            'http_requests_total',
            'Total HTTP requests',
            ['method', 'route', 'status_code']
        );
        $counter->inc([$method, $route, (string)$statusCode]);

        // Histograma de latencia (métricas RED)
        $histogram = $this->registry->getOrRegisterHistogram(
            'app',
            'http_request_duration_seconds',
            'HTTP request duration in seconds',
            ['method', 'route'],
            [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5]
        );
        $histogram->observe($durationSeconds, [$method, $route]);
    }

    public function renderMetrics(): string
    {
        $renderer = new RenderTextFormat();
        return $renderer->render($this->registry->getMetricFamilySamples());
    }
}

Registramos la ruta para el scraping de Prometheus en Laravel:

<?php
// routes/web.php
Route::get('/metrics', function (MetricsService $metrics) {
    return response($metrics->renderMetrics(), 200)
        ->header('Content-Type', \Prometheus\RenderTextFormat::MIME_TYPE);
})->middleware('throttle:60,1');

Logging estructurado en PHP: logs en JSON y correlación con trace ID

Los logs estructurados permiten a Loki indexar y filtrar datos de manera eficiente. Configuramos Monolog con un formateador JSON y añadimos trace_id y span_id desde el contexto activo de OpenTelemetry:

<?php
// config/logging.php

use Monolog\Formatter\JsonFormatter;
use Monolog\Handler\StreamHandler;
use OpenTelemetry\API\Globals;

'channels' => [
    'json' => [
        'driver' => 'monolog',
        'handler' => StreamHandler::class,
        'handler_with' => [
            'stream' => 'php://stdout',
            'level' => env('LOG_LEVEL', 'debug'),
        ],
        'formatter' => JsonFormatter::class,
        'tap' => [App\Logging\AddTraceContext::class],
    ],
],
<?php
// app/Logging/AddTraceContext.php

namespace App\Logging;

use Monolog\LogRecord;
use Monolog\Processor\ProcessorInterface;
use OpenTelemetry\API\Globals;

class AddTraceContext implements ProcessorInterface
{
    public function __invoke(LogRecord $record): LogRecord
    {
        $span = Globals::tracerProvider()->getTracer('app')
            ->spanBuilder('noop')->startSpan();
        $spanContext = $span->getContext();
        $span->end();

        // Obtenemos el span actual del contexto
        $currentSpan = \OpenTelemetry\API\Trace\Span::getCurrent();
        $ctx = $currentSpan->getContext();

        return $record->with(extra: array_merge($record->extra, [
            'trace_id' => $ctx->getTraceId(),
            'span_id'  => $ctx->getSpanId(),
            'service'  => env('OTEL_SERVICE_NAME', 'unknown'),
        ]));
    }
}

Los logs en formato JSON desde la salida estándar del contenedor son recogidos por el agente Promtail o Vector y enviados a Loki con las etiquetas service, level y env.

Configuración del OpenTelemetry Collector

El Collector es el elemento central del pipeline. Configuramos la recepción OTLP, el procesamiento y la exportación:

# otel-collector-config.yaml
receivers:
  otlp:
    protocols:
      grpc:
        endpoint: 0.0.0.0:4317
      http:
        endpoint: 0.0.0.0:4318

processors:
  batch:
    timeout: 1s
    send_batch_size: 1024
  resource:
    attributes:
      - key: deployment.environment
        value: production
        action: upsert
  memory_limiter:
    limit_mib: 512
    spike_limit_mib: 128
    check_interval: 5s

exporters:
  otlp/tempo:
    endpoint: tempo:4317
    tls:
      insecure: true
  loki:
    endpoint: http://loki:3100/loki/api/v1/push
    default_labels_enabled:
      exporter: false
      job: true
  prometheus:
    endpoint: 0.0.0.0:8889
    namespace: otelcol

service:
  pipelines:
    traces:
      receivers: [otlp]
      processors: [memory_limiter, batch, resource]
      exporters: [otlp/tempo]
    metrics:
      receivers: [otlp]
      processors: [memory_limiter, batch]
      exporters: [prometheus]
    logs:
      receivers: [otlp]
      processors: [memory_limiter, batch]
      exporters: [loki]

Configuración de Docker Compose para todo el stack en desarrollo local

# docker-compose.yml
version: "3.9"

services:
  app:
    build: ./app
    environment:
      OTEL_SERVICE_NAME: order-service
      OTEL_EXPORTER_OTLP_ENDPOINT: http://otel-collector:4318
      OTEL_EXPORTER_OTLP_PROTOCOL: http/protobuf
      OTEL_PROPAGATORS: tracecontext,baggage
    depends_on: [otel-collector]

  otel-collector:
    image: otel/opentelemetry-collector-contrib:0.96.0
    volumes:
      - ./otel-collector-config.yaml:/etc/otelcol-contrib/config.yaml
    ports:
      - "4317:4317"
      - "4318:4318"
      - "8889:8889"

  prometheus:
    image: prom/prometheus:v2.51.0
    volumes:
      - ./prometheus.yml:/etc/prometheus/prometheus.yml
      - prometheus_data:/prometheus
    ports:
      - "9090:9090"

  loki:
    image: grafana/loki:2.9.5
    volumes:
      - ./loki-config.yaml:/etc/loki/local-config.yaml
      - loki_data:/loki
    ports:
      - "3100:3100"

  tempo:
    image: grafana/tempo:2.4.1
    volumes:
      - ./tempo-config.yaml:/etc/tempo.yaml
      - tempo_data:/var/tempo
    ports:
      - "3200:3200"

  grafana:
    image: grafana/grafana:10.3.3
    environment:
      GF_SECURITY_ADMIN_PASSWORD: secret
    volumes:
      - ./grafana/provisioning:/etc/grafana/provisioning
      - grafana_data:/var/lib/grafana
    ports:
      - "3000:3000"
    depends_on: [prometheus, loki, tempo]

  promtail:
    image: grafana/promtail:2.9.5
    volumes:
      - /var/lib/docker/containers:/var/lib/docker/containers:ro
      - ./promtail-config.yaml:/etc/promtail/config.yml

volumes:
  prometheus_data:
  loki_data:
  tempo_data:
  grafana_data:

Despliegue del stack en Kubernetes con charts de Helm

Para el despliegue en producción en Kubernetes utilizamos los charts oficiales de Helm. Añadimos los repositorios e instalamos los componentes:

helm repo add prometheus-community https://prometheus-community.github.io/helm-charts
helm repo add grafana https://grafana.github.io/helm-charts
helm repo add open-telemetry https://open-telemetry.github.io/opentelemetry-helm-charts
helm repo update

# Prometheus + Alertmanager
helm upgrade --install prometheus prometheus-community/kube-prometheus-stack \
  --namespace monitoring --create-namespace \
  --values prometheus-values.yaml

# Loki (single binary para empezar)
helm upgrade --install loki grafana/loki \
  --namespace monitoring \
  --set loki.commonConfig.replication_factor=1 \
  --set loki.storage.type=filesystem

# Tempo
helm upgrade --install tempo grafana/tempo \
  --namespace monitoring

# OpenTelemetry Collector como DaemonSet
helm upgrade --install otel-collector open-telemetry/opentelemetry-collector \
  --namespace monitoring \
  --values otel-collector-values.yaml

Para la persistencia de datos de Prometheus y Loki, es obligatorio configurar un PersistentVolumeClaim:

# prometheus-values.yaml
prometheus:
  prometheusSpec:
    retention: 30d
    storageSpec:
      volumeClaimTemplate:
        spec:
          storageClassName: fast-ssd
          accessModes: [ReadWriteOnce]
          resources:
            requests:
              storage: 100Gi

Configuración de dashboards en Grafana: métricas RED y mapa de dependencias

Las métricas RED (Rate, Errors, Duration) son el estándar para monitorizar servicios. En Grafana creamos las variables $service y $route basadas en los valores de etiquetas de Prometheus y construimos los paneles:

  • Rate: sum(rate(app_http_requests_total{service="$service"}[5m])) by (route)
  • Error rate: sum(rate(app_http_requests_total{status_code=~"5.."}[5m])) / sum(rate(app_http_requests_total[5m]))
  • Duration P99: histogram_quantile(0.99, sum(rate(app_http_request_duration_seconds_bucket[5m])) by (le, route))

Para identificar solicitudes lentas usamos un panel de tipo Table ordenado por latencia P99. El mapa de dependencias entre servicios se construye con el plugin Grafana Service Graph basado en las métricas de spans de Tempo, que dibuja automáticamente el grafo de llamadas entre los microservicios PHP.

La característica clave: al hacer clic en un spike anómalo en el gráfico de métricas, Grafana ofrece la posibilidad de ver las trazas de Tempo para el mismo rango de tiempo. Desde la traza se puede acceder a los logs relacionados en Loki mediante el trace_id. Esto es la correlación de los tres pilares de la observabilidad en acción.

Alerting: Prometheus Alertmanager para servicios PHP

Conjunto típico de reglas de alerting para microservicios PHP:

# alerts/php-services.yaml
groups:
  - name: php-microservices
    interval: 30s
    rules:
      - alert: HighErrorRate
        expr: |
          sum(rate(app_http_requests_total{status_code=~"5.."}[5m]))
          /
          sum(rate(app_http_requests_total[5m])) > 0.05
        for: 2m
        labels:
          severity: critical
        annotations:
          summary: "Alta tasa de errores en {{ $labels.service }}"
          description: "La tasa de errores es {{ humanizePercentage $value }} en los últimos 5m"

      - alert: SlowResponseTime
        expr: |
          histogram_quantile(0.95,
            sum(rate(app_http_request_duration_seconds_bucket[5m])) by (le, service)
          ) > 2
        for: 5m
        labels:
          severity: warning
        annotations:
          summary: "Latencia P95 > 2s en {{ $labels.service }}"

      - alert: PHPFPMQueueFull
        expr: phpfpm_listen_queue > 10
        for: 1m
        labels:
          severity: critical
        annotations:
          summary: "La cola de PHP-FPM está saturada en {{ $labels.instance }}"

      - alert: HighMemoryUsage
        expr: |
          process_resident_memory_bytes{job="php-app"} > 512 * 1024 * 1024
        for: 5m
        labels:
          severity: warning
        annotations:
          summary: "Memoria del proceso PHP > 512MB"

Alertmanager enruta las alertas al canal de Slack del equipo y a PagerDuty para el nivel critical. La configuración de ventanas de silencio durante los despliegues planificados previene la fatiga por alertas.

Conclusión y consejos para escalar el stack

El stack formado por OpenTelemetry Collector, Prometheus, Loki, Tempo y Grafana cubre los tres pilares de la observabilidad para microservicios PHP sin ninguna dependencia en la nube. Estas son las recomendaciones clave para su evolución:

  • Muestreo de trazas: con alta carga, utiliza tail-based sampling en el OpenTelemetry Collector: conserva el 100% de las trazas con errores y entre el 1 y el 10% de las exitosas.
  • Escalado de Loki: cuando el volumen de logs supere los 10 GB/día, migra a Loki en modo distribuido con almacenamiento compatible con S3 (MinIO para self-hosted).
  • Escalado de Prometheus: utiliza Thanos o VictoriaMetrics para el almacenamiento a largo plazo y el escalado horizontal.
  • Seguridad: protege los endpoints del Collector, Prometheus y Loki mediante network policies en Kubernetes; usa mTLS para OTLP gRPC entre servicios.
  • Autodescubrimiento: configura el ServiceMonitor de Prometheus Operator para añadir automáticamente nuevos servicios PHP al scraping sin modificar la configuración.
  • Dashboards de SLO: utiliza el plugin Grafana SLO o pyrra para calcular automáticamente el error budget a partir de las métricas recopiladas.

Un stack de observabilidad self-hosted es una inversión en la madurez de la infraestructura. Un pipeline configurado correctamente una sola vez reduce drásticamente el MTTR (Mean Time To Recovery) y da al equipo la confianza necesaria para hacer despliegues en producción.

Tecnologías

Etiquetas

Ruslan Ismailov

Desarrollador Senior Web / Backend. Desarrollador senior web/backend con 9 años de experiencia. Stack: PHP, Laravel, PostgreSQL, Redis, Docker, Kubernetes, REST, microservicios, CI/CD. Más sobre mí →