feat(tools): XHProf 日志导入三层服务与函数监控物化

- XhprofProfileParserService(Base/App):纯数组解析 jsonl 行,
  meta 字段以真实 fixture 为准;simple_url 缺失兜底与采集端回调同算法;
  watch 正则按 callee 物化(ct/wt_sum/wt_max),非法正则编译计 fail 跳过
- XhprofLogReaderService(Base/App):增量读取,半行缓冲超限整行丢弃
  (不照抄 nginx 8MB 强制切行);EOF 无换行半行不 yield 不推进 offset;
  进度不自动落盘,由导入侧在同一事务内显式 savePosition
- XhprofLogImport 定时任务(Base/App):不受 XHPROF_ENABLE 门控,
  runs-*.jsonl 按文件名序增量导入;run 逐条 insert + detail chunk 20
  insertAll + run_watch insertAll 与 position 同事务落盘;导至 EOF 后
  只删已导完文件
- 修复 T1 缺陷:request_time decimal(12,3) 装不下 10 位时间戳,
  Scheme 与 migration 同步改 decimal(13,3)
- 新增 parser 单元测试(真实 fixture 断言,9 用例 42 断言)
  与 jsonl fixture;phpunit 注册 unit 测试套件
This commit is contained in:
augushong
2026-08-15 06:11:01 +08:00
parent b472df8c1d
commit a43a9a48b9
11 changed files with 1054 additions and 2 deletions

View File

@@ -0,0 +1,204 @@
<?php
declare(strict_types=1);
namespace base\common\service;
/**
* XHProf jsonl 行解析器Base 层).
*
* 输入php-profiler FileSaver 落盘的 jsonl 单行 {"profile":{...},"meta":{...}}
* 输出ul_xhprof_run 入库行字段 + 解码后的 profile供 watch 物化)。
*
* 纯数组处理,不依赖 Xhgui\Profiler 等镜像内类库app 侧禁止 use见计划 guardrails
*
* meta 真实结构(以容器内实采 fixture 为准tests/fixtures/xhprof-sample.jsonl
* url = meta.url原生存在通常为 path 形如 '/',无需从 REQUEST_URI 拼)
* simple_url = meta.simple_urlbootstrap 的 profiler.simple_url 回调原生产出;
* 仅缺失/为空时兜底自算,算法与回调保持一致,勿漂移)
* method = meta.SERVER.REQUEST_METHOD
* request_time = meta.request_ts_micro.sec + usec/1e6缺失退化 meta.SERVER.REQUEST_TIME_FLOAT
* wall/cpu/pmu = profile['main()'] 的 wt/cpu/pmumain() 缺失降级 0 并置 degraded由上层计 fail
*
* 业务侧通过 app/common/service/XhprofProfileParserService 覆盖本类方法.
*/
class XhprofProfileParserServiceBase
{
/** run.url 列长ul_xhprof_run.url varchar(500),超长截断) */
protected const URL_MAX_LENGTH = 500;
/** run.simple_url 列长 */
protected const SIMPLE_URL_MAX_LENGTH = 255;
/** run.method 列长 */
protected const METHOD_MAX_LENGTH = 10;
/**
* 解析单行 jsonl.
*
* @param string $line 原始行(不含换行符)
*
* @return array|null 成功返回 run 行字段 + profile空行/非法 JSON/缺 profile 键返回 null。
* main() 缺失时 wall/cpu/memory_peak 降级 0 且 degraded=true行仍可入库
*/
public function parse(string $line): ?array
{
$line = trim($line);
if ($line === '' || $line[0] !== '{') {
return null;
}
$data = json_decode($line, true);
if (!is_array($data) || !isset($data['profile']) || !is_array($data['profile'])) {
return null;
}
$meta = isset($data['meta']) && is_array($data['meta']) ? $data['meta'] : [];
// main() 缺失:降级 0 并标记 degraded导入侧对 degraded 行计 fail 但不丢数据)
$main = $data['profile']['main()'] ?? null;
$degraded = !is_array($main);
$wallTime = $degraded ? 0 : (int) ($main['wt'] ?? 0);
$cpuTime = $degraded ? 0 : (int) ($main['cpu'] ?? 0);
$memoryPeak = $degraded ? 0 : (int) ($main['pmu'] ?? 0);
$url = (string) ($meta['url'] ?? '');
$simpleUrl = (string) ($meta['simple_url'] ?? '');
if ($simpleUrl === '') {
$simpleUrl = $this->buildSimpleUrlFallback($url);
}
return [
'url' => mb_substr($url, 0, self::URL_MAX_LENGTH),
'simple_url' => mb_substr($simpleUrl, 0, self::SIMPLE_URL_MAX_LENGTH),
'method' => mb_substr((string) ($meta['SERVER']['REQUEST_METHOD'] ?? ''), 0, self::METHOD_MAX_LENGTH),
'wall_time' => $wallTime,
'cpu_time' => $cpuTime,
'memory_peak' => $memoryPeak,
'request_time' => $this->parseRequestTime($meta),
'profile' => $data['profile'],
'degraded' => $degraded,
];
}
/**
* simple_url 兜底计算.
*
* 与采集端 bootstrap 的 profiler.simple_url 回调算法完全一致(唯一实现点在采集端,
* 这里仅做缺失兜底path 去 query、纯数字段替换 ':id'、'/' 连接(无前导斜杠)。
*/
public function buildSimpleUrlFallback(string $url): string
{
$path = parse_url($url, PHP_URL_PATH);
if (!is_string($path) || $path === '') {
return '';
}
$parts = explode('/', trim($path, '/'));
foreach ($parts as &$part) {
if ($part !== '' && ctype_digit($part)) {
$part = ':id';
}
}
unset($part);
return implode('/', $parts);
}
/**
* 编译 watch 正则(导入批次调用一次,结果供 materializeWatches 复用,避免逐行重复编译).
*
* @param array $watches [['id' => int, 'regex' => string], ...]
*
* @return array{patterns: array<int, string>, fails: int}
* patterns 以 watch_id 为键id 非法/正则为空/@preg_match 编译失败
* 的条目跳过并计入 fails防非法正则打爆导入任务
*/
public function compileWatchRegexes(array $watches): array
{
$patterns = [];
$fails = 0;
foreach ($watches as $watch) {
$id = (int) ($watch['id'] ?? 0);
$regex = (string) ($watch['regex'] ?? '');
if ($id <= 0 || $regex === '') {
$fails++;
continue;
}
if (@preg_match($regex, '') === false) {
$fails++;
continue;
}
$patterns[$id] = $regex;
}
return ['patterns' => $patterns, 'fails' => $fails];
}
/**
* watch 物化:拆 "caller==>callee" 边,按 callee被调用函数名匹配正则.
*
* 命中边 ct 累加、wt 累加 wt_sum、wt_max 取最大。
* 裸键(如 main())无 ==> 分隔符,不参与物化。
*
* @param array $profile 解码后的 profile 边表
* @param array $patterns compileWatchRegexes 产出的 [watch_id => regex]
*
* @return array<int, array{ct: int, wt_sum: int, wt_max: int}> 以 watch_id 为键的聚合行
*/
public function materializeWatches(array $profile, array $patterns): array
{
if (empty($patterns)) {
return [];
}
$result = [];
foreach ($profile as $key => $metrics) {
$sep = strpos($key, '==>');
if ($sep === false) {
continue;
}
$callee = substr($key, $sep + 3);
$ct = (int) ($metrics['ct'] ?? 0);
$wt = (int) ($metrics['wt'] ?? 0);
foreach ($patterns as $watchId => $regex) {
if (@preg_match($regex, $callee) !== 1) {
continue;
}
if (!isset($result[$watchId])) {
$result[$watchId] = ['ct' => 0, 'wt_sum' => 0, 'wt_max' => 0];
}
$result[$watchId]['ct'] += $ct;
$result[$watchId]['wt_sum'] += $wt;
if ($wt > $result[$watchId]['wt_max']) {
$result[$watchId]['wt_max'] = $wt;
}
}
}
return $result;
}
/**
* request_time优先 meta.request_ts_microsec+usec缺失退化 meta.SERVER.REQUEST_TIME_FLOAT.
*
* @return float 浮点秒round 3 位,与 decimal(12,3) 列精度对齐)
*/
protected function parseRequestTime(array $meta): float
{
$ts = $meta['request_ts_micro'] ?? null;
if (is_array($ts) && isset($ts['sec'])) {
$sec = (int) $ts['sec'];
$usec = (int) ($ts['usec'] ?? 0);
return round($sec + $usec / 1000000, 3);
}
$float = $meta['SERVER']['REQUEST_TIME_FLOAT'] ?? null;
if (is_numeric($float)) {
return round((float) $float, 3);
}
return 0.0;
}
}