Ovladavanje PHP opservabilnošću: strukturirani zapisi, tragovi i djelotvorna upozorenja
Incident u produkciji rijetko počinje korisnom porukom o pogrešci. Češće počinje nejasnim simptomom: latencija je porasla, korisnici su ponavljali zahtjeve, a nekoliko je servisa emitiralо naizgled nepovezane kvarove. Razlika između minuta i sati dijagnostike ovisi o tome opisuju li zapisnici, metrike, praćenja i upozorenja isti zahtjev istim jezikom.
Ovaj vodič gradi taj zajednički jezik za HTTP servis u PHP-u 8.3. Dovršeni sustav emitira JSON zapisnike s identifikatorima korelacije, izlaže Prometheus metrike ograničenog kardinaliteta, propagira W3C kontekst praćenja, izvozi OTLP praćenja i usmjerava upozorenje o djelotvornoj stopi pogrešaka kroz Alertmanager.
Arhitektura i kompromisi
Put zahtjeva ide od Nginxa do PHP-FPM-a. PHP zapisuje strukturirane zapisnike na standardni izlaz za pogreške, održava brojače dijeljene među procesima u datoteci stanja zaštićenoj zaključavanjem te šalje korijenske raspone OpenTelemetry Collector-u putem OTLP/HTTP-a. Prometheus prikuplja podatke s aplikacijske krajnje točke /metrics i procjenjuje pravila upozorenja. Alertmanager prosljeđuje obavijesti zasebnom lokalnom odredištu koje bilježi ono što primi.
- Zapisnici čuvaju detaljne događaje, ali ih je pri velikom volumenu skupo pretraživati.
- Metrike čine trendove i upozorenja jeftinima, pod uvjetom da oznake imaju ograničen kardinalitet.
- Praćenja povezuju operacije među servisima, ali sinkroni izvoz povećava latenciju.
- იდენტifikatori korelacije timovima za podršku daju stabilan ključ za pretraživanje čak i kada je praćenje uzorkovano.
Skladište metrika temeljeno na datoteci namjerno je malo i bez ovisnosti. Radi među PHP-FPM radnicima u jednom spremniku, za razliku od običnih PHP globalnih varijabli, ali zaključavanje pri svakom zahtjevu nije prikladno za ekstreman promet. Pri većoj propusnosti zamijenite ga ugrađenim OpenTelemetry ili Prometheus klijentom koji podržava PHP-ov višprocesni model ili emitirajte metrike lokalnom kolektoru.
Preduvjeti i raspored projekta
Potrebni su vam Docker Engine s Composeom v2 i curl na glavnom računalu. Objavljeni priključci su 8080 za aplikaciju, 9090 za Prometheus i 9093 za Alertmanager. Telemetrijski promet samo unutar spremnika koristi priključke 4318 i 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
Izgradite PHP runtime i servisnu mrežu
Slika instalira PHP-ovo cURL proširenje za ograničene OTLP zahtjeve. FPM konfiguracija prosljeđuje izlaz radnika u zapisnik spremnika. Stanje runtime metrika nalazi se pod /var/run/app, u vlasništvu neprivilegiranog FPM korisnika.
# 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;
}
}
Ova vremenska ograničenja su različita: vremensko ograničenje povezivanja ne ograničava čitanje odgovora. Nginxovo vremensko ograničenje čitanja ostaje ispod PHP-ovog desetosekundnog ograničenja prekida, čime se sprječava da napušteni zahtjevi neograničeno zauzimaju kapacitet uzvodnih servisa.
Implementirajte granicu promatranja
Sljedeća klasa provjerava dolazne identifikatore umjesto da vjeruje proizvoljnom sadržaju zaglavlja. Rute se dobavljaju iz fiksnog skupa, što sprječava da ID-jevi korisnika ili sirovi URL-ovi postanu neograničene oznake metrika. Ažuriranja stanja drže isključivo zaključavanje samo tijekom čitanja i ponovnog zapisivanja malog JSON dokumenta.
<?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";
}
}
Povežite obradu zahtjeva s telemetrijom
Aplikacija normalizira svaki zahtjev na jednu od četiri oznake rute. Pozivateljima vraća oba identifikatora, čime se tiketi za podršku mogu odmah pretraživati. Iznimke postaju sigurni odgovori klijentima, dok detaljan kontekst kvara ostaje u zapisnicima.
<?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);
}
Prikupljajte praćenja i stvorite djelotvorno upozorenje
Debug izvoznik Collectora ispisuje potpune raspone radi provjere. U produkciji ga zamijenite podržanim izvoznikom za pozadinski sustav praćenja. Prometheus upozorava samo kada promet postoji, izbjegavajući obmanjujući postotak izveden iz jednog izoliranog kvara.
# 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
Izolirano odredište dokazuje da Alertmanager doista isporučuje obavijesti. Izdvaja samo ograničena polja i zapisuje izvorno stanje upozorenja te operativne napomene na standardni izlaz za pogreške.
<?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);
Testirajte cjelokupni put signala
Izgradite i pokrenite skup iz direktorija projekta. Ove naredbe stvaraju samo spremnike i volumene ograničene na projekt; ne mijenjaju pravila vatrozida glavnog računala ni konfiguraciju sustava.
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
Uspješan zahtjev trebao bi vratiti podudarna zaglavlja X-Correlation-ID i traceparent. Zapisnici aplikacije trebali bi sadržavati isti ID korelacije, ID praćenja i ID raspona. Zapisnik Collectora trebao bi prikazati poslužiteljski raspon s tim ID-jem praćenja.
Za pokretanje upozorenja pošaljite dovoljno prometa da petominutna stopa bude značajna, zatim pričekajte da prođu jednominutno razdoblje na čekanju i interval procjene:
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
Odredište bi na kraju trebalo zabilježiti CatalogApiHighErrorRate sa sažetkom i radnjom odgovora. Prometheus na http://127.0.0.1:9090 prikazuje izraz, dok Alertmanager na http://127.0.0.1:9093 prikazuje stanje isporuke.
Sigurnost, performanse i implementacija
Držite Prometheus, Alertmanager i Collector ingestiju na privatnim mrežama. Compose povezivanja koriste loopback za administrativna sučelja; na udaljenom računalu pristupajte im putem autentificiranog tunela ili obrnutog proxyja. Ne izlažite OTLP ingestiju ni demonstracijsko odredište internetu. Vatrozid glavnog računala trebao bi dopustiti samo namjeravani javni TLS priključak aplikacije.
Zaglavlja korelacije nepouzdan su ulaz, stoga su provjera i ograničenja duljine ključni. Nikada ne pridružujte pristupne tokene, tijela zahtjeva, adrese e-pošte, ID-jeve računa, argumente iznimki ili sirove URL-ove oznakama metrika. Primijenite redakciju zapisnika prije nego što zapisi napuste proces i zaštitite pohranu telemetrije jednako ozbiljno kao podatke aplikacije.
Sinkroni izvoz praćenja ovdje je namjerno vidljiv, ali njegov proračun od 200 milisekundi ipak može utjecati na repnu latenciju kada je Collector nezdrav. Produkcijska implementacija trebala bi koristiti održavani OpenTelemetry PHP SDK s grupiranjem, ograničenim redovima, uzorkovanjem i lokalnim Collectorom. Kvar telemetrije mora degradirati promatranje, a ne dostupnost aplikacije.
Zamijenite lokalno odredište upozorenja neovisno hostiranom integracijom za pozivanje dežurnih. Različito usmjeravajte upozorenja i stranice, uključite testirani priručnik za postupanje i osigurajte da razriješene obavijesti stignu na isto odredište. Implementirajte pravila upozorenja prije rizičnih promjena aplikacije kako bi put nadzora već postojao kada bude potreban.
Česti kvarovi
- Metrike ostaju prazne: provjerite radi li PHP kao
www-datai može li zapisivati u/var/run/app. - Prometheus prijavljuje da je cilj nedostupan: provjerite interni cilj
web:80, a ne mapiranje glavnog računala127.0.0.1:8080. - Praćenja nestaju: provjerite
trace.export_failed, zdravlje Collectora i točnu krajnju točku/v1/traces. - Upozorenje se nikada ne aktivira: procijenite njegov izraz u Prometheusu, potvrdite da postoji dovoljno prometa i zapamtite da
for: 1mpočinje tek nakon što izraz prvi put postane istinit. - Metrike eksplodiraju po veličini: potražite sirove putanje, identifikatore, poruke o pogreškama ili druge neograničene vrijednosti oznaka.
Završni popis za provjeru
- Aplikacija vraća provjerena zaglavlja korelacije i praćenja.
- JSON zapisnici povezuju kvarove s ID-jevima korelacije i ID-jevima praćenja.
- Metrike se agregiraju među FPM radnicima i izlažu samo ograničene oznake.
- OTLP zahtjevi imaju odvojena vremenska ograničenja povezivanja i ukupnog trajanja.
- Collector prima raspone bez blokiranja uspjeha aplikacije.
- Prometheus procjenjuje upozorenje o stopi pogrešaka uvjetovano prometom.
- Alertmanager isporučuje aktivirane i razriješene obavijesti.
- Administrativni priključci ostaju privatni ili autentificirani.
Dobra promatranost nije količina telemetrije koju servis proizvodi. Ona je brzina kojom jedan signal vodi do sljedećeg: upozorenje identificira servis, metrika utvrđuje oblik kvara, praćenje otkriva sporu ili pokvarenu putanju, a ID korelacije pronalazi odlučujući zapisnik. Tu vezu gradite namjerno i kvarovi u produkciji postat će ograničene istrage umjesto arheoloških ekspedicija.