From 07449da7e9425c4256df92dcbd030f7f0e0dfcf5 Mon Sep 17 00:00:00 2001 From: Konstantin Wohlwend Date: Wed, 17 Jun 2026 12:17:17 -0700 Subject: [PATCH] Mute ANALYTICS_NOT_ENABLED warning --- .../implementations/event-tracker.test.ts | 18 +++------- .../apps/implementations/event-tracker.ts | 33 +++++++++++-------- .../implementations/session-replay.test.ts | 24 +++----------- .../apps/implementations/session-replay.ts | 31 ++++++++++------- 4 files changed, 47 insertions(+), 59 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 13e5fd22c..698a48c43 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 @@ -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 = ""; @@ -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); 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 3dc2667a0..0c52fbbbc 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 @@ -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) { 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 bec79cddf..ed643d93c 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,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 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 13be57ec8..8d0ced92b 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 @@ -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>, }; +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;