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(experiments): Create experiment with existing feature flag #28004

Merged
merged 22 commits into from
Feb 5, 2025

Conversation

danielbachhuber
Copy link
Contributor

@danielbachhuber danielbachhuber commented Jan 29, 2025

Previously #27938

Changes

Allows an existing feature flag to be assigned to a new experiment:

CleanShot 2025-02-04 at 13 05 27

The feature flag has to match some criteria:

  • It has to have at least two variants.
  • The first variant must be called control.
  • It must not be associated with any other experiments.

The modal shows a tooltip if you try to select a feature flag that isn't eligible:

CleanShot 2025-01-31 at 06 03 38@2x

If you manually specify an ineligible feature flag, you'll see an error in the field:

CleanShot 2025-01-31 at 05 26 37@2x

Also removes the Holdout generally, for consistency between UX.

How did you test this code?

Happy path:

  • Created a new feature flag with multiple variants and control as the first variant.
  • Navigated to New experiment.
  • Clicked Choose under the Feature flag key field and selected the feature flag I previously created.
  • Created the experiment and verified the feature flag was assigned to it.

Copy link
Contributor

github-actions bot commented Jan 29, 2025

Size Change: +330 B (+0.03%)

Total Size: 1.17 MB

ℹ️ View Unchanged
Filename Size Change
frontend/dist/toolbar.js 1.17 MB +330 B (+0.03%)

compressed-size-action

@@ -252,6 +253,23 @@ def validate_parameters(self, value):

return value

def validate_existing_feature_flag_for_experiment(self, feature_flag: FeatureFlag):
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 feel reasonably confident in this validation logic but would love a second pair of eyes on it.

Copy link
Contributor

Choose a reason for hiding this comment

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

Looking good to me 👍

<div className="my-10">
<LemonBanner type="info">
Existing feature flag configuration will be applied to the experiment.
</LemonBanner>
Copy link
Contributor Author

@danielbachhuber danielbachhuber Jan 31, 2025

Choose a reason for hiding this comment

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

I don't love this — open to other ideas.

CleanShot 2025-01-31 at 06 32 21@2x

Copy link
Contributor

@jurajmajerik jurajmajerik Feb 3, 2025

Choose a reason for hiding this comment

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

I think Variants is the most appropriate header to have before this banner.

actions.setExperimentManualErrors({
...existingErrors,
feature_flag_key: validateFeatureFlagKey(featureFlagKey),
})
Copy link
Contributor Author

Choose a reason for hiding this comment

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

One problem here: when a new field is focused, the manual errors state is cleared and the error state is reset to the previous value of experimentErrors. As such, it's not possible to assign the value to a reducer and then reference the reducer in errors: ({ because the reducer will contain the value that existed before validateFeatureFlag() fired.

Workaround ideas?

Branch

CleanShot 2025-01-31 at 06 22 11

Master
CleanShot 2025-01-31 at 06 24 14

Copy link
Contributor Author

Choose a reason for hiding this comment

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

One problem here: when a new field is focused, the manual errors state is cleared and the error state is reset to the previous value of experimentErrors.

It actually happens onBlur for the field:

https://github.com/keajs/kea-forms/blob/7b7028dae22df4fa624331a713968be6bd8ee2a7/src/components.tsx#L119-L120

https://github.com/keajs/kea-forms/blob/839f6f71b72554ccaf404c5066da5c911b214934/src/builder.ts#L99-L106

Copy link
Contributor Author

Choose a reason for hiding this comment

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

962bd18 (and a302b2a) seem to work better:

CleanShot 2025-02-03 at 06 53 51

@danielbachhuber danielbachhuber marked this pull request as ready for review January 31, 2025 14:26
@danielbachhuber danielbachhuber requested review from a team January 31, 2025 14:26
Copy link
Contributor

@greptile-apps greptile-apps bot left a comment

Choose a reason for hiding this comment

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

PR Summary

This PR adds functionality to create experiments using existing feature flags, with robust validation and UI improvements. Here's a concise summary of the key changes:

  • Added feature flag selection modal in ExperimentForm.tsx with validation tooltips and error states
  • Implemented featureFlagEligibleForExperiment validation requiring multiple variants with 'control' first
  • Added async feature flag validation in experimentLogic.tsx with proper state management
  • Modified backend validation in experiments.py to support and validate existing feature flag usage
  • Added analytics tracking for feature flag modal interactions and selections

Key implementation points:

  • Feature flags must have multiple variants with 'control' as first variant
  • Flags cannot be deleted or associated with other experiments
  • UI provides clear feedback through tooltips and field errors
  • Backend validation ensures data integrity
  • Analytics track feature flag selection workflow

The changes look well-structured with good error handling and user feedback throughout the feature flag selection process.

7 file(s) reviewed, 9 comment(s)
Edit PR Review Bot Settings | Greptile


variants = feature_flag.filters.get("multivariate", {}).get("variants", [])

if len(variants) and len(variants) > 1:
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 condition len(variants) and len(variants) > 1 can be simplified to just len(variants) > 1 since that covers both cases

Suggested change
if len(variants) and len(variants) > 1:
if len(variants) > 1:

Comment on lines +425 to +430
let disabledReason: string | undefined = undefined
try {
featureFlagEligibleForExperiment(flag)
} catch (error) {
disabledReason = (error as Error).message
}
Copy link
Contributor

Choose a reason for hiding this comment

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

style: error is implicitly typed as unknown here - should explicitly catch Error type

Suggested change
let disabledReason: string | undefined = undefined
try {
featureFlagEligibleForExperiment(flag)
} catch (error) {
disabledReason = (error as Error).message
}
let disabledReason: string | undefined = undefined
try {
featureFlagEligibleForExperiment(flag)
} catch (error: unknown) {
disabledReason = (error instanceof Error) ? error.message : String(error)
}

Comment on lines +408 to +409
to={urls.featureFlag(flag.id as number)}
target="_blank"
Copy link
Contributor

Choose a reason for hiding this comment

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

logic: unsafe type assertion with 'as number' - should validate id exists first

Comment on lines +433 to +436
const featureFlag = { ...baseFeatureFlag, is_remote_configuration: true }
expect(() => featureFlagEligibleForExperiment(featureFlag)).toThrow(
'Feature flag must use multiple variants with control as the first variant.'
)
Copy link
Contributor

Choose a reason for hiding this comment

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

logic: Error message doesn't match the actual issue. Should be about remote configuration not being supported rather than about variants.

Suggested change
const featureFlag = { ...baseFeatureFlag, is_remote_configuration: true }
expect(() => featureFlagEligibleForExperiment(featureFlag)).toThrow(
'Feature flag must use multiple variants with control as the first variant.'
)
const featureFlag = { ...baseFeatureFlag, is_remote_configuration: true }
expect(() => featureFlagEligibleForExperiment(featureFlag)).toThrow(
'Remote configuration feature flags cannot be used in experiments.'
)

Comment on lines 1060 to 1066
let isValid
try {
isValid = featureFlagEligibleForExperiment(matchingFlag)
} catch (error) {
isValid = false
}
actions.setIsExistingFeatureFlag(true)
Copy link
Contributor

Choose a reason for hiding this comment

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

logic: The try-catch block swallows the error details. Consider logging the error or handling specific error cases differently.

Comment on lines 191 to 208
export function featureFlagEligibleForExperiment(featureFlag: FeatureFlagType): true {
if (featureFlag.deleted) {
throw new Error('Feature flag must not be deleted.')
}

if (featureFlag.experiment_set && featureFlag.experiment_set.length > 0) {
throw new Error('Feature flag is already associated with an experiment.')
}

if (featureFlag.filters.multivariate?.variants?.length && featureFlag.filters.multivariate.variants.length > 1) {
if (featureFlag.filters.multivariate.variants[0].key !== 'control') {
throw new Error('Feature flag must have control as the first variant.')
}
return true
}

throw new Error('Feature flag must use multiple variants with control as the first variant.')
}
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 function returns true but never returns false - it throws errors instead. Consider either changing return type to void or making it return a boolean.

throw new Error('Feature flag is already associated with an experiment.')
}

if (featureFlag.filters.multivariate?.variants?.length && featureFlag.filters.multivariate.variants.length > 1) {
Copy link
Contributor

Choose a reason for hiding this comment

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

style: Optional chaining on variants.length could be simplified to variants?.length > 1 since you're already checking for truthiness

Suggested change
if (featureFlag.filters.multivariate?.variants?.length && featureFlag.filters.multivariate.variants.length > 1) {
if (featureFlag.filters.multivariate?.variants?.length > 1) {

@jurajmajerik
Copy link
Contributor

I'm getting a form error even though everything though the flag seems to have been added correctly:

image

@jurajmajerik
Copy link
Contributor

jurajmajerik commented Feb 3, 2025

I think I would prefer the Variants header before this banner. Otherwise it looks as if the banner belongs to the Experiment type section.

image

Also:

  • Worth adding the link to the flag to the banner?
  • The Holdouts input should remain - it is assigned to the experiment, not to the flag.

@danielbachhuber
Copy link
Contributor Author

I'm getting a form error even though everything though the flag seems to have been added correctly:

@jurajmajerik You probably started reviewing before a302b2a was applied.

@danielbachhuber
Copy link
Contributor Author

I think I would prefer the Variants header before this banner. Otherwise it looks as if the banner belongs to the Experiment type section.
Worth adding the link to the flag to the banner?

@jurajmajerik Good suggestions, thank you:

CleanShot 2025-02-03 at 07 44 45@2x

The Holdouts input should remain - it is assigned to the experiment, not to the flag.

Kinda. It's actually mostly stored on the feature flag:

feature_flag_filters = {
"groups": [{"properties": [], "rollout_percentage": 100}],
"multivariate": {"variants": variants or default_variants},
"aggregation_group_type_index": aggregation_group_type_index,
"holdout_groups": holdout_groups,
}

I thought about including it on the form, but I feel like it's more tied to the feature flag configuration conceptually. Another example:

CleanShot 2025-02-03 at 07 46 17@2x

Is this something you're strongly opinionated about?

@jurajmajerik
Copy link
Contributor

Kinda. It's actually mostly stored on the feature flag:

Yes, but the selector in the form only sets the holdout_id field on an experiment. It doesn't modify the flag directly. The experiment serializer then takes care of propagating it onto the feature flag.

The Holdout group selector is available when creating an experiment with a new feature flag. Why hide it if the feature flag is already existing?

@jurajmajerik
Copy link
Contributor

@jurajmajerik You probably started reviewing before a302b2a was applied.

All good now!

@danielbachhuber
Copy link
Contributor Author

The Holdout group selector is available when creating an experiment with a new feature flag. Why hide it if the feature flag is already existing?

Per our conversation in Slack, I removed the Holdout selector from the creation form entirely.

@danielbachhuber danielbachhuber enabled auto-merge (squash) February 5, 2025 00:10
@danielbachhuber danielbachhuber merged commit 6a7387f into master Feb 5, 2025
105 checks passed
@danielbachhuber danielbachhuber deleted the experiments/attach-existing-ff branch February 5, 2025 09:05
@jurajmajerik
Copy link
Contributor

This is a very useful feature, thanks for doing this!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants