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 59b4ca4391b1e..e9efba458ed68 100644 Binary files a/frontend/__snapshots__/scenes-app-insights-error-empty-states--estimated-query-execution-time-too-long--light.png and b/frontend/__snapshots__/scenes-app-insights-error-empty-states--estimated-query-execution-time-too-long--light.png differ diff --git a/frontend/src/queries/schema.json b/frontend/src/queries/schema.json index 707e444b1b58a..d8a23cae2f1b6 100644 --- a/frontend/src/queries/schema.json +++ b/frontend/src/queries/schema.json @@ -1699,7 +1699,7 @@ "additionalProperties": false, "properties": { "funnelCustomSteps": { - "description": "Custom step numbers to get persons for. This overrides `funnelStep`.", + "description": "Custom step numbers to get persons for. This overrides `funnelStep`. Primarily for correlation use.", "items": { "type": "integer" }, @@ -1713,6 +1713,13 @@ "$ref": "#/definitions/BreakdownKeyType", "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": { + "type": "boolean" + }, + "funnelTrendsEntrancePeriodStart": { + "description": "Used together with `funnelTrendsDropOff` for funnels time conversion date for the persons modal.", + "type": "string" + }, "includeRecordings": { "type": "boolean" }, diff --git a/frontend/src/queries/schema.ts b/frontend/src/queries/schema.ts index a5e9a19afe49a..52f0393f6ec74 100644 --- a/frontend/src/queries/schema.ts +++ b/frontend/src/queries/schema.ts @@ -1083,11 +1083,14 @@ export interface FunnelsActorsQuery extends InsightActorsQueryBase { /** Index of the step for which we want to get the timestamp for, per person. * Positive for converted persons, negative for dropped of persons. */ funnelStep?: integer - /** Custom step numbers to get persons for. This overrides `funnelStep`. */ + /** Custom step numbers to get persons for. This overrides `funnelStep`. Primarily for correlation use. */ funnelCustomSteps?: integer[] /** 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. */ funnelStepBreakdown?: BreakdownKeyType + funnelTrendsDropOff?: boolean + /** Used together with `funnelTrendsDropOff` for funnels time conversion date for the persons modal. */ + funnelTrendsEntrancePeriodStart?: string } export type BreakdownValueInt = integer diff --git a/frontend/src/scenes/funnels/FunnelLineGraph.tsx b/frontend/src/scenes/funnels/FunnelLineGraph.tsx index 9efaa3e6741a5..ee879703a2b67 100644 --- a/frontend/src/scenes/funnels/FunnelLineGraph.tsx +++ b/frontend/src/scenes/funnels/FunnelLineGraph.tsx @@ -8,7 +8,7 @@ import { buildPeopleUrl } from 'scenes/trends/persons-modal/persons-modal-utils' import { openPersonsModal } from 'scenes/trends/persons-modal/PersonsModal' import { queryNodeToFilter } from '~/queries/nodes/InsightQuery/utils/queryNodeToFilter' -import { TrendsFilter } from '~/queries/schema' +import { FunnelsActorsQuery, NodeKind, TrendsFilter } from '~/queries/schema' import { isInsightQueryNode } from '~/queries/utils' import { ChartParams, GraphDataset, GraphType } from '~/types' @@ -28,8 +28,15 @@ export function FunnelLineGraph({ showPersonsModal = true, }: Omit): 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