From 1062117bb4e506238ddaa280ecf1767608af5d5f Mon Sep 17 00:00:00 2001 From: Daniel Jilg Date: Wed, 8 Nov 2023 20:50:11 +0100 Subject: [PATCH] Update README, crypto Implementation, and jsdoc comments --- README.md | 122 +++++++++++++++++++++++-------------------- src/telemetrydeck.js | 35 ++++++++++--- src/utils/crypto.js | 10 ---- src/utils/sha256.js | 19 ++++--- 4 files changed, 105 insertions(+), 81 deletions(-) delete mode 100644 src/utils/crypto.js diff --git a/README.md b/README.md index cc61baf..e46c687 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,25 @@ # 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 JavaScript code. -It has no dependencies and supports **modern evergreen browsers** and modern versions of Node.js with support for [cryptography](https://caniuse.com/cryptography). +TelemetryDeck allows you to capture and analyize users moving through your app and get help deciding how to grow, all without compromising privacy! -Signals sent with this SDK do not send any default values, besides signal `type`, `appID`, `user` and `sessionID`. +> [!NOTE] +> If you want to use TelemetryDeck for your blog or static website, we recommend the [TelemetryDeck Web SDK](https://github.com/TelemetryDeck/WebSDK) instead of this JavaScript SDK. -If you want to use this package in your web application, see recommended parameters below. +# Set Up -## Usage +The TelemetryDeck SDK has no dependencies and supports **modern evergreen browsers** and **modern versions of Node.js** with support for [cryptography](https://caniuse.com/cryptography). -### 📦 Advanced usage for applications that use a bundler (like Webpack, Rollup, …) +## Set up in Browser Based Applications that use a bundler (React, Vue, Angular, Svelte, Ember, …) -After installing the package via NPM, use it like this: +### 1. Installing the package + +Please install the package using npm or the package manager of your choice + +### 2. Initializing TelemetryDeck + +Initialize the TelemetryDeck SDK with your app ID and your user's user identifer. ```javascript import TelemetryDeck from '@telemetrydeck/sdk'; @@ -21,88 +28,89 @@ const td = new TelemetryDeck({ appID: '' user: '', }); +``` -// Basic signal -td.signal(''); +Please replace `` with the app ID in TelemetryDeck ([Dashboard](https://dashboard.telemetrydeck.com) -> App -> Set Up App). -// Adanced: Signal with custom payload -td.signal('', { - volume: '11', -}); -``` +You also need to identify your logged in user. Instead of ``, pass in any string that uniquely identifies your user, such as an email address. 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 can't specify a user identifer at initialization, you can set it later by setting `td.clientUser`. -If you want to pass optional parameters to the signal being sent, add them to the optional payload object. -## Usage with React Native -React Native does not support the `crypto` module, which is required for the SDK to work. We found [react-native-quick-crypto](https://github.com/margelo/react-native-quick-crypto) to be a suitable polyfill. Please note that this is not an officially supported solution. +Please note that `td.signal` is an async function that returns a promise. -## Queueing Signals +## Set up in Node.js Applications -The `TelemetryDeck` class comes with a built-in queuing mechanism for storing signals until they are flushed in a single request. Queued signals are sent with `receivedAt` prefilled with the time they were queued. +### 1. Installing the package -This uses an in-memory store by default. The store is not persisted between page reloads or app restarts. If you want to persist the store, you can pass a `store` object to the `TelemetryDeck` constructor. The store must implement the following interface: +Please install the package using npm or the package manager of your choice + +### 2. Initializing TelemetryDeck + +Initialize the TelemetryDeck SDK with your app ID and your user's user identifer. Since `globalThis.crypto.subtle.digest` does not exist in Node.js, you need to pass in an alternative implementation provided by Node.js. ```javascript -export class Store { - async push() // signal bodys are async and need to be awaited before stored - clear() // called after flush - values() // returns an array of resolved signal bodys in the order they were pushed -} +import TelemetryDeck from '@telemetrydeck/sdk'; +import crypto from 'crypto'; + +const td = new TelemetryDeck({ + appID: '' + user: '', + cryptoDigest: crypto.webcrypto.subtle.digest, +}); ``` -The default implementation can be found in `src/utils/store.js` and uses a monotone counter to keep track of the order of signals. +Please replace `` with the app ID in TelemetryDeck ([Dashboard](https://dashboard.telemetrydeck.com) -> App -> Set Up App). -### 📱 You need an App ID +You also need to identify your logged in user. Instead of ``, pass in any string that uniquely identifies your user, such as an email address. It will be cryptographically anonymized with a hash function. -Every application and website registered to TelemetryDeck has a unique ID that we use to assign incoming signals to the correct app. To get started, create a new app in the TelemetryDeck UI and copy the ID from there. +If can't specify a user identifer at initialization, you can set it later by setting `td.clientUser`. -### 👤 Optional: User Identifiers +Please note that `td.signal` is an async function that returns a promise. -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. +> [!NOTE] +> If you are using React Native, React Native does not support the `crypto` module, which is required for the SDK to work. We found [react-native-quick-crypto](https://github.com/margelo/react-native-quick-crypto) to be a suitable polyfill. Please note that this is not an officially supported solution. -Feel free to use personally identifiable information as the user identifier: We use a cryptographically secure double-hashing 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 processing or storing this data. -### 🚛 Optional: Payload +## Advanced Initalization Options -You can optionally attach an object with values to the signal. This will allow you to filter and aggregate signals by these values in the dashboard. +See the [source code](./src/telemetrydeck.js#L6-L17) for a full list of availble options acepted by the `TelemetryDeck` constructor. -Values will be stringified through `JSON.stringify()`, with few exceptions: -- Dates will be converted to ISO strings using `.toISOString()` -- Strings are passed as is (this prevents the JSON stringification of strings, which would add quotes around the string) -- `floatValue` is the only key in the payload that may hold a float value. Any value passed to this will be converted to a float using `Number.parseFloat()`. +# Sending Signals -#### Payload Recommendations for Web Apps +Send a basic signal by calling `td.signal()` with a signal type: -In most web apps you probably want to see a few default values which you can read from the browser. We recommend sending the following values in your custom payload: +```javascript +td.signal(''); +``` + +Send a signal with a custom payload by passing an object as the second argument. The payload's values will be [converted to Strings](./src/tests/store.test.js.js#L278-L310), except for `floatValue`, which can be a Float. ```javascript -td.signal('navigation', { - referrer: globalThis.document?.referrer, - locale: globalThis.navigator?.language, - url: globalThis.location?.href, - // ... +td.signal('Volume.Set', { + band: 'Spinal Tap', + floatValue: 11.0, }); ``` -#### Test Mode +# Advanced: Queueing Signals -You can enable test mode by setting `testMode` to `true` on your `TelemetryDeck` instance. +The `TelemetryDeck` class comes with a built-in queuing mechanism for storing signals until they are flushed in a single request. Queued signals are sent with `receivedAt` prefilled with the time they were queued. + +This uses an in-memory store by default. The store is not persisted between page reloads or app restarts. If you want to persist the store, you can pass a `store` object to the `TelemetryDeck` constructor. The store must implement the following interface: ```javascript -td.testMode = true; -td.signal('navigation', { - /* ... */ -}); // send with testMode enabled -td.testMode = false; -td.signal('navigation', { - /* ... */ -}); // send with testMode disabled +export class Store { + async push() // signal bodys are async and need to be awaited before stored + clear() // called after flush + values() // returns an array of resolved signal bodys in the order they were pushed +} ``` -### 📚 Full Docs +The default implementation can be found in `src/utils/store.js`. + +--- -Go to [telemetrydeck.com/docs](https://telemetrydeck.com/docs) to see all documentation articles +[TelemetryDeck](https://telemetrydeck.com?source=github) helps you build better products with live usage data. Try it out for free. \ No newline at end of file diff --git a/src/telemetrydeck.js b/src/telemetrydeck.js index c6130c6..5739656 100644 --- a/src/telemetrydeck.js +++ b/src/telemetrydeck.js @@ -9,10 +9,11 @@ import { version } from './utils/version.js'; * @property {string} appID the app ID to send telemetry data to * @property {string} clientUser the clientUser ID to send telemetry data to * @property {string} [target] the target URL to send telemetry data to - * @property {string} [sessionID] - * @property {string} [salt] - * @property {boolean} [testMode] - * @property {Store} [store] + * @property {string} [sessionID] An optional session ID to include in each signal + * @property {string} [salt] A salt to use when hashing the clientUser ID + * @property {boolean} [testMode] If "true", signals will be marked as test signals and only show up in Test Mode in the Dashbaord + * @property {Store} [store] A store to use for queueing signals + * @property {Function} [cryptoDigest] A function to use for calculating the SHA-256 hash of the clientUser ID. Null to use the browser's built-in crypto.subtle.digest function. */ export default class TelemetryDeck { @@ -27,7 +28,7 @@ export default class TelemetryDeck { * @param {TelemetryDeckOptions} options */ constructor(options = {}) { - const { target, appID, clientUser, sessionID, salt, testMode, store } = options; + const { target, appID, clientUser, sessionID, salt, testMode, store, cryptoDigest } = options; if (!appID) { throw new Error('appID is required'); @@ -40,9 +41,11 @@ export default class TelemetryDeck { this.sessionID = sessionID ?? randomString(); this.salt = salt; this.testMode = testMode ?? this.testMode; + this.cryptoDigest = cryptoDigest; } /** + * Send a TelemetryDeck signal * * @param {string} type the type of telemetry data to send * @param {TelemetryDeckPayload} [payload] custom payload to be stored with each signal @@ -56,6 +59,9 @@ export default class TelemetryDeck { } /** + * Enqueue a signal to be sent to TelemetryDeck later. + * + * Use flush() to send all queued signals. * * @param {string} type * @param {TelemetryDeckPayload} [payload] @@ -70,6 +76,9 @@ export default class TelemetryDeck { } /** + * Send all queued signals to TelemetryDeck. + * + * Enqueue signals with queue(). * * @returns > a promise with the response from the server, echoing the sent data */ @@ -81,8 +90,20 @@ export default class TelemetryDeck { return flushPromise; } + _clientUser = '' + _clientUserHashed = '' + + async _hashedClientUser(clientUser) { + if (clientUser !== this._clientUser) { + this._clientUserHashed = await sha256([this.clientUser, this.salt].join(''), this.cryptoDigest); + this._clientUser = this.clientUser; + } + + return this._clientUserHashed; + } + async _build(type, payload, options, receivedAt) { - const { appID, salt, testMode } = this; + const { appID, testMode } = this; let { clientUser, sessionID } = this; options = options ?? {}; @@ -101,7 +122,7 @@ export default class TelemetryDeck { throw new Error(`TelemetryDeck: "clientUser" is not set`); } - clientUser = await sha256([clientUser, salt].join('')); + clientUser = await this._hashedClientUser(clientUser); const body = { clientUser, diff --git a/src/utils/crypto.js b/src/utils/crypto.js deleted file mode 100644 index 111766f..0000000 --- a/src/utils/crypto.js +++ /dev/null @@ -1,10 +0,0 @@ -export async function getCrypto() { - let crypto = globalThis.crypto; - - if (!crypto) { - // eslint-disable-next-line unicorn/prefer-node-protocol - crypto = await import('crypto').then((m) => m.webcrypto); - } - - return crypto; -} diff --git a/src/utils/sha256.js b/src/utils/sha256.js index 1ed2974..b1beb62 100644 --- a/src/utils/sha256.js +++ b/src/utils/sha256.js @@ -1,14 +1,19 @@ -import { getCrypto } from './crypto.js'; - -// https://stackoverflow.com/a/48161723/54547 -export async function sha256(message) { - const crypto = await getCrypto(); - +/** + * Calculate the SHA-256 hash of a string using a provided crypto digest function. + * Defaults to globalThis.crypto.subtle.digest if available. + * + * // https://stackoverflow.com/a/48161723/54547 + * + * @param {Function} cryptoDigest + * @param {string} message + * @returns {Promise} + */ +export async function sha256(message, cryptoDigest = globalThis?.crypto?.subtle?.digest) { // encode as UTF-8 const messageBuffer = new TextEncoder().encode(message); // hash the message - const hashBuffer = await crypto.subtle.digest('SHA-256', messageBuffer); + const hashBuffer = await cryptoDigest('SHA-256', messageBuffer); // convert ArrayBuffer to Array const hashArray = [...new Uint8Array(hashBuffer)];