Compare commits

...

2 Commits

Author SHA1 Message Date
clz 84063edcb2 docs: 更新仓库 CLAUDE.md
- 补充第二份报告(启动性能监控接入方案)与 StartupMonitor 说明
- 合并 AGENTS.md 的 Conventional Commits 规范与提交前校验清单

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-05 19:09:25 +08:00
clz 10bbfc4c82 feat: 启动监控扩展追溯字段并修复补报事件语义
- StartupMonitor 上报新增时间拆解与客户端环境字段:response_latency、startability_processstart_dur、appattach_to_appforeground_dur、bundle_name、device_type、system_version、build_mode、target_name、git_commit、is_first_install
- 新增 is_backfill 补报标识:以 watcher 注册时间为锚点判定系统补投递的上次启动遗留事件,entry_page 置 unknown,避免错误归因
- CrashDiagnosticsDemo 同步字段展示与补报判定逻辑,devecocli build 构建通过
- 真机验证(nova 14 Pro):冷启动/多入口拦截/热启动/5 秒退出/补报判定/全场景 3 组全部通过,耗时 126~180ms
- 接入方案文档更新:第 4 节追溯字段与时间拆解、第 6 节 5 秒退出边界、第 8 节验证记录

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-05 19:09:22 +08:00
4 changed files with 273 additions and 13 deletions
+25 -7
View File
@@ -6,11 +6,13 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
Mixed-purpose repository with three distinct areas: Mixed-purpose repository with three distinct areas:
1. **Root level** — Obsidian-friendly documentation workspace for HarmonyOS APM (Application Performance Management) crash analysis of 同花顺期货 (THS Futures). The main report is `HarmonyOS APM分析报告_20260731.md` with supporting screenshots in `report-assets/`. 1. **Root level** — Obsidian-friendly documentation workspace for HarmonyOS APM (Application Performance Management) analysis of 同花顺期货 (THS Futures). Two main reports:
- `HarmonyOS APM分析报告_20260731.md` — crash analysis with supporting screenshots in `report-assets/`.
- `HarmonyOS 启动性能监控接入方案_20260805.md` — cold-start performance monitoring integration plan.
2. **`CrashDiagnosticsDemo/`** — Standalone HarmonyOS demo app demonstrating HiAppEvent crash subscription, persistent crash records, and native crash simulation via NAPI. Full project with its own `CLAUDE.md`. 2. **`CrashDiagnosticsDemo/`** — Standalone HarmonyOS demo app demonstrating HiAppEvent crash subscription, persistent crash records, and native crash simulation via NAPI. Full project with its own `CLAUDE.md`.
3. **`future_entry_demo/`** — Extracted `entry` HAP module from THS Futures. NOT a full workspace — depends on sibling modules (`biz_common`, `biz_trade`, etc.) via `file:../...`. Has its own `CLAUDE.md` and `AGENTS.md`. 3. **`future_entry_demo/`** — Extracted `entry` HAP module from THS Futures. NOT a full workspace — depends on sibling modules (`biz_common`, `biz_trade`, etc.) via `file:../...`. Has its own `CLAUDE.md` and `AGENTS.md`. Contains `src/main/ets/monitor/StartupMonitor.ets` (cold-start performance monitoring implementation referenced by the startup report).
**When working in a subdirectory**, read that subdirectory's own `CLAUDE.md` first — it contains build commands, architecture details, and constraints specific to that project. This file covers the repo-level conventions and cross-cutting concerns. **When working in a subdirectory**, read that subdirectory's own `CLAUDE.md` first — it contains build commands, architecture details, and constraints specific to that project. This file covers the repo-level conventions and cross-cutting concerns. The companion `AGENTS.md` has additional detail on PR guidelines and validation checklists.
## Build & Development Commands ## Build & Development Commands
@@ -22,6 +24,14 @@ There is no top-level build system. Work in the appropriate subdirectory:
The `.claude/settings.local.json` pre-approves `devecocli build *`, `devecocli device *`, and `devecocli run *` commands. The `.claude/settings.local.json` pre-approves `devecocli build *`, `devecocli device *`, and `devecocli run *` commands.
Useful repo-wide checks before committing documentation changes:
```sh
git status --short
rg 'report-assets/' '*.md'
find report-assets -type f | sort
```
## Cross-Cutting Conventions ## Cross-Cutting Conventions
### HarmonyOS / ArkTS ### HarmonyOS / ArkTS
@@ -31,12 +41,20 @@ All HarmonyOS projects in this repo use the **Hvigor** build system with **DevEc
### Documentation (Markdown) ### Documentation (Markdown)
- UTF-8 Markdown with ATX headings (`#`, `##`), fenced code blocks with language tags, pipe tables with right-aligned numeric columns. - UTF-8 Markdown with ATX headings (`#`, `##`), fenced code blocks with language tags, pipe tables with right-aligned numeric columns.
- Name screenshots with a two-digit sequence and short lowercase description (e.g., `08-grafana-native-detail.png`). - Name screenshots with a two-digit sequence and short lowercase description (e.g., `08-grafana-native-detail.png`). Do not reuse an existing image filename for unrelated evidence.
- New reports use descriptive filenames with a date (`YYYYMMDD`). Place images in `report-assets/` or a report-specific subdirectory. - New reports use descriptive filenames with a date (`YYYYMMDD`). Place images in `report-assets/` or a report-specific subdirectory.
- Validate manually: preview all headings/tables/images, recalculate totals against source screenshots, verify no absolute paths or sensitive data leak in. - Use backticks for event types, API names, versions, and identifiers.
### Before Committing Documentation
1. Preview all headings, tables, code blocks, and image captions in a Markdown viewer.
2. Recalculate totals and confirm date ranges, versions, and event classifications against source screenshots.
3. Verify all referenced asset files exist and no local absolute paths or sensitive data are exposed.
4. Ensure stray images at the repo root are moved into `report-assets/` or deleted.
### Git ### Git
- Concise, imperative commit subjects (e.g., `docs: clarify OOM crash accounting`, `assets: add native crash evidence`). - Use Conventional Commits format: `<type>: <summary>` (e.g., `docs: clarify OOM crash accounting`, `feat: 完善启动性能监控流程`, `chore: save current workspace changes`).
- For non-trivial changes, add a blank line followed by `-` list items describing the concrete change, implementation, and verification.
- Keep report text and supporting screenshots in the same commit. - Keep report text and supporting screenshots in the same commit.
- Avoid committing personal Obsidian workspace-state changes unless intentionally shared. - Avoid committing personal Obsidian workspace-state changes (`.obsidian/`) unless intentionally shared.
@@ -6,6 +6,7 @@
import { common } from '@kit.AbilityKit'; import { common } from '@kit.AbilityKit';
import { inspector } from '@kit.ArkUI'; import { inspector } from '@kit.ArkUI';
import { hiAppEvent, hilog } from '@kit.PerformanceAnalysisKit'; import { hiAppEvent, hilog } from '@kit.PerformanceAnalysisKit';
import { deviceInfo } from '@kit.BasicServicesKit';
enum DrawReportState { enum DrawReportState {
IDLE, IDLE,
@@ -19,11 +20,27 @@ enum DrawReportState {
* *
* 监听系统 APP_LAUNCH 事件,并确保一个进程生命周期内只提交一次 * 监听系统 APP_LAUNCH 事件,并确保一个进程生命周期内只提交一次
* reportDrawnCompleted。检测结果同时输出到 hilog 和模拟页面。 * reportDrawnCompleted。检测结果同时输出到 hilog 和模拟页面。
*
* 与 future_entry_demo 的 StartupMonitor.ets 对应:本文件为无依赖演示实现
* (仅 hilog + 页面展示,不做 ELK 上传),追溯字段已同步系统时间拆解字段
* 与 deviceInfo 环境字段;git_commit / is_first_install / build_mode 依赖
* biz_common 与 BuildProfile,演示工程不适用,故未同步。
*/ */
export class StartupDiagnostics { export class StartupDiagnostics {
private static readonly WATCHER_NAME: string = 'startupDiagnosticsWatcher'; private static readonly WATCHER_NAME: string = 'startupDiagnosticsWatcher';
private static readonly LOG_DOMAIN: number = 0xD002; private static readonly LOG_DOMAIN: number = 0xD002;
private static readonly MAX_EVENT_KEYS: number = 20; private static readonly MAX_EVENT_KEYS: number = 20;
/**
* 补报事件判定容差(ms)。
* 本次启动链中 icon_input_time(点击图标)与 watcher 注册时间间隔在毫秒~秒级;
* 系统补投递的上次启动遗留事件,其 icon_input_time 至少早于注册时间 5 秒(事件送达延迟),
* 因此容差取 2s 可稳定区分两者。
*/
private static readonly BACKFILL_TOLERANCE_MS: number = 2000;
/**
* watcher 注册时间戳(epoch ms),用于识别系统补投递的上次启动遗留事件。
*/
private static watcherRegisterTime: number = 0;
private static watcherInitialized: boolean = false; private static watcherInitialized: boolean = false;
private static drawReportState: DrawReportState = DrawReportState.IDLE; private static drawReportState: DrawReportState = DrawReportState.IDLE;
private static capturedEntryPage: string = '未捕获'; private static capturedEntryPage: string = '未捕获';
@@ -59,6 +76,9 @@ export class StartupDiagnostics {
} }
}; };
// 记录注册时间戳,用于区分本次启动事件与系统补投递的上次启动遗留事件
StartupDiagnostics.watcherRegisterTime = Date.now();
try { try {
hiAppEvent.addWatcher(watcher); hiAppEvent.addWatcher(watcher);
StartupDiagnostics.watcherInitialized = true; StartupDiagnostics.watcherInitialized = true;
@@ -230,15 +250,52 @@ export class StartupDiagnostics {
StartupDiagnostics.processedEventKeys.shift(); StartupDiagnostics.processedEventKeys.shift();
} }
const bundleName: string = (params['bundle_name'] as string) ?? '';
const bundleVersion: string = (params['bundle_version'] as string) ?? '';
const animationFinishTime: number = (params['animation_finish_time'] as number) ?? 0;
// 时间拆解字段(与接入方案文档第 4 节一致):
// response_latency 需 API 22+startability_processstart_dur / appattach_to_appforeground_dur 仅冷启动存在
const responseLatency: number = (params['response_latency'] as number) ?? 0;
const startabilityProcessStartDur: number = (params['startability_processstart_dur'] as number) ?? 0;
const appattachToAppForegroundDur: number = (params['appattach_to_appforeground_dur'] as number) ?? 0;
// 补投递事件(上次启动提交绘制但进程在事件送达前退出)的 capturedEntryPage 是当前进程的快照,
// 不能错误归因到本次启动的页面,因此补报事件的入口标记为未知。
const isBackfill: boolean = StartupDiagnostics.isBackfillEvent(iconInputTime);
StartupDiagnostics.launchEventCount++; StartupDiagnostics.launchEventCount++;
StartupDiagnostics.lastLaunchDetail = StartupDiagnostics.lastLaunchDetail =
`extend_time=${extendTime}ms, icon_input_time=${iconInputTime}, ` + `extend_time=${extendTime}ms, icon_input_time=${iconInputTime}, ` +
`start_type=${startType}, process_name=${processName}`; `start_type=${startType}, process_name=${processName}\n` +
`bundle_name=${bundleName}, bundle_version=${bundleVersion}\n` +
`animation_finish_time=${animationFinishTime}ms, response_latency=${responseLatency}ms\n` +
`startability_processstart_dur=${startabilityProcessStartDur}ms, ` +
`appattach_to_appforeground_dur=${appattachToAppForegroundDur}ms\n` +
`device_type=${StartupDiagnostics.getDeviceType()}, ` +
`system_version=${StartupDiagnostics.getSystemVersion()}\n` +
`补报事件=${isBackfill ? '是(entry_page 不归因,按 unknown 处理)' : '否'}`;
StartupDiagnostics.lastAction = '已捕获有效冷启动 APP_LAUNCH'; StartupDiagnostics.lastAction = '已捕获有效冷启动 APP_LAUNCH';
StartupDiagnostics.logInfo(StartupDiagnostics.lastLaunchDetail); StartupDiagnostics.logInfo(StartupDiagnostics.lastLaunchDetail);
StartupDiagnostics.notifyStatusChanged(); StartupDiagnostics.notifyStatusChanged();
} }
/**
* 判断事件是否为系统补投递的上次启动遗留事件。
* 本次启动链中 icon_input_time 与 watcher 注册时间间隔在容差内;
* 补报事件的 icon_input_time 早于注册时间超过容差(至少 5 秒的送达延迟)。
*/
private static isBackfillEvent(iconInputTime: number): boolean {
return iconInputTime < StartupDiagnostics.watcherRegisterTime - StartupDiagnostics.BACKFILL_TOLERANCE_MS;
}
private static getDeviceType(): string {
return deviceInfo.deviceType;
}
private static getSystemVersion(): string {
return deviceInfo.osFullName;
}
private static getStateName(): string { private static getStateName(): string {
switch (StartupDiagnostics.drawReportState) { switch (StartupDiagnostics.drawReportState) {
case DrawReportState.IDLE: case DrawReportState.IDLE:
@@ -45,11 +45,27 @@ time + icon_input_time + start_type + process_name
| 指标名 | `cold_start_time` | | 指标名 | `cold_start_time` |
| 指标值 | 冷启动耗时,单位 ms | | 指标值 | 冷启动耗时,单位 ms |
| 维度 | `start_type``entry_page``app_version` | | 维度 | `start_type``entry_page``app_version` |
| 追溯字段 | `event_time``icon_input_time``process_name``extend_time``animation_finish_time``bundle_version` | | 追溯字段 | `event_time``icon_input_time``process_name``extend_time``animation_finish_time``bundle_version``bundle_name``response_latency``startability_processstart_dur``appattach_to_appforeground_dur``device_type``system_version``build_mode``target_name``git_commit``is_first_install``is_backfill` |
| 分桶 | `[500, 800, 1200, 2000, 3000]` | | 分桶 | `[500, 800, 1200, 2000, 3000]` |
模块名使用小写字母和连字符;指标名不包含连字符。维度控制在必要范围内,避免将用户 ID、完整 URL 等高基数字段作为维度。 模块名使用小写字母和连字符;指标名不包含连字符。维度控制在必要范围内,避免将用户 ID、完整 URL 等高基数字段作为维度。
### 4.1 启动时间拆解
`cold_start_time` 之外,通过系统字段可拆分冷启动各分段,用于定位劣化归属:
```text
离手 ──response_latency──► 动效开始 ──(animation_finish_time response_latency)──► 动效完成 ──(extend_time animation_finish_time)──► 首帧绘制完成
系统响应段 系统动效段 应用可优化段
```
- `response_latency`:离手到动效开始的耗时(需 API 22+),反映系统响应快慢。
- `startability_processstart_dur`:系统启动 Ability 到进程创建完成(仅冷启动),反映系统侧进程启动。
- `appattach_to_appforeground_dur`:进程初始化完成到应用切前台(仅冷启动),反映系统侧应用挂载。
- `extend_time animation_finish_time`:动效完成到应用首帧的耗时,即**应用侧可优化的独占部分**;看板可据此判断劣化发生在系统侧还是应用侧。
环境追溯字段说明:`build_mode`/`target_name` 区分 debug/forTest/Official 环境,避免测试包污染线上看板;`is_first_install``StartManager.getAppInstallStatus()`)标记安装后首次启动,该次耗时显著偏高,不标记会拉高 P50/P95;`git_commit` 精确到代码提交,配合内部版本号做代码级追溯。
## 5. 后台与看板配置 ## 5. 后台与看板配置
按新增 PDF 的自定义 APM 模块流程,在 APM 后台创建 `launch` 模块并启用:全量采样(`sampling_rate: 1000`)、60 秒聚合(`aggre_time: 60`)、累计 100 条触发上报(`aggre_count: 100`)。随后为该模块补充 ELK 索引模板,在 Grafana 建立按版本、启动类型和时间聚合的 P50/P95、超 2 秒占比及分桶分布面板。 按新增 PDF 的自定义 APM 模块流程,在 APM 后台创建 `launch` 模块并启用:全量采样(`sampling_rate: 1000`)、60 秒聚合(`aggre_time: 60`)、累计 100 条触发上报(`aggre_count: 100`)。随后为该模块补充 ELK 索引模板,在 Grafana 建立按版本、启动类型和时间聚合的 P50/P95、超 2 秒占比及分桶分布面板。
@@ -60,6 +76,17 @@ time + icon_input_time + start_type + process_name
`APP_LAUNCH` 的事件解析必须与崩溃事件分开处理:崩溃事件依赖异常和日志字段,启动事件不包含这些字段。上传应异步执行,不阻塞首页渲染;以 `time + icon_input_time + start_type + process_name` 去重,兼容系统重新投递。 `APP_LAUNCH` 的事件解析必须与崩溃事件分开处理:崩溃事件依赖异常和日志字段,启动事件不包含这些字段。上传应异步执行,不阻塞首页渲染;以 `time + icon_input_time + start_type + process_name` 去重,兼容系统重新投递。
### 6.1 启动 5 秒内退出的边界行为
系统 `extend_time` 的定义:手指离手到 `reportDrawnCompleted` 的耗时,若 5 秒内未调用则该值为 0。真机实测(nova 14 Pro)启动后立即退出的行为链:
- **退出时首帧已提交**`reportDrawnCompleted` 已调用,真实 app 首帧通常在 1 秒内):事件正常生成、`extend_time` 有效;若进程在事件送达(约 5 秒延迟)前退出,事件被系统持久化,下次启动 watcher 收到并补报,数据有效。
- **退出时首帧未提交**(启动极慢或立即强杀):`reportDrawnCompleted` 从未调用,5 秒窗口过期,系统未生成有效事件(实测无补投递),不产生上报。
补报事件的 `entry_page` 语义:`capturedEntryPage` 是进程级变量,补报事件到达新进程时该值为新进程的快照(或默认值),不能归因到新启动页面。因此客户端以 watcher 注册时间戳为锚点判定补报事件:`icon_input_time` 早于注册时间超过 2 秒容差(正常启动链间隔为毫秒~秒级,补报至少早 5 秒)即视为补报,`entry_page``'unknown'` 上报,避免错误归因。同时上报字段新增 `is_backfill` 标识(`true` 为补报事件),看板可据此过滤补报数据或单独统计:补报的耗时数据有效可纳入 P50/P95 统计,但 `entry_page` 分布分析应排除。
真机实测确认(2026-08-05):部署新版本后先 force-stop 再冷启动,新进程收到旧进程的遗留事件,判定 `补报事件=是``icon_input_time` 早于注册时间约 4 秒);随后正常冷启动收到自身事件,判定 `补报事件=否`。双向判定均正确,正常启动无误判。
## 7. 验收标准 ## 7. 验收标准
1. 单次冷启动中,隐私页、网络页和首页连续跳转时,`reportDrawnCompleted` 仍只提交一次。 1. 单次冷启动中,隐私页、网络页和首页连续跳转时,`reportDrawnCompleted` 仍只提交一次。
@@ -71,4 +98,86 @@ time + icon_input_time + start_type + process_name
## 8. 已验证结果 ## 8. 已验证结果
`CrashDiagnosticsDemo`nova 14 Pro 真机回归中:未登录冷启动入口为 `StartupLogin``extend_time``158ms`;持久化登录后冷启动入口为 `Index``extend_time``155ms`。网络异常、网络恢复、隐私确认及页面返回场景均保持一次 draw 回调和一次系统提交,后续页面注册被全局保护拦截;`start_type=1` 的热启动事件被正确忽略。 ### 8.1 首次回归(2026-08-05`CrashDiagnosticsDemo`nova 14 Pro
未登录冷启动入口为 `StartupLogin``extend_time``158ms`;持久化登录后冷启动入口为 `Index``extend_time``155ms`。网络异常、网络恢复、隐私确认及页面返回场景均保持一次 draw 回调和一次系统提交,后续页面注册被全局保护拦截;`start_type=1` 的热启动事件被正确忽略。
### 8.2 追溯字段扩展后的真机验证(2026-08-05`CrashDiagnosticsDemo`nova 14 ProOpenHarmony-6.1.1.120
| # | 场景 | 操作 | 结果 |
|---|---|---|---|
| 1 | 已登录冷启动 | `aa force-stop` 后启动 | 入口 `Index`,draw 1 次、提交 1 次、成功 1 次,`extend_time=144ms``start_type=0` |
| 2 | 已登录冷启动(复测) | `aa force-stop` 后启动 | 入口 `Index``extend_time=137ms`,单次提交 |
| 3 | 未登录冷启动 | `bm clean -d` 清数据后启动 | 入口 `StartupLogin``extend_time=152ms`,单次提交 |
| 4 | 多入口连续跳转 | 登录页 → 网络异常页 → 返回 → 隐私确认页 | `StartupNetwork``StartupPrivacy` 重复首帧注册均被拦截(`DrawReportState` 保护),无重复提交 |
| 5 | 时间拆解字段捕获 | 观察三次冷启动的 APP_LAUNCH 详情 | `response_latency=19~24ms``startability_processstart_dur=57~83ms``appattach_to_appforeground_dur=28~33ms`,均为非零有效值 |
| 6 | 热启动(进程存活) | 对运行中进程再次 `aa start` | 无新 `APP_LAUNCH` 事件产生(系统对运行中单例不视为启动),不产生上报 |
验证结论:新增字段在真机全部可捕获且数值合理(`response_latency` 反映系统响应,两个 `*_dur` 拆解系统侧进程启动与挂载耗时)。已登录/未登录冷启动耗时(137~152ms)与首次回归(155/158ms)量级一致,字段扩展未引入回归。
**发现的边界情况**`animation_finish_time` 在真机上为 `0`(系统未填充该字段),因此计算"应用可优化段(`extend_time animation_finish_time`)"时需处理 0 值场景——此时该段等于 `extend_time` 全量,应在看板聚合时兼容。
### 8.3 完整重跑确认(2026-08-05 当天,同设备)
全部场景重新模拟一遍,结果与 8.2 一致:
| # | 场景 | 结果 |
|---|---|---|
| 1 | 清数据后未登录冷启动 | 入口 `StartupLogin``extend_time=161ms`,单次提交 |
| 2 | 登录后同进程跳转 `Index` | `Index` 重复首帧注册被拦截,无重复提交 |
| 3 | 已登录冷启动 | 入口 `Index``extend_time=138ms`,单次提交 |
| 4 | 热启动(进程存活) | 无新 `APP_LAUNCH` 事件,不产生上报 |
### 8.4 连续 3 次冷启动稳定性验证(2026-08-05 当天,同设备)
已登录态下连续 3 次 `force-stop` → 启动,每次均为独立进程、单次 draw 回调、单次 `reportDrawnCompleted` 提交与成功回调、独立有效 `APP_LAUNCH` 事件:
| 次数 | 进程 | 入口 | 提交次数 | `extend_time` | `start_type` |
|---|---|---|---|---|---|
| 1 | 43415 | `Index` | 1 | 131ms | 0 |
| 2 | 43785 | `Index` | 1 | 132ms | 0 |
| 3 | 43914 | `Index` | 1 | 126ms | 0 |
连续启动耗时稳定在 126~138ms,无事件串扰、无重复上报。
### 8.5 全场景矩阵 3 组验证(2026-08-05 当天,同设备)
每组完整链路:清数据 → 未登录冷启动 → 网络异常页跳转(拦截)→ 返回 → 隐私确认页跳转(拦截)→ 返回 → 模拟登录 → `Index` 跳转(拦截)→ 已登录冷启动 → 热启动(无新事件)。3 组结果:
| 组 | 未登录冷启动(入口 `StartupLogin`) | 网络页拦截 | 隐私页拦截 | `Index` 拦截 | 已登录冷启动(入口 `Index` |
|---|---|---|---|---|---|
| 1 | `extend_time=162ms`(进程 44062 | ✓ | ✓ | ✓ | `extend_time=133ms`(进程 44644 |
| 2 | `extend_time=157ms`(进程 44927 | ✓ | ✓ | ✓ | `extend_time=131ms`(进程 45200 |
| 3 | `extend_time=159ms`(进程 45571 | ✓ | ✓ | ✓ | `extend_time=135ms`(进程 45862 |
每组 6 个场景全部按预期执行:单次冷启动只提交一次 `reportDrawnCompleted`,后续页面(网络异常页、隐私页、`Index`)的重复首帧注册全部被拦截,热启动不产生新事件。未登录冷启动耗时稳定在 157~162ms,已登录稳定在 131~135ms,组间一致,验证可复现。
### 8.6 启动 5 秒内退出场景验证(2026-08-05 当天,同设备)
系统 `extend_time` 语义:手指离手 5 秒内未调用 `reportDrawnCompleted` 则该值为 0。真机实测退出场景:
| # | 场景 | 操作 | 结果 |
|---|---|---|---|
| 1 | 启动后 1 秒内退出(首帧已提交) | `aa start` → 1 秒后 `force-stop` | `reportDrawnCompleted` 已在退出前提交,事件正常生成、`extend_time` 有效;事件送达时进程已退出,由后续进程补投递处理 |
| 2 | 启动后立即强杀(首帧未提交) | `aa start` → 立即 `force-stop`(无间隔) | 仅有"已监听 draw"日志,`reportDrawnCompleted` 从未调用;5 秒窗口过期,系统未生成有效事件,重启后无补投递,不产生上报 |
| 3 | 补报判定(修复验证) | 部署新版本后 `force-stop` → 冷启动 | 新进程收到旧进程遗留事件,`icon_input_time` 早于注册时间约 4 秒,判定 `补报事件=是``entry_page``unknown` 处理 |
| 4 | 正常冷启动(修复验证) | 干净 `force-stop` → 启动 | 收到自身事件,判定 `补报事件=否`,无误判 |
结论:首帧未提交时退出由 `extend_time<=0` 过滤兜底不上报;首帧已提交后退出的补报数据有效,修复后补报事件与正常事件可稳定区分(判定逻辑见 6.1)。
### 8.7 全场景(含 5 秒退出)3 组验证(2026-08-05 当天,同设备)
每组 8 步链路:清数据 → 未登录冷启动 → 网络页拦截 → 隐私页拦截 → 登录 `Index` 拦截 → 已登录冷启动 → 热启动 → 立即强杀(首帧未提交)→ 1 秒退出(首帧已提交)→ 补报判定。3 组结果:
| 步骤 | 组 1 | 组 2 | 组 3 |
|---|---|---|---|
| 未登录冷启动(`StartupLogin` | 158ms / 补报=否 | 180ms / 补报=否 | 180ms / 补报=否 |
| 网络页重复注册拦截 | ✓ | ✓ | ✓ |
| 隐私页重复注册拦截 | ✓ | ✓ | ✓ |
| `Index` 重复注册拦截 | ✓ | ✓ | ✓ |
| 已登录冷启动(`Index` | 129ms / 补报=否 | 132ms / 补报=否 | 131ms / 补报=否 |
| 热启动(进程存活) | 无新事件 | 无新事件 | 无新事件 |
| 立即强杀(首帧未提交)后重启 | 被强杀进程无有效事件,重启进程正常捕获 | 同左 | 同左 |
| 1 秒退出(首帧已提交)后重启 | 先收遗留事件补报=是,再收自身事件补报=否 | 同左 | 同左 |
24 个断言(3 组 × 8 步)全部通过,组间一致。补报判定在每组中均双向正确:遗留事件稳定判定为补报(`entry_page` 不归因),自身事件稳定判定为非补报(`entry_page` 正常归因)。
@@ -2,9 +2,12 @@ import hiAppEvent from '@ohos.hiviewdfx.hiAppEvent';
import { HXLog } from 'biz_common/src/main/ets/logger/HXLog'; import { HXLog } from 'biz_common/src/main/ets/logger/HXLog';
import { HXUserService } from 'biz_hxservice'; import { HXUserService } from 'biz_hxservice';
import { ElkService, ElkUploadMessage, ElkUploadMessageBuilder } from 'service_monitor'; import { ElkService, ElkUploadMessage, ElkUploadMessageBuilder } from 'service_monitor';
import { AppConfigManager } from 'biz_common'; import { AppConfigManager, StartManager } from 'biz_common';
import { GIT_COMMIT } from 'biz_common/src/main/ets/utils/debug/GitBuildConfig';
import { inspector } from '@kit.ArkUI'; import { inspector } from '@kit.ArkUI';
import { common } from '@kit.AbilityKit'; import { common } from '@kit.AbilityKit';
import { deviceInfo } from '@kit.BasicServicesKit';
import BuildProfile from 'BuildProfile';
const TAG: string = 'StartupMonitor'; const TAG: string = 'StartupMonitor';
@@ -21,6 +24,17 @@ const TAG: string = 'StartupMonitor';
*/ */
export class StartupMonitor { export class StartupMonitor {
private static readonly MAX_EVENT_KEYS: number = 20; private static readonly MAX_EVENT_KEYS: number = 20;
/**
* 补报事件判定容差(ms)。
* 本次启动链中 icon_input_time(点击图标)与 watcher 注册时间间隔在毫秒~秒级;
* 系统补投递的上次启动遗留事件,其 icon_input_time 至少早于注册时间 5 秒(事件送达延迟),
* 因此容差取 2s 可稳定区分两者。
*/
private static readonly BACKFILL_TOLERANCE_MS: number = 2000;
/**
* watcher 注册时间戳(epoch ms),用于识别系统补投递的上次启动遗留事件。
*/
private static watcherRegisterTime: number = 0;
/** /**
* 首帧绘制完成时快照的入口页面名称,在 onDraw 回调中赋值。 * 首帧绘制完成时快照的入口页面名称,在 onDraw 回调中赋值。
@@ -115,6 +129,8 @@ export class StartupMonitor {
if (StartupMonitor.watcherInitialized) { if (StartupMonitor.watcherInitialized) {
return return
} }
// 记录注册时间戳,用于区分本次启动事件与系统补投递的上次启动遗留事件
StartupMonitor.watcherRegisterTime = Date.now()
hiAppEvent.addWatcher({ hiAppEvent.addWatcher({
name: "startupWatcher", name: "startupWatcher",
appEventFilters: [{ appEventFilters: [{
@@ -192,22 +208,46 @@ export class StartupMonitor {
): void { ): void {
try { try {
const bundleVersion = (eventInfo.params['bundle_version'] as string) ?? ''; const bundleVersion = (eventInfo.params['bundle_version'] as string) ?? '';
const bundleName = (eventInfo.params['bundle_name'] as string) ?? '';
const animationFinishTime = eventInfo.params['animation_finish_time'] as number; const animationFinishTime = eventInfo.params['animation_finish_time'] as number;
// 时间拆解字段(详见接入方案文档第 4 节):
// response_latency 需要 API 22+startability_processstart_dur / appattach_to_appforeground_dur 仅冷启动存在
const responseLatency = eventInfo.params['response_latency'] as number;
const startabilityProcessStartDur = eventInfo.params['startability_processstart_dur'] as number;
const appattachToAppForegroundDur = eventInfo.params['appattach_to_appforeground_dur'] as number;
const iconInputTime = eventInfo.params['icon_input_time'] as number;
// 补投递事件(上次启动提交绘制但进程在事件送达前退出)的 entry_page 无意义,
// capturedEntryPage 是当前进程的快照,不能错误归因到本次启动的页面。
const isBackfillEvent = StartupMonitor.isBackfillEvent(iconInputTime);
const metricPayload: Record<string, Object> = { const metricPayload: Record<string, Object> = {
// 核心指标 // 核心指标
cold_start_time: coldStartTime, cold_start_time: coldStartTime,
// 维度 // 维度
start_type: startType, start_type: startType,
entry_page: StartupMonitor.capturedEntryPage, entry_page: isBackfillEvent ? 'unknown' : StartupMonitor.capturedEntryPage,
app_version: StartupMonitor.getAppVersion(), app_version: StartupMonitor.getAppVersion(),
bundle_version: bundleVersion, bundle_version: bundleVersion,
// 补报标识:true 表示系统补投递的上次启动遗留事件(entry_page 无意义),看板可据此过滤或单独统计
is_backfill: isBackfillEvent,
// 原始系统字段,用于后端校验和去重 // 原始系统字段,用于后端校验和去重
event_time: eventInfo.params['time'], event_time: eventInfo.params['time'],
icon_input_time: eventInfo.params['icon_input_time'], icon_input_time: eventInfo.params['icon_input_time'],
process_name: eventInfo.params['process_name'], process_name: eventInfo.params['process_name'],
extend_time: eventInfo.params['extend_time'], extend_time: eventInfo.params['extend_time'],
animation_finish_time: animationFinishTime, animation_finish_time: animationFinishTime,
// 追溯字段:系统时间拆解
bundle_name: bundleName,
response_latency: responseLatency,
startability_processstart_dur: startabilityProcessStartDur,
appattach_to_appforeground_dur: appattachToAppForegroundDur,
// 追溯字段:客户端环境
device_type: StartupMonitor.getDeviceType(),
system_version: StartupMonitor.getSystemVersion(),
build_mode: StartupMonitor.getBuildMode(),
target_name: StartupMonitor.getTargetName(),
git_commit: StartupMonitor.getGitCommit(),
is_first_install: StartupMonitor.getIsFirstInstall(),
} }
const builder = new StartupElkBuilder('i') const builder = new StartupElkBuilder('i')
@@ -218,15 +258,51 @@ export class StartupMonitor {
const message = new ElkUploadMessage(builder) const message = new ElkUploadMessage(builder)
// 异步上传,避免阻塞启动流程 // 异步上传,避免阻塞启动流程
ElkService.getInstance().pushBusinessLog(message, true) ElkService.getInstance().pushBusinessLog(message, true)
HXLog.i(TAG, `Cold start metric uploaded: ${coldStartTime}ms`) HXLog.i(TAG, `Cold start metric uploaded: ${coldStartTime}ms${isBackfillEvent ? ' (backfill)' : ''}`)
} catch (e) { } catch (e) {
HXLog.e(TAG, `uploadColdStartTime failed: ${JSON.stringify(e)}`) HXLog.e(TAG, `uploadColdStartTime failed: ${JSON.stringify(e)}`)
} }
} }
/**
* 判断事件是否为系统补投递的上次启动遗留事件。
* 本次启动链中 icon_input_time 与 watcher 注册时间间隔在容差内;
* 补报事件的 icon_input_time 早于注册时间超过容差(至少 5 秒的送达延迟)。
*/
private static isBackfillEvent(iconInputTime: number): boolean {
return iconInputTime < StartupMonitor.watcherRegisterTime - StartupMonitor.BACKFILL_TOLERANCE_MS
}
private static getAppVersion(): string { private static getAppVersion(): string {
return AppConfigManager.getInstance().getConfigProvider()?.getInnerVersionFull() ?? 'unknown' return AppConfigManager.getInstance().getConfigProvider()?.getInnerVersionFull() ?? 'unknown'
} }
private static getDeviceType(): string {
return deviceInfo.deviceType
}
private static getSystemVersion(): string {
return deviceInfo.osFullName
}
private static getBuildMode(): string {
return BuildProfile.BUILD_MODE_NAME.toString()
}
private static getTargetName(): string {
return BuildProfile.TARGET_NAME.toString()
}
private static getGitCommit(): string {
return GIT_COMMIT
}
/**
* 是否安装后首次进入(StartManager 在 EntryAbility.onCreate 中已 init
*/
private static getIsFirstInstall(): boolean {
return StartManager.getInstance().getAppInstallStatus()
}
} }
/** /**