Add revenue metrics to top stats (#3059)

This commit adds revenue data to top stats. Average and total are displayed when filtering by a revenue goal (or many if they have the same currency set).
This commit is contained in:
Vini Brasil 2023-06-22 19:36:43 +01:00 committed by GitHub
parent d8543c81cc
commit b97290b5cf
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 308 additions and 65 deletions

View File

@ -19,13 +19,13 @@ export default class TopStats extends React.Component {
const formattedComparison = numberFormatter(Math.abs(comparison))
const defaultClassName = classNames({
"text-xs dark:text-gray-100": !forceDarkBg,
"text-xs text-gray-100": forceDarkBg
"pl-2 text-xs dark:text-gray-100": !forceDarkBg,
"pl-2 text-xs text-gray-100": forceDarkBg
})
const noChangeClassName = classNames({
"text-xs text-gray-700 dark:text-gray-300": !forceDarkBg,
"text-xs text-gray-300": forceDarkBg
"pl-2 text-xs text-gray-700 dark:text-gray-300": !forceDarkBg,
"pl-2 text-xs text-gray-300": forceDarkBg
})
if (comparison > 0) {
@ -46,6 +46,8 @@ export default class TopStats extends React.Component {
return durationFormatter(value)
} else if (['bounce rate', 'conversion rate'].includes(name.toLowerCase())) {
return value + '%'
} else if (['average revenue', 'total revenue'].includes(name.toLowerCase())) {
return value?.short
} else {
return numberFormatter(value)
}
@ -56,6 +58,8 @@ export default class TopStats extends React.Component {
return durationFormatter(value)
} else if (['bounce rate', 'conversion rate'].includes(name.toLowerCase())) {
return value + '%'
} else if (['average revenue', 'total revenue'].includes(name.toLowerCase())) {
return value?.long
} else {
return (value || 0).toLocaleString()
}

View File

@ -2,11 +2,26 @@ defmodule Plausible.Stats.Aggregate do
alias Plausible.Stats.Query
use Plausible.ClickhouseRepo
import Plausible.Stats.{Base, Imported, Util}
import Ecto.Query
@event_metrics [:visitors, :pageviews, :events, :sample_percent]
@event_metrics [
:visitors,
:pageviews,
:events,
:sample_percent,
:average_revenue,
:total_revenue
]
@session_metrics [:visits, :bounce_rate, :visit_duration, :views_per_visit, :sample_percent]
@revenue_metrics [:average_revenue, :total_revenue]
def aggregate(site, query, metrics) do
# Aggregating revenue data works only for same currency goals. If the query
# is filtered by goals with different currencies, for example, one USD and
# other EUR, revenue metrics are dropped.
currency = get_revenue_tracking_currency(site, query, metrics)
metrics = if currency, do: metrics, else: metrics -- @revenue_metrics
event_metrics = Enum.filter(metrics, &(&1 in @event_metrics))
event_task = fn -> aggregate_events(site, query, event_metrics) end
session_metrics = Enum.filter(metrics, &(&1 in @session_metrics))
@ -22,6 +37,7 @@ defmodule Plausible.Stats.Aggregate do
Plausible.ClickhouseRepo.parallel_tasks([session_task, event_task, time_on_page_task])
|> Enum.reduce(%{}, fn aggregate, task_result -> Map.merge(aggregate, task_result) end)
|> Enum.map(&maybe_round_value/1)
|> Enum.map(&cast_revenue_metric_to_money(&1, currency))
|> Enum.map(fn {metric, value} -> {metric, %{value: value}} end)
|> Enum.into(%{})
end
@ -134,4 +150,37 @@ defmodule Plausible.Stats.Aggregate do
end
defp maybe_round_value(entry), do: entry
defp get_revenue_tracking_currency(site, query, metrics) do
goal_filters =
case query.filters do
%{"event:goal" => {:is, {_, goal_name}}} -> [goal_name]
%{"event:goal" => {:member, list}} -> Enum.map(list, fn {_, goal_name} -> goal_name end)
_any -> []
end
if Enum.any?(metrics, &(&1 in @revenue_metrics)) && Enum.any?(goal_filters) do
revenue_goals_currencies =
Plausible.Repo.all(
from rg in assoc(site, :revenue_goals),
where: rg.event_name in ^goal_filters,
select: rg.currency,
distinct: true
)
if length(revenue_goals_currencies) == 1,
do: List.first(revenue_goals_currencies),
else: nil
else
nil
end
end
defp cast_revenue_metric_to_money({metric, value}, currency) do
if metric in @revenue_metrics and is_atom(currency) do
{metric, Money.new!(value, currency)}
else
{metric, value}
end
end
end

View File

@ -329,54 +329,61 @@ defmodule PlausibleWeb.Api.StatsController do
{stats, 100}
end
defp fetch_top_stats(site, %Query{filters: %{"event:goal" => _goal}} = query, comparison_query) do
totals_query = Query.remove_event_filters(query, [:goal, :props])
defp fetch_top_stats(site, %Query{filters: %{"event:goal" => _}} = query, comparison_query) do
query_without_filters = Query.remove_event_filters(query, [:goal, :props])
metrics = [:visitors, :events, :average_revenue, :total_revenue]
%{
visitors: %{value: unique_visitors}
} = Stats.aggregate(site, totals_query, [:visitors])
results_without_filters =
site
|> Stats.aggregate(query_without_filters, [:visitors])
|> transform_keys(%{visitors: :unique_visitors})
%{
visitors: %{value: converted_visitors},
events: %{value: completions}
} = Stats.aggregate(site, query, [:visitors, :events])
results =
site
|> Stats.aggregate(query, metrics)
|> transform_keys(%{visitors: :converted_visitors, events: :completions})
|> Map.merge(results_without_filters)
{prev_unique_visitors, prev_converted_visitors, prev_completions} =
comparison =
if comparison_query do
totals_comparison_query = Query.remove_event_filters(comparison_query, [:goal, :props])
comparison_query_without_filters =
Query.remove_event_filters(comparison_query, [:goal, :props])
%{
visitors: %{value: prev_unique_visitors}
} = Stats.aggregate(site, totals_comparison_query, [:visitors])
comparison_without_filters =
site
|> Stats.aggregate(comparison_query_without_filters, [:visitors])
|> transform_keys(%{visitors: :unique_visitors})
%{
visitors: %{value: prev_converted_visitors},
events: %{value: prev_completions}
} = Stats.aggregate(site, comparison_query, [:visitors, :events])
{prev_unique_visitors, prev_converted_visitors, prev_completions}
else
{nil, nil, nil}
site
|> Stats.aggregate(comparison_query, metrics)
|> transform_keys(%{visitors: :converted_visitors, events: :completions})
|> Map.merge(comparison_without_filters)
end
conversion_rate = calculate_cr(unique_visitors, converted_visitors)
prev_conversion_rate = calculate_cr(prev_unique_visitors, prev_converted_visitors)
conversion_rate = %{
cr: %{value: calculate_cr(results.unique_visitors.value, results.converted_visitors.value)}
}
build_item = fn name, value, comparison_value ->
if comparison_value do
change = percent_change(comparison_value, value)
%{name: name, value: value, comparison_value: comparison_value, change: change}
comparison_conversion_rate =
if comparison do
value =
calculate_cr(comparison.unique_visitors.value, comparison.converted_visitors.value)
%{cr: %{value: value}}
else
%{name: name, value: value}
nil
end
end
{[
build_item.("Unique visitors", unique_visitors, prev_unique_visitors),
build_item.("Unique conversions", converted_visitors, prev_converted_visitors),
build_item.("Total conversions", completions, prev_completions),
build_item.("Conversion rate", conversion_rate, prev_conversion_rate)
], 100}
[
top_stats_entry(results, comparison, "Unique visitors", :unique_visitors),
top_stats_entry(results, comparison, "Unique conversions", :converted_visitors),
top_stats_entry(results, comparison, "Total conversions", :completions),
top_stats_entry(results, comparison, "Average revenue", :average_revenue, &format_money/1),
top_stats_entry(results, comparison, "Total revenue", :total_revenue, &format_money/1),
top_stats_entry(conversion_rate, comparison_conversion_rate, "Conversion rate", :cr)
]
|> Enum.reject(&is_nil/1)
|> then(&{&1, 100})
end
defp fetch_top_stats(site, query, comparison_query) do
@ -420,16 +427,22 @@ defmodule PlausibleWeb.Api.StatsController do
{stats, current_results[:sample_percent][:value]}
end
defp top_stats_entry(current_results, prev_results, name, key) do
defp top_stats_entry(current_results, prev_results, name, key, formatter \\ & &1) do
if current_results[key] do
value = get_in(current_results, [key, :value])
if prev_results do
prev_value = get_in(prev_results, [key, :value])
change = calculate_change(key, prev_value, value)
%{name: name, value: value, comparison_value: prev_value, change: change}
%{
name: name,
value: formatter.(value),
comparison_value: formatter.(prev_value),
change: change
}
else
%{name: name, value: value}
%{name: name, value: formatter.(value)}
end
end
end
@ -444,6 +457,12 @@ defmodule PlausibleWeb.Api.StatsController do
defp percent_change(nil, _new_count), do: nil
defp percent_change(%Money{} = old_count, %Money{} = new_count) do
old_count = old_count |> Money.to_decimal() |> Decimal.to_float()
new_count = new_count |> Money.to_decimal() |> Decimal.to_float()
percent_change(old_count, new_count)
end
defp percent_change(old_count, new_count) do
cond do
old_count == 0 and new_count > 0 ->
@ -1062,7 +1081,8 @@ defmodule PlausibleWeb.Api.StatsController do
goal
|> Map.put(:prop_names, CustomProps.props_for_goal(site, query))
|> Map.put(:conversion_rate, calculate_cr(total_visitors, goal[:unique_conversions]))
|> format_revenue_metrics()
|> Enum.map(&format_revenue_metric/1)
|> Map.new()
end)
if params["csv"] do
@ -1072,21 +1092,27 @@ defmodule PlausibleWeb.Api.StatsController do
end
end
defp format_revenue_metrics(%{average_revenue: %Money{}, total_revenue: %Money{}} = results) do
%{
results
| average_revenue: %{
short: Money.to_string!(results.average_revenue, format: :short, fractional_digits: 1),
long: Money.to_string!(results.average_revenue)
},
total_revenue: %{
short: Money.to_string!(results.total_revenue, format: :short, fractional_digits: 1),
long: Money.to_string!(results.total_revenue)
}
}
@revenue_metrics [:average_revenue, :total_revenue]
defp format_revenue_metric({metric, value}) do
if metric in @revenue_metrics do
{metric, format_money(value)}
else
{metric, value}
end
end
defp format_revenue_metrics(results), do: results
defp format_money(value) do
case value do
%Money{} ->
%{
short: Money.to_string!(value, format: :short, fractional_digits: 1),
long: Money.to_string!(value)
}
_any ->
value
end
end
def prop_breakdown(conn, params) do
site = conn.assigns[:site]
@ -1162,13 +1188,12 @@ defmodule PlausibleWeb.Api.StatsController do
)
end
defp transform_keys(results, keys_to_replace) do
Enum.map(results, fn map ->
Enum.map(map, fn {key, val} ->
{Map.get(keys_to_replace, key, key), val}
end)
|> Enum.into(%{})
end)
defp transform_keys(result, keys_to_replace) when is_map(result) do
for {key, val} <- result, do: {Map.get(keys_to_replace, key, key), val}, into: %{}
end
defp transform_keys(results, keys_to_replace) when is_list(results) do
Enum.map(results, &transform_keys(&1, keys_to_replace))
end
defp parse_pagination(params) do

View File

@ -36,6 +36,7 @@ site =
{:ok, goal1} = Plausible.Goals.create(site, %{"page_path" => "/"})
{:ok, goal2} = Plausible.Goals.create(site, %{"page_path" => "/register"})
{:ok, goal3} = Plausible.Goals.create(site, %{"page_path" => "/login"})
{:ok, goal4} = Plausible.Goals.create(site, %{"event_name" => "Purchase", "currency" => "USD"})
{:ok, _funnel} =
Plausible.Funnels.create(site, "From homepage to login", [
@ -130,6 +131,35 @@ native_stats_range
end)
|> Plausible.TestUtils.populate_stats()
native_stats_range
|> Enum.with_index()
|> Enum.flat_map(fn {date, index} ->
Enum.map(0..Enum.random(1..50), fn _ ->
geolocation = Enum.random(geolocations)
[
name: goal4.event_name,
site_id: site.id,
hostname: site.domain,
timestamp: put_random_time.(date, index),
referrer_source: Enum.random(["", "Facebook", "Twitter", "DuckDuckGo", "Google"]),
browser: Enum.random(["Edge", "Chrome", "Safari", "Firefox", "Vivaldi"]),
browser_version: to_string(Enum.random(0..50)),
screen_size: Enum.random(["Mobile", "Tablet", "Desktop", "Laptop"]),
operating_system: Enum.random(["Windows", "macOS", "Linux"]),
operating_system_version: to_string(Enum.random(0..15)),
pathname:
Enum.random(["/", "/login", "/settings", "/register", "/docs", "/docs/1", "/docs/2"]),
user_id: Enum.random(1..1200),
revenue_reporting_amount: Decimal.new(Enum.random(100..10000)),
revenue_reporting_currency: "USD"
]
|> Keyword.merge(geolocation)
|> then(&Plausible.Factory.build(:event, &1))
end)
end)
|> Plausible.TestUtils.populate_stats()
site =
site
|> Plausible.Site.start_import(

View File

@ -690,6 +690,141 @@ defmodule PlausibleWeb.Api.StatsController.TopStatsTest do
assert %{"name" => "Conversion rate", "value" => 33.3} in res["top_stats"]
end
test "returns average and total when filtering by a revenue goal", %{conn: conn, site: site} do
insert(:goal, site: site, event_name: "Payment", currency: "USD")
insert(:goal, site: site, event_name: "AddToCart", currency: "EUR")
populate_stats(site, [
build(:event,
name: "Payment",
revenue_reporting_amount: Decimal.new(13_29),
revenue_reporting_currency: "USD"
),
build(:event,
name: "Payment",
revenue_reporting_amount: Decimal.new(19_90),
revenue_reporting_currency: "USD"
),
build(:event,
name: "AddToCart",
revenue_reporting_amount: Decimal.new(10_31),
revenue_reporting_currency: "EUR"
),
build(:event,
name: "AddToCart",
revenue_reporting_amount: Decimal.new(20_00),
revenue_reporting_currency: "EUR"
)
])
filters = Jason.encode!(%{goal: "Payment"})
conn = get(conn, "/api/stats/#{site.domain}/top-stats?period=all&filters=#{filters}")
assert %{"top_stats" => top_stats} = json_response(conn, 200)
assert %{
"name" => "Average revenue",
"value" => %{"long" => "$1,659.50", "short" => "$1.7K"}
} in top_stats
assert %{
"name" => "Total revenue",
"value" => %{"long" => "$3,319.00", "short" => "$3.3K"}
} in top_stats
end
test "returns average and total when filtering by many revenue goals with same currency", %{
conn: conn,
site: site
} do
insert(:goal, site: site, event_name: "Payment", currency: "USD")
insert(:goal, site: site, event_name: "Payment2", currency: "USD")
insert(:goal, site: site, event_name: "AddToCart", currency: "EUR")
populate_stats(site, [
build(:event,
name: "Payment",
revenue_reporting_amount: Decimal.new(13_29),
revenue_reporting_currency: "USD"
),
build(:event,
name: "Payment",
revenue_reporting_amount: Decimal.new(19_90),
revenue_reporting_currency: "USD"
),
build(:event,
name: "Payment2",
revenue_reporting_amount: Decimal.new(13_29),
revenue_reporting_currency: "USD"
),
build(:event,
name: "Payment2",
revenue_reporting_amount: Decimal.new(19_90),
revenue_reporting_currency: "USD"
),
build(:event,
name: "AddToCart",
revenue_reporting_amount: Decimal.new(10_31),
revenue_reporting_currency: "EUR"
),
build(:event,
name: "AddToCart",
revenue_reporting_amount: Decimal.new(20_00),
revenue_reporting_currency: "EUR"
)
])
filters = Jason.encode!(%{goal: "Payment|Payment2"})
conn = get(conn, "/api/stats/#{site.domain}/top-stats?period=all&filters=#{filters}")
assert %{"top_stats" => top_stats} = json_response(conn, 200)
assert %{
"name" => "Average revenue",
"value" => %{"long" => "$1,659.50", "short" => "$1.7K"}
} in top_stats
assert %{
"name" => "Total revenue",
"value" => %{"long" => "$6,638.00", "short" => "$6.6K"}
} in top_stats
end
test "does not return average and total when filtering by many revenue goals with different currencies",
%{conn: conn, site: site} do
insert(:goal, site: site, event_name: "Payment", currency: "USD")
insert(:goal, site: site, event_name: "AddToCart", currency: "EUR")
populate_stats(site, [
build(:event,
name: "Payment",
revenue_reporting_amount: Decimal.new(13_29),
revenue_reporting_currency: "USD"
),
build(:event,
name: "Payment",
revenue_reporting_amount: Decimal.new(19_90),
revenue_reporting_currency: "USD"
),
build(:event,
name: "AddToCart",
revenue_reporting_amount: Decimal.new(10_31),
revenue_reporting_currency: "EUR"
),
build(:event,
name: "AddToCart",
revenue_reporting_amount: Decimal.new(20_00),
revenue_reporting_currency: "EUR"
)
])
filters = Jason.encode!(%{goal: "Payment|AddToCart"})
conn = get(conn, "/api/stats/#{site.domain}/top-stats?period=all&filters=#{filters}")
assert %{"top_stats" => top_stats} = json_response(conn, 200)
metrics = Enum.map(top_stats, & &1["name"])
refute "Average revenue" in metrics
refute "Total revenue" in metrics
end
end
describe "GET /api/stats/top-stats - with comparisons" do