From 9add401e793c79c36f793599b5cb28d71803fd87 Mon Sep 17 00:00:00 2001 From: Rodrigo Iloro Date: Thu, 20 Aug 2026 01:10:22 -0300 Subject: [PATCH 1/2] feat(experiments): persist breakdown attribution for inline metrics --- .../MetricBreakdownAttribution.tsx | 19 +++- .../MetricsView/new/MetricRowGroup.tsx | 11 ++- .../MetricsView/new/MetricsTable.tsx | 12 +++ .../scenes/experiments/experimentLogic.tsx | 92 +++++++++++++++++-- 4 files changed, 119 insertions(+), 15 deletions(-) diff --git a/frontend/src/scenes/experiments/MetricBreakdowns/MetricBreakdownAttribution.tsx b/frontend/src/scenes/experiments/MetricBreakdowns/MetricBreakdownAttribution.tsx index 0c557c9548a4..e4718a8ac263 100644 --- a/frontend/src/scenes/experiments/MetricBreakdowns/MetricBreakdownAttribution.tsx +++ b/frontend/src/scenes/experiments/MetricBreakdowns/MetricBreakdownAttribution.tsx @@ -12,19 +12,28 @@ export function MetricBreakdownAttribution({ metric: ExperimentFunnelMetric onChange: (attributionType: BreakdownAttributionType, attributionValue?: number) => void }): JSX.Element { - const { funnel_order_type } = metric + const { breakdownAttributionType, breakdownAttributionValue, funnel_order_type } = metric const stepCount = metric.series?.length || 0 /** - * hardcoded for now, this have to be configured at the metric level. + * We need to construct the current value shown based on the attribution type and the funnel order. + * we default to `first touch`, but if the funnel is unordered, insterad of the step choice + * we show the "any step" option. + * For the step attribution type, we need to show the value. */ - const breakdownAttributionType = BreakdownAttributionType.FirstTouch - const breakdownAttributionValue = 0 + const currentValue: BreakdownAttributionType | `${BreakdownAttributionType.Step}/${number}` = + !breakdownAttributionType + ? BreakdownAttributionType.FirstTouch + : breakdownAttributionType === BreakdownAttributionType.Step + ? funnel_order_type === StepOrderValue.UNORDERED + ? BreakdownAttributionType.Step + : `${breakdownAttributionType}/${breakdownAttributionValue || 0}` + : breakdownAttributionType return ( scale: ReturnType onRemoveBreakdown: (index: number) => void + onBreakdownAttributionChange: (attributionType: BreakdownAttributionType, attributionValue?: number) => void onBreakdownLimitChange: (breakdownLimit: number) => void onRetry: () => void query?: Record @@ -107,6 +108,7 @@ function CollapsibleBreakdownSection({ colors, scale, onRemoveBreakdown, + onBreakdownAttributionChange, onBreakdownLimitChange, onRetry, query, @@ -171,7 +173,7 @@ function CollapsibleBreakdownSection({ {}} + onAttributionChange={onBreakdownAttributionChange} onBreakdownLimitChange={onBreakdownLimitChange} /> @@ -527,6 +529,7 @@ interface MetricRowGroupProps { onDeleteMetric?: () => void onBreakdownChange: (breakdown: Breakdown) => void onRemoveBreakdown: (index: number) => void + onBreakdownAttributionChange: (attributionType: BreakdownAttributionType, attributionValue?: number) => void onBreakdownLimitChange: (breakdownLimit: number) => void error?: any isLoading?: boolean @@ -551,6 +554,7 @@ export function MetricRowGroup({ onDeleteMetric, onBreakdownChange, onRemoveBreakdown, + onBreakdownAttributionChange, onBreakdownLimitChange, error, isLoading, @@ -902,7 +906,7 @@ export function MetricRowGroup({ metric={metric} isAlternatingRow={isAlternatingRow} onRemoveBreakdown={onRemoveBreakdown} - onAttributionChange={() => {}} + onAttributionChange={onBreakdownAttributionChange} onBreakdownLimitChange={onBreakdownLimitChange} /> )} @@ -1236,6 +1240,7 @@ export function MetricRowGroup({ colors={colors} scale={scale} onRemoveBreakdown={onRemoveBreakdown} + onBreakdownAttributionChange={onBreakdownAttributionChange} onBreakdownLimitChange={onBreakdownLimitChange} onRetry={handleRetry} query={debugQuery} diff --git a/frontend/src/scenes/experiments/MetricsView/new/MetricsTable.tsx b/frontend/src/scenes/experiments/MetricsView/new/MetricsTable.tsx index d11380f0db8d..4d7476533789 100644 --- a/frontend/src/scenes/experiments/MetricsView/new/MetricsTable.tsx +++ b/frontend/src/scenes/experiments/MetricsView/new/MetricsTable.tsx @@ -67,6 +67,7 @@ export function MetricsTable({ duplicateSharedMetricAsInlineMetric, updateExperimentMetrics, updateMetricBreakdown, + updateMetricBreakdownAttribution, updateMetricBreakdownLimit, removeMetricBreakdown, removeMetric, @@ -279,6 +280,17 @@ export function MetricsTable({ removeMetricBreakdown(metric.uuid, index, breakdown) }} + onBreakdownAttributionChange={(attributionType, attributionValue) => { + if (!metric.uuid) { + return + } + + updateMetricBreakdownAttribution( + metric.uuid, + attributionType, + attributionValue + ) + }} onBreakdownLimitChange={(breakdownLimit) => { if (!metric.uuid) { return diff --git a/frontend/src/scenes/experiments/experimentLogic.tsx b/frontend/src/scenes/experiments/experimentLogic.tsx index ccce7aef7900..1ed7bcbd5749 100644 --- a/frontend/src/scenes/experiments/experimentLogic.tsx +++ b/frontend/src/scenes/experiments/experimentLogic.tsx @@ -1315,6 +1315,11 @@ export interface experimentLogicActions { breakdown: Breakdown uuid: string } + updateMetricBreakdownAttribution: ( + uuid: string, + attributionType: BreakdownAttributionType, + attributionValue?: number + ) => { attributionType: BreakdownAttributionType; attributionValue: number | undefined; uuid: string } updateMetricBreakdownLimit: ( uuid: string, breakdownLimit: number @@ -1718,6 +1723,15 @@ export const experimentLogic = kea([ ) => ({ isSecondary, orderedUuids, removedUuids, movedUuids }), updateMetricBreakdown: (uuid: string, breakdown: Breakdown) => ({ uuid, breakdown }), removeMetricBreakdown: (uuid: string, index: number, breakdown: Breakdown) => ({ uuid, index, breakdown }), + updateMetricBreakdownAttribution: ( + uuid: string, + attributionType: BreakdownAttributionType, + attributionValue?: number + ) => ({ + uuid, + attributionType, + attributionValue, + }), updateMetricBreakdownLimit: (uuid: string, breakdownLimit: number) => ({ uuid, breakdownLimit }), // METRICS RESULTS clearMetricsResults: true, @@ -2078,6 +2092,42 @@ export const experimentLogic = kea([ [metricsKey]: metrics, } }, + updateMetricBreakdownAttribution: (state, { uuid, attributionType, attributionValue }) => { + /** + * figure out if it's a primary or secondary metric + */ + const metricsKey = + (state?.metrics || ([] as ExperimentMetric[])).findIndex((m) => m.uuid === uuid) > -1 + ? 'metrics' + : 'metrics_secondary' + + /** + * get the metrics group and find the metric index by uuid. + * bail if not found + */ + const metrics = [...(state?.[metricsKey] || [])] + const targetIndex = metrics.findIndex((m) => m.uuid === uuid) + if (targetIndex === -1) { + return state + } + + /** + * reconstruct the metric with the updated breakdown attribution + */ + metrics[targetIndex] = { + ...metrics[targetIndex], + breakdownAttributionType: attributionType, + breakdownAttributionValue: attributionValue, + } as ExperimentMetric + + /** + * return the updated experiment state + */ + return { + ...state, + [metricsKey]: metrics, + } + }, updateMetricBreakdownLimit: (state, { uuid, breakdownLimit }) => { /** * if the uuid is a shared metric, update saved_metrics metadata of the many to many @@ -3676,17 +3726,37 @@ export const experimentLogic = kea([ actions.loadSecondaryMetricsResults(true) } }, - updateMetricBreakdownLimit: async ({ uuid }) => { - const savedMetrics: ExperimentSavedMetric[] = [...(values.experiment.saved_metrics || [])] - const sharedMetric = savedMetrics.find(({ query: { uuid: savedMetricUuid } }) => savedMetricUuid === uuid) + updateMetricBreakdownAttribution: async ({ uuid }) => { + /** + * build the experiment payload with all experiment metrics + */ + const updatePayload: Partial & { update_feature_flag_params?: boolean } = { + metrics: values.experiment.metrics, + metrics_secondary: values.experiment.metrics_secondary, + update_feature_flag_params: false, + } /** - * find if the updated metris is primary + * guard against failed persist calling recalculations by awaiting the experiment save */ - const isPrimary = sharedMetric - ? sharedMetric.metadata.type === 'primary' - : values.experiment.metrics.some((m) => m.uuid === uuid) + await asyncActions.updateExperiment(updatePayload) + /** + * figure out if it's a primary metric + */ + const isPrimary = values.experiment.metrics.some((m) => m.uuid === uuid) + /** + * updating a breakdown limit triggers a recalculation. + */ + if (values.featureFlags[FEATURE_FLAGS.EXPERIMENTS_METRICS_RECALCULATION]) { + actions.refreshExperimentResults(true, 'config_change') + } else if (isPrimary) { + actions.loadPrimaryMetricsResults(true) + } else { + actions.loadSecondaryMetricsResults(true) + } + }, + updateMetricBreakdownLimit: async ({ uuid }) => { /** * build the update payload with all experiment metrics */ @@ -3699,6 +3769,8 @@ export const experimentLogic = kea([ /** * for shared metrics, we save the breakdown limit on the many to many relationship metadata */ + const savedMetrics: ExperimentSavedMetric[] = [...(values.experiment.saved_metrics || [])] + const sharedMetric = savedMetrics.find(({ query: { uuid: savedMetricUuid } }) => savedMetricUuid === uuid) if (sharedMetric) { updatePayload.saved_metrics_ids = savedMetrics.map(({ saved_metric, metadata }) => ({ id: saved_metric, @@ -3711,6 +3783,12 @@ export const experimentLogic = kea([ */ await asyncActions.updateExperiment(updatePayload) + /** + * find if the updated metris is primary + */ + const isPrimary = sharedMetric + ? sharedMetric.metadata.type === 'primary' + : values.experiment.metrics.some((m) => m.uuid === uuid) /** * updating a breakdown limit triggers a recalculation. */ From bb640fff6dacb8cdd4447eb9832cd8dacd459437 Mon Sep 17 00:00:00 2001 From: Rodrigo Iloro Date: Thu, 20 Aug 2026 02:40:11 -0300 Subject: [PATCH 2/2] feat(experiments): persist breakdown attribution for shared metrics --- .../scenes/experiments/experimentLogic.tsx | 63 ++++++++++++++++++- frontend/src/scenes/experiments/utils.ts | 21 ++++++- 2 files changed, 82 insertions(+), 2 deletions(-) diff --git a/frontend/src/scenes/experiments/experimentLogic.tsx b/frontend/src/scenes/experiments/experimentLogic.tsx index 1ed7bcbd5749..a506300c6c81 100644 --- a/frontend/src/scenes/experiments/experimentLogic.tsx +++ b/frontend/src/scenes/experiments/experimentLogic.tsx @@ -65,6 +65,7 @@ import { ExperimentTrendsQuery, FunnelsQuery, InsightVizNode, + isExperimentFunnelMetric, NodeKind, ProductIntentContext, ProductKey, @@ -464,7 +465,18 @@ const sharedMetricsToExperimentMetrics = ( .filter(({ metadata }) => metadata.type === type) .map(({ query, metadata }) => ({ ...query, + /** + * for funnel shared metrics, merge the breakdown attribution from metadata into the query + */ + ...(metadata?.breakdownAttributionType !== undefined && + isExperimentFunnelMetric(query) && { + breakdownAttributionType: metadata.breakdownAttributionType, + breakdownAttributionValue: metadata.breakdownAttributionValue, + }), // Merge breakdowns from metadata into the query + /** + * merge breakdown limits from metadata into the query + */ breakdownFilter: { ...query?.breakdownFilter, breakdowns: metadata?.breakdowns || [], @@ -483,6 +495,8 @@ export type ExperimentSavedMetric = { metadata: { type: 'primary' | 'secondary' breakdowns?: Breakdown[] + breakdownAttributionType?: BreakdownAttributionType + breakdownAttributionValue?: number breakdown_limit?: number } created_at: string @@ -2093,6 +2107,38 @@ export const experimentLogic = kea([ } }, updateMetricBreakdownAttribution: (state, { uuid, attributionType, attributionValue }) => { + /** + * if the uuid is a shared metric, update saved_metrics metadata of the many to many + * relationship, so the breakdown limit is exclusive to this experiment + */ + const savedMetrics: ExperimentSavedMetric[] = [...(state?.saved_metrics || [])] + const savedMetricIndex = savedMetrics.findIndex( + ({ query: { uuid: savedMetricUuid } }) => savedMetricUuid === uuid + ) + + /** + * if saved metric found... + */ + if (savedMetricIndex !== -1) { + const savedMetric = savedMetrics[savedMetricIndex] + savedMetrics[savedMetricIndex] = { + ...savedMetric, + metadata: { + ...savedMetric.metadata, + breakdownAttributionType: attributionType, + breakdownAttributionValue: attributionValue, + }, + } + + /** + * return the experiment state with the updated saved metrics. + */ + return { + ...state, + saved_metrics: savedMetrics, + } + } + /** * figure out if it's a primary or secondary metric */ @@ -3736,6 +3782,18 @@ export const experimentLogic = kea([ update_feature_flag_params: false, } + /** + * for shared metrics, we save the breakdown attribution on the many to many relationship metadata + */ + const savedMetrics: ExperimentSavedMetric[] = [...(values.experiment.saved_metrics || [])] + const sharedMetric = savedMetrics.find(({ query: { uuid: savedMetricUuid } }) => savedMetricUuid === uuid) + if (sharedMetric) { + updatePayload.saved_metrics_ids = savedMetrics.map(({ saved_metric, metadata }) => ({ + id: saved_metric, + metadata, + })) + } + /** * guard against failed persist calling recalculations by awaiting the experiment save */ @@ -3744,7 +3802,10 @@ export const experimentLogic = kea([ /** * figure out if it's a primary metric */ - const isPrimary = values.experiment.metrics.some((m) => m.uuid === uuid) + const isPrimary = sharedMetric + ? sharedMetric.metadata.type === 'primary' + : values.experiment.metrics.some((m) => m.uuid === uuid) + /** * updating a breakdown limit triggers a recalculation. */ diff --git a/frontend/src/scenes/experiments/utils.ts b/frontend/src/scenes/experiments/utils.ts index b22f1a0cd525..e07fa6898c50 100644 --- a/frontend/src/scenes/experiments/utils.ts +++ b/frontend/src/scenes/experiments/utils.ts @@ -1198,7 +1198,16 @@ export function getOrderedMetricsWithResults( name: sharedMetric.name, sharedMetricId: sharedMetric.saved_metric, isSharedMetric: true, - // Merge breakdowns from metadata into breakdownFilter + /** + * Merge per-experiment breakdown attribution from metadata into the query + */ + ...(sharedMetric.metadata?.breakdownAttributionType !== undefined && { + breakdownAttributionType: sharedMetric.metadata.breakdownAttributionType, + breakdownAttributionValue: sharedMetric.metadata.breakdownAttributionValue, + }), + /** + * Merge breakdowns from metadata into breakdownFilter + */ breakdownFilter: { ...sharedMetric.query?.breakdownFilter, breakdowns: sharedMetric.metadata?.breakdowns || [], @@ -1408,6 +1417,16 @@ export const metricResults = name: sharedMetric.name, sharedMetricId: sharedMetric.saved_metric, isSharedMetric: true, + /** + * Merge per-experiment breakdown attribution from metadata into the query + */ + ...(sharedMetric.metadata?.breakdownAttributionType !== undefined && { + breakdownAttributionType: sharedMetric.metadata.breakdownAttributionType, + breakdownAttributionValue: sharedMetric.metadata.breakdownAttributionValue, + }), + /** + * Merge breakdowns from metadata into breakdownFilter + */ breakdownFilter: { ...sharedMetric.query?.breakdownFilter, breakdowns: sharedMetric.metadata?.breakdowns || [],