Tutorials

Mastering PHP Observability: Structured Logs, Traces, and Actionable Alerts

Mastering PHP Observability: Structured Logs, Traces, and Actionable Alerts

A production incident rarely begins with a useful error message. More often, it begins with a vague symptom: latency climbed, customers retried, and several services emitted unrelated-looking failures. The difference between minutes and hours of diagnosis is whether logs, metrics, traces, and alerts describe the same request in the same language.

This tutorial builds that shared language for a PHP 8.3 HTTP service. The finished system emits JSON logs with correlation identifiers, exposes bounded-cardinality Prometheus metrics, propagates W3C trace context, exports OTLP traces, and routes an actionable error-rate alert through Alertmanager.

Architecture and trade-offs

The request path is Nginx to PHP-FPM. PHP writes structured logs to standard error, maintains process-shared counters in a lock-protected state file, and sends root spans to an OpenTelemetry Collector over OTLP/HTTP. Prometheus scrapes the application’s /metrics endpoint and evaluates alert rules. Alertmanager forwards notifications to a separate local sink that logs what it receives.

  • Logs preserve detailed events but are expensive to search at high volume.
  • Metrics make trends and alerts cheap, provided labels have bounded cardinality.
  • Traces connect operations across services, but synchronous exporting adds latency.
  • Correlation IDs give support teams a stable lookup key even when tracing is sampled.

The file-backed metric store is deliberately small and dependency-free. It works across PHP-FPM workers in one container, unlike ordinary PHP globals, but locking every request is not suitable for extreme traffic. At higher throughput, replace it with an in-process OpenTelemetry or Prometheus client that supports PHP’s multiprocess model, or emit metrics to a local collector.

Prerequisites and project layout

You need Docker Engine with Compose v2 and curl on the host. The published ports are 8080 for the application, 9090 for Prometheus, and 9093 for Alertmanager. Container-only telemetry traffic uses ports 4318 and 8081.

php-observability/
├── Dockerfile
├── compose.yaml
├── php/
│   └── zz-observability.conf
├── public/
│   └── index.php
├── src/
│   └── Observability.php
├── alert/
│   └── index.php
├── nginx/
│   └── default.conf
├── otel/
│   └── collector.yaml
├── prometheus/
│   ├── prometheus.yaml
│   └── alerts.yaml
└── alertmanager/
    └── alertmanager.yaml

Build the PHP runtime and service network

The image installs PHP’s cURL extension for bounded OTLP requests. The FPM configuration forwards worker output to the container log. Runtime metric state lives under /var/run/app, owned by the unprivileged FPM user.

# Dockerfile
FROM php:8.3-fpm-alpine

RUN apk add --no-cache curl-dev \
    && docker-php-ext-install curl \
    && mkdir -p /var/run/app \
    && chown www-data:www-data /var/run/app

WORKDIR /app
COPY php/zz-observability.conf /usr/local/etc/php-fpm.d/zz-observability.conf
COPY public/ public/
COPY src/ src/
COPY alert/ alert/

CMD ["php-fpm", "-F"]
; php/zz-observability.conf
[www]
catch_workers_output = yes
decorate_workers_output = no
clear_env = no
request_terminate_timeout = 10s
# compose.yaml
services:
  app:
    build: .
    environment:
      OTEL_EXPORTER_OTLP_ENDPOINT: http://otel:4318/v1/traces
      SERVICE_NAME: catalog-api
    expose: ["9000"]
    depends_on: [otel]

  web:
    image: nginx:1.27-alpine
    ports: ["127.0.0.1:8080:80"]
    volumes:
      - ./nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
    depends_on: [app]

  otel:
    image: otel/opentelemetry-collector-contrib:0.104.0
    command: ["--config=/etc/otelcol/config.yaml"]
    volumes:
      - ./otel/collector.yaml:/etc/otelcol/config.yaml:ro

  prometheus:
    image: prom/prometheus:v2.53.0
    command: ["--config.file=/etc/prometheus/prometheus.yaml"]
    ports: ["127.0.0.1:9090:9090"]
    volumes:
      - ./prometheus:/etc/prometheus:ro
    depends_on: [web, alertmanager]

  alertmanager:
    image: prom/alertmanager:v0.27.0
    command: ["--config.file=/etc/alertmanager/alertmanager.yaml"]
    ports: ["127.0.0.1:9093:9093"]
    volumes:
      - ./alertmanager:/etc/alertmanager:ro
    depends_on: [alert-sink]

  alert-sink:
    build: .
    command: ["php", "-S", "0.0.0.0:8081", "-t", "/app/alert"]
    expose: ["8081"]
# nginx/default.conf
server {
    listen 80;
    server_name _;

    location / {
        include fastcgi_params;
        fastcgi_param SCRIPT_FILENAME /app/public/index.php;
        fastcgi_pass app:9000;
        fastcgi_connect_timeout 1s;
        fastcgi_read_timeout 9s;
        fastcgi_send_timeout 2s;
    }
}

These timeouts are distinct: a connection timeout does not bound response reads. Nginx’s read timeout remains below PHP’s ten-second termination budget, preventing abandoned requests from occupying upstream capacity indefinitely.

Implement the observability boundary

The following class validates incoming identifiers instead of trusting arbitrary header content. Routes are supplied from a fixed set, preventing customer IDs or raw URLs from becoming unbounded metric labels. State updates hold an exclusive lock only while reading and rewriting a tiny JSON document.

<?php
// src/Observability.php
declare(strict_types=1);

final class Observability
{
    private const METRICS_FILE = '/var/run/app/metrics.json';

    public readonly string $correlationId;
    public readonly string $traceId;
    public readonly string $spanId;

    private string $parentSpanId = '';
    private string $traceFlags = '01';
    private float $startedAt;
    private int $startedMono;
    private bool $sampled = true;

    public function __construct(
        private readonly string $method,
        private readonly string $route
    ) {
        $this->startedAt = microtime(true);
        $this->startedMono = hrtime(true);

        $candidate = $_SERVER['HTTP_X_CORRELATION_ID'] ?? '';
        $this->correlationId = preg_match('/^[A-Za-z0-9_-]{8,64}$/D', $candidate)
            ? $candidate
            : bin2hex(random_bytes(16));

        $incoming = $_SERVER['HTTP_TRACEPARENT'] ?? '';
        if (preg_match(
            '/^00-([0-9a-f]{32})-([0-9a-f]{16})-([0-9a-f]{2})$/D',
            $incoming,
            $match
        ) && $match[1] !== str_repeat('0', 32)
          && $match[2] !== str_repeat('0', 16)) {
            $this->traceId = $match[1];
            $this->parentSpanId = $match[2];
            $this->traceFlags = $match[3];
            $this->sampled = (hexdec($match[3]) & 1) === 1;
        } else {
            $this->traceId = bin2hex(random_bytes(16));
        }

        $this->spanId = bin2hex(random_bytes(8));
    }

    public function log(string $level, string $message, array $context = []): void
    {
        $record = [
            'timestamp' => gmdate('c'),
            'level' => $level,
            'service' => getenv('SERVICE_NAME') ?: 'catalog-api',
            'message' => $message,
            'correlation_id' => $this->correlationId,
            'trace_id' => $this->traceId,
            'span_id' => $this->spanId,
            'context' => $context,
        ];

        file_put_contents(
            'php://stderr',
            json_encode($record, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES) . "\n"
        );
    }

    public function finish(int $status): void
    {
        $seconds = (hrtime(true) - $this->startedMono) / 1_000_000_000;
        $this->recordMetric($status, $seconds);
        $this->log('info', 'request.completed', [
            'method' => $this->method,
            'route' => $this->route,
            'status' => $status,
            'duration_ms' => round($seconds * 1000, 2),
        ]);

        if ($this->sampled) {
            $this->exportTrace($status);
        }
    }

    private function recordMetric(int $status, float $seconds): void
    {
        $handle = fopen(self::METRICS_FILE, 'c+');
        if ($handle === false || !flock($handle, LOCK_EX)) {
            $this->log('error', 'metrics.lock_failed');
            return;
        }

        $raw = stream_get_contents($handle);
        $state = $raw === '' ? [] : json_decode($raw, true, flags: JSON_THROW_ON_ERROR);
        $key = implode('|', [$this->method, $this->route, (string) $status]);
        $state[$key]['count'] = ($state[$key]['count'] ?? 0) + 1;
        $state[$key]['sum'] = ($state[$key]['sum'] ?? 0.0) + $seconds;

        rewind($handle);
        ftruncate($handle, 0);
        fwrite($handle, json_encode($state, JSON_THROW_ON_ERROR));
        fflush($handle);
        flock($handle, LOCK_UN);
        fclose($handle);
    }

    private function exportTrace(int $status): void
    {
        $attributes = [
            ['key' => 'http.request.method', 'value' => ['stringValue' => $this->method]],
            ['key' => 'http.route', 'value' => ['stringValue' => $this->route]],
            ['key' => 'http.response.status_code', 'value' => ['intValue' => (string) $status]],
            ['key' => 'app.correlation_id', 'value' => ['stringValue' => $this->correlationId]],
        ];

        $span = [
            'traceId' => $this->traceId,
            'spanId' => $this->spanId,
            'name' => $this->method . ' ' . $this->route,
            'kind' => 2,
            'startTimeUnixNano' => sprintf('%.0f', $this->startedAt * 1_000_000_000),
            'endTimeUnixNano' => sprintf('%.0f', microtime(true) * 1_000_000_000),
            'attributes' => $attributes,
            'status' => ['code' => $status >= 500 ? 2 : 1],
        ];
        if ($this->parentSpanId !== '') {
            $span['parentSpanId'] = $this->parentSpanId;
        }

        $payload = ['resourceSpans' => [[
            'resource' => ['attributes' => [[
                'key' => 'service.name',
                'value' => ['stringValue' => getenv('SERVICE_NAME') ?: 'catalog-api'],
            ]]],
            'scopeSpans' => [[
                'scope' => ['name' => 'catalog-api.manual'],
                'spans' => [$span],
            ]],
        ]]];

        $curl = curl_init(getenv('OTEL_EXPORTER_OTLP_ENDPOINT'));
        curl_setopt_array($curl, [
            CURLOPT_POST => true,
            CURLOPT_POSTFIELDS => json_encode($payload, JSON_THROW_ON_ERROR),
            CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_CONNECTTIMEOUT_MS => 50,
            CURLOPT_TIMEOUT_MS => 200,
        ]);
        curl_exec($curl);
        $code = curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
        $error = curl_error($curl);
        curl_close($curl);

        if ($code < 200 || $code >= 300) {
            $this->log('warning', 'trace.export_failed', [
                'http_status' => $code,
                'error' => $error,
            ]);
        }
    }

    public static function renderMetrics(): string
    {
        $handle = @fopen(self::METRICS_FILE, 'r');
        $state = [];
        if ($handle !== false && flock($handle, LOCK_SH)) {
            $raw = stream_get_contents($handle);
            $state = $raw === '' ? [] : json_decode($raw, true, flags: JSON_THROW_ON_ERROR);
            flock($handle, LOCK_UN);
            fclose($handle);
        }

        $lines = [
            '# HELP app_http_requests_total Completed HTTP requests.',
            '# TYPE app_http_requests_total counter',
            '# HELP app_http_request_duration_seconds Request duration.',
            '# TYPE app_http_request_duration_seconds summary',
        ];

        foreach ($state as $key => $value) {
            [$method, $route, $status] = explode('|', $key, 3);
            $labels = sprintf(
                'method="%s",route="%s",status="%s"',
                $method,
                $route,
                $status
            );
            $lines[] = "app_http_requests_total{{$labels}} {$value['count']}";
            $lines[] = "app_http_request_duration_seconds_sum{{$labels}} {$value['sum']}";
            $lines[] = "app_http_request_duration_seconds_count{{$labels}} {$value['count']}";
        }

        return implode("\n", $lines) . "\n";
    }
}

Connect request handling to the telemetry

The application normalizes every request to one of four route labels. It returns both identifiers to callers, making support tickets immediately searchable. Exceptions become safe client responses while detailed failure context remains in logs.

<?php
// public/index.php
declare(strict_types=1);

require __DIR__ . '/../src/Observability.php';

$path = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH);
$route = match ($path) {
    '/health' => '/health',
    '/metrics' => '/metrics',
    '/work' => '/work',
    default => '/not-found',
};

if ($route === '/metrics') {
    header('Content-Type: text/plain; version=0.0.4');
    echo Observability::renderMetrics();
    exit;
}

$obs = new Observability($_SERVER['REQUEST_METHOD'] ?? 'GET', $route);
header('X-Correlation-ID: ' . $obs->correlationId);
header('traceparent: 00-' . $obs->traceId . '-' . $obs->spanId . '-01');
header('Content-Type: application/json');

$status = 200;

try {
    if ($route === '/health') {
        echo json_encode(['status' => 'ok'], JSON_THROW_ON_ERROR);
    } elseif ($route === '/work') {
        usleep(25_000);
        if (($_GET['fail'] ?? '') === '1') {
            throw new RuntimeException('Synthetic dependency failure');
        }
        echo json_encode(['result' => 'completed'], JSON_THROW_ON_ERROR);
    } else {
        $status = 404;
        http_response_code($status);
        echo json_encode(['error' => 'not_found'], JSON_THROW_ON_ERROR);
    }
} catch (Throwable $error) {
    $status = 500;
    http_response_code($status);
    $obs->log('error', 'request.failed', [
        'exception' => $error::class,
        'error' => $error->getMessage(),
    ]);
    echo json_encode([
        'error' => 'internal_error',
        'correlation_id' => $obs->correlationId,
    ], JSON_THROW_ON_ERROR);
} finally {
    $obs->finish($status);
}

Collect traces and create an actionable alert

The Collector’s debug exporter prints complete spans for verification. Replace it in production with a supported trace backend exporter. Prometheus alerts only when traffic exists, avoiding a misleading percentage derived from one isolated failure.

# otel/collector.yaml
receivers:
  otlp:
    protocols:
      http:
        endpoint: 0.0.0.0:4318

exporters:
  debug:
    verbosity: detailed

service:
  pipelines:
    traces:
      receivers: [otlp]
      exporters: [debug]

# prometheus/prometheus.yaml
global:
  scrape_interval: 15s
  evaluation_interval: 15s

rule_files:
  - /etc/prometheus/alerts.yaml

alerting:
  alertmanagers:
    - static_configs:
        - targets: ["alertmanager:9093"]

scrape_configs:
  - job_name: catalog-api
    metrics_path: /metrics
    static_configs:
      - targets: ["web:80"]

# prometheus/alerts.yaml
groups:
  - name: catalog-api
    rules:
      - alert: CatalogApiHighErrorRate
        expr: |
          (
            sum(rate(app_http_requests_total{status=~"5.."}[5m]))
            /
            clamp_min(sum(rate(app_http_requests_total[5m])), 0.001)
          ) > 0.05
          and
          sum(rate(app_http_requests_total[5m])) > 0.01
        for: 1m
        labels:
          severity: page
          service: catalog-api
        annotations:
          summary: "catalog-api is returning more than 5% server errors"
          action: "Check recent deployments, dependency health, and traces for failing requests."

# alertmanager/alertmanager.yaml
route:
  receiver: local-observability-sink
  group_by: [alertname, service]
  group_wait: 10s
  group_interval: 5m
  repeat_interval: 4h

receivers:
  - name: local-observability-sink
    webhook_configs:
      - url: http://alert-sink:8081/
        send_resolved: true

The isolated sink proves that Alertmanager actually delivers notifications. It extracts only bounded fields and writes the original alert state plus operational annotations to standard error.

<?php
// alert/index.php
declare(strict_types=1);

$body = file_get_contents('php://input', false, null, 0, 1_048_576);
$payload = json_decode($body ?: '{}', true);

foreach (($payload['alerts'] ?? []) as $alert) {
    $record = [
        'timestamp' => gmdate('c'),
        'level' => 'warning',
        'service' => 'alert-sink',
        'message' => 'alert.notification',
        'status' => $alert['status'] ?? 'unknown',
        'alertname' => $alert['labels']['alertname'] ?? 'unknown',
        'target_service' => $alert['labels']['service'] ?? 'unknown',
        'severity' => $alert['labels']['severity'] ?? 'unknown',
        'summary' => $alert['annotations']['summary'] ?? '',
        'action' => $alert['annotations']['action'] ?? '',
    ];
    file_put_contents('php://stderr', json_encode($record, JSON_THROW_ON_ERROR) . "\n");
}

http_response_code(204);

Test the complete signal path

Build and start the stack from the project directory. These commands create only project-scoped containers and volumes; they do not alter host firewall rules or system configuration.

docker compose config
docker compose up --build -d

curl -i http://127.0.0.1:8080/health
curl -i -H 'X-Correlation-ID: support-case-8472' \
  http://127.0.0.1:8080/work
curl -i 'http://127.0.0.1:8080/work?fail=1'
curl -sS http://127.0.0.1:8080/metrics

docker compose logs app
docker compose logs otel

The successful request should return matching X-Correlation-ID and traceparent headers. Application logs should contain the same correlation ID, trace ID, and span ID. The Collector log should show a server span with that trace ID.

To exercise the alert, send enough traffic for the five-minute rate to be meaningful, then allow the one-minute pending period and evaluation interval to pass:

for request_number in $(seq 1 40); do
  if [ $((request_number % 4)) -eq 0 ]; then
    curl -sS -o /dev/null 'http://127.0.0.1:8080/work?fail=1'
  else
    curl -sS -o /dev/null http://127.0.0.1:8080/work
  fi
done

docker compose logs -f alert-sink

The sink should eventually log CatalogApiHighErrorRate with its summary and response action. Prometheus at http://127.0.0.1:9090 shows the expression, while Alertmanager at http://127.0.0.1:9093 shows delivery state.

Security, performance, and deployment

Keep Prometheus, Alertmanager, and Collector ingestion on private networks. The Compose bindings use loopback for administrative interfaces; on a remote host, access them through an authenticated tunnel or reverse proxy. Do not expose OTLP ingestion or the demonstration sink to the internet. A host firewall should allow only the application’s intended public TLS port.

Correlation headers are untrusted input, so validation and length limits are essential. Never attach access tokens, request bodies, email addresses, account IDs, exception arguments, or raw URLs to metric labels. Apply log redaction before records leave the process, and protect telemetry storage with the same seriousness as application data.

Synchronous trace export is intentionally visible here, but its 200-millisecond budget can still affect tail latency when the Collector is unhealthy. A production deployment should use a maintained OpenTelemetry PHP SDK with batching, bounded queues, sampling, and a local Collector. Telemetry failure must degrade observability, not application availability.

Replace the local alert sink with an independently hosted paging integration. Route warnings and pages differently, include a tested runbook, and ensure resolved notifications reach the same destination. Deploy alert rules before risky application changes so the monitoring path already exists when it is needed.

Common failures

  • Metrics stay empty: verify that PHP runs as www-data and can write /var/run/app.
  • Prometheus reports the target down: check the internal target web:80, not the host mapping 127.0.0.1:8080.
  • Traces disappear: inspect trace.export_failed, Collector health, and the exact /v1/traces endpoint.
  • An alert never fires: evaluate its expression in Prometheus, confirm enough traffic exists, and remember that for: 1m begins only after the expression first becomes true.
  • Metrics explode in size: look for raw paths, identifiers, error messages, or other unbounded label values.

Final verification checklist

  • The application returns validated correlation and trace headers.
  • JSON logs connect failures to correlation IDs and trace IDs.
  • Metrics aggregate across FPM workers and expose only bounded labels.
  • OTLP requests have separate connection and total timeouts.
  • The Collector receives spans without blocking application success.
  • Prometheus evaluates a traffic-gated error-rate alert.
  • Alertmanager delivers firing and resolved notifications.
  • Administrative ports remain private or authenticated.

Good observability is not the volume of telemetry a service produces. It is the speed with which one signal leads to the next: an alert identifies the service, a metric establishes the shape of the failure, a trace exposes the slow or broken path, and a correlation ID finds the decisive log. Build that chain deliberately, and production failures become bounded investigations instead of archaeological expeditions.

Blog author portrait

Mihajlo

I’m Mihajlo — a developer driven by curiosity, discipline, and the constant urge to create something meaningful. I share insights, tutorials, and free services to help others simplify their work and grow in the ever-evolving world of software and AI.