Skip to content

Allow passing groups to feature flags #2010

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions react/src/components/PostHogFeature.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export type PostHogFeatureProps = React.HTMLProps<HTMLDivElement> & {
visibilityObserverOptions?: IntersectionObserverInit
trackInteraction?: boolean
trackView?: boolean
groups?: Record<string, string>
}

export function PostHogFeature({
Expand All @@ -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
Expand All @@ -37,6 +39,7 @@ export function PostHogFeature({
options={visibilityObserverOptions}
trackInteraction={shouldTrackInteraction}
trackView={shouldTrackView}
groups={groups}
{...props}
>
{childNode}
Expand Down Expand Up @@ -128,18 +131,20 @@ function VisibilityAndClickTrackers({
trackInteraction,
trackView,
options,
groups,
...props
}: {
flag: string
children: React.ReactNode
trackInteraction: boolean
trackView: boolean
options?: IntersectionObserverInit
groups?: Record<string, string>
}): JSX.Element {
const clickTrackedRef = useRef(false)
const visibilityTrackedRef = useRef(false)
const posthog = usePostHog()
const variant = useFeatureFlagVariantKey(flag)
const variant = useFeatureFlagVariantKey(flag, { groups })
Copy link
Contributor

Choose a reason for hiding this comment

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

style: Consider moving variant call up to parent component to avoid duplicate feature flag evaluations (already called in lines 28-29)


const cachedOnClick = useCallback(() => {
if (!clickTrackedRef.current && trackInteraction) {
Expand Down
28 changes: 28 additions & 0 deletions react/src/components/__tests__/PostHogFeature.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<PostHogProvider client={mockPosthog}>
<PostHogFeature flag="test" groups={{ team: '123' }}>
<div data-testid="groupFeature">Group Feature</div>
</PostHogFeature>
</PostHogProvider>
)

// Verify that getFeatureFlag was called with groups option
expect(mockPosthog.getFeatureFlag).toHaveBeenCalledWith('test', expect.objectContaining({
groups: { team: '123' }
}))
})
})
25 changes: 24 additions & 1 deletion react/src/hooks/__tests__/featureFlags.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
}
Comment on lines +35 to +38
Copy link
Contributor

Choose a reason for hiding this comment

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

style: Magic string 'group_variant' should be defined as a constant at the top of the file

}
return FEATURE_FLAG_STATUS[flag]
},
getFeatureFlagPayload: (flag) => FEATURE_FLAG_PAYLOADS[flag],
onFeatureFlags: (callback) => {
const activeFlags = []
Expand Down Expand Up @@ -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)
})
})
13 changes: 9 additions & 4 deletions react/src/hooks/useFeatureFlagEnabled.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> }
): boolean | undefined {
const client = usePostHog()

const [featureEnabled, setFeatureEnabled] = useState<boolean | undefined>(() => client.isFeatureEnabled(flag))
const [featureEnabled, setFeatureEnabled] = useState<boolean | undefined>(() =>
!!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])
Copy link
Contributor

Choose a reason for hiding this comment

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

logic: including 'options' in deps array may cause infinite re-renders if object is recreated on each render


return featureEnabled
}
7 changes: 5 additions & 2 deletions react/src/hooks/useFeatureFlagPayload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> }
): JsonType {
const client = usePostHog()

const [featureFlagPayload, setFeatureFlagPayload] = useState<JsonType>(() => client.getFeatureFlagPayload(flag))
Copy link
Contributor

Choose a reason for hiding this comment

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

logic: Initial call to getFeatureFlagPayload() is missing the groups option, causing inconsistency with subsequent updates

Suggested change
const [featureFlagPayload, setFeatureFlagPayload] = useState<JsonType>(() => client.getFeatureFlagPayload(flag))
const [featureFlagPayload, setFeatureFlagPayload] = useState<JsonType>(() => client.getFeatureFlagPayload(flag, options?.groups))

Expand All @@ -11,7 +14,7 @@ export function useFeatureFlagPayload(flag: string): JsonType {
return client.onFeatureFlags(() => {
setFeatureFlagPayload(client.getFeatureFlagPayload(flag))
})
Comment on lines 14 to 16
Copy link
Contributor

Choose a reason for hiding this comment

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

logic: The groups option is not being passed to getFeatureFlagPayload() here, which means group-based evaluation won't work despite the parameter being added to the function signature

Suggested change
return client.onFeatureFlags(() => {
setFeatureFlagPayload(client.getFeatureFlagPayload(flag))
})
return client.onFeatureFlags(() => {
setFeatureFlagPayload(client.getFeatureFlagPayload(flag, options?.groups))
})

}, [client, flag])
}, [client, flag, options])

return featureFlagPayload
}
11 changes: 7 additions & 4 deletions react/src/hooks/useFeatureFlagVariantKey.ts
Original file line number Diff line number Diff line change
@@ -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, string> }
): string | boolean | undefined {
const client = usePostHog()

const [featureFlagVariantKey, setFeatureFlagVariantKey] = useState<string | boolean | undefined>(() =>
client.getFeatureFlag(flag)
client.getFeatureFlag(flag, { send_event: false, ...options })
)
Comment on lines 10 to 12
Copy link
Contributor

Choose a reason for hiding this comment

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

style: Consider memoizing options object with useMemo to prevent unnecessary re-renders when passing inline objects


useEffect(() => {
return client.onFeatureFlags(() => {
setFeatureFlagVariantKey(client.getFeatureFlag(flag))
setFeatureFlagVariantKey(client.getFeatureFlag(flag, { send_event: false, ...options }))
})
}, [client, flag])
}, [client, flag, options])
Copy link
Contributor

Choose a reason for hiding this comment

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

logic: Adding 'options' to deps array could cause infinite loops if options object is created inline - needs memoization


return featureFlagVariantKey
}
20 changes: 20 additions & 0 deletions src/__tests__/featureflags.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
20 changes: 17 additions & 3 deletions src/__tests__/posthog-core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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', () => {
Expand All @@ -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]
Comment on lines +1128 to +1130
Copy link
Contributor

Choose a reason for hiding this comment

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

style: Consider refactoring this payload extraction logic into a test helper function since it's used multiple times

// 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!)) {
Expand All @@ -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', () => {
Expand Down
7 changes: 7 additions & 0 deletions src/posthog-core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
13 changes: 10 additions & 3 deletions src/posthog-featureflags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Comment on lines +475 to 476
Copy link
Contributor

Choose a reason for hiding this comment

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

style: JSDoc param type doesn't match updated TypeScript type (Object|String vs { send_event?: boolean; groups?: Record<string, string> })

Suggested change
* @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.
*/
* @param {{ send_event?: boolean; groups?: Record<string, 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<string, string> } = {}
): 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
Expand Down Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1253,6 +1253,11 @@ export interface CaptureOptions {
*/
skip_client_rate_limiting?: boolean

/**
* If set, overrides the groups sent with this event
*/
groups?: Record<string, string>

/**
* If set, overrides the desired transport method
*/
Expand Down