Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/evaluation-environments-core.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'@posthog/core': minor
---

feat: Add evaluation environments support for feature flags

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.

The core library now handles sending the `evaluation_environments` parameter to the feature flags API when configured.
18 changes: 18 additions & 0 deletions .changeset/evaluation-environments-node.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
'posthog-node': minor
---

feat: Add evaluation environments support for feature flags

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.

Users can now configure the SDK with an `evaluationEnvironments` option:

```typescript
const client = new PostHog('api-key', {
host: 'https://app.posthog.com',
evaluationEnvironments: ['production', 'backend', 'api'],
})
```

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.
18 changes: 18 additions & 0 deletions .changeset/evaluation-environments-react-native.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
'posthog-react-native': minor
---

feat: Add evaluation environments support for feature flags

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.

Users can now configure the SDK with an `evaluationEnvironments` option:

```typescript
const posthog = new PostHog('api-key', {
host: 'https://app.posthog.com',
evaluationEnvironments: ['production', 'mobile', 'react-native'],
})
```

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.
25 changes: 17 additions & 8 deletions packages/core/src/posthog-core-stateless.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ export abstract class PostHogCoreStateless {
private removeDebugCallback?: () => void
private disableGeoip: boolean
private historicalMigration: boolean
private evaluationEnvironments?: readonly string[]
protected disabled
protected disableCompression: boolean

Expand Down Expand Up @@ -166,6 +167,7 @@ export abstract class PostHogCoreStateless {
this.disableGeoip = options.disableGeoip ?? true
this.disabled = options.disabled ?? false
this.historicalMigration = options?.historicalMigration ?? false
this.evaluationEnvironments = options?.evaluationEnvironments
Copy link
Contributor

Choose a reason for hiding this comment

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

syntax: Missing property declaration for evaluationEnvironments. This will cause a TypeScript compilation error since the property is not declared in the class.

Suggested change
this.evaluationEnvironments = options?.evaluationEnvironments
// Evaluation environments configuration
private evaluationEnvironments?: string[];
constructor(apiKey: string, options?: PostHogCoreOptions) {
assert(apiKey, "You must pass your PostHog project's api key.")
this.apiKey = apiKey
this.host = removeTrailingSlash(options?.host || 'https://us.i.posthog.com')
this.flushAt = options?.flushAt ? Math.max(options?.flushAt, 1) : 20
this.maxBatchSize = Math.max(this.flushAt, options?.maxBatchSize ?? 100)
this.maxQueueSize = Math.max(this.flushAt, options?.maxQueueSize ?? 1000)
this.flushInterval = options?.flushInterval ?? 10000
this.preloadFeatureFlags = options?.preloadFeatureFlags ?? true
// If enable is explicitly set to false we override the optout
this.defaultOptIn = options?.defaultOptIn ?? true
this.disableSurveys = options?.disableSurveys ?? false
this._retryOptions = {
retryCount: options?.fetchRetryCount ?? 3,
retryDelay: options?.fetchRetryDelay ?? 3000, // 3 seconds
retryCheck: isPostHogFetchError,
}
this.requestTimeout = options?.requestTimeout ?? 10000 // 10 seconds
this.featureFlagsRequestTimeoutMs = options?.featureFlagsRequestTimeoutMs ?? 3000 // 3 seconds
this.remoteConfigRequestTimeoutMs = options?.remoteConfigRequestTimeoutMs ?? 3000 // 3 seconds
this.disableGeoip = options?.disableGeoip ?? true
this.disabled = options?.disabled ?? false
this.historicalMigration = options?.historicalMigration ?? false
this.evaluationEnvironments = options?.evaluationEnvironments
// Init promise allows the derived class to block calls until it is ready
this._initPromise = Promise.resolve()
this._isInitialized = true
this.disableCompression = !isGzipSupported() || (options?.disableCompression ?? false)
}
Prompt To Fix With AI
This is a comment left during a code review. Path: packages/core/src/posthog-core-stateless.ts Line: 166:166 Comment: **syntax:** Missing property declaration for `evaluationEnvironments`. This will cause a TypeScript compilation error since the property is not declared in the class. ```suggestion  // Evaluation environments configuration  private evaluationEnvironments?: string[];   constructor(apiKey: string, options?: PostHogCoreOptions) {  assert(apiKey, "You must pass your PostHog project's api key.")   this.apiKey = apiKey  this.host = removeTrailingSlash(options?.host || 'https://us.i.posthog.com')  this.flushAt = options?.flushAt ? Math.max(options?.flushAt, 1) : 20  this.maxBatchSize = Math.max(this.flushAt, options?.maxBatchSize ?? 100)  this.maxQueueSize = Math.max(this.flushAt, options?.maxQueueSize ?? 1000)  this.flushInterval = options?.flushInterval ?? 10000  this.preloadFeatureFlags = options?.preloadFeatureFlags ?? true  // If enable is explicitly set to false we override the optout  this.defaultOptIn = options?.defaultOptIn ?? true  this.disableSurveys = options?.disableSurveys ?? false   this._retryOptions = {  retryCount: options?.fetchRetryCount ?? 3,  retryDelay: options?.fetchRetryDelay ?? 3000, // 3 seconds  retryCheck: isPostHogFetchError,  }  this.requestTimeout = options?.requestTimeout ?? 10000 // 10 seconds  this.featureFlagsRequestTimeoutMs = options?.featureFlagsRequestTimeoutMs ?? 3000 // 3 seconds  this.remoteConfigRequestTimeoutMs = options?.remoteConfigRequestTimeoutMs ?? 3000 // 3 seconds  this.disableGeoip = options?.disableGeoip ?? true  this.disabled = options?.disabled ?? false  this.historicalMigration = options?.historicalMigration ?? false  this.evaluationEnvironments = options?.evaluationEnvironments  // Init promise allows the derived class to block calls until it is ready  this._initPromise = Promise.resolve()  this._isInitialized = true  this.disableCompression = !isGzipSupported() || (options?.disableCompression ?? false)  } ``` How can I resolve this? If you propose a fix, please make it concise.
// Init promise allows the derived class to block calls until it is ready
this._initPromise = Promise.resolve()
this._isInitialized = true
Expand Down Expand Up @@ -453,17 +455,24 @@ export abstract class PostHogCoreStateless {
await this._initPromise

const url = `${this.host}/flags/?v=2&config=true`
const requestData: Record<string, any> = {
token: this.apiKey,
distinct_id: distinctId,
groups,
person_properties: personProperties,
group_properties: groupProperties,
...extraPayload,
}

// Add evaluation environments if configured
if (this.evaluationEnvironments && this.evaluationEnvironments.length > 0) {
requestData.evaluation_environments = this.evaluationEnvironments
}

const fetchOptions: PostHogFetchOptions = {
method: 'POST',
headers: { ...this.getCustomHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify({
token: this.apiKey,
distinct_id: distinctId,
groups,
person_properties: personProperties,
group_properties: groupProperties,
...extraPayload,
}),
body: JSON.stringify(requestData),
}

this._logger.info('Flags URL', url)
Expand Down
11 changes: 11 additions & 0 deletions packages/core/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,17 @@ export type PostHogCoreOptions = {
disableGeoip?: boolean
/** Special flag to indicate ingested data is for a historical migration. */
historicalMigration?: boolean
/**
* Evaluation environments for feature flags.
* 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.
*
* Examples: ['production', 'web', 'mobile']
*
* @default undefined
*/
evaluationEnvironments?: readonly string[]
}

export enum PostHogPersistedProperty {
Expand Down
92 changes: 91 additions & 1 deletion packages/node/src/__tests__/posthog-node.spec.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
import { PostHog } from '@/entrypoints/index.node'
import { PostHog, PostHogOptions } from '@/entrypoints/index.node'
import { anyFlagsCall, anyLocalEvalCall, apiImplementation, isPending, wait, waitForPromises } from './utils'
import { randomUUID } from 'crypto'

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

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

const posthogImmediateResolveOptions: PostHogOptions = {
fetchRetryCount: 0,
}

const waitForFlushTimer = async (): Promise<void> => {
await waitForPromises()
// To trigger the flush via the timer
Expand Down Expand Up @@ -2467,6 +2471,92 @@ describe('PostHog Node.js', () => {
})
})

describe('evaluation environments', () => {
beforeEach(() => {
mockedFetch.mockClear()
})

it('should send evaluation environments when configured', async () => {
mockedFetch.mockImplementation(
apiImplementation({
decideFlags: { 'test-flag': true },
flagsPayloads: {},
})
)

const posthogWithEnvs = new PostHog('TEST_API_KEY', {
host: 'http://example.com',
evaluationEnvironments: ['production', 'backend'],
...posthogImmediateResolveOptions,
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 extracting posthogImmediateResolveOptions to a shared constant at the top of the file for better maintainability

Context Used: Context from dashboard - When defining constants, such as template IDs, use a constants file or define them at the top of the... (source)

Prompt To Fix With AI
This is a comment left during a code review. Path: packages/node/src/__tests__/posthog-node.spec.ts Line: 2483:2483 Comment: **style:** Consider extracting `posthogImmediateResolveOptions` to a shared constant at the top of the file for better maintainability **Context Used:** Context from `dashboard` - When defining constants, such as template IDs, use a constants file or define them at the top of the... ([source](https://app.greptile.com/review/custom-context?memory=046b860e-6d45-4e24-be8e-6e1727a1b550)) How can I resolve this? If you propose a fix, please make it concise.
})

await posthogWithEnvs.getAllFlags('some-distinct-id')

expect(mockedFetch).toHaveBeenCalledWith(
'http://example.com/flags/?v=2&config=true',
expect.objectContaining({
method: 'POST',
body: expect.stringContaining('"evaluation_environments":["production","backend"]'),
Copy link
Contributor

Choose a reason for hiding this comment

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

style: This string assertion is fragile. Consider using JSON.parse() on the body and asserting against the parsed object structure instead

Prompt To Fix With AI
This is a comment left during a code review. Path: packages/node/src/__tests__/posthog-node.spec.ts Line: 2492:2492 Comment: **style:** This string assertion is fragile. Consider using `JSON.parse()` on the body and asserting against the parsed object structure instead How can I resolve this? If you propose a fix, please make it concise.
})
)

await posthogWithEnvs.shutdown()
})

it('should not send evaluation environments when not configured', async () => {
mockedFetch.mockImplementation(
apiImplementation({
decideFlags: { 'test-flag': true },
flagsPayloads: {},
})
)

const posthogWithoutEnvs = new PostHog('TEST_API_KEY', {
host: 'http://example.com',
...posthogImmediateResolveOptions,
})

await posthogWithoutEnvs.getAllFlags('some-distinct-id')

expect(mockedFetch).toHaveBeenCalledWith(
'http://example.com/flags/?v=2&config=true',
expect.objectContaining({
method: 'POST',
body: expect.not.stringContaining('evaluation_environments'),
})
)

await posthogWithoutEnvs.shutdown()
})

it('should not send evaluation environments when configured as empty array', async () => {
mockedFetch.mockImplementation(
apiImplementation({
decideFlags: { 'test-flag': true },
flagsPayloads: {},
})
)

const posthogWithEmptyEnvs = new PostHog('TEST_API_KEY', {
host: 'http://example.com',
evaluationEnvironments: [],
...posthogImmediateResolveOptions,
})

await posthogWithEmptyEnvs.getAllFlags('some-distinct-id')

expect(mockedFetch).toHaveBeenCalledWith(
'http://example.com/flags/?v=2&config=true',
expect.objectContaining({
method: 'POST',
body: expect.not.stringContaining('evaluation_environments'),
})
)

await posthogWithEmptyEnvs.shutdown()
})
})

describe('getRemoteConfigPayload', () => {
let requestRemoteConfigPayloadSpy: jest.SpyInstance

Expand Down
11 changes: 11 additions & 0 deletions packages/node/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,17 @@ export type PostHogOptions = PostHogCoreOptions & {
* If a function returns null, the event will be dropped.
*/
before_send?: BeforeSendFn | BeforeSendFn[]
/**
* Evaluation environments for feature flags.
* 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.
*
* Examples: ['production', 'backend', 'api']
*
* @default undefined
*/
evaluationEnvironments?: readonly string[]
}

export type PostHogFeatureFlag = {
Expand Down
55 changes: 55 additions & 0 deletions packages/react-native/test/posthog.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,61 @@ Linking.getInitialURL = jest.fn(() => Promise.resolve(null))
AppState.addEventListener = jest.fn()

describe('PostHog React Native', () => {
describe('evaluation environments', () => {
it('should send evaluation environments when configured', async () => {
posthog = new PostHog('test-token', {
evaluationEnvironments: ['production', 'mobile'],
flushInterval: 0,
})
await posthog.ready()

await posthog.reloadFeatureFlagsAsync()

expect((globalThis as any).window.fetch).toHaveBeenCalledWith(
expect.stringContaining('/flags/?v=2&config=true'),
expect.objectContaining({
method: 'POST',
body: expect.stringContaining('"evaluation_environments":["production","mobile"]'),
Copy link
Contributor

Choose a reason for hiding this comment

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

style: The test uses string matching on the request body, which could be brittle if the JSON serialization order changes. Consider using JSON.parse() to validate the presence of the field more reliably.

Prompt To Fix With AI
This is a comment left during a code review. Path: packages/react-native/test/posthog.spec.ts Line: 24:24 Comment: **style:** The test uses string matching on the request body, which could be brittle if the JSON serialization order changes. Consider using JSON.parse() to validate the presence of the field more reliably. How can I resolve this? If you propose a fix, please make it concise.
})
)
})

it('should not send evaluation environments when not configured', async () => {
posthog = new PostHog('test-token', {
flushInterval: 0,
})
await posthog.ready()

await posthog.reloadFeatureFlagsAsync()

expect((globalThis as any).window.fetch).toHaveBeenCalledWith(
expect.stringContaining('/flags/?v=2&config=true'),
expect.objectContaining({
method: 'POST',
body: expect.not.stringContaining('evaluation_environments'),
})
)
})

it('should not send evaluation environments when configured as empty array', async () => {
posthog = new PostHog('test-token', {
evaluationEnvironments: [],
flushInterval: 0,
})
await posthog.ready()

await posthog.reloadFeatureFlagsAsync()

expect((globalThis as any).window.fetch).toHaveBeenCalledWith(
expect.stringContaining('/flags/?v=2&config=true'),
expect.objectContaining({
method: 'POST',
body: expect.not.stringContaining('evaluation_environments'),
})
)
})
})

let mockStorage: PostHogCustomStorage
let cache: any = {}

Expand Down
Loading