From 948577e93d256f35b519eaf80253f3e7101b72ae Mon Sep 17 00:00:00 2001 From: Aman Yadav Date: Thu, 16 Apr 2026 09:48:39 +0530 Subject: [PATCH 01/25] feat(analytics): add video analytics pipeline with ingest server, event tracking, and ClickHouse schema --- .../javascript/pages/try-it-yourself.html | 4 + examples/javascript/src/try-it-yourself.ts | 7 + examples/react/src/App.tsx | 4 + examples/vue/src/App.vue | 4 + packages/video-player/javascript/index.ts | 20 + .../javascript/interfaces/Player.ts | 8 + .../analytics/analytics-state-machine.ts | 286 ++++++++++++++ .../modules/analytics/analytics-tracker.ts | 373 ++++++++++++++++++ .../modules/analytics/batch-queue.ts | 118 ++++++ .../javascript/modules/analytics/constants.ts | 13 + .../modules/analytics/event-row-encoder.ts | 236 +++++++++++ .../modules/analytics/id-factory.ts | 96 +++++ .../modules/analytics/player-adapter.ts | 103 +++++ .../javascript/modules/analytics/types.ts | 259 ++++++++++++ .../analytics/video-scale-percentage.ts | 41 ++ 15 files changed, 1572 insertions(+) create mode 100644 packages/video-player/javascript/modules/analytics/analytics-state-machine.ts create mode 100644 packages/video-player/javascript/modules/analytics/analytics-tracker.ts create mode 100644 packages/video-player/javascript/modules/analytics/batch-queue.ts create mode 100644 packages/video-player/javascript/modules/analytics/constants.ts create mode 100644 packages/video-player/javascript/modules/analytics/event-row-encoder.ts create mode 100644 packages/video-player/javascript/modules/analytics/id-factory.ts create mode 100644 packages/video-player/javascript/modules/analytics/player-adapter.ts create mode 100644 packages/video-player/javascript/modules/analytics/types.ts create mode 100644 packages/video-player/javascript/modules/analytics/video-scale-percentage.ts diff --git a/examples/javascript/pages/try-it-yourself.html b/examples/javascript/pages/try-it-yourself.html index b617030..dce73f4 100644 --- a/examples/javascript/pages/try-it-yourself.html +++ b/examples/javascript/pages/try-it-yourself.html @@ -257,6 +257,10 @@

Try It Yourself

+
+ + +
diff --git a/examples/javascript/src/try-it-yourself.ts b/examples/javascript/src/try-it-yourself.ts index 9283d29..ac14046 100644 --- a/examples/javascript/src/try-it-yourself.ts +++ b/examples/javascript/src/try-it-yourself.ts @@ -79,6 +79,13 @@ function buildPlayerConfig( playerOptions.seekThumbnails = true; } + if (features.includes('analytics')) { + playerOptions.analytics = { + enabled: true, + user_id: 'test_user_id', + }; + } + // Add signer function if URL is provided if (signerUrl && signerUrl.trim()) { playerOptions.signerFn = async (url: string): Promise => { diff --git a/examples/react/src/App.tsx b/examples/react/src/App.tsx index 4ea3a4d..8b00d34 100644 --- a/examples/react/src/App.tsx +++ b/examples/react/src/App.tsx @@ -21,6 +21,10 @@ export default function App() { logoImageUrl: 'https://ik.imgkit.net/ikmedia/logo/light_T4buIzohVH.svg', logoOnclickUrl: 'https://imagekit.io/', }, + analytics: { + enabled: true, + user_id: 'test_user_id', + }, }; // 2) For a single video source (SourceOptions) diff --git a/examples/vue/src/App.vue b/examples/vue/src/App.vue index deb4758..5323357 100644 --- a/examples/vue/src/App.vue +++ b/examples/vue/src/App.vue @@ -22,6 +22,10 @@ const playerRef = ref(null); const ikOptions: IKPlayerOptions = { imagekitId: 'YOUR_IMAGEKIT_ID', // Remember to replace this seekThumbnails: true, + analytics: { + enabled: true, + user_id: 'test_user_id', + }, }; const playlist: { diff --git a/packages/video-player/javascript/index.ts b/packages/video-player/javascript/index.ts index c11371e..720c109 100644 --- a/packages/video-player/javascript/index.ts +++ b/packages/video-player/javascript/index.ts @@ -19,6 +19,7 @@ import { setupKeyboardShortcuts } from './modules/keyboard-shortcuts'; import { setupContextMenu } from './modules/context-menu/setup'; import { createSourceOverride } from './modules/source-handler'; import { extendTrackSettings } from './modules/subtitles/track-settings-extension'; +import { createAnalyticsTracker } from './modules/analytics/analytics-tracker'; const defaults: IKPlayerOptions = { imagekitId: '', @@ -48,12 +49,31 @@ class ImageKitVideoPlayerPlugin extends Plugin { constructor(player: Player, options: IKPlayerOptions) { super(player); + const pageLoadStartMonotonic = + typeof performance !== 'undefined' ? performance.now() : 0; + this.ikGlobalSettings_ = videojs.mergeOptions(defaults, options); try { validateIKPlayerOptions(this.ikGlobalSettings_); this.overrideSrc(); + const analyticsOpts = this.ikGlobalSettings_.analytics; + if (analyticsOpts?.enabled) { + createAnalyticsTracker({ + config: { + user_id: analyticsOpts.user_id, + customDimensions: analyticsOpts.customDimensions, + debug: false, + }, + imagekitId: this.ikGlobalSettings_.imagekitId, + player: this.player, + getCurrentSource: () => this.getOriginalCurrentSource(), + cleanup: this.cleanup_, + pageLoadStartMonotonic, + }); + } + this.playlistManager_ = new PlaylistManager(this.player, this.ikGlobalSettings_); if (this.ikGlobalSettings_.floatingWhenNotVisible) { diff --git a/packages/video-player/javascript/interfaces/Player.ts b/packages/video-player/javascript/interfaces/Player.ts index e37137b..ac947ec 100644 --- a/packages/video-player/javascript/interfaces/Player.ts +++ b/packages/video-player/javascript/interfaces/Player.ts @@ -14,6 +14,12 @@ export interface ImageKitVideoPlayerPluginInstance { getPlayerOptions(): IKPlayerOptions; } +export interface AnalyticsConfig { + enabled?: boolean; + user_id?: string; + customDimensions?: Record; +} + export interface IKPlayerOptions { /** Your ImageKit ID */ imagekitId: string; @@ -44,6 +50,8 @@ export interface IKPlayerOptions { delayInMS?: number; /** Signer function for generating signed url */ signerFn?: (src: string) => Promise; + /** Analytics configuration */ + analytics?: AnalyticsConfig; } /** diff --git a/packages/video-player/javascript/modules/analytics/analytics-state-machine.ts b/packages/video-player/javascript/modules/analytics/analytics-state-machine.ts new file mode 100644 index 0000000..d57842a --- /dev/null +++ b/packages/video-player/javascript/modules/analytics/analytics-state-machine.ts @@ -0,0 +1,286 @@ +/** + * Strict playback/session/view state machine for analytics. + * Prevents invalid or duplicate events; computes timing and ordering. + */ +import type { + IKAnalyticsEventName, + ViewEndReason, + InternalAnalyticsEvent, +} from './types'; + +export type AnalyticsSignal = + | { type: 'session_init' } + | { type: 'player_ready'; playerStartupTimeMs: number; pageLoadTimeMs?: number } + | { type: 'load_start'; playbackId: string; videoSourceUrl: string; previousVideoSourceUrl?: string; isFirstView: boolean; isVideoChange: boolean } + | { type: 'play' } + | { type: 'playing' } + | { type: 'pause' } + | { type: 'timeupdate'; playbackTimeMs: number; playingTimeMs: number; playedDeltaMs: number; bitrate?: number } + | { type: 'seeking'; fromPositionMs: number } + | { type: 'seeked'; toPositionMs: number; seekTimeMs: number } + | { type: 'waiting' } + | { type: 'playing_after_waiting' } + | { type: 'ended' } + | { type: 'error'; errorCode: string; errorMessage?: string; errorContext?: string } + | { type: 'dispose' } + | { type: 'visibility_hidden' }; + +type PlaybackPhase = + | 'idle' // no view yet + | 'view_open' // viewinit emitted, waiting for play + | 'play_requested' // play emitted + | 'playing' // actively playing + | 'paused' + | 'seeking' + | 'buffering' // rebuffer open + | 'ended' + | 'errored' + | 'disposed'; + +export interface AnalyticsStateMachineCallbacks { + onSessionInit: () => void; + onPlayerReady: (playerStartupTimeMs: number, pageLoadTimeMs?: number) => void; + onViewInit: (playbackId: string) => void; + onViewStarted: (videoStartupTimeMs: number) => void; + onVideoChange: (playbackId: string) => void; + onViewEnd: (reason: ViewEndReason) => void; + onEvent: (event: InternalAnalyticsEvent) => void; +} + +export class AnalyticsStateMachine { + private callbacks_: AnalyticsStateMachineCallbacks; + private sessionInitialized_ = false; + private playerReady_ = false; + private phase_: PlaybackPhase = 'idle'; + private currentPlaybackId_: string | null = null; + private hasEmittedViewStarted_ = false; + private rebufferOpen_ = false; + private rebufferStartMonotonic_ = 0; + private seekOpen_ = false; + private seekStartMonotonic_ = 0; + private seekFromPositionMs_ = 0; + private playStartMonotonic_ = 0; // for video startup time + private lastEventMonotonic_ = 0; + private lastEventName_: IKAnalyticsEventName | null = null; + private eventOrder_ = 0; + private generateEventId_: () => string; + + constructor( + callbacks: AnalyticsStateMachineCallbacks, + generateEventId: () => string + ) { + this.callbacks_ = callbacks; + this.generateEventId_ = generateEventId; + } + + dispatch(signal: AnalyticsSignal, captureContext?: () => Partial): void { + const now = typeof performance !== 'undefined' ? performance.now() : Date.now(); + const ctx = captureContext?.() ?? {}; + + const emit = (event: IKAnalyticsEventName, payload: Partial = {}): void => { + const msFromPrev = this.lastEventMonotonic_ ? Math.round(now - this.lastEventMonotonic_) : 0; + this.eventOrder_ += 1; + const eventId = this.generateEventId_(); + this.callbacks_.onEvent({ + event, + event_id: eventId, + ms_from_previous_event: msFromPrev, + ...ctx, + ...payload, + } as InternalAnalyticsEvent); + this.lastEventMonotonic_ = now; + this.lastEventName_ = event; + }; + + switch (signal.type) { + case 'session_init': + if (this.sessionInitialized_) return; + this.sessionInitialized_ = true; + this.callbacks_.onSessionInit(); + emit('sessioninit'); + return; + + case 'player_ready': + if (this.playerReady_) return; + this.playerReady_ = true; + this.callbacks_.onPlayerReady(signal.playerStartupTimeMs, signal.pageLoadTimeMs); + emit('playerready', { + page_load_time_ms: signal.pageLoadTimeMs ?? 0, + player_startup_time_ms: signal.playerStartupTimeMs, + }); + return; + + case 'load_start': { + if (signal.isVideoChange && this.currentPlaybackId_) { + if (this.phase_ !== 'ended' && this.phase_ !== 'errored') { + emit('viewend', { view_end_reason: 'videochange', video_source_url: signal.previousVideoSourceUrl }); + this.callbacks_.onViewEnd('videochange'); + } + emit('videochange', { + video_source_url: signal.previousVideoSourceUrl, + new_video_source_url: signal.videoSourceUrl, + next_playback_id: signal.playbackId, + }); + this.callbacks_.onVideoChange?.(signal.playbackId); + } else { + this.callbacks_.onViewInit?.(signal.playbackId); + } + this.openView(signal.playbackId); + emit('viewinit', { video_source_url: signal.videoSourceUrl }); + return; + } + case 'play': + if (this.phase_ === 'ended' || this.phase_ === 'disposed' || this.phase_ === 'errored') return; + if (this.phase_ === 'idle') return; // must have viewopen first + this.playStartMonotonic_ = now; + emit('play', {}); + this.phase_ = 'play_requested'; + return; + + case 'playing': + if (this.phase_ === 'buffering') { + const rebufferDuration = Math.round(now - this.rebufferStartMonotonic_); + this.rebufferOpen_ = false; + emit('rebufferend', { rebuffer_duration_ms: rebufferDuration }); + } + if (!this.hasEmittedViewStarted_) { + this.hasEmittedViewStarted_ = true; + const videoStartupMs = Math.round(now - this.playStartMonotonic_); + this.callbacks_.onViewStarted?.(videoStartupMs); + emit('viewstarted', { video_startup_time_ms: videoStartupMs }); + } + emit('playing', ctx); + this.phase_ = 'playing'; + if (this.seekOpen_) { + this.seekOpen_ = false; + const toPositionMs = ctx.playback_time_instant_ms ?? 0; // already in ms + const seekTimeMs = Math.round(now - this.seekStartMonotonic_); + emit('seeked', { to_position_ms: toPositionMs, seek_time_ms: seekTimeMs }); + } + return; + + case 'pause': + if (this.phase_ === 'ended' || this.phase_ === 'disposed') return; + emit('pause', ctx); + this.phase_ = 'paused'; + return; + + case 'timeupdate': + if (this.phase_ !== 'playing') return; + emit('timeupdate', { + playback_time_instant_ms: signal.playbackTimeMs, + playing_time_ms: signal.playingTimeMs, + played_delta_ms: signal.playedDeltaMs, + bitrate: signal.bitrate, + ...ctx, + }); + return; + + case 'seeking': + if (this.phase_ === 'ended' || this.phase_ === 'disposed') return; + this.seekOpen_ = true; + this.seekStartMonotonic_ = now; + this.seekFromPositionMs_ = signal.fromPositionMs; + emit('seeking', { from_position_ms: signal.fromPositionMs }); + this.phase_ = 'seeking'; + return; + + case 'seeked': { + if (!this.seekOpen_) return; + this.seekOpen_ = false; + const seekTimeMs = Math.round(now - this.seekStartMonotonic_); + emit('seeked', { + to_position_ms: signal.toPositionMs, + seek_time_ms: seekTimeMs, + ...ctx, + }); + this.phase_ = 'playing'; + return; + } + + case 'waiting': + if (this.phase_ !== 'playing' && this.phase_ !== 'play_requested') return; + if (this.seekOpen_) return; // do not treat seek as rebuffer + if (this.rebufferOpen_) return; + this.rebufferOpen_ = true; + this.rebufferStartMonotonic_ = now; + emit('rebufferstart', ctx); + this.phase_ = 'buffering'; + return; + + case 'playing_after_waiting': + // Same as 'playing' - will close rebuffer if open + this.dispatch({ type: 'playing' }, captureContext); + return; + + case 'ended': + if (this.phase_ === 'ended' || this.phase_ === 'disposed') return; + emit('ended', ctx); + this.phase_ = 'ended'; + emit('viewend', { view_end_reason: 'ended' }); + this.callbacks_.onViewEnd('ended'); + return; + + case 'error': + emit('error', { + error_code: signal.errorCode, + error_message: signal.errorMessage, + error_context: signal.errorContext, + ...ctx, + }); + if (this.phase_ !== 'ended' && this.phase_ !== 'disposed') { + this.phase_ = 'errored'; + emit('viewend', { view_end_reason: 'error' }); + this.callbacks_.onViewEnd('error'); + } + return; + + case 'dispose': + if (this.phase_ === 'disposed') return; + if (this.phase_ !== 'ended' && this.phase_ !== 'errored' && this.currentPlaybackId_) { + emit('viewend', { view_end_reason: 'dispose' }); + this.callbacks_.onViewEnd('dispose'); + } + this.phase_ = 'disposed'; + return; + + case 'visibility_hidden': + // Tracker handles flush; state machine doesn't emit for this + return; + } + } + + openView(playbackId: string): void { + this.currentPlaybackId_ = playbackId; + this.phase_ = 'view_open'; + this.hasEmittedViewStarted_ = false; + this.rebufferOpen_ = false; + this.seekOpen_ = false; + this.eventOrder_ = 0; + this.lastEventName_ = null; + this.lastEventMonotonic_ = 0; + } + + openViewAfterVideoChange(playbackId: string): void { + this.currentPlaybackId_ = playbackId; + this.phase_ = 'view_open'; + this.hasEmittedViewStarted_ = false; + this.rebufferOpen_ = false; + this.seekOpen_ = false; + this.eventOrder_ = 0; + this.lastEventName_ = null; + this.lastEventMonotonic_ = typeof performance !== 'undefined' ? performance.now() : Date.now(); + } + + getCurrentPlaybackId(): string | null { + return this.currentPlaybackId_; + } + + getEventOrder(): number { + return this.eventOrder_; + } + + getLastEventName(): IKAnalyticsEventName | null { + return this.lastEventName_; + } +} diff --git a/packages/video-player/javascript/modules/analytics/analytics-tracker.ts b/packages/video-player/javascript/modules/analytics/analytics-tracker.ts new file mode 100644 index 0000000..f0e1df8 --- /dev/null +++ b/packages/video-player/javascript/modules/analytics/analytics-tracker.ts @@ -0,0 +1,373 @@ +/** + * Orchestrates analytics state machine, encoder, batch queue, and player adapter. + * Receives signals from the adapter, drives the state machine, and flushes events to ingest. + */ +import type Player from 'video.js/dist/types/player'; +import type { CleanupRegistry } from '../../utils'; +import type { SourceOptions } from '../../interfaces'; +import type { IKAnalyticsClientContext, AnalyticsConfig, InternalAnalyticsEvent } from './types'; +import { + ANALYTICS_FLUSH_INTERVAL_MS, + ANALYTICS_INGEST_URL, + ANALYTICS_MAX_BATCH_SIZE, + ANALYTICS_MAX_PLAYBACK_SPEED_FACTOR, + ANALYTICS_TIMEUPDATE_THROTTLE_MS, +} from './constants'; +import { getOrCreateSession, createPlayerInstanceId, createPlaybackId, createEventId } from './id-factory'; +import { AnalyticsStateMachine } from './analytics-state-machine'; +import { encodeEvent } from './event-row-encoder'; +import { createBatchQueue } from './batch-queue'; +import { createPlayerAdapter } from './player-adapter'; +import { computeVideoUpDownScalePercentages } from './video-scale-percentage'; + +const PLUGIN_NAME = 'imagekit-video-player'; +const PLUGIN_VERSION = '1.0.0-beta.1'; +const PLAYER_SOFTWARE = 'video.js'; +const PLAYER_SOFTWARE_VERSION = '8.20.0'; + +/** User-facing options only; ingest URL and batch defaults come from `./constants`. */ +export interface AnalyticsTrackerUserConfig { + user_id?: string; + customDimensions?: Record; + debug?: boolean; +} + +export interface AnalyticsTrackerOptions { + config: AnalyticsTrackerUserConfig; + imagekitId: string; + player: Player; + getCurrentSource: () => SourceOptions | null; + cleanup: CleanupRegistry; + pageLoadStartMonotonic?: number; +} + +export function createAnalyticsTracker(options: AnalyticsTrackerOptions): void { + const { + config: userConfig, + imagekitId, + player, + getCurrentSource, + cleanup, + pageLoadStartMonotonic = typeof performance !== 'undefined' ? performance.now() : 0, + } = options; + + const config: AnalyticsConfig = { + ingestUrl: ANALYTICS_INGEST_URL, + flushIntervalMs: ANALYTICS_FLUSH_INTERVAL_MS, + maxBatchSize: ANALYTICS_MAX_BATCH_SIZE, + timeupdateThrottleMs: ANALYTICS_TIMEUPDATE_THROTTLE_MS, + debug: userConfig.debug, + user_id: userConfig.user_id, + customDimensions: userConfig.customDimensions, + }; + + const session = getOrCreateSession(); + const playerInstanceId = createPlayerInstanceId(); + let currentPlaybackId: string | null = null; + let previousVideoSourceUrl: string | null = null; + let playerReadyMonotonic = 0; + let playingTimeAccumulatedMs = 0; + let lastTimeupdateEmitMonotonic = 0; + let lastEmittedPlaybackTimeMs = 0; + let progressiveBitrateBps: number | undefined; + + const context: IKAnalyticsClientContext = { + imagekit_id: imagekitId, + session_id: session.session_id, + player_instance_id: playerInstanceId, + user_id: config.user_id, + page_url: typeof window !== 'undefined' ? window.location.href : '', + device_display_width: typeof screen !== 'undefined' ? screen.width : 0, + 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, + }; + + const capturePlayerUiContext = (): void => { + try { + const root = player.el?.(); + const rect = root?.getBoundingClientRect?.(); + const w = rect?.width ?? 0; + const h = rect?.height ?? 0; + // Rendered player size in CSS pixels (not the video source resolution). + if (w > 0) context.player_width_pixels = Math.round(w); + if (h > 0) context.player_height_pixels = Math.round(h); + + // Best-effort autoplay / preload extraction from Video.js API. + const pAny = player as any; + const autoplayVal = typeof pAny.autoplay === 'function' ? pAny.autoplay() : undefined; + if (typeof autoplayVal === 'boolean') context.player_autoplay = autoplayVal; + + const preloadVal = typeof pAny.preload === 'function' ? pAny.preload() : undefined; + if (typeof preloadVal === 'string') { + // video.js preload: 'auto' | 'metadata' | 'none' + context.player_preload = preloadVal; + } + } catch { + // ignore + } + }; + + const batchQueue = createBatchQueue({ + ingestUrl: config.ingestUrl, + flushIntervalMs: config.flushIntervalMs, + maxBatchSize: config.maxBatchSize, + debug: config.debug, + }); + + const pushEvent = (internal: InternalAnalyticsEvent): void => { + const eventOrder = stateMachine.getEventOrder(); + const previousEvent = stateMachine.getLastEventName(); + const msFromPrev = internal.ms_from_previous_event ?? 0; + const encoded = encodeEvent(internal, context, currentPlaybackId, eventOrder, previousEvent, msFromPrev); + batchQueue.push(encoded, context); + }; + + const stateMachine = new AnalyticsStateMachine( + { + onSessionInit: () => {}, + onPlayerReady: () => {}, + onViewInit: (newId) => { currentPlaybackId = newId; }, + onViewStarted: () => {}, + onVideoChange: (newId) => { currentPlaybackId = newId; }, + onViewEnd: () => {}, + onEvent: pushEvent, + }, + createEventId + ); + + const captureContext = (): Partial => { + const src = getCurrentSource(); + const el = player.el()?.querySelector('video') as HTMLVideoElement | null | undefined; + const vw = el?.videoWidth ?? 0; + const vh = el?.videoHeight ?? 0; + const dur = player.duration(); + const ct = player.currentTime(); + const totalDurMs = typeof dur === 'number' && isFinite(dur) ? Math.round(dur * 1000) : undefined; + const playbackTimeMs = typeof ct === 'number' && isFinite(ct) ? Math.round(ct * 1000) : undefined; + const scale = computeVideoUpDownScalePercentages(el); + + return { + video_source_url: src?.src, + video_width_pixels: vw || undefined, + video_height_pixels: vh || undefined, + video_total_duration_ms: totalDurMs, + playback_time_instant_ms: playbackTimeMs, + playing_time_ms: playingTimeAccumulatedMs, + video_upscale_percentage: scale.video_upscale_percentage, + video_downscale_percentage: scale.video_downscale_percentage, + }; + }; + + // Session init on first use + stateMachine.dispatch({ type: 'session_init' }); + + createPlayerAdapter(player, cleanup, getCurrentSource, { + onSignal: (sig) => { + switch (sig.type) { + case 'player_ready': { + capturePlayerUiContext(); + playerReadyMonotonic = typeof performance !== 'undefined' ? performance.now() : Date.now(); + /** + * Player startup: ImageKit plugin / player initialization start → Video.js `ready`. + * `pageLoadStartMonotonic` is `performance.now()` at plugin ctor (see `index.ts`). + */ + const playerStartupMs = Math.round(playerReadyMonotonic - pageLoadStartMonotonic); + /** + * Page load segment: navigation → player initialization start (same ctor timestamp). + * `performance.now()` at ctor = ms since navigation time origin. + */ + const pageLoadMs = + typeof performance !== 'undefined' + ? Math.round(pageLoadStartMonotonic) + : playerStartupMs; + stateMachine.dispatch({ + type: 'player_ready', + playerStartupTimeMs: playerStartupMs, + pageLoadTimeMs: pageLoadMs, + }); + break; + } + case 'load_start': { + const source = sig.source; + const videoSourceUrl = source?.src ?? ''; + const isFirstView = currentPlaybackId === null; + const prevSourceUrl = previousVideoSourceUrl; + const isVideoChange = !isFirstView && !!videoSourceUrl && !!prevSourceUrl && videoSourceUrl !== prevSourceUrl; + + previousVideoSourceUrl = videoSourceUrl; + playingTimeAccumulatedMs = 0; + lastEmittedPlaybackTimeMs = 0; + lastTimeupdateEmitMonotonic = 0; + progressiveBitrateBps = undefined; + + // For progressive sources (no ABR), estimate bitrate from Content-Length + duration. + const isABR = !!source?.abs; + if (!isABR && videoSourceUrl) { + const srcUrl = videoSourceUrl; + player.one('loadedmetadata', () => { + const dur = player.duration(); + if (typeof dur !== 'number' || !isFinite(dur) || dur <= 0) return; + fetch(srcUrl, { method: 'HEAD' }) + .then(res => { + const cl = res.headers.get('content-length'); + if (cl) { + const bytes = parseInt(cl, 10); + if (bytes > 0) progressiveBitrateBps = Math.round((bytes * 8) / dur); + } + }) + .catch(() => { /* ignore – CORS or network failure */ }); + }); + } + + const newPlaybackId = createPlaybackId(); + stateMachine.dispatch( + { + type: 'load_start', + playbackId: newPlaybackId, + videoSourceUrl, + previousVideoSourceUrl: prevSourceUrl ?? undefined, + isFirstView, + isVideoChange, + }, + captureContext + ); + break; + } + case 'play': + stateMachine.dispatch({ type: 'play' }, captureContext); + break; + case 'playing': + stateMachine.dispatch({ type: 'playing' }, captureContext); + break; + case 'pause': + stateMachine.dispatch({ type: 'pause' }, captureContext); + break; + case 'timeupdate': { + const now = typeof performance !== 'undefined' ? performance.now() : Date.now(); + const throttleMs = config.timeupdateThrottleMs; + const elapsed = lastTimeupdateEmitMonotonic ? now - lastTimeupdateEmitMonotonic : throttleMs; + if (elapsed < throttleMs) break; // throttle: only emit every N ms + + const ct = player.currentTime(); + const playbackTimeMs = typeof ct === 'number' && isFinite(ct) ? Math.round(ct * 1000) : 0; + const rawDelta = playbackTimeMs - lastEmittedPlaybackTimeMs; + const maxPlausibleDelta = elapsed * ANALYTICS_MAX_PLAYBACK_SPEED_FACTOR; + const isDiscontinuity = rawDelta < 0 || rawDelta > maxPlausibleDelta; + const playedDeltaMs = isDiscontinuity ? 0 : Math.max(0, rawDelta); + playingTimeAccumulatedMs += playedDeltaMs; + lastTimeupdateEmitMonotonic = now; + lastEmittedPlaybackTimeMs = playbackTimeMs; + + let bitrate: number | undefined; + try { + const ql = (player as any).qualityLevels?.(); + if (ql && typeof ql.selectedIndex === 'number') { + const selected = ql[ql.selectedIndex]; + if (selected?.height) bitrate = (selected as any).bitrate; + } + } catch { + /* ignore */ + } + // Fallback: use estimated progressive bitrate when ABR quality levels unavailable + if (bitrate === undefined && progressiveBitrateBps !== undefined) { + bitrate = progressiveBitrateBps; + } + if (config.debug) { + console.log('[IK Analytics] timeupdate bitrate', { + bitrate_bps: bitrate, + bitrate_mbps_approx: + typeof bitrate === 'number' && isFinite(bitrate) ? bitrate / 1e6 : undefined, + }); + } + + stateMachine.dispatch( + { + type: 'timeupdate', + playbackTimeMs, + playingTimeMs: playingTimeAccumulatedMs, + playedDeltaMs, + bitrate, + }, + captureContext + ); + break; + } + case 'seeking': { + const ct = player.currentTime(); + const fromMs = typeof ct === 'number' && isFinite(ct) ? ct * 1000 : 0; + // Re-base when seeking is observed first so a racing timeupdate is less likely to use a stale baseline. + lastEmittedPlaybackTimeMs = + typeof ct === 'number' && isFinite(ct) ? Math.round(ct * 1000) : 0; + stateMachine.dispatch({ type: 'seeking', fromPositionMs: fromMs }, captureContext); + break; + } + case 'seeked': { + const ct = player.currentTime(); + const toMs = typeof ct === 'number' && isFinite(ct) ? ct * 1000 : 0; + // Re-base so the next timeupdate delta is ~0 until currentTime advances (avoids inflating + // played_delta_ms by the seek jump vs lastEmittedPlaybackTimeMs). + const playbackTimeMs = + typeof ct === 'number' && isFinite(ct) ? Math.round(ct * 1000) : 0; + lastEmittedPlaybackTimeMs = playbackTimeMs; + const seekTimeMs = 0; // state machine computes from seeking + stateMachine.dispatch({ type: 'seeked', toPositionMs: toMs, seekTimeMs }, captureContext); + break; + } + case 'waiting': + stateMachine.dispatch({ type: 'waiting' }, captureContext); + break; + case 'ended': + stateMachine.dispatch({ type: 'ended' }, captureContext); + break; + case 'error': { + const err = sig.error; + let code = 'UNKNOWN'; + let message: string | undefined; + let errCtx: string | undefined; + if (err && typeof err === 'object') { + const e = err as { code?: number; message?: string; status?: number }; + code = String(e.code ?? e.status ?? 'UNKNOWN'); + message = e.message; + errCtx = JSON.stringify(err); + } + stateMachine.dispatch( + { type: 'error', errorCode: code, errorMessage: message, errorContext: errCtx }, + captureContext + ); + break; + } + case 'source_changed': + // Handled via load_start; source_changed can be used for additional logic if needed + break; + case 'dispose': + stateMachine.dispatch({ type: 'dispose' }, captureContext); + batchQueue.flush('dispose'); + break; + } + }, + }); + + // Visibility-based flush + if (typeof document !== 'undefined') { + const onVisibilityChange = () => { + if (document.visibilityState === 'hidden') { + batchQueue.flush('visibility_hidden'); + } + }; + document.addEventListener('visibilitychange', onVisibilityChange); + cleanup.register(() => document.removeEventListener('visibilitychange', onVisibilityChange)); + } +} diff --git a/packages/video-player/javascript/modules/analytics/batch-queue.ts b/packages/video-player/javascript/modules/analytics/batch-queue.ts new file mode 100644 index 0000000..3b1b575 --- /dev/null +++ b/packages/video-player/javascript/modules/analytics/batch-queue.ts @@ -0,0 +1,118 @@ +/** + * Batches analytics events and flushes to ingest server. + * Supports interval flush, lifecycle flush (visibilitychange, pagehide), and sendBeacon fallback. + */ +import type { IKAnalyticsIngestRequest, IKAnalyticsEvent, IKAnalyticsClientContext } from './types'; +import { buildIngestRequest } from './event-row-encoder'; + +export type FlushReason = IKAnalyticsIngestRequest['flush_reason']; + +export interface BatchQueueOptions { + ingestUrl: string; + flushIntervalMs: number; + maxBatchSize: number; + debug?: boolean; +} + +export interface BatchQueue { + push: (event: IKAnalyticsEvent, context: IKAnalyticsClientContext) => void; + flush: (reason: FlushReason) => void; + dispose: () => void; +} + +export function createBatchQueue(opts: BatchQueueOptions): BatchQueue { + const { ingestUrl, flushIntervalMs, maxBatchSize, debug } = opts; + const events: IKAnalyticsEvent[] = []; + let lastContext: IKAnalyticsClientContext | null = null; + let intervalId: ReturnType | null = null; + let visibilityCleanup: (() => void) | null = null; + let pagehideCleanup: (() => void) | null = null; + let disposed = false; + + 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); + 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); + }); + } + } + + function push(event: IKAnalyticsEvent, context: IKAnalyticsClientContext) { + if (disposed) return; + lastContext = context; + events.push(event); + if (events.length >= maxBatchSize) { + doFlush('buffer_full'); + } + } + + function flush(reason: FlushReason) { + doFlush(reason); + } + + function startInterval() { + if (intervalId) return; + intervalId = setInterval(() => { + if (events.length > 0) doFlush('interval'); + }, flushIntervalMs); + } + + function stopInterval() { + if (intervalId) { + clearInterval(intervalId); + intervalId = null; + } + } + + function setupLifecycleListeners() { + if (typeof document === 'undefined') return; + const onVisibilityChange = () => { + if (document.visibilityState === 'hidden') { + flush('visibility_hidden'); + } + }; + document.addEventListener('visibilitychange', onVisibilityChange); + visibilityCleanup = () => document.removeEventListener('visibilitychange', onVisibilityChange); + + const onPageHide = () => flush('pagehide'); + window.addEventListener('pagehide', onPageHide); + pagehideCleanup = () => window.removeEventListener('pagehide', onPageHide); + } + + function dispose() { + disposed = true; + stopInterval(); + visibilityCleanup?.(); + pagehideCleanup?.(); + flush('dispose'); + } + + startInterval(); + setupLifecycleListeners(); + + return { + push, + flush, + dispose, + }; +} diff --git a/packages/video-player/javascript/modules/analytics/constants.ts b/packages/video-player/javascript/modules/analytics/constants.ts new file mode 100644 index 0000000..3693699 --- /dev/null +++ b/packages/video-player/javascript/modules/analytics/constants.ts @@ -0,0 +1,13 @@ +/** + * Hardcoded analytics ingest / batching defaults (not configurable via IKPlayerOptions). + */ +export const ANALYTICS_INGEST_URL = 'http://localhost:8081/analytics/ingest'; + +export const ANALYTICS_FLUSH_INTERVAL_MS = 5000; + +export const ANALYTICS_MAX_BATCH_SIZE = 50; + +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; diff --git a/packages/video-player/javascript/modules/analytics/event-row-encoder.ts b/packages/video-player/javascript/modules/analytics/event-row-encoder.ts new file mode 100644 index 0000000..68e6c87 --- /dev/null +++ b/packages/video-player/javascript/modules/analytics/event-row-encoder.ts @@ -0,0 +1,236 @@ +/** + * Converts internal analytics events into wire-format rows for ingest. + * Ensures session_start_date, event_order, previous_event, ms_from_previous_event are correct. + */ +import type { + IKAnalyticsEvent, + IKAnalyticsEventBase, + IKAnalyticsEventName, + IKAnalyticsClientContext, + IKAnalyticsIngestRequest, + IKAnalyticsEventSlim, + InternalAnalyticsEvent, + VideoSourceType, +} from './types'; + +const VIDEO_SOURCE_TYPES: VideoSourceType[] = ['hls', 'dash', 'mp4', 'webm', 'other']; +const CUSTOM_DATA_KEYS = [ + 'custom_data_1', + 'custom_data_2', + 'custom_data_3', + 'custom_data_4', + 'custom_data_5', + 'custom_data_6', + 'custom_data_7', + 'custom_data_8', + 'custom_data_9', + 'custom_data_10', +] as const; + +function deriveVideoSourceType(url: string): VideoSourceType { + const lower = url.toLowerCase(); + if (lower.includes('.m3u8') || lower.includes('m3u8')) return 'hls'; + if (lower.includes('.mpd') || lower.includes('mpd')) return 'dash'; + if (lower.includes('.mp4')) return 'mp4'; + if (lower.includes('.webm')) return 'webm'; + 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)}`); +} + +export function encodeEvent( + internal: InternalAnalyticsEvent, + context: IKAnalyticsClientContext, + playbackId: string | null, + eventOrder: number, + previousEvent: IKAnalyticsEventName | null, + msFromPrevious: number +): IKAnalyticsEvent { + const eventTime = new Date().toISOString(); + const base: IKAnalyticsEventBase = { + event_time_iso: eventTime, + session_start_date: context.session_start_date, + imagekit_id: context.imagekit_id, + session_id: context.session_id, + player_instance_id: context.player_instance_id, + event_order: eventOrder, + event_id: internal.event_id, + previous_event: previousEvent ?? undefined, + ms_from_previous_event: msFromPrevious, + }; + if (playbackId) base.playback_id = playbackId; + + 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; + if (internal.player_startup_time_ms != null) base.player_startup_time_ms = internal.player_startup_time_ms; + if (internal.playback_time_instant_ms != null) base.playback_time_instant_ms = internal.playback_time_instant_ms; + if (internal.playing_time_ms != null) base.playing_time_ms = internal.playing_time_ms; + if (internal.video_upscale_percentage != null) base.video_upscale_percentage = internal.video_upscale_percentage; + if (internal.video_downscale_percentage != null) base.video_downscale_percentage = internal.video_downscale_percentage; + if (internal.bitrate != null) base.bitrate = internal.bitrate; + if (internal.audio_codec) base.audio_codec = internal.audio_codec; + if (internal.video_codec) base.video_codec = internal.video_codec; + + if (internal.from_position_ms != null) base.from_position_ms = internal.from_position_ms; + if (internal.to_position_ms != null) base.to_position_ms = internal.to_position_ms; + if (internal.seek_time_ms != null) base.seek_time_ms = internal.seek_time_ms; + + if (internal.rebuffer_duration_ms != null) base.rebuffer_duration_ms = internal.rebuffer_duration_ms; + + if (internal.error_code) base.error_code = internal.error_code; + if (internal.error_message) base.error_message = internal.error_message; + if (internal.error_context) base.error_context = internal.error_context; + + if (internal.played_delta_ms != null) base.played_delta_ms = internal.played_delta_ms; + if (internal.view_end_reason) base.view_end_reason = internal.view_end_reason; + if (internal.new_video_source_url) base.new_video_source_url = internal.new_video_source_url; + if (internal.next_playback_id) base.next_playback_id = internal.next_playback_id; + + for (const key of CUSTOM_DATA_KEYS) { + if (internal[key] != null) { + base[key] = internal[key]; + } + } + + switch (internal.event) { + case 'viewstarted': + return { + ...base, + event: 'viewstarted', + playback_id: base.playback_id ?? '', + video_startup_time_ms: base.video_startup_time_ms ?? 0, + }; + case 'seeking': + return { + ...base, + event: 'seeking', + playback_id: base.playback_id ?? '', + from_position_ms: base.from_position_ms ?? 0, + }; + case 'seeked': + return { + ...base, + event: 'seeked', + playback_id: base.playback_id ?? '', + to_position_ms: base.to_position_ms ?? 0, + seek_time_ms: base.seek_time_ms ?? 0, + }; + case 'rebufferend': + return { + ...base, + event: 'rebufferend', + playback_id: base.playback_id ?? '', + rebuffer_duration_ms: base.rebuffer_duration_ms ?? 0, + }; + case 'error': + return { + ...base, + event: 'error', + playback_id: base.playback_id ?? '', + error_code: base.error_code ?? 'UNKNOWN', + }; + case 'timeupdate': + return { + ...base, + event: 'timeupdate', + playback_id: base.playback_id ?? '', + playback_time_instant_ms: base.playback_time_instant_ms ?? 0, + playing_time_ms: base.playing_time_ms ?? 0, + played_delta_ms: base.played_delta_ms ?? 0, + }; + case 'viewend': + return { + ...base, + event: 'viewend', + playback_id: base.playback_id ?? '', + view_end_reason: base.view_end_reason ?? 'navigation', + }; + case 'sessioninit': + return { ...base, event: 'sessioninit' }; + case 'playerready': + return { + ...base, + event: 'playerready', + page_load_time_ms: base.page_load_time_ms ?? 0, + player_startup_time_ms: base.player_startup_time_ms ?? 0, + }; + case 'viewinit': + return { ...base, event: 'viewinit', playback_id: base.playback_id ?? '' }; + case 'videochange': + return { ...base, event: 'videochange', playback_id: base.playback_id ?? '' }; + case 'play': + return { ...base, event: 'play', playback_id: base.playback_id ?? '' }; + case 'playing': + return { ...base, event: 'playing', playback_id: base.playback_id ?? '' }; + case 'pause': + return { ...base, event: 'pause', playback_id: base.playback_id ?? '' }; + case 'rebufferstart': + return { ...base, event: 'rebufferstart', playback_id: base.playback_id ?? '' }; + case 'ended': + return { ...base, event: 'ended', playback_id: base.playback_id ?? '' }; + default: + return assertNever(internal.event); + } +} + +const CONTEXT_ONLY_KEYS: (keyof IKAnalyticsEvent)[] = [ + 'imagekit_id', + 'session_id', + 'player_instance_id', + 'session_start_date', +]; + +export function toSlimEvent(event: IKAnalyticsEvent): IKAnalyticsEventSlim { + const slim = { ...event }; + for (const key of CONTEXT_ONLY_KEYS) delete slim[key]; + return slim as IKAnalyticsEventSlim; +} + +export function buildIngestRequest( + schemaVersion: 1, + context: IKAnalyticsClientContext, + events: IKAnalyticsEvent[], + flushReason?: IKAnalyticsIngestRequest['flush_reason'] +): IKAnalyticsIngestRequest { + return { + schema_version: schemaVersion, + batch_id: `b_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`, + sent_at_iso: new Date().toISOString(), + context, + events: events.map(toSlimEvent), + flush_reason: flushReason, + }; +} diff --git a/packages/video-player/javascript/modules/analytics/id-factory.ts b/packages/video-player/javascript/modules/analytics/id-factory.ts new file mode 100644 index 0000000..87ffdd6 --- /dev/null +++ b/packages/video-player/javascript/modules/analytics/id-factory.ts @@ -0,0 +1,96 @@ +/** + * Generates and manages analytics identifiers. + * session_id persisted in localStorage; others generated per lifecycle. + */ + +const SESSION_STORAGE_KEY = 'ik_analytics_session_id'; +const SESSION_START_KEY = 'ik_analytics_session_start'; + +function randomId(): string { + return `${Date.now()}-${Math.random().toString(36).slice(2, 11)}`; +} + +function getOrCreateSessionId(): string { + if (typeof window === 'undefined' || !window.localStorage) { + return randomId(); + } + try { + let id = localStorage.getItem(SESSION_STORAGE_KEY); + if (!id) { + id = randomId(); + localStorage.setItem(SESSION_STORAGE_KEY, id); + } + return id; + } catch { + return randomId(); + } +} + +function getOrSetSessionStart(): number { + if (typeof window === 'undefined' || !window.localStorage) { + return Date.now(); + } + try { + const stored = localStorage.getItem(SESSION_START_KEY); + if (stored) { + return parseInt(stored, 10); + } + const now = Date.now(); + localStorage.setItem(SESSION_START_KEY, String(now)); + return now; + } catch { + return Date.now(); + } +} + +/** + * Format session start date as YYYY-MM-DD for ClickHouse. + */ +function formatSessionStartDate(ts: number): string { + return new Date(ts).toISOString().slice(0, 10); +} + +export interface SessionIdentity { + session_id: string; + session_start_time_ms: number; + session_start_date: string; +} + +export interface PlayerIdentity { + player_instance_id: string; +} + +export interface PlaybackIdentity { + playback_id: string; +} + +/** + * Returns current session identity. Creates session on first call. + */ +export function getOrCreateSession(): SessionIdentity { + const session_id = getOrCreateSessionId(); + const session_start_time_ms = getOrSetSessionStart(); + const session_start_date = formatSessionStartDate(session_start_time_ms); + return { session_id, session_start_time_ms, session_start_date }; +} + +/** + * Generates a new player instance ID. + */ +export function createPlayerInstanceId(): string { + return randomId(); +} + +/** + * Generates a new playback ID (per view). + */ +export function createPlaybackId(): string { + return randomId(); +} + +/** + * Generates a unique event ID for deduplication. + */ +export function createEventId(): string { + return randomId(); +} diff --git a/packages/video-player/javascript/modules/analytics/player-adapter.ts b/packages/video-player/javascript/modules/analytics/player-adapter.ts new file mode 100644 index 0000000..5869914 --- /dev/null +++ b/packages/video-player/javascript/modules/analytics/player-adapter.ts @@ -0,0 +1,103 @@ +/** + * Adapts Video.js player events to normalized analytics signals. + * Subscribes to player runtime events and invokes callbacks for the analytics tracker. + */ +import type Player from 'video.js/dist/types/player'; +import type { CleanupRegistry } from '../../utils'; +import type { SourceOptions } from '../../interfaces'; + +export type AdapterSignal = + | { type: 'player_ready' } + | { type: 'load_start'; source: SourceOptions | null } + | { type: 'play' } + | { type: 'playing' } + | { type: 'pause' } + | { type: 'timeupdate' } + | { type: 'seeking' } + | { type: 'seeked' } + | { type: 'waiting' } + | { type: 'ended' } + | { type: 'error'; error: unknown } + | { type: 'source_changed'; source: SourceOptions | null } + | { type: 'dispose' }; + +export interface PlayerAdapterCallbacks { + onSignal: (signal: AdapterSignal) => void; +} + +export function createPlayerAdapter( + player: Player, + cleanup: CleanupRegistry, + getCurrentSource: () => SourceOptions | null, + callbacks: PlayerAdapterCallbacks +): void { + const { onSignal } = callbacks; + + // Player ready - fire after initial setup; we use 'ready' which fires when player is initialized + cleanup.registerVideoJsListener(player, 'ready', () => { + onSignal({ type: 'player_ready' }); + }); + + // Load start - new source loading + cleanup.registerVideoJsListener(player, 'loadstart', () => { + onSignal({ type: 'load_start', source: getCurrentSource() }); + }); + + // Play - attempt to play + cleanup.registerVideoJsListener(player, 'play', () => { + onSignal({ type: 'play' }); + }); + + // Playing - first frame / playback progressing + cleanup.registerVideoJsListener(player, 'playing', () => { + onSignal({ type: 'playing' }); + }); + + cleanup.registerVideoJsListener(player, 'pause', () => { + onSignal({ type: 'pause' }); + }); + + cleanup.registerVideoJsListener(player, 'timeupdate', () => { + onSignal({ type: 'timeupdate' }); + }); + + cleanup.registerVideoJsListener(player, 'seeking', () => { + onSignal({ type: 'seeking' }); + }); + + cleanup.registerVideoJsListener(player, 'seeked', () => { + onSignal({ type: 'seeked' }); + }); + + // Waiting - buffering/stall (Video.js fires this when playback stalls) + cleanup.registerVideoJsListener(player, 'waiting', () => { + onSignal({ type: 'waiting' }); + }); + + cleanup.registerVideoJsListener(player, 'ended', () => { + onSignal({ type: 'ended' }); + }); + + cleanup.registerVideoJsListener(player, 'error', () => { + const err = player.error(); + onSignal({ type: 'error', error: err }); + }); + + // Source change - when playlist or src changes externally + // loadstart already covers source changes; beforeplaylistitem fires before loadstart for playlist items + const playerAny = player as any; + if (typeof playerAny.on === 'function') { + const sourceChangedHandler = () => { + onSignal({ type: 'source_changed', source: getCurrentSource() }); + }; + playerAny.on('beforeplaylistitem', sourceChangedHandler); + cleanup.register(() => { + playerAny.off?.('beforeplaylistitem', sourceChangedHandler); + }); + } + + // Dispose - when player is disposed + cleanup.registerVideoJsListener(player, 'dispose', () => { + onSignal({ type: 'dispose' }); + }); +} diff --git a/packages/video-player/javascript/modules/analytics/types.ts b/packages/video-player/javascript/modules/analytics/types.ts new file mode 100644 index 0000000..5b4fe53 --- /dev/null +++ b/packages/video-player/javascript/modules/analytics/types.ts @@ -0,0 +1,259 @@ +/** + * Analytics types for ImageKit video player. + * Aligned with ClickHouse schema (session, player, event) and ingest contract. + */ + +export type IKAnalyticsEventName = + | 'sessioninit' + | 'playerready' + | 'viewinit' + | 'viewstarted' + | 'videochange' + | 'play' + | 'playing' + | 'pause' + | 'timeupdate' + | 'seeking' + | 'seeked' + | 'rebufferstart' + | 'rebufferend' + | 'error' + | 'ended' + | 'viewend'; + +export type VideoSourceType = 'hls' | 'dash' | 'mp4' | 'webm' | 'other'; + +export type Orientation = 'landscape' | 'portrait' | 'square' | 'unknown'; + +export type ViewEndReason = 'ended' | 'videochange' | 'error' | 'dispose' | 'navigation'; + +export interface IKAnalyticsClientContext { + imagekit_id: string; + session_id: string; + player_instance_id: string; + user_id?: string; + page_url: string; + device_display_width: number; + 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; + player_width_pixels?: number; + player_autoplay?: boolean; + player_preload?: string; + player_software?: string; + player_software_version?: string; + imagekit_plugin?: string; + imagekit_plugin_version?: string; +} + +/** + * Common fields for all analytics events. Does not include `event` (discriminator). + * Event-specific required fields are enforced by the union members (e.g. IKViewStartedEvent, IKSeekedEvent). + */ +export interface IKAnalyticsEventBase { + /** Required on every event */ + event_time_iso: string; + session_start_date: string; + playback_id?: string; + event_order: number; + event_id: string; + imagekit_id: string; + session_id: string; + player_instance_id: string; + + previous_event?: IKAnalyticsEventName; + ms_from_previous_event?: number; + + /** 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; + video_startup_time_ms?: number; + page_load_time_ms?: number; + player_startup_time_ms?: number; + playback_time_instant_ms?: number; + playing_time_ms?: number; + video_upscale_percentage?: number; + video_downscale_percentage?: number; + bitrate?: number; + audio_codec?: string; + video_codec?: string; + from_position_ms?: number; + to_position_ms?: number; + seek_time_ms?: number; + rebuffer_duration_ms?: number; + error_code?: string; + error_message?: string; + error_context?: string; + custom_data_1?: string; + custom_data_2?: string; + custom_data_3?: string; + custom_data_4?: string; + custom_data_5?: string; + custom_data_6?: string; + custom_data_7?: string; + custom_data_8?: string; + custom_data_9?: string; + custom_data_10?: string; + played_delta_ms?: number; + view_end_reason?: ViewEndReason; + new_video_source_url?: string; + next_playback_id?: string; +} + +type IKEventWithName = + IKAnalyticsEventBase & + { event: TName } & + TExtra; + +type IKPlaybackEventWithName = + IKEventWithName; + +export type IKSessionInitEvent = IKEventWithName<'sessioninit'>; +export type IKPlayerReadyEvent = IKEventWithName< + 'playerready', + { page_load_time_ms: number; player_startup_time_ms: number } +>; +export type IKViewInitEvent = IKPlaybackEventWithName<'viewinit'>; +export type IKVideoChangeEvent = IKPlaybackEventWithName<'videochange'>; +export type IKPlayEvent = IKPlaybackEventWithName<'play'>; +export type IKPlayingEvent = IKPlaybackEventWithName<'playing'>; +export type IKPauseEvent = IKPlaybackEventWithName<'pause'>; +export type IKRebufferStartEvent = IKPlaybackEventWithName<'rebufferstart'>; +export type IKEndedEvent = IKPlaybackEventWithName<'ended'>; + +export type IKViewStartedEvent = IKPlaybackEventWithName< + 'viewstarted', + { video_startup_time_ms: number } +>; + +export type IKSeekingEvent = IKPlaybackEventWithName< + 'seeking', + { from_position_ms: number } +>; + +export type IKSeekedEvent = IKPlaybackEventWithName< + 'seeked', + { to_position_ms: number; seek_time_ms: number } +>; + +export type IKRebufferEndEvent = IKPlaybackEventWithName< + 'rebufferend', + { rebuffer_duration_ms: number } +>; + +export type IKErrorEvent = IKPlaybackEventWithName< + 'error', + { error_code: string } +>; + +export type IKTimeupdateEvent = IKPlaybackEventWithName< + 'timeupdate', + { + playback_time_instant_ms: number; + playing_time_ms: number; + played_delta_ms: number; + } +>; + +export type IKViewEndEvent = IKPlaybackEventWithName< + 'viewend', + { view_end_reason: ViewEndReason } +>; + +export type IKAnalyticsEvent = + | IKSessionInitEvent + | IKPlayerReadyEvent + | IKViewInitEvent + | IKVideoChangeEvent + | IKPlayEvent + | IKPlayingEvent + | IKPauseEvent + | IKRebufferStartEvent + | IKEndedEvent + | IKViewStartedEvent + | IKSeekingEvent + | IKSeekedEvent + | IKRebufferEndEvent + | IKErrorEvent + | IKTimeupdateEvent + | IKViewEndEvent; + +/** Internal event shape before encoding to wire format */ +export interface InternalAnalyticsEvent { + event: IKAnalyticsEventName; + 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; + video_startup_time_ms?: number; + page_load_time_ms?: number; + player_startup_time_ms?: number; + playback_time_instant_ms?: number; + playing_time_ms?: number; + video_upscale_percentage?: number; + video_downscale_percentage?: number; + bitrate?: number; + audio_codec?: string; + video_codec?: string; + from_position_ms?: number; + to_position_ms?: number; + seek_time_ms?: number; + rebuffer_duration_ms?: number; + error_code?: string; + error_message?: string; + error_context?: string; + played_delta_ms?: number; + view_end_reason?: ViewEndReason; + new_video_source_url?: string; + next_playback_id?: string; + ms_from_previous_event?: number; + custom_data_1?: string; + custom_data_2?: string; + custom_data_3?: string; + custom_data_4?: string; + custom_data_5?: string; + custom_data_6?: string; + custom_data_7?: string; + custom_data_8?: string; + custom_data_9?: string; + custom_data_10?: string; +} + +/** Event payload for ingest: context-only fields are omitted (sent in context only). */ +export type IKAnalyticsEventSlim = Omit< + IKAnalyticsEvent, + 'imagekit_id' | 'session_id' | 'player_instance_id' | 'session_start_date' +>; + +export interface IKAnalyticsIngestRequest { + schema_version: 1; + batch_id?: string; + sent_at_iso?: string; + context: IKAnalyticsClientContext; + events: IKAnalyticsEventSlim[]; + flush_reason?: 'interval' | 'visibility_hidden' | 'pagehide' | 'manual' | 'buffer_full' | 'dispose'; +} + +export interface AnalyticsConfig { + ingestUrl: string; + flushIntervalMs: number; + maxBatchSize: number; + timeupdateThrottleMs: number; + debug?: boolean; + user_id?: string; + customDimensions?: Record; +} diff --git a/packages/video-player/javascript/modules/analytics/video-scale-percentage.ts b/packages/video-player/javascript/modules/analytics/video-scale-percentage.ts new file mode 100644 index 0000000..1fa55d9 --- /dev/null +++ b/packages/video-player/javascript/modules/analytics/video-scale-percentage.ts @@ -0,0 +1,41 @@ +/** + * Decoded frame size (current rendition for ABR, or file for progressive) vs painted video box (CSS px). + * Uses the dominant axis scale (max of width/height ratios), matching typical fill/cover-style stretching. + */ +export function computeVideoUpDownScalePercentages(videoEl: HTMLVideoElement | null | undefined): { + video_upscale_percentage: number; + video_downscale_percentage: number; +} { + const srcW = videoEl?.videoWidth ?? 0; + const srcH = videoEl?.videoHeight ?? 0; + if (srcW <= 0 || srcH <= 0) { + return { video_upscale_percentage: 0, video_downscale_percentage: 0 }; + } + + const rect = videoEl?.getBoundingClientRect?.(); + const dispW = rect?.width ?? 0; + const dispH = rect?.height ?? 0; + if (dispW <= 0 || dispH <= 0) { + return { video_upscale_percentage: 0, video_downscale_percentage: 0 }; + } + + const sx = dispW / srcW; + const sy = dispH / srcH; + const s = Math.max(sx, sy); + + if (!isFinite(s) || s <= 0) { + return { video_upscale_percentage: 0, video_downscale_percentage: 0 }; + } + + if (s >= 1) { + return { + video_upscale_percentage: Math.round((s - 1) * 100), + video_downscale_percentage: 0, + }; + } + + return { + video_upscale_percentage: 0, + video_downscale_percentage: Math.round((1 - s) * 100), + }; +} From ee93524d27e8e3ba602d2b37a0b9cec7b8c7675e Mon Sep 17 00:00:00 2001 From: Aman Yadav Date: Mon, 27 Apr 2026 10:28:58 +0530 Subject: [PATCH 02/25] feat(analytics): mandatory playback ID to remove duplicate row and track first view load --- .../javascript/modules/analytics/analytics-tracker.ts | 8 +++++--- .../video-player/javascript/modules/analytics/types.ts | 4 ++-- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/packages/video-player/javascript/modules/analytics/analytics-tracker.ts b/packages/video-player/javascript/modules/analytics/analytics-tracker.ts index f0e1df8..c961576 100644 --- a/packages/video-player/javascript/modules/analytics/analytics-tracker.ts +++ b/packages/video-player/javascript/modules/analytics/analytics-tracker.ts @@ -63,7 +63,8 @@ export function createAnalyticsTracker(options: AnalyticsTrackerOptions): void { const session = getOrCreateSession(); const playerInstanceId = createPlayerInstanceId(); - let currentPlaybackId: string | null = null; + let currentPlaybackId: string | null = createPlaybackId(); + let hasLoadedFirstView = false; let previousVideoSourceUrl: string | null = null; let playerReadyMonotonic = 0; let playingTimeAccumulatedMs = 0; @@ -203,7 +204,7 @@ export function createAnalyticsTracker(options: AnalyticsTrackerOptions): void { case 'load_start': { const source = sig.source; const videoSourceUrl = source?.src ?? ''; - const isFirstView = currentPlaybackId === null; + const isFirstView = !hasLoadedFirstView; const prevSourceUrl = previousVideoSourceUrl; const isVideoChange = !isFirstView && !!videoSourceUrl && !!prevSourceUrl && videoSourceUrl !== prevSourceUrl; @@ -232,7 +233,8 @@ export function createAnalyticsTracker(options: AnalyticsTrackerOptions): void { }); } - const newPlaybackId = createPlaybackId(); + const newPlaybackId = isFirstView ? currentPlaybackId! : createPlaybackId(); + hasLoadedFirstView = true; stateMachine.dispatch( { type: 'load_start', diff --git a/packages/video-player/javascript/modules/analytics/types.ts b/packages/video-player/javascript/modules/analytics/types.ts index 5b4fe53..bf808c3 100644 --- a/packages/video-player/javascript/modules/analytics/types.ts +++ b/packages/video-player/javascript/modules/analytics/types.ts @@ -119,8 +119,8 @@ type IKEventWithName = type IKPlaybackEventWithName = IKEventWithName; -export type IKSessionInitEvent = IKEventWithName<'sessioninit'>; -export type IKPlayerReadyEvent = IKEventWithName< +export type IKSessionInitEvent = IKPlaybackEventWithName<'sessioninit'>; +export type IKPlayerReadyEvent = IKPlaybackEventWithName< 'playerready', { page_load_time_ms: number; player_startup_time_ms: number } >; From b31088c0fce9d1d71ed7f0ca95a9e46cc7a5a65d Mon Sep 17 00:00:00 2001 From: Aman Yadav Date: Mon, 27 Apr 2026 16:48:31 +0530 Subject: [PATCH 03/25] feat(analytics): enhance codec detection and video source URL handling in analytics tracker --- .../modules/analytics/analytics-tracker.ts | 28 ++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/packages/video-player/javascript/modules/analytics/analytics-tracker.ts b/packages/video-player/javascript/modules/analytics/analytics-tracker.ts index c961576..66e5472 100644 --- a/packages/video-player/javascript/modules/analytics/analytics-tracker.ts +++ b/packages/video-player/javascript/modules/analytics/analytics-tracker.ts @@ -71,6 +71,8 @@ export function createAnalyticsTracker(options: AnalyticsTrackerOptions): void { let lastTimeupdateEmitMonotonic = 0; let lastEmittedPlaybackTimeMs = 0; let progressiveBitrateBps: number | undefined; + let detectedVideoCodec: string | undefined; + let detectedAudioCodec: string | undefined; const context: IKAnalyticsClientContext = { imagekit_id: imagekitId, @@ -151,6 +153,9 @@ export function createAnalyticsTracker(options: AnalyticsTrackerOptions): void { const captureContext = (): Partial => { const src = getCurrentSource(); + // Use player.currentSrc() for the actual URL being played (e.g. .m3u8 after HLS transform) + // Fall back to the original source URL if currentSrc is not yet available + const actualSrc = player.currentSrc?.() || src?.src; const el = player.el()?.querySelector('video') as HTMLVideoElement | null | undefined; const vw = el?.videoWidth ?? 0; const vh = el?.videoHeight ?? 0; @@ -161,7 +166,7 @@ export function createAnalyticsTracker(options: AnalyticsTrackerOptions): void { const scale = computeVideoUpDownScalePercentages(el); return { - video_source_url: src?.src, + video_source_url: actualSrc, video_width_pixels: vw || undefined, video_height_pixels: vh || undefined, video_total_duration_ms: totalDurMs, @@ -169,6 +174,8 @@ export function createAnalyticsTracker(options: AnalyticsTrackerOptions): void { playing_time_ms: playingTimeAccumulatedMs, video_upscale_percentage: scale.video_upscale_percentage, video_downscale_percentage: scale.video_downscale_percentage, + audio_codec: detectedAudioCodec, + video_codec: detectedVideoCodec, }; }; @@ -213,6 +220,8 @@ export function createAnalyticsTracker(options: AnalyticsTrackerOptions): void { lastEmittedPlaybackTimeMs = 0; lastTimeupdateEmitMonotonic = 0; progressiveBitrateBps = undefined; + detectedVideoCodec = undefined; + detectedAudioCodec = undefined; // For progressive sources (no ABR), estimate bitrate from Content-Length + duration. const isABR = !!source?.abs; @@ -233,6 +242,23 @@ export function createAnalyticsTracker(options: AnalyticsTrackerOptions): void { }); } + // Extract codec info from HLS/DASH — available after first segment is parsed + player.one('canplay', () => { + try { + const tech = (player as any).tech?.(true); + const vhs = tech?.vhs ?? tech?.hls; + if (vhs) { + // sourceUpdater_.codecs has {video: "avc1...", audio: "mp4a..."} after transmux + const pc = vhs.playlistController_ ?? vhs.masterPlaylistController_; + const codecs = pc?.sourceUpdater_?.codecs; + if (codecs && typeof codecs === 'object') { + if (codecs.video) detectedVideoCodec = codecs.video; + if (codecs.audio) detectedAudioCodec = codecs.audio; + } + } + } catch { /* ignore */ } + }); + const newPlaybackId = isFirstView ? currentPlaybackId! : createPlaybackId(); hasLoadedFirstView = true; stateMachine.dispatch( From b00bd4f5f93f1b7b712dd8f122a4f2cc223eca12 Mon Sep 17 00:00:00 2001 From: Aman Yadav Date: Thu, 30 Apr 2026 08:33:31 +0530 Subject: [PATCH 04/25] feat(video-player): add new "Try It Yourself 2" page and update Vite config --- .../javascript/pages/try-it-yourself-2.html | 336 ++++++++++++++++++ .../javascript/pages/try-it-yourself.html | 4 - examples/javascript/vite.config.ts | 1 + .../javascript/modules/analytics/constants.ts | 3 +- .../modules/playlist/playlist-menu-item.ts | 26 +- .../javascript/modules/playlist/thumbnail.ts | 28 +- packages/video-player/javascript/utils.ts | 2 +- 7 files changed, 379 insertions(+), 21 deletions(-) create mode 100644 examples/javascript/pages/try-it-yourself-2.html diff --git a/examples/javascript/pages/try-it-yourself-2.html b/examples/javascript/pages/try-it-yourself-2.html new file mode 100644 index 0000000..6a6a680 --- /dev/null +++ b/examples/javascript/pages/try-it-yourself-2.html @@ -0,0 +1,336 @@ + + + + + + + Try It Yourself 2 - ImageKit Video Player + + + + +
+ ← Back to examples +

Try It Yourself 2

+

Internal page with analytics option enabled for testing.

+ +
+
+
+ + +
+ +
+ + +
+ +
+ + +
JSON array of transformation objects. Example: [{"width": 800, "height": 600}]
+
+ +
+
+ + +
+ +
+ +
+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+ +
+ +
+ +

Generated Code

+
+
+
+
+ + + + diff --git a/examples/javascript/pages/try-it-yourself.html b/examples/javascript/pages/try-it-yourself.html index dce73f4..b617030 100644 --- a/examples/javascript/pages/try-it-yourself.html +++ b/examples/javascript/pages/try-it-yourself.html @@ -257,10 +257,6 @@

Try It Yourself

-
- - -
diff --git a/examples/javascript/vite.config.ts b/examples/javascript/vite.config.ts index fa50ed1..cff12d9 100644 --- a/examples/javascript/vite.config.ts +++ b/examples/javascript/vite.config.ts @@ -32,6 +32,7 @@ export default defineConfig({ abs: path.resolve(__dirname, 'pages/abs.html'), logo: path.resolve(__dirname, 'pages/logo.html'), tryItYourself: path.resolve(__dirname, 'pages/try-it-yourself.html'), + tryItYourself2: path.resolve(__dirname, 'pages/try-it-yourself-2.html'), // Note: You didn't provide a context-menu example, so it's commented out. // contextMenu: path.resolve(__dirname, 'pages/context-menu.html'), }, diff --git a/packages/video-player/javascript/modules/analytics/constants.ts b/packages/video-player/javascript/modules/analytics/constants.ts index 3693699..9642a80 100644 --- a/packages/video-player/javascript/modules/analytics/constants.ts +++ b/packages/video-player/javascript/modules/analytics/constants.ts @@ -1,7 +1,8 @@ /** * Hardcoded analytics ingest / batching defaults (not configurable via IKPlayerOptions). */ -export const ANALYTICS_INGEST_URL = 'http://localhost:8081/analytics/ingest'; +export const ANALYTICS_INGEST_URL = 'https://stage.imagekit.io/video-analytics-ingest/analytics/ingest'; +// export const ANALYTICS_INGEST_URL = 'http://localhost:8081/analytics/ingest'; export const ANALYTICS_FLUSH_INTERVAL_MS = 5000; diff --git a/packages/video-player/javascript/modules/playlist/playlist-menu-item.ts b/packages/video-player/javascript/modules/playlist/playlist-menu-item.ts index 4b96151..7f881f0 100644 --- a/packages/video-player/javascript/modules/playlist/playlist-menu-item.ts +++ b/packages/video-player/javascript/modules/playlist/playlist-menu-item.ts @@ -78,15 +78,27 @@ export class PlaylistMenuItem extends Component { if (item?.prepared?.playlistThumbnail) { return item.prepared.playlistThumbnail; } - if (!item.poster?.transformation) { - if (!item.poster) { - item.poster = {}; - } - item.poster.transformation = [DEFAULT_TRANSFORMATION] - + + // Clone item to avoid mutating the shared object + const itemCopy: AugmentedSourceOptions = { + ...item, + poster: item.poster ? { ...item.poster } : {} + }; + + // Ensure poster is defined (TypeScript narrowing) + if (!itemCopy.poster) { + itemCopy.poster = {}; + } + + // Apply default transformation if not provided + if (!itemCopy.poster.transformation) { + itemCopy.poster.transformation = [DEFAULT_TRANSFORMATION]; } + const player = this.player_ as Player; - const preparedUrl = await preparePosterSrc(item, player.imagekitVideoPlayer().getPlayerOptions()) + const preparedUrl = await preparePosterSrc(itemCopy, player.imagekitVideoPlayer().getPlayerOptions()) + + // Cache the result on the original item for performance if(!this.item.prepared) { this.item.prepared = {}; } diff --git a/packages/video-player/javascript/modules/playlist/thumbnail.ts b/packages/video-player/javascript/modules/playlist/thumbnail.ts index 7559f1a..d2eb93d 100644 --- a/packages/video-player/javascript/modules/playlist/thumbnail.ts +++ b/packages/video-player/javascript/modules/playlist/thumbnail.ts @@ -55,15 +55,27 @@ class Thumbnail extends ClickableComponent { if (item.prepared.playlistThumbnail) { return item.prepared.playlistThumbnail; } - if (!item.poster?.transformation) { - if (!item.poster) { - item.poster = {}; - } - item.poster.transformation = [DEFAULT_TRANSFORMATION] - + + // Clone item to avoid mutating the shared object + const itemCopy: AugmentedSourceOptions = { + ...item, + poster: item.poster ? { ...item.poster } : {} + }; + + // Ensure poster is defined (TypeScript narrowing) + if (!itemCopy.poster) { + itemCopy.poster = {}; + } + + // Apply default transformation if not provided + if (!itemCopy.poster.transformation) { + itemCopy.poster.transformation = [DEFAULT_TRANSFORMATION]; } - const preparedUrl = await preparePosterSrc(item, this.options_.playerOptions) - item.prepared.playlistThumbnail = preparedUrl; // Store the prepared URL in the item + + const preparedUrl = await preparePosterSrc(itemCopy, this.options_.playerOptions) + + // Cache the result on the original item for performance + item.prepared.playlistThumbnail = preparedUrl; return preparedUrl; } diff --git a/packages/video-player/javascript/utils.ts b/packages/video-player/javascript/utils.ts index e69c1bc..9d20557 100644 --- a/packages/video-player/javascript/utils.ts +++ b/packages/video-player/javascript/utils.ts @@ -207,7 +207,7 @@ export async function preparePosterSrc( if (input.poster && (input.poster.src || input.poster.transformation)) { posterSrcUrl = ikBuild({ - src: input.poster.src ?? url.toString() + `/${THUMBNAIL_SUFFIX}`, + src: input.poster.src ?? url.toString(), urlEndpoint: '', transformation: input.poster.transformation!, }); From d8ed9df8b0a4e3f8e166a292202a6a9404a02968 Mon Sep 17 00:00:00 2001 From: Aman Yadav Date: Thu, 30 Apr 2026 08:34:06 +0530 Subject: [PATCH 05/25] feat(video-player): implement default poster transformation handling in PresentUpcoming component --- .../modules/playlist/present-upcoming.ts | 26 +++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/packages/video-player/javascript/modules/playlist/present-upcoming.ts b/packages/video-player/javascript/modules/playlist/present-upcoming.ts index 1a3b08e..1c56cb1 100644 --- a/packages/video-player/javascript/modules/playlist/present-upcoming.ts +++ b/packages/video-player/javascript/modules/playlist/present-upcoming.ts @@ -3,12 +3,19 @@ import videojs from 'video.js'; import type Player from 'video.js/dist/types/player'; import type ComponentType from 'video.js/dist/types/component'; -import type { IKPlayerOptions, SourceOptions } from '../../interfaces'; +import type { IKPlayerOptions, SourceOptions, Transformation } from '../../interfaces'; import { preparePosterSrc, CleanupRegistry } from '../../utils'; import type { Player as ImageKitPlayer } from '../../interfaces/Player'; const Component = videojs.getComponent('Component') as typeof ComponentType; +const DEFAULT_TRANSFORMATION: Transformation = { + width: 600, + aspectRatio: '16-9', + cropMode: 'pad_resize', + background: 'black', +} + export class PresentUpcoming extends Component { private item_?: SourceOptions; private playerOptions_: IKPlayerOptions; @@ -93,7 +100,22 @@ export class PresentUpcoming extends Component { this.titleEl_.textContent = `Next up: ${title}`; try { - const posterUrl = await preparePosterSrc(item, this.playerOptions_); + // Clone item to avoid mutating the shared object + const itemCopy: SourceOptions = { + ...item, + poster: item.poster ? { ...item.poster } : {} + }; + + // Ensure poster is defined (TypeScript narrowing) + if (!itemCopy.poster) { + itemCopy.poster = {}; + } + + // Apply default transformation if not provided + if (!itemCopy.poster.transformation) { + itemCopy.poster.transformation = [DEFAULT_TRANSFORMATION]; + } + const posterUrl = await preparePosterSrc(itemCopy, this.playerOptions_); const img = document.createElement('img'); img.src = posterUrl; img.alt = `Next up: ${title}`; From ff2b8847e4576d601803d942228b8f3d7727c2b0 Mon Sep 17 00:00:00 2001 From: Aman Yadav Date: Tue, 19 May 2026 10:40:35 +0530 Subject: [PATCH 06/25] feat(analytics): improve seek handling in analytics state machine and player adapter to prevent unintended pause events --- .../analytics/analytics-state-machine.ts | 21 +++++++++++++++++-- .../modules/analytics/player-adapter.ts | 11 ++++++++++ 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/packages/video-player/javascript/modules/analytics/analytics-state-machine.ts b/packages/video-player/javascript/modules/analytics/analytics-state-machine.ts index d57842a..6048dc7 100644 --- a/packages/video-player/javascript/modules/analytics/analytics-state-machine.ts +++ b/packages/video-player/javascript/modules/analytics/analytics-state-machine.ts @@ -59,6 +59,7 @@ export class AnalyticsStateMachine { private seekOpen_ = false; private seekStartMonotonic_ = 0; private seekFromPositionMs_ = 0; + private phaseBeforeSeek_: PlaybackPhase | null = null; private playStartMonotonic_ = 0; // for video startup time private lastEventMonotonic_ = 0; private lastEventName_: IKAnalyticsEventName | null = null; @@ -161,6 +162,10 @@ export class AnalyticsStateMachine { case 'pause': if (this.phase_ === 'ended' || this.phase_ === 'disposed') return; + // Defense-in-depth against seek-induced pause events that slip past the adapter + // (e.g. race between the browser's internal pause and the `seeking` event). + if (this.phase_ === 'seeking') return; + if (this.phase_ === 'paused') return; // de-dupe consecutive pause events emit('pause', ctx); this.phase_ = 'paused'; return; @@ -178,6 +183,12 @@ export class AnalyticsStateMachine { case 'seeking': if (this.phase_ === 'ended' || this.phase_ === 'disposed') return; + // Dedupe: HTML5/Video.js can fire `seeking` many times per drag as currentTime + // updates. Collapse a continuous seek into one row; only the first opens it. + if (this.phase_ === 'seeking') return; + // Remember whether the user was playing or paused before the seek so we can + // restore it on `seeked` rather than always assuming `playing`. + this.phaseBeforeSeek_ = this.phase_; this.seekOpen_ = true; this.seekStartMonotonic_ = now; this.seekFromPositionMs_ = signal.fromPositionMs; @@ -194,12 +205,18 @@ export class AnalyticsStateMachine { seek_time_ms: seekTimeMs, ...ctx, }); - this.phase_ = 'playing'; + // Restore the pre-seek phase. If the user was paused before scrubbing, stay paused; + // a subsequent `playing` will move us forward when playback actually resumes. + const prior = this.phaseBeforeSeek_; + this.phaseBeforeSeek_ = null; + this.phase_ = prior === 'paused' ? 'paused' : 'playing'; return; } case 'waiting': - if (this.phase_ !== 'playing' && this.phase_ !== 'play_requested') return; + // Only mid-play stalls count as rebuffer; initial/resume buffering is video startup. + if (!this.hasEmittedViewStarted_) return; + if (this.phase_ !== 'playing') return; if (this.seekOpen_) return; // do not treat seek as rebuffer if (this.rebufferOpen_) return; this.rebufferOpen_ = true; diff --git a/packages/video-player/javascript/modules/analytics/player-adapter.ts b/packages/video-player/javascript/modules/analytics/player-adapter.ts index 5869914..13b7095 100644 --- a/packages/video-player/javascript/modules/analytics/player-adapter.ts +++ b/packages/video-player/javascript/modules/analytics/player-adapter.ts @@ -54,6 +54,11 @@ export function createPlayerAdapter( }); cleanup.registerVideoJsListener(player, 'pause', () => { + // Suppress seek-induced pause: HTML5 video may pause internally while seeking, + // and Video.js' default scrub behavior calls player.pause()/play() around drags. + // Neither represents user intent to pause. + const playerAny = player as unknown as { seeking?: () => boolean; scrubbing?: () => boolean }; + if (playerAny.seeking?.() || playerAny.scrubbing?.()) return; onSignal({ type: 'pause' }); }); @@ -61,11 +66,17 @@ export function createPlayerAdapter( onSignal({ type: 'timeupdate' }); }); + // Coalesce `seeking` bursts during a single scrub: emit only when not already inside + // a seek window. Reset on `seeked`. + let seekOpenInAdapter = false; cleanup.registerVideoJsListener(player, 'seeking', () => { + if (seekOpenInAdapter) return; + seekOpenInAdapter = true; onSignal({ type: 'seeking' }); }); cleanup.registerVideoJsListener(player, 'seeked', () => { + seekOpenInAdapter = false; onSignal({ type: 'seeked' }); }); From 2f6b966ed6946e56052ca26c87a63550b20d1418 Mon Sep 17 00:00:00 2001 From: Aman Yadav Date: Thu, 28 May 2026 08:56:12 +0700 Subject: [PATCH 07/25] 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 08/25] 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(); From 444a2a84878e496eb3c336c01c9b4debc4831e0c Mon Sep 17 00:00:00 2001 From: Aman Yadav Date: Sun, 31 May 2026 01:24:59 +0700 Subject: [PATCH 09/25] feat(analytics): add mapError function to remap/enrich error reporting in analytics --- packages/video-player/javascript/index.ts | 1 + .../javascript/interfaces/Player.ts | 25 +++++++++++++++++++ .../modules/analytics/analytics-tracker.ts | 18 ++++++++++++- 3 files changed, 43 insertions(+), 1 deletion(-) diff --git a/packages/video-player/javascript/index.ts b/packages/video-player/javascript/index.ts index 720c109..b177434 100644 --- a/packages/video-player/javascript/index.ts +++ b/packages/video-player/javascript/index.ts @@ -64,6 +64,7 @@ class ImageKitVideoPlayerPlugin extends Plugin { config: { user_id: analyticsOpts.user_id, customDimensions: analyticsOpts.customDimensions, + mapError: analyticsOpts.mapError, debug: false, }, imagekitId: this.ikGlobalSettings_.imagekitId, diff --git a/packages/video-player/javascript/interfaces/Player.ts b/packages/video-player/javascript/interfaces/Player.ts index ac947ec..dc67d0f 100644 --- a/packages/video-player/javascript/interfaces/Player.ts +++ b/packages/video-player/javascript/interfaces/Player.ts @@ -14,10 +14,35 @@ export interface ImageKitVideoPlayerPluginInstance { getPlayerOptions(): IKPlayerOptions; } +/** The shape returned by `mapError`. All fields are optional — omitted fields keep their original value. */ +export interface MappedError { + code?: string; + message?: string; + context?: string; +} + +/** The error info passed into `mapError`. */ +export interface RawPlayerError { + code: string; + message?: string; + context?: string; +} + export interface AnalyticsConfig { enabled?: boolean; user_id?: string; customDimensions?: Record; + /** + * Optional pure function called on every error before it is reported to analytics. + * Use it to remap or enrich error codes/messages with your own classification. + * + * @example + * mapError: (err) => ({ + * code: err.code === '2' ? 'network-or-geo' : err.code, + * message: err.message, + * }) + */ + mapError?: (error: RawPlayerError) => MappedError | undefined | null; } export interface IKPlayerOptions { diff --git a/packages/video-player/javascript/modules/analytics/analytics-tracker.ts b/packages/video-player/javascript/modules/analytics/analytics-tracker.ts index 228725b..0a57a62 100644 --- a/packages/video-player/javascript/modules/analytics/analytics-tracker.ts +++ b/packages/video-player/javascript/modules/analytics/analytics-tracker.ts @@ -4,7 +4,7 @@ */ import type Player from 'video.js/dist/types/player'; import type { CleanupRegistry } from '../../utils'; -import type { SourceOptions } from '../../interfaces'; +import type { SourceOptions, RawPlayerError, MappedError } from '../../interfaces'; import type { IKAnalyticsClientContext, AnalyticsConfig, InternalAnalyticsEvent } from './types'; import { ANALYTICS_FLUSH_INTERVAL_MS, @@ -30,6 +30,7 @@ export interface AnalyticsTrackerUserConfig { user_id?: string; customDimensions?: Record; debug?: boolean; + mapError?: (error: RawPlayerError) => MappedError | undefined | null; } export interface AnalyticsTrackerOptions { @@ -387,6 +388,21 @@ export function createAnalyticsTracker(options: AnalyticsTrackerOptions): void { message = e.message; errCtx = JSON.stringify(err); } + + // Allow customer to remap/enrich the error before reporting + if (typeof userConfig.mapError === 'function') { + try { + const mapped = userConfig.mapError({ code, message, context: errCtx }); + if (mapped && typeof mapped === 'object') { + if (mapped.code !== undefined) code = String(mapped.code); + if (mapped.message !== undefined) message = mapped.message; + if (mapped.context !== undefined) errCtx = mapped.context; + } + } catch { + // Swallow customer code errors to protect analytics pipeline + } + } + stateMachine.dispatch( { type: 'error', errorCode: code, errorMessage: message, errorContext: errCtx }, captureContext From 06da939f00eef913a03ad161d2e69a825ec93d96 Mon Sep 17 00:00:00 2001 From: Aman Yadav Date: Tue, 2 Jun 2026 09:21:37 +0700 Subject: [PATCH 10/25] feat(analytics): implement imperative mapError for astro support --- examples/astro/src/pages/index.astro | 70 +++++++++++++++++++ .../video-player/astro/IKVideoPlayer.astro | 51 +++++++++++++- packages/video-player/javascript/index.ts | 7 +- 3 files changed, 120 insertions(+), 8 deletions(-) diff --git a/examples/astro/src/pages/index.astro b/examples/astro/src/pages/index.astro index 28ec86f..0e94ffb 100644 --- a/examples/astro/src/pages/index.astro +++ b/examples/astro/src/pages/index.astro @@ -168,6 +168,37 @@ const playlist: { sources: SourceOptions[]; options?: PlaylistOptions } = { signerOptions={{ mode: 'imperative' }} /> + + +
+

5. Analytics with imperative mapError

+

+ Uses a deliberately broken URL to trigger error code + 2 (MEDIA_ERR_NETWORK). The mapError callback + remaps it to network-or-geo. Watch the box below and the + browser console for confirmation. +

+ +
+ Waiting for error… +
+
@@ -208,6 +239,45 @@ const playlist: { sources: SourceOptions[]; options?: PlaylistOptions } = { if (el) { el.signerFn = makeSigner(); } + + // Imperative mapError wiring for section §5. + // Remaps Video.js error code 2 (network) to a custom label. + type MapErrorFn = (error: { code: string; message?: string; context?: string }) => + { code?: string; message?: string; context?: string } | null | undefined; + type IKAnalyticsEl = HTMLElement & { mapError?: MapErrorFn }; + + const analyticsEl = document.querySelector('#player-analytics'); + const logEl = document.querySelector('#map-error-log'); + + function showLog(before: object, after: object | null | undefined) { + if (!logEl) return; + logEl.innerHTML = + `mapError called
` + + `IN:  ${JSON.stringify(before)}
` + + `OUT: ${JSON.stringify(after)}`; + } + + if (analyticsEl) { + analyticsEl.mapError = (err) => { + // Remap every Video.js MediaError code (1-4) to a readable label so the + // before/after diff is unmistakable in the log box. + const labels: Record = { + '1': 'aborted', + '2': 'network-or-geo', + '3': 'decode-error', + '4': 'src-unsupported-or-404', + }; + const result = { + code: labels[String(err.code)] ?? `unknown-${err.code}`, + message: err.message, + // Drop verbose context to keep payload small + context: undefined, + }; + console.log('[mapError] IN:', err, '→ OUT:', result); + showLog(err, result); + return result; + }; + }