media/js/base/stub-attribution/stub-attribution.js (461 lines of code) (raw):

/* * This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ // Create namespace if (typeof window.Mozilla === 'undefined') { window.Mozilla = {}; } (function () { 'use strict'; window.dataLayer = window.dataLayer || []; /** * Constructs attribution data based on utm parameters and referrer information * for relay to the Firefox stub installer. Data is first signed and encoded via * an XHR request to the `stub_attribution_code` service, before being appended * to Bouncer download URLs as query parameters. Data returned from the service * is also stored in a cookie to save multiple requests when navigating * pages. Bug https://bugzilla.mozilla.org/show_bug.cgi?id=1279291 */ var StubAttribution = {}; StubAttribution.COOKIE_CODE_ID = 'moz-stub-attribution-code'; StubAttribution.COOKIE_SIGNATURE_ID = 'moz-stub-attribution-sig'; StubAttribution.DLSOURCE = 'mozorg'; /** * Experiment name and variation globals. These values can be set directly by a * page's JS instead of relying on supplied URL query parameters. */ StubAttribution.experimentName; StubAttribution.experimentVariation; /** * Custom event handler callback globals. These can be defined as functions when * calling StubAttribution.init(); */ StubAttribution.successCallback; StubAttribution.timeoutCallback; StubAttribution.requestComplete = false; /** * Determines if session falls within the predefined stub attribution sample rate. * @return {Boolean}. */ StubAttribution.withinAttributionRate = function () { return Math.random() < StubAttribution.getAttributionRate() ? true : false; }; /** * Returns stub attribution value used for rate limiting. * @return {Number} float between 0 and 1. */ StubAttribution.getAttributionRate = function () { var rate = document .getElementsByTagName('html')[0] .getAttribute('data-stub-attribution-rate'); return isNaN(rate) || !rate ? 0 : Math.min(Math.max(parseFloat(rate), 0), 1); }; /** * Returns true if both cookies exist. * @return {Boolean} data. */ StubAttribution.hasCookie = function () { return ( Mozilla.Cookies.hasItem(StubAttribution.COOKIE_CODE_ID) && Mozilla.Cookies.hasItem(StubAttribution.COOKIE_SIGNATURE_ID) ); }; /** * Stores a cookie with stub attribution data values. * @param {Object} data - attribution_code, attribution_sig. */ StubAttribution.setCookie = function (data) { if (!data.attribution_code || !data.attribution_sig) { return; } // set cookie to expire in 24 hours var date = new Date(); date.setTime(date.getTime() + 1 * 24 * 60 * 60 * 1000); var expires = date.toUTCString(); Mozilla.Cookies.setItem( StubAttribution.COOKIE_CODE_ID, data.attribution_code, expires, '/', undefined, false, 'lax' ); Mozilla.Cookies.setItem( StubAttribution.COOKIE_SIGNATURE_ID, data.attribution_sig, expires, '/', undefined, false, 'lax' ); }; /** * Removes stub attribution cookie. */ StubAttribution.removeCookie = function () { window.Mozilla.Cookies.removeItem( StubAttribution.COOKIE_CODE_ID, '/', undefined, false, 'lax' ); window.Mozilla.Cookies.removeItem( StubAttribution.COOKIE_SIGNATURE_ID, '/', undefined, false, 'lax' ); }; /** * Gets stub attribution data from cookie. * @return {Object} - attribution_code, attribution_sig. */ StubAttribution.getCookie = function () { return { attribution_code: Mozilla.Cookies.getItem( StubAttribution.COOKIE_CODE_ID ), attribution_sig: Mozilla.Cookies.getItem( StubAttribution.COOKIE_SIGNATURE_ID ) }; }; /** * Updates all download links on the page with additional query params for * stub attribution. * @param {Object} data - attribution_code, attribution_sig. */ StubAttribution.updateBouncerLinks = function (data) { /** * If data is missing or the browser does not meet requirements for * stub attribution, then do nothing. */ if ( !data.attribution_code || !data.attribution_sig || !StubAttribution.meetsRequirements() ) { return; } // target download buttons and other-platforms modal links. var downloadLinks = document.querySelectorAll('.download-link'); for (var i = 0; i < downloadLinks.length; i++) { var link = downloadLinks[i]; var version; var directLink; // Append stub attribution data to direct download links. if ( (link.href && (link.href.indexOf('https://download.mozilla.org') !== -1 || link.href.indexOf( 'https://bouncer-bouncer.stage.mozaws.net/' ) !== -1)) || link.href.indexOf( 'https://dev.bouncer.nonprod.webservices.mozgcp.net' ) !== -1 ) { version = link.getAttribute('data-download-version'); // Append attribution params to Windows links. if (version && /win/.test(version)) { link.href = Mozilla.StubAttribution.appendToDownloadURL( link.href, data ); } // Append attribution params to macOS links (excluding ESR for now). if ( version && /osx/.test(version) && !/product=firefox-esr/.test(link.href) ) { link.href = Mozilla.StubAttribution.appendToDownloadURL( link.href, data ); } } else if ( link.href && link.href.indexOf('/firefox/download/thanks/') !== -1 ) { // Append stub data to direct-link data attributes on transitional links for old IE browsers (Issue #9350) directLink = link.getAttribute('data-direct-link'); if (directLink) { link.setAttribute( 'data-direct-link', Mozilla.StubAttribution.appendToDownloadURL( directLink, data ) ); } } } }; StubAttribution.removeLinkAttributionParams = function (href) { if (href.indexOf('?') > 0) { var params = new window._SearchParams(href.split('?')[1]); var origin = href.split('?')[0]; if ( params.has('attribution_code') && params.has('attribution_sig') ) { params.remove('attribution_code'); params.remove('attribution_sig'); return ( origin + '?' + window.decodeURIComponent(params.toString()) ); } } return href; }; StubAttribution.cleanBouncerLinks = function () { var downloadLinks = document.querySelectorAll('.download-link'); for (var i = 0; i < downloadLinks.length; i++) { downloadLinks[i].href = StubAttribution.removeLinkAttributionParams( downloadLinks[i].href ); if (downloadLinks[i].hasAttribute('data-direct-link')) { var attribute = StubAttribution.removeLinkAttributionParams( downloadLinks[i].getAttribute('data-direct-link') ); downloadLinks[i].setAttribute('data-direct-link', attribute); } } }; StubAttribution.removeAttributionData = function () { StubAttribution.removeCookie(); StubAttribution.cleanBouncerLinks(); StubAttribution.requestComplete = false; }; /** * Appends stub attribution data as URL parameters. * Note: data is already URI encoded when returned via the service. * @param {String} url - URL to append data to. * @param {Object} data - attribution_code, attribution_sig. * @return {String} url + additional parameters. */ StubAttribution.appendToDownloadURL = function (url, data) { if (!data.attribution_code || !data.attribution_sig) { return url; } // append stub attribution query params. for (var key in data) { if (Object.prototype.hasOwnProperty.call(data, key)) { if (key === 'attribution_code' || key === 'attribution_sig') { url += (url.indexOf('?') > -1 ? '&' : '?') + key + '=' + data[key]; } } } return url; }; /** * Handles XHR request from `stub_attribution_code` service. * @param {Object} data - attribution_code, attribution_sig. */ StubAttribution.onRequestSuccess = function (data) { if ( data.attribution_code && data.attribution_sig && !StubAttribution.requestComplete ) { // Update download links on the current page. StubAttribution.updateBouncerLinks(data); // Store attribution data in a cookie should the user navigate. StubAttribution.setCookie(data); StubAttribution.requestComplete = true; if (typeof StubAttribution.successCallback === 'function') { StubAttribution.successCallback(); } } }; StubAttribution.onRequestTimeout = function () { if (!StubAttribution.requestComplete) { StubAttribution.requestComplete = true; if (typeof StubAttribution.timeoutCallback === 'function') { StubAttribution.timeoutCallback(); } } }; /** * AJAX request to bedrock service to authenticate stub attribution request. * @param {Object} data - utm params and referrer. */ StubAttribution.requestAuthentication = function (data) { var SERVICE_URL = window.location.protocol + '//' + window.location.host + '/en-US/firefox/stub_attribution_code/'; var xhr = new window.XMLHttpRequest(); var timeoutValue = 10000; var timeout = setTimeout( StubAttribution.onRequestTimeout, timeoutValue ); xhr.open( 'GET', SERVICE_URL + '?' + window._SearchParams.objectToQueryString(data) ); xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest'); // use readystate change over onload for IE8 support. xhr.onreadystatechange = function () { if (xhr.readyState === 4) { var status = xhr.status; if (status && status >= 200 && status < 400) { try { var data = JSON.parse(xhr.responseText); clearTimeout(timeout); StubAttribution.onRequestSuccess(data); } catch (e) { // something went wrong, fallback to the timeout handler. StubAttribution.onRequestTimeout(); } } } }; // must come after open call above for IE 10 & 11 xhr.timeout = timeoutValue; xhr.send(); }; /** * Returns a browser name based on coarse UA string detection for only major browsers. * Other browsers (or modified UAs) that have strings that look like one of the top default user agent strings are treated as false positives. * @param {String} ua - Optional user agent string to facilitate testing. * @returns {String} - Browser name. */ StubAttribution.getUserAgent = function (ua) { ua = typeof ua !== 'undefined' ? ua : navigator.userAgent; if (/MSIE|Trident/i.test(ua)) { return 'ie'; } if (/Edg|Edge/i.test(ua)) { return 'edge'; } if (/Firefox/.test(ua)) { return 'firefox'; } if (/Chrome/.test(ua)) { return 'chrome'; } return 'other'; }; /** * Attempts to retrieve the GA4 client from the dataLayer * The GTAG GET API tag will write it to the dataLayer once GTM has loaded it * https://www.simoahava.com/gtmtips/write-client-id-other-gtag-fields-datalayer/ */ StubAttribution.getGtagClientID = function (dataLayer) { // need to pass in dataLayer for testing purposes, use global dataLayer if it's not passed dataLayer = typeof dataLayer !== 'undefined' ? dataLayer : window.dataLayer; var clientID = null; function _findAPI(obj) { for (var key in obj) { if ( typeof obj[key] === 'object' && Object.prototype.hasOwnProperty.call(obj, key) ) { if (key === 'gtagApiResult') { if (typeof obj[key].client_id === 'string') { clientID = obj[key].client_id; } else { return clientID; } break; } else { _findAPI(obj[key]); } } } } try { if (typeof dataLayer === 'object') { dataLayer.forEach(function (layer) { _findAPI(layer); }); } } catch (e) { // GA4 window.dataLayer.push({ event: 'log', label: 'getGtagClientID error: ' + e }); return null; } return clientID; }; /** * Returns a random identifier that we use to associate a * visitor's website GA data with their Telemetry attribution * data. This identifier is sent as a non-interaction event * to GA, and also to the stub attribution service as session_id. * @returns {String} session ID. */ StubAttribution.createSessionID = function () { return Math.floor(1000000000 + Math.random() * 9000000000).toString(); }; /** * A crude check to see if Google Analytics has loaded. * @param {Function} callback */ StubAttribution.waitForGoogleAnalyticsThen = function (callback) { var timeout; var pollRetry = 0; var interval = 100; var limit = 20; // (100 x 20) / 1000 = 2 seconds // Tries to get client IDs at a set interval function _checkGA() { clearTimeout(timeout); var clientIDGA4 = StubAttribution.getGtagClientID(); if (clientIDGA4) { callback(true); } else { if (pollRetry <= limit) { pollRetry += 1; timeout = window.setTimeout(_checkGA, interval); } else { if (clientIDGA4) { callback(true); } else { callback(false); } } } } _checkGA(); }; /** * Gets utm parameters and referrer information from the web page if they exist. * @param {String} ref - Optional referrer to facilitate testing. * @param {Boolean} omitNonEssentialFields - Optional flag to omit fields that are nonEssential for RTAMO. * @return {Object} - Stub attribution data object. */ StubAttribution.getAttributionData = function ( ref, omitNonEssentialFields ) { var params = new window._SearchParams(); var utms = params.utmParams(); var experiment = omitNonEssentialFields ? null : params.get('experiment') || StubAttribution.experimentName; var variation = omitNonEssentialFields ? null : params.get('variation') || StubAttribution.experimentVariation; var referrer = typeof ref === 'string' ? ref : document.referrer; var ua = omitNonEssentialFields ? 'other' : StubAttribution.getUserAgent(); var clientIDGA4 = omitNonEssentialFields ? null : StubAttribution.getGtagClientID(); var data = { utm_source: utms.utm_source, utm_medium: utms.utm_medium, utm_campaign: utms.utm_campaign, utm_content: utms.utm_content, referrer: referrer, ua: ua, experiment: experiment, variation: variation, client_id_ga4: clientIDGA4, session_id: clientIDGA4 ? StubAttribution.createSessionID() : null, dlsource: StubAttribution.DLSOURCE }; // Remove any undefined values. for (var key in data) { if (Object.prototype.hasOwnProperty.call(data, key)) { if (typeof data[key] === 'undefined' || data[key] === null) { delete data[key]; } } } return data; }; StubAttribution.hasValidData = function (data) { if ( typeof data.utm_content === 'string' && typeof data.referrer === 'string' ) { var content = data.utm_content; var charLimit = 150; // If utm_content is unusually long, return false early. if (content.length > charLimit) { return false; } // Attribution data can be double encoded while (content.indexOf('%') !== -1) { try { var result = decodeURIComponent(content); if (result === content) { break; } content = result; } catch (e) { break; } } // If RTAMO data does not originate from AMO, drop attribution (Issues 10337, 10524). if ( /^rta:/.test(content) && data.referrer.indexOf('https://addons.mozilla.org') === -1 ) { return false; } } return true; }; /** * Determine if the current page is /download/thanks * This is needed as /thanks auto-initiates the download. There is little point * trying to make an XHR request here before the download begins, and we don't * want to make the request a dependency on the download starting. * @return {Boolean}. */ StubAttribution.isFirefoxDownloadThanks = function (location) { location = typeof location !== 'undefined' ? location : window.location.href; return location.indexOf('/firefox/download/thanks/') > -1; }; /** * Determines if requirements for stub attribution to work are satisfied. * Stub attribution is only applicable to Windows/macOS users on desktop. * @return {Boolean}. */ StubAttribution.meetsRequirements = function () { if ( typeof window.site === 'undefined' || typeof Mozilla.Cookies === 'undefined' || typeof window._SearchParams === 'undefined' ) { return false; } if (!Mozilla.Cookies.enabled()) { return false; } if (!/windows|osx/i.test(window.site.platform)) { return false; } return true; }; /** * Determines whether to make a request to the stub authentication service. */ StubAttribution.init = function (successCallback, timeoutCallback) { var data = {}; if (!StubAttribution.meetsRequirements()) { return; } // Support custom callback functions for success and timeout. if (typeof successCallback === 'function') { StubAttribution.successCallback = successCallback; } if (typeof timeoutCallback === 'function') { StubAttribution.timeoutCallback = timeoutCallback; } /** * If cookie already exists, update download links on the page, * else make a request to the service if within attribution rate. */ if (StubAttribution.hasCookie()) { data = StubAttribution.getCookie(); StubAttribution.updateBouncerLinks(data); // As long as the user is not already on the automatic download page, // make the XHR request to the stub authentication service. } else if (!StubAttribution.isFirefoxDownloadThanks()) { // Wait for GA4 to load and return client IDs StubAttribution.waitForGoogleAnalyticsThen(function () { // get attribution data data = StubAttribution.getAttributionData(); if ( data && StubAttribution.withinAttributionRate() && StubAttribution.hasValidData(data) ) { // if data is valid and we are in sample rate: // request authentication from stub attribution service StubAttribution.requestAuthentication(data); // Send the session ID to GA4 if (data.client_id_ga4) { window.dataLayer.push({ event: 'stub_session_set', id: data.session_id }); } } }); } }; window.Mozilla.StubAttribution = StubAttribution; })();