Repository: PostHog/posthog
Pull request: feat(experiments): add support for "any order" in funnels
## Problem
We currently only support sequential funnels. Users have requested support for any order (something we support in product analytics).

## Changes
- Extend the schema for funnel metrics with `funnel_order_type`
- Add a new component `ExperimentMetricFunnelOrderSelector` to `ExperimentMetricForm`
- Use the field value in to the `aggregate_funnel_array` function
- Also includes a fix in the `generate_experiment_data` mgmt cmd to make sure events have increasing timestamps (to avoid race conditions when events have the exact same timestamp)

<img width="1007" alt="Screenshot 2025-06-17 at 12 50 45" src="https://github.com/user-attachments/assets/fbccc809-b239-41b2-b95a-3086e3677ce2" />

Fixes #33124 

## How did you test this code?
- All existing tests passes
- Added new unit tests to cover any order logic
- Added new unit tests to make sure `metricToQuery` correctly passes the `funnel_order_type` to the funnel insight queries


Problem statement:
Support "Any order" in experiment funnel metrics
### Feature request

We only support a specific order of events in experiment funnel metrics. In product analytics, we support "Any order". We should close the feature gap here.

## Potential solutions
What we can consider is to see if we can use the same Clickhouse UDF that product analytics funnel queries uses, and see if we can get this "for free".

Hints from issue discussion:

Commit message: add unit tests

Patch to review:
diff --git a/frontend/src/queries/schema.json b/frontend/src/queries/schema.json
index f143cf539dc24..fe048ec47a0b5 100644
--- a/frontend/src/queries/schema.json
+++ b/frontend/src/queries/schema.json
@@ -9282,6 +9282,9 @@
                 "conversion_window_unit": {
                     "$ref": "#/definitions/FunnelConversionWindowTimeUnit"
                 },
+                "funnel_order_type": {
+                    "$ref": "#/definitions/StepOrderValue"
+                },
                 "kind": {
                     "const": "ExperimentMetric",
                     "type": "string"
@@ -9316,6 +9319,9 @@
         "ExperimentFunnelMetricTypeProps": {
             "additionalProperties": false,
             "properties": {
+                "funnel_order_type": {
+                    "$ref": "#/definitions/StepOrderValue"
+                },
                 "metric_type": {
                     "const": "funnel",
                     "type": "string"
diff --git a/frontend/src/queries/schema/schema-general.ts b/frontend/src/queries/schema/schema-general.ts
index 77055b76cc6fb..48db9ac2f3d6b 100644
--- a/frontend/src/queries/schema/schema-general.ts
+++ b/frontend/src/queries/schema/schema-general.ts
@@ -42,6 +42,7 @@ import {
     RevenueAnalyticsPropertyFilter,
     SessionPropertyFilter,
     SessionRecordingType,
+    StepOrderValue,
     StickinessFilterType,
     TrendsFilterType,
 } from '~/types'
@@ -2254,6 +2255,7 @@ export const isExperimentMeanMetric = (metric: ExperimentMetric): metric is Expe
 export type ExperimentFunnelMetric = ExperimentMetricBaseProperties & {
     metric_type: ExperimentMetricType.FUNNEL
     series: ExperimentFunnelMetricStep[]
+    funnel_order_type?: StepOrderValue
 }
 
 export const isExperimentFunnelMetric = (metric: ExperimentMetric): metric is ExperimentFunnelMetric =>
diff --git a/frontend/src/scenes/experiments/ExperimentMetricForm.tsx b/frontend/src/scenes/experiments/ExperimentMetricForm.tsx
index 805bbc982b65b..70f2ab86e7f83 100644
--- a/frontend/src/scenes/experiments/ExperimentMetricForm.tsx
+++ b/frontend/src/scenes/experiments/ExperimentMetricForm.tsx
@@ -4,10 +4,17 @@ import { LemonRadio } from 'lib/lemon-ui/LemonRadio'
 import { ActionFilter } from 'scenes/insights/filters/ActionFilter/ActionFilter'
 
 import { Query } from '~/queries/Query/Query'
-import { ExperimentMetric, ExperimentMetricType, NodeKind } from '~/queries/schema/schema-general'
+import {
+    ExperimentMetric,
+    ExperimentMetricType,
+    isExperimentFunnelMetric,
+    isExperimentMeanMetric,
+    NodeKind,
+} from '~/queries/schema/schema-general'
 import { FilterType } from '~/types'
 
 import { ExperimentMetricConversionWindowFilter } from './ExperimentMetricConversionWindowFilter'
+import { ExperimentMetricFunnelOrderSelector } from './ExperimentMetricFunnelOrderSelector'
 import { ExperimentMetricOutlierHandling } from './ExperimentMetricOutlierHandling'
 import { commonActionFilterProps } from './Metrics/Selectors'
 import {
@@ -145,7 +152,10 @@ export function ExperimentMetricForm({
                 )}
             </div>
             <ExperimentMetricConversionWindowFilter metric={metric} handleSetMetric={handleSetMetric} />
-            {metric.metric_type === ExperimentMetricType.MEAN && (
+            {isExperimentFunnelMetric(metric) && (
+                <ExperimentMetricFunnelOrderSelector metric={metric} handleSetMetric={handleSetMetric} />
+            )}
+            {isExperimentMeanMetric(metric) && (
                 <ExperimentMetricOutlierHandling metric={metric} handleSetMetric={handleSetMetric} />
             )}
             <div>
@@ -165,9 +175,10 @@ export function ExperimentMetricForm({
                 </LemonLabel>
             </div>
             {/* :KLUDGE: Query chart type is inferred from the initial state, so need to render Trends and Funnels separately */}
-            {metric.metric_type === ExperimentMetricType.MEAN &&
-                metric.source.kind !== NodeKind.ExperimentDataWarehouseNode && <Query query={queryConfig} readOnly />}
-            {metric.metric_type === ExperimentMetricType.FUNNEL && <Query query={queryConfig} readOnly />}
+            {isExperimentMeanMetric(metric) && metric.source.kind !== NodeKind.ExperimentDataWarehouseNode && (
+                <Query query={queryConfig} readOnly />
+            )}
+            {isExperimentFunnelMetric(metric) && <Query query={queryConfig} readOnly />}
         </div>
     )
 }
diff --git a/frontend/src/scenes/experiments/ExperimentMetricFunnelOrderSelector.tsx b/frontend/src/scenes/experiments/ExperimentMetricFunnelOrderSelector.tsx
new file mode 100644
index 0000000000000..b8cb12021fc5b
--- /dev/null
+++ b/frontend/src/scenes/experiments/ExperimentMetricFunnelOrderSelector.tsx
@@ -0,0 +1,58 @@
+import { LemonLabel } from 'lib/lemon-ui/LemonLabel'
+import { LemonSelect } from 'lib/lemon-ui/LemonSelect'
+
+import { ExperimentFunnelMetric, ExperimentMetric } from '~/queries/schema/schema-general'
+import { StepOrderValue } from '~/types'
+
+const funnelOrderOptions = [
+    {
+        label: 'Sequential',
+        value: StepOrderValue.ORDERED,
+    },
+    {
+        label: 'Any order',
+        value: StepOrderValue.UNORDERED,
+    },
+]
+
+function StepOrderInfo(): JSX.Element {
+    return (
+        <ul className="list-disc pl-4">
+            <li>
+                <b>Sequential</b> - Step B must happen after Step A, but any number of events can happen between A and
+                B.
+            </li>
+            <li>
+                <b>Any order</b> - Steps can be completed in any sequence.
+            </li>
+        </ul>
+    )
+}
+
+export function ExperimentMetricFunnelOrderSelector({
+    metric,
+    handleSetMetric,
+}: {
+    metric: ExperimentFunnelMetric
+    handleSetMetric: (newMetric: ExperimentMetric) => void
+}): JSX.Element | null {
+    const handleFunnelOrderTypeChange = (funnelOrderType: StepOrderValue): void => {
+        handleSetMetric({
+            ...metric,
+            funnel_order_type: funnelOrderType,
+        })
+    }
+
+    return (
+        <div className="flex items-center gap-2">
+            <LemonLabel info={<StepOrderInfo />}>Step order</LemonLabel>
+            <LemonSelect
+                data-attr="experiment-funnel-order-selector"
+                value={metric.funnel_order_type || StepOrderValue.ORDERED}
+                onChange={handleFunnelOrderTypeChange}
+                dropdownMatchSelectWidth={false}
+                options={funnelOrderOptions}
+            />
+        </div>
+    )
+}
diff --git a/frontend/src/scenes/experiments/components/ResultsBreakdown/resultsBreakdownLogic.ts b/frontend/src/scenes/experiments/components/ResultsBreakdown/resultsBreakdownLogic.ts
index b5ee79b0e7228..78b77310ead22 100644
--- a/frontend/src/scenes/experiments/components/ResultsBreakdown/resultsBreakdownLogic.ts
+++ b/frontend/src/scenes/experiments/components/ResultsBreakdown/resultsBreakdownLogic.ts
@@ -12,7 +12,7 @@ import type {
     InsightVizNode,
     TrendsQuery,
 } from '~/queries/schema/schema-general'
-import { ExperimentMetricType, NodeKind } from '~/queries/schema/schema-general'
+import { ExperimentMetricType, isExperimentFunnelMetric, NodeKind } from '~/queries/schema/schema-general'
 import {
     addExposureToMetric,
     compose,
@@ -105,7 +105,9 @@ export const resultsBreakdownLogic = kea<resultsBreakdownLogicType>([
                         funnelsFilter: {
                             layout: FunnelLayout.vertical,
                             breakdownAttributionType: BreakdownAttributionType.FirstTouch,
-                            funnelOrderType: StepOrderValue.ORDERED,
+                            funnelOrderType: isExperimentFunnelMetric(metric)
+                                ? metric.funnel_order_type || StepOrderValue.ORDERED
+                                : StepOrderValue.ORDERED,
                             funnelStepReference: FunnelStepReference.total,
                             funnelVizType: FunnelVizType.Steps,
                             funnelWindowInterval: 14,
diff --git a/frontend/src/scenes/experiments/utils.test.ts b/frontend/src/scenes/experiments/utils.test.ts
index 46c3bfa055ed2..6e23a0c4e530e 100644
--- a/frontend/src/scenes/experiments/utils.test.ts
+++ b/frontend/src/scenes/experiments/utils.test.ts
@@ -28,6 +28,7 @@ import {
     PropertyFilterType,
     PropertyMathType,
     PropertyOperator,
+    StepOrderValue,
 } from '~/types'
 
 import { getNiceTickValues } from './MetricsView/shared/utils'
@@ -1217,6 +1218,163 @@ describe('metricToQuery', () => {
             ],
         })
     })
+
+    it('returns the correct query for a funnel metric with unordered step order', () => {
+        const metric: ExperimentMetric = {
+            kind: NodeKind.ExperimentMetric,
+            metric_type: ExperimentMetricType.FUNNEL,
+            series: [
+                {
+                    event: 'signup',
+                    kind: NodeKind.EventsNode,
+                    name: 'signup',
+                },
+                {
+                    event: 'purchase',
+                    kind: NodeKind.EventsNode,
+                    name: 'purchase',
+                },
+            ],
+            funnel_order_type: StepOrderValue.UNORDERED,
+        }
+
+        const query = metricToQuery(metric, false)
+        expect(query).toEqual({
+            kind: NodeKind.FunnelsQuery,
+            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,
+            },
+            funnelsFilter: {
+                layout: FunnelLayout.horizontal,
+                funnelOrderType: StepOrderValue.UNORDERED,
+            },
+            filterTestAccounts: false,
+            series: [
+                {
+                    kind: NodeKind.EventsNode,
+                    event: '$pageview',
+                    name: '$pageview',
+                    custom_name: 'Placeholder for experiment exposure',
+                },
+                {
+                    kind: NodeKind.EventsNode,
+                    event: 'signup',
+                    name: 'signup',
+                },
+                {
+                    kind: NodeKind.EventsNode,
+                    event: 'purchase',
+                    name: 'purchase',
+                },
+            ],
+        })
+    })
+
+    it('returns the correct query for a funnel metric with sequential step order (default)', () => {
+        const metric: ExperimentMetric = {
+            kind: NodeKind.ExperimentMetric,
+            metric_type: ExperimentMetricType.FUNNEL,
+            series: [
+                {
+                    event: 'signup',
+                    kind: NodeKind.EventsNode,
+                    name: 'signup',
+                },
+                {
+                    event: 'purchase',
+                    kind: NodeKind.EventsNode,
+                    name: 'purchase',
+                },
+            ],
+            funnel_order_type: StepOrderValue.ORDERED,
+        }
+
+        const query = metricToQuery(metric, false)
+        expect(query).toEqual({
+            kind: NodeKind.FunnelsQuery,
+            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,
+            },
+            funnelsFilter: {
+                layout: FunnelLayout.horizontal,
+                funnelOrderType: StepOrderValue.ORDERED,
+            },
+            filterTestAccounts: false,
+            series: [
+                {
+                    kind: NodeKind.EventsNode,
+                    event: '$pageview',
+                    name: '$pageview',
+                    custom_name: 'Placeholder for experiment exposure',
+                },
+                {
+                    kind: NodeKind.EventsNode,
+                    event: 'signup',
+                    name: 'signup',
+                },
+                {
+                    kind: NodeKind.EventsNode,
+                    event: 'purchase',
+                    name: 'purchase',
+                },
+            ],
+        })
+    })
+
+    it('returns the correct query for a funnel metric without funnel_order_type specified', () => {
+        const metric: ExperimentMetric = {
+            kind: NodeKind.ExperimentMetric,
+            metric_type: ExperimentMetricType.FUNNEL,
+            series: [
+                {
+                    event: 'signup',
+                    kind: NodeKind.EventsNode,
+                    name: 'signup',
+                },
+                {
+                    event: 'purchase',
+                    kind: NodeKind.EventsNode,
+                    name: 'purchase',
+                },
+            ],
+        }
+
+        const query = metricToQuery(metric, false)
+        expect(query).toEqual({
+            kind: NodeKind.FunnelsQuery,
+            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,
+            },
+            funnelsFilter: {
+                layout: FunnelLayout.horizontal,
+            },
+            filterTestAccounts: false,
+            series: [
+                {
+                    kind: NodeKind.EventsNode,
+                    event: '$pageview',
+                    name: '$pageview',
+                    custom_name: 'Placeholder for experiment exposure',
+                },
+                {
+                    kind: NodeKind.EventsNode,
+                    event: 'signup',
+                    name: 'signup',
+                },
+                {
+                    kind: NodeKind.EventsNode,
+                    event: 'purchase',
+                    name: 'purchase',
+                },
+            ],
+        })
+    })
 })
 
 describe('isLegacyExperimentQuery', () => {
diff --git a/frontend/src/scenes/experiments/utils.ts b/frontend/src/scenes/experiments/utils.ts
index caff3ce2ce893..08d9bf89969d8 100644
--- a/frontend/src/scenes/experiments/utils.ts
+++ b/frontend/src/scenes/experiments/utils.ts
@@ -428,6 +428,7 @@ export function getExperimentMetricFromInsight(
                 layout: insight.query.source.funnelsFilter?.layout,
                 breakdownAttributionType: insight.query.source.funnelsFilter?.breakdownAttributionType,
                 breakdownAttributionValue: insight.query.source.funnelsFilter?.breakdownAttributionValue,
+                funnelOrderType: insight.query.source.funnelsFilter?.funnelOrderType,
             },
             filterTestAccounts: insight.query.source.filterTestAccounts,
         })
@@ -726,6 +727,7 @@ export function metricToQuery(
                 },
                 funnelsFilter: {
                     layout: FunnelLayout.horizontal,
+                    ...(metric.funnel_order_type && { funnelOrderType: metric.funnel_order_type }),
                 },
                 series: getFunnelPreviewSeries(metric),
             } as FunnelsQuery
diff --git a/posthog/hogql_queries/experiments/funnel_query_utils.py b/posthog/hogql_queries/experiments/funnel_query_utils.py
index 778ea358864ca..a5bfe570c1563 100644
--- a/posthog/hogql_queries/experiments/funnel_query_utils.py
+++ b/posthog/hogql_queries/experiments/funnel_query_utils.py
@@ -47,6 +47,9 @@ def funnel_evaluation_expr(team: Team, funnel_metric: ExperimentFunnelMetric, ev
 
     step_conditions_str = ", ".join(step_conditions)
 
+    # Determine funnel order type - default to "ordered" for backward compatibility
+    funnel_order_type = funnel_metric.funnel_order_type or "ordered"
+
     expression = f"""
     if(
         length(
@@ -55,7 +58,7 @@ def funnel_evaluation_expr(team: Team, funnel_metric: ExperimentFunnelMetric, ev
                     {num_steps},
                     {conversion_window_seconds},
                     'first_touch',
-                    'ordered',
+                    '{funnel_order_type}',
                     array(array('')),
                     arraySort(t -> t.1, groupArray(tuple(
                         toFloat({timestamp_field}),
diff --git a/posthog/hogql_queries/experiments/test/experiment_query_runner/__snapshots__/test_funnel_metric.ambr b/posthog/hogql_queries/experiments/test/experiment_query_runner/__snapshots__/test_funnel_metric.ambr
index 65abeda5c3d13..c6532c05809d1 100644
--- a/posthog/hogql_queries/experiments/test/experiment_query_runner/__snapshots__/test_funnel_metric.ambr
+++ b/posthog/hogql_queries/experiments/test/experiment_query_runner/__snapshots__/test_funnel_metric.ambr
@@ -8,7 +8,7 @@
   FROM
     (SELECT exposures.variant AS variant,
             exposures.entity_id AS entity_id,
-            if(ifNull(greater(length(arrayFilter(result -> ifNull(greaterOrEquals(result.1, 1), 0), aggregate_funnel_array_v7(2, 94608000, 'first_touch', 'ordered', array(array('')), arraySort(t -> t.1, groupArray(tuple(accurateCastOrNull(metric_events.timestamp, 'Float64'), metric_events.uuid, array(''), arrayFilter(x -> ifNull(notEquals(x, 0), 1), [multiply(1, metric_events.step_0), multiply(2, metric_events.step_1)]))))))), 0), 0), 1, 0) AS value
+            if(ifNull(greater(length(arrayFilter(result -> ifNull(greaterOrEquals(result.1, 1), 0), aggregate_funnel_array(2, 94608000, 'first_touch', 'ordered', array(array('')), arraySort(t -> t.1, groupArray(tuple(accurateCastOrNull(metric_events.timestamp, 'Float64'), metric_events.uuid, array(''), arrayFilter(x -> ifNull(notEquals(x, 0), 1), [multiply(1, metric_events.step_0), multiply(2, metric_events.step_1)]))))))), 0), 0), 1, 0) AS value
      FROM
        (SELECT if(not(empty(events__override.distinct_id)), events__override.person_id, events.person_id) AS entity_id,
                if(ifNull(greater(count(DISTINCT replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(events.properties, '$feature_flag_response'), ''), 'null'), '^"|"$', '')), 1), 0), '$multiple', any(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(events.properties, '$feature_flag_response'), ''), 'null'), '^"|"$', ''))) AS variant,
@@ -79,7 +79,149 @@
   FROM
     (SELECT exposures.variant AS variant,
             exposures.entity_id AS entity_id,
-            if(ifNull(greater(length(arrayFilter(result -> ifNull(greaterOrEquals(result.1, 1), 0), aggregate_funnel_array_v7(2, 94608000, 'first_touch', 'ordered', array(array('')), arraySort(t -> t.1, groupArray(tuple(accurateCastOrNull(metric_events.timestamp, 'Float64'), metric_events.uuid, array(''), arrayFilter(x -> ifNull(notEquals(x, 0), 1), [multiply(1, metric_events.step_0), multiply(2, metric_events.step_1)]))))))), 0), 0), 1, 0) AS value
+            if(ifNull(greater(length(arrayFilter(result -> ifNull(greaterOrEquals(result.1, 1), 0), aggregate_funnel_array(2, 94608000, 'first_touch', 'ordered', array(array('')), arraySort(t -> t.1, groupArray(tuple(accurateCastOrNull(metric_events.timestamp, 'Float64'), metric_events.uuid, array(''), arrayFilter(x -> ifNull(notEquals(x, 0), 1), [multiply(1, metric_events.step_0), multiply(2, metric_events.step_1)]))))))), 0), 0), 1, 0) AS value
+     FROM
+       (SELECT if(not(empty(events__override.distinct_id)), events__override.person_id, events.person_id) AS entity_id,
+               if(ifNull(greater(count(DISTINCT replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(events.properties, '$feature_flag_response'), ''), 'null'), '^"|"$', '')), 1), 0), '$multiple', any(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(events.properties, '$feature_flag_response'), ''), 'null'), '^"|"$', ''))) AS variant,
+               min(toTimeZone(events.timestamp, 'UTC')) AS first_exposure_time
+        FROM events
+        LEFT OUTER JOIN
+          (SELECT argMax(person_distinct_id_overrides.person_id, person_distinct_id_overrides.version) AS person_id,
+                  person_distinct_id_overrides.distinct_id AS distinct_id
+           FROM person_distinct_id_overrides
+           WHERE equals(person_distinct_id_overrides.team_id, 99999)
+           GROUP BY person_distinct_id_overrides.distinct_id
+           HAVING ifNull(equals(argMax(person_distinct_id_overrides.is_deleted, person_distinct_id_overrides.version), 0), 0) SETTINGS optimize_aggregation_in_order=1) AS events__override ON equals(events.distinct_id, events__override.distinct_id)
+        WHERE and(equals(events.team_id, 99999), greaterOrEquals(toTimeZone(events.timestamp, 'UTC'), toDateTime64('today', 6, 'UTC')), lessOrEquals(toTimeZone(events.timestamp, 'UTC'), toDateTime64('explicit_redacted_timestamp', 6, 'UTC')), equals(events.event, '$feature_flag_called'), in(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(events.properties, '$feature_flag_response'), ''), 'null'), '^"|"$', ''), ['control', 'test']), ifNull(equals(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(events.properties, '$feature_flag'), ''), 'null'), '^"|"$', ''), 'test-experiment'), 0))
+        GROUP BY entity_id) AS exposures
+     LEFT JOIN
+       (SELECT toTimeZone(events.timestamp, 'UTC') AS timestamp,
+               if(not(empty(events__override.distinct_id)), events__override.person_id, events.person_id) AS entity_id,
+               exposure_data.variant AS variant,
+               events.event AS event,
+               events.uuid AS uuid,
+               events.properties AS properties,
+               if(equals(events.event, '$pageview'), 1, 0) AS step_0,
+               if(equals(events.event, 'purchase'), 1, 0) AS step_1
+        FROM events
+        LEFT OUTER JOIN
+          (SELECT argMax(person_distinct_id_overrides.person_id, person_distinct_id_overrides.version) AS person_id,
+                  person_distinct_id_overrides.distinct_id AS distinct_id
+           FROM person_distinct_id_overrides
+           WHERE equals(person_distinct_id_overrides.team_id, 99999)
+           GROUP BY person_distinct_id_overrides.distinct_id
+           HAVING ifNull(equals(argMax(person_distinct_id_overrides.is_deleted, person_distinct_id_overrides.version), 0), 0) SETTINGS optimize_aggregation_in_order=1) AS events__override ON equals(events.distinct_id, events__override.distinct_id)
+        INNER JOIN
+          (SELECT if(not(empty(events__override.distinct_id)), events__override.person_id, events.person_id) AS entity_id,
+                  if(ifNull(greater(count(DISTINCT replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(events.properties, '$feature_flag_response'), ''), 'null'), '^"|"$', '')), 1), 0), '$multiple', any(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(events.properties, '$feature_flag_response'), ''), 'null'), '^"|"$', ''))) AS variant,
+                  min(toTimeZone(events.timestamp, 'UTC')) AS first_exposure_time
+           FROM events
+           LEFT OUTER JOIN
+             (SELECT argMax(person_distinct_id_overrides.person_id, person_distinct_id_overrides.version) AS person_id,
+                     person_distinct_id_overrides.distinct_id AS distinct_id
+              FROM person_distinct_id_overrides
+              WHERE equals(person_distinct_id_overrides.team_id, 99999)
+              GROUP BY person_distinct_id_overrides.distinct_id
+              HAVING ifNull(equals(argMax(person_distinct_id_overrides.is_deleted, person_distinct_id_overrides.version), 0), 0) SETTINGS optimize_aggregation_in_order=1) AS events__override ON equals(events.distinct_id, events__override.distinct_id)
+           WHERE and(equals(events.team_id, 99999), greaterOrEquals(toTimeZone(events.timestamp, 'UTC'), toDateTime64('today', 6, 'UTC')), lessOrEquals(toTimeZone(events.timestamp, 'UTC'), toDateTime64('explicit_redacted_timestamp', 6, 'UTC')), equals(events.event, '$feature_flag_called'), in(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(events.properties, '$feature_flag_response'), ''), 'null'), '^"|"$', ''), ['control', 'test']), ifNull(equals(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(events.properties, '$feature_flag'), ''), 'null'), '^"|"$', ''), 'test-experiment'), 0))
+           GROUP BY entity_id) AS exposure_data ON equals(if(not(empty(events__override.distinct_id)), events__override.person_id, events.person_id), exposure_data.entity_id)
+        WHERE and(equals(events.team_id, 99999), greaterOrEquals(toTimeZone(events.timestamp, 'UTC'), toDateTime64('today', 6, 'UTC')), greaterOrEquals(toTimeZone(events.timestamp, 'UTC'), exposure_data.first_exposure_time), lessOrEquals(toTimeZone(events.timestamp, 'UTC'), toDateTime64('explicit_redacted_timestamp', 6, 'UTC')), or(equals(events.event, '$pageview'), equals(events.event, 'purchase')))) AS metric_events ON equals(toString(exposures.entity_id), toString(metric_events.entity_id))
+     GROUP BY exposures.variant,
+              exposures.entity_id) AS metric_events
+  GROUP BY metric_events.variant
+  LIMIT 100 SETTINGS readonly=2,
+                     max_execution_time=180,
+                     allow_experimental_object_type=1,
+                     format_csv_allow_double_quotes=0,
+                     max_ast_elements=4000000,
+                     max_expanded_ast_elements=4000000,
+                     max_bytes_before_external_group_by=0,
+                     transform_null_in=1,
+                     optimize_min_equality_disjunction_chain_length=4294967295,
+                     allow_experimental_join_condition=1
+  '''
+# ---
+# name: TestExperimentFunnelMetric.test_funnel_metric_ordered_vs_unordered_comparison
+  '''
+  SELECT metric_events.variant AS variant,
+         count(metric_events.entity_id) AS num_users,
+         sum(metric_events.value) AS total_sum,
+         sum(power(metric_events.value, 2)) AS total_sum_of_squares
+  FROM
+    (SELECT exposures.variant AS variant,
+            exposures.entity_id AS entity_id,
+            if(ifNull(greater(length(arrayFilter(result -> ifNull(greaterOrEquals(result.1, 1), 0), aggregate_funnel_array(2, 94608000, 'first_touch', 'ordered', array(array('')), arraySort(t -> t.1, groupArray(tuple(accurateCastOrNull(metric_events.timestamp, 'Float64'), metric_events.uuid, array(''), arrayFilter(x -> ifNull(notEquals(x, 0), 1), [multiply(1, metric_events.step_0), multiply(2, metric_events.step_1)]))))))), 0), 0), 1, 0) AS value
+     FROM
+       (SELECT if(not(empty(events__override.distinct_id)), events__override.person_id, events.person_id) AS entity_id,
+               if(ifNull(greater(count(DISTINCT replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(events.properties, '$feature_flag_response'), ''), 'null'), '^"|"$', '')), 1), 0), '$multiple', any(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(events.properties, '$feature_flag_response'), ''), 'null'), '^"|"$', ''))) AS variant,
+               min(toTimeZone(events.timestamp, 'UTC')) AS first_exposure_time
+        FROM events
+        LEFT OUTER JOIN
+          (SELECT argMax(person_distinct_id_overrides.person_id, person_distinct_id_overrides.version) AS person_id,
+                  person_distinct_id_overrides.distinct_id AS distinct_id
+           FROM person_distinct_id_overrides
+           WHERE equals(person_distinct_id_overrides.team_id, 99999)
+           GROUP BY person_distinct_id_overrides.distinct_id
+           HAVING ifNull(equals(argMax(person_distinct_id_overrides.is_deleted, person_distinct_id_overrides.version), 0), 0) SETTINGS optimize_aggregation_in_order=1) AS events__override ON equals(events.distinct_id, events__override.distinct_id)
+        WHERE and(equals(events.team_id, 99999), greaterOrEquals(toTimeZone(events.timestamp, 'UTC'), toDateTime64('today', 6, 'UTC')), lessOrEquals(toTimeZone(events.timestamp, 'UTC'), toDateTime64('explicit_redacted_timestamp', 6, 'UTC')), equals(events.event, '$feature_flag_called'), in(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(events.properties, '$feature_flag_response'), ''), 'null'), '^"|"$', ''), ['control', 'test']), ifNull(equals(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(events.properties, '$feature_flag'), ''), 'null'), '^"|"$', ''), 'test-experiment'), 0))
+        GROUP BY entity_id) AS exposures
+     LEFT JOIN
+       (SELECT toTimeZone(events.timestamp, 'UTC') AS timestamp,
+               if(not(empty(events__override.distinct_id)), events__override.person_id, events.person_id) AS entity_id,
+               exposure_data.variant AS variant,
+               events.event AS event,
+               events.uuid AS uuid,
+               events.properties AS properties,
+               if(equals(events.event, '$pageview'), 1, 0) AS step_0,
+               if(equals(events.event, 'purchase'), 1, 0) AS step_1
+        FROM events
+        LEFT OUTER JOIN
+          (SELECT argMax(person_distinct_id_overrides.person_id, person_distinct_id_overrides.version) AS person_id,
+                  person_distinct_id_overrides.distinct_id AS distinct_id
+           FROM person_distinct_id_overrides
+           WHERE equals(person_distinct_id_overrides.team_id, 99999)
+           GROUP BY person_distinct_id_overrides.distinct_id
+           HAVING ifNull(equals(argMax(person_distinct_id_overrides.is_deleted, person_distinct_id_overrides.version), 0), 0) SETTINGS optimize_aggregation_in_order=1) AS events__override ON equals(events.distinct_id, events__override.distinct_id)
+        INNER JOIN
+          (SELECT if(not(empty(events__override.distinct_id)), events__override.person_id, events.person_id) AS entity_id,
+                  if(ifNull(greater(count(DISTINCT replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(events.properties, '$feature_flag_response'), ''), 'null'), '^"|"$', '')), 1), 0), '$multiple', any(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(events.properties, '$feature_flag_response'), ''), 'null'), '^"|"$', ''))) AS variant,
+                  min(toTimeZone(events.timestamp, 'UTC')) AS first_exposure_time
+           FROM events
+           LEFT OUTER JOIN
+             (SELECT argMax(person_distinct_id_overrides.person_id, person_distinct_id_overrides.version) AS person_id,
+                     person_distinct_id_overrides.distinct_id AS distinct_id
+              FROM person_distinct_id_overrides
+              WHERE equals(person_distinct_id_overrides.team_id, 99999)
+              GROUP BY person_distinct_id_overrides.distinct_id
+              HAVING ifNull(equals(argMax(person_distinct_id_overrides.is_deleted, person_distinct_id_overrides.version), 0), 0) SETTINGS optimize_aggregation_in_order=1) AS events__override ON equals(events.distinct_id, events__override.distinct_id)
+           WHERE and(equals(events.team_id, 99999), greaterOrEquals(toTimeZone(events.timestamp, 'UTC'), toDateTime64('today', 6, 'UTC')), lessOrEquals(toTimeZone(events.timestamp, 'UTC'), toDateTime64('explicit_redacted_timestamp', 6, 'UTC')), equals(events.event, '$feature_flag_called'), in(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(events.properties, '$feature_flag_response'), ''), 'null'), '^"|"$', ''), ['control', 'test']), ifNull(equals(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(events.properties, '$feature_flag'), ''), 'null'), '^"|"$', ''), 'test-experiment'), 0))
+           GROUP BY entity_id) AS exposure_data ON equals(if(not(empty(events__override.distinct_id)), events__override.person_id, events.person_id), exposure_data.entity_id)
+        WHERE and(equals(events.team_id, 99999), greaterOrEquals(toTimeZone(events.timestamp, 'UTC'), toDateTime64('today', 6, 'UTC')), greaterOrEquals(toTimeZone(events.timestamp, 'UTC'), exposure_data.first_exposure_time), lessOrEquals(toTimeZone(events.timestamp, 'UTC'), toDateTime64('explicit_redacted_timestamp', 6, 'UTC')), or(equals(events.event, '$pageview'), equals(events.event, 'purchase')))) AS metric_events ON equals(toString(exposures.entity_id), toString(metric_events.entity_id))
+     GROUP BY exposures.variant,
+              exposures.entity_id) AS metric_events
+  GROUP BY metric_events.variant
+  LIMIT 100 SETTINGS readonly=2,
+                     max_execution_time=180,
+                     allow_experimental_object_type=1,
+                     format_csv_allow_double_quotes=0,
+                     max_ast_elements=4000000,
+                     max_expanded_ast_elements=4000000,
+                     max_bytes_before_external_group_by=0,
+                     transform_null_in=1,
+                     optimize_min_equality_disjunction_chain_length=4294967295,
+                     allow_experimental_join_condition=1
+  '''
+# ---
+# name: TestExperimentFunnelMetric.test_funnel_metric_ordered_vs_unordered_comparison.1
+  '''
+  SELECT metric_events.variant AS variant,
+         count(metric_events.entity_id) AS num_users,
+         sum(metric_events.value) AS total_sum,
+         sum(power(metric_events.value, 2)) AS total_sum_of_squares
+  FROM
+    (SELECT exposures.variant AS variant,
+            exposures.entity_id AS entity_id,
+            if(ifNull(greater(length(arrayFilter(result -> ifNull(greaterOrEquals(result.1, 1), 0), aggregate_funnel_array(2, 94608000, 'first_touch', 'unordered', array(array('')), arraySort(t -> t.1, groupArray(tuple(accurateCastOrNull(metric_events.timestamp, 'Float64'), metric_events.uuid, array(''), arrayFilter(x -> ifNull(notEquals(x, 0), 1), [multiply(1, metric_events.step_0), multiply(2, metric_events.step_1)]))))))), 0), 0), 1, 0) AS value
      FROM
        (SELECT if(not(empty(events__override.distinct_id)), events__override.person_id, events.person_id) AS entity_id,
                if(ifNull(greater(count(DISTINCT replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(events.properties, '$feature_flag_response'), ''), 'null'), '^"|"$', '')), 1), 0), '$multiple', any(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(events.properties, '$feature_flag_response'), ''), 'null'), '^"|"$', ''))) AS variant,
@@ -150,7 +292,7 @@
   FROM
     (SELECT exposures.variant AS variant,
             exposures.entity_id AS entity_id,
-            if(ifNull(greater(length(arrayFilter(result -> ifNull(greaterOrEquals(result.1, 1), 0), aggregate_funnel_array_v7(2, 94608000, 'first_touch', 'ordered', array(array('')), arraySort(t -> t.1, groupArray(tuple(accurateCastOrNull(metric_events.timestamp, 'Float64'), metric_events.uuid, array(''), arrayFilter(x -> ifNull(notEquals(x, 0), 1), [multiply(1, metric_events.step_0), multiply(2, metric_events.step_1)]))))))), 0), 0), 1, 0) AS value
+            if(ifNull(greater(length(arrayFilter(result -> ifNull(greaterOrEquals(result.1, 1), 0), aggregate_funnel_array(2, 94608000, 'first_touch', 'ordered', array(array('')), arraySort(t -> t.1, groupArray(tuple(accurateCastOrNull(metric_events.timestamp, 'Float64'), metric_events.uuid, array(''), arrayFilter(x -> ifNull(notEquals(x, 0), 1), [multiply(1, metric_events.step_0), multiply(2, metric_events.step_1)]))))))), 0), 0), 1, 0) AS value
      FROM
        (SELECT if(not(empty(events__override.distinct_id)), events__override.person_id, events.person_id) AS entity_id,
                if(ifNull(greater(count(DISTINCT replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(events.properties, '$feature_flag_response'), ''), 'null'), '^"|"$', '')), 1), 0), '$multiple', any(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(events.properties, '$feature_flag_response'), ''), 'null'), '^"|"$', ''))) AS variant,
@@ -221,7 +363,7 @@
   FROM
     (SELECT exposures.variant AS variant,
             exposures.entity_id AS entity_id,
-            if(ifNull(greater(length(arrayFilter(result -> ifNull(greaterOrEquals(result.1, 1), 0), aggregate_funnel_array_v7(2, 94608000, 'first_touch', 'ordered', array(array('')), arraySort(t -> t.1, groupArray(tuple(accurateCastOrNull(metric_events.timestamp, 'Float64'), metric_events.uuid, array(''), arrayFilter(x -> ifNull(notEquals(x, 0), 1), [multiply(1, metric_events.step_0), multiply(2, metric_events.step_1)]))))))), 0), 0), 1, 0) AS value
+            if(ifNull(greater(length(arrayFilter(result -> ifNull(greaterOrEquals(result.1, 1), 0), aggregate_funnel_array(2, 94608000, 'first_touch', 'ordered', array(array('')), arraySort(t -> t.1, groupArray(tuple(accurateCastOrNull(metric_events.timestamp, 'Float64'), metric_events.uuid, array(''), arrayFilter(x -> ifNull(notEquals(x, 0), 1), [multiply(1, metric_events.step_0), multiply(2, metric_events.step_1)]))))))), 0), 0), 1, 0) AS value
      FROM
        (SELECT if(not(empty(events__override.distinct_id)), events__override.person_id, events.person_id) AS entity_id,
                if(ifNull(greater(count(DISTINCT replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(events.properties, '$feature_flag_response'), ''), 'null'), '^"|"$', '')), 1), 0), '$multiple', any(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(events.properties, '$feature_flag_response'), ''), 'null'), '^"|"$', ''))) AS variant,
@@ -292,7 +434,7 @@
   FROM
     (SELECT exposures.variant AS variant,
             exposures.entity_id AS entity_id,
-            if(ifNull(greater(length(arrayFilter(result -> ifNull(greaterOrEquals(result.1, 1), 0), aggregate_funnel_array_v7(2, 86400, 'first_touch', 'ordered', array(array('')), arraySort(t -> t.1, groupArray(tuple(accurateCastOrNull(metric_events.timestamp, 'Float64'), metric_events.uuid, array(''), arrayFilter(x -> ifNull(notEquals(x, 0), 1), [multiply(1, metric_events.step_0), multiply(2, metric_events.step_1)]))))))), 0), 0), 1, 0) AS value
+            if(ifNull(greater(length(arrayFilter(result -> ifNull(greaterOrEquals(result.1, 1), 0), aggregate_funnel_array(2, 86400, 'first_touch', 'ordered', array(array('')), arraySort(t -> t.1, groupArray(tuple(accurateCastOrNull(metric_events.timestamp, 'Float64'), metric_events.uuid, array(''), arrayFilter(x -> ifNull(notEquals(x, 0), 1), [multiply(1, metric_events.step_0), multiply(2, metric_events.step_1)]))))))), 0), 0), 1, 0) AS value
      FROM
        (SELECT if(not(empty(events__override.distinct_id)), events__override.person_id, events.person_id) AS entity_id,
                if(ifNull(greater(count(DISTINCT replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(events.properties, '$feature_flag_response'), ''), 'null'), '^"|"$', '')), 1), 0), '$multiple', any(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(events.properties, '$feature_flag_response'), ''), 'null'), '^"|"$', ''))) AS variant,
@@ -363,7 +505,7 @@
   FROM
     (SELECT exposures.variant AS variant,
             exposures.entity_id AS entity_id,
-            if(ifNull(greater(length(arrayFilter(result -> ifNull(greaterOrEquals(result.1, 5), 0), aggregate_funnel_array_v7(6, 94608000, 'first_touch', 'ordered', array(array('')), arraySort(t -> t.1, groupArray(tuple(accurateCastOrNull(metric_events.timestamp, 'Float64'), metric_events.uuid, array(''), arrayFilter(x -> ifNull(notEquals(x, 0), 1), [multiply(1, metric_events.step_0), multiply(2, metric_events.step_1), multiply(3, metric_events.step_2), multiply(4, metric_events.step_3), multiply(5, metric_events.step_4), multiply(6, metric_events.step_5)]))))))), 0), 0), 1, 0) AS value
+            if(ifNull(greater(length(arrayFilter(result -> ifNull(greaterOrEquals(result.1, 5), 0), aggregate_funnel_array(6, 94608000, 'first_touch', 'ordered', array(array('')), arraySort(t -> t.1, groupArray(tuple(accurateCastOrNull(metric_events.timestamp, 'Float64'), metric_events.uuid, array(''), arrayFilter(x -> ifNull(notEquals(x, 0), 1), [multiply(1, metric_events.step_0), multiply(2, metric_events.step_1), multiply(3, metric_events.step_2), multiply(4, metric_events.step_3), multiply(5, metric_events.step_4), multiply(6, metric_events.step_5)]))))))), 0), 0), 1, 0) AS value
      FROM
        (SELECT if(not(empty(events__override.distinct_id)), events__override.person_id, events.person_id) AS entity_id,
                if(ifNull(greater(count(DISTINCT replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(events.properties, '$feature_flag_response'), ''), 'null'), '^"|"$', '')), 1), 0), '$multiple', any(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(events.properties, '$feature_flag_response'), ''), 'null'), '^"|"$', ''))) AS variant,
@@ -438,7 +580,7 @@
   FROM
     (SELECT exposures.variant AS variant,
             exposures.entity_id AS entity_id,
-            if(ifNull(greater(length(arrayFilter(result -> ifNull(greaterOrEquals(result.1, 2), 0), aggregate_funnel_array_v7(3, 94608000, 'first_touch', 'ordered', array(array('')), arraySort(t -> t.1, groupArray(tuple(accurateCastOrNull(metric_events.timestamp, 'Float64'), metric_events.uuid, array(''), arrayFilter(x -> ifNull(notEquals(x, 0), 1), [multiply(1, metric_events.step_0), multiply(2, metric_events.step_1), multiply(3, metric_events.step_2)]))))))), 0), 0), 1, 0) AS value
+            if(ifNull(greater(length(arrayFilter(result -> ifNull(greaterOrEquals(result.1, 2), 0), aggregate_funnel_array(3, 94608000, 'first_touch', 'ordered', array(array('')), arraySort(t -> t.1, groupArray(tuple(accurateCastOrNull(metric_events.timestamp, 'Float64'), metric_events.uuid, array(''), arrayFilter(x -> ifNull(notEquals(x, 0), 1), [multiply(1, metric_events.step_0), multiply(2, metric_events.step_1), multiply(3, metric_events.step_2)]))))))), 0), 0), 1, 0) AS value
      FROM
        (SELECT if(not(empty(events__override.distinct_id)), events__override.person_id, events.person_id) AS entity_id,
                if(ifNull(greater(count(DISTINCT replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(events.properties, '$feature_flag_response'), ''), 'null'), '^"|"$', '')), 1), 0), '$multiple', any(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(events.properties, '$feature_flag_response'), ''), 'null'), '^"|"$', ''))) AS variant,
@@ -510,7 +652,7 @@
   FROM
     (SELECT exposures.variant AS variant,
             exposures.entity_id AS entity_id,
-            if(ifNull(greater(length(arrayFilter(result -> ifNull(greaterOrEquals(result.1, 1), 0), aggregate_funnel_array_v7(2, 94608000, 'first_touch', 'ordered', array(array('')), arraySort(t -> t.1, groupArray(tuple(accurateCastOrNull(metric_events.timestamp, 'Float64'), metric_events.uuid, array(''), arrayFilter(x -> ifNull(notEquals(x, 0), 1), [multiply(1, metric_events.step_0), multiply(2, metric_events.step_1)]))))))), 0), 0), 1, 0) AS value
+            if(ifNull(greater(length(arrayFilter(result -> ifNull(greaterOrEquals(result.1, 1), 0), aggregate_funnel_array(2, 94608000, 'first_touch', 'ordered', array(array('')), arraySort(t -> t.1, groupArray(tuple(accurateCastOrNull(metric_events.timestamp, 'Float64'), metric_events.uuid, array(''), arrayFilter(x -> ifNull(notEquals(x, 0), 1), [multiply(1, metric_events.step_0), multiply(2, metric_events.step_1)]))))))), 0), 0), 1, 0) AS value
      FROM
        (SELECT if(not(empty(events__override.distinct_id)), events__override.person_id, events.person_id) AS entity_id,
                if(ifNull(greater(count(DISTINCT replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(events.properties, '$feature_flag_response'), ''), 'null'), '^"|"$', '')), 1), 0), '$multiple', any(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(events.properties, '$feature_flag_response'), ''), 'null'), '^"|"$', ''))) AS variant,
@@ -572,6 +714,77 @@
                      allow_experimental_join_condition=1
   '''
 # ---
+# name: TestExperimentFunnelMetric.test_funnel_metric_with_unordered_steps
+  '''
+  SELECT metric_events.variant AS variant,
+         count(metric_events.entity_id) AS num_users,
+         sum(metric_events.value) AS total_sum,
+         sum(power(metric_events.value, 2)) AS total_sum_of_squares
+  FROM
+    (SELECT exposures.variant AS variant,
+            exposures.entity_id AS entity_id,
+            if(ifNull(greater(length(arrayFilter(result -> ifNull(greaterOrEquals(result.1, 1), 0), aggregate_funnel_array(2, 94608000, 'first_touch', 'unordered', array(array('')), arraySort(t -> t.1, groupArray(tuple(accurateCastOrNull(metric_events.timestamp, 'Float64'), metric_events.uuid, array(''), arrayFilter(x -> ifNull(notEquals(x, 0), 1), [multiply(1, metric_events.step_0), multiply(2, metric_events.step_1)]))))))), 0), 0), 1, 0) AS value
+     FROM
+       (SELECT if(not(empty(events__override.distinct_id)), events__override.person_id, events.person_id) AS entity_id,
+               if(ifNull(greater(count(DISTINCT replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(events.properties, '$feature_flag_response'), ''), 'null'), '^"|"$', '')), 1), 0), '$multiple', any(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(events.properties, '$feature_flag_response'), ''), 'null'), '^"|"$', ''))) AS variant,
+               min(toTimeZone(events.timestamp, 'UTC')) AS first_exposure_time
+        FROM events
+        LEFT OUTER JOIN
+          (SELECT argMax(person_distinct_id_overrides.person_id, person_distinct_id_overrides.version) AS person_id,
+                  person_distinct_id_overrides.distinct_id AS distinct_id
+           FROM person_distinct_id_overrides
+           WHERE equals(person_distinct_id_overrides.team_id, 99999)
+           GROUP BY person_distinct_id_overrides.distinct_id
+           HAVING ifNull(equals(argMax(person_distinct_id_overrides.is_deleted, person_distinct_id_overrides.version), 0), 0) SETTINGS optimize_aggregation_in_order=1) AS events__override ON equals(events.distinct_id, events__override.distinct_id)
+        WHERE and(equals(events.team_id, 99999), greaterOrEquals(toTimeZone(events.timestamp, 'UTC'), toDateTime64('today', 6, 'UTC')), lessOrEquals(toTimeZone(events.timestamp, 'UTC'), toDateTime64('explicit_redacted_timestamp', 6, 'UTC')), equals(events.event, '$feature_flag_called'), in(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(events.properties, '$feature_flag_response'), ''), 'null'), '^"|"$', ''), ['control', 'test']), ifNull(equals(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(events.properties, '$feature_flag'), ''), 'null'), '^"|"$', ''), 'test-experiment'), 0))
+        GROUP BY entity_id) AS exposures
+     LEFT JOIN
+       (SELECT toTimeZone(events.timestamp, 'UTC') AS timestamp,
+               if(not(empty(events__override.distinct_id)), events__override.person_id, events.person_id) AS entity_id,
+               exposure_data.variant AS variant,
+               events.event AS event,
+               events.uuid AS uuid,
+               events.properties AS properties,
+               if(equals(events.event, '$pageview'), 1, 0) AS step_0,
+               if(equals(events.event, 'purchase'), 1, 0) AS step_1
+        FROM events
+        LEFT OUTER JOIN
+          (SELECT argMax(person_distinct_id_overrides.person_id, person_distinct_id_overrides.version) AS person_id,
+                  person_distinct_id_overrides.distinct_id AS distinct_id
+           FROM person_distinct_id_overrides
+           WHERE equals(person_distinct_id_overrides.team_id, 99999)
+           GROUP BY person_distinct_id_overrides.distinct_id
+           HAVING ifNull(equals(argMax(person_distinct_id_overrides.is_deleted, person_distinct_id_overrides.version), 0), 0) SETTINGS optimize_aggregation_in_order=1) AS events__override ON equals(events.distinct_id, events__override.distinct_id)
+        INNER JOIN
+          (SELECT if(not(empty(events__override.distinct_id)), events__override.person_id, events.person_id) AS entity_id,
+                  if(ifNull(greater(count(DISTINCT replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(events.properties, '$feature_flag_response'), ''), 'null'), '^"|"$', '')), 1), 0), '$multiple', any(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(events.properties, '$feature_flag_response'), ''), 'null'), '^"|"$', ''))) AS variant,
+                  min(toTimeZone(events.timestamp, 'UTC')) AS first_exposure_time
+           FROM events
+           LEFT OUTER JOIN
+             (SELECT argMax(person_distinct_id_overrides.person_id, person_distinct_id_overrides.version) AS person_id,
+                     person_distinct_id_overrides.distinct_id AS distinct_id
+              FROM person_distinct_id_overrides
+              WHERE equals(person_distinct_id_overrides.team_id, 99999)
+              GROUP BY person_distinct_id_overrides.distinct_id
+              HAVING ifNull(equals(argMax(person_distinct_id_overrides.is_deleted, person_distinct_id_overrides.version), 0), 0) SETTINGS optimize_aggregation_in_order=1) AS events__override ON equals(events.distinct_id, events__override.distinct_id)
+           WHERE and(equals(events.team_id, 99999), greaterOrEquals(toTimeZone(events.timestamp, 'UTC'), toDateTime64('today', 6, 'UTC')), lessOrEquals(toTimeZone(events.timestamp, 'UTC'), toDateTime64('explicit_redacted_timestamp', 6, 'UTC')), equals(events.event, '$feature_flag_called'), in(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(events.properties, '$feature_flag_response'), ''), 'null'), '^"|"$', ''), ['control', 'test']), ifNull(equals(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(events.properties, '$feature_flag'), ''), 'null'), '^"|"$', ''), 'test-experiment'), 0))
+           GROUP BY entity_id) AS exposure_data ON equals(if(not(empty(events__override.distinct_id)), events__override.person_id, events.person_id), exposure_data.entity_id)
+        WHERE and(equals(events.team_id, 99999), greaterOrEquals(toTimeZone(events.timestamp, 'UTC'), toDateTime64('today', 6, 'UTC')), greaterOrEquals(toTimeZone(events.timestamp, 'UTC'), exposure_data.first_exposure_time), lessOrEquals(toTimeZone(events.timestamp, 'UTC'), toDateTime64('explicit_redacted_timestamp', 6, 'UTC')), or(equals(events.event, '$pageview'), equals(events.event, 'purchase')))) AS metric_events ON equals(toString(exposures.entity_id), toString(metric_events.entity_id))
+     GROUP BY exposures.variant,
+              exposures.entity_id) AS metric_events
+  GROUP BY metric_events.variant
+  LIMIT 100 SETTINGS readonly=2,
+                     max_execution_time=180,
+                     allow_experimental_object_type=1,
+                     format_csv_allow_double_quotes=0,
+                     max_ast_elements=4000000,
+                     max_expanded_ast_elements=4000000,
+                     max_bytes_before_external_group_by=0,
+                     transform_null_in=1,
+                     optimize_min_equality_disjunction_chain_length=4294967295,
+                     allow_experimental_join_condition=1
+  '''
+# ---
 # name: TestExperimentFunnelMetric.test_query_runner_funnel_metric
   '''
   SELECT metric_events.variant AS variant,
@@ -581,7 +794,7 @@
   FROM
     (SELECT exposures.variant AS variant,
             exposures.entity_id AS entity_id,
-            if(ifNull(greater(length(arrayFilter(result -> ifNull(greaterOrEquals(result.1, 0), 0), aggregate_funnel_array_v7(1, 94608000, 'first_touch', 'ordered', array(array('')), arraySort(t -> t.1, groupArray(tuple(accurateCastOrNull(metric_events.timestamp, 'Float64'), metric_events.uuid, array(''), arrayFilter(x -> ifNull(notEquals(x, 0), 1), [multiply(1, metric_events.step_0)]))))))), 0), 0), 1, 0) AS value
+            if(ifNull(greater(length(arrayFilter(result -> ifNull(greaterOrEquals(result.1, 0), 0), aggregate_funnel_array(1, 94608000, 'first_touch', 'ordered', array(array('')), arraySort(t -> t.1, groupArray(tuple(accurateCastOrNull(metric_events.timestamp, 'Float64'), metric_events.uuid, array(''), arrayFilter(x -> ifNull(notEquals(x, 0), 1), [multiply(1, metric_events.step_0)]))))))), 0), 0), 1, 0) AS value
      FROM
        (SELECT if(not(empty(events__override.distinct_id)), events__override.person_id, events.person_id) AS entity_id,
                if(ifNull(greater(count(DISTINCT replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(events.properties, '$feature_flag_response'), ''), 'null'), '^"|"$', '')), 1), 0), '$multiple', any(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(events.properties, '$feature_flag_response'), ''), 'null'), '^"|"$', ''))) AS variant,
@@ -651,7 +864,7 @@
   FROM
     (SELECT exposures.variant AS variant,
             exposures.entity_id AS entity_id,
-            if(ifNull(greater(length(arrayFilter(result -> ifNull(greaterOrEquals(result.1, 0), 0), aggregate_funnel_array_v7(1, 94608000, 'first_touch', 'ordered', array(array('')), arraySort(t -> t.1, groupArray(tuple(accurateCastOrNull(metric_events.timestamp, 'Float64'), metric_events.uuid, array(''), arrayFilter(x -> ifNull(notEquals(x, 0), 1), [multiply(1, metric_events.step_0)]))))))), 0), 0), 1, 0) AS value
+            if(ifNull(greater(length(arrayFilter(result -> ifNull(greaterOrEquals(result.1, 0), 0), aggregate_funnel_array(1, 94608000, 'first_touch', 'ordered', array(array('')), arraySort(t -> t.1, groupArray(tuple(accurateCastOrNull(metric_events.timestamp, 'Float64'), metric_events.uuid, array(''), arrayFilter(x -> ifNull(notEquals(x, 0), 1), [multiply(1, metric_events.step_0)]))))))), 0), 0), 1, 0) AS value
      FROM
        (SELECT events.`$group_0` AS entity_id,
                if(ifNull(greater(count(DISTINCT replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(events.properties, '$feature_flag_response'), ''), 'null'), '^"|"$', '')), 1), 0), '$multiple', any(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(events.properties, '$feature_flag_response'), ''), 'null'), '^"|"$', ''))) AS variant,
@@ -709,7 +922,7 @@
   FROM
     (SELECT exposures.variant AS variant,
             exposures.entity_id AS entity_id,
-            if(ifNull(greater(length(arrayFilter(result -> ifNull(greaterOrEquals(result.1, 0), 0), aggregate_funnel_array_v7(1, 94608000, 'first_touch', 'ordered', array(array('')), arraySort(t -> t.1, groupArray(tuple(accurateCastOrNull(metric_events.timestamp, 'Float64'), metric_events.uuid, array(''), arrayFilter(x -> ifNull(notEquals(x, 0), 1), [multiply(1, metric_events.step_0)]))))))), 0), 0), 1, 0) AS value
+            if(ifNull(greater(length(arrayFilter(result -> ifNull(greaterOrEquals(result.1, 0), 0), aggregate_funnel_array(1, 94608000, 'first_touch', 'ordered', array(array('')), arraySort(t -> t.1, groupArray(tuple(accurateCastOrNull(metric_events.timestamp, 'Float64'), metric_events.uuid, array(''), arrayFilter(x -> ifNull(notEquals(x, 0), 1), [multiply(1, metric_events.step_0)]))))))), 0), 0), 1, 0) AS value
      FROM
        (SELECT if(not(empty(events__override.distinct_id)), events__override.person_id, events.person_id) AS entity_id,
                if(ifNull(greater(count(DISTINCT replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(events.properties, '$feature_flag_response'), ''), 'null'), '^"|"$', '')), 1), 0), '$multiple', any(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(events.properties, '$feature_flag_response'), ''), 'null'), '^"|"$', ''))) AS variant,
@@ -788,7 +1001,7 @@
   FROM
     (SELECT exposures.variant AS variant,
             exposures.entity_id AS entity_id,
-            if(ifNull(greater(length(arrayFilter(result -> ifNull(greaterOrEquals(result.1, 0), 0), aggregate_funnel_array_v7(1, 94608000, 'first_touch', 'ordered', array(array('')), arraySort(t -> t.1, groupArray(tuple(accurateCastOrNull(metric_events.timestamp, 'Float64'), metric_events.uuid, array(''), arrayFilter(x -> ifNull(notEquals(x, 0), 1), [multiply(1, metric_events.step_0)]))))))), 0), 0), 1, 0) AS value
+            if(ifNull(greater(length(arrayFilter(result -> ifNull(greaterOrEquals(result.1, 0), 0), aggregate_funnel_array(1, 94608000, 'first_touch', 'ordered', array(array('')), arraySort(t -> t.1, groupArray(tuple(accurateCastOrNull(metric_events.timestamp, 'Float64'), metric_events.uuid, array(''), arrayFilter(x -> ifNull(notEquals(x, 0), 1), [multiply(1, metric_events.step_0)]))))))), 0), 0), 1, 0) AS value
      FROM
        (SELECT if(not(empty(events__override.distinct_id)), events__override.person_id, events.person_id) AS entity_id,
                if(ifNull(greater(count(DISTINCT replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(events.properties, '$feature_flag_response'), ''), 'null'), '^"|"$', '')), 1), 0), '$multiple', any(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(events.properties, '$feature_flag_response'), ''), 'null'), '^"|"$', ''))) AS variant,
@@ -867,7 +1080,7 @@
   FROM
     (SELECT exposures.variant AS variant,
             exposures.entity_id AS entity_id,
-            if(ifNull(greater(length(arrayFilter(result -> ifNull(greaterOrEquals(result.1, 0), 0), aggregate_funnel_array_v7(1, 94608000, 'first_touch', 'ordered', array(array('')), arraySort(t -> t.1, groupArray(tuple(accurateCastOrNull(metric_events.timestamp, 'Float64'), metric_events.uuid, array(''), arrayFilter(x -> ifNull(notEquals(x, 0), 1), [multiply(1, metric_events.step_0)]))))))), 0), 0), 1, 0) AS value
+            if(ifNull(greater(length(arrayFilter(result -> ifNull(greaterOrEquals(result.1, 0), 0), aggregate_funnel_array(1, 94608000, 'first_touch', 'ordered', array(array('')), arraySort(t -> t.1, groupArray(tuple(accurateCastOrNull(metric_events.timestamp, 'Float64'), metric_events.uuid, array(''), arrayFilter(x -> ifNull(notEquals(x, 0), 1), [multiply(1, metric_events.step_0)]))))))), 0), 0), 1, 0) AS value
      FROM
        (SELECT if(not(empty(events__override.distinct_id)), events__override.person_id, events.person_id) AS entity_id,
                if(ifNull(greater(count(DISTINCT replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(events.properties, '$feature_flag_response'), ''), 'null'), '^"|"$', '')), 1), 0), '$multiple', any(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(events.properties, '$feature_flag_response'), ''), 'null'), '^"|"$', ''))) AS variant,
@@ -946,7 +1159,7 @@
   FROM
     (SELECT exposures.variant AS variant,
             exposures.entity_id AS entity_id,
-            if(ifNull(greater(length(arrayFilter(result -> ifNull(greaterOrEquals(result.1, 0), 0), aggregate_funnel_array_v7(1, 94608000, 'first_touch', 'ordered', array(array('')), arraySort(t -> t.1, groupArray(tuple(accurateCastOrNull(metric_events.timestamp, 'Float64'), metric_events.uuid, array(''), arrayFilter(x -> ifNull(notEquals(x, 0), 1), [multiply(1, metric_events.step_0)]))))))), 0), 0), 1, 0) AS value
+            if(ifNull(greater(length(arrayFilter(result -> ifNull(greaterOrEquals(result.1, 0), 0), aggregate_funnel_array(1, 94608000, 'first_touch', 'ordered', array(array('')), arraySort(t -> t.1, groupArray(tuple(accurateCastOrNull(metric_events.timestamp, 'Float64'), metric_events.uuid, array(''), arrayFilter(x -> ifNull(notEquals(x, 0), 1), [multiply(1, metric_events.step_0)]))))))), 0), 0), 1, 0) AS value
      FROM
        (SELECT if(not(empty(events__override.distinct_id)), events__override.person_id, events.person_id) AS entity_id,
                if(ifNull(greater(count(DISTINCT replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(events.properties, '$feature_flag_response'), ''), 'null'), '^"|"$', '')), 1), 0), '$multiple', any(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(events.properties, '$feature_flag_response'), ''), 'null'), '^"|"$', ''))) AS variant,
@@ -1055,7 +1268,7 @@
   FROM
     (SELECT exposures.variant AS variant,
             exposures.entity_id AS entity_id,
-            if(ifNull(greater(length(arrayFilter(result -> ifNull(greaterOrEquals(result.1, 0), 0), aggregate_funnel_array_v7(1, 94608000, 'first_touch', 'ordered', array(array('')), arraySort(t -> t.1, groupArray(tuple(accurateCastOrNull(metric_events.timestamp, 'Float64'), metric_events.uuid, array(''), arrayFilter(x -> ifNull(notEquals(x, 0), 1), [multiply(1, metric_events.step_0)]))))))), 0), 0), 1, 0) AS value
+            if(ifNull(greater(length(arrayFilter(result -> ifNull(greaterOrEquals(result.1, 0), 0), aggregate_funnel_array(1, 94608000, 'first_touch', 'ordered', array(array('')), arraySort(t -> t.1, groupArray(tuple(accurateCastOrNull(metric_events.timestamp, 'Float64'), metric_events.uuid, array(''), arrayFilter(x -> ifNull(notEquals(x, 0), 1), [multiply(1, metric_events.step_0)]))))))), 0), 0), 1, 0) AS value
      FROM
        (SELECT if(not(empty(events__override.distinct_id)), events__override.person_id, events.person_id) AS entity_id,
                if(ifNull(greater(count(DISTINCT replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(events.properties, '$feature_flag_response'), ''), 'null'), '^"|"$', '')), 1), 0), '$multiple', any(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(events.properties, '$feature_flag_response'), ''), 'null'), '^"|"$', ''))) AS variant,
@@ -1164,7 +1377,7 @@
   FROM
     (SELECT exposures.variant AS variant,
             exposures.entity_id AS entity_id,
-            if(ifNull(greater(length(arrayFilter(result -> ifNull(greaterOrEquals(result.1, 0), 0), aggregate_funnel_array_v7(1, 94608000, 'first_touch', 'ordered', array(array('')), arraySort(t -> t.1, groupArray(tuple(accurateCastOrNull(metric_events.timestamp, 'Float64'), metric_events.uuid, array(''), arrayFilter(x -> ifNull(notEquals(x, 0), 1), [multiply(1, metric_events.step_0)]))))))), 0), 0), 1, 0) AS value
+            if(ifNull(greater(length(arrayFilter(result -> ifNull(greaterOrEquals(result.1, 0), 0), aggregate_funnel_array(1, 94608000, 'first_touch', 'ordered', array(array('')), arraySort(t -> t.1, groupArray(tuple(accurateCastOrNull(metric_events.timestamp, 'Float64'), metric_events.uuid, array(''), arrayFilter(x -> ifNull(notEquals(x, 0), 1), [multiply(1, metric_events.step_0)]))))))), 0), 0), 1, 0) AS value
      FROM
        (SELECT if(not(empty(events__override.distinct_id)), events__override.person_id, events.person_id) AS entity_id,
                if(ifNull(greater(count(DISTINCT replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(events.properties, '$feature_flag_response'), ''), 'null'), '^"|"$', '')), 1), 0), '$multiple', any(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(events.properties, '$feature_flag_response'), ''), 'null'), '^"|"$', ''))) AS variant,
@@ -1273,7 +1486,7 @@
   FROM
     (SELECT exposures.variant AS variant,
             exposures.entity_id AS entity_id,
-            if(ifNull(greater(length(arrayFilter(result -> ifNull(greaterOrEquals(result.1, 0), 0), aggregate_funnel_array_v7(1, 94608000, 'first_touch', 'ordered', array(array('')), arraySort(t -> t.1, groupArray(tuple(accurateCastOrNull(metric_events.timestamp, 'Float64'), metric_events.uuid, array(''), arrayFilter(x -> ifNull(notEquals(x, 0), 1), [multiply(1, metric_events.step_0)]))))))), 0), 0), 1, 0) AS value
+            if(ifNull(greater(length(arrayFilter(result -> ifNull(greaterOrEquals(result.1, 0), 0), aggregate_funnel_array(1, 94608000, 'first_touch', 'ordered', array(array('')), arraySort(t -> t.1, groupArray(tuple(accurateCastOrNull(metric_events.timestamp, 'Float64'), metric_events.uuid, array(''), arrayFilter(x -> ifNull(notEquals(x, 0), 1), [multiply(1, metric_events.step_0)]))))))), 0), 0), 1, 0) AS value
      FROM
        (SELECT events.person_id AS entity_id,
                if(ifNull(greater(count(DISTINCT replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(events.properties, '$feature_flag_response'), ''), 'null'), '^"|"$', '')), 1), 0), '$multiple', any(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(events.properties, '$feature_flag_response'), ''), 'null'), '^"|"$', ''))) AS variant,
@@ -1331,7 +1544,7 @@
   FROM
     (SELECT exposures.variant AS variant,
             exposures.entity_id AS entity_id,
-            if(ifNull(greater(length(arrayFilter(result -> ifNull(greaterOrEquals(result.1, 0), 0), aggregate_funnel_array_v7(1, 94608000, 'first_touch', 'ordered', array(array('')), arraySort(t -> t.1, groupArray(tuple(accurateCastOrNull(metric_events.timestamp, 'Float64'), metric_events.uuid, array(''), arrayFilter(x -> ifNull(notEquals(x, 0), 1), [multiply(1, metric_events.step_0)]))))))), 0), 0), 1, 0) AS value
+            if(ifNull(greater(length(arrayFilter(result -> ifNull(greaterOrEquals(result.1, 0), 0), aggregate_funnel_array(1, 94608000, 'first_touch', 'ordered', array(array('')), arraySort(t -> t.1, groupArray(tuple(accurateCastOrNull(metric_events.timestamp, 'Float64'), metric_events.uuid, array(''), arrayFilter(x -> ifNull(notEquals(x, 0), 1), [multiply(1, metric_events.step_0)]))))))), 0), 0), 1, 0) AS value
      FROM
        (SELECT events.person_id AS entity_id,
                if(ifNull(greater(count(DISTINCT replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(events.properties, '$feature_flag_response'), ''), 'null'), '^"|"$', '')), 1), 0), '$multiple', any(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(events.properties, '$feature_flag_response'), ''), 'null'), '^"|"$', ''))) AS variant,
@@ -1389,7 +1602,7 @@
   FROM
     (SELECT exposures.variant AS variant,
             exposures.entity_id AS entity_id,
-            if(ifNull(greater(length(arrayFilter(result -> ifNull(greaterOrEquals(result.1, 0), 0), aggregate_funnel_array_v7(1, 94608000, 'first_touch', 'ordered', array(array('')), arraySort(t -> t.1, groupArray(tuple(accurateCastOrNull(metric_events.timestamp, 'Float64'), metric_events.uuid, array(''), arrayFilter(x -> ifNull(notEquals(x, 0), 1), [multiply(1, metric_events.step_0)]))))))), 0), 0), 1, 0) AS value
+            if(ifNull(greater(length(arrayFilter(result -> ifNull(greaterOrEquals(result.1, 0), 0), aggregate_funnel_array(1, 94608000, 'first_touch', 'ordered', array(array('')), arraySort(t -> t.1, groupArray(tuple(accurateCastOrNull(metric_events.timestamp, 'Float64'), metric_events.uuid, array(''), arrayFilter(x -> ifNull(notEquals(x, 0), 1), [multiply(1, metric_events.step_0)]))))))), 0), 0), 1, 0) AS value
      FROM
        (SELECT events.person_id AS entity_id,
                if(ifNull(greater(count(DISTINCT replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(events.properties, '$feature_flag_response'), ''), 'null'), '^"|"$', '')), 1), 0), '$multiple', any(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(events.properties, '$feature_flag_response'), ''), 'null'), '^"|"$', ''))) AS variant,
diff --git a/posthog/hogql_queries/experiments/test/experiment_query_runner/test_funnel_metric.py b/posthog/hogql_queries/experiments/test/experiment_query_runner/test_funnel_metric.py
index 89521379cda5c..939a08d3606b8 100644
--- a/posthog/hogql_queries/experiments/test/experiment_query_runner/test_funnel_metric.py
+++ b/posthog/hogql_queries/experiments/test/experiment_query_runner/test_funnel_metric.py
@@ -21,6 +21,7 @@
     PersonsOnEventsMode,
     ExperimentFunnelMetric,
     PropertyOperator,
+    StepOrderValue,
 )
 from posthog.test.base import (
     _create_event,
@@ -1757,3 +1758,312 @@ def test_funnel_metric_with_multiple_similar_steps(self):
         self.assertEqual(control_variant.failure_count, 0)
         self.assertEqual(test_variant.success_count, 0)
         self.assertEqual(test_variant.failure_count, 1)
+
+    @freeze_time("2024-01-01T12:00:00Z")
+    @snapshot_clickhouse_queries
+    def test_funnel_metric_with_unordered_steps(self):
+        feature_flag = self.create_feature_flag()
+        experiment = self.create_experiment(feature_flag=feature_flag)
+        experiment.stats_config = {"version": 2}
+        experiment.save()
+
+        ff_property = f"$feature/{feature_flag.key}"
+
+        # Create test data using journeys
+        journeys_for(
+            {
+                # User completes steps in reverse order - should succeed with unordered funnel
+                "user_control_1": [
+                    {
+                        "event": "$feature_flag_called",
+                        "timestamp": "2024-01-02T12:00:00",
+                        "properties": {
+                            "$feature_flag_response": "control",
+                            ff_property: "control",
+                            "$feature_flag": feature_flag.key,
+                        },
+                    },
+                    {
+                        "event": "purchase",
+                        "timestamp": "2024-01-02T12:01:00",
+                        "properties": {
+                            ff_property: "control",
+                        },
+                    },
+                    {
+                        "event": "$pageview",
+                        "timestamp": "2024-01-02T12:02:00",
+                        "properties": {
+                            ff_property: "control",
+                        },
+                    },
+                ],
+                # User completes steps in mixed order - should succeed with unordered funnel
+                "user_control_2": [
+                    {
+                        "event": "$feature_flag_called",
+                        "timestamp": "2024-01-02T12:00:00",
+                        "properties": {
+                            "$feature_flag_response": "control",
+                            ff_property: "control",
+                            "$feature_flag": feature_flag.key,
+                        },
+                    },
+                    {
+                        "event": "add_to_cart",
+                        "timestamp": "2024-01-02T12:01:00",
+                        "properties": {
+                            ff_property: "control",
+                        },
+                    },
+                    {
+                        "event": "$pageview",
+                        "timestamp": "2024-01-02T12:02:00",
+                        "properties": {
+                            ff_property: "control",
+                        },
+                    },
+                    {
+                        "event": "purchase",
+                        "timestamp": "2024-01-02T12:03:00",
+                        "properties": {
+                            ff_property: "control",
+                        },
+                    },
+                ],
+                # User completes only first step - should fail
+                "user_test_1": [
+                    {
+                        "event": "$feature_flag_called",
+                        "timestamp": "2024-01-02T12:00:00",
+                        "properties": {
+                            "$feature_flag_response": "test",
+                            ff_property: "test",
+                            "$feature_flag": feature_flag.key,
+                        },
+                    },
+                    {
+                        "event": "$pageview",
+                        "timestamp": "2024-01-02T12:01:00",
+                        "properties": {
+                            ff_property: "test",
+                        },
+                    },
+                ],
+                # User completes steps out of order - should succeed with unordered funnel
+                "user_test_2": [
+                    {
+                        "event": "$feature_flag_called",
+                        "timestamp": "2024-01-02T12:00:00",
+                        "properties": {
+                            "$feature_flag_response": "test",
+                            ff_property: "test",
+                            "$feature_flag": feature_flag.key,
+                        },
+                    },
+                    {
+                        "event": "purchase",
+                        "timestamp": "2024-01-02T12:01:00",
+                        "properties": {
+                            ff_property: "test",
+                        },
+                    },
+                    {
+                        "event": "add_to_cart",
+                        "timestamp": "2024-01-02T12:02:00",
+                        "properties": {
+                            ff_property: "test",
+                        },
+                    },
+                    {
+                        "event": "$pageview",
+                        "timestamp": "2024-01-02T12:03:00",
+                        "properties": {
+                            ff_property: "test",
+                        },
+                    },
+                ],
+            },
+            self.team,
+        )
+
+        flush_persons_and_events()
+
+        # Create funnel metric with unordered steps (simplified to 2 steps for debugging)
+        metric = ExperimentFunnelMetric(
+            series=[
+                EventsNode(event="$pageview"),
+                EventsNode(event="purchase"),
+            ],
+            funnel_order_type=StepOrderValue.UNORDERED,
+        )
+
+        experiment_query = ExperimentQuery(
+            experiment_id=experiment.id,
+            kind="ExperimentQuery",
+            metric=metric,
+        )
+
+        experiment.metrics = [metric.model_dump(mode="json")]
+        experiment.save()
+
+        query_runner = ExperimentQueryRunner(query=experiment_query, team=self.team)
+        result = cast(LegacyExperimentQueryResponse, query_runner.calculate())
+
+        self.assertEqual(len(result.variants), 2)
+
+        control_variant = cast(
+            ExperimentVariantFunnelsBaseStats, next(variant for variant in result.variants if variant.key == "control")
+        )
+        test_variant = cast(
+            ExperimentVariantFunnelsBaseStats, next(variant for variant in result.variants if variant.key == "test")
+        )
+
+        # With unordered 2-step funnel ($pageview + purchase), both control users should succeed
+        # (completed both steps in any order) and test user 2 should succeed (completed both steps),
+        # test user 1 should fail (only has $pageview, missing purchase)
+        self.assertEqual(control_variant.success_count, 2)
+        self.assertEqual(control_variant.failure_count, 0)
+        self.assertEqual(test_variant.success_count, 1)
+        self.assertEqual(test_variant.failure_count, 1)
+
+    @freeze_time("2024-01-01T12:00:00Z")
+    @snapshot_clickhouse_queries
+    def test_funnel_metric_ordered_vs_unordered_comparison(self):
+        """Test that ordered and unordered funnels behave differently when events are out of order"""
+        feature_flag = self.create_feature_flag()
+        experiment = self.create_experiment(feature_flag=feature_flag)
+        experiment.stats_config = {"version": 2}
+        experiment.save()
+
+        ff_property = f"$feature/{feature_flag.key}"
+
+        # Create test data where events occur in reverse order
+        journeys_for(
+            {
+                # Control user completes steps in reverse order
+                "user_control_1": [
+                    {
+                        "event": "$feature_flag_called",
+                        "timestamp": "2024-01-02T12:00:00",
+                        "properties": {
+                            "$feature_flag_response": "control",
+                            ff_property: "control",
+                            "$feature_flag": feature_flag.key,
+                        },
+                    },
+                    {
+                        "event": "purchase",  # step 2 happens first
+                        "timestamp": "2024-01-02T12:01:00",
+                        "properties": {
+                            ff_property: "control",
+                        },
+                    },
+                    {
+                        "event": "$pageview",  # step 1 happens second
+                        "timestamp": "2024-01-02T12:02:00",
+                        "properties": {
+                            ff_property: "control",
+                        },
+                    },
+                ],
+                # Test user completes steps in reverse order
+                "user_test_1": [
+                    {
+                        "event": "$feature_flag_called",
+                        "timestamp": "2024-01-02T12:00:00",
+                        "properties": {
+                            "$feature_flag_response": "test",
+                            ff_property: "test",
+                            "$feature_flag": feature_flag.key,
+                        },
+                    },
+                    {
+                        "event": "purchase",  # step 2 happens first
+                        "timestamp": "2024-01-02T12:01:00",
+                        "properties": {
+                            ff_property: "test",
+                        },
+                    },
+                    {
+                        "event": "$pageview",  # step 1 happens second
+                        "timestamp": "2024-01-02T12:02:00",
+                        "properties": {
+                            ff_property: "test",
+                        },
+                    },
+                ],
+            },
+            self.team,
+        )
+
+        flush_persons_and_events()
+
+        # Test with ordered funnel (should fail)
+        ordered_metric = ExperimentFunnelMetric(
+            series=[
+                EventsNode(event="$pageview"),
+                EventsNode(event="purchase"),
+            ],
+            funnel_order_type=StepOrderValue.ORDERED,
+        )
+
+        experiment_query = ExperimentQuery(
+            experiment_id=experiment.id,
+            kind="ExperimentQuery",
+            metric=ordered_metric,
+        )
+
+        experiment.metrics = [ordered_metric.model_dump(mode="json")]
+        experiment.save()
+
+        query_runner = ExperimentQueryRunner(query=experiment_query, team=self.team)
+        ordered_result = cast(LegacyExperimentQueryResponse, query_runner.calculate())
+
+        # Test with unordered funnel (should succeed)
+        unordered_metric = ExperimentFunnelMetric(
+            series=[
+                EventsNode(event="$pageview"),
+                EventsNode(event="purchase"),
+            ],
+            funnel_order_type=StepOrderValue.UNORDERED,
+        )
+
+        experiment_query.metric = unordered_metric
+        experiment.metrics = [unordered_metric.model_dump(mode="json")]
+        experiment.save()
+
+        query_runner = ExperimentQueryRunner(query=experiment_query, team=self.team)
+        unordered_result = cast(LegacyExperimentQueryResponse, query_runner.calculate())
+
+        # With ordered funnel, the out-of-order events should not be counted as success
+        ordered_control = cast(
+            ExperimentVariantFunnelsBaseStats,
+            next(variant for variant in ordered_result.variants if variant.key == "control"),
+        )
+        ordered_test = cast(
+            ExperimentVariantFunnelsBaseStats,
+            next(variant for variant in ordered_result.variants if variant.key == "test"),
+        )
+
+        # With unordered funnel, the out-of-order events should be counted as success
+        unordered_control = cast(
+            ExperimentVariantFunnelsBaseStats,
+            next(variant for variant in unordered_result.variants if variant.key == "control"),
+        )
+        unordered_test = cast(
+            ExperimentVariantFunnelsBaseStats,
+            next(variant for variant in unordered_result.variants if variant.key == "test"),
+        )
+
+        # Ordered should fail (0 success) because events are out of order
+        self.assertEqual(ordered_control.success_count, 0)
+        self.assertEqual(ordered_control.failure_count, 1)
+        self.assertEqual(ordered_test.success_count, 0)
+        self.assertEqual(ordered_test.failure_count, 1)
+
+        # Unordered should succeed (1 success) because order doesn't matter
+        self.assertEqual(unordered_control.success_count, 1)
+        self.assertEqual(unordered_control.failure_count, 0)
+        self.assertEqual(unordered_test.success_count, 1)
+        self.assertEqual(unordered_test.failure_count, 0)
diff --git a/posthog/management/commands/generate_experiment_data.py b/posthog/management/commands/generate_experiment_data.py
index 7eee28a17cc75..6b07ae24de4a4 100644
--- a/posthog/management/commands/generate_experiment_data.py
+++ b/posthog/management/commands/generate_experiment_data.py
@@ -259,8 +259,8 @@ def handle(self, *args, **options):
             )
 
             should_stop = False
-            for action in experiment_config.variants[variant].actions:
-                for _ in range(action.count):
+            for i, action in enumerate(experiment_config.variants[variant].actions):
+                for j in range(action.count):
                     if random.random() < action.probability:
                         # Prepare properties dictionary
                         properties: dict[str, Any] = {
@@ -281,7 +281,7 @@ def handle(self, *args, **options):
                         posthoganalytics.capture(
                             distinct_id=distinct_id,
                             event=action.event,
-                            timestamp=random_timestamp + timedelta(minutes=1),
+                            timestamp=random_timestamp + timedelta(minutes=1 + i + j),
                             properties=properties,
                         )
                     else:
diff --git a/posthog/schema.py b/posthog/schema.py
index ef49066875286..f1934f354ee50 100644
--- a/posthog/schema.py
+++ b/posthog/schema.py
@@ -9273,6 +9273,7 @@ class ExperimentFunnelMetricTypeProps(BaseModel):
     model_config = ConfigDict(
         extra="forbid",
     )
+    funnel_order_type: Optional[StepOrderValue] = None
     metric_type: Literal["funnel"] = "funnel"
     series: list[Union[EventsNode, ActionsNode]]
 
@@ -9684,6 +9685,7 @@ class ExperimentFunnelMetric(BaseModel):
     )
     conversion_window: Optional[int] = None
     conversion_window_unit: Optional[FunnelConversionWindowTimeUnit] = None
+    funnel_order_type: Optional[StepOrderValue] = None
     kind: Literal["ExperimentMetric"] = "ExperimentMetric"
     metric_type: Literal["funnel"] = "funnel"
     name: Optional[str] = None


Code review comments:
diff_hunk: @@ -281,7 +281,7 @@ def handle(self, *args, **options):
                         posthoganalytics.capture(
                             distinct_id=distinct_id,
                             event=action.event,
-                            timestamp=random_timestamp + timedelta(minutes=1),
+                            timestamp=random_timestamp + timedelta(minutes=1 + i + j),
path: posthog/management/commands/generate_experiment_data.py
text: @user1:
style: timestamp calculation with `1 + i + j` could cause events to overlap if there are many actions with high counts. Consider using a larger time increment

@author:
Fixed in 1badc66fd8c8

diff_hunk: @@ -0,0 +1,58 @@
+import { LemonLabel } from 'lib/lemon-ui/LemonLabel'
+import { LemonSelect } from 'lib/lemon-ui/LemonSelect'
+
+import { ExperimentFunnelMetric, ExperimentMetric } from '~/queries/schema/schema-general'
+import { StepOrderValue } from '~/types'
+
+const funnelOrderOptions = [
+    {
+        label: 'Sequential',
+        value: StepOrderValue.ORDERED,
+    },
+    {
+        label: 'Any order',
+        value: StepOrderValue.UNORDERED,
+    },
+]
+
+function StepOrderInfo(): JSX.Element {
+    return (
+        <ul className="list-disc pl-4">
+            <li>
+                <b>Sequential</b> - Step B must happen after Step A, but any number of events can happen between A and
+                B.
+            </li>
+            <li>
+                <b>Any order</b> - Steps can be completed in any sequence.
+            </li>
+        </ul>
+    )
+}
+
+export function ExperimentMetricFunnelOrderSelector({
+    metric,
+    handleSetMetric,
+}: {
+    metric: ExperimentFunnelMetric
+    handleSetMetric: (newMetric: ExperimentMetric) => void
+}): JSX.Element | null {
path: frontend/src/scenes/experiments/ExperimentMetricFunnelOrderSelector.tsx
text: @user1:
style: Return type can be just JSX.Element since the component never returns null

```suggestion
}): JSX.Element {
```

@author:
Fixed in ee3e61c6df76

diff_hunk: @@ -105,7 +105,9 @@ export const resultsBreakdownLogic = kea<resultsBreakdownLogicType>([
                         funnelsFilter: {
                             layout: FunnelLayout.vertical,
                             breakdownAttributionType: BreakdownAttributionType.FirstTouch,
-                            funnelOrderType: StepOrderValue.ORDERED,
+                            funnelOrderType: isExperimentFunnelMetric(metric)
+                                ? metric.funnel_order_type || StepOrderValue.ORDERED
+                                : StepOrderValue.ORDERED,
path: frontend/src/scenes/experiments/components/ResultsBreakdown/resultsBreakdownLogic.ts
text: @user1:
style: The ternary operator could be simplified to use nullish coalescing (??) since StepOrderValue.ORDERED is the fallback in both cases

```suggestion
                            funnelOrderType: isExperimentFunnelMetric(metric)
                                ? metric.funnel_order_type ?? StepOrderValue.ORDERED
                                : StepOrderValue.ORDERED,
```

@author:
Fixed in bb4009c7197b