diff --git a/react/src/components/PostHogFeature.tsx b/react/src/components/PostHogFeature.tsx index cd8d8f80c..0a592c79f 100644 --- a/react/src/components/PostHogFeature.tsx +++ b/react/src/components/PostHogFeature.tsx @@ -11,6 +11,7 @@ export type PostHogFeatureProps = React.HTMLProps & { visibilityObserverOptions?: IntersectionObserverInit trackInteraction?: boolean trackView?: boolean + groups?: Record } export function PostHogFeature({ @@ -21,10 +22,11 @@ export function PostHogFeature({ visibilityObserverOptions, trackInteraction, trackView, + groups, ...props }: PostHogFeatureProps): JSX.Element | null { - const payload = useFeatureFlagPayload(flag) - const variant = useFeatureFlagVariantKey(flag) + const payload = useFeatureFlagPayload(flag, { groups }) + const variant = useFeatureFlagVariantKey(flag, { groups }) const shouldTrackInteraction = trackInteraction ?? true const shouldTrackView = trackView ?? true @@ -37,6 +39,7 @@ export function PostHogFeature({ options={visibilityObserverOptions} trackInteraction={shouldTrackInteraction} trackView={shouldTrackView} + groups={groups} {...props} > {childNode} @@ -128,6 +131,7 @@ function VisibilityAndClickTrackers({ trackInteraction, trackView, options, + groups, ...props }: { flag: string @@ -135,11 +139,12 @@ function VisibilityAndClickTrackers({ trackInteraction: boolean trackView: boolean options?: IntersectionObserverInit + groups?: Record }): JSX.Element { const clickTrackedRef = useRef(false) const visibilityTrackedRef = useRef(false) const posthog = usePostHog() - const variant = useFeatureFlagVariantKey(flag) + const variant = useFeatureFlagVariantKey(flag, { groups }) const cachedOnClick = useCallback(() => { if (!clickTrackedRef.current && trackInteraction) { diff --git a/react/src/components/__tests__/PostHogFeature.test.jsx b/react/src/components/__tests__/PostHogFeature.test.jsx index 8df6722b5..952298e57 100644 --- a/react/src/components/__tests__/PostHogFeature.test.jsx +++ b/react/src/components/__tests__/PostHogFeature.test.jsx @@ -295,4 +295,32 @@ describe('PostHogFeature component', () => { fireEvent.click(screen.getByTestId('hi_example_feature_1_payload')) expect(given.posthog.capture).toHaveBeenCalledTimes(1) }) + + it('should support groups parameter', () => { + // Mock posthog to check if groups are passed through + const mockPosthog = { + ...given.posthog, + getFeatureFlag: jest.fn((flag, options) => { + // Return different values when groups are passed + if (options && options.groups && options.groups.team === '123') { + return 'group_variant' + } + return FEATURE_FLAG_STATUS[flag] + }), + getFeatureFlagPayload: jest.fn((flag) => FEATURE_FLAG_PAYLOADS[flag]) + } + + render( + + +
Group Feature
+
+
+ ) + + // Verify that getFeatureFlag was called with groups option + expect(mockPosthog.getFeatureFlag).toHaveBeenCalledWith('test', expect.objectContaining({ + groups: { team: '123' } + })) + }) }) diff --git a/react/src/hooks/__tests__/featureFlags.test.jsx b/react/src/hooks/__tests__/featureFlags.test.jsx index d07f8dfff..c456ddfa7 100644 --- a/react/src/hooks/__tests__/featureFlags.test.jsx +++ b/react/src/hooks/__tests__/featureFlags.test.jsx @@ -29,7 +29,16 @@ describe('useFeatureFlagPayload hook', () => { given('posthog', () => ({ isFeatureEnabled: (flag) => !!FEATURE_FLAG_STATUS[flag], - getFeatureFlag: (flag) => FEATURE_FLAG_STATUS[flag], + getFeatureFlag: (flag, options) => { + // Mock that groups options are passed through + if (options && options.groups) { + // Return different values when groups are passed to test the functionality + if (flag === 'group_specific_flag' && options.groups.team === '123') { + return 'group_variant' + } + } + return FEATURE_FLAG_STATUS[flag] + }, getFeatureFlagPayload: (flag) => FEATURE_FLAG_PAYLOADS[flag], onFeatureFlags: (callback) => { const activeFlags = [] @@ -90,4 +99,18 @@ describe('useFeatureFlagPayload hook', () => { }) expect(result.current).toEqual(expected) }) + + it('should pass groups to feature flag variant key', () => { + let { result } = renderHook(() => useFeatureFlagVariantKey('group_specific_flag', { groups: { team: '123' } }), { + wrapper: given.renderProvider, + }) + expect(result.current).toEqual('group_variant') + }) + + it('should pass groups to feature flag enabled check', () => { + let { result } = renderHook(() => useFeatureFlagEnabled('group_specific_flag', { groups: { team: '123' } }), { + wrapper: given.renderProvider, + }) + expect(result.current).toEqual(true) + }) }) diff --git a/react/src/hooks/useFeatureFlagEnabled.ts b/react/src/hooks/useFeatureFlagEnabled.ts index e183f2d71..c6174fa56 100644 --- a/react/src/hooks/useFeatureFlagEnabled.ts +++ b/react/src/hooks/useFeatureFlagEnabled.ts @@ -1,16 +1,21 @@ import { useEffect, useState } from 'react' import { usePostHog } from './usePostHog' -export function useFeatureFlagEnabled(flag: string): boolean | undefined { +export function useFeatureFlagEnabled( + flag: string, + options?: { groups?: Record } +): boolean | undefined { const client = usePostHog() - const [featureEnabled, setFeatureEnabled] = useState(() => client.isFeatureEnabled(flag)) + const [featureEnabled, setFeatureEnabled] = useState(() => + !!client.getFeatureFlag(flag, { send_event: false, ...options }) + ) useEffect(() => { return client.onFeatureFlags(() => { - setFeatureEnabled(client.isFeatureEnabled(flag)) + setFeatureEnabled(!!client.getFeatureFlag(flag, { send_event: false, ...options })) }) - }, [client, flag]) + }, [client, flag, options]) return featureEnabled } diff --git a/react/src/hooks/useFeatureFlagPayload.ts b/react/src/hooks/useFeatureFlagPayload.ts index 7ae7b32a0..07cafbbe4 100644 --- a/react/src/hooks/useFeatureFlagPayload.ts +++ b/react/src/hooks/useFeatureFlagPayload.ts @@ -2,7 +2,10 @@ import { useEffect, useState } from 'react' import { JsonType } from 'posthog-js' import { usePostHog } from './usePostHog' -export function useFeatureFlagPayload(flag: string): JsonType { +export function useFeatureFlagPayload( + flag: string, + options?: { groups?: Record } +): JsonType { const client = usePostHog() const [featureFlagPayload, setFeatureFlagPayload] = useState(() => client.getFeatureFlagPayload(flag)) @@ -11,7 +14,7 @@ export function useFeatureFlagPayload(flag: string): JsonType { return client.onFeatureFlags(() => { setFeatureFlagPayload(client.getFeatureFlagPayload(flag)) }) - }, [client, flag]) + }, [client, flag, options]) return featureFlagPayload } diff --git a/react/src/hooks/useFeatureFlagVariantKey.ts b/react/src/hooks/useFeatureFlagVariantKey.ts index 7bbda6489..10b08b523 100644 --- a/react/src/hooks/useFeatureFlagVariantKey.ts +++ b/react/src/hooks/useFeatureFlagVariantKey.ts @@ -1,18 +1,21 @@ import { useEffect, useState } from 'react' import { usePostHog } from './usePostHog' -export function useFeatureFlagVariantKey(flag: string): string | boolean | undefined { +export function useFeatureFlagVariantKey( + flag: string, + options?: { groups?: Record } +): string | boolean | undefined { const client = usePostHog() const [featureFlagVariantKey, setFeatureFlagVariantKey] = useState(() => - client.getFeatureFlag(flag) + client.getFeatureFlag(flag, { send_event: false, ...options }) ) useEffect(() => { return client.onFeatureFlags(() => { - setFeatureFlagVariantKey(client.getFeatureFlag(flag)) + setFeatureFlagVariantKey(client.getFeatureFlag(flag, { send_event: false, ...options })) }) - }, [client, flag]) + }, [client, flag, options]) return featureFlagVariantKey } diff --git a/src/__tests__/featureflags.test.ts b/src/__tests__/featureflags.test.ts index 136e216dd..54fba1b59 100644 --- a/src/__tests__/featureflags.test.ts +++ b/src/__tests__/featureflags.test.ts @@ -1586,6 +1586,26 @@ describe('featureflags', () => { ) }) + it('includes groups passed via options in feature flag called event', () => { + featureFlags.receivedFeatureFlags({ + featureFlags: { 'test-flag': true }, + featureFlagPayloads: {}, + requestId: TEST_REQUEST_ID, + }) + featureFlags._hasLoadedFlags = true + + featureFlags.getFeatureFlag('test-flag', { groups: { playlist: '1' } }) + + expect(instance.capture).toHaveBeenCalledWith( + '$feature_flag_called', + expect.objectContaining({ + $feature_flag: 'test-flag', + $feature_flag_response: true, + }), + { groups: { playlist: '1' } } + ) + }) + it('includes version in feature flag called event', () => { // Setup flags with requestId featureFlags.receivedFeatureFlags({ diff --git a/src/__tests__/posthog-core.ts b/src/__tests__/posthog-core.ts index 20a15ae9c..ce1b23710 100644 --- a/src/__tests__/posthog-core.ts +++ b/src/__tests__/posthog-core.ts @@ -12,6 +12,7 @@ import { SessionIdManager } from '../sessionid' import { RequestQueue } from '../request-queue' import { SessionRecording } from '../extensions/replay/sessionrecording' import { SessionPropsManager } from '../session-props' +import { isArray } from '../utils/type-utils' let mockGetProperties: jest.Mock @@ -1115,6 +1116,7 @@ describe('posthog core', () => { posthog._requestQueue = { enqueue: jest.fn(), } as unknown as RequestQueue + jest.spyOn(posthog as any, '_send_retriable_request').mockImplementation(jest.fn()) }) it('sends group information in event properties', () => { @@ -1123,9 +1125,9 @@ describe('posthog core', () => { posthog.capture('some_event', { prop: 5 }) - expect(posthog._requestQueue!.enqueue).toHaveBeenCalledTimes(1) - - const eventPayload = jest.mocked(posthog._requestQueue!.enqueue).mock.calls[0][0] + const eventPayload = + jest.mocked(posthog._requestQueue!.enqueue).mock.calls[0]?.[0] || + jest.mocked(posthog._send_retriable_request as any).mock.calls[0][0] // need to help TS know event payload data is not an array // eslint-disable-next-line posthog-js/no-direct-array-check if (Array.isArray(eventPayload.data!)) { @@ -1137,6 +1139,18 @@ describe('posthog core', () => { instance: 'app.posthog.com', }) }) + + it('supports groups passed via capture options', () => { + posthog.capture('some_event', {}, { groups: { team: '1' } }) + + const eventPayload = + jest.mocked(posthog._requestQueue!.enqueue).mock.calls[0]?.[0] || + jest.mocked(posthog._send_retriable_request as any).mock.calls[0][0] + if (isArray(eventPayload.data!)) { + throw new Error('') + } + expect(eventPayload.data!.properties.$groups).toEqual({ team: '1' }) + }) }) describe('error handling', () => { diff --git a/src/posthog-core.ts b/src/posthog-core.ts index 93fcf9122..b7b161740 100644 --- a/src/posthog-core.ts +++ b/src/posthog-core.ts @@ -914,6 +914,13 @@ export class PostHog { properties: this.calculateEventProperties(event_name, properties || {}, timestamp, uuid), } + if (options?.groups) { + data.properties['$groups'] = { + ...(data.properties['$groups'] || {}), + ...options.groups, + } + } + if (clientRateLimitContext) { data.properties['$lib_rate_limit_remaining_tokens'] = clientRateLimitContext.remainingTokens } diff --git a/src/posthog-featureflags.ts b/src/posthog-featureflags.ts index c4da5f1eb..f01c1a597 100644 --- a/src/posthog-featureflags.ts +++ b/src/posthog-featureflags.ts @@ -472,9 +472,12 @@ export class PostHogFeatureFlags { * if(posthog.getFeatureFlag('my-flag') === 'some-variant') { // do something } * * @param {Object|String} key Key of the feature flag. - * @param {Object|String} options (optional) If {send_event: false}, we won't send an $feature_flag_called event to PostHog. + * @param {Object|String} options (optional) If {send_event: false}, we won't send an $feature_flag_called event to PostHog. If {groups: {group_type: group_key}}, we will use the group key to evaluate the flag. */ - getFeatureFlag(key: string, options: { send_event?: boolean } = {}): boolean | string | undefined { + getFeatureFlag( + key: string, + options: { send_event?: boolean; groups?: Record } = {} + ): boolean | string | undefined { if (!this._hasLoadedFlags && !(this.getFlags() && this.getFlags().length > 0)) { logger.warn('getFeatureFlag for key "' + key + '" failed. Feature flags didn\'t load in time.') return undefined @@ -533,7 +536,11 @@ export class PostHogFeatureFlags { properties.$feature_flag_original_payload = flagDetails?.metadata?.original_payload } - this._instance.capture('$feature_flag_called', properties) + if (options.groups) { + this._instance.capture('$feature_flag_called', properties, { groups: options.groups }) + } else { + this._instance.capture('$feature_flag_called', properties) + } } } return flagValue diff --git a/src/types.ts b/src/types.ts index 2cec4a708..d39b48f13 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1253,6 +1253,11 @@ export interface CaptureOptions { */ skip_client_rate_limiting?: boolean + /** + * If set, overrides the groups sent with this event + */ + groups?: Record + /** * If set, overrides the desired transport method */