35-异常排查没上下文-把TraceId和错误边界补完整
第35篇保存失败只剩一句日志用 TraceContext 把入口、阶段和错误证据串起来摘要异常排查最怕日志里只有一句save failed。这类日志看似有错误实际回答不了用户从哪个入口进来、操作哪条数据、失败在哪个阶段、是否重试过、能不能复现。更稳的做法是把一次用户操作包装成TraceContext入口生成上下文Service 和 Repository 只补阶段错误边界统一转换日志输出结构化且脱敏。这样排障时能按 traceId 串起完整证据而不是靠回忆和猜测。我遇到过一个课程草稿保存问题。用户反馈“点保存没反应”日志里只有save error。同一天有三种入口都可能触发保存首页快捷编辑、通知进入继续编辑、离线恢复自动保存。最初谁也说不清是哪条路径失败。后来补上 traceId、entry、businessId、stage 和 attempt再看同一次失败才发现不是按钮事件丢了而是离线恢复入口把旧草稿写回 RDB 时附件索引为空导致 Repository 阶段失败。这篇文章解决四个实际问题为什么“多打几行日志”仍然无法复盘一次失败操作。如何把入口、业务对象、阶段和重试次数收进TraceContext。如何让 Service、Repository、错误边界和日志输出都使用同一个上下文。如何在不泄露手机号、Token、正文内容的前提下给排障同事留下可复现材料。先还原一次没有上下文的失败现场实际项目里保存失败通常不是一个按钮的问题而是一条跨页面、跨服务、跨持久化的链路。下面这类日志看起来“有错误”但没法直接定位日志片段缺了什么排障结果save failed入口、业务 id、阶段只能猜是哪个页面触发database error当前写的是主表还是附件表Repository 内部仍然要继续翻代码network timeout是否来自自动重试无法判断用户是否连点invalid draft草稿版本和来源不知道是用户编辑还是离线恢复一个能复盘的错误日志至少要回答五个问题谁触发、从哪进、操作什么、走到哪一步、这一步失败后页面怎么处理。缺一个字段排障就会退回到口头沟通。TraceContext 是证据包不是日志前缀很多项目已经有 traceId但只是把它拼在日志前面。这样只能串起几行文本无法表达入口、业务对象和重试轮次。更稳的做法是让TraceContext成为一次操作的证据包。exportenumSaveEntry{HomeQuickEdithome_quick_edit,NotificationResumenotification_resume,OfflineRecoveryoffline_recovery,DetailEditdetail_edit}exportinterfaceTraceContext{traceId:stringentry:SaveEntry businessId:stringuserIdHash:stringattempt:numberstartedAt:number}exportfunctioncreateTraceContext(entry:SaveEntry,businessId:string,userIdHash:string):TraceContext{constcontext:TraceContext{traceId:save_${Date.now()}_${businessId},entry,businessId,userIdHash,attempt:1,startedAt:Date.now()}returncontext}这里不放手机号、Token、正文内容。businessId用来定位业务对象entry用来区分入口attempt用来判断是否重试。真正敏感的数据留在受控数据源里不进普通日志。阶段名要贴近真实写入点只有 traceId 还不够。保存链路通常会经过页面校验、业务组装、本地写入、附件索引、摘要刷新等阶段。阶段名如果写成save依然不够排障。exportenumSaveStage{PageValidatepage.validate,ServiceNormalizeservice.normalize,RepositoryBaseRowrepository.base_row,RepositoryAttachmentIndexrepository.attachment_index,SummaryRefreshsummary.refresh}exportinterfaceTraceMark{traceId:stringstage:SaveStage elapsedMs:numbermessage:string}exportfunctionmarkStage(trace:TraceContext,stage:SaveStage,message:string):TraceMark{constmark:TraceMark{traceId:trace.traceId,stage,elapsedMs:Date.now()-trace.startedAt,message}returnmark}阶段名越贴近真实 owner后面越容易分工。看到repository.attachment_index就知道问题不在按钮点击也不在页面校验而在附件索引写入。页面生成上下文但不负责排障细节页面知道用户从哪里触发保存也知道当前编辑的是哪条业务数据。它适合生成入口上下文但不适合决定每个内部阶段怎么打日志。Componentstruct CourseDraftEditorPage{Statesaving:booleanfalseStateerrorText:stringprivatesaveService:CourseDraftSaveServicecreateCourseDraftSaveService()asyncsaveDraftFromButton():Promisevoid{if(this.saving){return}this.savingtruethis.errorTextconsttracecreateTraceContext(SaveEntry.DetailEdit,this.currentDraftId(),this.currentUserHash())constresultawaitthis.saveService.save(this.buildPayload(),trace)this.savingfalseif(!result.ok){this.errorTextresult.error.userMessage}}}页面只处理交互状态保存中、失败文案、按钮防重复。它不拼接日志不直接吞异常也不重新生成 traceId。这样从页面到 Service 的上下文是连续的。Service 负责业务阶段不重新造 traceIdService 的价值是把“保存”拆成业务动作校验、归一化、持久化、刷新摘要。每一步都使用同一个TraceContext。exportclassCourseDraftSaveService{constructor(privatereadonlyrepository:CourseDraftRepository,privatereadonlysummaryService:CourseSummaryService,privatereadonlylogger:TraceLogger){}asyncsave(payload:CourseDraftPayload,trace:TraceContext):PromiseSaveResult{this.logger.info(markStage(trace,SaveStage.ServiceNormalize,start normalize draft))constnormalizednormalizeDraftPayload(payload)if(!normalized.valid){returnSaveResultFactory.fail(createAppError(trace,SaveStage.PageValidate,DRAFT_INVALID))}constwriteResultawaitthis.repository.save(normalized.data,trace)if(!writeResult.ok){returnwriteResult}awaitthis.summaryService.refreshAfterSave(normalized.data.draftId,trace)returnSaveResultFactory.ok()}}这段代码有两个边界。第一Service 不知道日志最后写到哪里只调用TraceLogger。第二Repository 失败后直接返回结构化结果不把底层异常原样丢给页面。Repository 补充数据阶段和底层错误Repository 最清楚底层写入到了哪一步。这里应该补具体阶段而不是只抛一个database failed。exportclassCourseDraftRepository{constructor(privatereadonlystore:CourseDraftStore,privatereadonlylogger:TraceLogger){}asyncsave(record:CourseDraftRecord,trace:TraceContext):PromiseSaveResult{try{this.logger.info(markStage(trace,SaveStage.RepositoryBaseRow,write draft base row))awaitthis.store.writeBaseRow(record)this.logger.info(markStage(trace,SaveStage.RepositoryAttachmentIndex,write attachment index))awaitthis.store.writeAttachmentIndex(record.draftId,record.attachmentIds)returnSaveResultFactory.ok()}catch(err){constappErrorcreateAppError(trace,SaveStage.RepositoryAttachmentIndex,DRAFT_WRITE_FAILED)this.logger.error(appError)returnSaveResultFactory.fail(appError)}}}如果后续发现失败并不总在附件索引可以把 catch 拆到更细的阶段。关键是让阶段和真实写入点对应排查时不用再猜 Repository 内部走到哪一步。错误边界要分用户文案和排障字段用户需要知道“操作失败可以重试”。排障人员需要知道错误码、阶段、入口、业务 id 和 traceId。这两类信息不要混在一起。exportinterfaceAppError{traceId:stringcode:stringstage:SaveStage entry:SaveEntry businessId:stringuserMessage:stringdebugMessage:stringretryable:boolean}exportfunctioncreateAppError(trace:TraceContext,stage:SaveStage,code:string):AppError{consterror:AppError{traceId:trace.traceId,code,stage,entry:trace.entry,businessId:trace.businessId,userMessage:保存失败请稍后重试,debugMessage:${stage}:${code},retryable:true}returnerror}userMessage不包含堆栈和内部字段。debugMessage也不放正文、手机号或 Token。这样错误既能给用户一个明确反馈也能给排障留下结构化线索。日志输出要结构化并脱敏日志不要只拼字符串。结构化日志更方便按 traceId、stage、entry 聚合也更容易做脱敏。exportinterfaceTraceLogEvent{traceId:stringlevel:info|errorstage:stringentry:stringbusinessId:stringelapsedMs:numbermessage:string}exportclassTraceLogger{info(mark:TraceMark):void{constevent:TraceLogEvent{traceId:mark.traceId,level:info,stage:mark.stage,entry:,businessId:,elapsedMs:mark.elapsedMs,message:mark.message}this.write(event)}error(error:AppError):void{constevent:TraceLogEvent{traceId:error.traceId,level:error,stage:error.stage,entry:error.entry,businessId:error.businessId,elapsedMs:0,message:error.debugMessage}this.write(event)}privatewrite(event:TraceLogEvent):void{console.info(JSON.stringify(sanitizeTraceEvent(event)))}}示例里用console.info表达输出边界实际项目可以换成自己的hilog包装器或文件日志实现。业务代码不要直接调用底层日志 API而是统一走TraceLogger。脱敏规则要默认保守排障字段越完整越要防止隐私扩散。脱敏不是靠每个调用点自觉而是日志出口统一处理。constSECRET_KEY_PATTERN/(phone|token|password|content|body|authorization)/iexportfunctionsanitizeTraceEvent(event:TraceLogEvent):TraceLogEvent{constsafe:TraceLogEvent{traceId:event.traceId,level:event.level,stage:event.stage,entry:event.entry,businessId:maskBusinessId(event.businessId),elapsedMs:event.elapsedMs,message:SECRET_KEY_PATTERN.test(event.message)?[masked]:event.message}returnsafe}exportfunctionmaskBusinessId(value:string):string{if(value.length6){returnvalue}return${value.slice(0,3)}***${value.slice(value.length-3)}}普通日志里只需要定位对象不需要暴露完整对象 id。正文、Token、手机号这类字段不应该进入日志事件即使命中也要在出口变成[masked]。排障材料要能交给别人复现当线上失败需要另一个工程师接手时不能只给一句“用户说保存失败”。一份可复盘材料应该包含 traceId、入口、业务 id 摘要、失败阶段、错误码、用户动作和可重试状态。字段示例用途traceIdsave_1780000000000_draft1024串联同一次操作entryoffline_recovery判断入口差异stagerepository.attachment_index定位代码 ownercodeDRAFT_WRITE_FAILED聚合相同错误retryabletrue决定页面交互这份材料不需要包含正文内容。只要链路字段完整排障同事就能按入口复现按阶段找代码按错误码聚合影响面。交付前用搜索把散乱日志收回来改完 TraceContext 后要反向搜索旧日志和裸异常避免新旧两套排障方式并存。rg-nsave failed|save error|database error|console\\.error|console\\.infoentry features common rg-ntraceId|TraceContext|TraceLogger|SaveStage|AppErrorentry features common rg-nphone|token|password|authorization|content|bodyentry features common第一条找旧日志字符串第二条确认新边界是否覆盖主要保存链路第三条看敏感字段有没有进入普通日志。命中后不要只删日志要判断这个位置是否应该补 stage。验证清单验证项操作预期结果首页快捷保存失败模拟 Repository 写入失败日志能看到home_quick_edit和失败 stage通知入口继续编辑从通知进入后保存entry 能区分notification_resume离线恢复自动保存构造旧草稿恢复trace 指向offline_recovery连续点击保存快速点两次保存按钮页面只发起一次保存或 attempt 可区分敏感字段扫描搜索 Token、手机号、正文普通日志不出现完整敏感内容交给别人复现只提供 traceId 和 stage另一个工程师能定位到对应 owner如果只能做一次回归优先走“离线恢复 - 附件索引失败 - 页面提示可重试 - 日志按 traceId 串联”这条链路。它能覆盖入口、Service、Repository、错误边界和日志脱敏。常见问题和处理方式现象常见原因处理方式有 traceId 仍查不出原因没有 stage 和 entry把阶段和入口加入 TraceContext同一操作出现多个 traceId每层都重新生成只在入口创建后续传递用户看到内部错误userMessage 和 debugMessage 混用错误边界分开两类字段日志泄露正文业务 payload 直接进日志出口统一脱敏正文不进事件重试后无法区分没有 attemptTraceContext 加重试次数Repository 阶段太粗只写repository.save按主表、附件、摘要拆 stage这些问题不需要一次性引入很重的日志系统。先把核心保存链路收口就能明显提升排障效率。小结异常日志要能复盘一次操作异常排查不是把日志数量加倍而是让日志能回答关键问题。入口生成TraceContextService 和 Repository 传递同一个上下文阶段名贴近真实 owner错误边界拆开用户文案和排障字段日志出口统一脱敏。这样再看到“保存失败”时就能知道用户从哪里进来、操作哪条数据、失败在哪一步、下一步该找谁修。

相关新闻