mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-30 21:01:54 +08:00
Mute ANALYTICS_NOT_ENABLED warning
This commit is contained in:
parent
cc68e36260
commit
07449da7e9
@ -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);
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user