Skip to content
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

feat(feature-flags): support quota limiting for feature flags #403

Merged
merged 13 commits into from
Feb 27, 2025
120 changes: 71 additions & 49 deletions posthog-core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,11 @@ function isPostHogFetchError(err: any): boolean {
return typeof err === 'object' && (err instanceof PostHogFetchHttpError || err instanceof PostHogFetchNetworkError)
}

enum QuotaLimitedFeature {
FeatureFlags = 'feature_flags',
Recordings = 'recordings',
}

export abstract class PostHogCoreStateless {
// options
readonly apiKey: string
Expand Down Expand Up @@ -458,6 +463,17 @@ export abstract class PostHogCoreStateless {
}
const decideResponse = await this.getDecide(distinctId, groups, personProperties, groupProperties, extraPayload)

// Add check for quota limitation on feature flags
if (decideResponse?.quotaLimited?.includes(QuotaLimitedFeature.FeatureFlags)) {
console.warn(
'[FEATURE FLAGS] Feature flags quota limit exceeded - feature flags unavailable. Learn more about billing limits at https://posthog.com/docs/billing/limits-alerts'
)
return {
flags: undefined,
payloads: undefined,
}
}

const flags = decideResponse?.featureFlags
const payloads = decideResponse?.featureFlagPayloads

Expand Down Expand Up @@ -1158,7 +1174,7 @@ export abstract class PostHogCore extends PostHogCoreStateless {

private async _decideAsync(sendAnonDistinctId: boolean = true): Promise<PostHogDecideResponse | undefined> {
this._decideResponsePromise = this._initPromise
.then(() => {
.then(async () => {
const distinctId = this.getDistinctId()
const groups = this.props.$groups || {}
const personProperties =
Expand All @@ -1171,62 +1187,68 @@ export abstract class PostHogCore extends PostHogCoreStateless {
$anon_distinct_id: sendAnonDistinctId ? this.getAnonymousId() : undefined,
}

return super.getDecide(distinctId, groups, personProperties, groupProperties, extraProperties).then((res) => {
if (res?.featureFlags) {
// clear flag call reported if we have new flags since they might have changed
if (this.sendFeatureFlagEvent) {
this.flagCallReported = {}
}

let newFeatureFlags = res.featureFlags
let newFeatureFlagPayloads = res.featureFlagPayloads
if (res.errorsWhileComputingFlags) {
// if not all flags were computed, we upsert flags instead of replacing them
const currentFlags = this.getPersistedProperty<PostHogDecideResponse['featureFlags']>(
PostHogPersistedProperty.FeatureFlags
)

this.logMsgIfDebug(() =>
console.log('PostHog Debug', 'Cached feature flags: ', JSON.stringify(currentFlags))
)

const currentFlagPayloads = this.getPersistedProperty<PostHogDecideResponse['featureFlagPayloads']>(
PostHogPersistedProperty.FeatureFlagPayloads
)
newFeatureFlags = { ...currentFlags, ...res.featureFlags }
newFeatureFlagPayloads = { ...currentFlagPayloads, ...res.featureFlagPayloads }
}
this.setKnownFeatureFlags(newFeatureFlags)
this.setKnownFeatureFlagPayloads(
Object.fromEntries(
Object.entries(newFeatureFlagPayloads || {}).map(([k, v]) => [k, this._parsePayload(v)])
)
)
// Mark that we hit the /decide endpoint so we can capture this in the $feature_flag_called event
this.setPersistedProperty(PostHogPersistedProperty.DecideEndpointWasHit, true)

const sessionReplay = res?.sessionRecording
if (sessionReplay) {
this.setPersistedProperty(PostHogPersistedProperty.SessionReplay, sessionReplay)
this.logMsgIfDebug(() =>
console.log('PostHog Debug', 'Session replay config: ', JSON.stringify(sessionReplay))
)
} else {
this.logMsgIfDebug(() => console.info('PostHog Debug', 'Session replay config disabled.'))
this.setPersistedProperty(PostHogPersistedProperty.SessionReplay, null)
}
const res = await super.getDecide(distinctId, groups, personProperties, groupProperties, extraProperties)
// Add check for quota limitation on feature flags
if (res?.quotaLimited?.includes(QuotaLimitedFeature.FeatureFlags)) {
// Unset all feature flags by setting to null
this.setKnownFeatureFlags(null)
this.setKnownFeatureFlagPayloads(null)
console.warn(
'[FEATURE FLAGS] Feature flags quota limit exceeded - unsetting all flags. Learn more about billing limits at https://posthog.com/docs/billing/limits-alerts'
)
return res
}
if (res?.featureFlags) {
// clear flag call reported if we have new flags since they might have changed
if (this.sendFeatureFlagEvent) {
this.flagCallReported = {}
}

return res
})
let newFeatureFlags = res.featureFlags
let newFeatureFlagPayloads = res.featureFlagPayloads
if (res.errorsWhileComputingFlags) {
// if not all flags were computed, we upsert flags instead of replacing them
const currentFlags = this.getPersistedProperty<PostHogDecideResponse['featureFlags']>(
PostHogPersistedProperty.FeatureFlags
)

this.logMsgIfDebug(() =>
console.log('PostHog Debug', 'Cached feature flags: ', JSON.stringify(currentFlags))
)

const currentFlagPayloads = this.getPersistedProperty<PostHogDecideResponse['featureFlagPayloads']>(
PostHogPersistedProperty.FeatureFlagPayloads
)
newFeatureFlags = { ...currentFlags, ...res.featureFlags }
newFeatureFlagPayloads = { ...currentFlagPayloads, ...res.featureFlagPayloads }
}
this.setKnownFeatureFlags(newFeatureFlags)
this.setKnownFeatureFlagPayloads(
Object.fromEntries(Object.entries(newFeatureFlagPayloads || {}).map(([k, v]) => [k, this._parsePayload(v)]))
)
// Mark that we hit the /decide endpoint so we can capture this in the $feature_flag_called event
this.setPersistedProperty(PostHogPersistedProperty.DecideEndpointWasHit, true)

const sessionReplay = res?.sessionRecording
if (sessionReplay) {
this.setPersistedProperty(PostHogPersistedProperty.SessionReplay, sessionReplay)
this.logMsgIfDebug(() =>
console.log('PostHog Debug', 'Session replay config: ', JSON.stringify(sessionReplay))
)
} else {
this.logMsgIfDebug(() => console.info('PostHog Debug', 'Session replay config disabled.'))
this.setPersistedProperty(PostHogPersistedProperty.SessionReplay, null)
}
}
return res
})
.finally(() => {
this._decideResponsePromise = undefined
})
return this._decideResponsePromise
}

private setKnownFeatureFlags(featureFlags: PostHogDecideResponse['featureFlags']): void {
private setKnownFeatureFlags(featureFlags: PostHogDecideResponse['featureFlags'] | null): void {
Copy link
Member

Choose a reason for hiding this comment

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

since this and setKnownFeatureFlagPayloads accept null now, we can change the other places from:

this.setKnownFeatureFlags({})
this.setKnownFeatureFlagPayloads({})

to null values as well.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I did this I believe; I grepped those methods and I am using null in the only place they're called.

this.wrap(() => {
this.setPersistedProperty<PostHogDecideResponse['featureFlags']>(
PostHogPersistedProperty.FeatureFlags,
Expand All @@ -1236,7 +1258,7 @@ export abstract class PostHogCore extends PostHogCoreStateless {
})
}

private setKnownFeatureFlagPayloads(featureFlagPayloads: PostHogDecideResponse['featureFlagPayloads']): void {
private setKnownFeatureFlagPayloads(featureFlagPayloads: PostHogDecideResponse['featureFlagPayloads'] | null): void {
this.wrap(() => {
this.setPersistedProperty<PostHogDecideResponse['featureFlagPayloads']>(
PostHogPersistedProperty.FeatureFlagPayloads,
Expand Down
1 change: 1 addition & 0 deletions posthog-core/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ export type PostHogDecideResponse = {
[key: string]: JsonType
}
errorsWhileComputingFlags: boolean
quotaLimited?: string[]
sessionRecording?:
| boolean
| {
Expand Down
60 changes: 60 additions & 0 deletions posthog-core/test/posthog.featureflags.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -528,6 +528,66 @@ describe('PostHog Core', () => {
})
})
})

describe('when quota limited', () => {
beforeEach(() => {
;[posthog, mocks] = createTestClient('TEST_API_KEY', { flushAt: 1 }, (_mocks) => {
_mocks.fetch.mockImplementation((url) => {
if (url.includes('/decide/')) {
return Promise.resolve({
status: 200,
text: () => Promise.resolve('ok'),
json: () =>
Promise.resolve({
quotaLimited: ['feature_flags'],
featureFlags: {},
featureFlagPayloads: {},
}),
})
}
return errorAPIResponse
})
})

posthog.reloadFeatureFlags()
})

it('should unset all flags when feature_flags is quota limited', async () => {
// First verify the fetch was called correctly
expect(mocks.fetch).toHaveBeenCalledWith('https://us.i.posthog.com/decide/?v=3', {
body: JSON.stringify({
token: 'TEST_API_KEY',
distinct_id: posthog.getDistinctId(),
groups: {},
person_properties: {},
group_properties: {},
$anon_distinct_id: posthog.getAnonymousId(),
}),
method: 'POST',
headers: {
'Content-Type': 'application/json',
'User-Agent': 'posthog-core-tests',
},
signal: expect.anything(),
})

// Verify all flag methods return undefined when quota limited
expect(posthog.getFeatureFlags()).toEqual(undefined)
expect(posthog.getFeatureFlag('feature-1')).toEqual(undefined)
expect(posthog.getFeatureFlagPayloads()).toEqual(undefined)
expect(posthog.getFeatureFlagPayload('feature-1')).toEqual(undefined)
})

it('should emit debug message when quota limited', async () => {
const warnSpy = jest.spyOn(console, 'warn')
posthog.debug(true)
await posthog.reloadFeatureFlagsAsync()

expect(warnSpy).toHaveBeenCalledWith(
'[FEATURE FLAGS] Feature flags quota limit exceeded - unsetting all flags. Learn more about billing limits at https://posthog.com/docs/billing/limits-alerts'
)
})
})
})

describe('bootstrapped feature flags', () => {
Expand Down
4 changes: 4 additions & 0 deletions posthog-node/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# Next

# 4.8.1 – 2025-02-26

1. Supports gracefully handling quotaLimited responses from the PostHog API for feature flag evaluation

# 4.8.0 - 2025-02-26

1. Add guardrails and exponential error backoff in the feature flag local evaluation poller to prevent high rates of 401/403 traffic towards `/local_evaluation`
Expand Down
2 changes: 1 addition & 1 deletion posthog-node/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "posthog-node",
"version": "4.8.0",
"version": "4.8.1",
"description": "PostHog Node.js integration",
"repository": {
"type": "git",
Expand Down
13 changes: 13 additions & 0 deletions posthog-node/src/feature-flags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -418,6 +418,19 @@ class FeatureFlagsPoller {
)
}

if (res && res.status === 402) {
// Quota limited - clear all flags
console.warn(
'[FEATURE FLAGS] Feature flags quota limit exceeded - unsetting all local flags. Learn more about billing limits at https://posthog.com/docs/billing/limits-alerts'
)
this.featureFlags = []
this.featureFlagsByKey = {}
this.groupTypeMapping = {}
this.cohorts = {}
this.loadedSuccessfullyOnce = false
return
}

if (res && res.status !== 200) {
// something else went wrong, or the server is down.
// In this case, don't override existing flags
Expand Down
36 changes: 36 additions & 0 deletions posthog-node/test/feature-flags.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4467,3 +4467,39 @@ describe('consistency tests', () => {
})
})
})

describe('quota limiting', () => {
it('should clear local flags when quota limited', async () => {
const consoleSpy = jest.spyOn(console, 'warn')

mockedFetch.mockImplementation(
apiImplementation({
localFlagsStatus: 402,
})
)

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

// Enable debug mode to see the messages
posthog.debug(true)

// Force a reload and wait for it to complete
await posthog.reloadFeatureFlags()

// locally evaluate the flags
const res = await posthog.getAllFlagsAndPayloads('distinct-id', { onlyEvaluateLocally: true })

// expect the flags to be cleared and for the debug message to be logged
expect(res.featureFlags).toEqual({})
expect(res.featureFlagPayloads).toEqual({})
expect(consoleSpy).toHaveBeenCalledWith(
'[FEATURE FLAGS] Feature flags quota limit exceeded - unsetting all local flags. Learn more about billing limits at https://posthog.com/docs/billing/limits-alerts'
)

consoleSpy.mockRestore()
})
})
4 changes: 3 additions & 1 deletion posthog-node/test/test-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,13 @@ export const apiImplementation = ({
decideFlags,
decideFlagPayloads,
decideStatus = 200,
localFlagsStatus = 200,
}: {
localFlags?: any
decideFlags?: any
decideFlagPayloads?: any
decideStatus?: number
localFlagsStatus?: number
}) => {
return (url: any): Promise<any> => {
if ((url as any).includes('/decide/')) {
Expand All @@ -31,7 +33,7 @@ export const apiImplementation = ({

if ((url as any).includes('api/feature_flag/local_evaluation?token=TEST_API_KEY&send_cohorts')) {
return Promise.resolve({
status: 200,
status: localFlagsStatus,
text: () => Promise.resolve('ok'),
json: () => Promise.resolve(localFlags),
}) as any
Expand Down
19 changes: 13 additions & 6 deletions posthog-react-native/src/posthog-rn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -334,6 +334,12 @@ export class PostHog extends PostHogCore {
const value = decideFeatureFlags[linkedFlag]
if (typeof value === 'boolean') {
recordingActive = value
} else if (typeof value === 'string') {
// if its a multi-variant flag linked to "any"
recordingActive = true
} else {
// disable recording if the flag does not exist/quota limited
recordingActive = false
}

this.logMsgIfDebug(() => console.log('PostHog Debug', `Session replay ${linkedFlag} linked flag value: ${value}`))
Expand All @@ -342,12 +348,13 @@ export class PostHog extends PostHogCore {
const variant = linkedFlag['variant'] as string | undefined
if (flag && variant) {
const value = decideFeatureFlags[flag]
if (value) {
recordingActive = value === variant
this.logMsgIfDebug(() =>
console.log('PostHog Debug', `Session replay ${flag} linked flag variant: ${variant} and value ${value}`)
)
}
recordingActive = value === variant
this.logMsgIfDebug(() =>
console.log('PostHog Debug', `Session replay ${flag} linked flag variant: ${variant} and value ${value}`)
)
} else {
// disable recording if the flag does not exist/quota limited
recordingActive = false
}
}

Expand Down