APIv2: Revenue metrics (#4659)

* WIP: Start refactoring revenue metrics

* Hacks to make things work

* Remove old revenue code, remove revenue metrics if needed

* Update query_optimizer docs

* Minor fixes

* Add tests around average/total revenue when non-revenue goal filtering going on

* Optimize, calculate filters as expected (OR-ing clauses)

* Revenue: Handle cases where revenue metrics should not be returned or nil

* Expose revenue metrics in internal schema, add tests

* Docstring

* Remove TODO

* Typegen

* Solve warnings

* Remove nesting

* ce_test fix

* Tag tests as ee_only

* Fix: When filtering by revenue goal and no conversions, return 0.0 instead of nil

* More straight-forward preloading logic
This commit is contained in:
Karl-Aksel Puulmann 2024-10-09 13:18:48 +03:00 committed by GitHub
parent 5fec52ab36
commit 141eea88ff
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 585 additions and 286 deletions

View File

@ -6,7 +6,6 @@
*/ */
export type Metric = export type Metric =
| "time_on_page"
| "visitors" | "visitors"
| "visits" | "visits"
| "pageviews" | "pageviews"
@ -16,7 +15,10 @@ export type Metric =
| "events" | "events"
| "percentage" | "percentage"
| "conversion_rate" | "conversion_rate"
| "group_conversion_rate"; | "group_conversion_rate"
| "time_on_page"
| "total_revenue"
| "average_revenue";
export type DateRangeShorthand = "30m" | "realtime" | "all" | "day" | "7d" | "30d" | "month" | "6mo" | "12mo" | "year"; export type DateRangeShorthand = "30m" | "realtime" | "all" | "day" | "7d" | "30d" | "month" | "6mo" | "12mo" | "year";
/** /**
* @minItems 2 * @minItems 2

View File

@ -2,9 +2,6 @@ defmodule Plausible.Stats.Goal.Revenue do
@moduledoc """ @moduledoc """
Revenue specific functions for the stats scope Revenue specific functions for the stats scope
""" """
import Ecto.Query
alias Plausible.Stats.Filters
@revenue_metrics [:average_revenue, :total_revenue] @revenue_metrics [:average_revenue, :total_revenue]
@ -12,73 +9,65 @@ defmodule Plausible.Stats.Goal.Revenue do
@revenue_metrics @revenue_metrics
end end
@spec get_revenue_tracking_currency(Plausible.Site.t(), Plausible.Stats.Query.t(), [atom()]) ::
{atom() | nil, [atom()]}
@doc """ @doc """
Returns the common currency for the goal filters in a query. If there are no Preloads revenue currencies for a query.
goal filters, multiple currencies or the site owner does not have access to
revenue goals, `nil` is returned and revenue metrics are dropped.
Aggregating revenue data works only for same currency goals. If the query is Assumptions and business logic:
filtered by goals with different currencies, for example, one USD and other 1. Goals are already filtered according to query filters and dimensions
EUR, revenue metrics are dropped. 2. If there's a single currency involved, return map containing the default
3. If there's a breakdown by event:goal we return all the relevant currencies as a map
4. If filtering by multiple different currencies without event:goal breakdown empty map is returned
5. If user has no access or preloading is not needed, empty map is returned
The resulting data structure is attached to a `Query` and used below in `format_revenue_metric/3`.
""" """
def get_revenue_tracking_currency(site, query, metrics) do def preload_revenue_currencies(site, goals, metrics, dimensions) do
goal_filters = if requested?(metrics) and length(goals) > 0 and available?(site) do
case Filters.get_toplevel_filter(query, "event:goal") do goal_currency_map =
[:is, "event:goal", list] -> list goals
_ -> [] |> Map.new(fn goal -> {Plausible.Goal.display_name(goal), goal.currency} end)
|> Map.reject(fn {_goal, currency} -> is_nil(currency) end)
currencies = goal_currency_map |> Map.values() |> Enum.uniq()
goal_dimension? = "event:goal" in dimensions
case {currencies, goal_dimension?} do
{[currency], false} -> %{default: currency}
{_, true} -> goal_currency_map
_ -> %{}
end end
requested_revenue_metrics? = Enum.any?(metrics, &(&1 in @revenue_metrics))
filtering_by_goal? = Enum.any?(goal_filters)
revenue_goals_available? = fn ->
site = Plausible.Repo.preload(site, :owner)
Plausible.Billing.Feature.RevenueGoals.check_availability(site.owner) == :ok
end
if requested_revenue_metrics? && filtering_by_goal? && revenue_goals_available?.() do
revenue_goals_currencies =
Plausible.Repo.all(
from rg in Ecto.assoc(site, :revenue_goals),
where: rg.display_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 else
{nil, metrics -- @revenue_metrics} %{}
end end
end end
def cast_revenue_metrics_to_money([%{goal: _goal} | _rest] = results, revenue_goals) def format_revenue_metric(value, query, dimension_values) do
when is_list(revenue_goals) do currency =
for result <- results do query.revenue_currencies[:default] ||
if matching_goal = Enum.find(revenue_goals, &(&1.display_name == result.goal)) do get_goal_dimension_revenue_currency(query, dimension_values)
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 if currency do
for {metric, value} <- results, into: %{} do
{metric, maybe_cast_metric_to_money(value, metric, currency)}
end
end
def cast_revenue_metrics_to_money(results, _), do: results
def maybe_cast_metric_to_money(value, metric, currency) do
if currency && metric in @revenue_metrics do
Money.new!(value || 0, currency) Money.new!(value || 0, currency)
else else
value value
end end
end end
def available?(site) do
site = Plausible.Repo.preload(site, :owner)
Plausible.Billing.Feature.RevenueGoals.check_availability(site.owner) == :ok
end
# :NOTE: Legacy queries don't have metrics associated with them so work around the issue by assuming
# revenue metric was requested.
def requested?([]), do: true
def requested?(metrics), do: Enum.any?(metrics, &(&1 in @revenue_metrics))
defp get_goal_dimension_revenue_currency(query, dimension_values) do
Enum.zip(query.dimensions, dimension_values)
|> Enum.find_value(fn
{"event:goal", goal_label} -> Map.get(query.revenue_currencies, goal_label)
_ -> nil
end)
end
end end

View File

@ -34,8 +34,30 @@ defmodule Plausible.Goals.Filters do
end) end)
end end
def preload_needed_goals(site, filters) do
goals = Plausible.Goals.for_site(site)
Enum.reduce(filters, goals, fn
[operation, "event:goal", clauses], goals ->
goals_matching_any_clause(goals, operation, clauses)
_filter, goals ->
goals
end)
end
def filter_preloaded(preloaded_goals, operation, clause) when operation in [:is, :contains] do def filter_preloaded(preloaded_goals, operation, clause) when operation in [:is, :contains] do
Enum.filter(preloaded_goals, fn goal -> Enum.filter(preloaded_goals, fn goal -> matches?(goal, operation, clause) end)
end
defp goals_matching_any_clause(goals, operation, clauses) do
goals
|> Enum.filter(fn goal ->
Enum.any?(clauses, fn clause -> matches?(goal, operation, clause) end)
end)
end
defp matches?(goal, operation, clause) do
case operation do case operation do
:is -> :is ->
Plausible.Goal.display_name(goal) == clause Plausible.Goal.display_name(goal) == clause
@ -43,7 +65,6 @@ defmodule Plausible.Goals.Filters do
:contains -> :contains ->
String.contains?(Plausible.Goal.display_name(goal), clause) String.contains?(Plausible.Goal.display_name(goal), clause)
end end
end)
end end
defp build_condition(filtered_goals, imported?) do defp build_condition(filtered_goals, imported?) do

View File

@ -6,20 +6,16 @@ defmodule Plausible.Stats.Aggregate do
""" """
use Plausible.ClickhouseRepo use Plausible.ClickhouseRepo
use Plausible alias Plausible.Stats.{Query, QueryRunner, QueryOptimizer}
alias Plausible.Stats.{Query, QueryRunner}
def aggregate(site, query, metrics) do def aggregate(site, query, metrics) do
{currency, metrics} =
on_ee do
Plausible.Stats.Goal.Revenue.get_revenue_tracking_currency(site, query, metrics)
else
{nil, metrics}
end
Query.trace(query, metrics) Query.trace(query, metrics)
query = %Query{query | metrics: metrics} query =
query
|> Query.set(metrics: metrics, remove_unavailable_revenue_metrics: true)
|> QueryOptimizer.optimize()
query_result = QueryRunner.run(site, query) query_result = QueryRunner.run(site, query)
[entry] = query_result.results [entry] = query_result.results
@ -29,7 +25,7 @@ defmodule Plausible.Stats.Aggregate do
|> Enum.map(fn {metric, index} -> |> Enum.map(fn {metric, index} ->
{ {
metric, metric,
metric_map(entry, index, metric, currency) metric_map(entry, index, metric)
} }
end) end)
|> Enum.into(%{}) |> Enum.into(%{})
@ -38,27 +34,25 @@ defmodule Plausible.Stats.Aggregate do
def metric_map( def metric_map(
%{metrics: metrics, comparison: %{metrics: comparison_metrics, change: change}}, %{metrics: metrics, comparison: %{metrics: comparison_metrics, change: change}},
index, index,
metric, metric
currency
) do ) do
%{ %{
value: get_value(metrics, index, metric, currency), value: get_value(metrics, index, metric),
comparison_value: get_value(comparison_metrics, index, metric, currency), comparison_value: get_value(comparison_metrics, index, metric),
change: Enum.at(change, index) change: Enum.at(change, index)
} }
end end
def metric_map(%{metrics: metrics}, index, metric, currency) do def metric_map(%{metrics: metrics}, index, metric) do
%{ %{
value: get_value(metrics, index, metric, currency) value: get_value(metrics, index, metric)
} }
end end
def get_value(metric_list, index, metric, currency) do def get_value(metric_list, index, metric) do
metric_list metric_list
|> Enum.at(index) |> Enum.at(index)
|> maybe_round_value(metric) |> maybe_round_value(metric)
|> maybe_cast_metric_to_money(metric, currency)
end end
@metrics_to_round [:bounce_rate, :time_on_page, :visit_duration, :sample_percent] @metrics_to_round [:bounce_rate, :time_on_page, :visit_duration, :sample_percent]
@ -66,12 +60,4 @@ defmodule Plausible.Stats.Aggregate do
defp maybe_round_value(nil, _metric), do: nil defp maybe_round_value(nil, _metric), do: nil
defp maybe_round_value(value, metric) when metric in @metrics_to_round, do: round(value) defp maybe_round_value(value, metric) when metric in @metrics_to_round, do: round(value)
defp maybe_round_value(value, _metric), do: value defp maybe_round_value(value, _metric), do: value
on_ee do
defp maybe_cast_metric_to_money(value, metric, currency) do
Plausible.Stats.Goal.Revenue.maybe_cast_metric_to_money(value, metric, currency)
end
else
defp maybe_cast_metric_to_money(value, _metric, _currency), do: value
end
end end

View File

@ -6,10 +6,9 @@ defmodule Plausible.Stats.Breakdown do
""" """
use Plausible.ClickhouseRepo use Plausible.ClickhouseRepo
use Plausible
use Plausible.Stats.SQL.Fragments use Plausible.Stats.SQL.Fragments
alias Plausible.Stats.{Query, QueryOptimizer, QueryRunner} alias Plausible.Stats.{Query, QueryRunner, QueryOptimizer}
def breakdown( def breakdown(
site, site,
@ -22,8 +21,8 @@ defmodule Plausible.Stats.Breakdown do
transformed_order_by = transform_order_by(order_by || [], dimension) transformed_order_by = transform_order_by(order_by || [], dimension)
query_with_metrics = query_with_metrics =
Query.set( query
query, |> Query.set(
metrics: transformed_metrics, metrics: transformed_metrics,
# Concat client requested order with default order, overriding only if client explicitly requests it # Concat client requested order with default order, overriding only if client explicitly requests it
order_by: order_by:
@ -34,13 +33,13 @@ defmodule Plausible.Stats.Breakdown do
pagination: %{limit: limit, offset: (page - 1) * limit}, pagination: %{limit: limit, offset: (page - 1) * limit},
v2: true, v2: true,
# Allow pageview and event metrics to be queried off of sessions table # Allow pageview and event metrics to be queried off of sessions table
legacy_breakdown: true legacy_breakdown: true,
remove_unavailable_revenue_metrics: true
) )
|> QueryOptimizer.optimize() |> QueryOptimizer.optimize()
QueryRunner.run(site, query_with_metrics) QueryRunner.run(site, query_with_metrics)
|> build_breakdown_result(query_with_metrics, metrics) |> build_breakdown_result(query_with_metrics, metrics)
|> update_currency_metrics(site, query_with_metrics)
end end
defp build_breakdown_result(query_result, query, metrics) do defp build_breakdown_result(query_result, query, metrics) do
@ -122,41 +121,4 @@ defmodule Plausible.Stats.Breakdown do
end end
defp dimension_filters(_), do: [] defp dimension_filters(_), do: []
on_ee do
defp update_currency_metrics(results, site, %Query{dimensions: ["event:goal"]}) do
site = Plausible.Repo.preload(site, :goals)
{event_goals, _pageview_goals} = Enum.split_with(site.goals, & &1.event_name)
revenue_goals = Enum.filter(event_goals, &Plausible.Goal.Revenue.revenue?/1)
if length(revenue_goals) > 0 and Plausible.Billing.Feature.RevenueGoals.enabled?(site) do
Plausible.Stats.Goal.Revenue.cast_revenue_metrics_to_money(results, revenue_goals)
else
remove_revenue_metrics(results)
end
end
defp update_currency_metrics(results, site, query) do
{currency, _metrics} =
Plausible.Stats.Goal.Revenue.get_revenue_tracking_currency(site, query, query.metrics)
if currency do
results
|> Enum.map(&Plausible.Stats.Goal.Revenue.cast_revenue_metrics_to_money(&1, currency))
else
remove_revenue_metrics(results)
end
end
else
defp update_currency_metrics(results, _site, _query), do: remove_revenue_metrics(results)
end
defp remove_revenue_metrics(results) do
Enum.map(results, fn map ->
map
|> Map.delete(:total_revenue)
|> Map.delete(:average_revenue)
end)
end
end end

View File

@ -1,6 +1,8 @@
defmodule Plausible.Stats.Filters.QueryParser do defmodule Plausible.Stats.Filters.QueryParser do
@moduledoc false @moduledoc false
use Plausible
alias Plausible.Stats.{TableDecider, Filters, Metrics, DateTimeRange, JSONSchema} alias Plausible.Stats.{TableDecider, Filters, Metrics, DateTimeRange, JSONSchema}
@default_include %{ @default_include %{
@ -36,7 +38,8 @@ defmodule Plausible.Stats.Filters.QueryParser do
{:ok, order_by} <- parse_order_by(Map.get(params, "order_by")), {:ok, order_by} <- parse_order_by(Map.get(params, "order_by")),
{:ok, include} <- parse_include(site, Map.get(params, "include", %{})), {:ok, include} <- parse_include(site, Map.get(params, "include", %{})),
{:ok, pagination} <- parse_pagination(Map.get(params, "pagination", %{})), {:ok, pagination} <- parse_pagination(Map.get(params, "pagination", %{})),
preloaded_goals <- preload_goals_if_needed(site, filters, dimensions), {preloaded_goals, revenue_currencies} <-
preload_needed_goals(site, metrics, filters, dimensions),
query = %{ query = %{
metrics: metrics, metrics: metrics,
filters: filters, filters: filters,
@ -44,15 +47,17 @@ defmodule Plausible.Stats.Filters.QueryParser do
dimensions: dimensions, dimensions: dimensions,
order_by: order_by, order_by: order_by,
timezone: site.timezone, timezone: site.timezone,
preloaded_goals: preloaded_goals,
include: include, include: include,
pagination: pagination pagination: pagination,
preloaded_goals: preloaded_goals,
revenue_currencies: revenue_currencies
}, },
:ok <- validate_order_by(query), :ok <- validate_order_by(query),
:ok <- validate_custom_props_access(site, query), :ok <- validate_custom_props_access(site, query),
:ok <- validate_toplevel_only_filter_dimension(query), :ok <- validate_toplevel_only_filter_dimension(query),
:ok <- validate_special_metrics_filters(query), :ok <- validate_special_metrics_filters(query),
:ok <- validate_filtered_goals_exist(query), :ok <- validate_filtered_goals_exist(query),
:ok <- validate_revenue_metrics_access(site, query),
:ok <- validate_metrics(query), :ok <- validate_metrics(query),
:ok <- validate_include(query) do :ok <- validate_include(query) do
{:ok, query} {:ok, query}
@ -405,14 +410,19 @@ defmodule Plausible.Stats.Filters.QueryParser do
end end
end end
def preload_goals_if_needed(site, filters, dimensions) do def preload_needed_goals(site, metrics, filters, dimensions) do
goal_filters? = goal_filters? =
Enum.any?(filters, fn [_, filter_key | _rest] -> filter_key == "event:goal" end) Enum.any?(filters, fn [_, filter_key | _rest] -> filter_key == "event:goal" end)
if goal_filters? or Enum.member?(dimensions, "event:goal") do if goal_filters? or Enum.member?(dimensions, "event:goal") do
Plausible.Goals.for_site(site) goals = Plausible.Goals.Filters.preload_needed_goals(site, filters)
{
goals,
preload_revenue_currencies(site, goals, metrics, dimensions)
}
else else
[] {[], %{}}
end end
end end
@ -460,6 +470,25 @@ defmodule Plausible.Stats.Filters.QueryParser do
end end
end end
on_ee do
alias Plausible.Stats.Goal.Revenue
defdelegate preload_revenue_currencies(site, preloaded_goals, metrics, dimensions),
to: Plausible.Stats.Goal.Revenue
defp validate_revenue_metrics_access(site, query) do
if Revenue.requested?(query.metrics) and not Revenue.available?(site) do
{:error, "The owner of this site does not have access to the revenue metrics feature."}
else
:ok
end
end
else
defp preload_revenue_currencies(_site, _preloaded_goals, _metrics, _dimensions), do: %{}
defp validate_revenue_metrics_access(_site, _query), do: :ok
end
defp validate_goal_filter(clause, configured_goals) do defp validate_goal_filter(clause, configured_goals) do
configured_goal_names = configured_goal_names =
Enum.map(configured_goals, fn goal -> Plausible.Goal.display_name(goal) end) Enum.map(configured_goals, fn goal -> Plausible.Goal.display_name(goal) end)

View File

@ -32,14 +32,15 @@ defmodule Plausible.Stats.Legacy.QueryBuilder do
end end
defp put_preloaded_goals(query, site) do defp put_preloaded_goals(query, site) do
goals = {preloaded_goals, revenue_currencies} =
Plausible.Stats.Filters.QueryParser.preload_goals_if_needed( Plausible.Stats.Filters.QueryParser.preload_needed_goals(
site, site,
query.metrics,
query.filters, query.filters,
query.dimensions query.dimensions
) )
struct!(query, preloaded_goals: goals) struct!(query, preloaded_goals: preloaded_goals, revenue_currencies: revenue_currencies)
end end
defp put_period(%Query{now: now} = query, _site, %{"period" => period}) defp put_period(%Query{now: now} = query, _site, %{"period" => period})

View File

@ -17,7 +17,9 @@ defmodule Plausible.Stats.Query do
timezone: nil, timezone: nil,
v2: false, v2: false,
legacy_breakdown: false, legacy_breakdown: false,
remove_unavailable_revenue_metrics: false,
preloaded_goals: [], preloaded_goals: [],
revenue_currencies: %{},
include: Plausible.Stats.Filters.QueryParser.default_include(), include: Plausible.Stats.Filters.QueryParser.default_include(),
debug_metadata: %{}, debug_metadata: %{},
pagination: nil pagination: nil

View File

@ -13,6 +13,8 @@ defmodule Plausible.Stats.QueryOptimizer do
1. Figures out what the right granularity to group by time is 1. Figures out what the right granularity to group by time is
2. Adds a missing order_by clause to a query 2. Adds a missing order_by clause to a query
3. Updating "time" dimension in order_by to the right granularity 3. Updating "time" dimension in order_by to the right granularity
4. Updates event:hostname filters to also apply on visit level for sane results.
5. Removes revenue metrics from dashboard queries if not requested, present or unavailable for the site.
""" """
def optimize(query) do def optimize(query) do
@ -47,7 +49,8 @@ defmodule Plausible.Stats.QueryOptimizer do
&update_group_by_time/1, &update_group_by_time/1,
&add_missing_order_by/1, &add_missing_order_by/1,
&update_time_in_order_by/1, &update_time_in_order_by/1,
&extend_hostname_filters_to_visit/1 &extend_hostname_filters_to_visit/1,
&remove_revenue_metrics_if_unavailable/1
] ]
end end
@ -172,4 +175,16 @@ defmodule Plausible.Stats.QueryOptimizer do
pagination: nil pagination: nil
) )
end end
on_ee do
defp remove_revenue_metrics_if_unavailable(query) do
if query.remove_unavailable_revenue_metrics and map_size(query.revenue_currencies) == 0 do
Query.set(query, metrics: query.metrics -- Plausible.Stats.Goal.Revenue.revenue_metrics())
else
query
end
end
else
defp remove_revenue_metrics_if_unavailable(query), do: query
end
end end

View File

@ -9,6 +9,7 @@ defmodule Plausible.Stats.QueryRunner do
3. Passing total_rows from clickhouse to QueryResult meta 3. Passing total_rows from clickhouse to QueryResult meta
""" """
use Plausible
use Plausible.ClickhouseRepo use Plausible.ClickhouseRepo
alias Plausible.Stats.{ alias Plausible.Stats.{
@ -141,7 +142,7 @@ defmodule Plausible.Stats.QueryRunner do
%{ %{
dimensions: dimensions, dimensions: dimensions,
metrics: Enum.map(query.metrics, &get_metric(entry, &1, dimensions, time_on_page)) metrics: Enum.map(query.metrics, &get_metric(entry, &1, dimensions, query, time_on_page))
} }
end) end)
end end
@ -168,10 +169,19 @@ defmodule Plausible.Stats.QueryRunner do
Map.get(entry, Util.shortname(query, dimension)) Map.get(entry, Util.shortname(query, dimension))
end end
defp get_metric(_entry, :time_on_page, dimensions, time_on_page), on_ee do
defp get_metric(entry, metric, dimensions, query, _time_on_page)
when metric in [:average_revenue, :total_revenue] do
value = Map.get(entry, metric)
Plausible.Stats.Goal.Revenue.format_revenue_metric(value, query, dimensions)
end
end
defp get_metric(_entry, :time_on_page, dimensions, _query, time_on_page),
do: Map.get(time_on_page, dimensions) do: Map.get(time_on_page, dimensions)
defp get_metric(entry, metric, _dimensions, _time_on_page), do: Map.get(entry, metric) defp get_metric(entry, metric, _dimensions, _query, _time_on_page), do: Map.get(entry, metric)
# Special case: If comparison and single time dimension, add 0 rows - otherwise # Special case: If comparison and single time dimension, add 0 rows - otherwise
# comparisons would not be shown for timeseries with 0 values. # comparisons would not be shown for timeseries with 0 values.

View File

@ -5,9 +5,8 @@ defmodule Plausible.Stats.Timeseries do
Avoid adding new logic here - update QueryBuilder etc instead. Avoid adding new logic here - update QueryBuilder etc instead.
""" """
use Plausible
use Plausible.ClickhouseRepo use Plausible.ClickhouseRepo
alias Plausible.Stats.{Comparisons, Query, QueryRunner, Time} alias Plausible.Stats.{Comparisons, Query, QueryRunner, QueryOptimizer, Time}
@time_dimension %{ @time_dimension %{
"month" => "time:month", "month" => "time:month",
@ -18,21 +17,16 @@ defmodule Plausible.Stats.Timeseries do
} }
def timeseries(site, query, metrics) do def timeseries(site, query, metrics) do
{currency, metrics} =
on_ee do
Plausible.Stats.Goal.Revenue.get_revenue_tracking_currency(site, query, metrics)
else
{nil, metrics}
end
query = query =
Query.set( query
query, |> Query.set(
metrics: transform_metrics(metrics, %{conversion_rate: :group_conversion_rate}), metrics: transform_metrics(metrics, %{conversion_rate: :group_conversion_rate}),
dimensions: [time_dimension(query)], dimensions: [time_dimension(query)],
order_by: [{time_dimension(query), :asc}], order_by: [{time_dimension(query), :asc}],
v2: true v2: true,
remove_unavailable_revenue_metrics: true
) )
|> QueryOptimizer.optimize()
comparison_query = comparison_query =
if(query.include.comparisons, if(query.include.comparisons,
@ -43,8 +37,8 @@ defmodule Plausible.Stats.Timeseries do
query_result = QueryRunner.run(site, query) query_result = QueryRunner.run(site, query)
{ {
build_result(query_result, query, currency, fn entry -> entry end), build_result(query_result, query, fn entry -> entry end),
build_result(query_result, comparison_query, currency, fn entry -> entry.comparison end), build_result(query_result, comparison_query, fn entry -> entry.comparison end),
query_result.meta query_result.meta
} }
end end
@ -53,7 +47,7 @@ defmodule Plausible.Stats.Timeseries do
# Given a query result, build a legacy timeseries result # Given a query result, build a legacy timeseries result
# Format is %{ date => %{ date: date_string, [metric] => value } } with a bunch of special cases for the UI # Format is %{ date => %{ date: date_string, [metric] => value } } with a bunch of special cases for the UI
defp build_result(query_result, %Query{} = query, currency, extract_entry) do defp build_result(query_result, %Query{} = query, extract_entry) do
query_result.results query_result.results
|> Enum.map(&extract_entry.(&1)) |> Enum.map(&extract_entry.(&1))
|> Enum.map(fn %{dimensions: [time_dimension_value], metrics: metrics} -> |> Enum.map(fn %{dimensions: [time_dimension_value], metrics: metrics} ->
@ -65,12 +59,12 @@ defmodule Plausible.Stats.Timeseries do
} }
end) end)
|> Map.new() |> Map.new()
|> add_labels(query, currency) |> add_labels(query)
end end
defp build_result(_, _, _, _), do: nil defp build_result(_, _, _), do: nil
defp add_labels(results_map, query, currency) do defp add_labels(results_map, query) do
query query
|> Time.time_labels() |> Time.time_labels()
|> Enum.map(fn key -> |> Enum.map(fn key ->
@ -79,7 +73,6 @@ defmodule Plausible.Stats.Timeseries do
key, key,
empty_row(key, query.metrics) empty_row(key, query.metrics)
) )
|> cast_revenue_metrics_to_money(currency)
end) end)
|> transform_realtime_labels(query) |> transform_realtime_labels(query)
|> transform_keys(%{group_conversion_rate: :conversion_rate}) |> transform_keys(%{group_conversion_rate: :conversion_rate})
@ -122,12 +115,4 @@ defmodule Plausible.Stats.Timeseries do
end end
defp transform_realtime_labels(results, _query), do: results defp transform_realtime_labels(results, _query), do: results
on_ee do
defp cast_revenue_metrics_to_money(results, revenue_goals) do
Plausible.Stats.Goal.Revenue.cast_revenue_metrics_to_money(results, revenue_goals)
end
else
defp cast_revenue_metrics_to_money(results, _revenue_goals), do: results
end
end end

View File

@ -218,10 +218,6 @@
}, },
"metric": { "metric": {
"oneOf": [ "oneOf": [
{
"const": "time_on_page",
"$comment": "only :internal"
},
{ {
"const": "visitors", "const": "visitors",
"description": "Metric counting the number of unique visitors" "description": "Metric counting the number of unique visitors"
@ -261,6 +257,18 @@
{ {
"const": "group_conversion_rate", "const": "group_conversion_rate",
"markdownDescription": "The percentage of visitors who completed the goal with the same dimension. Requires: dimension list passed, an `event:goal` filter or `event:goal` dimension" "markdownDescription": "The percentage of visitors who completed the goal with the same dimension. Requires: dimension list passed, an `event:goal` filter or `event:goal` dimension"
},
{
"const": "time_on_page",
"$comment": "only :internal"
},
{
"const": "total_revenue",
"$comment": "only :internal"
},
{
"const": "average_revenue",
"$comment": "only :internal"
} }
] ]
}, },

View File

@ -47,7 +47,12 @@ defmodule Plausible.Stats.Filters.QueryParserTest do
def check_success(params, site, expected_result, schema_type \\ :public) do def check_success(params, site, expected_result, schema_type \\ :public) do
assert {:ok, result} = parse(site, schema_type, params, @now) assert {:ok, result} = parse(site, schema_type, params, @now)
return_value = Map.take(result, [:preloaded_goals, :revenue_currencies])
result = Map.drop(result, [:preloaded_goals, :revenue_currencies])
assert result == expected_result assert result == expected_result
return_value
end end
def check_error(params, site, expected_error_message, schema_type \\ :public) do def check_error(params, site, expected_error_message, schema_type \\ :public) do
@ -69,13 +74,22 @@ defmodule Plausible.Stats.Filters.QueryParserTest do
order_by: nil, order_by: nil,
timezone: site.timezone, timezone: site.timezone,
include: %{imports: false, time_labels: false, total_rows: false, comparisons: nil}, include: %{imports: false, time_labels: false, total_rows: false, comparisons: nil},
pagination: %{limit: 10_000, offset: 0}, pagination: %{limit: 10_000, offset: 0}
preloaded_goals: []
} }
check_success(params, site, expected_parsed, schema_type) check_success(params, site, expected_parsed, schema_type)
end end
def check_goals(actual, opts) do
preloaded_goal_names =
actual[:preloaded_goals]
|> Enum.map(& &1.display_name)
|> Enum.sort()
assert preloaded_goal_names == Keyword.get(opts, :preloaded_goals)
assert actual[:revenue_currencies] == Keyword.get(opts, :revenue_currencies)
end
test "parsing empty map fails", %{site: site} do test "parsing empty map fails", %{site: site} do
%{} %{}
|> check_error(site, "#: Required properties site_id, metrics, date_range were not present.") |> check_error(site, "#: Required properties site_id, metrics, date_range were not present.")
@ -92,8 +106,7 @@ defmodule Plausible.Stats.Filters.QueryParserTest do
order_by: nil, order_by: nil,
timezone: site.timezone, timezone: site.timezone,
include: %{imports: false, time_labels: false, total_rows: false, comparisons: nil}, include: %{imports: false, time_labels: false, total_rows: false, comparisons: nil},
pagination: %{limit: 10_000, offset: 0}, pagination: %{limit: 10_000, offset: 0}
preloaded_goals: []
}) })
end end
@ -134,8 +147,7 @@ defmodule Plausible.Stats.Filters.QueryParserTest do
order_by: nil, order_by: nil,
timezone: site.timezone, timezone: site.timezone,
include: %{imports: false, time_labels: false, total_rows: false, comparisons: nil}, include: %{imports: false, time_labels: false, total_rows: false, comparisons: nil},
pagination: %{limit: 10_000, offset: 0}, pagination: %{limit: 10_000, offset: 0}
preloaded_goals: []
}, },
:internal :internal
) )
@ -201,8 +213,7 @@ defmodule Plausible.Stats.Filters.QueryParserTest do
order_by: nil, order_by: nil,
timezone: site.timezone, timezone: site.timezone,
include: %{imports: false, time_labels: false, total_rows: false, comparisons: nil}, include: %{imports: false, time_labels: false, total_rows: false, comparisons: nil},
pagination: %{limit: 10_000, offset: 0}, pagination: %{limit: 10_000, offset: 0}
preloaded_goals: []
}, },
:internal :internal
) )
@ -327,8 +338,7 @@ defmodule Plausible.Stats.Filters.QueryParserTest do
order_by: nil, order_by: nil,
timezone: site.timezone, timezone: site.timezone,
include: %{imports: false, time_labels: false, total_rows: false, comparisons: nil}, include: %{imports: false, time_labels: false, total_rows: false, comparisons: nil},
pagination: %{limit: 10_000, offset: 0}, pagination: %{limit: 10_000, offset: 0}
preloaded_goals: []
}) })
end end
@ -353,8 +363,7 @@ defmodule Plausible.Stats.Filters.QueryParserTest do
order_by: nil, order_by: nil,
timezone: site.timezone, timezone: site.timezone,
include: %{imports: false, time_labels: false, total_rows: false, comparisons: nil}, include: %{imports: false, time_labels: false, total_rows: false, comparisons: nil},
pagination: %{limit: 10_000, offset: 0}, pagination: %{limit: 10_000, offset: 0}
preloaded_goals: []
}) })
end end
end end
@ -380,8 +389,7 @@ defmodule Plausible.Stats.Filters.QueryParserTest do
order_by: nil, order_by: nil,
timezone: site.timezone, timezone: site.timezone,
include: %{imports: false, time_labels: false, total_rows: false, comparisons: nil}, include: %{imports: false, time_labels: false, total_rows: false, comparisons: nil},
pagination: %{limit: 10_000, offset: 0}, pagination: %{limit: 10_000, offset: 0}
preloaded_goals: []
}) })
end end
end end
@ -447,8 +455,7 @@ defmodule Plausible.Stats.Filters.QueryParserTest do
order_by: nil, order_by: nil,
timezone: site.timezone, timezone: site.timezone,
include: %{imports: false, time_labels: false, total_rows: false, comparisons: nil}, include: %{imports: false, time_labels: false, total_rows: false, comparisons: nil},
pagination: %{limit: 10_000, offset: 0}, pagination: %{limit: 10_000, offset: 0}
preloaded_goals: []
}) })
%{ %{
@ -467,8 +474,7 @@ defmodule Plausible.Stats.Filters.QueryParserTest do
order_by: nil, order_by: nil,
timezone: site.timezone, timezone: site.timezone,
include: %{imports: false, time_labels: false, total_rows: false, comparisons: nil}, include: %{imports: false, time_labels: false, total_rows: false, comparisons: nil},
pagination: %{limit: 10_000, offset: 0}, pagination: %{limit: 10_000, offset: 0}
preloaded_goals: []
}) })
end end
@ -528,8 +534,7 @@ defmodule Plausible.Stats.Filters.QueryParserTest do
order_by: nil, order_by: nil,
timezone: site.timezone, timezone: site.timezone,
include: %{imports: false, time_labels: false, total_rows: false, comparisons: nil}, include: %{imports: false, time_labels: false, total_rows: false, comparisons: nil},
pagination: %{limit: 10_000, offset: 0}, pagination: %{limit: 10_000, offset: 0}
preloaded_goals: []
}) })
end end
@ -576,8 +581,7 @@ defmodule Plausible.Stats.Filters.QueryParserTest do
order_by: nil, order_by: nil,
timezone: site.timezone, timezone: site.timezone,
include: %{imports: false, time_labels: false, total_rows: false, comparisons: nil}, include: %{imports: false, time_labels: false, total_rows: false, comparisons: nil},
pagination: %{limit: 10_000, offset: 0}, pagination: %{limit: 10_000, offset: 0}
preloaded_goals: []
}) })
end end
@ -612,8 +616,7 @@ defmodule Plausible.Stats.Filters.QueryParserTest do
order_by: nil, order_by: nil,
timezone: site.timezone, timezone: site.timezone,
include: %{imports: true, time_labels: true, total_rows: true, comparisons: nil}, include: %{imports: true, time_labels: true, total_rows: true, comparisons: nil},
pagination: %{limit: 10_000, offset: 0}, pagination: %{limit: 10_000, offset: 0}
preloaded_goals: []
}) })
end end
@ -676,8 +679,7 @@ defmodule Plausible.Stats.Filters.QueryParserTest do
time_labels: false, time_labels: false,
total_rows: false total_rows: false
}, },
pagination: %{limit: 10_000, offset: 0}, pagination: %{limit: 10_000, offset: 0}
preloaded_goals: []
}, },
:internal :internal
) )
@ -707,8 +709,7 @@ defmodule Plausible.Stats.Filters.QueryParserTest do
time_labels: false, time_labels: false,
total_rows: false total_rows: false
}, },
pagination: %{limit: 10_000, offset: 0}, pagination: %{limit: 10_000, offset: 0}
preloaded_goals: []
}, },
:internal :internal
) )
@ -741,8 +742,7 @@ defmodule Plausible.Stats.Filters.QueryParserTest do
time_labels: false, time_labels: false,
total_rows: false total_rows: false
}, },
pagination: %{limit: 10_000, offset: 0}, pagination: %{limit: 10_000, offset: 0}
preloaded_goals: []
}, },
:internal :internal
) )
@ -799,8 +799,7 @@ defmodule Plausible.Stats.Filters.QueryParserTest do
order_by: nil, order_by: nil,
timezone: site.timezone, timezone: site.timezone,
include: %{imports: false, time_labels: false, total_rows: false, comparisons: nil}, include: %{imports: false, time_labels: false, total_rows: false, comparisons: nil},
pagination: %{limit: 100, offset: 200}, pagination: %{limit: 100, offset: 200}
preloaded_goals: []
}) })
end end
@ -1125,8 +1124,7 @@ defmodule Plausible.Stats.Filters.QueryParserTest do
order_by: nil, order_by: nil,
timezone: site.timezone, timezone: site.timezone,
include: %{imports: false, time_labels: false, total_rows: false, comparisons: nil}, include: %{imports: false, time_labels: false, total_rows: false, comparisons: nil},
pagination: %{limit: 10_000, offset: 0}, pagination: %{limit: 10_000, offset: 0}
preloaded_goals: []
}) })
end end
end end
@ -1147,8 +1145,7 @@ defmodule Plausible.Stats.Filters.QueryParserTest do
order_by: nil, order_by: nil,
timezone: site.timezone, timezone: site.timezone,
include: %{imports: false, time_labels: false, total_rows: false, comparisons: nil}, include: %{imports: false, time_labels: false, total_rows: false, comparisons: nil},
pagination: %{limit: 10_000, offset: 0}, pagination: %{limit: 10_000, offset: 0}
preloaded_goals: []
}) })
end end
end end
@ -1168,8 +1165,7 @@ defmodule Plausible.Stats.Filters.QueryParserTest do
order_by: nil, order_by: nil,
timezone: site.timezone, timezone: site.timezone,
include: %{imports: false, time_labels: false, total_rows: false, comparisons: nil}, include: %{imports: false, time_labels: false, total_rows: false, comparisons: nil},
pagination: %{limit: 10_000, offset: 0}, pagination: %{limit: 10_000, offset: 0}
preloaded_goals: []
}) })
end end
@ -1230,8 +1226,7 @@ defmodule Plausible.Stats.Filters.QueryParserTest do
order_by: [{:events, :desc}, {:visitors, :asc}], order_by: [{:events, :desc}, {:visitors, :asc}],
timezone: site.timezone, timezone: site.timezone,
include: %{imports: false, time_labels: false, total_rows: false, comparisons: nil}, include: %{imports: false, time_labels: false, total_rows: false, comparisons: nil},
pagination: %{limit: 10_000, offset: 0}, pagination: %{limit: 10_000, offset: 0}
preloaded_goals: []
}) })
end end
@ -1251,8 +1246,7 @@ defmodule Plausible.Stats.Filters.QueryParserTest do
order_by: [{"event:name", :desc}], order_by: [{"event:name", :desc}],
timezone: site.timezone, timezone: site.timezone,
include: %{imports: false, time_labels: false, total_rows: false, comparisons: nil}, include: %{imports: false, time_labels: false, total_rows: false, comparisons: nil},
pagination: %{limit: 10_000, offset: 0}, pagination: %{limit: 10_000, offset: 0}
preloaded_goals: []
}) })
end end
@ -1344,45 +1338,51 @@ defmodule Plausible.Stats.Filters.QueryParserTest do
) )
end end
# test "succeeds with event:goal filter", %{site: site} do test "succeeds with event:goal filter", %{site: site} do
# insert(:goal, %{site: site, event_name: "Signup"}) insert(:goal, %{site: site, event_name: "Signup"})
insert(:goal, %{site: site, event_name: "Purchase", currency: "USD"})
# %{ %{
# "metrics" => ["conversion_rate"], "site_id" => site.domain,
# "date_range" => "all", "metrics" => ["conversion_rate"],
# "filters" => [["is", "event:goal", ["Signup"]]] "date_range" => "all",
# } "filters" => [["is", "event:goal", ["Signup"]]]
# |> check_success(site, %{ }
# metrics: [:conversion_rate], |> check_success(site, %{
# utc_time_range: @date_range_day, metrics: [:conversion_rate],
# filters: [[:is, "event:goal", [event: "Signup"]]], utc_time_range: @date_range_day,
# dimensions: [], filters: [[:is, "event:goal", ["Signup"]]],
# order_by: nil, dimensions: [],
# timezone: site.timezone, order_by: nil,
# include: %{imports: false, time_labels: false}, timezone: site.timezone,
# preloaded_goals: [event: "Signup"] include: %{imports: false, time_labels: false, total_rows: false, comparisons: nil},
# }) pagination: %{limit: 10_000, offset: 0}
# end })
|> check_goals(preloaded_goals: ["Signup"], revenue_currencies: %{})
end
# test "succeeds with event:goal dimension", %{site: site} do test "succeeds with event:goal dimension", %{site: site} do
# goal = insert(:goal, %{site: site, event_name: "Signup"}) insert(:goal, %{site: site, event_name: "Purchase", currency: "USD"})
insert(:goal, %{site: site, event_name: "Signup"})
# %{ %{
# "metrics" => ["conversion_rate"], "site_id" => site.domain,
# "date_range" => "all", "metrics" => ["conversion_rate"],
# "dimensions" => ["event:goal"] "date_range" => "all",
# } "dimensions" => ["event:goal"]
# |> check_success(site, %{ }
# metrics: [:conversion_rate], |> check_success(site, %{
# utc_time_range: @date_range_day, metrics: [:conversion_rate],
# filters: [], utc_time_range: @date_range_day,
# dimensions: ["event:goal"], filters: [],
# order_by: nil, dimensions: ["event:goal"],
# timezone: site.timezone, order_by: nil,
# include: %{imports: false, time_labels: false}, timezone: site.timezone,
# preloaded_goals: [goal] include: %{imports: false, time_labels: false, total_rows: false, comparisons: nil},
# }) pagination: %{limit: 10_000, offset: 0}
# end })
|> check_goals(preloaded_goals: ["Purchase", "Signup"], revenue_currencies: %{})
end
test "custom properties filter with special metric", %{site: site} do test "custom properties filter with special metric", %{site: site} do
%{ %{
@ -1402,8 +1402,7 @@ defmodule Plausible.Stats.Filters.QueryParserTest do
order_by: nil, order_by: nil,
timezone: site.timezone, timezone: site.timezone,
include: %{imports: false, time_labels: false, total_rows: false, comparisons: nil}, include: %{imports: false, time_labels: false, total_rows: false, comparisons: nil},
pagination: %{limit: 10_000, offset: 0}, pagination: %{limit: 10_000, offset: 0}
preloaded_goals: []
}) })
end end
@ -1423,25 +1422,27 @@ defmodule Plausible.Stats.Filters.QueryParserTest do
end end
describe "views_per_visit metric" do describe "views_per_visit metric" do
# test "succeeds with normal filters", %{site: site} do test "succeeds with normal filters", %{site: site} do
# insert(:goal, %{site: site, event_name: "Signup"}) insert(:goal, %{site: site, event_name: "Signup"})
# %{ %{
# "metrics" => ["views_per_visit"], "site_id" => site.domain,
# "date_range" => "all", "metrics" => ["views_per_visit"],
# "filters" => [["is", "event:goal", ["Signup"]]] "date_range" => "all",
# } "filters" => [["is", "event:goal", ["Signup"]]]
# |> check_success(site, %{ }
# metrics: [:views_per_visit], |> check_success(site, %{
# utc_time_range: @date_range_day, metrics: [:views_per_visit],
# filters: [[:is, "event:goal", [event: "Signup"]]], utc_time_range: @date_range_day,
# dimensions: [], filters: [[:is, "event:goal", ["Signup"]]],
# order_by: nil, dimensions: [],
# timezone: site.timezone, order_by: nil,
# include: %{imports: false, time_labels: false}, timezone: site.timezone,
# preloaded_goals: [event: "Signup"] include: %{imports: false, time_labels: false, total_rows: false, comparisons: nil},
# }) pagination: %{limit: 10_000, offset: 0}
# end })
|> check_goals(preloaded_goals: ["Signup"], revenue_currencies: %{})
end
test "fails validation if event:page filter specified", %{site: site} do test "fails validation if event:page filter specified", %{site: site} do
%{ %{
@ -1470,6 +1471,209 @@ defmodule Plausible.Stats.Filters.QueryParserTest do
end end
end end
describe "revenue metrics" do
@describetag :ee_only
setup %{user: user} do
plan =
insert(:enterprise_plan,
features: [Plausible.Billing.Feature.RevenueGoals],
user_id: user.id
)
subscription = insert(:subscription, user: user, paddle_plan_id: plan.paddle_plan_id)
{:ok, subscription: subscription}
end
test "not valid in public schema", %{site: site} do
%{
"site_id" => site.domain,
"metrics" => ["total_revenue", "average_revenue"],
"date_range" => "all"
}
|> check_error(
site,
"#/metrics/0: Invalid metric \"total_revenue\"\n#/metrics/1: Invalid metric \"average_revenue\""
)
end
test "can request in internal schema", %{site: site} do
%{
"site_id" => site.domain,
"metrics" => ["total_revenue", "average_revenue"],
"date_range" => "all"
}
|> check_success(
site,
%{
metrics: [:total_revenue, :average_revenue],
utc_time_range: @date_range_day,
filters: [],
dimensions: [],
order_by: nil,
timezone: site.timezone,
include: %{imports: false, time_labels: false, total_rows: false, comparisons: nil},
pagination: %{limit: 10_000, offset: 0}
},
:internal
)
end
test "no access", %{site: site, user: user, subscription: subscription} do
Repo.delete!(subscription)
plan =
insert(:enterprise_plan, features: [Plausible.Billing.Feature.StatsAPI], user_id: user.id)
insert(:subscription, user: user, paddle_plan_id: plan.paddle_plan_id)
%{
"site_id" => site.domain,
"metrics" => ["total_revenue", "average_revenue"],
"date_range" => "all"
}
|> check_error(
site,
"The owner of this site does not have access to the revenue metrics feature.",
:internal
)
end
test "with event:goal filters with same currency", %{site: site} do
insert(:goal,
site: site,
event_name: "Purchase",
currency: "USD",
display_name: "PurchaseUSD"
)
insert(:goal, site: site, event_name: "Subscription", currency: "USD")
insert(:goal, site: site, event_name: "Signup")
insert(:goal, site: site, event_name: "Logout")
%{
"site_id" => site.domain,
"metrics" => ["total_revenue", "average_revenue"],
"date_range" => "all",
"filters" => [["is", "event:goal", ["PurchaseUSD", "Signup", "Subscription"]]]
}
|> check_success(
site,
%{
metrics: [:total_revenue, :average_revenue],
utc_time_range: @date_range_day,
filters: [[:is, "event:goal", ["PurchaseUSD", "Signup", "Subscription"]]],
dimensions: [],
order_by: nil,
timezone: site.timezone,
include: %{imports: false, time_labels: false, total_rows: false, comparisons: nil},
pagination: %{limit: 10_000, offset: 0}
},
:internal
)
|> check_goals(
preloaded_goals: ["PurchaseUSD", "Signup", "Subscription"],
revenue_currencies: %{default: :USD}
)
end
test "with event:goal filters with different currencies", %{site: site} do
insert(:goal, site: site, event_name: "Purchase", currency: "USD")
insert(:goal, site: site, event_name: "Subscription", currency: "EUR")
insert(:goal, site: site, event_name: "Signup")
%{
"site_id" => site.domain,
"metrics" => ["total_revenue", "average_revenue"],
"date_range" => "all",
"filters" => [["is", "event:goal", ["Purchase", "Signup", "Subscription"]]]
}
|> check_success(
site,
%{
metrics: [:total_revenue, :average_revenue],
utc_time_range: @date_range_day,
filters: [[:is, "event:goal", ["Purchase", "Signup", "Subscription"]]],
dimensions: [],
order_by: nil,
timezone: site.timezone,
include: %{imports: false, time_labels: false, total_rows: false, comparisons: nil},
pagination: %{limit: 10_000, offset: 0}
},
:internal
)
|> check_goals(
preloaded_goals: ["Purchase", "Signup", "Subscription"],
revenue_currencies: %{}
)
end
test "with event:goal dimension, different currencies", %{site: site} do
insert(:goal, site: site, event_name: "Purchase", currency: "USD")
insert(:goal, site: site, event_name: "Donation", currency: "EUR")
insert(:goal, site: site, event_name: "Signup")
%{
"site_id" => site.domain,
"metrics" => ["total_revenue", "average_revenue"],
"date_range" => "all",
"dimensions" => ["event:goal"]
}
|> check_success(
site,
%{
metrics: [:total_revenue, :average_revenue],
utc_time_range: @date_range_day,
filters: [],
dimensions: ["event:goal"],
order_by: nil,
timezone: site.timezone,
include: %{imports: false, time_labels: false, total_rows: false, comparisons: nil},
pagination: %{limit: 10_000, offset: 0}
},
:internal
)
|> check_goals(
preloaded_goals: ["Donation", "Purchase", "Signup"],
revenue_currencies: %{"Donation" => :EUR, "Purchase" => :USD}
)
end
test "with event:goal dimension and filters", %{site: site} do
insert(:goal, site: site, event_name: "Purchase", currency: "USD")
insert(:goal, site: site, event_name: "Subscription", currency: "USD")
insert(:goal, site: site, event_name: "Signup")
insert(:goal, site: site, event_name: "Logout")
%{
"site_id" => site.domain,
"metrics" => ["total_revenue", "average_revenue"],
"date_range" => "all",
"dimensions" => ["event:goal"],
"filters" => [["is", "event:goal", ["Purchase", "Signup"]]]
}
|> check_success(
site,
%{
metrics: [:total_revenue, :average_revenue],
utc_time_range: @date_range_day,
filters: [[:is, "event:goal", ["Purchase", "Signup"]]],
dimensions: ["event:goal"],
order_by: nil,
timezone: site.timezone,
include: %{imports: false, time_labels: false, total_rows: false, comparisons: nil},
pagination: %{limit: 10_000, offset: 0}
},
:internal
)
|> check_goals(
preloaded_goals: ["Purchase", "Signup"],
revenue_currencies: %{"Purchase" => :USD}
)
end
end
describe "session metrics" do describe "session metrics" do
test "single session metric succeeds", %{site: site} do test "single session metric succeeds", %{site: site} do
%{ %{
@ -1486,8 +1690,7 @@ defmodule Plausible.Stats.Filters.QueryParserTest do
order_by: nil, order_by: nil,
timezone: site.timezone, timezone: site.timezone,
include: %{imports: false, time_labels: false, total_rows: false, comparisons: nil}, include: %{imports: false, time_labels: false, total_rows: false, comparisons: nil},
pagination: %{limit: 10_000, offset: 0}, pagination: %{limit: 10_000, offset: 0}
preloaded_goals: []
}) })
end end
@ -1519,8 +1722,7 @@ defmodule Plausible.Stats.Filters.QueryParserTest do
order_by: nil, order_by: nil,
timezone: site.timezone, timezone: site.timezone,
include: %{imports: false, time_labels: false, total_rows: false, comparisons: nil}, include: %{imports: false, time_labels: false, total_rows: false, comparisons: nil},
pagination: %{limit: 10_000, offset: 0}, pagination: %{limit: 10_000, offset: 0}
preloaded_goals: []
}) })
end end
@ -1539,8 +1741,7 @@ defmodule Plausible.Stats.Filters.QueryParserTest do
order_by: nil, order_by: nil,
timezone: site.timezone, timezone: site.timezone,
include: %{imports: false, time_labels: false, total_rows: false, comparisons: nil}, include: %{imports: false, time_labels: false, total_rows: false, comparisons: nil},
pagination: %{limit: 10_000, offset: 0}, pagination: %{limit: 10_000, offset: 0}
preloaded_goals: []
}) })
end end
end end

View File

@ -1451,6 +1451,7 @@ defmodule PlausibleWeb.Api.StatsController.TopStatsTest do
} in top_stats } in top_stats
end end
@tag :ee_only
test "does not return average and total when filtering by many revenue goals with different currencies", test "does not return average and total when filtering by many revenue goals with different currencies",
%{conn: conn, site: site} do %{conn: conn, site: site} do
insert(:goal, site: site, event_name: "Payment", currency: "USD") insert(:goal, site: site, event_name: "Payment", currency: "USD")
@ -1488,6 +1489,93 @@ defmodule PlausibleWeb.Api.StatsController.TopStatsTest do
refute "Total revenue" in metrics refute "Total revenue" in metrics
end end
@tag :ee_only
test "returns average and total revenue when filtering by many goals some which don't have currencies",
%{conn: conn, site: site} do
insert(:goal, site: site, event_name: "Payment", currency: "USD")
insert(:goal, site: site, event_name: "Signup")
populate_stats(site, [
build(:event,
name: "Payment",
revenue_reporting_amount: Decimal.new(1_000),
revenue_reporting_currency: "USD"
),
build(:event,
name: "Payment",
revenue_reporting_amount: Decimal.new(1_000),
revenue_reporting_currency: "USD"
),
build(:event, name: "Signup"),
build(:event, name: "Signup")
])
filters = Jason.encode!(%{goal: "Payment|Signup"})
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,000.00", "short" => "$1.0K"},
"graph_metric" => "average_revenue"
} in top_stats
assert %{
"name" => "Total revenue",
"value" => %{"long" => "$2,000.00", "short" => "$2.0K"},
"graph_metric" => "total_revenue"
} in top_stats
end
@tag :ee_only
test "returns average and total revenue when no conversions",
%{conn: conn, site: site} do
insert(:goal, site: site, event_name: "Payment", currency: "USD")
filters = Jason.encode!(%{goal: "Payment|Signup"})
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" => "$0.00", "short" => "$0.0"},
"graph_metric" => "average_revenue"
} in top_stats
assert %{
"name" => "Total revenue",
"value" => %{"long" => "$0.00", "short" => "$0.0"},
"graph_metric" => "total_revenue"
} in top_stats
end
@tag :ee_only
test "does not return average and total revenue when filtering non-currency goal",
%{conn: conn, site: site} do
insert(:goal, site: site, event_name: "Payment", display_name: "PaymentWithoutCurrency")
populate_stats(site, [
build(:event,
name: "Payment",
revenue_reporting_amount: Decimal.new(1_000),
revenue_reporting_currency: "USD"
),
build(:event,
name: "Payment",
revenue_reporting_amount: Decimal.new(1_000),
revenue_reporting_currency: "USD"
)
])
filters = Jason.encode!(%{goal: "PaymentWithoutCurrency"})
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
test "does not return average and total when site owner is on a growth plan", test "does not return average and total when site owner is on a growth plan",
%{conn: conn, site: site, user: user} do %{conn: conn, site: site, user: user} do
insert(:growth_subscription, user: user) insert(:growth_subscription, user: user)