Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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_<digits>`) and drop invalid entries. */
function sanitizeCustomDimensions(
dims: Record<string, string> | undefined
): Record<string, string> | undefined {
if (!dims || typeof dims !== 'object') return undefined;
const out: Record<string, string> = {};
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;
}
Comment on lines +44 to +63

export function createAnalyticsTracker(options: AnalyticsTrackerOptions): void {
const {
config: userConfig,
Expand Down Expand Up @@ -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 => {
Expand Down Expand Up @@ -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') {
Expand Down
54 changes: 30 additions & 24 deletions packages/video-player/javascript/modules/analytics/batch-queue.ts
Original file line number Diff line number Diff line change
@@ -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=<base64url(gzip(json))>` 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'];

Expand Down Expand Up @@ -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 });
Comment on lines 37 to +45
}

/** 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');
}
Comment on lines +48 to 70
}

Expand Down Expand Up @@ -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();
Expand Down
16 changes: 11 additions & 5 deletions packages/video-player/javascript/modules/analytics/constants.ts
Original file line number Diff line number Diff line change
@@ -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';
Comment on lines +1 to +2

export const ANALYTICS_FLUSH_INTERVAL_MS = 5000;

Expand All @@ -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;
Comment on lines +12 to +20
Comment on lines +19 to +20
Original file line number Diff line number Diff line change
Expand Up @@ -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)}`);
}
Expand Down Expand Up @@ -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;
Expand Down
99 changes: 99 additions & 0 deletions packages/video-player/javascript/modules/analytics/transport.ts
Original file line number Diff line number Diff line change
@@ -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(/=+$/, '');
}
Comment on lines +8 to +15
Comment on lines +8 to +15
Comment on lines +8 to +15

/** 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<Uint8Array | null> {
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=<base64url(gzip(json))>`.
* 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<void> {
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,
Comment on lines +80 to +92
credentials: 'omit',
mode: 'no-cors',
});
} catch (err) {
if (debug) console.warn('[IK Analytics] Flush failed', err);
}
}
21 changes: 14 additions & 7 deletions packages/video-player/javascript/modules/analytics/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<string, string>;
Comment on lines +50 to +55
}

/**
Expand All @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 {
Expand Down
Loading