-
Notifications
You must be signed in to change notification settings - Fork 1.5k
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): Add custom exposures to new query runner #29015
Conversation
Size Change: 0 B Total Size: 9.71 MB ℹ️ View Unchanged
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
PR Summary
Based on the provided files, I'll summarize the key changes in this PR that adds custom exposure criteria to experiments:
- Introduces a new "Exposure criteria" UI component allowing users to choose between Default (using
$feature_flag_called
event) and Custom exposure tracking modes. - Adds
exposure_criteria
JSONField to the Experiment model with corresponding migration, storing custom event and property filters for experiment exposure tracking. - Refactors experiment query runners (
ExperimentQueryRunner
andExperimentExposureQueryRunner
) to support both default and custom exposure configurations. - Moves
filterTestAccounts
from individual metrics to experiment-levelexposure_criteria
, consolidating test account filtering configuration. - Adds new schema types
ExperimentEventExposureConfig
andExperimentExposureCriteria
to support structured exposure configuration.
The implementation appears well-structured with:
- Comprehensive test coverage for both default and custom exposure scenarios
- Proper error handling and timezone support in query runners
- Backward compatibility by defaulting to original behavior when no custom config is present
- Clean UI integration with existing experiment management interface
22 file(s) reviewed, 11 comment(s)
Edit PR Review Bot Settings | Greptile
@@ -37,9 +37,11 @@ const dataWarehousePopoverFields: DataWarehousePopoverField[] = [ | |||
export function ExperimentMetricForm({ | |||
metric, | |||
handleSetMetric, | |||
filterTestAccounts, | |||
}: { | |||
metric: ExperimentMetric | |||
handleSetMetric: any |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
style: handleSetMetric is typed as 'any' which could lead to type safety issues
handleSetMetric: any | |
handleSetMetric: (params: { newMetric: ExperimentMetric }) => void |
<LemonButton className="mt-2" size="xsmall" type="secondary" onClick={() => openExposureCriteriaModal()}> | ||
Edit | ||
</LemonButton> | ||
<ExposureCriteriaModal /> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
style: Modal is rendered unconditionally which could cause unnecessary re-renders. Consider moving ExposureCriteriaModal to a separate component and only rendering when isExposureCriteriaModalOpen is true.
checked={(() => { | ||
const val = experiment.exposure_criteria?.filterTestAccounts | ||
return hasFilters ? !!val : false | ||
})()} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
style: IIFE is unnecessary here and adds complexity. Can be simplified to: checked={hasFilters && !!experiment.exposure_criteria?.filterTestAccounts}
checked={(() => { | |
const val = experiment.exposure_criteria?.filterTestAccounts | |
return hasFilters ? !!val : false | |
})()} | |
checked={hasFilters && !!experiment.exposure_criteria?.filterTestAccounts} |
event: '$feature_flag_called', | ||
properties: [], |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
logic: Custom mode initializes with $feature_flag_called event which could be confusing since that's the default mode's behavior. Consider initializing with null or empty string.
form="edit-experiment-exposure-form" | ||
type="secondary" |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
logic: form attribute is specified but no <form> element exists in the modal. This could cause unexpected behavior with form submission.
} | ||
|
||
const query = metricToQuery(metric) | ||
const query = metricToQuery(metric, false) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
style: filterTestAccounts parameter is now required but not documented in function signature
export function exposureConfigToFilter(exposure_config: ExperimentEventExposureConfig): FilterType { | ||
if (exposure_config.kind === NodeKind.ExperimentEventExposureConfig) { | ||
return { | ||
events: [ | ||
{ | ||
id: exposure_config.event, | ||
name: exposure_config.event, | ||
kind: NodeKind.EventsNode, | ||
type: 'events', | ||
properties: exposure_config.properties, | ||
} as EventsNode, | ||
], | ||
actions: [], | ||
data_warehouse: [], | ||
} | ||
} | ||
|
||
return {} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
logic: This function returns an empty object if the exposure config kind doesn't match ExperimentEventExposureConfig. Should throw an error or handle other kinds explicitly.
export function exposureConfigToFilter(exposure_config: ExperimentEventExposureConfig): FilterType { | |
if (exposure_config.kind === NodeKind.ExperimentEventExposureConfig) { | |
return { | |
events: [ | |
{ | |
id: exposure_config.event, | |
name: exposure_config.event, | |
kind: NodeKind.EventsNode, | |
type: 'events', | |
properties: exposure_config.properties, | |
} as EventsNode, | |
], | |
actions: [], | |
data_warehouse: [], | |
} | |
} | |
return {} | |
} | |
export function exposureConfigToFilter(exposure_config: ExperimentEventExposureConfig): FilterType { | |
if (exposure_config.kind === NodeKind.ExperimentEventExposureConfig) { | |
return { | |
events: [ | |
{ | |
id: exposure_config.event, | |
name: exposure_config.event, | |
kind: NodeKind.EventsNode, | |
type: 'events', | |
properties: exposure_config.properties, | |
} as EventsNode, | |
], | |
actions: [], | |
data_warehouse: [], | |
} | |
} | |
throw new Error(`Unsupported exposure config kind: ${exposure_config.kind}`) | |
} |
if (entity.kind === NodeKind.EventsNode) { | ||
if (entity.type === 'events') { | ||
return { | ||
kind: NodeKind.ExperimentEventExposureConfig, | ||
event: entity.id, | ||
properties: entity.properties, | ||
} | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
style: Consider supporting other node kinds like ActionsNode for exposure config, similar to how metricConfigToFilter handles multiple types.
if exposure_config and exposure_config.get("kind") == "ExperimentEventExposureConfig": | ||
event_name = exposure_config.get("event") |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
logic: Should validate that event_name is not empty/None when using custom exposure config
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Validated on the way in: 795dceb
self.assertEqual(response.total_exposures["control"], 3) | ||
self.assertEqual(response.total_exposures["test"], 5) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
logic: Test case doesn't verify that control user with plan=free was correctly excluded from exposure count
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Invalid:
diff --git a/posthog/hogql_queries/experiments/test/test_experiment_exposures_query_runner.py b/posthog/hogql_queries/experiments/test/test_experiment_exposures_query_runner.py
index c85b5d3a79..c12fc78598 100644
--- a/posthog/hogql_queries/experiments/test/test_experiment_exposures_query_runner.py
+++ b/posthog/hogql_queries/experiments/test/test_experiment_exposures_query_runner.py
@@ -559,7 +559,7 @@ class TestExperimentExposuresQueryRunner(ClickhouseTestMixin, APIBaseTest):
{
"event": "$pageview",
"timestamp": "2024-01-03",
- "properties": {ff_property: "control", "plan": "free"},
+ "properties": {ff_property: "control"},
},
],
"user_test_1": [
FAILED posthog/hogql_queries/experiments/test/test_experiment_exposures_query_runner.py::TestExperimentExposuresQueryRunner::test_exposure_query_with_custom_exposure - AssertionError: 4.0 != 3
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Looks great! I'm getting the following error though:
"/Users/jurajmajerik/posthog/posthog/hogql_queries/experiments/experiment_query_runner.py", line 454, in <lambda>
sorted_results = sorted(response.results, key=lambda x: self.variants.index(x[0]))
^^^^^^^^^^^^^^^^^^^^^^^^^
ValueError: '' is not in list
I think the issue is:
- Some
$feature_flag_called
events are missing the variant value. - These events are still included in the query response with an empty string ("") as the breakdown.
- The sorting line fails because "" is not in self.variants:
sorted_results = sorted(response.results, key=lambda x: self.variants.index(x[0]))
To fix this, you could:
- Make sure the breakdown only includes valid variants
- Handle the sorting more gracefully
Please also add a test, as $feature_flag_called
events without variant data are common in production!
@jurajmajerik Fixed up with ea3c873
Funny enough, I spent an hour pulling my hair out in the car debugging this issue 🙃 I sent bad data into the system too. I decided not to fix at the time because I figured it was better for the query to error if you have invalid data vs. silently ignore it. I guess we can incorporate it in some form of health check later, though. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Great! 🙌
Changes
Adds a new "Exposure criteria" UI element where the customer can:
I didn't actually display anything in the UI element because I didn't have any great ideas for it yet.
Applies the exposure criteria to
ExperimentQueryRunner
andExperimentExposureQueryRunner
.CleanShot.2025-02-20.at.16.52.16.mp4
How did you test this code?
Tests should pass.
I also created a new experiment, sent some test events, and verified I could use custom exposure criteria as expected.