├── .editorconfig ├── .gitignore ├── .npmignore ├── .travis.yml ├── Gruntfile.js ├── LICENSE ├── README.md ├── dist ├── simple-tracker.min.js └── simple-tracker.min.map ├── examples ├── README.md ├── all-functions.html └── server-examples │ ├── .gitignore │ ├── README.md │ ├── aws-lambda │ ├── google-analytics.js │ └── track.js │ ├── netlify.toml │ └── package.json ├── index.js ├── package.json ├── test └── unit │ └── simple-tracker.js └── version.sh /.editorconfig: -------------------------------------------------------------------------------- 1 | # This file is for unifying the coding style for different editors and IDEs 2 | # editorconfig.org 3 | 4 | root = true 5 | 6 | [*] 7 | indent_style = space 8 | indent_size = 2 9 | end_of_line = lf 10 | charset = utf-8 11 | trim_trailing_whitespace = true 12 | insert_final_newline = true 13 | 14 | [package.json] 15 | indent_style = space 16 | indent_size = 2 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /dist 2 | /node_modules 3 | /bower_components 4 | /test/simple-tracker.js 5 | /.nyc_output 6 | /coverage 7 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | jasminetest 2 | Gruntfile.js 3 | bower.json 4 | .editorconfig 5 | package-lock.json 6 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "node" 4 | after_success: npm run coverage 5 | -------------------------------------------------------------------------------- /Gruntfile.js: -------------------------------------------------------------------------------- 1 | module.exports = function (grunt) { 2 | var packageJson = grunt.file.readJSON('package.json'); 3 | grunt.initConfig({ 4 | pkg: packageJson, 5 | uglify: { 6 | options: { 7 | sourceMap: true, 8 | sourceMapName: 'dist/simple-tracker.min.map', 9 | banner: '/*! <%= pkg.name %> - v<%= pkg.version %> | MIT\n' + 10 | ' * https://github.com/codeniko/simple-tracker */' 11 | }, 12 | main: { 13 | files: [{ 14 | src: 'index.js', 15 | dest: 'dist/simple-tracker.min.js' 16 | }] 17 | }, 18 | } 19 | }); 20 | grunt.loadNpmTasks('grunt-contrib-uglify'); 21 | grunt.registerTask('default', ['uglify']); 22 | }; 23 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Nikolay Feldman 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Simple Tracker 2 | =============== 3 | [![NPM version](https://img.shields.io/npm/v/simple-tracker.svg)](https://npmjs.org/package/simple-tracker) 4 | [![Build Status](https://travis-ci.com/codeniko/simple-tracker.svg?branch=master)](https://travis-ci.com/codeniko/simple-tracker) 5 | [![codecov](https://codecov.io/gh/codeniko/simple-tracker/branch/master/graph/badge.svg)](https://codecov.io/gh/codeniko/simple-tracker) 6 | ![gzip size](http://img.badgesize.io/https://unpkg.com/simple-tracker@latest/dist/simple-tracker.min.js?compression=gzip) 7 | [![dependencies](https://david-dm.org/codeniko/simple-tracker.svg)](https://david-dm.org/codeniko/simple-tracker) 8 | [![License](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/codeniko/simple-tracker/blob/master/LICENSE) 9 | 10 | Easy client-side tracking library to log events, metrics, errors, and messages. Send data to any server endpoint for log management and event tracking services like Google Analytics, Splunk, ELK/Logstash, Loggly, Open Web Analytics, etc... 11 | 12 | You can make use of Simple Tracker one of two ways. You can install through [npm and use it as a module](#installation-through-npm-as-module), or you can [include the uglified script file in your HTML page](#installation-in-html). 13 | 14 | Inspiration 15 | ------------ 16 | If you run an adblocker or a trackerblocker plugin in your browser, page requests to google analytics and other well known tracking libraries get blocked causing you to lose valuable metrics/logs from your websites. To circumvent these blockers, you'd have to write some javascript on your pages to send tracking data to an endpoint that won't be blocked and configure a server or cloud function to proxy this data to a service of your choice. Simple Tracker is the first piece to that solution. It's a light-weight client library that makes sending tracking data simple. 17 | 18 | If you're looking to connect your tracking data sent from Simple Tracker to a cloud function, [check out these example AWS Lambda functions](examples/server-examples) which proxies the data to a free log management service called [Loggly](https://www.loggly.com/). 19 | 20 | 21 | Installation through NPM as module 22 | ------------ 23 | In command line, run: 24 | ```sh 25 | npm install simple-tracker 26 | ``` 27 | In code: 28 | ```javascript 29 | import tracker from 'simple-tracker' // or const tracker = require('simple-tracker') 30 | 31 | // initialize tracker endpoint and settings 32 | tracker.push({ 33 | endpoint: '/endpoint', // Endpoint to send tracking data to 34 | attachClientContext: true, // Attach various client context, such as useragent, platform, and page url 35 | }); 36 | ``` 37 | 38 | You only need to initialize endpoint and settings as above once. After initializing, simply import `tracker` in other modules or components: 39 | ```javascript 40 | import tracker from 'simple-tracker' // or const tracker = require('simple-tracker') 41 | 42 | tracker.push({ event: 'pageview' }) 43 | ``` 44 | Here is a live example page: [https://codeniko.github.io/simple-tracker/examples/all-functions.html](https://codeniko.github.io/simple-tracker/examples/all-functions.html) 45 | 46 | 47 | Installation in HTML 48 | ------------ 49 | Place the following on your page. While you can use the script at the [CDN link](https://unpkg.com/simple-tracker@latest/dist/simple-tracker.min.js) below, I recommend you to download the script and host it yourself. 50 | ```html 51 | 52 | 59 | ``` 60 | 61 | Here is a live example page: [https://codeniko.github.io/simple-tracker/examples/all-functions.html](https://codeniko.github.io/simple-tracker/examples/all-functions.html) 62 | 63 | Quick Usage 64 | ----- 65 | Logging text: 66 | ```javascript 67 | tracker.push('some text to track'); 68 | ``` 69 | 70 | Logging JSON: 71 | ```javascript 72 | tracker.push({ 73 | message: 'my tracking string', 74 | values: [1, 2, 3, 'a', 'b', 'c'] 75 | }); 76 | ``` 77 | 78 | This will send a POST request containing a sessionId, and client context if enabled (enabled by default). An example request payload: 79 | ```json 80 | { 81 | "sessionId": "11bf5b37-e0b8-42e0-8dcf-dc8c4aefc000", 82 | "message": "my tracking string", 83 | "values": [1, 2, 3, "a", "b", "c"], 84 | "context": { 85 | "url": "https://nfeld.com/", 86 | "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/65.0.3325.181 Safari/537.36", 87 | "platform": "MacIntel" 88 | } 89 | } 90 | ``` 91 | 92 | There are also several convenience functions defined to push common tracking data such as `tracker.logEvent(event)`, `tracker.logMessage('message')`, and `tracker.logMetric('metric', 'value')`. [You can find examples of these and more below.](#tracker-functions) 93 | 94 | Session Id 95 | ----- 96 | Simple Tracker makes use of cookies to persist the sessionId that accompanies all tracking data. If the sessionId is not explicitly set in [configuration](#all-configurations), one will be generated as a UUIDv4 string. Regardless if explicitly set or generated, the sessionId will be stored in a cookie named `trcksesh` and will be destroyed when session ends (browser closes) 97 | 98 | All configurations 99 | ----- 100 | ```javascript 101 | tracker.push({ 102 | endpoint: '/endpoint', // Endpoint to send tracking data to 103 | sessionId: 'SESSION_ID', // Explicitly set a session id 104 | sendCaughtExceptions: true/false, // Send exceptions caught by browser. DEFAULT: false 105 | attachClientContext: true/false, // Attach client context to requests. Includes: useragent, platform, and page url. DEFAULT: true 106 | attachSessionId: true/false, // Attach sessionId to requests. DEFAULT: true 107 | devMode: true/false // Toggle dev mode. If enabled, outgoing requests are blocked and logged for debugging instead. DEFAULT: false 108 | }); 109 | ``` 110 | 111 | Adding to client context object 112 | ----- 113 | You can add your own values to persist inside of the client context object, or even overwrite the object entirely. You can access the object with `tracker.clientContext`. Any values you add to the clientContext object will go out on every tracking request 114 | ```javascript 115 | // assign more values 116 | tracker.clientContext.username = 'codeniko', 117 | tracker.clientContext.location = 'San Francisco, CA' 118 | 119 | // overwriting context entirely 120 | tracker.clientContext = { 121 | username = 'codeniko', 122 | location = 'San Francisco, CA' 123 | } 124 | ``` 125 | 126 | Tracker functions 127 | ----- 128 | Here is a live example page showing all of the convenience functions: 129 | [https://codeniko.github.io/simple-tracker/examples/all-functions.html](https://codeniko.github.io/simple-tracker/examples/all-functions.html) 130 | 131 | `logEvent(event, additionalParams)`: Log an event that occurred, with optional additionalParams 132 | ```javascript 133 | tracker.logEvent('contact_form_submitted', { name: 'niko', fromEmail: 'niko@nfeld.com' }); 134 | 135 | // Request: POST /endpoint 136 | { 137 | "type": "event", 138 | "event": "contact_form_submitted", 139 | "sessionId": "11bf5b37-e0b8-42e0-8dcf-dc8c4aefc000", 140 | "name": "niko", 141 | "fromEmail": "niko@nfeld.com" 142 | } 143 | ``` 144 | 145 | `logMessage(message, optionalLevel)`: Log simple message, with optional level as second argument 146 | ```javascript 147 | tracker.logMessage('some message', 'info'); 148 | 149 | // Request: POST /endpoint 150 | { 151 | "type": "message", 152 | "message": "some message", 153 | "level": "info", 154 | "sessionId": "11bf5b37-e0b8-42e0-8dcf-dc8c4aefc000" 155 | } 156 | ``` 157 | 158 | `logException(exceptionObject)`: Log exceptional error. Can pass an exception object, or other value 159 | ```javascript 160 | tracker.logException(new Error('some exception').stack); 161 | 162 | // Request: POST /endpoint 163 | { 164 | "type": "exception", 165 | "level": "error", 166 | "exception": "Error: some exception at :1:1", 167 | "sessionId": "11bf5b37-e0b8-42e0-8dcf-dc8c4aefc000" 168 | } 169 | ``` 170 | 171 | `logMetric(metricKey, metricValue)`: Log a metric and its value 172 | ```javascript 173 | tracker.logMetric('page_load_time', 254); 174 | 175 | // Request: POST /endpoint 176 | { 177 | "type": "metric", 178 | "metric": "page_load_time", 179 | "value": 254, 180 | "sessionId": "11bf5b37-e0b8-42e0-8dcf-dc8c4aefc000" 181 | } 182 | ``` 183 | 184 | Start/stop a timer to record a metric 185 | `startTimer(metricKey)`: Start a timer named by metricKey 186 | `stopTimer(metricKey, metricValue)`: Stop timer named metricKey and log result in millis as metric value 187 | ```javascript 188 | tracker.startTimer('page_load_time'); 189 | // wait 1 second 190 | tracker.stopTimer('page_load_time'); 191 | 192 | // Request: POST /endpoint 193 | { 194 | "type": "metric", 195 | "metric": "page_load_time", 196 | "value": 1000, 197 | "sessionId": "11bf5b37-e0b8-42e0-8dcf-dc8c4aefc000" 198 | } 199 | ``` 200 | 201 | `push(dataObject)`: Push any data of your choice 202 | ```javascript 203 | tracker.push({ 204 | message: 'my tracking string', 205 | values: [1, 2, 3, 'a', 'b', 'c'], 206 | userMap: { 207 | codeniko: 1234, 208 | chance: 8888 209 | } 210 | }); 211 | 212 | // Request: POST /endpoint 213 | { 214 | "message": "my tracking string", 215 | "values": [1, 2, 3, "a", "b", "c"], 216 | "userMap": { 217 | "codeniko": 1234, 218 | "chance": 8888 219 | }, 220 | "sessionId": "11bf5b37-e0b8-42e0-8dcf-dc8c4aefc000" 221 | } 222 | ``` 223 | 224 | Examples out in production 225 | ----- 226 | You can find Simple Tracker used on the following websites. For some fun, ensure your adblocker is enabled, open up devtool console, refresh/navigate the pages and observe network requests to `/ga` for google analytics pageviews and `/track` for log messages. 227 | [https://jessicalchang.com](https://jessicalchang.com) 228 | [https://nfeld.com](https://nfeld.com) 229 | 230 | 231 | Bugs, feature requests, & contributing 232 | ----- 233 | If you found a bug or want to request a feature, [create a new issue](https://github.com/codeniko/simple-tracker/issues). Contributions via pull requests are more than welcome :) 234 | 235 | Running unit tests and code coverage 236 | ---------- 237 | ```sh 238 | npm test 239 | ``` 240 | -------------------------------------------------------------------------------- /dist/simple-tracker.min.js: -------------------------------------------------------------------------------- 1 | /*! simple-tracker - v1.2.3 | MIT 2 | * https://github.com/codeniko/simple-tracker */ 3 | !function(){"use strict";function e(r){var i,s,a,o="trcksesh",c=o.length+1,l=r.document,d=!0,u=!0,p=!1,f={};function n(e){return e?(e^16*Math.random()>>e/4).toString(16):([1e7]+-1e3+-4e3+-8e3+-1e11).replace(/[018]/g,n)}function h(e){var t;s=e||s||function(){var e=l.cookie,t=e.indexOf(o);if(0<=t){var n=e.indexOf(";",t+1);return n=n<0?e.length:n,e.slice(t+c,n)}}()||n(),t=s,l.cookie=o+"="+t}function e(){this.clientContext={url:r.location.href,userAgent:r.navigator.userAgent||null,platform:r.navigator.platform||null}}e.prototype={onerror:function(e,t,n,o,i){var r={message:e,lineno:n,colno:o,stack:i?i.stack:"n/a"};this.logException(r)},logEvent:function(e,t){var n={type:"event",event:e};if("object"==typeof t)for(var o in t)t.hasOwnProperty(o)&&(n[o]=t[o]);this.push(n)},logException:function(e){this.push({level:"error",type:"exception",exception:e})},logMessage:function(e,t){var n={type:"message",message:e};t&&(n.level=t),this.push(n)},logMetric:function(e,t){this.push({type:"metric",metric:e,value:t})},startTimer:function(e){var t=r.performance;t.now&&(f[e]&&p&&console.warn("Timing metric '"+e+"' already started"),p&&console.debug("timer started for:",e),f[e]=t.now())},stopTimer:function(e){var t=r.performance;if(t.now){var n=t.now(),o=f[e];if(void 0!==o){var i=Math.round(n-o);f[e]=void 0,p&&console.debug("timer stopped for:",e,"time="+i),this.logMetric(e,i)}else p&&console.warn("Timing metric '"+e+"' wasn't started")}},push:function(e){var t,n,o=typeof e;"object"!==o&&"string"!==o||("string"===o?e={text:e}:(void 0!==e.devMode&&(p=!!e.devMode,delete e.devMode),void 0!==e.attachClientContext&&(d=!!e.attachClientContext,delete e.attachClientContext),void 0!==e.attachSessionId&&(u=!!e.attachSessionId,delete e.attachSessionId),e.sessionId&&(h(e.sessionId),delete e.sessionId),e.endpoint&&(s||h(),i=e.endpoint,delete e.endpoint),void 0!==e.sendCaughtExceptions&&(!!e.sendCaughtExceptions&&(t=this.onerror,n=r.onerror,r.onerror=function(){t.apply(a,arguments),"function"==typeof n&&n.apply(r,arguments)}),delete e.sendCaughtExceptions)),function(e){if(i&&0 2 | 3 | 4 | Simple-Tracker example 5 | 6 | 13 | 14 | 15 |

Simple-Tracker example

16 | https://github.com/codeniko/simple-tracker

17 | 18 | Open up the console through devTools to see the tracker in action. Refresh the page to restart 19 | 20 | 21 | 22 | 23 | 87 | 88 | 89 | -------------------------------------------------------------------------------- /examples/server-examples/.gitignore: -------------------------------------------------------------------------------- 1 | # dependencies 2 | /node_modules 3 | 4 | # production 5 | /build 6 | 7 | # misc 8 | .DS_Store 9 | 10 | npm-debug.log* 11 | -------------------------------------------------------------------------------- /examples/server-examples/README.md: -------------------------------------------------------------------------------- 1 | Server examples using AWS Lambda 2 | =============== 3 | 4 | Here you'll find a project that includes two AWS Lambda functions. One is used to proxy pageviews to google analytics incase google analytics is blocked on the client side. Another is used to proxy arbitrary tracking data to a log management service I found called [Loggly](https://www.loggly.com/). These are fully functional AWS Lambda examples and I currently use these for my own websites. 5 | 6 | Project details 7 | --------- 8 | This project uses a service called [Netlify](https://www.netlify.com/) to continously deploy the AWS Lambda functions. Underlying, Netlify uses AWS Lambda and you can use the example lambda function code interchangeably between Netlify and AWS. What's nice about Netlify is it's easier to setup than AWS and builds/deploys your lambda functions as you make commits and push to your repository. 9 | 10 | 11 | Proxying to Google Analytics 12 | --------- 13 | This was the main reason I created [simple-tracker](https://github.com/codeniko/simple-tracker). If a user has an adblocker, google analytics is usually blocked and you lose metrics, including pageviews. This means that the data google analytics tool shows to you is underreported. You're getting more traffic than you think you do because [~40% of users use an adblocker](https://marketingland.com/survey-shows-us-ad-blocking-usage-40-percent-laptops-15-percent-mobile-216324), including myself. 14 | 15 | The example lambda function is [aws-lambda/google-analytics.js](aws-lambda/google-analytics.js). Edit the function and add your own domains the whitelist for CORS (needed for POST requests depending on how you choose to host these functions.) 16 | 17 | 18 | Proxying to a log management service (Loggly in this case) 19 | --------- 20 | Log management is useful to debug issues in production. During development, everything may look fine to you, but your page is probably exploding somewhere out in the world if you never had logs. There are a multitude amount of various browsers across different platforms and browser versions with varying support for features that make development for all of them difficult. You will write webpages that can have bugs for some browsers, but hopefully you'll catch them. You'll also be surprised when you see how many people are still using old and unsupported browsers/versions that may not support some features you're dependent on. 21 | 22 | The example lambda function for this is [aws-lambda/track.js](aws-lambda/track.js). In it, you'll see I'm proxying to a free log management service called [Loggly](https://www.loggly.com). Loggly is similar to splunk, but you may choose to change this to whatever service you want. Basically, use simple-tracker to send any and all useful data to this proxy like pageviews, events, errors, and other messages to help you debug things in production. Sign up at Loggly, and edit the function to put in your Loggly Key. Don't forget to whitelist your domains in there for CORS. 23 | 24 | 25 | Development setup 26 | --------- 27 | If you want to modify these examples, you can test your code locally. 28 | 29 | First install the dependencies and then run them 30 | ```shell 31 | npm install 32 | npm run serve 33 | ``` 34 | 35 | This will serve your lambda functions. You can find each at 36 | `http://localhost:9000/google-analytics` 37 | `http://localhost:9000/track` 38 | 39 | Use simple-tracker to point to either of those endpoints and test away. Depending on where you send your requests from, you may need to modify the lambda functions to allow all cross origin request using`'*'`. You'll see a comment in the code for this. 40 | 41 | Assuming you continue to use netlify, your endpoint paths in production will be `/.netlify/functions/track` and `/.netlify/functions/google-analytics` 42 | -------------------------------------------------------------------------------- /examples/server-examples/aws-lambda/google-analytics.js: -------------------------------------------------------------------------------- 1 | const request = require('request') 2 | const querystring = require('querystring') 3 | const uuidv4 = require('uuid/v4') 4 | 5 | const GA_ENDPOINT = `https://www.google-analytics.com/collect` 6 | 7 | // Domains to whitelist. Replace with your own! 8 | const originWhitelist = [] // keep this empty and append domains to whitelist using whiteListDomain() 9 | whitelistDomain('test.com') 10 | whitelistDomain('nfeld.com') 11 | 12 | 13 | function whitelistDomain(domain, addWww = true) { 14 | const prefixes = [ 15 | 'https://', 16 | 'http://', 17 | ] 18 | if (addWww) { 19 | prefixes.push('https://www.') 20 | prefixes.push('http://www.') 21 | } 22 | prefixes.forEach(prefix => originWhitelist.push(prefix + domain)) 23 | } 24 | 25 | 26 | function proxyToGoogleAnalytics(event, done) { 27 | // get GA params whether GET or POST request 28 | const params = event.httpMethod.toUpperCase() === 'GET' ? event.queryStringParameters : JSON.parse(event.body) 29 | const headers = event.headers || {} 30 | 31 | // attach other GA params, required for IP address since client doesn't have access to it. UA and CID can be sent from client 32 | params.uip = headers['x-forwarded-for'] || headers['x-bb-ip'] || '' // ip override. Look into headers for clients IP address, as opposed to IP address of host running lambda function 33 | params.ua = params.ua || headers['user-agent'] || '' // user agent override 34 | params.cid = params.cid || uuidv4() // REQUIRED: use given cid, or generate a new one as last resort. Generating should be avoided because one user can show up in GA multiple times. If user refresh page `n` times, you'll get `n` pageviews logged into GA from "different" users. Client should generate a uuid and store in cookies, local storage, or generate a fingerprint. Check simple-tracker client example 35 | 36 | console.info('proxying params:', params) 37 | const qs = querystring.stringify(params) 38 | 39 | const reqOptions = { 40 | method: 'POST', 41 | headers: { 42 | 'Content-Type': 'image/gif', 43 | }, 44 | url: GA_ENDPOINT, 45 | body: qs, 46 | } 47 | 48 | request(reqOptions, (error, result) => { 49 | if (error) { 50 | console.info('googleanalytics error!', error) 51 | } else { 52 | console.info('googleanalytics status code', result.statusCode, result.statusMessage) 53 | } 54 | }) 55 | 56 | done() 57 | } 58 | 59 | exports.handler = function(event, context, callback) { 60 | const origin = event.headers['origin'] || event.headers['Origin'] || '' 61 | console.log(`Received ${event.httpMethod} request from, origin: ${origin}`) 62 | 63 | const isOriginWhitelisted = originWhitelist.indexOf(origin) >= 0 64 | console.info('is whitelisted?', isOriginWhitelisted) 65 | 66 | const headers = { 67 | //'Access-Control-Allow-Origin': '*', // allow all domains to POST. Use for localhost development only 68 | 'Access-Control-Allow-Origin': isOriginWhitelisted ? origin : originWhitelist[0], 69 | 'Access-Control-Allow-Methods': 'GET,POST,OPTIONS', 70 | 'Access-Control-Allow-Headers': 'Content-Type,Accept', 71 | } 72 | 73 | const done = () => { 74 | callback(null, { 75 | statusCode: 200, 76 | headers, 77 | body: '', 78 | }) 79 | } 80 | 81 | const httpMethod = event.httpMethod.toUpperCase() 82 | 83 | if (event.httpMethod === 'OPTIONS') { // CORS (required if you use a different subdomain to host this function, or a different domain entirely) 84 | done() 85 | } else if ((httpMethod === 'GET' || httpMethod === 'POST') && isOriginWhitelisted) { // allow GET or POST, but only for whitelisted domains 86 | proxyToGoogleAnalytics(event, done) 87 | } else { 88 | callback('Not found') 89 | } 90 | } 91 | 92 | /* 93 | Docs on GA endpoint and example params 94 | 95 | https://developers.google.com/analytics/devguides/collection/protocol/v1/devguide 96 | 97 | v: 1 98 | _v: j67 99 | a: 751874410 100 | t: pageview 101 | _s: 1 102 | dl: https://nfeld.com/contact.html 103 | dr: https://google.com 104 | ul: en-us 105 | de: UTF-8 106 | dt: Nikolay Feldman - Software Engineer 107 | sd: 24-bit 108 | sr: 1440x900 109 | vp: 945x777 110 | je: 0 111 | _u: blabla~ 112 | jid: 113 | gjid: 114 | cid: 1837873423.1522911810 115 | tid: UA-116530991-1 116 | _gid: 1828045325.1524815793 117 | gtm: u4d 118 | z: 1379041260 119 | */ 120 | -------------------------------------------------------------------------------- /examples/server-examples/aws-lambda/track.js: -------------------------------------------------------------------------------- 1 | const request = require('request') 2 | 3 | const LOGGLY_KEY = '' // replace with your loggly key 4 | const TAG = 'simple-track' // can replace with a tag of your choice 5 | const ENDPOINT = `http://logs-01.loggly.com/inputs/${LOGGLY_KEY}/tag/${TAG}/` 6 | 7 | // Domains to whitelist. Replace with your own! 8 | const originWhitelist = [] // keep this empty and append domains to whitelist using whiteListDomain() 9 | whitelistDomain('test.com') 10 | whitelistDomain('nfeld.com') 11 | 12 | 13 | function whitelistDomain(domain, addWww = true) { 14 | const prefixes = [ 15 | 'https://', 16 | 'http://', 17 | ] 18 | if (addWww) { 19 | prefixes.push('https://www.') 20 | prefixes.push('http://www.') 21 | } 22 | prefixes.forEach(prefix => originWhitelist.push(prefix + domain)) 23 | } 24 | 25 | 26 | function track(event, done) { 27 | // event.queryStringParameters for querystring params already parsed into object 28 | const trackerData = JSON.parse(event.body) // data from simple-tracker request 29 | const headers = event.headers || {} 30 | const ip = headers['x-forwarded-for'] || headers['x-bb-ip'] || '' // ip address of user incase you want to append to trackerData. I do it below. 31 | 32 | console.info('tracker payload:', event.body) 33 | console.info('ip:', ip) 34 | 35 | // attach ip to context 36 | if (trackerData && trackerData.context) { 37 | trackerData.context.ip = ip 38 | } 39 | 40 | const reqOptions = { 41 | method: 'POST', 42 | headers: { 43 | 'Content-Type': 'application/json', 44 | }, 45 | json: true, 46 | url: ENDPOINT, 47 | body: trackerData, 48 | } 49 | 50 | request(reqOptions, (error, result) => { 51 | if (error) { 52 | console.info('loggly error!', error) 53 | } else { 54 | console.info('result from loggly:', result.statusCode, result.statusMessage) 55 | } 56 | }) 57 | 58 | done() 59 | } 60 | 61 | 62 | exports.handler = function(event, context, callback) { 63 | const origin = event.headers['origin'] || event.headers['Origin'] || '' 64 | console.log(`Received ${event.httpMethod} request from, origin: ${origin}`) 65 | 66 | const isOriginWhitelisted = originWhitelist.indexOf(origin) >= 0 67 | console.info('is whitelisted?', isOriginWhitelisted) 68 | 69 | const headers = { 70 | //'Access-Control-Allow-Origin': '*', // allow all domains to POST. Use for localhost development only 71 | 'Access-Control-Allow-Origin': isOriginWhitelisted ? origin : originWhitelist[0], 72 | 'Access-Control-Allow-Methods': 'POST,OPTIONS', 73 | 'Access-Control-Allow-Headers': 'Content-Type,Accept', 74 | } 75 | 76 | const done = () => { 77 | callback(null, { 78 | statusCode: 200, 79 | headers, 80 | body: '', 81 | }) 82 | } 83 | 84 | if (event.httpMethod === 'OPTIONS') { // CORS (required if you use a different subdomain to host this function, or a different domain entirely) 85 | done() 86 | } else if (event.httpMethod !== 'POST' || !isOriginWhitelisted) { // allow POST request from whitelisted domains 87 | callback('Not found') 88 | } else { 89 | track(event, done) 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /examples/server-examples/netlify.toml: -------------------------------------------------------------------------------- 1 | [build] 2 | Command = "npm run build" 3 | Functions = "build" 4 | Publish = "build" 5 | -------------------------------------------------------------------------------- /examples/server-examples/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "google-analytics-aws-proxy", 3 | "version": "0.1.0", 4 | "author": "Nikolay Feldman", 5 | "repository": { 6 | "type": "git", 7 | "url": "git@github.com:codeniko/simple-tracker.git" 8 | }, 9 | "private": true, 10 | "scripts": { 11 | "build": "netlify-lambda build aws-lambda", 12 | "serve": "netlify-lambda serve aws-lambda", 13 | "clean": "rm -rf ./npm-debug.log ./build" 14 | }, 15 | "dependencies": { 16 | "querystring": "^0.2.0", 17 | "request": "^2.85.0", 18 | "uuid": "^3.2.1" 19 | }, 20 | "devDependencies": { 21 | "@babel/core": "^7.0.0-beta.44", 22 | "netlify-lambda": "^1.0.0-babel-7-beta" 23 | }, 24 | "proxy": { 25 | "/.netlify/functions": { 26 | "target": "http://localhost:9000", 27 | "pathRewrite": { 28 | "^/\\.netlify/functions": "" 29 | } 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | /*! simple-tracker | MIT * 2 | * https://github.com/codeniko/simple-tracker !*/ 3 | 4 | (function() { 5 | 'use strict' 6 | 7 | function simpleTracker(window) { 8 | var SESSION_KEY = 'trcksesh' 9 | var SESSION_KEY_LENGTH = SESSION_KEY.length + 1 10 | 11 | var document = window.document 12 | var sendCaughtExceptions = false 13 | var attachClientContext = true 14 | var attachSessionId = true 15 | var devMode = false 16 | var endpoint 17 | var sessionId 18 | var tracker 19 | var timer = {} 20 | 21 | function uuidv4(a) { 22 | return a?(a^Math.random()*16>>a/4).toString(16):([1e7]+-1e3+-4e3+-8e3+-1e11).replace(/[018]/g,uuidv4) 23 | } 24 | 25 | // override to call own onerror function, followed by original onerror 26 | function setOnError(f) { 27 | var _onerror = window.onerror 28 | // msg, url, line, col, err 29 | window.onerror = function() { 30 | f.apply(tracker, arguments) 31 | if (typeof _onerror === 'function') { 32 | _onerror.apply(window, arguments) 33 | } 34 | } 35 | } 36 | 37 | function getClientContext() { 38 | return { 39 | url: window.location.href, 40 | userAgent: window.navigator.userAgent || null, 41 | platform: window.navigator.platform || null 42 | } 43 | } 44 | 45 | function readCookie() { 46 | var cookie = document.cookie 47 | var i = cookie.indexOf(SESSION_KEY) 48 | if (i >= 0) { 49 | var end = cookie.indexOf(';', i + 1) 50 | end = end < 0 ? cookie.length : end 51 | return cookie.slice(i + SESSION_KEY_LENGTH, end) 52 | } 53 | } 54 | 55 | function setCookie(value) { 56 | document.cookie = SESSION_KEY + '=' + value 57 | } 58 | 59 | function setSession(newSessionId) { 60 | sessionId = newSessionId || sessionId || readCookie() || uuidv4() 61 | setCookie(sessionId) 62 | } 63 | 64 | function track(data) { 65 | if (endpoint && Object.keys(data).length > 0) { 66 | if (attachSessionId) { 67 | data.sessionId = sessionId 68 | } 69 | if (attachClientContext) { 70 | data.context = tracker.clientContext 71 | } 72 | 73 | if (!devMode) { 74 | try { 75 | // let's not use fetch to avoid a polyfill 76 | var xmlHttp = new window.XMLHttpRequest() 77 | xmlHttp.open('POST', endpoint, true) // true for async 78 | xmlHttp.setRequestHeader('Content-Type', 'application/json') 79 | xmlHttp.send(JSON.stringify(data)) 80 | } catch(ex) { } 81 | } else { 82 | console.debug('SimpleTracker: POST ' + endpoint, data) 83 | } 84 | } 85 | } 86 | 87 | function SimpleTracker() { 88 | this.clientContext = getClientContext() 89 | } 90 | 91 | SimpleTracker.prototype = { 92 | 93 | // accessible to those have this tracker object so they can create their own onerror functions and still able to invoke default onerror behavior for our tracker. 94 | onerror: function(msg, url, line, col, err) { 95 | var exception = { 96 | message: msg, 97 | lineno: line, 98 | colno: col, 99 | stack: err ? err.stack : 'n/a' 100 | } 101 | 102 | this.logException(exception) 103 | }, 104 | 105 | logEvent: function(event, additionalParams) { 106 | var data = { 107 | type: 'event', 108 | event: event 109 | } 110 | 111 | // if additional params defined, copy them over 112 | if (typeof additionalParams === 'object') { 113 | for (var prop in additionalParams) { 114 | if (additionalParams.hasOwnProperty(prop)) { 115 | data[prop] = additionalParams[prop] 116 | } 117 | } 118 | } 119 | 120 | this.push(data) 121 | }, 122 | 123 | logException: function(exception) { 124 | this.push({ 125 | level: 'error', 126 | type: 'exception', 127 | exception: exception 128 | }) 129 | }, 130 | 131 | logMessage: function(message, level) { 132 | var data = { 133 | type: 'message', 134 | message: message 135 | } 136 | 137 | // add optional level if defined, not included otherwise 138 | if (level) { 139 | data.level = level 140 | } 141 | 142 | this.push(data) 143 | }, 144 | 145 | logMetric: function(metric, value) { 146 | this.push({ 147 | type: 'metric', 148 | metric: metric, 149 | value: value 150 | }) 151 | }, 152 | 153 | startTimer: function(metric) { 154 | var performance = window.performance 155 | if (performance.now) { 156 | /* istanbul ignore if */ 157 | if (timer[metric] && devMode) { 158 | console.warn("Timing metric '" + metric + "' already started") 159 | } 160 | devMode && console.debug('timer started for:', metric) 161 | timer[metric] = performance.now() 162 | } 163 | }, 164 | 165 | stopTimer: function(metric) { 166 | var performance = window.performance 167 | if (performance.now) { 168 | var stopTime = performance.now() 169 | var startTime = timer[metric] 170 | /* istanbul ignore else */ 171 | if (startTime !== undefined) { 172 | var diff = Math.round(stopTime - startTime) 173 | timer[metric] = undefined 174 | devMode && console.debug('timer stopped for:', metric, 'time=' + diff) 175 | this.logMetric(metric, diff) 176 | } else { 177 | devMode && console.warn("Timing metric '" + metric + "' wasn't started") 178 | } 179 | } 180 | }, 181 | 182 | push: function(data) { 183 | var type = typeof data 184 | 185 | if (type !== 'object' && type !== 'string') { 186 | return 187 | } 188 | 189 | if (type === 'string') { 190 | data = { 191 | text: data 192 | } 193 | } else { 194 | // toggle devmode, where requests wont be sent, but logged in console for debugging instead 195 | if (data.devMode !== undefined) { 196 | devMode = !!data.devMode 197 | delete data.devMode 198 | } 199 | 200 | if (data.attachClientContext !== undefined) { 201 | attachClientContext = !!data.attachClientContext 202 | delete data.attachClientContext 203 | } 204 | 205 | if (data.attachSessionId !== undefined) { 206 | attachSessionId = !!data.attachSessionId 207 | delete data.attachSessionId 208 | } 209 | 210 | if (data.sessionId) { 211 | setSession(data.sessionId) 212 | delete data.sessionId 213 | } 214 | 215 | if (data.endpoint) { 216 | // other initializations should go here since endpoint is the only required property that needs to be set 217 | if (!sessionId) { 218 | setSession() 219 | } 220 | endpoint = data.endpoint 221 | delete data.endpoint 222 | } 223 | 224 | if (data.sendCaughtExceptions !== undefined) { 225 | sendCaughtExceptions = !!data.sendCaughtExceptions 226 | if (sendCaughtExceptions) { 227 | setOnError(this.onerror) 228 | } 229 | delete data.sendCaughtExceptions 230 | } 231 | } 232 | 233 | track(data) 234 | } 235 | } 236 | 237 | var existingTracker = window.tracker // either instance of SimpleTracker or existing array of events to log that were added while this script was being loaded asyncronously 238 | 239 | if (existingTracker && existingTracker.length) { 240 | // move all from existing and push into our tracker object 241 | tracker = new SimpleTracker() 242 | var i = 0 243 | var length = existingTracker.length 244 | for (i = 0; i < length; i++) { 245 | tracker.push(existingTracker[i]) 246 | } 247 | } else { 248 | tracker = new SimpleTracker() 249 | } 250 | 251 | window.tracker = tracker 252 | window.SimpleTracker = SimpleTracker 253 | return tracker 254 | } 255 | 256 | var isModule = typeof module !== 'undefined' && module.exports 257 | /* istanbul ignore else */ 258 | if (typeof window !== 'undefined') { 259 | var tracker = simpleTracker(window) // sets window.tracker 260 | if (isModule) { 261 | simpleTracker.default = tracker 262 | module.exports = tracker 263 | } 264 | } else if (isModule) { 265 | // for testing 266 | simpleTracker.default = simpleTracker 267 | module.exports = simpleTracker 268 | } else { 269 | throw new Error('') 270 | } 271 | })() 272 | 273 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "simple-tracker", 3 | "version": "1.2.3", 4 | "description": "Easy client-side tracking library to log events, metrics, errors, and messages", 5 | "main": "dist/simple-tracker.min.js", 6 | "repository": { 7 | "type": "git", 8 | "url": "git://github.com/codeniko/simple-tracker.git" 9 | }, 10 | "scripts": { 11 | "build": "grunt", 12 | "unit-test": "ENV=tests ./node_modules/.bin/_mocha -R spec ./test/unit/**/*.js ./test/unit/**/**/*.js", 13 | "test": "nyc --reporter=html --reporter=text npm run unit-test", 14 | "preversion": "npm test", 15 | "coverage": "nyc report --reporter=text-lcov > coverage.lcov && codecov" 16 | }, 17 | "author": "Nikolay Feldman", 18 | "keywords": [ 19 | "simple tracker", 20 | "tracker", 21 | "track", 22 | "instrumentation", 23 | "i13n", 24 | "analytics", 25 | "log management", 26 | "logger", 27 | "jslogger", 28 | "log" 29 | ], 30 | "license": "MIT", 31 | "devDependencies": { 32 | "bower": "^1.8.0", 33 | "chai": "^4.1.2", 34 | "codecov": "^3.0.1", 35 | "grunt": "^1.0.1", 36 | "grunt-contrib-uglify": "^3.0.1", 37 | "mocha": "^5.1.1", 38 | "mocha-lcov-reporter": "^1.3.0", 39 | "nyc": "^11.7.1", 40 | "rewire": "^4.0.0", 41 | "sinon": "^4.5.0" 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /test/unit/simple-tracker.js: -------------------------------------------------------------------------------- 1 | /* global describe, it, beforeEach */ 2 | 3 | const assert = require('chai').assert 4 | const sinon = require('sinon') 5 | const rewire = require('rewire') 6 | 7 | const simpleTracker = require('../../index.js') 8 | 9 | function silenceConsoleLogs() { 10 | // dont silence console.log as that will hide test results 11 | console.info = () => {} 12 | console.error = () => {} 13 | console.warn = () => {} 14 | console.debug = () => {} 15 | } 16 | 17 | describe('simple-tracker', function() { 18 | silenceConsoleLogs() 19 | 20 | let window, document, tracker, mockRequest 21 | const mockEndpoint = '**ENDPOINT**' 22 | const mockSessionId = '**SESSION_ID**' 23 | const mockData1 = '**DATA1**' 24 | const mockData2 = '**DATA2**' 25 | const mockHref = '**HREF**' 26 | const mockUserAgent = '**USER_AGENT**' 27 | const mockPlatform = '**PLATFORM**' 28 | const mockCookieValue = 'MOCK_COOKIE' 29 | const cookieKey = 'trcksesh' 30 | const mockCookies = `${cookieKey}=${mockCookieValue}` 31 | 32 | // mock XMLHttpRequest. Its prototype functions will be stubbed in reset() with createStubInstance 33 | function MockXMLHttpRequest() {} 34 | MockXMLHttpRequest.prototype = { 35 | open: function() {}, 36 | setRequestHeader: function() {}, 37 | send: function() {}, 38 | } 39 | 40 | let onerrorSpy 41 | 42 | function reset() { 43 | onerrorSpy = sinon.spy() 44 | document = { 45 | cookie: '', 46 | } 47 | window = { 48 | document, 49 | XMLHttpRequest: MockXMLHttpRequest, 50 | location: { 51 | href: mockHref, 52 | }, 53 | navigator: { 54 | userAgent: mockUserAgent, 55 | platform: mockPlatform, 56 | }, 57 | onerror: onerrorSpy, 58 | performance: { 59 | now: sinon.stub(), 60 | } 61 | } 62 | simpleTracker(window) // sets tracker to window object 63 | mockRequest = sinon.createStubInstance(MockXMLHttpRequest) 64 | sinon.stub(window, 'XMLHttpRequest').returns(mockRequest) 65 | tracker = window.tracker 66 | } 67 | 68 | function assertSentRequest(expectedEndpoint, expectedData, requestIndex) { 69 | if (mockRequest.open.callCount === 0) { 70 | assert(false, 'No outgoing request made') 71 | return 72 | } 73 | 74 | const lastRequestIndex = mockRequest.open.callCount - 1 75 | const callIndex = requestIndex !== undefined ? requestIndex : lastRequestIndex // ternary op because requestIndex could be 0, which would then use last invocation 76 | const openSpy = mockRequest.open.getCall(callIndex) // assert specific request or assert last invocation. 77 | assert.equal(openSpy.args[0], 'POST') 78 | assert.equal(openSpy.args[1], expectedEndpoint) 79 | assert.isTrue(openSpy.args[2]) 80 | 81 | const sendSpy = mockRequest.send.getCall(callIndex) 82 | assert.deepEqual(JSON.parse(sendSpy.args[0]), expectedData) 83 | 84 | assert.isTrue(mockRequest.setRequestHeader.getCall(callIndex).calledWith('Content-Type', 'application/json')) 85 | } 86 | 87 | beforeEach(function() { 88 | reset() 89 | }) 90 | 91 | 92 | it('Initialized correctly', function(done) { 93 | assert.isFunction(tracker.onerror) 94 | done() 95 | }) 96 | 97 | it('should not send request if no data to send', function(done) { 98 | tracker.push({ 99 | endpoint: mockEndpoint, 100 | sendCaughtExceptions: true, 101 | sessionId: mockSessionId, 102 | }) 103 | 104 | assert.isTrue(mockRequest.open.notCalled) 105 | assert.isTrue(mockRequest.send.notCalled) 106 | done() 107 | }) 108 | 109 | it('should send request if there is data to send', function(done) { 110 | tracker.push({ 111 | endpoint: mockEndpoint, 112 | sendCaughtExceptions: true, 113 | sessionId: mockSessionId, 114 | attachClientContext: false, 115 | mockData1, 116 | }) 117 | 118 | assert.isTrue(mockRequest.open.calledOnce) 119 | assert.isTrue(mockRequest.send.calledOnce) 120 | assertSentRequest(mockEndpoint, { 121 | mockData1, 122 | sessionId: mockSessionId, 123 | }) 124 | done() 125 | }) 126 | 127 | it('should send request for each push', function(done) { 128 | // first push 129 | tracker.push({ 130 | endpoint: mockEndpoint, 131 | sessionId: mockSessionId, 132 | attachClientContext: false, 133 | mockData1, 134 | }) 135 | 136 | assert.isTrue(mockRequest.open.calledOnce) 137 | assert.isTrue(mockRequest.send.calledOnce) 138 | assertSentRequest(mockEndpoint, { 139 | mockData1, 140 | sessionId: mockSessionId, 141 | }) 142 | 143 | // second push 144 | tracker.push({ 145 | mockData2, 146 | sessionId: mockSessionId, 147 | }) 148 | 149 | assert.isTrue(mockRequest.open.calledTwice) 150 | assert.isTrue(mockRequest.send.calledTwice) 151 | assertSentRequest(mockEndpoint, { 152 | mockData2, 153 | sessionId: mockSessionId, 154 | }) 155 | done() 156 | }) 157 | 158 | it('should set and read sessionId to/from cookies', function(done) { 159 | document.cookie = mockCookies 160 | assert.equal(document.cookie, mockCookies) 161 | 162 | // first push sets endpoint, which triggers set session and will read cookie 163 | tracker.push({ 164 | endpoint: mockEndpoint, 165 | mockData1, 166 | attachClientContext: false, 167 | }) 168 | 169 | assert.equal(document.cookie, mockCookies) // should remain unchanged 170 | assertSentRequest(mockEndpoint, { 171 | mockData1, 172 | sessionId: mockCookieValue, // sessionId sent containing value from cookie 173 | }) 174 | 175 | // second push sets session id, which will set new cookie 176 | tracker.push({ 177 | sessionId: mockSessionId, 178 | mockData2, 179 | }) 180 | 181 | assert.equal(document.cookie, `trcksesh=${mockSessionId}`) // cookie should change 182 | assertSentRequest(mockEndpoint, { 183 | mockData2, 184 | sessionId: mockSessionId, // new sessionId sent 185 | }) 186 | done() 187 | }) 188 | 189 | it('should generate new sessionId if one does not exist, and store in cookie', function(done) { 190 | assert.equal(document.cookie, '') 191 | 192 | tracker.push({ 193 | endpoint: mockEndpoint, 194 | mockData1, 195 | attachClientContext: false, 196 | }) 197 | 198 | assert.notEqual(document.cookie, '') // should change 199 | // get newly generated sessionId from cookie 200 | const splitCookie = document.cookie.split('=') 201 | const newSessionId = splitCookie[1] 202 | assert.equal(splitCookie[0], cookieKey) 203 | assert.notEqual(newSessionId, '') 204 | assertSentRequest(mockEndpoint, { 205 | mockData1, 206 | sessionId: newSessionId, 207 | }) 208 | done() 209 | }) 210 | 211 | it('should honor attachSessionId flag', function(done) { 212 | // should send session Id 213 | tracker.push({ 214 | endpoint: mockEndpoint, 215 | attachClientContext: false, 216 | attachSessionId: true, 217 | sessionId: mockSessionId, 218 | mockData1, 219 | }) 220 | assertSentRequest(mockEndpoint, { 221 | mockData1, 222 | sessionId: mockSessionId, 223 | }) 224 | 225 | // should not send session id 226 | tracker.push({ 227 | attachSessionId: false, 228 | mockData2, 229 | }) 230 | assertSentRequest(mockEndpoint, { 231 | mockData2, 232 | }) 233 | done() 234 | }) 235 | 236 | it('should accept data of type "text"', function(done) { 237 | tracker.push({ 238 | endpoint: mockEndpoint, 239 | sessionId: mockSessionId, 240 | attachClientContext: false, 241 | }) 242 | tracker.push(mockData1) // push a string 243 | 244 | assert.isTrue(mockRequest.open.calledOnce) 245 | assert.isTrue(mockRequest.send.calledOnce) 246 | assertSentRequest(mockEndpoint, { 247 | text: mockData1, 248 | sessionId: mockSessionId, 249 | }) 250 | done() 251 | }) 252 | 253 | it('should track exceptions if enabled', function(done) { 254 | tracker.push({ 255 | endpoint: mockEndpoint, 256 | sessionId: mockSessionId, 257 | sendCaughtExceptions: true, 258 | attachClientContext: false, 259 | }) 260 | const mockError = Error(mockData2) 261 | 262 | window.onerror(mockData1, mockEndpoint, 1, 1, mockError) 263 | 264 | assert.isTrue(onerrorSpy.calledWith(mockData1, mockEndpoint, 1, 1, mockError)) 265 | assert.isTrue(mockRequest.open.calledOnce) 266 | assert.isTrue(mockRequest.send.calledOnce) 267 | assertSentRequest(mockEndpoint, { 268 | type: 'exception', 269 | level: 'error', 270 | exception: { 271 | colno: 1, 272 | lineno: 1, 273 | message: mockData1, 274 | stack: mockError.stack, 275 | }, 276 | sessionId: mockSessionId, 277 | }) 278 | done() 279 | }) 280 | 281 | it('should not track exceptions if disabled', function(done) { 282 | tracker.push({ 283 | endpoint: mockEndpoint, 284 | sessionId: mockSessionId, 285 | sendCaughtExceptions: false, 286 | attachClientContext: false, 287 | }) 288 | const mockError = Error(mockData2) 289 | 290 | window.onerror(mockData1, mockEndpoint, 1, 1, mockError) 291 | 292 | assert.isTrue(onerrorSpy.calledWith(mockData1, mockEndpoint, 1, 1, mockError)) 293 | assert.isTrue(mockRequest.open.notCalled) 294 | assert.isTrue(mockRequest.send.notCalled) 295 | done() 296 | }) 297 | 298 | it('should send client context', function(done) { 299 | tracker.push({ 300 | endpoint: mockEndpoint, 301 | sessionId: mockSessionId, 302 | attachClientContext: true, 303 | mockData1, 304 | mockData2, 305 | }) 306 | 307 | assertSentRequest(mockEndpoint, { 308 | mockData1, 309 | mockData2, 310 | sessionId: mockSessionId, 311 | context: { 312 | platform: mockPlatform, 313 | url: mockHref, 314 | userAgent: mockUserAgent, 315 | }, 316 | }) 317 | done() 318 | }) 319 | 320 | it('should persist additional client context values', function(done) { 321 | tracker.push({ 322 | endpoint: mockEndpoint, 323 | sessionId: mockSessionId, 324 | attachClientContext: true, 325 | }) 326 | 327 | // assign additional values to client object 328 | tracker.clientContext.mockData2 = mockData2 329 | tracker.push({ mockData1 }) 330 | 331 | assertSentRequest(mockEndpoint, { 332 | mockData1, 333 | sessionId: mockSessionId, 334 | context: { 335 | platform: mockPlatform, 336 | url: mockHref, 337 | userAgent: mockUserAgent, 338 | mockData2, 339 | }, 340 | }) 341 | 342 | // overwrite client object 343 | tracker.clientContext = { mockData1, mockData2 } 344 | tracker.push({ mockData1 }) 345 | assertSentRequest(mockEndpoint, { 346 | mockData1, 347 | sessionId: mockSessionId, 348 | context: { 349 | mockData1, 350 | mockData2, 351 | }, 352 | }) 353 | done() 354 | }) 355 | 356 | it('should send data that was pushed prior to loading tracker', function(done) { 357 | const initialTracker = [] 358 | window.tracker = initialTracker 359 | 360 | // tracker is not yet loaded, 3 pushes: one config, 2 data 361 | initialTracker.push({ 362 | endpoint: mockEndpoint, 363 | sessionId: mockSessionId, 364 | attachClientContext: false, 365 | }) 366 | initialTracker.push({ mockData1 }) 367 | initialTracker.push({ mockData2, mockCookieValue }) 368 | 369 | assert.isTrue(mockRequest.open.notCalled) 370 | assert.isTrue(mockRequest.send.notCalled) 371 | 372 | // let's load our tracker 373 | simpleTracker(window) 374 | 375 | assert.isTrue(mockRequest.open.calledTwice) 376 | assert.isTrue(mockRequest.send.calledTwice) 377 | 378 | assertSentRequest(mockEndpoint, { 379 | mockData1, 380 | sessionId: mockSessionId, 381 | }, 0) 382 | assertSentRequest(mockEndpoint, { 383 | mockData2, 384 | mockCookieValue, 385 | sessionId: mockSessionId, 386 | }) 387 | done() 388 | }) 389 | 390 | it('should log events and events with params', function(done) { 391 | tracker.push({ 392 | endpoint: mockEndpoint, 393 | sessionId: mockSessionId, 394 | attachClientContext: false, 395 | }) 396 | 397 | // log event without additional params 398 | tracker.logEvent(mockData1) 399 | 400 | assertSentRequest(mockEndpoint, { 401 | type: 'event', 402 | event: mockData1, 403 | sessionId: mockSessionId, 404 | }) 405 | 406 | // log event with additional params 407 | tracker.logEvent(mockData1, { mockData2 }) 408 | 409 | assertSentRequest(mockEndpoint, { 410 | type: 'event', 411 | event: mockData1, 412 | sessionId: mockSessionId, 413 | mockData2, 414 | }) 415 | done() 416 | }) 417 | 418 | it('should log message', function(done) { 419 | tracker.push({ 420 | endpoint: mockEndpoint, 421 | sessionId: mockSessionId, 422 | attachClientContext: false, 423 | }) 424 | const level = 'info' 425 | tracker.logMessage(mockData1, level) 426 | 427 | assertSentRequest(mockEndpoint, { 428 | level, 429 | type: 'message', 430 | message: mockData1, 431 | sessionId: mockSessionId, 432 | }) 433 | done() 434 | }) 435 | 436 | it('should log metric', function(done) { 437 | tracker.push({ 438 | endpoint: mockEndpoint, 439 | sessionId: mockSessionId, 440 | attachClientContext: false, 441 | }) 442 | tracker.logMetric(mockData1, mockData2) 443 | 444 | assertSentRequest(mockEndpoint, { 445 | type: 'metric', 446 | metric: mockData1, 447 | value: mockData2, 448 | sessionId: mockSessionId, 449 | }) 450 | done() 451 | }) 452 | 453 | it('should log timing metric', function(done) { 454 | const perfStub = window.performance.now 455 | perfStub.onCall(0).returns(1000.000005) 456 | perfStub.onCall(1).returns(5000.700001) 457 | 458 | tracker.push({ 459 | endpoint: mockEndpoint, 460 | sessionId: mockSessionId, 461 | attachClientContext: false, 462 | }) 463 | tracker.startTimer(mockData1) 464 | tracker.stopTimer(mockData1) 465 | 466 | assertSentRequest(mockEndpoint, { 467 | type: 'metric', 468 | metric: mockData1, 469 | value: 4001, 470 | sessionId: mockSessionId, 471 | }) 472 | done() 473 | }) 474 | 475 | it('should persist singleton tracker across multiple loads', function(done) { 476 | // 1st load 477 | tracker.push({ 478 | endpoint: mockEndpoint, 479 | sessionId: mockSessionId, 480 | attachClientContext: false, 481 | }) 482 | 483 | tracker.push({ mockData1 }) 484 | 485 | assert.isTrue(mockRequest.open.calledOnce) 486 | assert.isTrue(mockRequest.send.calledOnce) 487 | assertSentRequest(mockEndpoint, { 488 | sessionId: mockSessionId, 489 | mockData1, 490 | }) 491 | 492 | // second load, same window obj 493 | simpleTracker(window) 494 | tracker.push({ mockData2 }) 495 | 496 | assert.isTrue(mockRequest.open.calledTwice) 497 | assert.isTrue(mockRequest.send.calledTwice) 498 | assertSentRequest(mockEndpoint, { 499 | sessionId: mockSessionId, 500 | mockData2, 501 | }) 502 | 503 | done() 504 | }) 505 | 506 | it('dont send request out in devMode', function(done) { 507 | tracker.push({ 508 | endpoint: mockEndpoint, 509 | sessionId: mockSessionId, 510 | devMode: true, 511 | }) 512 | 513 | tracker.push({ mockData1 }) 514 | 515 | assert.isTrue(mockRequest.open.notCalled) 516 | assert.isTrue(mockRequest.send.notCalled) 517 | 518 | done() 519 | }) 520 | 521 | it('should auto initialize with window object if one exists', function(done) { 522 | delete window.tracker // ensure no instance of tracker loaded 523 | delete window.SimpleTracker 524 | global.window = window 525 | 526 | assert.isUndefined(window.tracker) 527 | assert.isUndefined(window.SimpleTracker) 528 | 529 | // window object is now in global, so should be picked up automatically 530 | const rTracker = rewire('../../index.js') // rewire loads fresh instance of module 531 | 532 | assert.isDefined(window.SimpleTracker) 533 | assert.instanceOf(window.tracker, window.SimpleTracker) 534 | assert.instanceOf(rTracker, window.SimpleTracker) 535 | 536 | delete global.window // cleanup 537 | 538 | done() 539 | }) 540 | 541 | it('dont do anything if data pushed is not an object or string', function(done) { 542 | tracker.push(() => {}) 543 | assert.isTrue(mockRequest.open.notCalled) 544 | assert.isTrue(mockRequest.send.notCalled) 545 | done() 546 | }) 547 | 548 | }) 549 | -------------------------------------------------------------------------------- /version.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | TEMP=/tmp/st-temp 4 | PACKAGE=package.json 5 | 6 | function bump() { 7 | local version="$1" 8 | echo "Bumping version to v$version" 9 | 10 | sed "3s/: \".*\"/: \"${version}\"/" "$PACKAGE" > "$TEMP" 11 | mv "$TEMP" "$PACKAGE" 12 | 13 | npm run build 14 | 15 | git add $PACKAGE dist 16 | git commit -m "$version" 17 | git tag "v$version" 18 | 19 | git --no-pager show 20 | echo "Tag v$version ready to be published!" 21 | } 22 | 23 | if [[ $# -eq 1 ]]; then 24 | version="$1" 25 | read -p "Bump to version $version?" yn 26 | case $yn in 27 | [Yy]* ) bump "$version";; 28 | [Nn]* ) exit;; 29 | * ) echo "Please answer yes or no.";; 30 | esac 31 | fi 32 | --------------------------------------------------------------------------------