Skip to content

Commit

Permalink
First pass at client assigned feature flags
Browse files Browse the repository at this point in the history
  • Loading branch information
danielbachhuber committed Nov 12, 2024
1 parent ec5e822 commit 7aeda53
Show file tree
Hide file tree
Showing 5 changed files with 240 additions and 0 deletions.
64 changes: 64 additions & 0 deletions src/__tests__/featureflags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -993,6 +993,70 @@ describe('featureflags', () => {
})
})
})
describe('clientAssignedFeatureFlags', () => {
it('should match the correct variant', () => {
const flag = { key: 'test-flag', variants: { 'variant-1': 50, 'variant-2': 30, 'variant-3': 20 } }
// Repeating the hash function should produce the same output
expect(featureFlags._getMatchingVariant(flag)).toBe('variant-1')
expect(featureFlags._getMatchingVariant(flag)).toBe('variant-1')
expect(featureFlags._getMatchingVariant(flag)).toBe('variant-1')
expect(featureFlags._getMatchingVariant(flag)).toBe('variant-1')
})

it('should compute the correct lookup tables', () => {
const variants1 = {
'variant-1': 50,
'variant-2': 30,
'variant-3': 20,
}
expect(featureFlags._variantLookupTable(variants1)).toEqual([
{ value_min: 0, value_max: 50, key: 'variant-1' },
{ value_min: 50, value_max: 80, key: 'variant-2' },
{ value_min: 80, value_max: 100, key: 'variant-3' },
])

const variants2 = {
'variant-1': 33,
'variant-2': 33,
'variant-3': 34,
}
expect(featureFlags._variantLookupTable(variants2)).toEqual([
{ value_min: 0, value_max: 33, key: 'variant-1' },
{ value_min: 33, value_max: 66, key: 'variant-2' },
{ value_min: 66, value_max: 100, key: 'variant-3' },
])
})

it('should compute the correct hash values', () => {
const testFlag = 'test-flag'

// Repeating the hash function should produce the same output
// Same as: import hashlib; hashlib.sha1("test-flag.distinct_id_1".encode("utf-8")).hexdigest()[:15]
expect(featureFlags._hash('test-flag.distinct_id_1')).toBe('59f5e7274a66f06')
expect(featureFlags._hash('test-flag.distinct_id_1')).toBe('59f5e7274a66f06')
// A different input should produce a different hash
// Same as: import hashlib; hashlib.sha1("test-flag.distinct_id_2".encode("utf-8")).hexdigest()[:15]
expect(featureFlags._hash('test-flag.distinct_id_2')).toBe('59589dd697c3745')

// Same identifier should get same hash
// distinct_id_1 + test-flag = 0.35140843114131903
expect(featureFlags._get_hash(testFlag, 'distinct_id_1')).toBeCloseTo(0.35140843114131903)
expect(featureFlags._get_hash(testFlag, 'distinct_id_1')).toBeCloseTo(0.35140843114131903)

// Different identifiers should get different hashes
// distinct_id_2 + test-flag = 0.34900843133051557
expect(featureFlags._get_hash(testFlag, 'distinct_id_2')).toBeCloseTo(0.34900843133051557)

// Different salt should produce different hash
// distinct_id_1 + test-flag + salt = 0.05659409091269017
expect(featureFlags._get_hash(testFlag, 'distinct_id_1', 'salt')).toBeCloseTo(0.05659409091269017)

// Different flag keys should produce different hashes
const differentFlag = 'different-flag'
// distinct_id_1 + different-flag = 0.5078604702829128
expect(featureFlags._get_hash(differentFlag, 'distinct_id_1')).toBeCloseTo(0.5078604702829128)
})
})
})

describe('parseFeatureFlagDecideResponse', () => {
Expand Down
27 changes: 27 additions & 0 deletions src/__tests__/posthog-core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -810,6 +810,33 @@ describe('posthog core', () => {
})
})

describe('client assigned feature flags', () => {
it('onFeatureFlags should be called immediately if client assigned feature flags are defined', () => {
let called = false
const posthog = posthogWith({
bootstrap: {
clientAssignedFeatureFlags: [{ key: 'test-flag', variants: { test: 0.5, control: 0.5 } }],
},
})

posthog.featureFlags.onFeatureFlags(() => (called = true))
expect(called).toEqual(true)
})

it('onFeatureFlags should not be called immediately if client assigned feature flags bootstrap is empty', () => {
let called = false

const posthog = posthogWith({
bootstrap: {
clientAssignedFeatureFlags: [],
},
})

posthog.featureFlags.onFeatureFlags(() => (called = true))
expect(called).toEqual(false)
})
})

describe('init()', () => {
jest.spyOn(window, 'window', 'get')

Expand Down
28 changes: 28 additions & 0 deletions src/posthog-core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -478,6 +478,26 @@ export class PostHog {
})
}

if (this._hasClientAssignedFeatureFlags()) {
const clientAssignedFeatureFlags: Record<string, string | boolean> = {}
const clientAssignedFeatureFlagPayloads: Record<string, JsonType> = {}

for (const flag of this.config.bootstrap?.clientAssignedFeatureFlags || []) {
const variant = this.featureFlags._getMatchingVariant(flag)
if (variant) {
clientAssignedFeatureFlags[flag.key] = variant
if (flag.payload) {
clientAssignedFeatureFlagPayloads[flag.key] = flag.payload
}
}
}

this.featureFlags.receivedFeatureFlags({
featureFlags: clientAssignedFeatureFlags,
featureFlagPayloads: clientAssignedFeatureFlagPayloads,
})
}

if (this._hasBootstrappedFeatureFlags()) {
const activeFlags = Object.keys(config.bootstrap?.featureFlags || {})
.filter((flag) => !!config.bootstrap?.featureFlags?.[flag])
Expand Down Expand Up @@ -746,6 +766,14 @@ export class PostHog {
execute(capturing_calls, this)
}

_hasClientAssignedFeatureFlags(): boolean {
return (
(this.config.bootstrap?.clientAssignedFeatureFlags &&
Object.keys(this.config.bootstrap?.clientAssignedFeatureFlags).length > 0) ||
false
)
}

_hasBootstrappedFeatureFlags(): boolean {
return (
(this.config.bootstrap?.featureFlags && Object.keys(this.config.bootstrap?.featureFlags).length > 0) ||
Expand Down
115 changes: 115 additions & 0 deletions src/posthog-featureflags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
Properties,
JsonType,
Compression,
ClientAssignedFeatureFlag,
} from './types'
import { PostHogPersistence } from './posthog-persistence'

Expand Down Expand Up @@ -483,4 +484,118 @@ export class PostHogFeatureFlags {
this.instance.unregister(STORED_GROUP_PROPERTIES_KEY)
}
}

_getMatchingVariant(featureFlag: ClientAssignedFeatureFlag): string | null {
const lookupTable = this._variantLookupTable(featureFlag.variants)
const hash = this._get_hash(featureFlag.key, this.instance.get_distinct_id(), 'variant')

for (const variant of lookupTable) {
if (hash >= variant.value_min && hash < variant.value_max) {
return variant.key
}
}
return null
}

// TODO how should this behave for erroneous values?
_variantLookupTable(variants: Record<string, number>): { value_min: number; value_max: number; key: string }[] {
const lookupTable: { value_min: number; value_max: number; key: string }[] = []
let valueMin = 0

for (const [variant, percentage] of Object.entries(variants)) {
const valueMax = valueMin + percentage
lookupTable.push({
value_min: valueMin,
value_max: valueMax,
key: variant,
})
valueMin = valueMax
}
return lookupTable
}

_get_hash(featureFlagKey: string, distinctId: string, salt: string = ''): number {
const hashKey = `${featureFlagKey}.${distinctId}${salt}`
const hashHex = this._hash(hashKey)
// TODO do we care about IE11 support for BigInt?
const hashInt = BigInt(`0x${hashHex}`)
const LONG_SCALE = BigInt('0xFFFFFFFFFFFFFFF')
return Number(hashInt) / Number(LONG_SCALE) // Normalize the hash to a value between 0 and 1
}

// TODO how much do we trust sonnet to write a hashing function?
_hash(input: string): string {
function rotateLeft(n: number, s: number): number {
return ((n << s) | (n >>> (32 - s))) >>> 0
}

let H0 = 0x67452301
let H1 = 0xefcdab89
let H2 = 0x98badcfe
let H3 = 0x10325476
let H4 = 0xc3d2e1f0

// Convert string to byte array
const bytes: number[] = []
for (let i = 0; i < input.length; i++) {
const char = input.charCodeAt(i)
bytes.push(char & 0xff)
}

// Add padding
bytes.push(0x80)
while ((bytes.length * 8) % 512 !== 448) {
bytes.push(0)
}

const bitLen = input.length * 8
bytes.push(0, 0, 0, 0) // JavaScript bitwise ops are 32-bit
bytes.push((bitLen >>> 24) & 0xff)
bytes.push((bitLen >>> 16) & 0xff)
bytes.push((bitLen >>> 8) & 0xff)
bytes.push(bitLen & 0xff)

// Process blocks
for (let i = 0; i < bytes.length; i += 64) {
const w = new Array(80)
for (let j = 0; j < 16; j++) {
w[j] =
(bytes[i + j * 4] << 24) |
(bytes[i + j * 4 + 1] << 16) |
(bytes[i + j * 4 + 2] << 8) |
bytes[i + j * 4 + 3]
}

for (let j = 16; j < 80; j++) {
w[j] = rotateLeft(w[j - 3] ^ w[j - 8] ^ w[j - 14] ^ w[j - 16], 1)
}

let [a, b, c, d, e] = [H0, H1, H2, H3, H4]

for (let j = 0; j < 80; j++) {
const f =
j < 20 ? (b & c) | (~b & d) : j < 40 ? b ^ c ^ d : j < 60 ? (b & c) | (b & d) | (c & d) : b ^ c ^ d

const k = j < 20 ? 0x5a827999 : j < 40 ? 0x6ed9eba1 : j < 60 ? 0x8f1bbcdc : 0xca62c1d6

const temp = (rotateLeft(a, 5) + f + e + k + w[j]) >>> 0
e = d
d = c
c = rotateLeft(b, 30)
b = a
a = temp
}

H0 = (H0 + a) >>> 0
H1 = (H1 + b) >>> 0
H2 = (H2 + c) >>> 0
H3 = (H3 + d) >>> 0
H4 = (H4 + e) >>> 0
}

return [H0, H1, H2, H3, H4]
.map((n) => n.toString(16).padStart(8, '0'))
.join('')
.slice(0, 15)
}
}
6 changes: 6 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,10 +77,16 @@ export interface AutocaptureConfig {

capture_copied_text?: boolean
}
export type ClientAssignedFeatureFlag = {
key: string
variants: Record<string, number>
payload?: JsonType
}

export interface BootstrapConfig {
distinctID?: string
isIdentifiedID?: boolean
clientAssignedFeatureFlags?: ClientAssignedFeatureFlag[]
featureFlags?: Record<string, boolean | string>
featureFlagPayloads?: Record<string, JsonType>
/**
Expand Down

0 comments on commit 7aeda53

Please sign in to comment.