0
0
Fork 0
mirror of https://github.com/discourse/discourse.git synced 2026-08-05 23:04:22 +08:00
discourse/app/models/search_log.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

198 lines
5.3 KiB
Ruby
Vendored

# frozen_string_literal: true
class SearchLog < ActiveRecord::Base
MAXIMUM_USER_AGENT_LENGTH = 2000
validates :term, presence: true
validates :user_agent, length: { maximum: MAXIMUM_USER_AGENT_LENGTH }
belongs_to :user
scope :non_staff, -> { joins(:user).where(users: { admin: false, moderator: false }) }
def ctr
return 0 if click_through == 0 || searches == 0
((click_through.to_f / searches.to_f) * 100).ceil(1)
end
def self.search_types
@search_types ||= Enum.new(header: 1, full_page: 2)
end
def self.search_result_types
@search_result_types ||= Enum.new(topic: 1, user: 2, category: 3, tag: 4)
end
def self.redis_key(ip_address:, user_id: nil)
if user_id
"__SEARCH__LOG_#{user_id}"
else
"__SEARCH__LOG_#{ip_address}"
end
end
# for testing
def self.clear_debounce_cache!
Discourse.redis.keys("__SEARCH__LOG_*").each { |k| Discourse.redis.del(k) }
end
def self.log(term:, search_type:, ip_address:, user_agent: nil, user_id: nil)
return [:error] if term.blank?
can_log_search =
DiscoursePluginRegistry.apply_modifier(:search_log_can_log, term: term, user_id: user_id)
return if !can_log_search
search_type = search_types[search_type]
return [:error] if search_type.blank? || ip_address.blank?
ip_address = nil if user_id
key = redis_key(user_id: user_id, ip_address: ip_address)
if user_agent && user_agent.length > MAXIMUM_USER_AGENT_LENGTH
user_agent = user_agent.truncate(MAXIMUM_USER_AGENT_LENGTH, omission: "")
end
result = nil
if existing = Discourse.redis.get(key)
id, old_term = existing.split(",", 2)
if term.start_with?(old_term)
where(id: id.to_i).update_all(created_at: Time.zone.now, term: term)
result = [:updated, id.to_i]
end
end
if !result
log =
create!(
term: term,
search_type: search_type,
ip_address: ip_address,
user_agent: user_agent,
user_id: user_id,
)
result = [:created, log.id]
end
Discourse.redis.setex(key, 5, "#{result[1]},#{term}")
result
end
def self.term_details(term, period = :weekly, search_type = :all)
details = []
result =
SearchLog.select("COUNT(*) AS count, search_logs.created_at::date AS date").where(
"lower(search_logs.term) = ? AND search_logs.created_at > ?",
term.downcase,
start_of(period),
)
result = result.where("search_type = ?", search_types[search_type]) if search_type == :header ||
search_type == :full_page
result = result.where.not(search_result_id: nil) if search_type == :click_through_only
result = result.non_staff if search_type == :non_staff_only
result
.order("date")
.group("search_logs.created_at::date")
.each { |record| details << { x: Date.parse(record["date"].to_s), y: record["count"] } }
{
type: "search_log_term",
title: I18n.t("search_logs.graph_title"),
start_date: start_of(period),
end_date: Time.zone.now,
data: details,
period: period.to_s,
}
end
def self.trending(period = :all, search_type = :all)
SearchLog.trending_from(start_of(period), search_type: search_type)
end
def self.trending_from(start_date, options = {})
end_date = options[:end_date]
search_type = options[:search_type] || :all
limit = options[:limit] || 100
select_sql = <<~SQL
lower(term) term,
COUNT(*) AS searches,
SUM(CASE
WHEN search_result_id IS NOT NULL THEN 1
ELSE 0
END) AS click_through
SQL
result = SearchLog.select(select_sql).where("search_logs.created_at > ?", start_date)
result = result.where("search_logs.created_at < ?", end_date) if end_date
if search_type == :non_staff_only
result = result.non_staff
elsif search_type != :all
result = result.where("search_type = ?", search_types[search_type])
end
result.group("lower(term)").order("searches DESC, click_through DESC, term ASC").limit(limit)
end
def self.clean_up
search_id =
SearchLog.order(:id).offset(SiteSetting.search_query_log_max_size).limit(1).pluck(:id)
SearchLog.where("id < ?", search_id[0]).delete_all if search_id.present?
SearchLog.where(
"created_at < TIMESTAMP ?",
SiteSetting.search_query_log_max_retention_days.days.ago,
).delete_all
end
def self.start_of(period)
period =
case period
when :yearly
1.year.ago
when :monthly
1.month.ago
when :quarterly
3.months.ago
when :weekly
1.week.ago
when :daily
Time.zone.now
else
1000.years.ago
end
period&.to_date
end
private_class_method :start_of
end
# == Schema Information
#
# Table name: search_logs
#
# id :integer not null, primary key
# ip_address :inet
# search_result_type :integer
# search_type :integer not null
# term :string not null
# user_agent :string(2000)
# created_at :datetime not null
# search_result_id :integer
# user_id :integer
#
# Indexes
#
# index_search_logs_on_created_at (created_at)
# index_search_logs_on_user_id_and_created_at (user_id,created_at) WHERE (user_id IS NOT NULL)
#