diff --git a/extensions/amp-story-auto-ads/0.1/test/test-amp-story-auto-ads.js b/extensions/amp-story-auto-ads/0.1/test/test-amp-story-auto-ads.js index 1ba9e77c1cf8..57992a513d70 100644 --- a/extensions/amp-story-auto-ads/0.1/test/test-amp-story-auto-ads.js +++ b/extensions/amp-story-auto-ads/0.1/test/test-amp-story-auto-ads.js @@ -33,6 +33,7 @@ import { } from './story-mock'; import {Services} from '../../../../src/services'; import {macroTask} from '../../../../testing/yield'; +import {registerServiceBuilder} from '../../../../src/service'; const NOOP = () => {}; @@ -57,6 +58,9 @@ describes.realWin( doc = win.document; const viewer = Services.viewerForDoc(env.ampdoc); sandbox.stub(Services, 'viewerForDoc').returns(viewer); + registerServiceBuilder(win, 'performance', () => ({ + isPerformanceTrackingOn: () => false, + })); adElement = win.document.createElement('amp-story-auto-ads'); storyElement = win.document.createElement('amp-story'); win.document.body.appendChild(storyElement); diff --git a/extensions/amp-story/1.0/amp-story-page.js b/extensions/amp-story/1.0/amp-story-page.js index d2c991ecd2de..745f224d653a 100644 --- a/extensions/amp-story/1.0/amp-story-page.js +++ b/extensions/amp-story/1.0/amp-story-page.js @@ -67,6 +67,7 @@ import {getAmpdoc} from '../../../src/service'; import {getData, listen} from '../../../src/event-helper'; import {getFriendlyIframeEmbedOptional} from '../../../src/iframe-helper'; import {getLogEntries} from './logging'; +import {getMediaPerformanceMetricsService} from './media-performance-metrics-service'; import {getMode} from '../../../src/mode'; import {htmlFor} from '../../../src/static-template'; import {isExperimentOn} from '../../../src/experiments'; @@ -246,6 +247,14 @@ export class AmpStoryPage extends AMP.BaseElement { const deferred = new Deferred(); + /** @private @const {!./media-performance-metrics-service.MediaPerformanceMetricsService} */ + this.mediaPerformanceMetricsService_ = getMediaPerformanceMetricsService( + this.win + ); + + /** @private {!Array} */ + this.performanceTrackedVideos_ = []; + /** @private @const {!Promise} */ this.mediaPoolPromise_ = deferred.promise; @@ -422,6 +431,7 @@ export class AmpStoryPage extends AMP.BaseElement { pauseCallback() { this.advancement_.stop(); + this.stopMeasuringVideoPerformance_(); this.stopListeningToVideoEvents_(); this.toggleErrorMessage_(false); this.togglePlayMessage_(false); @@ -455,6 +465,7 @@ export class AmpStoryPage extends AMP.BaseElement { this.checkPageHasAudio_(); this.renderOpenAttachmentUI_(); this.findAndPrepareEmbeddedComponents_(); + this.startMeasuringVideoPerformance_(); this.preloadAllMedia_() .then(() => this.startListeningToVideoEvents_()) .then(() => this.playAllMedia_()); @@ -1258,6 +1269,41 @@ export class AmpStoryPage extends AMP.BaseElement { }); } + /** + * Starts measuring video performance metrics, if performance tracking is on. + * Has to be called directly before playing the video. + * @private + */ + startMeasuringVideoPerformance_() { + if (!this.mediaPerformanceMetricsService_.isPerformanceTrackingOn()) { + return; + } + + this.performanceTrackedVideos_ = /** @type {!Array} */ (this.getAllVideos_()); + for (let i = 0; i < this.performanceTrackedVideos_.length; i++) { + this.mediaPerformanceMetricsService_.startMeasuring( + this.performanceTrackedVideos_[i] + ); + } + } + + /** + * Stops measuring video performance metrics, if performance tracking is on. + * Computes and sends the metrics. + * @private + */ + stopMeasuringVideoPerformance_() { + if (!this.mediaPerformanceMetricsService_.isPerformanceTrackingOn()) { + return; + } + + for (let i = 0; i < this.performanceTrackedVideos_.length; i++) { + this.mediaPerformanceMetricsService_.stopMeasuring( + this.performanceTrackedVideos_[i] + ); + } + } + /** * Displays a loading spinner whenever the video is buffering. * Has to be called after the mediaPool preload method, that swaps the video diff --git a/extensions/amp-story/1.0/media-performance-metrics-service.js b/extensions/amp-story/1.0/media-performance-metrics-service.js new file mode 100644 index 000000000000..c0c2d55a0e42 --- /dev/null +++ b/extensions/amp-story/1.0/media-performance-metrics-service.js @@ -0,0 +1,413 @@ +/** + * Copyright 2019 The AMP HTML Authors. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + MEDIA_LOAD_FAILURE_SRC_PROPERTY, + listen, +} from '../../../src/event-helper'; +import {Services} from '../../../src/services'; +import {dev} from '../../../src/log'; +import {lastChildElement} from '../../../src/dom'; +import {map} from '../../../src/utils/object'; +import {registerServiceBuilder} from '../../../src/service'; + +/** + * Media status. + * @enum + */ +const Status = { + ERRORED: 0, + PAUSED: 1, + PLAYING: 2, + WAITING: 3, +}; + +/** + * @typedef {{ + * start: number, + * playing: number, + * waiting: number, + * }} + */ +let TimeStampsDef; + +/** + * @typedef {{ + * error: ?number, + * jointLatency: number, + * rebuffers: number, + * rebufferTime: number, + * watchTime: number + * }} + */ +let MetricsDef; + +/** + * @typedef {{ + * media: !HTMLMediaElement, + * status: number, + * unlisteners: !Array, + * timeStamps: !TimeStampsDef, + * metrics: !MetricsDef + * }} + */ +let MediaEntryDef; + +/** @type {string} */ +const ID_PROPERTY = '__AMP_MEDIA_PERFORMANCE_METRICS_ID'; + +/** @type {number} */ +const MINIMUM_TIME_THRESHOLD_MS = 1000; + +/** @type {number} */ +const REBUFFER_THRESHOLD_MS = 250; + +/** @type {string} */ +const TAG = 'media-performance-metrics'; + +/** + * Util function to retrieve the media performance metrics service. Ensures we + * can retrieve the service synchronously from the amp-story codebase without + * running into race conditions. + * @param {!Window} win + * @return {!MediaPerformanceMetricsService} + */ +export const getMediaPerformanceMetricsService = win => { + let service = Services.mediaPerformanceMetricsService(win); + + if (!service) { + service = new MediaPerformanceMetricsService(win); + registerServiceBuilder(win, 'media-performance-metrics', () => service); + } + + return service; +}; + +/** + * Media performance metrics service. + * @final + */ +export class MediaPerformanceMetricsService { + /** + * @param {!Window} win + */ + constructor(win) { + /** @private {number} */ + this.mediaId_ = 1; + + // TODO(gmajoulet): switch to WeakMap once the AMPHTML project allows them. + /** @private @const {!Object} */ + this.mediaMap_ = map(); + + /** @private @const {!../../../src/service/performance-impl.Performance} */ + this.performanceService_ = Services.performanceFor(win); + } + + /** + * Identifies if the viewer is able to track performance. If the document is + * not embedded, there is no messaging channel, so no performance tracking is + * needed since there is nobody to forward the events. + * @return {boolean} + */ + isPerformanceTrackingOn() { + return this.performanceService_.isPerformanceTrackingOn(); + } + + /** + * Starts recording performance metrics for a a given HTMLMediaElement. This + * method has to be called right before trying to play the media. This allows + * to reliably record joint latency (time to play), as well initial buffering. + * @param {!HTMLMediaElement} media + */ + startMeasuring(media) { + // Media must start paused in order to determine the joint latency, and + // initial buffering, if any. + if (!media.paused) { + dev().expectedError(TAG, 'media must start paused'); + return; + } + + const unlisteners = this.listen_(media); + const mediaEntry = this.getNewMediaEntry_(media, unlisteners); + this.setMediaEntry_(media, mediaEntry); + + // Checks if the media already errored (eg: could have failed the source + // selection). + if ( + media.error || + media[MEDIA_LOAD_FAILURE_SRC_PROPERTY] === media.currentSrc + ) { + mediaEntry.metrics.error = media.error ? media.error.code : 0; + mediaEntry.status = Status.ERRORED; + } + } + + /** + * Stops recording, computes, and sends performance metrics collected for the + * given media element. + * @param {!HTMLMediaElement} media + */ + stopMeasuring(media) { + const mediaEntry = this.getMediaEntry_(media); + + if (!mediaEntry) { + return; + } + + mediaEntry.unlisteners.forEach(unlisten => unlisten()); + this.deleteMediaEntry_(media); + + switch (mediaEntry.status) { + case Status.PLAYING: + this.addWatchTime_(mediaEntry); + break; + case Status.WAITING: + this.addRebuffer_(mediaEntry); + break; + } + + this.sendMetrics_(mediaEntry); + } + + /** + * @param {!MediaEntryDef} mediaEntry + * @private + */ + sendMetrics_(mediaEntry) { + const {metrics} = mediaEntry; + + // If the media errored. + if (metrics.error !== null) { + this.performanceService_.tickDelta('verr', metrics.error || 0); + this.performanceService_.flush(); + return; + } + + // If the user was on the video for less than one second, ignore the metrics + // (eg: users tapping through a story, or scrolling through content). + if ( + !metrics.jointLatency && + Date.now() - mediaEntry.timeStamps.start < MINIMUM_TIME_THRESHOLD_MS + ) { + return; + } + + // If the playback did not start. + if (!metrics.jointLatency) { + this.performanceService_.tickDelta('verr', 5 /* Custom error code */); + this.performanceService_.flush(); + return; + } + + const rebufferRate = + Math.round( + (metrics.rebufferTime / (metrics.rebufferTime + metrics.watchTime)) * + 1000 + ) / 1000; + + this.performanceService_.tickDelta('vjl', metrics.jointLatency); + this.performanceService_.tickDelta('vwt', metrics.watchTime); + this.performanceService_.tickDelta('vrb', metrics.rebuffers); + this.performanceService_.tickDelta('vrbr', rebufferRate); + if (metrics.rebuffers) { + this.performanceService_.tickDelta( + 'vmtbrb', + Math.round(metrics.watchTime / metrics.rebuffers) + ); + } + this.performanceService_.flush(); + } + + /** + * @param {!HTMLMediaElement} media + * @return {!MediaEntryDef} + * @private + */ + getMediaEntry_(media) { + return this.mediaMap_[media[ID_PROPERTY]]; + } + + /** + * @param {!HTMLMediaElement} media + * @param {!MediaEntryDef} mediaEntry + * @private + */ + setMediaEntry_(media, mediaEntry) { + media[ID_PROPERTY] = media[ID_PROPERTY] || this.mediaId_++; + this.mediaMap_[media[ID_PROPERTY]] = mediaEntry; + } + + /** + * @param {!HTMLMediaElement} media + * @private + */ + deleteMediaEntry_(media) { + delete this.mediaMap_[media[ID_PROPERTY]]; + } + + /** + * @param {!HTMLMediaElement} media + * @param {!Array} unlisteners + * @return {!MediaEntryDef} + * @private + */ + getNewMediaEntry_(media, unlisteners) { + return { + media, + status: Status.PAUSED, + unlisteners, + timeStamps: { + start: Date.now(), + playing: 0, + waiting: 0, + }, + metrics: { + error: null, + jointLatency: 0, + meanTimeBetweenRebuffers: 0, + rebuffers: 0, + rebufferTime: 0, + watchTime: 0, + }, + }; + } + + /** + * Increments the watch time with the duration from the last `playing` event. + * @param {!MediaEntryDef} mediaEntry + * @private + */ + addWatchTime_(mediaEntry) { + mediaEntry.metrics.watchTime += Date.now() - mediaEntry.timeStamps.playing; + } + + /** + * Increments the rebuffer time with the duration from the last `waiting` + * event, and increments the rebuffers count. + * @param {!MediaEntryDef} mediaEntry + * @private + */ + addRebuffer_(mediaEntry) { + const rebufferTime = Date.now() - mediaEntry.timeStamps.waiting; + if (rebufferTime > REBUFFER_THRESHOLD_MS) { + mediaEntry.metrics.rebuffers++; + mediaEntry.metrics.rebufferTime += rebufferTime; + } + } + + /** + * @param {!HTMLMediaElement} media + * @return {!Array} + * @private + */ + listen_(media) { + const unlisteners = [ + listen(media, 'ended', this.onPauseOrEnded_.bind(this)), + listen(media, 'pause', this.onPauseOrEnded_.bind(this)), + listen(media, 'playing', this.onPlaying_.bind(this)), + listen(media, 'waiting', this.onWaiting_.bind(this)), + ]; + + // If the media element has no `src`, it will try to load the sources in + // document order. If the last source errors, then the media element + // loading errored. + let errorTarget = media; + if (!media.hasAttribute('src')) { + errorTarget = lastChildElement( + media, + child => child.tagName === 'SOURCE' + ); + } + unlisteners.push( + listen(errorTarget || media, 'error', this.onError_.bind(this)) + ); + + return unlisteners; + } + + /** + * @param {!Event} event + * @private + */ + onError_(event) { + // Media error target could be either HTMLMediaElement or HTMLSourceElement. + const media = + event.target.tagName === 'SOURCE' ? event.target.parent : event.target; + const mediaEntry = this.getMediaEntry_( + /** @type {!HTMLMediaElement} */ (media) + ); + + mediaEntry.metrics.error = media.error ? media.error.code : 0; + + mediaEntry.status = Status.ERRORED; + } + + /** + * @param {!Event} event + * @private + */ + onPauseOrEnded_(event) { + const mediaEntry = this.getMediaEntry_( + /** @type {!HTMLMediaElement} */ (event.target) + ); + + if (mediaEntry.status === Status.PLAYING) { + this.addWatchTime_(mediaEntry); + } + + mediaEntry.status = Status.PAUSED; + } + + /** + * @param {!Event} event + * @private + */ + onPlaying_(event) { + const mediaEntry = this.getMediaEntry_( + /** @type {!HTMLMediaElement} */ (event.target) + ); + const {timeStamps, metrics} = mediaEntry; + + if (!metrics.jointLatency) { + metrics.jointLatency = Date.now() - timeStamps.start; + } + + if (mediaEntry.status === Status.WAITING) { + this.addRebuffer_(mediaEntry); + } + + timeStamps.playing = Date.now(); + mediaEntry.status = Status.PLAYING; + } + + /** + * @param {!Event} event + * @private + */ + onWaiting_(event) { + const mediaEntry = this.getMediaEntry_( + /** @type {!HTMLMediaElement} */ (event.target) + ); + const {timeStamps} = mediaEntry; + + if (mediaEntry.status === Status.PLAYING) { + this.addWatchTime_(mediaEntry); + } + + timeStamps.waiting = Date.now(); + mediaEntry.status = Status.WAITING; + } +} diff --git a/extensions/amp-story/1.0/test/test-amp-story-cta-layer.js b/extensions/amp-story/1.0/test/test-amp-story-cta-layer.js index 6f33b97f8880..8e1dcb89225d 100644 --- a/extensions/amp-story/1.0/test/test-amp-story-cta-layer.js +++ b/extensions/amp-story/1.0/test/test-amp-story-cta-layer.js @@ -15,6 +15,7 @@ */ import {AmpStoryCtaLayer} from '../amp-story-cta-layer'; +import {registerServiceBuilder} from '../../../../src/service'; describes.realWin( 'amp-story-cta-layer', @@ -30,6 +31,9 @@ describes.realWin( beforeEach(() => { win = env.win; + registerServiceBuilder(win, 'performance', () => ({ + isPerformanceTrackingOn: () => false, + })); const ampStoryCtaLayerEl = win.document.createElement( 'amp-story-cta-layer' ); diff --git a/extensions/amp-story/1.0/test/test-amp-story-page.js b/extensions/amp-story/1.0/test/test-amp-story-page.js index dc654f781453..ba911fcbad63 100644 --- a/extensions/amp-story/1.0/test/test-amp-story-page.js +++ b/extensions/amp-story/1.0/test/test-amp-story-page.js @@ -28,9 +28,11 @@ describes.realWin('amp-story-page', {amp: true}, env => { let element; let gridLayerEl; let page; + let isPerformanceTrackingOn; beforeEach(() => { win = env.win; + isPerformanceTrackingOn = false; const mediaPoolRoot = { getElement: () => win.document.createElement('div'), @@ -46,6 +48,10 @@ describes.realWin('amp-story-page', {amp: true}, env => { const localizationService = new LocalizationService(win); registerServiceBuilder(win, 'localization', () => localizationService); + registerServiceBuilder(win, 'performance', () => ({ + isPerformanceTrackingOn: () => isPerformanceTrackingOn, + })); + const story = win.document.createElement('amp-story'); story.getImpl = () => Promise.resolve(mediaPoolRoot); @@ -448,4 +454,71 @@ describes.realWin('amp-story-page', {amp: true}, env => { expect(openAttachmentLabelEl.textContent).to.equal('Custom label'); }); }); + + it('should start tracking media performance when entering the page', () => { + sandbox + .stub(page.resources_, 'getResourceForElement') + .returns({isDisplayed: () => true}); + isPerformanceTrackingOn = true; + const startMeasuringStub = sandbox.stub( + page.mediaPerformanceMetricsService_, + 'startMeasuring' + ); + + const videoEl = win.document.createElement('video'); + videoEl.setAttribute('src', 'https://example.com/video.mp3'); + gridLayerEl.appendChild(videoEl); + + page.buildCallback(); + return page.layoutCallback().then(() => { + page.setState(PageState.PLAYING); + + expect(startMeasuringStub).to.have.been.calledOnceWithExactly(videoEl); + }); + }); + + it('should stop tracking media performance when leaving the page', () => { + sandbox + .stub(page.resources_, 'getResourceForElement') + .returns({isDisplayed: () => true}); + isPerformanceTrackingOn = true; + const stopMeasuringStub = sandbox.stub( + page.mediaPerformanceMetricsService_, + 'stopMeasuring' + ); + + const videoEl = win.document.createElement('video'); + videoEl.setAttribute('src', 'https://example.com/video.mp3'); + gridLayerEl.appendChild(videoEl); + + page.buildCallback(); + return page.layoutCallback().then(() => { + page.setState(PageState.PLAYING); + page.setState(PageState.NOT_ACTIVE); + + expect(stopMeasuringStub).to.have.been.calledOnceWithExactly(videoEl); + }); + }); + + it('should not start tracking media performance if tracking is off', () => { + sandbox + .stub(page.resources_, 'getResourceForElement') + .returns({isDisplayed: () => true}); + isPerformanceTrackingOn = false; + const startMeasuringStub = sandbox.stub( + page.mediaPerformanceMetricsService_, + 'startMeasuring' + ); + + const videoEl = win.document.createElement('video'); + videoEl.setAttribute('src', 'https://example.com/video.mp3'); + gridLayerEl.appendChild(videoEl); + + page.buildCallback(); + return page.layoutCallback().then(() => { + page.setState(PageState.PLAYING); + + expect(startMeasuringStub).to.not.have.been.called; + }); + }); }); diff --git a/extensions/amp-story/1.0/test/test-amp-story.js b/extensions/amp-story/1.0/test/test-amp-story.js index 65d0878c4277..22f79b8df595 100644 --- a/extensions/amp-story/1.0/test/test-amp-story.js +++ b/extensions/amp-story/1.0/test/test-amp-story.js @@ -112,6 +112,10 @@ describes.realWin( .returns(hasSwipeCapability); sandbox.stub(Services, 'viewerForDoc').returns(viewer); + registerServiceBuilder(win, 'performance', () => ({ + isPerformanceTrackingOn: () => false, + })); + const storeService = new AmpStoryStoreService(win); registerServiceBuilder(win, 'story-store', () => storeService); diff --git a/extensions/amp-story/1.0/test/test-full-bleed-animations.js b/extensions/amp-story/1.0/test/test-full-bleed-animations.js index 975a57e7a245..744de271f3ee 100644 --- a/extensions/amp-story/1.0/test/test-full-bleed-animations.js +++ b/extensions/amp-story/1.0/test/test-full-bleed-animations.js @@ -51,6 +51,10 @@ describes.realWin( const viewer = Services.viewerForDoc(env.ampdoc); sandbox.stub(Services, 'viewerForDoc').returns(viewer); + registerServiceBuilder(win, 'performance', () => ({ + isPerformanceTrackingOn: () => false, + })); + const storeService = new AmpStoryStoreService(win); registerServiceBuilder(win, 'story-store', () => storeService); diff --git a/extensions/amp-story/1.0/test/test-live-story-manager.js b/extensions/amp-story/1.0/test/test-live-story-manager.js index a8e47f14137b..5390ae37c5c5 100644 --- a/extensions/amp-story/1.0/test/test-live-story-manager.js +++ b/extensions/amp-story/1.0/test/test-live-story-manager.js @@ -21,6 +21,7 @@ import {CommonSignals} from '../../../../src/common-signals'; import {LiveStoryManager} from '../live-story-manager'; import {Services} from '../../../../src/services'; import {addAttributesToElement} from '../../../../src/dom'; +import {registerServiceBuilder} from '../../../../src/service'; describes.realWin( 'LiveStoryManager', @@ -62,6 +63,10 @@ describes.realWin( sandbox.stub(Services, 'viewerForDoc').returns(viewer); sandbox.stub(win.history, 'replaceState'); + registerServiceBuilder(win, 'performance', () => ({ + isPerformanceTrackingOn: () => false, + })); + storyEl = win.document.createElement('amp-story'); win.document.body.appendChild(storyEl); addAttributesToElement(storyEl, { diff --git a/extensions/amp-story/1.0/test/test-media-performance-metrics-service.js b/extensions/amp-story/1.0/test/test-media-performance-metrics-service.js new file mode 100644 index 000000000000..c6671a60f4b2 --- /dev/null +++ b/extensions/amp-story/1.0/test/test-media-performance-metrics-service.js @@ -0,0 +1,364 @@ +/** + * Copyright 2019 The AMP HTML Authors. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import * as lolex from 'lolex'; +import {MEDIA_LOAD_FAILURE_SRC_PROPERTY} from '../../../../src/event-helper'; +import {MediaPerformanceMetricsService} from '../media-performance-metrics-service'; +import {Services} from '../../../../src/services'; + +describes.fakeWin('media-performance-metrics-service', {}, env => { + let clock; + let service; + let tickStub; + let win; + + before(() => { + clock = lolex.install({ + target: win, + toFake: ['Date'], + now: 0, + }); + }); + + after(() => { + clock.uninstall(); + }); + + beforeEach(() => { + win = env.win; + sandbox + .stub(Services, 'performanceFor') + .returns({tickDelta: () => {}, flush: () => {}}); + service = new MediaPerformanceMetricsService(); + tickStub = sandbox.stub(service.performanceService_, 'tickDelta'); + }); + + afterEach(() => { + clock.reset(); + }); + + it('should record and flush metrics', () => { + const flushStub = sandbox.stub(service.performanceService_, 'flush'); + + const video = win.document.createElement('video'); + service.startMeasuring(video); + clock.tick(20); + video.dispatchEvent(new Event('playing')); + clock.tick(100); + video.dispatchEvent(new Event('waiting')); + clock.tick(300); + service.stopMeasuring(video); + + expect(tickStub).to.have.callCount(5); + expect(flushStub).to.have.been.calledOnce; + }); + + it('should record and flush metrics on error', () => { + const flushStub = sandbox.stub(service.performanceService_, 'flush'); + + const video = win.document.createElement('video'); + service.startMeasuring(video); + clock.tick(2000); + video.dispatchEvent(new Event('error')); + clock.tick(10000); + service.stopMeasuring(video); + + expect(tickStub).to.have.callCount(1); + expect(flushStub).to.have.been.calledOnce; + }); + + it('should record and flush metrics for multiple media', () => { + const flushStub = sandbox.stub(service.performanceService_, 'flush'); + + const video1 = win.document.createElement('video'); + const video2 = win.document.createElement('video'); + service.startMeasuring(video1); + service.startMeasuring(video2); + clock.tick(100); + video1.dispatchEvent(new Event('playing')); + clock.tick(200); + service.stopMeasuring(video1); + video2.dispatchEvent(new Event('waiting')); + clock.tick(300); + video2.dispatchEvent(new Event('playing')); + service.stopMeasuring(video2); + + expect(tickStub).to.have.callCount(9); + expect(flushStub).to.have.been.calledTwice; + }); + + describe('Joint latency', () => { + it('should record joint latency if playback starts with no wait', () => { + const video = win.document.createElement('video'); + service.startMeasuring(video); + clock.tick(100); + video.dispatchEvent(new Event('playing')); + clock.tick(200); + service.stopMeasuring(video); + + expect(tickStub).to.have.been.calledWithExactly('vjl', 100); + }); + + it('should record joint latency when waiting first', () => { + const video = win.document.createElement('video'); + service.startMeasuring(video); + clock.tick(100); + video.dispatchEvent(new Event('waiting')); + clock.tick(200); + video.dispatchEvent(new Event('playing')); + service.stopMeasuring(video); + + expect(tickStub).to.have.been.calledWithExactly('vjl', 300); + }); + + it('should not record joint latency if playback does not start', () => { + const video = win.document.createElement('video'); + service.startMeasuring(video); + clock.tick(100); + video.dispatchEvent(new Event('waiting')); + clock.tick(200); + service.stopMeasuring(video); + + expect(tickStub).to.not.have.been.calledWith('vjl'); + }); + + it('should record joint latency for multiple media', () => { + const video1 = win.document.createElement('video'); + const video2 = win.document.createElement('video'); + service.startMeasuring(video1); + service.startMeasuring(video2); + clock.tick(100); + video1.dispatchEvent(new Event('playing')); + clock.tick(200); + service.stopMeasuring(video1); + video2.dispatchEvent(new Event('waiting')); + clock.tick(300); + video2.dispatchEvent(new Event('playing')); + service.stopMeasuring(video2); + + expect(tickStub).to.have.been.calledWithExactly('vjl', 100); + expect(tickStub).to.have.been.calledWithExactly('vjl', 600); + }); + }); + + describe('Watch time', () => { + it('should record watch time', () => { + const video = win.document.createElement('video'); + service.startMeasuring(video); + clock.tick(100); + video.dispatchEvent(new Event('playing')); + clock.tick(200); + service.stopMeasuring(video); + + expect(tickStub).to.have.been.calledWithExactly('vwt', 200); + }); + + it('should record watch time and handle pause events', () => { + const video = win.document.createElement('video'); + service.startMeasuring(video); + clock.tick(100); + video.dispatchEvent(new Event('playing')); + clock.tick(200); + video.dispatchEvent(new Event('pause')); + clock.tick(300); + video.dispatchEvent(new Event('playing')); + clock.tick(400); + service.stopMeasuring(video); + + expect(tickStub).to.have.been.calledWithExactly('vwt', 600); + }); + + it('should record watch time and handle ended events', () => { + const video = win.document.createElement('video'); + service.startMeasuring(video); + clock.tick(100); + video.dispatchEvent(new Event('playing')); + clock.tick(200); + video.dispatchEvent(new Event('ended')); + clock.tick(300); + video.dispatchEvent(new Event('playing')); + clock.tick(400); + service.stopMeasuring(video); + + expect(tickStub).to.have.been.calledWithExactly('vwt', 600); + }); + + it('should record watch time and handle rebuffers', () => { + const video = win.document.createElement('video'); + service.startMeasuring(video); + clock.tick(100); + video.dispatchEvent(new Event('playing')); + clock.tick(200); + video.dispatchEvent(new Event('waiting')); + clock.tick(300); + video.dispatchEvent(new Event('playing')); + clock.tick(400); + service.stopMeasuring(video); + + expect(tickStub).to.have.been.calledWithExactly('vwt', 600); + }); + }); + + describe('Rebuffers', () => { + it('should count rebuffers', () => { + const video = win.document.createElement('video'); + service.startMeasuring(video); + clock.tick(100); + video.dispatchEvent(new Event('playing')); + clock.tick(200); + video.dispatchEvent(new Event('waiting')); + clock.tick(300); + video.dispatchEvent(new Event('playing')); + clock.tick(400); + video.dispatchEvent(new Event('waiting')); + clock.tick(500); + service.stopMeasuring(video); + + expect(tickStub).to.have.been.calledWithExactly('vrb', 2); + }); + + it('should record rebuffer rate', () => { + const video = win.document.createElement('video'); + service.startMeasuring(video); + clock.tick(100); + video.dispatchEvent(new Event('playing')); + clock.tick(200); + video.dispatchEvent(new Event('waiting')); + clock.tick(300); + video.dispatchEvent(new Event('playing')); + clock.tick(400); + video.dispatchEvent(new Event('waiting')); + clock.tick(500); + service.stopMeasuring(video); + + // playing: 600; waiting: 800 + // 800 / (600 + 800) ~= 0.571 + expect(tickStub).to.have.been.calledWithExactly('vrbr', 0.571); + }); + + it('should record mean time between rebuffers', () => { + const video = win.document.createElement('video'); + service.startMeasuring(video); + clock.tick(100); + video.dispatchEvent(new Event('playing')); + clock.tick(200); + video.dispatchEvent(new Event('waiting')); + clock.tick(300); + video.dispatchEvent(new Event('playing')); + clock.tick(400); + video.dispatchEvent(new Event('waiting')); + clock.tick(500); + service.stopMeasuring(video); + + // 600ms playing divided by 2 rebuffer events. + expect(tickStub).to.have.been.calledWithExactly('vmtbrb', 300); + }); + + it('should count the initial buffering as a rebuffer', () => { + const video = win.document.createElement('video'); + service.startMeasuring(video); + clock.tick(300); + video.dispatchEvent(new Event('waiting')); + clock.tick(400); + video.dispatchEvent(new Event('playing')); + clock.tick(500); + video.dispatchEvent(new Event('waiting')); + clock.tick(600); + video.dispatchEvent(new Event('playing')); + clock.tick(700); + service.stopMeasuring(video); + + expect(tickStub).to.have.been.calledWithExactly('vrb', 2); + }); + + it('should exclude very brief rebuffers', () => { + const video = win.document.createElement('video'); + service.startMeasuring(video); + clock.tick(100); + video.dispatchEvent(new Event('playing')); + clock.tick(200); + video.dispatchEvent(new Event('waiting')); + clock.tick(10); + video.dispatchEvent(new Event('playing')); + clock.tick(300); + service.stopMeasuring(video); + + expect(tickStub).to.have.been.calledWithExactly('vrb', 0); + }); + + it('should record rebuffer rate even with no rebuffer events', () => { + const video = win.document.createElement('video'); + service.startMeasuring(video); + clock.tick(100); + video.dispatchEvent(new Event('playing')); + clock.tick(200); + service.stopMeasuring(video); + + expect(tickStub).to.have.been.calledWithExactly('vrbr', 0); + }); + + it('should not send mean time between rebuffers when no rebuffer', () => { + const video = win.document.createElement('video'); + service.startMeasuring(video); + clock.tick(100); + video.dispatchEvent(new Event('playing')); + clock.tick(200); + service.stopMeasuring(video); + + expect(tickStub).to.not.have.been.calledWith('vmtbrb'); + }); + }); + + describe('Errors', () => { + it('should detect the video as already errored', done => { + const video = win.document.createElement('video'); + + video.onerror = () => { + service.startMeasuring(video); + service.stopMeasuring(video); + + expect(tickStub).to.have.been.calledOnceWithExactly('verr', 4); + done(); + }; + + // MediaError.code = 4 (MEDIA_ERR_SRC_NOT_SUPPORTED) + video.src = '404.mp4'; + }); + + it('should detect the video as already errored from ', () => { + const video = win.document.createElement('video'); + video[MEDIA_LOAD_FAILURE_SRC_PROPERTY] = ''; + + service.startMeasuring(video); + service.stopMeasuring(video); + + expect(tickStub).to.have.been.calledOnceWithExactly('verr', 0); + }); + + it('should detect that the video errors', () => { + const video = win.document.createElement('video'); + service.startMeasuring(video); + clock.tick(100); + video.dispatchEvent(new Event('playing')); + clock.tick(200); + video.dispatchEvent(new Event('error')); + clock.tick(300); + service.stopMeasuring(video); + + expect(tickStub).to.have.been.calledOnceWithExactly('verr', 0); + }); + }); +}); diff --git a/src/services.js b/src/services.js index 57f1962cc2b2..2ffa788d1b2a 100644 --- a/src/services.js +++ b/src/services.js @@ -522,6 +522,17 @@ export class Services { ); } + /** + * @param {!Window} win + * @return {?../extensions/amp-story/1.0/media-performance-metrics-service.MediaPerformanceMetricsService} + */ + static mediaPerformanceMetricsService(win) { + return ( + /** @type {?../extensions/amp-story/1.0/media-performance-metrics-service.MediaPerformanceMetricsService} */ + (getExistingServiceOrNull(win, 'media-performance-metrics')) + ); + } + /** * @param {!Window} win * @return {!Promise}