0
0
Fork 0
mirror of https://github.com/discourse/discourse.git synced 2026-08-06 06:24:48 +08:00
discourse/app/models/browser_pageview_session_engagement_daily_rollup.rb
Alan Guo Xiang Tan 539eff3f93
FEATURE: Show bounce rate and average session duration on the dashboard (#40936)
This PR adds two KPI tiles to the Traffic section — bounce rate and
average session duration — measured per browsing session as real engaged
time: the seconds a tab is both visible and focused.

It builds on the per-session engagement core already captures
(`browser_pageview_session_engagements`), so the only new table is its
daily summary `browser_pageview_session_engagement_daily_rollups` —
additive, no existing schema changes.

Key technical changes:

1. Reuse core's capture. Engaged time is already recorded per session by
core's `human-activity-tracker` → `/srv/se` beacon →
`browser_pageview_session_engagements`; this PR adds no second tracker,
endpoint, table, or site setting.

2. Key engagement capture on `persist_browser_pageview_events` alone.
`Middleware::RequestTracker#is_engagement_tracking_request?` and the
`discourse-engagement-tracking-enabled` meta tag no longer also require
the `dashboard_improvements` upcoming change, so engagement is captured
wherever pageview events are persisted rather than only where the
redesigned dashboard is enabled.

3. Pre-compute in a scheduled job. The existing
`Jobs::MaintainBrowserPageviewRollups` job, which already maintains the
country and referrer rollups on the same cadence and setting gate, now
also rolls sessions into
`browser_pageview_session_engagement_daily_rollups`, one row per `(date,
logged_in)`, so the dashboard reads this small summary instead of
scanning raw events. A session is bounced when it has a single pageview
and under ten engaged seconds (no engagement counts as zero). Sessions
that started within the last ten minutes are held out of aggregation
until a later run, so a live session is not counted as a zero-engagement
bounce before the client's first engagement flush (three minutes in) has
arrived. Engagement rolls up only from the first engagement row's date
forward, because pageview rows predate this feature and have no
engagement data — counting them would make all of history look like
instant bounces.

4. Show the tiles only when there's data. They render when
`persist_browser_pageview_events` is on, alongside the existing Direct
traffic KPI, with a neutral placeholder until the first visits are
recorded; duration is formatted as `Xm Ys`.
2026-07-03 10:49:32 +08:00

87 lines
3.1 KiB
Ruby
Vendored

# frozen_string_literal: true
class BrowserPageviewSessionEngagementDailyRollup < ActiveRecord::Base
BOUNCE_ENGAGED_SECONDS_THRESHOLD = 10
MIN_SESSION_AGE = 10.minutes
private_constant :BOUNCE_ENGAGED_SECONDS_THRESHOLD, :MIN_SESSION_AGE
def self.aggregate(start_date:, end_date:, source: BrowserPageviewEvent.rollup_source)
start_date = start_date.to_date
end_date = end_date.to_date + 1
transaction do
DB.exec(<<~SQL, start_date:, end_date:, source:)
DELETE FROM browser_pageview_session_engagement_daily_rollups rollup
WHERE rollup.date >= :start_date
AND rollup.date < :end_date
AND EXISTS (
SELECT 1
FROM browser_pageview_events
WHERE created_at >= rollup.date
AND created_at < rollup.date + 1
AND source = :source
)
SQL
DB.exec(
<<~SQL,
WITH active_sessions AS (
SELECT DISTINCT session_id
FROM browser_pageview_events
WHERE created_at >= :start_date
AND created_at < LEAST(:end_date::timestamp, :session_started_before::timestamp)
AND source = :source
),
session_pageviews AS (
SELECT
bpe.session_id,
MIN(bpe.created_at)::date AS date,
COUNT(*) AS pageview_count,
bool_or(bpe.user_id IS NOT NULL) AS logged_in
FROM browser_pageview_events bpe
JOIN active_sessions ON active_sessions.session_id = bpe.session_id
WHERE bpe.source = :source
GROUP BY bpe.session_id
HAVING MIN(bpe.created_at) >= :start_date
)
INSERT INTO browser_pageview_session_engagement_daily_rollups
(date, logged_in, sessions, bounced, engaged_seconds_total)
SELECT
session_pageviews.date,
session_pageviews.logged_in,
COUNT(*) AS sessions,
COUNT(*) FILTER (
WHERE session_pageviews.pageview_count = 1
AND COALESCE(engagement.engaged_seconds, 0) < :bounce_threshold
) AS bounced,
COALESCE(SUM(engagement.engaged_seconds), 0) AS engaged_seconds_total
FROM session_pageviews
LEFT JOIN browser_pageview_session_engagements engagement
ON engagement.session_id = session_pageviews.session_id
GROUP BY session_pageviews.date, session_pageviews.logged_in
SQL
start_date:,
end_date:,
session_started_before: MIN_SESSION_AGE.ago,
bounce_threshold: BOUNCE_ENGAGED_SECONDS_THRESHOLD,
source:,
)
end
end
end
# == Schema Information
#
# Table name: browser_pageview_session_engagement_daily_rollups
#
# id :bigint not null, primary key
# bounced :bigint not null
# date :date not null
# engaged_seconds_total :bigint not null
# logged_in :boolean not null
# sessions :bigint not null
#
# Indexes
#
# idx_bpse_rollups_date_logged_in_unique (date,logged_in) UNIQUE
#