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..f75ce5ba45 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; @@ -142,6 +163,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/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/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/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