Туториали

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

Совладување со набљудливоста во PHP: структурирани дневници, траги и функционални предупредувања

Инцидент во продукција ретко започнува со корисна порака за грешка. Почесто започнува со нејасен симптом: латентноста порасна, клиентите повторуваа обиди и неколку услуги испуштаа навидум неповрзани неуспеси. Разликата меѓу минути и часови дијагностика е во тоа дали логовите, метриките, трасите и предупредувањата го опишуваат истото барање на ист јазик.

Овој туторијал го гради тој заеднички јазик за PHP 8.3 HTTP услуга. Завршениот систем испушта JSON логови со идентификатори за корелација, изложува Prometheus метрики со ограничена кардиналност, пропагира W3C контекст на траса, извезува OTLP траси и насочува применливо предупредување за стапка на грешки преку Alertmanager.

Архитектура и компромиси

Патеката на барањето е од Nginx до PHP-FPM. PHP запишува структурирани логови во стандардниот излез за грешки, одржува бројачи споделени меѓу процесите во датотека со состојба заштитена со заклучување и испраќа коренски распони до OpenTelemetry Collector преку OTLP/HTTP. Prometheus ја прибира крајната точка /metrics на апликацијата и ги евалуира правилата за предупредувања. Alertmanager ги препраќа известувањата до посебен локален приемник што го евидентира она што го прима.

  • Логовите зачувуваат детални настани, но се скапи за пребарување при голем обем.
  • Метриките ги прават трендовите и предупредувањата евтини, под услов ознаките да имаат ограничена кардиналност.
  • Трасите ги поврзуваат операциите меѓу услугите, но синхроното извезување додава латентност.
  • Идентификаторите за корелација им даваат на тимовите за поддршка стабилен клуч за пребарување дури и кога трасирањето е примерокувано.

Складиштето за метрики засновано на датотека е намерно мало и без зависности. Работи меѓу PHP-FPM работници во еден контејнер, за разлика од обичните PHP глобални променливи, но заклучувањето на секое барање не е соодветно за екстремен сообраќај. При поголем проток, заменете го со OpenTelemetry или Prometheus клиент во процесот што го поддржува мултипроцесниот модел на PHP, или испуштајте метрики до локален колектор.

Предуслови и распоред на проектот

Потребни ви се Docker Engine со Compose v2 и curl на домаќинот. Објавените порти се 8080 за апликацијата, 9090 за Prometheus и 9093 за Alertmanager. Телеметрискиот сообраќај само меѓу контејнери ги користи портите 4318 и 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

Изградете ја PHP околината и мрежата на услуги

Сликата ја инсталира cURL екстензијата на PHP за ограничени OTLP барања. FPM конфигурацијата го препраќа излезот од работниците до логот на контејнерот. Состојбата на метриките во извршување се наоѓа под /var/run/app, во сопственост на непривилегираниот FPM корисник.

# 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;
    }
}

Овие временски ограничувања се различни: ограничувањето за поврзување не го ограничува читањето на одговорот. Временското ограничување за читање на Nginx останува под буџетот за прекинување од десет секунди на PHP, спречувајќи напуштените барања да зафаќаат upstream капацитет неодредено време.

Имплементирајте ја границата за набљудливост

Следната класа ги валидира влезните идентификатори наместо да верува на произволна содржина во заглавјата. Рутите се обезбедуваат од фиксен сет, спречувајќи идентификаторите на клиенти или суровите URL-адреси да станат неограничени ознаки за метрики. Ажурирањата на состојбата држат ексклузивно заклучување само додека читаат и повторно запишуваат мал JSON документ.

<?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";
    }
}

Поврзете го ракувањето со барањата со телеметријата

Апликацијата го нормализира секое барање на една од четири ознаки за рута. Таа ги враќа двата идентификатори на повикувачите, правејќи ги билетите за поддршка веднаш пребарливи. Исклучоците стануваат безбедни одговори за клиентите, додека деталниот контекст за неуспехот останува во логовите.

<?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);
}

Собирајте траси и создадете применливо предупредување

Debug извозникот на Collector печати целосни распони за проверка. Во продукција заменете го со поддржан извозник за backend на траси. Prometheus предупредува само кога постои сообраќај, избегнувајќи погрешен процент изведен од еден изолиран неуспех.

# 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 враќа повеќе од 5% серверски грешки"
          action: "Проверете ги неодамнешните распоредувања, здравјето на зависностите и трасите за неуспешни барања."

# 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

Изолираниот приемник докажува дека Alertmanager навистина доставува известувања. Тој извлекува само ограничени полиња и ја запишува оригиналната состојба на предупредувањето, заедно со оперативните белешки, во стандардниот излез за грешки.

<?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);

Тестирајте ја целосната патека на сигналот

Изградете го и стартувајте го стекот од директориумот на проектот. Овие команди создаваат само контејнери и волумени ограничени на проектот; не ги менуваат правилата на firewall-от на домаќинот ниту системската конфигурација.

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

Успешното барање треба да врати соодветни заглавја X-Correlation-ID и traceparent. Логовите на апликацијата треба да ги содржат истиот идентификатор за корелација, идентификатор на траса и идентификатор на распон. Логот на Collector треба да прикаже серверски распон со тој идентификатор на траса.

За да го тестирате предупредувањето, испратете доволно сообраќај за петминутната стапка да биде значајна, потоа дозволете да поминат едноминутниот период на чекање и интервалот на евалуација:

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

Приемникот на крајот треба да го евидентира CatalogApiHighErrorRate со неговото резиме и акцијата за одговор. Prometheus на http://127.0.0.1:9090 го прикажува изразот, додека Alertmanager на http://127.0.0.1:9093 ја прикажува состојбата на доставување.

Безбедност, перформанси и распоредување

Чувајте ги Prometheus, Alertmanager и внесувањето во Collector на приватни мрежи. Compose поврзувањата користат loopback за административните интерфејси; на оддалечен домаќин, пристапувајте им преку автентициран тунел или reverse proxy. Не изложувајте OTLP внесување или демонстрацискиот приемник на интернет. Firewall-от на домаќинот треба да дозволува само наменетата јавна TLS порта на апликацијата.

Заглавјата за корелација се недоверлив влез, затоа валидацијата и ограничувањата на должината се суштински. Никогаш не прикачувајте токени за пристап, тела на барања, е-поштенски адреси, идентификатори на сметки, аргументи на исклучоци или сурови URL-адреси на ознаките за метрики. Применете редакција на логовите пред записите да го напуштат процесот и заштитете го складирањето на телеметрија со истата сериозност како и податоците на апликацијата.

Синхроното извезување на траси е намерно видливо тука, но неговиот буџет од 200 милисекунди сè уште може да влијае на латентноста на опашката кога Collector не е здрав. Продукциското распоредување треба да користи одржуван OpenTelemetry PHP SDK со групирање, ограничени редици, примерокување и локален Collector. Неуспехот на телеметријата мора да ја деградира набљудливоста, а не достапноста на апликацијата.

Заменете го локалниот приемник за предупредувања со независно хостирана интеграција за повикување. Насочувајте ги предупредувањата и повиците различно, вклучете тестиран runbook и осигурете се дека известувањата за разрешување стигнуваат до истото одредиште. Распоредете ги правилата за предупредувања пред ризични промени на апликацијата, така што патеката за мониторинг веќе постои кога е потребна.

Чести неуспеси

  • Метриките остануваат празни: проверете дали PHP работи како www-data и може да запишува во /var/run/app.
  • Prometheus известува дека целта е недостапна: проверете ја внатрешната цел web:80, а не мапирањето на домаќинот 127.0.0.1:8080.
  • Трасите исчезнуваат: проверете ги trace.export_failed, здравјето на Collector и точната крајна точка /v1/traces.
  • Предупредување никогаш не се активира: евалуирајте го неговиот израз во Prometheus, потврдете дека има доволно сообраќај и запомнете дека for: 1m започнува дури откако изразот првпат ќе стане точен.
  • Метриките експлодираат по големина: побарајте сурови патеки, идентификатори, пораки за грешки или други неограничени вредности на ознаки.

Конечна контролна листа за проверка

  • Апликацијата враќа валидирани заглавја за корелација и траса.
  • JSON логовите ги поврзуваат неуспесите со идентификаторите за корелација и траса.
  • Метриките се агрегираат меѓу FPM работниците и изложуваат само ограничени ознаки.
  • OTLP барањата имаат одделни временски ограничувања за поврзување и за вкупно траење.
  • Collector прима распони без да го блокира успешното извршување на апликацијата.
  • Prometheus евалуира предупредување за стапка на грешки ограничено со сообраќај.
  • Alertmanager доставува известувања за активирање и разрешување.
  • Административните порти остануваат приватни или автентицирани.

Добрата набљудливост не е обемот на телеметрија што го создава една услуга. Таа е брзината со која еден сигнал води до следниот: предупредувањето ја идентификува услугата, метриката го утврдува обликот на неуспехот, трасата ја открива бавната или расипаната патека, а идентификаторот за корелација го наоѓа пресудниот лог. Изградете го тој синџир намерно, и продукциските неуспеси ќе станат ограничени истраги наместо археолошки експедиции.

Портрет на автор на блогот

Mihajlo

Јас сум Михајло - развивач поттикнат од љубопитност, дисциплина и постојаната желба да создадам нешто значајно. Споделувам увиди, упатства и бесплатни услуги за да им помогнам на другите да ја поедностават својата работа и да растат во постојано развивачкиот свет на софтверот и вештачката интелигенција.