fix: silently disable EventTracker and SessionRecorder when analytics app is not enabled (#1599)

This commit is contained in:
Konsti Wohlwend 2026-06-16 10:42:57 -07:00 committed by GitHub
parent 689b05fdaa
commit 7b824526fd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 144 additions and 2 deletions

View File

@ -385,4 +385,45 @@ describe("EventTracker", () => {
tracker.stop();
}
});
it("silently disables when server responds with ANALYTICS_NOT_ENABLED", 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.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();
}
});
});

View File

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

View File

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

View File

@ -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<typeof setInterval> | 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 {