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