From 2f6b966ed6946e56052ca26c87a63550b20d1418 Mon Sep 17 00:00:00 2001 From: Aman Yadav Date: Thu, 28 May 2026 08:56:12 +0700 Subject: [PATCH 1/2] feat(analytics): enhance analytics tracking with custom dimensions and transport layer improvements --- .../modules/analytics/analytics-tracker.ts | 30 ++- .../javascript/modules/analytics/constants.ts | 16 +- .../modules/analytics/event-row-encoder.ts | 23 -- .../javascript/modules/analytics/transport.ts | 99 ++++++++ .../javascript/modules/analytics/types.ts | 21 +- .../modules/analytics/wire-format-v1.ts | 235 ++++++++++++++++++ 6 files changed, 382 insertions(+), 42 deletions(-) create mode 100644 packages/video-player/javascript/modules/analytics/transport.ts create mode 100644 packages/video-player/javascript/modules/analytics/wire-format-v1.ts diff --git a/packages/video-player/javascript/modules/analytics/analytics-tracker.ts b/packages/video-player/javascript/modules/analytics/analytics-tracker.ts index 66e5472..228725b 100644 --- a/packages/video-player/javascript/modules/analytics/analytics-tracker.ts +++ b/packages/video-player/javascript/modules/analytics/analytics-tracker.ts @@ -41,6 +41,27 @@ export interface AnalyticsTrackerOptions { pageLoadStartMonotonic?: number; } +/** Valid slot keys: `cd_` followed by digits. */ +const CUSTOM_DIMENSION_SLOT_RE = /^cd_\d+$/; + +/** Validate slot keys (`cd_`) and drop invalid entries. */ +function sanitizeCustomDimensions( + dims: Record | undefined +): Record | undefined { + if (!dims || typeof dims !== 'object') return undefined; + const out: Record = {}; + let count = 0; + for (const [slot, value] of Object.entries(dims)) { + if (typeof slot !== 'string' || !CUSTOM_DIMENSION_SLOT_RE.test(slot)) continue; + if (value === null || value === undefined) continue; + const s = String(value); + if (!s) continue; + out[slot] = s; + count++; + } + return count > 0 ? out : undefined; +} + export function createAnalyticsTracker(options: AnalyticsTrackerOptions): void { const { config: userConfig, @@ -84,18 +105,13 @@ export function createAnalyticsTracker(options: AnalyticsTrackerOptions): void { device_display_height: typeof screen !== 'undefined' ? screen.height : 0, device_display_dpr: typeof window !== 'undefined' ? window.devicePixelRatio || 1 : 1, user_agent: typeof navigator !== 'undefined' ? navigator.userAgent : '', - user_agent_data: - typeof navigator !== 'undefined' - ? (navigator as Navigator & { userAgentData?: unknown }).userAgentData - : undefined, - language: typeof navigator !== 'undefined' ? navigator.language : undefined, - time_zone: typeof Intl !== 'undefined' ? Intl.DateTimeFormat().resolvedOptions().timeZone : undefined, session_start_date: session.session_start_date, session_start_time_iso: new Date(session.session_start_time_ms).toISOString(), player_software: PLAYER_SOFTWARE, player_software_version: PLAYER_SOFTWARE_VERSION, imagekit_plugin: PLUGIN_NAME, imagekit_plugin_version: PLUGIN_VERSION, + custom_dimensions: sanitizeCustomDimensions(userConfig.customDimensions), }; const capturePlayerUiContext = (): void => { @@ -388,7 +404,7 @@ export function createAnalyticsTracker(options: AnalyticsTrackerOptions): void { }, }); - // Visibility-based flush + // Flush when page goes hidden (e.g. tab switch, navigation away). if (typeof document !== 'undefined') { const onVisibilityChange = () => { if (document.visibilityState === 'hidden') { diff --git a/packages/video-player/javascript/modules/analytics/constants.ts b/packages/video-player/javascript/modules/analytics/constants.ts index 9642a80..c1a348f 100644 --- a/packages/video-player/javascript/modules/analytics/constants.ts +++ b/packages/video-player/javascript/modules/analytics/constants.ts @@ -1,8 +1,5 @@ -/** - * Hardcoded analytics ingest / batching defaults (not configurable via IKPlayerOptions). - */ -export const ANALYTICS_INGEST_URL = 'https://stage.imagekit.io/video-analytics-ingest/analytics/ingest'; -// export const ANALYTICS_INGEST_URL = 'http://localhost:8081/analytics/ingest'; +/** Analytics ingest endpoint. */ +export const ANALYTICS_INGEST_URL = 'https://stage-ikedge.imagekit.io/b'; export const ANALYTICS_FLUSH_INTERVAL_MS = 5000; @@ -12,3 +9,12 @@ export const ANALYTICS_TIMEUPDATE_THROTTLE_MS = 2000; /** If media-time advance exceeds this × wall ms since last emit, treat as seek/scrub (played_delta_ms = 0). */ export const ANALYTICS_MAX_PLAYBACK_SPEED_FACTOR = 3; + +/** Conservative ceiling on final GET URL bytes. */ +export const ANALYTICS_URL_SAFE_LIMIT_BYTES = 4000; + +/** + * Raw-JSON byte threshold that triggers a `size_limit` flush before another event is enqueued. + * Sized so that after gzip + base64url expansion the final URL stays under ANALYTICS_URL_SAFE_LIMIT_BYTES. + */ +export const ANALYTICS_RAW_JSON_FLUSH_THRESHOLD = 6000; diff --git a/packages/video-player/javascript/modules/analytics/event-row-encoder.ts b/packages/video-player/javascript/modules/analytics/event-row-encoder.ts index 68e6c87..ef99329 100644 --- a/packages/video-player/javascript/modules/analytics/event-row-encoder.ts +++ b/packages/video-player/javascript/modules/analytics/event-row-encoder.ts @@ -36,21 +36,6 @@ function deriveVideoSourceType(url: string): VideoSourceType { return 'other'; } -function deriveHostname(url: string): string { - try { - return new URL(url).hostname; - } catch { - return ''; - } -} - -function deriveOrientation(width: number, height: number): 'landscape' | 'portrait' | 'square' | 'unknown' { - if (!width || !height) return 'unknown'; - if (width > height) return 'landscape'; - if (height > width) return 'portrait'; - return 'square'; -} - function assertNever(x: never): never { throw new Error(`Unsupported analytics event: ${String(x)}`); } @@ -80,18 +65,10 @@ export function encodeEvent( if (internal.video_source_url) { base.video_source_url = internal.video_source_url; base.video_source_type = internal.video_source_type ?? deriveVideoSourceType(internal.video_source_url); - base.video_source_hostname = deriveHostname(internal.video_source_url); } if (internal.video_width_pixels != null) base.video_width_pixels = internal.video_width_pixels; if (internal.video_height_pixels != null) base.video_height_pixels = internal.video_height_pixels; if (internal.video_total_duration_ms != null) base.video_total_duration_ms = internal.video_total_duration_ms; - if (internal.orientation != null) base.orientation = internal.orientation; - else if ( - internal.video_width_pixels != null && - internal.video_height_pixels != null - ) { - base.orientation = deriveOrientation(internal.video_width_pixels, internal.video_height_pixels); - } if (internal.video_startup_time_ms != null) base.video_startup_time_ms = internal.video_startup_time_ms; if (internal.page_load_time_ms != null) base.page_load_time_ms = internal.page_load_time_ms; diff --git a/packages/video-player/javascript/modules/analytics/transport.ts b/packages/video-player/javascript/modules/analytics/transport.ts new file mode 100644 index 0000000..14d38da --- /dev/null +++ b/packages/video-player/javascript/modules/analytics/transport.ts @@ -0,0 +1,99 @@ +/** + * Transport for v1 analytics ingest: gzip → base64url → GET ?d=… with keepalive. + * One code path for every flush reason (interval, buffer_full, size_limit, visibility_hidden, pagehide, dispose, manual). + */ +import type { IKAnalyticsIngestRequestV1 } from './wire-format-v1'; + +/** Base64url-encode a byte array (RFC 4648 §5, no padding). */ +function base64urlEncode(bytes: Uint8Array): string { + let bin = ''; + for (let i = 0; i < bytes.length; i++) { + bin += String.fromCharCode(bytes[i]); + } + const b64 = typeof btoa !== 'undefined' ? btoa(bin) : ''; + return b64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); +} + +/** UTF-8 encode a string to a Uint8Array. */ +function utf8Bytes(s: string): Uint8Array { + return new TextEncoder().encode(s); +} + +/** Gzip a string via native CompressionStream. Returns null when unsupported. */ +async function gzipString(s: string): Promise { + const CS: typeof CompressionStream | undefined = + (globalThis as unknown as { CompressionStream?: typeof CompressionStream }).CompressionStream; + if (!CS) return null; + try { + const input = utf8Bytes(s); + const stream = new Blob([input]).stream().pipeThrough(new CS('gzip')); + const buf = await new Response(stream).arrayBuffer(); + return new Uint8Array(buf); + } catch { + return null; + } +} + +export interface SendBatchOptions { + ingestUrl: string; + debug?: boolean; +} + +/** + * Send a v1 batch as `GET ingestUrl?d=`. + * Uses `fetch` with `keepalive: true` so unload-time flushes still ship. + * Falls back to uncompressed payload on the rare browsers without CompressionStream. + */ +export async function sendBatchV1( + req: IKAnalyticsIngestRequestV1, + opts: SendBatchOptions +): Promise { + const { ingestUrl, debug } = opts; + const json = JSON.stringify(req); + + let payload: Uint8Array | null = await gzipString(json); + let encodingTag = 'g'; + + if (!payload) { + payload = utf8Bytes(json); + encodingTag = 'r'; + if (debug) { + console.warn('[IK Analytics] CompressionStream unavailable; sending uncompressed payload'); + } + } + + const b64url = base64urlEncode(payload); + const sep = ingestUrl.includes('?') ? '&' : '?'; + const url = `${ingestUrl}${sep}d=${b64url}&z=${encodingTag}`; + + if (debug) { + console.log( + '[IK Analytics] GET batch', + req.r, + req.e.length, + 'events', + 'urlBytes:', url.length + ); + } + + try { + // `keepalive` is only needed for unload-time flushes so the request survives + // the page tearing down. Using it for routine flushes counts against Chrome's + // 64 KB per-origin keepalive quota and can cause silent failures (which Chrome + // mis-labels as "CORS error" in DevTools). + // + // `mode: 'no-cors'` is safe here because the beacon is fire-and-forget — we + // never read the response. This avoids any CORS surface (including preflights + // if/when we add custom headers later). + const isUnloadFlush = + req.r === 'pagehide' || req.r === 'visibility_hidden' || req.r === 'dispose'; + await fetch(url, { + method: 'GET', + keepalive: isUnloadFlush, + credentials: 'omit', + mode: 'no-cors', + }); + } catch (err) { + if (debug) console.warn('[IK Analytics] Flush failed', err); + } +} diff --git a/packages/video-player/javascript/modules/analytics/types.ts b/packages/video-player/javascript/modules/analytics/types.ts index bf808c3..3dd3fce 100644 --- a/packages/video-player/javascript/modules/analytics/types.ts +++ b/packages/video-player/javascript/modules/analytics/types.ts @@ -37,9 +37,6 @@ export interface IKAnalyticsClientContext { device_display_height: number; device_display_dpr: number; user_agent: string; - user_agent_data?: unknown; - language?: string; - time_zone?: string; session_start_date: string; // YYYY-MM-DD session_start_time_iso?: string; player_height_pixels?: number; @@ -50,6 +47,12 @@ export interface IKAnalyticsClientContext { player_software_version?: string; imagekit_plugin?: string; imagekit_plugin_version?: string; + /** + * Per-session custom dimensions, keyed by slot code (`cd_01` .. `cd_99`). + * Slot codes are allocated in the dashboard (Settings → Video Analytics → + * Custom Dimensions). Stored verbatim on the server in the matching slot. + */ + custom_dimensions?: Record; } /** @@ -73,8 +76,6 @@ export interface IKAnalyticsEventBase { /** Optional payload; required only on specific event types (see union) */ video_source_url?: string; video_source_type?: VideoSourceType; - video_source_hostname?: string; - orientation?: Orientation; video_width_pixels?: number; video_height_pixels?: number; video_total_duration_ms?: number; @@ -195,7 +196,6 @@ export interface InternalAnalyticsEvent { event_id: string; video_source_url?: string; video_source_type?: VideoSourceType; - orientation?: Orientation; video_width_pixels?: number; video_height_pixels?: number; video_total_duration_ms?: number; @@ -245,7 +245,14 @@ export interface IKAnalyticsIngestRequest { sent_at_iso?: string; context: IKAnalyticsClientContext; events: IKAnalyticsEventSlim[]; - flush_reason?: 'interval' | 'visibility_hidden' | 'pagehide' | 'manual' | 'buffer_full' | 'dispose'; + flush_reason?: + | 'interval' + | 'visibility_hidden' + | 'pagehide' + | 'manual' + | 'buffer_full' + | 'dispose' + | 'size_limit'; } export interface AnalyticsConfig { diff --git a/packages/video-player/javascript/modules/analytics/wire-format-v1.ts b/packages/video-player/javascript/modules/analytics/wire-format-v1.ts new file mode 100644 index 0000000..60466e1 --- /dev/null +++ b/packages/video-player/javascript/modules/analytics/wire-format-v1.ts @@ -0,0 +1,235 @@ +/** + * V1 analytics wire format — single source of truth for positional event rows + short context keys. + * Mirror copy lives in video-analytics-ingest-server/internal/wire. + * Field order and key map MUST stay in lockstep with that file. + * + * Design notes: + * - `orientation` and `video_source_hostname` are derived server-side from video_source_url + w/h. + * - `error_context` is truncated to 512 B at encode time. + */ +import type { + IKAnalyticsClientContext, + IKAnalyticsEvent, + IKAnalyticsEventSlim, + IKAnalyticsIngestRequest, +} from './types'; + +export const WIRE_VERSION = 1; + +/** + * Ordered positions for an event row on the wire. + * Trailing nulls are trimmed by encoder; decoder pads missing positions with null. + * NEVER reorder or insert; only append (and bump WIRE_VERSION for breaking changes). + */ +export const EVENT_FIELDS_V1 = [ + 'event_time', // 0 — ms epoch (number) + 'event_order', // 1 + 'event_id', // 2 + 'event', // 3 — IKAnalyticsEventName + 'previous_event', // 4 + 'ms_from_previous_event', // 5 + 'playback_id', // 6 + 'video_source_url', // 7 + 'video_source_type', // 8 + 'video_width_pixels', // 9 + 'video_height_pixels', // 10 + 'video_total_duration_ms', // 11 + 'playback_time_instant_ms', // 12 + 'playing_time_ms', // 13 + 'video_upscale_percentage', // 14 + 'video_downscale_percentage', // 15 + 'from_position_ms', // 16 + 'to_position_ms', // 17 + 'seek_time_ms', // 18 + 'bitrate', // 19 + 'played_delta_ms', // 20 + 'rebuffer_duration_ms', // 21 + 'error_code', // 22 + 'error_message', // 23 + 'view_end_reason', // 24 + 'new_video_source_url', // 25 + 'next_playback_id', // 26 + 'video_startup_time_ms', // 27 + 'player_startup_time_ms', // 28 + 'page_load_time_ms', // 29 + 'audio_codec', // 30 + 'video_codec', // 31 + 'error_context', // 32 +] as const; + +export type EventFieldV1 = (typeof EVENT_FIELDS_V1)[number]; + +/** Short-key → canonical context field. */ +export const CONTEXT_KEY_MAP_V1: Record = { + ik: 'imagekit_id', + s: 'session_id', + p: 'player_instance_id', + u: 'user_id', + url: 'page_url', + dw: 'device_display_width', + dh: 'device_display_height', + dpr: 'device_display_dpr', + ua: 'user_agent', + ssd: 'session_start_date', + sst: 'session_start_time_iso', + psw: 'player_software', + psv: 'player_software_version', + ip: 'imagekit_plugin', + ipv: 'imagekit_plugin_version', + pw: 'player_width_pixels', + ph: 'player_height_pixels', + pa: 'player_autoplay', + pp: 'player_preload', + /** + * Custom dimensions object keyed by slot code: `{ cd_01: value, cd_02: value }`. + * Slots are allocated per tenant in the dashboard; the ingest server stores + * each entry verbatim in the ClickHouse Map column. + */ + cd: 'custom_dimensions', +}; + +/** Canonical → short-key (inverse of CONTEXT_KEY_MAP_V1). */ +export const CONTEXT_KEY_MAP_V1_INVERSE: Record = (() => { + const out: Record = {}; + for (const [short, long] of Object.entries(CONTEXT_KEY_MAP_V1)) { + out[long] = short; + } + return out; +})(); + +/** Wire-format envelope (what's gzipped + base64url-encoded into ?d=). */ +export interface IKAnalyticsIngestRequestV1 { + v: 1; + b: string; // batch_id + t: number; // sent_at ms epoch + r: string; // flush_reason + c: Record; // context (short keys) + e: Array>; // positional event rows, trailing nulls trimmed +} + +/** Truncate string to N bytes (UTF-8 safe). Appends ellipsis when truncated. */ +export function truncateBytes(s: string | undefined, maxBytes: number): string | undefined { + if (s == null) return s; + if (typeof TextEncoder === 'undefined') { + // Fallback for environments without TextEncoder — use char length. + return s.length <= maxBytes ? s : s.slice(0, maxBytes - 1) + '…'; + } + const enc = new TextEncoder(); + const bytes = enc.encode(s); + if (bytes.length <= maxBytes) return s; + // Decode a prefix of bytes safely, leaving room for the ellipsis (3 bytes UTF-8). + const cut = bytes.slice(0, maxBytes - 3); + const dec = new TextDecoder('utf-8', { fatal: false }); + return dec.decode(cut) + '…'; +} + +/** Encode canonical context into short-key wire shape, dropping undefined values. */ +export function encodeContextV1(context: IKAnalyticsClientContext): Record { + const out: Record = {}; + for (const [longKey, value] of Object.entries(context)) { + if (value === undefined || value === null) continue; + const shortKey = CONTEXT_KEY_MAP_V1_INVERSE[longKey] ?? longKey; + out[shortKey] = value; + } + return out; +} + +/** Decode wire-shape context back to canonical (long-key) shape. */ +export function decodeContextV1(shortContext: Record): IKAnalyticsClientContext { + const out: Record = {}; + for (const [key, value] of Object.entries(shortContext)) { + const longKey = CONTEXT_KEY_MAP_V1[key] ?? key; + out[longKey] = value; + } + return out as unknown as IKAnalyticsClientContext; +} + +/** Defensive per-field truncation applied before wire encoding. */ +function applyTruncation(ev: IKAnalyticsEventSlim): IKAnalyticsEventSlim { + let out: IKAnalyticsEventSlim = ev; + if (out.error_message) { + const t = truncateBytes(out.error_message, 256); + if (t !== out.error_message) out = { ...out, error_message: t } as IKAnalyticsEventSlim; + } + if (out.error_context) { + const t = truncateBytes(out.error_context, 512); + if (t !== out.error_context) out = { ...out, error_context: t } as IKAnalyticsEventSlim; + } + return out; +} + +/** Encode one slim event into a positional row, with trailing-null trim. */ +export function encodeEventRowV1(ev: IKAnalyticsEventSlim): Array { + const truncated = applyTruncation(ev); + const e = truncated as unknown as Record; + const row: Array = new Array(EVENT_FIELDS_V1.length); + + for (let i = 0; i < EVENT_FIELDS_V1.length; i++) { + const field = EVENT_FIELDS_V1[i]; + if (field === 'event_time') { + // Convert ISO string → ms epoch. + const iso = e.event_time_iso as string | undefined; + const t = iso ? Date.parse(iso) : NaN; + row[i] = Number.isFinite(t) ? t : null; + } else { + const v = e[field]; + row[i] = v === undefined ? null : v; + } + } + + // Trim trailing nulls. + let len = row.length; + while (len > 0 && row[len - 1] === null) len--; + row.length = len; + + return row; +} + +/** Decode a positional row back to the canonical slim-event object. */ +export function decodeEventRowV1(row: Array): IKAnalyticsEventSlim { + const out: Record = {}; + for (let i = 0; i < EVENT_FIELDS_V1.length; i++) { + const value = i < row.length ? row[i] : null; + if (value === null || value === undefined) continue; + const field = EVENT_FIELDS_V1[i]; + if (field === 'event_time') { + const t = typeof value === 'number' ? value : Number(value); + if (Number.isFinite(t)) { + out.event_time_iso = new Date(t).toISOString(); + } + } else { + out[field] = value; + } + } + return out as IKAnalyticsEventSlim; +} + +/** Build the full v1 wire envelope from canonical inputs. */ +export function buildIngestRequestV1( + context: IKAnalyticsClientContext, + events: IKAnalyticsEventSlim[] | IKAnalyticsEvent[], + flushReason: string, + batchId?: string, + sentAtMs?: number +): IKAnalyticsIngestRequestV1 { + return { + v: WIRE_VERSION, + b: batchId ?? `b_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`, + t: sentAtMs ?? Date.now(), + r: flushReason, + c: encodeContextV1(context), + e: (events as IKAnalyticsEventSlim[]).map(encodeEventRowV1), + }; +} + +/** Decode a wire envelope back to the canonical IKAnalyticsIngestRequest shape. */ +export function decodeIngestRequestV1(raw: IKAnalyticsIngestRequestV1): IKAnalyticsIngestRequest { + return { + schema_version: 1, + batch_id: raw.b, + sent_at_iso: new Date(raw.t).toISOString(), + context: decodeContextV1(raw.c), + events: raw.e.map(decodeEventRowV1) as IKAnalyticsIngestRequest['events'], + flush_reason: raw.r as IKAnalyticsIngestRequest['flush_reason'], + }; +} From 2066246cbd9136c1c73d9aa6875dcbedf357c0ac Mon Sep 17 00:00:00 2001 From: Aman Yadav Date: Thu, 28 May 2026 09:18:02 +0700 Subject: [PATCH 2/2] feat(analytics): refactor batch processing to use v1 transport and add size-based flush trigger --- .../modules/analytics/batch-queue.ts | 54 ++++++++++--------- 1 file changed, 30 insertions(+), 24 deletions(-) diff --git a/packages/video-player/javascript/modules/analytics/batch-queue.ts b/packages/video-player/javascript/modules/analytics/batch-queue.ts index 3b1b575..a6add25 100644 --- a/packages/video-player/javascript/modules/analytics/batch-queue.ts +++ b/packages/video-player/javascript/modules/analytics/batch-queue.ts @@ -1,9 +1,13 @@ /** * Batches analytics events and flushes to ingest server. - * Supports interval flush, lifecycle flush (visibilitychange, pagehide), and sendBeacon fallback. + * v1 transport: every flush is a `GET ingestUrl?d=` with `keepalive: true`. + * Size-based flush trigger guarantees URL stays under ANALYTICS_URL_SAFE_LIMIT_BYTES. */ import type { IKAnalyticsIngestRequest, IKAnalyticsEvent, IKAnalyticsClientContext } from './types'; -import { buildIngestRequest } from './event-row-encoder'; +import { toSlimEvent } from './event-row-encoder'; +import { buildIngestRequestV1 } from './wire-format-v1'; +import { sendBatchV1 } from './transport'; +import { ANALYTICS_RAW_JSON_FLUSH_THRESHOLD } from './constants'; export type FlushReason = IKAnalyticsIngestRequest['flush_reason']; @@ -32,37 +36,37 @@ export function createBatchQueue(opts: BatchQueueOptions): BatchQueue { function doFlush(reason: FlushReason) { if (events.length === 0 || !lastContext) return; const batch = events.splice(0, events.length); - const req = buildIngestRequest(1, lastContext, batch, reason); - sendToIngest(req, reason === 'visibility_hidden' || reason === 'pagehide' || reason === 'dispose'); - } - - function sendToIngest(req: IKAnalyticsIngestRequest, useBeacon: boolean) { - if (disposed) return; - const payload = JSON.stringify(req); + const slim = batch.map(toSlimEvent); + const req = buildIngestRequestV1(lastContext, slim, reason ?? ''); if (debug) { - console.log('[IK Analytics] Sending batch', req.flush_reason, req.events.length, 'events'); - } - if (useBeacon && typeof navigator !== 'undefined' && navigator.sendBeacon) { - const blob = new Blob([payload], { type: 'application/json' }); - navigator.sendBeacon(ingestUrl, blob); - } else { - fetch(ingestUrl, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: payload, - keepalive: true, - }).catch((err) => { - if (debug) console.warn('[IK Analytics] Flush failed', err); - }); + console.log('[IK Analytics] Sending batch', req.r, req.e.length, 'events'); } + if (disposed && reason !== 'dispose' && reason !== 'pagehide' && reason !== 'visibility_hidden') return; + void sendBatchV1(req, { ingestUrl, debug }); + } + + /** Rough projected raw-JSON size of the current batch if flushed now. */ + function projectedBatchJsonLength(): number { + if (!lastContext || events.length === 0) return 0; + const slim = events.map(toSlimEvent); + const req = buildIngestRequestV1(lastContext, slim, 'size_limit'); + return JSON.stringify(req).length; } function push(event: IKAnalyticsEvent, context: IKAnalyticsClientContext) { if (disposed) return; lastContext = context; events.push(event); + + // Hard cap on event count. if (events.length >= maxBatchSize) { doFlush('buffer_full'); + return; + } + + // Size-based trigger: keep URL safely under the limit. + if (projectedBatchJsonLength() >= ANALYTICS_RAW_JSON_FLUSH_THRESHOLD) { + doFlush('size_limit'); } } @@ -100,11 +104,13 @@ export function createBatchQueue(opts: BatchQueueOptions): BatchQueue { } function dispose() { + if (disposed) return; + // Flush remaining events BEFORE marking disposed so doFlush actually sends. + flush('dispose'); disposed = true; stopInterval(); visibilityCleanup?.(); pagehideCleanup?.(); - flush('dispose'); } startInterval();