Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Feature Flags support #29

Merged
merged 13 commits into from
Jun 8, 2021
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
node_modules
.nyc_output
.idea
local.js
144 changes: 144 additions & 0 deletions feature-flags.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
const axios = require('axios')
const crypto = require('crypto')
const ms = require('ms')
const version = require('./package.json').version

const LONG_SCALE = 0xfffffffffffffff

class ValueError extends Error {}

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 ValueError(
`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,
}
35 changes: 32 additions & 3 deletions index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ declare module 'posthog-node' {
host?: string
api_host?: string
enable?: boolean
personalApiKey?: string
featureFlagsPollingInterval?: number
}

interface CommonParamsInterfacePropertiesProp {
Expand All @@ -34,7 +36,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,
Expand All @@ -43,7 +45,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.
Expand All @@ -56,6 +58,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<boolean>


/**
* @description Force an immediate reload of the polled feature flags. Please note that they are
* already polled automatically at a regular interval.
*/
reloadFeatureFlags(): Promise<void>

/**
* @description Flushes the events still in the queue and clears the feature flags poller to allow for
* a clean shutdown.
*/
shutdown(): void
}

}
49 changes: 48 additions & 1 deletion index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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) {
Expand All @@ -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,
Expand All @@ -48,6 +54,31 @@ 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) {
Expand Down Expand Up @@ -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
*
Expand Down Expand Up @@ -256,6 +296,13 @@ class PostHog {
})
}

shutdown() {
if (this.personalApiKey) {
this.featureFlagsPoller.stopPoller()
}
this.flush()
}

_isErrorRetryable(error) {
// Retry Network Errors.
if (axiosRetry.isNetworkError(error)) {
Expand Down
5 changes: 2 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
"scripts": {
"dependencies": "yarn",
"size": "size-limit",
"test": "standard && nyc ava",
"test": "nyc ava",
"report-coverage": "nyc report --reporter=lcov > coverage.lcov && codecov",
"format": "prettier --write ."
},
Expand Down Expand Up @@ -63,7 +63,6 @@
"prettier": "^2.3.1",
"sinon": "^7.3.2",
"size-limit": "^1.3.5",
"snyk": "^1.171.1",
"standard": "^12.0.1"
"snyk": "^1.171.1"
}
}
60 changes: 60 additions & 0 deletions tests/assets/mockFlagsResponse.js
Original file line number Diff line number Diff line change
@@ -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 }
Loading