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

feat(flags): Add feature flag version info, evaluation reason, and id to $feature_flag_called event #427

Merged
merged 18 commits into from
Mar 28, 2025
Merged
Show file tree
Hide file tree
Changes from 14 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,15 @@ The short-term goal is to have a dedicated React Native library free from any pl

1. Installation to Expo managed projects without any separate compilation / ejecting
2. Tighter integration to RN enabling hooks, context, autocapture etc.

## Running Tests

```bash
yarn test
```

Or to run a single test suite:

```bash
yarn test:node
```
6 changes: 4 additions & 2 deletions examples/example-node/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,10 @@ app.get('/user/:userId/action', (req, res) => {

app.get('/user/:userId/flags/:flagId', async (req, res) => {
const flag = await posthog.getFeatureFlag(req.params.flagId, req.params.userId).catch((e) => console.error(e))

res.send({ [req.params.flagId]: flag })
const payload = await posthog
.getFeatureFlagPayload(req.params.flagId, req.params.userId)
.catch((e) => console.error(e))
res.send({ [req.params.flagId]: { flag, payload } })
})

const server = app.listen(8020, () => {
Expand Down
2 changes: 2 additions & 0 deletions jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ module.exports = {
fakeTimers: { enableGlobally: true },
transformIgnorePatterns: ['<rootDir>/node_modules/'],
testPathIgnorePatterns: ['<rootDir>/lib/', '/node_modules/', '/examples/'],
silent: true,
verbose: false,
Comment on lines +14 to +15
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I turned these off because there's a lot of console output when running tests which adds a lot of noise to the test output and makes it hard to find what really went wrong.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah, these can be configured at test time through CLI arguments anyway.


globals: {
'ts-jest': {
Expand Down
194 changes: 194 additions & 0 deletions posthog-core/src/featureFlagUtils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
import {
FeatureFlagDetail,
FeatureFlagValue,
JsonType,
PostHogDecideResponse,
PostHogV3DecideResponse,
PostHogV4DecideResponse,
PostHogFlagsAndPayloadsResponse,
PartialWithRequired,
PostHogFeatureFlagsResponse,
} from './types'

export const normalizeDecideResponse = (
decideResponse:
| PartialWithRequired<PostHogV4DecideResponse, 'flags'>
| PartialWithRequired<PostHogV3DecideResponse, 'featureFlags' | 'featureFlagPayloads'>
): PostHogFeatureFlagsResponse => {
if ('flags' in decideResponse) {
// Convert v4 format to v3 format
const featureFlags = getFlagValuesFromFlags(decideResponse.flags)
const featureFlagPayloads = getPayloadsFromFlags(decideResponse.flags)

return {
...decideResponse,
featureFlags,
featureFlagPayloads,
}
} else {
// Convert v3 format to v4 format
const featureFlags = decideResponse.featureFlags ?? {}
const featureFlagPayloads = Object.fromEntries(
Object.entries(decideResponse.featureFlagPayloads || {}).map(([k, v]) => [k, parsePayload(v)])
)

const flags = Object.fromEntries(
Object.entries(featureFlags).map(([key, value]) => [
key,
getFlagDetailFromFlagAndPayload(key, value, featureFlagPayloads[key]),
])
)

return {
...decideResponse,
featureFlags,
featureFlagPayloads,
flags,
}
}
}

function getFlagDetailFromFlagAndPayload(
key: string,
value: FeatureFlagValue,
payload: JsonType | undefined
): FeatureFlagDetail {
return {
key: key,
enabled: typeof value === 'string' ? true : value,
variant: typeof value === 'string' ? value : undefined,
reason: undefined,
metadata: {
id: undefined,
version: undefined,
payload: payload ? JSON.stringify(payload) : undefined,
description: undefined,
},
}
}

/**
* Get the flag values from the flags v4 response.
* @param flags - The flags
* @returns The flag values
*/
export const getFlagValuesFromFlags = (
flags: PostHogDecideResponse['flags']
): PostHogDecideResponse['featureFlags'] => {
return Object.fromEntries(
Object.entries(flags ?? {})
.map(([key, detail]) => [key, getFeatureFlagValue(detail)])
.filter(([, value]): boolean => value !== undefined)
)
}

/**
* Get the payloads from the flags v4 response.
* @param flags - The flags
* @returns The payloads
*/
export const getPayloadsFromFlags = (
flags: PostHogDecideResponse['flags']
): PostHogDecideResponse['featureFlagPayloads'] => {
const safeFlags = flags ?? {}
return Object.fromEntries(
Object.keys(safeFlags)
.filter((flag) => {
const details = safeFlags[flag]
return details.enabled && details.metadata && details.metadata.payload !== undefined
})
.map((flag) => {
const payload = safeFlags[flag].metadata?.payload as string
return [flag, payload ? parsePayload(payload) : undefined]
})
)
}

/**
* Get the flag details from the legacy v3 flags and payloads. As such, it will lack the reason, id, version, and description.
* @param decideResponse - The decide response
* @returns The flag details
*/
export const getFlagDetailsFromFlagsAndPayloads = (
decideResponse: PostHogFeatureFlagsResponse
): PostHogDecideResponse['flags'] => {
const flags = decideResponse.featureFlags ?? {}
const payloads = decideResponse.featureFlagPayloads ?? {}
return Object.fromEntries(
Object.entries(flags).map(([key, value]) => [
key,
{
key: key,
enabled: typeof value === 'string' ? true : value,
variant: typeof value === 'string' ? value : undefined,
reason: undefined,
metadata: {
id: undefined,
version: undefined,
payload: payloads?.[key] ? JSON.stringify(payloads[key]) : undefined,
description: undefined,
},
},
])
)
}

export const getFeatureFlagValue = (detail: FeatureFlagDetail | undefined): FeatureFlagValue | undefined => {
return detail === undefined ? undefined : detail.variant ?? detail.enabled
}

export const parsePayload = (response: any): any => {
if (typeof response !== 'string') {
return response
}

try {
return JSON.parse(response)
} catch {
return response
}
}

/**
* Get the normalized flag details from the flags and payloads.
* This is used to convert things like boostrap and stored feature flags and payloads to the v4 format.
* This helps us ensure backwards compatibility.
* If a key exists in the featureFlagPayloads that is not in the featureFlags, we treat it as a true feature flag.
*
* @param featureFlags - The feature flags
* @param featureFlagPayloads - The feature flag payloads
* @returns The normalized flag details
*/
export const createDecideResponseFromFlagsAndPayloads = (
featureFlags: PostHogV3DecideResponse['featureFlags'],
featureFlagPayloads: PostHogV3DecideResponse['featureFlagPayloads']
): PostHogFeatureFlagsResponse => {
// If a feature flag payload key is not in the feature flags, we treat it as true feature flag.
const allKeys = [...new Set([...Object.keys(featureFlags ?? {}), ...Object.keys(featureFlagPayloads ?? {})])]
const enabledFlags = allKeys
.filter((flag) => !!featureFlags[flag] || !!featureFlagPayloads[flag])
.reduce((res: Record<string, FeatureFlagValue>, key) => ((res[key] = featureFlags[key] ?? true), res), {})

const flagDetails: PostHogFlagsAndPayloadsResponse = {
featureFlags: enabledFlags,
featureFlagPayloads: featureFlagPayloads ?? {},
}

return normalizeDecideResponse(flagDetails as PostHogV3DecideResponse)
}

export const updateFlagValue = (flag: FeatureFlagDetail, value: FeatureFlagValue): FeatureFlagDetail => {
return {
...flag,
enabled: getEnabledFromValue(value),
variant: getVariantFromValue(value),
}
}

function getEnabledFromValue(value: FeatureFlagValue): boolean {
return typeof value === 'string' ? true : value
}

function getVariantFromValue(value: FeatureFlagValue): string | undefined {
return typeof value === 'string' ? value : undefined
}
Loading