From da1d9fbfe0bdff348c0715db69948b5c2a36c1ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20Oberm=C3=BCller?= Date: Mon, 26 Feb 2024 10:37:53 +0100 Subject: [PATCH] feat(hogql): add remaining funnel actors (#20496) --- ...d-query-execution-time-too-long--light.png | Bin 91876 -> 91875 bytes frontend/src/queries/schema.json | 9 +- frontend/src/queries/schema.ts | 5 +- .../src/scenes/funnels/FunnelLineGraph.tsx | 47 ++- .../src/scenes/funnels/funnelDataLogic.ts | 11 +- .../scenes/funnels/funnelPersonsModalLogic.ts | 12 +- .../insights/funnels/__init__.py | 4 + .../insights/funnels/funnel_strict_persons.py | 28 ++ .../insights/funnels/funnel_trends.py | 17 +- .../insights/funnels/funnel_trends_persons.py | 87 ++++++ .../funnels/funnel_unordered_persons.py | 37 +++ .../test_funnel_trends_persons.ambr | 277 ++++++++++++++++++ .../funnels/test/test_funnel_persons.py | 102 ++++--- .../test/test_funnel_strict_persons.py | 252 ++++++++++++++++ .../test/test_funnel_trends_persons.py | 162 ++++++++++ .../test/test_funnel_unordered_persons.py | 185 ++++++++++++ .../hogql_queries/insights/funnels/utils.py | 16 +- .../test_lifecycle_query_runner.ambr | 2 +- .../test/__snapshots__/test_trends.ambr | 16 +- posthog/schema.py | 8 +- 20 files changed, 1178 insertions(+), 99 deletions(-) create mode 100644 posthog/hogql_queries/insights/funnels/funnel_strict_persons.py create mode 100644 posthog/hogql_queries/insights/funnels/funnel_trends_persons.py create mode 100644 posthog/hogql_queries/insights/funnels/funnel_unordered_persons.py create mode 100644 posthog/hogql_queries/insights/funnels/test/__snapshots__/test_funnel_trends_persons.ambr create mode 100644 posthog/hogql_queries/insights/funnels/test/test_funnel_strict_persons.py create mode 100644 posthog/hogql_queries/insights/funnels/test/test_funnel_trends_persons.py create mode 100644 posthog/hogql_queries/insights/funnels/test/test_funnel_unordered_persons.py diff --git a/frontend/__snapshots__/scenes-app-insights-error-empty-states--estimated-query-execution-time-too-long--light.png b/frontend/__snapshots__/scenes-app-insights-error-empty-states--estimated-query-execution-time-too-long--light.png index 59b4ca4391b1e4763ed04a2da9feb2ef6662c148..e9efba458ed686f59da4a483f340bfccc0cf6c58 100644 GIT binary patch delta 22734 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 H*xdLpzyq!x delta 22870 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: JSX.Element | null { const { insightProps } = useValues(insightLogic) - const { indexedSteps, aggregationTargetLabel, incompletenessOffsetFromEnd, interval, querySource, insightData } = - useValues(funnelDataLogic(insightProps)) + const { + hogQLInsightsFunnelsFlagEnabled, + indexedSteps, + aggregationTargetLabel, + incompletenessOffsetFromEnd, + interval, + querySource, + insightData, + } = useValues(funnelDataLogic(insightProps)) if (!isInsightQueryNode(querySource)) { return null @@ -78,19 +85,31 @@ export function FunnelLineGraph({ const day = dataset?.days?.[index] ?? '' const label = dataset?.label ?? dataset?.labels?.[index] ?? '' - const filters = queryNodeToFilter(querySource) // for persons modal - const personsUrl = buildPeopleUrl({ - filters, - date_from: day ?? '', - response: insightData, - }) - if (personsUrl) { + const title = `${capitalizeFirstLetter( + aggregationTargetLabel.plural + )} converted on ${dayjs(label).format('MMMM Do YYYY')}` + + if (hogQLInsightsFunnelsFlagEnabled) { + const query: FunnelsActorsQuery = { + kind: NodeKind.InsightActorsQuery, + source: querySource, + funnelTrendsDropOff: false, + funnelTrendsEntrancePeriodStart: dayjs(day).format('YYYY-MM-DD HH:mm:ss'), + } openPersonsModal({ - url: personsUrl, - title: `${capitalizeFirstLetter( - aggregationTargetLabel.plural - )} converted on ${dayjs(label).format('MMMM Do YYYY')}`, + title, + query, + }) + } else { + const filters = queryNodeToFilter(querySource) // for persons modal + const personsUrl = buildPeopleUrl({ + filters, + date_from: day ?? '', + response: insightData, }) + if (personsUrl) { + openPersonsModal({ title, url: personsUrl }) + } } } } diff --git a/frontend/src/scenes/funnels/funnelDataLogic.ts b/frontend/src/scenes/funnels/funnelDataLogic.ts index 52dc7e90e74e6..5a1e708eb6b59 100644 --- a/frontend/src/scenes/funnels/funnelDataLogic.ts +++ b/frontend/src/scenes/funnels/funnelDataLogic.ts @@ -1,6 +1,7 @@ import { actions, connect, kea, key, path, props, reducers, selectors } from 'kea' -import { BIN_COUNT_AUTO } from 'lib/constants' +import { BIN_COUNT_AUTO, FEATURE_FLAGS } from 'lib/constants' import { dayjs } from 'lib/dayjs' +import { featureFlagLogic } from 'lib/logic/featureFlagLogic' import { average, percentage, sum } from 'lib/utils' import { insightVizDataLogic } from 'scenes/insights/insightVizDataLogic' import { keyForInsightLogicProps } from 'scenes/insights/sharedUtils' @@ -61,6 +62,8 @@ export const funnelDataLogic = kea([ ], groupsModel, ['aggregationLabel'], + featureFlagLogic, + ['featureFlags'], ], actions: [insightVizDataLogic(props), ['updateInsightFilter', 'updateQuerySource']], })), @@ -79,6 +82,12 @@ export const funnelDataLogic = kea([ }), selectors(() => ({ + hogQLInsightsFunnelsFlagEnabled: [ + (s) => [s.featureFlags], + (featureFlags): boolean => { + return !!featureFlags[FEATURE_FLAGS.HOGQL_INSIGHTS_FUNNELS] + }, + ], querySource: [ (s) => [s.vizQuerySource], (vizQuerySource) => (isFunnelsQuery(vizQuerySource) ? vizQuerySource : null), diff --git a/frontend/src/scenes/funnels/funnelPersonsModalLogic.ts b/frontend/src/scenes/funnels/funnelPersonsModalLogic.ts index 8f83fe3b62b68..909ace73eb886 100644 --- a/frontend/src/scenes/funnels/funnelPersonsModalLogic.ts +++ b/frontend/src/scenes/funnels/funnelPersonsModalLogic.ts @@ -1,6 +1,4 @@ import { actions, connect, kea, key, listeners, path, props, selectors } from 'kea' -import { FEATURE_FLAGS } from 'lib/constants' -import { featureFlagLogic } from 'lib/logic/featureFlagLogic' import { insightLogic } from 'scenes/insights/insightLogic' import { keyForInsightLogicProps } from 'scenes/insights/sharedUtils' import { funnelTitle } from 'scenes/trends/persons-modal/persons-modal-utils' @@ -36,9 +34,7 @@ export const funnelPersonsModalLogic = kea([ insightLogic(props), ['isInDashboardContext', 'isInExperimentContext'], funnelDataLogic(props), - ['steps', 'querySource', 'funnelsFilter'], - featureFlagLogic, - ['featureFlags'], + ['hogQLInsightsFunnelsFlagEnabled', 'steps', 'querySource', 'funnelsFilter'], ], })), @@ -82,12 +78,6 @@ export const funnelPersonsModalLogic = kea([ return !isInDashboardContext && !funnelsFilter?.funnelAggregateByHogQL }, ], - hogQLInsightsFunnelsFlagEnabled: [ - (s) => [s.featureFlags], - (featureFlags): boolean => { - return !!featureFlags[FEATURE_FLAGS.HOGQL_INSIGHTS_FUNNELS] - }, - ], }), listeners(({ values }) => ({ diff --git a/posthog/hogql_queries/insights/funnels/__init__.py b/posthog/hogql_queries/insights/funnels/__init__.py index 2e3275ff7fdfe..8a20d9784df8b 100644 --- a/posthog/hogql_queries/insights/funnels/__init__.py +++ b/posthog/hogql_queries/insights/funnels/__init__.py @@ -4,3 +4,7 @@ from .funnel_unordered import FunnelUnordered from .funnel_time_to_convert import FunnelTimeToConvert from .funnel_trends import FunnelTrends +from .funnel_persons import FunnelActors +from .funnel_strict_persons import FunnelStrictActors +from .funnel_unordered_persons import FunnelUnorderedActors +from .funnel_trends_persons import FunnelTrendsActors diff --git a/posthog/hogql_queries/insights/funnels/funnel_strict_persons.py b/posthog/hogql_queries/insights/funnels/funnel_strict_persons.py new file mode 100644 index 0000000000000..d457a50c93758 --- /dev/null +++ b/posthog/hogql_queries/insights/funnels/funnel_strict_persons.py @@ -0,0 +1,28 @@ +from typing import List + +from posthog.hogql import ast +from posthog.hogql_queries.insights.funnels.funnel_strict import FunnelStrict + + +class FunnelStrictActors(FunnelStrict): + def actor_query( + self, + # extra_fields: Optional[List[str]] = None, + ) -> ast.SelectQuery: + select: List[ast.Expr] = [ + ast.Alias(alias="actor_id", expr=ast.Field(chain=["aggregation_target"])), + *self._get_funnel_person_step_events(), + *self._get_timestamp_outer_select(), + # {extra_fields} + ] + select_from = ast.JoinExpr(table=self.get_step_counts_query()) + where = self._get_funnel_person_step_condition() + order_by = [ast.OrderExpr(expr=ast.Field(chain=["aggregation_target"]))] + + return ast.SelectQuery( + select=select, + select_from=select_from, + order_by=order_by, + where=where, + # SETTINGS max_ast_elements=1000000, max_expanded_ast_elements=1000000 + ) diff --git a/posthog/hogql_queries/insights/funnels/funnel_trends.py b/posthog/hogql_queries/insights/funnels/funnel_trends.py index 834c9e1e23f20..fc07841a6de4e 100644 --- a/posthog/hogql_queries/insights/funnels/funnel_trends.py +++ b/posthog/hogql_queries/insights/funnels/funnel_trends.py @@ -272,10 +272,6 @@ def get_step_counts_without_aggregation_query( steps_per_person_query = self.funnel_order.get_step_counts_without_aggregation_query() - # # This is used by funnel trends when we only need data for one period, e.g. person per data point - # if specific_entrance_period_start: - # self.params["entrance_period_start"] = specific_entrance_period_start.strftime(TIMESTAMP_FORMAT) - # event_select_clause = "" # if self._filter.include_recordings: # max_steps = len(self._filter.entities) @@ -291,14 +287,23 @@ def get_step_counts_without_aggregation_query( *breakdown_clause, ] select_from = ast.JoinExpr(table=steps_per_person_query) - # {"WHERE toDateTime(entrance_period_start) = %(entrance_period_start)s" if specific_entrance_period_start else ""} + # This is used by funnel trends when we only need data for one period, e.g. person per data point + where = ( + ast.CompareOperation( + op=ast.CompareOperationOp.Eq, + left=parse_expr("entrance_period_start"), + right=ast.Constant(value=specific_entrance_period_start), + ) + if specific_entrance_period_start + else None + ) group_by: List[ast.Expr] = [ ast.Field(chain=["aggregation_target"]), ast.Field(chain=["entrance_period_start"]), *breakdown_clause, ] - return ast.SelectQuery(select=select, select_from=select_from, group_by=group_by) + return ast.SelectQuery(select=select, select_from=select_from, where=where, group_by=group_by) def get_steps_reached_conditions(self) -> Tuple[str, str, str]: funnelsFilter, max_steps = self.context.funnelsFilter, self.context.max_steps diff --git a/posthog/hogql_queries/insights/funnels/funnel_trends_persons.py b/posthog/hogql_queries/insights/funnels/funnel_trends_persons.py new file mode 100644 index 0000000000000..571362e222957 --- /dev/null +++ b/posthog/hogql_queries/insights/funnels/funnel_trends_persons.py @@ -0,0 +1,87 @@ +from datetime import datetime +from typing import List + +from rest_framework.exceptions import ValidationError + +from posthog.hogql import ast +from posthog.hogql.parser import parse_expr +from posthog.hogql_queries.insights.funnels.funnel_query_context import FunnelQueryContext +from posthog.hogql_queries.insights.funnels.funnel_trends import FunnelTrends +from posthog.utils import relative_date_parse + + +class FunnelTrendsActors(FunnelTrends): + entrancePeriodStart: datetime + dropOff: bool + + def __init__(self, context: FunnelQueryContext, just_summarize=False): + super().__init__(context, just_summarize) + + team, actorsQuery = self.context.team, self.context.actorsQuery + + if actorsQuery is None: + raise ValidationError("No actors query present.") + + if actorsQuery.funnelTrendsDropOff is None: + raise ValidationError(f"Actors parameter `funnelTrendsDropOff` must be provided for funnel trends persons!") + + if actorsQuery.funnelTrendsEntrancePeriodStart is None: + raise ValidationError( + f"Actors parameter `funnelTrendsEntrancePeriodStart` must be provided funnel trends persons!" + ) + + entrancePeriodStart = relative_date_parse(actorsQuery.funnelTrendsEntrancePeriodStart, team.timezone_info) + if entrancePeriodStart is None: + raise ValidationError( + f"Actors parameter `funnelTrendsEntrancePeriodStart` must be a valid relative date string!" + ) + + self.dropOff = actorsQuery.funnelTrendsDropOff + self.entrancePeriodStart = entrancePeriodStart + + def _get_funnel_person_step_events(self) -> List[ast.Expr]: + # if self._filter.include_recordings: + # # Get the event that should be used to match the recording + # funnel_to_step = self._filter.funnel_to_step + # is_drop_off = self._filter.drop_off + + # if funnel_to_step is None or is_drop_off: + # # If there is no funnel_to_step or if we are looking for drop off, we need to get the users final event + # return ", final_matching_events as matching_events" + # else: + # # Otherwise, we return the event of the funnel_to_step + # self.params.update({"matching_events_step_num": funnel_to_step}) + # return ", step_%(matching_events_step_num)s_matching_events as matching_events" + return [] + + def actor_query(self) -> ast.SelectQuery: + step_counts_query = self.get_step_counts_without_aggregation_query( + specific_entrance_period_start=self.entrancePeriodStart + ) + + # Expects multiple rows for same person, first event time, steps taken. + ( + _, + reached_to_step_count_condition, + did_not_reach_to_step_count_condition, + ) = self.get_steps_reached_conditions() + + select: List[ast.Expr] = [ + ast.Alias(alias="actor_id", expr=ast.Field(chain=["aggregation_target"])), + *self._get_funnel_person_step_events(), + ] + select_from = ast.JoinExpr(table=step_counts_query) + where = ( + parse_expr(did_not_reach_to_step_count_condition) + if self.dropOff + else parse_expr(reached_to_step_count_condition) + ) + order_by = [ast.OrderExpr(expr=ast.Field(chain=["aggregation_target"]))] + + return ast.SelectQuery( + select=select, + select_from=select_from, + order_by=order_by, + where=where, + # SETTINGS max_ast_elements=1000000, max_expanded_ast_elements=1000000 + ) diff --git a/posthog/hogql_queries/insights/funnels/funnel_unordered_persons.py b/posthog/hogql_queries/insights/funnels/funnel_unordered_persons.py new file mode 100644 index 0000000000000..08e4b210b4f67 --- /dev/null +++ b/posthog/hogql_queries/insights/funnels/funnel_unordered_persons.py @@ -0,0 +1,37 @@ +from typing import List + +from posthog.hogql import ast +from posthog.hogql.parser import parse_expr +from posthog.hogql_queries.insights.funnels.funnel_unordered import FunnelUnordered + + +class FunnelUnorderedActors(FunnelUnordered): + def _get_funnel_person_step_events(self) -> List[ast.Expr]: + # Unordered funnels does not support matching events (and thereby recordings), + # but it simplifies the logic if we return an empty array for matching events + # if self._filter.include_recordings: + if False: + return [parse_expr("array() as matching_events")] # type: ignore + return [] + + def actor_query( + self, + # extra_fields: Optional[List[str]] = None, + ) -> ast.SelectQuery: + select: List[ast.Expr] = [ + ast.Alias(alias="actor_id", expr=ast.Field(chain=["aggregation_target"])), + *self._get_funnel_person_step_events(), + *self._get_timestamp_outer_select(), + # {extra_fields} + ] + select_from = ast.JoinExpr(table=self.get_step_counts_query()) + where = self._get_funnel_person_step_condition() + order_by = [ast.OrderExpr(expr=ast.Field(chain=["aggregation_target"]))] + + return ast.SelectQuery( + select=select, + select_from=select_from, + order_by=order_by, + where=where, + # SETTINGS max_ast_elements=1000000, max_expanded_ast_elements=1000000 + ) diff --git a/posthog/hogql_queries/insights/funnels/test/__snapshots__/test_funnel_trends_persons.ambr b/posthog/hogql_queries/insights/funnels/test/__snapshots__/test_funnel_trends_persons.ambr new file mode 100644 index 0000000000000..5c0eb5ea141c7 --- /dev/null +++ b/posthog/hogql_queries/insights/funnels/test/__snapshots__/test_funnel_trends_persons.ambr @@ -0,0 +1,277 @@ +# serializer version: 1 +# name: TestFunnelTrendsPersons.test_funnel_trend_persons_returns_recordings + ''' + SELECT persons.id, + persons.id AS id, + toTimeZone(persons.created_at, 'UTC') AS created_at, + 1 + FROM + (SELECT argMax(person.created_at, person.version) AS created_at, + person.id AS id + FROM person + WHERE equals(person.team_id, 2) + GROUP BY person.id + HAVING ifNull(equals(argMax(person.is_deleted, person.version), 0), 0) SETTINGS optimize_aggregation_in_order=1) AS persons + INNER JOIN + (SELECT aggregation_target AS actor_id + FROM + (SELECT aggregation_target AS aggregation_target, + toStartOfDay(timestamp) AS entrance_period_start, + max(steps) AS steps_completed + FROM + (SELECT aggregation_target AS aggregation_target, + timestamp AS timestamp, + step_0 AS step_0, + latest_0 AS latest_0, + step_1 AS step_1, + latest_1 AS latest_1, + step_2 AS step_2, + latest_2 AS latest_2, + if(and(ifNull(lessOrEquals(latest_0, latest_1), 0), ifNull(lessOrEquals(latest_1, plus(latest_0, toIntervalDay(14))), 0), ifNull(lessOrEquals(latest_1, latest_2), 0), ifNull(lessOrEquals(latest_2, plus(latest_0, toIntervalDay(14))), 0)), 3, if(and(ifNull(lessOrEquals(latest_0, latest_1), 0), ifNull(lessOrEquals(latest_1, plus(latest_0, toIntervalDay(14))), 0)), 2, 1)) AS steps, + if(and(isNotNull(latest_1), ifNull(lessOrEquals(latest_1, plus(latest_0, toIntervalDay(14))), 0)), dateDiff('second', latest_0, latest_1), NULL) AS step_1_conversion_time, + if(and(isNotNull(latest_2), ifNull(lessOrEquals(latest_2, plus(latest_1, toIntervalDay(14))), 0)), dateDiff('second', latest_1, latest_2), NULL) AS step_2_conversion_time + FROM + (SELECT aggregation_target AS aggregation_target, + timestamp AS timestamp, + step_0 AS step_0, + latest_0 AS latest_0, + step_1 AS step_1, + latest_1 AS latest_1, + step_2 AS step_2, + min(latest_2) OVER (PARTITION BY aggregation_target + ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) AS latest_2 + FROM + (SELECT aggregation_target AS aggregation_target, + timestamp AS timestamp, + step_0 AS step_0, + latest_0 AS latest_0, + step_1 AS step_1, + latest_1 AS latest_1, + step_2 AS step_2, + if(ifNull(less(latest_2, latest_1), 0), NULL, latest_2) AS latest_2 + FROM + (SELECT aggregation_target AS aggregation_target, + timestamp AS timestamp, + step_0 AS step_0, + latest_0 AS latest_0, + step_1 AS step_1, + min(latest_1) OVER (PARTITION BY aggregation_target + ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) AS latest_1, + step_2 AS step_2, + min(latest_2) OVER (PARTITION BY aggregation_target + ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) AS latest_2 + FROM + (SELECT toTimeZone(e.timestamp, 'UTC') AS timestamp, + e__pdi.person_id AS aggregation_target, + if(equals(e.event, 'step one'), 1, 0) AS step_0, + if(ifNull(equals(step_0, 1), 0), timestamp, NULL) AS latest_0, + if(equals(e.event, 'step two'), 1, 0) AS step_1, + if(ifNull(equals(step_1, 1), 0), timestamp, NULL) AS latest_1, + if(equals(e.event, 'step three'), 1, 0) AS step_2, + if(ifNull(equals(step_2, 1), 0), timestamp, NULL) AS latest_2 + FROM events AS e + INNER JOIN + (SELECT argMax(person_distinct_id2.person_id, person_distinct_id2.version) AS person_id, + person_distinct_id2.distinct_id AS distinct_id + FROM person_distinct_id2 + WHERE equals(person_distinct_id2.team_id, 2) + GROUP BY person_distinct_id2.distinct_id + HAVING ifNull(equals(argMax(person_distinct_id2.is_deleted, person_distinct_id2.version), 0), 0)) AS e__pdi ON equals(e.distinct_id, e__pdi.distinct_id) + WHERE and(equals(e.team_id, 2), and(and(greaterOrEquals(toTimeZone(e.timestamp, 'UTC'), toDateTime64('2021-05-01 00:00:00.000000', 6, 'UTC')), lessOrEquals(toTimeZone(e.timestamp, 'UTC'), toDateTime64('2021-05-07 23:59:59.999999', 6, 'UTC'))), in(e.event, tuple('step one', 'step three', 'step two'))), or(ifNull(equals(step_0, 1), 0), ifNull(equals(step_1, 1), 0), ifNull(equals(step_2, 1), 0))))))) + WHERE ifNull(equals(step_0, 1), 0)) + WHERE ifNull(equals(entrance_period_start, toDateTime64('2021-05-01 00:00:00.000000', 6, 'UTC')), 0) + GROUP BY aggregation_target, + entrance_period_start) + WHERE ifNull(greaterOrEquals(steps_completed, 2), 0) + ORDER BY aggregation_target ASC) AS source ON equals(persons.id, source.actor_id) + ORDER BY toTimeZone(persons.created_at, 'UTC') DESC + LIMIT 101 + OFFSET 0 SETTINGS readonly=2, + max_execution_time=60, + allow_experimental_object_type=1 + ''' +# --- +# name: TestFunnelTrendsPersons.test_funnel_trend_persons_with_drop_off + ''' + SELECT persons.id, + persons.id AS id, + toTimeZone(persons.created_at, 'UTC') AS created_at, + 1 + FROM + (SELECT argMax(person.created_at, person.version) AS created_at, + person.id AS id + FROM person + WHERE equals(person.team_id, 2) + GROUP BY person.id + HAVING ifNull(equals(argMax(person.is_deleted, person.version), 0), 0) SETTINGS optimize_aggregation_in_order=1) AS persons + INNER JOIN + (SELECT aggregation_target AS actor_id + FROM + (SELECT aggregation_target AS aggregation_target, + toStartOfDay(timestamp) AS entrance_period_start, + max(steps) AS steps_completed + FROM + (SELECT aggregation_target AS aggregation_target, + timestamp AS timestamp, + step_0 AS step_0, + latest_0 AS latest_0, + step_1 AS step_1, + latest_1 AS latest_1, + step_2 AS step_2, + latest_2 AS latest_2, + if(and(ifNull(lessOrEquals(latest_0, latest_1), 0), ifNull(lessOrEquals(latest_1, plus(latest_0, toIntervalDay(14))), 0), ifNull(lessOrEquals(latest_1, latest_2), 0), ifNull(lessOrEquals(latest_2, plus(latest_0, toIntervalDay(14))), 0)), 3, if(and(ifNull(lessOrEquals(latest_0, latest_1), 0), ifNull(lessOrEquals(latest_1, plus(latest_0, toIntervalDay(14))), 0)), 2, 1)) AS steps, + if(and(isNotNull(latest_1), ifNull(lessOrEquals(latest_1, plus(latest_0, toIntervalDay(14))), 0)), dateDiff('second', latest_0, latest_1), NULL) AS step_1_conversion_time, + if(and(isNotNull(latest_2), ifNull(lessOrEquals(latest_2, plus(latest_1, toIntervalDay(14))), 0)), dateDiff('second', latest_1, latest_2), NULL) AS step_2_conversion_time + FROM + (SELECT aggregation_target AS aggregation_target, + timestamp AS timestamp, + step_0 AS step_0, + latest_0 AS latest_0, + step_1 AS step_1, + latest_1 AS latest_1, + step_2 AS step_2, + min(latest_2) OVER (PARTITION BY aggregation_target + ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) AS latest_2 + FROM + (SELECT aggregation_target AS aggregation_target, + timestamp AS timestamp, + step_0 AS step_0, + latest_0 AS latest_0, + step_1 AS step_1, + latest_1 AS latest_1, + step_2 AS step_2, + if(ifNull(less(latest_2, latest_1), 0), NULL, latest_2) AS latest_2 + FROM + (SELECT aggregation_target AS aggregation_target, + timestamp AS timestamp, + step_0 AS step_0, + latest_0 AS latest_0, + step_1 AS step_1, + min(latest_1) OVER (PARTITION BY aggregation_target + ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) AS latest_1, + step_2 AS step_2, + min(latest_2) OVER (PARTITION BY aggregation_target + ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) AS latest_2 + FROM + (SELECT toTimeZone(e.timestamp, 'UTC') AS timestamp, + e__pdi.person_id AS aggregation_target, + if(equals(e.event, 'step one'), 1, 0) AS step_0, + if(ifNull(equals(step_0, 1), 0), timestamp, NULL) AS latest_0, + if(equals(e.event, 'step two'), 1, 0) AS step_1, + if(ifNull(equals(step_1, 1), 0), timestamp, NULL) AS latest_1, + if(equals(e.event, 'step three'), 1, 0) AS step_2, + if(ifNull(equals(step_2, 1), 0), timestamp, NULL) AS latest_2 + FROM events AS e + INNER JOIN + (SELECT argMax(person_distinct_id2.person_id, person_distinct_id2.version) AS person_id, + person_distinct_id2.distinct_id AS distinct_id + FROM person_distinct_id2 + WHERE equals(person_distinct_id2.team_id, 2) + GROUP BY person_distinct_id2.distinct_id + HAVING ifNull(equals(argMax(person_distinct_id2.is_deleted, person_distinct_id2.version), 0), 0)) AS e__pdi ON equals(e.distinct_id, e__pdi.distinct_id) + WHERE and(equals(e.team_id, 2), and(and(greaterOrEquals(toTimeZone(e.timestamp, 'UTC'), toDateTime64('2021-05-01 00:00:00.000000', 6, 'UTC')), lessOrEquals(toTimeZone(e.timestamp, 'UTC'), toDateTime64('2021-05-07 23:59:59.999999', 6, 'UTC'))), in(e.event, tuple('step one', 'step three', 'step two'))), or(ifNull(equals(step_0, 1), 0), ifNull(equals(step_1, 1), 0), ifNull(equals(step_2, 1), 0))))))) + WHERE ifNull(equals(step_0, 1), 0)) + WHERE ifNull(equals(entrance_period_start, toDateTime64('2021-05-01 00:00:00.000000', 6, 'UTC')), 0) + GROUP BY aggregation_target, + entrance_period_start) + WHERE and(ifNull(greaterOrEquals(steps_completed, 1), 0), ifNull(less(steps_completed, 3), 0)) + ORDER BY aggregation_target ASC) AS source ON equals(persons.id, source.actor_id) + ORDER BY toTimeZone(persons.created_at, 'UTC') DESC + LIMIT 101 + OFFSET 0 SETTINGS readonly=2, + max_execution_time=60, + allow_experimental_object_type=1 + ''' +# --- +# name: TestFunnelTrendsPersons.test_funnel_trend_persons_with_no_to_step + ''' + SELECT persons.id, + persons.id AS id, + toTimeZone(persons.created_at, 'UTC') AS created_at, + 1 + FROM + (SELECT argMax(person.created_at, person.version) AS created_at, + person.id AS id + FROM person + WHERE equals(person.team_id, 2) + GROUP BY person.id + HAVING ifNull(equals(argMax(person.is_deleted, person.version), 0), 0) SETTINGS optimize_aggregation_in_order=1) AS persons + INNER JOIN + (SELECT aggregation_target AS actor_id + FROM + (SELECT aggregation_target AS aggregation_target, + toStartOfDay(timestamp) AS entrance_period_start, + max(steps) AS steps_completed + FROM + (SELECT aggregation_target AS aggregation_target, + timestamp AS timestamp, + step_0 AS step_0, + latest_0 AS latest_0, + step_1 AS step_1, + latest_1 AS latest_1, + step_2 AS step_2, + latest_2 AS latest_2, + if(and(ifNull(lessOrEquals(latest_0, latest_1), 0), ifNull(lessOrEquals(latest_1, plus(latest_0, toIntervalDay(14))), 0), ifNull(lessOrEquals(latest_1, latest_2), 0), ifNull(lessOrEquals(latest_2, plus(latest_0, toIntervalDay(14))), 0)), 3, if(and(ifNull(lessOrEquals(latest_0, latest_1), 0), ifNull(lessOrEquals(latest_1, plus(latest_0, toIntervalDay(14))), 0)), 2, 1)) AS steps, + if(and(isNotNull(latest_1), ifNull(lessOrEquals(latest_1, plus(latest_0, toIntervalDay(14))), 0)), dateDiff('second', latest_0, latest_1), NULL) AS step_1_conversion_time, + if(and(isNotNull(latest_2), ifNull(lessOrEquals(latest_2, plus(latest_1, toIntervalDay(14))), 0)), dateDiff('second', latest_1, latest_2), NULL) AS step_2_conversion_time + FROM + (SELECT aggregation_target AS aggregation_target, + timestamp AS timestamp, + step_0 AS step_0, + latest_0 AS latest_0, + step_1 AS step_1, + latest_1 AS latest_1, + step_2 AS step_2, + min(latest_2) OVER (PARTITION BY aggregation_target + ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) AS latest_2 + FROM + (SELECT aggregation_target AS aggregation_target, + timestamp AS timestamp, + step_0 AS step_0, + latest_0 AS latest_0, + step_1 AS step_1, + latest_1 AS latest_1, + step_2 AS step_2, + if(ifNull(less(latest_2, latest_1), 0), NULL, latest_2) AS latest_2 + FROM + (SELECT aggregation_target AS aggregation_target, + timestamp AS timestamp, + step_0 AS step_0, + latest_0 AS latest_0, + step_1 AS step_1, + min(latest_1) OVER (PARTITION BY aggregation_target + ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) AS latest_1, + step_2 AS step_2, + min(latest_2) OVER (PARTITION BY aggregation_target + ORDER BY timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND 0 PRECEDING) AS latest_2 + FROM + (SELECT toTimeZone(e.timestamp, 'UTC') AS timestamp, + e__pdi.person_id AS aggregation_target, + if(equals(e.event, 'step one'), 1, 0) AS step_0, + if(ifNull(equals(step_0, 1), 0), timestamp, NULL) AS latest_0, + if(equals(e.event, 'step two'), 1, 0) AS step_1, + if(ifNull(equals(step_1, 1), 0), timestamp, NULL) AS latest_1, + if(equals(e.event, 'step three'), 1, 0) AS step_2, + if(ifNull(equals(step_2, 1), 0), timestamp, NULL) AS latest_2 + FROM events AS e + INNER JOIN + (SELECT argMax(person_distinct_id2.person_id, person_distinct_id2.version) AS person_id, + person_distinct_id2.distinct_id AS distinct_id + FROM person_distinct_id2 + WHERE equals(person_distinct_id2.team_id, 2) + GROUP BY person_distinct_id2.distinct_id + HAVING ifNull(equals(argMax(person_distinct_id2.is_deleted, person_distinct_id2.version), 0), 0)) AS e__pdi ON equals(e.distinct_id, e__pdi.distinct_id) + WHERE and(equals(e.team_id, 2), and(and(greaterOrEquals(toTimeZone(e.timestamp, 'UTC'), toDateTime64('2021-05-01 00:00:00.000000', 6, 'UTC')), lessOrEquals(toTimeZone(e.timestamp, 'UTC'), toDateTime64('2021-05-07 23:59:59.999999', 6, 'UTC'))), in(e.event, tuple('step one', 'step three', 'step two'))), or(ifNull(equals(step_0, 1), 0), ifNull(equals(step_1, 1), 0), ifNull(equals(step_2, 1), 0))))))) + WHERE ifNull(equals(step_0, 1), 0)) + WHERE ifNull(equals(entrance_period_start, toDateTime64('2021-05-01 00:00:00.000000', 6, 'UTC')), 0) + GROUP BY aggregation_target, + entrance_period_start) + WHERE ifNull(greaterOrEquals(steps_completed, 3), 0) + ORDER BY aggregation_target ASC) AS source ON equals(persons.id, source.actor_id) + ORDER BY toTimeZone(persons.created_at, 'UTC') DESC + LIMIT 101 + OFFSET 0 SETTINGS readonly=2, + max_execution_time=60, + allow_experimental_object_type=1 + ''' +# --- diff --git a/posthog/hogql_queries/insights/funnels/test/test_funnel_persons.py b/posthog/hogql_queries/insights/funnels/test/test_funnel_persons.py index 33bd2bb77a25c..19897f122b187 100644 --- a/posthog/hogql_queries/insights/funnels/test/test_funnel_persons.py +++ b/posthog/hogql_queries/insights/funnels/test/test_funnel_persons.py @@ -1,5 +1,5 @@ from datetime import datetime -from typing import Dict, List, cast, Any +from typing import Dict, List, Optional, cast, Any from posthog.constants import INSIGHT_FUNNELS @@ -8,6 +8,7 @@ from posthog.models import Cohort from posthog.models.event.util import bulk_create_events from posthog.models.person.util import bulk_create_persons +from posthog.models.team.team import Team from posthog.schema import ActorsQuery, FunnelsActorsQuery, FunnelsQuery from posthog.test.base import ( APIBaseTest, @@ -24,26 +25,31 @@ PERSON_ID_COLUMN = 2 -class TestFunnelPersons(ClickhouseTestMixin, APIBaseTest): - def _get_actors( - self, - filters: Dict[str, Any], - funnelStep: int | None = None, - funnelCustomSteps: List[int] | None = None, - funnelStepBreakdown: str | float | List[str | float] | None = None, - offset: int | None = None, - ): - funnels_query = cast(FunnelsQuery, filter_to_query(filters)) - funnel_actors_query = FunnelsActorsQuery( - source=funnels_query, - funnelStep=funnelStep, - funnelCustomSteps=funnelCustomSteps, - funnelStepBreakdown=funnelStepBreakdown, - ) - actors_query = ActorsQuery(source=funnel_actors_query, offset=offset) - response = ActorsQueryRunner(query=actors_query, team=self.team).calculate() - return response.results +def get_actors( + filters: Dict[str, Any], + team: Team, + funnelStep: Optional[int] = None, + funnelCustomSteps: Optional[List[int]] = None, + funnelStepBreakdown: Optional[str | float | List[str | float]] = None, + funnelTrendsDropOff: Optional[bool] = None, + funnelTrendsEntrancePeriodStart: Optional[str] = None, + offset: Optional[int] = None, +): + funnels_query = cast(FunnelsQuery, filter_to_query(filters)) + funnel_actors_query = FunnelsActorsQuery( + source=funnels_query, + funnelStep=funnelStep, + funnelCustomSteps=funnelCustomSteps, + funnelStepBreakdown=funnelStepBreakdown, + funnelTrendsDropOff=funnelTrendsDropOff, + funnelTrendsEntrancePeriodStart=funnelTrendsEntrancePeriodStart, + ) + actors_query = ActorsQuery(source=funnel_actors_query, offset=offset) + response = ActorsQueryRunner(query=actors_query, team=team).calculate() + return response.results + +class TestFunnelPersons(ClickhouseTestMixin, APIBaseTest): def _create_sample_data_multiple_dropoffs(self): for i in range(35): bulk_create_persons([{"distinct_ids": [f"user_{i}"], "team_id": self.team.pk}]) @@ -167,7 +173,7 @@ def test_first_step(self): ], } - results = self._get_actors(filters, funnelStep=1) + results = get_actors(filters, self.team, funnelStep=1) self.assertEqual(35, len(results)) @@ -186,7 +192,7 @@ def test_last_step(self): ], } - results = self._get_actors(filters, funnelStep=3) + results = get_actors(filters, self.team, funnelStep=3) self.assertEqual(5, len(results)) @@ -205,7 +211,7 @@ def test_second_step_dropoff(self): ], } - results = self._get_actors(filters, funnelStep=-2) + results = get_actors(filters, self.team, funnelStep=-2) self.assertEqual(20, len(results)) @@ -224,7 +230,7 @@ def test_last_step_dropoff(self): ], } - results = self._get_actors(filters, funnelStep=-3) + results = get_actors(filters, self.team, funnelStep=-3) self.assertEqual(10, len(results)) @@ -266,11 +272,11 @@ def test_basic_offset(self): } # fetch first 100 people - results = self._get_actors(filters, funnelStep=1) + results = get_actors(filters, self.team, funnelStep=1) self.assertEqual(100, len(results)) # fetch next 100 people (just 10 remaining) - results = self._get_actors(filters, funnelStep=1, offset=100) + results = get_actors(filters, self.team, funnelStep=1, offset=100) self.assertEqual(10, len(results)) def test_steps_with_custom_steps_parameter_are_equivalent_to_funnel_step(self): @@ -298,9 +304,9 @@ def test_steps_with_custom_steps_parameter_are_equivalent_to_funnel_step(self): ] for funnelStep, funnelCustomSteps, expected_count in parameters: - results = self._get_actors(filters, funnelStep=funnelStep) + results = get_actors(filters, self.team, funnelStep=funnelStep) - new_results = self._get_actors(filters, funnelStep=funnelStep, funnelCustomSteps=funnelCustomSteps) + new_results = get_actors(filters, self.team, funnelStep=funnelStep, funnelCustomSteps=funnelCustomSteps) self.assertEqual(new_results, results) self.assertEqual(len(results), expected_count) @@ -329,7 +335,7 @@ def test_steps_with_custom_steps_parameter_where_funnel_step_equivalence_isnt_po ] for funnelCustomSteps, expected_count in parameters: - new_results = self._get_actors(filters, funnelCustomSteps=funnelCustomSteps) + new_results = get_actors(filters, self.team, funnelCustomSteps=funnelCustomSteps) self.assertEqual(len(new_results), expected_count) @@ -348,8 +354,8 @@ def test_steps_with_custom_steps_parameter_overrides_funnel_step(self): ], } - results = self._get_actors( - filters, funnelStep=1, funnelCustomSteps=[3] + results = get_actors( + filters, self.team, funnelStep=1, funnelCustomSteps=[3] ) # funnelStep=1 means custom steps = [1,2,3] self.assertEqual(len(results), 5) @@ -372,13 +378,13 @@ def test_first_step_breakdowns(self): "breakdown": "$browser", } - results = self._get_actors(filters, funnelStep=1) + results = get_actors(filters, self.team, funnelStep=1) self.assertCountEqual([val[0]["id"] for val in results], [person1.uuid, person2.uuid]) - results = self._get_actors(filters, funnelStep=1, funnelStepBreakdown=["Chrome"]) + results = get_actors(filters, self.team, funnelStep=1, funnelStepBreakdown=["Chrome"]) self.assertCountEqual([val[0]["id"] for val in results], [person1.uuid]) - results = self._get_actors(filters, funnelStep=1, funnelStepBreakdown=["Safari"]) + results = get_actors(filters, self.team, funnelStep=1, funnelStepBreakdown=["Safari"]) self.assertCountEqual([val[0]["id"] for val in results], [person2.uuid]) def test_first_step_breakdowns_with_multi_property_breakdown(self): @@ -398,13 +404,13 @@ def test_first_step_breakdowns_with_multi_property_breakdown(self): "breakdown": ["$browser", "$browser_version"], } - results = self._get_actors(filters, funnelStep=1) + results = get_actors(filters, self.team, funnelStep=1) self.assertCountEqual([val[0]["id"] for val in results], [person1.uuid, person2.uuid]) - results = self._get_actors(filters, funnelStep=1, funnelStepBreakdown=["Chrome", "95"]) + results = get_actors(filters, self.team, funnelStep=1, funnelStepBreakdown=["Chrome", "95"]) self.assertCountEqual([val[0]["id"] for val in results], [person1.uuid]) - results = self._get_actors(filters, funnelStep=1, funnelStepBreakdown=["Safari", "14"]) + results = get_actors(filters, self.team, funnelStep=1, funnelStepBreakdown=["Safari", "14"]) self.assertCountEqual([val[0]["id"] for val in results], [person2.uuid]) @also_test_with_materialized_columns(person_properties=["$country"]) @@ -425,24 +431,24 @@ def test_first_step_breakdown_person(self): "breakdown": "$country", } - results = self._get_actors(filters, funnelStep=1) + results = get_actors(filters, self.team, funnelStep=1) self.assertCountEqual([val[0]["id"] for val in results], [person1.uuid, person2.uuid]) - results = self._get_actors(filters, funnelStep=1, funnelStepBreakdown=["EE"]) + results = get_actors(filters, self.team, funnelStep=1, funnelStepBreakdown=["EE"]) self.assertCountEqual([val[0]["id"] for val in results], [person2.uuid]) # Check custom_steps give same answers for breakdowns - custom_step_results = self._get_actors( - filters, funnelStep=1, funnelCustomSteps=[1, 2, 3], funnelStepBreakdown=["EE"] + custom_step_results = get_actors( + filters, self.team, funnelStep=1, funnelCustomSteps=[1, 2, 3], funnelStepBreakdown=["EE"] ) self.assertEqual(results, custom_step_results) - results = self._get_actors(filters, funnelStep=1, funnelStepBreakdown=["PL"]) + results = get_actors(filters, self.team, funnelStep=1, funnelStepBreakdown=["PL"]) self.assertCountEqual([val[0]["id"] for val in results], [person1.uuid]) # Check custom_steps give same answers for breakdowns - custom_step_results = self._get_actors( - filters, funnelStep=1, funnelCustomSteps=[1, 2, 3], funnelStepBreakdown=["PL"] + custom_step_results = get_actors( + filters, self.team, funnelStep=1, funnelCustomSteps=[1, 2, 3], funnelStepBreakdown=["PL"] ) self.assertEqual(results, custom_step_results) @@ -477,7 +483,7 @@ def test_funnel_cohort_breakdown_persons(self): "breakdown": [cohort.pk], } - results = self._get_actors(filters, funnelStep=1) + results = get_actors(filters, self.team, funnelStep=1) self.assertEqual(results[0][0]["id"], person.uuid) # @snapshot_clickhouse_queries @@ -523,7 +529,7 @@ def test_funnel_cohort_breakdown_persons(self): # ], # } # # "include_recordings": "true", - # results = self._get_actors(filters, funnelStep=1) + # results = get_actors(filters, self.team, funnelStep=1) # self.assertEqual(results[0]["id"], p1.uuid) # self.assertEqual(results[0]["matched_recordings"], []) @@ -541,7 +547,7 @@ def test_funnel_cohort_breakdown_persons(self): # ], # } # # "include_recordings": "true", - # results = self._get_actors(filters, funnelStep=2) + # results = get_actors(filters, self.team, funnelStep=2) # self.assertEqual(results[0]["id"], p1.uuid) # self.assertEqual( # results[0]["matched_recordings"], @@ -573,7 +579,7 @@ def test_funnel_cohort_breakdown_persons(self): # ], # } # # "include_recordings": "true", - # results = self._get_actors(filters, funnelStep=-3) + # results = get_actors(filters, self.team, funnelStep=-3) # self.assertEqual(results[0]["id"], p1.uuid) # self.assertEqual( # results[0]["matched_recordings"], diff --git a/posthog/hogql_queries/insights/funnels/test/test_funnel_strict_persons.py b/posthog/hogql_queries/insights/funnels/test/test_funnel_strict_persons.py new file mode 100644 index 0000000000000..53ea15ed5e0ee --- /dev/null +++ b/posthog/hogql_queries/insights/funnels/test/test_funnel_strict_persons.py @@ -0,0 +1,252 @@ +from datetime import datetime + + +from posthog.constants import INSIGHT_FUNNELS +from posthog.hogql_queries.insights.funnels.test.test_funnel_persons import get_actors +from posthog.test.base import ( + APIBaseTest, + ClickhouseTestMixin, +) +from posthog.test.test_journeys import journeys_for + +FORMAT_TIME = "%Y-%m-%d 00:00:00" + + +class TestFunnelStrictStepsPersons(ClickhouseTestMixin, APIBaseTest): + def _create_sample_data_multiple_dropoffs(self): + events_by_person = {} + for i in range(5): + events_by_person[f"user_{i}"] = [ + {"event": "step one", "timestamp": datetime(2021, 5, 1)}, + {"event": "step fake", "timestamp": datetime(2021, 5, 2)}, + {"event": "step two", "timestamp": datetime(2021, 5, 3)}, + {"event": "step three", "timestamp": datetime(2021, 5, 5)}, + ] + + for i in range(5, 15): + events_by_person[f"user_{i}"] = [ + {"event": "step one", "timestamp": datetime(2021, 5, 1)}, + {"event": "step two", "timestamp": datetime(2021, 5, 3)}, + ] + + for i in range(15, 35): + events_by_person[f"user_{i}"] = [{"event": "step one", "timestamp": datetime(2021, 5, 1)}] + + journeys_for(events_by_person, self.team) + + def test_first_step(self): + self._create_sample_data_multiple_dropoffs() + filters = { + "insight": INSIGHT_FUNNELS, + "funnel_order_type": "strict", + "interval": "day", + "date_from": "2021-05-01 00:00:00", + "date_to": "2021-05-07 00:00:00", + "funnel_window_days": 7, + "events": [ + {"id": "step one", "order": 0}, + {"id": "step two", "order": 1}, + {"id": "step three", "order": 2}, + ], + } + + results = get_actors(filters, self.team, funnelStep=1) + + self.assertEqual(35, len(results)) + + def test_second_step(self): + self._create_sample_data_multiple_dropoffs() + filters = { + "insight": INSIGHT_FUNNELS, + "funnel_order_type": "strict", + "interval": "day", + "date_from": "2021-05-01 00:00:00", + "date_to": "2021-05-07 00:00:00", + "funnel_window_days": 7, + "events": [ + {"id": "step one", "order": 0}, + {"id": "step two", "order": 1}, + {"id": "step three", "order": 2}, + ], + } + + results = get_actors(filters, self.team, funnelStep=2) + + self.assertEqual(10, len(results)) + + def test_second_step_dropoff(self): + self._create_sample_data_multiple_dropoffs() + filters = { + "insight": INSIGHT_FUNNELS, + "funnel_order_type": "strict", + "interval": "day", + "date_from": "2021-05-01 00:00:00", + "date_to": "2021-05-07 00:00:00", + "funnel_window_days": 7, + "events": [ + {"id": "step one", "order": 0}, + {"id": "step two", "order": 1}, + {"id": "step three", "order": 2}, + ], + } + + results = get_actors(filters, self.team, funnelStep=-2) + + self.assertEqual(25, len(results)) + + def test_third_step(self): + self._create_sample_data_multiple_dropoffs() + filters = { + "insight": INSIGHT_FUNNELS, + "funnel_order_type": "strict", + "interval": "day", + "date_from": "2021-05-01 00:00:00", + "date_to": "2021-05-07 00:00:00", + "funnel_window_days": 7, + "funnel_step": 3, + "events": [ + {"id": "step one", "order": 0}, + {"id": "step two", "order": 1}, + {"id": "step three", "order": 2}, + ], + } + + results = get_actors(filters, self.team, funnelStep=3) + + self.assertEqual(0, len(results)) + + # @snapshot_clickhouse_queries + # @freeze_time("2021-01-02 00:00:00.000Z") + # def test_strict_funnel_person_recordings(self): + # p1 = _create_person(distinct_ids=[f"user_1"], team=self.team) + # _create_event( + # event="step one", + # distinct_id="user_1", + # team=self.team, + # timestamp=timezone.now().strftime("%Y-%m-%d %H:%M:%S.%f"), + # properties={"$session_id": "s1", "$window_id": "w1"}, + # event_uuid="11111111-1111-1111-1111-111111111111", + # ) + # _create_event( + # event="step two", + # distinct_id="user_1", + # team=self.team, + # timestamp=(timezone.now() + timedelta(days=1)).strftime("%Y-%m-%d %H:%M:%S.%f"), + # properties={"$session_id": "s2", "$window_id": "w2"}, + # event_uuid="21111111-1111-1111-1111-111111111111", + # ) + # _create_event( + # event="interupting step", + # distinct_id="user_1", + # team=self.team, + # timestamp=(timezone.now() + timedelta(days=2)).strftime("%Y-%m-%d %H:%M:%S.%f"), + # properties={"$session_id": "s2", "$window_id": "w2"}, + # event_uuid="21111111-1111-1111-1111-111111111111", + # ) + # _create_event( + # event="step three", + # distinct_id="user_1", + # team=self.team, + # timestamp=(timezone.now() + timedelta(days=3)).strftime("%Y-%m-%d %H:%M:%S.%f"), + # properties={"$session_id": "s2", "$window_id": "w2"}, + # event_uuid="21111111-1111-1111-1111-111111111111", + # ) + # timestamp = datetime(2021, 1, 3, 0, 0, 0) + # produce_replay_summary( + # team_id=self.team.pk, + # session_id="s2", + # distinct_id="user_1", + # first_timestamp=timestamp, + # last_timestamp=timestamp, + # ) + + # # First event, but no recording + # filters = { + # "insight": INSIGHT_FUNNELS, + # "funnel_order_type": "strict", + # "date_from": "2021-01-01", + # "date_to": "2021-01-08", + # "interval": "day", + # "funnel_window_days": 7, + # "events": [ + # {"id": "step one", "order": 0}, + # {"id": "step two", "order": 1}, + # {"id": "step three", "order": 2}, + # ], + # } + + # # "include_recordings": "true", + # results = get_actors(filters, self.team, funnelStep=1) + + # self.assertEqual(results[0]["id"], p1.uuid) + # self.assertEqual(results[0]["matched_recordings"], []) + + # # Second event, with recording + # filters = { + # "insight": INSIGHT_FUNNELS, + # "funnel_order_type": "strict", + # "date_from": "2021-01-01", + # "date_to": "2021-01-08", + # "interval": "day", + # "funnel_window_days": 7, + # "events": [ + # {"id": "step one", "order": 0}, + # {"id": "step two", "order": 1}, + # {"id": "step three", "order": 2}, + # ], + # } + + # # "include_recordings": "true", + # results = get_actors(filters, self.team, funnelStep=2) + + # self.assertEqual(results[0]["id"], p1.uuid) + # self.assertEqual( + # results[0]["matched_recordings"], + # [ + # { + # "session_id": "s2", + # "events": [ + # { + # "uuid": UUID("21111111-1111-1111-1111-111111111111"), + # "timestamp": timezone.now() + timedelta(days=1), + # "window_id": "w2", + # } + # ], + # } + # ], + # ) + + # # Third event dropoff, with recording + # filters = { + # "insight": INSIGHT_FUNNELS, + # "funnel_order_type": "strict", + # "date_from": "2021-01-01", + # "date_to": "2021-01-08", + # "interval": "day", + # "funnel_window_days": 7, + # "events": [ + # {"id": "step one", "order": 0}, + # {"id": "step two", "order": 1}, + # {"id": "step three", "order": 2}, + # ], + # } + + # # "include_recordings": "true", + # results = get_actors(filters, self.team, funnelStep=-3) + + # self.assertEqual(results[0]["id"], p1.uuid) + # self.assertEqual( + # results[0]["matched_recordings"], + # [ + # { + # "session_id": "s2", + # "events": [ + # { + # "uuid": UUID("21111111-1111-1111-1111-111111111111"), + # "timestamp": timezone.now() + timedelta(days=1), + # "window_id": "w2", + # } + # ], + # } + # ], + # ) diff --git a/posthog/hogql_queries/insights/funnels/test/test_funnel_trends_persons.py b/posthog/hogql_queries/insights/funnels/test/test_funnel_trends_persons.py new file mode 100644 index 0000000000000..e1f4125c9e0ae --- /dev/null +++ b/posthog/hogql_queries/insights/funnels/test/test_funnel_trends_persons.py @@ -0,0 +1,162 @@ +from datetime import datetime, timedelta + +from posthog.constants import INSIGHT_FUNNELS, FunnelVizType +from posthog.hogql_queries.insights.funnels.test.test_funnel_persons import get_actors +from posthog.session_recordings.queries.test.session_replay_sql import ( + produce_replay_summary, +) +from posthog.test.base import ( + APIBaseTest, + ClickhouseTestMixin, + snapshot_clickhouse_queries, +) +from posthog.test.test_journeys import journeys_for + +filters = { + "insight": INSIGHT_FUNNELS, + "funnel_viz_type": FunnelVizType.TRENDS, + "interval": "day", + "date_from": "2021-05-01 00:00:00", + "date_to": "2021-05-07 23:59:59", + "funnel_window_days": 14, + "funnel_from_step": 0, + "events": [ + {"id": "step one", "order": 0}, + {"id": "step two", "order": 1}, + {"id": "step three", "order": 2}, + ], +} + + +class TestFunnelTrendsPersons(ClickhouseTestMixin, APIBaseTest): + @snapshot_clickhouse_queries + def test_funnel_trend_persons_returns_recordings(self): + persons = journeys_for( + { + "user_one": [ + { + "event": "step one", + "timestamp": datetime(2021, 5, 1), + "properties": {"$session_id": "s1a"}, + }, + { + "event": "step two", + "timestamp": datetime(2021, 5, 2), + "properties": {"$session_id": "s1b"}, + }, + { + "event": "step three", + "timestamp": datetime(2021, 5, 3), + "properties": {"$session_id": "s1c"}, + }, + ] + }, + self.team, + ) + timestamp = datetime(2021, 5, 1) + produce_replay_summary( + team_id=self.team.pk, + session_id="s1b", + distinct_id="user_one", + first_timestamp=timestamp, + last_timestamp=timestamp, + ) + + # "include_recordings": "true", + results = get_actors( + {"funnel_to_step": 1, **filters}, + self.team, + funnelTrendsDropOff=False, + funnelTrendsEntrancePeriodStart="2021-05-01 00:00:00", + ) + + self.assertEqual([person[0]["id"] for person in results], [persons["user_one"].uuid]) + # self.assertEqual( + # [person["matched_recordings"][0]["session_id"] for person in results], + # ["s1b"], + # ) + + @snapshot_clickhouse_queries + def test_funnel_trend_persons_with_no_to_step(self): + persons = journeys_for( + { + "user_one": [ + { + "event": "step one", + "timestamp": datetime(2021, 5, 1), + "properties": {"$session_id": "s1a"}, + }, + { + "event": "step two", + "timestamp": datetime(2021, 5, 2), + "properties": {"$session_id": "s1b"}, + }, + { + "event": "step three", + "timestamp": datetime(2021, 5, 3), + "properties": {"$session_id": "s1c"}, + }, + ] + }, + self.team, + ) + # the session recording can start a little before the events in the funnel + timestamp = datetime(2021, 5, 1) - timedelta(hours=12) + produce_replay_summary( + team_id=self.team.pk, + session_id="s1c", + distinct_id="user_one", + first_timestamp=timestamp, + last_timestamp=timestamp, + ) + + # "include_recordings": "true", + results = get_actors( + filters, + self.team, + funnelTrendsDropOff=False, + funnelTrendsEntrancePeriodStart="2021-05-01 00:00:00", + ) + + self.assertEqual([person[0]["id"] for person in results], [persons["user_one"].uuid]) + # self.assertEqual( + # [person["matched_recordings"][0]["session_id"] for person in results], + # ["s1c"], + # ) + + @snapshot_clickhouse_queries + def test_funnel_trend_persons_with_drop_off(self): + persons = journeys_for( + { + "user_one": [ + { + "event": "step one", + "timestamp": datetime(2021, 5, 1), + "properties": {"$session_id": "s1a"}, + } + ] + }, + self.team, + ) + timestamp = datetime(2021, 5, 1) + produce_replay_summary( + team_id=self.team.pk, + session_id="s1a", + distinct_id="user_one", + first_timestamp=timestamp, + last_timestamp=timestamp, + ) + + # "include_recordings": "true", + results = get_actors( + filters, + self.team, + funnelTrendsDropOff=True, + funnelTrendsEntrancePeriodStart="2021-05-01 00:00:00", + ) + + self.assertEqual([person[0]["id"] for person in results], [persons["user_one"].uuid]) + # self.assertEqual( + # [person["matched_recordings"][0].get("session_id") for person in results], + # ["s1a"], + # ) diff --git a/posthog/hogql_queries/insights/funnels/test/test_funnel_unordered_persons.py b/posthog/hogql_queries/insights/funnels/test/test_funnel_unordered_persons.py new file mode 100644 index 0000000000000..0d9861c1ff75c --- /dev/null +++ b/posthog/hogql_queries/insights/funnels/test/test_funnel_unordered_persons.py @@ -0,0 +1,185 @@ +from datetime import datetime + + +from posthog.constants import INSIGHT_FUNNELS +from posthog.hogql_queries.insights.funnels.test.test_funnel_persons import get_actors +from posthog.test.base import ( + APIBaseTest, + ClickhouseTestMixin, +) +from posthog.test.test_journeys import journeys_for + +FORMAT_TIME = "%Y-%m-%d 00:00:00" + + +class TestFunnelUnorderedStepsPersons(ClickhouseTestMixin, APIBaseTest): + def _create_sample_data_multiple_dropoffs(self): + events_by_person = {} + for i in range(5): + events_by_person[f"user_{i}"] = [ + {"event": "step one", "timestamp": datetime(2021, 5, 1)}, + {"event": "step three", "timestamp": datetime(2021, 5, 3)}, + {"event": "step two", "timestamp": datetime(2021, 5, 5)}, + ] + + for i in range(5, 15): + events_by_person[f"user_{i}"] = [ + {"event": "step two", "timestamp": datetime(2021, 5, 1)}, + {"event": "step one", "timestamp": datetime(2021, 5, 3)}, + ] + + for i in range(15, 35): + events_by_person[f"user_{i}"] = [{"event": "step one", "timestamp": datetime(2021, 5, 1)}] + + journeys_for(events_by_person, self.team) + + # def test_invalid_steps(self): + # filters = { + # "insight": INSIGHT_FUNNELS, + # "funnel_order_type": "unordered", + # "interval": "day", + # "date_from": "2021-05-01 00:00:00", + # "date_to": "2021-05-07 00:00:00", + # "funnel_window_days": 7, + # "events": [ + # {"id": "step one", "order": 0}, + # {"id": "step two", "order": 1}, + # {"id": "step three", "order": 2}, + # ], + # } + + # with self.assertRaises(ValueError): + # get_actors(filters, self.team, funnelStep="blah") # type: ignore + + # with pytest.raises(ValueError): + # get_actors(filters, self.team, funnelStep=-1) + + def test_first_step(self): + self._create_sample_data_multiple_dropoffs() + filters = { + "insight": INSIGHT_FUNNELS, + "funnel_order_type": "unordered", + "interval": "day", + "date_from": "2021-05-01 00:00:00", + "date_to": "2021-05-07 00:00:00", + "funnel_window_days": 7, + "events": [ + {"id": "step one", "order": 0}, + {"id": "step two", "order": 1}, + {"id": "step three", "order": 2}, + ], + } + + results = get_actors(filters, self.team, funnelStep=1) + + self.assertEqual(35, len(results)) + + def test_last_step(self): + self._create_sample_data_multiple_dropoffs() + filters = { + "insight": INSIGHT_FUNNELS, + "funnel_order_type": "unordered", + "interval": "day", + "date_from": "2021-05-01 00:00:00", + "date_to": "2021-05-07 00:00:00", + "funnel_window_days": 7, + "events": [ + {"id": "step one", "order": 0}, + {"id": "step two", "order": 1}, + {"id": "step three", "order": 2}, + ], + } + + results = get_actors(filters, self.team, funnelStep=3) + + self.assertEqual(5, len(results)) + + def test_second_step_dropoff(self): + self._create_sample_data_multiple_dropoffs() + filters = { + "insight": INSIGHT_FUNNELS, + "funnel_order_type": "unordered", + "interval": "day", + "date_from": "2021-05-01 00:00:00", + "date_to": "2021-05-07 00:00:00", + "funnel_window_days": 7, + "events": [ + {"id": "step one", "order": 0}, + {"id": "step two", "order": 1}, + {"id": "step three", "order": 2}, + ], + } + + results = get_actors(filters, self.team, funnelStep=-2) + + self.assertEqual(20, len(results)) + + def test_last_step_dropoff(self): + self._create_sample_data_multiple_dropoffs() + filters = { + "insight": INSIGHT_FUNNELS, + "funnel_order_type": "unordered", + "interval": "day", + "date_from": "2021-05-01 00:00:00", + "date_to": "2021-05-07 00:00:00", + "funnel_window_days": 7, + "events": [ + {"id": "step one", "order": 0}, + {"id": "step two", "order": 1}, + {"id": "step three", "order": 2}, + ], + } + + results = get_actors(filters, self.team, funnelStep=-3) + + self.assertEqual(10, len(results)) + + # @snapshot_clickhouse_queries + # @freeze_time("2021-01-02 00:00:00.000Z") + # def test_unordered_funnel_does_not_return_recordings(self): + # p1 = _create_person(distinct_ids=[f"user_1"], team=self.team) + # _create_event( + # event="step two", + # distinct_id="user_1", + # team=self.team, + # timestamp=timezone.now().strftime("%Y-%m-%d %H:%M:%S.%f"), + # properties={"$session_id": "s1", "$window_id": "w1"}, + # event_uuid="21111111-1111-1111-1111-111111111111", + # ) + # _create_event( + # event="step one", + # distinct_id="user_1", + # team=self.team, + # timestamp=(timezone.now() + timedelta(days=1)).strftime("%Y-%m-%d %H:%M:%S.%f"), + # properties={"$session_id": "s1", "$window_id": "w1"}, + # event_uuid="11111111-1111-1111-1111-111111111111", + # ) + + # timestamp = timezone.now() + timedelta(days=1) + # produce_replay_summary( + # team_id=self.team.pk, + # session_id="s1", + # distinct_id="user_1", + # first_timestamp=timestamp, + # last_timestamp=timestamp, + # ) + + # filters = { + # "insight": INSIGHT_FUNNELS, + # "funnel_order_type": "unordered", + # "date_from": "2021-01-01", + # "date_to": "2021-01-08", + # "interval": "day", + # "funnel_window_days": 7, + # "funnel_step": 1, + # "events": [ + # {"id": "step one", "order": 0}, + # {"id": "step two", "order": 1}, + # {"id": "step three", "order": 2}, + # ], + # } + # # "include_recordings": "true", # <- The important line + # results = get_actors(filters, self.team, funnelStep=1) + + # self.assertEqual(results[0]["id"], p1.uuid) + # self.assertEqual(results[0]["matched_recordings"], []) diff --git a/posthog/hogql_queries/insights/funnels/utils.py b/posthog/hogql_queries/insights/funnels/utils.py index a3d717a3d06c7..ff7a52db0a1f1 100644 --- a/posthog/hogql_queries/insights/funnels/utils.py +++ b/posthog/hogql_queries/insights/funnels/utils.py @@ -23,7 +23,12 @@ def get_funnel_order_class(funnelsFilter: FunnelsFilter): def get_funnel_actor_class(funnelsFilter: FunnelsFilter): - from posthog.hogql_queries.insights.funnels.funnel_persons import FunnelActors + from posthog.hogql_queries.insights.funnels import ( + FunnelActors, + FunnelStrictActors, + FunnelUnorderedActors, + FunnelTrendsActors, + ) # if filter.correlation_person_entity and EE_AVAILABLE: if False: @@ -39,15 +44,12 @@ def get_funnel_actor_class(funnelsFilter: FunnelsFilter): "Funnel Correlations is not available without an enterprise license and enterprise supported deployment" ) elif funnelsFilter.funnelVizType == FunnelVizType.trends: - return FunnelActors - # return FunnelTrendsActors + return FunnelTrendsActors else: if funnelsFilter.funnelOrderType == StepOrderValue.unordered: - return FunnelActors - # return FunnelUnorderedActors + return FunnelUnorderedActors elif funnelsFilter.funnelOrderType == StepOrderValue.strict: - return FunnelActors - # return FunnelStrictActors + return FunnelStrictActors else: return FunnelActors diff --git a/posthog/hogql_queries/insights/test/__snapshots__/test_lifecycle_query_runner.ambr b/posthog/hogql_queries/insights/test/__snapshots__/test_lifecycle_query_runner.ambr index 26b58ea62e96c..bbca1ba255e1b 100644 --- a/posthog/hogql_queries/insights/test/__snapshots__/test_lifecycle_query_runner.ambr +++ b/posthog/hogql_queries/insights/test/__snapshots__/test_lifecycle_query_runner.ambr @@ -79,7 +79,7 @@ WHERE and(equals(events.team_id, 2), greaterOrEquals(toTimeZone(events.timestamp, 'UTC'), minus(toStartOfDay(assumeNotNull(parseDateTime64BestEffortOrNull('2020-01-12 00:00:00', 6, 'UTC'))), toIntervalDay(1))), less(toTimeZone(events.timestamp, 'UTC'), plus(toStartOfDay(assumeNotNull(parseDateTime64BestEffortOrNull('2020-01-19 23:59:59', 6, 'UTC'))), toIntervalDay(1))), ifNull(in(person_id, (SELECT cohortpeople.person_id AS person_id FROM cohortpeople - WHERE and(equals(cohortpeople.team_id, 2), equals(cohortpeople.cohort_id, 8)) + WHERE and(equals(cohortpeople.team_id, 2), equals(cohortpeople.cohort_id, 6)) GROUP BY cohortpeople.person_id, cohortpeople.cohort_id, cohortpeople.version HAVING ifNull(greater(sum(cohortpeople.sign), 0), 0))), 0), equals(events.event, '$pageview')) GROUP BY person_id) diff --git a/posthog/hogql_queries/insights/trends/test/__snapshots__/test_trends.ambr b/posthog/hogql_queries/insights/trends/test/__snapshots__/test_trends.ambr index cce9b7bef6ab0..a7b7590efc6e0 100644 --- a/posthog/hogql_queries/insights/trends/test/__snapshots__/test_trends.ambr +++ b/posthog/hogql_queries/insights/trends/test/__snapshots__/test_trends.ambr @@ -85,7 +85,7 @@ WHERE and(equals(e.team_id, 2), greaterOrEquals(toTimeZone(e.timestamp, 'UTC'), toStartOfDay(assumeNotNull(parseDateTime64BestEffortOrNull('2020-01-01 00:00:00', 6, 'UTC')))), lessOrEquals(toTimeZone(e.timestamp, 'UTC'), assumeNotNull(parseDateTime64BestEffortOrNull('2020-01-07 23:59:59', 6, 'UTC'))), ifNull(equals(e__pdi__person.`properties___$bool_prop`, 'x'), 0), and(equals(e.event, 'sign up'), ifNull(in(e__pdi.person_id, (SELECT cohortpeople.person_id AS person_id FROM cohortpeople - WHERE and(equals(cohortpeople.team_id, 2), equals(cohortpeople.cohort_id, 9)) + WHERE and(equals(cohortpeople.team_id, 2), equals(cohortpeople.cohort_id, 7)) GROUP BY cohortpeople.person_id, cohortpeople.cohort_id, cohortpeople.version HAVING ifNull(greater(sum(cohortpeople.sign), 0), 0))), 0))) GROUP BY day_start) @@ -172,7 +172,7 @@ WHERE and(equals(e.team_id, 2), greaterOrEquals(toTimeZone(e.timestamp, 'UTC'), toStartOfDay(assumeNotNull(parseDateTime64BestEffortOrNull('2020-01-01 00:00:00', 6, 'UTC')))), lessOrEquals(toTimeZone(e.timestamp, 'UTC'), assumeNotNull(parseDateTime64BestEffortOrNull('2020-01-07 23:59:59', 6, 'UTC'))), ifNull(equals(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.person_properties, '$bool_prop'), ''), 'null'), '^"|"$', ''), 'x'), 0), and(equals(e.event, 'sign up'), ifNull(in(ifNull(nullIf(e__override.override_person_id, '00000000-0000-0000-0000-000000000000'), e.person_id), (SELECT cohortpeople.person_id AS person_id FROM cohortpeople - WHERE and(equals(cohortpeople.team_id, 2), equals(cohortpeople.cohort_id, 10)) + WHERE and(equals(cohortpeople.team_id, 2), equals(cohortpeople.cohort_id, 8)) GROUP BY cohortpeople.person_id, cohortpeople.cohort_id, cohortpeople.version HAVING ifNull(greater(sum(cohortpeople.sign), 0), 0))), 0))) GROUP BY day_start) @@ -688,7 +688,7 @@ WHERE and(equals(e.team_id, 2), and(equals(e.event, '$pageview'), and(or(ifNull(equals(e__pdi__person.properties___name, 'p1'), 0), ifNull(equals(e__pdi__person.properties___name, 'p2'), 0), ifNull(equals(e__pdi__person.properties___name, 'p3'), 0)), ifNull(in(e__pdi.person_id, (SELECT cohortpeople.person_id AS person_id FROM cohortpeople - WHERE and(equals(cohortpeople.team_id, 2), equals(cohortpeople.cohort_id, 29)) + WHERE and(equals(cohortpeople.team_id, 2), equals(cohortpeople.cohort_id, 27)) GROUP BY cohortpeople.person_id, cohortpeople.cohort_id, cohortpeople.version HAVING ifNull(greater(sum(cohortpeople.sign), 0), 0))), 0)))) GROUP BY value @@ -757,7 +757,7 @@ WHERE and(equals(e.team_id, 2), and(and(equals(e.event, '$pageview'), and(or(ifNull(equals(e__pdi__person.properties___name, 'p1'), 0), ifNull(equals(e__pdi__person.properties___name, 'p2'), 0), ifNull(equals(e__pdi__person.properties___name, 'p3'), 0)), ifNull(in(e__pdi.person_id, (SELECT cohortpeople.person_id AS person_id FROM cohortpeople - WHERE and(equals(cohortpeople.team_id, 2), equals(cohortpeople.cohort_id, 29)) + WHERE and(equals(cohortpeople.team_id, 2), equals(cohortpeople.cohort_id, 27)) GROUP BY cohortpeople.person_id, cohortpeople.cohort_id, cohortpeople.version HAVING ifNull(greater(sum(cohortpeople.sign), 0), 0))), 0))), or(ifNull(equals(transform(ifNull(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.properties, 'key'), ''), 'null'), '^"|"$', ''), '$$_posthog_breakdown_null_$$'), ['$$_posthog_breakdown_other_$$', 'val'], ['$$_posthog_breakdown_other_$$', 'val'], '$$_posthog_breakdown_other_$$'), '$$_posthog_breakdown_other_$$'), 0), ifNull(equals(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.properties, 'key'), ''), 'null'), '^"|"$', ''), 'val'), 0))), ifNull(greaterOrEquals(timestamp, minus(assumeNotNull(parseDateTime64BestEffortOrNull('2020-01-01 00:00:00', 6, 'UTC')), toIntervalDay(7))), 0), ifNull(lessOrEquals(timestamp, assumeNotNull(parseDateTime64BestEffortOrNull('2020-01-12 23:59:59', 6, 'UTC'))), 0)) GROUP BY timestamp, actor_id, @@ -1592,7 +1592,7 @@ WHERE and(equals(e.team_id, 2), greaterOrEquals(toTimeZone(e.timestamp, 'UTC'), toStartOfDay(assumeNotNull(parseDateTime64BestEffortOrNull('2019-12-28 00:00:00', 6, 'UTC')))), lessOrEquals(toTimeZone(e.timestamp, 'UTC'), assumeNotNull(parseDateTime64BestEffortOrNull('2020-01-04 23:59:59', 6, 'UTC'))), and(equals(e.event, 'sign up'), ifNull(in(e__pdi.person_id, (SELECT cohortpeople.person_id AS person_id FROM cohortpeople - WHERE and(equals(cohortpeople.team_id, 2), equals(cohortpeople.cohort_id, 42)) + WHERE and(equals(cohortpeople.team_id, 2), equals(cohortpeople.cohort_id, 40)) GROUP BY cohortpeople.person_id, cohortpeople.cohort_id, cohortpeople.version HAVING ifNull(greater(sum(cohortpeople.sign), 0), 0))), 0))) GROUP BY value @@ -1640,7 +1640,7 @@ WHERE and(equals(e.team_id, 2), greaterOrEquals(toTimeZone(e.timestamp, 'UTC'), toStartOfDay(assumeNotNull(parseDateTime64BestEffortOrNull('2019-12-28 00:00:00', 6, 'UTC')))), lessOrEquals(toTimeZone(e.timestamp, 'UTC'), assumeNotNull(parseDateTime64BestEffortOrNull('2020-01-04 23:59:59', 6, 'UTC'))), and(equals(e.event, 'sign up'), ifNull(in(e__pdi.person_id, (SELECT cohortpeople.person_id AS person_id FROM cohortpeople - WHERE and(equals(cohortpeople.team_id, 2), equals(cohortpeople.cohort_id, 42)) + WHERE and(equals(cohortpeople.team_id, 2), equals(cohortpeople.cohort_id, 40)) GROUP BY cohortpeople.person_id, cohortpeople.cohort_id, cohortpeople.version HAVING ifNull(greater(sum(cohortpeople.sign), 0), 0))), 0)), or(ifNull(equals(transform(ifNull(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.properties, '$some_property'), ''), 'null'), '^"|"$', ''), '$$_posthog_breakdown_null_$$'), ['$$_posthog_breakdown_other_$$', 'value', 'other_value'], ['$$_posthog_breakdown_other_$$', 'value', 'other_value'], '$$_posthog_breakdown_other_$$'), '$$_posthog_breakdown_other_$$'), 0), ifNull(equals(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.properties, '$some_property'), ''), 'null'), '^"|"$', ''), 'value'), 0), ifNull(equals(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.properties, '$some_property'), ''), 'null'), '^"|"$', ''), 'other_value'), 0))) GROUP BY day_start, @@ -1691,7 +1691,7 @@ WHERE and(equals(e.team_id, 2), greaterOrEquals(toTimeZone(e.timestamp, 'UTC'), toStartOfDay(assumeNotNull(parseDateTime64BestEffortOrNull('2019-12-28 00:00:00', 6, 'UTC')))), lessOrEquals(toTimeZone(e.timestamp, 'UTC'), assumeNotNull(parseDateTime64BestEffortOrNull('2020-01-04 23:59:59', 6, 'UTC'))), and(equals(e.event, 'sign up'), ifNull(in(ifNull(nullIf(e__override.override_person_id, '00000000-0000-0000-0000-000000000000'), e.person_id), (SELECT cohortpeople.person_id AS person_id FROM cohortpeople - WHERE and(equals(cohortpeople.team_id, 2), equals(cohortpeople.cohort_id, 43)) + WHERE and(equals(cohortpeople.team_id, 2), equals(cohortpeople.cohort_id, 41)) GROUP BY cohortpeople.person_id, cohortpeople.cohort_id, cohortpeople.version HAVING ifNull(greater(sum(cohortpeople.sign), 0), 0))), 0))) GROUP BY value @@ -1738,7 +1738,7 @@ WHERE and(equals(e.team_id, 2), greaterOrEquals(toTimeZone(e.timestamp, 'UTC'), toStartOfDay(assumeNotNull(parseDateTime64BestEffortOrNull('2019-12-28 00:00:00', 6, 'UTC')))), lessOrEquals(toTimeZone(e.timestamp, 'UTC'), assumeNotNull(parseDateTime64BestEffortOrNull('2020-01-04 23:59:59', 6, 'UTC'))), and(equals(e.event, 'sign up'), ifNull(in(ifNull(nullIf(e__override.override_person_id, '00000000-0000-0000-0000-000000000000'), e.person_id), (SELECT cohortpeople.person_id AS person_id FROM cohortpeople - WHERE and(equals(cohortpeople.team_id, 2), equals(cohortpeople.cohort_id, 43)) + WHERE and(equals(cohortpeople.team_id, 2), equals(cohortpeople.cohort_id, 41)) GROUP BY cohortpeople.person_id, cohortpeople.cohort_id, cohortpeople.version HAVING ifNull(greater(sum(cohortpeople.sign), 0), 0))), 0)), or(ifNull(equals(transform(ifNull(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.properties, '$some_property'), ''), 'null'), '^"|"$', ''), '$$_posthog_breakdown_null_$$'), ['$$_posthog_breakdown_other_$$', 'value', 'other_value'], ['$$_posthog_breakdown_other_$$', 'value', 'other_value'], '$$_posthog_breakdown_other_$$'), '$$_posthog_breakdown_other_$$'), 0), ifNull(equals(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.properties, '$some_property'), ''), 'null'), '^"|"$', ''), 'value'), 0), ifNull(equals(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.properties, '$some_property'), ''), 'null'), '^"|"$', ''), 'other_value'), 0))) GROUP BY day_start, diff --git a/posthog/schema.py b/posthog/schema.py index 4704f1077f010..d9774ce049f71 100644 --- a/posthog/schema.py +++ b/posthog/schema.py @@ -2544,7 +2544,8 @@ class FunnelsActorsQuery(BaseModel): extra="forbid", ) funnelCustomSteps: Optional[List[int]] = Field( - default=None, description="Custom step numbers to get persons for. This overrides `funnelStep`." + default=None, + description="Custom step numbers to get persons for. This overrides `funnelStep`. Primarily for correlation use.", ) funnelStep: Optional[int] = Field( default=None, @@ -2554,6 +2555,11 @@ class FunnelsActorsQuery(BaseModel): default=None, description="The breakdown value for which to get persons for. This is an array for person and event properties, a string for groups and an integer for cohorts.", ) + funnelTrendsDropOff: Optional[bool] = None + funnelTrendsEntrancePeriodStart: Optional[str] = Field( + default=None, + description="Used together with `funnelTrendsDropOff` for funnels time conversion date for the persons modal.", + ) includeRecordings: Optional[bool] = None kind: Literal["InsightActorsQuery"] = "InsightActorsQuery" response: Optional[ActorsQueryResponse] = None