mirror of
https://github.com/plausible/analytics.git
synced 2024-12-22 17:11:36 +03:00
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:
parent
d8543c81cc
commit
b97290b5cf
@ -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()
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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(
|
||||
|
@ -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
|
||||
|
Loading…
Reference in New Issue
Block a user