(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