FEATURE: support search click through tracking for user, category and tags

https://meta.discourse.org/t/search-logs-page/73281/11?u=techapj

This commit adds following features:

- support for tracking click through to user, tag and category
- new filter for search type (header, full page)

This commit also removes "most viewed topic" field from search logs page because we are now tracking multiple click through entities, so topic is not a special entity anymore. This also improves query perf. The query now takes `20.5ms` to runs, as opposed to `655.9ms` previously.
This commit is contained in:
Arpit Jalan 2017-11-28 23:24:27 +05:30
parent f37a1d5976
commit e3925278e2
14 changed files with 126 additions and 62 deletions

View File

@ -1,4 +1,11 @@
export default Ember.Controller.extend({ export default Ember.Controller.extend({
loading: false, loading: false,
period: "all" period: "all",
searchType: "all",
searchTypeOptions: [
{id: 'all', name: I18n.t('admin.logs.search_logs.types.all_search_types')},
{id: 'header', name: I18n.t('admin.logs.search_logs.types.header')},
{id: 'full_page', name: I18n.t('admin.logs.search_logs.types.full_page')}
]
}); });

View File

@ -6,20 +6,19 @@ export default Discourse.Route.extend({
}, },
queryParams: { queryParams: {
period: { period: { refreshModel: true },
refreshModel: true searchType: { refreshModel: true }
}
}, },
model(params) { model(params) {
this._params = params; this._params = params;
return ajax('/admin/logs/search_logs.json', { data: { period: params.period } }).then(search_logs => { return ajax('/admin/logs/search_logs.json', { data: { period: params.period, search_type: params.searchType } }).then(search_logs => {
return search_logs.map(sl => Ember.Object.create(sl)); return search_logs.map(sl => Ember.Object.create(sl));
}); });
}, },
setupController(controller, model) { setupController(controller, model) {
const params = this._params; const params = this._params;
controller.setProperties({ model, period: params.period }); controller.setProperties({ model, period: params.period, searchType: params.searchType });
} }
}); });

View File

@ -1,5 +1,6 @@
<p> <p>
{{period-chooser period=period}} {{period-chooser period=period}}
{{combo-box content=searchTypeOptions value=searchType class='search-logs-filter'}}
</p> </p>
<br> <br>
@ -11,7 +12,6 @@
<div class="col heading term">{{i18n 'admin.logs.search_logs.term'}}</div> <div class="col heading term">{{i18n 'admin.logs.search_logs.term'}}</div>
<div class="col heading">{{i18n 'admin.logs.search_logs.searches'}}</div> <div class="col heading">{{i18n 'admin.logs.search_logs.searches'}}</div>
<div class="col heading">{{i18n 'admin.logs.search_logs.click_through'}}</div> <div class="col heading">{{i18n 'admin.logs.search_logs.click_through'}}</div>
<div class="col heading topic">{{i18n 'admin.logs.search_logs.most_viewed_topic'}}</div>
<div class="col heading" title="{{i18n 'admin.logs.search_logs.unique_title'}}">{{i18n 'admin.logs.search_logs.unique'}}</div> <div class="col heading" title="{{i18n 'admin.logs.search_logs.unique_title'}}">{{i18n 'admin.logs.search_logs.unique'}}</div>
</div> </div>
@ -20,11 +20,6 @@
<div class="col term">{{item.term}}</div> <div class="col term">{{item.term}}</div>
<div class="col">{{item.searches}}</div> <div class="col">{{item.searches}}</div>
<div class="col">{{item.click_through}}</div> <div class="col">{{item.click_through}}</div>
<div class="col topic">
{{#if item.clicked_topic_id}}
<a href='{{unbound item.topic_url}}'>{{item.topic_title}}</a>
{{/if}}
</div>
<div class="col">{{item.unique}}</div> <div class="col">{{item.unique}}</div>
</div> </div>
{{/each}} {{/each}}

View File

@ -24,9 +24,13 @@ function createSearchResult({ type, linkField, builder }) {
return attrs.results.map(r => { return attrs.results.map(r => {
let searchResultId; let searchResultId;
if (type === "topic") { if (type === "topic") {
searchResultId = r.get('topic_id'); searchResultId = r.get('topic_id');
} else {
searchResultId = r.get('id');
} }
return h('li', this.attach('link', { return h('li', this.attach('link', {
href: r.get(linkField), href: r.get(linkField),
contents: () => builder.call(this, r, attrs.term), contents: () => builder.call(this, r, attrs.term),

View File

@ -227,6 +227,11 @@ $mobile-breakpoint: 700px;
.select-box-kit.dropdown-select-box { .select-box-kit.dropdown-select-box {
width: auto; width: auto;
} }
.search-logs-filter {
width: 200px;
float: right;
}
} }
.admin-container .controls { .admin-container .controls {
@ -1267,20 +1272,13 @@ table.api-keys {
.search-logs-list{ .search-logs-list{
.col { .col {
text-align: center; text-align: center;
width: 10%; width: 15%;
} }
.col.term { .col.term {
width: 30%; width: 45%;
text-align: left; text-align: left;
} }
.col.topic {
width: 35%;
text-align: left;
text-overflow: ellipsis;
white-space: nowrap;
}
} }
.log-details-modal { .log-details-modal {

View File

@ -2,7 +2,8 @@ class Admin::SearchLogsController < Admin::AdminController
def index def index
period = params[:period] || "all" period = params[:period] || "all"
render_serialized(SearchLog.trending(period.to_sym), SearchLogsSerializer) search_type = params[:search_type] || "all"
render_serialized(SearchLog.trending(period&.to_sym, search_type&.to_sym), SearchLogsSerializer)
end end
end end

View File

@ -77,7 +77,8 @@ class SearchController < ApplicationController
params.require(:search_result_type) params.require(:search_result_type)
params.require(:search_result_id) params.require(:search_result_id)
if params[:search_result_type] == 'topic' search_result_type = params[:search_result_type].downcase.to_sym
if SearchLog.search_result_types.has_key?(search_result_type)
attributes = { id: params[:search_log_id] } attributes = { id: params[:search_log_id] }
if current_user.present? if current_user.present?
attributes[:user_id] = current_user.id attributes[:user_id] = current_user.id
@ -85,8 +86,15 @@ class SearchController < ApplicationController
attributes[:ip_address] = request.remote_ip attributes[:ip_address] = request.remote_ip
end end
if search_result_type == :tag
search_result_id = Tag.find_by_name(params[:search_result_id])&.id
else
search_result_id = params[:search_result_id]
end
SearchLog.where(attributes).update_all( SearchLog.where(attributes).update_all(
clicked_topic_id: params[:search_result_id] search_result_type: SearchLog.search_result_types[search_result_type],
search_result_id: search_result_id
) )
end end

View File

@ -1,7 +1,6 @@
require_dependency 'enum' require_dependency 'enum'
class SearchLog < ActiveRecord::Base class SearchLog < ActiveRecord::Base
belongs_to :topic, foreign_key: :clicked_topic_id
validates_presence_of :term, :ip_address validates_presence_of :term, :ip_address
def self.search_types def self.search_types
@ -11,6 +10,15 @@ class SearchLog < ActiveRecord::Base
) )
end end
def self.search_result_types
@search_result_types ||= Enum.new(
topic: 1,
user: 2,
category: 3,
tag: 4
)
end
def self.log(term:, search_type:, ip_address:, user_id: nil) def self.log(term:, search_type:, ip_address:, user_id: nil)
search_type = search_types[search_type] search_type = search_types[search_type]
@ -49,19 +57,19 @@ class SearchLog < ActiveRecord::Base
end end
end end
def self.trending(period = :all) def self.trending(period = :all, search_type = :all)
SearchLog.select("term, result = SearchLog.select("term,
COUNT(*) AS searches, COUNT(*) AS searches,
SUM(CASE SUM(CASE
WHEN clicked_topic_id IS NOT NULL THEN 1 WHEN search_result_id IS NOT NULL THEN 1
ELSE 0 ELSE 0
END) AS click_through, END) AS click_through,
MODE() WITHIN GROUP (ORDER BY clicked_topic_id) AS clicked_topic_id, COUNT(DISTINCT ip_address) AS unique")
COUNT(DISTINCT ip_address) AS unique")
.includes(:topic)
.where('created_at > ?', start_of(period)) .where('created_at > ?', start_of(period))
.group(:term)
.order('COUNT(DISTINCT ip_address) DESC') result = result.where('search_type = ?', search_types[search_type]) unless search_type == :all
result = result.group(:term)
.order('COUNT(DISTINCT ip_address) DESC, COUNT(*) DESC')
.limit(100).to_a .limit(100).to_a
end end

View File

@ -2,16 +2,5 @@ class SearchLogsSerializer < ApplicationSerializer
attributes :term, attributes :term,
:searches, :searches,
:click_through, :click_through,
:clicked_topic_id,
:topic_title,
:topic_url,
:unique :unique
def topic_title
object&.topic&.title
end
def topic_url
object&.topic&.url
end
end end

View File

@ -3209,9 +3209,12 @@ en:
term: "Term" term: "Term"
searches: "Searches" searches: "Searches"
click_through: "Click Through" click_through: "Click Through"
most_viewed_topic: "Most Viewed Topic"
unique: "Unique" unique: "Unique"
unique_title: "unique users performing the search" unique_title: "unique users performing the search"
types:
all_search_types: "All search types"
header: "Header"
full_page: "Full Page"
logster: logster:
title: "Error Logs" title: "Error Logs"

View File

@ -0,0 +1,13 @@
class RenameClickedTopicIdToSearchResultId < ActiveRecord::Migration[5.1]
def up
rename_column :search_logs, :clicked_topic_id, :search_result_id
add_column :search_logs, :search_result_type, :integer, null: true
execute "UPDATE search_logs SET search_result_type = 1 WHERE search_result_id is NOT NULL"
end
def down
rename_column :search_logs, :search_result_id, :clicked_topic_id
remove_column :search_logs, :search_result_type
end
end

View File

@ -187,14 +187,14 @@ describe SearchController do
} }
expect(response).to be_success expect(response).to be_success
expect(SearchLog.find(search_log_id).clicked_topic_id).to be_blank expect(SearchLog.find(search_log_id).search_result_id).to be_blank
end end
it "records the click for a logged in user" do it "records the click for a logged in user" do
user = log_in(:user) user = log_in(:user)
_, search_log_id = SearchLog.log( _, search_log_id = SearchLog.log(
term: 'kitty', term: 'foobar',
search_type: :header, search_type: :header,
user_id: user.id, user_id: user.id,
ip_address: '127.0.0.1' ip_address: '127.0.0.1'
@ -203,11 +203,12 @@ describe SearchController do
post :click, params: { post :click, params: {
search_log_id: search_log_id, search_log_id: search_log_id,
search_result_id: 12345, search_result_id: 12345,
search_result_type: 'topic' search_result_type: 'user'
}, format: :json }, format: :json
expect(response).to be_success expect(response).to be_success
expect(SearchLog.find(search_log_id).clicked_topic_id).to eq(12345) expect(SearchLog.find(search_log_id).search_result_id).to eq(12345)
expect(SearchLog.find(search_log_id).search_result_type).to eq(SearchLog.search_result_types[:user])
end end
it "records the click for an anonymous user" do it "records the click for an anonymous user" do
@ -226,7 +227,8 @@ describe SearchController do
}, format: :json }, format: :json
expect(response).to be_success expect(response).to be_success
expect(SearchLog.find(search_log_id).clicked_topic_id).to eq(22222) expect(SearchLog.find(search_log_id).search_result_id).to eq(22222)
expect(SearchLog.find(search_log_id).search_result_type).to eq(SearchLog.search_result_types[:topic])
end end
it "doesn't record the click for a different IP" do it "doesn't record the click for a different IP" do
@ -245,7 +247,48 @@ describe SearchController do
} }
expect(response).to be_success expect(response).to be_success
expect(SearchLog.find(search_log_id).clicked_topic_id).to be_blank expect(SearchLog.find(search_log_id).search_result_id).to be_blank
end
it "records the click for search result type category" do
request.remote_addr = '192.168.0.1';
_, search_log_id = SearchLog.log(
term: 'dev',
search_type: :header,
ip_address: '192.168.0.1'
)
post :click, params: {
search_log_id: search_log_id,
search_result_id: 23456,
search_result_type: 'category'
}, format: :json
expect(response).to be_success
expect(SearchLog.find(search_log_id).search_result_id).to eq(23456)
expect(SearchLog.find(search_log_id).search_result_type).to eq(SearchLog.search_result_types[:category])
end
it "records the click for search result type tag" do
request.remote_addr = '192.168.0.1';
tag = Fabricate(:tag, name: 'test')
_, search_log_id = SearchLog.log(
term: 'test',
search_type: :header,
ip_address: '192.168.0.1'
)
post :click, params: {
search_log_id: search_log_id,
search_result_id: tag.name,
search_result_type: 'tag'
}, format: :json
expect(response).to be_success
expect(SearchLog.find(search_log_id).search_result_id).to eq(tag.id)
expect(SearchLog.find(search_log_id).search_result_type).to eq(SearchLog.search_result_types[:tag])
end end
end end
end end

View File

@ -179,15 +179,11 @@ RSpec.describe SearchLog, type: :model do
expect(top_trending.searches).to eq(3) expect(top_trending.searches).to eq(3)
expect(top_trending.unique).to eq(2) expect(top_trending.unique).to eq(2)
expect(top_trending.click_through).to eq(0) expect(top_trending.click_through).to eq(0)
expect(top_trending.clicked_topic_id).to eq(nil)
popular_topic = Fabricate(:topic) SearchLog.where(term: 'ruby', ip_address: '127.0.0.1').update_all(search_result_id: 12)
not_so_popular_topic = Fabricate(:topic) SearchLog.where(term: 'ruby', ip_address: '127.0.0.2').update_all(search_result_id: 24)
SearchLog.where(term: 'ruby', ip_address: '127.0.0.1').update_all(clicked_topic_id: popular_topic.id)
SearchLog.where(term: 'ruby', ip_address: '127.0.0.2').update_all(clicked_topic_id: not_so_popular_topic.id)
top_trending = SearchLog.trending.first top_trending = SearchLog.trending.first
expect(top_trending.click_through).to eq(3) expect(top_trending.click_through).to eq(3)
expect(top_trending.clicked_topic_id).to eq(popular_topic.id)
end end
end end

View File

@ -393,7 +393,7 @@ export default function() {
this.get('/admin/logs/search_logs.json', () => { this.get('/admin/logs/search_logs.json', () => {
return response(200, [ return response(200, [
{"term":"foobar","searches":35,"click_through":6,"clicked_topic_id":1550,"topic_title":"Foo Bar Topic Title","topic_url":"http://discourse.example.com/t/foo-bar-topic-title/1550","unique":16} {"term":"foobar","searches":35,"click_through":6,"unique":16}
]); ]);
}); });