diff --git a/ee/clickhouse/queries/experiments/funnel_experiment_result.py b/ee/clickhouse/queries/experiments/funnel_experiment_result.py index e311657cc52c7..f68816ed3b129 100644 --- a/ee/clickhouse/queries/experiments/funnel_experiment_result.py +++ b/ee/clickhouse/queries/experiments/funnel_experiment_result.py @@ -13,6 +13,7 @@ calculate_credible_intervals, calculate_probabilities, ) +from posthog.models.experiment import ExperimentHoldout from posthog.models.feature_flag import FeatureFlag from posthog.models.filters.filter import Filter from posthog.models.team import Team @@ -54,10 +55,13 @@ def __init__( feature_flag: FeatureFlag, experiment_start_date: datetime, experiment_end_date: Optional[datetime] = None, + holdout: Optional[ExperimentHoldout] = None, funnel_class: type[ClickhouseFunnel] = ClickhouseFunnel, ): breakdown_key = f"$feature/{feature_flag.key}" self.variants = [variant["key"] for variant in feature_flag.variants] + if holdout: + self.variants.append(f"holdout-{holdout.id}") # our filters assume that the given time ranges are in the project timezone. # while start and end date are in UTC. diff --git a/ee/clickhouse/queries/experiments/trend_experiment_result.py b/ee/clickhouse/queries/experiments/trend_experiment_result.py index 0971120f2366a..ac9508d21051c 100644 --- a/ee/clickhouse/queries/experiments/trend_experiment_result.py +++ b/ee/clickhouse/queries/experiments/trend_experiment_result.py @@ -22,6 +22,7 @@ calculate_credible_intervals, calculate_probabilities, ) +from posthog.models.experiment import ExperimentHoldout from posthog.models.feature_flag import FeatureFlag from posthog.models.filters.filter import Filter from posthog.models.team import Team @@ -81,9 +82,12 @@ def __init__( experiment_end_date: Optional[datetime] = None, trend_class: type[Trends] = Trends, custom_exposure_filter: Optional[Filter] = None, + holdout: Optional[ExperimentHoldout] = None, ): breakdown_key = f"$feature/{feature_flag.key}" self.variants = [variant["key"] for variant in feature_flag.variants] + if holdout: + self.variants.append(f"holdout-{holdout.id}") # our filters assume that the given time ranges are in the project timezone. # while start and end date are in UTC. diff --git a/ee/clickhouse/views/experiments.py b/ee/clickhouse/views/experiments.py index d33f99bb41a3c..6df24dc012cea 100644 --- a/ee/clickhouse/views/experiments.py +++ b/ee/clickhouse/views/experiments.py @@ -51,6 +51,7 @@ def _calculate_experiment_results(experiment: Experiment, refresh: bool = False) experiment.feature_flag, experiment.start_date, experiment.end_date, + holdout=experiment.holdout, custom_exposure_filter=exposure_filter, ).get_results() else: @@ -60,6 +61,7 @@ def _calculate_experiment_results(experiment: Experiment, refresh: bool = False) experiment.feature_flag, experiment.start_date, experiment.end_date, + holdout=experiment.holdout, ).get_results() return _experiment_results_cached( diff --git a/frontend/__snapshots__/scenes-app-experiments--complete-funnel-experiment--dark.png b/frontend/__snapshots__/scenes-app-experiments--complete-funnel-experiment--dark.png index 5a042b0f3b12e..a0adec369a052 100644 Binary files a/frontend/__snapshots__/scenes-app-experiments--complete-funnel-experiment--dark.png and b/frontend/__snapshots__/scenes-app-experiments--complete-funnel-experiment--dark.png differ diff --git a/frontend/__snapshots__/scenes-app-experiments--complete-funnel-experiment--light.png b/frontend/__snapshots__/scenes-app-experiments--complete-funnel-experiment--light.png index e10f4cc6a6a75..bf1949df70469 100644 Binary files a/frontend/__snapshots__/scenes-app-experiments--complete-funnel-experiment--light.png and b/frontend/__snapshots__/scenes-app-experiments--complete-funnel-experiment--light.png differ diff --git a/frontend/__snapshots__/scenes-app-experiments--running-trend-experiment--dark.png b/frontend/__snapshots__/scenes-app-experiments--running-trend-experiment--dark.png index 32591170a12eb..a82b90c53b1d1 100644 Binary files a/frontend/__snapshots__/scenes-app-experiments--running-trend-experiment--dark.png and b/frontend/__snapshots__/scenes-app-experiments--running-trend-experiment--dark.png differ diff --git a/frontend/__snapshots__/scenes-app-experiments--running-trend-experiment--light.png b/frontend/__snapshots__/scenes-app-experiments--running-trend-experiment--light.png index 8e6538c1e24dd..425ee3b2416b4 100644 Binary files a/frontend/__snapshots__/scenes-app-experiments--running-trend-experiment--light.png and b/frontend/__snapshots__/scenes-app-experiments--running-trend-experiment--light.png differ diff --git a/frontend/__snapshots__/scenes-app-experiments--running-trend-experiment-many-variants--dark.png b/frontend/__snapshots__/scenes-app-experiments--running-trend-experiment-many-variants--dark.png index 784e386f8b67f..f529d6ff05a85 100644 Binary files a/frontend/__snapshots__/scenes-app-experiments--running-trend-experiment-many-variants--dark.png and b/frontend/__snapshots__/scenes-app-experiments--running-trend-experiment-many-variants--dark.png differ diff --git a/frontend/__snapshots__/scenes-app-experiments--running-trend-experiment-many-variants--light.png b/frontend/__snapshots__/scenes-app-experiments--running-trend-experiment-many-variants--light.png index 9c8668050ec83..d79998d829a77 100644 Binary files a/frontend/__snapshots__/scenes-app-experiments--running-trend-experiment-many-variants--light.png and b/frontend/__snapshots__/scenes-app-experiments--running-trend-experiment-many-variants--light.png differ diff --git a/frontend/src/lib/constants.tsx b/frontend/src/lib/constants.tsx index 3639d06e4b466..cc6a67fc504a2 100644 --- a/frontend/src/lib/constants.tsx +++ b/frontend/src/lib/constants.tsx @@ -220,6 +220,7 @@ export const FEATURE_FLAGS = { LEGACY_ACTION_WEBHOOKS: 'legacy-action-webhooks', // owner: @mariusandra #team-cdp SESSION_REPLAY_URL_TRIGGER: 'session-replay-url-trigger', // owner: @richard-better #team-replay REPLAY_TEMPLATES: 'replay-templates', // owner: @raquelmsmith #team-replay + EXPERIMENTS_HOLDOUTS: 'experiments-holdouts', // owner: @jurajmajerik #team-experiments MESSAGING: 'messaging', // owner @mariusandra #team-cdp } as const export type FeatureFlagKey = (typeof FEATURE_FLAGS)[keyof typeof FEATURE_FLAGS] diff --git a/frontend/src/scenes/experiments/ExperimentForm.tsx b/frontend/src/scenes/experiments/ExperimentForm.tsx index 484281178822c..bcae52d655911 100644 --- a/frontend/src/scenes/experiments/ExperimentForm.tsx +++ b/frontend/src/scenes/experiments/ExperimentForm.tsx @@ -5,11 +5,12 @@ import { LemonDivider, LemonInput, LemonTextArea, Tooltip } from '@posthog/lemon import { BindLogic, useActions, useValues } from 'kea' import { Form, Group } from 'kea-forms' import { ExperimentVariantNumber } from 'lib/components/SeriesGlyph' -import { MAX_EXPERIMENT_VARIANTS } from 'lib/constants' +import { FEATURE_FLAGS, MAX_EXPERIMENT_VARIANTS } from 'lib/constants' import { IconChevronLeft } from 'lib/lemon-ui/icons' import { LemonButton } from 'lib/lemon-ui/LemonButton' import { LemonField } from 'lib/lemon-ui/LemonField' import { LemonRadio } from 'lib/lemon-ui/LemonRadio' +import { LemonSelect } from 'lib/lemon-ui/LemonSelect' import { capitalizeFirstLetter } from 'lib/utils' import { useEffect } from 'react' import { insightDataLogic } from 'scenes/insights/insightDataLogic' @@ -23,7 +24,7 @@ import { experimentLogic } from './experimentLogic' import { ExperimentInsightCreator } from './MetricSelector' const StepInfo = (): JSX.Element => { - const { experiment } = useValues(experimentLogic) + const { experiment, featureFlags } = useValues(experimentLogic) const { addExperimentGroup, removeExperimentGroup, moveToNextFormStep } = useActions(experimentLogic) return ( @@ -134,6 +135,14 @@ const StepInfo = (): JSX.Element => { + {featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOLDOUTS] && ( +
+

Holdout group

+
Exclude a stable group of users from the experiment.
+ + +
+ )} { ) } +export const HoldoutSelector = (): JSX.Element => { + const { experiment, holdouts } = useValues(experimentLogic) + const { setExperiment } = useActions(experimentLogic) + + const holdoutOptions = holdouts.map((holdout) => ({ + value: holdout.id, + label: holdout.name, + })) + holdoutOptions.unshift({ value: null, label: 'No holdout' }) + + return ( +
+ { + setExperiment({ + ...experiment, + holdout_id: value, + }) + }} + data-attr="experiment-holdout-selector" + /> +
+ ) +} + export function ExperimentForm(): JSX.Element { const { currentFormStep, props } = useValues(experimentLogic) const { setCurrentFormStep } = useActions(experimentLogic) diff --git a/frontend/src/scenes/experiments/ExperimentView/ExperimentView.tsx b/frontend/src/scenes/experiments/ExperimentView/ExperimentView.tsx index 0430a50a0b41b..1c8bf5bd5e71a 100644 --- a/frontend/src/scenes/experiments/ExperimentView/ExperimentView.tsx +++ b/frontend/src/scenes/experiments/ExperimentView/ExperimentView.tsx @@ -2,6 +2,8 @@ import '../Experiment.scss' import { LemonDivider } from '@posthog/lemon-ui' import { useActions, useValues } from 'kea' +import { FEATURE_FLAGS } from 'lib/constants' +import { featureFlagLogic } from 'lib/logic/featureFlagLogic' import { ExperimentImplementationDetails } from '../ExperimentImplementationDetails' import { experimentLogic } from '../experimentLogic' @@ -15,6 +17,7 @@ import { import { DataCollection } from './DataCollection' import { DistributionTable } from './DistributionTable' import { ExperimentExposureModal, ExperimentGoalModal, Goal } from './Goal' +import { HoldoutSelector } from './HoldoutSelector' import { Info } from './Info' import { Overview } from './Overview' import { ReleaseConditionsTable } from './ReleaseConditionsTable' @@ -24,6 +27,7 @@ import { SecondaryMetricsTable } from './SecondaryMetricsTable' export function ExperimentView(): JSX.Element { const { experiment, experimentLoading, experimentResultsLoading, experimentId, experimentResults } = useValues(experimentLogic) + const { featureFlags } = useValues(featureFlagLogic) const { updateExperimentSecondaryMetrics } = useActions(experimentLogic) @@ -47,6 +51,7 @@ export function ExperimentView(): JSX.Element {
+ {featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOLDOUTS] && }
@@ -60,6 +65,7 @@ export function ExperimentView(): JSX.Element {
+ {featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOLDOUTS] && }
diff --git a/frontend/src/scenes/experiments/ExperimentView/HoldoutSelector.tsx b/frontend/src/scenes/experiments/ExperimentView/HoldoutSelector.tsx new file mode 100644 index 0000000000000..f8982ad3d3636 --- /dev/null +++ b/frontend/src/scenes/experiments/ExperimentView/HoldoutSelector.tsx @@ -0,0 +1,47 @@ +import { IconInfo } from '@posthog/icons' +import { LemonSelect, Tooltip } from '@posthog/lemon-ui' +import { useActions, useValues } from 'kea' + +import { experimentLogic } from '../experimentLogic' + +export function HoldoutSelector(): JSX.Element { + const { experiment, holdouts, isExperimentRunning } = useValues(experimentLogic) + const { setExperiment, updateExperiment } = useActions(experimentLogic) + + const holdoutOptions = holdouts.map((holdout) => ({ + value: holdout.id, + label: holdout.name, + })) + holdoutOptions.unshift({ value: null, label: 'No holdout' }) + + return ( +
+
+

Holdout group

+ + + +
+
+ { + setExperiment({ + ...experiment, + holdout_id: value, + }) + updateExperiment({ holdout_id: value }) + }} + data-attr="experiment-holdout-selector" + /> +
+
+ ) +} diff --git a/frontend/src/scenes/experiments/ExperimentView/components.tsx b/frontend/src/scenes/experiments/ExperimentView/components.tsx index 71e6c7f35dd25..b4e092bf8f77f 100644 --- a/frontend/src/scenes/experiments/ExperimentView/components.tsx +++ b/frontend/src/scenes/experiments/ExperimentView/components.tsx @@ -50,10 +50,25 @@ export function VariantTag({ experimentId: number | 'new' variantKey: string }): JSX.Element { - const { experimentResults, getIndexForVariant } = useValues(experimentLogic({ experimentId })) + const { experiment, experimentResults, getIndexForVariant } = useValues(experimentLogic({ experimentId })) + + if (experiment.holdout && variantKey === `holdout-${experiment.holdout_id}`) { + return ( + +
+ {experiment.holdout.name} + + ) + } return ( - +
- {tab === ExperimentsTabs.Archived ? ( - + + {featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOLDOUTS] && tab === ExperimentsTabs.Holdouts ? ( + ) : ( - router.actions.push(urls.experiment('new'))} - isEmpty={shouldShowEmptyState} - customHog={ExperimentsHog} - /> - )} - {!shouldShowEmptyState && ( <> -
- + ) : ( + router.actions.push(urls.experiment('new'))} + isEmpty={shouldShowEmptyState} + customHog={ExperimentsHog} /> -
- - Status - - { - if (status) { - setSearchStatus(status as ProgressStatus | 'all') - } + )} + {!shouldShowEmptyState && ( + <> +
+ +
+ + Status + + { + if (status) { + setSearchStatus(status as ProgressStatus | 'all') + } + }} + options={ + [ + { label: 'All', value: 'all' }, + { label: 'Draft', value: ProgressStatus.Draft }, + { label: 'Running', value: ProgressStatus.Running }, + { label: 'Complete', value: ProgressStatus.Complete }, + ] as { label: string; value: string }[] + } + value={searchStatus ?? 'all'} + dropdownMatchSelectWidth={false} + dropdownMaxContentWidth + /> + + Created by + + setUserFilter(user?.uuid ?? null)} + /> +
+
+ - - Created by - - setUserFilter(user?.uuid ?? null)} - /> -
-
- + + )} )}
diff --git a/frontend/src/scenes/experiments/Holdouts.tsx b/frontend/src/scenes/experiments/Holdouts.tsx new file mode 100644 index 0000000000000..e6d7d6a13f328 --- /dev/null +++ b/frontend/src/scenes/experiments/Holdouts.tsx @@ -0,0 +1,229 @@ +import { IconPencil, IconTrash } from '@posthog/icons' +import { + LemonBanner, + LemonButton, + LemonDialog, + LemonDivider, + LemonInput, + LemonLabel, + LemonModal, + LemonTable, + LemonTableColumns, +} from '@posthog/lemon-ui' +import { useActions, useValues } from 'kea' +import { LemonSlider } from 'lib/lemon-ui/LemonSlider' +import { useState } from 'react' + +import { Holdout, holdoutsLogic, NEW_HOLDOUT } from './holdoutsLogic' + +export function Holdouts(): JSX.Element { + const { holdouts, holdoutsLoading, holdout } = useValues(holdoutsLogic) + const { createHoldout, deleteHoldout, setHoldout, updateHoldout } = useActions(holdoutsLogic) + + const [isHoldoutModalOpen, setIsHoldoutModalOpen] = useState(false) + const [editingHoldout, setEditingHoldout] = useState(null) + + const openEditModal = (holdout: Holdout): void => { + setEditingHoldout(holdout) + setHoldout(holdout) + setIsHoldoutModalOpen(true) + } + + const openCreateModal = (): void => { + setEditingHoldout(null) + setHoldout({ ...NEW_HOLDOUT }) + setIsHoldoutModalOpen(true) + } + + const closeModal = (): void => { + setIsHoldoutModalOpen(false) + setEditingHoldout(null) + } + + const getDisabledReason = (): string | undefined => { + if (!holdout.name) { + return 'Name is required' + } + if (holdout.filters?.[0]?.rollout_percentage === undefined) { + return 'Rollout percentage is required' + } + } + + const columns = [ + { + title: 'Name', + dataIndex: 'name', + key: 'name', + render: (name: string) =>
{name}
, + }, + { + title: 'Description', + dataIndex: 'description', + key: 'description', + }, + { + title: 'Rollout Percentage', + dataIndex: 'filters', + key: 'rollout', + render: (filters: Holdout['filters']) => { + const percentage = filters?.[0]?.rollout_percentage || 0 + return
{percentage} %
+ }, + }, + { + title: 'Actions', + key: 'actions', + render: (_: any, record: Holdout) => ( +
+ } + onClick={() => openEditModal(record)} + /> + } + size="xsmall" + status="danger" + onClick={() => { + LemonDialog.open({ + title: 'Delete this holdout?', + content: ( +
+ Are you sure you want to delete the holdout "{record.name}"? This action + cannot be undone. +
+ ), + primaryButton: { + children: 'Delete', + type: 'primary', + status: 'danger', + onClick: () => deleteHoldout(record.id), + size: 'small', + }, + secondaryButton: { + children: 'Cancel', + type: 'tertiary', + size: 'small', + }, + }) + }} + /> +
+ ), + }, + ] + + return ( +
+ + Cancel + { + if (editingHoldout) { + updateHoldout(editingHoldout.id, holdout) + } else { + createHoldout() + } + closeModal() + }} + disabledReason={getDisabledReason()} + > + {editingHoldout ? 'Update' : 'Save'} + + + } + > +
+
+ Name + setHoldout({ name })} + placeholder="e.g. 'Frontend holdout group 1'" + /> +
+
+ Description + setHoldout({ description })} + /> +
+
+ + +
+
+ Specify the percentage population that should be included in this holdout group. + This is stable across experiments. +
+
+
+
+ Roll out to{' '} + + setHoldout({ + filters: [{ properties: [], rollout_percentage }], + }) + } + min={0} + max={100} + step={1} + className="ml-1.5 w-20" + /> + + setHoldout({ + filters: [{ properties: [], rollout_percentage }], + }) + } + min={0} + max={100} + step="any" + suffix={%} + /> + of total users. +
+
+
+
+ + +
+
+ Holdouts are stable groups of users excluded from experiment variations.They act as a baseline, + helping you see how users behave without any changes applied. This lets you directly compare + their behavior to those exposed to the experiment variations. Once a holdout is configured, you + can apply it to an experiment during creation. +
+
+
+ + You have not created any holdouts yet.
+ } + loading={holdoutsLoading} + dataSource={holdouts} + columns={columns as LemonTableColumns} + /> + + New holdout + +
+ ) +} diff --git a/frontend/src/scenes/experiments/experimentLogic.tsx b/frontend/src/scenes/experiments/experimentLogic.tsx index 09b6597176fc8..19e33aca83831 100644 --- a/frontend/src/scenes/experiments/experimentLogic.tsx +++ b/frontend/src/scenes/experiments/experimentLogic.tsx @@ -54,6 +54,7 @@ import { import { EXPERIMENT_EXPOSURE_INSIGHT_ID, EXPERIMENT_INSIGHT_ID } from './constants' import type { experimentLogicType } from './experimentLogicType' import { experimentsLogic } from './experimentsLogic' +import { holdoutsLogic } from './holdoutsLogic' import { getMinimumDetectableEffect, transformFiltersForWinningVariant } from './utils' const NEW_EXPERIMENT: Experiment = { @@ -71,6 +72,7 @@ const NEW_EXPERIMENT: Experiment = { created_at: null, created_by: null, updated_at: null, + holdout_id: null, } export interface ExperimentLogicProps { @@ -112,6 +114,8 @@ export const experimentLogic = kea([ ['insightDataLoading as goalInsightDataLoading'], featureFlagLogic, ['featureFlags'], + holdoutsLogic, + ['holdouts'], ], actions: [ experimentsLogic, diff --git a/frontend/src/scenes/experiments/holdoutsLogic.tsx b/frontend/src/scenes/experiments/holdoutsLogic.tsx new file mode 100644 index 0000000000000..3f70a30d61216 --- /dev/null +++ b/frontend/src/scenes/experiments/holdoutsLogic.tsx @@ -0,0 +1,95 @@ +import { actions, events, kea, listeners, path, reducers } from 'kea' +import { loaders } from 'kea-loaders' +import api from 'lib/api' +import { lemonToast } from 'lib/lemon-ui/LemonToast/LemonToast' + +import { UserBasicType } from '~/types' + +import type { holdoutsLogicType } from './holdoutsLogicType' + +export interface Holdout { + id: number | null + name: string + description: string | null + filters: Record + created_by: UserBasicType | null + created_at: string | null + updated_at: string | null +} + +export const NEW_HOLDOUT: Holdout = { + id: null, + name: '', + description: null, + filters: [ + { + properties: [], + rollout_percentage: 10, + variant: 'holdout', + }, + ], + created_by: null, + created_at: null, + updated_at: null, +} + +export const holdoutsLogic = kea([ + path(['scenes', 'experiments', 'holdoutsLogic']), + actions({ + setHoldout: (holdout: Partial) => ({ holdout }), + createHoldout: true, + updateHoldout: (id: number | null, holdout: Partial) => ({ id, holdout }), + deleteHoldout: (id: number | null) => ({ id }), + loadHoldout: (id: number | null) => ({ id }), + }), + reducers({ + holdout: [ + NEW_HOLDOUT, + { + setHoldout: (state, { holdout }) => ({ ...state, ...holdout }), + }, + ], + }), + loaders(({ values }) => ({ + holdouts: [ + [] as Holdout[], + { + loadHoldouts: async () => { + const response = await api.get(`api/projects/@current/experiment_holdouts/`) + return response.results as Holdout[] + }, + createHoldout: async () => { + const response = await api.create(`api/projects/@current/experiment_holdouts/`, values.holdout) + return [...values.holdouts, response] as Holdout[] + }, + updateHoldout: async ({ id, holdout }) => { + const response = await api.update(`api/projects/@current/experiment_holdouts/${id}/`, holdout) + return values.holdouts.map((h) => (h.id === id ? response : h)) as Holdout[] + }, + deleteHoldout: async ({ id }) => { + await api.delete(`api/projects/@current/experiment_holdouts/${id}/`) + return values.holdouts.filter((h) => h.id !== id) + }, + }, + ], + })), + listeners(({ actions }) => ({ + createHoldoutSuccess: () => { + lemonToast.success('Holdout created') + actions.loadHoldouts() + }, + updateHoldoutSuccess: () => { + lemonToast.success('Holdout updated') + actions.loadHoldouts() + }, + deleteHoldoutSuccess: () => { + lemonToast.success('Holdout deleted') + actions.loadHoldouts() + }, + })), + events(({ actions }) => ({ + afterMount: () => { + actions.loadHoldouts() + }, + })), +]) diff --git a/frontend/src/types.ts b/frontend/src/types.ts index b01a6a080d5c9..7c0a8acedad00 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -25,6 +25,7 @@ import type { PostHog, SupportedWebVitalsMetrics } from 'posthog-js' import { Layout } from 'react-grid-layout' import { LogLevel } from 'rrweb' import { BehavioralFilterKey, BehavioralFilterType } from 'scenes/cohorts/CohortFilters/types' +import { Holdout } from 'scenes/experiments/holdoutsLogic' import { AggregationAxisFormat } from 'scenes/insights/aggregationAxisFormat' import { JSONContent } from 'scenes/notebooks/Notebook/utils' import { Scene } from 'scenes/sceneTypes' @@ -679,6 +680,7 @@ export enum ExperimentsTabs { All = 'all', Yours = 'yours', Archived = 'archived', + Holdouts = 'holdouts', } export enum ActivityTab { @@ -3256,6 +3258,8 @@ export interface Experiment { created_at: string | null created_by: UserBasicType | null updated_at: string | null + holdout_id?: number | null + holdout?: Holdout } export interface FunnelExperimentVariant {