分布式追踪与 APM

OxPHP 支持 W3C Trace Context 传播、OpenTelemetry(OTel)导出,以及内置的应用性能监控(APM)。传入的 traceparent 头会被解析并延续,trace ID 可在 PHP 中通过 $_SERVER 获取,访问日志包含追踪字段,span 可导出到 Jaeger、Grafana Tempo、Zipkin 或任何兼容 OTLP 的后端。

APM 插件在 OTel 基础之上增加了三层追踪能力:

  • 自动埋点 —— 在引擎层挂钩 PHP 内部函数(PDO、mysqli、cURL、Redis、Memcached、文件 I/O);每次调用都会生成一个 span,无需改动任何代码
  • 基于注解的追踪 —— 用 #[OxPHP\Apm\Trace] 注解任意 PHP 函数或方法即可自动创建 span
  • PHP SDK —— 提供 10 个 oxphp_apm_*() 函数,用于手动创建 span、设置属性、记录事件与错误

工作原理

  1. 传入请求 —— OxPHP 依据 W3C Trace Context 规范读取 traceparenttracestate
  2. 新 span —— 为本跳生成一个新的 span ID。传入的 span ID 成为其父 span
  3. 传播到 PHP —— trace ID 被注入到 $_SERVER['OXPHP_TRACE_ID']$_SERVER['OXPHP_SPAN_ID']$_SERVER['OXPHP_PARENT_SPAN_ID']
  4. 访问日志 —— 结构化 JSON 日志包含 trace_idspan_id 字段,用于日志关联
  5. 响应头 —— 更新后的 traceparent 头(带有 OxPHP 的 span ID)会被加入响应,以便下游服务延续该追踪
  6. OTel 导出(可选)—— 当启用 OTel 插件时,每个请求都会成为一个 span,通过 OTLP 携带 HTTP 语义约定属性导出

如果不存在 traceparent 头,OxPHP 会生成新的 trace ID 和 span ID,并开启一条全新的追踪。

配置

W3C Trace Context(内置)

变量 默认值 说明
TRACE_CONTEXT false 启用 W3C Trace Context 传播。设为 true1

OpenTelemetry 插件

OTel 插件是一个编译期特性(plugin-otel)。启用后,它会自动开启 trace-context 传播(效果等同于设置 TRACE_CONTEXT=true)。

变量 默认值 说明
OTEL_ENABLED false 启用 OpenTelemetry 插件。布尔值 —— 参见 布尔值
OTEL_EXPORTER_OTLP_PROTOCOL grpc 导出协议:grpchttp/protobuf
OTEL_EXPORTER_OTLP_ENDPOINT http://localhost:4317(gRPC)或 http://localhost:4318(HTTP) OTLP collector 端点。https:// URL 会在两种传输方式上通过 TLS 导出,并针对系统信任库进行校验(运行时镜像必须自带 CA 证书包,例如 ca-certificates —— 官方镜像已安装它);尚不支持自定义 CA 证书包和 mTLS
OTEL_EXPORTER_OTLP_TIMEOUT 10000 导出超时,单位毫秒
OTEL_EXPORTER_OTLP_HEADERS (未设置) 认证头:key=value,key2=value2
OTEL_SERVICE_NAME oxphp 导出 span 中的服务名
OTEL_SERVICE_VERSION (未设置) 服务版本属性
OTEL_RESOURCE_ATTRIBUTES (未设置) 额外的资源属性:env=prod,region=us-east-1
OTEL_TRACES_SAMPLER parentbased_traceidratio 采样策略:always_onalways_offtraceidratioparentbased_always_onparentbased_always_offparentbased_traceidratio
OTEL_TRACES_SAMPLER_ARG 1.0 基于比例的采样器的采样比率(0.0–1.0)
Note

超出范围或无效的 OTEL_TRACES_SAMPLER_ARG 值会被钳制到 [0.0, 1.0],并以 warn 级别记录日志。无法识别的 OTEL_TRACES_SAMPLER 值会回退到 parentbased_traceidratio 并记录日志。

APM 插件

APM 插件是一个编译期特性(plugin-apm),依赖 OTel 插件。它增加了自动埋点、#[OxPHP\Apm\Trace] 装饰器以及 PHP 追踪 SDK。

变量 默认值 说明
OTEL_APM_ENABLED false 启用 APM:自动埋点、错误捕获、PHP SDK。需要 OTEL_ENABLED=true。布尔值 —— 参见 布尔值
OTEL_APM_SLOW_QUERY_MS 100 慢查询阈值,单位毫秒。超过此值的查询会在其 span 上被标记 oxphp.db.slow=true
OTEL_APM_DB_CAPTURE_PARAMS_ENABLED false db.params span 属性中记录绑定参数。布尔值 —— 参见 布尔值
OTEL_APM_STACKTRACE_MAX_BYTES 8192 exception.stacktrace 属性的最大字节数。超过上限时会从尾部截断调用栈(保留根帧),并加上 …(truncated) 标记。0 表示禁用截断
OTEL_APM_MESSAGE_MAX_BYTES 4096 exception.message 属性的最大字节数(默认值与 New Relic 的单属性值长度上限一致)。超过上限时会从尾部截断消息,并加上 …(truncated) 标记。0 表示禁用截断

PHP 中的 trace context

TRACE_CONTEXT=true 时,你的 PHP 脚本中可以使用三个 $_SERVER 变量:

变量 说明 示例
OXPHP_TRACE_ID W3C trace ID(32 个十六进制字符) 4bf92f3577b34da6a3ce929d0e0e4736
OXPHP_SPAN_ID 本次请求的 OxPHP span ID(16 个十六进制字符) 00f067aa0ba902b7
OXPHP_PARENT_SPAN_ID 传入的父 span ID(16 个十六进制字符,若为新追踪则为空) a3ce929d0e0e4736

利用它们把 trace context 传播到下游服务:

php
<?php $traceId = $_SERVER['OXPHP_TRACE_ID'] ?? ''; $spanId = $_SERVER['OXPHP_SPAN_ID'] ?? ''; if ($traceId) { // Build a traceparent header for downstream calls $traceparent = "00-{$traceId}-{$spanId}-01"; $response = file_get_contents('https://api.example.com/data', false, stream_context_create([ 'http' => [ 'header' => "traceparent: {$traceparent}\r\n", ], ]) ); }

配合 Guzzle

php
<?php $traceId = $_SERVER['OXPHP_TRACE_ID'] ?? ''; $spanId = $_SERVER['OXPHP_SPAN_ID'] ?? ''; $client = new \GuzzleHttp\Client(); $response = $client->get('https://api.example.com/users', [ 'headers' => [ 'traceparent' => "00-{$traceId}-{$spanId}-01", ], ]);

访问日志关联

启用 trace context 后,结构化 JSON 访问日志会包含 trace_idspan_id 字段:

json
{ "timestamp": "2026-03-23T10:15:30.123Z", "level": "INFO", "fields": { "request_id": "4bf92f3577b34da6-00f067aa", "trace_id": "4bf92f3577b34da6a3ce929d0e0e4736", "span_id": "00f067aa0ba902b7", "method": "GET", "path": "/api/users", "status": 200, "duration_us": 1523, "remote_ip": "10.0.0.1", "message": "request completed" } }

随后你就可以在日志聚合系统(Loki、Elasticsearch、Splunk、CloudWatch)中按 trace ID 搜索日志,找到某条分布式追踪对应的每一条日志记录。

响应头

OxPHP 会为每个响应加上 traceparent 头,携带 OxPHP 自己的 span ID:

http
traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01

如果传入请求包含 tracestate 头,它也会一并转发到响应中。

OpenTelemetry 集成

启用 OTel 插件后,每个 HTTP 请求都会成为一个 span,通过 OTLP 导出到你的追踪后端。

Span 属性

导出的 span 包含标准的 HTTP 语义约定属性:

属性 说明
http.request.method HTTP 方法(GET、POST 等)
url.path 请求路径
http.response.status_code 响应状态码
client.address 客户端 IP 地址
server.address 服务器监听地址
oxphp.request_id OxPHP 请求 ID
http.request.body.size 请求体大小,单位字节(若非零)
http.response.body.size 响应体大小,单位字节(若非零)

5xx 响应会被标记为错误 span。

Span 事件

子 span 还会携带 span 事件 —— 带时间戳的注解,作为 OpenTelemetry 事件导出,并被 Jaeger、Grafana Tempo 及其他 OTLP 后端原生渲染。每个事件上的 oxphp.event.kind 属性用于标识其类型:

oxphp.event.kind 来源 事件属性
exception 抛出异常的 #[OxPHP\Apm\Trace] 函数,或 oxphp_apm_error() exception.typeexception.messageexception.stacktrace
custom oxphp_apm_event() 用户提供
mark 性能分析器 #[Mark] 注解 用户提供
slow 性能分析器 #[SlowThreshold] 触发 threshold_mselapsed_ms
memory_spike 性能分析器 #[MemoryThreshold] 触发 threshold_kbdelta_bytes

对于由 APM 埋点生成的事件,oxphp.event.kind 属性还可能额外携带 sqlhttpalloc

配合 OTel 的请求 ID

当 OTel 插件处于激活状态时,请求 ID 会从 trace context 派生:取 trace ID 的前 16 个字符与 span ID 的前 8 个字符,用短横线分隔。它会出现在日志、X-Request-ID 响应头以及 PHP 的 oxphp_request_id() 中。

APM:自动埋点

启用 APM 插件后,OxPHP 会在引擎层自动挂钩 33 个 PHP 内部函数。每次调用被挂钩的函数都会在当前请求的根 span 下创建一个子 span —— 无需改动任何代码。

被挂钩的函数

类别 函数
PDO PDO::__constructPDO::queryPDO::execPDO::preparePDOStatement::execute
mysqli mysqli::__constructmysqli::querymysqli::preparemysqli_stmt::execute
cURL curl_initcurl_setoptcurl_execcurl_multi_exec
Redis Redis::connectRedis::getRedis::setRedis::delRedis::mgetRedis::msetRedis::hgetRedis::hsetRedis::lpushRedis::rpush
Memcached Memcached::getMemcached::setMemcached::deleteMemcached::getMultiMemcached::setMulti
文件 I/O fopenfreadfwritefile_get_contentsfile_put_contents

只有对实际已加载的扩展才会安装挂钩。如果你的构建不包含 Redis 扩展,Redis 挂钩会被静默跳过。

挂钩安装

挂钩安装采用两阶段设计,以保证 PHP ZTS 下的线程安全:

  1. 阶段 1(MINIT) —— 在模块初始化期间,OxPHP 会针对已加载的扩展校验每个目标函数,并将原始处理器指针捕获到一个只读的已批准列表中
  2. 阶段 2(RINIT) —— 在每个工作线程处理首个请求时,已批准的挂钩会被安装到该线程的函数表中

这确保了每个 ZTS 工作线程都拥有一致的函数表修改和线程本地状态。

APM:基于注解的追踪

#[OxPHP\Apm\Trace] 注解会自动围绕被装饰的函数和方法创建 span。与自动埋点挂钩(针对内部 C 函数)不同,它作用于用户定义的 PHP 代码。

php
<?php use OxPHP\Apm\Trace; #[Trace] function processOrder(int $orderId): void { // A span named "processOrder" is created on entry and closed on exit. // If an exception is thrown, the span is marked as error and an // "exception" span event records exception.type, exception.message // and exception.stacktrace. } class PaymentService { #[Trace] public function charge(float $amount): bool { // Span named "PaymentService::charge" return true; } }

#[Trace] 注解同时作用于函数和方法。无需任何注册调用 —— APM 插件会在初始化期间自动注册该装饰器。

如果被装饰的函数抛出异常,span 的状态会被设为错误,并记录一个 exception 事件,携带完整的 OpenTelemetry 语义约定数据:exception.type(类名)、exception.message(消息)以及 exception.stacktrace(来自 getTraceAsString() 的调用栈)。消息会被截断到 OTEL_APM_MESSAGE_MAX_BYTES 字节(默认 4096),调用栈会被截断到 OTEL_APM_STACKTRACE_MAX_BYTES 字节(默认 8192);0 可禁用任一上限。帧内的参数捕获遵循 PHP 自身的 zend.exception_ignore_args 设置。

APM:PHP 追踪 SDK

APM 插件注册了 10 个 oxphp_apm_*() 函数,用于手动管理 span。所有函数在 APM 被禁用时都是安全的空操作,因此你的代码无需修改即可在任何环境中运行。

创建 span

php
<?php // Start a span and get its local ID $spanId = oxphp_apm_start('cache.warm', ['cache.size' => '1024']); // ... do work ... // Close the span oxphp_apm_end($spanId);

添加属性和事件

php
<?php $spanId = oxphp_apm_start('order.process'); // Add attributes to the current span (or a specific one) oxphp_apm_attribute('order.id', $orderId); oxphp_apm_attribute('order.total', $total, $spanId); // Record an event on the span oxphp_apm_event('payment.authorized', [ 'provider' => 'stripe', 'amount' => (string) $amount, ]); oxphp_apm_end($spanId);

记录错误

php
<?php $spanId = oxphp_apm_start('external.api'); try { $result = callExternalApi(); } catch (\Throwable $e) { // Mark the span as error oxphp_apm_error($e, $spanId); throw $e; } finally { oxphp_apm_end($spanId); }

传播 trace context

php
<?php // Get the current trace ID and span ID $traceId = oxphp_apm_trace_id(); $currentSpanId = oxphp_apm_span_id(); // Or get a ready-to-use traceparent header value $traceparent = oxphp_apm_header(); // "00-{trace_id}-{span_id}-01" // Propagate to downstream services $response = file_get_contents('https://api.example.com/data', false, stream_context_create([ 'http' => [ 'header' => "traceparent: {$traceparent}\r\n", ], ]) );

函数参考

函数 返回值 说明
oxphp_apm_trace(name, callback, ?attributes) void 在一个 span 内执行回调(保留供将来使用)
oxphp_apm_start(name, ?attributes) int 开启一个 span 并返回其本地 ID。APM 被禁用时返回 0
oxphp_apm_end(span_id) void 关闭给定本地 ID 的 span
oxphp_apm_attribute(key, value, ?span_id) void 在当前或指定的 span 上设置一个属性
oxphp_apm_event(name, ?attributes, ?span_id) void 在当前或指定的 span 上记录一个带时间戳的事件
oxphp_apm_error(exception, ?span_id) void 将当前或指定的 span 标记为错误并记录一个 exception 事件。Throwable 对象会贡献 exception.typeexception.messageexception.stacktrace;而单纯的字符串参数会被记录为 exception.message,并归入通用的 exception.type(值为 Error),以便在按类型分组的后端中该事件仍然可见
oxphp_apm_status(code, ?description, ?span_id) void 设置 span 状态:0 = Unset,1 = Ok,2 = Error
oxphp_apm_trace_id() string 当前 trace ID(32 个十六进制字符)。APM 被禁用时为空
oxphp_apm_span_id() string 当前 span ID(16 个十六进制字符)。没有活动 span 时为空
oxphp_apm_header() string 当前 span 上下文的 W3C traceparent 头值

完整的函数签名参考,请参见 PHP 函数

Docker 示例

开箱即用的 compose.yaml 变体:

在没有外部后端的情况下启用 W3C trace 传播:

compose.yaml
services: app: image: ghcr.io/oxphp/oxphp:0.10.0 ports: - "80:80" environment: - TRACE_CONTEXT=true - INTERNAL_ADDR=0.0.0.0:9090
Note

必须在构建时启用 plugin-apm Cargo 特性。官方 OxPHP 镜像默认包含它。

可观测性栈

OxPHP 提供三大协同工作的可观测性支柱:

支柱 特性 关联方式
指标(Metrics) 位于 /metrics 的 Prometheus 计数器和直方图 聚合的性能数据
日志(Logging) 通过 ACCESS_LOG 输出的结构化 JSON 访问日志 每请求级别的细节,可按 trace_id 搜索
追踪(Tracing) W3C Trace Context + OTLP 导出 端到端的分布式请求流

三者共享同一个 trace_idrequest_id,因此你可以从 Grafana 仪表盘的告警一路下钻到 Tempo 追踪,再到某个请求对应的 Loki 日志行。

排查故障

响应中没有出现追踪头

TRACE_CONTEXT 未启用。

修复: 设置 TRACE_CONTEXT=true,或用 OTEL_ENABLED=true 启用 OTel 插件(它会自动启用 trace context)。

$_SERVER 追踪变量为空

trace context 被禁用,或者这些变量是在 OxPHP 之外被检查的。

检查: OXPHP_TRACE_IDOXPHP_SPAN_IDOXPHP_PARENT_SPAN_ID 变量仅在 TRACE_CONTEXT=true 且请求由 OxPHP 处理时才存在。测试方法:

php
<?php echo $_SERVER['OXPHP_TRACE_ID'] ?? 'trace context not enabled';
Jaeger/Tempo 中没有出现 span

检查: 确认从 OxPHP 容器可以访问 OTLP 端点:

bash
docker compose exec app curl -v http://jaeger:4317

检查: 确认插件已启用:

bash
curl -s http://localhost:9090/config | jq '.plugins'

修复: 确保 OTEL_ENABLED=true,且 OTEL_EXPORTER_OTLP_ENDPOINT 指向正确的 collector 地址。

生产环境采样量过高

在高流量下导出每一个 span 代价高昂。

修复: 降低采样比率:

bash
OTEL_TRACES_SAMPLER=parentbased_traceidratio OTEL_TRACES_SAMPLER_ARG=0.1 # Sample 10% of traces

基于父级的采样意味着:如果传入请求携带的是已采样的追踪,那么无论比率如何它都会被采样。在 OxPHP 处开启的新追踪则按配置的比率采样。如果 OTEL_TRACES_SAMPLER 被设为无法识别的值,OxPHP 会记录一条警告并回退到 parentbased_traceidratio

另请参阅

  • PHP 函数 —— oxphp_apm_*() 函数参考
  • 装饰器 —— 基于注解的函数拦截,包括 #[Trace]
  • 访问日志 —— 带追踪字段的结构化 JSON 日志
  • 请求 ID —— 请求 ID 如何与 trace context 交互
  • 指标 —— Prometheus 指标参考
  • 健康检查 —— 显示 trace context 状态的 /config 端点
  • 配置参考 —— 所有环境变量