Skip to content

Commit 1d83b7d

Browse files
posthog[bot]tests-posthog[bot]gustavohstrassburger
authored
fix(flags): surface real errors in the flag testing tab (#108975)
Co-authored-by: posthog[bot] <206114724+posthog[bot]@users.noreply.github.com> Co-authored-by: tests-posthog[bot] <250237707+tests-posthog[bot]@users.noreply.github.com> Co-authored-by: Gustavo H. Strassburger <gustavo@posthog.com>
1 parent 6e7cc9f commit 1d83b7d

7 files changed

Lines changed: 370 additions & 55 deletions

File tree

‎frontend/src/scenes/feature-flags/FeatureFlagTestingTab.tsx‎

Lines changed: 2 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -99,22 +99,11 @@ export function FeatureFlagTestingTab({ featureFlag }: { featureFlag: FeatureFla
9999
setIncludeTime,
100100
setSelectedResultDistinctId,
101101
clearTestForm,
102-
testFlagEvaluation,
103-
testAllDistinctIds,
102+
submitTestEvaluation,
104103
} = useActions(logic)
105104

106105
const isLoading = testEvaluationLoading || allEvaluationsLoading
107106

108-
const handleSubmit = (): void => {
109-
if (hasMultipleDistinctIds) {
110-
// Evaluate every merged distinct ID in one go so their variants can be
111-
// compared side by side, rather than re-running the tool per ID.
112-
testAllDistinctIds({ flagId: featureFlag.id!, distinctIds: personDistinctIds, formData })
113-
} else {
114-
testFlagEvaluation({ flagId: featureFlag.id!, formData })
115-
}
116-
}
117-
118107
const hasConditions = !!result?.conditions?.length
119108
// The batch row whose detail is currently expanded — the explicit selection, or
120109
// the first row when nothing has been clicked yet (matches the activeResult selector).
@@ -270,7 +259,7 @@ export function FeatureFlagTestingTab({ featureFlag }: { featureFlag: FeatureFla
270259
<LemonButton
271260
type="primary"
272261
loading={isLoading}
273-
onClick={handleSubmit}
262+
onClick={() => submitTestEvaluation()}
274263
disabledReason={!hasValidPerson ? 'Please select a person' : undefined}
275264
>
276265
{hasMultipleDistinctIds

‎frontend/src/scenes/feature-flags/featureFlagTestingLogic.test.ts‎

Lines changed: 90 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -445,11 +445,7 @@ describe('featureFlagTestingLogic', () => {
445445
})
446446
})
447447

448-
describe('groups validation through testFlagEvaluation', () => {
449-
// The invalid-groups cases fail the evaluation loader on purpose; kea-loaders would log each
450-
beforeEach(silenceKeaLoadersErrors)
451-
afterEach(resumeKeaLoadersErrors)
452-
448+
describe('form validation through submitTestEvaluation', () => {
453449
it.each([
454450
{ description: 'valid object succeeds', groups: '{"team": "backend"}', expectedError: null },
455451
{ description: 'empty string succeeds', groups: '', expectedError: null },
@@ -466,13 +462,99 @@ describe('featureFlagTestingLogic', () => {
466462
expectedError: 'groups must be a JSON object',
467463
},
468464
{ description: 'json number fails', groups: '42', expectedError: 'groups must be a JSON object' },
469-
])('$description', async ({ groups, expectedError }) => {
465+
{ description: 'json null fails', groups: 'null', expectedError: 'groups must be a JSON object' },
466+
{
467+
description: 'malformed timestamp fails',
468+
groups: '',
469+
timestamp: '2024-01-01',
470+
expectedError: 'Invalid timestamp format',
471+
},
472+
])('$description', async ({ groups, timestamp = '', expectedError }) => {
473+
logic.actions.setTestFormData({ distinct_id: 'p1', timestamp, groups })
474+
475+
if (expectedError) {
476+
// Invalid input must never reach the loader, where kea-loaders reports it as an exception.
477+
await expectLogic(logic, () => {
478+
logic.actions.submitTestEvaluation()
479+
})
480+
.toFinishAllListeners()
481+
.toNotHaveDispatchedActions(['testFlagEvaluation', 'testFlagEvaluationFailure'])
482+
} else {
483+
await expectLogic(logic, () => {
484+
logic.actions.submitTestEvaluation()
485+
}).toDispatchActions(['testFlagEvaluation', 'testFlagEvaluationSuccess'])
486+
}
487+
488+
expect(logic.values.testError).toBe(expectedError)
489+
})
490+
491+
it('runs the batch evaluation for a person with merged distinct IDs', async () => {
492+
logic.actions.setSelectedPerson({
493+
name: 'Jane Doe',
494+
uuid: 'uuid-abc',
495+
distinct_ids: ['user-123', 'user-456'],
496+
})
497+
logic.actions.setTestFormData({ distinct_id: 'user-123', timestamp: '', groups: '' })
498+
499+
await expectLogic(logic, () => {
500+
logic.actions.submitTestEvaluation()
501+
})
502+
.toDispatchActions([
503+
logic.actionCreators.testAllDistinctIds({
504+
flagId: 1,
505+
distinctIds: ['user-123', 'user-456'],
506+
formData: { distinct_id: 'user-123', timestamp: '', groups: '' },
507+
}),
508+
'testAllDistinctIdsSuccess',
509+
])
510+
.toNotHaveDispatchedActions(['testFlagEvaluation'])
511+
})
512+
})
513+
514+
describe('API error messages', () => {
515+
// The failing responses fail the evaluation loader on purpose; kea-loaders would log each
516+
beforeEach(silenceKeaLoadersErrors)
517+
afterEach(resumeKeaLoadersErrors)
518+
519+
it.each([
520+
{
521+
description: 'maps a timestamp failure in the error field',
522+
status: 500,
523+
body: { error: 'Failed to build person properties at specified timestamp.' },
524+
expectedError:
525+
'Unable to build person properties at the selected timestamp. This person may not have had any recorded activity at that time, or the timestamp may be too far in the past.',
526+
},
527+
{
528+
description: 'maps an invalid timestamp in the error field',
529+
status: 400,
530+
body: { error: 'Invalid timestamp format.' },
531+
expectedError: 'Invalid timestamp. Please select a valid date and time.',
532+
},
533+
{
534+
description: 'keeps a specific timestamp reason as is',
535+
status: 400,
536+
body: { error: "Feature flag 'test-flag' did not exist at the specified timestamp." },
537+
expectedError: "Feature flag 'test-flag' did not exist at the specified timestamp.",
538+
},
539+
{
540+
description: 'maps a missing person in the detail field',
541+
status: 404,
542+
body: { detail: 'Person not found for person_id: abc' },
543+
expectedError: 'Person not found. This person may not have existed at the selected timestamp.',
544+
},
545+
])('$description', async ({ status, body, expectedError }) => {
546+
useMocks({
547+
post: {
548+
'/api/projects/:team/feature_flags/1/test_evaluation': () => [status, body],
549+
},
550+
})
551+
470552
await expectLogic(logic, () => {
471553
logic.actions.testFlagEvaluation({
472554
flagId: 1,
473-
formData: { distinct_id: 'p1', timestamp: '', groups },
555+
formData: { distinct_id: 'p1', timestamp: '', groups: '' },
474556
})
475-
}).toFinishAllListeners()
557+
}).toDispatchActions(['testFlagEvaluationFailure'])
476558

477559
expect(logic.values.testError).toBe(expectedError)
478560
})

‎frontend/src/scenes/feature-flags/featureFlagTestingLogic.ts‎

Lines changed: 48 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -45,8 +45,8 @@ const testEvaluationConcurrencyController = new ConcurrencyController(TEST_EVALU
4545

4646
// Build the request body for a single evaluation. Shared by the single-ID and
4747
// batch paths so groups/timestamp are parsed and validated identically. Throws on
48-
// invalid groups or timestamp — callers let that fail the loader so the error
49-
// surfaces once via testError rather than per distinct ID.
48+
// invalid groups or timestamp. submitTestEvaluation checks the form with
49+
// testFormValidationError first, so this throw does not reach the loaders.
5050
function buildEvaluationRequest(formData: TestFormData, distinctId: string): FeatureFlagTestEvaluationRequestApi {
5151
const data: FeatureFlagTestEvaluationRequestApi = {}
5252

@@ -70,34 +70,38 @@ function buildEvaluationRequest(formData: TestFormData, distinctId: string): Fea
7070
return data
7171
}
7272

73+
// A loader that throws an error without an HTTP status is reported to error tracking,
74+
// so invalid user input must stop before a loader runs.
75+
function testFormValidationError(formData: TestFormData): string | null {
76+
try {
77+
buildEvaluationRequest(formData, '')
78+
return null
79+
} catch (e) {
80+
return e instanceof Error ? e.message : String(e)
81+
}
82+
}
83+
7384
// Map an evaluation failure to the user-facing message. Shared by the single-ID
7485
// and batch failure handlers so both surface the same friendly rewrites.
7586
function evaluationErrorMessage(error: string, errorObject?: unknown): string {
76-
const apiError = errorObject as ApiError
77-
if (apiError?.detail) {
78-
const errorDetail = apiError.detail
79-
80-
if (errorDetail.includes('Failed to build person properties at specified timestamp')) {
81-
return 'Unable to build person properties at the selected timestamp. This person may not have had any recorded activity at that time, or the timestamp may be too far in the past.'
82-
}
83-
84-
if (errorDetail.includes('person') && errorDetail.includes('not found')) {
85-
return 'Person not found. This person may not have existed at the selected timestamp.'
86-
}
87+
const apiError = errorObject as ApiError | undefined
88+
// ApiError.message already holds the body's `error` field, which test_evaluation uses for its own failures.
89+
const message = apiError?.detail || apiError?.message || error || ''
90+
const lowerMessage = message.toLowerCase()
8791

88-
if (errorDetail.includes('timestamp') || errorDetail.toLowerCase() === 'invalid timestamp') {
89-
return 'Invalid timestamp. Please select a valid date and time.'
90-
}
92+
if (lowerMessage.includes('failed to build person properties at specified timestamp')) {
93+
return 'Unable to build person properties at the selected timestamp. This person may not have had any recorded activity at that time, or the timestamp may be too far in the past.'
94+
}
9195

92-
return errorDetail
96+
if (lowerMessage.includes('person') && lowerMessage.includes('not found')) {
97+
return 'Person not found. This person may not have existed at the selected timestamp.'
9398
}
9499

95-
const errorMessage = apiError?.message || error || ''
96-
if (errorMessage.includes('Failed to build person properties at specified timestamp')) {
97-
return 'Unable to build person properties at the selected timestamp. This person may not have had any recorded activity at that time, or the timestamp may be too far in the past.'
100+
if (lowerMessage.includes('invalid timestamp') || apiError?.attr === 'timestamp') {
101+
return 'Invalid timestamp. Please select a valid date and time.'
98102
}
99103

100-
return errorMessage || 'An unexpected error occurred while testing the feature flag'
104+
return message || 'An unexpected error occurred while testing the feature flag'
101105
}
102106

103107
function validateAndParseGroups(groups: string): Record<string, any> {
@@ -108,7 +112,7 @@ function validateAndParseGroups(groups: string): Record<string, any> {
108112

109113
try {
110114
const parsed = JSON.parse(trimmed)
111-
if (typeof parsed !== 'object' || Array.isArray(parsed)) {
115+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
112116
throw new Error('groups must be a JSON object')
113117
}
114118
return parsed
@@ -271,6 +275,9 @@ export interface featureFlagTestingLogicActions {
271275
setTestFormData: (formData: Partial<TestFormData>) => {
272276
formData: Partial<TestFormData>
273277
}
278+
submitTestEvaluation: () => {
279+
value: true
280+
}
274281
testAllDistinctIds: ({
275282
flagId,
276283
distinctIds,
@@ -397,6 +404,7 @@ export const featureFlagTestingLogic = kea<featureFlagTestingLogicType>([
397404
setSelectedResultDistinctId: (distinctId: string | null) => ({ distinctId }),
398405
clearBatchEvaluations: true,
399406
clearTestForm: true,
407+
submitTestEvaluation: true,
400408
}),
401409
loaders(({ values }) => ({
402410
resolvedPersonDistinctIds: [
@@ -434,8 +442,7 @@ export const featureFlagTestingLogic = kea<featureFlagTestingLogicType>([
434442
distinctIds: string[]
435443
formData: TestFormData
436444
}) => {
437-
// Parse groups/timestamp once up front. A malformed form throws here and
438-
// fails the whole batch loudly (one testError) rather than N identical times.
445+
// Parse groups/timestamp once up front, not once per distinct ID.
439446
const baseData = buildEvaluationRequest(formData, '')
440447

441448
return await Promise.all(
@@ -484,8 +491,7 @@ export const featureFlagTestingLogic = kea<featureFlagTestingLogicType>([
484491
clearTestForm: () => null,
485492
testFlagEvaluationFailure: (_, { error, errorObject }: { error: string; errorObject?: unknown }) =>
486493
evaluationErrorMessage(error, errorObject),
487-
// A batch failure means the shared form (groups/timestamp) was invalid — the
488-
// per-ID API errors are caught inside the loader and shown in the table.
494+
// The loader catches per-ID API errors and shows them in the table.
489495
testAllDistinctIdsFailure: (_, { error, errorObject }: { error: string; errorObject?: unknown }) =>
490496
evaluationErrorMessage(error, errorObject),
491497
},
@@ -539,7 +545,22 @@ export const featureFlagTestingLogic = kea<featureFlagTestingLogicType>([
539545
},
540546
],
541547
}),
542-
listeners(({ actions, values }) => ({
548+
listeners(({ actions, values, props }) => ({
549+
submitTestEvaluation: () => {
550+
const formData = values.testFormData
551+
const formError = testFormValidationError(formData)
552+
if (formError) {
553+
actions.setTestError(formError)
554+
return
555+
}
556+
if (values.hasMultipleDistinctIds) {
557+
// Evaluate every merged distinct ID in one go so their variants can be
558+
// compared side by side, rather than re-running the tool per ID.
559+
actions.testAllDistinctIds({ flagId: props.flagId, distinctIds: values.personDistinctIds, formData })
560+
} else {
561+
actions.testFlagEvaluation({ flagId: props.flagId, formData })
562+
}
563+
},
543564
setSelectedPerson: ({ person, distinctId }) => {
544565
// A different person invalidates any batch table from the previous one.
545566
actions.clearBatchEvaluations()

0 commit comments

Comments
 (0)