ELL3N HSU

Available in Chinese only.

← 文章

Frontend Insight:错误快照、请求关联与源码定位的维护边界

展开原文快照、console 生命周期、受控请求、受限搜索、SourceMap、策略发布与删除恢复,解释实现中的失败状态及验证方法。

点开一条错误,工程师通常会继续找前面的日志、同时发生的请求和对应源码。这些上下文能帮助还原过程,却比一个错误计数大得多,也未必需要保存同样长的时间。因此,诊断详情需要自己的采集、存储和读取路径,不能直接沿用普通事件表的全部规则。

这篇沿 Frontend Insight 的诊断实现解释各层如何协作。项目的用途与设计取舍见项目介绍,本文侧重源码阅读、失败分支和维护约束;统一基线见开发者阅读入口。

基础事实与详情为什么分开

例如,一次错误的详情过期了,页面可以不再展示原文,但当天的错误次数不应减少。实现中,一次错误仍只产生一个基础事件,质量计数、影响用户和页面统计都读取这份事实。可选诊断信封另存原文与上下文,独立处理采样、截断、限流、拒绝、过期和删除。

基础事件进入统计,诊断详情经过独立预算和存储后由授权 API 读取基础事件进入统计,诊断详情经过独立预算和存储后由授权 API 读取
基础事实和诊断详情按事件身份关联;图中省略具体重试与后台清理。

宽图可横向滚动。查看原图查看原图

tracker.ts 的 reportError 汇合全局异常、Promise、框架适配和显式上报,调用可选提取器,再沿 captureDiagnostic 检查准入与信封预算。采集入口本身不安装完整诊断模块;应用显式引入 errors、console、requests 或 policy,才获得对应上下文。

服务端由 RuntimeDiagnostics.admit 固定 receipt、摘要与首次到期时间。策略关闭、限流等情形可以抑制详情而保留基础事件;但若准入所需存储不可用,接收端会返回 DIAGNOSTICS_ADMISSION_UNAVAILABLE(503),而不是伪造接收成功。consumer 写基础 raw_events 后再持久化详情并更新 receipt;依赖故障仍走重试语义。分离意味着查询口径与生命周期独立,不意味着基础链路对所有依赖故障都不受影响。

代码入口负责的边界修改时需要保护的行为
web-tracker/src/errors.ts异常提取、快照、身份去重、框架适配不执行业务 getter/toJSON,不修改原错误
event-contract/src/diagnostics.ts信封及字段预算raw 豁免限定在合同规定区域,不放宽普通 payload
server-core/src/runtime-diagnostics.ts准入、存储、授权、审计与到期原文读取前与异步返回前复核能力和状态
server-core/src/error-stack.ts原始栈的派生解析解析失败仍保留原文,不抓取外部 SourceMap
web/src/components/DiagnosticDetail.vue按需展开、展示、复制与清理旧 scope 的响应不回写,复制重新授权

原文快照不能执行被观察对象的逻辑

假设错误上下文里有一个带 getter 的业务对象。采集器为了记录它而读取属性,就可能再次执行 getter;使用 JSON.stringify 也可能调用自定义 toJSON。这些函数可以抛异常、修改状态,甚至发请求。于是,原本只想观察错误的代码,反而参与了业务执行。

实现通过 property descriptor 区分数据属性和访问器,只读取允许的数据值,并限制节点数、深度、记录数和序列化字节。循环引用、不可读属性和被截断字段都有状态说明。得到的是受预算约束的快照,不能声称任意对象都被完整、无损地复制。

原始 name/message/stack 与解析后的字段分开保存。编译栈解析器支持不同浏览器的常见格式;遇到非标准栈可以返回 partial 或 parse_failed,不能通过删除错误或返回空列表隐藏失败。历史基础事件可能只剩脱敏摘要,legacy=true 表明这个边界,不能从摘要补造原始 token、栈或请求正文。

异常身份与限流也需要分开。一个 Error 经过框架处理器后继续传播到全局,当前任务内应避免重复记录;但相同消息的 25 个独立 Error 仍是 25 次基础错误。在默认预算下,单 pageView 的分钟额度允许最多 20 份详情,额外抑制不能改成“只发生了 20 次错误”。基于消息长期去重会把真实重复故障一并吞掉。

console 上下文有两个快照时点

console.ts 使用共享 hub 包装 console 方法,同时保持原 receiver、参数、返回和抛错行为。每个订阅者有独立缓冲及 appId 范围;冲突时清空上下文,销毁时只恢复自己仍然拥有的包装,不能覆盖其他库后来安装的方法。

第一个快照时点是 console 调用。假设业务执行下面的教学示例:

const state = { phase: "submitting" };
console.info(state);
state.phase = "failed";
throw new Error("submit failed");

这里 console 调用时的值是 submitting,后来才被改为 failed。如果日志只留下对象引用,排障时就可能看到后来的值,从而误判执行顺序。前置日志应保留调用当时的 submitting。console-snapshot.ts 因此在记录时保存受控内容,而不是保留可继续变化的对象引用。

第二个快照时点是异常进入提取器。console 回溯窗口先被冻结,再进行异常提取;框架处理器随后打印的日志属于后续上下文,不能追加进已经冻结的那次异常。导航、账户和 tracker 生命周期变化也会改变记录的归属范围。

排查“某条 debug 日志没有显示”时,可以依次看模块是否安装、scope 是否冲突、本地与远端级别、页面与回溯窗口、记录及预算状态、存储与访问权限、UI 过滤条件。每一层都会影响展示,仅从空白列表无法定位采集故障。

请求关联保留当时状态,不后台代读正文

受控 fetch 包装记录请求过程,保持 Promise、Response 和错误的身份及业务行为。HTTP 503 仍是 fetch 的 fulfilled 响应,HTTP 200 也可能承载业务拒绝;请求证据不自行产生第二份 API 分母或 JS 错误。质量事实由原质量采集入口负责,业务结果由应用显式解释。

SDK 不对业务 Response 调用 text() / json(),也不通过 clone() / tee() 后台读取另一份流。应用按自身需求读完文本后,显式提供给采集器,SDK 只保存有限前缀。这样限制了自动采集范围,同时避免为了日志额外消费流;应用自己的正文读取开销依然存在。

// 接口关系示例:policy 已按本地模块与服务端策略初始化。
const fetchEvidence = policy.bind({ requestId: "request-example" }).wrapFetch(fetch);
const response = await fetchEvidence("/api/report");
const text = await response.text(); // 业务读取
policy.responseText(response, text); // 提供已读原文
if (!response.ok) tracker.captureException(new Error("report failed"));

这段示例没有用重新序列化 JSON 冒充响应原文。它也没有自动产生质量请求事实;如果应用使用 quality.observeApi,应按其唯一入口组织同一次请求的统计,避免再重复调用错误统计接口。

假设异常发生时,请求还没有返回,诊断就应该记录 pending。稍后响应和正文到达,是另一时点的信息。晚回调可以按本地观察 ID 更新仍在同页缓冲里的记录,但已发送的历史信封要保留原样。因此,请求发起、收到响应、应用取得正文和异常发生分别记录。换页或策略重建后,旧 collector 的回调也不能进入新缓冲。

getContext 提供真实 operation/workflow 实例,bind 固定显式参数。相同 step 名称或时间接近不能证明请求属于某一次操作;没有依据时只能显示时间邻近。本地观察 ID 也不是服务端 requestId,跨端关联需要应用实际传递对应身份。

大小预算按序列化结果计算

JavaScript 字符数不能替代 UTF-8 JSON 字节数。中文、转义符和状态元数据都影响结果,因此请求快照先按编码后的字节选择前缀,再标注截断状态,避免留下半个 UTF-16 代理对。

当前合同包含多层约束:

范围上限维护含义
传输批次128 KiB、50 个事件多个合法小事件也可能令批次超额
基础事件8 KiB,不含 diagnostic诊断扩展不放宽普通 payload
诊断信封64 KiB各字段独立合格仍需检查总和
原始栈16 KiB、32 帧、cause 深度 4保存截断原因,原文与解析结果分开
console50 条、24 KiB 总量、2 KiB/条条数和字节共同限制
请求集合20 条、32 KiB 总量、24 KiB/条请求/响应 body 各 16 KiB,头等字段另有预算
关联 timeline30 条、8 KiB 总量、2 KiB/条不能用另一数组名绕开总预算

预算对应 constants.ts 与 diagnosticBudgetError,项目策略还能进一步收紧。XHR 目前只有已有质量观察能力,没有自动采原文的诊断 wrapper;SSE、不可见响应头、应用未读正文和未安装模块的内容也不能凭空补全。

采集、存储、解析和访问分别报告状态

层次状态示例读者可以作出的判断
采集complete / truncated / rate_limited / too_large / unavailable这次快照是否完整,哪里没取得
存储与生命周期pending / ready / write_failed / expired / disabled / policy_suppressed / deleted详情是否曾准入,是否仍可读取
解析parsed / partial / parse_failed / unavailable / not_applicable原始栈能否派生结构化帧
访问401 / 403,页面显示 forbidden当前身份不能读取,不代表内容不存在

这些只是各层状态示例,不是一个可以互换的枚举。项目成员能看质量统计,不等于拥有 diagnostics.read。详情接口使用 no-store,但禁缓存不能替代服务端授权;每次读取、复制和导出仍需核对当前能力。

页面关闭详情、失焦或切 scope 时清理原文;打开期间按周期续验权限,异步返回前也检查范围。这里没有推送式瞬间擦除承诺,也无法远程收回已下载文件。错误、栈、console、请求和源码由文本 token 组件着色,不使用 v-html;行号与高亮样式不进入复制或导出的原文。

搜索和导出怎样保持同一权限边界

diagnostic-search.ts 先从基础事实有界选择候选,再读未过期且 ready 的 receipt 与详情。搜索词在服务内匹配解码字符串,不拼入 SQL、普通日志或 URL。读前及返回前重新授权,预算失败有明确错误,不能伪装成空结果。

稳定实例链接以项目和 eventId 定位,不依赖用户此前打开过某一页列表。错误组影响统计仍从基础事实按 eventId 去重,再统计 user、device 和 page route;诊断采样、TTL 和写失败不作为计数过滤条件。界面显示的历史首次,是当前原始事实保留窗口内的最早观察,不是无限历史。

导出使用即时 POST 和流式文件响应,同时要求 read 与 export。输出中 storedEnvelopeJson 是已经保存的信封文本,diagnostic 是解析对象,view 是派生展示。格式化一个解析对象会改变文本表示,不能将它冒充原始存储字符串。浏览器下载使用同一会话刷新机制,释放 blob,并用 AbortController 与 generation 拒绝旧范围的异步回写。

SourceMap 先匹配构建,再解释位置

压缩文件同名并不代表内容相同。源码定位以项目、release、build、资源标识及 JavaScript/map 摘要共同确定身份;同一身份对应不同内容时拒绝覆盖。URL 的 origin/query 也不能随意去掉,否则两个不同资源可能被误判为同一个。

维护路径从构建 manifest 与 tools/sourcemaps.mjs 上传 CLI 开始,经过 API、sourcemaps.ts、受限解析 worker,再进入详情展示。数据库保存元数据和凭证哈希,map 本体保存在私有文件卷;worker 只接收已授权、有界的 map 字符串与行列,不执行上传代码,不从栈 URL 或本地源码目录自动补读。

输入条件应保留的结果
release/build 或摘要不符拒绝匹配,原始编译栈继续存在
map 元数据存在但文件缺失storage_missing,不能假装没有该错误
找到映射但没有 sourcesContent文件位置可用,源码片段不可用
cause 或子错误含多个帧逐帧报告映射状态,不能只看顶层成功
只有 read 权限不自动获得导出原文或整 map 的权限

改动这条路径后,可在隔离环境用固定构建测试错身份、缺文件与无 sourcesContent。恢复应验证实际源码位置与权限,而不只是验证文件名重新出现。

策略发布、同步与服务器准入

诊断策略的实际范围是本地已安装并授权模块、服务端已发布配置、硬预算三者的交集。服务端允许 console 不代表浏览器自动安装 console wrapper;本地不采成功响应正文时,远端宽松配置也不会替应用开启。

管理员发布策略时,DiagnosticOperations.publish 在事务内比较 revision,防止旧编辑覆盖新版本。可选 policy.ts 通过既有 workflow-config 接口在首次及每 60 秒同步,5 秒超时,响应限制 64 KiB。失败保留最后有效配置;首次没有有效配置时使用明确的兼容默认。服务端对新详情继续独立执行最新准入,不能信任客户端自报 policyVersion。

策略改变会重建上下文 collector 并清空旧缓冲;进行中的业务请求继续执行,晚到结果不进入新 collector。已经冻结、排队或等待重试的事件不改写,保留采集时策略版本。这也解释了管理操作的区别:关闭策略影响后续采集,不会抹掉已经保存的历史;撤销权限阻止读取,并不删除内容;修改保留期只影响新准入记录。需要删除历史时,应走专门的删除路径。

SDK 的本地 60 秒窗口与服务端按接收时钟的整分钟桶并不完全重合,最终以服务端准入为准;重试同一 receipt 不重复占额。修改策略也不会重置已有额度。

删除与恢复要防止内容复活

单实例删除先写持久墓碑,再修改 receipt;即使 receipt 尚不存在也保留墓碑,以阻止晚到 consumer 将它重新写成 ready。读取、搜索、复制和导出都检查删除状态,后台 mutation 再做物理回收。它不会删除基础 raw_events 或对应 SourceMap,因此基础错误率不受影响,源码文件有自己的生命周期。

诊断详情 TTL 和后台清理是异步过程。逻辑不可读先成立,物理磁盘回收需要另行观察。维护页显示的逻辑 JSON 字节、receipt 数与私有制品元数据也不是单项目实际磁盘;共享 ClickHouse 的磁盘不能从这些数直接精确归属,无延迟样本显示 null。

恢复旧备份可能带回旧授权、上传凭证或删除前的内容。需要同一停写窗口内的 MySQL 元数据、ClickHouse 详情和私有 map;恢复后保持入口隔离,核对成员、read/export、策略、墓碑和文件,再补执行恢复点之后的撤权、删除与轮换。旧服务不理解墓碑时,回滚后不能开放其原文读取端点。

客户端健康快照也有范围限制:它是自报的模块状态和累计计数,不能当作授权依据、全量客户端覆盖率或精确事件总量。一次查询失败应保留错误,不能用零值让维护页面看起来正常。

验证需要对应具体承诺

对象提取单测检查 getter/toJSON、循环和预算;真实浏览器测试检查 console 包装、Promise 身份、流式响应与生命周期;服务集成检查授权、TTL、写失败与重放;隔离恢复检查 receipt、详情、map 和删除状态。它们不能互相替代。

维护入口包括:

pnpm exec vitest run packages/web-tracker/test/errors.test.ts packages/web-tracker/test/console.test.ts packages/server-core/test/error-stack.test.ts
pnpm exec playwright test -c tests/config/playwright.diagnostics.config.ts

第一组需要源码依赖,第二组还需要指南所定义的真实服务和合成业务场景。本文迁移没有重新执行应用验收。已有诊断验证记录分别记录 Chromium/WebKit 真链路、Firefox 栈格式 fixture 与 React 适配器回调,不能把后两项写成完整浏览器或 React 渲染树验收。

例如该记录在 709db4bb、4 vCPU、约 16.7 GB 内存的隔离 GitHub runner 上,用异常 200 events/s 持续 10 秒,得到接收 p95 52.89 ms、详情持久化 p95 9,562 ms。它说明接收和可查询是不同阶段;短时合成测试不能给出长期生产容量承诺。采样、保留期与存储预算仍需根据目标环境确定。