diff --git a/.size-limit.js b/.size-limit.js index 32483540f9..bbaace9a63 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -1,10 +1,8 @@ const limits = [ { // analytics-browser bundle - // Bumped 65kb → 66kb for shadow DOM support in plugin-autocapture-browser - // (SR-4788). Current actual: ~65.0kb gzipped. path: './packages/analytics-browser/lib/scripts/amplitude-min.js.gz', - limit: '66kb', + limit: '69kb', brotli: false, }, { @@ -16,7 +14,7 @@ const limits = [ { // unified SDK bundle path: './packages/unified/lib/scripts/amplitude-min.umd.js.gz', - limit: '225kb', + limit: '235kb', brotli: false, }, { diff --git a/packages/analytics-browser/src/config.ts b/packages/analytics-browser/src/config.ts index 6bcf3a4575..94779e100d 100644 --- a/packages/analytics-browser/src/config.ts +++ b/packages/analytics-browser/src/config.ts @@ -44,6 +44,27 @@ import { AmplitudeBrowser } from './browser-client'; import { VERSION } from './version'; import { getDomain, KNOWN_2LDS } from './attribution/helpers'; +export const getDelayedEventsServerUrl = ( + serverUrl: string | undefined, + delayedEventsServerUrl: string | undefined, + serverZone: ServerZoneType = DEFAULT_SERVER_ZONE, +) => { + if (serverUrl) { + // serverUrl already includes /2/httpapi; Destination uses the same `${serverUrl}/delayed` fallback. + return `${serverUrl}/delayed`; + } + if (delayedEventsServerUrl) { + return delayedEventsServerUrl; + } + switch (serverZone) { + case 'EU': + return 'https://delayed-events.prod.eu-central-1.amplitude.com/2/httpapi/delayed'; + case 'US': + default: + return 'https://delayed-events.prod.us-west-2.amplitude.com/2/httpapi/delayed'; + } +}; + // Exported for testing purposes only. Do not expose to public interface. export class BrowserConfig extends Config implements IBrowserConfig { public readonly version = VERSION; @@ -87,7 +108,7 @@ export class BrowserConfig extends Config implements IBrowserConfig { public partnerId?: string, public plan?: Plan, public serverUrl: string = '', - public delayedEventsServerUrl?: string, + delayedEventsServerUrl?: string, public serverZone: ServerZoneType = DEFAULT_SERVER_ZONE, sessionId?: number, deferredSessionId?: number, @@ -114,7 +135,13 @@ export class BrowserConfig extends Config implements IBrowserConfig { public enableRequestBodyCompression: boolean = false, public customEnrichment?: boolean | CustomEnrichmentOptions, ) { - super({ apiKey, storageProvider, transportProvider: createTransport(transport) }); + super({ + apiKey, + storageProvider, + transportProvider: createTransport(transport), + delayedEventsServerUrl, + serverZone, + }); this._cookieStorage = cookieStorage; this.deviceId = deviceId; this.lastEventId = lastEventId; @@ -142,6 +169,7 @@ export class BrowserConfig extends Config implements IBrowserConfig { this.fetchRemoteConfig = _fetchRemoteConfig; this.topLevelDomain = topLevelDomain || getDomain(); + this.delayedEventsServerUrl = getDelayedEventsServerUrl(serverUrl, delayedEventsServerUrl, serverZone); } get cookieStorage() { diff --git a/packages/analytics-browser/src/constants.ts b/packages/analytics-browser/src/constants.ts index da6c9d46e3..59611e4384 100644 --- a/packages/analytics-browser/src/constants.ts +++ b/packages/analytics-browser/src/constants.ts @@ -6,6 +6,8 @@ export const DEFAULT_PAGE_VIEW_EVENT = `${DEFAULT_EVENT_PREFIX} Page Viewed`; export const DEFAULT_FORM_START_EVENT = `${DEFAULT_EVENT_PREFIX} Form Started`; export const DEFAULT_FORM_SUBMIT_EVENT = `${DEFAULT_EVENT_PREFIX} Form Submitted`; export const DEFAULT_FILE_DOWNLOAD_EVENT = `${DEFAULT_EVENT_PREFIX} File Downloaded`; +export const DEFAULT_CONTENT_STARTED_EVENT = `${DEFAULT_EVENT_PREFIX} Content Started`; +export const DEFAULT_CONTENT_STOPPED_EVENT = `${DEFAULT_EVENT_PREFIX} Content Stopped`; export const DEFAULT_SESSION_START_EVENT = 'session_start'; export const DEFAULT_SESSION_END_EVENT = 'session_end'; diff --git a/packages/analytics-browser/src/video-capture/video-capture.ts b/packages/analytics-browser/src/video-capture/video-capture.ts index 2f377b34dc..8a35344ba6 100644 --- a/packages/analytics-browser/src/video-capture/video-capture.ts +++ b/packages/analytics-browser/src/video-capture/video-capture.ts @@ -8,12 +8,13 @@ import { BaseEvent, getHeartbeatInstance, } from '@amplitude/analytics-core'; +import { DEFAULT_CONTENT_STARTED_EVENT, DEFAULT_CONTENT_STOPPED_EVENT } from '../constants'; /** Playback states where a view session is still in progress (e.g. buffering). */ const ACTIVE_PLAYBACK_STATES = new Set(['playing', 'waiting']); export class VideoCapture { - private videoEl: HTMLVideoElement | null = null; + private videoEl: HTMLMediaElement | null = null; private heartbeat: ReturnType; private embeddedVideoPlayer: EmbeddedVideoPlayer | null = null; private vendor?: VideoVendor; @@ -28,12 +29,12 @@ export class VideoCapture { } /** - * Specify a video element to capture events from + * Specify a video or audio element to capture events from * - * @param videoEl - The HTML video element to capture events from. + * @param videoEl - The HTML video or audio element to capture events from. * @returns The VideoCapture instance. */ - withVideoElement(videoEl: HTMLVideoElement): VideoCapture { + withVideoElement(videoEl: HTMLMediaElement): VideoCapture { this.videoEl = videoEl; return this; } @@ -71,7 +72,7 @@ export class VideoCapture { } /** - * Track a "Video Content Started" event every time the video starts playing + * Track a "[Amplitude] Content Started" event every time the video starts playing * @returns The VideoCapture instance. */ captureVideoStarted(): VideoCapture { @@ -81,7 +82,7 @@ export class VideoCapture { const now = new Date().getTime(); const startEvent: BaseEvent = { insert_id: UUID(), - event_type: 'Video Content Started', + event_type: DEFAULT_CONTENT_STARTED_EVENT, time: now, event_properties: { ...nextState.lastEvent, @@ -93,7 +94,7 @@ export class VideoCapture { this.stopEvent = { ...startEvent, insert_id: UUID(), - event_type: 'Video Content Stopped', + event_type: DEFAULT_CONTENT_STOPPED_EVENT, time: now + 1, event_properties: { ...nextState.lastEvent, @@ -111,7 +112,7 @@ export class VideoCapture { } /** - * Track a "Video Content Stopped" event every time the video stops playing + * Track a "[Amplitude] Content Stopped" event every time the video stops playing * @returns The VideoCapture instance. */ captureVideoStopped(): VideoCapture { @@ -212,9 +213,14 @@ export class VideoCapture { duration: nextState.lastEvent?.duration ?? 0, start_time: nextState.lastEvent?.start_time ?? 0, position: nextState.position ?? 0, + delivery_mode: this.getDeliveryMode(), }; } + private getDeliveryMode(): 'video' | 'audio' { + return this.videoEl instanceof HTMLAudioElement ? 'audio' : 'video'; + } + parseStopEventProperties(nextState: VideoState): Record { const percentCompleted = ((nextState.position ?? 0) / (nextState.lastEvent?.duration ?? 0)) * 100; return { @@ -235,23 +241,23 @@ type UntrackVideoResult = () => void; export type TrackVideoResult = UntrackVideoResult | Error; /** - * Track video analytics events for an HTML video element or embedded video player.js instance. + * Track video analytics events for an HTML video or audio element or embedded video player.js instance. * - * Captures Video Started and Video Stopped events. + * Captures [Amplitude] Content Started and [Amplitude] Content Stopped events. * * @experimental This function is experimental and may not be stable. * @param amplitude - The Amplitude client instance. - * @param videoEl - The HTML video element or embedded video player.js instance to capture events from. + * @param videoEl - The HTML video or audio element or embedded video player.js instance to capture events from. * @param options - The options for the video capture. * @returns A function to stop the video capture. */ export function trackVideo( amplitude: BrowserClient, - videoEl: HTMLVideoElement | EmbeddedVideoPlayer, + videoEl: HTMLMediaElement | EmbeddedVideoPlayer, options: VideoCaptureOptions = {}, ): TrackVideoResult { const videoCapture = new VideoCapture(amplitude); - if (videoEl instanceof HTMLVideoElement) { + if (videoEl instanceof HTMLMediaElement) { videoCapture.withVideoElement(videoEl); } else { videoCapture.withEmbeddedPlayer(videoEl); diff --git a/packages/analytics-browser/test/config.test.ts b/packages/analytics-browser/test/config.test.ts index 9754f7fb58..3a9e016bf3 100644 --- a/packages/analytics-browser/test/config.test.ts +++ b/packages/analytics-browser/test/config.test.ts @@ -157,7 +157,7 @@ describe('config', () => { }, topLevelDomain: '.amplitude.com', enableRequestBodyCompression: false, - delayedEventsServerUrl: undefined, + delayedEventsServerUrl: 'https://delayed-events.prod.us-west-2.amplitude.com/2/httpapi/delayed', }); expect(getTopLevelDomain).toHaveBeenCalledTimes(1); }); @@ -169,6 +169,32 @@ describe('config', () => { expect(config.delayedEventsServerUrl).toBe(delayedEventsServerUrl); }); + test('should default delayedEventsServerUrl for EU', async () => { + jest.spyOn(Config, 'getTopLevelDomain').mockResolvedValueOnce('.amplitude.com'); + const config = await Config.useBrowserConfig(apiKey, { serverZone: 'EU' }, new AmplitudeBrowser()); + expect(config.delayedEventsServerUrl).toBe( + 'https://delayed-events.prod.eu-central-1.amplitude.com/2/httpapi/delayed', + ); + }); + + test('should derive delayedEventsServerUrl from custom serverUrl', async () => { + jest.spyOn(Config, 'getTopLevelDomain').mockResolvedValueOnce('.amplitude.com'); + const serverUrl = 'https://proxy.example.com/2/httpapi'; + const config = await Config.useBrowserConfig(apiKey, { serverUrl }, new AmplitudeBrowser()); + expect(config.delayedEventsServerUrl).toBe(`${serverUrl}/delayed`); + }); + + test('should prefer custom serverUrl over delayedEventsServerUrl', async () => { + jest.spyOn(Config, 'getTopLevelDomain').mockResolvedValueOnce('.amplitude.com'); + const serverUrl = 'https://proxy.example.com/2/httpapi'; + const config = await Config.useBrowserConfig( + apiKey, + { serverUrl, delayedEventsServerUrl: 'https://example.com/2/httpapi/delayed' }, + new AmplitudeBrowser(), + ); + expect(config.delayedEventsServerUrl).toBe(`${serverUrl}/delayed`); + }); + test('should fall back to memoryStorage when storageProvider is not enabled', async () => { const localStorageIsEnabledSpy = jest .spyOn(LocalStorageModule.LocalStorage.prototype, 'isEnabled') @@ -293,7 +319,7 @@ describe('config', () => { }, topLevelDomain: 'amplitude.com', enableRequestBodyCompression: false, - delayedEventsServerUrl: undefined, + delayedEventsServerUrl: 'https://delayed-events.prod.us-west-2.amplitude.com/2/httpapi/delayed', }); }); }); @@ -380,6 +406,26 @@ describe('config', () => { }); }); + describe('getDelayedEventsServerUrl', () => { + test('should default to the US delayed events endpoint', () => { + expect(Config.getDelayedEventsServerUrl(undefined, undefined)).toBe( + 'https://delayed-events.prod.us-west-2.amplitude.com/2/httpapi/delayed', + ); + }); + + test('should default unknown server zones to the US delayed events endpoint', () => { + expect(Config.getDelayedEventsServerUrl(undefined, undefined, 'STAGING' as never)).toBe( + 'https://delayed-events.prod.us-west-2.amplitude.com/2/httpapi/delayed', + ); + }); + + test('should append /delayed onto a serverUrl that already includes /2/httpapi', () => { + expect(Config.getDelayedEventsServerUrl('https://proxy.example.com/2/httpapi', undefined)).toBe( + 'https://proxy.example.com/2/httpapi/delayed', + ); + }); + }); + describe('createCookieStorage', () => { test('should return cookies', async () => { const storage = Config.createCookieStorage(DEFAULT_IDENTITY_STORAGE); diff --git a/packages/analytics-browser/test/video-capture/video-capture.test.ts b/packages/analytics-browser/test/video-capture/video-capture.test.ts index b8b6ad4d58..9c382ba217 100644 --- a/packages/analytics-browser/test/video-capture/video-capture.test.ts +++ b/packages/analytics-browser/test/video-capture/video-capture.test.ts @@ -59,7 +59,7 @@ describe('VideoCapture', () => { await flushHeartbeat(); expect(mockAmplitude.track).toHaveBeenNthCalledWith( 1, - 'Video Content Started', + '[Amplitude] Content Started', { duration: 10, hello: 'world', @@ -67,6 +67,7 @@ describe('VideoCapture', () => { play_id: expect.any(String), position: 0, start_time: 0, + delivery_mode: 'video', }, { delay: { id: expect.any(String) }, @@ -76,7 +77,7 @@ describe('VideoCapture', () => { ); expect(mockAmplitude.track).toHaveBeenNthCalledWith( 2, - 'Video Content Stopped', + '[Amplitude] Content Stopped', { duration: 10, hello: 'world', @@ -87,6 +88,7 @@ describe('VideoCapture', () => { watch_duration: 0, percent_completed: 0, stop_reason: 'timeout', + delivery_mode: 'video', }, { delay: { id: expect.any(String), timeout: 3_600_000 }, @@ -102,7 +104,7 @@ describe('VideoCapture', () => { await flushHeartbeat(); expect(mockAmplitude.track).toHaveBeenNthCalledWith( 3, - 'Video Content Stopped', + '[Amplitude] Content Stopped', { duration: 10, hello: 'world', @@ -113,6 +115,7 @@ describe('VideoCapture', () => { watch_duration: 0, percent_completed: 50, stop_reason: 'paused', + delivery_mode: 'video', }, { delay: { id: expect.any(String) }, @@ -185,7 +188,7 @@ describe('VideoCapture', () => { await flushHeartbeat(); expect(mockAmplitude.track).toHaveBeenNthCalledWith( 1, - 'Video Content Started', + '[Amplitude] Content Started', { duration: 10, hello: 'world', @@ -194,6 +197,7 @@ describe('VideoCapture', () => { position: 0, start_time: 0, view_session_id: expect.any(String), + delivery_mode: 'video', }, { delay: { id: expect.any(String) }, @@ -208,7 +212,7 @@ describe('VideoCapture', () => { await flushHeartbeat(); expect(mockAmplitude.track).toHaveBeenNthCalledWith( 3, - 'Video Content Stopped', + '[Amplitude] Content Stopped', { duration: 10, hello: 'world', @@ -220,6 +224,7 @@ describe('VideoCapture', () => { percent_completed: 50, stop_reason: 'paused', view_session_id: expect.any(String), + delivery_mode: 'video', }, { delay: { id: expect.any(String) }, @@ -249,13 +254,14 @@ describe('VideoCapture', () => { await flushHeartbeat(); expect(mockAmplitude.track).toHaveBeenNthCalledWith( 1, - 'Video Content Started', + '[Amplitude] Content Started', { duration: 10, play_id: expect.any(String), position: 0, start_time: 0, view_session_id: expect.any(String), + delivery_mode: 'video', }, { delay: { id: expect.any(String) }, @@ -270,7 +276,7 @@ describe('VideoCapture', () => { await flushHeartbeat(); expect(mockAmplitude.track).toHaveBeenNthCalledWith( 3, - 'Video Content Stopped', + '[Amplitude] Content Stopped', { duration: 10, play_id: expect.any(String), @@ -280,6 +286,7 @@ describe('VideoCapture', () => { percent_completed: 50, stop_reason: 'paused', view_session_id: expect.any(String), + delivery_mode: 'video', }, { delay: { id: expect.any(String) }, @@ -294,6 +301,22 @@ describe('VideoCapture', () => { const stopVideoCapture = trackVideo(mockAmplitude, null as unknown as HTMLVideoElement); expect(stopVideoCapture).toBeInstanceOf(Error); }); + + it('should set delivery_mode to audio for an HTML audio element', async () => { + const stopVideoCapture = trackVideo(mockAmplitude, document.createElement('audio')); + currentVideoObserver!.emitStateChange( + { playbackState: 'paused', lastEvent: undefined }, + { playbackState: 'playing', lastEvent: { duration: 10, last_position: undefined } }, + ); + await flushHeartbeat(); + expect(mockAmplitude.track).toHaveBeenNthCalledWith( + 1, + '[Amplitude] Content Started', + expect.objectContaining({ delivery_mode: 'audio' }), + expect.any(Object), + ); + typeof stopVideoCapture === 'function' && stopVideoCapture(); + }); }); describe('buffering (waiting state)', () => { @@ -346,7 +369,7 @@ describe('VideoCapture', () => { expect(mockAmplitude.track).toHaveBeenCalledTimes(3); expect(mockAmplitude.track).toHaveBeenNthCalledWith( 3, - 'Video Content Stopped', + '[Amplitude] Content Stopped', expect.objectContaining({ play_id: playId, stop_reason: 'paused', @@ -376,7 +399,7 @@ describe('VideoCapture', () => { await jest.advanceTimersByTimeAsync(60_000); expect(mockAmplitude.track).toHaveBeenCalledTimes(1); expect(mockAmplitude.track).toHaveBeenCalledWith( - 'Video Content Stopped', + '[Amplitude] Content Stopped', expect.objectContaining({ position: 8, watch_duration: 8, @@ -395,9 +418,9 @@ describe('VideoCapture', () => { }; const pausedState: VideoState = { playbackState: 'paused', lastEvent: undefined }; - /** The "Video Content Stopped" event flushed by stop(). */ + /** The "[Amplitude] Content Stopped" event flushed by stop(). */ const untrackedStopEvent = expect.objectContaining({ - event_type: 'Video Content Stopped', + event_type: '[Amplitude] Content Stopped', event_properties: expect.objectContaining({ stop_reason: 'untracked' }), }); @@ -506,7 +529,7 @@ describe('VideoCapture', () => { expect(mockAmplitude.track).toHaveBeenCalledTimes(1); expect(mockAmplitude.track).toHaveBeenCalledWith( - 'Video Content Stopped', + '[Amplitude] Content Stopped', expect.objectContaining({ stop_reason: 'untracked', position: 4, watch_duration: 4 }), expect.objectContaining({ delay: { id: expect.any(String) } }), ); @@ -559,7 +582,7 @@ describe('VideoCapture', () => { await jest.advanceTimersByTimeAsync(60_000); expect(mockAmplitude.track).toHaveBeenCalledTimes(1); expect(mockAmplitude.track).toHaveBeenCalledWith( - 'Video Content Stopped', + '[Amplitude] Content Stopped', expect.objectContaining({ video: 'second', stop_reason: 'timeout' }), expect.objectContaining({ delay: { id: expect.any(String), timeout: 3_600_000 } }), ); @@ -579,6 +602,7 @@ describe('VideoCapture', () => { duration: 10, start_time: 2, position: 5, + delivery_mode: 'video', }); }); @@ -592,6 +616,21 @@ describe('VideoCapture', () => { duration: 0, start_time: 0, position: 0, + delivery_mode: 'video', + }); + }); + + it('should set delivery_mode to audio for an audio element', () => { + const capture = new VideoCapture(mockAmplitude).withVideoElement(document.createElement('audio')); + expect( + capture.parseStartEventProperties({ + playbackState: 'playing', + }), + ).toEqual({ + duration: 0, + start_time: 0, + position: 0, + delivery_mode: 'audio', }); }); }); @@ -612,6 +651,7 @@ describe('VideoCapture', () => { position: 5, watch_duration: 30, percent_completed: 50, + delivery_mode: 'video', }); }); @@ -626,6 +666,7 @@ describe('VideoCapture', () => { position: 0, watch_duration: 0, percent_completed: 0, + delivery_mode: 'video', }); }); }); diff --git a/packages/analytics-core/src/config.ts b/packages/analytics-core/src/config.ts index c246deb0ae..ceeccee9fc 100644 --- a/packages/analytics-core/src/config.ts +++ b/packages/analytics-core/src/config.ts @@ -73,7 +73,6 @@ export class Config implements IConfig { this.offline = options.offline !== undefined ? options.offline : defaultConfig.offline; this.optOut = options.optOut ?? defaultConfig.optOut; this.serverUrl = options.serverUrl; - this.delayedEventsServerUrl = options.delayedEventsServerUrl; this.serverZone = options.serverZone || defaultConfig.serverZone; this.storageProvider = options.storageProvider; this.transportProvider = options.transportProvider; @@ -83,6 +82,7 @@ export class Config implements IConfig { const serverConfig = createServerConfig(options.serverUrl, options.serverZone, options.useBatch); this.serverZone = serverConfig.serverZone; this.serverUrl = serverConfig.serverUrl; + this.delayedEventsServerUrl = getDelayedEventsServerUrl(options.delayedEventsServerUrl, this.serverZone); } } @@ -108,6 +108,22 @@ export const createServerConfig = ( }; }; +export const getDelayedEventsServerUrl = ( + delayedEventsServerUrl: string | undefined, + serverZone: ServerZoneType = getDefaultConfig().serverZone, +) => { + if (delayedEventsServerUrl) { + return delayedEventsServerUrl; + } + switch (serverZone) { + case 'EU': + return 'https://delayed-events.prod.eu-central-1.amplitude.com/2/httpapi/delayed'; + case 'US': + default: + return 'https://delayed-events.prod.us-west-2.amplitude.com/2/httpapi/delayed'; + } +}; + export class RequestMetadata implements IRequestMetadata { sdk: { metrics: { diff --git a/packages/analytics-core/src/heartbeat.ts b/packages/analytics-core/src/heartbeat.ts index 8281afcc38..882dca6d27 100644 --- a/packages/analytics-core/src/heartbeat.ts +++ b/packages/analytics-core/src/heartbeat.ts @@ -3,6 +3,7 @@ import { CoreClient } from './types/client/core-client'; import { BaseEvent, Delay } from './types/event/base-event'; import { Result } from './types/result'; import { UUID } from './utils/uuid'; +import { getGlobalScope } from './global-scope'; type DelayedEvent = BaseEvent & { delay: Delay; @@ -15,7 +16,7 @@ export class Heartbeat { private delayId: string; private interval: NodeJS.Timeout | null = null; private resetPromise: Promise | null = null; - + private global: ReturnType | undefined; constructor( private client: CoreClient, private pulse: number, @@ -24,6 +25,15 @@ export class Heartbeat { ) { this.events = new Map(); this.delayId = UUID(); + this.global = getGlobalScope(); + if (typeof Window !== 'undefined' && this.global instanceof Window) { + const win = this.global; + win.addEventListener('visibilitychange', () => { + if (win.document.visibilityState === 'hidden') { + void this.resetHeartbeat(); + } + }); + } } private async heartbeat(): Promise { @@ -146,7 +156,6 @@ export class Heartbeat { } const DEFAULT_HEARTBEAT_INTERVAL = 60_000; -// const DEFAULT_HEARTBEAT_INTERVAL = 2_000; // TODO: DO NOT MERGE THIS const DEFAULT_HEARTBEAT_DELAY_TIMEOUT = 3_600_000; type HeartbeatMap = Map; diff --git a/packages/analytics-core/src/observers/video.ts b/packages/analytics-core/src/observers/video.ts index e5f3b7a701..c57a175ac3 100644 --- a/packages/analytics-core/src/observers/video.ts +++ b/packages/analytics-core/src/observers/video.ts @@ -22,7 +22,7 @@ export type State = { }; export type VideoObserverParams = { - videoEl: HTMLVideoElement | EmbeddedVideoPlayer | MuxElement; + videoEl: HTMLMediaElement | EmbeddedVideoPlayer | MuxElement; onStateChange: (previousState: State, nextState: State) => void; vendor?: Vendor; isEmbedded?: boolean; @@ -74,7 +74,7 @@ export class VideoObserver { if (isEmbedded) { this.untrack = trackEmbeddedVideo(videoEl as EmbeddedVideoPlayer, this.handler, vendor); } else { - this.untrack = trackHtmlVideo(videoEl as HTMLVideoElement, this.handler, vendor); + this.untrack = trackHtmlVideo(videoEl as HTMLMediaElement, this.handler, vendor); } } diff --git a/packages/analytics-core/src/video-analytics/track-video.ts b/packages/analytics-core/src/video-analytics/track-video.ts index 4a70185933..f6fa1aa696 100644 --- a/packages/analytics-core/src/video-analytics/track-video.ts +++ b/packages/analytics-core/src/video-analytics/track-video.ts @@ -17,7 +17,7 @@ function calculatePercentCompleted(currentTime: number, duration: number) { return percentCompleted; } -function getVideoData(videoEl: HTMLVideoElement | MuxElement, stopReason?: VideoStopReason) { +function getVideoData(videoEl: HTMLMediaElement | MuxElement, stopReason?: VideoStopReason) { const currentTime = videoEl.currentTime; const duration = videoEl.duration; return { @@ -44,7 +44,7 @@ function getMuxMetadata(videoEl: MuxElement) { * @param handlers - The video handlers to call when on video lifecycle events. * @returns A function to untrack the video. */ -export function trackHtmlVideo(videoEl: HTMLVideoElement | MuxElement, handlers: VideoHandler, vendor?: Vendor) { +export function trackHtmlVideo(videoEl: HTMLMediaElement | MuxElement, handlers: VideoHandler, vendor?: Vendor) { const playHandler = () => { const startEvent: VideoEvent = { ...getVideoData(videoEl), @@ -91,7 +91,7 @@ export function trackHtmlVideo(videoEl: HTMLVideoElement | MuxElement, handlers: videoEl.addEventListener('seeked', seekedHandler); const timeupdateHandler = () => { - const media = videoEl as HTMLVideoElement; + const media = videoEl as HTMLMediaElement; const timeupdateEvent: TimeUpdateEvent = { position: videoEl.currentTime, isSeeking: !!media.seeking, diff --git a/packages/analytics-core/test/config.test.ts b/packages/analytics-core/test/config.test.ts index 52c74e21b3..e6ffc4996c 100644 --- a/packages/analytics-core/test/config.test.ts +++ b/packages/analytics-core/test/config.test.ts @@ -33,7 +33,7 @@ describe('config', () => { plan: undefined, ingestionMetadata: undefined, serverUrl: 'https://api2.amplitude.com/2/httpapi', - delayedEventsServerUrl: undefined, + delayedEventsServerUrl: 'https://delayed-events.prod.us-west-2.amplitude.com/2/httpapi/delayed', serverZone: 'US', storageProvider: defaultConfig.storageProvider, transportProvider: defaultConfig.transportProvider, @@ -55,6 +55,19 @@ describe('config', () => { expect(config.delayedEventsServerUrl).toBe(delayedEventsServerUrl); }); + test('should default delayedEventsServerUrl for EU', () => { + const defaultConfig = useDefaultConfig(); + const config = new Config({ + apiKey: API_KEY, + serverZone: 'EU', + storageProvider: defaultConfig.storageProvider, + transportProvider: defaultConfig.transportProvider, + }); + expect(config.delayedEventsServerUrl).toBe( + 'https://delayed-events.prod.eu-central-1.amplitude.com/2/httpapi/delayed', + ); + }); + test('should overwrite default config', () => { const defaultConfig = useDefaultConfig(); const config = new Config({ @@ -90,7 +103,7 @@ describe('config', () => { sourceVersion: '2.0.0', }, serverUrl: 'https://api2.amplitude.com/batch', - delayedEventsServerUrl: undefined, + delayedEventsServerUrl: 'https://delayed-events.prod.us-west-2.amplitude.com/2/httpapi/delayed', serverZone: 'US', storageProvider: defaultConfig.storageProvider, transportProvider: defaultConfig.transportProvider, diff --git a/packages/analytics-core/test/heartbeat.test.ts b/packages/analytics-core/test/heartbeat.test.ts index 639ccf9aa6..2f85be976c 100644 --- a/packages/analytics-core/test/heartbeat.test.ts +++ b/packages/analytics-core/test/heartbeat.test.ts @@ -1,6 +1,7 @@ import { Heartbeat } from '../src/heartbeat'; import { getHeartbeatInstance } from '../src/'; import { CoreClient } from '../src/types/client/core-client'; +import * as GlobalScopeModule from '../src/global-scope'; describe('heartbeat', () => { let mockClient: CoreClient; @@ -330,6 +331,75 @@ describe('heartbeat', () => { }); }); + describe('page visibility', () => { + class FakeWindow { + document = { visibilityState: 'visible' }; + private listeners: Array<() => void> = []; + addEventListener = jest.fn((event: string, handler: () => void) => { + if (event === 'visibilitychange') { + this.listeners.push(handler); + } + }); + dispatchVisibility(state: string) { + this.document.visibilityState = state; + this.listeners.forEach((handler) => handler()); + } + } + + let fakeWindow: FakeWindow; + let originalWindow: unknown; + + beforeEach(() => { + fakeWindow = new FakeWindow(); + originalWindow = (globalThis as { Window?: unknown }).Window; + (globalThis as { Window: unknown }).Window = FakeWindow; + jest.spyOn(GlobalScopeModule, 'getGlobalScope').mockReturnValue(fakeWindow as unknown as typeof globalThis); + heartbeat = new Heartbeat(mockClient, 1000, 1000, mockLoggerProvider); + }); + + afterEach(() => { + jest.spyOn(GlobalScopeModule, 'getGlobalScope').mockRestore(); + if (originalWindow === undefined) { + delete (globalThis as { Window?: unknown }).Window; + } else { + (globalThis as { Window: unknown }).Window = originalWindow; + } + }); + + test('should subscribe to visibilitychange on Window', () => { + expect(fakeWindow.addEventListener).toHaveBeenCalledWith('visibilitychange', expect.any(Function)); + }); + + test('should reset heartbeat when the page becomes hidden', async () => { + await trackWithTimers({ + insert_id: '1', + event_type: 'test', + event_properties: { test: 'test' }, + }); + expect(trackMock).toHaveBeenCalledTimes(1); + jest.clearAllMocks(); + + fakeWindow.dispatchVisibility('hidden'); + await jest.advanceTimersByTimeAsync(0); + + expect(trackMock).toHaveBeenCalledTimes(1); + }); + + test('should not reset heartbeat when visibility changes to visible', async () => { + await trackWithTimers({ + insert_id: '1', + event_type: 'test', + event_properties: { test: 'test' }, + }); + jest.clearAllMocks(); + + fakeWindow.dispatchVisibility('visible'); + await jest.advanceTimersByTimeAsync(0); + + expect(trackMock).not.toHaveBeenCalled(); + }); + }); + describe('.resetHeartbeat', () => { test('should only reset the heartbeat once if called multiple times', async () => { const heartbeatMock = jest.spyOn(heartbeat as any, 'heartbeat'); diff --git a/packages/analytics-core/test/observers/video.test.ts b/packages/analytics-core/test/observers/video.test.ts index 7ed65649b2..612d4877c5 100644 --- a/packages/analytics-core/test/observers/video.test.ts +++ b/packages/analytics-core/test/observers/video.test.ts @@ -26,6 +26,15 @@ describe('VideoObserver', () => { expect(trackHtmlVideo).toHaveBeenCalledTimes(1); }); + it('should call trackHtmlVideo for an HTML audio element', () => { + const audio = document.createElement('audio'); + new VideoObserver({ + videoEl: audio, + onStateChange: jest.fn(), + }); + expect(trackHtmlVideo).toHaveBeenCalledTimes(1); + }); + it('should call trackEmbeddedVideo when isEmbedded is true and vendor is mux', () => { const player = null; new VideoObserver({ diff --git a/packages/analytics-node/test/config.test.ts b/packages/analytics-node/test/config.test.ts index e1c7dc31b0..69e6bf9230 100644 --- a/packages/analytics-node/test/config.test.ts +++ b/packages/analytics-node/test/config.test.ts @@ -26,7 +26,7 @@ describe('config', () => { plan: undefined, ingestionMetadata: undefined, serverUrl: 'https://api2.amplitude.com/2/httpapi', - delayedEventsServerUrl: undefined, + delayedEventsServerUrl: 'https://delayed-events.prod.us-west-2.amplitude.com/2/httpapi/delayed', serverZone: 'US', storageProvider: undefined, transportProvider: new Http(), @@ -61,7 +61,7 @@ describe('config', () => { plan: undefined, ingestionMetadata: undefined, serverUrl: 'https://api2.amplitude.com/2/httpapi', - delayedEventsServerUrl: undefined, + delayedEventsServerUrl: 'https://delayed-events.prod.us-west-2.amplitude.com/2/httpapi/delayed', serverZone: 'US', storageProvider: undefined, transportProvider: new Http(), diff --git a/packages/analytics-react-native/test/config.test.ts b/packages/analytics-react-native/test/config.test.ts index 0cf7262988..56b050edd3 100644 --- a/packages/analytics-react-native/test/config.test.ts +++ b/packages/analytics-react-native/test/config.test.ts @@ -43,7 +43,7 @@ describe('config', () => { plan: undefined, ingestionMetadata: undefined, serverUrl: 'https://api2.amplitude.com/2/httpapi', - delayedEventsServerUrl: undefined, + delayedEventsServerUrl: 'https://delayed-events.prod.us-west-2.amplitude.com/2/httpapi/delayed', serverZone: 'US', sessionTimeout: 300000, trackingOptions: { @@ -99,7 +99,7 @@ describe('config', () => { plan: undefined, ingestionMetadata: undefined, serverUrl: 'https://api2.amplitude.com/2/httpapi', - delayedEventsServerUrl: undefined, + delayedEventsServerUrl: 'https://delayed-events.prod.us-west-2.amplitude.com/2/httpapi/delayed', serverZone: 'US', sessionTimeout: 300000, storageProvider: new core.MemoryStorage(), @@ -181,7 +181,7 @@ describe('config', () => { sourceVersion: '2.0.0', }, serverUrl: 'https://api2.amplitude.com/2/httpapi', - delayedEventsServerUrl: undefined, + delayedEventsServerUrl: 'https://delayed-events.prod.us-west-2.amplitude.com/2/httpapi/delayed', serverZone: 'US', _sessionId: -1, sessionTimeout: 1, diff --git a/test-server/video-analytics/track-html-video.html b/test-server/video-analytics/track-html-video.html index 7c3ad24884..4a60313296 100644 --- a/test-server/video-analytics/track-html-video.html +++ b/test-server/video-analytics/track-html-video.html @@ -6,12 +6,12 @@ +

Track HTML Video Test