
这个月我把下班后的碎片时间全砸进了一个代号叫Apollo-11的项目里。它不是登月计划而是一个属于自己的前端性能监控 SDK。为什么起这么唬人的名字因为我的目标很直白像登月一样从零把一块石头送上天。具体来说就是写一个足够轻量、无侵入、可私有化部署的监控小工具解决“页面白屏了没数据”“用户说按钮点了没反应我们一无所知”这类日常痛点。它采集页面性能指标、用户核心行为和运行时错误通过 Beacon 优先的方式异步上报到自己搭的采集服务里。如果你也维护着几个业务页面被“看不见的故障”折磨过这篇拆解应该能帮上忙。1. 项目定位与整体设计为什么叫 Apollo-111.1 要解决的痛点我最早是被一个反馈逼出来的某页面在低端手机上白屏用户群里有人截图但我们没有任何日志。常规监控平台虽然能查报错但重链路、高成本数据也得经过第三方服务。对于需要数据链路自控的团队来说自己维护一套小型采集器非常常见。于是我给这个项目定了三条硬指标接入成本低一行Apollo.init()搞定不污染业务代码。体积可控压缩后目标小于 8KB不成为页面上最重的资源。可二次开发模块之间解耦后续能扩展自定义指标和告警逻辑。这不只是一个采集脚本而是一个“从页面到服务端”的完整闭环。最初的 MVP 版只包含性能统计和错误采集后面才逐步加入用户行为队列与采样策略。每个模块单独抽象就是为了避免以后想换上报协议时要把整个 SDK 推倒重来。1.2 技术选型为什么自己写而不是直接上现成的很多人会问“现成的监控服务不香吗”香但对我来说不够。私有化部署就卡住了很多诉求更别说定制业务指标时的二次开发成本。自己写 SDK 本质上是把一个通用能力做薄整体可控性反而更高。核心选型如下语言TypeScript。采集端会暴露给业务方使用类型提示能避免大量低级错误也方便其他人直接看方法签名维护。浏览器 APIPerformanceObserver Navigation Timing Resource Timing。这套原生能力基本覆盖了核心 Web Vitals不用再手动埋点去算 FP、FCP、LCP。上报优先navigator.sendBeacon页面卸载时也能把数据送出去不支持时降级到fetch的keepalive再不行退成普通 XMLHttpRequest。构建Rollup 或 tsup 输出 ESM 和 UMD 双格式。现代项目用 ESM 可以摇树老页面直接script引入也完全兼容。选型的原则只有一个尽量贴近浏览器原生能力减少依赖。监控代码本身如果引入了大量第三方库反而会拖慢页面甚至成为被监控的“异常源”。1.3 模块化架构Apollo-11 的目录结构按职责切分不搞大泥球config/配置解析和默认值合并。collectors/性能采集、错误采集、行为采集三个采集器。transport/上报队列、数据格式化、协议封装。core/初始化流程、生命周期、公共类型。采集器只负责“拿到原始数据”不关心怎么上报transport 只负责“把数据送到服务端”不关心数据从哪来。这样每次浏览器 API 更新、新增指标时只改对应采集器就行。初始化时Apollo.init()会先合并默认配置然后依次注册各采集器再启动一个定时器来 flush 队列。整个流程非常像一个小型消息管道采集器产生事件事件进入队列队列按策略上报上报结果反馈到统计计数里方便以后扩展重试。2. 核心细节解析与实操要点从采集到上报2.1 性能指标的采集真相性能数据的核心来源是PerformanceObserver。很多人担心浏览器兼容性其实现代浏览器已经支持得不错而且我们可以通过buffered: true读取页面加载早期产生但来不及监听的 entry。LCP 的监听代码大概是这样的const lcpObserver new PerformanceObserver((list) { const entries list.getEntries(); const latestEntry entries[entries.length - 1] as PerformanceEntry; reportMetric(lcp, latestEntry.startTime); }); try { lcpObserver.observe({ type: largest-contentful-paint, buffered: true }); } catch (e) { // 当前浏览器不支持 LCP跳过 }CLS 需要监听layout-shift类型累加所有非预期布局偏移的分数。INP 则要监听event类型的 PerformanceEntry取每次交互的延迟值。还要注意 Web Vitals 的口径会迭代因此 SDK 里最好带上vitalVersion字段方便后续分析数据口径变化。TTFB 的计算也不能省responseStart - requestStart。DOMContentLoaded 和 load 时间可以直接从performance.getEntriesByType(navigation)里拿。这些指标虽然老但排查首屏问题很管用。有一个容易踩坑的细节LCP 元素可能在用户离开页面那一刻才确定也没有稳定触发点。可靠做法是监听visibilitychange当页面进入 hidden 状态时取当前记录里的最终 LCP 值一次性上报。否则很可能永远等不到最后结果。2.2 无侵入采集用户行为光有性能指标还不够白屏时常伴随某些操作路径。行为采集要做的是“无侵入”不能为了监控给业务代码塞一堆埋点。我采用了两种方式通过事件委托捕获click、input、submit等交互事件记录元素标签、类名、文本片段和坐标。监听history.pushState与popstate配合hashchange记录路由变化。代码上要注意不要重复绑定同一实例。每个采集器都有started标志位防止init()被调用两次后事件回调叠加。行为数据在内存里维护一个环形队列最多保留 100 条新数据进来最老的数据被覆盖。当发生错误时把当前队列和错误事件一起打包。这个“黑匣子”设计让每一份错误报告都自带“事故前因后果”。行为采集最大的坑是隐私。千万不要把 input 的真实内容录进队列尤其是密码框。我在采集元素信息时会过滤掉typepassword的节点同时对可能包含手机号、身份证的文本字段做脱敏用正则替换成掩码。这不只是为了合规也是不让数据变成监控人员的负担。2.3 上报策略sendBeacon 是首选但不是万能上报最关键的目标是不阻塞主线程、不丢数据。sendBeacon天然适合这个场景它在浏览器内部调度在页面卸载时也能尽量发出请求。但我要说它并不是银弹。不同浏览器对 Beacon 请求体大小有限制常见上限是 64KB。部分老旧的 WebView 里sendBeacon不可用。Beacon 的 Content-Type 默认是text/plain后端如果按 JSON 解析可能会踩坑。所以 transport 层要做分层降级function send(data: string): boolean { if (typeof navigator ! undefined navigator.sendBeacon) { const blob new Blob([data], { type: application/json }); return navigator.sendBeacon(endpoint, blob); } if (typeof fetch function) { fetch(endpoint, { method: POST, body: data, keepalive: true }).catch(() {}); return true; } const xhr new XMLHttpRequest(); xhr.open(POST, endpoint); xhr.setRequestHeader(Content-Type, application/json); xhr.send(data); return true; }使用 Blob 显式指定 JSON 类型是为了避免服务端因为text/plain不识别。批量上报也能控制请求个数队列攒够 10 条或达到 10 秒间隔就 flush 一次。这样既减少连接开销又能保证实时性。上报数据的队列要综合考虑成功率。成功后清空对应数据失败则保留但最多重试 3 次超过后丢弃并计数。避免失败请求无限重试把页面拖垮这在弱网环境尤其重要。3. 实操过程与核心环节实现把 Apollo-11 跑起来3.1 SDK 初始化配置一个监控 SDK 的初始化体验基本决定了它能否被推广到团队里。我提供的核心接口很简单export interface ApolloOptions { dsn: string; // 采集服务端接口地址 sampleRate?: number; // 抽样率0 到 1默认 1 maxQueueSize?: number; // 内存队列最大长度默认 10 flushInterval?: number; // 上报间隔默认 10_000 ms enablePerformance?: boolean; // 是否采集性能指标 enableBehavior?: boolean; // 是否采集用户行为 enableError?: boolean; // 是否采集 JS 错误 ignoreErrors?: string[]; // 忽略的错误关键词 }内部合并默认值的方式很简单const defaultOptions: ApolloOptions { dsn: , sampleRate: 1, maxQueueSize: 10, flushInterval: 10000, enablePerformance: true, enableBehavior: true, enableError: true, ignoreErrors: [], }; const finalOptions { ...defaultOptions, ...userOptions };抽样率不是越高越好。假设一个日活 10 万的页面你什么都采集一天的原始数据量可能超过几百万条个人搭建的采集服务大概率扛不住。抽样率 0.1也就是只放行 10% 的用户已经足够覆盖大多数稳定性问题。当然错误采集通常不抽样因为错误数据量的量级远低于 PV 类事件。3.2 打包发布UMD 和 ESM 双重导出SDK 的消费场景有两类一类是 Vite/Webpack 项目喜欢import Apollo from apollo-11另一类是 CMS 或传统页面直接想在script标签后用全局变量window.Apollo。为了兼容构建产物得同时输出 ESM 和 UMD。我的构建脚本里用的是 tsup配置很短export default defineConfig({ entry: [src/index.ts], format: [esm, umd], dts: true, minify: true, target: es2018, outExtension({ format }) { return { js: format esm ? .js : .min.js }; }, });有一点要特别注意默认 UMD 导出可能叫Apollo如果页面里已经有同名全局变量会被覆盖。稳妥做法是 SDK 内部支持通过window.APOLLO_SDK_NAME自定义全局名称或者挂到window.__APOLLO_11__这种不易冲突的路径上。实际使用里全局变量冲突比想象中常见。3.3 本地联调与数据链路验证拿到 SDK 后第一步不是接业务而是先验证数据链路。我在本地起了一个最小的 Node.js 采集服务纯原生实现不依赖框架const http require(http); http.createServer((req, res) { if (req.method ! POST) { res.statusCode 405; return res.end(Method Not Allowed); } let body ; req.on(data, (chunk) { body chunk; }); req.on(end, () { try { const payload JSON.parse(body); console.log(JSON.stringify(payload, null, 2)); res.statusCode 200; res.end(ok); } catch (e) { res.statusCode 400; res.end(bad request); } }); }).listen(3000, () { console.log(collector listening on 3000); });然后在页面上初始化script src./dist/apollo-11.min.js/script script Apollo.init({ dsn: http://localhost:3000/ingest }); /script打开浏览器开发者工具的 Network 面板正常加载后应该能看到 POST 请求请求体里是 JSON 数组里面包含页面性能指标。如果没有上报优先看控制台有没有报错、dsn 地址是否可跨域访问。本地开发要处理跨域的话把Access-Control-Allow-Origin: *挂在响应头上即可但生产环境不能这么做。联调还得验证页面刷新、关闭时数据是否来得及发送。最简单的方法是在页面加载后立即关闭标签页然后看采集服务日志。如果日志里什么都没有大概率是 Beacon 降级逻辑没生效或者初始化时机太晚。4. 常见问题与排查技巧实录4.1 sendBeacon 数据“丢失”了这个问题我排查了很久。页面正常上报但采集服务收到的数量明显少于预期。最后发现是发送请求体的 Content-Type 问题服务端用了比较严格的 JSON 解析中间件只接受application/json而sendBeacon默认发送的是text/plain。虽然后端可以兼容但我还是选择在前端把数据包成 Blob 并显式指定 JSON 类型。另一个丢失原因是数据量超过了 Beacon 的单次上限。如果你把整天的所有数据一次性打包发送很容易触发限制。解决方案是每次 flush 时拆包单个请求体控制在 32KB 以内超过就分多个请求发送。这里给出一个简单的拆包逻辑const SLICE_SIZE 32 * 1024; // 32KB function splitPayload(items: unknown[]): unknown[][] { const result: unknown[][] []; let current: unknown[] []; let currentSize 0; for (const item of items) { const itemSize JSON.stringify(item).length; if (currentSize itemSize SLICE_SIZE current.length 0) { result.push(current); current []; currentSize 0; } current.push(item); currentSize itemSize; } if (current.length 0) result.push(current); return result; }请求大小要提前预估别到了上报链路报错才想起来。4.2 监控 SDK 自身的错误导致循环上报错误监控最怕的就是“捕到自己”。比如 SDK 内部某个兼容性检查抛错被全局 error 监听器捕获然后上报上报过程又因为网络异常抛错再次被捕获直接形成死循环。我在错误采集器里加了两个防护错误堆栈的关键文件名中包含 SDK 自身的 chunk 名或apollo关键字就直接忽略。上报函数内部用 try-catch 包裹并且在 catch 后置一个suppressError标志避免同一错误被递归捕获。let inReport false; function safeReport(payload: unknown): void { if (inReport) return; inReport true; try { transport.send(payload); } catch (e) { // 静默处理 } finally { inReport false; } }这个细节看起来小但如果漏了生产环境很可能出现“上报风暴”直接把采集服务打挂。4.3 性能数据上报前页面已经关闭做性能采集时有一个天然矛盾核心指标 LCP 可能要等页面完全加载后才能算准但用户可能提前关闭页面监听回调还没触发。我的处理方法是初始化时设置buffered: true让PerformanceObserver尽可能读取既有数据。尽早调用Apollo.init()例如在head中的内联脚本里而不是等到 DOMContentLoaded。监听visibilitychange当页面进入 hidden 状态时立即手动汇总当前已知指标并 flush。用这种方式“没等到 LCP 最终值”被“上报当前已观测到的最优值”替换。后续聚合时可以对缺少 LCP 的记录单独标记而不是当成完全丢数据。4.4 跨浏览器指标口径不一致不同浏览器对性能 API 的支持差别不小。Safari 以前不支持event类型 PerformanceEntry因此计算不了 INP部分 WebView 的 CLS 值还会因为浏览器版本不同出现偏差。不能指望一个 polyfill 解决所有问题。我采用的方法是特性检测如果浏览器不支持某个 Observer采集器直接返回空数据但在上报体里带上browser.ua和supportedMetrics。后端统计时按浏览器分组看而不是把所有浏览器混在一起算均值。比如 LCP 在不同浏览器的触发时间本身有差异混包很容易得出错误结论。按浏览器维度拆开看比调优页面更能还原真实用户体验。5. 影响范围与后续扩展5.1 这套方案能覆盖什么业务Apollo-11 目前最适合中小团队、对数据所有权敏感、希望快速接入前端监控的场景。它的覆盖面不是“企业级全链路监控”而是“前端运行时最核心的三大件性能、错误、行为”。它解决不了以下问题后端服务器指标、数据库慢查询、接口依赖链路。前端 SDK 只能看到请求从发起到响应的耗时无法知道网关后面发生了什么。如果团队需要的是后端全链路追踪那 Apollo-11 只能作为补充不能替代。但如果是排查白屏、按钮无响应、首屏加载慢、上线后错误率突增这些问题这套方案足够了。它能把响应时间从“找用户聊、猜原因”压缩到“看数据、定位代码影响范围”。5.2 后续扩展方向当前版本的扩展重点有三个告警采集服务端收到数据后对错误率做滑动窗口计算超过阈值触发通知。面板可视化把上报指标存入时间序列数据库用详情页展示某个版本上线后的 LCP 趋势。Source Map 还原发布时把 source map 传到服务端前端只上报压缩后的堆栈后端结合 source map 还原出原始文件、行列号和函数名。尤其最后一项可以说是错误监控从“能用”到“好用”的关键。没有 source map你看到的是apollo-11.min.js:1:23456有了 source map你直接看到的是src/utils/schema.ts:15:10排查效率完全不同。扩展性能采集时还可以加入资源加载瀑布图。PerformanceResourceTiming里有每个静态资源的 DNS、TCP、TLS、请求和响应耗时能做资源级分析。把这些数据存下来是可以直接指导优化策略的。最后分享一个我在这个项目里踩坑后想通的经验做监控不要只盯平均值要看 P75、P95 分位数。均值很容易被极端值拉高或者被大量正常样本掩盖问题。Apollo-11 之后还没跑完但每一步都值得记录下来。如果你也要做类似的工具建议先把“数据链路能通”放在第一位再谈指标多准确、界面多好看。