UNPKG

ackee-tracker

Version:
438 lines (329 loc) 10.1 kB
import platform from 'platform' const isBrowser = typeof window !== 'undefined' /** * Validates options and sets defaults for undefined properties. * @param {?Object} opts * @returns {Object} opts - Validated options. */ const validate = function(opts = {}) { // Create new object to avoid changes by reference const _opts = {} // Defaults to false _opts.detailed = opts.detailed === true // Defaults to true _opts.ignoreLocalhost = opts.ignoreLocalhost !== false // Defaults to true _opts.ignoreOwnVisits = opts.ignoreOwnVisits !== false return _opts } /** * Determines if a host is a localhost. * @param {String} hostname - Hostname that should be tested. * @returns {Boolean} isLocalhost */ const isLocalhost = function(hostname) { return hostname === '' || hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1' } /** * Determines if user agent is a bot. Approach is to get most bots, assuming other bots don't run JS. * Source: https://stackoverflow.com/questions/20084513/detect-search-crawlers-via-javascript/20084661 * @param {String} userAgent - User agent that should be tested. * @returns {Boolean} isBot */ const isBot = function(userAgent) { return (/bot|crawler|spider|crawling/i).test(userAgent) } /** * Checks if an id is a fake id. This is the case when Ackee ignores you because of the `ackee_ignore` cookie. * @param {String} id - Id that should be tested. * @returns {Boolean} isFakeId */ const isFakeId = function(id) { return id === '88888888-8888-8888-8888-888888888888' } /** * Checks if the website is in background (e.g. user has minimzed or switched tabs). * @returns {boolean} */ const isInBackground = function() { return document.visibilityState === 'hidden' } /** * Get the optional source parameter. * @returns {String} source */ const source = function() { const source = (location.search.split(`source=`)[1] || '').split('&')[0] return source === '' ? undefined : source } /** * Gathers all platform-, screen- and user-related information. * @param {Boolean} detailed - Include personal data. * @returns {Object} attributes - User-related information. */ export const attributes = function(detailed = false) { const defaultData = { siteLocation: window.location.href, siteReferrer: document.referrer, source: source() } const detailedData = { siteLanguage: (navigator.language || navigator.userLanguage).substr(0, 2), screenWidth: screen.width, screenHeight: screen.height, screenColorDepth: screen.colorDepth, deviceName: platform.product, deviceManufacturer: platform.manufacturer, osName: platform.os.family, osVersion: platform.os.version, browserName: platform.name, browserVersion: platform.version, browserWidth: window.outerWidth, browserHeight: window.outerHeight } return { ...defaultData, ...(detailed === true ? detailedData : {}) } } /** * Creates an object with a query and variables property to create a record on the server. * @param {String} domainId - Id of the domain. * @param {Object} input - Data that should be transferred to the server. * @returns {Object} Create record body. */ const createRecordBody = function(domainId, input) { return { query: ` mutation createRecord($domainId: ID!, $input: CreateRecordInput!) { createRecord(domainId: $domainId, input: $input) { payload { id } } } `, variables: { domainId, input } } } /** * Creates an object with a query and variables property to update a record on the server. * @param {String} recordId - Id of the record. * @returns {Object} Update record body. */ const updateRecordBody = function(recordId) { return { query: ` mutation updateRecord($recordId: ID!) { updateRecord(id: $recordId) { success } } `, variables: { recordId } } } /** * Creates an object with a query and variables property to create an action on the server. * @param {String} eventId - Id of the event. * @param {Object} input - Data that should be transferred to the server. * @returns {Object} Create action body. */ const createActionBody = function(eventId, input) { return { query: ` mutation createAction($eventId: ID!, $input: CreateActionInput!) { createAction(eventId: $eventId, input: $input) { payload { id } } } `, variables: { eventId, input } } } /** * Creates an object with a query and variables property to update an action on the server. * @param {String} actionId - Id of the action. * @param {Object} input - Data that should be transferred to the server. * @returns {Object} Update action body. */ const updateActionBody = function(actionId, input) { return { query: ` mutation updateAction($actionId: ID!, $input: UpdateActionInput!) { updateAction(id: $actionId, input: $input) { success } } `, variables: { actionId, input } } } /** * Construct URL to the GraphQL endpoint of Ackee. * @param {String} server - URL of the Ackee server. * @returns {String} endpoint - URL to the GraphQL endpoint of the Ackee server. */ const endpoint = function(server) { const hasTrailingSlash = server.substr(-1) === '/' return server + (hasTrailingSlash === true ? '' : '/') + 'api' } /** * Sends a request to a specified URL. * Won't catch all errors as some are already logged by the browser. * In this case the callback won't fire. * @param {String} url - URL to the GraphQL endpoint of the Ackee server. * @param {Object} body - JSON which will be send to the server. * @param {Object} opts - Options. * @param {?Function} next - The callback that handles the response. Receives the following properties: json. */ const send = function(url, body, opts, next) { const xhr = new XMLHttpRequest() xhr.open('POST', url) xhr.onload = () => { if (xhr.status !== 200) { throw new Error('Server returned with an unhandled status') } let json = null try { json = JSON.parse(xhr.responseText) } catch (e) { throw new Error('Failed to parse response from server') } if (json.errors != null) { throw new Error(json.errors[0].message) } if (typeof next === 'function') { return next(json) } } xhr.setRequestHeader('Content-Type', 'application/json;charset=UTF-8') xhr.withCredentials = opts.ignoreOwnVisits xhr.send(JSON.stringify(body)) } /** * Looks for an element with Ackee attributes and executes Ackee with the given attributes. * Fails silently. */ export const detect = function() { const elem = document.querySelector('[data-ackee-domain-id]') if (elem == null) return const server = elem.getAttribute('data-ackee-server') || '' const domainId = elem.getAttribute('data-ackee-domain-id') const opts = elem.getAttribute('data-ackee-opts') || '{}' create(server, JSON.parse(opts)).record(domainId) } /** * Creates a new instance. * @param {String} server - URL of the Ackee server. * @param {?Object} opts * @returns {Object} instance */ export const create = function(server, opts) { opts = validate(opts) const url = endpoint(server) const noop = () => {} // Fake instance when Ackee ignores you const fakeInstance = { record: () => ({ stop: noop }), updateRecord: () => ({ stop: noop }), action: noop, updateAction: noop } if (opts.ignoreLocalhost === true && isLocalhost(location.hostname) === true) { console.warn('Ackee ignores you because you are on localhost') return fakeInstance } if (isBot(navigator.userAgent) === true) { console.warn('Ackee ignores you because you are a bot') return fakeInstance } // Creates a new record on the server and updates the record // very x seconds to track the duration of the visit. Tries to use // the default attributes when there're no custom attributes defined. const _record = (domainId, attrs = attributes(opts.detailed), next) => { // Function to stop updating the record let isStopped = false const stop = () => { isStopped = true } send(url, createRecordBody(domainId, attrs), opts, (json) => { const recordId = json.data.createRecord.payload.id if (isFakeId(recordId) === true) { return console.warn('Ackee ignores you because this is your own site') } const interval = setInterval(() => { if (isStopped === true) { clearInterval(interval) return } if (isInBackground() === true) return send(url, updateRecordBody(recordId), opts) }, 15000) if (typeof next === 'function') { return next(recordId) } }) return { stop } } // Updates a record very x seconds to track the duration of the visit const _updateRecord = (recordId) => { // Function to stop updating the record let isStopped = false const stop = () => { isStopped = true } if (isFakeId(recordId) === true) { console.warn('Ackee ignores you because this is your own site') return { stop } } const interval = setInterval(() => { if (isStopped === true) { clearInterval(interval) return } if (isInBackground() === true) return send(url, updateRecordBody(recordId), opts) }, 15000) return { stop } } // Creates a new action on the server const _action = (eventId, attrs, next) => { send(url, createActionBody(eventId, attrs), opts, (json) => { const actionId = json.data.createAction.payload.id if (isFakeId(actionId) === true) { return console.warn('Ackee ignores you because this is your own site') } if (typeof next === 'function') { return next(actionId) } }) } // Updates an action const _updateAction = (actionId, attrs) => { if (isFakeId(actionId) === true) { return console.warn('Ackee ignores you because this is your own site') } send(url, updateActionBody(actionId, attrs), opts) } // Return the real instance return { record: _record, updateRecord: _updateRecord, action: _action, updateAction: _updateAction } } // Only run Ackee automatically when executed in a browser environment if (isBrowser === true) { detect() }