学完这篇,你能把一个只会 `error_log` 的 PHP 项目,逐步改造成「日志带请求 ID、跨服务能串成一条链路、最终接进 OpenTelemetry」的可观测状态。
第一步:先把 error_log 用对
很多人以为 `error_log` 就是「随便记点东西」。它其实有三件事要配:
; php.ini
log_errors = On
display_errors = Off ; 生产必须关,别把报错吐给用户
error_reporting = E_ALL
error_log = /var/log/php/error.log
然后是函数本身,第二个参数决定去向:`0` 走 SAPI 日志、`3` 写到指定文件、`4` 走 SAPI:
error_log('db connect failed', 3, '/var/log/php/app.log');
注意:`error_log` 写出来的行没有统一时间戳、没有级别、没有上下文,而且多个 PHP-FPM 进程同时追加同一个文件会交错。它能救急,不能当生产日志方案。
第二步:包一个最小的结构化日志函数
目标很简单:一行一条 JSON,按天分文件。
function log_line(string $level, string $msg, array $ctx = []): void
{
$line = json_encode([
'ts' => date('c'),
'level' => $level,
'msg' => $msg,
'ctx' => $ctx,
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
error_log($line . PHP_EOL, 3, __DIR__ . '/logs/app-' . date('Y-m-d') . '.log');
}
log_line('error', 'pay notify failed', ['order_id' => $oid, 'code' => $code]);
`JSON_UNESCAPED_UNICODE` 别省,否则中文日志全是 `\uXXXX`,查起来想砸键盘。
注意:日志目录要可写,并且配好 logrotate 或自己做定时清理。见过太多人日志把磁盘打满,然后整站 500。
第三步:加请求 ID,把一次请求串起来
同一个请求可能打十几条日志,得有个共同标识:
$rid = $_SERVER['HTTP_X_REQUEST_ID'] ?? bin2hex(random_bytes(16));
header('X-Request-Id: ' . $rid);
之后所有 `log_line` 的 `$ctx` 里都塞上 `'rid' => $rid`。用户报错时让他把页面上的 Request-Id 发给你,一条 grep 就能捞全。
第四步:升级成 W3C traceparent
如果你的系统不止 PHP,前面还有 Nginx、网关,后面还有 Java/Go 服务,就别自己发明格式了,用标准头 `traceparent`:
traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01
格式是 `版本-trace-id-span-id-flags`。收到就解析复用,收不到就自己生成:
$tp = $_SERVER['HTTP_TRACEPARENT'] ?? null;
$traceId = $tp ? explode('-', $tp)[1] : bin2hex(random_bytes(16));
$spanId = bin2hex(random_bytes(8));
注意:trace-id 必须是 16 字节(32 位十六进制),span-id 是 8 字节(16 位十六进制),全 0 属于非法值。格式写错,下游直接丢弃,链路就断在这。
第五步:装 OpenTelemetry
PHP 这边是「扩展 + Composer 包」两件套。扩展负责上下文传播,SDK 负责采样和导出:
pecl install opentelemetry
composer require open-telemetry/sdk open-telemetry/exporter-otlp
自动插桩按需装,比如 Curl、PDO:
composer require open-telemetry/opentelemetry-auto-curl
composer require open-telemetry/opentelemetry-auto-pdo
环境变量(写进 php-fpm 的配置或容器 env):
OTEL_PHP_AUTOLOAD_ENABLED=true
OTEL_SERVICE_NAME=my-php-app
OTEL_TRACES_EXPORTER=otlp
OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf
OTEL_EXPORTER_OTLP_ENDPOINT=http://127.0.0.1:4318
OTEL_TRACES_SAMPLER=parentbased_traceidratio
OTEL_TRACES_SAMPLER_ARG=0.1
注意:`OTEL_PHP_AUTOLOAD_ENABLED` 依赖 Composer 的 autoload 被加载。像 Clara BBS 这类无框架、不需要 Composer 的轻量 PHP 系统,最省事的做法是先只做第四步的 traceparent 透传 + 日志带 trace_id,等真需要看火焰图时再单独引入 Composer。
第六步:手动埋关键业务点
自动插桩只能覆盖框架和扩展的调用,业务语义得自己打:
$tracer = \OpenTelemetry\API\Globals::tracerProvider()->getTracer('app');
$span = $tracer->spanBuilder('post.create')->startSpan();
$span->setAttribute('user.id', $uid);
try {
// 业务逻辑
} finally {
$span->end();
}
第七步:让日志和 trace 互相能找到
在结构化日志里加上 `trace_id` 和 `span_id`,从当前 span 上下文取。这样在 Jaeger 里点开一条慢 trace,能直接跳到对应日志;反过来,从日志里的 trace_id 也能跳回完整调用链。
第八步:本地跑起来看效果
docker run -d --name jaeger \
-p 4317:4317 -p 4318:4318 -p 16686:16686 \
jaegertracing/all-in-one:latest
访问你的站点,然后打开 `http://localhost:16686`,按 `OTEL_SERVICE_NAME` 搜服务名即可。
注意:生产别 100% 采样,先 5%~10% 起步;导出器用批处理模式,别让上报阻塞请求;密码、Token、Cookie、身份证等内容一律不要写进 span 属性和日志。
小结
- `error_log` 只能兜底,生产要换成「JSON + 按天分文件」的结构化日志
- 一次请求一个 Request-Id,是所有排查的地基
- 跨服务用标准 `traceparent`,别自造协议
- OpenTelemetry = 扩展(传播)+ Composer SDK(采样导出),自动插桩管框架,业务埋点管语义
- 日志里写 `trace_id`,日志和链路才真正连成一张网
- 采样率、日志轮转、敏感信息脱敏,这三条上线前必须过一遍