mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-19 21:00:40 +08:00
fix: silently disable EventTracker and SessionRecorder when analytics app is not enabled (#1599)
This commit is contained in:
parent
689b05fdaa
commit
7b824526fd
@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@ -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 {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user