diff --git a/frontend/__snapshots__/scenes-other-settings--settings-project--dark.png b/frontend/__snapshots__/scenes-other-settings--settings-project--dark.png index bcef1677c71eb..21592267c1f1c 100644 Binary files a/frontend/__snapshots__/scenes-other-settings--settings-project--dark.png and b/frontend/__snapshots__/scenes-other-settings--settings-project--dark.png differ diff --git a/frontend/__snapshots__/scenes-other-settings--settings-project--light.png b/frontend/__snapshots__/scenes-other-settings--settings-project--light.png index bf25bd234a23a..6b749977be35f 100644 Binary files a/frontend/__snapshots__/scenes-other-settings--settings-project--light.png and b/frontend/__snapshots__/scenes-other-settings--settings-project--light.png differ diff --git a/frontend/__snapshots__/scenes-other-settings--settings-project-with-replay-features--dark.png b/frontend/__snapshots__/scenes-other-settings--settings-project-with-replay-features--dark.png index 37aa45a49e13b..de098d4ce843e 100644 Binary files a/frontend/__snapshots__/scenes-other-settings--settings-project-with-replay-features--dark.png and b/frontend/__snapshots__/scenes-other-settings--settings-project-with-replay-features--dark.png differ diff --git a/frontend/__snapshots__/scenes-other-settings--settings-project-with-replay-features--light.png b/frontend/__snapshots__/scenes-other-settings--settings-project-with-replay-features--light.png index 0ba27182db8f5..de9f65b23f02c 100644 Binary files a/frontend/__snapshots__/scenes-other-settings--settings-project-with-replay-features--light.png and b/frontend/__snapshots__/scenes-other-settings--settings-project-with-replay-features--light.png differ diff --git a/frontend/__snapshots__/scenes-other-settings--settings-session-timeout-all-options--dark.png b/frontend/__snapshots__/scenes-other-settings--settings-session-timeout-all-options--dark.png index bcef1677c71eb..21592267c1f1c 100644 Binary files a/frontend/__snapshots__/scenes-other-settings--settings-session-timeout-all-options--dark.png and b/frontend/__snapshots__/scenes-other-settings--settings-session-timeout-all-options--dark.png differ diff --git a/frontend/__snapshots__/scenes-other-settings--settings-session-timeout-all-options--light.png b/frontend/__snapshots__/scenes-other-settings--settings-session-timeout-all-options--light.png index bf25bd234a23a..6b749977be35f 100644 Binary files a/frontend/__snapshots__/scenes-other-settings--settings-session-timeout-all-options--light.png and b/frontend/__snapshots__/scenes-other-settings--settings-session-timeout-all-options--light.png differ diff --git a/frontend/__snapshots__/scenes-other-settings--settings-session-timeout-password-only--dark.png b/frontend/__snapshots__/scenes-other-settings--settings-session-timeout-password-only--dark.png index bcef1677c71eb..21592267c1f1c 100644 Binary files a/frontend/__snapshots__/scenes-other-settings--settings-session-timeout-password-only--dark.png and b/frontend/__snapshots__/scenes-other-settings--settings-session-timeout-password-only--dark.png differ diff --git a/frontend/__snapshots__/scenes-other-settings--settings-session-timeout-password-only--light.png b/frontend/__snapshots__/scenes-other-settings--settings-session-timeout-password-only--light.png index bf25bd234a23a..6b749977be35f 100644 Binary files a/frontend/__snapshots__/scenes-other-settings--settings-session-timeout-password-only--light.png and b/frontend/__snapshots__/scenes-other-settings--settings-session-timeout-password-only--light.png differ diff --git a/frontend/__snapshots__/scenes-other-settings--settings-session-timeout-sso-enforced-github--dark.png b/frontend/__snapshots__/scenes-other-settings--settings-session-timeout-sso-enforced-github--dark.png index bcef1677c71eb..21592267c1f1c 100644 Binary files a/frontend/__snapshots__/scenes-other-settings--settings-session-timeout-sso-enforced-github--dark.png and b/frontend/__snapshots__/scenes-other-settings--settings-session-timeout-sso-enforced-github--dark.png differ diff --git a/frontend/__snapshots__/scenes-other-settings--settings-session-timeout-sso-enforced-github--light.png b/frontend/__snapshots__/scenes-other-settings--settings-session-timeout-sso-enforced-github--light.png index bf25bd234a23a..6b749977be35f 100644 Binary files a/frontend/__snapshots__/scenes-other-settings--settings-session-timeout-sso-enforced-github--light.png and b/frontend/__snapshots__/scenes-other-settings--settings-session-timeout-sso-enforced-github--light.png differ diff --git a/frontend/__snapshots__/scenes-other-settings--settings-session-timeout-sso-enforced-google--dark.png b/frontend/__snapshots__/scenes-other-settings--settings-session-timeout-sso-enforced-google--dark.png index bcef1677c71eb..21592267c1f1c 100644 Binary files a/frontend/__snapshots__/scenes-other-settings--settings-session-timeout-sso-enforced-google--dark.png and b/frontend/__snapshots__/scenes-other-settings--settings-session-timeout-sso-enforced-google--dark.png differ diff --git a/frontend/__snapshots__/scenes-other-settings--settings-session-timeout-sso-enforced-google--light.png b/frontend/__snapshots__/scenes-other-settings--settings-session-timeout-sso-enforced-google--light.png index bf25bd234a23a..6b749977be35f 100644 Binary files a/frontend/__snapshots__/scenes-other-settings--settings-session-timeout-sso-enforced-google--light.png and b/frontend/__snapshots__/scenes-other-settings--settings-session-timeout-sso-enforced-google--light.png differ diff --git a/frontend/__snapshots__/scenes-other-settings--settings-session-timeout-sso-enforced-saml--dark.png b/frontend/__snapshots__/scenes-other-settings--settings-session-timeout-sso-enforced-saml--dark.png index bcef1677c71eb..21592267c1f1c 100644 Binary files a/frontend/__snapshots__/scenes-other-settings--settings-session-timeout-sso-enforced-saml--dark.png and b/frontend/__snapshots__/scenes-other-settings--settings-session-timeout-sso-enforced-saml--dark.png differ diff --git a/frontend/__snapshots__/scenes-other-settings--settings-session-timeout-sso-enforced-saml--light.png b/frontend/__snapshots__/scenes-other-settings--settings-session-timeout-sso-enforced-saml--light.png index bf25bd234a23a..6b749977be35f 100644 Binary files a/frontend/__snapshots__/scenes-other-settings--settings-session-timeout-sso-enforced-saml--light.png and b/frontend/__snapshots__/scenes-other-settings--settings-session-timeout-sso-enforced-saml--light.png differ diff --git a/frontend/__snapshots__/scenes-other-settings--settings-session-timeout-sso-only--dark.png b/frontend/__snapshots__/scenes-other-settings--settings-session-timeout-sso-only--dark.png index bcef1677c71eb..21592267c1f1c 100644 Binary files a/frontend/__snapshots__/scenes-other-settings--settings-session-timeout-sso-only--dark.png and b/frontend/__snapshots__/scenes-other-settings--settings-session-timeout-sso-only--dark.png differ diff --git a/frontend/__snapshots__/scenes-other-settings--settings-session-timeout-sso-only--light.png b/frontend/__snapshots__/scenes-other-settings--settings-session-timeout-sso-only--light.png index bf25bd234a23a..6b749977be35f 100644 Binary files a/frontend/__snapshots__/scenes-other-settings--settings-session-timeout-sso-only--light.png and b/frontend/__snapshots__/scenes-other-settings--settings-session-timeout-sso-only--light.png differ diff --git a/frontend/src/queries/schema.json b/frontend/src/queries/schema.json index 0e994fc360b15..dc52be4fe170c 100644 --- a/frontend/src/queries/schema.json +++ b/frontend/src/queries/schema.json @@ -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" diff --git a/frontend/src/queries/schema.ts b/frontend/src/queries/schema.ts index 00206211cdad1..daaae3e403c08 100644 --- a/frontend/src/queries/schema.ts +++ b/frontend/src/queries/schema.ts @@ -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 diff --git a/frontend/src/scenes/settings/SettingsMap.tsx b/frontend/src/scenes/settings/SettingsMap.tsx index b7ca139fa3056..58d50bfeddb18 100644 --- a/frontend/src/scenes/settings/SettingsMap.tsx +++ b/frontend/src/scenes/settings/SettingsMap.tsx @@ -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' @@ -194,12 +195,6 @@ export const SETTINGS_MAP: SettingSection[] = [ component: , flag: 'SETTINGS_PERSONS_JOIN_MODE', }, - { - id: 'bounce-rate-page-view-mode', - title: 'Bounce rate page view mode', - component: , - flag: 'SETTINGS_BOUNCE_RATE_PAGE_VIEW_MODE', - }, { id: 'session-table-version', title: 'Sessions Table Version', @@ -225,6 +220,17 @@ export const SETTINGS_MAP: SettingSection[] = [ component: , flag: 'COOKIELESS_SERVER_HASH_MODE_SETTING', }, + { + id: 'bounce-rate-duration', + title: 'Bounce rate duration', + component: , + }, + { + id: 'bounce-rate-page-view-mode', + title: 'Bounce rate page view mode', + component: , + flag: 'SETTINGS_BOUNCE_RATE_PAGE_VIEW_MODE', + }, ], }, diff --git a/frontend/src/scenes/settings/environment/BounceRateDuration.tsx b/frontend/src/scenes/settings/environment/BounceRateDuration.tsx new file mode 100644 index 0000000000000..7adc18ef8b3f6 --- /dev/null +++ b/frontend/src/scenes/settings/environment/BounceRateDuration.tsx @@ -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(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(null) + + return ( + <> +

+ 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. +

+ { + if (x == null || Number.isNaN(x)) { + setBounceRateDuration(DEFAULT_BOUNCE_RATE_DURATION) + } else { + setBounceRateDuration(x) + } + }} + inputRef={inputRef} + suffix={ + } + tooltip="Clear input" + onClick={(e) => { + e.stopPropagation() + setBounceRateDuration(DEFAULT_BOUNCE_RATE_DURATION) + inputRef.current?.focus() + }} + /> + } + /> +
+ 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 + +
+ + ) +} diff --git a/frontend/src/scenes/settings/types.ts b/frontend/src/scenes/settings/types.ts index 4281935f54190..14664e4734831 100644 --- a/frontend/src/scenes/settings/types.ts +++ b/frontend/src/scenes/settings/types.ts @@ -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' diff --git a/posthog/hogql/database/schema/sessions_v1.py b/posthog/hogql/database/schema/sessions_v1.py index d2fb8c7d80eaf..298a96a70d41d 100644 --- a/posthog/hogql/database/schema/sessions_v1.py +++ b/posthog/hogql/database/schema/sessions_v1.py @@ -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 @@ -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: @@ -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), + ], ), ], ) diff --git a/posthog/hogql/database/schema/sessions_v2.py b/posthog/hogql/database/schema/sessions_v2.py index 08da40eaa68c3..66bfae2d3952d 100644 --- a/posthog/hogql/database/schema/sessions_v2.py +++ b/posthog/hogql/database/schema/sessions_v2.py @@ -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 @@ -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"), @@ -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( @@ -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), + ], ), ], ) @@ -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), + ], ), ], ) diff --git a/posthog/hogql/database/schema/test/test_sessions_v2.py b/posthog/hogql/database/schema/test/test_sessions_v2.py index 0130dce96dcfa..f59d0075352bd 100644 --- a/posthog/hogql/database/schema/test/test_sessions_v2.py +++ b/posthog/hogql/database/schema/test/test_sessions_v2.py @@ -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, @@ -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()) diff --git a/posthog/schema.py b/posthog/schema.py index 5ae3ffb9266a4..98159253931bc 100644 --- a/posthog/schema.py +++ b/posthog/schema.py @@ -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