mirror of
https://gitee.com/ulthon/ulthon_admin.git
synced 2026-08-30 12:45:32 +08:00
- 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 测试套件
205 lines
7.4 KiB
PHP
205 lines
7.4 KiB
PHP
<?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_url(bootstrap 的 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/pmu(main() 缺失降级 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_micro(sec+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;
|
||
}
|
||
}
|