HarmonyOS HiLog 日志治理实战:分级、脱敏、采样与线上排查

很多线上问题不是没有日志,而是日志太乱:同一个模块多个 tag,错误日志没有 traceId,用户手机号直接打印,灰度包一打开日志量暴涨。真正可用的日志体系要同时满足四件事:开发能定位、线上能控制、隐私不泄露、复盘能串起来。

这篇文章围绕 HiLog 写一套可落地的日志治理方法,把“随手打印”改成“可排查的工程证据”。

请添加图片描述

1. 资料定位:日志不是输出越多越好

HarmonyOS 提供 HiLog 能力和 hilogtool 等调试工具,适合在开发和排查阶段观察运行状态。项目里要先约定日志的职责边界:Debug 看过程,Info 看关键路径,Warn 看可恢复风险,Error 看失败结果,Fatal 只用于不可继续的严重问题。

资料/工具 用途 本文落地点
HiLog 应用侧输出分级日志 统一封装
hilogtool 设备侧查看日志 按 tag/domain 过滤
隐私规范 避免敏感字段明文 脱敏函数
发布开关 控制线上日志量 采样与级别

建议在工程文档里写清楚:当前验证环境、API 版本、是否真机、是否 release 包、日志级别开关在哪里。否则同一段日志在 debug 包能看到,release 包看不到,排查时很容易误判。

2. 总流程:先定字段,再写封装

请添加图片描述

日志治理不要从“哪里加一行打印”开始,而要从字段开始。最少需要 domain、tag、level、traceId、message、costMs、errorCode。字段稳定后,后续才能做聚合、过滤和复盘。

export type LogLevel = 'DEBUG' | 'INFO' | 'WARN' | 'ERROR'

export interface AppLogEvent {
  level: LogLevel
  domain: number
  tag: string
  traceId: string
  message: string
  costMs?: number
  errorCode?: string
}

这个接口拥有日志事件的结构边界。它不关心日志最终写到哪里,只规定业务层必须提供哪些字段。它防止日志格式随人变化;下一层统一决定脱敏、采样和输出级别。

3. 结构归属:业务层不要直接拼日志

请添加图片描述

业务代码直接拼字符串会带来两个问题:格式不统一,敏感字段容易漏。更稳的结构是业务层只提交事件,日志层负责格式化,排查层按 traceId 回放。

import { hilog } from '@kit.PerformanceAnalysisKit'

const DEFAULT_DOMAIN = 0x1901

export class AppLogger {
  static info(tag: string, traceId: string, message: string, costMs?: number): void {
    this.write({ level: 'INFO', domain: DEFAULT_DOMAIN, tag, traceId, message, costMs })
  }

  static warn(tag: string, traceId: string, message: string, errorCode?: string): void {
    this.write({ level: 'WARN', domain: DEFAULT_DOMAIN, tag, traceId, message, errorCode })
  }

  private static write(event: AppLogEvent): void {
    const safeMessage = LogMasker.mask(event.message)
    hilog.info(event.domain, event.tag, 'trace=%{public}s level=%{public}s msg=%{public}s cost=%{public}d code=%{public}s',
      event.traceId, event.level, safeMessage, event.costMs ?? -1, event.errorCode ?? '-')
  }
}

AppLogger 的边界是统一输出,不处理业务决策。它验证的是日志字段和安全消息,预防随手打印导致的格式分裂。下一层会补采样策略,避免线上日志过多。

4. 脱敏:手机号、Token、身份证不能进明文日志

日志泄露经常不是大事故开始的,而是一句临时排查代码没删。建议把脱敏做成默认行为,业务侧想输出原文也没有入口。

export class LogMasker {
  static mask(input: string): string {
    return input
      .replace(/1\d{10}/g, value => `${value.slice(0, 3)}****${value.slice(7)}`)
      .replace(/[A-Za-z0-9_\-]{24,}/g, '***TOKEN***')
      .replace(/\b\d{17}[\dXx]\b/g, value => `${value.slice(0, 4)}************${value.slice(-2)}`)
  }
}

这段代码处理的是日志安全边界。它不负责判断字段是不是敏感,只对输出文本做最后一道保护。它能减少手机号、长 token、身份证号误入日志的风险;下一步还要通过代码审查禁止直接调用底层日志。

5. traceId:让一次业务操作能串起来

没有 traceId 的日志只能按时间猜。登录、支付、同步、上传这类链路都应该从入口生成 traceId,并贯穿页面、服务、网络、缓存。

export class TraceFactory {
  static create(prefix: string): string {
    const now = Date.now()
    const random = Math.floor(Math.random() * 10000)
    return `${prefix}-${now}-${random}`
  }
}

export async function runLogin(account: string): Promise<void> {
  const traceId = TraceFactory.create('login')
  AppLogger.info('LoginPage', traceId, `submit account=${account}`)
  await AuthService.login(traceId, account)
  AppLogger.info('LoginPage', traceId, 'login finished')
}

这段代码的输入是一次业务动作。TraceFactory 不需要知道业务细节,只负责生成可搜索的链路号。runLogin 把 traceId 传到服务层,防止页面日志和网络日志断开。

6. 分级:Error 只写失败结果,Warn 写可恢复风险

日志级别混乱会导致两个后果:线上错误数虚高,真正问题被噪声淹没。建议先把场景分清楚,再决定级别。

场景 推荐级别 示例
页面进入 INFO page_enter
接口耗时偏高 WARN api_slow
token 过期可刷新 WARN token_refresh
支付失败 ERROR pay_failed
本地缓存未命中 DEBUG cache_miss
export function logApiResult(traceId: string, api: string, costMs: number, code: number): void {
  if (code >= 500) {
    AppLogger.warn('HttpClient', traceId, `api=${api} server_error`, String(code))
    return
  }
  if (costMs > 1200) {
    AppLogger.warn('HttpClient', traceId, `api=${api} slow`, 'SLOW_API')
    return
  }
  AppLogger.info('HttpClient', traceId, `api=${api} ok`, costMs)
}

这里把失败、慢请求、正常请求分开处理。它预防所有异常都打成 Error,也避免正常请求刷屏。下一层如果接入聚合平台,可以直接按 errorCodetag 分组。

7. 采样:线上日志要可控

release 包里不应该无限输出详细日志。采样策略要和模块、级别、用户范围绑定:Error 全量保留,Info 按比例,Debug 默认关闭。

日志开关建议来自配置层,而不是散落在业务代码里。这样灰度期间可以提高某个模块的 Info 比例,正式放量后再收紧,不需要每次都改页面逻辑。

export const releaseLogSwitch: LogSwitch = {
  enableDebug: false,
  infoSampleRate: 0.05,
  forceTraceIds: []
}

export const grayLogSwitch: LogSwitch = {
  enableDebug: false,
  infoSampleRate: 0.25,
  forceTraceIds: ['login-1783000000000-1024']
}

这段配置拥有发布环境的日志策略边界。它不包含具体业务,只控制输出比例和定向链路。它能防止线上日志量失控,也能在灰度排查时保留足够证据。

export interface LogSwitch {
  enableDebug: boolean
  infoSampleRate: number
  forceTraceIds: string[]
}

export class LogSampler {
  constructor(private readonly config: LogSwitch) {}

  shouldWrite(event: AppLogEvent): boolean {
    if (event.level === 'ERROR' || event.level === 'WARN') return true
    if (this.config.forceTraceIds.includes(event.traceId)) return true
    if (event.level === 'DEBUG') return this.config.enableDebug
    return Math.random() < this.config.infoSampleRate
  }
}

LogSampler 的边界是输出决策,不修改日志内容。它信任配置中心下发的比例,但保留 forceTraceIds 给定向排查使用。这样线上既能控量,又能针对某个用户链路放开观察。

8. 排查回放:从一条用户反馈还原完整链路

拿到用户反馈后,排查顺序建议固定:先查 traceId,没有 traceId 就按时间、设备、页面缩小范围;再看 Warn/Error;最后补看 Info 阶段耗时。

export interface LogReplayStep {
  time: string
  tag: string
  level: LogLevel
  message: string
}

export function findLastRiskStep(steps: LogReplayStep[]): LogReplayStep | undefined {
  return steps
    .filter(item => item.level === 'WARN' || item.level === 'ERROR')
    .sort((a, b) => a.time.localeCompare(b.time))
    .pop()
}

这个函数用于复盘,不用于线上实时逻辑。它的输入是一组已收集日志,输出最后一个风险步骤。它能帮助读者快速判断应该先看网络、缓存、权限还是页面状态。

9. 常见问题排查表

现象 可能原因 查看方式 修复建议
日志查不到 tag/domain 不统一 按模块搜索 收口到 AppLogger
用户信息明文 业务直接拼字符串 搜索手机号样式 默认脱敏
release 日志过多 没有采样 看日志量趋势 Info 采样、Debug 关闭
错误无法串联 缺少 traceId 按时间手工拼 入口生成 traceId
Error 太多 级别滥用 看错误分布 Warn/Error 边界重写

这里的重点是把日志问题当成工程问题,而不是“多打几行”。日志字段、级别、脱敏、采样、回放都闭环,线上问题才有定位效率。

10. 发布前验收清单

验收项 通过标准
统一入口 业务代码不直接调用底层日志
敏感字段保护 手机号、token、证件号自动脱敏
traceId 贯穿 页面、服务、网络日志能串联
级别清晰 Warn/Error 不混用
日志量可控 release 包有采样策略
可复盘 用户反馈能还原关键路径

发布前建议抽三个真实链路做回放:登录失败、接口超时、支付取消。每条链路都能看到入口、请求、结果和错误码,日志体系才算可用。

日志回放复现场景:给读者一组可执行核验

日志治理要验证 traceId 能不能串起页面、服务和网络。没有回放能力的日志,只是分散的文本。

核验维度 读者需要准备的证据
输入 页面入口、用户动作、关键参数
过程 日志、状态变化、异常分支
输出 UI 表现、回调结果、持久化结果
回归 同场景重复执行后的结果
interface LogReplayCase {
  traceId: any
  pageStep: any
  serviceStep: any
  networkStep: any
}

const replay92: LogReplayCase = {
  traceId: 'sample',
  pageStep: 'sample',
  serviceStep: 'sample',
  networkStep: 'sample',
}

function assertReplay92(item: LogReplayCase): void {
  if (item.traceId.length === 0) throw new Error('日志缺少 traceId')
}

这组核验强调日志链路回放,读者可以用它确认页面、服务和网络日志是否被同一个 traceId 串起。

日志串联回放表:把文章方法变成可复现动作

日志治理要证明能回放一次真实操作。建议从页面点击开始,经过服务层、网络层、解析层,最后回到 UI 状态,全部使用同一个 traceId。

回放动作 核验方式
页面日志 准备输入、执行操作、记录结果、给出结论
服务日志 准备输入、执行操作、记录结果、给出结论
网络日志 准备输入、执行操作、记录结果、给出结论
解析日志 准备输入、执行操作、记录结果、给出结论

日志治理要以回放一次用户操作为验收目标。读者可以从页面点击开始,沿着服务、网络、解析、UI 更新逐层查同一个 traceId。只要某一层断开,排查时就会回到按时间猜测。把日志串起来后,问题定位速度通常比单纯增加日志数量更稳定。

11. 小结:日志治理的目标是可复盘

HiLog 的价值不在于输出多少行,而在于关键时刻能不能回答三个问题:用户做了什么,系统走到哪一步,失败发生在哪一层。把分级、脱敏、采样和 traceId 做成统一封装后,日志就不再是临时调试文本,而是线上稳定性的基础设施。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐