Plot revenue metrics on main graph (#3112)

* Move money cast function to Stats.Util

* Add revenue metrics to main graph API

* Plot revenue metrics on the main graph

* Return plain numbers instead of a map

* Fix Credo issues

* Fix canMetricBeGraphed function

* Revert canMetricBeGraphed function changes
This commit is contained in:
Vini Brasil 2023-07-12 10:25:34 +01:00 committed by GitHub
parent 36bfdb35f5
commit fd01a67a5f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 260 additions and 65 deletions

View File

@ -10,6 +10,8 @@ export const METRIC_MAPPING = {
'Total visits': 'visits',
'Bounce rate': 'bounce_rate',
'Unique conversions': 'conversions',
'Average revenue': 'average_revenue',
'Total revenue': 'total_revenue',
}
export const METRIC_LABELS = {
@ -20,6 +22,8 @@ export const METRIC_LABELS = {
'bounce_rate': 'Bounce Rate',
'visit_duration': 'Visit Duration',
'conversions': 'Converted Visitors',
'average_revenue': 'Average Revenue',
'total_revenue': 'Total Revenue',
}
export const METRIC_FORMATTER = {
@ -30,6 +34,8 @@ export const METRIC_FORMATTER = {
'bounce_rate': (number) => (`${number}%`),
'visit_duration': durationFormatter,
'conversions': numberFormatter,
'total_revenue': numberFormatter,
'average_revenue': numberFormatter,
}
export const LoadingState = {

View File

@ -13,14 +13,9 @@ defmodule Plausible.Stats.Aggregate do
: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
{currency, metrics} = get_revenue_tracking_currency(site, query, metrics)
event_metrics = Enum.filter(metrics, &(&1 in @event_metrics))
event_task = fn -> aggregate_events(site, query, event_metrics) end
@ -36,8 +31,8 @@ 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)
|> cast_revenue_metrics_to_money(currency)
|> 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
@ -150,37 +145,4 @@ 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

@ -23,10 +23,12 @@ defmodule Plausible.Stats.Breakdown do
event_results =
if Enum.any?(event_goals) do
revenue_goals = Enum.filter(event_goals, &Plausible.Goal.revenue?/1)
site
|> breakdown(event_query, "event:name", metrics, pagination)
|> transform_keys(%{name: :goal})
|> cast_revenue_metrics_to_money(event_goals)
|> cast_revenue_metrics_to_money(revenue_goals)
else
[]
end
@ -157,26 +159,6 @@ defmodule Plausible.Stats.Breakdown do
breakdown_sessions(site, query, property, metrics, pagination)
end
defp cast_revenue_metrics_to_money(event_results, goals) do
cast_and_put = fn map, key, currency ->
if decimal = Map.get(map, key),
do: Map.put(map, key, Money.new!(currency, decimal)),
else: map
end
revenue_goals = Enum.filter(goals, &Plausible.Goal.revenue?/1)
Enum.map(event_results, fn result ->
if matching_goal = Enum.find(revenue_goals, &(&1.event_name == result.goal)) do
result
|> cast_and_put.(:total_revenue, matching_goal.currency)
|> cast_and_put.(:average_revenue, matching_goal.currency)
else
result
end
end)
end
defp zip_results(event_result, session_result, property, metrics) do
null_row = Enum.map(metrics, fn metric -> {metric, nil} end) |> Enum.into(%{})

View File

@ -4,11 +4,18 @@ defmodule Plausible.Stats.Timeseries do
import Plausible.Stats.{Base, Util}
use Plausible.Stats.Fragments
@typep metric :: :pageviews | :visitors | :visits | :bounce_rate | :visit_duration
@typep metric ::
:pageviews
| :visitors
| :visits
| :bounce_rate
| :visit_duration
| :average_revenue
| :total_revenue
@typep value :: nil | integer() | float()
@type results :: nonempty_list(%{required(:date) => Date.t(), required(metric()) => value()})
@event_metrics [:visitors, :pageviews]
@event_metrics [:visitors, :pageviews, :average_revenue, :total_revenue]
@session_metrics [:visits, :bounce_rate, :visit_duration, :views_per_visit]
def timeseries(site, query, metrics) do
steps = buckets(query)
@ -16,6 +23,8 @@ defmodule Plausible.Stats.Timeseries do
event_metrics = Enum.filter(metrics, &(&1 in @event_metrics))
session_metrics = Enum.filter(metrics, &(&1 in @session_metrics))
{currency, event_metrics} = get_revenue_tracking_currency(site, query, event_metrics)
[event_result, session_result] =
Plausible.ClickhouseRepo.parallel_tasks([
fn -> events_timeseries(site, query, event_metrics) end,
@ -27,6 +36,7 @@ defmodule Plausible.Stats.Timeseries do
|> Map.merge(Enum.find(event_result, fn row -> date_eq(row[:date], step) end) || %{})
|> Map.merge(Enum.find(session_result, fn row -> date_eq(row[:date], step) end) || %{})
|> Map.update!(:date, &date_format/1)
|> cast_revenue_metrics_to_money(currency)
end)
end
@ -216,7 +226,9 @@ defmodule Plausible.Stats.Timeseries do
:visits -> Map.merge(row, %{visits: 0})
:views_per_visit -> Map.merge(row, %{views_per_visit: 0.0})
:bounce_rate -> Map.merge(row, %{bounce_rate: nil})
:visit_duration -> Map.merge(row, %{:visit_duration => nil})
:visit_duration -> Map.merge(row, %{visit_duration: nil})
:average_revenue -> Map.merge(row, %{average_revenue: nil})
:total_revenue -> Map.merge(row, %{total_revenue: nil})
end
end)
end

View File

@ -3,6 +3,8 @@ defmodule Plausible.Stats.Util do
Utilities for modifying stat results
"""
import Ecto.Query
@doc """
`__internal_visits` is fetched when querying bounce rate and visit duration, as it
is needed to calculate these from imported data. This function removes that metric
@ -20,4 +22,65 @@ defmodule Plausible.Stats.Util do
def remove_internal_visits_metric(result) when is_map(result) do
Map.delete(result, :__internal_visits)
end
@revenue_metrics [:average_revenue, :total_revenue]
@spec get_revenue_tracking_currency(Plausible.Site.t(), Plausible.Stats.Query.t(), [atom()]) ::
{atom() | nil, [atom()]}
@doc """
Returns the common currency for the goal filters in a query. If there are no
goal filters, or multiple currencies, `nil` is returned and revenue metrics
are dropped.
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.
"""
def 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 Ecto.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), metrics},
else: {nil, metrics -- @revenue_metrics}
else
{nil, metrics -- @revenue_metrics}
end
end
def cast_revenue_metrics_to_money([%{goal: _goal} | _rest] = results, revenue_goals)
when is_list(revenue_goals) do
for result <- results do
if matching_goal = Enum.find(revenue_goals, &(&1.event_name == result.goal)) do
cast_revenue_metrics_to_money(result, matching_goal.currency)
else
result
end
end
end
def cast_revenue_metrics_to_money(results, currency) when is_map(results) do
for {metric, value} <- results, into: %{} do
if metric in @revenue_metrics && currency do
{metric, Money.new!(value || 0, currency)}
else
{metric, value}
end
end
end
def cast_revenue_metrics_to_money(results, _), do: results
end

View File

@ -145,7 +145,13 @@ defmodule PlausibleWeb.Api.StatsController do
end
defp plot_timeseries(timeseries, metric) do
Enum.map(timeseries, fn row -> row[metric] || 0 end)
Enum.map(timeseries, fn row ->
case row[metric] do
nil -> 0
%Money{} = money -> Decimal.to_float(money.amount)
value -> value
end
end)
end
defp label_timeseries(main_result, nil) do

View File

@ -929,4 +929,168 @@ defmodule PlausibleWeb.Api.StatsController.MainGraphTest do
assert 0 == Enum.sum(comparison_plot)
end
end
describe "GET /api/stats/main-graph - total_revenue plot" do
setup [:create_user, :log_in, :create_new_site, :add_imported_data]
test "plots total_revenue for a month", %{conn: conn, site: site} do
insert(:goal, site: site, event_name: "Payment", currency: "USD")
populate_stats(site, [
build(:event,
name: "Payment",
revenue_reporting_amount: Decimal.new("13.29"),
revenue_reporting_currency: "USD",
timestamp: ~N[2021-01-01 00:00:00]
),
build(:event,
name: "Payment",
revenue_reporting_amount: Decimal.new("19.90"),
revenue_reporting_currency: "USD",
timestamp: ~N[2021-01-05 00:00:00]
),
build(:event,
name: "Payment",
revenue_reporting_amount: Decimal.new("10.31"),
revenue_reporting_currency: "USD",
timestamp: ~N[2021-01-31 00:00:00]
),
build(:event,
name: "Payment",
revenue_reporting_amount: Decimal.new("20.0"),
revenue_reporting_currency: "USD",
timestamp: ~N[2021-01-31 00:00:00]
)
])
filters = Jason.encode!(%{goal: "Payment"})
conn =
get(
conn,
"/api/stats/#{site.domain}/main-graph?period=month&date=2021-01-01&metric=total_revenue&filters=#{filters}"
)
assert %{"plot" => plot} = json_response(conn, 200)
assert plot == [
13.29,
0.0,
0.0,
0.0,
19.9,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
30.31
]
end
end
describe "GET /api/stats/main-graph - average_revenue plot" do
setup [:create_user, :log_in, :create_new_site, :add_imported_data]
test "plots total_revenue for a month", %{conn: conn, site: site} do
insert(:goal, site: site, event_name: "Payment", currency: "USD")
populate_stats(site, [
build(:event,
name: "Payment",
revenue_reporting_amount: Decimal.new("13.29"),
revenue_reporting_currency: "USD",
timestamp: ~N[2021-01-01 00:00:00]
),
build(:event,
name: "Payment",
revenue_reporting_amount: Decimal.new("50.50"),
revenue_reporting_currency: "USD",
timestamp: ~N[2021-01-01 00:00:00]
),
build(:event,
name: "Payment",
revenue_reporting_amount: Decimal.new("19.90"),
revenue_reporting_currency: "USD",
timestamp: ~N[2021-01-05 00:00:00]
),
build(:event,
name: "Payment",
revenue_reporting_amount: Decimal.new("10.31"),
revenue_reporting_currency: "USD",
timestamp: ~N[2021-01-31 00:00:00]
),
build(:event,
name: "Payment",
revenue_reporting_amount: Decimal.new("20.0"),
revenue_reporting_currency: "USD",
timestamp: ~N[2021-01-31 00:00:00]
)
])
filters = Jason.encode!(%{goal: "Payment"})
conn =
get(
conn,
"/api/stats/#{site.domain}/main-graph?period=month&date=2021-01-01&metric=average_revenue&filters=#{filters}"
)
assert %{"plot" => plot} = json_response(conn, 200)
assert plot == [
31.895,
0.0,
0.0,
0.0,
19.9,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
15.155
]
end
end
end