Files
ulthon_admin/tests/Unit/XhprofProfileParserTest.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

183 lines
7.0 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 tests\Unit;
use app\common\service\XhprofProfileParserService;
use PHPUnit\Framework\TestCase;
/**
* XHProf jsonl 行解析器单元测试(纯数组处理,不触 DB.
*
* fixturetests/fixtures/xhprof-sample.jsonl 为容器内真实采样首行
* 103441 字节md5 与容器内原行一致),断言值来自该 fixture 实测。
*/
class XhprofProfileParserTest extends TestCase
{
private XhprofProfileParserService $parser;
protected function setUp(): void
{
$this->parser = new XhprofProfileParserService();
}
private function loadFixtureLine(): string
{
$content = file_get_contents(__DIR__ . '/../fixtures/xhprof-sample.jsonl');
self::assertNotFalse($content, 'fixture 文件缺失');
return trim($content);
}
/**
* 真实 fixture 行:解析出的字段值与 fixture 内实际数值一致.
*/
public function test_parse_real_fixture_line(): void
{
$parsed = $this->parser->parse($this->loadFixtureLine());
self::assertNotNull($parsed);
// 断言值来自 analyze 实测(见 .omo/evidence/task-3-xhprof-log-backend.txt
self::assertSame('/', $parsed['url']);
self::assertSame('', $parsed['simple_url']); // 根路径 simple_url 原生即为空串
self::assertSame('GET', $parsed['method']);
self::assertSame(8402968, $parsed['wall_time']);
self::assertSame(340812, $parsed['cpu_time']);
self::assertSame(1981664, $parsed['memory_peak']);
// request_ts_micro = {"sec":1786744108,"usec":846845}
self::assertEqualsWithDelta(1786744108.847, $parsed['request_time'], 0.0005);
self::assertFalse($parsed['degraded']);
self::assertCount(954, $parsed['profile']);
}
public function test_parse_rejects_bad_lines(): void
{
self::assertNull($this->parser->parse(''));
self::assertNull($this->parser->parse('not json at all'));
self::assertNull($this->parser->parse('{truncated'));
self::assertNull($this->parser->parse('{"meta":{"url":"/"}}')); // 缺 profile 键
self::assertNull($this->parser->parse('["array","not","object"]'));
}
/**
* simple_url 缺失时兜底path 去 query、纯数字段替换 :id与 bootstrap 回调算法一致).
*/
public function test_simple_url_fallback(): void
{
$line = json_encode([
'profile' => ['main()' => ['ct' => 1, 'wt' => 10, 'cpu' => 1, 'mu' => 1, 'pmu' => 2]],
'meta' => ['url' => '/api/goods/45/detail?foo=bar&baz=1'],
], JSON_UNESCAPED_SLASHES);
$parsed = $this->parser->parse($line);
self::assertNotNull($parsed);
self::assertSame('api/goods/:id/detail', $parsed['simple_url']);
self::assertSame(10, $parsed['wall_time']);
self::assertSame(2, $parsed['memory_peak']);
self::assertSame('', $parsed['method']);
self::assertSame(0.0, $parsed['request_time']);
}
/**
* url 超 500 截断.
*/
public function test_url_truncated_to_500(): void
{
$line = json_encode([
'profile' => ['main()' => ['ct' => 1, 'wt' => 1, 'cpu' => 0, 'mu' => 0, 'pmu' => 0]],
'meta' => ['url' => '/' . str_repeat('a', 600), 'simple_url' => 'x'],
]);
$parsed = $this->parser->parse($line);
self::assertNotNull($parsed);
self::assertSame(500, mb_strlen($parsed['url']));
}
/**
* main() 缺失:降级 0 且 degraded=true行仍可入库由导入侧计 fail.
*/
public function test_parse_missing_main_degraded(): void
{
$line = json_encode([
'profile' => ['foo==>bar' => ['ct' => 1, 'wt' => 5]],
'meta' => ['url' => '/x'],
]);
$parsed = $this->parser->parse($line);
self::assertNotNull($parsed);
self::assertTrue($parsed['degraded']);
self::assertSame(0, $parsed['wall_time']);
self::assertSame(0, $parsed['cpu_time']);
self::assertSame(0, $parsed['memory_peak']);
}
/**
* watch 物化:按 callee 匹配caller 匹配不命中ct 累加、wt_sum 累加、wt_max 取最大.
*/
public function test_materialize_watches_by_callee(): void
{
$profile = [
'main()' => ['ct' => 1, 'wt' => 1000, 'cpu' => 10, 'mu' => 0, 'pmu' => 0],
'main()==>think\\db\\PDOConnection::query' => ['ct' => 2, 'wt' => 500],
'foo==>think\\db\\PDOConnection::query' => ['ct' => 3, 'wt' => 700],
'think\\db\\PDOConnection::query==>bar' => ['ct' => 9, 'wt' => 9999], // callee=bar 不命中
'plain_function' => ['ct' => 4, 'wt' => 4], // 裸键不参与
];
$patterns = [7 => '~^think\\\\db\\\\PDOConnection::~'];
$result = $this->parser->materializeWatches($profile, $patterns);
self::assertArrayHasKey(7, $result);
self::assertSame(5, $result[7]['ct']);
self::assertSame(1200, $result[7]['wt_sum']);
self::assertSame(700, $result[7]['wt_max']);
}
/**
* 真实 fixture 的 watch 物化PDOConnection 规则必然命中fixture 实测 33 条边).
*/
public function test_materialize_watches_on_real_fixture(): void
{
$parsed = $this->parser->parse($this->loadFixtureLine());
self::assertNotNull($parsed);
$result = $this->parser->materializeWatches($parsed['profile'], [99 => '~PDOConnection::~']);
self::assertArrayHasKey(99, $result);
self::assertGreaterThan(0, $result[99]['ct']);
self::assertGreaterThan(0, $result[99]['wt_sum']);
}
/**
* 正则编译:非法正则跳过该条并计 fail合法条目进 patterns.
*/
public function test_compile_watch_regexes(): void
{
$watches = [
['id' => 1, 'regex' => '~^think\\\\db\\\\PDOConnection::.*$~'],
['id' => 2, 'regex' => '/[unclosed'], // 非法正则
['id' => 3, 'regex' => ''], // 空正则
['id' => 0, 'regex' => '~ok~'], // 非法 id
];
$compiled = $this->parser->compileWatchRegexes($watches);
self::assertSame(3, $compiled['fails']);
self::assertSame([1 => '~^think\\\\db\\\\PDOConnection::.*$~'], $compiled['patterns']);
}
/**
* request_time 退化链request_ts_micro 优先,缺失退化 REQUEST_TIME_FLOAT.
*/
public function test_request_time_fallback_chain(): void
{
$base = ['profile' => ['main()' => ['ct' => 1, 'wt' => 1]]];
$withTsMicro = $this->parser->parse(json_encode($base + [
'meta' => ['request_ts_micro' => ['sec' => 1000000000, 'usec' => 250000]],
]));
self::assertSame(1000000000.25, $withTsMicro['request_time']);
$withFloatOnly = $this->parser->parse(json_encode($base + [
'meta' => ['SERVER' => ['REQUEST_TIME_FLOAT' => 1234567890.123456]],
]));
self::assertSame(1234567890.123, $withFloatOnly['request_time']);
}
}