diff --git a/frontend/__snapshots__/filters-propertyfilters--comparing-property-filters--dark.png b/frontend/__snapshots__/filters-propertyfilters--comparing-property-filters--dark.png index 7f844b8eea43a..8293c0f34b9c1 100644 Binary files a/frontend/__snapshots__/filters-propertyfilters--comparing-property-filters--dark.png and b/frontend/__snapshots__/filters-propertyfilters--comparing-property-filters--dark.png differ diff --git a/frontend/__snapshots__/filters-propertyfilters--comparing-property-filters--light.png b/frontend/__snapshots__/filters-propertyfilters--comparing-property-filters--light.png index 99511d25ec6d2..cc11baf701551 100644 Binary files a/frontend/__snapshots__/filters-propertyfilters--comparing-property-filters--light.png and b/frontend/__snapshots__/filters-propertyfilters--comparing-property-filters--light.png differ diff --git a/frontend/__snapshots__/filters-propertyfilters-operatorvalueselect--operator-value-with-boolean-property--dark.png b/frontend/__snapshots__/filters-propertyfilters-operatorvalueselect--operator-value-with-boolean-property--dark.png index da8bc70d5780a..2927b08d4a097 100644 Binary files a/frontend/__snapshots__/filters-propertyfilters-operatorvalueselect--operator-value-with-boolean-property--dark.png and b/frontend/__snapshots__/filters-propertyfilters-operatorvalueselect--operator-value-with-boolean-property--dark.png differ diff --git a/frontend/__snapshots__/filters-propertyfilters-operatorvalueselect--operator-value-with-boolean-property--light.png b/frontend/__snapshots__/filters-propertyfilters-operatorvalueselect--operator-value-with-boolean-property--light.png index c014b78a377a5..7bf5c9fb14b05 100644 Binary files a/frontend/__snapshots__/filters-propertyfilters-operatorvalueselect--operator-value-with-boolean-property--light.png and b/frontend/__snapshots__/filters-propertyfilters-operatorvalueselect--operator-value-with-boolean-property--light.png differ diff --git a/frontend/__snapshots__/filters-propertyfilters-operatorvalueselect--operator-value-with-numeric-property--dark.png b/frontend/__snapshots__/filters-propertyfilters-operatorvalueselect--operator-value-with-numeric-property--dark.png index d0f22279032b7..b2c79fdb6f295 100644 Binary files a/frontend/__snapshots__/filters-propertyfilters-operatorvalueselect--operator-value-with-numeric-property--dark.png and b/frontend/__snapshots__/filters-propertyfilters-operatorvalueselect--operator-value-with-numeric-property--dark.png differ diff --git a/frontend/__snapshots__/filters-propertyfilters-operatorvalueselect--operator-value-with-numeric-property--light.png b/frontend/__snapshots__/filters-propertyfilters-operatorvalueselect--operator-value-with-numeric-property--light.png index 1509d3039bc4f..d813993f680f7 100644 Binary files a/frontend/__snapshots__/filters-propertyfilters-operatorvalueselect--operator-value-with-numeric-property--light.png and b/frontend/__snapshots__/filters-propertyfilters-operatorvalueselect--operator-value-with-numeric-property--light.png differ diff --git a/frontend/__snapshots__/filters-propertyfilters-operatorvalueselect--operator-value-with-selector-property--dark.png b/frontend/__snapshots__/filters-propertyfilters-operatorvalueselect--operator-value-with-selector-property--dark.png index 076c4af3d8252..29433842843a6 100644 Binary files a/frontend/__snapshots__/filters-propertyfilters-operatorvalueselect--operator-value-with-selector-property--dark.png and b/frontend/__snapshots__/filters-propertyfilters-operatorvalueselect--operator-value-with-selector-property--dark.png differ diff --git a/frontend/__snapshots__/filters-propertyfilters-operatorvalueselect--operator-value-with-selector-property--light.png b/frontend/__snapshots__/filters-propertyfilters-operatorvalueselect--operator-value-with-selector-property--light.png index cffb26c0bc2d2..84900b04424e4 100644 Binary files a/frontend/__snapshots__/filters-propertyfilters-operatorvalueselect--operator-value-with-selector-property--light.png and b/frontend/__snapshots__/filters-propertyfilters-operatorvalueselect--operator-value-with-selector-property--light.png differ diff --git a/frontend/__snapshots__/filters-propertyfilters-operatorvalueselect--operator-value-with-string-property--dark.png b/frontend/__snapshots__/filters-propertyfilters-operatorvalueselect--operator-value-with-string-property--dark.png index 231ba3beb2279..19cb23c7f1ac8 100644 Binary files a/frontend/__snapshots__/filters-propertyfilters-operatorvalueselect--operator-value-with-string-property--dark.png and b/frontend/__snapshots__/filters-propertyfilters-operatorvalueselect--operator-value-with-string-property--dark.png differ diff --git a/frontend/__snapshots__/filters-propertyfilters-operatorvalueselect--operator-value-with-string-property--light.png b/frontend/__snapshots__/filters-propertyfilters-operatorvalueselect--operator-value-with-string-property--light.png index 98bb84b7de983..fc652f4b0db8f 100644 Binary files a/frontend/__snapshots__/filters-propertyfilters-operatorvalueselect--operator-value-with-string-property--light.png and b/frontend/__snapshots__/filters-propertyfilters-operatorvalueselect--operator-value-with-string-property--light.png differ diff --git a/frontend/__snapshots__/filters-propertyfilters-operatorvalueselect--operator-value-with-unknown-property--dark.png b/frontend/__snapshots__/filters-propertyfilters-operatorvalueselect--operator-value-with-unknown-property--dark.png index 35f9c2f54e508..657613e0c5cb2 100644 Binary files a/frontend/__snapshots__/filters-propertyfilters-operatorvalueselect--operator-value-with-unknown-property--dark.png and b/frontend/__snapshots__/filters-propertyfilters-operatorvalueselect--operator-value-with-unknown-property--dark.png differ diff --git a/frontend/__snapshots__/filters-propertyfilters-operatorvalueselect--operator-value-with-unknown-property--light.png b/frontend/__snapshots__/filters-propertyfilters-operatorvalueselect--operator-value-with-unknown-property--light.png index a80c182a7eaf1..c12303d8675eb 100644 Binary files a/frontend/__snapshots__/filters-propertyfilters-operatorvalueselect--operator-value-with-unknown-property--light.png and b/frontend/__snapshots__/filters-propertyfilters-operatorvalueselect--operator-value-with-unknown-property--light.png differ diff --git a/frontend/__snapshots__/scenes-app-insights--stickiness-edit--dark--webkit.png b/frontend/__snapshots__/scenes-app-insights--stickiness-edit--dark--webkit.png index c57ba3ccef8c0..b28957afbc051 100644 Binary files a/frontend/__snapshots__/scenes-app-insights--stickiness-edit--dark--webkit.png and b/frontend/__snapshots__/scenes-app-insights--stickiness-edit--dark--webkit.png differ diff --git a/frontend/__snapshots__/scenes-app-insights--stickiness-edit--dark.png b/frontend/__snapshots__/scenes-app-insights--stickiness-edit--dark.png index 9f91442d51b37..c40e7b001a7c0 100644 Binary files a/frontend/__snapshots__/scenes-app-insights--stickiness-edit--dark.png and b/frontend/__snapshots__/scenes-app-insights--stickiness-edit--dark.png differ diff --git a/frontend/__snapshots__/scenes-app-insights--stickiness-edit--light--webkit.png b/frontend/__snapshots__/scenes-app-insights--stickiness-edit--light--webkit.png index b42f782b0f6c0..ff7c81f0ade23 100644 Binary files a/frontend/__snapshots__/scenes-app-insights--stickiness-edit--light--webkit.png and b/frontend/__snapshots__/scenes-app-insights--stickiness-edit--light--webkit.png differ diff --git a/frontend/__snapshots__/scenes-app-insights--stickiness-edit--light.png b/frontend/__snapshots__/scenes-app-insights--stickiness-edit--light.png index 1567c8324dfbf..c8f7a3e25edcc 100644 Binary files a/frontend/__snapshots__/scenes-app-insights--stickiness-edit--light.png and b/frontend/__snapshots__/scenes-app-insights--stickiness-edit--light.png differ diff --git a/frontend/src/lib/utils.tsx b/frontend/src/lib/utils.tsx index fb7ac59b7166f..f512aa8e5dd85 100644 --- a/frontend/src/lib/utils.tsx +++ b/frontend/src/lib/utils.tsx @@ -236,7 +236,14 @@ export const cohortOperatorMap: Record = { not_in: 'user not in', } +export const stickinessOperatorMap: Record = { + exact: 'Exactly', + gte: 'At least', + lte: 'At most (but at least once)', +} + export const allOperatorsMapping: Record = { + ...stickinessOperatorMap, ...dateTimeOperatorMap, ...stringOperatorMap, ...numericOperatorMap, diff --git a/frontend/src/queries/nodes/InsightViz/EditorFilters.tsx b/frontend/src/queries/nodes/InsightViz/EditorFilters.tsx index 14de57bdd06a3..58bcbf6154cde 100644 --- a/frontend/src/queries/nodes/InsightViz/EditorFilters.tsx +++ b/frontend/src/queries/nodes/InsightViz/EditorFilters.tsx @@ -22,6 +22,7 @@ import { insightLogic } from 'scenes/insights/insightLogic' import { insightVizDataLogic } from 'scenes/insights/insightVizDataLogic' import { userLogic } from 'scenes/userLogic' +import { StickinessCriteria } from '~/queries/nodes/InsightViz/StickinessCriteria' import { InsightQueryNode } from '~/queries/schema' import { AvailableFeature, @@ -56,6 +57,7 @@ export function EditorFilters({ query, showing, embedded }: EditorFiltersProps): isRetention, isPaths, isLifecycle, + isStickiness, isTrendsLike, display, breakdownFilter, @@ -163,6 +165,31 @@ export function EditorFilters({ query, showing, embedded }: EditorFiltersProps): component: LifecycleToggles as (props: EditorFilterProps) => JSX.Element | null, } : null, + isStickiness + ? { + key: 'stickinessCriteria', + label: () => ( +
+ Stickiness Criteria + +
+ The stickiness criteria defines how many times a user must perform an + event inside of a given interval in order to be considered "sticky." +
+
+ } + > + + + + ), + position: 'right', + component: StickinessCriteria as (props: EditorFilterProps) => JSX.Element | null, + } + : null, { key: 'properties', label: 'Filters', diff --git a/frontend/src/queries/nodes/InsightViz/StickinessCriteria.tsx b/frontend/src/queries/nodes/InsightViz/StickinessCriteria.tsx new file mode 100644 index 0000000000000..550a3c71278ee --- /dev/null +++ b/frontend/src/queries/nodes/InsightViz/StickinessCriteria.tsx @@ -0,0 +1,49 @@ +import { LemonInput } from '@posthog/lemon-ui' +import { useActions, useValues } from 'kea' +import { OperatorSelect } from 'lib/components/PropertyFilters/components/OperatorValueSelect' +import { insightVizDataLogic } from 'scenes/insights/insightVizDataLogic' + +import { StickinessOperator } from '~/queries/schema' +import { EditorFilterProps, PropertyOperator } from '~/types' + +export function StickinessCriteria({ insightProps }: EditorFilterProps): JSX.Element { + const { stickinessFilter } = useValues(insightVizDataLogic(insightProps)) + const { updateInsightFilter } = useActions(insightVizDataLogic(insightProps)) + + const stickinessCriteria = stickinessFilter?.stickinessCriteria + const currentOperator = stickinessCriteria?.operator ?? PropertyOperator.GreaterThanOrEqual + const currentValue = stickinessCriteria?.value ?? 1 + + const operators: StickinessOperator[] = [ + PropertyOperator.LessThanOrEqual, + PropertyOperator.GreaterThanOrEqual, + PropertyOperator.Exact, + ] + + return ( +
+ { + updateInsightFilter({ + stickinessCriteria: { operator: newOperator as StickinessOperator, value: currentValue }, + }) + }} + /> + { + if (newValue !== undefined) { + updateInsightFilter({ stickinessCriteria: { operator: currentOperator, value: newValue } }) + } + }} + /> + time(s) per interval +
+ ) +} diff --git a/frontend/src/queries/schema.json b/frontend/src/queries/schema.json index 8a13b2c3c8697..3dd548292d574 100644 --- a/frontend/src/queries/schema.json +++ b/frontend/src/queries/schema.json @@ -12226,6 +12226,19 @@ }, "showValuesOnSeries": { "type": "boolean" + }, + "stickinessCriteria": { + "additionalProperties": false, + "properties": { + "operator": { + "$ref": "#/definitions/StickinessOperator" + }, + "value": { + "type": "integer" + } + }, + "required": ["operator", "value"], + "type": "object" } }, "type": "object" @@ -12265,6 +12278,10 @@ }, "type": "object" }, + "StickinessOperator": { + "enum": ["gte", "lte", "exact"], + "type": "string" + }, "StickinessQuery": { "additionalProperties": false, "properties": { diff --git a/frontend/src/queries/schema.ts b/frontend/src/queries/schema.ts index 85384688db342..4a3a91c4506d9 100644 --- a/frontend/src/queries/schema.ts +++ b/frontend/src/queries/schema.ts @@ -1500,11 +1500,20 @@ export interface PathsQuery extends InsightsQueryBase { /** `StickinessFilterType` minus everything inherited from `FilterType` and persons modal related params */ export type StickinessFilterLegacy = Omit +export type StickinessOperator = + | PropertyOperator.GreaterThanOrEqual + | PropertyOperator.LessThanOrEqual + | PropertyOperator.Exact + export type StickinessFilter = { display?: StickinessFilterLegacy['display'] showLegend?: StickinessFilterLegacy['show_legend'] showValuesOnSeries?: StickinessFilterLegacy['show_values_on_series'] hiddenLegendIndexes?: integer[] + stickinessCriteria?: { + operator: StickinessOperator + value: integer + } } export const STICKINESS_FILTER_PROPERTIES = new Set([ diff --git a/posthog/hogql_queries/insights/stickiness_query_runner.py b/posthog/hogql_queries/insights/stickiness_query_runner.py index 43928a4bd90e6..1917c32c7cc83 100644 --- a/posthog/hogql_queries/insights/stickiness_query_runner.py +++ b/posthog/hogql_queries/insights/stickiness_query_runner.py @@ -20,6 +20,7 @@ from posthog.hogql_queries.utils.query_previous_period_date_range import QueryPreviousPeriodDateRange from posthog.models import Team from posthog.models.action.action import Action +from posthog.models.cohort.util import get_count_operator, get_count_operator_ast from posthog.models.filters.mixins.utils import cached_property from posthog.schema import ( ActionsNode, @@ -87,46 +88,63 @@ def _aggregation_expressions(self, series: EventsNode | ActionsNode | DataWareho return ast.Field(chain=["e", "person_id"]) + def _having_clause(self) -> ast.Expr: + if not (self.query.stickinessFilter and self.query.stickinessFilter.stickinessCriteria): + return parse_expr("count() > 0") + operator = self.query.stickinessFilter.stickinessCriteria.operator + value = ast.Constant(value=self.query.stickinessFilter.stickinessCriteria.value) + return parse_expr(f"""count() {get_count_operator(operator)} {{value}}""", {"value": value}) + def _events_query(self, series_with_extra: SeriesWithExtras) -> ast.SelectQuery: - num_intervals_column_expr = ast.Alias( - alias="num_intervals", - expr=ast.Call( - distinct=True, - name="count", - args=[self.query_date_range.date_to_start_of_interval_hogql(ast.Field(chain=["e", "timestamp"]))], - ), + inner_query = parse_select( + """ + SELECT + {aggregation} as aggregation_target, + {start_of_interval} as start_of_interval, + FROM events e + SAMPLE {sample} + WHERE {where_clause} + GROUP BY aggregation_target, start_of_interval + HAVING {having_clause} + """, + { + "aggregation": self._aggregation_expressions(series_with_extra.series), + "start_of_interval": self.query_date_range.date_to_start_of_interval_hogql( + ast.Field(chain=["e", "timestamp"]) + ), + "sample": self._sample_value(), + "where_clause": self.where_clause(series_with_extra), + "having_clause": self._having_clause(), + }, ) - aggregation = ast.Alias( - alias="aggregation_target", expr=self._aggregation_expressions(series_with_extra.series) + middle_query = parse_select( + """ + SELECT + aggregation_target, + count() as num_intervals + FROM + {inner_query} + GROUP BY + aggregation_target + """, + {"inner_query": inner_query}, ) - select_query = parse_select( + outer_query = parse_select( """ - SELECT - count(DISTINCT aggregation_target), - num_intervals - FROM ( - SELECT {aggregation}, {num_intervals_column_expr} - FROM events e - SAMPLE {sample} - WHERE {where_clause} - GROUP BY aggregation_target - ) - WHERE num_intervals <= {num_intervals} - GROUP BY num_intervals - ORDER BY num_intervals + SELECT + count(DISTINCT aggregation_target) as num_actors, + num_intervals + FROM + {middle_query} + GROUP BY num_intervals + ORDER BY num_intervals """, - placeholders={ - "where_clause": self.where_clause(series_with_extra), - "num_intervals": ast.Constant(value=self.intervals_num()), - "sample": self._sample_value(), - "num_intervals_column_expr": num_intervals_column_expr, - "aggregation": aggregation, - }, + {"middle_query": middle_query}, ) - return cast(ast.SelectQuery, select_query) + return cast(ast.SelectQuery, outer_query) def to_query(self) -> ast.SelectSetQuery: return ast.SelectSetQuery.create_from_queries(self.to_queries(), "UNION ALL") @@ -145,12 +163,12 @@ def to_queries(self) -> list[ast.SelectQuery]: select_query = parse_select( """ SELECT - groupArray(aggregation_target) as counts, + groupArray(num_actors) as counts, groupArray(num_intervals) as intervals FROM ( - SELECT sum(aggregation_target) as aggregation_target, num_intervals + SELECT sum(num_actors) as num_actors, num_intervals FROM ( - SELECT 0 as aggregation_target, (number + 1) as num_intervals + SELECT 0 as num_actors, (number + 1) as num_intervals FROM numbers(dateDiff({interval}, {date_from_start_of_interval}, {date_to_start_of_interval} + {interval_addition})) UNION ALL {events_query} @@ -170,7 +188,9 @@ def to_queries(self) -> list[ast.SelectQuery]: return queries - def to_actors_query(self, interval_num: Optional[int] = None) -> ast.SelectQuery | ast.SelectSetQuery: + def to_actors_query( + self, interval_num: Optional[int] = None, operator: Optional[str] = None + ) -> ast.SelectQuery | ast.SelectSetQuery: queries: list[ast.SelectQuery] = [] for series in self.series: @@ -188,7 +208,7 @@ def to_actors_query(self, interval_num: Optional[int] = None) -> ast.SelectQuery if interval_num is not None: events_query.where = ast.CompareOperation( left=ast.Field(chain=["num_intervals"]), - op=ast.CompareOperationOp.Eq, + op=ast.CompareOperationOp.Eq if operator is None else get_count_operator_ast(operator), right=ast.Constant(value=interval_num), ) diff --git a/posthog/hogql_queries/insights/test/__snapshots__/test_insight_actors_query_runner.ambr b/posthog/hogql_queries/insights/test/__snapshots__/test_insight_actors_query_runner.ambr index fd53c91934ed8..b8db8df7c3613 100644 --- a/posthog/hogql_queries/insights/test/__snapshots__/test_insight_actors_query_runner.ambr +++ b/posthog/hogql_queries/insights/test/__snapshots__/test_insight_actors_query_runner.ambr @@ -172,10 +172,16 @@ FROM (SELECT aggregation_target AS group_key FROM - (SELECT e.`$group_0` AS aggregation_target, - count(DISTINCT toStartOfDay(toTimeZone(e.timestamp, 'US/Pacific'))) AS num_intervals - FROM events AS e SAMPLE 1 - WHERE and(equals(e.team_id, 99999), greaterOrEquals(toTimeZone(e.timestamp, 'US/Pacific'), toStartOfDay(assumeNotNull(parseDateTime64BestEffortOrNull('2020-01-01 00:00:00', 6, 'US/Pacific')))), lessOrEquals(toTimeZone(e.timestamp, 'US/Pacific'), assumeNotNull(parseDateTime64BestEffortOrNull('2020-01-19 23:59:59', 6, 'US/Pacific'))), equals(e.event, '$pageview'), notEquals(e.`$group_0`, '')) + (SELECT aggregation_target AS aggregation_target, + count() AS num_intervals + FROM + (SELECT e.`$group_0` AS aggregation_target, + toStartOfDay(toTimeZone(e.timestamp, 'US/Pacific')) AS start_of_interval + FROM events AS e SAMPLE 1 + WHERE and(equals(e.team_id, 99999), greaterOrEquals(toTimeZone(e.timestamp, 'US/Pacific'), toStartOfDay(assumeNotNull(parseDateTime64BestEffortOrNull('2020-01-01 00:00:00', 6, 'US/Pacific')))), lessOrEquals(toTimeZone(e.timestamp, 'US/Pacific'), assumeNotNull(parseDateTime64BestEffortOrNull('2020-01-19 23:59:59', 6, 'US/Pacific'))), equals(e.event, '$pageview'), notEquals(e.`$group_0`, '')) + GROUP BY aggregation_target, + start_of_interval + HAVING ifNull(greater(count(), 0), 0)) GROUP BY aggregation_target) WHERE ifNull(equals(num_intervals, 7), 0)) AS source INNER JOIN @@ -204,17 +210,23 @@ FROM (SELECT aggregation_target AS actor_id FROM - (SELECT if(not(empty(e__override.distinct_id)), e__override.person_id, e.person_id) AS aggregation_target, - count(DISTINCT toStartOfDay(toTimeZone(e.timestamp, 'US/Pacific'))) AS num_intervals - FROM events AS e SAMPLE 1 - LEFT OUTER JOIN - (SELECT argMax(person_distinct_id_overrides.person_id, person_distinct_id_overrides.version) AS person_id, - person_distinct_id_overrides.distinct_id AS distinct_id - FROM person_distinct_id_overrides - WHERE equals(person_distinct_id_overrides.team_id, 99999) - GROUP BY person_distinct_id_overrides.distinct_id - HAVING ifNull(equals(argMax(person_distinct_id_overrides.is_deleted, person_distinct_id_overrides.version), 0), 0) SETTINGS optimize_aggregation_in_order=1) AS e__override ON equals(e.distinct_id, e__override.distinct_id) - WHERE and(equals(e.team_id, 99999), greaterOrEquals(toTimeZone(e.timestamp, 'US/Pacific'), toStartOfDay(assumeNotNull(parseDateTime64BestEffortOrNull('2020-01-09 00:00:00', 6, 'US/Pacific')))), lessOrEquals(toTimeZone(e.timestamp, 'US/Pacific'), assumeNotNull(parseDateTime64BestEffortOrNull('2020-01-19 23:59:59', 6, 'US/Pacific'))), equals(e.event, '$pageview')) + (SELECT aggregation_target AS aggregation_target, + count() AS num_intervals + FROM + (SELECT if(not(empty(e__override.distinct_id)), e__override.person_id, e.person_id) AS aggregation_target, + toStartOfDay(toTimeZone(e.timestamp, 'US/Pacific')) AS start_of_interval + FROM events AS e SAMPLE 1 + LEFT OUTER JOIN + (SELECT argMax(person_distinct_id_overrides.person_id, person_distinct_id_overrides.version) AS person_id, + person_distinct_id_overrides.distinct_id AS distinct_id + FROM person_distinct_id_overrides + WHERE equals(person_distinct_id_overrides.team_id, 99999) + GROUP BY person_distinct_id_overrides.distinct_id + HAVING ifNull(equals(argMax(person_distinct_id_overrides.is_deleted, person_distinct_id_overrides.version), 0), 0) SETTINGS optimize_aggregation_in_order=1) AS e__override ON equals(e.distinct_id, e__override.distinct_id) + WHERE and(equals(e.team_id, 99999), greaterOrEquals(toTimeZone(e.timestamp, 'US/Pacific'), toStartOfDay(assumeNotNull(parseDateTime64BestEffortOrNull('2020-01-09 00:00:00', 6, 'US/Pacific')))), lessOrEquals(toTimeZone(e.timestamp, 'US/Pacific'), assumeNotNull(parseDateTime64BestEffortOrNull('2020-01-19 23:59:59', 6, 'US/Pacific'))), equals(e.event, '$pageview')) + GROUP BY aggregation_target, + start_of_interval + HAVING ifNull(greater(count(), 0), 0)) GROUP BY aggregation_target) WHERE ifNull(equals(num_intervals, 2), 0)) AS source INNER JOIN @@ -229,15 +241,19 @@ FROM (SELECT aggregation_target AS actor_id FROM - (SELECT if(not(empty(e__override.distinct_id)), e__override.person_id, e.person_id) AS aggregation_target, count(DISTINCT toStartOfDay(toTimeZone(e.timestamp, 'US/Pacific'))) AS num_intervals - FROM events AS e SAMPLE 1 - LEFT OUTER JOIN - (SELECT argMax(person_distinct_id_overrides.person_id, person_distinct_id_overrides.version) AS person_id, person_distinct_id_overrides.distinct_id AS distinct_id - FROM person_distinct_id_overrides - WHERE equals(person_distinct_id_overrides.team_id, 99999) - GROUP BY person_distinct_id_overrides.distinct_id - HAVING ifNull(equals(argMax(person_distinct_id_overrides.is_deleted, person_distinct_id_overrides.version), 0), 0) SETTINGS optimize_aggregation_in_order=1) AS e__override ON equals(e.distinct_id, e__override.distinct_id) - WHERE and(equals(e.team_id, 99999), greaterOrEquals(toTimeZone(e.timestamp, 'US/Pacific'), toStartOfDay(assumeNotNull(parseDateTime64BestEffortOrNull('2020-01-09 00:00:00', 6, 'US/Pacific')))), lessOrEquals(toTimeZone(e.timestamp, 'US/Pacific'), assumeNotNull(parseDateTime64BestEffortOrNull('2020-01-19 23:59:59', 6, 'US/Pacific'))), equals(e.event, '$pageview')) + (SELECT aggregation_target AS aggregation_target, count() AS num_intervals + FROM + (SELECT if(not(empty(e__override.distinct_id)), e__override.person_id, e.person_id) AS aggregation_target, toStartOfDay(toTimeZone(e.timestamp, 'US/Pacific')) AS start_of_interval + FROM events AS e SAMPLE 1 + LEFT OUTER JOIN + (SELECT argMax(person_distinct_id_overrides.person_id, person_distinct_id_overrides.version) AS person_id, person_distinct_id_overrides.distinct_id AS distinct_id + FROM person_distinct_id_overrides + WHERE equals(person_distinct_id_overrides.team_id, 99999) + GROUP BY person_distinct_id_overrides.distinct_id + HAVING ifNull(equals(argMax(person_distinct_id_overrides.is_deleted, person_distinct_id_overrides.version), 0), 0) SETTINGS optimize_aggregation_in_order=1) AS e__override ON equals(e.distinct_id, e__override.distinct_id) + WHERE and(equals(e.team_id, 99999), greaterOrEquals(toTimeZone(e.timestamp, 'US/Pacific'), toStartOfDay(assumeNotNull(parseDateTime64BestEffortOrNull('2020-01-09 00:00:00', 6, 'US/Pacific')))), lessOrEquals(toTimeZone(e.timestamp, 'US/Pacific'), assumeNotNull(parseDateTime64BestEffortOrNull('2020-01-19 23:59:59', 6, 'US/Pacific'))), equals(e.event, '$pageview')) + GROUP BY aggregation_target, start_of_interval + HAVING ifNull(greater(count(), 0), 0)) GROUP BY aggregation_target) WHERE ifNull(equals(num_intervals, 2), 0)) AS source))) GROUP BY person.id diff --git a/posthog/hogql_queries/insights/test/test_stickiness_query_runner.py b/posthog/hogql_queries/insights/test/test_stickiness_query_runner.py index 4752ae508e374..394fdcf0142b3 100644 --- a/posthog/hogql_queries/insights/test/test_stickiness_query_runner.py +++ b/posthog/hogql_queries/insights/test/test_stickiness_query_runner.py @@ -36,7 +36,7 @@ CompareFilter, ) from posthog.settings import HOGQL_INCREASED_MAX_EXECUTION_TIME -from posthog.test.base import APIBaseTest, _create_event, _create_person +from posthog.test.base import APIBaseTest, _create_event, _create_person, ClickhouseTestMixin @dataclass @@ -72,7 +72,7 @@ class SeriesTestData: ] -class TestStickinessQueryRunner(APIBaseTest): +class TestStickinessQueryRunner(ClickhouseTestMixin, APIBaseTest): default_date_from = "2020-01-11" default_date_to = "2020-01-20" @@ -542,6 +542,73 @@ def test_compare_to(self): assert response.results[1]["compare_label"] == "previous" assert response.results[1]["data"] == [0, 0, 0, 0, 1, 0, 0, 0, 1] + def test_criteria(self): + self._create_events( + [ + SeriesTestData( + distinct_id="p1", + events=[ + Series( + event="$pageview", + timestamps=[ + "2020-01-11T12:00:00Z", + "2020-01-11T12:00:00Z", + "2020-01-13T12:00:00Z", + "2020-01-13T12:00:00Z", + "2020-01-15T12:00:00Z", + "2020-01-15T12:00:00Z", + "2020-01-17T12:00:00Z", + "2020-01-17T12:00:00Z", + "2020-01-17T12:00:00Z", + "2020-01-19T12:00:00Z", + ], + ), + ], + properties={"$browser": "Chrome", "prop": 10, "bool_field": True, "$group_0": "org:1"}, + ), + SeriesTestData( + distinct_id="p2", + events=[ + Series( + event="$pageview", + timestamps=[ + "2020-01-12T12:00:00Z", + "2020-01-12T12:00:00Z", + "2020-01-13T12:00:00Z", + "2020-01-13T12:00:00Z", + "2020-01-19T12:00:00Z", + ], + ), + ], + properties={"$browser": "Chrome", "prop": 10, "bool_field": True, "$group_0": "org:1"}, + ), + ] + ) + + response = self._run_query( + date_from="2020-01-12", + date_to="2020-01-20", + filters=StickinessFilter(**{"stickinessCriteria": {"operator": "gte", "value": 2}}), + ) + assert response.results[0]["count"] == 2 + assert response.results[0]["data"] == [0, 1, 1, 0, 0, 0, 0, 0, 0] + + response = self._run_query( + date_from="2020-01-12", + date_to="2020-01-20", + filters=StickinessFilter(**{"stickinessCriteria": {"operator": "lte", "value": 1}}), + ) + assert response.results[0]["count"] == 2 + assert response.results[0]["data"] == [2, 0, 0, 0, 0, 0, 0, 0, 0] + + response = self._run_query( + date_from="2020-01-11", + date_to="2020-01-20", + filters=StickinessFilter(**{"stickinessCriteria": {"operator": "exact", "value": 2}}), + ) + assert response.results[0]["count"] == 2 + assert response.results[0]["data"] == [0, 1, 1, 0, 0, 0, 0, 0, 0, 0] + def test_filter_test_accounts(self): self._create_test_events() diff --git a/posthog/models/cohort/util.py b/posthog/models/cohort/util.py index 6d8de53b0e8de..fe589236fa62e 100644 --- a/posthog/models/cohort/util.py +++ b/posthog/models/cohort/util.py @@ -147,6 +147,21 @@ def get_count_operator(count_operator: Optional[str]) -> str: raise ValidationError("count_operator must be gte, lte, eq, or None") +def get_count_operator_ast(count_operator: Optional[str]) -> ast.CompareOperationOp: + if count_operator == "gte": + return ast.CompareOperationOp.GtEq + elif count_operator == "lte": + return ast.CompareOperationOp.LtEq + elif count_operator == "gt": + return ast.CompareOperationOp.Gt + elif count_operator == "lt": + return ast.CompareOperationOp.Lt + elif count_operator == "eq" or count_operator == "exact" or count_operator is None: + return ast.CompareOperationOp.Eq + else: + raise ValidationError("count_operator must be gte, lte, eq, or None") + + def get_entity_query( event_id: Optional[str], action_id: Optional[int], diff --git a/posthog/schema.py b/posthog/schema.py index aebc91b420e42..1935f76b76db3 100644 --- a/posthog/schema.py +++ b/posthog/schema.py @@ -1535,16 +1535,6 @@ class StepOrderValue(StrEnum): ORDERED = "ordered" -class StickinessFilter(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - display: Optional[ChartDisplayType] = None - hiddenLegendIndexes: Optional[list[int]] = None - showLegend: Optional[bool] = None - showValuesOnSeries: Optional[bool] = None - - class StickinessFilterLegacy(BaseModel): model_config = ConfigDict( extra="forbid", @@ -1557,6 +1547,12 @@ class StickinessFilterLegacy(BaseModel): show_values_on_series: Optional[bool] = None +class StickinessOperator(StrEnum): + GTE = "gte" + LTE = "lte" + EXACT = "exact" + + class SuggestedQuestionsQueryResponse(BaseModel): model_config = ConfigDict( extra="forbid", @@ -3294,6 +3290,25 @@ class SessionsTimelineQueryResponse(BaseModel): ) +class StickinessCriteria(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + operator: StickinessOperator + value: int + + +class StickinessFilter(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + display: Optional[ChartDisplayType] = None + hiddenLegendIndexes: Optional[list[int]] = None + showLegend: Optional[bool] = None + showValuesOnSeries: Optional[bool] = None + stickinessCriteria: Optional[StickinessCriteria] = None + + class StickinessQueryResponse(BaseModel): model_config = ConfigDict( extra="forbid",