Search by

easyswoole / trace

kiss291323003

A lightweight manual call-chain timing tracer for PHP

Package info

github.com/easy-swoole/trace

Homepage

pkg:composer/easyswoole/trace

Statistics

Installs: 17 054

Dependents: 1

Suggesters: 0

Stars: 1

Open Issues: 0

2.0.2 2026-10-15 06:52 UTC

README

一个面向 PHP 8.1+ 的轻量级手动调用链追踪器。适合在 HTTP 请求、CLI 任务、Swoole 协程、RPC 调用和后台消费任务中记录串行/并行调用关系、耗时、状态、参数与异常,并将节点转换成统一的 SpanBean 后批量入库。

本组件只负责记录调用关系和耗时,不负责启动协程、调度并行任务或直接写数据库。

特性

  • 使用 hrtime() 单调时钟计算耗时,不受系统时间校准影响。
  • 同时记录 Unix 微秒时间,便于数据库查询、排序和展示。
  • 使用 W3C Trace Context 兼容格式的 Trace ID 与 Span ID。
  • 每个节点有一个串行后继节点和任意多个并行节点。
  • 父节点结束时自动收口未结束的后代节点。
  • 支持成功、异常、超时和未正常收口等状态。
  • 支持协程延迟节点的自动持久化兜底。
  • 通过 SpanBean 统一数据库字段。
  • 支持从数据库 Bean 恢复完整调用树。
  • 支持 JSON 树形输出和纯文本调用树展示。

环境要求

  • PHP >= 8.1
  • Composer

安装依赖并运行测试:

composer install
composer test

快速开始

<?php

require __DIR__ . '/vendor/autoload.php';

use EasySwoole\Trace\Trace;
use EasySwoole\Trace\Utility;

$trace = new Trace('http-request', params: [
    'method' => 'GET',
    'uri' => '/users/10001',
]);

$controller = $trace->next('UserController::profile');
$service = $controller->next('UserService::profile', [
    'user_id' => 10001,
]);

$orders = $service->parallel('order-service');
$coupons = $service->parallel('coupon-service');

$orders->endCall();
$coupons->endCall();
$service->endCall();
$controller->endCall();
$trace->endCall();

echo Utility::toTree($trace), PHP_EOL;

示例输出:

Trace: 4b0b2f5d54bb61e080ecce28ded4bc6a
http-request [serial | success | 10.000 ms] (span: 0000000000000001)
└── UserController::profile [serial | success | 9.500 ms] (span: 0000000000000002)
    └── UserService::profile [serial | success | 8.000 ms] (span: 0000000000000003)
        ├── order-service [parallel | success | 5.000 ms] (span: 0000000000000004)
        └── coupon-service [parallel | success | 7.000 ms] (span: 0000000000000005)

调用链结构

串行后继节点

使用 next() 创建串行调用链。每个节点最多只能创建一个 next:

$controller = $trace->next('controller');
$service = $controller->next('service');
$database = $service->next('database');

形成:

trace → controller → service → database

同一个节点第二次调用 next() 会抛出 LogicException:

$trace->next('first');
$trace->next('second'); // LogicException

并行节点

使用 parallel() 在当前节点下创建任意多个并行节点:

$serviceA = $point->parallel('service-a');
$serviceB = $point->parallel('service-b');
$serviceC = $point->parallel('service-c');

parallel() 只标记节点与父节点的关系,不会真正启动协程或异步任务。实际并发需要由 Swoole 协程、进程池或其他异步框架完成。

一个节点可以同时拥有一个 next 和多个 parallel:

$next = $point->next('next-call');
$parallel1 = $point->parallel('parallel-1');
$parallel2 = $point->parallel('parallel-2');

节点结束与状态

节点状态使用原生字符串枚举 TraceStatus:

use EasySwoole\Trace\TraceStatus;

TraceStatus::Running;
TraceStatus::Success;
TraceStatus::Exception;
TraceStatus::Timeout;
TraceStatus::Abandoned;

正常结束:

$point->endCall();
// 等价于:
$point->endCall(TraceStatus::Success);

异常结束:

try {
    callRemoteService();
    $point->endCall();
} catch (Throwable $error) {
    $point->fail($error);
    throw $error;
}

超时:

$point->timeout('deadline exceeded');

endCall()、fail() 和 timeout() 都是幂等的。节点第一次结束后,后续结束调用不会覆盖已有状态。

父节点结束时,所有尚未结束的后代节点会使用相同结束时间自动收口,并标记为 TraceStatus::Abandoned:

$parallel1 = $point->parallel('service-1');
$parallel2 = $point->parallel('service-2');

$parallel1->endCall();
// parallel2 没有正常结束。

$point->endCall(); // parallel2 自动变为 abandoned

节点结束后不能继续创建 next() 或 parallel() 节点。

参数记录

创建节点时可以记录任意数组参数:

$point = $trace->next('query-user', [
    'user_id' => 10001,
    'filters' => [
        'status' => 'active',
    ],
]);

执行过程中也可以整体替换:

$point->setParams([
    'user_id' => 10002,
]);

$params = $point->params();

参数由追踪器原样保存。写数据库时可以由存储层编码为 JSON:

$paramsJson = json_encode(
    $span->params,
    JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR,
);

参数注意事项

  • 不要记录密码、Token、Cookie、Authorization 或密钥。
  • 谨慎记录身份证号、银行卡号、手机号等个人信息。
  • 不建议记录完整请求体、响应体、文件内容或大对象。
  • 确保参数能够被 json_encode() 正确编码。
  • 参数大小不受本组件限制,应由调用方和日志服务控制。

Trace ID 与 Span ID

每条调用链共享一个 Trace ID,每个节点拥有独立的 Span ID,并使用 Parent Span ID 关联父节点:

$trace->traceId();
$point->spanId();
$point->parentSpanId();

标识格式:

  • Trace ID:32 位非零小写十六进制字符串。
  • Span ID:16 位非零小写十六进制字符串。
  • 根节点的 Parent Span ID 默认为 null。

接收上游调用链时,可以继续使用上游标识:

$trace = new Trace(
    name: 'incoming-request',
    traceId: $incomingTraceId,
    parentSpanId: $incomingSpanId,
);

传入格式错误或全零的 ID 会抛出 InvalidArgumentException。本组件不会自动解析或生成 HTTP traceparent Header,Header 的收发应由应用层完成。

HTTP 跨服务链路衔接

推荐使用 W3C Trace Context 的 traceparent Header 在服务之间传递链路上下文:

traceparent: 00-{trace-id}-{parent-span-id}-{trace-flags}

例如:

traceparent: 00-4b0b2f5d54bb61e080ecce28ded4bc6a-00f067aa0ba902b7-01

字段含义:

  • 00:Trace Context 版本。
  • 4b0b...bc6a:32 位 Trace ID。
  • 00f0...02b7:16 位上游 Span ID。
  • 01:Trace Flags,最低位为采样标志。

下面以“客户端 → 用户服务 A → 订单服务 B”为例。

解析和生成 traceparent

下面的辅助代码可以放在应用自己的 HTTP 中间件中:

/**
 * @return null|array{trace_id: string, parent_span_id: string, flags: string}
 */
function parseTraceparent(?string $header): ?array
{
    if ($header === null) {
        return null;
    }

    $header = strtolower(trim($header));
    if (!preg_match(
        '/^00-([0-9a-f]{32})-([0-9a-f]{16})-([0-9a-f]{2})$/',
        $header,
        $matches,
    )) {
        return null;
    }

    // W3C 不允许全零 Trace ID 或 Span ID。
    if (trim($matches[1], '0') === '' || trim($matches[2], '0') === '') {
        return null;
    }

    return [
        'trace_id' => $matches[1],
        'parent_span_id' => $matches[2],
        'flags' => $matches[3],
    ];
}

function buildTraceparent(
    string $traceId,
    string $parentSpanId,
    string $flags = '01',
): string {
    return sprintf('00-%s-%s-%s', $traceId, $parentSpanId, $flags);
}

生产环境可以把这两个函数封装进框架中间件;不要在每个控制器里重复解析 Header。

服务 A:接收上游请求

服务 A 的 HTTP 中间件读取请求 Header。存在合法 traceparent 时继续上游链路,否则创建一条新链路:

use EasySwoole\Trace\Trace;

$incoming = parseTraceparent(
    $request->getHeaderLine('traceparent'),
);

if ($incoming === null) {
    $trace = new Trace('user-service.request', params: [
        'method' => $request->getMethod(),
        'route' => '/users/{id}',
    ]);
    $traceFlags = '01';
} else {
    $trace = new Trace(
        name: 'user-service.request',
        traceId: $incoming['trace_id'],
        parentSpanId: $incoming['parent_span_id'],
        params: [
            'method' => $request->getMethod(),
            'route' => '/users/{id}',
        ],
    );
    $traceFlags = $incoming['flags'];
}

$controller = $trace->next('UserController::profile');

此时服务 A 的根节点使用相同 Trace ID,并通过 parentSpanId 指向上游服务的 Span。

服务 A:调用服务 B

服务 A 调用订单服务 B 前,先创建代表这次 HTTP 客户端调用的 Span。发送给 B 的 Parent Span ID 必须是这个客户端 Span 的 ID,而不是服务 A 根节点的 ID:

$orderRequest = $controller->parallel('HTTP GET order-service', [
    'method' => 'GET',
    'host' => 'order-service',
    'route' => '/orders',
]);

$headers = [
    'traceparent' => buildTraceparent(
        $trace->traceId(),
        $orderRequest->spanId(),
        $traceFlags,
    ),
];

try {
    $response = $httpClient->get(
        'http://order-service/orders?user_id=10001',
        headers: $headers,
    );

    if ($response->getStatusCode() >= 500) {
        $orderRequest->fail(
            new RuntimeException('order-service returned 5xx'),
        );
    } else {
        $orderRequest->endCall();
    }
} catch (Throwable $error) {
    $orderRequest->fail($error);
    throw $error;
}

链路关系为:

上游服务 Span
└── user-service.request
    └── UserController::profile
        └── HTTP GET order-service

服务 B:继续服务 A 的链路

订单服务 B 收到请求后,同样在 HTTP 中间件中解析 traceparent:

$incoming = parseTraceparent(
    $request->getHeaderLine('traceparent'),
);

$trace = $incoming === null
    ? new Trace('order-service.request')
    : new Trace(
        name: 'order-service.request',
        traceId: $incoming['trace_id'],
        parentSpanId: $incoming['parent_span_id'],
    );

$controller = $trace->next('OrderController::list');
$database = $controller->next('query-orders', [
    'user_id' => 10001,
]);

try {
    $orders = queryOrders(10001);
    $database->endCall();
    $controller->endCall();
    $trace->endCall();
} catch (Throwable $error) {
    $database->fail($error);
    $controller->fail($error);
    $trace->fail($error);
    throw $error;
}

服务 A 和服务 B 入库后,通过相同的 trace_id 可以查询到完整跨服务链路:

user-service.request
└── UserController::profile
    └── HTTP GET order-service
        └── order-service.request
            └── OrderController::list
                └── query-orders

数据库中的服务 B 根 Span,其 parent_span_id 等于服务 A 中 HTTP GET order-service Span 的 span_id。

HTTP 跨服务注意事项

  • 优先使用标准 traceparent,不要同时维护含义重复的私有 Trace Header。
  • 永远不要直接信任外部 Header;格式非法、版本不支持或 ID 全零时,应创建新的 Trace。
  • 下游 Header 中的 Parent Span ID 应使用“本次 HTTP 客户端调用节点”的 Span ID。
  • Trace ID 在整条跨服务链路中保持不变;每个服务和调用节点都生成新的 Span ID。
  • traceparent 只传递链路上下文,不应放入用户资料、认证信息或业务参数。
  • 如果需要严格遵循采样决策,应原样传播 Trace Flags;当前组件本身不执行采样。
  • HTTP 客户端 Span 应覆盖完整网络调用时间,并在响应、异常或超时时结束。
  • 服务端根 Span 应覆盖完整请求生命周期,建议在 HTTP 中间件的 finally 阶段统一收口和入库。
  • 多个服务可能并发写入相同 Trace ID,数据库必须使用 Span ID 区分记录。
  • 跨服务恢复整棵树时,需要从共享存储或日志分析服务查询该 Trace ID 的所有 Span。

时间与耗时

每个 SpanBean 同时包含两类时间信息:

  • startedAtUnixUs / endedAtUnixUs:Unix 微秒时间,用于入库、排序和展示。
  • durationUs:使用 hrtime() 单调时钟计算的耗时。

不要使用 Unix 时间直接计算高精度耗时,因为系统时间可能被 NTP 或管理员调整。

SpanBean 与数据库持久化

单节点转换:

use EasySwoole\Trace\Bean\SpanBean;

$span = $point->toSpanBean();

整条调用链转换为扁平 Bean 集合:

/** @var SpanBean[] $spans */
$spans = $trace->spans();

只获取当前节点:

$current = $point->spans(onlySelf: true);

SpanBean 字段:

属性 数据库字段 类型 说明
traceId trace_id string 调用链 ID
spanId span_id string 当前节点 ID
parentSpanId parent_span_id ?string 父节点 ID
name name string 节点名称
type type string serial 或 parallel
status status TraceStatus 节点状态
startedAtUnixUs started_at_unix_us int 开始时间
endedAtUnixUs ended_at_unix_us ?int 结束时间
durationUs duration_us int 单调时钟耗时
params params array 原始参数,入库时可编码为 JSON
error error ?string 异常类名与错误消息
endedBy ended_by ?EndTrigger 节点结束来源;运行中为 null

转换成数据库字段数组:

$row = $span->toArray();

一个简单的批量入库流程:

if (!$trace->isPersisted()) {
    $repository->insertMany($trace->spans());
    $trace->markPersisted();
}

只有数据库事务成功提交后才应调用 markPersisted()。该方法默认递归标记当前节点及所有已经存在的后代节点,并且支持重复调用。

协程与自动持久化

在协程模式下,外层流程可能先完成批量入库,而内部协程之后才创建或结束新的节点。可以通过 setAutoPersist() 设置补写回调:

use EasySwoole\Trace\TracePoint;

$trace->setAutoPersist(
    static function (TracePoint $point) use ($repository): void {
        $repository->insertMany(
            $point->spans(onlySelf: true),
        );
    },
);

// 在外层入库前先建立一个仍处于运行状态的协程范围。
$asyncScope = $trace->parallel('async-scope');

回调会自动传播给:

  • 设置回调前已经存在的所有后代节点。
  • 设置回调后创建的 next() 节点。
  • 设置回调后创建的 parallel() 节点。
  • 后续任意深度的新节点。

典型流程:

// 外层先批量写入当前已经存在的节点。
$repository->insertMany($trace->spans());
$trace->markPersisted();

// 内部协程之后创建的新节点默认是未持久化状态,
// 并且已经继承 auto-persist callback。
$latePoint = $asyncScope->next('late-coroutine');

如果节点创建于 Swoole 协程中,会自动为该节点注册一次 Coroutine::defer()。协程结束时如果节点尚未结束,会自动按 abandoned 状态收口,然后执行持久化回调。非协程环境不会注册 defer,节点析构仍会执行相同的兜底逻辑。

协程检测不要求项目必须安装 Swoole 扩展;没有安装扩展或当前不在协程中时会直接跳过。defer 使用弱引用关联节点,不会因为兜底回调而额外延长节点生命周期。

也可以在协程结束前主动补写:

$point->persistIfNeeded();

persistIfNeeded() 返回本次是否执行了回调:

$didPersist = $point->persistIfNeeded();

每个已结束节点都会在 SpanBean::endedBy 和数据库字段 ended_by 中记录结束来源:

  • manual:显式调用 endCall()、fail() 或 timeout()。
  • coroutine_defer:节点未正常结束,由 Swoole 协程 defer 自动收口。
  • destructor:节点未正常结束,由对象析构自动收口。
  • parent:父节点结束时,当前节点仍未结束而被递归收口。

因此可以直接查询没有规范闭合的节点:

SELECT *
FROM trace_spans
WHERE ended_by IN ('coroutine_defer', 'destructor', 'parent');

运行中的节点 ended_by 为 null。defer 或析构不会改写已经结束节点的状态、时间和 ended_by;它们只会为仍未结束的节点兜底收口。

defer 和析构只收口当前节点,不会递归修改仍被其他代码持有的子节点。只有显式结束父节点时,才会把其尚未结束的后代节点以 parent 来源递归收口。

已经标记入库的节点,在后续执行以下操作后会重新变成未持久化状态:

  • endCall()
  • fail()
  • timeout()
  • setParams()

这保证运行中的协程节点结束后能够补写最终状态。

自动持久化注意事项

  • 析构回调只是兜底机制,不保证在业务期望的准确时间执行。
  • Swoole 协程中的 defer 比析构更及时,但仍属于兜底;业务可以控制流程时仍应显式持久化。
  • 优先在协程结束前显式调用 persistIfNeeded()。
  • 回调应尽量短小,不要在析构阶段执行长时间阻塞操作。
  • 回调必须在数据库连接和依赖对象仍然可用时执行。
  • 回调抛出异常时不会标记成功,并产生 E_USER_WARNING。
  • 回调应只写当前节点,使用 spans(onlySelf: true),避免重复写入整棵树。
  • 不要让回调强引用当前 Trace,否则可能形成引用环并推迟析构。
  • 数据库应以 (trace_id, span_id) 建立唯一约束,并使用 upsert 支持节点状态补写。
  • defer 和析构会把仍在运行的节点标记为 abandoned,便于追踪没有正常闭合的调用。
  • 自动补写不允许绕过节点生命周期:父节点一旦 endCall(),就不能再创建后继或并行节点。
  • 如果内部协程需要晚于外层入库继续建链,应保持对应的协程范围节点为运行状态,只先入库和 markPersisted(),待协程完成后再结束节点。

从数据库恢复调用树

数据库查询结果可以通过 SpanBean::fromArray() 转换。该方法支持数据库驱动返回的整数字符串,也支持 params 为数组或 JSON 字符串:

use EasySwoole\Trace\Bean\SpanBean;
use EasySwoole\Trace\Utility;

$rows = $repository->findByTraceId(
    '4b0b2f5d54bb61e080ecce28ded4bc6a',
);

$beans = array_map(
    static fn (array $row): SpanBean => SpanBean::fromArray($row),
    $rows,
);

$trace = Utility::restoreFromBeans($beans);

输入顺序没有要求。恢复过程会校验:

  • Bean 集合不能为空。
  • 所有 Bean 必须属于同一个 Trace ID。
  • Span ID 不能重复。
  • 必须能够确定唯一根节点。
  • 父子关系不能断裂或形成循环。
  • 每个节点最多只能有一个串行后继节点。

恢复后的所有节点会自动标记为已经持久化,避免析构时重复补写。

恢复调用树需要查询该 Trace 范围内的完整 Span 集合。如果只传入部分数据,缺失父节点的 Span 可能被识别为新的根节点,最终导致多根或断链校验失败。

输出调用树

实时 Trace 和数据库恢复后的 Trace 都可以输出为文本树:

echo Utility::toTree($trace), PHP_EOL;

输出包含:

  • Trace ID
  • 节点名称
  • 串行或并行类型
  • 节点状态
  • 毫秒耗时
  • Span ID
  • 异常信息

获取完整树形数组:

$tree = $trace->toArray();

JSON 输出:

echo json_encode(
    $trace,
    JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR,
);

toArray() 和 JSON 序列化包含递归 children;spans() 返回不包含 children 的扁平 SpanBean[],适合数据库入库。

API 速查

Trace / TracePoint

$point->next($name, $params);          // 创建唯一串行后继节点
$point->parallel($name, $params);      // 创建并行节点
$point->endCall();                     // 成功结束
$point->fail($throwable);              // 异常结束
$point->timeout($message);             // 超时结束
$point->setParams($params);            // 替换节点参数
$point->params();                      // 获取参数
$point->status();                      // 获取 TraceStatus
$point->children();                    // 获取直接子节点
$point->toSpanBean();                  // 转换当前节点
$point->spans();                       // 当前节点及所有后代 Bean
$point->spans(onlySelf: true);         // 仅当前节点 Bean
$point->toArray();                     // 树形数组
$point->setAutoPersist($callback);     // 设置自动补写回调
$point->persistIfNeeded();             // 主动执行补写
$point->isPersisted();                 // 是否已持久化
$point->markPersisted();               // 标记已持久化

Utility

Utility::restoreFromBeans($beans);     // 从 Bean 恢复调用树
Utility::toTree($trace);               // 输出文本调用树

常见注意点

  1. next() 表示唯一串行后继,不是普通的子节点集合;多个并列调用应使用 parallel()。
  2. 父节点结束会递归结束未完成后代,因此不要过早调用父节点的 endCall()。
  3. parallel() 不会创建协程,只记录调用关系。
  4. markPersisted() 必须在数据库写入成功后调用。
  5. 对同一个 Span 的多次写入应使用唯一键和 upsert,尤其是在协程补写场景下。
  6. 不要使用 Unix 时间差代替 durationUs 作为精确耗时。
  7. 自动持久化依赖对象析构,只应作为兜底;优先显式调用 persistIfNeeded()。
  8. SpanBean::fromArray() 会严格校验字段、ID、状态、类型和时间,非法数据库数据会抛出 InvalidArgumentException。
  9. 节点结束后不能继续添加调用节点。
  10. toArray() 适合展示,spans() 适合入库,不要把递归 children 写入 Span 表。