0
0
Fork 0
mirror of https://github.com/discourse/discourse.git synced 2026-08-05 18:09:26 +08:00
discourse/app/services/admin_dashboard_search.rb
Alan Guo Xiang Tan 8f5e9a4ace
UX: Focus dashboard search analytics on member searches (#41647)
This PR removes searches by admins and moderators from the new
dashboard’s Search section. This helps staff focus on what community
members are searching for.

Staff often search for personal or work-related topics. Including those
searches adds noise and makes it harder to see what members need and
what content may be missing.

Key changes:

* Exclude admin and moderator searches from totals, changes over time,
trending searches, and content gaps.
* On the Search Logs pages, rename the “Logged in only” filter to
“Non-staff users” because the old filter still included admins and
moderators.
* Use the non-staff filter when someone clicks a trending search, so the
details also leave out staff searches.
2026-07-13 12:41:19 +08:00

199 lines
5.6 KiB
Ruby
Vendored

# frozen_string_literal: true
class AdminDashboardSearch
DEFAULT_RANGE_DAYS = 30
TOP_TERMS_LIMIT = 10
ALARM_THRESHOLD_PERCENT = 10
POOR_MATCH_MAX_CTR_PERCENT = 20
TRENDING_PERIODS_BY_MAX_DAYS = {
7 => "weekly",
31 => "monthly",
92 => "quarterly",
366 => "yearly",
}.freeze
private_constant :DEFAULT_RANGE_DAYS,
:TOP_TERMS_LIMIT,
:ALARM_THRESHOLD_PERCENT,
:POOR_MATCH_MAX_CTR_PERCENT,
:TRENDING_PERIODS_BY_MAX_DAYS
def self.build(start_date:, end_date:)
new(start_date: start_date, end_date: end_date).build
end
def initialize(start_date:, end_date:)
@start_date = parse_date(start_date) || default_start_date
@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_start_date
@end_date = Time.zone.now.end_of_day
end
end
def build
return { logging_enabled: false } if !SiteSetting.log_search_queries
current = window_stats(window_start: start_date, window_end: end_date)
prior = window_stats(window_start: prior_start_date, window_end: prior_end_date)
kpis = build_kpis(current: current, prior: prior)
{
logging_enabled: true,
headline_state: headline_state(current: current, kpis: kpis),
kpis: kpis,
trending: trending,
trending_period: trending_period,
content_gaps: content_gaps,
}
end
private
attr_reader :start_date, :end_date
def build_kpis(current:, prior:)
{
total_searches: total_searches_kpi(current: current, prior: prior),
no_result_rate: no_result_rate_kpi(current: current, prior: prior),
}
end
def total_searches_kpi(current:, prior:)
kpi = { value: current[:total] }
if current[:total].positive? && prior[:total].positive?
change = formatted_change((current[:total] - prior[:total]) * 100.0 / prior[:total])
kpi[:percent_change] = change if change
end
kpi
end
def no_result_rate_kpi(current:, prior:)
return { value: nil, exceeds_threshold: false } if current[:total].zero?
kpi = {
value: no_result_rate(current).round,
exceeds_threshold: current[:no_match] * 100 > current[:total] * ALARM_THRESHOLD_PERCENT,
}
if prior[:total].positive?
change = formatted_change(no_result_rate(current) - no_result_rate(prior))
kpi[:point_change] = change if change
end
kpi
end
def no_result_rate(stats)
stats[:no_match] * 100.0 / stats[:total]
end
def formatted_change(value)
rounded = value.abs < 1 ? value.round(1) : value.round
rounded.zero? ? nil : rounded
end
def headline_state(current:, kpis:)
return "no_signal" if current[:total].zero?
return "content_gaps" if kpis[:no_result_rate][:exceeds_threshold]
return "rate_climbing" if kpis[:no_result_rate][:point_change].to_f.positive?
return "shrinking" if kpis[:total_searches][:percent_change].to_f.negative?
"healthy"
end
def trending
rows =
non_staff_search_logs_in(window_start: start_date, window_end: end_date)
.select(<<~SQL)
lower(search_logs.term) AS term,
COUNT(*) AS searches,
SUM(CASE WHEN search_result_id IS NOT NULL THEN 1 ELSE 0 END) AS clicks
SQL
.group("lower(search_logs.term)")
.order("searches DESC, clicks DESC, term ASC")
.limit(TOP_TERMS_LIMIT)
rows.map { |row| { term: row.term, searches: row.searches } }
end
def trending_period
TRENDING_PERIODS_BY_MAX_DAYS.each do |max_days, period|
return period if selected_day_count <= max_days
end
"all"
end
def content_gaps
rows =
non_staff_search_logs_in(window_start: start_date, window_end: end_date)
.select(<<~SQL)
lower(search_logs.term) AS term,
COUNT(*) AS searches,
SUM(CASE WHEN search_result_id IS NOT NULL THEN 1 ELSE 0 END) AS clicks
SQL
.group("lower(search_logs.term)")
.having(<<~SQL, POOR_MATCH_MAX_CTR_PERCENT)
SUM(CASE WHEN search_result_id IS NOT NULL THEN 1 ELSE 0 END) * 100 <=
COUNT(*) * ?
SQL
.order("searches DESC, term ASC")
.limit(TOP_TERMS_LIMIT)
rows.map do |row|
{
term: row.term,
searches: row.searches,
status: row.clicks.to_i.zero? ? "no_match" : "poor_match",
}
end
end
def window_stats(window_start:, window_end:)
term_stats =
non_staff_search_logs_in(window_start: window_start, window_end: window_end).select(
<<~SQL,
COUNT(*) AS searches,
SUM(CASE WHEN search_result_id IS NOT NULL THEN 1 ELSE 0 END) AS clicks
SQL
).group("lower(search_logs.term)")
row = SearchLog.from("(#{term_stats.to_sql}) term_stats").select(<<~SQL).take
COALESCE(SUM(searches), 0)::bigint AS total,
COALESCE(SUM(CASE WHEN clicks = 0 THEN searches ELSE 0 END), 0)::bigint AS no_match
SQL
{ total: row.total, no_match: row.no_match }
end
def non_staff_search_logs_in(window_start:, window_end:)
SearchLog.non_staff.where(created_at: window_start..window_end)
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 default_start_date
(DEFAULT_RANGE_DAYS - 1).days.ago.beginning_of_day
end
def prior_start_date
(start_date.to_date - selected_day_count).beginning_of_day
end
def prior_end_date
(start_date.to_date - 1).end_of_day
end
def selected_day_count
(end_date.to_date - start_date.to_date).to_i + 1
end
end