* Add counts for number of views when showing referrers We're sampling unauthenticated page views (every 10th visit is recorded) and using the "counts_for_number_of_views" attribute to mark the 10x weight of these sampled events. The referrers section of the statistics page however was counting the number of page view rows (without regard to the weight) and it looked like external referrers (like a google search) were counting 10 times on the statistics page. Update the analytics service to show the sum of the weights, rather than the count of events, on the "Traffic Source Summary" of the article stats page. This is loaded from /api/analytics/referrers?article_id=:id on the front end. Needs a test that demonstrates this (currently all specs for this stayed green suggesting the coverage didn't include anything that represents this situation). * Avoid calling sum twice by reusing the value alias the .sum(:column) method in this chain creates an alias 'select sum(column) as sum_column' which can be used here to avoid recalculating the sum once for sorting and again for return value. Sort by the return value's alias. * Update the test to include weighted page views Since we want to demonstrate that the count shown for visits matches the sum of the weighted page views, add weighting to the top url visits and expect the sum of the weights to be presented.
206 lines
7.9 KiB
Ruby
206 lines
7.9 KiB
Ruby
class AnalyticsService
|
|
DEFAULT_REACTION_TOTALS = { total: 0, like: 0, readinglist: 0, unicorn: 0 }.freeze
|
|
|
|
def initialize(user_or_org, start_date: "", end_date: "", article_id: nil)
|
|
@user_or_org = user_or_org
|
|
@article_id = article_id
|
|
@start_date = Time.zone.parse(start_date.to_s)&.beginning_of_day
|
|
@end_date = Time.zone.parse(end_date.to_s)&.end_of_day || Time.current.end_of_day
|
|
|
|
load_data
|
|
end
|
|
|
|
# Computes total counts for comments, reactions, follows and page views
|
|
def totals
|
|
{
|
|
comments: { total: comment_data.size },
|
|
follows: { total: follow_data.size },
|
|
reactions: calculate_reactions_totals,
|
|
page_views: calculate_page_views_totals
|
|
}
|
|
end
|
|
|
|
# Computes counts for comments, reactions, follows and page views per each day
|
|
def grouped_by_day
|
|
return {} unless start_date && end_date
|
|
|
|
# cache all stats in the date range for the requested user or organization
|
|
cache_key = "analytics-for-dates-#{start_date}-#{end_date}-#{user_or_org.class.name}-#{user_or_org.id}"
|
|
cache_key = "#{cache_key}-article-#{article_id}" if article_id
|
|
|
|
Rails.cache.fetch(cache_key, expires_in: 7.days) do
|
|
# 1. calculate all stats using group queries at once
|
|
comments_stats_per_day = calculate_comments_stats_per_day(comment_data)
|
|
follows_stats_per_day = calculate_follows_stats_per_day(follow_data)
|
|
reactions_stats_per_day = calculate_reactions_stats_per_day(reaction_data)
|
|
page_views_stats_per_day = calculate_page_views_stats_per_day(page_view_data)
|
|
|
|
# 2. build the final hash, one per each day
|
|
stats = {}
|
|
(start_date.to_date..end_date.to_date).each do |date|
|
|
stats[date.iso8601] = stats_per_day(
|
|
date,
|
|
comments_stats: comments_stats_per_day,
|
|
follows_stats: follows_stats_per_day,
|
|
reactions_stats: reactions_stats_per_day,
|
|
page_views_stats: page_views_stats_per_day,
|
|
)
|
|
end
|
|
|
|
stats
|
|
end
|
|
end
|
|
|
|
# Returns the list of referrers
|
|
def referrers(top: 20)
|
|
# count_all is the name of the field autogenerated by Rails with COUNT(*)
|
|
counts = page_view_data
|
|
.group(:domain)
|
|
.order(Arel.sql("sum_counts_for_number_of_views DESC"))
|
|
.limit(top)
|
|
.sum("counts_for_number_of_views")
|
|
# we transform this in a list of hashes in case we need to add more keys
|
|
domains = counts.map { |domain, count| { domain: domain, count: count } }
|
|
|
|
{ domains: domains }
|
|
end
|
|
|
|
private
|
|
|
|
attr_reader(
|
|
:user_or_org, :article_id, :start_date, :end_date,
|
|
:article_data, :reaction_data, :comment_data, :follow_data, :page_view_data
|
|
)
|
|
|
|
def load_data
|
|
@article_data = Article.published.where("#{user_or_org.class.name.downcase}_id" => user_or_org.id)
|
|
if @article_id
|
|
@article_data = @article_data.where(id: @article_id)
|
|
|
|
# check article_id is published and belongs to the user/org
|
|
raise ArgumentError, "You can't view this article's stats" unless @article_data.exists?
|
|
|
|
article_ids = [@article_id]
|
|
else
|
|
article_ids = @article_data.ids
|
|
end
|
|
|
|
# prepare relations for metrics
|
|
@comment_data = Comment
|
|
.where(commentable_id: article_ids, commentable_type: "Article")
|
|
.where("score > 0")
|
|
@follow_data = Follow
|
|
.where(followable_type: user_or_org.class.name, followable_id: user_or_org.id)
|
|
@reaction_data = Reaction.public_category
|
|
.where(reactable_id: article_ids, reactable_type: "Article")
|
|
@page_view_data = PageView.where(article_id: article_ids)
|
|
|
|
# filter data by date if needed
|
|
return unless start_date && end_date
|
|
|
|
@comment_data = @comment_data.where(created_at: @start_date..@end_date)
|
|
@reaction_data = @reaction_data.where(created_at: @start_date..@end_date)
|
|
@page_view_data = @page_view_data.where(created_at: @start_date..@end_date)
|
|
end
|
|
|
|
def calculate_reactions_totals
|
|
# NOTE: the order of the keys needs to be the same as the one of the counts
|
|
keys = %i[total like readinglist unicorn]
|
|
counts = reaction_data.pick(
|
|
Arel.sql("COUNT(*)"),
|
|
Arel.sql("COUNT(*) FILTER (WHERE category = 'like')"),
|
|
Arel.sql("COUNT(*) FILTER (WHERE category = 'readinglist')"),
|
|
Arel.sql("COUNT(*) FILTER (WHERE category = 'unicorn')"),
|
|
)
|
|
|
|
if counts
|
|
# this transforms the counts, eg. [1, 0, 1, 0]
|
|
# in a hash, eg. {total: 1, like: 0, readinglist: 1, unicorn: 0}
|
|
keys.zip(counts).to_h
|
|
else
|
|
DEFAULT_REACTION_TOTALS
|
|
end
|
|
end
|
|
|
|
def calculate_page_views_totals
|
|
total_views = article_data.sum(:page_views_count)
|
|
logged_in_page_view_data = page_view_data.where.not(user_id: nil)
|
|
average = logged_in_page_view_data.pick(Arel.sql("AVG(time_tracked_in_seconds)"))
|
|
average_read_time_in_seconds = (average || 0).round # average is a BigDecimal
|
|
|
|
{
|
|
total: total_views,
|
|
average_read_time_in_seconds: average_read_time_in_seconds,
|
|
total_read_time_in_seconds: average_read_time_in_seconds * total_views
|
|
}
|
|
end
|
|
|
|
def calculate_comments_stats_per_day(comment_data)
|
|
# AR returns a hash with date => count, we transform it using ISO dates for convenience
|
|
comment_data.group("DATE(created_at)").count.transform_keys(&:iso8601)
|
|
end
|
|
|
|
def calculate_follows_stats_per_day(follow_data)
|
|
# AR returns a hash with date => count, we transform it using ISO dates for convenience
|
|
follow_data.group("DATE(created_at)").count.transform_keys(&:iso8601)
|
|
end
|
|
|
|
def calculate_reactions_stats_per_day(reaction_data)
|
|
# we issue one single query that contains all requested aggregates
|
|
# and that groups them by date
|
|
reactions = reaction_data.select(
|
|
Arel.sql("DATE(created_at)").as("date"),
|
|
Arel.sql("COUNT(*)").as("total"),
|
|
Arel.sql("COUNT(*) FILTER (WHERE category = 'like')").as("like"),
|
|
Arel.sql("COUNT(*) FILTER (WHERE category = 'readinglist')").as("readinglist"),
|
|
Arel.sql("COUNT(*) FILTER (WHERE category = 'unicorn')").as("unicorn"),
|
|
).group("DATE(created_at)")
|
|
|
|
# this transforms the collection of pseudo Reaction objects previously selected
|
|
# in a hash, eg. {total: 1, like: 0, readinglist: 1, unicorn: 0}
|
|
reactions.each_with_object({}) do |reaction, hash|
|
|
hash[reaction.date.iso8601] = {
|
|
total: reaction.total,
|
|
like: reaction.like,
|
|
readinglist: reaction.readinglist,
|
|
unicorn: reaction.unicorn
|
|
}
|
|
end
|
|
end
|
|
|
|
def calculate_page_views_stats_per_day(page_view_data)
|
|
# we issue one single query that contains all requested aggregates
|
|
# and that groups them by date
|
|
page_views = page_view_data.select(
|
|
Arel.sql("DATE(created_at)").as("date"),
|
|
Arel.sql("SUM(counts_for_number_of_views)").as("total"),
|
|
# count the average only for logged in users
|
|
Arel.sql("AVG(time_tracked_in_seconds) FILTER (WHERE user_id IS NOT NULL)").as("average"),
|
|
).group("DATE(created_at)")
|
|
|
|
# this transforms the collection of pseudo PageView objects previously selected
|
|
# in a hash, eg. {total: 2, average_read_time_in_seconds: 10, total_read_time_in_seconds: 20}
|
|
page_views.each_with_object({}) do |page_view, hash|
|
|
average = (page_view.average || 0).round # average is a BigDecimal
|
|
hash[page_view.date.iso8601] = {
|
|
total: page_view.total,
|
|
average_read_time_in_seconds: average,
|
|
total_read_time_in_seconds: page_view.total * average
|
|
}
|
|
end
|
|
end
|
|
|
|
def stats_per_day(date, comments_stats:, follows_stats:, reactions_stats:, page_views_stats:)
|
|
# we need these defaults because SQL doesn't return any data for dates that don't have any
|
|
default_reactions_stats = { total: 0, like: 0, readinglist: 0, unicorn: 0 }
|
|
default_page_views_stats = { total: 0, average_read_time_in_seconds: 0, total_read_time_in_seconds: 0 }
|
|
iso_date = date.iso8601
|
|
|
|
{
|
|
comments: { total: comments_stats[iso_date] || 0 },
|
|
follows: { total: follows_stats[iso_date] || 0 },
|
|
reactions: reactions_stats[iso_date] || default_reactions_stats,
|
|
page_views: page_views_stats[iso_date] || default_page_views_stats
|
|
}
|
|
end
|
|
end
|