0
0
Fork 0
mirror of https://github.com/discourse/discourse.git synced 2026-08-07 13:19:19 +08:00
discourse/app/services/admin_dashboard_site_traffic.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

379 lines
11 KiB
Ruby
Vendored

# frozen_string_literal: true
class AdminDashboardSiteTraffic
DEFAULT_RANGE_DAYS = 30
TOP_CARD_LIMIT = 5
SERIES_LABEL_REQS = {
logged_in: "page_view_logged_in_browser",
anonymous: "page_view_anon_browser",
embedded: "page_view_embed",
crawlers: "page_view_crawler",
}.freeze
private_constant :DEFAULT_RANGE_DAYS
private_constant :TOP_CARD_LIMIT
private_constant :SERIES_LABEL_REQS
def self.build(start_date:, end_date:, guardian:)
new(start_date: start_date, end_date: end_date, guardian: guardian).build
end
def initialize(start_date:, end_date:, guardian:)
@guardian = guardian
@start_date = parse_date(start_date) || (DEFAULT_RANGE_DAYS - 1).days.ago.beginning_of_day
@end_date = parse_date(end_date)&.end_of_day || Time.zone.now.end_of_day
if @start_date.to_date > @end_date.to_date
@start_date = (DEFAULT_RANGE_DAYS - 1).days.ago.beginning_of_day
@end_date = Time.zone.now.end_of_day
end
end
def build
current_rows = traffic_rows(start_date.to_date, end_date.to_date)
prior_rows = traffic_rows(prior_start_date, prior_end_date)
include_embedded = include_embedded_series?
totals = build_totals(current_rows, include_embedded: include_embedded)
response = {
kpis: kpis(totals, prior_rows),
pageview_series: pageview_series(current_rows, include_embedded: include_embedded),
}
if SiteSetting.persist_browser_pageview_events
top_countries = fetch_card("top_countries_by_browser_pageviews")
response[:top_countries] = top_countries if top_countries
top_referrers = fetch_card("top_referrers_by_browser_pageviews")
response[:top_referrers] = top_referrers if top_referrers
end
response
end
private
attr_reader :start_date, :end_date, :guardian
def fetch_card(type)
return nil if Report.hidden?(type, guardian: guardian)
opts = {
start_date: start_date,
end_date: end_date,
guardian: guardian,
filters: {
login_required: SiteSetting.login_required,
host: Discourse.current_hostname,
},
wrap_exceptions_in_test: true,
}
cached = Report.find_cached(type, opts)
return cached_to_payload(cached) if cached
report = Report.find(type, opts)
return { rows: [], error: "exception" } if report.nil?
# Timeouts skip the cache so the next request retries instead of being
# pinned to the error for the full 35-minute TTL.
Report.cache(report) if report.error != :timeout
return { rows: [], error: report.error.to_s } if report.error.present?
{ rows: report.data.first(TOP_CARD_LIMIT), error: nil }
end
def cached_to_payload(cached)
error = cached[:error]
return { rows: [], error: error.to_s } if error.present?
{ rows: (cached[:data] || []).map(&:symbolize_keys).first(TOP_CARD_LIMIT), error: nil }
end
def series_ids(include_embedded:)
series = %i[logged_in]
return series if login_required?
series << :anonymous
series << :embedded if include_embedded
series << :crawlers
series
end
def kpis(totals, prior_rows)
kpis = { browser_pageviews: browser_pageviews_kpi(totals, prior_rows) }
logged_in_share = logged_in_share_value(totals)
kpis[:logged_in_share] = { value: logged_in_share } if !logged_in_share.nil?
direct_traffic = direct_traffic_value
kpis[:direct_traffic] = { value: direct_traffic } if !direct_traffic.nil?
if SiteSetting.persist_browser_pageview_events
kpis[:bounce_rate] = { value: bounce_rate_value }
kpis[:average_session_duration_seconds] = { value: average_session_duration_value }
end
kpis
end
def bounce_rate_value
sessions = session_engagement_totals[:sessions]
return nil if sessions.zero?
((session_engagement_totals[:bounced].to_f / sessions) * 100).round
end
def average_session_duration_value
sessions = session_engagement_totals[:sessions]
return nil if sessions.zero?
(session_engagement_totals[:engaged_seconds_total].to_f / sessions).round
end
def session_engagement_totals
@session_engagement_totals ||=
DB
.query_hash(<<~SQL, start_date: start_date.to_date, end_date: end_date.to_date)
SELECT
COALESCE(SUM(sessions), 0)::bigint AS sessions,
COALESCE(SUM(bounced), 0)::bigint AS bounced,
COALESCE(SUM(engaged_seconds_total), 0)::bigint AS engaged_seconds_total
FROM browser_pageview_session_engagement_daily_rollups
WHERE date >= :start_date
AND date <= :end_date
SQL
.first
.symbolize_keys
end
def browser_pageviews_kpi(totals, prior_rows)
kpi = { value: totals[:human] }
trend = build_trend(totals, prior_rows)
return kpi if trend.blank?
kpi.merge(trend)
end
def logged_in_share_value(totals)
return nil if login_required?
totals[:human].positive? ? ((totals[:logged_in].to_f / totals[:human]) * 100).round : 0
end
def direct_traffic_value
return nil if !SiteSetting.persist_browser_pageview_events
count_column = login_required? ? "logged_in_count" : "count"
row = DB.query(<<~SQL, start_date: start_date.to_date, end_date: end_date.to_date).first
SELECT
COALESCE(SUM(#{count_column}), 0)::bigint AS total,
COALESCE(SUM(#{count_column}) FILTER (WHERE normalized_referrer IS NULL), 0)::bigint AS direct
FROM browser_pageview_referrer_daily_rollups
WHERE date >= :start_date
AND date <= :end_date
SQL
return nil if row.total.zero?
((row.direct.to_f / row.total) * 100).round
end
def pageview_series(rows, include_embedded:)
series_ids(include_embedded: include_embedded).map do |id|
{
req: series_req(id),
label: series_label(id),
color: series_color(id),
data: rows.map { |row| pageview_series_point(row, id) },
}
end
end
def pageview_series_point(row, id)
{ x: row.date.iso8601, y: row.public_send(id).to_i }
end
def series_req(id)
selected_request_type_names.fetch(id)
end
def series_label(id)
I18n.t("reports.site_traffic.xaxis.#{series_label_req(id)}")
end
def series_label_req(id)
SERIES_LABEL_REQS.fetch(id)
end
def series_color(id)
Reports::SiteTraffic::SERIES_COLORS.fetch(series_label_req(id))
end
def login_required?
SiteSetting.login_required
end
def prior_start_date
prior_end_date - (selected_day_count - 1)
end
def prior_end_date
start_date.to_date - 1
end
def prior_period_complete?
prior_period_tracking_started?
end
def prior_period_tracking_started?
return @prior_period_tracking_started if defined?(@prior_period_tracking_started)
req_type_sql =
if login_required?
"req_type = :logged_in_req_type"
else
"req_type IN (:logged_in_req_type, :anonymous_req_type)"
end
@prior_period_tracking_started =
DB.query_single(
<<~SQL,
SELECT 1
FROM application_requests
WHERE date <= :prior_start_date
AND #{req_type_sql}
LIMIT 1
SQL
prior_start_date: prior_start_date,
logged_in_req_type: selected_request_types[:logged_in],
anonymous_req_type: selected_request_types[:anonymous],
).present?
end
def parse_date(value)
return nil if value.blank?
Time.zone.parse(value.to_s)&.beginning_of_day
rescue ArgumentError, TypeError
nil
end
def selected_request_types
@selected_request_types ||=
selected_request_type_names.transform_values { |name| ApplicationRequest.req_types[name] }
end
def selected_request_type_names
@selected_request_type_names ||=
if SiteSetting.use_legacy_pageviews
{ logged_in: "page_view_logged_in", anonymous: "page_view_anon" }
else
{ logged_in: "page_view_logged_in_browser", anonymous: "page_view_anon_browser" }
end.merge(crawlers: "page_view_crawler", embedded: "page_view_embed")
end
def traffic_rows(range_start_date, range_end_date)
DB.query(
<<~SQL,
WITH dates AS (
SELECT
request_date::date AS date
FROM generate_series(
CAST(:start_date AS date),
CAST(:end_date AS date),
INTERVAL '1 day'
) request_date
)
SELECT
dates.date,
COALESCE(SUM(CASE WHEN ar.req_type = :logged_in_req_type THEN ar.count ELSE 0 END), 0)::bigint AS logged_in,
COALESCE(SUM(CASE WHEN ar.req_type = :anonymous_req_type THEN ar.count ELSE 0 END), 0)::bigint AS anonymous,
COALESCE(SUM(CASE WHEN ar.req_type = :crawler_req_type THEN ar.count ELSE 0 END), 0)::bigint AS crawlers,
COALESCE(SUM(CASE WHEN ar.req_type = :embedded_req_type THEN ar.count ELSE 0 END), 0)::bigint AS embedded
FROM dates
LEFT JOIN application_requests ar
ON ar.date = dates.date
AND ar.req_type IN (
:logged_in_req_type,
:anonymous_req_type,
:crawler_req_type,
:embedded_req_type
)
GROUP BY dates.date
ORDER BY dates.date ASC
SQL
start_date: range_start_date,
end_date: range_end_date,
logged_in_req_type: selected_request_types[:logged_in],
anonymous_req_type: selected_request_types[:anonymous],
crawler_req_type: selected_request_types[:crawlers],
embedded_req_type: selected_request_types[:embedded],
)
end
def build_totals(rows, include_embedded:)
logged_in = sum_rows(rows, :logged_in)
anonymous = login_required? ? 0 : sum_rows(rows, :anonymous)
crawlers = login_required? ? 0 : sum_rows(rows, :crawlers)
embedded = include_embedded ? sum_rows(rows, :embedded) : 0
{
logged_in: logged_in,
anonymous: anonymous,
embedded: embedded,
crawlers: crawlers,
human: logged_in + anonymous,
}
end
def build_trend(totals, prior_rows)
return nil if !prior_period_complete?
current_human = totals[:human]
previous_human = prior_human(prior_rows)
return nil if previous_human.zero? || current_human == previous_human
percent_change = ((current_human - previous_human).to_f / previous_human) * 100
return nil if percent_change.abs < 0.05
{
percent_change: format_trend_percent_change(percent_change),
comparison_period: {
start_date: prior_start_date.iso8601,
end_date: prior_end_date.iso8601,
},
}
end
def prior_human(prior_rows)
logged_in = sum_rows(prior_rows, :logged_in)
anonymous = login_required? ? 0 : sum_rows(prior_rows, :anonymous)
logged_in + anonymous
end
def format_trend_percent_change(percent_change)
percent_change.abs < 1 ? percent_change.round(1) : percent_change.round
end
def sum_rows(rows, field)
rows.sum { |row| row.public_send(field).to_i }
end
def include_embedded_series?
!login_required? && embedding_enabled? && EmbeddableHost.exists?
end
def embedding_enabled?
SiteSetting.embed_topics_list || SiteSetting.embed_full_app
end
def selected_day_count
(end_date.to_date - start_date.to_date).to_i + 1
end
end