From 7fb28679982d1f576316dd53aabefec922180606 Mon Sep 17 00:00:00 2001 From: Florian Pichler Date: Sun, 16 Jan 2022 23:54:55 +0100 Subject: [PATCH] Allow loading of script to be deferred --- .npmignore | 3 +- README.md | 122 +++++++++++++++++++++----------- package.json | 2 +- rollup.config.js | 20 ++++++ src/telemetrydeck.mjs | 109 +++++++++++++++++----------- src/utils/assert-key-value.mjs | 7 ++ src/utils/sha256.mjs | 15 ++++ src/utils/transform-payload.mjs | 3 + tests/telemetrydeck.test.mjs | 64 ++++++++--------- 9 files changed, 223 insertions(+), 122 deletions(-) create mode 100644 src/utils/assert-key-value.mjs create mode 100644 src/utils/sha256.mjs create mode 100644 src/utils/transform-payload.mjs diff --git a/.npmignore b/.npmignore index 14f1e76..23f8a94 100644 --- a/.npmignore +++ b/.npmignore @@ -5,4 +5,5 @@ .release-it.js node_modules RELEASE.md -rollup.config.js \ No newline at end of file +rollup.config.js +src \ No newline at end of file diff --git a/README.md b/README.md index 4bb2d79..8d285d1 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,15 @@ # Telemetry Deck JavaScript SDK -This package allows you to send signals to [TelemetryDeck](https://telemetrydeck.com) from your JavaScript code. +This package allows you to send signals to [TelemetryDeck](https://telemetrydeck.com) from your JavaScript code. -It has no package dependencies and supports modern evergreen browsers which support [cryptography](https://caniuse.com/cryptography). +It has no package dependencies and supports **modern evergreen browsers** which support [cryptography](https://caniuse.com/cryptography). Signals sent with this version of the SDK automatically send the following payload items: -- url -- useragent -- locale -- platform +- `url` +- `useragent` +- `locale` +- `platform` You can filter and show these values in the TelemetryDeck dashboard. @@ -17,60 +17,96 @@ Test Mode is currently not supported. ## Usage -### 📄 Usage via Script Tag +### 📄 Usage via Script tag -For regular websites and to try out the code quickly, you can use [UNPKG](https://unpkg.com), a free CDN which allows you to load files from any npm package. +For basic websites and to try out the code quickly, you can use [UNPKG](https://unpkg.com), a free CDN which allows you to load files from any npm package. -Include the following snippet in your page header: +Include the following snippet at the bottom of your HTML page: ```html - ``` -then include a script tag at the bottom of your page like this to send a signal once every time the page loads: +Then add a second script tag after it like this to send a signal once every time the page loads: ```html ``` -Please replace `YOUR_APP_ID` with the app ID you received from TelemetryDeck, and set a user identifier if possible. +Please replace `YOUR_APP_ID` with the app ID you received from TelemetryDeck, and `USER_IDENTIFIER` with a user identifier. If you have none, consider `anonymous`. + +You can add as many signals as you need to track different interactions with your page. Once the page and script are fully loaded, signals will be sent immediatlty. + +```js +// basic signal +window.td.push('signal'); + +// with custom data +window.td.push('signal', { route: '/' }); +``` + +#### Alternative usage for more complex tracking needs -### 📦 Usage for applications that use a bundler (like Webpack, Rollup, …) +```html + +``` + +### 📦 Advanced usage for applications that use a bundler (like Webpack, Rollup, …) After installing the package via NPM, use it like this: ```js -import { signal } from 'telemetry-deck'; - -// -signal( - // required options to identify your app and the user - { - appID: 'YOUR_APP_ID', - userIdentifier: 'ANONYMOUS', - }, - // custom payload stored with the signal - { - route: 'some/page/path', - } -); +import { TelemtryDeck } from 'telemetry-deck'; + +const td = new TelemetryDeck({ app: YOUR_APP_ID, user: YOUR_USER_IDENTIFIER }); + +// Process any events that have been qeued up +// Qeueud signals do not contain a client side timestamp and will be timestamped +// on the server at the time of arrival. Consider adding a timestamp value to +// your payloads if you need to be able to correlate them. +const queuedEvents = [ + 'app', + YOUR_APP_ID, + 'user', + YOUR_USER_IDENTIFIER, + 'signal', + 'signal', + { route: 'some/page/path' }, +]; +td.ingest(qeuedEvents); + +// Basic signal +td.signal(); + +// Update app or user identifier +td.app(YOUR_NEW_APP_ID); +td.user(YOUR_NEW_USER_IDENTIFIER); + +// Signal with custom payload +td.signal({ + route: 'some/page/path', +}); ``` -Please replace `YOUR_APP_ID` with the app ID you received from TelemetryDeck. If you have any string that identifies your user, such as an email address, pass it into `userIdentifier` – it will be cryptographically anonymized with a hash function. +Please replace `YOUR_APP_ID` with the app ID you received from TelemetryDeck. If you have any string that identifies your user, such as an email address, use it as `YOUR_USER_IDENTIFIER` – it will be cryptographically anonymized with a hash function. -If you want to pass optional parameters to the signal being sent, add them to the optional paylaod object. +If you want to pass optional parameters to the signal being sent, add them to the optional payload object. ## More Info @@ -80,14 +116,14 @@ Every application and website registered to TelemetryDeck has its own unique ID ### 👤 Optional: User Identifiers -TelemetryDeck can count users if you assign it a unique identifier for each user that doesn't change. This identifier can be any string that is unique to the user, such as their email address, or a randomly generated UUID. +TelemetryDeck can count users if you assign it a unique identifier for each user that doesn't change. This identifier can be any string that is unique to the user, such as their email address, or a randomly generated UUID. Feel free to use personally identifiable information as the user identifier: We use a cryptographically secure double-hasing process on client and server to make sure the data that arrives at our servers is anonymized and can not be traced back to individual users via their identifiers. A user's identifier is hashed inside the library, and then salted+hashed again on arrival at the server. This way the data is anonymized as defined by the GDPR and you don't have to ask for user consent for procesing or storing this data. ### 🚛 Optional: Payload -You can optionally attach an object with string values to the signal. This will allow you to filter and aggregate signal by these values in the dashboard. +You can optionally attach an object with string values to the signal. This will allow you to filter and aggregate signal by these values in the dashboard. ### 📚 Full Docs -Go to [docs.telemetrydeck.com](https://docs.telemetrydeck.com) to see all documentation articles \ No newline at end of file +Go to [docs.telemetrydeck.com](https://docs.telemetrydeck.com) to see all documentation articles diff --git a/package.json b/package.json index 2cbdd87..d329513 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "version": "0.1.0", "description": "JavaScript package to send TelemetryDeck signals", "main": "dist/telemetrydeck.js", - "module": "src/telemetrydeck.mjs", + "module": "dist/telemetrydeck.mjs", "scripts": { "build": "rollup -c", "changelog": "lerna-changelog", diff --git a/rollup.config.js b/rollup.config.js index cab44a5..c44dd31 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -2,6 +2,7 @@ import json from '@rollup/plugin-json'; import { terser } from 'rollup-plugin-terser'; export default [ + // CommonJS build for Node.js { input: 'src/telemetrydeck.mjs', output: { @@ -10,6 +11,25 @@ export default [ }, plugins: [json()], }, + // ES module build for browsers + { + input: 'src/telemetrydeck.mjs', + output: { + file: 'dist/telemtrydeck.mjs', + format: 'module', + }, + plugins: [json()], + }, + // minified ES module build + { + input: 'src/telemetrydeck.mjs', + output: { + file: 'dist/telemtrydeck.min.mjs', + format: 'module', + }, + plugins: [json(), terser()], + }, + // minified UMD build for most browsers { input: 'src/telemetrydeck.mjs', output: { diff --git a/src/telemetrydeck.mjs b/src/telemetrydeck.mjs index 94b1ba0..408d5cd 100644 --- a/src/telemetrydeck.mjs +++ b/src/telemetrydeck.mjs @@ -1,61 +1,76 @@ import { version } from '../package.json'; +import sha256 from './utils/sha256.mjs'; +import assertKeyValue from './utils/assert-key-value.mjs'; +import transformPayload from './utils/transform-payload.mjs'; -const transformPayload = (payload) => Object.entries(payload).map((entry) => entry.join(':')); +const APP = 'app'; +const USER = 'user'; +const SIGNAL = 'signal'; +const METHODS = new Set([APP, USER, SIGNAL]); -const assertKeyValue = (key, value) => { - if (!value) { - throw new Error(`TelemetryDeck: ${key} is not set`); +export class TelemetryDeck { + constructor(options = {}) { + const { target, app, user } = options; + + this.target = target ?? 'https://nom.telemetrydeck.com/v1/'; + this._app = app; + this._user = user; } -}; -// https://stackoverflow.com/a/48161723/54547 -async function sha256(message) { - // encode as UTF-8 - const messageBuffer = new TextEncoder().encode(message); + async ingest(waitingData) { + let queue = []; - // hash the message - const hashBuffer = await crypto.subtle.digest('SHA-256', messageBuffer); + for (const value of waitingData) { + if (METHODS.has(value)) { + queue.push([value]); + } else { + queue.at(-1).push(value); + } + } - // convert ArrayBuffer to Array - const hashArray = [...new Uint8Array(hashBuffer)]; + for (const [method, data] of queue) { + try { + await this[method].call(this, data); + } catch (error) { + console.error(error); + } + } + } - // convert bytes to hex string - const hashHex = hashArray.map((b) => b.toString(16).padStart(2, '0')).join(''); - return hashHex; -} + [APP](appId) { + this._app = appId; + } -class TelemetryDeck { - constructor(appID, target) { - this.appID = appID; - this.target = target ?? 'https://nom.telemetrydeck.com/v1/'; + [USER](identifier) { + this._user = identifier; + } - assertKeyValue('appID', appID); + /** + * This method is used to queue messages to be sent by TelemtryDeck + * @param {string} type + * @param {string} [payload] + * + * @returns {Promise} + */ + push(method, data) { + return this[method](data); } /** * - * @paam {string} userIdentifier to be hashed * @param {Object?} payload custom payload to be stored with each signal * @returns > a promise with the response from the server, echoing the sent data */ - async signal(userIdentifier, payload) { + async [SIGNAL](payload) { const { href: url } = location; const { userAgent: useragent, language: locale, userAgentData, vendor } = navigator; + const { _app, target } = this; + let { _user } = this; - payload = { - url, - useragent, - locale, - platform: userAgentData ?? '', - vendor, - ...payload, - }; + assertKeyValue(APP, _app); + assertKeyValue(USER, _user); - let { appID, target } = this; - - assertKeyValue('userIdentifier', userIdentifier); - - userIdentifier = await sha256(userIdentifier); + _user = await sha256(_user); return fetch(target, { method: 'POST', @@ -65,16 +80,26 @@ class TelemetryDeck { }, body: JSON.stringify([ { - appID, - clientUser: userIdentifier, - sessionID: userIdentifier, + appID: _app, + clientUser: _user, + sessionID: _user, telemetryClientVersion: version, type: 'pageview', - payload: transformPayload(payload), + payload: transformPayload({ + url, + useragent, + locale, + platform: userAgentData ?? '', + vendor, + ...payload, + }), }, ]), }); } } -export default TelemetryDeck; +if (window && window.td) { + window.td = new TelemetryDeck({}); + window.td.ingest(); +} diff --git a/src/utils/assert-key-value.mjs b/src/utils/assert-key-value.mjs new file mode 100644 index 0000000..d7bc3af --- /dev/null +++ b/src/utils/assert-key-value.mjs @@ -0,0 +1,7 @@ +const assertKeyValue = (key, value) => { + if (!value) { + throw new Error(`TelemetryDeck: "${key}" is not set`); + } +}; + +export default assertKeyValue; diff --git a/src/utils/sha256.mjs b/src/utils/sha256.mjs new file mode 100644 index 0000000..4e68a6c --- /dev/null +++ b/src/utils/sha256.mjs @@ -0,0 +1,15 @@ +// https://stackoverflow.com/a/48161723/54547 +export default async function sha256(message) { + // encode as UTF-8 + const messageBuffer = new TextEncoder().encode(message); + + // hash the message + const hashBuffer = await crypto.subtle.digest('SHA-256', messageBuffer); + + // convert ArrayBuffer to Array + const hashArray = [...new Uint8Array(hashBuffer)]; + + // convert bytes to hex string + const hashHex = hashArray.map((b) => b.toString(16).padStart(2, '0')).join(''); + return hashHex; +} \ No newline at end of file diff --git a/src/utils/transform-payload.mjs b/src/utils/transform-payload.mjs new file mode 100644 index 0000000..36e2fdc --- /dev/null +++ b/src/utils/transform-payload.mjs @@ -0,0 +1,3 @@ +const transformPayload = (payload) => Object.entries(payload).map((entry) => entry.join(':')); + +export default transformPayload; diff --git a/tests/telemetrydeck.test.mjs b/tests/telemetrydeck.test.mjs index 86d53fd..3d9891e 100644 --- a/tests/telemetrydeck.test.mjs +++ b/tests/telemetrydeck.test.mjs @@ -1,5 +1,5 @@ /* eslint-disable jest/no-conditional-expect */ -import TelemetryDeck from '../src/telemetrydeck.mjs'; +import { TelemetryDeck } from '../src/telemetrydeck.mjs'; import { version } from '../package.json'; const oldWindowLocation = window.location; @@ -11,37 +11,42 @@ beforeAll(() => { afterAll(() => { window.location = oldWindowLocation; + window.td = undefined; }); describe('TelemetryDeck constructor', () => { - test('throws error if appID is not passed', async () => { - expect.assertions(1); - - try { - new TelemetryDeck(); - } catch (error) { - expect(error.message).toMatch('TelemetryDeck: appID is not set'); - } + test('can be instantiated with defaults', async () => { + const td = new TelemetryDeck(); + expect(td).toBeDefined(); + expect(td.target).toBe('https://nom.telemetrydeck.com/v1/'); + expect(td._app).toBeUndefined(); + expect(td._user).toBeUndefined(); }); }); describe('TelemetryDeck.signal()', () => { - test('throws error if userIdentifier is not passed', async () => { - const td = new TelemetryDeck('foo'); + test('throws error if app is not set', async () => { + const td = new TelemetryDeck({ user: 'foo' }); - await expect(td.signal()).rejects.toThrow('TelemetryDeck: userIdentifier is not set'); + await expect(td.signal()).rejects.toThrow('TelemetryDeck: "app" is not set'); }); - test('sends signal to a different TelemetryDeck host with basic data', async () => { + test('throws error if user is not set', async () => { + const td = new TelemetryDeck({ app: 'foo' }); + + await expect(td.signal()).rejects.toThrow('TelemetryDeck: "user" is not set'); + }); + + test('sends signal to TelemetryDeck with basic data', async () => { window.location.href = 'https://nasa.gov'; const spy = jest.spyOn(window, 'fetch'); - const td = new TelemetryDeck('foo', 'https://nom.nasa.gov/v1/'); - - await td.signal('bar'); + const queue = ['app', 'foo', 'user', 'bar', 'signal']; + const td = new TelemetryDeck(); + await td.ingest(queue); expect(spy).toHaveBeenCalledTimes(1); - expect(spy).toHaveBeenCalledWith('https://nom.nasa.gov/v1/', { + expect(spy).toHaveBeenCalledWith('https://nom.telemetrydeck.com/v1/', { body: JSON.stringify([ { appID: 'foo', @@ -66,15 +71,13 @@ describe('TelemetryDeck.signal()', () => { }); }); - test('sends signal to TelemetryDeck with basic data', async () => { - window.location.href = 'https://nasa.gov'; - + test('sends signals to TelemetryDeck with additional payload data', async () => { const spy = jest.spyOn(window, 'fetch'); - const td = new TelemetryDeck('foo'); + const queue = ['app', 'foo', 'user', 'bar', 'signal', 'signal', { baz: 'bat' }]; + const td = new TelemetryDeck(); + await td.ingest(queue); - await td.signal('bar'); - - expect(spy).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenCalledTimes(2); expect(spy).toHaveBeenCalledWith('https://nom.telemetrydeck.com/v1/', { body: JSON.stringify([ { @@ -98,17 +101,6 @@ describe('TelemetryDeck.signal()', () => { method: 'POST', mode: 'cors', }); - }); - - test('sends signal to TelemetryDeck with additional payload data', async () => { - const spy = jest.spyOn(window, 'fetch'); - const td = new TelemetryDeck('foo'); - - await td.signal('bar', { - baz: 'bat', - }); - - expect(spy).toHaveBeenCalledTimes(1); expect(spy).toHaveBeenCalledWith('https://nom.telemetrydeck.com/v1/', { body: JSON.stringify([ { @@ -134,4 +126,6 @@ describe('TelemetryDeck.signal()', () => { mode: 'cors', }); }); + + // TODO: test that queued data is sent by the default instance });