From 591554c3b16b68d60cbbeb0321d8eb897d2fc89d Mon Sep 17 00:00:00 2001 From: Konsti Wohlwend Date: Mon, 22 Jun 2026 16:12:54 -0700 Subject: [PATCH] Silently ignore network errors in EventTracker and SessionRecorder flush (#1638) --- .../implementations/event-tracker.test.ts | 33 ++++++++++++ .../apps/implementations/event-tracker.ts | 7 ++- .../implementations/session-replay.test.ts | 52 +++++++++++++++++++ .../apps/implementations/session-replay.ts | 20 +++++++ 4 files changed, 111 insertions(+), 1 deletion(-) diff --git a/packages/template/src/lib/hexclave-app/apps/implementations/event-tracker.test.ts b/packages/template/src/lib/hexclave-app/apps/implementations/event-tracker.test.ts index 698a48c43..dbb0f02d3 100644 --- a/packages/template/src/lib/hexclave-app/apps/implementations/event-tracker.test.ts +++ b/packages/template/src/lib/hexclave-app/apps/implementations/event-tracker.test.ts @@ -387,6 +387,39 @@ describe("EventTracker", () => { } }); + it("silently ignores network errors caused by ad blockers", async () => { + vi.useFakeTimers(); + document.body.innerHTML = ""; + + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const sentBodies: string[] = []; + const tracker = new EventTracker({ + projectId: "internal", + sendBatch: async (body) => { + sentBodies.push(body); + return Result.error(new TypeError("Failed to fetch")); + }, + }); + + try { + tracker.start(); + + await advancePastFlush(); + expect(sentBodies).toHaveLength(1); + expect(warnSpy).not.toHaveBeenCalled(); + + // Unlike ANALYTICS_NOT_ENABLED, ad blocker errors do NOT disable the + // tracker — subsequent flushes continue attempting delivery. + document.querySelector("button")?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + await advancePastFlush(); + expect(sentBodies).toHaveLength(2); + expect(warnSpy).not.toHaveBeenCalled(); + } finally { + tracker.stop(); + warnSpy.mockRestore(); + } + }); + it("silently disables when client interface returns ANALYTICS_NOT_ENABLED as an error", async () => { vi.useFakeTimers(); document.body.innerHTML = ""; diff --git a/packages/template/src/lib/hexclave-app/apps/implementations/event-tracker.ts b/packages/template/src/lib/hexclave-app/apps/implementations/event-tracker.ts index 0c52fbbbc..746939f25 100644 --- a/packages/template/src/lib/hexclave-app/apps/implementations/event-tracker.ts +++ b/packages/template/src/lib/hexclave-app/apps/implementations/event-tracker.ts @@ -4,7 +4,7 @@ import { cssEscapeIdent } from "@hexclave/shared/dist/utils/dom"; import { buildElementsChain, ELEMENTS_CHAIN_MAX_DEPTH } from "@hexclave/shared/dist/utils/elements-chain"; import { runAsynchronously } from "@hexclave/shared/dist/utils/promises"; import { Result } from "@hexclave/shared/dist/utils/results"; -import { generateUuid, isAnalyticsNotEnabledError } from "./session-replay"; +import { generateUuid, isAdBlockerNetworkError, isAnalyticsNotEnabledError } from "./session-replay"; const FLUSH_INTERVAL_MS = 10_000; const MAX_EVENTS_PER_BATCH = 50; @@ -507,6 +507,11 @@ export class EventTracker { this._disable(); return; } + // Ad blockers commonly block analytics endpoints, causing network + // errors. These are expected and should not pollute the console. + if (isAdBlockerNetworkError(res.error)) { + return; + } console.warn("EventTracker flush failed:", res.error); return; } diff --git a/packages/template/src/lib/hexclave-app/apps/implementations/session-replay.test.ts b/packages/template/src/lib/hexclave-app/apps/implementations/session-replay.test.ts index ed643d93c..663fa5b11 100644 --- a/packages/template/src/lib/hexclave-app/apps/implementations/session-replay.test.ts +++ b/packages/template/src/lib/hexclave-app/apps/implementations/session-replay.test.ts @@ -45,6 +45,58 @@ describe("analytics option JSON conversion", () => { }); describe("SessionRecorder flush", () => { + it("silently ignores network errors caused by ad blockers", async () => { + vi.useFakeTimers(); + + const storageKey = `hexclave:session-replay:v1:test-project`; + localStorage.setItem(storageKey, JSON.stringify({ + session_id: "test-session", + created_at_ms: Date.now(), + last_activity_ms: Date.now(), + })); + + const sentBodies: string[] = []; + const recorder = new SessionRecorder( + { + projectId: "test-project", + sendBatch: async (body) => { + sentBodies.push(body); + return Result.error(new TypeError("Failed to fetch")); + }, + }, + {}, + ); + + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + try { + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + (recorder as any)._events = [{ type: 2, timestamp: Date.now(), data: {} }]; + + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call + (recorder as any)._tick(); + await vi.advanceTimersByTimeAsync(0); + + expect(sentBodies).toHaveLength(1); + expect(warnSpy).not.toHaveBeenCalled(); + + // Unlike ANALYTICS_NOT_ENABLED, ad blocker errors do NOT disable the + // recorder — subsequent flushes continue attempting delivery. + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + (recorder as any)._events = [{ type: 3, timestamp: Date.now(), data: {} }]; + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call + (recorder as any)._tick(); + await vi.advanceTimersByTimeAsync(0); + expect(sentBodies).toHaveLength(2); + expect(warnSpy).not.toHaveBeenCalled(); + } finally { + recorder.stop(); + warnSpy.mockRestore(); + localStorage.removeItem(storageKey); + vi.useRealTimers(); + } + }); + it("silently disables when client interface returns ANALYTICS_NOT_ENABLED as an error", async () => { vi.useFakeTimers(); diff --git a/packages/template/src/lib/hexclave-app/apps/implementations/session-replay.ts b/packages/template/src/lib/hexclave-app/apps/implementations/session-replay.ts index 8d0ced92b..713d0da28 100644 --- a/packages/template/src/lib/hexclave-app/apps/implementations/session-replay.ts +++ b/packages/template/src/lib/hexclave-app/apps/implementations/session-replay.ts @@ -166,6 +166,21 @@ export function isAnalyticsNotEnabledError(error: unknown): boolean { return KnownErrors.AnalyticsNotEnabled.isInstance(error); } +/** + * Whether the error looks like a network failure caused by an ad blocker or + * similar extension blocking analytics requests. These are expected in + * production and should be silently ignored rather than logged as warnings. + */ +export function isAdBlockerNetworkError(error: unknown): boolean { + if (error instanceof Error) { + return error.message.includes("Failed to fetch") + || error.message.includes("NetworkError") + || error.message.includes("Load failed") + || error.message.includes("network connection"); + } + return false; +} + export class SessionRecorder { private _started = false; private _cancelled = false; @@ -274,6 +289,11 @@ export class SessionRecorder { this._disable(); return; } + // Ad blockers commonly block analytics endpoints, causing network + // errors. These are expected and should not pollute the console. + if (isAdBlockerNetworkError(res.error)) { + return; + } captureWarning("SessionRecorder.flush", res.error); return; }