├── .npmrc ├── .gitattributes ├── .gitignore ├── screenshot-real-time.png ├── screenshot-askpermission.png ├── screenshot-ga-dashboard.png ├── screenshot-ga-custom-dimensions.png ├── .github ├── security.md └── workflows │ └── main.yml ├── .editorconfig ├── test ├── _sub-process.js ├── providers.js ├── providers-yandex-metrica.js ├── ask-permission.js ├── providers-google-analytics.js └── insight.js ├── contributing.md ├── license ├── package.json ├── lib ├── push.js ├── providers.js └── index.js └── readme.md /.npmrc: -------------------------------------------------------------------------------- 1 | package-lock=false 2 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | yarn.lock 3 | -------------------------------------------------------------------------------- /screenshot-real-time.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sindresorhus/insight/HEAD/screenshot-real-time.png -------------------------------------------------------------------------------- /screenshot-askpermission.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sindresorhus/insight/HEAD/screenshot-askpermission.png -------------------------------------------------------------------------------- /screenshot-ga-dashboard.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sindresorhus/insight/HEAD/screenshot-ga-dashboard.png -------------------------------------------------------------------------------- /screenshot-ga-custom-dimensions.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sindresorhus/insight/HEAD/screenshot-ga-custom-dimensions.png -------------------------------------------------------------------------------- /.github/security.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | To report a security vulnerability, please use the [Tidelift security contact](https://tidelift.com/security). Tidelift will coordinate the fix and disclosure. 4 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = tab 5 | end_of_line = lf 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | 10 | [*.yml] 11 | indent_style = space 12 | indent_size = 2 13 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | - push 4 | - pull_request 5 | jobs: 6 | test: 7 | name: Node.js ${{ matrix.node-version }} 8 | runs-on: ubuntu-latest 9 | strategy: 10 | fail-fast: false 11 | matrix: 12 | node-version: 13 | - 20 14 | - 18 15 | steps: 16 | - uses: actions/checkout@v4 17 | - uses: actions/setup-node@v4 18 | with: 19 | node-version: ${{ matrix.node-version }} 20 | - run: npm install 21 | - run: npm test 22 | -------------------------------------------------------------------------------- /test/_sub-process.js: -------------------------------------------------------------------------------- 1 | import process from 'node:process'; 2 | import Insight from '../lib/index.js'; 3 | 4 | const insight = new Insight({ 5 | packageName: 'yeoman', 6 | packageVersion: '0.0.0', 7 | trackingCode: 'GA-1234567-1', 8 | }); 9 | 10 | if (process.env.permissionTimeout) { 11 | insight._permissionTimeout = process.env.permissionTimeout; 12 | } 13 | 14 | try { 15 | await insight.askPermission(''); 16 | process.exit(145); // eslint-disable-line unicorn/no-process-exit 17 | } catch (error) { 18 | console.error(error); 19 | process.exit(1); // eslint-disable-line unicorn/no-process-exit 20 | } 21 | -------------------------------------------------------------------------------- /contributing.md: -------------------------------------------------------------------------------- 1 | ## Testing 2 | 3 | In addition to the regular tests via `npm test`, contributors should also ensure the analytics tracking vendor continues to receive data. 4 | 5 | ### Google Analytics (GA) 6 | 7 | Please sign up for a free GA web-tracking account, then run below script using your tracking code: 8 | 9 | ```js 10 | import Insight from 'lib/insight.js'; 11 | 12 | const insight = new Insight({ 13 | trackingCode: 'UA-00000000-0', // Replace with your test GA tracking code. 14 | packageName: 'test app', 15 | packageVersion: '0.0.1' 16 | }); 17 | 18 | insight.track('hello', 'sindre'); 19 | ``` 20 | 21 | Then visit GA's Real Time dashboard and ensure data is showing up: 22 | 23 | ![analytics screenshot](screenshot-real-time.png) 24 | -------------------------------------------------------------------------------- /test/providers.js: -------------------------------------------------------------------------------- 1 | import sinon from 'sinon'; 2 | import test from 'ava'; 3 | import Insight from '../lib/index.js'; 4 | 5 | const package_ = 'yeoman'; 6 | const version = '0.0.0'; 7 | 8 | let config; 9 | let insight; 10 | 11 | test.beforeEach(() => { 12 | config = { 13 | get: sinon.spy(() => true), 14 | set: sinon.spy(), 15 | }; 16 | 17 | insight = new Insight({ 18 | trackingCode: 'xxx', 19 | packageName: package_, 20 | packageVersion: version, 21 | config, 22 | }); 23 | }); 24 | 25 | test('access the config object for reading', t => { 26 | t.true(insight.optOut); 27 | t.true(config.get.called); 28 | }); 29 | 30 | test('access the config object for writing', t => { 31 | const sentinel = {}; 32 | insight.optOut = sentinel; 33 | t.true(config.set.calledWith('optOut', sentinel)); 34 | }); 35 | -------------------------------------------------------------------------------- /test/providers-yandex-metrica.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import {Cookie} from 'tough-cookie'; 3 | import Insight from '../lib/index.js'; 4 | 5 | const package_ = 'yeoman'; 6 | const version = '0.0.0'; 7 | const code = 'GA-1234567-1'; 8 | const ts = Date.UTC(2013, 7, 24, 22, 33, 44); 9 | const pageviewPayload = { 10 | path: '/test/path', 11 | type: 'pageview', 12 | }; 13 | 14 | const insight = new Insight({ 15 | trackingCode: code, 16 | trackingProvider: 'yandex', 17 | packageName: package_, 18 | packageVersion: version, 19 | }); 20 | 21 | test('form valid request', t => { 22 | const requestObject = insight._getRequestObj(ts, pageviewPayload); 23 | 24 | const _qs = Object.fromEntries(requestObject.searchParams); 25 | 26 | t.is(_qs['page-url'], `http://${package_}.insight/test/path?version=${version}`); 27 | t.is(_qs['browser-info'], `i:20130824223344:z:0:t:${pageviewPayload.path}`); 28 | 29 | const cookieHeader = requestObject.headers.Cookie; 30 | const cookie = Cookie.parse(cookieHeader); 31 | t.is(Number(cookie.value), Number(insight.clientId)); 32 | }); 33 | -------------------------------------------------------------------------------- /license: -------------------------------------------------------------------------------- 1 | Copyright Google 2 | 3 | Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 4 | 5 | 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 6 | 7 | 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 8 | 9 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 10 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "insight", 3 | "version": "0.12.0", 4 | "description": "Understand how your tool is being used by anonymously reporting usage metrics to Google Analytics or Yandex.Metrica", 5 | "license": "BSD-2-Clause", 6 | "repository": "sindresorhus/insight", 7 | "author": { 8 | "name": "Sindre Sorhus", 9 | "email": "sindresorhus@gmail.com", 10 | "url": "https://sindresorhus.com" 11 | }, 12 | "type": "module", 13 | "exports": "./lib/index.js", 14 | "sideEffects": false, 15 | "engines": { 16 | "node": ">=18.18" 17 | }, 18 | "scripts": { 19 | "test": "xo && ava --timeout=20s" 20 | }, 21 | "xo": { 22 | "rules": { 23 | "unicorn/prefer-module": "off" 24 | } 25 | }, 26 | "files": [ 27 | "lib" 28 | ], 29 | "keywords": [ 30 | "package", 31 | "stats", 32 | "google", 33 | "analytics", 34 | "track", 35 | "metrics", 36 | "yandex", 37 | "metrica" 38 | ], 39 | "dependencies": { 40 | "chalk": "^5.4.1", 41 | "conf": "^13.1.0", 42 | "inquirer": "^12.3.2", 43 | "ky": "^1.7.4", 44 | "lodash.debounce": "^4.0.8", 45 | "os-name": "^6.0.0" 46 | }, 47 | "devDependencies": { 48 | "ava": "^6.2.0", 49 | "execa": "^9.5.2", 50 | "sinon": "^19.0.2", 51 | "tough-cookie": "^5.1.0", 52 | "xo": "^0.60.0" 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /test/ask-permission.js: -------------------------------------------------------------------------------- 1 | import process from 'node:process'; 2 | import {execa} from 'execa'; 3 | import test from 'ava'; 4 | 5 | test('skip in TTY mode', async t => { 6 | const error = await t.throwsAsync(execa('node', ['./test/_sub-process.js'])); 7 | t.is(error.exitCode, 145); 8 | }); 9 | 10 | test('skip when using the --no-insight flag', async t => { 11 | const error = await t.throwsAsync(execa('node', ['./test/_sub-process.js', '--no-insight'], { 12 | stdio: 'inherit', 13 | })); 14 | t.is(error.exitCode, 145); 15 | }); 16 | 17 | test('skip in CI mode', async t => { 18 | const {CI} = process.env; 19 | process.env.CI = true; 20 | 21 | const error = await t.throwsAsync(execa('node', ['./test/_sub-process.js'], { 22 | stdio: 'inherit', 23 | })); 24 | t.is(error.exitCode, 145); 25 | 26 | process.env.CI = CI; 27 | }); 28 | 29 | test('skip after timeout', async t => { 30 | const {CI} = process.env; 31 | const {permissionTimeout} = process.env; 32 | 33 | process.env.CI = true; 34 | process.env.permissionTimeout = 0.1; 35 | 36 | const error = await t.throwsAsync(execa('node', ['./test/_sub-process.js'], { 37 | stdio: 'inherit', 38 | })); 39 | t.is(error.exitCode, 145); 40 | 41 | process.env.CI = CI; 42 | process.env.permissionTimeout = permissionTimeout; 43 | }); 44 | -------------------------------------------------------------------------------- /lib/push.js: -------------------------------------------------------------------------------- 1 | import process from 'node:process'; 2 | import ky from 'ky'; 3 | import Insight from './index.js'; 4 | 5 | // Messaged on each debounced `track()` 6 | // Gets the queue, merges is with the previous and tries to upload everything 7 | // If it fails, it will save everything again 8 | process.on('message', async message => { 9 | const insight = new Insight(message); 10 | const {config} = insight; 11 | const queue = config.get('queue') ?? {}; 12 | 13 | Object.assign(queue, message.queue); 14 | config.delete('queue'); 15 | 16 | try { 17 | // Process queue items sequentially 18 | for (const element of Object.keys(queue)) { 19 | const [id] = element.split(' '); 20 | const payload = queue[element]; 21 | 22 | const requestObject = insight._getRequestObj(id, payload); 23 | 24 | // Convert request options to ky format 25 | const kyOptions = { 26 | method: requestObject.method ?? 'GET', 27 | headers: requestObject.headers, 28 | body: requestObject.body, 29 | searchParams: requestObject.searchParams, 30 | retry: 0, // Disable retries as we handle failures by saving to queue 31 | }; 32 | 33 | // Wait for each request to complete before moving to the next 34 | // eslint-disable-next-line no-await-in-loop 35 | await ky(requestObject.url, kyOptions); 36 | } 37 | } catch { 38 | const existingQueue = config.get('queue') ?? {}; 39 | Object.assign(existingQueue, queue); 40 | config.set('queue', existingQueue); 41 | } finally { 42 | process.exit(); 43 | } 44 | }); 45 | -------------------------------------------------------------------------------- /test/providers-google-analytics.js: -------------------------------------------------------------------------------- 1 | import process from 'node:process'; 2 | import osName from 'os-name'; 3 | import test from 'ava'; 4 | import Insight from '../lib/index.js'; 5 | 6 | const package_ = 'yeoman'; 7 | const version = '0.0.0'; 8 | const code = 'GA-1234567-1'; 9 | const ts = Date.UTC(2013, 7, 24, 22, 33, 44); 10 | const pageviewPayload = { 11 | path: '/test/path', 12 | type: 'pageview', 13 | }; 14 | const eventPayload = { 15 | category: 'category', 16 | action: 'action', 17 | label: 'label', 18 | value: 'value', 19 | type: 'event', 20 | }; 21 | 22 | const insight = new Insight({ 23 | trackingCode: code, 24 | packageName: package_, 25 | packageVersion: version, 26 | }); 27 | 28 | test('form valid request for pageview', t => { 29 | const requestObject = insight._getRequestObj(ts, pageviewPayload); 30 | const parameters = new URLSearchParams(requestObject.body); 31 | 32 | t.is(parameters.get('tid'), code); 33 | t.is(Number(parameters.get('cid')), Number(insight.clientId)); 34 | t.is(parameters.get('dp'), pageviewPayload.path); 35 | t.is(parameters.get('cd1'), osName()); 36 | t.is(parameters.get('cd2'), process.version); 37 | t.is(parameters.get('cd3'), version); 38 | }); 39 | 40 | test('form valid request for eventTracking', t => { 41 | const requestObject = insight._getRequestObj(ts, eventPayload); 42 | const parameters = new URLSearchParams(requestObject.body); 43 | 44 | t.is(parameters.get('tid'), code); 45 | t.is(Number(parameters.get('cid')), Number(insight.clientId)); 46 | t.is(parameters.get('ec'), eventPayload.category); 47 | t.is(parameters.get('ea'), eventPayload.action); 48 | t.is(parameters.get('el'), eventPayload.label); 49 | t.is(parameters.get('ev'), eventPayload.value); 50 | t.is(parameters.get('cd1'), osName()); 51 | t.is(parameters.get('cd2'), process.version); 52 | t.is(parameters.get('cd3'), version); 53 | }); 54 | 55 | /* eslint-disable ava/no-skip-test */ 56 | // Please see contributing.md 57 | test.skip('should show submitted data in Real Time dashboard, see docs on how to manually test', () => {}); 58 | /* eslint-enable ava/no-skip-test */ 59 | -------------------------------------------------------------------------------- /lib/providers.js: -------------------------------------------------------------------------------- 1 | /** 2 | Tracking providers. 3 | 4 | Each provider is a function(id, path) that should return options object for ky() call. It will be called bound to Insight instance object. 5 | */ 6 | 7 | const payload = { 8 | // Google Analytics — https://www.google.com/analytics/ 9 | google(id, payload) { 10 | const now = Date.now(); 11 | 12 | const queryParameters = new URLSearchParams({ 13 | // GA Measurement Protocol API version 14 | v: '1', 15 | 16 | // Hit type 17 | t: payload.type, 18 | 19 | // Anonymize IP 20 | aip: '1', 21 | 22 | tid: this.trackingCode, 23 | 24 | // Random UUID 25 | cid: this.clientId, 26 | 27 | cd1: this.os, 28 | 29 | // GA custom dimension 2 = Node Version, scope = Session 30 | cd2: this.nodeVersion, 31 | 32 | // GA custom dimension 3 = App Version, scope = Session (temp solution until refactored to work w/ GA app tracking) 33 | cd3: this.appVersion, 34 | 35 | // Queue time - delta (ms) between now and track time 36 | qt: now - Number.parseInt(id, 10), 37 | 38 | // Cache busting, need to be last param sent 39 | z: now, 40 | }); 41 | 42 | // Set payload data based on the tracking type 43 | if (payload.type === 'event') { 44 | queryParameters.set('ec', payload.category); // Event category 45 | queryParameters.set('ea', payload.action); // Event action 46 | 47 | if (payload.label) { 48 | queryParameters.set('el', payload.label); // Event label 49 | } 50 | 51 | if (payload.value) { 52 | queryParameters.set('ev', payload.value); // Event value 53 | } 54 | } else { 55 | queryParameters.set('dp', payload.path); // Document path 56 | } 57 | 58 | return { 59 | url: 'https://ssl.google-analytics.com/collect', 60 | method: 'POST', 61 | // GA docs recommend body payload via POST instead of querystring via GET 62 | body: queryParameters.toString(), 63 | headers: { 64 | 'content-type': 'application/x-www-form-urlencoded', 65 | }, 66 | }; 67 | }, 68 | 69 | // Yandex.Metrica - https://metrica.yandex.com 70 | yandex(id, payload) { 71 | const ts = new Date(Number.parseInt(id, 10)) 72 | .toISOString() 73 | .replaceAll(/[-:T]/g, '') // Remove `-`, `:`, and `T` 74 | .replace(/\..*$/, ''); // Remove milliseconds 75 | 76 | const {path} = payload; 77 | 78 | // Query parameters for Yandex.Metrica 79 | const queryParameters = new URLSearchParams({ 80 | wmode: '3', // Web mode 81 | ut: 'noindex', // User type 82 | 'page-url': `http://${this.packageName}.insight${path}?version=${this.packageVersion}`, 83 | 'browser-info': `i:${ts}:z:0:t:${path}`, 84 | // Cache busting 85 | rn: Date.now(), 86 | }); 87 | 88 | const url = `https://mc.yandex.ru/watch/${this.trackingCode}`; 89 | 90 | return { 91 | url, 92 | method: 'GET', 93 | searchParams: queryParameters, 94 | headers: { 95 | Cookie: `yandexuid=${this.clientId}`, 96 | }, 97 | }; 98 | }, 99 | }; 100 | 101 | export default payload; 102 | -------------------------------------------------------------------------------- /test/insight.js: -------------------------------------------------------------------------------- 1 | import {setTimeout as delay, setImmediate as setImmediatePromise} from 'node:timers/promises'; 2 | import sinon from 'sinon'; 3 | import test from 'ava'; 4 | import Insight from '../lib/index.js'; 5 | 6 | test('throw exception when trackingCode or packageName is not provided', t => { 7 | /* eslint-disable no-new */ 8 | t.throws(() => { 9 | new Insight({}); 10 | }, {instanceOf: Error}); 11 | 12 | t.throws(() => { 13 | new Insight({trackingCode: 'xxx'}); 14 | }, {instanceOf: Error}); 15 | 16 | t.throws(() => { 17 | new Insight({packageName: 'xxx'}); 18 | }, {instanceOf: Error}); 19 | /* eslint-enable no-new */ 20 | }); 21 | 22 | test('forks a new tracker right after track()', async t => { 23 | const insight = newInsight(); 24 | insight.track('test'); 25 | 26 | await setImmediatePromise(); 27 | 28 | t.deepEqual(forkedCalls(insight), [ 29 | // A single fork with a single path 30 | ['/test'], 31 | ]); 32 | }); 33 | 34 | test('only forks once if many pages are tracked in the same event loop run', async t => { 35 | const insight = newInsight(); 36 | insight.track('foo'); 37 | insight.track('bar'); 38 | 39 | await setImmediatePromise(); 40 | 41 | t.deepEqual(forkedCalls(insight), [ 42 | // A single fork with both paths 43 | ['/foo', '/bar'], 44 | ]); 45 | }); 46 | 47 | test('debounces forking every 100 millis (close together)', async t => { 48 | const insight = newInsight(); 49 | insight.track('0'); 50 | 51 | await delay(50); 52 | insight.track('50'); 53 | 54 | await delay(50); 55 | insight.track('100'); 56 | 57 | await delay(50); 58 | insight.track('150'); 59 | 60 | await delay(50); 61 | insight.track('200'); 62 | 63 | await delay(1000); 64 | 65 | t.deepEqual(forkedCalls(insight), [ 66 | // The first one is sent straight away because of the leading debounce 67 | ['/0'], 68 | // The others are grouped together because they're all < 100ms apart 69 | ['/50', '/100', '/150', '/200'], 70 | ]); 71 | }); 72 | 73 | test('debounces forking every 100 millis (far apart)', async t => { 74 | const insight = newInsight(); 75 | insight.track('0'); 76 | 77 | await delay(50); 78 | insight.track('50'); 79 | 80 | await delay(50); 81 | insight.track('100'); 82 | 83 | await delay(50); 84 | insight.track('150'); 85 | 86 | await delay(150); 87 | insight.track('300'); 88 | 89 | await delay(50); 90 | insight.track('350'); 91 | 92 | await delay(1000); 93 | 94 | t.deepEqual(forkedCalls(insight), [ 95 | // Leading call 96 | ['/0'], 97 | // Sent together since there is an empty 100ms window afterwards 98 | ['/50', '/100', '/150'], 99 | // Sent on its own because it's a new leading debounce 100 | ['/300'], 101 | // Finally, the last one is sent 102 | ['/350'], 103 | ]); 104 | }); 105 | 106 | // Return a valid insight instance which doesn't actually send analytics (mocked) 107 | function newInsight() { 108 | const insight = new Insight({ 109 | trackingCode: 'xxx', 110 | packageName: 'yeoman', 111 | packageVersion: '0.0.0', 112 | }); 113 | insight.optOut = false; 114 | insight._fork = sinon.stub(); 115 | return insight; 116 | } 117 | 118 | // Returns all forked calls, and which paths were tracked in that fork 119 | // This is handy to get a view of all forks at once instead of debugging 1 by 1 120 | // [ 121 | // ['/one', 'two'], // first call tracked 2 paths 122 | // ['/three', 'four'], // second call tracked 2 more paths 123 | // ] 124 | function forkedCalls(insight) { 125 | return insight._fork.args.map(callArguments => 126 | Object.values(callArguments[0].queue).map(q => q.path), 127 | ); 128 | } 129 | -------------------------------------------------------------------------------- /lib/index.js: -------------------------------------------------------------------------------- 1 | import process from 'node:process'; 2 | import path from 'node:path'; 3 | import {fileURLToPath} from 'node:url'; 4 | import childProcess from 'node:child_process'; 5 | import {randomUUID} from 'node:crypto'; 6 | import osName from 'os-name'; 7 | import Conf from 'conf'; 8 | import chalk from 'chalk'; 9 | import debounce from 'lodash.debounce'; 10 | import inquirer from 'inquirer'; 11 | import providers from './providers.js'; 12 | 13 | const DEBOUNCE_MS = 100; 14 | const __filename = fileURLToPath(import.meta.url); 15 | const __dirname = path.dirname(__filename); 16 | 17 | class Insight { 18 | #queue = {}; 19 | #permissionTimeout = 30; 20 | #debouncedSend; 21 | 22 | constructor(options = {}) { 23 | const {pkg: package_ = {}} = options; 24 | 25 | // Deprecated options 26 | // TODO: Remove these at some point in the future 27 | if (options.packageName) { 28 | package_.name = options.packageName; 29 | } 30 | 31 | if (options.packageVersion) { 32 | package_.version = options.packageVersion; 33 | } 34 | 35 | if (!options.trackingCode || !package_.name) { 36 | throw new Error('trackingCode and pkg.name required'); 37 | } 38 | 39 | this.trackingCode = options.trackingCode; 40 | this.trackingProvider = options.trackingProvider ?? 'google'; 41 | this.packageName = package_.name; 42 | this.packageVersion = package_.version ?? 'undefined'; 43 | this.os = osName(); 44 | this.nodeVersion = process.version; 45 | this.appVersion = this.packageVersion; 46 | this.config = options.config ?? new Conf({ 47 | projectName: package_.name, 48 | configName: `insight-${this.packageName}`, 49 | defaults: { 50 | clientId: options.clientId ?? Math.floor(Date.now() * Math.random()), 51 | }, 52 | }); 53 | 54 | this.#debouncedSend = debounce(this.#send.bind(this), DEBOUNCE_MS, {leading: true}); 55 | } 56 | 57 | get optOut() { 58 | return this.config.get('optOut'); 59 | } 60 | 61 | set optOut(value) { 62 | this.config.set('optOut', value); 63 | } 64 | 65 | get clientId() { 66 | return this.config.get('clientId'); 67 | } 68 | 69 | set clientId(value) { 70 | this.config.set('clientId', value); 71 | } 72 | 73 | #save() { 74 | setImmediate(() => { 75 | this.#debouncedSend(); 76 | }); 77 | } 78 | 79 | #send() { 80 | const pending = Object.keys(this.#queue).length; 81 | if (pending === 0) { 82 | return; 83 | } 84 | 85 | this._fork(this.#getPayload()); 86 | this.#queue = {}; 87 | } 88 | 89 | // For testing. 90 | _fork(payload) { 91 | // Extracted to a method so it can be easily mocked 92 | const cp = childProcess.fork(path.join(__dirname, 'push.js'), {silent: true}); 93 | cp.send(payload); 94 | cp.unref(); 95 | cp.disconnect(); 96 | } 97 | 98 | #getPayload() { 99 | return { 100 | queue: {...this.#queue}, 101 | packageName: this.packageName, 102 | packageVersion: this.packageVersion, 103 | trackingCode: this.trackingCode, 104 | trackingProvider: this.trackingProvider, 105 | }; 106 | } 107 | 108 | // For testing. 109 | _getRequestObj(...arguments_) { 110 | return providers[this.trackingProvider].apply(this, arguments_); 111 | } 112 | 113 | track(...arguments_) { 114 | if (this.optOut) { 115 | return; 116 | } 117 | 118 | const path = '/' + arguments_.map(element => 119 | String(element).trim().replace(/ /, '-'), 120 | ).join('/'); 121 | 122 | // Timestamp isn't unique enough since it can end up with duplicate entries 123 | this.#queue[`${Date.now()} ${randomUUID()}`] = { 124 | path, 125 | type: 'pageview', 126 | }; 127 | this.#save(); 128 | } 129 | 130 | trackEvent(options) { 131 | if (this.optOut) { 132 | return; 133 | } 134 | 135 | if (this.trackingProvider !== 'google') { 136 | throw new Error('Event tracking is supported only for Google Analytics'); 137 | } 138 | 139 | if (!options?.category || !options?.action) { 140 | throw new Error('`category` and `action` required'); 141 | } 142 | 143 | // Timestamp isn't unique enough since it can end up with duplicate entries 144 | this.#queue[`${Date.now()} ${randomUUID()}`] = { 145 | category: options.category, 146 | action: options.action, 147 | label: options.label, 148 | value: options.value, 149 | type: 'event', 150 | }; 151 | 152 | this.#save(); 153 | } 154 | 155 | async askPermission(message) { 156 | const defaultMessage = `May ${chalk.cyan(this.packageName)} anonymously report usage statistics to improve the tool over time?`; 157 | 158 | if (!process.stdout.isTTY || process.argv.includes('--no-insight') || process.env.CI) { 159 | return; 160 | } 161 | 162 | const prompt = inquirer.prompt({ 163 | type: 'confirm', 164 | name: 'optIn', 165 | message: message ?? defaultMessage, 166 | default: true, 167 | }); 168 | 169 | // Set a 30 sec timeout before giving up on getting an answer 170 | let permissionTimeout; 171 | const timeoutPromise = new Promise(resolve => { 172 | permissionTimeout = setTimeout(() => { 173 | // Stop listening for stdin 174 | prompt.ui.close(); 175 | 176 | // Automatically opt out 177 | this.optOut = true; 178 | resolve(false); 179 | }, this.#permissionTimeout * 1000); 180 | }); 181 | 182 | const promise = (async () => { 183 | const {optIn} = await prompt; 184 | 185 | // Clear the permission timeout upon getting an answer 186 | clearTimeout(permissionTimeout); 187 | 188 | this.optOut = !optIn; 189 | return optIn; 190 | })(); 191 | 192 | // Return the result of the prompt if it finishes first otherwise default to the timeout's value. 193 | return Promise.race([promise, timeoutPromise]); 194 | } 195 | } 196 | 197 | export default Insight; 198 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # Insight 2 | 3 | > Understand how your tool is being used by anonymously reporting usage metrics to [Google Analytics](https://www.google.com/analytics/) or [Yandex.Metrica](https://metrica.yandex.com) 4 | 5 | **This package is in maintenance mode. No new features will be added.** 6 | 7 | ## Install 8 | 9 | ```sh 10 | npm install insight 11 | ``` 12 | 13 | ## Access data / generate dashboards 14 | 15 | ### Google Analytics (GA) 16 | 17 | - Use [Embed API](https://developers.google.com/analytics/devguides/reporting/embed/v1/) to embed charts 18 | - Use [Core Reporting API](https://developers.google.com/analytics/devguides/reporting/core/v3/) or [Real Time Reporting API](https://developers.google.com/analytics/devguides/reporting/realtime/v3/) to access raw data, then build custom visualization, e.g. [metrics from Bower](https://bower.io/stats/) 19 | - Use GA's dashboards directly, e.g. metrics from [Yeoman](https://yeoman.io): 20 | 21 | ![analytics screenshot](screenshot-ga-dashboard.png) 22 | 23 | ## Provider Setup 24 | 25 | ### Google Analytics (GA) 26 | 27 | Currently, Insight should be used with GA set up as web tracking due to use of URLs. Future plans include refactoring to work with GA set up for app-based tracking and the [Measurement Protocol](https://developers.google.com/analytics/devguides/collection/protocol/v1/). 28 | 29 | For debugging, Insight can track OS version, Node.js version, and version of the app that implements Insight. Please set up custom dimensions per below screenshot. This is a temporary solution until Insight is refactored into app-based tracking. 30 | 31 | ![GA custom dimensions screenshot](screenshot-ga-custom-dimensions.png) 32 | 33 | ## Collected Data 34 | 35 | Insight cares deeply about the security of your user's data and strives to be fully transparent with what it tracks. All data is sent via HTTPS secure connections. Insight provides API to offer an easy way for users to opt-out at any time. 36 | 37 | Below is what Insight is capable of tracking. Individual implementation can choose to not track some items. 38 | 39 | - The version of the module that implements Insight 40 | - Module commands/events (e.g. install / search) 41 | - Name and version of packages involved with command used 42 | - Version of node.js & OS for developer debugging 43 | - A random & absolutely anonymous ID 44 | 45 | ## Usage 46 | 47 | ### Google Analytics 48 | 49 | ```js 50 | import Insight from 'insight'; 51 | import packageJson from './package.json' with {type: 'json'}; 52 | 53 | const insight = new Insight({ 54 | // Google Analytics tracking code 55 | trackingCode: 'UA-XXXXXXXX-X', 56 | pkg: packageJson 57 | }); 58 | 59 | // Ask for permission the first time 60 | if (insight.optOut === undefined) { 61 | insight.askPermission(); 62 | } 63 | 64 | insight.track('foo', 'bar'); 65 | // Recorded in Analytics as `/foo/bar` 66 | 67 | insight.trackEvent({ 68 | category: 'eventCategory', 69 | action: 'eventAction', 70 | label: 'eventLabel', 71 | value: 'eventValue' 72 | }); 73 | // Recorded in Analytics behavior/events section 74 | ``` 75 | 76 | ### Yandex.Metrica 77 | 78 | ```js 79 | import Insight from 'insight'; 80 | import packageJson from './package.json' with {type: 'json'}; 81 | 82 | const insight = new Insight({ 83 | // Yandex.Metrica counter id 84 | trackingCode: 'XXXXXXXXX' 85 | trackingProvider: 'yandex', 86 | pkg: packageJson 87 | }); 88 | 89 | // Ask for permission the first time 90 | if (insight.optOut === undefined) { 91 | insight.askPermission(); 92 | } 93 | 94 | insight.track('foo', 'bar'); 95 | // Recorded in Yandex.Metrica as `http://.insight/foo/bar` 96 | ``` 97 | 98 | ## API 99 | 100 | ### Insight(options) 101 | 102 | #### trackingCode 103 | 104 | **Required**\ 105 | Type: `string` 106 | 107 | Your Google Analytics [trackingCode](https://support.google.com/analytics/bin/answer.py?hl=en&answer=1008080) or Yandex.Metrica [counter id](https://help.yandex.com/metrika/?id=1121963). 108 | 109 | #### trackingProvider 110 | 111 | Type: `string`\ 112 | Default: `'google'`\ 113 | Values: `'google' | 'yandex'` 114 | 115 | Tracking provider to use. 116 | 117 | #### pkg 118 | 119 | Type: `object` 120 | 121 | ##### name 122 | 123 | **Required**\ 124 | Type: `string` 125 | 126 | ##### version 127 | 128 | Type: `string`\ 129 | Default: `'undefined'` 130 | 131 | #### config 132 | 133 | Type: `object`\ 134 | Default: An instance of [`conf`](https://github.com/sindresorhus/conf) 135 | 136 | If you want to use your own configuration mechanism instead of the default `conf`-based one, you can provide an object that has to implement two synchronous methods: 137 | 138 | - `get(key)` 139 | - `set(key, value)` 140 | 141 | ### Instance methods 142 | 143 | #### .track(keyword, ...keyword?) 144 | 145 | Accepts keywords which ends up as a path in Analytics. 146 | 147 | `.track('init', 'backbone')` becomes `/init/backbone` 148 | 149 | #### .trackEvent(options) 150 | 151 | Accepts event category, action, label and value as described in the [GA event tracking](https://developers.google.com/analytics/devguides/collection/analyticsjs/events) documentation via the options object. Note: Does not work with Yandex.Metrica. 152 | 153 | ```js 154 | .trackEvent({ 155 | category: 'download', 156 | action: 'image', 157 | label: 'logo-image' 158 | }); 159 | ``` 160 | 161 | ##### category 162 | 163 | **Required**\ 164 | Type: `string` 165 | 166 | Event category: Typically the object that was interacted with (e.g. 'Video'). 167 | 168 | ##### action 169 | 170 | **Required**\ 171 | Type: `string` 172 | 173 | Event action: The type of interaction (e.g. 'play'). 174 | 175 | ##### label 176 | 177 | Type: `string` 178 | 179 | Event label: Useful for categorizing events (e.g. 'Fall Campaign'). 180 | 181 | ##### value 182 | 183 | Type: `integer` 184 | 185 | Event value: A numeric value associated with the event (e.g. 42). 186 | 187 | #### .askPermission(message?) 188 | 189 | Asks the user permission to opt-in to tracking and sets the `optOut` property in `config`. You can also choose to set `optOut` property in `config` manually. 190 | 191 | ![askPermission screenshot](screenshot-askpermission.png) 192 | 193 | Optionally supply your own `message`. If `message` is `null`, default message will be used. This also resolves with the new value of `optIn` when the prompt is done and is useful for when you want to continue the execution while the prompt is running. 194 | 195 | #### .optOut 196 | 197 | Returns a boolean whether the user has opted out of tracking. Should preferably only be set by a user action, eg. a prompt. 198 | --------------------------------------------------------------------------------