Skip to content

Commit

Permalink
feat(web-analytics): Add custom bounce rate control (#27121)
Browse files Browse the repository at this point in the history
Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com>
  • Loading branch information
robbie-c and github-actions[bot] authored Dec 26, 2024
1 parent b09e3f0 commit e56f52b
Show file tree
Hide file tree
Showing 25 changed files with 195 additions and 15 deletions.
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.
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.
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.
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.
3 changes: 3 additions & 0 deletions frontend/src/queries/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -7359,6 +7359,9 @@
"additionalProperties": false,
"description": "HogQL Query Options are automatically set per team. However, they can be overridden in the query.",
"properties": {
"bounceRateDurationSeconds": {
"type": "number"
},
"bounceRatePageViewMode": {
"enum": ["count_pageviews", "uniq_urls", "uniq_page_screen_autocaptures"],
"type": "string"
Expand Down
1 change: 1 addition & 0 deletions frontend/src/queries/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,7 @@ export interface HogQLQueryModifiers {
s3TableUseInvalidColumns?: boolean
personsJoinMode?: 'inner' | 'left'
bounceRatePageViewMode?: 'count_pageviews' | 'uniq_urls' | 'uniq_page_screen_autocaptures'
bounceRateDurationSeconds?: number
sessionTableVersion?: 'auto' | 'v1' | 'v2'
propertyGroupsMode?: 'enabled' | 'disabled' | 'optimized'
useMaterializedViews?: boolean
Expand Down
18 changes: 12 additions & 6 deletions frontend/src/scenes/settings/SettingsMap.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { BounceRateDurationSetting } from 'scenes/settings/environment/BounceRateDuration'
import { BounceRatePageViewModeSetting } from 'scenes/settings/environment/BounceRatePageViewMode'
import { CookielessServerHashModeSetting } from 'scenes/settings/environment/CookielessServerHashMode'
import { CustomChannelTypes } from 'scenes/settings/environment/CustomChannelTypes'
Expand Down Expand Up @@ -194,12 +195,6 @@ export const SETTINGS_MAP: SettingSection[] = [
component: <PersonsJoinMode />,
flag: 'SETTINGS_PERSONS_JOIN_MODE',
},
{
id: 'bounce-rate-page-view-mode',
title: 'Bounce rate page view mode',
component: <BounceRatePageViewModeSetting />,
flag: 'SETTINGS_BOUNCE_RATE_PAGE_VIEW_MODE',
},
{
id: 'session-table-version',
title: 'Sessions Table Version',
Expand All @@ -225,6 +220,17 @@ export const SETTINGS_MAP: SettingSection[] = [
component: <CookielessServerHashModeSetting />,
flag: 'COOKIELESS_SERVER_HASH_MODE_SETTING',
},
{
id: 'bounce-rate-duration',
title: 'Bounce rate duration',
component: <BounceRateDurationSetting />,
},
{
id: 'bounce-rate-page-view-mode',
title: 'Bounce rate page view mode',
component: <BounceRatePageViewModeSetting />,
flag: 'SETTINGS_BOUNCE_RATE_PAGE_VIEW_MODE',
},
],
},

Expand Down
88 changes: 88 additions & 0 deletions frontend/src/scenes/settings/environment/BounceRateDuration.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { IconX } from '@posthog/icons'
import { useActions, useValues } from 'kea'
import { LemonButton } from 'lib/lemon-ui/LemonButton'
import { LemonInput } from 'lib/lemon-ui/LemonInput'
import React, { useState } from 'react'
import { teamLogic } from 'scenes/teamLogic'

const MIN_BOUNCE_RATE_DURATION = 1
const MAX_BOUNCE_RATE_DURATION = 120
const DEFAULT_BOUNCE_RATE_DURATION = 10

export function BounceRateDurationSetting(): JSX.Element {
const { updateCurrentTeam } = useActions(teamLogic)
const { currentTeam } = useValues(teamLogic)

const savedDuration =
currentTeam?.modifiers?.bounceRateDurationSeconds ?? currentTeam?.default_modifiers?.bounceRateDurationSeconds
const [bounceRateDuration, setBounceRateDuration] = useState<number>(savedDuration ?? DEFAULT_BOUNCE_RATE_DURATION)

const handleChange = (duration: number | undefined): void => {
if (Number.isNaN(duration)) {
duration = undefined
}
updateCurrentTeam({
modifiers: { ...currentTeam?.modifiers, bounceRateDurationSeconds: duration },
})
}

const inputRef = React.useRef<HTMLInputElement>(null)

return (
<>
<p>
Choose how long a user can stay on a page, in seconds, before the session is not a bounce. Leave blank
to use the default of {DEFAULT_BOUNCE_RATE_DURATION} seconds, or set a custom value between{' '}
{MIN_BOUNCE_RATE_DURATION} second and {MAX_BOUNCE_RATE_DURATION} seconds inclusive.
</p>
<LemonInput
type="number"
min={MIN_BOUNCE_RATE_DURATION}
max={MAX_BOUNCE_RATE_DURATION}
value={bounceRateDuration ?? null}
onChange={(x) => {
if (x == null || Number.isNaN(x)) {
setBounceRateDuration(DEFAULT_BOUNCE_RATE_DURATION)
} else {
setBounceRateDuration(x)
}
}}
inputRef={inputRef}
suffix={
<LemonButton
size="small"
noPadding
icon={<IconX />}
tooltip="Clear input"
onClick={(e) => {
e.stopPropagation()
setBounceRateDuration(DEFAULT_BOUNCE_RATE_DURATION)
inputRef.current?.focus()
}}
/>
}
/>
<div className="mt-4">
<LemonButton
type="primary"
onClick={() => handleChange(bounceRateDuration)}
disabledReason={
bounceRateDuration === savedDuration
? 'No changes to save'
: bounceRateDuration == undefined
? undefined
: isNaN(bounceRateDuration)
? 'Invalid number'
: bounceRateDuration < MIN_BOUNCE_RATE_DURATION
? `Duration must be at least ${MIN_BOUNCE_RATE_DURATION} second`
: bounceRateDuration > MAX_BOUNCE_RATE_DURATION
? `Duration must be less than ${MAX_BOUNCE_RATE_DURATION} seconds`
: undefined
}
>
Save
</LemonButton>
</div>
</>
)
}
1 change: 1 addition & 0 deletions frontend/src/scenes/settings/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ export type SettingId =
| 'hedgehog-mode'
| 'persons-join-mode'
| 'bounce-rate-page-view-mode'
| 'bounce-rate-duration'
| 'session-table-version'
| 'web-vitals-autocapture'
| 'dead-clicks-autocapture'
Expand Down
14 changes: 12 additions & 2 deletions posthog/hogql/database/schema/sessions_v1.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@
if TYPE_CHECKING:
from posthog.models.team import Team

DEFAULT_BOUNCE_RATE_DURATION_SECONDS = 10

RAW_SESSIONS_FIELDS: dict[str, FieldOrTable] = {
"id": StringDatabaseField(name="session_id"),
# TODO remove this, it's a duplicate of the correct session_id field below to get some trends working on a deadline
Expand Down Expand Up @@ -205,6 +207,11 @@ def arg_max_merge_field(field_name: str) -> ast.Call:
args=[aggregate_fields["$urls"]],
)

bounce_rate_duration_seconds = (
context.modifiers.bounceRateDurationSeconds
if context.modifiers.bounceRateDurationSeconds is not None
else DEFAULT_BOUNCE_RATE_DURATION_SECONDS
)
if context.modifiers.bounceRatePageViewMode == BounceRatePageViewMode.UNIQ_URLS:
bounce_pageview_count = aggregate_fields["$num_uniq_urls"]
else:
Expand All @@ -227,10 +234,13 @@ def arg_max_merge_field(field_name: str) -> ast.Call:
ast.Call(
name="greater", args=[aggregate_fields["$autocapture_count"], ast.Constant(value=0)]
),
# if session duration >= 10 seconds, not a bounce
# if session duration >= bounce_rate_duration_seconds, not a bounce
ast.Call(
name="greaterOrEquals",
args=[aggregate_fields["$session_duration"], ast.Constant(value=10)],
args=[
aggregate_fields["$session_duration"],
ast.Constant(value=bounce_rate_duration_seconds),
],
),
],
)
Expand Down
22 changes: 17 additions & 5 deletions posthog/hogql/database/schema/sessions_v2.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
ChannelTypeExprs,
DEFAULT_CHANNEL_TYPES,
)
from posthog.hogql.database.schema.sessions_v1 import null_if_empty
from posthog.hogql.database.schema.sessions_v1 import null_if_empty, DEFAULT_BOUNCE_RATE_DURATION_SECONDS
from posthog.hogql.database.schema.util.where_clause_extractor import SessionMinTimestampWhereClauseExtractorV2
from posthog.hogql.errors import ResolutionError
from posthog.hogql.modifiers import create_default_modifiers_for_team
Expand All @@ -37,6 +37,7 @@
if TYPE_CHECKING:
from posthog.models.team import Team


RAW_SESSIONS_FIELDS: dict[str, FieldOrTable] = {
"session_id_v7": IntegerDatabaseField(name="session_id_v7"),
"team_id": IntegerDatabaseField(name="team_id"),
Expand Down Expand Up @@ -252,6 +253,11 @@ def arg_max_merge_field(field_name: str) -> ast.Call:
args=[aggregate_fields["$urls"]],
)

bounce_rate_duration_seconds = (
context.modifiers.bounceRateDurationSeconds
if context.modifiers.bounceRateDurationSeconds is not None
else DEFAULT_BOUNCE_RATE_DURATION_SECONDS
)
if context.modifiers.bounceRatePageViewMode == BounceRatePageViewMode.UNIQ_PAGE_SCREEN_AUTOCAPTURES:
bounce_event_count = aggregate_fields["$page_screen_autocapture_count_up_to"]
aggregate_fields["$is_bounce"] = ast.Call(
Expand All @@ -268,10 +274,13 @@ def arg_max_merge_field(field_name: str) -> ast.Call:
args=[
# if pageviews + autocaptures > 1, not a bounce
ast.Call(name="greater", args=[bounce_event_count, ast.Constant(value=1)]),
# if session duration >= 10 seconds, not a bounce
# if session duration >= bounce_rate_duration_seconds, not a bounce
ast.Call(
name="greaterOrEquals",
args=[aggregate_fields["$session_duration"], ast.Constant(value=10)],
args=[
aggregate_fields["$session_duration"],
ast.Constant(value=bounce_rate_duration_seconds),
],
),
],
)
Expand Down Expand Up @@ -299,10 +308,13 @@ def arg_max_merge_field(field_name: str) -> ast.Call:
ast.Call(
name="greater", args=[aggregate_fields["$autocapture_count"], ast.Constant(value=0)]
),
# if session duration >= 10 seconds, not a bounce
# if session duration >= bounce_rate_duration_seconds, not a bounce
ast.Call(
name="greaterOrEquals",
args=[aggregate_fields["$session_duration"], ast.Constant(value=10)],
args=[
aggregate_fields["$session_duration"],
ast.Constant(value=bounce_rate_duration_seconds),
],
),
],
)
Expand Down
62 changes: 60 additions & 2 deletions posthog/hogql/database/schema/test/test_sessions_v2.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,11 @@


class TestSessionsV2(ClickhouseTestMixin, APIBaseTest):
def __execute(self, query, bounce_rate_mode=BounceRatePageViewMode.COUNT_PAGEVIEWS):
def __execute(self, query, bounce_rate_mode=BounceRatePageViewMode.COUNT_PAGEVIEWS, bounce_rate_duration=None):
modifiers = HogQLQueryModifiers(
sessionTableVersion=SessionTableVersion.V2, bounceRatePageViewMode=bounce_rate_mode
sessionTableVersion=SessionTableVersion.V2,
bounceRatePageViewMode=bounce_rate_mode,
bounceRateDurationSeconds=bounce_rate_duration,
)
return execute_hogql_query(
query=query,
Expand Down Expand Up @@ -515,6 +517,62 @@ def test_bounce_rate(self, bounce_rate_mode):
(0, s5),
]

@parameterized.expand(
[[BounceRatePageViewMode.UNIQ_PAGE_SCREEN_AUTOCAPTURES], [BounceRatePageViewMode.COUNT_PAGEVIEWS]]
)
def test_custom_bounce_rate_duration(self, bounce_rate_mode):
time = time_ns() // (10**6)
# ensure the sessions ids are sortable by giving them different time components
s = str(uuid7(time))

# 15 second session with a pageleave
_create_event(
event="$pageview",
team=self.team,
distinct_id="d4",
properties={"$session_id": s, "$current_url": "https://example.com/6"},
timestamp="2023-12-11T12:00:00",
)
_create_event(
event="$pageleave",
team=self.team,
distinct_id="d4",
properties={"$session_id": s, "$current_url": "https://example.com/6"},
timestamp="2023-12-11T12:00:15",
)

# with default settings this should not be a bounce
assert (
self.__execute(
parse_select(
"select $is_bounce, session_id from sessions ORDER BY session_id",
),
bounce_rate_mode=bounce_rate_mode,
)
).results == [(0, s)]

# with a custom 10 second duration this should not be a bounce
assert (
self.__execute(
parse_select(
"select $is_bounce, session_id from sessions ORDER BY session_id",
),
bounce_rate_mode=bounce_rate_mode,
bounce_rate_duration=10,
)
).results == [(0, s)]

# with a custom 30 second duration this should be a bounce
assert (
self.__execute(
parse_select(
"select $is_bounce, session_id from sessions ORDER BY session_id",
),
bounce_rate_mode=bounce_rate_mode,
bounce_rate_duration=30,
)
).results == [(1, s)]

def test_last_external_click_url(self):
s1 = str(uuid7())

Expand Down
1 change: 1 addition & 0 deletions posthog/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -2264,6 +2264,7 @@ class HogQLQueryModifiers(BaseModel):
model_config = ConfigDict(
extra="forbid",
)
bounceRateDurationSeconds: Optional[float] = None
bounceRatePageViewMode: Optional[BounceRatePageViewMode] = None
customChannelTypeRules: Optional[list[CustomChannelRule]] = None
dataWarehouseEventsModifiers: Optional[list[DataWarehouseEventsModifier]] = None
Expand Down

0 comments on commit e56f52b

Please sign in to comment.