Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

User turnstile event #6980

Merged
merged 13 commits into from
Aug 6, 2018
Merged
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@
"glob": "^7.0.3",
"in-publish": "^2.0.0",
"is-builtin-module": "^1.0.0",
"jsdom": "^11.6.2",
"jsdom": "^11.11.0",
"json-stringify-pretty-compact": "^1.0.4",
"lodash": "^4.16.0",
"mock-geolocation": "^1.0.11",
Expand Down
4 changes: 3 additions & 1 deletion src/source/raster_tile_source.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 } from '../util/mapbox';
import { normalizeTileURL as normalizeURL, postTurnstileEvent } from '../util/mapbox';
import TileBounds from './tile_bounds';
import Texture from '../render/texture';

Expand Down Expand Up @@ -65,6 +65,8 @@ class RasterTileSource extends Evented implements Source {
extend(this, tileJSON);
if (tileJSON.bounds) this.tileBounds = new TileBounds(tileJSON.bounds, this.minzoom, this.maxzoom);

postTurnstileEvent(tileJSON.tiles);

// `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
// ref: https://github.com/mapbox/mapbox-gl-js/pull/4347#discussion_r104418088
Expand Down
4 changes: 3 additions & 1 deletion src/source/vector_tile_source.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 } from '../util/mapbox';
import { normalizeTileURL as normalizeURL, postTurnstileEvent } from '../util/mapbox';
import TileBounds from './tile_bounds';
import { ResourceType } from '../util/ajax';
import browser from '../util/browser';
Expand Down Expand Up @@ -72,6 +72,8 @@ class VectorTileSource extends Evented implements Source {
extend(this, tileJSON);
if (tileJSON.bounds) this.tileBounds = new TileBounds(tileJSON.bounds, this.minzoom, this.maxzoom);

postTurnstileEvent(tileJSON.tiles);

// `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
// ref: https://github.com/mapbox/mapbox-gl-js/pull/4347#discussion_r104418088
Expand Down
21 changes: 20 additions & 1 deletion src/util/ajax.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// @flow

import window from './window';
import { extend } from './util';

import type { Callback } from '../types/callback';
import type { Cancelable } from '../types/cancelable';
Expand Down Expand Up @@ -37,6 +38,7 @@ if (typeof Object.freeze == 'function') {
export type RequestParameters = {
url: string,
headers?: Object,
method?: 'GET' | 'POST' | 'PUT',
credentials?: 'same-origin' | 'include',
collectResourceTiming?: boolean
};
Expand All @@ -62,7 +64,7 @@ class AJAXError extends Error {
function makeRequest(requestParameters: RequestParameters): XMLHttpRequest {
const xhr: XMLHttpRequest = new window.XMLHttpRequest();

xhr.open('GET', requestParameters.url, true);
xhr.open(requestParameters.method || 'GET', requestParameters.url, true);
for (const k in requestParameters.headers) {
xhr.setRequestHeader(k, requestParameters.headers[k]);
}
Expand Down Expand Up @@ -122,6 +124,23 @@ export const getArrayBuffer = function(requestParameters: RequestParameters, cal
return { cancel: () => xhr.abort() };
};

export const postData = function(requestParameters: RequestParameters, payload: string, callback: Callback<mixed>): Cancelable {
const xhr = makeRequest(extend(requestParameters, {method: 'POST'}));

xhr.onerror = function() {
callback(new Error(xhr.statusText));
};
xhr.onload = function() {
if (xhr.status >= 200 && xhr.status < 300) {
callback(null, xhr.response);
} else {
callback(new AJAXError(xhr.statusText, xhr.status, requestParameters.url));
}
};
xhr.send(payload);
return { cancel: () => xhr.abort() };
};

function sameOrigin(url) {
const a: HTMLAnchorElement = window.document.createElement('a');
a.href = url;
Expand Down
2 changes: 2 additions & 0 deletions src/util/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,14 @@

type Config = {|
API_URL: string,
EVENTS_URL: string,
REQUIRE_ACCESS_TOKEN: boolean,
ACCESS_TOKEN: ?string
|};

const config: Config = {
API_URL: 'https://api.mapbox.com',
EVENTS_URL: 'https://events.mapbox.com/events/v2',
REQUIRE_ACCESS_TOKEN: true,
ACCESS_TOKEN: null
};
Expand Down
119 changes: 119 additions & 0 deletions src/util/mapbox.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,16 @@
import config from './config';

import browser from './browser';
import window from './window';
import { version } from '../../package.json';
import { uuid, validateUuid, storageAvailable, warnOnce } from './util';
import { postData } from './ajax';

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';

type UrlObject = {|
protocol: string,
Expand Down Expand Up @@ -119,3 +127,114 @@ function formatUrl(obj: UrlObject): string {
const params = obj.params.length ? `?${obj.params.join('&')}` : '';
return `${obj.protocol}://${obj.authority}${obj.path}${params}`;
}

export class TurnstileEvent {
eventData: { anonId: ?string, lastSuccess: ?number, accessToken: ?string};
queue: Array<number>;
pending: boolean
pendingRequest: ?Cancelable;

constructor() {
this.eventData = { anonId: null, lastSuccess: null, accessToken: config.ACCESS_TOKEN};
this.queue = [];
this.pending = false;
this.pendingRequest = null;
}

postTurnstileEvent(tileUrls: Array<string>) {
//Enabled only when Mapbox Access Token is set and a source uses
// mapbox tiles.
if (config.ACCESS_TOKEN &&
Array.isArray(tileUrls) &&
tileUrls.some((url) => { return /(mapbox\.c)(n|om)/i.test(url); })) {
this.queueRequest(browser.now());
}
}

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;

//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) {
//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');
}
}

if (!validateUuid(this.eventData.anonId)) {
this.eventData.anonId = uuid();
dueForEvent = true;
}
const nextUpdate = this.queue.shift();

// Record turnstile event once per calendar day.
if (this.eventData.lastSuccess) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's the expected behavior if localStorage is not available? As of now, it looks like we'd only send a turnstile event once per page in that case, since lastSuccess would never be set.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

eventData caches the id and success values within a single session. But I see the problem, this value needs to be updated irrespective of localStorageAvailable when a request succeeds.

If localStorage is not available, there may be more than one event sent per calendar day, but the events server de-duplicates those.

const lastUpdate = new Date(this.eventData.lastSuccess);
const nextDate = new Date(nextUpdate);
const daysElapsed = (nextUpdate - this.eventData.lastSuccess) / (24 * 60 * 60 * 1000);
dueForEvent = dueForEvent || daysElapsed >= 1 || daysElapsed < -1 || lastUpdate.getDate() !== nextDate.getDate();
}

if (!dueForEvent) {
return this.processRequests();
}

const evenstUrlObject: UrlObject = parseUrl(config.EVENTS_URL);
evenstUrlObject.params.push(`access_token=${config.ACCESS_TOKEN || ''}`);
const request: RequestParameters = {
url: formatUrl(evenstUrlObject),
headers: {
'Content-Type': 'text/plain' //Skip the pre-flight OPTIONS request
}
};

const payload = JSON.stringify([{
event: 'appUserTurnstile',
created: (new Date(nextUpdate)).toISOString(),
sdkIdentifier: 'mapbox-gl-js',
sdkVersion: version,
'enabled.telemetry': false,
userId: this.eventData.anonId
}]);

this.pendingRequest = postData(request, payload, (error) => {
this.pendingRequest = null;
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.processRequests();
}
});
}
}

const turnstileEvent_ = new TurnstileEvent();

export const postTurnstileEvent = turnstileEvent_.postTurnstileEvent.bind(turnstileEvent_);
33 changes: 33 additions & 0 deletions src/util/util.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import UnitBezier from '@mapbox/unitbezier';

import Coordinate from '../geo/coordinate';
import Point from '@mapbox/point-geometry';
import window from './window';

import type {Callback} from '../types/callback';

Expand Down Expand Up @@ -196,6 +197,27 @@ export function uniqueId(): number {
return id++;
}

/**
* Return a random UUID (v4). Taken from: https://gist.github.com/jed/982883
*/
export function uuid(): string {
function b(a) {
return a ? (a ^ Math.random() * 16 >> a / 4).toString(16) :
//$FlowFixMe: Flow doesn't like the implied array literal conversion here
([1e7] + -[1e3] + -4e3 + -8e3 + -1e11).replace(/[018]/g, b);
}
return b();
}

/**
* Validate a string to match UUID(v4) of the
* form: xxxxxxxx-xxxx-4xxx-[89ab]xxx-xxxxxxxxxxxx
* @param str string to validate.
*/
export function validateUuid(str: ?string): boolean {
return str ? /^[0-9a-f]{8}-[0-9a-f]{4}-[4][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(str) : false;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

return str && /.../i.test(str);

Copy link
Contributor Author

@asheemmamoowala asheemmamoowala Jul 23, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Flow complaints that

 - null or undefined [1] is incompatible with boolean [2].
 - string [3] is incompatible with boolean [2].

}

/**
* Given an array of member function names as strings, replace all of them
* with bound versions that will always refer to `context` as `this`. This
Expand Down Expand Up @@ -440,3 +462,14 @@ export function parseCacheControl(cacheControl: string): Object {

return header;
}

export function storageAvailable(type: string): boolean {
try {
const storage = window[type];
storage.setItem('_mapbox_test_', 1);
storage.removeItem('_mapbox_test_');
return true;
} catch (e) {
return false;
}
}
10 changes: 10 additions & 0 deletions test/ajax_stubs.js
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,16 @@ export const getArrayBuffer = function({ url }, callback) {
});
};

export const postData = function({ url }, payload, callback) {
return request.post(url, payload, (error, response, body) => {
if (!error && response.statusCode >= 200 && response.statusCode < 300) {
callback(null, {data: body});
} else {
callback(error || new Error(response.statusCode));
}
});
};

export const getImage = function({ url }, callback) {
if (cache[url]) return cached(cache[url], callback);
return request({ url, encoding: null }, (error, response, body) => {
Expand Down
14 changes: 13 additions & 1 deletion test/unit/util/ajax.test.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { test } from 'mapbox-gl-js-test';
import {
getArrayBuffer,
getJSON
getJSON,
postData
} from '../../../src/util/ajax';
import window from '../../../src/util/window';

Expand Down Expand Up @@ -97,5 +98,16 @@ test('ajax', (t) => {
window.server.respond();
});

t.test('postData, 204(no content): no error', (t) => {
window.server.respondWith(request => {
request.respond(204);
});
postData({ url:'api.mapbox.com' }, {}, (error) => {
t.equal(error, null);
t.end();
});
window.server.respond();
});

t.end();
});
Loading