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 {