Silently ignore network errors in EventTracker and SessionRecorder flush (#1638)

This commit is contained in:
Konsti Wohlwend 2026-06-22 16:12:54 -07:00 committed by GitHub
parent 845487adee
commit 591554c3b1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 111 additions and 1 deletions

View File

@ -387,6 +387,39 @@ describe("EventTracker", () => {
}
});
it("silently ignores network errors caused by ad blockers", async () => {
vi.useFakeTimers();
document.body.innerHTML = "<button>Click me</button>";
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 = "<button>Click me</button>";

View File

@ -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;
}

View File

@ -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();

View File

@ -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;
}