Skip to content

Commit

Permalink
Merge pull request #121 from bitmovin/feature/PW-22287-fix-delayed-cs…
Browse files Browse the repository at this point in the history
…ai-buffering-reporting

[PW-22287] Fix delayed buffering reporting for ad-related events
  • Loading branch information
wasp898 authored Dec 24, 2024
2 parents 3345e20 + b127914 commit e28e103
Show file tree
Hide file tree
Showing 9 changed files with 291 additions and 193 deletions.
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/)
and this project adheres to [Semantic Versioning](http://semver.org/).

## [Unreleased]
### Fixed
- Delayed buffering reporting on CSAI events
- Reporting of ad break ended for SSAI ads

## [6.1.1] - 2024-12-19
### Fixed
Expand Down
4 changes: 1 addition & 3 deletions spec/helper/MockHelper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -222,9 +222,7 @@ export namespace MockHelper {
}
}),
subtitles: {
list: jest.fn(() => {
return [];
}),
list: jest.fn(() => []),
},
on: (eventType: PlayerEvent, callback: PlayerEventCallback) => playerEventHelper.on(eventType, callback),
off: (eventType: PlayerEvent, callback: PlayerEventCallback) => playerEventHelper.off(eventType, callback),
Expand Down
6 changes: 5 additions & 1 deletion spec/tests/ConvivaAnalytics.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -503,18 +503,21 @@ describe(ConvivaAnalytics, () => {
})

it('reports ad finished', () => {
playerEventHelper.fireAdBreakStartedEvent(0);
playerEventHelper.fireAdFinishedEvent();

expect(MockHelper.latestAdAnalytics.reportAdEnded).toHaveBeenCalled();
})

it('reports ad skipped', () => {
playerEventHelper.fireAdBreakStartedEvent(0);
playerEventHelper.fireAdSkippedEvent();

expect(MockHelper.latestAdAnalytics.reportAdSkipped).toHaveBeenCalled();
})

it('reports ad error', () => {
playerEventHelper.fireAdBreakStartedEvent(0);
playerEventHelper.fireAdErrorEvent();

expect(MockHelper.latestAdAnalytics.reportAdError).toHaveBeenCalledWith(
Expand All @@ -524,12 +527,13 @@ describe(ConvivaAnalytics, () => {
})

it('reports ad break ended when restoring content', () => {
playerEventHelper.fireAdBreakStartedEvent(0);
playerEventHelper.fireRestoringContentEvent();

expect(MockHelper.latestVideoAnalytics.reportAdBreakEnded).toHaveBeenCalledTimes(1);
});

it('reports the playback metric on ad finished', () => {
it('reports the playback metric on ad break finished', () => {
(MockHelper.latestVideoAnalytics.reportPlaybackMetric as jest.Mock).mockReset();

playerEventHelper.fireAdBreakFinishedEvent();
Expand Down
117 changes: 48 additions & 69 deletions spec/tests/ConvivaAnalyticsTracker.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,83 +77,62 @@ describe(ConvivaAnalyticsTracker, () => {
expect(invokedTimesAfter).toBe(invokedTimesBefore);
})

it('should report ad break ended on RestoringContent event', () => {
const { playerMock, playerEventHelper } = MockHelper.createPlayerMock();
const convivaAnalyticsTracker = new ConvivaAnalyticsTracker('test-key');

convivaAnalyticsTracker.attachPlayer(playerMock);
playerEventHelper.firePlayEvent();
convivaAnalyticsTracker.trackRestoringContent();

expect(MockHelper.latestVideoAnalytics.reportAdBreakEnded).toHaveBeenCalledTimes(1);
});

it('should not report the player state on AdBreakFinished events if there is still an active ad', () => {
const { playerMock, playerEventHelper } = MockHelper.createPlayerMock();
const convivaAnalyticsTracker = new ConvivaAnalyticsTracker('test-key');

convivaAnalyticsTracker.attachPlayer(playerMock);
playerEventHelper.fireAdBreakFinishedEvent();
convivaAnalyticsTracker.trackAdBreakFinished();

expect(MockHelper.latestVideoAnalytics.reportPlaybackMetric).not.toHaveBeenCalled();
});

it('should report the player state on AdBreakFinished events if there is no active ad', () => {
const { playerMock, playerEventHelper } = MockHelper.createPlayerMock();
const convivaAnalyticsTracker = new ConvivaAnalyticsTracker('test-key');

convivaAnalyticsTracker.attachPlayer(playerMock);
playerEventHelper.firePlayEvent();
convivaAnalyticsTracker.trackRestoringContent();
convivaAnalyticsTracker.trackAdBreakFinished();
describe('CSAI', () => {
let convivaAnalyticsTracker: ConvivaAnalyticsTracker;

expect(MockHelper.latestVideoAnalytics.reportPlaybackMetric).toHaveBeenCalled();
beforeEach(() => {
const { playerMock, playerEventHelper } = MockHelper.createPlayerMock();
convivaAnalyticsTracker = new ConvivaAnalyticsTracker('test-key');
convivaAnalyticsTracker.attachPlayer(playerMock);
playerEventHelper.firePlayEvent();
jest.spyOn(playerMock, 'isPlaying').mockReturnValue(true);
});

it('should not report ad break ended on AdBreakFinished if there is still an active ad', () => {
convivaAnalyticsTracker.trackAdBreakStarted(Conviva.Constants.AdType.CLIENT_SIDE);
convivaAnalyticsTracker.trackAdBreakFinished(Conviva.Constants.AdType.CLIENT_SIDE);

expect(MockHelper.latestVideoAnalytics.reportAdBreakEnded).toHaveBeenCalledTimes(0);
expect(MockHelper.latestVideoAnalytics.reportPlaybackMetric).not.toHaveBeenCalledWith(Conviva.Constants.Playback.PLAYER_STATE, Conviva.Constants.PlayerState.PLAYING);
});

it('should report ad break ended if AdBreakFinished is preceeded by RestoringContent event', () => {
convivaAnalyticsTracker.trackAdBreakStarted(Conviva.Constants.AdType.CLIENT_SIDE);
convivaAnalyticsTracker.trackRestoringContent();
convivaAnalyticsTracker.trackAdBreakFinished(Conviva.Constants.AdType.CLIENT_SIDE);

expect(MockHelper.latestVideoAnalytics.reportAdBreakEnded).toHaveBeenCalledTimes(1);
expect(MockHelper.latestVideoAnalytics.reportPlaybackMetric).toHaveBeenCalledWith(Conviva.Constants.Playback.PLAYER_STATE, Conviva.Constants.PlayerState.PLAYING);
});

it('should not report ad break ended multiple times', () => {
convivaAnalyticsTracker.trackAdBreakStarted(Conviva.Constants.AdType.CLIENT_SIDE);
convivaAnalyticsTracker.trackRestoringContent();
convivaAnalyticsTracker.trackRestoringContent();
convivaAnalyticsTracker.trackRestoringContent();

expect(MockHelper.latestVideoAnalytics.reportAdBreakEnded).toHaveBeenCalledTimes(1);
});
});

describe('trackPlaybackStateChanged', () => {
describe('SSAI', () => {
let convivaAnalyticsTracker: ConvivaAnalyticsTracker;

beforeEach(() => {
const { playerMock, playerEventHelper } = MockHelper.createPlayerMock();
convivaAnalyticsTracker = new ConvivaAnalyticsTracker('test-key');

const {playerMock} = MockHelper.createPlayerMock();
convivaAnalyticsTracker.attachPlayer(playerMock);
jest.spyOn(playerMock, 'getSource').mockImplementation(() => ({ title: 'test-title' }));
convivaAnalyticsTracker.initializeSession();
})

test.each([
PlayerEvent.Play,
PlayerEvent.Seek,
PlayerEvent.TimeShift,
PlayerEvent.AdBreakStarted,
PlayerEvent.AdFinished,
])('should start timer for stalling when reported player event is %s', (event) => {
const stallTrackingStartTimeoutSpy = jest.spyOn(convivaAnalyticsTracker['stallTrackingTimeout'], 'start');

convivaAnalyticsTracker.trackPlaybackStateChanged({ type: event } as PlayerEventBase);

expect(stallTrackingStartTimeoutSpy).toHaveBeenCalled();
})

test.each([
PlayerEvent.StallStarted,
PlayerEvent.Playing,
PlayerEvent.Paused,
PlayerEvent.Seeked,
PlayerEvent.TimeShifted,
PlayerEvent.StallEnded,
PlayerEvent.PlaybackFinished,
PlayerEvent.AdStarted,
])('should clear timer for stalling when reported player event is %s', (event) => {
const stallTrackingStopTimeoutSpy = jest.spyOn(convivaAnalyticsTracker['stallTrackingTimeout'], 'clear');

convivaAnalyticsTracker.trackPlaybackStateChanged({ type: event } as PlayerEventBase);

expect(stallTrackingStopTimeoutSpy).toHaveBeenCalled();
})
})
playerEventHelper.firePlayEvent();
jest.spyOn(playerMock, 'isPlaying').mockReturnValue(true);
});

it('should report the player state on AdBreakFinished when there is an active ad', () => {
convivaAnalyticsTracker.trackAdBreakStarted(Conviva.Constants.AdType.SERVER_SIDE);
convivaAnalyticsTracker.trackAdBreakFinished(Conviva.Constants.AdType.SERVER_SIDE);

expect(MockHelper.latestVideoAnalytics.reportPlaybackMetric).toHaveBeenCalledWith(Conviva.Constants.Playback.PLAYER_STATE, Conviva.Constants.PlayerState.PLAYING);
});
});
})

const getInvokedTimes = (mock: unknown) => {
Expand Down
149 changes: 120 additions & 29 deletions spec/tests/PlayerEvents.spec.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,27 @@
import { MockHelper, PlayerEventHelper } from '../helper/MockHelper';
import { ConvivaAnalytics } from '../../src/ts';
import * as Conviva from '@convivainc/conviva-js-coresdk';
import { PlayerAPI } from 'bitmovin-player';
import { AdEvent, PlayerAPI } from 'bitmovin-player';
import { ConvivaAnalyticsTracker } from '../../src/ts/ConvivaAnalyticsTracker';
import { PlayerEvent } from '../helper/PlayerEvent';

jest.mock('@convivainc/conviva-js-coresdk', () => {
const { MockHelper } = jest.requireActual('../helper/MockHelper');
return MockHelper.createConvivaMock();
});

const PlayerState = Conviva.Constants.PlayerState;
const PLAYER_STATE = Conviva.Constants.Playback.PLAYER_STATE;

describe('player event tests', () => {
let playerMock: PlayerAPI;
let playerEventHelper: PlayerEventHelper
let convivaAnalytics: ConvivaAnalytics;

beforeEach(() => {
({ playerMock, playerEventHelper } = MockHelper.createPlayerMock());

new ConvivaAnalytics(playerMock, 'TEST-KEY');
convivaAnalytics = new ConvivaAnalytics(playerMock, 'TEST-KEY');
});

describe('player event handling', () => {
Expand All @@ -39,41 +44,96 @@ describe('player event tests', () => {
playerEventHelper.firePlayEvent();
});

it('on playing', () => {
jest.spyOn(playerMock, 'isPaused').mockReturnValue(false);
jest.spyOn(playerMock, 'isPlaying').mockReturnValue(true);
playerEventHelper.firePlayingEvent();

expect(MockHelper.latestVideoAnalytics.reportPlaybackMetric).toHaveBeenCalledWith(
Conviva.Constants.Playback.PLAYER_STATE,
Conviva.Constants.PlayerState.PLAYING,
);
test.each`
event | isAdActive | expectedPlayerState
${PlayerEvent.Playing} | ${false} | ${PlayerState.PLAYING}
${PlayerEvent.Playing} | ${true} | ${PlayerState.PLAYING}
${PlayerEvent.Paused} | ${false} | ${PlayerState.PAUSED}
${PlayerEvent.Paused} | ${true} | ${PlayerState.PAUSED}
${PlayerEvent.StallStarted} | ${false} | ${PlayerState.BUFFERING}
${PlayerEvent.StallStarted} | ${true} | ${PlayerState.BUFFERING}
${PlayerEvent.StallEnded} | ${false} | ${PlayerState.PLAYING}
${PlayerEvent.StallEnded} | ${true} | ${PlayerState.PLAYING}
${PlayerEvent.Seeked} | ${false} | ${PlayerState.PLAYING}
${PlayerEvent.Seeked} | ${true} | ${PlayerState.PLAYING}
${PlayerEvent.TimeShifted} | ${false} | ${PlayerState.PLAYING}
${PlayerEvent.TimeShifted} | ${true} | ${PlayerState.PLAYING}
`('should report player state $expectedPlayerState on ad metric $isAdActive after event $event', ({ event, isAdActive, expectedPlayerState }) => {
jest.spyOn(playerMock, 'isPlaying').mockReturnValue(expectedPlayerState === PlayerState.PLAYING);
jest.spyOn(playerMock, 'isPaused').mockReturnValue(expectedPlayerState === PlayerState.PAUSED);

const reportAdMetricSpy = jest.spyOn(MockHelper.latestAdAnalytics, 'reportAdMetric');
const reportPlaybackMetricSpy = jest.spyOn(MockHelper.latestVideoAnalytics, 'reportPlaybackMetric')
const mockAdData: AdEvent = { ad: { id: 'test-ad-id', data: {} } } as AdEvent;

if (isAdActive) {
convivaAnalytics['convivaAnalyticsTracker'].trackAdBreakStarted(Conviva.Constants.AdType.CLIENT_SIDE);
}
playerEventHelper.fireEvent({ time: 0, timestamp: Date.now(), type: event, ...mockAdData });

if (isAdActive) {
expect(reportAdMetricSpy).toHaveBeenLastCalledWith(PLAYER_STATE, expectedPlayerState);
expect(reportPlaybackMetricSpy).not.toHaveBeenLastCalledWith(PLAYER_STATE, expectedPlayerState);
} else {
expect(reportAdMetricSpy).not.toHaveBeenLastCalledWith(PLAYER_STATE, expectedPlayerState);
expect(reportPlaybackMetricSpy).toHaveBeenLastCalledWith(PLAYER_STATE, expectedPlayerState);
}
});

it('on pause', () => {
jest.spyOn(playerMock, 'isPlaying').mockReturnValue(false);
jest.spyOn(playerMock, 'isPaused').mockReturnValue(true);
playerEventHelper.firePauseEvent();

expect(MockHelper.latestVideoAnalytics.reportPlaybackMetric).toHaveBeenCalledWith(
Conviva.Constants.Playback.PLAYER_STATE,
Conviva.Constants.PlayerState.PAUSED,
);
test.each`
event | isAdActive | expectedAdMetric | expectedPlaybackMetric
${PlayerEvent.AdBreakStarted} | ${false} | ${PlayerState.BUFFERING} | ${undefined}
${PlayerEvent.AdBreakStarted} | ${true} | ${PlayerState.BUFFERING} | ${undefined}
${PlayerEvent.AdStarted} | ${false} | ${undefined} | ${undefined}
${PlayerEvent.AdStarted} | ${true} | ${PlayerState.PLAYING} | ${undefined}
${PlayerEvent.AdError} | ${false} | ${undefined} | ${undefined}
${PlayerEvent.AdError} | ${true} | ${undefined} | ${undefined}
${PlayerEvent.AdSkipped} | ${false} | ${undefined} | ${undefined}
${PlayerEvent.AdSkipped} | ${true} | ${undefined} | ${undefined}
${PlayerEvent.AdFinished} | ${false} | ${undefined} | ${undefined}
${PlayerEvent.AdFinished} | ${true} | ${PlayerState.BUFFERING} | ${undefined}
${PlayerEvent.RestoringContent} | ${false} | ${undefined} | ${PlayerState.BUFFERING}
${PlayerEvent.RestoringContent} | ${true} | ${undefined} | ${PlayerState.BUFFERING}
${PlayerEvent.AdBreakFinished} | ${false} | ${undefined} | ${PlayerState.PLAYING}
${PlayerEvent.AdBreakFinished} | ${true} | ${undefined} | ${undefined}
`('should report ad metric $expectedAdMetric and playback metric $expectedPlaybackMetric on event $event when ad is active $isAdActive', ({ event, isAdActive, expectedAdMetric, expectedPlaybackMetric }) => {
jest.spyOn(playerMock, 'isPlaying').mockReturnValue(expectedPlaybackMetric === PlayerState.PLAYING || expectedAdMetric === PlayerState.PLAYING);

const reportAdMetricSpy = jest.spyOn(MockHelper.latestAdAnalytics, 'reportAdMetric');
const reportPlaybackMetricSpy = jest.spyOn(MockHelper.latestVideoAnalytics, 'reportPlaybackMetric')
const mockAdData: AdEvent = { ad: { id: 'test-ad-id', data: {} } } as AdEvent;

if (isAdActive) {
if (event === PlayerEvent.AdStarted || event === PlayerEvent.AdFinished) {
// Needs ad break initialization
playerEventHelper.fireAdBreakStartedEvent(0);
} else {
// Just track the active ad break state
convivaAnalytics['convivaAnalyticsTracker'].trackAdBreakStarted(Conviva.Constants.AdType.CLIENT_SIDE);
}
}
playerEventHelper.fireEvent({ time: 0, timestamp: Date.now(), type: event, ...mockAdData });

if (expectedAdMetric) {
expect(reportAdMetricSpy).toHaveBeenLastCalledWith(PLAYER_STATE, expectedAdMetric);
} else {
expect(reportAdMetricSpy).not.toHaveBeenLastCalledWith(PLAYER_STATE, expect.anything());
}

if (expectedPlaybackMetric) {
expect(reportPlaybackMetricSpy).toHaveBeenLastCalledWith(PLAYER_STATE, expectedPlaybackMetric);
} else {
expect(reportPlaybackMetricSpy).not.toHaveBeenLastCalledWith(PLAYER_STATE, expect.anything());
}
});
});
it('should not crash here', () => {
jest.spyOn(playerMock, 'isPaused').mockReturnValue(true);
playerEventHelper.firePauseEvent();

expect(MockHelper.latestVideoAnalytics.reportPlaybackMetric).not.toHaveBeenCalled();
});

describe('v8 stalling handling', () => {
describe('delayed stalling reporting', () => {
// In v8 there is no stalling event between play / playing; seek / seeked; timeshift / thimeshifted but it
// can be treated as stalling so we need to report it (maybe timeout in favor of seeking in buffer)

describe('reports stalling', () => {
describe('durring playback', () => {
describe('during playback', () => {
beforeEach(() => {
playerEventHelper.firePlayEvent();
playerEventHelper.firePlayingEvent();
Expand Down Expand Up @@ -162,6 +222,37 @@ describe('player event tests', () => {
);
});
});

describe('stallTrackingTimeout', () => {
test.each([
PlayerEvent.Play,
PlayerEvent.Seek,
PlayerEvent.TimeShift,
])('should start timer for stalling when reported player event is %s', (event) => {
const stallTrackingStartTimeoutSpy = jest.spyOn(convivaAnalytics['convivaAnalyticsTracker'], 'startStallTrackingTimeout');

playerEventHelper.fireEvent({ time: 0, timestamp: Date.now(), type: event });

expect(stallTrackingStartTimeoutSpy).toHaveBeenCalled();
});

test.each([
PlayerEvent.StallStarted,
PlayerEvent.Playing,
PlayerEvent.Paused,
PlayerEvent.Seeked,
PlayerEvent.TimeShifted,
PlayerEvent.StallEnded,
PlayerEvent.PlaybackFinished,
PlayerEvent.AdStarted,
])('should clear timer for stalling when reported player event is %s', (event) => {
const stallTrackingStopTimeoutSpy = jest.spyOn(convivaAnalytics['convivaAnalyticsTracker'], 'clearStallTrackingTimeout');

playerEventHelper.fireEvent({ time: 0, timestamp: Date.now(), type: event });

expect(stallTrackingStopTimeoutSpy).toHaveBeenCalled();
});
});
});

describe('end session', () => {
Expand Down Expand Up @@ -251,7 +342,7 @@ describe('player event tests', () => {
);
});

it('track mid-roll ad', () => {
it('track mid-roll ad', () => {
playerEventHelper.fireAdBreakStartedEvent(5);
playerEventHelper.fireAdStartedEvent();
expect(MockHelper.latestVideoAnalytics.reportAdBreakStarted).toHaveBeenCalledTimes(1);
Expand Down
Loading

0 comments on commit e28e103

Please sign in to comment.