Skip to content

Commit fe8090c

Browse files
authored
feat: Add $feature_flag_error property to web (#2919)
* fix: `RequestResponse` can contain an `error` Previously we'd stuff the error (typed any) into the text property (typed string) if `fetch` failed. Now, we'll use the `error` property. * feat: Append $feature_flag_error property on error This is only sent when an error occurs reading/fetching flags, emit via $feature_flag_called * chore: Add changesets
1 parent 2425a29 commit fe8090c

File tree

8 files changed

+426
-4
lines changed

8 files changed

+426
-4
lines changed

.changeset/clever-bikes-nail.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@posthog/types': minor
3+
---
4+
5+
Add `error` property to `RequestResponse`

.changeset/common-friends-sell.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'posthog-js': minor
3+
---
4+
5+
Add `$feature_flag_error` property to `$feature_flag_called` events

packages/browser/src/__tests__/featureflags.test.ts

Lines changed: 356 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
/*eslint @typescript-eslint/no-empty-function: "off" */
22

3-
import { filterActiveFeatureFlags, parseFlagsResponse, PostHogFeatureFlags } from '../posthog-featureflags'
3+
import {
4+
filterActiveFeatureFlags,
5+
parseFlagsResponse,
6+
PostHogFeatureFlags,
7+
FeatureFlagError,
8+
} from '../posthog-featureflags'
49
import { PostHogPersistence } from '../posthog-persistence'
510
import { RequestRouter } from '../utils/request-router'
611
import { PostHogConfig } from '../types'
@@ -3083,3 +3088,353 @@ describe('updateFlags', () => {
30833088
expect(posthog.persistence?.props.$active_feature_flags).toEqual(['persisted-flag', 'variant-flag'])
30843089
})
30853090
})
3091+
3092+
describe('$feature_flag_error tracking', () => {
3093+
let instance: any
3094+
let featureFlags: PostHogFeatureFlags
3095+
let mockWarn: jest.SpyInstance
3096+
3097+
const config = {
3098+
token: 'random fake token',
3099+
persistence: 'memory',
3100+
api_host: 'https://app.posthog.com',
3101+
} as PostHogConfig
3102+
3103+
beforeEach(() => {
3104+
const internalEventEmitter = new SimpleEventEmitter()
3105+
instance = {
3106+
config: { ...config },
3107+
get_distinct_id: () => 'blah id',
3108+
getGroups: () => {},
3109+
persistence: new PostHogPersistence(config),
3110+
requestRouter: new RequestRouter({ config } as any),
3111+
register: (props: any) => instance.persistence.register(props),
3112+
unregister: (key: string) => instance.persistence.unregister(key),
3113+
get_property: (key: string) => instance.persistence.props[key],
3114+
capture: jest.fn(),
3115+
_send_request: jest.fn(),
3116+
_onRemoteConfig: jest.fn(),
3117+
reloadFeatureFlags: () => featureFlags.reloadFeatureFlags(),
3118+
_shouldDisableFlags: () => false,
3119+
_internalEventEmitter: internalEventEmitter,
3120+
on: (event: string, cb: (...args: any[]) => void) => internalEventEmitter.on(event, cb),
3121+
}
3122+
3123+
featureFlags = new PostHogFeatureFlags(instance)
3124+
mockWarn = jest.spyOn(window.console, 'warn').mockImplementation()
3125+
instance.persistence.unregister('$flag_call_reported')
3126+
instance.persistence.unregister('$feature_flag_errors')
3127+
})
3128+
3129+
afterEach(() => {
3130+
mockWarn.mockRestore()
3131+
jest.clearAllMocks()
3132+
})
3133+
3134+
it('should set $feature_flag_error to api_error_{status} on server error', () => {
3135+
instance._send_request = jest.fn().mockImplementation(({ callback }) =>
3136+
callback({
3137+
statusCode: 500,
3138+
json: {},
3139+
})
3140+
)
3141+
3142+
featureFlags.reloadFeatureFlags()
3143+
jest.runAllTimers()
3144+
3145+
expect(instance.persistence.props.$feature_flag_errors).toEqual(['api_error_500'])
3146+
})
3147+
3148+
it('should set $feature_flag_error to connection_error on network failure', () => {
3149+
const networkError = new Error('Network request failed')
3150+
networkError.name = 'TypeError'
3151+
3152+
instance._send_request = jest.fn().mockImplementation(({ callback }) =>
3153+
callback({
3154+
statusCode: 0,
3155+
error: networkError,
3156+
json: null,
3157+
})
3158+
)
3159+
3160+
featureFlags.reloadFeatureFlags()
3161+
jest.runAllTimers()
3162+
3163+
expect(instance.persistence.props.$feature_flag_errors).toEqual([FeatureFlagError.CONNECTION_ERROR])
3164+
})
3165+
3166+
it('should set $feature_flag_error to timeout when request times out (AbortError)', () => {
3167+
const abortError = new Error('Aborted')
3168+
abortError.name = 'AbortError'
3169+
3170+
instance._send_request = jest.fn().mockImplementation(({ callback }) =>
3171+
callback({
3172+
statusCode: 0,
3173+
error: abortError,
3174+
json: null,
3175+
})
3176+
)
3177+
3178+
featureFlags.reloadFeatureFlags()
3179+
jest.runAllTimers()
3180+
3181+
expect(instance.persistence.props.$feature_flag_errors).toEqual([FeatureFlagError.TIMEOUT])
3182+
})
3183+
3184+
it('should set $feature_flag_error to errors_while_computing_flags when errorsWhileComputingFlags is true', () => {
3185+
instance._send_request = jest.fn().mockImplementation(({ callback }) =>
3186+
callback({
3187+
statusCode: 200,
3188+
json: {
3189+
flags: {
3190+
'test-flag': { key: 'test-flag', enabled: true },
3191+
},
3192+
errorsWhileComputingFlags: true,
3193+
},
3194+
})
3195+
)
3196+
3197+
featureFlags.reloadFeatureFlags()
3198+
jest.runAllTimers()
3199+
3200+
expect(instance.persistence.props.$feature_flag_errors).toEqual([FeatureFlagError.ERRORS_WHILE_COMPUTING])
3201+
})
3202+
3203+
it('should set $feature_flag_error to quota_limited when quota limited', () => {
3204+
instance._send_request = jest.fn().mockImplementation(({ callback }) =>
3205+
callback({
3206+
statusCode: 200,
3207+
json: {
3208+
flags: {},
3209+
quotaLimited: ['feature_flags'],
3210+
},
3211+
})
3212+
)
3213+
3214+
featureFlags.reloadFeatureFlags()
3215+
jest.runAllTimers()
3216+
3217+
expect(instance.persistence.props.$feature_flag_errors).toEqual([FeatureFlagError.QUOTA_LIMITED])
3218+
})
3219+
3220+
it('should set $feature_flag_error to unknown_error when error is not an Error instance', () => {
3221+
instance._send_request = jest.fn().mockImplementation(({ callback }) =>
3222+
callback({
3223+
statusCode: 0,
3224+
error: 'String error message',
3225+
json: null,
3226+
})
3227+
)
3228+
3229+
featureFlags.reloadFeatureFlags()
3230+
jest.runAllTimers()
3231+
3232+
expect(instance.persistence.props.$feature_flag_errors).toEqual([FeatureFlagError.UNKNOWN_ERROR])
3233+
})
3234+
3235+
it.each([401, 403, 404, 502, 503])('should set $feature_flag_error to api_error_%i for status %i', (status) => {
3236+
instance._send_request = jest
3237+
.fn()
3238+
.mockImplementation(({ callback }) => callback({ statusCode: status, json: {} }))
3239+
3240+
featureFlags.reloadFeatureFlags()
3241+
jest.runAllTimers()
3242+
3243+
expect(instance.persistence.props.$feature_flag_errors).toEqual([`api_error_${status}`])
3244+
})
3245+
3246+
it('should include $feature_flag_error in $feature_flag_called event capture', () => {
3247+
instance._send_request = jest.fn().mockImplementation(({ callback }) =>
3248+
callback({
3249+
statusCode: 200,
3250+
json: {
3251+
flags: {
3252+
'test-flag': { key: 'test-flag', enabled: true },
3253+
},
3254+
errorsWhileComputingFlags: true,
3255+
},
3256+
})
3257+
)
3258+
3259+
featureFlags.reloadFeatureFlags()
3260+
jest.runAllTimers()
3261+
3262+
featureFlags.getFeatureFlag('test-flag')
3263+
3264+
expect(instance.capture).toHaveBeenCalledWith(
3265+
'$feature_flag_called',
3266+
expect.objectContaining({
3267+
$feature_flag: 'test-flag',
3268+
$feature_flag_response: true,
3269+
$feature_flag_error: FeatureFlagError.ERRORS_WHILE_COMPUTING,
3270+
})
3271+
)
3272+
})
3273+
3274+
it('should set $feature_flag_error to flag_missing when flag is not in response', () => {
3275+
instance._send_request = jest.fn().mockImplementation(({ callback }) =>
3276+
callback({
3277+
statusCode: 200,
3278+
json: {
3279+
flags: {
3280+
'other-flag': { key: 'other-flag', enabled: true },
3281+
},
3282+
},
3283+
})
3284+
)
3285+
3286+
featureFlags.reloadFeatureFlags()
3287+
jest.runAllTimers()
3288+
3289+
featureFlags.getFeatureFlag('non-existent-flag')
3290+
3291+
expect(instance.capture).toHaveBeenCalledWith(
3292+
'$feature_flag_called',
3293+
expect.objectContaining({
3294+
$feature_flag: 'non-existent-flag',
3295+
$feature_flag_response: undefined,
3296+
$feature_flag_error: FeatureFlagError.FLAG_MISSING,
3297+
})
3298+
)
3299+
})
3300+
3301+
it('should join multiple errors with commas', () => {
3302+
instance._send_request = jest.fn().mockImplementation(({ callback }) =>
3303+
callback({
3304+
statusCode: 200,
3305+
json: {
3306+
flags: {},
3307+
errorsWhileComputingFlags: true,
3308+
},
3309+
})
3310+
)
3311+
3312+
featureFlags.reloadFeatureFlags()
3313+
jest.runAllTimers()
3314+
3315+
// Flag is not in response, and errorsWhileComputingFlags is true
3316+
featureFlags.getFeatureFlag('missing-flag')
3317+
3318+
expect(instance.capture).toHaveBeenCalledWith(
3319+
'$feature_flag_called',
3320+
expect.objectContaining({
3321+
$feature_flag: 'missing-flag',
3322+
$feature_flag_response: undefined,
3323+
$feature_flag_error: `${FeatureFlagError.ERRORS_WHILE_COMPUTING},${FeatureFlagError.FLAG_MISSING}`,
3324+
})
3325+
)
3326+
})
3327+
3328+
it('should not include $feature_flag_error when there are no errors', () => {
3329+
instance._send_request = jest.fn().mockImplementation(({ callback }) =>
3330+
callback({
3331+
statusCode: 200,
3332+
json: {
3333+
flags: {
3334+
'success-flag': { key: 'success-flag', enabled: true },
3335+
},
3336+
errorsWhileComputingFlags: false,
3337+
},
3338+
})
3339+
)
3340+
3341+
featureFlags.reloadFeatureFlags()
3342+
jest.runAllTimers()
3343+
3344+
featureFlags.getFeatureFlag('success-flag')
3345+
3346+
expect(instance.capture).toHaveBeenCalledWith(
3347+
'$feature_flag_called',
3348+
expect.not.objectContaining({
3349+
$feature_flag_error: expect.anything(),
3350+
})
3351+
)
3352+
})
3353+
3354+
it('should clear errors on successful subsequent request', () => {
3355+
// First request with error
3356+
instance._send_request = jest.fn().mockImplementation(({ callback }) =>
3357+
callback({
3358+
statusCode: 500,
3359+
json: {},
3360+
})
3361+
)
3362+
3363+
featureFlags.reloadFeatureFlags()
3364+
jest.runAllTimers()
3365+
3366+
expect(instance.persistence.props.$feature_flag_errors).toEqual(['api_error_500'])
3367+
3368+
// Second successful request
3369+
instance._send_request = jest.fn().mockImplementation(({ callback }) =>
3370+
callback({
3371+
statusCode: 200,
3372+
json: {
3373+
flags: {
3374+
'success-flag': { key: 'success-flag', enabled: true },
3375+
},
3376+
},
3377+
})
3378+
)
3379+
3380+
featureFlags.reloadFeatureFlags()
3381+
jest.runAllTimers()
3382+
3383+
expect(instance.persistence.props.$feature_flag_errors).toEqual([])
3384+
})
3385+
3386+
it('should track quota_limited and flag_missing together', () => {
3387+
instance._send_request = jest.fn().mockImplementation(({ callback }) =>
3388+
callback({
3389+
statusCode: 200,
3390+
json: {
3391+
flags: {},
3392+
quotaLimited: ['feature_flags'],
3393+
},
3394+
})
3395+
)
3396+
3397+
featureFlags.reloadFeatureFlags()
3398+
jest.runAllTimers()
3399+
3400+
featureFlags.getFeatureFlag('some-flag')
3401+
3402+
expect(instance.capture).toHaveBeenCalledWith(
3403+
'$feature_flag_called',
3404+
expect.objectContaining({
3405+
$feature_flag: 'some-flag',
3406+
$feature_flag_response: undefined,
3407+
$feature_flag_error: `${FeatureFlagError.QUOTA_LIMITED},${FeatureFlagError.FLAG_MISSING}`,
3408+
})
3409+
)
3410+
})
3411+
3412+
it('should include persisted errors in $feature_flag_called event after reload', () => {
3413+
// Setup: flags loaded with errors_while_computing
3414+
instance._send_request = jest.fn().mockImplementation(({ callback }) =>
3415+
callback({
3416+
statusCode: 200,
3417+
json: {
3418+
flags: { 'test-flag': { key: 'test-flag', enabled: true } },
3419+
errorsWhileComputingFlags: true,
3420+
},
3421+
})
3422+
)
3423+
featureFlags.reloadFeatureFlags()
3424+
jest.runAllTimers()
3425+
3426+
// Simulate reload - new FeatureFlags instance with same persistence
3427+
const newFeatureFlags = new PostHogFeatureFlags(instance)
3428+
3429+
// Getting flag should include persisted error
3430+
newFeatureFlags.getFeatureFlag('test-flag')
3431+
3432+
expect(instance.capture).toHaveBeenCalledWith(
3433+
'$feature_flag_called',
3434+
expect.objectContaining({
3435+
$feature_flag: 'test-flag',
3436+
$feature_flag_error: FeatureFlagError.ERRORS_WHILE_COMPUTING,
3437+
})
3438+
)
3439+
})
3440+
})

packages/browser/src/constants.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ export const SURVEYS_ACTIVATED = '$surveys_activated'
5757
export const PRODUCT_TOURS_ACTIVATED = '$product_tours_activated'
5858
export const CONVERSATIONS = '$conversations'
5959
export const FLAG_CALL_REPORTED = '$flag_call_reported'
60+
export const PERSISTENCE_FEATURE_FLAG_ERRORS = '$feature_flag_errors'
6061
export const USER_STATE = '$user_state'
6162
export const CLIENT_SESSION_PROPS = '$client_session_props'
6263
export const CAPTURE_RATE_LIMIT = '$capture_rate_limit'
@@ -97,6 +98,7 @@ export const PERSISTENCE_RESERVED_PROPERTIES = [
9798
STORED_PERSON_PROPERTIES_KEY,
9899
SURVEYS,
99100
FLAG_CALL_REPORTED,
101+
PERSISTENCE_FEATURE_FLAG_ERRORS,
100102
CLIENT_SESSION_PROPS,
101103
CAPTURE_RATE_LIMIT,
102104
INITIAL_CAMPAIGN_PARAMS,

0 commit comments

Comments
 (0)