mirror of
https://github.com/plausible/analytics.git
synced 2024-12-23 09:33:19 +03:00
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:
parent
36bfdb35f5
commit
fd01a67a5f
@ -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 = {
|
||||
|
@ -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
|
||||
|
@ -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(%{})
|
||||
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
Loading…
Reference in New Issue
Block a user