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): Add custom exposures to new query runner #29015

Merged
merged 18 commits into from
Feb 21, 2025

Conversation

danielbachhuber
Copy link
Contributor

@danielbachhuber danielbachhuber commented Feb 20, 2025

Changes

Adds a new "Exposure criteria" UI element where the customer can:

  • Choose between Default and Custom as their exposure criteria.
  • Specify an event and event property filters for their custom exposure.
  • Enable or disable the test and internal accounts filter.

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 and ExperimentExposureQueryRunner.

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.

Copy link
Contributor

github-actions bot commented Feb 20, 2025

Size Change: 0 B

Total Size: 9.71 MB

ℹ️ View Unchanged
Filename Size
frontend/dist/toolbar.js 9.71 MB

compressed-size-action

@danielbachhuber danielbachhuber changed the title feat(experiments): First pass at exposure criteria modal feat(experiments): Adds custom exposures to new query runner Feb 21, 2025
@danielbachhuber danielbachhuber marked this pull request as ready for review February 21, 2025 00:54
@danielbachhuber danielbachhuber requested a review from a team February 21, 2025 00:54
@danielbachhuber danielbachhuber changed the title feat(experiments): Adds custom exposures to new query runner feat(experiments): Add custom exposures to new query runner Feb 21, 2025
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

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 and ExperimentExposureQueryRunner) to support both default and custom exposure configurations.
  • Moves filterTestAccounts from individual metrics to experiment-level exposure_criteria, consolidating test account filtering configuration.
  • Adds new schema types ExperimentEventExposureConfig and ExperimentExposureCriteria 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
Copy link
Contributor

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

Suggested change
handleSetMetric: any
handleSetMetric: (params: { newMetric: ExperimentMetric }) => void

<LemonButton className="mt-2" size="xsmall" type="secondary" onClick={() => openExposureCriteriaModal()}>
Edit
</LemonButton>
<ExposureCriteriaModal />
Copy link
Contributor

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.

Comment on lines +132 to +135
checked={(() => {
const val = experiment.exposure_criteria?.filterTestAccounts
return hasFilters ? !!val : false
})()}
Copy link
Contributor

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}

Suggested change
checked={(() => {
const val = experiment.exposure_criteria?.filterTestAccounts
return hasFilters ? !!val : false
})()}
checked={hasFilters && !!experiment.exposure_criteria?.filterTestAccounts}

Comment on lines +89 to +90
event: '$feature_flag_called',
properties: [],
Copy link
Contributor

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.

Comment on lines +32 to +33
form="edit-experiment-exposure-form"
type="secondary"
Copy link
Contributor

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)
Copy link
Contributor

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

Comment on lines +396 to +414
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 {}
}
Copy link
Contributor

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.

Suggested change
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}`)
}

Comment on lines +423 to +431
if (entity.kind === NodeKind.EventsNode) {
if (entity.type === 'events') {
return {
kind: NodeKind.ExperimentEventExposureConfig,
event: entity.id,
properties: entity.properties,
}
}
}
Copy link
Contributor

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.

Comment on lines +85 to +86
if exposure_config and exposure_config.get("kind") == "ExperimentEventExposureConfig":
event_name = exposure_config.get("event")
Copy link
Contributor

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

Copy link
Contributor Author

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

Comment on lines +639 to +640
self.assertEqual(response.total_exposures["control"], 3)
self.assertEqual(response.total_exposures["test"], 5)
Copy link
Contributor

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

Copy link
Contributor Author

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

Copy link
Contributor

@jurajmajerik jurajmajerik left a 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!

@danielbachhuber
Copy link
Contributor Author

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

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]))

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.

Copy link
Contributor

@jurajmajerik jurajmajerik left a comment

Choose a reason for hiding this comment

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

Great! 🙌

@danielbachhuber danielbachhuber merged commit 5ced32e into master Feb 21, 2025
97 checks passed
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