diff --git a/.changeset/mixed-targeting-local-eval.md b/.changeset/mixed-targeting-local-eval.md new file mode 100644 index 0000000000..9024ab25cf --- /dev/null +++ b/.changeset/mixed-targeting-local-eval.md @@ -0,0 +1,5 @@ +--- +'posthog-node': minor +--- + +feat(flags): support mixed targeting in local evaluation diff --git a/packages/node/src/__tests__/feature-flags.spec.ts b/packages/node/src/__tests__/feature-flags.spec.ts index 410ee8d014..0ee427393b 100644 --- a/packages/node/src/__tests__/feature-flags.spec.ts +++ b/packages/node/src/__tests__/feature-flags.spec.ts @@ -6707,3 +6707,155 @@ describe('strictLocalEvaluation option', () => { expect(flagDefinitionsLoadedAt).toBeGreaterThan(0) }) }) + +describe('mixed targeting local evaluation', () => { + let posthog: PostHog + + jest.useFakeTimers() + + afterEach(async () => { + await posthog.shutdown() + }) + + const mixedFlag = { + id: 1, + name: 'Mixed Flag', + key: 'mixed-flag', + active: true, + filters: { + aggregation_group_type_index: null, + groups: [ + { + aggregation_group_type_index: 0, + properties: [{ key: 'plan', operator: 'exact', value: ['enterprise'], type: 'group', group_type_index: 0 }], + rollout_percentage: 100, + }, + { + aggregation_group_type_index: null, + properties: [{ key: 'email', operator: 'exact', value: ['test@example.com'], type: 'person' }], + rollout_percentage: 100, + }, + ], + }, + } + + const mixedFlagLocalResponse = { + flags: [mixedFlag], + group_type_mapping: { '0': 'company' }, + } + + const onlyGroupFlag = { + ...mixedFlag, + key: 'only-group-flag', + filters: { + aggregation_group_type_index: null, + groups: [ + { + aggregation_group_type_index: 0, + properties: [{ key: 'plan', operator: 'exact', value: ['enterprise'], type: 'group', group_type_index: 0 }], + rollout_percentage: 100, + }, + ], + }, + } + + it.each([ + { + name: 'person condition matches when no groups passed', + flagKey: 'mixed-flag', + distinctId: 'user-1', + options: { personProperties: { email: 'test@example.com' } }, + expected: true, + }, + { + name: 'group condition matches when group props match', + flagKey: 'mixed-flag', + distinctId: 'user-2', + options: { + groups: { company: 'acme' }, + groupProperties: { company: { plan: 'enterprise' } }, + personProperties: { email: 'nope@example.com' }, + }, + expected: true, + }, + { + name: 'no match when both person and group fail', + flagKey: 'mixed-flag', + distinctId: 'user-3', + options: { + groups: { company: 'acme' }, + groupProperties: { company: { plan: 'free' } }, + personProperties: { email: 'nope@example.com' }, + }, + expected: false, + }, + { + name: 'only group conditions, no groups passed: returns false without /flags fallback', + flagKey: 'only-group-flag', + distinctId: 'user-1', + options: {}, + expected: false, + localFlags: { flags: [onlyGroupFlag], group_type_mapping: { '0': 'company' } }, + decideFlags: { 'only-group-flag': 'server-fallback' }, + }, + ])('$name', async ({ flagKey, distinctId, options, expected, localFlags, decideFlags }) => { + mockedFetch.mockImplementation( + apiImplementation({ + localFlags: localFlags ?? mixedFlagLocalResponse, + decideFlags, + }) + ) + + posthog = new PostHog('TEST_API_KEY', { + host: 'http://example.com', + personalApiKey: 'TEST_PERSONAL_API_KEY', + ...posthogImmediateResolveOptions, + }) + + expect(await posthog.getFeatureFlag(flagKey, distinctId, options)).toEqual(expected) + // No fallback to /flags — mixed targeting must resolve locally. + expect(mockedFetch).not.toHaveBeenCalledWith(...anyFlagsCall) + }) + + it('rollout uses group bucketing for group conditions and distinct_id for person conditions', async () => { + // A group condition with low rollout on one group key and high rollout on a person condition. + // The group condition should hash on the group key, not the distinct_id. + const flag = { + id: 1, + name: 'Rollout Flag', + key: 'rollout-flag', + active: true, + filters: { + aggregation_group_type_index: null, + groups: [ + { + aggregation_group_type_index: 0, + properties: [], + rollout_percentage: 100, + }, + ], + }, + } + mockedFetch.mockImplementation( + apiImplementation({ + localFlags: { flags: [flag], group_type_mapping: { '0': 'company' } }, + }) + ) + + posthog = new PostHog('TEST_API_KEY', { + host: 'http://example.com', + personalApiKey: 'TEST_PERSONAL_API_KEY', + ...posthogImmediateResolveOptions, + }) + + // With rollout 100%, matches deterministically regardless of hashing — but calling with the group + // passed should resolve locally, proving the group bucketing path is taken. + expect( + await posthog.getFeatureFlag('rollout-flag', 'any-distinct-id', { + groups: { company: 'acme' }, + groupProperties: { company: {} }, + }) + ).toEqual(true) + expect(mockedFetch).not.toHaveBeenCalledWith(...anyFlagsCall) + }) +}) diff --git a/packages/node/src/extensions/feature-flags/feature-flags.ts b/packages/node/src/extensions/feature-flags/feature-flags.ts index a175917ced..2675905e24 100644 --- a/packages/node/src/extensions/feature-flags/feature-flags.ts +++ b/packages/node/src/extensions/feature-flags/feature-flags.ts @@ -485,18 +485,55 @@ class FeatureFlagsPoller { ): Promise { const flagFilters = flag.filters || {} const flagConditions = flagFilters.groups || [] + const flagAggregation = flagFilters.aggregation_group_type_index + const { groups, groupProperties } = evaluationContext let isInconclusive = false let result = undefined for (const condition of flagConditions) { try { - if (await this.isConditionMatch(flag, bucketingValue, condition, properties, evaluationContext)) { + // Per-condition aggregation overrides only when the condition explicitly + // sets its own aggregation_group_type_index (mixed targeting). + // When absent, use the properties/bucketing already resolved by the caller. + const conditionAggregation = + condition.aggregation_group_type_index !== undefined + ? condition.aggregation_group_type_index + : flagAggregation + + let effectiveProperties = properties + let effectiveBucketingValue = bucketingValue + + // Mixed-override path: condition-level aggregation differs from flag-level. + // This assumes flag-level aggregation is null/undefined for mixed flags. + if (conditionAggregation !== flagAggregation) { + if (conditionAggregation !== null && conditionAggregation !== undefined) { + const groupName = this.groupTypeMapping[String(conditionAggregation)] + if (!groupName || !(groupName in groups)) { + this.logMsgIfDebug(() => + console.debug( + `[FEATURE FLAGS] Skipping group condition for flag '${flag.key}': group type index ${conditionAggregation} not available` + ) + ) + continue + } + if (!(groupName in groupProperties)) { + isInconclusive = true + continue + } + effectiveProperties = groupProperties[groupName] + effectiveBucketingValue = groups[groupName] + } + } + + if ( + await this.isConditionMatch(flag, effectiveBucketingValue, condition, effectiveProperties, evaluationContext) + ) { const variantOverride = condition.variant const flagVariants = flagFilters.multivariate?.variants || [] if (variantOverride && flagVariants.some((variant) => variant.key === variantOverride)) { result = variantOverride } else { - result = (await this.getMatchingVariant(flag, bucketingValue)) || true + result = (await this.getMatchingVariant(flag, effectiveBucketingValue)) || true } break } diff --git a/packages/node/src/types.ts b/packages/node/src/types.ts index 6630773cc0..5fedd43876 100644 --- a/packages/node/src/types.ts +++ b/packages/node/src/types.ts @@ -112,6 +112,7 @@ export type FeatureFlagCondition = { properties: FlagProperty[] rollout_percentage?: number variant?: string + aggregation_group_type_index?: number | null } export type FeatureFlagBucketingIdentifier = 'distinct_id' | 'device_id' | '' | null