Files
ulthon_admin/extend/base/common/service/XhprofProfileParserServiceBase.php
augushong a43a9a48b9 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 测试套件
2026-08-15 06:11:01 +08:00

205 lines
7.4 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?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;
}
}