From 7373fd2d87cd9cf432b30d54e228ac0eb00db616 Mon Sep 17 00:00:00 2001 From: Molly Lloyd Date: Mon, 8 Oct 2018 16:07:50 -0700 Subject: [PATCH] map load telemetry event --- src/source/raster_tile_source.js | 3 +- src/source/vector_tile_source.js | 3 +- src/ui/map.js | 14 +- src/util/mapbox.js | 145 ++++++++++++++---- test/unit/util/mapbox.test.js | 245 ++++++++++++++++++++++++++++++- 5 files changed, 375 insertions(+), 35 deletions(-) diff --git a/src/source/raster_tile_source.js b/src/source/raster_tile_source.js index 30055b7525e..855cfdbf8da 100644 --- a/src/source/raster_tile_source.js +++ b/src/source/raster_tile_source.js @@ -5,7 +5,7 @@ import { extend, pick } from '../util/util'; import { getImage, ResourceType } from '../util/ajax'; import { Event, ErrorEvent, Evented } from '../util/evented'; import loadTileJSON from './load_tilejson'; -import { normalizeTileURL as normalizeURL, postTurnstileEvent } from '../util/mapbox'; +import { normalizeTileURL as normalizeURL, postTurnstileEvent, postMapLoadEvent } from '../util/mapbox'; import TileBounds from './tile_bounds'; import Texture from '../render/texture'; @@ -70,6 +70,7 @@ class RasterTileSource extends Evented implements Source { if (tileJSON.bounds) this.tileBounds = new TileBounds(tileJSON.bounds, this.minzoom, this.maxzoom); postTurnstileEvent(tileJSON.tiles); + postMapLoadEvent(tileJSON.tiles, this.map._getMapId()); // `content` is included here to prevent a race condition where `Style#_updateSources` is called // before the TileJSON arrives. this makes sure the tiles needed are loaded once TileJSON arrives diff --git a/src/source/vector_tile_source.js b/src/source/vector_tile_source.js index cb1a46eaa3b..76c7a2168f2 100644 --- a/src/source/vector_tile_source.js +++ b/src/source/vector_tile_source.js @@ -4,7 +4,7 @@ import { Event, ErrorEvent, Evented } from '../util/evented'; import { extend, pick } from '../util/util'; import loadTileJSON from './load_tilejson'; -import { normalizeTileURL as normalizeURL, postTurnstileEvent } from '../util/mapbox'; +import { normalizeTileURL as normalizeURL, postTurnstileEvent, postMapLoadEvent } from '../util/mapbox'; import TileBounds from './tile_bounds'; import { ResourceType } from '../util/ajax'; import browser from '../util/browser'; @@ -74,6 +74,7 @@ class VectorTileSource extends Evented implements Source { if (tileJSON.bounds) this.tileBounds = new TileBounds(tileJSON.bounds, this.minzoom, this.maxzoom); postTurnstileEvent(tileJSON.tiles); + postMapLoadEvent(tileJSON.tiles, this.map._getMapId()); // `content` is included here to prevent a race condition where `Style#_updateSources` is called // before the TileJSON arrives. this makes sure the tiles needed are loaded once TileJSON arrives diff --git a/src/ui/map.js b/src/ui/map.js index 8ff6957ac91..507aa4e7e96 100755 --- a/src/ui/map.js +++ b/src/ui/map.js @@ -1,6 +1,6 @@ // @flow -import { extend, bindAll, warnOnce } from '../util/util'; +import { extend, bindAll, warnOnce, uniqueId } from '../util/util'; import browser from '../util/browser'; import window from '../util/window'; @@ -262,6 +262,7 @@ class Map extends Camera { _collectResourceTiming: boolean; _renderTaskQueue: TaskQueue; _controls: Array; + _mapId: number; /** * The map's {@link ScrollZoomHandler}, which implements zooming in and out with a scroll wheel or trackpad. @@ -323,6 +324,7 @@ class Map extends Camera { this._collectResourceTiming = options.collectResourceTiming; this._renderTaskQueue = new TaskQueue(); this._controls = []; + this._mapId = uniqueId(); const transformRequestFn = options.transformRequest; this._transformRequest = transformRequestFn ? @@ -406,6 +408,16 @@ class Map extends Camera { }); } + /* + * Returns a unique number for this map instance which is used for the MapLoadEvent + * to make sure we only fire one event per instantiated map object. + * @private + * @returns {number} + */ + _getMapId() { + return this._mapId; + } + /** * Adds a {@link IControl} to the map, calling `control.onAdd(this)`. * diff --git a/src/util/mapbox.js b/src/util/mapbox.js index 18b68167392..4d1a518b724 100644 --- a/src/util/mapbox.js +++ b/src/util/mapbox.js @@ -12,7 +12,7 @@ import type { RequestParameters } from './ajax'; import type { Cancelable } from '../types/cancelable'; const help = 'See https://www.mapbox.com/api-documentation/#access-tokens'; -const turnstileEventStorageKey = 'mapbox.turnstileEventData'; +const telemEventKey = 'mapbox.eventData'; type UrlObject = {| protocol: string, @@ -133,19 +133,122 @@ function formatUrl(obj: UrlObject): string { return `${obj.protocol}://${obj.authority}${obj.path}${params}`; } -export class TurnstileEvent { +class TelemetryEvent { eventData: { anonId: ?string, lastSuccess: ?number, accessToken: ?string}; - queue: Array; - pending: boolean + queue: Array; pendingRequest: ?Cancelable; constructor() { this.eventData = { anonId: null, lastSuccess: null, accessToken: config.ACCESS_TOKEN}; this.queue = []; - this.pending = false; this.pendingRequest = null; } + fetchEventData() { + const isLocalStorageAvailable = storageAvailable('localStorage'); + const storageKey = `${telemEventKey}:${config.ACCESS_TOKEN || ''}`; + + if (isLocalStorageAvailable) { + //Retrieve cached data + try { + const data = window.localStorage.getItem(storageKey); + if (data) { + this.eventData = JSON.parse(data); + } + } catch (e) { + warnOnce('Unable to read from LocalStorage'); + } + } + } + + saveEventData() { + const isLocalStorageAvailable = storageAvailable('localStorage'); + const storageKey = `${telemEventKey}:${config.ACCESS_TOKEN || ''}`; + + if (isLocalStorageAvailable) { + try { + window.localStorage.setItem(storageKey, JSON.stringify(this.eventData)); + } catch (e) { + warnOnce('Unable to write to LocalStorage'); + } + } + + } + + processRequests() {} + + queueRequest(date: number | {id: number, timestamp: number}) { + this.queue.push(date); + this.processRequests(); + } +} + +export class MapLoadEvent extends TelemetryEvent { + eventData: { anonId: ?string, lastSuccess: ?number, accessToken: ?string}; + queue: Array<{ id: number, timestamp: number}>; + pendingRequest: ?Cancelable; + +success: {[number]: boolean}; + + constructor() { + super(); + this.success = {}; + } + + postMapLoadEvent(tileUrls: Array, mapId: number) { + //Enabled only when Mapbox Access Token is set and a source uses + // mapbox tiles. + if (config.ACCESS_TOKEN && + Array.isArray(tileUrls) && + tileUrls.some(url => isMapboxHTTPURL(url))) { + this.queueRequest({id: mapId, timestamp: Date.now()}); + } + } + + processRequests() { + if (this.pendingRequest || this.queue.length === 0) return; + const {id, timestamp} = this.queue.shift(); + + // Only one load event should fire per map + if (id && this.success[id]) return; + + if (!this.eventData.anonId) { + this.fetchEventData(); + } + + if (!validateUuid(this.eventData.anonId)) { + this.eventData.anonId = uuid(); + } + + const eventsUrlObject: UrlObject = parseUrl(config.EVENTS_URL); + eventsUrlObject.params.push(`access_token=${config.ACCESS_TOKEN || ''}`); + const request: RequestParameters = { + url: formatUrl(eventsUrlObject), + headers: { + 'Content-Type': 'text/plain' //Skip the pre-flight OPTIONS request + }, + body: JSON.stringify([{ + event: 'map.load', + created: new Date(timestamp).toISOString(), + sdkIdentifier: 'mapbox-gl-js', + sdkVersion: version, + userId: this.eventData.anonId + }]) + }; + + this.pendingRequest = postData(request, (error) => { + this.pendingRequest = null; + if (!error) { + if (id) this.success[id] = true; + this.saveEventData(); + this.processRequests(); + } + }); + } +} + + +export class TurnstileEvent extends TelemetryEvent { + postTurnstileEvent(tileUrls: Array) { //Enabled only when Mapbox Access Token is set and a source uses // mapbox tiles. @@ -156,34 +259,20 @@ export class TurnstileEvent { } } - queueRequest(date: number) { - this.queue.push(date); - this.processRequests(); - } processRequests() { if (this.pendingRequest || this.queue.length === 0) { return; } - const storageKey = `${turnstileEventStorageKey}:${config.ACCESS_TOKEN || ''}`; - const isLocalStorageAvailable = storageAvailable('localStorage'); - let dueForEvent = this.eventData.accessToken ? (this.eventData.accessToken !== config.ACCESS_TOKEN) : false; + let dueForEvent = this.eventData.accessToken ? (this.eventData.accessToken !== config.ACCESS_TOKEN) : false; //Reset event data cache if the access token changed. if (dueForEvent) { this.eventData.anonId = this.eventData.lastSuccess = null; } - if ((!this.eventData.anonId || !this.eventData.lastSuccess) && - isLocalStorageAvailable) { + if (!this.eventData.anonId || !this.eventData.lastSuccess) { //Retrieve cached data - try { - const data = window.localStorage.getItem(storageKey); - if (data) { - this.eventData = JSON.parse(data); - } - } catch (e) { - warnOnce('Unable to read from LocalStorage'); - } + this.fetchEventData(); } if (!validateUuid(this.eventData.anonId)) { @@ -227,13 +316,7 @@ export class TurnstileEvent { if (!error) { this.eventData.lastSuccess = nextUpdate; this.eventData.accessToken = config.ACCESS_TOKEN; - if (isLocalStorageAvailable) { - try { - window.localStorage.setItem(storageKey, JSON.stringify(this.eventData)); - } catch (e) { - warnOnce('Unable to write to LocalStorage'); - } - } + this.saveEventData(); this.processRequests(); } }); @@ -241,5 +324,7 @@ export class TurnstileEvent { } const turnstileEvent_ = new TurnstileEvent(); - export const postTurnstileEvent = turnstileEvent_.postTurnstileEvent.bind(turnstileEvent_); + +const mapLoadEvent_ = new MapLoadEvent(); +export const postMapLoadEvent = mapLoadEvent_.postMapLoadEvent.bind(mapLoadEvent_); diff --git a/test/unit/util/mapbox.test.js b/test/unit/util/mapbox.test.js index 06a320388ae..77f430b7cf2 100644 --- a/test/unit/util/mapbox.test.js +++ b/test/unit/util/mapbox.test.js @@ -382,14 +382,13 @@ test("mapbox", (t) => { t.test('does not POST event when previously stored data is on the same day', (t) => { const now = +Date.now(); - window.localStorage.setItem(`mapbox.turnstileEventData:${config.ACCESS_TOKEN}`, JSON.stringify({ + window.localStorage.setItem(`mapbox.eventData:${config.ACCESS_TOKEN}`, JSON.stringify({ anonId: uuid(), lastSuccess: now })); // Post 5 seconds later withFixedDate(t, now + 5, () => event.postTurnstileEvent(mapboxTileURLs)); - t.false(window.server.requests.length); t.end(); }); @@ -616,6 +615,248 @@ test("mapbox", (t) => { t.end(); }); + t.test('MapLoadEvent', (t) => { + let event; + t.beforeEach((callback) => { + window.useFakeXMLHttpRequest(); + event = new mapbox.MapLoadEvent(); + callback(); + }); + + t.afterEach((callback) => { + window.restore(); + callback(); + }); + + t.test('mapbox.postMapLoadEvent', (t) => { + t.ok(mapbox.postMapLoadEvent); + t.end(); + }); + + t.test('does not POST when mapboxgl.ACCESS_TOKEN is not set', (t) => { + config.ACCESS_TOKEN = null; + + event.postMapLoadEvent(mapboxTileURLs, 1); + t.equal(window.server.requests.length, 0); + t.end(); + }); + + t.test('does not POST when url does not point to mapbox.com', (t) => { + event.postMapLoadEvent(nonMapboxTileURLs, 1); + + t.equal(window.server.requests.length, 0); + t.end(); + }); + + t.test('POSTs cn event when API_URL changes to cn endpoint', (t) => { + const previousUrl = config.API_URL; + config.API_URL = 'https://api.mapbox.cn'; + + event.postMapLoadEvent(mapboxTileURLs, 1); + + const req = window.server.requests[0]; + req.respond(200); + + t.true(req.url.indexOf('https://events.mapbox.cn') > -1); + config.API_URL = previousUrl; + t.end(); + }); + + t.test('with LocalStorage available', (t) => { + let prevLocalStorage; + t.beforeEach((callback) => { + prevLocalStorage = window.localStorage; + window.localStorage = { + data: {}, + setItem: function (id, val) { + this.data[id] = String(val); + }, + getItem: function (id) { + return this.data.hasOwnProperty(id) ? this.data[id] : undefined; + }, + removeItem: function (id) { + if (this.hasOwnProperty(id)) delete this[id]; + } + }; + callback(); + }); + + t.afterEach((callback) => { + window.localStorage = prevLocalStorage; + callback(); + }); + + t.test('POSTs event when previously stored anonId is not a valid uuid', (t) => { + window.localStorage.setItem(`mapbox.turnstileEventData:${config.ACCESS_TOKEN}`, JSON.stringify({ + anonId: 'anonymous' + })); + + event.postMapLoadEvent(mapboxTileURLs, 1); + const req = window.server.requests[0]; + req.respond(200); + + const reqBody = JSON.parse(req.requestBody)[0]; + t.notEqual(reqBody.userId, 'anonymous'); + t.end(); + }); + + t.test('does not POST map.load event second time within same calendar day', (t) => { + let now = +Date.now(); + withFixedDate(t, now, () => event.postMapLoadEvent(mapboxTileURLs, 1)); + + //Post second event + const firstEvent = now; + now += (60 * 1000); // A bit later + withFixedDate(t, now, () => event.postMapLoadEvent(mapboxTileURLs, 1)); + + const req = window.server.requests[0]; + req.respond(200); + + t.equal(window.server.requests.length, 1); + + const reqBody = JSON.parse(req.requestBody)[0]; + t.equal(reqBody.created, new Date(firstEvent).toISOString()); + + t.end(); + }); + + t.test('does not POST map.load event second time when clock goes backwards less than a day', (t) => { + let now = +Date.now(); + withFixedDate(t, now, () => event.postMapLoadEvent(mapboxTileURLs, 1)); + + //Post second event + const firstEvent = now; + now -= (60 * 1000); // A bit earlier + withFixedDate(t, now, () => event.postMapLoadEvent(mapboxTileURLs, 1)); + + const req = window.server.requests[0]; + req.respond(200); + + t.equal(window.server.requests.length, 1); + + const reqBody = JSON.parse(req.requestBody)[0]; + t.equal(reqBody.created, new Date(firstEvent).toISOString()); + + t.end(); + }); + + t.test('POSTs map.load event when access token changes', (t) => { + config.ACCESS_TOKEN = 'pk.new.*'; + + event.postMapLoadEvent(mapboxTileURLs, 1); + + const req = window.server.requests[0]; + req.respond(200); + + t.equal(req.url, `${config.EVENTS_URL}?access_token=pk.new.*`); + + t.end(); + }); + + t.end(); + }); + + t.test('when LocalStorage is not available', (t) => { + t.test('POSTs map.load event', (t) => { + event.postMapLoadEvent(mapboxTileURLs, 1); + + const req = window.server.requests[0]; + req.respond(200); + + const reqBody = JSON.parse(req.requestBody)[0]; + t.equal(req.url, `${config.EVENTS_URL}?access_token=key`); + t.equal(req.method, 'POST'); + t.equal(reqBody.event, 'map.load'); + t.equal(reqBody.sdkVersion, version); + t.ok(reqBody.userId); + + t.end(); + }); + + t.test('does not POST map.load multiple times for the same map instance', (t) => { + const now = Date.now(); + withFixedDate(t, now, () => event.postMapLoadEvent(mapboxTileURLs, 1)); + withFixedDate(t, now + 5, () => event.postMapLoadEvent(mapboxTileURLs, 1)); + + const req = window.server.requests[0]; + req.respond(200); + + t.equal(window.server.requests.length, 1); + + const reqBody = JSON.parse(req.requestBody)[0]; + t.equal(reqBody.created, new Date(now).toISOString()); + + t.end(); + }); + + t.test('POSTs map.load event when access token changes', (t) => { + config.ACCESS_TOKEN = 'pk.new.*'; + + event.postMapLoadEvent(mapboxTileURLs, 1); + + const req = window.server.requests[0]; + req.respond(200); + + const reqBody = JSON.parse(req.requestBody)[0]; + t.equal(req.url, `${config.EVENTS_URL}?access_token=pk.new.*`); + t.equal(req.method, 'POST'); + t.equal(reqBody.event, 'map.load'); + t.equal(reqBody.sdkVersion, version); + t.ok(reqBody.userId); + + t.end(); + }); + + t.test('POSTs distinct map.load for multiple maps', (t) => { + event.postMapLoadEvent(mapboxTileURLs, 1); + const now = +Date.now(); + withFixedDate(t, now, ()=> event.postMapLoadEvent(mapboxTileURLs, 2)); + + let req = window.server.requests[0]; + req.respond(200); + + req = window.server.requests[1]; + req.respond(200); + const reqBody = JSON.parse(req.requestBody)[0]; + t.equal(req.url, `${config.EVENTS_URL}?access_token=key`); + t.equal(req.method, 'POST'); + t.equal(reqBody.event, 'map.load'); + t.equal(reqBody.sdkVersion, version); + t.ok(reqBody.userId); + t.equal(reqBody.created, new Date(now).toISOString()); + + t.end(); + }); + + t.test('Queues and POSTs map.load events when triggerred in quick succession by different maps', (t) => { + const now = Date.now(); + withFixedDate(t, now, () => event.postMapLoadEvent(mapboxTileURLs, 1)); + withFixedDate(t, now, () => event.postMapLoadEvent(mapboxTileURLs, 2)); + withFixedDate(t, now, () => event.postMapLoadEvent(mapboxTileURLs, 3)); + + const reqOne = window.server.requests[0]; + reqOne.respond(200); + let reqBody = JSON.parse(reqOne.requestBody)[0]; + t.equal(reqBody.created, new Date(now).toISOString()); + + const reqTwo = window.server.requests[1]; + reqTwo.respond(200); + reqBody = JSON.parse(reqTwo.requestBody)[0]; + t.equal(reqBody.created, new Date(now).toISOString()); + + const reqThree = window.server.requests[2]; + reqThree.respond(200); + reqBody = JSON.parse(reqThree.requestBody)[0]; + t.equal(reqBody.created, new Date(now).toISOString()); + + t.end(); + }); + + t.end(); + }); + + t.end(); + }); t.end(); });