From bfaf9e17f39292c6d8e8cb5abc20c6bb93c21c0b Mon Sep 17 00:00:00 2001 From: zp Date: Tue, 25 Aug 2026 22:18:43 +0800 Subject: [PATCH] =?UTF-8?q?fix(web-record):=20=E5=AF=BC=E5=87=BA=E5=A4=B1?= =?UTF-8?q?=E8=B4=A5=E5=8A=A0=E9=80=80=E9=81=BF=E4=B8=8E=E7=86=94=E6=96=AD?= =?UTF-8?q?,=E5=88=AB=E6=8A=8A=E6=8E=A7=E5=88=B6=E5=8F=B0=E5=92=8C?= =?UTF-8?q?=E4=B8=BB=E7=BA=BF=E7=A8=8B=E5=88=B7=E7=88=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 现场(2026-08-25,甲方内网):到不了配置的 collector,原实现每 5 秒把同一批 事件原样重发,失败 429 次没停过。后果不只是日志难看: - 控制台 429 条同样的 error,把真正有用的报错淹了; - 每一轮都白做一次 JSON.stringify + 压缩(最多 500 条 rrweb 事件), 主线程被反复占用,页面肉眼可见地卡; - 宿主 SPA 每次路由变化都会调 record(),失败态下等于反复重新武装。 四条对策: 1. 连败指数退避(5s→10s→20s…封顶 5 分钟),挡在定时器层。flush() 本身 语义不变——直接调仍然立即尝试,drainFlush/stop 的行为没动。 2. 连败到 5 次熔断:停采集、清缓冲,并打一条写明端点和处置办法的日志 (封闭环境该去设 SOFTPROBE_BASE_URL,而不是让它一直重试)。 3. 只在连败的第一条打日志,后续静默——日志本身就是故障放大器。 4. 熔断后 record() 拒绝重新武装。 单次失败仍然保留缓冲(可能只是网络抖动),这一点刻意不改。 测试:新增 4 条,在旧实现上全红(4 failed / 8 passed),新实现 12 全绿; 既有 8 条(含 keeps buffer when flush fails)自始至终绿,原语义没被动。 Co-Authored-By: Claude Opus 5 --- packages/web-record/src/sdk.ts | 65 ++++++++++++++++- packages/web-record/tests/sdk.test.ts | 100 ++++++++++++++++++++++++++ 2 files changed, 164 insertions(+), 1 deletion(-) diff --git a/packages/web-record/src/sdk.ts b/packages/web-record/src/sdk.ts index 0648b60..c434d40 100644 --- a/packages/web-record/src/sdk.ts +++ b/packages/web-record/src/sdk.ts @@ -24,6 +24,10 @@ import type { } from "./types.js"; const MAX_EVENTS = 500; +/** 连续导出失败多少次后彻底放弃采集(见 {@link RecordSdk.noteExportFailure})。 */ +const MAX_CONSECUTIVE_EXPORT_FAILURES = 5; +/** 退避上限。不可达时不应一直定期打点。 */ +const BACKOFF_MAX_MS = 5 * 60_000; const COOKIE_NAME = "_sp_vid"; const COOKIE_MAX_AGE = 365 * 24 * 60 * 60; @@ -54,6 +58,12 @@ export class RecordSdk implements WebRecordController { private systemInfo: Record | null = null; private stopHandle: RecordingHandle | null = null; private saving = false; + /** 连续导出失败次数;成功即归零。 */ + private consecutiveFailures = 0; + /** 退避到期时间戳(ms);在此之前定时器不再尝试导出。 */ + private nextAttemptAt = 0; + /** 熔断标志:连败到上限后置位,导出与采集一起停。 */ + private exportDisabled = false; private fetchImpl: typeof fetch; /** Serializes setSessionId drains so overlapping navigations cannot race. */ private sessionSwitch: Promise = Promise.resolve(); @@ -134,6 +144,9 @@ export class RecordSdk implements WebRecordController { record(opts?: { tags?: Tags }): RecordingHandle | null { if (!this.enabled) return null; + // 熔断后不再重新武装。宿主(SPA)每次路由变化都会调 record(),不挡住的话 + // 每导航一次就白起一轮 rrweb 采集,下一个 tick 再自己停掉。 + if (this.exportDisabled) return null; if (this.stopHandle) return this.stopHandle; if (opts?.tags) this.setTags(opts.tags); @@ -153,6 +166,13 @@ export class RecordSdk implements WebRecordController { } as Parameters[0]); const intervalId = setInterval(() => { + // 熔断后连采集一起停:导出不出去还继续录,只是白耗主线程和内存。 + if (this.exportDisabled) { + this.stop(); + return; + } + // 退避未到,这轮跳过。这里刻意不打日志——日志本身就是故障放大器。 + if (Date.now() < this.nextAttemptAt) return; void this.flush(); if (this.events.length) { const last = this.events[this.events.length - 1]; @@ -239,6 +259,7 @@ export class RecordSdk implements WebRecordController { } async flush(): Promise { + if (this.exportDisabled) return false; if (!this.enabled || this.events.length === 0 || this.saving) return true; this.saving = true; // Splice out the batch BEFORE await so events recorded while export is in @@ -286,16 +307,58 @@ export class RecordSdk implements WebRecordController { ); } this.batchIndex = batchIndex + 1; + this.consecutiveFailures = 0; + this.nextAttemptAt = 0; return true; } catch (error) { - console.error("[@softprobe/web-record] Failed to flush recording:", error); this.events.unshift(...batch); + this.noteExportFailure(error); return false; } finally { this.saving = false; } } + /** + * 导出失败后的退避与熔断。 + * + *

现场实证(2026-08-25,甲方内网):到不了配置的 collector,原实现每 5 秒把同一批 + * 事件原样重发,失败 429 次没停过 —— 控制台被刷爆,主线程每轮还白做一次 + * `JSON.stringify` + 压缩,页面肉眼可见地卡。三条对策: + *

    + *
  • 连败指数退避,不再固定间隔硬打;
  • + *
  • 只在连败的第一条打日志,后续静默;
  • + *
  • 连败到上限就停掉采集并清空缓冲,再打一条写明端点和处置办法。
  • + *
+ * 单次失败仍然保留缓冲(可能只是网络抖动),这一点不变。 + */ + private noteExportFailure(error: unknown): void { + this.consecutiveFailures += 1; + if (this.consecutiveFailures === 1) { + console.error( + "[@softprobe/web-record] Failed to flush recording:", + error, + ); + } + if (this.consecutiveFailures >= MAX_CONSECUTIVE_EXPORT_FAILURES) { + this.exportDisabled = true; + this.events.length = 0; + console.error( + `[@softprobe/web-record] Giving up after ${this.consecutiveFailures} ` + + `consecutive export failures; session recording disabled. ` + + `Endpoint: ${this.otlpEndpoint}. On a closed network, point ` + + `SOFTPROBE_BASE_URL at a reachable collector or disable web recording.`, + ); + return; + } + this.nextAttemptAt = + Date.now() + + Math.min( + BACKOFF_MAX_MS, + this.intervalMs * 2 ** (this.consecutiveFailures - 1), + ); + } + private async collectSystemInfo(): Promise< Record > { diff --git a/packages/web-record/tests/sdk.test.ts b/packages/web-record/tests/sdk.test.ts index 8bc805b..98b433a 100644 --- a/packages/web-record/tests/sdk.test.ts +++ b/packages/web-record/tests/sdk.test.ts @@ -12,6 +12,8 @@ type SdkInternals = { events: unknown[]; systemInfo: Record; saving: boolean; + consecutiveFailures: number; + exportDisabled: boolean; }; function internals(sdk: InstanceType): SdkInternals { @@ -270,4 +272,102 @@ describe("RecordSdk.init", () => { expect(sessionIds).toEqual(["ses_old"]); expect(internals(sdk).events).toEqual([]); }); + // ——— 导出失败的退避与熔断 ——— + // 回归背景(2026-08-25 现场):collector 不可达时,原实现每 5 秒原样重发同一批事件, + // 失败 429 次没停过 —— 控制台被刷爆、主线程每轮白做一次序列化+压缩,页面明显卡。 + // 下面三条在旧实现上都是红的。 + + function failingSdk(sessionId: string) { + const sdk = RecordSdk.init({ + publicKey: "token", + baseUrl: "http://thelake.test", + sessionId, + manual: true, + }) as InstanceType; + internals(sdk).systemInfo = {}; + return sdk; + } + + it("logs once per failure streak instead of once per attempt", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => new Response("nope", { status: 503 })), + ); + const err = vi.spyOn(console, "error").mockImplementation(() => {}); + const sdk = failingSdk("ses_streak"); + + for (let i = 0; i < 3; i += 1) { + internals(sdk).events = [{ type: 4, timestamp: 1, eventIndex: i + 1 }]; + await sdk.flush(); + } + + // 旧实现:每次失败都 console.error 一条 → 3。 + expect(err).toHaveBeenCalledTimes(1); + }); + + it("gives up after a failure streak: drops the buffer and stops exporting", async () => { + const fetchMock = vi.fn(async () => new Response("nope", { status: 503 })); + vi.stubGlobal("fetch", fetchMock); + vi.spyOn(console, "error").mockImplementation(() => {}); + const sdk = failingSdk("ses_giveup"); + + for (let i = 0; i < 5; i += 1) { + internals(sdk).events = [{ type: 4, timestamp: 1, eventIndex: i + 1 }]; + await sdk.flush(); + } + + // 旧实现:事件被 unshift 回去,永远留着 → 长度 1。 + expect(internals(sdk).exportDisabled).toBe(true); + expect(internals(sdk).events).toHaveLength(0); + + // 熔断后不许再打网络,也不许再吞掉新事件。 + const callsBefore = fetchMock.mock.calls.length; + internals(sdk).events = [{ type: 4, timestamp: 1, eventIndex: 99 }]; + await sdk.flush(); + expect(fetchMock.mock.calls.length).toBe(callsBefore); + expect(internals(sdk).events).toHaveLength(1); + }); + + it("resets the failure streak after a successful export", async () => { + let healthy = false; + vi.stubGlobal( + "fetch", + vi.fn(async () => + healthy + ? new Response("{}", { status: 200 }) + : new Response("nope", { status: 503 }), + ), + ); + vi.spyOn(console, "error").mockImplementation(() => {}); + const sdk = failingSdk("ses_recover"); + + internals(sdk).events = [{ type: 4, timestamp: 1, eventIndex: 1 }]; + await sdk.flush(); + expect(internals(sdk).consecutiveFailures).toBe(1); + + healthy = true; + internals(sdk).events = [{ type: 4, timestamp: 1, eventIndex: 2 }]; + await sdk.flush(); + + // 旧实现:根本没有这个计数 → undefined。 + expect(internals(sdk).consecutiveFailures).toBe(0); + expect(internals(sdk).exportDisabled).toBe(false); + }); + it("refuses to re-arm recording after giving up", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => new Response("nope", { status: 503 })), + ); + vi.spyOn(console, "error").mockImplementation(() => {}); + const sdk = failingSdk("ses_rearm"); + + for (let i = 0; i < 5; i += 1) { + internals(sdk).events = [{ type: 4, timestamp: 1, eventIndex: i + 1 }]; + await sdk.flush(); + } + expect(internals(sdk).exportDisabled).toBe(true); + + // 宿主每次路由变化都会调 record();熔断后必须拒绝,否则每导航一次白起一轮采集。 + expect(sdk.record()).toBeNull(); + }); });