From a19a801b826706459fa5b3f8f65ae82ed1035352 Mon Sep 17 00:00:00 2001 From: Patricio Date: Fri, 24 Apr 2026 17:46:35 -0300 Subject: [PATCH 1/2] feat(flags): support mixed targeting in local evaluation --- .changeset/mixed-targeting-local-eval.md | 5 + .../node/src/__tests__/feature-flags.spec.ts | 153 ++++++++++++++++++ .../extensions/feature-flags/feature-flags.ts | 41 ++++- packages/node/src/types.ts | 1 + 4 files changed, 198 insertions(+), 2 deletions(-) create mode 100644 .changeset/mixed-targeting-local-eval.md diff --git a/.changeset/mixed-targeting-local-eval.md b/.changeset/mixed-targeting-local-eval.md new file mode 100644 index 0000000000..a5a51fe451 --- /dev/null +++ b/.changeset/mixed-targeting-local-eval.md @@ -0,0 +1,5 @@ +--- +'posthog-node': patch +--- + +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..b14b8789df 100644 --- a/packages/node/src/__tests__/feature-flags.spec.ts +++ b/packages/node/src/__tests__/feature-flags.spec.ts @@ -6707,3 +6707,156 @@ 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' }, + } + + it.each([ + { + name: 'person condition matches when no groups passed', + distinctId: 'user-1', + options: { personProperties: { email: 'test@example.com' } }, + expected: true, + }, + { + name: 'group condition matches when group props match', + 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', + distinctId: 'user-3', + options: { + groups: { company: 'acme' }, + groupProperties: { company: { plan: 'free' } }, + personProperties: { email: 'nope@example.com' }, + }, + expected: false, + }, + ])('$name', async ({ distinctId, options, expected }) => { + mockedFetch.mockImplementation(apiImplementation({ localFlags: mixedFlagLocalResponse })) + + posthog = new PostHog('TEST_API_KEY', { + host: 'http://example.com', + personalApiKey: 'TEST_PERSONAL_API_KEY', + ...posthogImmediateResolveOptions, + }) + + expect(await posthog.getFeatureFlag('mixed-flag', distinctId, options)).toEqual(expected) + // No fallback to /flags — mixed targeting must resolve locally. + expect(mockedFetch).not.toHaveBeenCalledWith(...anyFlagsCall) + }) + + it('only group conditions, no groups passed: returns false (not inconclusive, no /flags fallback)', async () => { + 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, + }, + ], + }, + } + mockedFetch.mockImplementation( + apiImplementation({ + localFlags: { flags: [onlyGroupFlag], group_type_mapping: { '0': 'company' } }, + decideFlags: { 'only-group-flag': 'server-fallback' }, + }) + ) + + posthog = new PostHog('TEST_API_KEY', { + host: 'http://example.com', + personalApiKey: 'TEST_PERSONAL_API_KEY', + ...posthogImmediateResolveOptions, + }) + + // Group condition skips (no groups passed); no inconclusive raised, no server fallback. + expect(await posthog.getFeatureFlag('only-group-flag', 'user-1')).toEqual(false) + 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 From 5ae481b9fbb13ef172d8d111c2effaf6c8a97549 Mon Sep 17 00:00:00 2001 From: Patricio Date: Tue, 28 Apr 2026 10:24:02 -0300 Subject: [PATCH 2/2] chore(flags): address review feedback --- .changeset/mixed-targeting-local-eval.md | 2 +- .../node/src/__tests__/feature-flags.spec.ts | 65 +++++++++---------- 2 files changed, 33 insertions(+), 34 deletions(-) diff --git a/.changeset/mixed-targeting-local-eval.md b/.changeset/mixed-targeting-local-eval.md index a5a51fe451..9024ab25cf 100644 --- a/.changeset/mixed-targeting-local-eval.md +++ b/.changeset/mixed-targeting-local-eval.md @@ -1,5 +1,5 @@ --- -'posthog-node': patch +'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 b14b8789df..0ee427393b 100644 --- a/packages/node/src/__tests__/feature-flags.spec.ts +++ b/packages/node/src/__tests__/feature-flags.spec.ts @@ -6744,15 +6744,32 @@ describe('mixed targeting local evaluation', () => { 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' }, @@ -6763,6 +6780,7 @@ describe('mixed targeting local evaluation', () => { }, { name: 'no match when both person and group fail', + flagKey: 'mixed-flag', distinctId: 'user-3', options: { groups: { company: 'acme' }, @@ -6771,39 +6789,20 @@ describe('mixed targeting local evaluation', () => { }, expected: false, }, - ])('$name', async ({ distinctId, options, expected }) => { - mockedFetch.mockImplementation(apiImplementation({ localFlags: mixedFlagLocalResponse })) - - posthog = new PostHog('TEST_API_KEY', { - host: 'http://example.com', - personalApiKey: 'TEST_PERSONAL_API_KEY', - ...posthogImmediateResolveOptions, - }) - - expect(await posthog.getFeatureFlag('mixed-flag', distinctId, options)).toEqual(expected) - // No fallback to /flags — mixed targeting must resolve locally. - expect(mockedFetch).not.toHaveBeenCalledWith(...anyFlagsCall) - }) - - it('only group conditions, no groups passed: returns false (not inconclusive, no /flags fallback)', async () => { - 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, - }, - ], - }, - } + { + 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: { flags: [onlyGroupFlag], group_type_mapping: { '0': 'company' } }, - decideFlags: { 'only-group-flag': 'server-fallback' }, + localFlags: localFlags ?? mixedFlagLocalResponse, + decideFlags, }) ) @@ -6813,8 +6812,8 @@ describe('mixed targeting local evaluation', () => { ...posthogImmediateResolveOptions, }) - // Group condition skips (no groups passed); no inconclusive raised, no server fallback. - expect(await posthog.getFeatureFlag('only-group-flag', 'user-1')).toEqual(false) + expect(await posthog.getFeatureFlag(flagKey, distinctId, options)).toEqual(expected) + // No fallback to /flags — mixed targeting must resolve locally. expect(mockedFetch).not.toHaveBeenCalledWith(...anyFlagsCall) })