From 7b824526fd8f5bd991540d4575f48a2bc9d7c015 Mon Sep 17 00:00:00 2001 From: Konsti Wohlwend Date: Tue, 16 Jun 2026 10:42:57 -0700 Subject: [PATCH] fix: silently disable EventTracker and SessionRecorder when analytics app is not enabled (#1599) --- .../implementations/event-tracker.test.ts | 41 ++++++++++ .../apps/implementations/event-tracker.ts | 16 ++++ .../implementations/session-replay.test.ts | 75 ++++++++++++++++++- .../apps/implementations/session-replay.ts | 14 ++++ 4 files changed, 144 insertions(+), 2 deletions(-) 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 a3af0cab3..13e5fd22c 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 @@ -385,4 +385,45 @@ describe("EventTracker", () => { tracker.stop(); } }); + + it("silently disables when server responds with ANALYTICS_NOT_ENABLED", 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.ok(new Response( + JSON.stringify({ code: "ANALYTICS_NOT_ENABLED", error: "Analytics is not enabled for this project." }), + { + status: 400, + headers: { "x-stack-known-error": "ANALYTICS_NOT_ENABLED" }, + }, + )); + }, + }); + + try { + tracker.start(); + + // First flush sends the initial page-view event; server rejects it. + await advancePastFlush(); + expect(sentBodies).toHaveLength(1); + + // No console.warn should have been emitted. + expect(warnSpy).not.toHaveBeenCalled(); + + // After disabling, new events should not accumulate or trigger further + // flushes. + document.querySelector("button")?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + await advancePastFlush(); + expect(sentBodies).toHaveLength(1); + } finally { + tracker.stop(); + warnSpy.mockRestore(); + } + }); }); 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 f8b085174..3dc2667a0 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 @@ -108,6 +108,7 @@ type TrackedEvent = { export class EventTracker { private _started = false; private _cancelled = false; + private _disabled = false; private _detachListeners: (() => void) | null = null; private _flushTimer: ReturnType | null = null; private _events: TrackedEvent[] = []; @@ -173,6 +174,7 @@ export class EventTracker { } private _pushEvent(event: TrackedEvent) { + if (this._disabled) return; this._events.push(event); this._approxBytes += JSON.stringify(event).length; if (this._events.length >= MAX_EVENTS_PER_BATCH || this._approxBytes >= MAX_APPROX_BYTES_PER_BATCH) { @@ -464,6 +466,8 @@ export class EventTracker { } private async _flush(options: { keepalive: boolean }) { + if (this._disabled) return; + // A keepalive flush means the page is unloading — a click still awaiting // dead-click classification led to that unload, so it is alive by // definition and ships unmarked. @@ -500,6 +504,18 @@ export class EventTracker { } if (!res.data.ok) { + // If the server tells us analytics is not enabled for this project, + // silently disable the tracker — no point retrying or warning the user. + const knownError = res.data.headers.get("x-hexclave-known-error") ?? res.data.headers.get("x-stack-known-error"); + if (knownError === "ANALYTICS_NOT_ENABLED") { + this._disabled = true; + if (this._flushTimer !== null) { + clearInterval(this._flushTimer); + this._flushTimer = null; + } + this._teardown(); + return; + } console.warn("EventTracker flush failed:", res.data.status, await res.data.text()); } } 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 eb46f4da1..bec79cddf 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 @@ -1,5 +1,8 @@ -import { describe, expect, it } from "vitest"; -import { analyticsOptionsFromJson, analyticsOptionsToJson, getSessionReplayOptions } from "./session-replay"; +// @vitest-environment jsdom + +import { describe, expect, it, vi } from "vitest"; +import { Result } from "@hexclave/shared/dist/utils/results"; +import { analyticsOptionsFromJson, analyticsOptionsToJson, getSessionReplayOptions, SessionRecorder } from "./session-replay"; describe("session replay options", () => { it("enables replays by default", () => { @@ -39,3 +42,71 @@ describe("analytics option JSON conversion", () => { expect(roundTripped?.replays?.blockClass).toEqual(/stack-sensitive/u); }); }); + +describe("SessionRecorder flush", () => { + it("silently disables when server responds with ANALYTICS_NOT_ENABLED", async () => { + vi.useFakeTimers(); + + // Seed localStorage with a valid session so _flush doesn't fail on getOrRotateSession + 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.ok(new Response( + JSON.stringify({ code: "ANALYTICS_NOT_ENABLED", error: "Analytics is not enabled for this project." }), + { + status: 400, + headers: { "x-stack-known-error": "ANALYTICS_NOT_ENABLED" }, + }, + )); + }, + }, + {}, + ); + + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + try { + // Inject an event directly into the recorder's buffer to test flush behavior + // without needing rrweb. We access private fields for testing purposes. + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + (recorder as any)._events = [{ type: 2, timestamp: Date.now(), data: {} }]; + + // Manually trigger a tick (which calls _flush) + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call + (recorder as any)._tick(); + await vi.advanceTimersByTimeAsync(0); + + // One batch should have been sent + expect(sentBodies).toHaveLength(1); + + // No console.warn about "SessionRecorder flush failed" should have been emitted + const flushWarnings = warnSpy.mock.calls.filter( + (args) => typeof args[0] === "string" && args[0].includes("SessionRecorder") + ); + expect(flushWarnings).toHaveLength(0); + + // After disabling, pushing new events and triggering another tick should not send + // 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(1); + } finally { + recorder.stop(); + warnSpy.mockRestore(); + localStorage.removeItem(storageKey); + vi.useRealTimers(); + } + }); +}); 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 1b138e4da..13be57ec8 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 @@ -164,6 +164,7 @@ export type SessionRecorderDeps = { export class SessionRecorder { private _started = false; private _cancelled = false; + private _disabled = false; private _stopRecording: (() => void) | null = null; private _detachListeners: (() => void) | null = null; private _flushTimer: ReturnType | null = null; @@ -231,6 +232,7 @@ export class SessionRecorder { } private async _flush(options: { keepalive: boolean }) { + if (this._disabled) return; if (this._events.length === 0) return; // Prevent concurrent in-flight HTTP requests. When a flush is already // in-flight, a second batch could race on the server (both call @@ -268,6 +270,18 @@ export class SessionRecorder { } if (!res.data.ok) { + // If the server tells us analytics is not enabled for this project, + // silently disable the recorder — no point retrying or warning the user. + const knownError = res.data.headers.get("x-hexclave-known-error") ?? res.data.headers.get("x-stack-known-error"); + if (knownError === "ANALYTICS_NOT_ENABLED") { + this._disabled = true; + if (this._flushTimer !== null) { + clearInterval(this._flushTimer); + this._flushTimer = null; + } + this._stopCurrentRecording(); + return; + } captureWarning("SessionRecorder.flush", new Error(`SessionRecorder flush failed: ${res.data.status} ${await res.data.text()}`)); } } finally {