Mute ANALYTICS_NOT_ENABLED warning

This commit is contained in:
Konstantin Wohlwend 2026-06-17 12:17:17 -07:00
parent cc68e36260
commit 07449da7e9
4 changed files with 47 additions and 59 deletions

View File

@ -1,5 +1,6 @@
// @vitest-environment jsdom
import { KnownErrors } from "@hexclave/shared/dist/known-errors";
import { Result } from "@hexclave/shared/dist/utils/results";
import { afterEach, describe, expect, it, vi } from "vitest";
import { EventTracker } from "./event-tracker";
@ -386,7 +387,7 @@ describe("EventTracker", () => {
}
});
it("silently disables when server responds with ANALYTICS_NOT_ENABLED", async () => {
it("silently disables when client interface returns ANALYTICS_NOT_ENABLED as an error", async () => {
vi.useFakeTimers();
document.body.innerHTML = "<button>Click me</button>";
@ -396,28 +397,19 @@ describe("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" },
},
));
return Result.error(new KnownErrors.AnalyticsNotEnabled());
},
});
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();
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
expect((tracker as any)._flushTimer).toBeNull();
// 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);

View File

@ -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 } from "./session-replay";
import { generateUuid, isAnalyticsNotEnabledError } from "./session-replay";
const FLUSH_INTERVAL_MS = 10_000;
const MAX_EVENTS_PER_BATCH = 50;
@ -30,6 +30,10 @@ function hasHistoryMethods(value: unknown): value is { pushState: History["pushS
return typeof value.pushState === "function" && typeof value.replaceState === "function";
}
function getTextSnippet(textContent: string | null): string {
return textContent == null ? "" : textContent.trim().substring(0, 200);
}
// Pixel quantization factor for x/y/viewport in stored click events. Matches the
// SCALE_FACTOR used by the ClickHouse clickmap_events MV — keep them in sync.
const CLICKMAP_SCALE_FACTOR = 16;
@ -317,7 +321,7 @@ export class EventTracker {
event_at_ms: Date.now(),
data: {
tag_name: target.tagName.toLowerCase(),
text: target.textContent.trim().substring(0, 200),
text: getTextSnippet(target.textContent),
href: this._findNearestAnchorHref(target),
selector: this._buildSelector(target),
elements_chain: buildElementsChain(target),
@ -499,27 +503,28 @@ export class EventTracker {
);
if (res.status === "error") {
if (isAnalyticsNotEnabledError(res.error)) {
this._disable();
return;
}
console.warn("EventTracker flush failed:", res.error);
return;
}
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());
}
}
private _disable() {
this._disabled = true;
if (this._flushTimer !== null) {
clearInterval(this._flushTimer);
this._flushTimer = null;
}
this._teardown();
}
private _tick() {
if (this._cancelled) return;
if (this._events.length > 0) {

View File

@ -1,5 +1,6 @@
// @vitest-environment jsdom
import { KnownErrors } from "@hexclave/shared/dist/known-errors";
import { describe, expect, it, vi } from "vitest";
import { Result } from "@hexclave/shared/dist/utils/results";
import { analyticsOptionsFromJson, analyticsOptionsToJson, getSessionReplayOptions, SessionRecorder } from "./session-replay";
@ -44,10 +45,9 @@ describe("analytics option JSON conversion", () => {
});
describe("SessionRecorder flush", () => {
it("silently disables when server responds with ANALYTICS_NOT_ENABLED", async () => {
it("silently disables when client interface returns ANALYTICS_NOT_ENABLED as an error", 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",
@ -61,13 +61,7 @@ describe("SessionRecorder flush", () => {
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" },
},
));
return Result.error(new KnownErrors.AnalyticsNotEnabled());
},
},
{},
@ -76,26 +70,16 @@ describe("SessionRecorder flush", () => {
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);
expect(warnSpy).not.toHaveBeenCalled();
// 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

View File

@ -1,3 +1,4 @@
import { KnownErrors } from "@hexclave/shared/dist/known-errors";
import { isBrowserLike } from "@hexclave/shared/dist/utils/env";
import { captureWarning } from "@hexclave/shared/dist/utils/errors";
import { runAsynchronously } from "@hexclave/shared/dist/utils/promises";
@ -161,6 +162,10 @@ export type SessionRecorderDeps = {
sendBatch: (body: string, options: { keepalive: boolean }) => Promise<Result<Response, Error>>,
};
export function isAnalyticsNotEnabledError(error: unknown): boolean {
return KnownErrors.AnalyticsNotEnabled.isInstance(error);
}
export class SessionRecorder {
private _started = false;
private _cancelled = false;
@ -265,23 +270,15 @@ export class SessionRecorder {
);
if (res.status === "error") {
if (isAnalyticsNotEnabledError(res.error)) {
this._disable();
return;
}
captureWarning("SessionRecorder.flush", res.error);
return;
}
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 {
@ -289,6 +286,16 @@ export class SessionRecorder {
}
}
private _disable() {
this._disabled = true;
this.clearBuffer();
if (this._flushTimer !== null) {
clearInterval(this._flushTimer);
this._flushTimer = null;
}
this._stopCurrentRecording();
}
private async _startRecording() {
if (this._recording || this._cancelled) return;