Skip to content

Commit daf919b

Browse files
authored
feat(flags): add evaluation_environments to node and react-native SDKs (#2417)
1 parent cd4a0d0 commit daf919b

File tree

8 files changed

+230
-9
lines changed

8 files changed

+230
-9
lines changed
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
'@posthog/core': minor
3+
---
4+
5+
feat: Add evaluation environments support for feature flags
6+
7+
This PR adds base support for evaluation environments in the core library, allowing SDKs that extend the core to specify which environment tags their SDK instance should use when evaluating feature flags.
8+
9+
The core library now handles sending the `evaluation_environments` parameter to the feature flags API when configured.
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
'posthog-node': minor
3+
---
4+
5+
feat: Add evaluation environments support for feature flags
6+
7+
This PR implements support for evaluation environments in the posthog-node SDK, allowing users to specify which environment tags their SDK instance should use when evaluating feature flags.
8+
9+
Users can now configure the SDK with an `evaluationEnvironments` option:
10+
11+
```typescript
12+
const client = new PostHog('api-key', {
13+
host: 'https://app.posthog.com',
14+
evaluationEnvironments: ['production', 'backend', 'api'],
15+
})
16+
```
17+
18+
When set, only feature flags that have at least one matching evaluation tag will be evaluated for this SDK instance. Feature flags with no evaluation tags will always be evaluated.
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
'posthog-react-native': minor
3+
---
4+
5+
feat: Add evaluation environments support for feature flags
6+
7+
This PR implements support for evaluation environments in the posthog-react-native SDK, allowing users to specify which environment tags their SDK instance should use when evaluating feature flags.
8+
9+
Users can now configure the SDK with an `evaluationEnvironments` option:
10+
11+
```typescript
12+
const posthog = new PostHog('api-key', {
13+
host: 'https://app.posthog.com',
14+
evaluationEnvironments: ['production', 'mobile', 'react-native'],
15+
})
16+
```
17+
18+
When set, only feature flags that have at least one matching evaluation tag will be evaluated for this SDK instance. Feature flags with no evaluation tags will always be evaluated.

packages/core/src/posthog-core-stateless.ts

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,7 @@ export abstract class PostHogCoreStateless {
116116
private removeDebugCallback?: () => void
117117
private disableGeoip: boolean
118118
private historicalMigration: boolean
119+
private evaluationEnvironments?: readonly string[]
119120
protected disabled
120121
protected disableCompression: boolean
121122

@@ -166,6 +167,7 @@ export abstract class PostHogCoreStateless {
166167
this.disableGeoip = options.disableGeoip ?? true
167168
this.disabled = options.disabled ?? false
168169
this.historicalMigration = options?.historicalMigration ?? false
170+
this.evaluationEnvironments = options?.evaluationEnvironments
169171
// Init promise allows the derived class to block calls until it is ready
170172
this._initPromise = Promise.resolve()
171173
this._isInitialized = true
@@ -453,17 +455,24 @@ export abstract class PostHogCoreStateless {
453455
await this._initPromise
454456

455457
const url = `${this.host}/flags/?v=2&config=true`
458+
const requestData: Record<string, any> = {
459+
token: this.apiKey,
460+
distinct_id: distinctId,
461+
groups,
462+
person_properties: personProperties,
463+
group_properties: groupProperties,
464+
...extraPayload,
465+
}
466+
467+
// Add evaluation environments if configured
468+
if (this.evaluationEnvironments && this.evaluationEnvironments.length > 0) {
469+
requestData.evaluation_environments = this.evaluationEnvironments
470+
}
471+
456472
const fetchOptions: PostHogFetchOptions = {
457473
method: 'POST',
458474
headers: { ...this.getCustomHeaders(), 'Content-Type': 'application/json' },
459-
body: JSON.stringify({
460-
token: this.apiKey,
461-
distinct_id: distinctId,
462-
groups,
463-
person_properties: personProperties,
464-
group_properties: groupProperties,
465-
...extraPayload,
466-
}),
475+
body: JSON.stringify(requestData),
467476
}
468477

469478
this._logger.info('Flags URL', url)

packages/core/src/types.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,17 @@ export type PostHogCoreOptions = {
5555
disableGeoip?: boolean
5656
/** Special flag to indicate ingested data is for a historical migration. */
5757
historicalMigration?: boolean
58+
/**
59+
* Evaluation environments for feature flags.
60+
* When set, only feature flags that have at least one matching evaluation tag
61+
* will be evaluated for this SDK instance. Feature flags with no evaluation tags
62+
* will always be evaluated.
63+
*
64+
* Examples: ['production', 'web', 'mobile']
65+
*
66+
* @default undefined
67+
*/
68+
evaluationEnvironments?: readonly string[]
5869
}
5970

6071
export enum PostHogPersistedProperty {

packages/node/src/__tests__/posthog-node.spec.ts

Lines changed: 91 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
1-
import { PostHog } from '@/entrypoints/index.node'
1+
import { PostHog, PostHogOptions } from '@/entrypoints/index.node'
22
import { anyFlagsCall, anyLocalEvalCall, apiImplementation, isPending, wait, waitForPromises } from './utils'
33
import { randomUUID } from 'crypto'
44

55
jest.mock('../version', () => ({ version: '1.2.3' }))
66

77
const mockedFetch = jest.spyOn(globalThis, 'fetch').mockImplementation()
88

9+
const posthogImmediateResolveOptions: PostHogOptions = {
10+
fetchRetryCount: 0,
11+
}
12+
913
const waitForFlushTimer = async (): Promise<void> => {
1014
await waitForPromises()
1115
// To trigger the flush via the timer
@@ -2469,6 +2473,92 @@ describe('PostHog Node.js', () => {
24692473
})
24702474
})
24712475

2476+
describe('evaluation environments', () => {
2477+
beforeEach(() => {
2478+
mockedFetch.mockClear()
2479+
})
2480+
2481+
it('should send evaluation environments when configured', async () => {
2482+
mockedFetch.mockImplementation(
2483+
apiImplementation({
2484+
decideFlags: { 'test-flag': true },
2485+
flagsPayloads: {},
2486+
})
2487+
)
2488+
2489+
const posthogWithEnvs = new PostHog('TEST_API_KEY', {
2490+
host: 'http://example.com',
2491+
evaluationEnvironments: ['production', 'backend'],
2492+
...posthogImmediateResolveOptions,
2493+
})
2494+
2495+
await posthogWithEnvs.getAllFlags('some-distinct-id')
2496+
2497+
expect(mockedFetch).toHaveBeenCalledWith(
2498+
'http://example.com/flags/?v=2&config=true',
2499+
expect.objectContaining({
2500+
method: 'POST',
2501+
body: expect.stringContaining('"evaluation_environments":["production","backend"]'),
2502+
})
2503+
)
2504+
2505+
await posthogWithEnvs.shutdown()
2506+
})
2507+
2508+
it('should not send evaluation environments when not configured', async () => {
2509+
mockedFetch.mockImplementation(
2510+
apiImplementation({
2511+
decideFlags: { 'test-flag': true },
2512+
flagsPayloads: {},
2513+
})
2514+
)
2515+
2516+
const posthogWithoutEnvs = new PostHog('TEST_API_KEY', {
2517+
host: 'http://example.com',
2518+
...posthogImmediateResolveOptions,
2519+
})
2520+
2521+
await posthogWithoutEnvs.getAllFlags('some-distinct-id')
2522+
2523+
expect(mockedFetch).toHaveBeenCalledWith(
2524+
'http://example.com/flags/?v=2&config=true',
2525+
expect.objectContaining({
2526+
method: 'POST',
2527+
body: expect.not.stringContaining('evaluation_environments'),
2528+
})
2529+
)
2530+
2531+
await posthogWithoutEnvs.shutdown()
2532+
})
2533+
2534+
it('should not send evaluation environments when configured as empty array', async () => {
2535+
mockedFetch.mockImplementation(
2536+
apiImplementation({
2537+
decideFlags: { 'test-flag': true },
2538+
flagsPayloads: {},
2539+
})
2540+
)
2541+
2542+
const posthogWithEmptyEnvs = new PostHog('TEST_API_KEY', {
2543+
host: 'http://example.com',
2544+
evaluationEnvironments: [],
2545+
...posthogImmediateResolveOptions,
2546+
})
2547+
2548+
await posthogWithEmptyEnvs.getAllFlags('some-distinct-id')
2549+
2550+
expect(mockedFetch).toHaveBeenCalledWith(
2551+
'http://example.com/flags/?v=2&config=true',
2552+
expect.objectContaining({
2553+
method: 'POST',
2554+
body: expect.not.stringContaining('evaluation_environments'),
2555+
})
2556+
)
2557+
2558+
await posthogWithEmptyEnvs.shutdown()
2559+
})
2560+
})
2561+
24722562
describe('getRemoteConfigPayload', () => {
24732563
let requestRemoteConfigPayloadSpy: jest.SpyInstance
24742564

packages/node/src/types.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,17 @@ export type PostHogOptions = PostHogCoreOptions & {
7878
* If a function returns null, the event will be dropped.
7979
*/
8080
before_send?: BeforeSendFn | BeforeSendFn[]
81+
/**
82+
* Evaluation environments for feature flags.
83+
* When set, only feature flags that have at least one matching evaluation tag
84+
* will be evaluated for this SDK instance. Feature flags with no evaluation tags
85+
* will always be evaluated.
86+
*
87+
* Examples: ['production', 'backend', 'api']
88+
*
89+
* @default undefined
90+
*/
91+
evaluationEnvironments?: readonly string[]
8192
}
8293

8394
export type PostHogFeatureFlag = {

packages/react-native/test/posthog.spec.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,61 @@ Linking.getInitialURL = jest.fn(() => Promise.resolve(null))
77
AppState.addEventListener = jest.fn()
88

99
describe('PostHog React Native', () => {
10+
describe('evaluation environments', () => {
11+
it('should send evaluation environments when configured', async () => {
12+
posthog = new PostHog('test-token', {
13+
evaluationEnvironments: ['production', 'mobile'],
14+
flushInterval: 0,
15+
})
16+
await posthog.ready()
17+
18+
await posthog.reloadFeatureFlagsAsync()
19+
20+
expect((globalThis as any).window.fetch).toHaveBeenCalledWith(
21+
expect.stringContaining('/flags/?v=2&config=true'),
22+
expect.objectContaining({
23+
method: 'POST',
24+
body: expect.stringContaining('"evaluation_environments":["production","mobile"]'),
25+
})
26+
)
27+
})
28+
29+
it('should not send evaluation environments when not configured', async () => {
30+
posthog = new PostHog('test-token', {
31+
flushInterval: 0,
32+
})
33+
await posthog.ready()
34+
35+
await posthog.reloadFeatureFlagsAsync()
36+
37+
expect((globalThis as any).window.fetch).toHaveBeenCalledWith(
38+
expect.stringContaining('/flags/?v=2&config=true'),
39+
expect.objectContaining({
40+
method: 'POST',
41+
body: expect.not.stringContaining('evaluation_environments'),
42+
})
43+
)
44+
})
45+
46+
it('should not send evaluation environments when configured as empty array', async () => {
47+
posthog = new PostHog('test-token', {
48+
evaluationEnvironments: [],
49+
flushInterval: 0,
50+
})
51+
await posthog.ready()
52+
53+
await posthog.reloadFeatureFlagsAsync()
54+
55+
expect((globalThis as any).window.fetch).toHaveBeenCalledWith(
56+
expect.stringContaining('/flags/?v=2&config=true'),
57+
expect.objectContaining({
58+
method: 'POST',
59+
body: expect.not.stringContaining('evaluation_environments'),
60+
})
61+
)
62+
})
63+
})
64+
1065
let mockStorage: PostHogCustomStorage
1166
let cache: any = {}
1267

0 commit comments

Comments
 (0)