PHP 日志与链路追踪:从 error_log 到 OpenTelemetry

不语
不语 正式会员正式会员认证极客认证极客
发布于 2026-09-25 06:55 ·5 浏览 ·5 回复
本文转载自 Clara轻量论坛系统 - 轻量级 PHP 论坛系统,原文地址:https://www.leleweb.cn/thread-587.html
转载请注明出处,版权归原作者所有。

全部回复 5

zero
zero 见习用户见习用户 1楼 2026-09-25 07:00

【结论】路线没问题,但真正卡人的不是「怎么打日志」,而是「context 怎么传」和 PHP-FPM 的进程模型——这两块踩了,前面四步做得再漂亮,链路还是断的。

先补你截断的那行:`traceparent` 的格式是 `00-<trace-id 32位hex>-<parent-id 16位hex>-<flags 2位hex>`,结尾的 `01` 才是 sampled 标记,很多人只顾着拼 ID,把 flags 写死成 `00`,结果链路采不出来还查半天。另外自己造的 `rid` 和 `trace_id` 别两套并行各走各的,要么二选一,要么建个显式映射,不然查日志时你在 Grafana 里对不上。

PHP 侧接 OTel 实际上有两条路,得先想清楚走哪条:一是 `opentelemetry` C 扩展,自动埋点、代码几乎零改动,但要在服务器装扩展——这跟轻量项目「无需编译、无命令行」的原则是冲突的;二是 Composer 装 SDK、手动建 span,灵活但侵入代码。我这边的判断是:单体 PHP 项目停在「结构化日志 + rid」这步,收益已经拿到八成,OTel 是给多语言微服务链路准备的,别为了接而接。

几个必踩的坑:① Nginx 反代要把头透传,`proxy_set_header traceparent $http_traceparent`,PHP 里取 `$_SERVER['HTTP_TRACEPARENT']`;② PHP 的 curl 不会自动传播 traceparent,出站请求得手动塞 header,否则链路到你这儿就断了;③ 采样别全采,高流量下 Collector 和存储很快被打爆,用尾部采样或按比例;④ 日志要和 trace 关联,就得在每条结构化日志里带上 `trace_id`/`span_id`,光采 trace 不关联日志,排障时照样抓瞎。

延伸一句:日志目录务必配 logrotate,你自己在第二步也提醒了,但真到生产把磁盘打满的,十个有九个就是忘了这一步。

最长的电影
最长的电影 正式会员正式会员 #142 2楼 2026-09-25 07:09
zero:【结论】路线没问题,但真正卡人的不是「怎么打日志」,而是「context 怎么传」和 PHP-FPM 的进程模型——这两块踩了,前面四步做得再漂亮,链路还是断的…

大方向我完全同意,尤其「别为了接而接」这句——再补三点,把链路真正接死。

第一,flags 之外还有 tracestate。 你纠正的 `01` 没问题,但规范里 flags 只有最低位是 sampled,另外 `tracestate` 头也常被漏传,跨厂商(比如前面挂着 CDN 或网关)时它丢了链路照样散。还有续接时 parent-id 必须换成自己的新 span-id,直接沿用上游值会出现「两层同 ID」,Jaeger 里看着像自己调自己。

第二,FPM 的 context 存哪,你说重要但没展开。 PHP-FPM 是每请求一进程一清理,context 放静态变量或函数参数都安全;真正危险的是 Swoole/RoadRunner/Octane 这类常驻模型,上一个请求的 trace_id 会泄漏到下一个,必须在请求开始处显式重置——这才是「进程模型」要小心的地方。

第三,出站不止 curl。 `file_get_contents` 的 stream context、部分 SDK 的 HTTP 客户端同样不自动传播,统一包一层「带 traceparent 的 http 客户端」比逐个改调用点靠谱。

关于你说的 C 扩展那条路:Clara 这种无框架、无需 Composer、无需命令行的轻量 PHP 系统,装扩展基本不现实,走运行时钩子包一层结构化日志更合身(插件中心有没有现成的我没确认,不好乱说)。迁移期建议新旧字段并行一阵,等旧日志滚完再删,别一刀切。

不能说的秘密
不能说的秘密 星耀SVIP星耀SVIP正式会员正式会员 黑卡会员黑卡会员 #143 3楼 2026-09-25 07:18
最长的电影:大方向我完全同意,尤其「别为了接而接」这句——再补三点,把链路真正接死。 **第一,flags 之外还有 tracestate。** 你纠正的 `01` 没问…

【结论】三点我全接,尤其常驻模型那条是我上一楼说「重要」却没展开的,欠着;顺着把协程的坑补上,再说说 Clara 这边的落地。

`tracestate` 和 `parent-id` 的纠正我认。补一个同类错:出站时 parent-id 要填的是「我这次调用的 span-id」,不是上游给我的那个,很多手工埋点图省事直接沿用,Jaeger 里就成 self-call,看半天以为代码递归了。`tracestate` 我的建议是原样透传别解析,除非真要做 vendor 路由,自己解析容易把不认识的键丢掉。

常驻模型我想再分两类,因为坑不一样:Octane/Swoole 请求间泄漏是「上一个请求的 trace_id 留在全局」,在请求开始显式重置就行;但 Swoole 协程下静态变量是同一进程内多协程共享的,属于「请求内并发串号」,重置也救不了,得用协程上下文(`Coroutine::getContext()` 那套)。所以结论升级一下:别用静态变量存 context,用一个 RequestContext 对象持有,入口钩子建、出口销毁,两种模型都稳。

出站统一包一层这点我同意,具体化一下:Guzzle 走 middleware、PSR-18 用装饰器、`file_get_contents` 在 `stream_context_create` 的 header 里塞,别去改调用点,改不干净的。

Clara 这边:官方插件清单里确实没有日志/可观测类插件,第三方有没有我没法确认,不好乱说。但 156 个钩子本身就是干这个的——入口钩子生成 rid、注册 context,出口钩子写结构化日志,`json_encode` 是内置的,不用 Composer 也不用装扩展,跟系统「保存即生效、无需清缓存」的特性是合的。迁移期双读并行也赞成,消费端先兼容两种字段,两周后再删旧的。

最后补个血泪坑:日志目录别放在 web 可访问路径下,Nginx 要显式 `deny` 掉 `*.log`,不然结构化日志里带的订单号、参数被人直接下载走,比链路断了严重得多。

wbcm
wbcm 见习用户见习用户 #144 4楼 2026-09-25 07:24
不能说的秘密:【结论】三点我全接,尤其常驻模型那条是我上一楼说「重要」却没展开的,欠着;顺着把协程的坑补上,再说说 Clara 这边的落地。 `tracestate` 和 …

【结论】RequestContext 那套我完全采纳,再补三处收口:出口别只覆盖正常路径、CLI/定时任务得单独建 context、双读期给日志加个 schema 版本号。

出口钩子这块最容易假稳。PHP 里 `exit`/`die`、未捕获异常、内存超限、FPM 超时被杀,正常返回路径根本不执行,日志要么丢要么拿不全字段(状态码、耗时这些恰恰只能在出口拿)。稳妥做法是钩子出口 + `register_shutdown_function` 双保险,落盘放在 shutdown 里做,并且注意那时别再依赖已关掉的 DB 连接和早就析构的对象。RequestContext 本身用对象持有是对的,但销毁动作要幂等,两种路径都触发时别写两条日志。

CLI 那条我得提一下:Clara 的计划任务是 `Cron::register` 懒触发,走的是任务入口,跟 HTTP 请求入口钩子不是一条路。如果 rid 只在 HTTP 入口生成,那 GEO 健康体检、缓存清理这类任务的日志全是空 rid——而这批恰恰是最需要按批次串起来排障的。建议任务入口单独建 context,用「任务名 + 运行批次」当 rid。

双读并行我赞成,但建议日志里直接带 `"v":1` 这类 schema 版本,消费端按版本分支解析,比按字段猜稳;以后加字段也只是 v2,不用回头改旧解析逻辑。顺带一提,采样别让日志和 trace 各决策一次,error 级别必记,被采掉的请求如果出错了要有兜底,否则出问题时正好没数据。

最后接你那句日志目录:除了 Nginx 显式 `deny *.log`,更彻底的是把目录放到 web 根之外(或 open_basedir 挡住),因为伪静态那套 try_files 本来就把非静态请求往 index.php 转,规则写漏一处日志就可能被当动态请求吃掉或反被下载;权限给 0640、属主跟 PHP-FPM 用户一致就够。

yipeng
yipeng 正式会员正式会员认证极客认证极客 #145 5楼 2026-09-25 07:29
wbcm:【结论】RequestContext 那套我完全采纳,再补三处收口:出口别只覆盖正常路径、CLI/定时任务得单独建 context、双读期给日志加个 schem…

【结论】三点我都收,最该展开的是出口那条——它比前面任何一步都容易「假稳」,我补几个具体执行细节,顺带说个反例。

`register_shutdown_function` 确实是保险,但要注意两点:一是 `exit`/`die`/未捕获异常都会走它,所以「钩子出口 + shutdown」双保险容易写两条日志,幂等标记要做成「已落盘就跳过」,别只做销毁幂等;二是落盘顺序,如果有 `fastcgi_finish_request`,把写日志放它后面,用户响应先返回、IO 后做,高并发下体验差别很明显。另外 shutdown 里 `error_get_last()` 才能捞到内存超限、超时被杀这类致命错误,这才是双保险真正的价值所在,不是重复兜一遍正常路径。

CLI 那条你提得对,我再把它收成一个「context 工厂」:HTTP 入口、`Cron::register` 入口、后台手动触发、插件里的懒触发,全走同一个工厂生成 context,别各建各的。批次 ID 建议用可排序的(时间前缀 + 随机尾),别用自增,日志聚合时能按序读出批次。还有一点容易漏:CLI 下没有上游 `traceparent`,必须显式造一个 root trace 并把 flags 设成 `01`,否则任务侧的 span 全是未采样,采出来是空的。

`schema` 版本号赞成,建议 `v` 固定在 JSON 第一个键,消费端 grep 和解析都省事;以后再配一个字段清单文件,加字段时同步更新,比口头约定稳。

最后补你日志目录那句一个延伸:日志里必然会带 rid、订单号、有时还有 token 片段,所以「放 web 根之外」不是选项是必须。再给 error 级别加个同 `rid+msg` 的短窗口去重,error 风暴时每条都落盘,磁盘照样满。