Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add criteria to stickiness insight #26410

Merged
merged 28 commits into from
Nov 26, 2024
Merged
Show file tree
Hide file tree
Changes from 19 commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
49f42bf
stickiness
aspicer Nov 25, 2024
e6f943e
new stickiness
aspicer Nov 25, 2024
684dc1f
changes
aspicer Nov 25, 2024
a212b3d
front end
aspicer Nov 25, 2024
0893f51
stickiness
aspicer Nov 25, 2024
68c06c8
fix mypy
aspicer Nov 25, 2024
3efbe58
merge and rebuild schema
aspicer Nov 25, 2024
ea0d388
Update UI snapshots for `webkit` (2)
github-actions[bot] Nov 25, 2024
88cafe8
Update query snapshots
github-actions[bot] Nov 25, 2024
f1741ef
Update UI snapshots for `chromium` (2)
github-actions[bot] Nov 25, 2024
e994f37
hm
aspicer Nov 26, 2024
bef27a2
Merge branch 'aspicer/stickiness' of github.com:PostHog/posthog into …
aspicer Nov 26, 2024
07c8d2f
Merge remote-tracking branch 'origin/master' into aspicer/stickiness
aspicer Nov 26, 2024
fbcb6b2
hm
aspicer Nov 26, 2024
d6d1775
Update UI snapshots for `webkit` (2)
github-actions[bot] Nov 26, 2024
4febf88
Update UI snapshots for `chromium` (2)
github-actions[bot] Nov 26, 2024
5c53e0c
hm
aspicer Nov 26, 2024
8f9ba80
Merge branch 'aspicer/stickiness' of github.com:PostHog/posthog into …
aspicer Nov 26, 2024
210b444
Update query snapshots
github-actions[bot] Nov 26, 2024
93f362b
ahhhh
aspicer Nov 26, 2024
0949181
Merge branch 'aspicer/stickiness' of github.com:PostHog/posthog into …
aspicer Nov 26, 2024
f076b47
Merge remote-tracking branch 'origin/master' into aspicer/stickiness
aspicer Nov 26, 2024
8f033ea
clean up
aspicer Nov 26, 2024
984d71f
change
aspicer Nov 26, 2024
071f4c2
Update UI snapshots for `chromium` (1)
github-actions[bot] Nov 26, 2024
604d666
Update UI snapshots for `chromium` (2)
github-actions[bot] Nov 26, 2024
a9c0a81
Update UI snapshots for `chromium` (1)
github-actions[bot] Nov 26, 2024
4c8977b
Update UI snapshots for `chromium` (2)
github-actions[bot] Nov 26, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/actions/run-backend-tests/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -210,7 +210,7 @@ runs:

- name: Upload updated timing data as artifacts
uses: actions/upload-artifact@v4
if: ${{ inputs.person-on-events != 'true' && inputs.clickhouse-server-image == 'clickhouse/clickhouse-server:24.8.7.41' }}
if: ${{ inputs.person-on-events != 'true' && inputs.clickhouse-server-image == 'clickhouse/clickhouse-server:23.12.6.19' }}
with:
name: timing_data-${{ inputs.segment }}-${{ inputs.group }}
path: .test_durations
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/ci-backend-update-test-timing.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,13 @@ jobs:
group: 1
token: ${{ secrets.POSTHOG_BOT_GITHUB_TOKEN }}
python-version: '3.11.9'
clickhouse-server-image: 'clickhouse/clickhouse-server:24.8.7.41'
clickhouse-server-image: 'clickhouse/clickhouse-server:23.12.6.19'
segment: 'FOSS'
person-on-events: false

- name: Upload updated timing data as artifacts
uses: actions/upload-artifact@v4
if: ${{ inputs.person-on-events != 'true' && inputs.clickhouse-server-image == 'clickhouse/clickhouse-server:24.8.7.41' }}
if: ${{ inputs.person-on-events != 'true' && inputs.clickhouse-server-image == 'clickhouse/clickhouse-server:23.12.6.19' }}
with:
name: timing_data-${{ inputs.segment }}-${{ inputs.group }}
path: .test_durations
Expand Down
10 changes: 5 additions & 5 deletions .github/workflows/ci-backend.yml
Original file line number Diff line number Diff line change
Expand Up @@ -234,7 +234,7 @@ jobs:
fail-fast: false
matrix:
python-version: ['3.11.9']
clickhouse-server-image: ['clickhouse/clickhouse-server:24.8.7.41']
clickhouse-server-image: ['clickhouse/clickhouse-server:23.12.6.19']
segment: ['Core']
person-on-events: [false, true]
# :NOTE: Keep concurrency and groups in sync
Expand All @@ -243,19 +243,19 @@ jobs:
include:
- segment: 'Temporal'
person-on-events: false
clickhouse-server-image: 'clickhouse/clickhouse-server:24.8.7.41'
clickhouse-server-image: 'clickhouse/clickhouse-server:23.12.6.19'
python-version: '3.11.9'
concurrency: 3
group: 1
- segment: 'Temporal'
person-on-events: false
clickhouse-server-image: 'clickhouse/clickhouse-server:24.8.7.41'
clickhouse-server-image: 'clickhouse/clickhouse-server:23.12.6.19'
python-version: '3.11.9'
concurrency: 3
group: 2
- segment: 'Temporal'
person-on-events: false
clickhouse-server-image: 'clickhouse/clickhouse-server:24.8.7.41'
clickhouse-server-image: 'clickhouse/clickhouse-server:23.12.6.19'
python-version: '3.11.9'
concurrency: 3
group: 3
Expand Down Expand Up @@ -326,7 +326,7 @@ jobs:
strategy:
fail-fast: false
matrix:
clickhouse-server-image: ['clickhouse/clickhouse-server:24.8.7.41']
clickhouse-server-image: ['clickhouse/clickhouse-server:23.12.6.19']
if: needs.changes.outputs.backend == 'true'
runs-on: ubuntu-latest
steps:
Expand Down
2 changes: 1 addition & 1 deletion docker-compose.base.yml
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ services:
# Note: please keep the default version in sync across
# `posthog` and the `charts-clickhouse` repos
#
image: ${CLICKHOUSE_SERVER_IMAGE:-clickhouse/clickhouse-server:24.8.7.41}
image: ${CLICKHOUSE_SERVER_IMAGE:-clickhouse/clickhouse-server:23.12.6.19}
restart: on-failure

zookeeper:
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
7 changes: 7 additions & 0 deletions frontend/src/lib/utils.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,12 @@ export const cohortOperatorMap: Record<string, string> = {
not_in: 'user not in',
}

export const stickinessOperatorMap: Record<string, string> = {
exact: '= equals',
gte: '≥ greater than or equals',
lte: '≤ less than or equals (but at least once)',
}

export const allOperatorsMapping: Record<string, string> = {
...dateTimeOperatorMap,
...stringOperatorMap,
Expand All @@ -245,6 +251,7 @@ export const allOperatorsMapping: Record<string, string> = {
...durationOperatorMap,
...selectorOperatorMap,
...cohortOperatorMap,
...stickinessOperatorMap,
// slight overkill to spread all of these into the map
// but gives freedom for them to diverge more over time
}
Expand Down
27 changes: 27 additions & 0 deletions frontend/src/queries/nodes/InsightViz/EditorFilters.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -56,6 +57,7 @@ export function EditorFilters({ query, showing, embedded }: EditorFiltersProps):
isRetention,
isPaths,
isLifecycle,
isStickiness,
isTrendsLike,
display,
breakdownFilter,
Expand Down Expand Up @@ -163,6 +165,31 @@ export function EditorFilters({ query, showing, embedded }: EditorFiltersProps):
component: LifecycleToggles as (props: EditorFilterProps) => JSX.Element | null,
}
: null,
isStickiness
? {
key: 'stickinessCriteria',
label: () => (
<div className="flex">
<span>Stickiness Criteria</span>
<Tooltip
closeDelayMs={200}
title={
<div className="space-y-2">
<div>
The stickiness criteria defines how many times a user must perform an
event inside of a given interval in order to be considered "sticky."
</div>
</div>
}
>
<IconInfo className="text-xl text-muted-alt shrink-0 ml-1" />
</Tooltip>
</div>
),
position: 'right',
component: StickinessCriteria as (props: EditorFilterProps) => JSX.Element | null,
}
: null,
{
key: 'properties',
label: 'Filters',
Expand Down
49 changes: 49 additions & 0 deletions frontend/src/queries/nodes/InsightViz/StickinessCriteria.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="flex items-center gap-2">
<OperatorSelect
className="flex-1"
operator={currentOperator}
operators={operators}
onChange={(newOperator: PropertyOperator) => {
updateInsightFilter({
stickinessCriteria: { operator: newOperator as StickinessOperator, value: currentValue },
})
}}
/>
<LemonInput
type="number"
className="ml-2 w-20"
defaultValue={currentValue}
min={1}
onChange={(newValue: number | undefined) => {
if (newValue !== undefined) {
updateInsightFilter({ stickinessCriteria: { operator: currentOperator, value: newValue } })
}
}}
/>
times per interval
</div>
)
}
17 changes: 17 additions & 0 deletions frontend/src/queries/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -12220,6 +12220,19 @@
},
"showValuesOnSeries": {
"type": "boolean"
},
"stickinessCriteria": {
"additionalProperties": false,
"properties": {
"operator": {
"$ref": "#/definitions/StickinessOperator"
},
"value": {
"type": "integer"
}
},
"required": ["operator", "value"],
"type": "object"
}
},
"type": "object"
Expand Down Expand Up @@ -12259,6 +12272,10 @@
},
"type": "object"
},
"StickinessOperator": {
"enum": ["gte", "lte", "exact"],
"type": "string"
},
"StickinessQuery": {
"additionalProperties": false,
"properties": {
Expand Down
9 changes: 9 additions & 0 deletions frontend/src/queries/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1500,11 +1500,20 @@ export interface PathsQuery extends InsightsQueryBase<PathsQueryResponse> {
/** `StickinessFilterType` minus everything inherited from `FilterType` and persons modal related params */
export type StickinessFilterLegacy = Omit<StickinessFilterType, keyof FilterType | 'stickiness_days' | 'shown_as'>

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<keyof StickinessFilter>([
Expand Down
92 changes: 56 additions & 36 deletions posthog/hogql_queries/insights/stickiness_query_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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")
Expand 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}
Expand All @@ -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:
Expand All @@ -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),
)

Expand Down
Loading
Loading