From 3be0fcd0576554db7cfa800987d96ece55273630 Mon Sep 17 00:00:00 2001 From: Andy Sellick Date: Tue, 1 Aug 2023 16:47:10 +0100 Subject: [PATCH 1/3] Update GA4 schema - add new attributes for video tracking --- .../analytics-ga4/ga4-schemas.js | 5 ++- .../analytics-ga4/ga4-schemas.spec.js | 25 ++++++++++++--- .../analytics-ga4/ga4-scroll-tracker.spec.js | 32 ++++--------------- 3 files changed, 31 insertions(+), 31 deletions(-) diff --git a/app/assets/javascripts/govuk_publishing_components/analytics-ga4/ga4-schemas.js b/app/assets/javascripts/govuk_publishing_components/analytics-ga4/ga4-schemas.js index 55756ffc41..a28aac887b 100644 --- a/app/assets/javascripts/govuk_publishing_components/analytics-ga4/ga4-schemas.js +++ b/app/assets/javascripts/govuk_publishing_components/analytics-ga4/ga4-schemas.js @@ -29,7 +29,10 @@ link_domain: this.undefined, link_path_parts: this.undefined, tool_name: this.undefined, - percent_scrolled: this.undefined + percent_scrolled: this.undefined, + video_current_time: this.undefined, + video_duration: this.undefined, + video_percent: this.undefined } } } diff --git a/spec/javascripts/govuk_publishing_components/analytics-ga4/ga4-schemas.spec.js b/spec/javascripts/govuk_publishing_components/analytics-ga4/ga4-schemas.spec.js index 484e9cc5b0..516a99e488 100644 --- a/spec/javascripts/govuk_publishing_components/analytics-ga4/ga4-schemas.spec.js +++ b/spec/javascripts/govuk_publishing_components/analytics-ga4/ga4-schemas.spec.js @@ -39,7 +39,10 @@ describe('Google Analytics schemas', function () { link_domain: undefined, link_path_parts: undefined, tool_name: undefined, - percent_scrolled: undefined + percent_scrolled: undefined, + video_current_time: this.undefined, + video_duration: this.undefined, + video_percent: this.undefined } } var returned = schemas.mergeProperties(data, 'example') @@ -73,7 +76,10 @@ describe('Google Analytics schemas', function () { link_domain: undefined, link_path_parts: undefined, tool_name: undefined, - percent_scrolled: undefined + percent_scrolled: undefined, + video_current_time: this.undefined, + video_duration: this.undefined, + video_percent: this.undefined } } var returned = schemas.mergeProperties(data, 'example') @@ -106,7 +112,10 @@ describe('Google Analytics schemas', function () { link_domain: undefined, link_path_parts: undefined, tool_name: undefined, - percent_scrolled: undefined + percent_scrolled: undefined, + video_current_time: this.undefined, + video_duration: this.undefined, + video_percent: this.undefined } } var returned = schemas.mergeProperties(data, 'example') @@ -138,7 +147,10 @@ describe('Google Analytics schemas', function () { link_domain: undefined, link_path_parts: undefined, tool_name: undefined, - percent_scrolled: undefined + percent_scrolled: undefined, + video_current_time: this.undefined, + video_duration: this.undefined, + video_percent: this.undefined } } var returned = schemas.mergeProperties(data, 'example') @@ -176,7 +188,10 @@ describe('Google Analytics schemas', function () { not: 'defined by the schema' }, tool_name: undefined, - percent_scrolled: undefined + percent_scrolled: undefined, + video_current_time: this.undefined, + video_duration: this.undefined, + video_percent: this.undefined } } var returned = schemas.mergeProperties(data, 'example') diff --git a/spec/javascripts/govuk_publishing_components/analytics-ga4/ga4-scroll-tracker.spec.js b/spec/javascripts/govuk_publishing_components/analytics-ga4/ga4-scroll-tracker.spec.js index fd79e65531..82a909c655 100644 --- a/spec/javascripts/govuk_publishing_components/analytics-ga4/ga4-scroll-tracker.spec.js +++ b/spec/javascripts/govuk_publishing_components/analytics-ga4/ga4-scroll-tracker.spec.js @@ -8,30 +8,12 @@ describe('GA4 scroll tracker', function () { window.dataLayer = [] window.GOVUK.setCookie('cookies_policy', '{"essential":true,"settings":true,"usage":true,"campaigns":true}') jasmine.clock().install() - expected = { - event: 'event_data', - event_data: { - action: 'scroll', - event_name: 'scroll', - external: undefined, - index: { - index_link: undefined, - index_section: undefined, - index_section_count: undefined - }, - index_total: undefined, - link_domain: undefined, - link_path_parts: undefined, - method: undefined, - percent_scrolled: undefined, - section: undefined, - text: undefined, - tool_name: undefined, - type: undefined, - url: undefined - }, - govuk_gem_version: 'gem-version' - } + + expected = new GOVUK.analyticsGa4.Schemas().eventSchema() + expected.event = 'event_data' + expected.event_data.action = 'scroll' + expected.event_data.event_name = 'scroll' + expected.govuk_gem_version = 'gem-version' spyOn(GOVUK.analyticsGa4.core, 'getGemVersion').and.returnValue('gem-version') }) @@ -215,7 +197,7 @@ describe('GA4 scroll tracker', function () { }) it('should send a tracking event on page load for positions that are already visible', function () { - setPageHeight(window.innerHeight) + setPageHeight(10) expect(window.dataLayer.length).toEqual(5) From fe00decfc57fadcb6c335a045663a5700f808406 Mon Sep 17 00:00:00 2001 From: Andy Sellick Date: Wed, 2 Aug 2023 15:07:35 +0100 Subject: [PATCH 2/3] Add GA4 video tracker --- .../analytics-ga4.js | 1 + .../analytics-ga4/ga4-video-tracker.js | 88 +++++++ .../lib/govspeak/youtube-link-enhancement.js | 7 + .../analytics-ga4/ga4-video-tracker.spec.js | 243 ++++++++++++++++++ 4 files changed, 339 insertions(+) create mode 100644 app/assets/javascripts/govuk_publishing_components/analytics-ga4/ga4-video-tracker.js create mode 100644 spec/javascripts/govuk_publishing_components/analytics-ga4/ga4-video-tracker.spec.js diff --git a/app/assets/javascripts/govuk_publishing_components/analytics-ga4.js b/app/assets/javascripts/govuk_publishing_components/analytics-ga4.js index 5f314a09c7..0f88d7bf62 100644 --- a/app/assets/javascripts/govuk_publishing_components/analytics-ga4.js +++ b/app/assets/javascripts/govuk_publishing_components/analytics-ga4.js @@ -11,4 +11,5 @@ //= require ./analytics-ga4/ga4-auto-tracker //= require ./analytics-ga4/ga4-smart-answer-results-tracker //= require ./analytics-ga4/ga4-scroll-tracker +//= require ./analytics-ga4/ga4-video-tracker //= require ./analytics-ga4/init-ga4 diff --git a/app/assets/javascripts/govuk_publishing_components/analytics-ga4/ga4-video-tracker.js b/app/assets/javascripts/govuk_publishing_components/analytics-ga4/ga4-video-tracker.js new file mode 100644 index 0000000000..556591b6a9 --- /dev/null +++ b/app/assets/javascripts/govuk_publishing_components/analytics-ga4/ga4-video-tracker.js @@ -0,0 +1,88 @@ +window.GOVUK = window.GOVUK || {} +window.GOVUK.analyticsGa4 = window.GOVUK.analyticsGa4 || {} +window.GOVUK.analyticsGa4.analyticsModules = window.GOVUK.analyticsGa4.analyticsModules || {}; + +(function (analyticsModules) { + 'use strict' + + var VideoTracker = { + init: function () { + this.handlers = {} + }, + + configureVideo: function (event) { + var player = event.target + var videoId = player.id + var duration = player.getDuration() + var percentages = [25, 50, 75] + + for (var i = 0; i < percentages.length; i++) { + var percent = percentages[i] + var position = (duration / 100) * percent + this.handlers['video-' + videoId + '-' + percent + '-percent-begin'] = position + // interval is once a second, so end point must be at least one second beyond begin point + this.handlers['video-' + videoId + '-' + percent + '-percent-end'] = position + 2 + } + }, + + trackVideo: function (event, state) { + var videoTracker = window.GOVUK.analyticsGa4.analyticsModules.VideoTracker + var player = event.target + var videoId = player.id + clearInterval(videoTracker.handlers['video-' + videoId]) + + if (state === 'VideoUnstarted') { + videoTracker.handlers['video-' + videoId] = setInterval(videoTracker.checkProgress, 1000, player) + videoTracker.sendData(player, 'start', 0) // VideoUnstarted seems to only happen the first time video is played + } else if (state === 'VideoPlaying') { + videoTracker.handlers['video-' + videoId] = setInterval(videoTracker.checkProgress, 1000, player) + } else if (state === 'VideoEnded') { + if (!videoTracker.handlers['video-' + videoId + '-100']) { + videoTracker.sendData(player, 'complete', 100) + videoTracker.handlers['video-' + videoId + '-100'] = true + } + } + }, + + checkProgress: function (player) { + var videoId = player.id + var videoTracker = window.GOVUK.analyticsGa4.analyticsModules.VideoTracker + var pos = player.getCurrentTime() + var percentages = [25, 50, 75] + + // this looks really clunky and long hand + // but we have to do this once a second so doing the minimum before dropping out + // of an if statement is more efficient than combining all these statements into one + for (var i = 0; i < percentages.length; i++) { + if (pos >= videoTracker.handlers['video-' + videoId + '-' + percentages[i] + '-percent-begin']) { + if (pos < videoTracker.handlers['video-' + videoId + '-' + percentages[i] + '-percent-end']) { + if (!videoTracker.handlers['video-' + videoId + '-' + percentages[i]]) { + videoTracker.sendData(player, 'progress', percentages[i]) + videoTracker.handlers['video-' + videoId + '-' + percentages[i]] = true + } + return + } + } + } + }, + + sendData: function (player, event, position) { + var data = {} + data.event_name = 'video_' + event + data.type = 'video' + data.url = player.getVideoUrl() + data.text = player.videoTitle + data.action = event + data.video_current_time = Math.round(player.getCurrentTime()) + data.video_duration = Math.ceil(player.getDuration()) // number returned from the API varies, so round up + data.video_percent = position + + var schemas = new window.GOVUK.analyticsGa4.Schemas() + var schema = schemas.mergeProperties(data, 'event_data') + + window.GOVUK.analyticsGa4.core.sendData(schema) + } + } + + analyticsModules.VideoTracker = VideoTracker +})(window.GOVUK.analyticsGa4.analyticsModules) diff --git a/app/assets/javascripts/govuk_publishing_components/lib/govspeak/youtube-link-enhancement.js b/app/assets/javascripts/govuk_publishing_components/lib/govspeak/youtube-link-enhancement.js index 10523eeb09..abd0479939 100644 --- a/app/assets/javascripts/govuk_publishing_components/lib/govspeak/youtube-link-enhancement.js +++ b/app/assets/javascripts/govuk_publishing_components/lib/govspeak/youtube-link-enhancement.js @@ -116,6 +116,9 @@ // https://github.com/alphagov/govuk_publishing_components/pull/908#discussion_r302913995 var videoTitle = options.title event.target.getIframe().title = videoTitle + ' (video)' + if (window.GOVUK.analyticsGa4.analyticsModules.VideoTracker) { + window.GOVUK.analyticsGa4.analyticsModules.VideoTracker.configureVideo(event) + } }, onStateChange: function (event) { var eventData = event.data @@ -140,6 +143,10 @@ window.GOVUK.analytics.trackEvent(tracking.category, tracking.action, tracking.label) } + + if (window.GOVUK.analyticsGa4.analyticsModules.VideoTracker) { + window.GOVUK.analyticsGa4.analyticsModules.VideoTracker.trackVideo(event, states[eventData]) + } } } }) diff --git a/spec/javascripts/govuk_publishing_components/analytics-ga4/ga4-video-tracker.spec.js b/spec/javascripts/govuk_publishing_components/analytics-ga4/ga4-video-tracker.spec.js new file mode 100644 index 0000000000..4d40f310b8 --- /dev/null +++ b/spec/javascripts/govuk_publishing_components/analytics-ga4/ga4-video-tracker.spec.js @@ -0,0 +1,243 @@ +/* eslint-env jasmine */ + +describe('Google Analytics video tracker', function () { + var GOVUK = window.GOVUK + var videoTracker, event, expected + + beforeEach(function () { + window.dataLayer = [] + videoTracker = window.GOVUK.analyticsGa4.analyticsModules.VideoTracker + videoTracker.init() + window.GOVUK.analyticsGa4.vars.gem_version = 'aVersion' + event = { + target: { + id: 1, + getCurrentTime: function () { + return 0 + }, + getVideoUrl: function () { + return 'youtube/something' + }, + getDuration: function () { + return 500 + } + } + } + }) + + it('performs initial setup', function () { + var expectedHandlers = { + 'video-1-25-percent-begin': 125, + 'video-1-25-percent-end': 127, + 'video-1-50-percent-begin': 250, + 'video-1-50-percent-end': 252, + 'video-1-75-percent-begin': 375, + 'video-1-75-percent-end': 377 + } + videoTracker.configureVideo(event) + expect(videoTracker.handlers).toEqual(expectedHandlers) + }) + + describe('sends events', function () { + beforeEach(function () { + videoTracker.configureVideo(event) + expected = new GOVUK.analyticsGa4.Schemas().eventSchema() + expected.event = 'event_data' + expected.event_data.type = 'video' + expected.event_data.url = 'youtube/something' + expected.event_data.video_duration = 500 + expected.govuk_gem_version = 'aVersion' + }) + + it('when a video starts', function () { + videoTracker.trackVideo(event, 'VideoUnstarted') + expected.event_data.event_name = 'video_start' + expected.event_data.action = 'start' + expected.event_data.video_current_time = 0 + expected.event_data.video_percent = 0 + + expect(window.dataLayer[0]).toEqual(expected) + // stop the interval from continuing and interfering with other tests + videoTracker.trackVideo(event, 'VideoEnded') + }) + + it('when a video ends', function () { + videoTracker.trackVideo(event, 'VideoEnded') + expected.event_data.event_name = 'video_complete' + expected.event_data.action = 'complete' + expected.event_data.video_current_time = 0 + expected.event_data.video_percent = 100 + + expect(window.dataLayer[0]).toEqual(expected) + }) + }) + + describe('when monitoring video playback', function () { + beforeEach(function () { + videoTracker.configureVideo(event) + spyOn(videoTracker, 'checkProgress') + jasmine.clock().install() + }) + + afterEach(function () { + videoTracker.checkProgress.calls.reset() + jasmine.clock().uninstall() + }) + + it('starts monitoring when a video is played', function () { + videoTracker.trackVideo(event, 'VideoUnstarted') + jasmine.clock().tick(2000) + expect(videoTracker.checkProgress).toHaveBeenCalled() + }) + + it('stops monitoring when a video is paused', function () { + videoTracker.trackVideo(event, 'VideoUnstarted') + jasmine.clock().tick(2000) + expect(videoTracker.checkProgress).toHaveBeenCalled() + jasmine.clock().tick(2000) + + videoTracker.checkProgress.calls.reset() + videoTracker.trackVideo(event, 'VideoPaused') + jasmine.clock().tick(2000) + expect(videoTracker.checkProgress).not.toHaveBeenCalled() + }) + }) + + describe('sends events during playing', function () { + beforeEach(function () { + videoTracker.configureVideo(event) + expected = new GOVUK.analyticsGa4.Schemas().eventSchema() + expected.event = 'event_data' + expected.event_data.type = 'video' + expected.event_data.url = 'youtube/something' + expected.event_data.video_duration = 500 + expected.event_data.event_name = 'video_progress' + expected.event_data.action = 'progress' + expected.govuk_gem_version = 'aVersion' + + jasmine.clock().install() + }) + + afterEach(function () { + jasmine.clock().uninstall() + }) + + it('at 25 percent', function () { + event.target.getCurrentTime = function () { + return 125 + } + expected.event_data.video_current_time = 125 + expected.event_data.video_percent = 25 + + videoTracker.trackVideo(event, 'VideoPlaying') + jasmine.clock().tick(1000) + expect(window.dataLayer[0]).toEqual(expected) + }) + + it('at 50 percent', function () { + event.target.getCurrentTime = function () { + return 250 + } + expected.event_data.video_current_time = 250 + expected.event_data.video_percent = 50 + + videoTracker.trackVideo(event, 'VideoPlaying') + jasmine.clock().tick(1000) + expect(window.dataLayer[0]).toEqual(expected) + }) + + it('at 75 percent', function () { + event.target.getCurrentTime = function () { + return 375 + } + expected.event_data.video_current_time = 375 + expected.event_data.video_percent = 75 + + videoTracker.trackVideo(event, 'VideoPlaying') + jasmine.clock().tick(1000) + expect(window.dataLayer[0]).toEqual(expected) + }) + + it('sends only one event for each percentage reached', function () { + event.target.getCurrentTime = function () { + return 250 + } + expected.event_data.video_current_time = 250 + expected.event_data.video_percent = 50 + + videoTracker.trackVideo(event, 'VideoPlaying') + jasmine.clock().tick(1000) + expect(window.dataLayer[0]).toEqual(expected) + + videoTracker.trackVideo(event, 'VideoPlaying') + jasmine.clock().tick(5000) + expect(window.dataLayer.length).toEqual(1) + }) + }) + + describe('with more than one video', function () { + var event2 = { + target: { + id: 2, + getCurrentTime: function () { + return 0 + }, + getVideoUrl: function () { + return 'youtube/somethingElse' + }, + getDuration: function () { + return 1000 + } + } + } + + beforeEach(function () { + spyOn(videoTracker, 'checkProgress') + jasmine.clock().install() + }) + + afterEach(function () { + videoTracker.checkProgress.calls.reset() + jasmine.clock().uninstall() + }) + + it('sets distinct handlers', function () { + videoTracker.configureVideo(event) + videoTracker.configureVideo(event2) + + var expectedHandlers = { + 'video-1-25-percent-begin': 125, + 'video-1-25-percent-end': 127, + 'video-1-50-percent-begin': 250, + 'video-1-50-percent-end': 252, + 'video-1-75-percent-begin': 375, + 'video-1-75-percent-end': 377, + 'video-2-25-percent-begin': 250, + 'video-2-25-percent-end': 252, + 'video-2-50-percent-begin': 500, + 'video-2-50-percent-end': 502, + 'video-2-75-percent-begin': 750, + 'video-2-75-percent-end': 752 + } + expect(videoTracker.handlers).toEqual(expectedHandlers) + }) + + it('handles monitoring when videos are paused', function () { + videoTracker.trackVideo(event, 'VideoUnstarted') + videoTracker.trackVideo(event2, 'VideoUnstarted') + jasmine.clock().tick(2000) + expect(videoTracker.checkProgress).toHaveBeenCalled() + jasmine.clock().tick(2000) + + videoTracker.checkProgress.calls.reset() + videoTracker.trackVideo(event, 'VideoPaused') + jasmine.clock().tick(2000) + expect(videoTracker.checkProgress).toHaveBeenCalled() + + videoTracker.checkProgress.calls.reset() + videoTracker.trackVideo(event2, 'VideoPaused') + jasmine.clock().tick(2000) + expect(videoTracker.checkProgress).not.toHaveBeenCalled() + }) + }) +}) From f71adf43aa1647594db0931b7cbe3142cbd346d8 Mon Sep 17 00:00:00 2001 From: Andy Sellick Date: Wed, 2 Aug 2023 15:07:50 +0100 Subject: [PATCH 3/3] Update docs - add docs for video tracker - update changelog --- CHANGELOG.md | 1 + docs/analytics-ga4/ga4-video-tracker.md | 33 +++++++++++++++++++++++++ 2 files changed, 34 insertions(+) create mode 100644 docs/analytics-ga4/ga4-video-tracker.md diff --git a/CHANGELOG.md b/CHANGELOG.md index fff60078dd..b217b9b975 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ * Add section attribute to scroll tracking ([PR #3537](https://github.com/alphagov/govuk_publishing_components/pull/3537)) * Add navigation-page-type GA4 pageview attribute ([PR #3529](https://github.com/alphagov/govuk_publishing_components/pull/3529)) * Update the documentation for loading component stylesheets individually ([PR #3543](https://github.com/alphagov/govuk_publishing_components/pull/3543)) +* Add GA4 video tracking ([PR #3535](https://github.com/alphagov/govuk_publishing_components/pull/3535)) ## 35.13.1 diff --git a/docs/analytics-ga4/ga4-video-tracker.md b/docs/analytics-ga4/ga4-video-tracker.md new file mode 100644 index 0000000000..33b80c31c9 --- /dev/null +++ b/docs/analytics-ga4/ga4-video-tracker.md @@ -0,0 +1,33 @@ +# Google Analytics 4 video tracker + +This script tracks interactions with videos on GOV.UK. It sends an event to the dataLayer when any of the following occur. + +- a video starts playing +- a video reaches the end +- a video gets to 25%, 50%, or 75% of the way through + +These events only fire once for each video on any given page. Below is an example of the data sent to the dataLayer for a specific video, on beginning of playback. + +``` +{ + 'event': 'event_data', + 'event_data': { + 'event_name': 'video_start', + 'type': 'video', + 'url': 'https://www.youtube.com/watch?v=TvHpBXB0q0Y', + 'text': 'My first Self Assessment tax return', + 'action': 'start', + 'video_current_time': 0, + 'video_duration': 152, + 'video_percent': 0 + } +} +``` + +## How it works + +Assuming that consent has been given, the video tracker is launched automatically by the [Youtube link enhancement script](https://github.com/alphagov/govuk_publishing_components/blob/main/app/assets/javascripts/govuk_publishing_components/lib/govspeak/youtube-link-enhancement.js). Note that if cookie consent is not given, videos are not loaded on GOV.UK. Instead the user is shown a link to the video. + +The Youtube enhancement script creates an embedded Youtube player based on a link to a video. This includes the `enablejsapi` option, which is needed for tracking. The `onStateChange` event provided by the Youtube API allows code to be executed when a user interacts with a video, for example when playing or pausing. + +The function attached to this event listener calls the GA4 video tracker. Since there is no event fired during playback, in order to track progress (e.g. reaching 25%) an interval is created once a second, that checks progress using calls to the Youtube API. When it reaches one of the percentage positions it pushes information to the dataLayer and records that this event should not be fired again.