分布式追踪与 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、设置属性、记录事件与错误
工作原理
- 传入请求 —— OxPHP 依据 W3C Trace Context 规范读取
traceparent和tracestate头 - 新 span —— 为本跳生成一个新的 span ID。传入的 span ID 成为其父 span
- 传播到 PHP —— trace ID 被注入到
$_SERVER['OXPHP_TRACE_ID']、$_SERVER['OXPHP_SPAN_ID']和$_SERVER['OXPHP_PARENT_SPAN_ID'] - 访问日志 —— 结构化 JSON 日志包含
trace_id和span_id字段,用于日志关联 - 响应头 —— 更新后的
traceparent头(带有 OxPHP 的 span ID)会被加入响应,以便下游服务延续该追踪 - OTel 导出(可选)—— 当启用 OTel 插件时,每个请求都会成为一个 span,通过 OTLP 携带 HTTP 语义约定属性导出
如果不存在 traceparent 头,OxPHP 会生成新的 trace ID 和 span ID,并开启一条全新的追踪。
配置
W3C Trace Context(内置)
| 变量 | 默认值 | 说明 |
|---|---|---|
TRACE_CONTEXT |
false |
启用 W3C Trace Context 传播。设为 true 或 1 |
OpenTelemetry 插件
OTel 插件是一个编译期特性(plugin-otel)。启用后,它会自动开启 trace-context 传播(效果等同于设置 TRACE_CONTEXT=true)。
| 变量 | 默认值 | 说明 |
|---|---|---|
OTEL_ENABLED |
false |
启用 OpenTelemetry 插件。布尔值 —— 参见 布尔值 |
OTEL_EXPORTER_OTLP_PROTOCOL |
grpc |
导出协议:grpc 或 http/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_on、always_off、traceidratio、parentbased_always_on、parentbased_always_off、parentbased_traceidratio |
OTEL_TRACES_SAMPLER_ARG |
1.0 |
基于比例的采样器的采样比率(0.0–1.0) |
超出范围或无效的 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
$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
$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_id 和 span_id 字段:
{
"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:
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.type、exception.message、exception.stacktrace |
custom |
oxphp_apm_event() |
用户提供 |
mark |
性能分析器 #[Mark] 注解 |
用户提供 |
slow |
性能分析器 #[SlowThreshold] 触发 |
threshold_ms、elapsed_ms |
memory_spike |
性能分析器 #[MemoryThreshold] 触发 |
threshold_kb、delta_bytes |
对于由 APM 埋点生成的事件,oxphp.event.kind 属性还可能额外携带 sql、http 或 alloc。
配合 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::__construct、PDO::query、PDO::exec、PDO::prepare、PDOStatement::execute |
| mysqli | mysqli::__construct、mysqli::query、mysqli::prepare、mysqli_stmt::execute |
| cURL | curl_init、curl_setopt、curl_exec、curl_multi_exec |
| Redis | Redis::connect、Redis::get、Redis::set、Redis::del、Redis::mget、Redis::mset、Redis::hget、Redis::hset、Redis::lpush、Redis::rpush |
| Memcached | Memcached::get、Memcached::set、Memcached::delete、Memcached::getMulti、Memcached::setMulti |
| 文件 I/O | fopen、fread、fwrite、file_get_contents、file_put_contents |
只有对实际已加载的扩展才会安装挂钩。如果你的构建不包含 Redis 扩展,Redis 挂钩会被静默跳过。
挂钩安装
挂钩安装采用两阶段设计,以保证 PHP ZTS 下的线程安全:
- 阶段 1(MINIT) —— 在模块初始化期间,OxPHP 会针对已加载的扩展校验每个目标函数,并将原始处理器指针捕获到一个只读的已批准列表中
- 阶段 2(RINIT) —— 在每个工作线程处理首个请求时,已批准的挂钩会被安装到该线程的函数表中
这确保了每个 ZTS 工作线程都拥有一致的函数表修改和线程本地状态。
APM:基于注解的追踪
#[OxPHP\Apm\Trace] 注解会自动围绕被装饰的函数和方法创建 span。与自动埋点挂钩(针对内部 C 函数)不同,它作用于用户定义的 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
// 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
$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
$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
// 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.type、exception.message 和 exception.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 传播:
services:
app:
image: ghcr.io/oxphp/oxphp:0.10.0
ports:
- "80:80"
environment:
- TRACE_CONTEXT=true
- INTERNAL_ADDR=0.0.0.0:9090以 Jaeger 作为追踪后端的完整可观测性栈:
services:
app:
image: ghcr.io/oxphp/oxphp:0.10.0
ports:
- "80:80"
environment:
- OTEL_ENABLED=true
- OTEL_EXPORTER_OTLP_ENDPOINT=http://jaeger:4317
- OTEL_SERVICE_NAME=my-app
- OTEL_SERVICE_VERSION=1.0.0
- OTEL_RESOURCE_ATTRIBUTES=env=production
- INTERNAL_ADDR=0.0.0.0:9090
jaeger:
image: jaegertracing/all-in-one:latest
ports:
- "16686:16686" # Jaeger UI
- "4317:4317" # OTLP gRPCservices:
app:
image: ghcr.io/oxphp/oxphp:0.10.0
ports:
- "80:80"
environment:
- OTEL_ENABLED=true
- OTEL_EXPORTER_OTLP_ENDPOINT=http://tempo:4317
- OTEL_SERVICE_NAME=my-app
tempo:
image: grafana/tempo:latest
ports:
- "4317:4317"
grafana:
image: grafana/grafana:latest
ports:
- "3000:3000"对数据库查询、HTTP 调用、缓存操作和文件 I/O 进行自动埋点的完整可观测性:
services:
app:
image: ghcr.io/oxphp/oxphp:0.10.0
ports:
- "80:80"
environment:
- OTEL_ENABLED=true
- OTEL_EXPORTER_OTLP_ENDPOINT=http://jaeger:4317
- OTEL_SERVICE_NAME=my-app
- OTEL_APM_ENABLED=true
- OTEL_APM_SLOW_QUERY_MS=50
- INTERNAL_ADDR=0.0.0.0:9090
jaeger:
image: jaegertracing/all-in-one:latest
ports:
- "16686:16686" # Jaeger UI
- "4317:4317" # OTLP gRPC
environment:
- COLLECTOR_OTLP_ENABLED=true必须在构建时启用 plugin-apm Cargo 特性。官方 OxPHP 镜像默认包含它。
可观测性栈
OxPHP 提供三大协同工作的可观测性支柱:
| 支柱 | 特性 | 关联方式 |
|---|---|---|
| 指标(Metrics) | 位于 /metrics 的 Prometheus 计数器和直方图 |
聚合的性能数据 |
| 日志(Logging) | 通过 ACCESS_LOG 输出的结构化 JSON 访问日志 |
每请求级别的细节,可按 trace_id 搜索 |
| 追踪(Tracing) | W3C Trace Context + OTLP 导出 | 端到端的分布式请求流 |
三者共享同一个 trace_id 和 request_id,因此你可以从 Grafana 仪表盘的告警一路下钻到 Tempo 追踪,再到某个请求对应的 Loki 日志行。
排查故障
响应中没有出现追踪头
TRACE_CONTEXT 未启用。
修复: 设置 TRACE_CONTEXT=true,或用 OTEL_ENABLED=true 启用 OTel 插件(它会自动启用 trace context)。
$_SERVER 追踪变量为空
trace context 被禁用,或者这些变量是在 OxPHP 之外被检查的。
检查: OXPHP_TRACE_ID、OXPHP_SPAN_ID 和 OXPHP_PARENT_SPAN_ID 变量仅在 TRACE_CONTEXT=true 且请求由 OxPHP 处理时才存在。测试方法:
<?php
echo $_SERVER['OXPHP_TRACE_ID'] ?? 'trace context not enabled';Jaeger/Tempo 中没有出现 span
检查: 确认从 OxPHP 容器可以访问 OTLP 端点:
docker compose exec app curl -v http://jaeger:4317检查: 确认插件已启用:
curl -s http://localhost:9090/config | jq '.plugins'修复: 确保 OTEL_ENABLED=true,且 OTEL_EXPORTER_OTLP_ENDPOINT 指向正确的 collector 地址。
生产环境采样量过高
在高流量下导出每一个 span 代价高昂。
修复: 降低采样比率:
OTEL_TRACES_SAMPLER=parentbased_traceidratio
OTEL_TRACES_SAMPLER_ARG=0.1 # Sample 10% of traces基于父级的采样意味着:如果传入请求携带的是已采样的追踪,那么无论比率如何它都会被采样。在 OxPHP 处开启的新追踪则按配置的比率采样。如果 OTEL_TRACES_SAMPLER 被设为无法识别的值,OxPHP 会记录一条警告并回退到 parentbased_traceidratio。