diff --git a/README.md b/README.md index 7bb01517..d5fdaab3 100644 --- a/README.md +++ b/README.md @@ -8,3 +8,16 @@ 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. + +## Contributing + +Information on how to contribute to the project and run tests can be found [here](CONTRIBUTING.md). + +## Releasing + +Information on how to release the individual libraries can be found [here](RELEASE.md). + +## License + +This project is licensed under the [MIT License](LICENSE). + diff --git a/examples/example-node/server.ts b/examples/example-node/server.ts index f0d0afc3..5a0dbf96 100644 --- a/examples/example-node/server.ts +++ b/examples/example-node/server.ts @@ -63,8 +63,15 @@ 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)) + const payload = await posthog + .getFeatureFlagPayload(req.params.flagId, req.params.userId) + .catch((e) => console.error(e)) + res.send({ [req.params.flagId]: { flag, payload } }) +}) - res.send({ [req.params.flagId]: flag }) +app.get('/user/:userId/flags', async (req, res) => { + const allFlags = await posthog.getAllFlagsAndPayloads(req.params.userId).catch((e) => console.error(e)) + res.send(allFlags) }) const server = app.listen(8020, () => { diff --git a/jest.config.js b/jest.config.js index 52950517..491e955d 100644 --- a/jest.config.js +++ b/jest.config.js @@ -11,6 +11,8 @@ module.exports = { fakeTimers: { enableGlobally: true }, transformIgnorePatterns: ['/node_modules/'], testPathIgnorePatterns: ['/lib/', '/node_modules/', '/examples/'], + silent: true, + verbose: false, globals: { 'ts-jest': { diff --git a/posthog-core/src/featureFlagUtils.ts b/posthog-core/src/featureFlagUtils.ts new file mode 100644 index 00000000..1762ca69 --- /dev/null +++ b/posthog-core/src/featureFlagUtils.ts @@ -0,0 +1,194 @@ +import { + FeatureFlagDetail, + FeatureFlagValue, + JsonType, + PostHogDecideResponse, + PostHogV3DecideResponse, + PostHogV4DecideResponse, + PostHogFlagsAndPayloadsResponse, + PartialWithRequired, + PostHogFeatureFlagsResponse, +} from './types' + +export const normalizeDecideResponse = ( + decideResponse: + | PartialWithRequired + | PartialWithRequired +): 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, 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 +} diff --git a/posthog-core/src/index.ts b/posthog-core/src/index.ts index b8b237a7..d35a6841 100644 --- a/posthog-core/src/index.ts +++ b/posthog-core/src/index.ts @@ -10,7 +10,21 @@ import { PostHogCaptureOptions, JsonType, PostHogRemoteConfig, + FeatureFlagValue, + PostHogV4DecideResponse, + PostHogV3DecideResponse, + PostHogFeatureFlagDetails, + PostHogFlagsStorageFormat, + FeatureFlagDetail, } from './types' +import { + createDecideResponseFromFlagsAndPayloads, + getFeatureFlagValue, + getFlagValuesFromFlags, + getPayloadsFromFlags, + normalizeDecideResponse, + updateFlagValue, +} from './featureFlagUtils' import { assert, currentISOTime, @@ -342,7 +356,7 @@ export abstract class PostHogCoreStateless { ): Promise { await this._initPromise - const url = `${this.host}/decide/?v=3` + const url = `${this.host}/decide/?v=4` const fetchOptions: PostHogFetchOptions = { method: 'POST', headers: { ...this.getCustomHeaders(), 'Content-Type': 'application/json' }, @@ -357,11 +371,12 @@ export abstract class PostHogCoreStateless { } // Don't retry /decide API calls return this.fetchWithRetry(url, fetchOptions, { retryCount: 0 }, this.featureFlagsRequestTimeoutMs) - .then((response) => response.json() as Promise) + .then((response) => response.json() as Promise) + .then((response) => normalizeDecideResponse(response)) .catch((error) => { this._events.emit('error', error) return undefined - }) + }) as Promise } protected async getFeatureFlagStateless( @@ -372,22 +387,21 @@ export abstract class PostHogCoreStateless { groupProperties: Record> = {}, disableGeoip?: boolean ): Promise<{ - response: boolean | string | undefined + response: FeatureFlagValue | undefined requestId: string | undefined }> { await this._initPromise - const decideResponse = await this.getFeatureFlagsStateless( + const flagDetailResponse = await this.getFeatureFlagDetailStateless( + key, distinctId, groups, personProperties, groupProperties, - disableGeoip, - [key] + disableGeoip ) - const featureFlags = decideResponse.flags - if (!featureFlags) { + if (flagDetailResponse === undefined) { // If we haven't loaded flags yet, or errored out, we respond with undefined return { response: undefined, @@ -395,8 +409,7 @@ export abstract class PostHogCoreStateless { } } - let response = featureFlags[key] - // `/decide` v3 returns all flags + let response = getFeatureFlagValue(flagDetailResponse.response) if (response === undefined) { // For cases where the flag is unknown, return false @@ -406,6 +419,45 @@ export abstract class PostHogCoreStateless { // If we have flags we either return the value (true or string) or false return { response, + requestId: flagDetailResponse.requestId, + } + } + + protected async getFeatureFlagDetailStateless( + key: string, + distinctId: string, + groups: Record = {}, + personProperties: Record = {}, + groupProperties: Record> = {}, + disableGeoip?: boolean + ): Promise< + | { + response: FeatureFlagDetail | undefined + requestId: string | undefined + } + | undefined + > { + await this._initPromise + + const decideResponse = await this.getFeatureFlagDetailsStateless( + distinctId, + groups, + personProperties, + groupProperties, + disableGeoip, + [key] + ) + + if (decideResponse === undefined) { + return undefined + } + + const featureFlags = decideResponse.flags + + const flagDetail = featureFlags[key] + + return { + response: flagDetail, requestId: decideResponse.requestId, } } @@ -467,14 +519,6 @@ export abstract class PostHogCoreStateless { return payloads } - protected _parsePayload(response: any): any { - try { - return JSON.parse(response) - } catch { - return response - } - } - protected async getFeatureFlagsStateless( distinctId: string, groups: Record = {}, @@ -513,6 +557,40 @@ export abstract class PostHogCoreStateless { }> { await this._initPromise + const featureFlagDetails = await this.getFeatureFlagDetailsStateless( + distinctId, + groups, + personProperties, + groupProperties, + disableGeoip, + flagKeysToEvaluate + ) + + if (!featureFlagDetails) { + return { + flags: undefined, + payloads: undefined, + requestId: undefined, + } + } + + return { + flags: featureFlagDetails.featureFlags, + payloads: featureFlagDetails.featureFlagPayloads, + requestId: featureFlagDetails.requestId, + } + } + + protected async getFeatureFlagDetailsStateless( + distinctId: string, + groups: Record = {}, + personProperties: Record = {}, + groupProperties: Record> = {}, + disableGeoip?: boolean, + flagKeysToEvaluate?: string[] + ): Promise { + await this._initPromise + const extraPayload: Record = {} if (disableGeoip ?? this.disableGeoip) { extraPayload['geoip_disable'] = true @@ -522,39 +600,32 @@ export abstract class PostHogCoreStateless { } const decideResponse = await this.getDecide(distinctId, groups, personProperties, groupProperties, extraPayload) + if (decideResponse === undefined) { + // We probably errored out, so return undefined + return undefined + } + // if there's an error on the decideResponse, log a console error, but don't throw an error - if (decideResponse?.errorsWhileComputingFlags) { + if (decideResponse.errorsWhileComputingFlags) { console.error( '[FEATURE FLAGS] Error while computing feature flags, some flags may be missing or incorrect. Learn more at https://posthog.com/docs/feature-flags/best-practices' ) } // Add check for quota limitation on feature flags - if (decideResponse?.quotaLimited?.includes(QuotaLimitedFeature.FeatureFlags)) { + if (decideResponse.quotaLimited?.includes(QuotaLimitedFeature.FeatureFlags)) { console.warn( '[FEATURE FLAGS] Feature flags quota limit exceeded - feature flags unavailable. Learn more about billing limits at https://posthog.com/docs/billing/limits-alerts' ) return { - flags: undefined, - payloads: undefined, + flags: {}, + featureFlags: {}, + featureFlagPayloads: {}, requestId: decideResponse?.requestId, } } - const flags = decideResponse?.featureFlags - const payloads = decideResponse?.featureFlagPayloads - - let parsedPayloads = payloads - - if (payloads) { - parsedPayloads = Object.fromEntries(Object.entries(payloads).map(([k, v]) => [k, this._parsePayload(v)])) - } - - return { - flags, - payloads: parsedPayloads, - requestId: decideResponse?.requestId, - } + return decideResponse } /*** @@ -899,34 +970,27 @@ export abstract class PostHogCore extends PostHogCoreStateless { } } - const bootstrapfeatureFlags = bootstrap.featureFlags - if (bootstrapfeatureFlags && Object.keys(bootstrapfeatureFlags).length) { - const bootstrapFlags = Object.keys(bootstrapfeatureFlags) - .filter((flag) => !!bootstrapfeatureFlags[flag]) - .reduce( - (res: Record, key) => ((res[key] = bootstrapfeatureFlags[key] || false), res), - {} - ) - - if (Object.keys(bootstrapFlags).length) { - this.setPersistedProperty(PostHogPersistedProperty.BootstrapFeatureFlags, bootstrapFlags) + const bootstrapFeatureFlags = bootstrap.featureFlags + const bootstrapFeatureFlagPayloads = bootstrap.featureFlagPayloads ?? {} + if (bootstrapFeatureFlags && Object.keys(bootstrapFeatureFlags).length) { + const normalizedBootstrapFeatureFlagDetails = createDecideResponseFromFlagsAndPayloads( + bootstrapFeatureFlags, + bootstrapFeatureFlagPayloads + ) - const currentFlags = - this.getPersistedProperty(PostHogPersistedProperty.FeatureFlags) || {} - const newFeatureFlags = { ...bootstrapFlags, ...currentFlags } - this.setKnownFeatureFlags(newFeatureFlags) - } + if (Object.keys(normalizedBootstrapFeatureFlagDetails.flags).length > 0) { + this.setBootstrappedFeatureFlagDetails(normalizedBootstrapFeatureFlagDetails) - const bootstrapFlagPayloads = bootstrap.featureFlagPayloads - if (bootstrapFlagPayloads && Object.keys(bootstrapFlagPayloads).length) { - this.setPersistedProperty(PostHogPersistedProperty.BootstrapFeatureFlagPayloads, bootstrapFlagPayloads) + const currentFeatureFlagDetails = this.getKnownFeatureFlagDetails() || { flags: {}, requestId: undefined } + const newFeatureFlagDetails = { + flags: { + ...normalizedBootstrapFeatureFlagDetails.flags, + ...currentFeatureFlagDetails.flags, + }, + requestId: normalizedBootstrapFeatureFlagDetails.requestId, + } - const currentFlagPayloads = - this.getPersistedProperty( - PostHogPersistedProperty.FeatureFlagPayloads - ) || {} - const newFeatureFlagPayloads = { ...bootstrapFlagPayloads, ...currentFlagPayloads } - this.setKnownFeatureFlagPayloads(newFeatureFlagPayloads) + this.setKnownFeatureFlagDetails(newFeatureFlagDetails) } } } @@ -975,7 +1039,7 @@ export abstract class PostHogCore extends PostHogCoreStateless { protected getCommonEventProperties(): any { const featureFlags = this.getFeatureFlags() - const featureVariantProperties: Record = {} + const featureVariantProperties: Record = {} if (featureFlags) { for (const [feature, variant] of Object.entries(featureFlags)) { featureVariantProperties[`$feature/${feature}`] = variant @@ -1358,8 +1422,7 @@ export abstract class PostHogCore extends PostHogCoreStateless { // we only dont load flags if the remote config has no feature flags if (response.hasFeatureFlags === false) { // resetting flags to empty object - this.setKnownFeatureFlags({}) - this.setKnownFeatureFlagPayloads({}) + this.setKnownFeatureFlagDetails({ flags: {} }) this.logMsgIfDebug(() => console.warn('Remote config has no feature flags, will not load feature flags.')) } else if (this.preloadFeatureFlags !== false) { @@ -1397,8 +1460,7 @@ export abstract class PostHogCore extends PostHogCoreStateless { // Add check for quota limitation on feature flags if (res?.quotaLimited?.includes(QuotaLimitedFeature.FeatureFlags)) { // Unset all feature flags by setting to null - this.setKnownFeatureFlags(null) - this.setKnownFeatureFlagPayloads(null) + this.setKnownFeatureFlagDetails(null) console.warn( '[FEATURE FLAGS] Feature flags quota limit exceeded - unsetting all flags. Learn more about billing limits at https://posthog.com/docs/billing/limits-alerts' ) @@ -1410,28 +1472,20 @@ export abstract class PostHogCore extends PostHogCoreStateless { this.flagCallReported = {} } - let newFeatureFlags = res.featureFlags - let newFeatureFlagPayloads = res.featureFlagPayloads + let newFeatureFlagDetails = res if (res.errorsWhileComputingFlags) { // if not all flags were computed, we upsert flags instead of replacing them - const currentFlags = this.getPersistedProperty( - PostHogPersistedProperty.FeatureFlags - ) - + const currentFlagDetails = this.getKnownFeatureFlagDetails() this.logMsgIfDebug(() => - console.log('PostHog Debug', 'Cached feature flags: ', JSON.stringify(currentFlags)) + console.log('PostHog Debug', 'Cached feature flags: ', JSON.stringify(currentFlagDetails)) ) - const currentFlagPayloads = this.getPersistedProperty( - PostHogPersistedProperty.FeatureFlagPayloads - ) - newFeatureFlags = { ...currentFlags, ...res.featureFlags } - newFeatureFlagPayloads = { ...currentFlagPayloads, ...res.featureFlagPayloads } + newFeatureFlagDetails = { + ...res, + flags: { ...currentFlagDetails?.flags, ...res.flags }, + } } - this.setKnownFeatureFlags(newFeatureFlags) - this.setKnownFeatureFlagPayloads( - Object.fromEntries(Object.entries(newFeatureFlagPayloads || {}).map(([k, v]) => [k, this._parsePayload(v)])) - ) + this.setKnownFeatureFlagDetails(newFeatureFlagDetails) // Mark that we hit the /decide endpoint so we can capture this in the $feature_flag_called event this.setPersistedProperty(PostHogPersistedProperty.DecideEndpointWasHit, true) @@ -1445,35 +1499,97 @@ export abstract class PostHogCore extends PostHogCoreStateless { return this._decideResponsePromise } - private setKnownFeatureFlags(featureFlags: PostHogDecideResponse['featureFlags'] | null): void { + // We only store the flags and request id in the feature flag details storage key + private setKnownFeatureFlagDetails(decideResponse: PostHogFlagsStorageFormat | null): void { this.wrap(() => { - this.setPersistedProperty( - PostHogPersistedProperty.FeatureFlags, - featureFlags - ) - this._events.emit('featureflags', featureFlags) + this.setPersistedProperty(PostHogPersistedProperty.FeatureFlagDetails, decideResponse) + + this._events.emit('featureflags', getFlagValuesFromFlags(decideResponse?.flags ?? {})) }) } - private setKnownFeatureFlagPayloads(featureFlagPayloads: PostHogDecideResponse['featureFlagPayloads'] | null): void { - this.wrap(() => { - this.setPersistedProperty( - PostHogPersistedProperty.FeatureFlagPayloads, - featureFlagPayloads + private getKnownFeatureFlagDetails(): PostHogFeatureFlagDetails | undefined { + const storedDetails = this.getPersistedProperty( + PostHogPersistedProperty.FeatureFlagDetails + ) + if (!storedDetails) { + // Rebuild from the stored feature flags and feature flag payloads + const featureFlags = this.getPersistedProperty( + PostHogPersistedProperty.FeatureFlags ) - }) + const featureFlagPayloads = this.getPersistedProperty( + PostHogPersistedProperty.FeatureFlagPayloads + ) + + if (featureFlags === undefined && featureFlagPayloads === undefined) { + return undefined + } + + return createDecideResponseFromFlagsAndPayloads(featureFlags ?? {}, featureFlagPayloads ?? {}) + } + + return normalizeDecideResponse( + storedDetails as PostHogV3DecideResponse | PostHogV4DecideResponse + ) as PostHogFeatureFlagDetails } - getFeatureFlag(key: string): boolean | string | undefined { - const featureFlags = this.getFeatureFlags() + private getKnownFeatureFlags(): PostHogDecideResponse['featureFlags'] | undefined { + const featureFlagDetails = this.getKnownFeatureFlagDetails() + if (!featureFlagDetails) { + return undefined + } + return getFlagValuesFromFlags(featureFlagDetails.flags) + } + + private getKnownFeatureFlagPayloads(): PostHogDecideResponse['featureFlagPayloads'] | undefined { + const featureFlagDetails = this.getKnownFeatureFlagDetails() + if (!featureFlagDetails) { + return undefined + } + return getPayloadsFromFlags(featureFlagDetails.flags) + } + + private getBootstrappedFeatureFlagDetails(): PostHogFeatureFlagDetails | undefined { + const details = this.getPersistedProperty( + PostHogPersistedProperty.BootstrapFeatureFlagDetails + ) + if (!details) { + return undefined + } + return details + } + + private setBootstrappedFeatureFlagDetails(details: PostHogFeatureFlagDetails): void { + this.setPersistedProperty(PostHogPersistedProperty.BootstrapFeatureFlagDetails, details) + } + + private getBootstrappedFeatureFlags(): PostHogDecideResponse['featureFlags'] | undefined { + const details = this.getBootstrappedFeatureFlagDetails() + if (!details) { + return undefined + } + return getFlagValuesFromFlags(details.flags) + } + + private getBootstrappedFeatureFlagPayloads(): PostHogDecideResponse['featureFlagPayloads'] | undefined { + const details = this.getBootstrappedFeatureFlagDetails() + if (!details) { + return undefined + } + return getPayloadsFromFlags(details.flags) + } - if (!featureFlags) { + getFeatureFlag(key: string): FeatureFlagValue | undefined { + const details = this.getFeatureFlagDetails() + + if (!details) { // If we haven't loaded flags yet, or errored out, we respond with undefined return undefined } - let response = featureFlags[key] - // `/decide` v3 returns all flags + const featureFlag = details.flags[key] + + let response = getFeatureFlagValue(featureFlag) if (response === undefined) { // For cases where the flag is unknown, return false @@ -1481,18 +1597,21 @@ export abstract class PostHogCore extends PostHogCoreStateless { } if (this.sendFeatureFlagEvent && !this.flagCallReported[key]) { + const bootstrappedResponse = this.getBootstrappedFeatureFlags()?.[key] + const bootstrappedPayload = this.getBootstrappedFeatureFlagPayloads()?.[key] + this.flagCallReported[key] = true this.capture('$feature_flag_called', { $feature_flag: key, $feature_flag_response: response, - $feature_flag_bootstrapped_response: this.getPersistedProperty( - PostHogPersistedProperty.BootstrapFeatureFlags - )?.[key], - $feature_flag_bootstrapped_payload: this.getPersistedProperty( - PostHogPersistedProperty.BootstrapFeatureFlagPayloads - )?.[key], + $feature_flag_id: featureFlag?.metadata?.id, + $feature_flag_version: featureFlag?.metadata?.version, + $feature_flag_reason: featureFlag?.reason?.description ?? featureFlag?.reason?.code, + $feature_flag_bootstrapped_response: bootstrappedResponse, + $feature_flag_bootstrapped_payload: bootstrappedPayload, // If we haven't yet received a response from the /decide endpoint, we must have used the bootstrapped value $used_bootstrap_value: !this.getPersistedProperty(PostHogPersistedProperty.DecideEndpointWasHit), + $feature_flag_request_id: details.requestId, }) } @@ -1518,36 +1637,45 @@ export abstract class PostHogCore extends PostHogCoreStateless { } getFeatureFlagPayloads(): PostHogDecideResponse['featureFlagPayloads'] | undefined { - const payloads = this.getPersistedProperty( - PostHogPersistedProperty.FeatureFlagPayloads - ) - - return payloads + return this.getFeatureFlagDetails()?.featureFlagPayloads } getFeatureFlags(): PostHogDecideResponse['featureFlags'] | undefined { // NOTE: We don't check for _initPromise here as the function is designed to be // callable before the state being loaded anyways - let flags = this.getPersistedProperty(PostHogPersistedProperty.FeatureFlags) + return this.getFeatureFlagDetails()?.featureFlags + } + + getFeatureFlagDetails(): PostHogFeatureFlagDetails | undefined { + // NOTE: We don't check for _initPromise here as the function is designed to be + // callable before the state being loaded anyways + let details = this.getKnownFeatureFlagDetails() const overriddenFlags = this.getPersistedProperty( PostHogPersistedProperty.OverrideFeatureFlags ) if (!overriddenFlags) { - return flags + return details } - flags = flags || {} + details = details ?? { featureFlags: {}, featureFlagPayloads: {}, flags: {} } + + const flags: Record = details.flags ?? {} for (const key in overriddenFlags) { if (!overriddenFlags[key]) { delete flags[key] } else { - flags[key] = overriddenFlags[key] + flags[key] = updateFlagValue(flags[key], overriddenFlags[key]) } } - return flags + const result = { + ...details, + flags, + } + + return normalizeDecideResponse(result) as PostHogFeatureFlagDetails } getFeatureFlagsAndPayloads(): { @@ -1604,7 +1732,7 @@ export abstract class PostHogCore extends PostHogCoreStateless { }) } - onFeatureFlag(key: string, cb: (value: string | boolean) => void): () => void { + onFeatureFlag(key: string, cb: (value: FeatureFlagValue) => void): () => void { return this.on('featureflags', async () => { const flagResponse = this.getFeatureFlag(key) if (flagResponse !== undefined) { diff --git a/posthog-core/src/types.ts b/posthog-core/src/types.ts index 3e688e96..8b0ec061 100644 --- a/posthog-core/src/types.ts +++ b/posthog-core/src/types.ts @@ -37,7 +37,7 @@ export type PostHogCoreOptions = { bootstrap?: { distinctId?: string isIdentifiedId?: boolean - featureFlags?: Record + featureFlags?: Record featureFlagPayloads?: Record } /** How many times we will retry HTTP requests. Defaults to 3. */ @@ -63,8 +63,10 @@ export enum PostHogPersistedProperty { AnonymousId = 'anonymous_id', DistinctId = 'distinct_id', Props = 'props', + FeatureFlagDetails = 'feature_flag_details', FeatureFlags = 'feature_flags', FeatureFlagPayloads = 'feature_flag_payloads', + BootstrapFeatureFlagDetails = 'bootstrap_feature_flag_details', BootstrapFeatureFlags = 'bootstrap_feature_flags', BootstrapFeatureFlagPayloads = 'bootstrap_feature_flag_payloads', OverrideFeatureFlags = 'override_feature_flags', @@ -146,13 +148,18 @@ export interface PostHogRemoteConfig { hasFeatureFlags?: boolean } +export type FeatureFlagValue = string | boolean + export interface PostHogDecideResponse extends Omit { featureFlags: { - [key: string]: string | boolean + [key: string]: FeatureFlagValue } featureFlagPayloads: { [key: string]: JsonType } + flags: { + [key: string]: FeatureFlagDetail + } errorsWhileComputingFlags: boolean sessionRecording?: | boolean @@ -163,11 +170,91 @@ export interface PostHogDecideResponse extends Omit + +/** + * Creates a type with all properties of T, but makes only K properties required while the rest remain optional. + * + * @template T - The base type containing all properties + * @template K - Union type of keys from T that should be required + * + * @example + * interface User { + * id: number; + * name: string; + * email?: string; + * age?: number; + * } + * + * // Makes 'id' and 'name' required, but 'email' and 'age' optional + * type RequiredUser = PartialWithRequired; + * + * const user: RequiredUser = { + * id: 1, // Must be provided + * name: "John" // Must be provided + * // email and age are optional + * }; + */ +export type PartialWithRequired = { + [P in K]: T[P] // Required fields +} & { + [P in Exclude]?: T[P] // Optional fields } +/** + * These are the fields we care about from PostHogDecideResponse for feature flags. + */ +export type PostHogFeatureFlagDetails = PartialWithRequired< + PostHogDecideResponse, + 'flags' | 'featureFlags' | 'featureFlagPayloads' | 'requestId' +> + +/** + * Models the response from the v3 `/decide` endpoint. + */ +export type PostHogV3DecideResponse = Omit +export type PostHogV4DecideResponse = Omit + +/** + * The format of the flags object in persisted storage + * + * When we pull flags from persistence, we can normalize them to PostHogFeatureFlagDetails + * so that we can support v3 and v4 of the API. + */ +export type PostHogFlagsStorageFormat = Pick + +/** + * Models legacy flags and payloads return type for many public methods. + */ +export type PostHogFlagsAndPayloadsResponse = Partial< + Pick +> + export type JsonType = string | number | boolean | null | { [key: string]: JsonType } | Array export type FetchLike = (url: string, options: PostHogFetchOptions) => Promise + +export type FeatureFlagDetail = { + key: string + enabled: boolean + variant: string | undefined + reason: EvaluationReason | undefined + metadata: FeatureFlagMetadata | undefined +} + +export type FeatureFlagMetadata = { + id: number | undefined + version: number | undefined + description: string | undefined + // Payloads in the response are always JSON encoded as a string + payload: string | undefined +} + +export type EvaluationReason = { + code: string | undefined + condition_index: number | undefined + description: string | undefined +} diff --git a/posthog-core/test/featureFlagUtils.spec.ts b/posthog-core/test/featureFlagUtils.spec.ts new file mode 100644 index 00000000..0b7fc697 --- /dev/null +++ b/posthog-core/test/featureFlagUtils.spec.ts @@ -0,0 +1,427 @@ +import { + getFlagValuesFromFlags, + getPayloadsFromFlags, + getFlagDetailsFromFlagsAndPayloads, + getFeatureFlagValue, + normalizeDecideResponse, +} from '../src/featureFlagUtils' +import { PostHogDecideResponse, FeatureFlagDetail } from '../src/types' + +describe('featureFlagUtils', () => { + describe('getFeatureFlagValue', () => { + it('should return variant if present', () => { + const flag: FeatureFlagDetail = { + key: 'test-flag', + enabled: true, + variant: 'test-variant', + reason: undefined, + metadata: { id: 1, version: undefined, description: undefined, payload: undefined }, + } + expect(getFeatureFlagValue(flag)).toBe('test-variant') + }) + + it('should return enabled if no variant', () => { + const flag1: FeatureFlagDetail = { + key: 'test-flag-1', + enabled: true, + variant: undefined, + reason: undefined, + metadata: { id: 1, version: undefined, description: undefined, payload: undefined }, + } + const flag2: FeatureFlagDetail = { + key: 'test-flag-2', + enabled: false, + variant: undefined, + reason: undefined, + metadata: { id: 2, version: undefined, description: undefined, payload: undefined }, + } + expect(getFeatureFlagValue(flag1)).toBe(true) + expect(getFeatureFlagValue(flag2)).toBe(false) + }) + + it('should return undefined if neither variant nor enabled', () => { + const flag: FeatureFlagDetail = { + key: 'test-flag', + enabled: false, + variant: undefined, + reason: undefined, + metadata: { id: 1, version: undefined, description: undefined, payload: undefined }, + } + expect(getFeatureFlagValue(flag)).toBe(false) + }) + }) + + describe('getFlagValuesFromFlags', () => { + it('should extract flag values from flags', () => { + const flags: Record = { + 'flag-1': { + key: 'flag-1', + enabled: true, + variant: undefined, + reason: undefined, + metadata: { id: 1, version: undefined, description: undefined, payload: undefined }, + }, + 'flag-2': { + key: 'flag-2', + enabled: false, + variant: undefined, + reason: undefined, + metadata: { id: 2, version: undefined, description: undefined, payload: undefined }, + }, + 'flag-3': { + key: 'flag-3', + enabled: true, + variant: 'test-variant', + reason: undefined, + metadata: { id: 3, version: undefined, description: undefined, payload: undefined }, + }, + } + + expect(getFlagValuesFromFlags(flags)).toEqual({ + 'flag-1': true, + 'flag-2': false, + 'flag-3': 'test-variant', + }) + }) + + it('should handle empty flags object', () => { + expect(getFlagValuesFromFlags({})).toEqual({}) + }) + }) + + describe('getPayloadsFromFlags', () => { + it('should extract payloads from enabled flags with metadata', () => { + const flags: Record = { + 'flag-with-object-payload': { + key: 'flag-with-object-payload', + enabled: true, + variant: undefined, + reason: undefined, + metadata: { id: 1, version: undefined, description: undefined, payload: '{"key": "value"}' }, + }, + 'flag-with-single-item-array-payload': { + key: 'flag-with-single-item-array-payload', + enabled: true, + variant: undefined, + reason: undefined, + metadata: { id: 1, version: undefined, description: undefined, payload: '[5]' }, + }, + 'flag-with-array-payload': { + key: 'flag-with-array-payload', + enabled: true, + variant: undefined, + reason: undefined, + metadata: { id: 1, version: undefined, description: undefined, payload: '[1, 2, 3]' }, + }, + 'disabled-flag': { + key: 'disabled-flag', + enabled: false, + variant: undefined, + reason: undefined, + metadata: { id: 2, version: undefined, description: undefined, payload: undefined }, + }, + 'enabled-flag-no-payload': { + key: 'enabled-flag-no-payload', + enabled: true, + variant: undefined, + reason: undefined, + metadata: { id: 3, version: undefined, description: undefined, payload: undefined }, + }, + } + + expect(getPayloadsFromFlags(flags)).toEqual({ + 'flag-with-object-payload': { key: 'value' }, + 'flag-with-single-item-array-payload': [5], + 'flag-with-array-payload': [1, 2, 3], + }) + }) + + it('should handle empty flags object', () => { + expect(getPayloadsFromFlags({})).toEqual({}) + }) + + it('should handle flags with no payloads', () => { + const flags: Record = { + 'flag-1': { + key: 'flag-1', + enabled: true, + variant: undefined, + reason: undefined, + metadata: { id: 1, version: undefined, description: undefined, payload: undefined }, + }, + 'flag-2': { + key: 'flag-2', + enabled: true, + variant: undefined, + reason: undefined, + metadata: { id: 2, version: undefined, description: undefined, payload: undefined }, + }, + } + + expect(getPayloadsFromFlags(flags)).toEqual({}) + }) + }) + + describe('getFlagDetailsFromFlagsAndPayloads', () => { + it('should convert v3 flags and payloads to flag details', () => { + const decideResponse: PostHogDecideResponse = { + featureFlags: { + 'flag-1': true, + 'flag-2': 'variant-1', + 'flag-3': false, + }, + featureFlagPayloads: { + 'flag-1': { key: 'value1' }, + 'flag-2': { key: 'value2' }, + }, + flags: {}, + errorsWhileComputingFlags: false, + } + + const result = getFlagDetailsFromFlagsAndPayloads(decideResponse) + + expect(result).toEqual({ + 'flag-1': { + key: 'flag-1', + enabled: true, + variant: undefined, + reason: undefined, + metadata: { + id: undefined, + version: undefined, + payload: '{"key":"value1"}', + description: undefined, + }, + }, + 'flag-2': { + key: 'flag-2', + enabled: true, + variant: 'variant-1', + reason: undefined, + metadata: { + id: undefined, + version: undefined, + payload: '{"key":"value2"}', + description: undefined, + }, + }, + 'flag-3': { + key: 'flag-3', + enabled: false, + variant: undefined, + reason: undefined, + metadata: { + id: undefined, + version: undefined, + payload: undefined, + description: undefined, + }, + }, + }) + }) + + it('should handle empty flags and payloads', () => { + const decideResponse: PostHogDecideResponse = { + featureFlags: {}, + featureFlagPayloads: {}, + flags: {}, + errorsWhileComputingFlags: false, + } + + expect(getFlagDetailsFromFlagsAndPayloads(decideResponse)).toEqual({}) + }) + }) + + describe('normalizeDecideResponse', () => { + it('should convert v4 response to v3 format', () => { + const v4Response: PostHogDecideResponse = { + flags: { + 'flag-1': { + key: 'flag-1', + enabled: true, + variant: undefined, + reason: undefined, + metadata: { + id: undefined, + version: undefined, + payload: '{"key":"value1"}', + description: undefined, + }, + }, + 'flag-2': { + key: 'flag-2', + enabled: true, + variant: 'variant-1', + reason: undefined, + metadata: { + id: undefined, + version: undefined, + payload: '{"key":"value2"}', + description: undefined, + }, + }, + 'flag-3': { + key: 'flag-3', + enabled: false, + variant: undefined, + reason: undefined, + metadata: { + id: undefined, + version: undefined, + payload: undefined, + description: undefined, + }, + }, + }, + errorsWhileComputingFlags: false, + featureFlags: {}, + featureFlagPayloads: {}, + } + + const result = normalizeDecideResponse(v4Response) + + expect(result).toEqual({ + featureFlags: { + 'flag-1': true, + 'flag-2': 'variant-1', + 'flag-3': false, + }, + featureFlagPayloads: { + 'flag-1': { key: 'value1' }, + 'flag-2': { key: 'value2' }, + }, + flags: v4Response.flags, + errorsWhileComputingFlags: false, + }) + }) + + it('should convert v3 response to v4 format', () => { + const v3Response: Omit = { + featureFlags: { + 'flag-1': true, + 'flag-2': 'variant-1', + 'flag-3': false, + }, + featureFlagPayloads: { + 'flag-1': { key: 'value1' }, + 'flag-2': { key: 'value2' }, + }, + errorsWhileComputingFlags: false, + } + + const result = normalizeDecideResponse(v3Response) + + expect(result).toEqual({ + featureFlags: { + 'flag-1': true, + 'flag-2': 'variant-1', + 'flag-3': false, + }, + featureFlagPayloads: { + 'flag-1': { key: 'value1' }, + 'flag-2': { key: 'value2' }, + }, + flags: { + 'flag-1': { + key: 'flag-1', + enabled: true, + variant: undefined, + reason: undefined, + metadata: { + id: undefined, + version: undefined, + payload: '{"key":"value1"}', + description: undefined, + }, + }, + 'flag-2': { + key: 'flag-2', + enabled: true, + variant: 'variant-1', + reason: undefined, + metadata: { + id: undefined, + version: undefined, + payload: '{"key":"value2"}', + description: undefined, + }, + }, + 'flag-3': { + key: 'flag-3', + enabled: false, + variant: undefined, + reason: undefined, + metadata: { + id: undefined, + version: undefined, + payload: undefined, + description: undefined, + }, + }, + }, + errorsWhileComputingFlags: false, + }) + }) + + it('should handle empty flags and payloads', () => { + const v3Response: Omit = { + featureFlags: {}, + featureFlagPayloads: {}, + errorsWhileComputingFlags: false, + } + + const result = normalizeDecideResponse(v3Response) + + expect(result).toEqual({ + featureFlags: {}, + featureFlagPayloads: {}, + flags: {}, + errorsWhileComputingFlags: false, + }) + }) + + it('should preserve additional fields', () => { + const v3Response: Omit = { + featureFlags: { + 'flag-1': true, + }, + featureFlagPayloads: { + 'flag-1': { key: 'value1' }, + }, + errorsWhileComputingFlags: false, + sessionRecording: true, + quotaLimited: ['feature_flags'], + requestId: 'test-request-id', + } + + const result = normalizeDecideResponse(v3Response) + + expect(result).toEqual({ + featureFlags: { + 'flag-1': true, + }, + featureFlagPayloads: { + 'flag-1': { key: 'value1' }, + }, + flags: { + 'flag-1': { + key: 'flag-1', + enabled: true, + variant: undefined, + reason: undefined, + metadata: { + id: undefined, + version: undefined, + payload: '{"key":"value1"}', + description: undefined, + }, + }, + }, + errorsWhileComputingFlags: false, + sessionRecording: true, + quotaLimited: ['feature_flags'], + requestId: 'test-request-id', + }) + }) + }) +}) diff --git a/posthog-core/test/posthog.core.spec.ts b/posthog-core/test/posthog.core.spec.ts new file mode 100644 index 00000000..13ea3bc2 --- /dev/null +++ b/posthog-core/test/posthog.core.spec.ts @@ -0,0 +1,135 @@ +import { createTestClient, PostHogCoreTestClient, PostHogCoreTestClientMocks } from './test-utils/PostHogCoreTestClient' + +describe('PostHog Core', () => { + let posthog: PostHogCoreTestClient + let mocks: PostHogCoreTestClientMocks + + jest.useFakeTimers() + jest.setSystemTime(new Date('2022-01-01')) + + const errorAPIResponse = Promise.resolve({ + status: 400, + text: () => Promise.resolve('error'), + json: () => + Promise.resolve({ + status: 'error', + }), + }) + + describe('getDecide', () => { + beforeEach(() => { + ;[posthog, mocks] = createTestClient('TEST_API_KEY', { flushAt: 1 }) + }) + + it('should handle successful v3 response and return normalized response', async () => { + const mockV3Response = { + featureFlags: { 'test-flag': true }, + featureFlagPayloads: { 'test-flag': { a: 'payload' } }, + } + + const expectedResponse = { + ...mockV3Response, + flags: { + 'test-flag': { + key: 'test-flag', + enabled: true, + variant: undefined, + reason: undefined, + metadata: { + id: undefined, + version: undefined, + description: undefined, + payload: '{"a":"payload"}', + }, + }, + }, + } + + mocks.fetch.mockImplementation((url) => { + if (url.includes('/decide/?v=4')) { + return Promise.resolve({ + status: 200, + text: () => Promise.resolve('ok'), + json: () => Promise.resolve(mockV3Response), + }) + } + return errorAPIResponse + }) + + const response = await posthog.getDecide('test-distinct-id') + expect(response).toEqual(expectedResponse) + }) + + it('should handle successful v4 response and return normalized response', async () => { + const mockV4Response = { + flags: { + 'test-flag': { + key: 'test-flag', + enabled: true, + variant: 'test-payload', + reason: { + code: 'matched_condition', + description: 'matched condition set 1', + condition_index: 0, + }, + metadata: { + id: 1, + version: 1, + description: 'test-flag', + payload: '{"a":"payload"}', + }, + }, + }, + } + + const expectedResponse = { + ...mockV4Response, + featureFlags: { 'test-flag': 'test-payload' }, + featureFlagPayloads: { 'test-flag': { a: 'payload' } }, + } + mocks.fetch.mockImplementation((url) => { + if (url.includes('/decide/?v=4')) { + return Promise.resolve({ + status: 200, + text: () => Promise.resolve('ok'), + json: () => Promise.resolve(mockV4Response), + }) + } + return errorAPIResponse + }) + + const response = await posthog.getDecide('test-distinct-id') + expect(response).toEqual(expectedResponse) + }) + + it('should handle error response', async () => { + mocks.fetch.mockImplementation((url) => { + if (url.includes('/decide/?v=4')) { + return Promise.resolve({ + status: 400, + text: () => Promise.resolve('error'), + json: () => Promise.resolve({ error: 'went wrong' }), + }) + } + return errorAPIResponse + }) + + const response = await posthog.getDecide('test-distinct-id') + expect(response).toBeUndefined() + }) + + it('should handle network errors', async () => { + const emitSpy = jest.spyOn(posthog['_events'], 'emit') + mocks.fetch.mockImplementation((url) => { + if (url.includes('/decide/?v=4')) { + return Promise.reject(new Error('Network error')) + } + return errorAPIResponse + }) + + const response = await posthog.getDecide('test-distinct-id') + expect(response).toBeUndefined() + expect(emitSpy).toHaveBeenCalledWith('error', expect.any(Error)) + }) + }) +}) diff --git a/posthog-core/test/posthog.featureflags.spec.ts b/posthog-core/test/posthog.featureflags.spec.ts index daf08a7e..a9bcbe2a 100644 --- a/posthog-core/test/posthog.featureflags.spec.ts +++ b/posthog-core/test/posthog.featureflags.spec.ts @@ -1,28 +1,88 @@ -import { PostHogPersistedProperty } from '../src' +import { PostHogPersistedProperty, PostHogV4DecideResponse } from '../src' +import { normalizeDecideResponse } from '../src/featureFlagUtils' import { createTestClient, PostHogCoreTestClient, PostHogCoreTestClientMocks } from './test-utils/PostHogCoreTestClient' import { parseBody, waitForPromises } from './test-utils/test-utils' -describe('PostHog Core', () => { +describe('PostHog Feature Flags v4', () => { let posthog: PostHogCoreTestClient let mocks: PostHogCoreTestClientMocks jest.useFakeTimers() jest.setSystemTime(new Date('2022-01-01')) - const createMockFeatureFlags = (): any => ({ + const createMockFeatureFlags = (): Partial => ({ + 'feature-1': { + key: 'feature-1', + enabled: true, + variant: undefined, + reason: { + code: 'matched_condition', + description: 'matched condition set 1', + condition_index: 0, + }, + metadata: { + id: 1, + version: 1, + description: 'feature-1', + payload: '{"color":"blue"}', + }, + }, + 'feature-2': { + key: 'feature-2', + enabled: true, + variant: undefined, + reason: { + code: 'matched_condition', + description: 'matched condition set 2', + condition_index: 1, + }, + metadata: { + id: 2, + version: 42, + description: 'feature-2', + payload: undefined, + }, + }, + 'feature-variant': { + key: 'feature-variant', + enabled: true, + variant: 'variant', + reason: { + code: 'matched_condition', + description: 'matched condition set 3', + condition_index: 2, + }, + metadata: { + id: 3, + version: 1, + description: 'feature-variant', + payload: '[5]', + }, + }, + 'json-payload': { + key: 'json-payload', + enabled: true, + variant: undefined, + reason: { + code: 'matched_condition', + description: 'matched condition set 4', + condition_index: 4, + }, + metadata: { + id: 4, + version: 1, + description: 'json-payload', + payload: '{"a":"payload"}', + }, + }, + }) + + const expectedFeatureFlagResponses = { 'feature-1': true, 'feature-2': true, 'feature-variant': 'variant', 'json-payload': true, - }) - - const createMockFeatureFlagPayloads = (): any => ({ - 'feature-1': JSON.stringify({ - color: 'blue', - }), - 'feature-variant': JSON.stringify([5]), - 'json-payload': '{"a":"payload"}', - }) + } const errorAPIResponse = Promise.resolve({ status: 400, @@ -36,14 +96,14 @@ describe('PostHog Core', () => { beforeEach(() => { ;[posthog, mocks] = createTestClient('TEST_API_KEY', { flushAt: 1 }, (_mocks) => { _mocks.fetch.mockImplementation((url) => { - if (url.includes('/decide/?v=3')) { + if (url.includes('/decide/?v=4')) { return Promise.resolve({ status: 200, text: () => Promise.resolve('ok'), json: () => Promise.resolve({ - featureFlags: createMockFeatureFlags(), - featureFlagPayloads: createMockFeatureFlagPayloads(), + flags: createMockFeatureFlags(), + requestId: '0152a345-295f-4fba-adac-2e6ea9c91082', }), }) } @@ -84,8 +144,10 @@ describe('PostHog Core', () => { }) it('should load persisted feature flags', () => { - posthog.setPersistedProperty(PostHogPersistedProperty.FeatureFlags, createMockFeatureFlags()) - expect(posthog.getFeatureFlags()).toEqual(createMockFeatureFlags()) + const decideResponse = { flags: createMockFeatureFlags() } as PostHogV4DecideResponse + const normalizedFeatureFlags = normalizeDecideResponse(decideResponse) + posthog.setPersistedProperty(PostHogPersistedProperty.FeatureFlagDetails, normalizedFeatureFlags) + expect(posthog.getFeatureFlags()).toEqual(expectedFeatureFlagResponses) }) it('should only call fetch once if already calling', async () => { @@ -94,7 +156,19 @@ describe('PostHog Core', () => { posthog.reloadFeatureFlagsAsync() const flags = await posthog.reloadFeatureFlagsAsync() expect(mocks.fetch).toHaveBeenCalledTimes(1) - expect(flags).toEqual(createMockFeatureFlags()) + expect(flags).toEqual(expectedFeatureFlagResponses) + }) + + it('should emit featureflags event when flags are loaded', async () => { + const receivedFlags: any[] = [] + const unsubscribe = posthog.onFeatureFlags((flags) => { + receivedFlags.push(flags) + }) + + await posthog.reloadFeatureFlagsAsync() + unsubscribe() + + expect(receivedFlags).toEqual([expectedFeatureFlagResponses]) }) describe('when loaded', () => { @@ -109,13 +183,12 @@ describe('PostHog Core', () => { expect(posthog.getFeatureFlag('feature-missing')).toEqual(false) }) - it('should return payload of matched flags only', async () => { - expect(posthog.getFeatureFlagPayload('feature-variant')).toEqual([5]) - expect(posthog.getFeatureFlagPayload('feature-1')).toEqual({ - color: 'blue', - }) - - expect(posthog.getFeatureFlagPayload('feature-2')).toEqual(null) + it.each([ + ['feature-variant', [5]], + ['feature-1', { color: 'blue' }], + ['feature-2', null], + ])('should return correct payload for flag %s', (flagKey, expectedPayload) => { + expect(posthog.getFeatureFlagPayload(flagKey)).toEqual(expectedPayload) }) describe('when errored out', () => { @@ -141,7 +214,7 @@ describe('PostHog Core', () => { }) it('should return undefined', async () => { - expect(mocks.fetch).toHaveBeenCalledWith('https://us.i.posthog.com/decide/?v=3', { + expect(mocks.fetch).toHaveBeenCalledWith('https://us.i.posthog.com/decide/?v=4', { body: JSON.stringify({ token: 'TEST_API_KEY', distinct_id: posthog.getDistinctId(), @@ -176,26 +249,59 @@ describe('PostHog Core', () => { ;[posthog, mocks] = createTestClient('TEST_API_KEY', { flushAt: 1 }, (_mocks) => { _mocks.fetch .mockImplementationOnce((url) => { - if (url.includes('/decide/?v=3')) { + if (url.includes('/decide/?v=4')) { return Promise.resolve({ status: 200, text: () => Promise.resolve('ok'), json: () => Promise.resolve({ - featureFlags: createMockFeatureFlags(), + flags: createMockFeatureFlags(), }), }) } return errorAPIResponse }) .mockImplementationOnce((url) => { - if (url.includes('/decide/?v=3')) { + if (url.includes('/decide/?v=4')) { return Promise.resolve({ status: 200, text: () => Promise.resolve('ok'), json: () => Promise.resolve({ - featureFlags: { 'x-flag': 'x-value', 'feature-1': false }, + flags: { + 'x-flag': { + key: 'x-flag', + enabled: true, + variant: 'x-value', + reason: { + code: 'matched_condition', + description: 'matched condition set 5', + condition_index: 0, + }, + metadata: { + id: 5, + version: 1, + description: 'x-flag', + payload: '{"x":"value"}', + }, + }, + 'feature-1': { + key: 'feature-1', + enabled: false, + variant: undefined, + reason: { + code: 'matched_condition', + description: 'matched condition set 6', + condition_index: 0, + }, + metadata: { + id: 6, + version: 1, + description: 'feature-1', + payload: '{"color":"blue"}', + }, + }, + }, errorsWhileComputingFlags: true, }), }) @@ -212,7 +318,7 @@ describe('PostHog Core', () => { }) it('should return combined results', async () => { - expect(mocks.fetch).toHaveBeenCalledWith('https://us.i.posthog.com/decide/?v=3', { + expect(mocks.fetch).toHaveBeenCalledWith('https://us.i.posthog.com/decide/?v=4', { body: JSON.stringify({ token: 'TEST_API_KEY', distinct_id: posthog.getDistinctId(), @@ -239,7 +345,7 @@ describe('PostHog Core', () => { // now second call to feature flags await posthog.reloadFeatureFlagsAsync(false) - expect(mocks.fetch).toHaveBeenCalledWith('https://us.i.posthog.com/decide/?v=3', { + expect(mocks.fetch).toHaveBeenCalledWith('https://us.i.posthog.com/decide/?v=4', { body: JSON.stringify({ token: 'TEST_API_KEY', distinct_id: posthog.getDistinctId(), @@ -281,27 +387,62 @@ describe('PostHog Core', () => { ;[posthog, mocks] = createTestClient('TEST_API_KEY', { flushAt: 1 }, (_mocks) => { _mocks.fetch .mockImplementationOnce((url) => { - if (url.includes('/decide/?v=3')) { + if (url.includes('/decide/?v=4')) { return Promise.resolve({ status: 200, text: () => Promise.resolve('ok'), json: () => Promise.resolve({ - featureFlags: createMockFeatureFlags(), + flags: createMockFeatureFlags(), + requestId: '18043bf7-9cf6-44cd-b959-9662ee20d371', }), }) } return errorAPIResponse }) .mockImplementationOnce((url) => { - if (url.includes('/decide/?v=3')) { + if (url.includes('/decide/?v=4')) { return Promise.resolve({ status: 200, text: () => Promise.resolve('ok'), json: () => Promise.resolve({ - featureFlags: { 'x-flag': 'x-value', 'feature-1': false }, + flags: { + 'x-flag': { + key: 'x-flag', + enabled: true, + variant: 'x-value', + reason: { + code: 'matched_condition', + description: 'matched condition set 5', + condition_index: 0, + }, + metadata: { + id: 5, + version: 1, + description: 'x-flag', + payload: '{"x":"value"}', + }, + }, + 'feature-1': { + key: 'feature-1', + enabled: false, + variant: undefined, + reason: { + code: 'matched_condition', + description: 'matched condition set 6', + condition_index: 0, + }, + metadata: { + id: 6, + version: 1, + description: 'feature-1', + payload: '{"color":"blue"}', + }, + }, + }, errorsWhileComputingFlags: false, + requestId: 'bccd3c21-38e6-4499-a804-89f77ddcd1fc', }), }) } @@ -317,7 +458,7 @@ describe('PostHog Core', () => { }) it('should return only latest results', async () => { - expect(mocks.fetch).toHaveBeenCalledWith('https://us.i.posthog.com/decide/?v=3', { + expect(mocks.fetch).toHaveBeenCalledWith('https://us.i.posthog.com/decide/?v=4', { body: JSON.stringify({ token: 'TEST_API_KEY', distinct_id: posthog.getDistinctId(), @@ -344,7 +485,7 @@ describe('PostHog Core', () => { // now second call to feature flags await posthog.reloadFeatureFlagsAsync(false) - expect(mocks.fetch).toHaveBeenCalledWith('https://us.i.posthog.com/decide/?v=3', { + expect(mocks.fetch).toHaveBeenCalledWith('https://us.i.posthog.com/decide/?v=4', { body: JSON.stringify({ token: 'TEST_API_KEY', distinct_id: posthog.getDistinctId(), @@ -393,31 +534,67 @@ describe('PostHog Core', () => { }) }) - it('should capture $feature_flag_called when called', async () => { - expect(posthog.getFeatureFlag('feature-1')).toEqual(true) - await waitForPromises() - expect(mocks.fetch).toHaveBeenCalledTimes(2) - - expect(parseBody(mocks.fetch.mock.calls[1])).toMatchObject({ - batch: [ - { - event: '$feature_flag_called', - distinct_id: posthog.getDistinctId(), - properties: { - $feature_flag: 'feature-1', - $feature_flag_response: true, - '$feature/feature-1': true, - $used_bootstrap_value: false, + it.each([ + { + key: 'feature-1', + expected_response: true, + expected_id: 1, + expected_version: 1, + expected_reason: 'matched condition set 1', + }, + { + key: 'feature-2', + expected_response: true, + expected_id: 2, + expected_version: 42, + expected_reason: 'matched condition set 2', + }, + { + key: 'feature-variant', + expected_response: 'variant', + expected_id: 3, + expected_version: 1, + expected_reason: 'matched condition set 3', + }, + { + key: 'json-payload', + expected_response: true, + expected_id: 4, + expected_version: 1, + expected_reason: 'matched condition set 4', + }, + ])( + 'should capture feature_flag_called when called for %s', + async ({ key, expected_response, expected_id, expected_version, expected_reason }) => { + expect(posthog.getFeatureFlag(key)).toEqual(expected_response) + await waitForPromises() + expect(mocks.fetch).toHaveBeenCalledTimes(2) + + expect(parseBody(mocks.fetch.mock.calls[1])).toMatchObject({ + batch: [ + { + event: '$feature_flag_called', + distinct_id: posthog.getDistinctId(), + properties: { + $feature_flag: key, + $feature_flag_response: expected_response, + $feature_flag_id: expected_id, + $feature_flag_version: expected_version, + $feature_flag_reason: expected_reason, + '$feature/feature-1': true, + $used_bootstrap_value: false, + $feature_flag_request_id: '0152a345-295f-4fba-adac-2e6ea9c91082', + }, + type: 'capture', }, - type: 'capture', - }, - ], - }) + ], + }) - // Only tracked once - expect(posthog.getFeatureFlag('feature-1')).toEqual(true) - expect(mocks.fetch).toHaveBeenCalledTimes(2) - }) + // Only tracked once + expect(posthog.getFeatureFlag('feature-1')).toEqual(true) + expect(mocks.fetch).toHaveBeenCalledTimes(2) + } + ) it('should capture $feature_flag_called again if new flags', async () => { expect(posthog.getFeatureFlag('feature-1')).toEqual(true) @@ -434,6 +611,7 @@ describe('PostHog Core', () => { $feature_flag_response: true, '$feature/feature-1': true, $used_bootstrap_value: false, + $feature_flag_request_id: '0152a345-295f-4fba-adac-2e6ea9c91082', }, type: 'capture', }, @@ -490,7 +668,14 @@ describe('PostHog Core', () => { }) it('should persist feature flags', () => { - expect(posthog.getPersistedProperty(PostHogPersistedProperty.FeatureFlags)).toEqual(createMockFeatureFlags()) + const expectedFeatureFlags = { + flags: createMockFeatureFlags(), + requestId: '0152a345-295f-4fba-adac-2e6ea9c91082', + } + const normalizedFeatureFlags = normalizeDecideResponse(expectedFeatureFlags as PostHogV4DecideResponse) + expect(posthog.getPersistedProperty(PostHogPersistedProperty.FeatureFlagDetails)).toEqual( + normalizedFeatureFlags + ) }) it('should include feature flags in subsequent captures', async () => { @@ -521,7 +706,10 @@ describe('PostHog Core', () => { 'feature-2': false, 'feature-variant': 'control', }) - expect(posthog.getFeatureFlags()).toEqual({ + + const received = posthog.getFeatureFlags() + + expect(received).toEqual({ 'json-payload': true, 'feature-1': true, 'feature-variant': 'control', @@ -540,8 +728,7 @@ describe('PostHog Core', () => { json: () => Promise.resolve({ quotaLimited: ['feature_flags'], - featureFlags: {}, - featureFlagPayloads: {}, + flags: {}, }), }) } @@ -554,7 +741,7 @@ describe('PostHog Core', () => { it('should unset all flags when feature_flags is quota limited', async () => { // First verify the fetch was called correctly - expect(mocks.fetch).toHaveBeenCalledWith('https://us.i.posthog.com/decide/?v=3', { + expect(mocks.fetch).toHaveBeenCalledWith('https://us.i.posthog.com/decide/?v=4', { body: JSON.stringify({ token: 'TEST_API_KEY', distinct_id: posthog.getDistinctId(), @@ -612,6 +799,9 @@ describe('PostHog Core', () => { color: 'feature-1-bootstrap-color', }, enabled: 200, + 'not-in-featureFlags': { + color: { foo: 'bar' }, + }, }, }, }, @@ -639,6 +829,7 @@ describe('PostHog Core', () => { 'bootstrap-1': 'variant-1', enabled: true, 'feature-1': 'feature-1-bootstrap-value', + 'not-in-featureFlags': true, }) expect(posthog.getDistinctId()).toEqual('tomato') expect(posthog.getAnonymousId()).toEqual('tomato') @@ -649,6 +840,7 @@ describe('PostHog Core', () => { expect(posthog.getFeatureFlag('bootstrap-1')).toEqual('variant-1') expect(posthog.getFeatureFlag('enabled')).toEqual(true) expect(posthog.getFeatureFlag('disabled')).toEqual(false) + expect(posthog.getFeatureFlag('not-in-featureFlags')).toEqual(true) }) it('getFeatureFlag should capture $feature_flag_called with bootstrapped values', async () => { @@ -681,6 +873,7 @@ describe('PostHog Core', () => { expect(posthog.isFeatureEnabled('bootstrap-1')).toEqual(true) expect(posthog.isFeatureEnabled('enabled')).toEqual(true) expect(posthog.isFeatureEnabled('disabled')).toEqual(false) + expect(posthog.isFeatureEnabled('not-in-featureFlags')).toEqual(true) }) it('getFeatureFlagPayload should return bootstrapped payloads', () => { @@ -689,6 +882,9 @@ describe('PostHog Core', () => { some: 'key', }) expect(posthog.getFeatureFlagPayload('enabled')).toEqual(200) + expect(posthog.getFeatureFlagPayload('not-in-featureFlags')).toEqual({ + color: { foo: 'bar' }, + }) }) describe('when loaded', () => { @@ -724,8 +920,7 @@ describe('PostHog Core', () => { text: () => Promise.resolve('ok'), json: () => Promise.resolve({ - featureFlags: createMockFeatureFlags(), - featureFlagPayloads: createMockFeatureFlagPayloads(), + flags: createMockFeatureFlags(), }), }) } @@ -746,7 +941,7 @@ describe('PostHog Core', () => { }) it('should load new feature flags', async () => { - expect(mocks.fetch).toHaveBeenCalledWith('https://us.i.posthog.com/decide/?v=3', { + expect(mocks.fetch).toHaveBeenCalledWith('https://us.i.posthog.com/decide/?v=4', { body: JSON.stringify({ token: 'TEST_API_KEY', distinct_id: posthog.getDistinctId(), @@ -772,7 +967,7 @@ describe('PostHog Core', () => { }) it('should load new feature flag payloads', async () => { - expect(mocks.fetch).toHaveBeenCalledWith('https://us.i.posthog.com/decide/?v=3', { + expect(mocks.fetch).toHaveBeenCalledWith('https://us.i.posthog.com/decide/?v=4', { body: JSON.stringify({ token: 'TEST_API_KEY', distinct_id: posthog.getDistinctId(), @@ -794,7 +989,7 @@ describe('PostHog Core', () => { expect(posthog.getFeatureFlagPayload('feature-variant')).toEqual([5]) }) - it('should capture $feature_flag_called with bootstrapped values', async () => { + it('should capture feature_flag_called with bootstrapped values', async () => { expect(posthog.getFeatureFlag('feature-1')).toEqual(true) await waitForPromises() @@ -846,8 +1041,7 @@ describe('PostHog Core', () => { text: () => Promise.resolve('ok'), json: () => Promise.resolve({ - featureFlags: createMockFeatureFlags(), - featureFlagPayloads: createMockFeatureFlagPayloads(), + flags: createMockFeatureFlags(), }), }) } @@ -862,10 +1056,30 @@ describe('PostHog Core', () => { }) }) }, + // Storage cache { distinct_id: '123', - feature_flags: { 'bootstrap-1': 'variant-2' }, - feature_flag_payloads: { 'bootstrap-1': { some: 'other-key' } }, + feature_flag_details: { + flags: { + 'bootstrap-1': { + key: 'bootstrap-1', + enabled: true, + variant: 'variant-2', + reason: { + code: 'matched_condition', + description: 'matched condition set 1', + condition_index: 0, + }, + metadata: { + id: 1, + version: 1, + description: 'bootstrap-1', + payload: '{"some":"other-key"}', + }, + }, + requestId: '8c865d72-94ef-4088-8b4e-cdb7983f0f81', + }, + }, } ) }) diff --git a/posthog-core/test/posthog.featureflags.v3.spec.ts b/posthog-core/test/posthog.featureflags.v3.spec.ts new file mode 100644 index 00000000..1bb3ddf7 --- /dev/null +++ b/posthog-core/test/posthog.featureflags.v3.spec.ts @@ -0,0 +1,917 @@ +import { PostHogPersistedProperty, PostHogV3DecideResponse } from '../src' +import { normalizeDecideResponse } from '../src/featureFlagUtils' +import { createTestClient, PostHogCoreTestClient, PostHogCoreTestClientMocks } from './test-utils/PostHogCoreTestClient' +import { parseBody, waitForPromises } from './test-utils/test-utils' + +describe('PostHog Feature Flags v3', () => { + let posthog: PostHogCoreTestClient + let mocks: PostHogCoreTestClientMocks + + jest.useFakeTimers() + jest.setSystemTime(new Date('2022-01-01')) + + const createMockFeatureFlags = (): any => ({ + 'feature-1': true, + 'feature-2': true, + 'feature-variant': 'variant', + 'json-payload': true, + }) + + const createMockFeatureFlagPayloads = (): any => ({ + 'feature-1': JSON.stringify({ + color: 'blue', + }), + 'feature-variant': JSON.stringify([5]), + 'json-payload': '{"a":"payload"}', + }) + + const errorAPIResponse = Promise.resolve({ + status: 400, + text: () => Promise.resolve('error'), + json: () => + Promise.resolve({ + status: 'error', + }), + }) + + beforeEach(() => { + ;[posthog, mocks] = createTestClient('TEST_API_KEY', { flushAt: 1 }, (_mocks) => { + _mocks.fetch.mockImplementation((url) => { + if (url.includes('/decide/?v=4')) { + return Promise.resolve({ + status: 200, + text: () => Promise.resolve('ok'), + json: () => + Promise.resolve({ + featureFlags: createMockFeatureFlags(), + featureFlagPayloads: createMockFeatureFlagPayloads(), + }), + }) + } + + return Promise.resolve({ + status: 200, + text: () => Promise.resolve('ok'), + json: () => + Promise.resolve({ + status: 'ok', + }), + }) + }) + }) + }) + + describe('featureflags', () => { + it('getFeatureFlags should return undefined if not loaded', () => { + expect(posthog.getFeatureFlags()).toEqual(undefined) + }) + + it('getFeatureFlagPayloads should return undefined if not loaded', () => { + expect(posthog.getFeatureFlagPayloads()).toEqual(undefined) + }) + + it('getFeatureFlag should return undefined if not loaded', () => { + expect(posthog.getFeatureFlag('my-flag')).toEqual(undefined) + expect(posthog.getFeatureFlag('feature-1')).toEqual(undefined) + }) + + it('getFeatureFlagPayload should return undefined if not loaded', () => { + expect(posthog.getFeatureFlagPayload('my-flag')).toEqual(undefined) + }) + + it('isFeatureEnabled should return undefined if not loaded', () => { + expect(posthog.isFeatureEnabled('my-flag')).toEqual(undefined) + expect(posthog.isFeatureEnabled('feature-1')).toEqual(undefined) + }) + + it('should load legacy persisted feature flags', () => { + posthog.setPersistedProperty(PostHogPersistedProperty.FeatureFlags, createMockFeatureFlags()) + expect(posthog.getFeatureFlags()).toEqual(createMockFeatureFlags()) + }) + + it('should only call fetch once if already calling', async () => { + expect(mocks.fetch).toHaveBeenCalledTimes(0) + posthog.reloadFeatureFlagsAsync() + posthog.reloadFeatureFlagsAsync() + const flags = await posthog.reloadFeatureFlagsAsync() + expect(mocks.fetch).toHaveBeenCalledTimes(1) + expect(flags).toEqual(createMockFeatureFlags()) + }) + + it('should emit featureflags event when flags are loaded', async () => { + const receivedFlags: any[] = [] + const unsubscribe = posthog.onFeatureFlags((flags) => { + receivedFlags.push(flags) + }) + + await posthog.reloadFeatureFlagsAsync() + unsubscribe() + + expect(receivedFlags).toEqual([createMockFeatureFlags()]) + }) + + describe('when loaded', () => { + beforeEach(() => { + // The core doesn't reload flags by default (this is handled differently by web and RN) + posthog.reloadFeatureFlags() + }) + + it('should return the value of a flag', async () => { + expect(posthog.getFeatureFlag('feature-1')).toEqual(true) + expect(posthog.getFeatureFlag('feature-variant')).toEqual('variant') + expect(posthog.getFeatureFlag('feature-missing')).toEqual(false) + }) + + it('should return payload of matched flags only', async () => { + expect(posthog.getFeatureFlagPayload('feature-variant')).toEqual([5]) + expect(posthog.getFeatureFlagPayload('feature-1')).toEqual({ + color: 'blue', + }) + + expect(posthog.getFeatureFlagPayload('feature-2')).toEqual(null) + }) + + describe('when errored out', () => { + beforeEach(() => { + ;[posthog, mocks] = createTestClient('TEST_API_KEY', { flushAt: 1 }, (_mocks) => { + _mocks.fetch.mockImplementation((url) => { + if (url.includes('/decide/')) { + return Promise.resolve({ + status: 400, + text: () => Promise.resolve('ok'), + json: () => + Promise.resolve({ + error: 'went wrong', + }), + }) + } + + return errorAPIResponse + }) + }) + + posthog.reloadFeatureFlags() + }) + + it('should return undefined', async () => { + expect(mocks.fetch).toHaveBeenCalledWith('https://us.i.posthog.com/decide/?v=4', { + body: JSON.stringify({ + token: 'TEST_API_KEY', + distinct_id: posthog.getDistinctId(), + groups: {}, + person_properties: {}, + group_properties: {}, + $anon_distinct_id: posthog.getAnonymousId(), + }), + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'User-Agent': 'posthog-core-tests', + }, + signal: expect.anything(), + }) + + expect(posthog.getFeatureFlag('feature-1')).toEqual(undefined) + expect(posthog.getFeatureFlag('feature-variant')).toEqual(undefined) + expect(posthog.getFeatureFlag('feature-missing')).toEqual(undefined) + + expect(posthog.isFeatureEnabled('feature-1')).toEqual(undefined) + expect(posthog.isFeatureEnabled('feature-variant')).toEqual(undefined) + expect(posthog.isFeatureEnabled('feature-missing')).toEqual(undefined) + + expect(posthog.getFeatureFlagPayloads()).toEqual(undefined) + expect(posthog.getFeatureFlagPayload('feature-1')).toEqual(undefined) + }) + }) + + describe('when subsequent decide calls return partial results', () => { + beforeEach(() => { + ;[posthog, mocks] = createTestClient('TEST_API_KEY', { flushAt: 1 }, (_mocks) => { + _mocks.fetch + .mockImplementationOnce((url) => { + if (url.includes('/decide/?v=4')) { + return Promise.resolve({ + status: 200, + text: () => Promise.resolve('ok'), + json: () => + Promise.resolve({ + featureFlags: createMockFeatureFlags(), + }), + }) + } + return errorAPIResponse + }) + .mockImplementationOnce((url) => { + if (url.includes('/decide/?v=4')) { + return Promise.resolve({ + status: 200, + text: () => Promise.resolve('ok'), + json: () => + Promise.resolve({ + featureFlags: { 'x-flag': 'x-value', 'feature-1': false }, + errorsWhileComputingFlags: true, + }), + }) + } + + return errorAPIResponse + }) + .mockImplementation(() => { + return errorAPIResponse + }) + }) + + posthog.reloadFeatureFlags() + }) + + it('should return combined results', async () => { + expect(mocks.fetch).toHaveBeenCalledWith('https://us.i.posthog.com/decide/?v=4', { + body: JSON.stringify({ + token: 'TEST_API_KEY', + distinct_id: posthog.getDistinctId(), + groups: {}, + person_properties: {}, + group_properties: {}, + $anon_distinct_id: posthog.getAnonymousId(), + }), + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'User-Agent': 'posthog-core-tests', + }, + signal: expect.anything(), + }) + + expect(posthog.getFeatureFlags()).toEqual({ + 'feature-1': true, + 'feature-2': true, + 'json-payload': true, + 'feature-variant': 'variant', + }) + + // now second call to feature flags + await posthog.reloadFeatureFlagsAsync(false) + + expect(mocks.fetch).toHaveBeenCalledWith('https://us.i.posthog.com/decide/?v=4', { + body: JSON.stringify({ + token: 'TEST_API_KEY', + distinct_id: posthog.getDistinctId(), + groups: {}, + person_properties: {}, + group_properties: {}, + $anon_distinct_id: posthog.getAnonymousId(), + }), + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'User-Agent': 'posthog-core-tests', + }, + signal: expect.anything(), + }) + + expect(posthog.getFeatureFlags()).toEqual({ + 'feature-1': false, + 'feature-2': true, + 'json-payload': true, + 'feature-variant': 'variant', + 'x-flag': 'x-value', + }) + + expect(posthog.getFeatureFlag('feature-1')).toEqual(false) + expect(posthog.getFeatureFlag('feature-variant')).toEqual('variant') + expect(posthog.getFeatureFlag('feature-missing')).toEqual(false) + expect(posthog.getFeatureFlag('x-flag')).toEqual('x-value') + + expect(posthog.isFeatureEnabled('feature-1')).toEqual(false) + expect(posthog.isFeatureEnabled('feature-variant')).toEqual(true) + expect(posthog.isFeatureEnabled('feature-missing')).toEqual(false) + expect(posthog.isFeatureEnabled('x-flag')).toEqual(true) + }) + }) + + describe('when subsequent decide calls return results without errors', () => { + beforeEach(() => { + ;[posthog, mocks] = createTestClient('TEST_API_KEY', { flushAt: 1 }, (_mocks) => { + _mocks.fetch + .mockImplementationOnce((url) => { + if (url.includes('/decide/?v=4')) { + return Promise.resolve({ + status: 200, + text: () => Promise.resolve('ok'), + json: () => + Promise.resolve({ + featureFlags: createMockFeatureFlags(), + }), + }) + } + return errorAPIResponse + }) + .mockImplementationOnce((url) => { + if (url.includes('/decide/?v=4')) { + return Promise.resolve({ + status: 200, + text: () => Promise.resolve('ok'), + json: () => + Promise.resolve({ + featureFlags: { 'x-flag': 'x-value', 'feature-1': false }, + errorsWhileComputingFlags: false, + }), + }) + } + + return errorAPIResponse + }) + .mockImplementation(() => { + return errorAPIResponse + }) + }) + + posthog.reloadFeatureFlags() + }) + + it('should return only latest results', async () => { + expect(mocks.fetch).toHaveBeenCalledWith('https://us.i.posthog.com/decide/?v=4', { + body: JSON.stringify({ + token: 'TEST_API_KEY', + distinct_id: posthog.getDistinctId(), + groups: {}, + person_properties: {}, + group_properties: {}, + $anon_distinct_id: posthog.getAnonymousId(), + }), + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'User-Agent': 'posthog-core-tests', + }, + signal: expect.anything(), + }) + + expect(posthog.getFeatureFlags()).toEqual({ + 'feature-1': true, + 'feature-2': true, + 'json-payload': true, + 'feature-variant': 'variant', + }) + + // now second call to feature flags + await posthog.reloadFeatureFlagsAsync(false) + + expect(mocks.fetch).toHaveBeenCalledWith('https://us.i.posthog.com/decide/?v=4', { + body: JSON.stringify({ + token: 'TEST_API_KEY', + distinct_id: posthog.getDistinctId(), + groups: {}, + person_properties: {}, + group_properties: {}, + $anon_distinct_id: posthog.getAnonymousId(), + }), + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'User-Agent': 'posthog-core-tests', + }, + signal: expect.anything(), + }) + + expect(posthog.getFeatureFlags()).toEqual({ + 'feature-1': false, + 'x-flag': 'x-value', + }) + + expect(posthog.getFeatureFlag('feature-1')).toEqual(false) + expect(posthog.getFeatureFlag('feature-variant')).toEqual(false) + expect(posthog.getFeatureFlag('feature-missing')).toEqual(false) + expect(posthog.getFeatureFlag('x-flag')).toEqual('x-value') + + expect(posthog.isFeatureEnabled('feature-1')).toEqual(false) + expect(posthog.isFeatureEnabled('feature-variant')).toEqual(false) + expect(posthog.isFeatureEnabled('feature-missing')).toEqual(false) + expect(posthog.isFeatureEnabled('x-flag')).toEqual(true) + }) + }) + + it('should return the boolean value of a flag', async () => { + expect(posthog.isFeatureEnabled('feature-1')).toEqual(true) + expect(posthog.isFeatureEnabled('feature-variant')).toEqual(true) + expect(posthog.isFeatureEnabled('feature-missing')).toEqual(false) + }) + + it('should reload if groups are set', async () => { + posthog.group('my-group', 'is-great') + await waitForPromises() + expect(mocks.fetch).toHaveBeenCalledTimes(2) + expect(JSON.parse(mocks.fetch.mock.calls[1][1].body || '')).toMatchObject({ + groups: { 'my-group': 'is-great' }, + }) + }) + + it('should capture $feature_flag_called when called', async () => { + expect(posthog.getFeatureFlag('feature-1')).toEqual(true) + await waitForPromises() + expect(mocks.fetch).toHaveBeenCalledTimes(2) + + expect(parseBody(mocks.fetch.mock.calls[1])).toMatchObject({ + batch: [ + { + event: '$feature_flag_called', + distinct_id: posthog.getDistinctId(), + properties: { + $feature_flag: 'feature-1', + $feature_flag_response: true, + '$feature/feature-1': true, + $used_bootstrap_value: false, + }, + type: 'capture', + }, + ], + }) + + // Only tracked once + expect(posthog.getFeatureFlag('feature-1')).toEqual(true) + expect(mocks.fetch).toHaveBeenCalledTimes(2) + }) + + it('should capture $feature_flag_called again if new flags', async () => { + expect(posthog.getFeatureFlag('feature-1')).toEqual(true) + await waitForPromises() + expect(mocks.fetch).toHaveBeenCalledTimes(2) + + expect(parseBody(mocks.fetch.mock.calls[1])).toMatchObject({ + batch: [ + { + event: '$feature_flag_called', + distinct_id: posthog.getDistinctId(), + properties: { + $feature_flag: 'feature-1', + $feature_flag_response: true, + '$feature/feature-1': true, + $used_bootstrap_value: false, + }, + type: 'capture', + }, + ], + }) + + await posthog.reloadFeatureFlagsAsync() + posthog.getFeatureFlag('feature-1') + + await waitForPromises() + expect(mocks.fetch).toHaveBeenCalledTimes(4) + + expect(parseBody(mocks.fetch.mock.calls[3])).toMatchObject({ + batch: [ + { + event: '$feature_flag_called', + distinct_id: posthog.getDistinctId(), + properties: { + $feature_flag: 'feature-1', + $feature_flag_response: true, + '$feature/feature-1': true, + $used_bootstrap_value: false, + }, + type: 'capture', + }, + ], + }) + }) + + it('should capture $feature_flag_called when called, but not add all cached flags', async () => { + expect(posthog.getFeatureFlag('feature-1')).toEqual(true) + await waitForPromises() + expect(mocks.fetch).toHaveBeenCalledTimes(2) + + expect(parseBody(mocks.fetch.mock.calls[1])).toMatchObject({ + batch: [ + { + event: '$feature_flag_called', + distinct_id: posthog.getDistinctId(), + properties: { + $feature_flag: 'feature-1', + $feature_flag_response: true, + '$feature/feature-1': true, + $used_bootstrap_value: false, + }, + type: 'capture', + }, + ], + }) + + // Only tracked once + expect(posthog.getFeatureFlag('feature-1')).toEqual(true) + expect(mocks.fetch).toHaveBeenCalledTimes(2) + }) + + it('should persist feature flags', () => { + const expectedFeatureFlags = { + featureFlags: createMockFeatureFlags(), + featureFlagPayloads: createMockFeatureFlagPayloads(), + } + const normalizedFeatureFlags = normalizeDecideResponse(expectedFeatureFlags as PostHogV3DecideResponse) + expect(posthog.getPersistedProperty(PostHogPersistedProperty.FeatureFlagDetails)).toEqual( + normalizedFeatureFlags + ) + }) + + it('should include feature flags in subsequent captures', async () => { + posthog.capture('test-event', { foo: 'bar' }) + + await waitForPromises() + + expect(parseBody(mocks.fetch.mock.calls[1])).toMatchObject({ + batch: [ + { + event: 'test-event', + distinct_id: posthog.getDistinctId(), + properties: { + $active_feature_flags: ['feature-1', 'feature-2', 'feature-variant', 'json-payload'], + '$feature/feature-1': true, + '$feature/feature-2': true, + '$feature/json-payload': true, + '$feature/feature-variant': 'variant', + }, + type: 'capture', + }, + ], + }) + }) + + it('should override flags', () => { + posthog.overrideFeatureFlag({ + 'feature-2': false, + 'feature-variant': 'control', + }) + expect(posthog.getFeatureFlags()).toEqual({ + 'json-payload': true, + 'feature-1': true, + 'feature-variant': 'control', + }) + }) + }) + + describe('when quota limited', () => { + beforeEach(() => { + ;[posthog, mocks] = createTestClient('TEST_API_KEY', { flushAt: 1 }, (_mocks) => { + _mocks.fetch.mockImplementation((url) => { + if (url.includes('/decide/')) { + return Promise.resolve({ + status: 200, + text: () => Promise.resolve('ok'), + json: () => + Promise.resolve({ + quotaLimited: ['feature_flags'], + featureFlags: {}, + featureFlagPayloads: {}, + }), + }) + } + return errorAPIResponse + }) + }) + + posthog.reloadFeatureFlags() + }) + + it('should unset all flags when feature_flags is quota limited', async () => { + // First verify the fetch was called correctly + expect(mocks.fetch).toHaveBeenCalledWith('https://us.i.posthog.com/decide/?v=4', { + body: JSON.stringify({ + token: 'TEST_API_KEY', + distinct_id: posthog.getDistinctId(), + groups: {}, + person_properties: {}, + group_properties: {}, + $anon_distinct_id: posthog.getAnonymousId(), + }), + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'User-Agent': 'posthog-core-tests', + }, + signal: expect.anything(), + }) + + // Verify all flag methods return undefined when quota limited + expect(posthog.getFeatureFlags()).toEqual(undefined) + expect(posthog.getFeatureFlag('feature-1')).toEqual(undefined) + expect(posthog.getFeatureFlagPayloads()).toEqual(undefined) + expect(posthog.getFeatureFlagPayload('feature-1')).toEqual(undefined) + }) + + it('should emit debug message when quota limited', async () => { + const warnSpy = jest.spyOn(console, 'warn') + posthog.debug(true) + await posthog.reloadFeatureFlagsAsync() + + expect(warnSpy).toHaveBeenCalledWith( + '[FEATURE FLAGS] Feature flags quota limit exceeded - unsetting all flags. Learn more about billing limits at https://posthog.com/docs/billing/limits-alerts' + ) + }) + }) + }) + + describe('bootstrapped feature flags', () => { + beforeEach(() => { + ;[posthog, mocks] = createTestClient( + 'TEST_API_KEY', + { + flushAt: 1, + bootstrap: { + distinctId: 'tomato', + featureFlags: { + 'bootstrap-1': 'variant-1', + 'feature-1': 'feature-1-bootstrap-value', + enabled: true, + disabled: false, + }, + featureFlagPayloads: { + 'bootstrap-1': { + some: 'key', + }, + 'feature-1': { + color: 'feature-1-bootstrap-color', + }, + 'not-in-featureFlags': { + color: { foo: 'bar' }, + }, + enabled: 200, + }, + }, + }, + (_mocks) => { + _mocks.fetch.mockImplementation((url) => { + if (url.includes('/decide/')) { + return Promise.reject(new Error('Not responding to emulate use of bootstrapped values')) + } + + return Promise.resolve({ + status: 200, + text: () => Promise.resolve('ok'), + json: () => + Promise.resolve({ + status: 'ok', + }), + }) + }) + } + ) + }) + + it('getFeatureFlags should return bootstrapped flags', async () => { + expect(posthog.getFeatureFlags()).toEqual({ + 'bootstrap-1': 'variant-1', + enabled: true, + 'feature-1': 'feature-1-bootstrap-value', + 'not-in-featureFlags': true, + }) + expect(posthog.getDistinctId()).toEqual('tomato') + expect(posthog.getAnonymousId()).toEqual('tomato') + }) + + it('getFeatureFlag should return bootstrapped flags', async () => { + expect(posthog.getFeatureFlag('my-flag')).toEqual(false) + expect(posthog.getFeatureFlag('bootstrap-1')).toEqual('variant-1') + expect(posthog.getFeatureFlag('enabled')).toEqual(true) + expect(posthog.getFeatureFlag('disabled')).toEqual(false) + // If a bootstrapped payload is not in the feature flags, we treat it as true + expect(posthog.getFeatureFlag('not-in-featureFlags')).toEqual(true) + }) + + it('getFeatureFlag should capture $feature_flag_called with bootstrapped values', async () => { + expect(posthog.getFeatureFlag('bootstrap-1')).toEqual('variant-1') + + await waitForPromises() + expect(mocks.fetch).toHaveBeenCalledTimes(1) + + expect(parseBody(mocks.fetch.mock.calls[0])).toMatchObject({ + batch: [ + { + event: '$feature_flag_called', + distinct_id: posthog.getDistinctId(), + properties: { + $feature_flag: 'bootstrap-1', + $feature_flag_response: 'variant-1', + '$feature/bootstrap-1': 'variant-1', + $feature_flag_bootstrapped_response: 'variant-1', + $feature_flag_bootstrapped_payload: { some: 'key' }, + $used_bootstrap_value: true, + }, + type: 'capture', + }, + ], + }) + }) + + it('isFeatureEnabled should return true/false for bootstrapped flags', () => { + expect(posthog.isFeatureEnabled('my-flag')).toEqual(false) + expect(posthog.isFeatureEnabled('bootstrap-1')).toEqual(true) + expect(posthog.isFeatureEnabled('enabled')).toEqual(true) + expect(posthog.isFeatureEnabled('disabled')).toEqual(false) + expect(posthog.isFeatureEnabled('not-in-featureFlags')).toEqual(true) + }) + + it('getFeatureFlagPayload should return bootstrapped payloads', () => { + expect(posthog.getFeatureFlagPayload('my-flag')).toEqual(null) + expect(posthog.getFeatureFlagPayload('bootstrap-1')).toEqual({ + some: 'key', + }) + expect(posthog.getFeatureFlagPayload('enabled')).toEqual(200) + expect(posthog.getFeatureFlagPayload('not-in-featureFlags')).toEqual({ + color: { foo: 'bar' }, + }) + }) + + describe('when loaded', () => { + beforeEach(() => { + ;[posthog, mocks] = createTestClient( + 'TEST_API_KEY', + { + flushAt: 1, + bootstrap: { + distinctId: 'tomato', + featureFlags: { + 'bootstrap-1': 'variant-1', + 'feature-1': 'feature-1-bootstrap-value', + enabled: true, + disabled: false, + }, + featureFlagPayloads: { + 'bootstrap-1': { + some: 'key', + }, + 'feature-1': { + color: 'feature-1-bootstrap-color', + }, + enabled: 200, + }, + }, + }, + (_mocks) => { + _mocks.fetch.mockImplementation((url) => { + if (url.includes('/decide/')) { + return Promise.resolve({ + status: 200, + text: () => Promise.resolve('ok'), + json: () => + Promise.resolve({ + featureFlags: createMockFeatureFlags(), + featureFlagPayloads: createMockFeatureFlagPayloads(), + }), + }) + } + + return Promise.resolve({ + status: 200, + text: () => Promise.resolve('ok'), + json: () => + Promise.resolve({ + status: 'ok', + }), + }) + }) + } + ) + + posthog.reloadFeatureFlags() + }) + + it('should load new feature flags', async () => { + expect(mocks.fetch).toHaveBeenCalledWith('https://us.i.posthog.com/decide/?v=4', { + body: JSON.stringify({ + token: 'TEST_API_KEY', + distinct_id: posthog.getDistinctId(), + groups: {}, + person_properties: {}, + group_properties: {}, + $anon_distinct_id: 'tomato', + }), + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'User-Agent': 'posthog-core-tests', + }, + signal: expect.anything(), + }) + + expect(posthog.getFeatureFlags()).toEqual({ + 'feature-1': true, + 'feature-2': true, + 'json-payload': true, + 'feature-variant': 'variant', + }) + }) + + it('should load new feature flag payloads', async () => { + expect(mocks.fetch).toHaveBeenCalledWith('https://us.i.posthog.com/decide/?v=4', { + body: JSON.stringify({ + token: 'TEST_API_KEY', + distinct_id: posthog.getDistinctId(), + groups: {}, + person_properties: {}, + group_properties: {}, + $anon_distinct_id: 'tomato', + }), + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'User-Agent': 'posthog-core-tests', + }, + signal: expect.anything(), + }) + expect(posthog.getFeatureFlagPayload('feature-1')).toEqual({ + color: 'blue', + }) + expect(posthog.getFeatureFlagPayload('feature-variant')).toEqual([5]) + }) + + it('should capture $feature_flag_called with bootstrapped values', async () => { + expect(posthog.getFeatureFlag('feature-1')).toEqual(true) + + await waitForPromises() + expect(mocks.fetch).toHaveBeenCalledTimes(2) + + expect(parseBody(mocks.fetch.mock.calls[1])).toMatchObject({ + batch: [ + { + event: '$feature_flag_called', + distinct_id: posthog.getDistinctId(), + properties: { + $feature_flag: 'feature-1', + $feature_flag_response: true, + '$feature/feature-1': true, + $feature_flag_bootstrapped_response: 'feature-1-bootstrap-value', + $feature_flag_bootstrapped_payload: { color: 'feature-1-bootstrap-color' }, + $used_bootstrap_value: false, + }, + type: 'capture', + }, + ], + }) + }) + }) + }) + + describe('bootstapped do not overwrite values', () => { + beforeEach(() => { + ;[posthog, mocks] = createTestClient( + 'TEST_API_KEY', + { + flushAt: 1, + bootstrap: { + distinctId: 'tomato', + featureFlags: { 'bootstrap-1': 'variant-1', enabled: true, disabled: false }, + featureFlagPayloads: { + 'bootstrap-1': { + some: 'key', + }, + enabled: 200, + }, + }, + }, + (_mocks) => { + _mocks.fetch.mockImplementation((url) => { + if (url.includes('/decide/')) { + return Promise.resolve({ + status: 200, + text: () => Promise.resolve('ok'), + json: () => + Promise.resolve({ + featureFlags: createMockFeatureFlags(), + featureFlagPayloads: createMockFeatureFlagPayloads(), + }), + }) + } + + return Promise.resolve({ + status: 200, + text: () => Promise.resolve('ok'), + json: () => + Promise.resolve({ + status: 'ok', + }), + }) + }) + }, + { + distinct_id: '123', + feature_flags: { 'bootstrap-1': 'variant-2' }, + feature_flag_payloads: { 'bootstrap-1': { some: 'other-key' } }, + } + ) + }) + + it('distinct id should not be overwritten if already there', () => { + expect(posthog.getDistinctId()).toEqual('123') + }) + + it('flags should not be overwritten if already there', () => { + expect(posthog.getFeatureFlag('bootstrap-1')).toEqual('variant-2') + }) + + it('flag payloads should not be overwritten if already there', () => { + expect(posthog.getFeatureFlagPayload('bootstrap-1')).toEqual({ + some: 'other-key', + }) + }) + }) +}) diff --git a/posthog-core/test/test-utils/PostHogCoreTestClient.ts b/posthog-core/test/test-utils/PostHogCoreTestClient.ts index 5039e650..232fe714 100644 --- a/posthog-core/test/test-utils/PostHogCoreTestClient.ts +++ b/posthog-core/test/test-utils/PostHogCoreTestClient.ts @@ -1,4 +1,11 @@ -import { JsonType, PostHogCore, PostHogCoreOptions, PostHogFetchOptions, PostHogFetchResponse } from '../../src' +import { + JsonType, + PostHogCore, + PostHogCoreOptions, + PostHogFetchOptions, + PostHogFetchResponse, + PostHogDecideResponse, +} from '../../src' const version = '2.0.0-alpha' @@ -19,6 +26,17 @@ export class PostHogCoreTestClient extends PostHogCore { this.setupBootstrap(options) } + // Expose protected methods for testing + public getDecide( + distinctId: string, + groups: Record = {}, + personProperties: Record = {}, + groupProperties: Record> = {}, + extraPayload: Record = {} + ): Promise { + return super.getDecide(distinctId, groups, personProperties, groupProperties, extraPayload) + } + getPersistedProperty(key: string): T { return this.mocks.storage.getItem(key) } diff --git a/posthog-node/CHANGELOG.md b/posthog-node/CHANGELOG.md index 01101d52..1f046d84 100644 --- a/posthog-node/CHANGELOG.md +++ b/posthog-node/CHANGELOG.md @@ -1,4 +1,8 @@ -# Next +# 4.11.0 - 2025-03-28 + +## Added + +1. `$feature_flag_called` event now includes additional properties such as `feature_flag_id`, `feature_flag_version`, `feature_flag_reason`, and `feature_flag_request_id`. ## Fixed diff --git a/posthog-node/package.json b/posthog-node/package.json index 920b805d..fce32872 100644 --- a/posthog-node/package.json +++ b/posthog-node/package.json @@ -1,6 +1,6 @@ { "name": "posthog-node", - "version": "4.10.2", + "version": "4.11.0", "description": "PostHog Node.js integration", "repository": { "type": "git", diff --git a/posthog-node/src/feature-flags.ts b/posthog-node/src/feature-flags.ts index 04f650a7..40519e9a 100644 --- a/posthog-node/src/feature-flags.ts +++ b/posthog-node/src/feature-flags.ts @@ -1,5 +1,5 @@ import { FeatureFlagCondition, FlagProperty, PostHogFeatureFlag, PropertyGroup } from './types' -import { JsonType, PostHogFetchOptions, PostHogFetchResponse } from 'posthog-core/src' +import { FeatureFlagValue, JsonType, PostHogFetchOptions, PostHogFetchResponse } from 'posthog-core/src' import { safeSetTimeout } from 'posthog-core/src/utils' import fetch from './fetch' import { SIXTY_SECONDS } from './posthog-node' @@ -104,10 +104,10 @@ class FeatureFlagsPoller { groups: Record = {}, personProperties: Record = {}, groupProperties: Record> = {} - ): Promise { + ): Promise { await this.loadFeatureFlags() - let response: string | boolean | undefined = undefined + let response: FeatureFlagValue | undefined = undefined let featureFlag = undefined if (!this.loadedSuccessfullyOnce) { @@ -137,7 +137,7 @@ class FeatureFlagsPoller { return response } - async computeFeatureFlagPayloadLocally(key: string, matchValue: string | boolean): Promise { + async computeFeatureFlagPayloadLocally(key: string, matchValue: FeatureFlagValue): Promise { await this.loadFeatureFlags() let response = undefined @@ -170,13 +170,13 @@ class FeatureFlagsPoller { personProperties: Record = {}, groupProperties: Record> = {} ): Promise<{ - response: Record + response: Record payloads: Record fallbackToDecide: boolean }> { await this.loadFeatureFlags() - const response: Record = {} + const response: Record = {} const payloads: Record = {} let fallbackToDecide = this.featureFlags.length == 0 @@ -209,7 +209,7 @@ class FeatureFlagsPoller { groups: Record = {}, personProperties: Record = {}, groupProperties: Record> = {} - ): Promise { + ): Promise { if (flag.ensure_experience_continuity) { throw new InconclusiveMatchError('Flag has experience continuity enabled') } @@ -251,7 +251,7 @@ class FeatureFlagsPoller { flag: PostHogFeatureFlag, distinctId: string, properties: Record - ): Promise { + ): Promise { const flagFilters = flag.filters || {} const flagConditions = flagFilters.groups || [] let isInconclusive = false @@ -343,7 +343,7 @@ class FeatureFlagsPoller { return true } - async getMatchingVariant(flag: PostHogFeatureFlag, distinctId: string): Promise { + async getMatchingVariant(flag: PostHogFeatureFlag, distinctId: string): Promise { const hashValue = await _hash(flag.key, distinctId, 'variant') const matchingVariant = this.variantLookupTable(flag).find((variant) => { return hashValue >= variant.valueMin && hashValue < variant.valueMax diff --git a/posthog-node/src/posthog-node.ts b/posthog-node/src/posthog-node.ts index 5a8f916e..c7b765a0 100644 --- a/posthog-node/src/posthog-node.ts +++ b/posthog-node/src/posthog-node.ts @@ -12,9 +12,11 @@ import { } from '../../posthog-core/src' import { PostHogMemoryStorage } from '../../posthog-core/src/storage-memory' import { EventMessage, GroupIdentifyMessage, IdentifyMessage, PostHogNodeV1 } from './types' +import { FeatureFlagDetail, FeatureFlagValue } from '../../posthog-core/src/types' import { FeatureFlagsPoller } from './feature-flags' import fetch from './fetch' import ErrorTracking from './error-tracking' +import { getFeatureFlagValue } from 'posthog-core/src/featureFlagUtils' export type PostHogOptions = PostHogCoreOptions & { persistence?: 'memory' @@ -229,7 +231,7 @@ export class PostHog extends PostHogCoreStateless implements PostHogNodeV1 { sendFeatureFlagEvents?: boolean disableGeoip?: boolean } - ): Promise { + ): Promise { const { groups, disableGeoip } = options || {} let { onlyEvaluateLocally, sendFeatureFlagEvents, personProperties, groupProperties } = options || {} @@ -261,8 +263,9 @@ export class PostHog extends PostHogCoreStateless implements PostHogNodeV1 { const flagWasLocallyEvaluated = response !== undefined let requestId = undefined + let flagDetail: FeatureFlagDetail | undefined = undefined if (!flagWasLocallyEvaluated && !onlyEvaluateLocally) { - const remoteResponse = await super.getFeatureFlagStateless( + const remoteResponse = await super.getFeatureFlagDetailStateless( key, distinctId, groups, @@ -270,8 +273,14 @@ export class PostHog extends PostHogCoreStateless implements PostHogNodeV1 { groupProperties, disableGeoip ) - response = remoteResponse.response - requestId = remoteResponse.requestId + + if (remoteResponse === undefined) { + return undefined + } + + flagDetail = remoteResponse.response + response = getFeatureFlagValue(flagDetail) ?? false + requestId = remoteResponse?.requestId } const featureFlagReportedKey = `${key}_${response}` @@ -295,6 +304,9 @@ export class PostHog extends PostHogCoreStateless implements PostHogNodeV1 { properties: { $feature_flag: key, $feature_flag_response: response, + $feature_flag_id: flagDetail?.metadata?.id, + $feature_flag_version: flagDetail?.metadata?.version, + $feature_flag_reason: flagDetail?.reason?.description ?? flagDetail?.reason?.code, locally_evaluated: flagWasLocallyEvaluated, [`$feature/${key}`]: response, $feature_flag_request_id: requestId, @@ -309,7 +321,7 @@ export class PostHog extends PostHogCoreStateless implements PostHogNodeV1 { async getFeatureFlagPayload( key: string, distinctId: string, - matchValue?: string | boolean, + matchValue?: FeatureFlagValue, options?: { groups?: Record personProperties?: Record @@ -334,17 +346,22 @@ export class PostHog extends PostHogCoreStateless implements PostHogNodeV1 { let response = undefined - // Try to get match value locally if not provided - if (!matchValue) { - matchValue = await this.getFeatureFlag(key, distinctId, { - ...options, - onlyEvaluateLocally: true, - }) - } + const localEvaluationEnabled = this.featureFlagsPoller !== undefined + if (localEvaluationEnabled) { + // Try to get match value locally if not provided + if (!matchValue) { + matchValue = await this.getFeatureFlag(key, distinctId, { + ...options, + onlyEvaluateLocally: true, + sendFeatureFlagEvents: false, + }) + } - if (matchValue) { - response = await this.featureFlagsPoller?.computeFeatureFlagPayloadLocally(key, matchValue) + if (matchValue) { + response = await this.featureFlagsPoller?.computeFeatureFlagPayloadLocally(key, matchValue) + } } + //} // set defaults if (onlyEvaluateLocally == undefined) { @@ -406,9 +423,9 @@ export class PostHog extends PostHogCoreStateless implements PostHogNodeV1 { onlyEvaluateLocally?: boolean disableGeoip?: boolean } - ): Promise> { + ): Promise> { const response = await this.getAllFlagsAndPayloads(distinctId, options) - return response.featureFlags + return response.featureFlags || {} } async getAllFlagsAndPayloads( diff --git a/posthog-node/src/types.ts b/posthog-node/src/types.ts index f7b0a7e9..608dce83 100644 --- a/posthog-node/src/types.ts +++ b/posthog-node/src/types.ts @@ -1,4 +1,4 @@ -import { JsonType } from '../../posthog-core/src' +import { FeatureFlagValue, JsonType } from '../../posthog-core/src' export interface IdentifyMessage { distinctId: string @@ -155,7 +155,7 @@ export type PostHogNodeV1 = { onlyEvaluateLocally?: boolean sendFeatureFlagEvents?: boolean } - ): Promise + ): Promise /** * @description Retrieves payload associated with the specified flag and matched value that is passed in. @@ -186,7 +186,7 @@ export type PostHogNodeV1 = { getFeatureFlagPayload( key: string, distinctId: string, - matchValue?: string | boolean, + matchValue?: FeatureFlagValue, options?: { onlyEvaluateLocally?: boolean } diff --git a/posthog-node/test/feature-flags.decide.spec.ts b/posthog-node/test/feature-flags.decide.spec.ts new file mode 100644 index 00000000..46e5a51f --- /dev/null +++ b/posthog-node/test/feature-flags.decide.spec.ts @@ -0,0 +1,293 @@ +import { PostHog as PostHog, PostHogOptions } from '../src/posthog-node' +import fetch from '../src/fetch' +import { apiImplementation, apiImplementationV4 } from './test-utils' +import { waitForPromises } from 'posthog-core/test/test-utils/test-utils' +import { PostHogV4DecideResponse } from 'posthog-core/src/types' +jest.mock('../src/fetch') + +jest.spyOn(console, 'debug').mockImplementation() + +const mockedFetch = jest.mocked(fetch, true) + +const posthogImmediateResolveOptions: PostHogOptions = { + fetchRetryCount: 0, +} + +describe('decide v4', () => { + describe('getFeatureFlag v4', () => { + it('returns false if the flag is not found', async () => { + const decideResponse: PostHogV4DecideResponse = { + flags: {}, + errorsWhileComputingFlags: false, + requestId: '0152a345-295f-4fba-adac-2e6ea9c91082', + } + mockedFetch.mockImplementation(apiImplementationV4(decideResponse)) + + const posthog = new PostHog('TEST_API_KEY', { + host: 'http://example.com', + ...posthogImmediateResolveOptions, + }) + let capturedMessage: any + posthog.on('capture', (message) => { + capturedMessage = message + }) + + const result = await posthog.getFeatureFlag('non-existent-flag', 'some-distinct-id') + + expect(result).toBe(false) + expect(mockedFetch).toHaveBeenCalledWith('http://example.com/decide/?v=4', expect.any(Object)) + + await waitForPromises() + expect(capturedMessage).toMatchObject({ + distinct_id: 'some-distinct-id', + event: '$feature_flag_called', + library: posthog.getLibraryId(), + library_version: posthog.getLibraryVersion(), + properties: { + '$feature/non-existent-flag': false, + $feature_flag: 'non-existent-flag', + $feature_flag_response: false, + $feature_flag_request_id: '0152a345-295f-4fba-adac-2e6ea9c91082', + $groups: undefined, + $lib: posthog.getLibraryId(), + $lib_version: posthog.getLibraryVersion(), + locally_evaluated: false, + }, + }) + }) + + it.each([ + { + key: 'variant-flag', + expectedResponse: 'variant-value', + expectedReason: 'Matched condition set 3', + expectedId: 2, + expectedVersion: 23, + }, + { + key: 'boolean-flag', + expectedResponse: true, + expectedReason: 'Matched condition set 1', + expectedId: 1, + expectedVersion: 12, + }, + { + key: 'non-matching-flag', + expectedResponse: false, + expectedReason: 'Did not match any condition', + expectedId: 3, + expectedVersion: 2, + }, + ])( + 'captures a feature flag called event with extra metadata when the flag is found', + async ({ key, expectedResponse, expectedReason, expectedId, expectedVersion }) => { + const decideResponse: PostHogV4DecideResponse = { + flags: { + 'variant-flag': { + key: 'variant-flag', + enabled: true, + variant: 'variant-value', + reason: { + code: 'variant', + condition_index: 2, + description: 'Matched condition set 3', + }, + metadata: { + id: 2, + version: 23, + payload: '{"key": "value"}', + description: 'description', + }, + }, + 'boolean-flag': { + key: 'boolean-flag', + enabled: true, + variant: undefined, + reason: { + code: 'boolean', + condition_index: 1, + description: 'Matched condition set 1', + }, + metadata: { + id: 1, + version: 12, + payload: undefined, + description: 'description', + }, + }, + 'non-matching-flag': { + key: 'non-matching-flag', + enabled: false, + variant: undefined, + reason: { + code: 'boolean', + condition_index: 1, + description: 'Did not match any condition', + }, + metadata: { + id: 3, + version: 2, + payload: undefined, + description: 'description', + }, + }, + }, + errorsWhileComputingFlags: false, + requestId: '0152a345-295f-4fba-adac-2e6ea9c91082', + } + mockedFetch.mockImplementation(apiImplementationV4(decideResponse)) + + const posthog = new PostHog('TEST_API_KEY', { + host: 'http://example.com', + ...posthogImmediateResolveOptions, + }) + let capturedMessage: any + posthog.on('capture', (message) => { + capturedMessage = message + }) + + const result = await posthog.getFeatureFlag(key, 'some-distinct-id') + + expect(result).toBe(expectedResponse) + expect(mockedFetch).toHaveBeenCalledWith('http://example.com/decide/?v=4', expect.any(Object)) + + await waitForPromises() + expect(capturedMessage).toMatchObject({ + distinct_id: 'some-distinct-id', + event: '$feature_flag_called', + library: posthog.getLibraryId(), + library_version: posthog.getLibraryVersion(), + properties: { + [`$feature/${key}`]: expectedResponse, + $feature_flag: key, + $feature_flag_response: expectedResponse, + $feature_flag_id: expectedId, + $feature_flag_version: expectedVersion, + $feature_flag_reason: expectedReason, + $feature_flag_request_id: '0152a345-295f-4fba-adac-2e6ea9c91082', + $groups: undefined, + $lib: posthog.getLibraryId(), + $lib_version: posthog.getLibraryVersion(), + locally_evaluated: false, + }, + }) + } + ) + + describe('getFeatureFlagPayload v4', () => { + it('returns payload', async () => { + mockedFetch.mockImplementation( + apiImplementationV4({ + flags: { + 'flag-with-payload': { + key: 'flag-with-payload', + enabled: true, + variant: undefined, + reason: { + code: 'boolean', + condition_index: 1, + description: 'Matched condition set 2', + }, + metadata: { + id: 1, + version: 12, + payload: '[0, 1, 2]', + description: 'description', + }, + }, + }, + errorsWhileComputingFlags: false, + }) + ) + + const posthog = new PostHog('TEST_API_KEY', { + host: 'http://example.com', + ...posthogImmediateResolveOptions, + }) + let capturedMessage: any + posthog.on('capture', (message) => { + capturedMessage = message + }) + + const result = await posthog.getFeatureFlagPayload('flag-with-payload', 'some-distinct-id') + + expect(result).toEqual([0, 1, 2]) + expect(mockedFetch).toHaveBeenCalledWith('http://example.com/decide/?v=4', expect.any(Object)) + + await waitForPromises() + expect(capturedMessage).toBeUndefined() + }) + }) + }) +}) + +describe('decide v3', () => { + describe('getFeatureFlag v3', () => { + it('returns false if the flag is not found', async () => { + mockedFetch.mockImplementation(apiImplementation({ decideFlags: {} })) + + const posthog = new PostHog('TEST_API_KEY', { + host: 'http://example.com', + ...posthogImmediateResolveOptions, + }) + let capturedMessage: any + posthog.on('capture', (message) => { + capturedMessage = message + }) + + const result = await posthog.getFeatureFlag('non-existent-flag', 'some-distinct-id') + + expect(result).toBe(false) + expect(mockedFetch).toHaveBeenCalledWith('http://example.com/decide/?v=4', expect.any(Object)) + + await waitForPromises() + expect(capturedMessage).toMatchObject({ + distinct_id: 'some-distinct-id', + event: '$feature_flag_called', + library: posthog.getLibraryId(), + library_version: posthog.getLibraryVersion(), + properties: { + '$feature/non-existent-flag': false, + $feature_flag: 'non-existent-flag', + $feature_flag_response: false, + $groups: undefined, + $lib: posthog.getLibraryId(), + $lib_version: posthog.getLibraryVersion(), + locally_evaluated: false, + }, + }) + }) + + describe('getFeatureFlagPayload v3', () => { + it('returns payload', async () => { + mockedFetch.mockImplementation( + apiImplementation({ + decideFlags: { + 'flag-with-payload': true, + }, + decideFlagPayloads: { + 'flag-with-payload': [0, 1, 2], + }, + }) + ) + + const posthog = new PostHog('TEST_API_KEY', { + host: 'http://example.com', + ...posthogImmediateResolveOptions, + }) + let capturedMessage: any = undefined + posthog.on('capture', (message) => { + capturedMessage = message + }) + + const result = await posthog.getFeatureFlagPayload('flag-with-payload', 'some-distinct-id') + + expect(result).toEqual([0, 1, 2]) + expect(mockedFetch).toHaveBeenCalledWith('http://example.com/decide/?v=4', expect.any(Object)) + + await waitForPromises() + expect(capturedMessage).toBeUndefined() + }) + }) + }) +}) diff --git a/posthog-node/test/feature-flags.spec.ts b/posthog-node/test/feature-flags.spec.ts index 55026507..12ce03be 100644 --- a/posthog-node/test/feature-flags.spec.ts +++ b/posthog-node/test/feature-flags.spec.ts @@ -347,7 +347,7 @@ describe('local evaluation', () => { }) ).toEqual('decide-fallback-value') expect(mockedFetch).toHaveBeenCalledWith( - 'http://example.com/decide/?v=3', + 'http://example.com/decide/?v=4', expect.objectContaining({ body: JSON.stringify({ token: 'TEST_API_KEY', @@ -371,7 +371,7 @@ describe('local evaluation', () => { await posthog.getFeatureFlag('complex-flag', 'some-distinct-id', { personProperties: { doesnt_matter: '1' } }) ).toEqual('decide-fallback-value') expect(mockedFetch).toHaveBeenCalledWith( - 'http://example.com/decide/?v=3', + 'http://example.com/decide/?v=4', expect.objectContaining({ body: JSON.stringify({ token: 'TEST_API_KEY', diff --git a/posthog-node/test/posthog-node.spec.ts b/posthog-node/test/posthog-node.spec.ts index 3f34188b..2e146b90 100644 --- a/posthog-node/test/posthog-node.spec.ts +++ b/posthog-node/test/posthog-node.spec.ts @@ -614,7 +614,7 @@ describe('PostHog Node.js', () => { ) expect(mockedFetch).toHaveBeenCalledTimes(1) expect(mockedFetch).toHaveBeenCalledWith( - 'http://example.com/decide/?v=3', + 'http://example.com/decide/?v=4', expect.objectContaining({ method: 'POST', body: expect.stringContaining('"geoip_disable":true') }) ) }) @@ -647,7 +647,7 @@ describe('PostHog Node.js', () => { await waitForPromises() expect(mockedFetch).toHaveBeenCalledWith( - 'http://example.com/decide/?v=3', + 'http://example.com/decide/?v=4', expect.objectContaining({ method: 'POST' }) ) @@ -672,7 +672,7 @@ describe('PostHog Node.js', () => { expect(mockedFetch).not.toHaveBeenCalledWith(...anyLocalEvalCall) expect(mockedFetch).toHaveBeenCalledWith( - 'http://example.com/decide/?v=3', + 'http://example.com/decide/?v=4', expect.objectContaining({ method: 'POST', body: expect.stringContaining('"geoip_disable":true') }) ) }) @@ -732,7 +732,7 @@ describe('PostHog Node.js', () => { expect(mockedFetch).toHaveBeenCalledWith(...anyLocalEvalCall) // no decide call expect(mockedFetch).not.toHaveBeenCalledWith( - 'http://example.com/decide/?v=3', + 'http://example.com/decide/?v=4', expect.objectContaining({ method: 'POST' }) ) @@ -790,7 +790,7 @@ describe('PostHog Node.js', () => { expect(mockedFetch).toHaveBeenCalledWith(...anyLocalEvalCall) // no decide call expect(mockedFetch).not.toHaveBeenCalledWith( - 'http://example.com/decide/?v=3', + 'http://example.com/decide/?v=4', expect.objectContaining({ method: 'POST' }) ) @@ -838,7 +838,7 @@ describe('PostHog Node.js', () => { await waitForFlushTimer() expect(mockedFetch).toHaveBeenCalledWith( - 'http://example.com/decide/?v=3', + 'http://example.com/decide/?v=4', expect.objectContaining({ method: 'POST', body: expect.not.stringContaining('geoip_disable') }) ) @@ -1091,7 +1091,7 @@ describe('PostHog Node.js', () => { ).resolves.toEqual(2) expect(mockedFetch).toHaveBeenCalledTimes(1) expect(mockedFetch).toHaveBeenCalledWith( - 'http://example.com/decide/?v=3', + 'http://example.com/decide/?v=4', expect.objectContaining({ method: 'POST', body: expect.stringContaining('"geoip_disable":true') }) ) }) @@ -1133,7 +1133,7 @@ describe('PostHog Node.js', () => { ).resolves.toEqual([1]) expect(mockedFetch).toHaveBeenCalledTimes(1) expect(mockedFetch).toHaveBeenCalledWith( - 'http://example.com/decide/?v=3', + 'http://example.com/decide/?v=4', expect.objectContaining({ method: 'POST', body: expect.stringContaining('"geoip_disable":true') }) ) }) @@ -1153,7 +1153,7 @@ describe('PostHog Node.js', () => { ).resolves.toEqual(2) expect(mockedFetch).toHaveBeenCalledTimes(1) expect(mockedFetch).toHaveBeenCalledWith( - 'http://example.com/decide/?v=3', + 'http://example.com/decide/?v=4', expect.objectContaining({ method: 'POST', body: expect.stringContaining('"geoip_disable":true') }) ) @@ -1162,7 +1162,7 @@ describe('PostHog Node.js', () => { await expect(posthog.isFeatureEnabled('feature-variant', '123', { disableGeoip: false })).resolves.toEqual(true) expect(mockedFetch).toHaveBeenCalledTimes(1) expect(mockedFetch).toHaveBeenCalledWith( - 'http://example.com/decide/?v=3', + 'http://example.com/decide/?v=4', expect.objectContaining({ method: 'POST', body: expect.not.stringContaining('geoip_disable') }) ) }) @@ -1176,7 +1176,7 @@ describe('PostHog Node.js', () => { jest.runOnlyPendingTimers() expect(mockedFetch).toHaveBeenCalledWith( - 'http://example.com/decide/?v=3', + 'http://example.com/decide/?v=4', expect.objectContaining({ body: JSON.stringify({ token: 'TEST_API_KEY', @@ -1206,7 +1206,7 @@ describe('PostHog Node.js', () => { jest.runOnlyPendingTimers() expect(mockedFetch).toHaveBeenCalledWith( - 'http://example.com/decide/?v=3', + 'http://example.com/decide/?v=4', expect.objectContaining({ body: JSON.stringify({ token: 'TEST_API_KEY', @@ -1237,7 +1237,7 @@ describe('PostHog Node.js', () => { jest.runOnlyPendingTimers() expect(mockedFetch).toHaveBeenCalledWith( - 'http://example.com/decide/?v=3', + 'http://example.com/decide/?v=4', expect.objectContaining({ body: JSON.stringify({ token: 'TEST_API_KEY', @@ -1261,7 +1261,7 @@ describe('PostHog Node.js', () => { jest.runOnlyPendingTimers() expect(mockedFetch).toHaveBeenCalledWith( - 'http://example.com/decide/?v=3', + 'http://example.com/decide/?v=4', expect.objectContaining({ body: JSON.stringify({ token: 'TEST_API_KEY', @@ -1281,7 +1281,7 @@ describe('PostHog Node.js', () => { jest.runOnlyPendingTimers() expect(mockedFetch).toHaveBeenCalledWith( - 'http://example.com/decide/?v=3', + 'http://example.com/decide/?v=4', expect.objectContaining({ body: JSON.stringify({ token: 'TEST_API_KEY', @@ -1303,7 +1303,7 @@ describe('PostHog Node.js', () => { jest.runOnlyPendingTimers() expect(mockedFetch).toHaveBeenCalledWith( - 'http://example.com/decide/?v=3', + 'http://example.com/decide/?v=4', expect.objectContaining({ body: JSON.stringify({ token: 'TEST_API_KEY', diff --git a/posthog-node/test/test-utils.ts b/posthog-node/test/test-utils.ts index 8c82ece0..5ecdaa8f 100644 --- a/posthog-node/test/test-utils.ts +++ b/posthog-node/test/test-utils.ts @@ -1,3 +1,26 @@ +import { PostHogV4DecideResponse } from 'posthog-core/src/types' + +export const apiImplementationV4 = (decideResponse?: PostHogV4DecideResponse) => { + return (url: any): Promise => { + if ((url as any).includes('/decide/?v=4')) { + return Promise.resolve({ + status: 200, + text: () => Promise.resolve('ok'), + json: () => Promise.resolve(decideResponse), + }) as any + } + + return Promise.resolve({ + status: 400, + text: () => Promise.resolve('ok'), + json: () => + Promise.resolve({ + status: 'ok', + }), + }) as any + } +} + export const apiImplementation = ({ localFlags, decideFlags, @@ -68,4 +91,4 @@ export const anyLocalEvalCall = [ 'http://example.com/api/feature_flag/local_evaluation?token=TEST_API_KEY&send_cohorts', expect.any(Object), ] -export const anyDecideCall = ['http://example.com/decide/?v=3', expect.any(Object)] +export const anyDecideCall = ['http://example.com/decide/?v=4', expect.any(Object)] diff --git a/posthog-react-native/CHANGELOG.md b/posthog-react-native/CHANGELOG.md index ca0e104c..d801c566 100644 --- a/posthog-react-native/CHANGELOG.md +++ b/posthog-react-native/CHANGELOG.md @@ -1,5 +1,9 @@ # Next +## Added + +1. `$feature_flag_called` event now includes additional properties such as `feature_flag_id`, `feature_flag_version`, `feature_flag_reason`, and `feature_flag_request_id`. + ## Fixed 1. apiKey cannot be empty. diff --git a/posthog-react-native/src/hooks/useFeatureFlag.ts b/posthog-react-native/src/hooks/useFeatureFlag.ts index 8b55cb1e..f1cdb4ae 100644 --- a/posthog-react-native/src/hooks/useFeatureFlag.ts +++ b/posthog-react-native/src/hooks/useFeatureFlag.ts @@ -1,13 +1,13 @@ import { useEffect, useState } from 'react' import { usePostHog } from './usePostHog' -import { JsonType } from 'posthog-core/src' +import { JsonType, FeatureFlagValue } from 'posthog-core/src' import { PostHog } from '../posthog-rn' -export function useFeatureFlag(flag: string, client?: PostHog): string | boolean | undefined { +export function useFeatureFlag(flag: string, client?: PostHog): FeatureFlagValue | undefined { const contextClient = usePostHog() const posthog = client || contextClient - const [featureFlag, setFeatureFlag] = useState(posthog.getFeatureFlag(flag)) + const [featureFlag, setFeatureFlag] = useState(posthog.getFeatureFlag(flag)) useEffect(() => { setFeatureFlag(posthog.getFeatureFlag(flag)) @@ -19,7 +19,7 @@ export function useFeatureFlag(flag: string, client?: PostHog): string | boolean return featureFlag } -export type FeatureFlagWithPayload = [boolean | string | undefined, JsonType | undefined] +export type FeatureFlagWithPayload = [FeatureFlagValue | undefined, JsonType | undefined] export function useFeatureFlagWithPayload(flag: string, client?: PostHog): FeatureFlagWithPayload { const contextClient = usePostHog() diff --git a/posthog-react-native/src/surveys/getActiveMatchingSurveys.ts b/posthog-react-native/src/surveys/getActiveMatchingSurveys.ts index 0fc56e81..bf20a4fa 100644 --- a/posthog-react-native/src/surveys/getActiveMatchingSurveys.ts +++ b/posthog-react-native/src/surveys/getActiveMatchingSurveys.ts @@ -1,6 +1,7 @@ import { canActivateRepeatedly, hasEvents } from './surveys-utils' import { Survey, SurveyMatchType } from '../../../posthog-core/src/surveys-types' import { currentDeviceType } from '../native-deps' +import { FeatureFlagValue } from 'posthog-core/src' const isMatchingRegex = function (value: string, pattern: string): boolean { if (!isValidRegex(pattern)) { @@ -55,7 +56,7 @@ function doesSurveyDeviceTypesMatch(survey: Survey): boolean { export function getActiveMatchingSurveys( surveys: Survey[], - flags: Record, + flags: Record, seenSurveys: string[], activatedSurveys: ReadonlySet // lastSeenSurveyDate: Date | undefined diff --git a/posthog-web/test/posthog-web.spec.ts b/posthog-web/test/posthog-web.spec.ts index e3f127aa..a82d2bdf 100644 --- a/posthog-web/test/posthog-web.spec.ts +++ b/posthog-web/test/posthog-web.spec.ts @@ -61,7 +61,7 @@ describe('PostHogWeb', () => { await waitForPromises() - expect(fetch).toHaveBeenCalledWith('https://us.i.posthog.com/decide/?v=3', { + expect(fetch).toHaveBeenCalledWith('https://us.i.posthog.com/decide/?v=4', { body: JSON.stringify({ token: 'TEST_API_KEY', distinct_id: posthog.getDistinctId(),