diff --git a/.gitignore b/.gitignore index 8d76113..c9250e7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ node_modules .nyc_output .idea +local.js \ No newline at end of file diff --git a/event-validation.js b/event-validation.js index ad1eb1c..f5a018c 100644 --- a/event-validation.js +++ b/event-validation.js @@ -2,7 +2,7 @@ var type = require('component-type') var join = require('join-component') var assert = require('assert') -// PostHog messages can be a maximum of 32kb. +// PostHog messages can be a maximum of 32 kB. var MAX_SIZE = 32 << 10 module.exports = eventValidation @@ -74,7 +74,7 @@ function validateGenericEvent(event) { assert(type(event) === 'object', 'You must pass a message object.') var json = JSON.stringify(event) // Strings are variable byte encoded, so json.length is not sufficient. - assert(Buffer.byteLength(json, 'utf8') < MAX_SIZE, 'Your message must be < 32kb.') + assert(Buffer.byteLength(json, 'utf8') < MAX_SIZE, 'Your message must be < 32 kB.') for (var key in genericValidationRules) { var val = event[key] diff --git a/feature-flags.js b/feature-flags.js new file mode 100644 index 0000000..bce6d92 --- /dev/null +++ b/feature-flags.js @@ -0,0 +1,143 @@ +const axios = require('axios') +const crypto = require('crypto') +const ms = require('ms') +const version = require('./package.json').version + +const LONG_SCALE = 0xfffffffffffffff + + +class FeatureFlagsPoller { + constructor({ pollingInterval, personalApiKey, projectApiKey, timeout, host, featureFlagCalledCallback }) { + this.pollingInterval = pollingInterval + this.personalApiKey = personalApiKey + this.featureFlags = [] + this.loadedSuccessfullyOnce = false + this.timeout = timeout + this.projectApiKey = projectApiKey + this.featureFlagCalledCallback = featureFlagCalledCallback + this.host = host + this.poller = null + + void this.loadFeatureFlags() + } + + async isFeatureEnabled(key, distinctId, defaultResult = false) { + await this.loadFeatureFlags() + + if (!this.loadedSuccessfullyOnce) { + return defaultResult + } + + let featureFlag = null + + for (const flag of this.featureFlags) { + if (key === flag.key) { + featureFlag = flag + break + } + } + + if (!featureFlag) { + return defaultResult + } + + let isFlagEnabledResponse + + if (featureFlag.is_simple_flag) { + isFlagEnabledResponse = this._isSimpleFlagEnabled({ + key, + distinctId, + rolloutPercentage: featureFlag.rolloutPercentage, + }) + } else { + const res = await this._request({ path: 'decide', method: 'POST', data: { distinct_id: distinctId } }) + isFlagEnabledResponse = res.data.featureFlags.indexOf(key) >= 0 + } + + this.featureFlagCalledCallback(key, distinctId, isFlagEnabledResponse) + return isFlagEnabledResponse + } + + async loadFeatureFlags(forceReload = false) { + if (!this.loadedSuccessfullyOnce || forceReload) { + await this._loadFeatureFlags() + } + } + + /* istanbul ignore next */ + async _loadFeatureFlags() { + if (this.poller) { + clearTimeout(this.poller) + this.poller = null + } + this.poller = setTimeout(() => this._loadFeatureFlags(), this.pollingInterval) + + const res = await this._request({ path: 'api/feature_flag', usePersonalApiKey: true }) + + if (res && res.status === 401) { + throw new Error( + `Your personalApiKey is invalid. Are you sure you're not using your Project API key? More information: https://posthog.com/docs/api/overview` + ) + } + + this.featureFlags = res.data.results + this.loadedSuccessfullyOnce = true + } + + // sha1('a.b') should equal '69f6642c9d71b463485b4faf4e989dc3fe77a8c6' + // integerRepresentationOfHashSubset / LONG_SCALE for sha1('a.b') should equal 0.4139158829615955 + _isSimpleFlagEnabled({ key, distinctId, rolloutPercentage }) { + if (!rolloutPercentage) { + return true + } + const sha1Hash = crypto.createHash('sha1') + sha1Hash.update(`${key}.${distinctId}`) + const integerRepresentationOfHashSubset = parseInt(sha1Hash.digest('hex').slice(0, 15), 16) + return integerRepresentationOfHashSubset / LONG_SCALE <= rolloutPercentage / 100 + } + + /* istanbul ignore next */ + async _request({ path, method = 'GET', usePersonalApiKey = false, data = {} }) { + let headers = { + 'Content-Type': 'application/json', + } + + if (usePersonalApiKey) { + headers = { ...headers, Authorization: `Bearer ${this.personalApiKey}` } + } else { + data = { ...data, token: this.projectApiKey } + } + + if (typeof window === 'undefined') { + headers['user-agent'] = `posthog-node/${version}` + } + + const req = { + method: method, + url: `${this.host}/${path}/`, + headers: headers, + data: JSON.stringify(data), + } + + if (this.timeout) { + req.timeout = typeof this.timeout === 'string' ? ms(this.timeout) : this.timeout + } + + let res + try { + res = await axios(req) + } catch (err) { + throw new Error(`Request to ${path} failed with error: ${err.message}`) + } + + return res + } + + stopPoller() { + clearTimeout(this.poller) + } +} + +module.exports = { + FeatureFlagsPoller, +} diff --git a/index.d.ts b/index.d.ts index b9eebbc..6f14dc9 100644 --- a/index.d.ts +++ b/index.d.ts @@ -6,8 +6,9 @@ declare module 'posthog-node' { flushAt?: number flushInterval?: number host?: string - api_host?: string enable?: boolean + personalApiKey?: string + featureFlagsPollingInterval?: number } interface CommonParamsInterfacePropertiesProp { @@ -34,7 +35,7 @@ declare module 'posthog-node' { * @param event We recommend using [verb] [noun], like movie played or movie updated to easily identify what your events mean later on. * @param properties OPTIONAL | which can be a dict with any information you'd like to add */ - capture({ distinctId, event, properties }: EventMessage): PostHog + capture({ distinctId, event, properties }: EventMessage): void /** * @description Identify lets you add metadata on your users so you can more easily identify who they are in PostHog, @@ -43,7 +44,7 @@ declare module 'posthog-node' { * @param distinctId which uniquely identifies your user * @param properties with a dict with any key: value pairs */ - identify({ distinctId, properties }: IdentifyMessage): PostHog + identify({ distinctId, properties }: IdentifyMessage): void /** * @description To marry up whatever a user does before they sign up or log in with what they do after you need to make an alias call. @@ -56,6 +57,33 @@ declare module 'posthog-node' { * @param distinctId the current unique id * @param alias the unique ID of the user before */ - alias(data: { distinctId: string; alias: string }): PostHog + alias(data: { distinctId: string; alias: string }): void + + + /** + * @description PostHog feature flags (https://posthog.com/docs/features/feature-flags) + * allow you to safely deploy and roll back new features. Once you've created a feature flag in PostHog, + * you can use this method to check if the flag is on for a given user, allowing you to create logic to turn + * features on and off for different user groups or individual users. + * IMPORTANT: To use this method, you need to specify `personalApiKey` in your config! More info: https://posthog.com/docs/api/overview + * @param key the unique key of your feature flag + * @param distinctId the current unique id + * @param defaultResult optional - default value to be returned if the feature flag is not on for the user + */ + isFeatureEnabled(key: string, distinctId: string, defaultResult?: boolean): Promise + + + /** + * @description Force an immediate reload of the polled feature flags. Please note that they are + * already polled automatically at a regular interval. + */ + reloadFeatureFlags(): Promise + + /** + * @description Flushes the events still in the queue and clears the feature flags poller to allow for + * a clean shutdown. + */ + shutdown(): void } + } diff --git a/index.js b/index.js index 1529472..a18d39c 100644 --- a/index.js +++ b/index.js @@ -7,10 +7,12 @@ const axiosRetry = require('axios-retry') const ms = require('ms') const version = require('./package.json').version const looselyValidate = require('./event-validation') +const { FeatureFlagsPoller } = require('./feature-flags') const setImmediate = global.setImmediate || process.nextTick.bind(process) const noop = () => {} +const FIVE_MINUTES = 5 * 60 * 1000 class PostHog { /** * Initialize a new `PostHog` with your PostHog project's `apiKey` and an @@ -22,6 +24,8 @@ class PostHog { * @property {Number} flushInterval (default: 10000) * @property {String} host (default: 'https://app.posthog.com') * @property {Boolean} enable (default: true) + * @property {String} featureFlagsPollingInterval (default: 300000) + * @property {String} personalApiKey */ constructor(apiKey, options) { @@ -36,6 +40,8 @@ class PostHog { this.flushAt = Math.max(options.flushAt, 1) || 20 this.flushInterval = typeof options.flushInterval === 'number' ? options.flushInterval : 10000 this.flushed = false + this.personalApiKey = options.personalApiKey + Object.defineProperty(this, 'enable', { configurable: false, writable: false, @@ -48,16 +54,41 @@ class PostHog { retryCondition: this._isErrorRetryable, retryDelay: axiosRetry.exponentialDelay, }) + + if (this.personalApiKey) { + const featureFlagCalledCallback = (key, distinctId, isFlagEnabledResponse) => { + this.capture({ + distinctId, + event: '$feature_flag_called', + properties: { + $feature_flag: key, + $feature_flag_response: isFlagEnabledResponse, + }, + }) + } + + this.featureFlagsPoller = new FeatureFlagsPoller({ + featureFlagsPollingInterval: + typeof options.featureFlagsPollingInterval === 'number' + ? options.featureFlagsPollingInterval + : FIVE_MINUTES, + personalApiKey: options.personalApiKey, + projectApiKey: apiKey, + timeout: options.timeout || false, + host: this.host, + featureFlagCalledCallback, + }) + } } _validate(message, type) { try { looselyValidate(message, type) } catch (e) { - if (e.message === 'Your message must be < 32kb.') { + if (e.message === 'Your message must be < 32 kB.') { console.log( - 'Your message must be < 32kb. This is currently surfaced as a warning to allow clients to update. Versions released after August 1, 2018 will throw an error instead. Please update your code before then.', - message + 'Your message must be < 32 kB.', + JSON.stringify(message) ) return } @@ -183,10 +214,19 @@ class PostHog { } if (this.flushInterval && !this.timer) { - this.timer = setTimeout(this.flush.bind(this), this.flushInterval) + this.timer = setTimeout(() => this.flush(), this.flushInterval) } } + async isFeatureEnabled(key, distinctId, defaultResult) { + assert(this.personalApiKey, 'You have to specify the option personalApiKey to use feature flags.') + return await this.featureFlagsPoller.isFeatureEnabled(key, distinctId, defaultResult) + } + + async reloadFeatureFlags() { + await this.featureFlagsPoller.loadFeatureFlags(true) + } + /** * Flush the current queue * @@ -256,6 +296,13 @@ class PostHog { }) } + shutdown() { + if (this.personalApiKey) { + this.featureFlagsPoller.stopPoller() + } + this.flush() + } + _isErrorRetryable(error) { // Retry Network Errors. if (axiosRetry.isNetworkError(error)) { diff --git a/package.json b/package.json index 34c9669..2023ccd 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,7 @@ "scripts": { "dependencies": "yarn", "size": "size-limit", - "test": "nyc ava", + "test": "nyc ava tests/*.js", "report-coverage": "nyc report --reporter=lcov > coverage.lcov && codecov", "format": "prettier --write ." }, diff --git a/tests/assets/mockFlagsResponse.js b/tests/assets/mockFlagsResponse.js new file mode 100644 index 0000000..68184cb --- /dev/null +++ b/tests/assets/mockFlagsResponse.js @@ -0,0 +1,60 @@ +const mockSimpleFlagResponse = { + "count": 1, + "next": null, + "previous": null, + "results": [ + { + "id": 719, + "name": "", + "key": "simpleFlag", + "filters": { + "groups": [ + { + "properties": [], + "rollout_percentage": null + } + ] + }, + "deleted": false, + "active": true, + "is_simple_flag": true, + "rollout_percentage": null + }, + { + "id": 720, + "name": "", + "key": "enabled-flag", + "filters": { + "groups": [ + { + "properties": [], + "rollout_percentage": null + } + ] + }, + "deleted": false, + "active": true, + "is_simple_flag": false, + "rollout_percentage": null + }, + { + "id": 721, + "name": "", + "key": "disabled-flag", + "filters": { + "groups": [ + { + "properties": [], + "rollout_percentage": null + } + ] + }, + "deleted": false, + "active": true, + "is_simple_flag": false, + "rollout_percentage": null + }, + ] +} + +module.exports = { mockSimpleFlagResponse } \ No newline at end of file diff --git a/test.js b/tests/test.js similarity index 81% rename from test.js rename to tests/test.js index e76ccfa..74baa14 100644 --- a/test.js +++ b/tests/test.js @@ -4,8 +4,10 @@ import express from 'express' import delay from 'delay' import pify from 'pify' import test from 'ava' -import PostHog from '.' -import { version } from './package' +import PostHog from '../index' +import { version } from '../package' +import { mockSimpleFlagResponse } from './assets/mockFlagsResponse' + const noop = () => {} @@ -57,6 +59,14 @@ test.before.cb((t) => { res.json({}) }) + .get('/api/feature_flag', (req, res) => { + return res.status(200).json(mockSimpleFlagResponse) + }) + .post('/decide', (req, res) => { + return res.status(200).json({ + featureFlags: ['enabled-flag'] + }) + }) .listen(port, t.end) }) @@ -265,7 +275,7 @@ test('flush - time out if configured', async (t) => { }, ] await t.throws(client.flush(), 'timeout of 500ms exceeded') -}) +}) test('flush - skip when client is disabled', async (t) => { const client = createClient({ enable: false }) @@ -397,7 +407,7 @@ test('isErrorRetryable', (t) => { t.false(client._isErrorRetryable({ response: { status: 200 } })) }) -test('allows messages > 32kb', (t) => { +test('allows messages > 32 kB', (t) => { const client = createClient() const event = { @@ -413,3 +423,58 @@ test('allows messages > 32kb', (t) => { client.capture(event, noop) }) }) + +test('feature flags - require personalApiKey', async (t) => { + const client = createClient() + + await t.throws(client.isFeatureEnabled('simpleFlag', 'some id'), 'You have to specify the option personalApiKey to use feature flags.') + + client.shutdown() +}) + +test('feature flags - isSimpleFlag', async (t) => { + const client = createClient({ personalApiKey: 'my very secret key' }) + + const isEnabled = await client.isFeatureEnabled('simpleFlag', 'some id') + + t.is(isEnabled, true) + + client.shutdown() +}) + +test('feature flags - complex flags', async (t) => { + const client = createClient({ personalApiKey: 'my very secret key' }) + + const expectedEnabledFlag = await client.isFeatureEnabled('enabled-flag', 'some id') + const expectedDisabledFlag = await client.isFeatureEnabled('disabled-flag', 'some id') + + t.is(expectedEnabledFlag, true) + t.is(expectedDisabledFlag, false) + + client.shutdown() +}) + +test('feature flags - default override', async (t) => { + const client = createClient({ personalApiKey: 'my very secret key' }) + + let flagEnabled = await client.isFeatureEnabled('i-dont-exist', 'some id') + t.is(flagEnabled, false) + + flagEnabled = await client.isFeatureEnabled('i-dont-exist', 'some id', true) + t.is(flagEnabled, true) + + client.shutdown() +}) + +test('feature flags - simple flag calculation', async (t) => { + const client = createClient({ personalApiKey: 'my very secret key' }) + + // This tests that the hashing + mathematical operations across libs are consistent + let flagEnabled = client.featureFlagsPoller._isSimpleFlagEnabled({key: 'a', distinctId: 'b', rolloutPercentage: 42}) + t.is(flagEnabled, true) + + flagEnabled = client.featureFlagsPoller._isSimpleFlagEnabled({key: 'a', distinctId: 'b', rolloutPercentage: 40}) + t.is(flagEnabled, false) + + client.shutdown() +})