|
1 | 1 | /*eslint @typescript-eslint/no-empty-function: "off" */ |
2 | 2 |
|
3 | | -import { filterActiveFeatureFlags, parseFlagsResponse, PostHogFeatureFlags } from '../posthog-featureflags' |
| 3 | +import { |
| 4 | + filterActiveFeatureFlags, |
| 5 | + parseFlagsResponse, |
| 6 | + PostHogFeatureFlags, |
| 7 | + FeatureFlagError, |
| 8 | +} from '../posthog-featureflags' |
4 | 9 | import { PostHogPersistence } from '../posthog-persistence' |
5 | 10 | import { RequestRouter } from '../utils/request-router' |
6 | 11 | import { PostHogConfig } from '../types' |
@@ -3083,3 +3088,353 @@ describe('updateFlags', () => { |
3083 | 3088 | expect(posthog.persistence?.props.$active_feature_flags).toEqual(['persisted-flag', 'variant-flag']) |
3084 | 3089 | }) |
3085 | 3090 | }) |
| 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 | +}) |
0 commit comments