diff --git a/analytics.js b/analytics.js index ed778b8..b2c2fd4 100644 --- a/analytics.js +++ b/analytics.js @@ -1,61 +1,65 @@ -import { Platform, Dimensions } from 'react-native'; -import { Constants } from 'expo'; +import { Platform, Dimensions } from "react-native"; +import { Constants } from "expo"; -import { ScreenHit, PageHit, Event, Serializable } from './hits'; +import { ScreenHit, PageHit, Event, Serializable } from "./hits"; +import HitValidator from "./validation"; -const { width, height } = Dimensions.get('window'); +const { width, height } = Dimensions.get("window"); let defaultOptions = { debug: false }; export default class Analytics { - customDimensions = [] - - constructor(propertyId, additionalParameters = {}, options = defaultOptions){ - this.propertyId = propertyId; - this.options = options; - this.clientId = Constants.deviceId; - - this.promiseGetWebViewUserAgentAsync = Constants.getWebViewUserAgentAsync() - .then(userAgent => { - this.userAgent = userAgent; - - this.parameters = { - an: Constants.manifest.name, - aid: Constants.manifest.slug, - av: Constants.manifest.version, - sr: `${width}x${height}`, - ...additionalParameters - }; - - if(this.options.debug){ - console.log(`[expo-analytics] UserAgent=${userAgent}`); - console.log(`[expo-analytics] Additional parameters=`, this.parameters); - } - }); - } - - hit(hit){ - // send only after the user agent is saved - return this.promiseGetWebViewUserAgentAsync - .then(() => this.send(hit)); - } - - event(event){ - // send only after the user agent is saved - return this.promiseGetWebViewUserAgentAsync - .then(() => this.send(event)); - } - - addCustomDimension(index, value){ - this.customDimensions[index] = value; - } - - removeCustomDimension(index){ - delete this.customDimensions[index]; - } - - send(hit) { - /* format: https://www.google-analytics.com/collect? + + customDimensions = []; + + constructor(propertyId, additionalParameters = {}, options = defaultOptions) { + this.propertyId = propertyId; + this.options = options; + this.clientId = Constants.deviceId; + this.hitValidator = new HitValidator({ debug: this.options.debug }); + + this.promiseGetWebViewUserAgentAsync = Constants.getWebViewUserAgentAsync().then( + userAgent => { + this.userAgent = userAgent; + + this.parameters = { + an: Constants.manifest.name, + aid: Constants.manifest.slug, + av: Constants.manifest.version, + sr: `${width}x${height}`, + ...additionalParameters + }; + + if (this.options.debug) { + console.log(`[expo-analytics] UserAgent=${userAgent}`); + console.log( + `[expo-analytics] Additional parameters=`, + this.parameters + ); + } + } + ); + } + + hit(hit) { + // send only after the user agent is saved + return this.promiseGetWebViewUserAgentAsync.then(() => this.send(hit)); + } + + event(event) { + // send only after the user agent is saved + return this.promiseGetWebViewUserAgentAsync.then(() => this.send(event)); + } + + addCustomDimension(index, value) { + this.customDimensions[index] = value; + } + + removeCustomDimension(index) { + delete this.customDimensions[index]; + } + + send(hit) { + /* format: https://www.google-analytics.com/collect? + * &tid= GA property ID (required) * &v= GA protocol version (always 1) (required) * &t= hit type (pageview / screenview) @@ -72,24 +76,34 @@ export default class Analytics { * &z= cache buster (prevent browsers from caching GET requests -- should always be last) */ - const customDimensions = this.customDimensions.map((value, index) => `cd${index}=${value}`).join('&'); - - const params = new Serializable(this.parameters).toQueryString(); - - const url = `https://www.google-analytics.com/collect?tid=${this.propertyId}&v=1&cid=${this.clientId}&${hit.toQueryString()}&${params}&${customDimensions}&z=${Math.round(Math.random() * 1e8)}`; - - let options = { - method: 'get', - headers: { - 'User-Agent': this.userAgent - } - } - - if(this.options.debug){ - console.log(`[expo-analytics] Sending GET request to ${url}`); - } - - return fetch(url, options); - } - + const customDimensions = this.customDimensions + .map((value, index) => `cd${index}=${value}`) + .join("&"); + + const params = new Serializable(this.parameters).toQueryString(); + + const url = `https://www.google-analytics.com/collect?tid=${ + this.propertyId + }&v=1&cid=${ + this.clientId + }&${hit.toQueryString()}&${params}&${customDimensions}&z=${Math.round( + Math.random() * 1e8 + )}`; + + let options = { + method: "get", + headers: { + "User-Agent": this.userAgent + } + }; + + return this.hitValidator.validate({ url }).then(valid => { + if (valid) { + if (this.options.debug) { + console.log(`[expo-analytics] Sending GET request to ${url}`); + } + return fetch(url, options); + } + }); + } } diff --git a/validation.js b/validation.js new file mode 100644 index 0000000..5e51d30 --- /dev/null +++ b/validation.js @@ -0,0 +1,61 @@ +export default class HitValidator { + constructor({ debug }) { + this.debug = debug; + } + + validate = ({ url }) => { + // Do not validate in prod mode + if (!this.debug) return Promise.resolve(true); + + const hostname = this.extractHostname(url); + const queryStr = url.slice(url.indexOf(hostname) + hostname.length); + + return fetch(`https://${hostname}/debug${queryStr}`, { method: "get" }) + .then(result => result.json()) + .then(res => { + if (res.hitParsingResult && Array.isArray(res.hitParsingResult)) { + const validationResult = res.hitParsingResult[0]; + const { valid, parserMessage } = validationResult; + if (valid) return true; + + // INFO message will not prevent hit from being successfully sent + // but WARN will...which is confusing for me...I thought only ERROR + // will cause hit to be not recorded + parserMessage + .filter(m => m.messageType !== "INFO") + .forEach(m => this.outputError(m, url)); + return false; + } + throw new Error(`Unexpected hitParsingResult: ${res.hitParsingResult}`); + }) + .catch(err => { + console.log( + `[expo-analytics] Failed to validate hit request: ${err.message}` + ); + }); + }; + + outputError = (message, url) => { + const { description, messageType } = message; + console.log( + `[expo-analytics] Failed to pass validation for ${url}:\n ${messageType} ${description}` + ); + }; + + extractHostname = url => { + var hostname; + //find & remove protocol (http, ftp, etc.) and get hostname + if (url.indexOf("//") > -1) { + hostname = url.split("/")[2]; + } else { + hostname = url.split("/")[0]; + } + + //find & remove port number + hostname = hostname.split(":")[0]; + //find & remove "?" + hostname = hostname.split("?")[0]; + + return hostname; + }; +}