//===========================================
// THIS FILE IS AUTO-GENERATED FROM TEMPLATE. DO NOT EDIT IT DIRECTLY UNLESS YOU ALSO EDIT THE CORRESPONDING FILE IN packages/template
//===========================================
import { KnownErrors } from "@hexclave/shared/dist/known-errors";
import { isBrowserLike } from "@hexclave/shared/dist/utils/env";
import { captureWarning, throwErr } from "@hexclave/shared/dist/utils/errors";
import { runAsynchronously } from "@hexclave/shared/dist/utils/promises";
import { Result } from "@hexclave/shared/dist/utils/results";
export type AnalyticsReplayOptions = {
/**
* Whether session replays are enabled.
*
* @default true
*/
enabled?: boolean,
/**
* Whether to mask the content of all `` elements.
*
* @default true
*/
maskAllInputs?: boolean,
/**
* A CSS class name or RegExp. Elements with a matching class will be blocked
* (replaced with a placeholder in the recording).
*
* @default undefined
*/
blockClass?: string | RegExp,
/**
* A CSS selector string. Elements matching this selector will be blocked
* (replaced with a placeholder in the recording).
*
* @default undefined
*/
blockSelector?: string,
};
export type AnalyticsOptions = {
/**
* Whether SDK-managed analytics capture is enabled.
*
* @default true
*/
enabled?: boolean,
/**
* Options for session replay recording. Replays are enabled by default;
* set `enabled: false` to opt out.
*/
replays?: AnalyticsReplayOptions,
};
export function getSessionReplayOptions(analyticsOptions: AnalyticsOptions | undefined): AnalyticsReplayOptions {
return {
...analyticsOptions?.replays,
enabled: analyticsOptions?.replays?.enabled ?? true,
};
}
/**
* Converts AnalyticsOptions to a JSON-safe representation.
* RegExp blockClass values are serialized as `{ __regexp, __flags }` objects.
* The return type is AnalyticsOptions to keep StackClientAppJson simple;
* the actual runtime value is JSON-safe.
*/
export function analyticsOptionsToJson(options: AnalyticsOptions | undefined): AnalyticsOptions | undefined {
if (!options?.replays?.blockClass) return options;
const { blockClass, ...rest } = options.replays;
if (!(blockClass instanceof RegExp)) return options;
return {
...options,
replays: {
...rest,
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
blockClass: { __regexp: blockClass.source, __flags: blockClass.flags } as any,
},
};
}
/**
* Reconstructs AnalyticsOptions from a JSON-deserialized value.
* Converts `{ __regexp, __flags }` objects back to RegExp instances.
*/
export function analyticsOptionsFromJson(json: AnalyticsOptions | undefined): AnalyticsOptions | undefined {
if (!json?.replays?.blockClass) return json;
const { blockClass, ...rest } = json.replays;
if (typeof blockClass === 'object' && '__regexp' in blockClass) {
const bc = blockClass as unknown as { __regexp: string, __flags: string };
return {
...json,
replays: {
...rest,
blockClass: new RegExp(bc.__regexp, bc.__flags),
},
};
}
return json;
}
// ---------- Recording internals ----------
// Hexclave rebrand: canonical localStorage prefix (colon delimiters preserved).
const LOCAL_STORAGE_PREFIX = "hexclave:session-replay:v1";
// Hexclave rebrand: legacy prefix — dual-read only, so a recording session active
// across an SDK upgrade is not orphaned. Never written.
const LEGACY_LOCAL_STORAGE_PREFIX = "stack:session-replay:v1";
const IDLE_TTL_MS = 3 * 60 * 1000;
const FLUSH_INTERVAL_MS = 5_000;
const MAX_EVENTS_PER_BATCH = 200;
const MAX_APPROX_BYTES_PER_BATCH = 512_000;
// Uncompressed per-batch target; the transport gzips before sending.
const MAX_BATCH_UNCOMPRESSED_BYTES = 900_000;
// The server gunzips the whole batch (envelope + events) and rejects anything
// whose decompressed size exceeds MAX_DECOMPRESSED_BYTES (8 MiB) in the backend
// route. Since a single oversized event is sent alone, reserve headroom for the
// JSON envelope (session/batch ids, timestamps, wrapper keys) so an event that
// passes this check can't render a batch that trips the server's cap by a few
// hundred bytes. Keep the server's MAX_DECOMPRESSED_BYTES >= this + the margin.
const BATCH_ENVELOPE_OVERHEAD_BYTES = 1024;
const MAX_SINGLE_EVENT_BYTES = 8 * 1024 * 1024 - BATCH_ENVELOPE_OVERHEAD_BYTES;
// Reused across the emit hot path to avoid per-event allocation.
const textEncoder = new TextEncoder();
export type StoredSession = {
session_id: string,
created_at_ms: number,
last_activity_ms: number,
};
export function safeParseStoredSession(raw: string | null): StoredSession | null {
if (!raw) return null;
try {
const parsed = JSON.parse(raw);
if (typeof parsed !== "object" || parsed === null) return null;
if (typeof parsed.session_id !== "string") return null;
if (typeof parsed.created_at_ms !== "number") return null;
if (typeof parsed.last_activity_ms !== "number") return null;
return parsed as StoredSession;
} catch {
return null;
}
}
export function makeStorageKey(projectId: string) {
return `${LOCAL_STORAGE_PREFIX}:${projectId}`;
}
// Hexclave rebrand: legacy key, dual-read only (never written).
export function makeLegacyStorageKey(projectId: string) {
return `${LEGACY_LOCAL_STORAGE_PREFIX}:${projectId}`;
}
export function generateUuid() {
return crypto.randomUUID();
}
export function getOrRotateSession(options: { key: string, legacyKey?: string, nowMs: number }): StoredSession {
// Hexclave rebrand: prefer the new key; fall back to the legacy key so a
// recording session active across an SDK upgrade is not orphaned.
const existing = safeParseStoredSession(localStorage.getItem(options.key))
?? (options.legacyKey ? safeParseStoredSession(localStorage.getItem(options.legacyKey)) : null);
if (existing && options.nowMs - existing.last_activity_ms <= IDLE_TTL_MS) {
return existing;
}
const next: StoredSession = {
session_id: generateUuid(),
created_at_ms: options.nowMs,
last_activity_ms: options.nowMs,
};
localStorage.setItem(options.key, JSON.stringify(next));
return next;
}
export type SessionRecorderDeps = {
projectId: string,
sendBatch: (body: string, options: { keepalive: boolean }) => Promise>,
};
export function isAnalyticsNotEnabledError(error: unknown): boolean {
return KnownErrors.AnalyticsNotEnabled.isInstance(error);
}
/**
* Whether the error looks like a network failure caused by an ad blocker or
* similar extension blocking analytics requests. These are expected in
* production and should be silently ignored rather than logged as warnings.
*/
export function isAdBlockerNetworkError(error: unknown): boolean {
if (error instanceof Error) {
return error.message.includes("Failed to fetch")
|| error.message.includes("NetworkError")
|| error.message.includes("Load failed")
|| error.message.includes("network connection");
}
return false;
}
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 | null = null;
private _events: unknown[] = [];
private _eventSizes: number[] = [];
private _approxBytes = 0;
private _lastPersistActivity = 0;
private _recording = false;
private _rrwebModule: typeof import("rrweb") | null = null;
private _lastBrowserSessionId: string | null = null;
private _takingSnapshot = false;
private _flushInProgress = false;
private readonly _sessionReplaySegmentId: string;
private readonly _storageKey: string;
// Hexclave rebrand: legacy key used for dual-read fallback only.
private readonly _legacyStorageKey: string;
private readonly _deps: SessionRecorderDeps;
private readonly _replayOptions: AnalyticsReplayOptions;
constructor(deps: SessionRecorderDeps, replayOptions: AnalyticsReplayOptions) {
this._deps = deps;
this._replayOptions = replayOptions;
this._sessionReplaySegmentId = generateUuid();
this._storageKey = makeStorageKey(deps.projectId);
this._legacyStorageKey = makeLegacyStorageKey(deps.projectId);
}
/**
* Starts recording. Idempotent — calling multiple times is safe.
*/
start() {
if (this._started) return;
if (!isBrowserLike()) return;
this._started = true;
// Kick off rrweb recording
runAsynchronously(() => this._startRecording(), { noErrorLogging: true });
// Periodic flush
this._flushTimer = setInterval(() => this._tick(), FLUSH_INTERVAL_MS);
}
stop() {
this._cancelled = true;
if (this._flushTimer !== null) {
clearInterval(this._flushTimer);
this._flushTimer = null;
}
// Flush remaining events before cleanup
runAsynchronously(() => this._flush({ keepalive: true }));
this._stopCurrentRecording();
}
clearBuffer() {
this._events = [];
this._eventSizes = [];
this._approxBytes = 0;
}
private _persistActivity(nowMs: number): StoredSession {
const stored = getOrRotateSession({ key: this._storageKey, legacyKey: this._legacyStorageKey, nowMs });
if (nowMs - this._lastPersistActivity < 5_000) return stored;
this._lastPersistActivity = nowMs;
const updated: StoredSession = { ...stored, last_activity_ms: nowMs };
localStorage.setItem(this._storageKey, JSON.stringify(updated));
return stored;
}
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
// findRecentSessionReplay before either upsert commits) and create
// duplicate SessionReplay records. Events stay in _events and will be
// picked up by the next tick or batch-size check.
if (this._flushInProgress) return;
const nowMs = Date.now();
const stored = getOrRotateSession({ key: this._storageKey, legacyKey: this._legacyStorageKey, nowMs });
// Capture all buffered events upfront (before any await) so that
// stop() / _stopCurrentRecording() clearing this._events cannot race
// with the async send loop below and silently discard overflow batches.
const allEvents = this._events;
const allSizes = this._eventSizes;
this._events = [];
this._eventSizes = [];
this._approxBytes = 0;
// Non-keepalive flushes gzip before sending, so a single event up to the
// server's decompressed budget can be sent alone. Keepalive flushes
// (pagehide/visibilitychange/stop) skip async gzip to dispatch before page
// tear-down, so they're bound by the server's raw ~1MB body limit; cap their
// single-event size at the uncompressed batch target so an oversized event
// is dropped rather than 413-ing the flush and losing the events behind it.
const maxSingleEventBytes = options.keepalive ? MAX_BATCH_UNCOMPRESSED_BYTES : MAX_SINGLE_EVENT_BYTES;
this._flushInProgress = true;
try {
let offset = 0;
while (offset < allEvents.length) {
// A single event over the limit can't be sent (rrweb events aren't
// splittable); drop it and move on to the rest of the buffer.
const firstSize = allSizes[offset] ?? throwErr("_eventSizes out of sync with _events — this should never happen");
if (firstSize > maxSingleEventBytes) {
captureWarning(
"SessionRecorder.flush",
new Error(`Dropping oversized session replay event (${firstSize} bytes > ${maxSingleEventBytes} byte limit); it cannot be sent without exceeding the server's body limit.`),
);
offset += 1;
continue;
}
let batchBytes = 0;
let batchEnd = offset;
for (let i = offset; i < allEvents.length; i++) {
const nextSize = allSizes[i] ?? throwErr("_eventSizes out of sync with _events — this should never happen");
if (batchBytes + nextSize > MAX_BATCH_UNCOMPRESSED_BYTES && batchEnd > offset) break;
batchBytes += nextSize;
batchEnd = i + 1;
}
const batchEvents = allEvents.slice(offset, batchEnd);
offset = batchEnd;
const batchId = generateUuid();
const payload = {
browser_session_id: stored.session_id,
session_replay_segment_id: this._sessionReplaySegmentId,
batch_id: batchId,
started_at_ms: stored.created_at_ms,
sent_at_ms: nowMs,
events: batchEvents,
};
const res = await this._deps.sendBatch(
JSON.stringify(payload),
{ keepalive: options.keepalive },
);
if (res.status === "error") {
if (isAnalyticsNotEnabledError(res.error)) {
this._disable();
return;
}
// Ad blockers commonly block analytics endpoints, causing network
// errors. These are expected and should not pollute the console.
if (isAdBlockerNetworkError(res.error)) {
return;
}
captureWarning("SessionRecorder.flush", res.error);
return;
}
if (!res.data.ok) {
// On any non-2xx we stop the loop, so this batch and every event still
// buffered behind it are dropped (not retried). Count them for the log.
const droppedCount = batchEvents.length + (allEvents.length - offset);
if (res.data.status === 413) {
// The payload exceeded the server's body limit despite the client-side
// size caps — most likely a single poorly-compressible event (e.g. an
// embedded image/canvas) that gzipped above the wire limit. Distinct
// from other failures because the caps are supposed to prevent it.
captureWarning(
"SessionRecorder.flush",
new Error(`Session replay batch rejected with 413 (payload too large) despite client-side size caps; dropping ${droppedCount} buffered event(s). A poorly-compressible event likely exceeded the server's body limit after gzip.`),
);
return;
}
captureWarning("SessionRecorder.flush", new Error(`SessionRecorder flush failed (dropping ${droppedCount} buffered event(s)): ${res.data.status} ${await res.data.text()}`));
return;
}
}
} finally {
this._flushInProgress = false;
}
}
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;
if (!this._rrwebModule) {
const rrwebImport = await Result.fromPromise(import("rrweb"));
if (rrwebImport.status === "error") {
console.warn("SessionRecorder: rrweb import failed. Is rrweb installed?", rrwebImport.error);
return;
}
this._rrwebModule = rrwebImport.data;
}
// cancelled may change during the await above
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (this._cancelled) return;
this._stopRecording = this._rrwebModule.record({
emit: (event) => {
const nowMs = Date.now();
const stored = this._persistActivity(nowMs);
// Detect session rotation: after 3+ minutes idle, getOrRotateSession
// creates a new session ID. We need to inject a FullSnapshot so the
// new server-side SessionReplay record is playable.
if (this._lastBrowserSessionId === null) {
this._lastBrowserSessionId = stored.session_id;
} else if (stored.session_id !== this._lastBrowserSessionId && !this._takingSnapshot) {
this._lastBrowserSessionId = stored.session_id;
// Inject a FullSnapshot for the new session (calls emit synchronously)
this._takingSnapshot = true;
try {
this._rrwebModule!.record.takeFullSnapshot();
} finally {
this._takingSnapshot = false;
}
}
// Measure UTF-8 byte length to match the server's byte limit (.length counts UTF-16 units, undercounting multibyte content).
const eventSize = textEncoder.encode(JSON.stringify(event)).byteLength;
this._events.push(event);
this._eventSizes.push(eventSize);
this._approxBytes += eventSize;
if (this._events.length >= MAX_EVENTS_PER_BATCH || this._approxBytes >= MAX_APPROX_BYTES_PER_BATCH) {
runAsynchronously(() => this._flush({ keepalive: false }));
}
},
maskAllInputs: this._replayOptions.maskAllInputs ?? true,
...(this._replayOptions.blockClass !== undefined ? { blockClass: this._replayOptions.blockClass } : {}),
...(this._replayOptions.blockSelector !== undefined ? { blockSelector: this._replayOptions.blockSelector } : {}),
}) ?? null;
this._recording = true;
const onPageHide = () => {
runAsynchronously(() => this._flush({ keepalive: true }));
};
window.addEventListener("pagehide", onPageHide);
document.addEventListener("visibilitychange", onPageHide);
this._detachListeners = () => {
window.removeEventListener("pagehide", onPageHide);
document.removeEventListener("visibilitychange", onPageHide);
};
}
private _stopCurrentRecording() {
if (this._detachListeners) {
this._detachListeners();
this._detachListeners = null;
}
if (this._stopRecording) {
this._stopRecording();
this._stopRecording = null;
}
this._events = [];
this._eventSizes = [];
this._approxBytes = 0;
this._recording = false;
}
private _tick() {
if (this._cancelled) return;
if (this._events.length > 0) {
runAsynchronously(() => this._flush({ keepalive: false }));
}
}
}