diff --git a/ee/clickhouse/views/experiment_saved_metrics.py b/ee/clickhouse/views/experiment_saved_metrics.py index 9dc2fcd94e073..911a34530c0b2 100644 --- a/ee/clickhouse/views/experiment_saved_metrics.py +++ b/ee/clickhouse/views/experiment_saved_metrics.py @@ -6,10 +6,13 @@ from posthog.api.routing import TeamAndOrgViewSetMixin from posthog.api.shared import UserBasicSerializer from posthog.models.experiment import ExperimentSavedMetric, ExperimentToSavedMetric -from posthog.schema import FunnelsQuery, TrendsQuery +from posthog.schema import ExperimentFunnelsQuery, ExperimentTrendsQuery class ExperimentToSavedMetricSerializer(serializers.ModelSerializer): + query = serializers.JSONField(source="saved_metric.query", read_only=True) + name = serializers.CharField(source="saved_metric.name", read_only=True) + class Meta: model = ExperimentToSavedMetric fields = [ @@ -18,6 +21,8 @@ class Meta: "saved_metric", "metadata", "created_at", + "query", + "name", ] read_only_fields = [ "id", @@ -52,15 +57,15 @@ def validate_query(self, value): metric_query = value - if metric_query.get("kind") not in ["TrendsQuery", "FunnelsQuery"]: - raise ValidationError("Metric query kind must be 'TrendsQuery' or 'FunnelsQuery'") + if metric_query.get("kind") not in ["ExperimentTrendsQuery", "ExperimentFunnelsQuery"]: + raise ValidationError("Metric query kind must be 'ExperimentTrendsQuery' or 'ExperimentFunnelsQuery'") # pydantic models are used to validate the query try: - if metric_query["kind"] == "TrendsQuery": - TrendsQuery(**metric_query) + if metric_query["kind"] == "ExperimentTrendsQuery": + ExperimentTrendsQuery(**metric_query) else: - FunnelsQuery(**metric_query) + ExperimentFunnelsQuery(**metric_query) except pydantic.ValidationError as e: raise ValidationError(str(e.errors())) from e diff --git a/ee/clickhouse/views/experiments.py b/ee/clickhouse/views/experiments.py index b8444d819bafa..273e6d1f612f2 100644 --- a/ee/clickhouse/views/experiments.py +++ b/ee/clickhouse/views/experiments.py @@ -165,9 +165,7 @@ class ExperimentSerializer(serializers.ModelSerializer): queryset=ExperimentHoldout.objects.all(), source="holdout", required=False, allow_null=True ) saved_metrics = ExperimentToSavedMetricSerializer(many=True, source="experimenttosavedmetric_set", read_only=True) - saved_metrics_ids = serializers.ListField( - child=serializers.JSONField(), write_only=True, required=False, allow_null=True - ) + saved_metrics_ids = serializers.ListField(child=serializers.JSONField(), required=False, allow_null=True) class Meta: model = Experiment diff --git a/ee/clickhouse/views/test/test_clickhouse_experiments.py b/ee/clickhouse/views/test/test_clickhouse_experiments.py index a4c8bf9f3eb13..4501301e3befd 100644 --- a/ee/clickhouse/views/test/test_clickhouse_experiments.py +++ b/ee/clickhouse/views/test/test_clickhouse_experiments.py @@ -370,7 +370,13 @@ def test_saved_metrics(self): { "name": "Test Experiment saved metric", "description": "Test description", - "query": {"kind": "TrendsQuery", "series": [{"kind": "EventsNode", "event": "$pageview"}]}, + "query": { + "kind": "ExperimentTrendsQuery", + "count_query": { + "kind": "TrendsQuery", + "series": [{"kind": "EventsNode", "event": "$pageview"}], + }, + }, }, ) @@ -380,7 +386,10 @@ def test_saved_metrics(self): self.assertEqual(response.json()["description"], "Test description") self.assertEqual( response.json()["query"], - {"kind": "TrendsQuery", "series": [{"kind": "EventsNode", "event": "$pageview"}]}, + { + "kind": "ExperimentTrendsQuery", + "count_query": {"kind": "TrendsQuery", "series": [{"kind": "EventsNode", "event": "$pageview"}]}, + }, ) self.assertEqual(response.json()["created_by"]["id"], self.user.pk) @@ -418,7 +427,11 @@ def test_saved_metrics(self): saved_metric = Experiment.objects.get(pk=exp_id).saved_metrics.first() self.assertEqual(saved_metric.id, saved_metric_id) self.assertEqual( - saved_metric.query, {"kind": "TrendsQuery", "series": [{"kind": "EventsNode", "event": "$pageview"}]} + saved_metric.query, + { + "kind": "ExperimentTrendsQuery", + "count_query": {"kind": "TrendsQuery", "series": [{"kind": "EventsNode", "event": "$pageview"}]}, + }, ) # Now try updating experiment with new saved metric @@ -427,7 +440,10 @@ def test_saved_metrics(self): { "name": "Test Experiment saved metric 2", "description": "Test description 2", - "query": {"kind": "TrendsQuery", "series": [{"kind": "EventsNode", "event": "$pageleave"}]}, + "query": { + "kind": "ExperimentTrendsQuery", + "count_query": {"kind": "TrendsQuery", "series": [{"kind": "EventsNode", "event": "$pageleave"}]}, + }, }, ) @@ -513,7 +529,10 @@ def test_validate_saved_metrics_payload(self): { "name": "Test Experiment saved metric", "description": "Test description", - "query": {"kind": "TrendsQuery", "series": [{"kind": "EventsNode", "event": "$pageview"}]}, + "query": { + "kind": "ExperimentTrendsQuery", + "count_query": {"kind": "TrendsQuery", "series": [{"kind": "EventsNode", "event": "$pageview"}]}, + }, }, ) diff --git a/ee/clickhouse/views/test/test_experiment_saved_metrics.py b/ee/clickhouse/views/test/test_experiment_saved_metrics.py index 51ef8242614ac..90575cbba074d 100644 --- a/ee/clickhouse/views/test/test_experiment_saved_metrics.py +++ b/ee/clickhouse/views/test/test_experiment_saved_metrics.py @@ -34,7 +34,9 @@ def test_validation_of_query_metric(self): ) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertEqual(response.json()["detail"], "Metric query kind must be 'TrendsQuery' or 'FunnelsQuery'") + self.assertEqual( + response.json()["detail"], "Metric query kind must be 'ExperimentTrendsQuery' or 'ExperimentFunnelsQuery'" + ) response = self.client.post( f"/api/projects/{self.team.id}/experiment_saved_metrics/", @@ -47,40 +49,46 @@ def test_validation_of_query_metric(self): ) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertEqual(response.json()["detail"], "Metric query kind must be 'TrendsQuery' or 'FunnelsQuery'") + self.assertEqual( + response.json()["detail"], "Metric query kind must be 'ExperimentTrendsQuery' or 'ExperimentFunnelsQuery'" + ) response = self.client.post( f"/api/projects/{self.team.id}/experiment_saved_metrics/", data={ "name": "Test Experiment saved metric", "description": "Test description", - "query": {"kind": "ExperimentTrendsQuery"}, + "query": {"kind": "TrendsQuery"}, }, format="json", ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertEqual(response.json()["detail"], "Metric query kind must be 'TrendsQuery' or 'FunnelsQuery'") + self.assertEqual( + response.json()["detail"], "Metric query kind must be 'ExperimentTrendsQuery' or 'ExperimentFunnelsQuery'" + ) response = self.client.post( f"/api/projects/{self.team.id}/experiment_saved_metrics/", data={ "name": "Test Experiment saved metric", "description": "Test description", - "query": {"kind": "TrendsQuery"}, + "query": {"kind": "ExperimentTrendsQuery"}, }, format="json", ) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertTrue("'loc': ('series',), 'msg': 'Field required'" in response.json()["detail"]) + self.assertTrue("'loc': ('count_query',), 'msg': 'Field required'" in response.json()["detail"]) response = self.client.post( f"/api/projects/{self.team.id}/experiment_saved_metrics/", data={ "name": "Test Experiment saved metric", "description": "Test description", - "query": {"kind": "TrendsQuery", "series": [{"kind": "EventsNode", "event": "$pageview"}]}, + "query": { + "kind": "ExperimentTrendsQuery", + "count_query": {"kind": "TrendsQuery", "series": [{"kind": "EventsNode", "event": "$pageview"}]}, + }, }, format="json", ) @@ -93,7 +101,13 @@ def test_create_update_experiment_saved_metrics(self) -> None: data={ "name": "Test Experiment saved metric", "description": "Test description", - "query": {"kind": "TrendsQuery", "series": [{"kind": "EventsNode", "event": "$pageview"}]}, + "query": { + "kind": "ExperimentTrendsQuery", + "count_query": { + "kind": "TrendsQuery", + "series": [{"kind": "EventsNode", "event": "$pageview"}], + }, + }, }, format="json", ) @@ -104,7 +118,10 @@ def test_create_update_experiment_saved_metrics(self) -> None: self.assertEqual(response.json()["description"], "Test description") self.assertEqual( response.json()["query"], - {"kind": "TrendsQuery", "series": [{"kind": "EventsNode", "event": "$pageview"}]}, + { + "kind": "ExperimentTrendsQuery", + "count_query": {"kind": "TrendsQuery", "series": [{"kind": "EventsNode", "event": "$pageview"}]}, + }, ) self.assertEqual(response.json()["created_by"]["id"], self.user.pk) @@ -142,7 +159,11 @@ def test_create_update_experiment_saved_metrics(self) -> None: saved_metric = Experiment.objects.get(pk=exp_id).saved_metrics.first() self.assertEqual(saved_metric.id, saved_metric_id) self.assertEqual( - saved_metric.query, {"kind": "TrendsQuery", "series": [{"kind": "EventsNode", "event": "$pageview"}]} + saved_metric.query, + { + "kind": "ExperimentTrendsQuery", + "count_query": {"kind": "TrendsQuery", "series": [{"kind": "EventsNode", "event": "$pageview"}]}, + }, ) # Now try updating saved metric @@ -151,7 +172,10 @@ def test_create_update_experiment_saved_metrics(self) -> None: { "name": "Test Experiment saved metric 2", "description": "Test description 2", - "query": {"kind": "TrendsQuery", "series": [{"kind": "EventsNode", "event": "$pageleave"}]}, + "query": { + "kind": "ExperimentTrendsQuery", + "count_query": {"kind": "TrendsQuery", "series": [{"kind": "EventsNode", "event": "$pageleave"}]}, + }, }, ) @@ -159,7 +183,10 @@ def test_create_update_experiment_saved_metrics(self) -> None: self.assertEqual(response.json()["name"], "Test Experiment saved metric 2") self.assertEqual( response.json()["query"], - {"kind": "TrendsQuery", "series": [{"kind": "EventsNode", "event": "$pageleave"}]}, + { + "kind": "ExperimentTrendsQuery", + "count_query": {"kind": "TrendsQuery", "series": [{"kind": "EventsNode", "event": "$pageleave"}]}, + }, ) # make sure experiment in question was updated as well @@ -168,7 +195,10 @@ def test_create_update_experiment_saved_metrics(self) -> None: self.assertEqual(saved_metric.id, saved_metric_id) self.assertEqual( saved_metric.query, - {"kind": "TrendsQuery", "series": [{"kind": "EventsNode", "event": "$pageleave"}]}, + { + "kind": "ExperimentTrendsQuery", + "count_query": {"kind": "TrendsQuery", "series": [{"kind": "EventsNode", "event": "$pageleave"}]}, + }, ) self.assertEqual(saved_metric.name, "Test Experiment saved metric 2") self.assertEqual(saved_metric.description, "Test description 2") @@ -186,7 +216,10 @@ def test_invalid_create(self): f"/api/projects/{self.team.id}/experiment_saved_metrics/", data={ "name": None, # invalid - "query": {"kind": "TrendsQuery", "series": [{"kind": "EventsNode", "event": "$pageview"}]}, + "query": { + "kind": "ExperimentTrendsQuery", + "count_query": {"kind": "TrendsQuery", "series": [{"kind": "EventsNode", "event": "$pageview"}]}, + }, }, format="json", ) diff --git a/frontend/__snapshots__/scenes-app-insights--trends-line--light--webkit.png b/frontend/__snapshots__/scenes-app-insights--trends-line--light--webkit.png index f4ee11e4c1d59..b86d6375ae469 100644 Binary files a/frontend/__snapshots__/scenes-app-insights--trends-line--light--webkit.png and b/frontend/__snapshots__/scenes-app-insights--trends-line--light--webkit.png differ diff --git a/frontend/__snapshots__/scenes-app-insights--trends-line--light.png b/frontend/__snapshots__/scenes-app-insights--trends-line--light.png index 264c80096163f..4ee6c11e37374 100644 Binary files a/frontend/__snapshots__/scenes-app-insights--trends-line--light.png and b/frontend/__snapshots__/scenes-app-insights--trends-line--light.png differ diff --git a/frontend/src/scenes/appScenes.ts b/frontend/src/scenes/appScenes.ts index f6a646f64f7c4..f7dc7944da802 100644 --- a/frontend/src/scenes/appScenes.ts +++ b/frontend/src/scenes/appScenes.ts @@ -28,6 +28,8 @@ export const appScenes: Record any> = { [Scene.Group]: () => import('./groups/Group'), [Scene.Action]: () => import('./actions/Action'), [Scene.Experiments]: () => import('./experiments/Experiments'), + [Scene.ExperimentsSavedMetrics]: () => import('./experiments/SavedMetrics/SavedMetrics'), + [Scene.ExperimentsSavedMetric]: () => import('./experiments/SavedMetrics/SavedMetric'), [Scene.Experiment]: () => import('./experiments/Experiment'), [Scene.FeatureFlags]: () => import('./feature-flags/FeatureFlags'), [Scene.FeatureManagement]: () => import('./feature-flags/FeatureManagement'), diff --git a/frontend/src/scenes/experiments/ExperimentView/CumulativeExposuresChart.tsx b/frontend/src/scenes/experiments/ExperimentView/CumulativeExposuresChart.tsx index 6efdd992f5988..ef0bff8ac948a 100644 --- a/frontend/src/scenes/experiments/ExperimentView/CumulativeExposuresChart.tsx +++ b/frontend/src/scenes/experiments/ExperimentView/CumulativeExposuresChart.tsx @@ -10,10 +10,10 @@ import { BaseMathType, ChartDisplayType, InsightType, PropertyFilterType, Proper import { experimentLogic } from '../experimentLogic' export function CumulativeExposuresChart(): JSX.Element { - const { experiment, metricResults, getMetricType } = useValues(experimentLogic) + const { experiment, metricResults, _getMetricType } = useValues(experimentLogic) const metricIdx = 0 - const metricType = getMetricType(metricIdx) + const metricType = _getMetricType(experiment.metrics[metricIdx]) const result = metricResults?.[metricIdx] const variants = experiment.parameters?.feature_flag_variants?.map((variant) => variant.key) || [] if (experiment.holdout) { diff --git a/frontend/src/scenes/experiments/ExperimentView/DataCollection.tsx b/frontend/src/scenes/experiments/ExperimentView/DataCollection.tsx index b22eb57b35d4b..5d0b7e1389a52 100644 --- a/frontend/src/scenes/experiments/ExperimentView/DataCollection.tsx +++ b/frontend/src/scenes/experiments/ExperimentView/DataCollection.tsx @@ -17,7 +17,7 @@ export function DataCollection(): JSX.Element { const { experimentId, experiment, - getMetricType, + _getMetricType, funnelResultsPersonsTotal, actualRunningTime, minimumDetectableEffect, @@ -25,7 +25,7 @@ export function DataCollection(): JSX.Element { const { openExperimentCollectionGoalModal } = useActions(experimentLogic) - const metricType = getMetricType(0) + const metricType = _getMetricType(experiment.metrics[0]) const recommendedRunningTime = experiment?.parameters?.recommended_running_time || 1 const recommendedSampleSize = experiment?.parameters?.recommended_sample_size || 100 @@ -80,7 +80,7 @@ export function DataCollection(): JSX.Element { {metricType === InsightType.TRENDS && ( @@ -172,7 +172,8 @@ export function DataCollection(): JSX.Element { export function DataCollectionGoalModal({ experimentId }: { experimentId: Experiment['id'] }): JSX.Element { const { isExperimentCollectionGoalModalOpen, - getMetricType, + experiment, + _getMetricType, trendMetricInsightLoading, funnelMetricInsightLoading, } = useValues(experimentLogic({ experimentId })) @@ -181,7 +182,9 @@ export function DataCollectionGoalModal({ experimentId }: { experimentId: Experi ) const isInsightLoading = - getMetricType(0) === InsightType.TRENDS ? trendMetricInsightLoading : funnelMetricInsightLoading + _getMetricType(experiment.metrics[0]) === InsightType.TRENDS + ? trendMetricInsightLoading + : funnelMetricInsightLoading return ( - {getMetricType(0) === InsightType.TRENDS ? ( + {_getMetricType(experiment.metrics[0]) === InsightType.TRENDS ? ( ) : ( diff --git a/frontend/src/scenes/experiments/ExperimentView/ExperimentView.tsx b/frontend/src/scenes/experiments/ExperimentView/ExperimentView.tsx index b8e2ce7a0eab8..96d3dd3ff86e4 100644 --- a/frontend/src/scenes/experiments/ExperimentView/ExperimentView.tsx +++ b/frontend/src/scenes/experiments/ExperimentView/ExperimentView.tsx @@ -7,6 +7,8 @@ import { WebExperimentImplementationDetails } from 'scenes/experiments/WebExperi import { ExperimentImplementationDetails } from '../ExperimentImplementationDetails' import { experimentLogic } from '../experimentLogic' import { MetricModal } from '../Metrics/MetricModal' +import { MetricSourceModal } from '../Metrics/MetricSourceModal' +import { SavedMetricModal } from '../Metrics/SavedMetricModal' import { MetricsView } from '../MetricsView/MetricsView' import { ExperimentLoadingAnimation, @@ -141,9 +143,15 @@ export function ExperimentView(): JSX.Element { /> )} + + + + + + diff --git a/frontend/src/scenes/experiments/ExperimentView/Goal.tsx b/frontend/src/scenes/experiments/ExperimentView/Goal.tsx index 0438835528246..e9bd49756b1de 100644 --- a/frontend/src/scenes/experiments/ExperimentView/Goal.tsx +++ b/frontend/src/scenes/experiments/ExperimentView/Goal.tsx @@ -240,10 +240,10 @@ export function ExposureMetric({ experimentId }: { experimentId: Experiment['id' } export function Goal(): JSX.Element { - const { experiment, experimentId, getMetricType, experimentMathAggregationForTrends, hasGoalSet, featureFlags } = + const { experiment, experimentId, _getMetricType, experimentMathAggregationForTrends, hasGoalSet, featureFlags } = useValues(experimentLogic) const { setExperiment, openPrimaryMetricModal } = useActions(experimentLogic) - const metricType = getMetricType(0) + const metricType = _getMetricType(experiment.metrics[0]) // :FLAG: CLEAN UP AFTER MIGRATION const isDataWarehouseMetric = diff --git a/frontend/src/scenes/experiments/ExperimentView/Results.tsx b/frontend/src/scenes/experiments/ExperimentView/Results.tsx index 61574de1b3966..b0b291554ef90 100644 --- a/frontend/src/scenes/experiments/ExperimentView/Results.tsx +++ b/frontend/src/scenes/experiments/ExperimentView/Results.tsx @@ -5,7 +5,7 @@ import { ResultsHeader, ResultsQuery } from './components' import { SummaryTable } from './SummaryTable' export function Results(): JSX.Element { - const { metricResults } = useValues(experimentLogic) + const { experiment, metricResults } = useValues(experimentLogic) const result = metricResults?.[0] if (!result) { return <> @@ -14,7 +14,7 @@ export function Results(): JSX.Element { return (
- +
) diff --git a/frontend/src/scenes/experiments/ExperimentView/SecondaryMetricsTable.tsx b/frontend/src/scenes/experiments/ExperimentView/SecondaryMetricsTable.tsx index e8d5baf4eed0e..3b1db8847de0c 100644 --- a/frontend/src/scenes/experiments/ExperimentView/SecondaryMetricsTable.tsx +++ b/frontend/src/scenes/experiments/ExperimentView/SecondaryMetricsTable.tsx @@ -22,7 +22,7 @@ export function SecondaryMetricsTable({ experimentId }: { experimentId: Experime metricResults, secondaryMetricResultsLoading, experiment, - getSecondaryMetricType, + _getMetricType, secondaryMetricResults, tabularSecondaryMetricResults, countDataForVariant, @@ -69,7 +69,7 @@ export function SecondaryMetricsTable({ experimentId }: { experimentId: Experime metrics?.forEach((metric, idx) => { const targetResults = secondaryMetricResults?.[idx] const winningVariant = getHighestProbabilityVariant(targetResults || null) - const metricType = getSecondaryMetricType(idx) + const metricType = _getMetricType(metric) const Header = (): JSX.Element => (
diff --git a/frontend/src/scenes/experiments/ExperimentView/SummaryTable.tsx b/frontend/src/scenes/experiments/ExperimentView/SummaryTable.tsx index 14ee1d3cbcf7a..536aaa75aa615 100644 --- a/frontend/src/scenes/experiments/ExperimentView/SummaryTable.tsx +++ b/frontend/src/scenes/experiments/ExperimentView/SummaryTable.tsx @@ -8,6 +8,7 @@ import { humanFriendlyNumber } from 'lib/utils' import posthog from 'posthog-js' import { urls } from 'scenes/urls' +import { ExperimentFunnelsQuery, ExperimentTrendsQuery } from '~/queries/schema' import { FilterLogicalOperator, InsightType, @@ -23,9 +24,11 @@ import { experimentLogic } from '../experimentLogic' import { VariantTag } from './components' export function SummaryTable({ + metric, metricIndex = 0, isSecondary = false, }: { + metric: ExperimentTrendsQuery | ExperimentFunnelsQuery metricIndex?: number isSecondary?: boolean }): JSX.Element { @@ -35,8 +38,7 @@ export function SummaryTable({ metricResults, secondaryMetricResults, tabularExperimentResults, - getMetricType, - getSecondaryMetricType, + _getMetricType, exposureCountDataForVariant, conversionRateForVariant, experimentMathAggregationForTrends, @@ -44,7 +46,7 @@ export function SummaryTable({ getHighestProbabilityVariant, credibleIntervalForVariant, } = useValues(experimentLogic) - const metricType = isSecondary ? getSecondaryMetricType(metricIndex) : getMetricType(metricIndex) + const metricType = _getMetricType(metric) const result = isSecondary ? secondaryMetricResults?.[metricIndex] : metricResults?.[metricIndex] if (!result) { return <> diff --git a/frontend/src/scenes/experiments/ExperimentView/components.tsx b/frontend/src/scenes/experiments/ExperimentView/components.tsx index afa0e77054f99..cd252785b116c 100644 --- a/frontend/src/scenes/experiments/ExperimentView/components.tsx +++ b/frontend/src/scenes/experiments/ExperimentView/components.tsx @@ -69,30 +69,32 @@ export function VariantTag({ if (experiment.holdout && variantKey === `holdout-${experiment.holdout_id}`) { return ( - +
- {experiment.holdout.name} + + {experiment.holdout.name} + ) } return ( - +
@@ -232,10 +234,11 @@ export function ExploreButton({ return ( } to={urls.insightNew(undefined, undefined, query)} + targetBlank > Explore as Insight @@ -659,7 +662,7 @@ export function ShipVariantModal({ experimentId }: { experimentId: Experiment['i export function ActionBanner(): JSX.Element { const { experiment, - getMetricType, + _getMetricType, metricResults, experimentLoading, metricResultsLoading, @@ -678,7 +681,7 @@ export function ActionBanner(): JSX.Element { const { aggregationLabel } = useValues(groupsModel) - const metricType = getMetricType(0) + const metricType = _getMetricType(experiment.metrics[0]) const aggregationTargetName = experiment.filters.aggregation_group_type_index != null diff --git a/frontend/src/scenes/experiments/Experiments.tsx b/frontend/src/scenes/experiments/Experiments.tsx index 26c84171c6a8c..e31d1958000cd 100644 --- a/frontend/src/scenes/experiments/Experiments.tsx +++ b/frontend/src/scenes/experiments/Experiments.tsx @@ -5,6 +5,7 @@ import { ExperimentsHog } from 'lib/components/hedgehogs' import { MemberSelect } from 'lib/components/MemberSelect' import { PageHeader } from 'lib/components/PageHeader' import { ProductIntroduction } from 'lib/components/ProductIntroduction/ProductIntroduction' +import { FEATURE_FLAGS } from 'lib/constants' import { dayjs } from 'lib/dayjs' import { LemonButton } from 'lib/lemon-ui/LemonButton' import { More } from 'lib/lemon-ui/LemonButton/More' @@ -23,6 +24,7 @@ import { Experiment, ExperimentsTabs, ProductKey, ProgressStatus } from '~/types import { experimentsLogic, getExperimentStatus } from './experimentsLogic' import { StatusTag } from './ExperimentView/components' import { Holdouts } from './Holdouts' +import { SavedMetrics } from './SavedMetrics/SavedMetrics' export const scene: SceneExport = { component: Experiments, @@ -30,8 +32,16 @@ export const scene: SceneExport = { } export function Experiments(): JSX.Element { - const { filteredExperiments, experimentsLoading, tab, searchTerm, shouldShowEmptyState, searchStatus, userFilter } = - useValues(experimentsLogic) + const { + filteredExperiments, + experimentsLoading, + tab, + searchTerm, + shouldShowEmptyState, + searchStatus, + userFilter, + featureFlags, + } = useValues(experimentsLogic) const { setExperimentsTab, deleteExperiment, archiveExperiment, setSearchStatus, setSearchTerm, setUserFilter } = useActions(experimentsLogic) @@ -211,11 +221,16 @@ export function Experiments(): JSX.Element { { key: ExperimentsTabs.Yours, label: 'Your experiments' }, { key: ExperimentsTabs.Archived, label: 'Archived experiments' }, { key: ExperimentsTabs.Holdouts, label: 'Holdout groups' }, + ...(featureFlags[FEATURE_FLAGS.EXPERIMENTS_MULTIPLE_METRICS] + ? [{ key: ExperimentsTabs.SavedMetrics, label: 'Shared metrics' }] + : []), ]} /> {tab === ExperimentsTabs.Holdouts ? ( + ) : tab === ExperimentsTabs.SavedMetrics && featureFlags[FEATURE_FLAGS.EXPERIMENTS_MULTIPLE_METRICS] ? ( + ) : ( <> {tab === ExperimentsTabs.Archived ? ( diff --git a/frontend/src/scenes/experiments/Metrics/MetricModal.tsx b/frontend/src/scenes/experiments/Metrics/MetricModal.tsx index dde6c2e1b6d00..889d68ae4d31f 100644 --- a/frontend/src/scenes/experiments/Metrics/MetricModal.tsx +++ b/frontend/src/scenes/experiments/Metrics/MetricModal.tsx @@ -1,4 +1,4 @@ -import { LemonButton, LemonModal, LemonSelect } from '@posthog/lemon-ui' +import { LemonButton, LemonDialog, LemonModal, LemonSelect } from '@posthog/lemon-ui' import { useActions, useValues } from 'kea' import { ExperimentFunnelsQuery } from '~/queries/schema' @@ -18,8 +18,7 @@ export function MetricModal({ const { experiment, experimentLoading, - getMetricType, - getSecondaryMetricType, + _getMetricType, isPrimaryMetricModalOpen, isSecondaryMetricModalOpen, editingPrimaryMetricIndex, @@ -36,9 +35,9 @@ export function MetricModal({ return <> } - const metricType = isSecondary ? getSecondaryMetricType(metricIdx) : getMetricType(metricIdx) const metrics = experiment[metricsField] const metric = metrics[metricIdx] + const metricType = _getMetricType(metric) const funnelStepsLength = (metric as ExperimentFunnelsQuery)?.funnels_query?.series?.length || 0 return ( @@ -53,11 +52,27 @@ export function MetricModal({ type="secondary" status="danger" onClick={() => { - const newMetrics = metrics.filter((_, idx) => idx !== metricIdx) - setExperiment({ - [metricsField]: newMetrics, + LemonDialog.open({ + title: 'Delete this metric?', + content:
This action cannot be undone.
, + primaryButton: { + children: 'Delete', + type: 'primary', + onClick: () => { + const newMetrics = metrics.filter((_, idx) => idx !== metricIdx) + setExperiment({ + [metricsField]: newMetrics, + }) + updateExperimentGoal() + }, + size: 'small', + }, + secondaryButton: { + children: 'Cancel', + type: 'tertiary', + size: 'small', + }, }) - updateExperimentGoal() }} > Delete diff --git a/frontend/src/scenes/experiments/Metrics/MetricSourceModal.tsx b/frontend/src/scenes/experiments/Metrics/MetricSourceModal.tsx new file mode 100644 index 0000000000000..bd2134359d9f8 --- /dev/null +++ b/frontend/src/scenes/experiments/Metrics/MetricSourceModal.tsx @@ -0,0 +1,73 @@ +import { LemonModal } from '@posthog/lemon-ui' +import { useActions, useValues } from 'kea' + +import { Experiment } from '~/types' + +import { experimentLogic, getDefaultFunnelsMetric } from '../experimentLogic' + +export function MetricSourceModal({ + experimentId, + isSecondary, +}: { + experimentId: Experiment['id'] + isSecondary?: boolean +}): JSX.Element { + const { experiment, isPrimaryMetricSourceModalOpen, isSecondaryMetricSourceModalOpen } = useValues( + experimentLogic({ experimentId }) + ) + const { + setExperiment, + closePrimaryMetricSourceModal, + closeSecondaryMetricSourceModal, + openPrimaryMetricModal, + openPrimarySavedMetricModal, + openSecondaryMetricModal, + openSecondarySavedMetricModal, + } = useActions(experimentLogic({ experimentId })) + + const metricsField = isSecondary ? 'metrics_secondary' : 'metrics' + const isOpen = isSecondary ? isSecondaryMetricSourceModalOpen : isPrimaryMetricSourceModalOpen + const closeCurrentModal = isSecondary ? closeSecondaryMetricSourceModal : closePrimaryMetricSourceModal + const openMetricModal = isSecondary ? openSecondaryMetricModal : openPrimaryMetricModal + const openSavedMetricModal = isSecondary ? openSecondarySavedMetricModal : openPrimarySavedMetricModal + + return ( + +
+
{ + closeCurrentModal() + + const newMetrics = [...experiment[metricsField], getDefaultFunnelsMetric()] + setExperiment({ + [metricsField]: newMetrics, + }) + openMetricModal(newMetrics.length - 1) + }} + > +
+ Custom +
+
+ Create a new metric specific to this experiment. +
+
+
{ + closeCurrentModal() + openSavedMetricModal(null) + }} + > +
+ Shared +
+
+ Use a pre-configured metric that can be reused across experiments. +
+
+
+
+ ) +} diff --git a/frontend/src/scenes/experiments/Metrics/SavedMetricModal.tsx b/frontend/src/scenes/experiments/Metrics/SavedMetricModal.tsx new file mode 100644 index 0000000000000..3f6cfbc01f2c9 --- /dev/null +++ b/frontend/src/scenes/experiments/Metrics/SavedMetricModal.tsx @@ -0,0 +1,143 @@ +import { LemonButton, LemonModal, LemonSelect, Link } from '@posthog/lemon-ui' +import { useActions, useValues } from 'kea' +import { IconOpenInNew } from 'lib/lemon-ui/icons' +import { useEffect, useState } from 'react' +import { urls } from 'scenes/urls' + +import { Experiment } from '~/types' + +import { experimentLogic } from '../experimentLogic' +import { MetricDisplayFunnels, MetricDisplayTrends } from '../ExperimentView/Goal' +import { SavedMetric } from '../SavedMetrics/savedMetricLogic' + +export function SavedMetricModal({ + experimentId, + isSecondary, +}: { + experimentId: Experiment['id'] + isSecondary?: boolean +}): JSX.Element { + const { savedMetrics, isPrimarySavedMetricModalOpen, isSecondarySavedMetricModalOpen, editingSavedMetricId } = + useValues(experimentLogic({ experimentId })) + const { + closePrimarySavedMetricModal, + closeSecondarySavedMetricModal, + addSavedMetricToExperiment, + removeSavedMetricFromExperiment, + } = useActions(experimentLogic({ experimentId })) + + const [selectedMetricId, setSelectedMetricId] = useState(null) + const [mode, setMode] = useState<'create' | 'edit'>('create') + + useEffect(() => { + if (editingSavedMetricId) { + setSelectedMetricId(editingSavedMetricId) + setMode('edit') + } + }, [editingSavedMetricId]) + + if (!savedMetrics) { + return <> + } + + const isOpen = isSecondary ? isSecondarySavedMetricModalOpen : isPrimarySavedMetricModalOpen + const closeModal = isSecondary ? closeSecondarySavedMetricModal : closePrimarySavedMetricModal + + return ( + +
+ {editingSavedMetricId && ( + { + removeSavedMetricFromExperiment(editingSavedMetricId) + }} + type="secondary" + > + Remove from experiment + + )} +
+
+ + Cancel + + {/* Changing the existing metric is a pain because saved metrics are stored separately */} + {/* Only allow deletion for now */} + {mode === 'create' && ( + { + if (selectedMetricId) { + addSavedMetricToExperiment(selectedMetricId, { + type: isSecondary ? 'secondary' : 'primary', + }) + } + }} + type="primary" + disabledReason={!selectedMetricId ? 'Please select a metric' : undefined} + > + Add metric + + )} +
+
+ } + > + {mode === 'create' && ( +
+ ({ + label: metric.name, + value: metric.id, + }))} + placeholder="Select a saved metric" + loading={false} + value={selectedMetricId} + onSelect={(value) => { + setSelectedMetricId(value) + }} + /> +
+ )} + + {selectedMetricId && ( +
+ {(() => { + const metric = savedMetrics.find((m: SavedMetric) => m.id === selectedMetricId) + if (!metric) { + return <> + } + + return ( +
+
+

{metric.name}

+ + + +
+ {metric.description &&

{metric.description}

} + {metric.query.kind === 'ExperimentTrendsQuery' && ( + + )} + {metric.query.kind === 'ExperimentFunnelsQuery' && ( + + )} +
+ ) + })()} +
+ )} + + ) +} diff --git a/frontend/src/scenes/experiments/MetricsView/DeltaChart.tsx b/frontend/src/scenes/experiments/MetricsView/DeltaChart.tsx index 2f7455288b68f..820b7b38acfd3 100644 --- a/frontend/src/scenes/experiments/MetricsView/DeltaChart.tsx +++ b/frontend/src/scenes/experiments/MetricsView/DeltaChart.tsx @@ -1,6 +1,7 @@ import { IconActivity, IconGraph, IconMinus, IconPencil, IconTrending } from '@posthog/icons' import { LemonBanner, LemonButton, LemonModal, LemonTag, LemonTagType, Tooltip } from '@posthog/lemon-ui' import { useActions, useValues } from 'kea' +import { LemonProgress } from 'lib/lemon-ui/LemonProgress' import { humanFriendlyNumber } from 'lib/utils' import { useEffect, useRef, useState } from 'react' @@ -68,7 +69,12 @@ export function DeltaChart({ } = useValues(experimentLogic) const { experiment } = useValues(experimentLogic) - const { openPrimaryMetricModal, openSecondaryMetricModal } = useActions(experimentLogic) + const { + openPrimaryMetricModal, + openSecondaryMetricModal, + openPrimarySavedMetricModal, + openSecondarySavedMetricModal, + } = useActions(experimentLogic) const [tooltipData, setTooltipData] = useState<{ x: number; y: number; variant: string } | null>(null) const [emptyStateTooltipVisible, setEmptyStateTooltipVisible] = useState(true) const [tooltipPosition, setTooltipPosition] = useState({ x: 0, y: 0 }) @@ -100,8 +106,8 @@ export function DeltaChart({ TICK_TEXT_COLOR: 'var(--text-secondary-3000)', BOUNDARY_LINES: 'var(--border-3000)', ZERO_LINE: 'var(--border-bold)', - BAR_NEGATIVE: isDarkModeOn ? 'rgb(206 66 54)' : '#F44435', - BAR_BEST: isDarkModeOn ? 'rgb(49 145 51)' : '#4DAF4F', + BAR_NEGATIVE: isDarkModeOn ? '#c32f45' : '#f84257', + BAR_POSITIVE: isDarkModeOn ? '#12a461' : '#36cd6f', BAR_DEFAULT: isDarkModeOn ? 'rgb(121 121 121)' : 'rgb(217 217 217)', BAR_CONTROL: isDarkModeOn ? 'rgba(217, 217, 217, 0.2)' : 'rgba(217, 217, 217, 0.4)', BAR_MIDDLE_POINT: 'black', @@ -186,21 +192,76 @@ export function DeltaChart({ type="secondary" size="xsmall" icon={} - onClick={() => + onClick={() => { + if (metric.isSavedMetric) { + if (isSecondary) { + openSecondarySavedMetricModal(metric.savedMetricId) + } else { + openPrimarySavedMetricModal(metric.savedMetricId) + } + return + } isSecondary ? openSecondaryMetricModal(metricIndex) : openPrimaryMetricModal(metricIndex) - } + }} />
- - {metric.kind === 'ExperimentFunnelsQuery' ? 'Funnel' : 'Trend'} - +
+ + {metric.kind === 'ExperimentFunnelsQuery' ? 'Funnel' : 'Trend'} + + {metric.isSavedMetric && ( + + Shared + + )} +
- + {/* Detailed results panel */} +
+ {isFirstMetric && ( + + )} + {isFirstMetric &&
} + {result && ( +
+ +
+ } + onClick={() => setIsModalOpen(true)} + > + Detailed results + +
+
+ )} +
{/* Variants panel */} {/* eslint-disable-next-line react/forbid-dom-props */}
@@ -222,9 +283,23 @@ export function DeltaChart({ display: 'flex', alignItems: 'center', paddingLeft: '10px', + position: 'relative', + minWidth: 0, + overflow: 'hidden', }} > - +
+
+ +
))}
@@ -385,7 +460,7 @@ export function DeltaChart({ H ${valueToX(0)} V ${y} `} - fill={COLORS.BAR_BEST} + fill={COLORS.BAR_POSITIVE} /> ) : ( @@ -395,7 +470,7 @@ export function DeltaChart({ y={y} width={x2 - x1} height={BAR_HEIGHT} - fill={upper <= 0 ? COLORS.BAR_NEGATIVE : COLORS.BAR_BEST} + fill={upper <= 0 ? COLORS.BAR_NEGATIVE : COLORS.BAR_POSITIVE} rx={4} ry={4} /> @@ -530,6 +605,22 @@ export function DeltaChart({ >
+
+ Win probability: + {result?.probability?.[tooltipData.variant] !== undefined ? ( + + + + {(result.probability[tooltipData.variant] * 100).toFixed(2)}% + + + ) : ( + '—' + )} +
{metricType === InsightType.TRENDS ? ( <>
@@ -675,46 +766,6 @@ export function DeltaChart({
)}
- {/* Detailed results panel */} -
- {isFirstMetric && ( - - )} - {isFirstMetric &&
} - {result && ( -
- -
- } - onClick={() => setIsModalOpen(true)} - > - Detailed results - -
-
- )} -
- +
@@ -778,7 +829,7 @@ function SignificanceHighlight({ return details ? ( -
{inner}
+
{inner}
) : (
{inner}
diff --git a/frontend/src/scenes/experiments/MetricsView/MetricsView.tsx b/frontend/src/scenes/experiments/MetricsView/MetricsView.tsx index 2628f353942f5..9a362d9961974 100644 --- a/frontend/src/scenes/experiments/MetricsView/MetricsView.tsx +++ b/frontend/src/scenes/experiments/MetricsView/MetricsView.tsx @@ -1,9 +1,9 @@ -import { IconPlus } from '@posthog/icons' -import { LemonButton } from '@posthog/lemon-ui' +import { IconInfo, IconPlus } from '@posthog/icons' +import { LemonButton, Tooltip } from '@posthog/lemon-ui' import { useActions, useValues } from 'kea' import { IconAreaChart } from 'lib/lemon-ui/icons' -import { experimentLogic, getDefaultFunnelsMetric } from '../experimentLogic' +import { experimentLogic } from '../experimentLogic' import { MAX_PRIMARY_METRICS, MAX_SECONDARY_METRICS } from './const' import { DeltaChart } from './DeltaChart' @@ -44,7 +44,7 @@ export function getNiceTickValues(maxAbsValue: number): number[] { function AddPrimaryMetric(): JSX.Element { const { experiment } = useValues(experimentLogic) - const { setExperiment, openPrimaryMetricModal } = useActions(experimentLogic) + const { openPrimaryMetricSourceModal } = useActions(experimentLogic) return ( { - const newMetrics = [...experiment.metrics, getDefaultFunnelsMetric()] - setExperiment({ - metrics: newMetrics, - }) - openPrimaryMetricModal(newMetrics.length - 1) + openPrimaryMetricSourceModal() }} disabledReason={ experiment.metrics.length >= MAX_PRIMARY_METRICS @@ -71,18 +67,14 @@ function AddPrimaryMetric(): JSX.Element { export function AddSecondaryMetric(): JSX.Element { const { experiment } = useValues(experimentLogic) - const { setExperiment, openSecondaryMetricModal } = useActions(experimentLogic) + const { openSecondaryMetricSourceModal } = useActions(experimentLogic) return ( } type="secondary" size="xsmall" onClick={() => { - const newMetricsSecondary = [...experiment.metrics_secondary, getDefaultFunnelsMetric()] - setExperiment({ - metrics_secondary: newMetricsSecondary, - }) - openSecondaryMetricModal(newMetricsSecondary.length - 1) + openSecondaryMetricSourceModal() }} disabledReason={ experiment.metrics_secondary.length >= MAX_SECONDARY_METRICS @@ -98,8 +90,7 @@ export function AddSecondaryMetric(): JSX.Element { export function MetricsView({ isSecondary }: { isSecondary?: boolean }): JSX.Element { const { experiment, - getMetricType, - getSecondaryMetricType, + _getMetricType, metricResults, secondaryMetricResults, primaryMetricsResultErrors, @@ -108,19 +99,32 @@ export function MetricsView({ isSecondary }: { isSecondary?: boolean }): JSX.Ele } = useValues(experimentLogic) const variants = experiment.parameters.feature_flag_variants - const metrics = isSecondary ? experiment.metrics_secondary : experiment.metrics const results = isSecondary ? secondaryMetricResults : metricResults const errors = isSecondary ? secondaryMetricsResultErrors : primaryMetricsResultErrors + let metrics = isSecondary ? experiment.metrics_secondary : experiment.metrics + const savedMetrics = experiment.saved_metrics + .filter((savedMetric) => savedMetric.metadata.type === (isSecondary ? 'secondary' : 'primary')) + .map((savedMetric) => ({ + ...savedMetric.query, + name: savedMetric.name, + savedMetricId: savedMetric.saved_metric, + isSavedMetric: true, + })) + + if (savedMetrics) { + metrics = [...metrics, ...savedMetrics] + } + // Calculate the maximum absolute value across ALL metrics const maxAbsValue = Math.max( - ...metrics.flatMap((_, metricIndex) => { + ...metrics.flatMap((metric, metricIndex) => { const result = results?.[metricIndex] if (!result) { return [] } return variants.flatMap((variant) => { - const metricType = isSecondary ? getSecondaryMetricType(metricIndex) : getMetricType(metricIndex) + const metricType = _getMetricType(metric) const interval = credibleIntervalForVariant(result, variant.key, metricType) return interval ? [Math.abs(interval[0] / 100), Math.abs(interval[1] / 100)] : [] }) @@ -136,10 +140,21 @@ export function MetricsView({ isSecondary }: { isSecondary?: boolean }): JSX.Ele
-
-

+
+

{isSecondary ? 'Secondary metrics' : 'Primary metrics'}

+ {metrics.length > 0 && ( + + + + )}

@@ -178,11 +193,7 @@ export function MetricsView({ isSecondary }: { isSecondary?: boolean }): JSX.Ele result={result} error={errors?.[metricIndex]} variants={variants} - metricType={ - isSecondary - ? getSecondaryMetricType(metricIndex) - : getMetricType(metricIndex) - } + metricType={_getMetricType(metric)} metricIndex={metricIndex} isFirstMetric={isFirstMetric} metric={metric} diff --git a/frontend/src/scenes/experiments/SavedMetrics/SavedFunnelsMetricForm.tsx b/frontend/src/scenes/experiments/SavedMetrics/SavedFunnelsMetricForm.tsx new file mode 100644 index 0000000000000..6a69c279f7286 --- /dev/null +++ b/frontend/src/scenes/experiments/SavedMetrics/SavedFunnelsMetricForm.tsx @@ -0,0 +1,203 @@ +import { LemonBanner } from '@posthog/lemon-ui' +import { useActions, useValues } from 'kea' +import { TaxonomicFilterGroupType } from 'lib/components/TaxonomicFilter/types' +import { TestAccountFilterSwitch } from 'lib/components/TestAccountFiltersSwitch' +import { EXPERIMENT_DEFAULT_DURATION } from 'lib/constants' +import { ActionFilter } from 'scenes/insights/filters/ActionFilter/ActionFilter' +import { MathAvailability } from 'scenes/insights/filters/ActionFilter/ActionFilterRow/ActionFilterRow' +import { getHogQLValue } from 'scenes/insights/filters/AggregationSelect' +import { teamLogic } from 'scenes/teamLogic' + +import { actionsAndEventsToSeries } from '~/queries/nodes/InsightQuery/utils/filtersToQueryNode' +import { queryNodeToFilter } from '~/queries/nodes/InsightQuery/utils/queryNodeToFilter' +import { Query } from '~/queries/Query/Query' +import { ExperimentFunnelsQuery, NodeKind } from '~/queries/schema' +import { BreakdownAttributionType, FilterType } from '~/types' + +import { + commonActionFilterProps, + FunnelAggregationSelect, + FunnelAttributionSelect, + FunnelConversionWindowFilter, +} from '../Metrics/Selectors' +import { savedMetricLogic } from './savedMetricLogic' + +export function SavedFunnelsMetricForm(): JSX.Element { + const { savedMetric } = useValues(savedMetricLogic) + const { setSavedMetric } = useActions(savedMetricLogic) + + const { currentTeam } = useValues(teamLogic) + const hasFilters = (currentTeam?.test_account_filters || []).length > 0 + + const actionFilterProps = { + ...commonActionFilterProps, + actionsTaxonomicGroupTypes: [TaxonomicFilterGroupType.Events, TaxonomicFilterGroupType.Actions], + } + + if (!savedMetric?.query) { + return <> + } + + const savedMetricQuery = savedMetric.query as ExperimentFunnelsQuery + + return ( + <> + ): void => { + if (!savedMetric?.query) { + return + } + + const series = actionsAndEventsToSeries( + { actions, events, data_warehouse } as any, + true, + MathAvailability.None + ) + setSavedMetric({ + query: { + ...savedMetricQuery, + funnels_query: { + ...savedMetricQuery.funnels_query, + series, + }, + }, + }) + }} + typeKey="experiment-metric" + mathAvailability={MathAvailability.None} + buttonCopy="Add funnel step" + showSeriesIndicator={true} + seriesIndicatorType="numeric" + sortable={true} + showNestedArrow={true} + {...actionFilterProps} + /> +
+ { + setSavedMetric({ + query: { + ...savedMetricQuery, + funnels_query: { + ...savedMetricQuery.funnels_query, + aggregation_group_type_index: value, + }, + }, + }) + }} + /> + { + setSavedMetric({ + query: { + ...savedMetricQuery, + funnels_query: { + ...savedMetricQuery.funnels_query, + // funnelWindowInterval: funnelWindowInterval, + funnelsFilter: { + ...savedMetricQuery.funnels_query.funnelsFilter, + funnelWindowInterval: funnelWindowInterval, + }, + }, + }, + }) + }} + onFunnelWindowIntervalUnitChange={(funnelWindowIntervalUnit) => { + setSavedMetric({ + query: { + ...savedMetricQuery, + funnels_query: { + ...savedMetricQuery.funnels_query, + funnelsFilter: { + ...savedMetricQuery.funnels_query.funnelsFilter, + funnelWindowIntervalUnit: funnelWindowIntervalUnit || undefined, + }, + }, + }, + }) + }} + /> + { + const breakdownAttributionType = + savedMetricQuery.funnels_query?.funnelsFilter?.breakdownAttributionType + const breakdownAttributionValue = + savedMetricQuery.funnels_query?.funnelsFilter?.breakdownAttributionValue + + const currentValue: BreakdownAttributionType | `${BreakdownAttributionType.Step}/${number}` = + !breakdownAttributionType + ? BreakdownAttributionType.FirstTouch + : breakdownAttributionType === BreakdownAttributionType.Step + ? `${breakdownAttributionType}/${breakdownAttributionValue || 0}` + : breakdownAttributionType + + return currentValue + })()} + onChange={(value) => { + const [breakdownAttributionType, breakdownAttributionValue] = (value || '').split('/') + setSavedMetric({ + query: { + ...savedMetricQuery, + funnels_query: { + ...savedMetricQuery.funnels_query, + funnelsFilter: { + ...savedMetricQuery.funnels_query.funnelsFilter, + breakdownAttributionType: breakdownAttributionType as BreakdownAttributionType, + breakdownAttributionValue: breakdownAttributionValue + ? parseInt(breakdownAttributionValue) + : undefined, + }, + }, + }, + }) + }} + stepsLength={savedMetricQuery.funnels_query?.series?.length} + /> + { + const val = savedMetricQuery.funnels_query?.filterTestAccounts + return hasFilters ? !!val : false + })()} + onChange={(checked: boolean) => { + setSavedMetric({ + query: { + ...savedMetricQuery, + funnels_query: { + ...savedMetricQuery.funnels_query, + filterTestAccounts: checked, + }, + }, + }) + }} + fullWidth + /> +
+ + + Preview insights are generated based on {EXPERIMENT_DEFAULT_DURATION} days of data. This can cause a + mismatch between the preview and the actual results. + + +
+ +
+ + ) +} diff --git a/frontend/src/scenes/experiments/SavedMetrics/SavedMetric.tsx b/frontend/src/scenes/experiments/SavedMetrics/SavedMetric.tsx new file mode 100644 index 0000000000000..c56d9a94c616c --- /dev/null +++ b/frontend/src/scenes/experiments/SavedMetrics/SavedMetric.tsx @@ -0,0 +1,154 @@ +import { IconCheckCircle } from '@posthog/icons' +import { LemonButton, LemonDialog, LemonInput, LemonLabel, Spinner } from '@posthog/lemon-ui' +import { useActions, useValues } from 'kea' +import { SceneExport } from 'scenes/sceneTypes' + +import { themeLogic } from '~/layout/navigation-3000/themeLogic' +import { NodeKind } from '~/queries/schema' + +import { getDefaultFunnelsMetric, getDefaultTrendsMetric } from '../experimentLogic' +import { SavedFunnelsMetricForm } from './SavedFunnelsMetricForm' +import { savedMetricLogic } from './savedMetricLogic' +import { SavedTrendsMetricForm } from './SavedTrendsMetricForm' + +export const scene: SceneExport = { + component: SavedMetric, + logic: savedMetricLogic, + paramsToProps: ({ params: { id } }) => ({ + savedMetricId: id === 'new' ? 'new' : parseInt(id), + }), +} + +export function SavedMetric(): JSX.Element { + const { savedMetricId, savedMetric } = useValues(savedMetricLogic) + const { setSavedMetric, createSavedMetric, updateSavedMetric, deleteSavedMetric } = useActions(savedMetricLogic) + const { isDarkModeOn } = useValues(themeLogic) + + if (!savedMetric || !savedMetric.query) { + return ( +
+ +
+ ) + } + + return ( +
+
+
{ + setSavedMetric({ + query: getDefaultTrendsMetric(), + }) + }} + > +
+ Trend + {savedMetric.query.kind === NodeKind.ExperimentTrendsQuery && ( + + )} +
+
+ Track a single event, action or a property value. +
+
+
{ + setSavedMetric({ + query: getDefaultFunnelsMetric(), + }) + }} + > +
+ Funnel + {savedMetric.query.kind === NodeKind.ExperimentFunnelsQuery && ( + + )} +
+
+ Analyze conversion rates between sequential steps. +
+
+
+
+
+ Name + { + setSavedMetric({ + name: newName, + }) + }} + /> +
+
+ Description (optional) + { + setSavedMetric({ + description: newDescription, + }) + }} + /> +
+ {savedMetric.query.kind === NodeKind.ExperimentTrendsQuery ? ( + + ) : ( + + )} +
+
+ { + LemonDialog.open({ + title: 'Delete this metric?', + content:
This action cannot be undone.
, + primaryButton: { + children: 'Delete', + type: 'primary', + onClick: () => deleteSavedMetric(), + size: 'small', + }, + secondaryButton: { + children: 'Cancel', + type: 'tertiary', + size: 'small', + }, + }) + }} + > + Delete +
+ { + if (savedMetricId === 'new') { + createSavedMetric() + } else { + updateSavedMetric() + } + }} + > + Save + +
+
+ ) +} diff --git a/frontend/src/scenes/experiments/SavedMetrics/SavedMetrics.tsx b/frontend/src/scenes/experiments/SavedMetrics/SavedMetrics.tsx new file mode 100644 index 0000000000000..c75588b77688e --- /dev/null +++ b/frontend/src/scenes/experiments/SavedMetrics/SavedMetrics.tsx @@ -0,0 +1,84 @@ +import { IconArrowLeft, IconPencil } from '@posthog/icons' +import { LemonBanner, LemonButton, LemonTable, LemonTableColumn, LemonTableColumns } from '@posthog/lemon-ui' +import { useValues } from 'kea' +import { router } from 'kea-router' +import { createdByColumn } from 'lib/lemon-ui/LemonTable/columnUtils' +import { createdAtColumn } from 'lib/lemon-ui/LemonTable/columnUtils' +import { SceneExport } from 'scenes/sceneTypes' +import { urls } from 'scenes/urls' + +import { SavedMetric } from './savedMetricLogic' +import { savedMetricsLogic } from './savedMetricsLogic' + +export const scene: SceneExport = { + component: SavedMetrics, + logic: savedMetricsLogic, +} + +const columns: LemonTableColumns = [ + { + key: 'name', + title: 'Name', + render: (_, savedMetric) => { + return
{savedMetric.name}
+ }, + }, + { + key: 'description', + title: 'Description', + dataIndex: 'description', + }, + createdByColumn() as LemonTableColumn, + createdAtColumn() as LemonTableColumn, + { + key: 'actions', + title: 'Actions', + render: (_, savedMetric) => { + return ( + } + onClick={() => { + router.actions.push(urls.experimentsSavedMetric(savedMetric.id)) + }} + /> + ) + }, + }, +] + +export function SavedMetrics(): JSX.Element { + const { savedMetrics, savedMetricsLoading } = useValues(savedMetricsLogic) + + return ( +
+ } + size="small" + > + Back to experiments + + + Saved metrics let you create reusable metrics that you can quickly add to any experiment. They are ideal + for tracking key metrics like conversion rates or revenue across different experiments without having to + set them up each time. + +
+ + New saved metric + +
+ You haven't created any saved metrics yet.
} + /> +
+ ) +} diff --git a/frontend/src/scenes/experiments/SavedMetrics/SavedTrendsMetricForm.tsx b/frontend/src/scenes/experiments/SavedMetrics/SavedTrendsMetricForm.tsx new file mode 100644 index 0000000000000..7b8068f945bbd --- /dev/null +++ b/frontend/src/scenes/experiments/SavedMetrics/SavedTrendsMetricForm.tsx @@ -0,0 +1,275 @@ +import { IconCheckCircle } from '@posthog/icons' +import { LemonBanner, LemonTabs, LemonTag } from '@posthog/lemon-ui' +import { useActions, useValues } from 'kea' +import { TestAccountFilterSwitch } from 'lib/components/TestAccountFiltersSwitch' +import { EXPERIMENT_DEFAULT_DURATION } from 'lib/constants' +import { dayjs } from 'lib/dayjs' +import { useState } from 'react' +import { ActionFilter } from 'scenes/insights/filters/ActionFilter/ActionFilter' +import { MathAvailability } from 'scenes/insights/filters/ActionFilter/ActionFilterRow/ActionFilterRow' +import { teamLogic } from 'scenes/teamLogic' + +import { actionsAndEventsToSeries } from '~/queries/nodes/InsightQuery/utils/filtersToQueryNode' +import { queryNodeToFilter } from '~/queries/nodes/InsightQuery/utils/queryNodeToFilter' +import { Query } from '~/queries/Query/Query' +import { ExperimentTrendsQuery, InsightQueryNode, NodeKind } from '~/queries/schema' +import { BaseMathType, ChartDisplayType, FilterType, PropertyMathType } from '~/types' + +import { commonActionFilterProps } from '../Metrics/Selectors' +import { savedMetricLogic } from './savedMetricLogic' + +export function SavedTrendsMetricForm(): JSX.Element { + const { savedMetric } = useValues(savedMetricLogic) + const { setSavedMetric } = useActions(savedMetricLogic) + const { currentTeam } = useValues(teamLogic) + const hasFilters = (currentTeam?.test_account_filters || []).length > 0 + const [activeTab, setActiveTab] = useState('main') + + if (!savedMetric?.query) { + return <> + } + + const savedMetricQuery = savedMetric.query as ExperimentTrendsQuery + + return ( + <> + setActiveTab(newKey)} + tabs={[ + { + key: 'main', + label: 'Main metric', + content: ( + <> + ): void => { + const series = actionsAndEventsToSeries( + { actions, events, data_warehouse } as any, + true, + MathAvailability.All + ) + setSavedMetric({ + query: { + ...savedMetricQuery, + count_query: { + ...savedMetricQuery.count_query, + series, + }, + }, + }) + }} + typeKey="experiment-metric" + buttonCopy="Add graph series" + showSeriesIndicator={true} + entitiesLimit={1} + showNumericalPropsOnly={true} + onlyPropertyMathDefinitions={[PropertyMathType.Average]} + {...commonActionFilterProps} + /> +
+ { + const val = savedMetricQuery.count_query?.filterTestAccounts + return hasFilters ? !!val : false + })()} + onChange={(checked: boolean) => { + setSavedMetric({ + query: { + ...savedMetricQuery, + count_query: { + ...savedMetricQuery.count_query, + filterTestAccounts: checked, + }, + }, + }) + }} + fullWidth + /> +
+ + Preview insights are generated based on {EXPERIMENT_DEFAULT_DURATION} days of data. + This can cause a mismatch between the preview and the actual results. + +
+ +
+ + ), + }, + { + key: 'exposure', + label: 'Exposure', + content: ( + <> +
+
{ + setSavedMetric({ + query: { + ...savedMetricQuery, + exposure_query: undefined, + }, + }) + }} + > +
+ Default + {!savedMetricQuery.exposure_query && ( + + )} +
+
+ Uses the number of unique users who trigger the{' '} + $feature_flag_called event as your exposure count. This + is the recommended setting for most experiments, as it accurately tracks + variant exposure. +
+
+
{ + setSavedMetric({ + query: { + ...savedMetricQuery, + exposure_query: { + kind: NodeKind.TrendsQuery, + series: [ + { + kind: NodeKind.EventsNode, + name: '$feature_flag_called', + event: '$feature_flag_called', + math: BaseMathType.UniqueUsers, + }, + ], + interval: 'day', + dateRange: { + date_from: dayjs() + .subtract(EXPERIMENT_DEFAULT_DURATION, 'day') + .format('YYYY-MM-DDTHH:mm'), + date_to: dayjs().endOf('d').format('YYYY-MM-DDTHH:mm'), + explicitDate: true, + }, + trendsFilter: { + display: ChartDisplayType.ActionsLineGraph, + }, + filterTestAccounts: true, + }, + }, + }) + }} + > +
+ Custom + {savedMetricQuery.exposure_query && ( + + )} +
+
+ Define your own exposure metric for specific use cases, such as counting by + sessions instead of users. This gives you full control but requires careful + configuration. +
+
+
+ {savedMetricQuery.exposure_query && ( + <> + ): void => { + const series = actionsAndEventsToSeries( + { actions, events, data_warehouse } as any, + true, + MathAvailability.All + ) + setSavedMetric({ + query: { + ...savedMetricQuery, + exposure_query: { + ...savedMetricQuery.exposure_query, + series, + }, + }, + }) + }} + typeKey="experiment-metric" + buttonCopy="Add graph series" + showSeriesIndicator={true} + entitiesLimit={1} + showNumericalPropsOnly={true} + {...commonActionFilterProps} + /> +
+ { + const val = savedMetricQuery.exposure_query?.filterTestAccounts + return hasFilters ? !!val : false + })()} + onChange={(checked: boolean) => { + setSavedMetric({ + query: { + ...savedMetricQuery, + exposure_query: { + ...savedMetricQuery.exposure_query, + filterTestAccounts: checked, + }, + }, + }) + }} + fullWidth + /> +
+ + Preview insights are generated based on {EXPERIMENT_DEFAULT_DURATION} days + of data. This can cause a mismatch between the preview and the actual + results. + +
+ +
+ + )} + + ), + }, + ]} + /> + + ) +} diff --git a/frontend/src/scenes/experiments/SavedMetrics/savedMetricLogic.tsx b/frontend/src/scenes/experiments/SavedMetrics/savedMetricLogic.tsx new file mode 100644 index 0000000000000..38648d7e7ca89 --- /dev/null +++ b/frontend/src/scenes/experiments/SavedMetrics/savedMetricLogic.tsx @@ -0,0 +1,127 @@ +import { lemonToast } from '@posthog/lemon-ui' +import { actions, connect, kea, key, listeners, path, props, reducers, selectors } from 'kea' +import { loaders } from 'kea-loaders' +import { router, urlToAction } from 'kea-router' +import api from 'lib/api' + +import { UserBasicType } from '~/types' + +import { getDefaultTrendsMetric } from '../experimentLogic' +import type { savedMetricLogicType } from './savedMetricLogicType' +import { savedMetricsLogic } from './savedMetricsLogic' + +export interface SavedMetricLogicProps { + savedMetricId?: string | number +} + +export interface SavedMetric { + id: number + name: string + description?: string + query: Record + created_by: UserBasicType | null + created_at: string | null + updated_at: string | null +} + +export const NEW_SAVED_METRIC: Partial = { + name: '', + description: '', + query: getDefaultTrendsMetric(), +} + +export const savedMetricLogic = kea([ + props({} as SavedMetricLogicProps), + path((key) => ['scenes', 'experiments', 'savedMetricLogic', key]), + key((props) => props.savedMetricId || 'new'), + connect(() => ({ + actions: [savedMetricsLogic, ['loadSavedMetrics']], + })), + actions({ + setSavedMetric: (metric: Partial) => ({ metric }), + createSavedMetric: true, + updateSavedMetric: true, + deleteSavedMetric: true, + }), + + loaders(({ props }) => ({ + savedMetric: { + loadSavedMetric: async () => { + if (props.savedMetricId && props.savedMetricId !== 'new') { + const response = await api.get( + `api/projects/@current/experiment_saved_metrics/${props.savedMetricId}` + ) + return response as SavedMetric + } + return { ...NEW_SAVED_METRIC } + }, + }, + })), + + listeners(({ actions, values }) => ({ + createSavedMetric: async () => { + const response = await api.create(`api/projects/@current/experiment_saved_metrics/`, values.savedMetric) + if (response.id) { + lemonToast.success('Saved metric created successfully') + actions.loadSavedMetrics() + router.actions.push('/experiments/saved-metrics') + } + }, + updateSavedMetric: async () => { + const response = await api.update( + `api/projects/@current/experiment_saved_metrics/${values.savedMetricId}`, + values.savedMetric + ) + if (response.id) { + lemonToast.success('Saved metric updated successfully') + actions.loadSavedMetrics() + router.actions.push('/experiments/saved-metrics') + } + }, + deleteSavedMetric: async () => { + try { + await api.delete(`api/projects/@current/experiment_saved_metrics/${values.savedMetricId}`) + lemonToast.success('Saved metric deleted successfully') + actions.loadSavedMetrics() + router.actions.push('/experiments/saved-metrics') + } catch (error) { + lemonToast.error('Failed to delete saved metric') + console.error(error) + } + }, + })), + + reducers({ + savedMetric: [ + { ...NEW_SAVED_METRIC } as Partial, + { + setSavedMetric: (state, { metric }) => ({ ...state, ...metric }), + }, + ], + }), + + selectors({ + savedMetricId: [ + () => [(_, props) => props.savedMetricId ?? 'new'], + (savedMetricId): string | number => savedMetricId, + ], + isNew: [(s) => [s.savedMetricId], (savedMetricId) => savedMetricId === 'new'], + }), + + urlToAction(({ actions, values }) => ({ + '/experiments/saved-metrics/:id': ({ id }, _, __, currentLocation, previousLocation) => { + const didPathChange = currentLocation.initial || currentLocation.pathname !== previousLocation?.pathname + + if (id && didPathChange) { + const parsedId = id === 'new' ? 'new' : parseInt(id) + if (parsedId === 'new') { + actions.setSavedMetric({ ...NEW_SAVED_METRIC }) + } + + if (parsedId !== 'new' && parsedId === values.savedMetricId) { + actions.loadSavedMetric() + } + } + }, + })), +]) diff --git a/frontend/src/scenes/experiments/SavedMetrics/savedMetricsLogic.tsx b/frontend/src/scenes/experiments/SavedMetrics/savedMetricsLogic.tsx new file mode 100644 index 0000000000000..f9044a4eb181c --- /dev/null +++ b/frontend/src/scenes/experiments/SavedMetrics/savedMetricsLogic.tsx @@ -0,0 +1,48 @@ +import { actions, events, kea, listeners, path, reducers } from 'kea' +import { loaders } from 'kea-loaders' +import { router } from 'kea-router' +import api from 'lib/api' + +import { SavedMetric } from './savedMetricLogic' +import type { savedMetricsLogicType } from './savedMetricsLogicType' + +export enum SavedMetricsTabs { + All = 'all', + Yours = 'yours', + Archived = 'archived', +} + +export const savedMetricsLogic = kea([ + path(['scenes', 'experiments', 'savedMetricsLogic']), + actions({ + setSavedMetricsTab: (tabKey: SavedMetricsTabs) => ({ tabKey }), + }), + + loaders({ + savedMetrics: { + loadSavedMetrics: async () => { + const response = await api.get('api/projects/@current/experiment_saved_metrics') + return response.results as SavedMetric[] + }, + }, + }), + + reducers({ + tab: [ + SavedMetricsTabs.All as SavedMetricsTabs, + { + setSavedMetricsTab: (_, { tabKey }) => tabKey, + }, + ], + }), + listeners(() => ({ + setSavedMetricsTab: () => { + router.actions.push('/experiments/saved-metrics') + }, + })), + events(({ actions }) => ({ + afterMount: () => { + actions.loadSavedMetrics() + }, + })), +]) diff --git a/frontend/src/scenes/experiments/experimentLogic.tsx b/frontend/src/scenes/experiments/experimentLogic.tsx index beb7356103c97..338237d903ad7 100644 --- a/frontend/src/scenes/experiments/experimentLogic.tsx +++ b/frontend/src/scenes/experiments/experimentLogic.tsx @@ -66,6 +66,8 @@ import { MetricInsightId } from './constants' import type { experimentLogicType } from './experimentLogicType' import { experimentsLogic } from './experimentsLogic' import { holdoutsLogic } from './holdoutsLogic' +import { SavedMetric } from './SavedMetrics/savedMetricLogic' +import { savedMetricsLogic } from './SavedMetrics/savedMetricsLogic' import { getMinimumDetectableEffect, transformFiltersForWinningVariant } from './utils' const NEW_EXPERIMENT: Experiment = { @@ -76,6 +78,8 @@ const NEW_EXPERIMENT: Experiment = { filters: {}, metrics: [], metrics_secondary: [], + saved_metrics_ids: [], + saved_metrics: [], parameters: { feature_flag_variants: [ { key: 'control', rollout_percentage: 50 }, @@ -148,6 +152,8 @@ export const experimentLogic = kea([ ['insightDataLoading as trendMetricInsightLoading'], insightDataLogic({ dashboardItemId: MetricInsightId.Funnels }), ['insightDataLoading as funnelMetricInsightLoading'], + savedMetricsLogic, + ['savedMetrics'], ], actions: [ experimentsLogic, @@ -273,6 +279,22 @@ export const experimentLogic = kea([ openSecondaryMetricModal: (index: number) => ({ index }), closeSecondaryMetricModal: true, setSecondaryMetricsResultErrors: (errors: any[]) => ({ errors }), + openPrimaryMetricSourceModal: true, + closePrimaryMetricSourceModal: true, + openSecondaryMetricSourceModal: true, + closeSecondaryMetricSourceModal: true, + openPrimarySavedMetricModal: (savedMetricId: SavedMetric['id'] | null) => ({ savedMetricId }), + closePrimarySavedMetricModal: true, + openSecondarySavedMetricModal: (savedMetricId: SavedMetric['id'] | null) => ({ savedMetricId }), + closeSecondarySavedMetricModal: true, + addSavedMetricToExperiment: ( + savedMetricId: SavedMetric['id'], + metadata: { type: 'primary' | 'secondary' } + ) => ({ + savedMetricId, + metadata, + }), + removeSavedMetricFromExperiment: (savedMetricId: SavedMetric['id']) => ({ savedMetricId }), }), reducers({ experiment: [ @@ -514,6 +536,16 @@ export const experimentLogic = kea([ updateExperimentGoal: () => null, }, ], + editingSavedMetricId: [ + null as SavedMetric['id'] | null, + { + openPrimarySavedMetricModal: (_, { savedMetricId }) => savedMetricId, + openSecondarySavedMetricModal: (_, { savedMetricId }) => savedMetricId, + closePrimarySavedMetricModal: () => null, + closeSecondarySavedMetricModal: () => null, + updateExperimentGoal: () => null, + }, + ], secondaryMetricsResultErrors: [ [] as any[], { @@ -522,6 +554,34 @@ export const experimentLogic = kea([ loadExperiment: () => [], }, ], + isPrimaryMetricSourceModalOpen: [ + false, + { + openPrimaryMetricSourceModal: () => true, + closePrimaryMetricSourceModal: () => false, + }, + ], + isSecondaryMetricSourceModalOpen: [ + false, + { + openSecondaryMetricSourceModal: () => true, + closeSecondaryMetricSourceModal: () => false, + }, + ], + isPrimarySavedMetricModalOpen: [ + false, + { + openPrimarySavedMetricModal: () => true, + closePrimarySavedMetricModal: () => false, + }, + ], + isSecondarySavedMetricModalOpen: [ + false, + { + openSecondarySavedMetricModal: () => true, + closeSecondarySavedMetricModal: () => false, + }, + ], }), listeners(({ values, actions }) => ({ createExperiment: async ({ draft }) => { @@ -697,6 +757,12 @@ export const experimentLogic = kea([ closeSecondaryMetricModal: () => { actions.loadExperiment() }, + closePrimarySavedMetricModal: () => { + actions.loadExperiment() + }, + closeSecondarySavedMetricModal: () => { + actions.loadExperiment() + }, resetRunningExperiment: async () => { actions.updateExperiment({ start_date: null, end_date: null, archived: false }) values.experiment && actions.reportExperimentReset(values.experiment) @@ -842,6 +908,36 @@ export const experimentLogic = kea([ holdout_id: values.experiment.holdout_id, }) }, + addSavedMetricToExperiment: async ({ savedMetricId, metadata }) => { + const savedMetricsIds = values.experiment.saved_metrics.map((savedMetric) => ({ + id: savedMetric.saved_metric, + metadata, + })) + savedMetricsIds.push({ id: savedMetricId, metadata }) + + await api.update(`api/projects/${values.currentProjectId}/experiments/${values.experimentId}`, { + saved_metrics_ids: savedMetricsIds, + }) + + actions.closePrimarySavedMetricModal() + actions.closeSecondarySavedMetricModal() + actions.loadExperiment() + }, + removeSavedMetricFromExperiment: async ({ savedMetricId }) => { + const savedMetricsIds = values.experiment.saved_metrics + .filter((savedMetric) => savedMetric.saved_metric !== savedMetricId) + .map((savedMetric) => ({ + id: savedMetric.saved_metric, + metadata: savedMetric.metadata, + })) + await api.update(`api/projects/${values.currentProjectId}/experiments/${values.experimentId}`, { + saved_metrics_ids: savedMetricsIds, + }) + + actions.closePrimarySavedMetricModal() + actions.closeSecondarySavedMetricModal() + actions.loadExperiment() + }, })), loaders(({ actions, props, values }) => ({ experiment: { @@ -876,8 +972,16 @@ export const experimentLogic = kea([ loadMetricResults: async ( refresh?: boolean ): Promise<(CachedExperimentTrendsQueryResponse | CachedExperimentFunnelsQueryResponse | null)[]> => { + let metrics = values.experiment?.metrics + const savedMetrics = values.experiment?.saved_metrics + .filter((savedMetric) => savedMetric.metadata.type === 'primary') + .map((savedMetric) => savedMetric.query) + if (savedMetrics) { + metrics = [...metrics, ...savedMetrics] + } + return (await Promise.all( - values.experiment?.metrics.map(async (metric, index) => { + metrics.map(async (metric, index) => { try { const queryWithExperimentId = { ...metric, @@ -913,8 +1017,16 @@ export const experimentLogic = kea([ loadSecondaryMetricResults: async ( refresh?: boolean ): Promise<(CachedExperimentTrendsQueryResponse | CachedExperimentFunnelsQueryResponse | null)[]> => { + let metrics = values.experiment?.metrics_secondary + const savedMetrics = values.experiment?.saved_metrics + .filter((savedMetric) => savedMetric.metadata.type === 'secondary') + .map((savedMetric) => savedMetric.query) + if (savedMetrics) { + metrics = [...metrics, ...savedMetrics] + } + return (await Promise.all( - values.experiment?.metrics_secondary.map(async (metric, index) => { + metrics.map(async (metric, index) => { try { const queryWithExperimentId = { ...metric, @@ -992,20 +1104,11 @@ export const experimentLogic = kea([ () => [(_, props) => props.experimentId ?? 'new'], (experimentId): Experiment['id'] => experimentId, ], - getMetricType: [ - (s) => [s.experiment], - (experiment) => - (metricIdx: number = 0) => { - const query = experiment?.metrics?.[metricIdx] - return query?.kind === NodeKind.ExperimentTrendsQuery ? InsightType.TRENDS : InsightType.FUNNELS - }, - ], - getSecondaryMetricType: [ - (s) => [s.experiment], - (experiment) => - (metricIdx: number = 0) => { - const query = experiment?.metrics_secondary?.[metricIdx] - return query?.kind === NodeKind.ExperimentTrendsQuery ? InsightType.TRENDS : InsightType.FUNNELS + _getMetricType: [ + () => [], + () => + (metric: ExperimentTrendsQuery | ExperimentFunnelsQuery): InsightType => { + return metric?.kind === NodeKind.ExperimentTrendsQuery ? InsightType.TRENDS : InsightType.FUNNELS }, ], isExperimentRunning: [ @@ -1090,12 +1193,16 @@ export const experimentLogic = kea([ }, ], minimumDetectableEffect: [ - (s) => [s.experiment, s.getMetricType, s.conversionMetrics, s.trendResults], - (newExperiment, getMetricType, conversionMetrics, trendResults): number => { + (s) => [s.experiment, s._getMetricType, s.conversionMetrics, s.trendResults], + (newExperiment, _getMetricType, conversionMetrics, trendResults): number => { return ( newExperiment?.parameters?.minimum_detectable_effect || // :KLUDGE: extracted the method due to difficulties with logic tests - getMinimumDetectableEffect(getMetricType(0), conversionMetrics, trendResults) || + getMinimumDetectableEffect( + _getMetricType(newExperiment?.metrics[0]), + conversionMetrics, + trendResults + ) || 0 ) }, @@ -1176,7 +1283,7 @@ export const experimentLogic = kea([ (s) => [ s.experiment, s.variants, - s.getMetricType, + s._getMetricType, s.funnelResults, s.conversionMetrics, s.expectedRunningTime, @@ -1187,7 +1294,7 @@ export const experimentLogic = kea([ ( experiment, variants, - getMetricType, + _getMetricType, funnelResults, conversionMetrics, expectedRunningTime, @@ -1195,7 +1302,7 @@ export const experimentLogic = kea([ minimumSampleSizePerVariant, recommendedExposureForCountData ): number => { - if (getMetricType(0) === InsightType.FUNNELS) { + if (_getMetricType(experiment.metrics[0]) === InsightType.FUNNELS) { const currentDuration = dayjs().diff(dayjs(experiment?.start_date), 'hour') const funnelEntrants = funnelResults?.[0]?.count @@ -1323,8 +1430,8 @@ export const experimentLogic = kea([ }, ], getIndexForVariant: [ - (s) => [s.getMetricType], - (getMetricType) => + (s) => [s.experiment, s._getMetricType], + (experiment, _getMetricType) => ( metricResult: | Partial @@ -1340,7 +1447,7 @@ export const experimentLogic = kea([ } let index = -1 - if (getMetricType(0) === InsightType.FUNNELS) { + if (_getMetricType(experiment.metrics[0]) === InsightType.FUNNELS) { // Funnel Insight is displayed in order of decreasing count index = (Array.isArray(metricResult.insight) ? [...metricResult.insight] : []) .sort((a, b) => { @@ -1362,7 +1469,7 @@ export const experimentLogic = kea([ } const result = index === -1 ? null : index - if (result !== null && getMetricType(0) === InsightType.FUNNELS) { + if (result !== null && _getMetricType(experiment.metrics[0]) === InsightType.FUNNELS) { return result + 1 } return result @@ -1463,7 +1570,7 @@ export const experimentLogic = kea([ }, ], tabularExperimentResults: [ - (s) => [s.experiment, s.metricResults, s.getMetricType], + (s) => [s.experiment, s.metricResults, s._getMetricType], ( experiment, metricResults: ( @@ -1471,11 +1578,11 @@ export const experimentLogic = kea([ | CachedExperimentTrendsQueryResponse | null )[], - getMetricType + _getMetricType ) => (metricIndex: number = 0): any[] => { const tabularResults = [] - const metricType = getMetricType(metricIndex) + const metricType = _getMetricType(experiment.metrics[metricIndex]) const result = metricResults?.[metricIndex] if (result) { @@ -1571,19 +1678,20 @@ export const experimentLogic = kea([ }, ], funnelResultsPersonsTotal: [ - (s) => [s.metricResults, s.getMetricType], + (s) => [s.experiment, s.metricResults, s._getMetricType], ( + experiment, metricResults: ( | CachedExperimentFunnelsQueryResponse | CachedExperimentTrendsQueryResponse | null )[], - getMetricType + _getMetricType ) => (metricIndex: number = 0): number => { const result = metricResults?.[metricIndex] - if (getMetricType(metricIndex) !== InsightType.FUNNELS || !result?.insight) { + if (_getMetricType(experiment.metrics[metricIndex]) !== InsightType.FUNNELS || !result?.insight) { return 0 } @@ -1671,7 +1779,6 @@ export const experimentLogic = kea([ if (parsedId === 'new') { actions.resetExperiment() } - if (parsedId !== 'new' && parsedId === values.experimentId) { actions.loadExperiment() } diff --git a/frontend/src/scenes/experiments/experimentsLogic.ts b/frontend/src/scenes/experiments/experimentsLogic.ts index 317b353070773..b9558daf5c07e 100644 --- a/frontend/src/scenes/experiments/experimentsLogic.ts +++ b/frontend/src/scenes/experiments/experimentsLogic.ts @@ -1,7 +1,8 @@ import { LemonTagType } from '@posthog/lemon-ui' import Fuse from 'fuse.js' -import { actions, connect, events, kea, path, reducers, selectors } from 'kea' +import { actions, connect, events, kea, listeners, path, reducers, selectors } from 'kea' import { loaders } from 'kea-loaders' +import { router } from 'kea-router' import api from 'lib/api' import { FEATURE_FLAGS } from 'lib/constants' import { lemonToast } from 'lib/lemon-ui/LemonToast/LemonToast' @@ -43,6 +44,8 @@ export const experimentsLogic = kea([ ['user', 'hasAvailableFeature'], featureFlagLogic, ['featureFlags'], + router, + ['location'], ], }), actions({ @@ -67,10 +70,21 @@ export const experimentsLogic = kea([ tab: [ ExperimentsTabs.All as ExperimentsTabs, { - setExperimentsTab: (_, { tabKey }) => tabKey, + setExperimentsTab: (state, { tabKey }) => tabKey ?? state, }, ], }), + listeners(({ actions }) => ({ + setExperimentsTab: ({ tabKey }) => { + if (tabKey === ExperimentsTabs.SavedMetrics) { + // Saved Metrics is a fake tab that we use to redirect to the saved metrics page + actions.setExperimentsTab(ExperimentsTabs.All) + router.actions.push('/experiments/saved-metrics') + } else { + router.actions.push('/experiments') + } + }, + })), loaders(({ values }) => ({ experiments: [ [] as Experiment[], diff --git a/frontend/src/scenes/notebooks/Nodes/NotebookNodeExperiment.tsx b/frontend/src/scenes/notebooks/Nodes/NotebookNodeExperiment.tsx index afde3f1836415..c87e65370cd23 100644 --- a/frontend/src/scenes/notebooks/Nodes/NotebookNodeExperiment.tsx +++ b/frontend/src/scenes/notebooks/Nodes/NotebookNodeExperiment.tsx @@ -81,7 +81,7 @@ const Component = ({ attributes }: NotebookNodeProps
- +
diff --git a/frontend/src/scenes/sceneTypes.ts b/frontend/src/scenes/sceneTypes.ts index 74cb1e175500d..4ce10aa5d33fb 100644 --- a/frontend/src/scenes/sceneTypes.ts +++ b/frontend/src/scenes/sceneTypes.ts @@ -36,6 +36,8 @@ export enum Scene { Group = 'Group', Action = 'Action', Experiments = 'Experiments', + ExperimentsSavedMetrics = 'ExperimentsSavedMetrics', + ExperimentsSavedMetric = 'ExperimentsSavedMetric', Experiment = 'Experiment', FeatureManagement = 'FeatureManagement', FeatureFlags = 'FeatureFlags', diff --git a/frontend/src/scenes/scenes.ts b/frontend/src/scenes/scenes.ts index 6a3dadf7d5c1e..816a79b3129db 100644 --- a/frontend/src/scenes/scenes.ts +++ b/frontend/src/scenes/scenes.ts @@ -203,6 +203,18 @@ export const sceneConfigurations: Record = { defaultDocsPath: '/docs/experiments/creating-an-experiment', activityScope: ActivityScope.EXPERIMENT, }, + [Scene.ExperimentsSavedMetric]: { + projectBased: true, + name: 'Saved metric', + defaultDocsPath: '/docs/experiments/creating-an-experiment', + activityScope: ActivityScope.EXPERIMENT, + }, + [Scene.ExperimentsSavedMetrics]: { + projectBased: true, + name: 'Saved metrics', + defaultDocsPath: '/docs/experiments/creating-an-experiment', + activityScope: ActivityScope.EXPERIMENT, + }, [Scene.FeatureFlags]: { projectBased: true, name: 'Feature flags', @@ -560,6 +572,8 @@ export const routes: Record = { [urls.cohort(':id')]: Scene.Cohort, [urls.cohorts()]: Scene.PersonsManagement, [urls.experiments()]: Scene.Experiments, + [urls.experimentsSavedMetrics()]: Scene.ExperimentsSavedMetrics, + [urls.experimentsSavedMetric(':id')]: Scene.ExperimentsSavedMetric, [urls.experiment(':id')]: Scene.Experiment, [urls.earlyAccessFeatures()]: Scene.EarlyAccessFeatures, [urls.earlyAccessFeature(':id')]: Scene.EarlyAccessFeature, @@ -591,6 +605,7 @@ export const routes: Record = { [urls.instanceStatus()]: Scene.SystemStatus, [urls.instanceSettings()]: Scene.SystemStatus, [urls.instanceStaffUsers()]: Scene.SystemStatus, + [urls.instanceKafkaInspector()]: Scene.SystemStatus, [urls.instanceMetrics()]: Scene.SystemStatus, [urls.asyncMigrations()]: Scene.AsyncMigrations, [urls.asyncMigrationsFuture()]: Scene.AsyncMigrations, diff --git a/frontend/src/scenes/urls.ts b/frontend/src/scenes/urls.ts index 183766b86db2b..ad08c747bd629 100644 --- a/frontend/src/scenes/urls.ts +++ b/frontend/src/scenes/urls.ts @@ -157,6 +157,8 @@ export const urls = { cohorts: (): string => '/cohorts', experiment: (id: string | number): string => `/experiments/${id}`, experiments: (): string => '/experiments', + experimentsSavedMetrics: (): string => '/experiments/saved-metrics', + experimentsSavedMetric: (id: string | number): string => `/experiments/saved-metrics/${id}`, featureFlags: (tab?: string): string => `/feature_flags${tab ? `?tab=${tab}` : ''}`, featureFlag: (id: string | number): string => `/feature_flags/${id}`, featureManagement: (id?: string | number): string => `/features${id ? `/${id}` : ''}`, @@ -213,6 +215,7 @@ export const urls = { // Self-hosted only instanceStatus: (): string => '/instance/status', instanceStaffUsers: (): string => '/instance/staff_users', + instanceKafkaInspector: (): string => '/instance/kafka_inspector', instanceSettings: (): string => '/instance/settings', instanceMetrics: (): string => `/instance/metrics`, asyncMigrations: (): string => '/instance/async_migrations', diff --git a/frontend/src/types.ts b/frontend/src/types.ts index a9e20bb1bb9fc..be7dea47a8302 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -702,6 +702,7 @@ export enum ExperimentsTabs { Yours = 'yours', Archived = 'archived', Holdouts = 'holdouts', + SavedMetrics = 'saved-metrics', } export enum ActivityTab { @@ -3310,6 +3311,8 @@ export interface Experiment { filters: TrendsFilterType | FunnelsFilterType metrics: (ExperimentTrendsQuery | ExperimentFunnelsQuery)[] metrics_secondary: (ExperimentTrendsQuery | ExperimentFunnelsQuery)[] + saved_metrics_ids: { id: number; metadata: { type: 'primary' | 'secondary' } }[] + saved_metrics: any[] parameters: { minimum_detectable_effect?: number recommended_running_time?: number diff --git a/posthog/hogql_queries/experiments/experiment_funnels_query_runner.py b/posthog/hogql_queries/experiments/experiment_funnels_query_runner.py index 363ee06bc78be..08c7e3dd91de5 100644 --- a/posthog/hogql_queries/experiments/experiment_funnels_query_runner.py +++ b/posthog/hogql_queries/experiments/experiment_funnels_query_runner.py @@ -31,6 +31,7 @@ from typing import Optional, Any, cast from zoneinfo import ZoneInfo from rest_framework.exceptions import ValidationError +from datetime import datetime, timedelta, UTC class ExperimentFunnelsQueryRunner(QueryRunner): @@ -216,3 +217,14 @@ def _validate_event_variants(self, funnels_result: FunnelsQueryResponse): def to_query(self) -> ast.SelectQuery: raise ValueError(f"Cannot convert source query of type {self.query.funnels_query.kind} to query") + + # Cache results for 24 hours + def cache_target_age(self, last_refresh: Optional[datetime], lazy: bool = False) -> Optional[datetime]: + if last_refresh is None: + return None + return last_refresh + timedelta(hours=24) + + def _is_stale(self, last_refresh: Optional[datetime], lazy: bool = False) -> bool: + if not last_refresh: + return True + return (datetime.now(UTC) - last_refresh) > timedelta(hours=24) diff --git a/posthog/hogql_queries/experiments/experiment_trends_query_runner.py b/posthog/hogql_queries/experiments/experiment_trends_query_runner.py index 6cf76b4c5b4e7..b3c72c788c951 100644 --- a/posthog/hogql_queries/experiments/experiment_trends_query_runner.py +++ b/posthog/hogql_queries/experiments/experiment_trends_query_runner.py @@ -45,6 +45,7 @@ ) from typing import Any, Optional import threading +from datetime import datetime, timedelta, UTC class ExperimentTrendsQueryRunner(QueryRunner): @@ -430,3 +431,14 @@ def _is_data_warehouse_query(self, query: TrendsQuery) -> bool: def to_query(self) -> ast.SelectQuery: raise ValueError(f"Cannot convert source query of type {self.query.count_query.kind} to query") + + # Cache results for 24 hours + def cache_target_age(self, last_refresh: Optional[datetime], lazy: bool = False) -> Optional[datetime]: + if last_refresh is None: + return None + return last_refresh + timedelta(hours=24) + + def _is_stale(self, last_refresh: Optional[datetime], lazy: bool = False) -> bool: + if not last_refresh: + return True + return (datetime.now(UTC) - last_refresh) > timedelta(hours=24)