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 =
| "time_on_page"
| "visitors"
| "visits"
| "pageviews"
@ -16,7 +15,10 @@ export type Metric =
| "events"
| "percentage"
| "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";
/**
* @minItems 2

View File

@ -2,9 +2,6 @@ defmodule Plausible.Stats.Goal.Revenue do
@moduledoc """
Revenue specific functions for the stats scope
"""
import Ecto.Query
alias Plausible.Stats.Filters
@revenue_metrics [:average_revenue, :total_revenue]
@ -12,73 +9,65 @@ defmodule Plausible.Stats.Goal.Revenue do
@revenue_metrics
end
@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, multiple currencies or the site owner does not have access to
revenue goals, `nil` is returned and revenue metrics are dropped.
Preloads revenue currencies for a query.
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.
Assumptions and business logic:
1. Goals are already filtered according to query filters and dimensions
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
goal_filters =
case Filters.get_toplevel_filter(query, "event:goal") do
[:is, "event:goal", list] -> list
_ -> []
def preload_revenue_currencies(site, goals, metrics, dimensions) do
if requested?(metrics) and length(goals) > 0 and available?(site) do
goal_currency_map =
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
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
{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.display_name == result.goal)) do
cast_revenue_metrics_to_money(result, matching_goal.currency)
else
result
end
end
end
def format_revenue_metric(value, query, dimension_values) do
currency =
query.revenue_currencies[:default] ||
get_goal_dimension_revenue_currency(query, dimension_values)
def cast_revenue_metrics_to_money(results, currency) when is_map(results) 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
if currency do
Money.new!(value || 0, currency)
else
value
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

View File

@ -34,18 +34,39 @@ defmodule Plausible.Goals.Filters do
end)
end
def filter_preloaded(preloaded_goals, operation, clause) when operation in [:is, :contains] do
Enum.filter(preloaded_goals, fn goal ->
case operation do
:is ->
Plausible.Goal.display_name(goal) == clause
def preload_needed_goals(site, filters) do
goals = Plausible.Goals.for_site(site)
:contains ->
String.contains?(Plausible.Goal.display_name(goal), clause)
end
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
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
:is ->
Plausible.Goal.display_name(goal) == clause
:contains ->
String.contains?(Plausible.Goal.display_name(goal), clause)
end
end
defp build_condition(filtered_goals, imported?) do
Enum.reduce(filtered_goals, false, fn goal, dynamic_statement ->
case goal do

View File

@ -6,20 +6,16 @@ defmodule Plausible.Stats.Aggregate do
"""
use Plausible.ClickhouseRepo
use Plausible
alias Plausible.Stats.{Query, QueryRunner}
alias Plausible.Stats.{Query, QueryRunner, QueryOptimizer}
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 = %Query{query | metrics: metrics}
query =
query
|> Query.set(metrics: metrics, remove_unavailable_revenue_metrics: true)
|> QueryOptimizer.optimize()
query_result = QueryRunner.run(site, query)
[entry] = query_result.results
@ -29,7 +25,7 @@ defmodule Plausible.Stats.Aggregate do
|> Enum.map(fn {metric, index} ->
{
metric,
metric_map(entry, index, metric, currency)
metric_map(entry, index, metric)
}
end)
|> Enum.into(%{})
@ -38,27 +34,25 @@ defmodule Plausible.Stats.Aggregate do
def metric_map(
%{metrics: metrics, comparison: %{metrics: comparison_metrics, change: change}},
index,
metric,
currency
metric
) do
%{
value: get_value(metrics, index, metric, currency),
comparison_value: get_value(comparison_metrics, index, metric, currency),
value: get_value(metrics, index, metric),
comparison_value: get_value(comparison_metrics, index, metric),
change: Enum.at(change, index)
}
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
def get_value(metric_list, index, metric, currency) do
def get_value(metric_list, index, metric) do
metric_list
|> Enum.at(index)
|> maybe_round_value(metric)
|> maybe_cast_metric_to_money(metric, currency)
end
@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(value, metric) when metric in @metrics_to_round, do: round(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

View File

@ -6,10 +6,9 @@ defmodule Plausible.Stats.Breakdown do
"""
use Plausible.ClickhouseRepo
use Plausible
use Plausible.Stats.SQL.Fragments
alias Plausible.Stats.{Query, QueryOptimizer, QueryRunner}
alias Plausible.Stats.{Query, QueryRunner, QueryOptimizer}
def breakdown(
site,
@ -22,8 +21,8 @@ defmodule Plausible.Stats.Breakdown do
transformed_order_by = transform_order_by(order_by || [], dimension)
query_with_metrics =
Query.set(
query,
query
|> Query.set(
metrics: transformed_metrics,
# Concat client requested order with default order, overriding only if client explicitly requests it
order_by:
@ -34,13 +33,13 @@ defmodule Plausible.Stats.Breakdown do
pagination: %{limit: limit, offset: (page - 1) * limit},
v2: true,
# 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()
QueryRunner.run(site, query_with_metrics)
|> build_breakdown_result(query_with_metrics, metrics)
|> update_currency_metrics(site, query_with_metrics)
end
defp build_breakdown_result(query_result, query, metrics) do
@ -122,41 +121,4 @@ defmodule Plausible.Stats.Breakdown do
end
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

View File

@ -1,6 +1,8 @@
defmodule Plausible.Stats.Filters.QueryParser do
@moduledoc false
use Plausible
alias Plausible.Stats.{TableDecider, Filters, Metrics, DateTimeRange, JSONSchema}
@default_include %{
@ -36,7 +38,8 @@ defmodule Plausible.Stats.Filters.QueryParser do
{:ok, order_by} <- parse_order_by(Map.get(params, "order_by")),
{:ok, include} <- parse_include(site, Map.get(params, "include", %{})),
{: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 = %{
metrics: metrics,
filters: filters,
@ -44,15 +47,17 @@ defmodule Plausible.Stats.Filters.QueryParser do
dimensions: dimensions,
order_by: order_by,
timezone: site.timezone,
preloaded_goals: preloaded_goals,
include: include,
pagination: pagination
pagination: pagination,
preloaded_goals: preloaded_goals,
revenue_currencies: revenue_currencies
},
:ok <- validate_order_by(query),
:ok <- validate_custom_props_access(site, query),
:ok <- validate_toplevel_only_filter_dimension(query),
:ok <- validate_special_metrics_filters(query),
:ok <- validate_filtered_goals_exist(query),
:ok <- validate_revenue_metrics_access(site, query),
:ok <- validate_metrics(query),
:ok <- validate_include(query) do
{:ok, query}
@ -405,14 +410,19 @@ defmodule Plausible.Stats.Filters.QueryParser do
end
end
def preload_goals_if_needed(site, filters, dimensions) do
def preload_needed_goals(site, metrics, filters, dimensions) do
goal_filters? =
Enum.any?(filters, fn [_, filter_key | _rest] -> filter_key == "event:goal" end)
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
[]
{[], %{}}
end
end
@ -460,6 +470,25 @@ defmodule Plausible.Stats.Filters.QueryParser do
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
configured_goal_names =
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
defp put_preloaded_goals(query, site) do
goals =
Plausible.Stats.Filters.QueryParser.preload_goals_if_needed(
{preloaded_goals, revenue_currencies} =
Plausible.Stats.Filters.QueryParser.preload_needed_goals(
site,
query.metrics,
query.filters,
query.dimensions
)
struct!(query, preloaded_goals: goals)
struct!(query, preloaded_goals: preloaded_goals, revenue_currencies: revenue_currencies)
end
defp put_period(%Query{now: now} = query, _site, %{"period" => period})

View File

@ -17,7 +17,9 @@ defmodule Plausible.Stats.Query do
timezone: nil,
v2: false,
legacy_breakdown: false,
remove_unavailable_revenue_metrics: false,
preloaded_goals: [],
revenue_currencies: %{},
include: Plausible.Stats.Filters.QueryParser.default_include(),
debug_metadata: %{},
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
2. Adds a missing order_by clause to a query
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
@ -47,7 +49,8 @@ defmodule Plausible.Stats.QueryOptimizer do
&update_group_by_time/1,
&add_missing_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
@ -172,4 +175,16 @@ defmodule Plausible.Stats.QueryOptimizer do
pagination: nil
)
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

View File

@ -9,6 +9,7 @@ defmodule Plausible.Stats.QueryRunner do
3. Passing total_rows from clickhouse to QueryResult meta
"""
use Plausible
use Plausible.ClickhouseRepo
alias Plausible.Stats.{
@ -141,7 +142,7 @@ defmodule Plausible.Stats.QueryRunner do
%{
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
@ -168,10 +169,19 @@ defmodule Plausible.Stats.QueryRunner do
Map.get(entry, Util.shortname(query, dimension))
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)
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
# 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.
"""
use Plausible
use Plausible.ClickhouseRepo
alias Plausible.Stats.{Comparisons, Query, QueryRunner, Time}
alias Plausible.Stats.{Comparisons, Query, QueryRunner, QueryOptimizer, Time}
@time_dimension %{
"month" => "time:month",
@ -18,21 +17,16 @@ defmodule Plausible.Stats.Timeseries 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.set(
query,
query
|> Query.set(
metrics: transform_metrics(metrics, %{conversion_rate: :group_conversion_rate}),
dimensions: [time_dimension(query)],
order_by: [{time_dimension(query), :asc}],
v2: true
v2: true,
remove_unavailable_revenue_metrics: true
)
|> QueryOptimizer.optimize()
comparison_query =
if(query.include.comparisons,
@ -43,8 +37,8 @@ defmodule Plausible.Stats.Timeseries do
query_result = QueryRunner.run(site, query)
{
build_result(query_result, query, currency, fn entry -> entry end),
build_result(query_result, comparison_query, currency, fn entry -> entry.comparison end),
build_result(query_result, query, fn entry -> entry end),
build_result(query_result, comparison_query, fn entry -> entry.comparison end),
query_result.meta
}
end
@ -53,7 +47,7 @@ defmodule Plausible.Stats.Timeseries do
# 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
defp build_result(query_result, %Query{} = query, currency, extract_entry) do
defp build_result(query_result, %Query{} = query, extract_entry) do
query_result.results
|> Enum.map(&extract_entry.(&1))
|> Enum.map(fn %{dimensions: [time_dimension_value], metrics: metrics} ->
@ -65,12 +59,12 @@ defmodule Plausible.Stats.Timeseries do
}
end)
|> Map.new()
|> add_labels(query, currency)
|> add_labels(query)
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
|> Time.time_labels()
|> Enum.map(fn key ->
@ -79,7 +73,6 @@ defmodule Plausible.Stats.Timeseries do
key,
empty_row(key, query.metrics)
)
|> cast_revenue_metrics_to_money(currency)
end)
|> transform_realtime_labels(query)
|> transform_keys(%{group_conversion_rate: :conversion_rate})
@ -122,12 +115,4 @@ defmodule Plausible.Stats.Timeseries do
end
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

View File

@ -218,10 +218,6 @@
},
"metric": {
"oneOf": [
{
"const": "time_on_page",
"$comment": "only :internal"
},
{
"const": "visitors",
"description": "Metric counting the number of unique visitors"
@ -261,6 +257,18 @@
{
"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"
},
{
"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
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
return_value
end
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,
timezone: site.timezone,
include: %{imports: false, time_labels: false, total_rows: false, comparisons: nil},
pagination: %{limit: 10_000, offset: 0},
preloaded_goals: []
pagination: %{limit: 10_000, offset: 0}
}
check_success(params, site, expected_parsed, schema_type)
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
%{}
|> 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,
timezone: site.timezone,
include: %{imports: false, time_labels: false, total_rows: false, comparisons: nil},
pagination: %{limit: 10_000, offset: 0},
preloaded_goals: []
pagination: %{limit: 10_000, offset: 0}
})
end
@ -134,8 +147,7 @@ defmodule Plausible.Stats.Filters.QueryParserTest do
order_by: nil,
timezone: site.timezone,
include: %{imports: false, time_labels: false, total_rows: false, comparisons: nil},
pagination: %{limit: 10_000, offset: 0},
preloaded_goals: []
pagination: %{limit: 10_000, offset: 0}
},
:internal
)
@ -201,8 +213,7 @@ defmodule Plausible.Stats.Filters.QueryParserTest do
order_by: nil,
timezone: site.timezone,
include: %{imports: false, time_labels: false, total_rows: false, comparisons: nil},
pagination: %{limit: 10_000, offset: 0},
preloaded_goals: []
pagination: %{limit: 10_000, offset: 0}
},
:internal
)
@ -327,8 +338,7 @@ defmodule Plausible.Stats.Filters.QueryParserTest do
order_by: nil,
timezone: site.timezone,
include: %{imports: false, time_labels: false, total_rows: false, comparisons: nil},
pagination: %{limit: 10_000, offset: 0},
preloaded_goals: []
pagination: %{limit: 10_000, offset: 0}
})
end
@ -353,8 +363,7 @@ defmodule Plausible.Stats.Filters.QueryParserTest do
order_by: nil,
timezone: site.timezone,
include: %{imports: false, time_labels: false, total_rows: false, comparisons: nil},
pagination: %{limit: 10_000, offset: 0},
preloaded_goals: []
pagination: %{limit: 10_000, offset: 0}
})
end
end
@ -380,8 +389,7 @@ defmodule Plausible.Stats.Filters.QueryParserTest do
order_by: nil,
timezone: site.timezone,
include: %{imports: false, time_labels: false, total_rows: false, comparisons: nil},
pagination: %{limit: 10_000, offset: 0},
preloaded_goals: []
pagination: %{limit: 10_000, offset: 0}
})
end
end
@ -447,8 +455,7 @@ defmodule Plausible.Stats.Filters.QueryParserTest do
order_by: nil,
timezone: site.timezone,
include: %{imports: false, time_labels: false, total_rows: false, comparisons: nil},
pagination: %{limit: 10_000, offset: 0},
preloaded_goals: []
pagination: %{limit: 10_000, offset: 0}
})
%{
@ -467,8 +474,7 @@ defmodule Plausible.Stats.Filters.QueryParserTest do
order_by: nil,
timezone: site.timezone,
include: %{imports: false, time_labels: false, total_rows: false, comparisons: nil},
pagination: %{limit: 10_000, offset: 0},
preloaded_goals: []
pagination: %{limit: 10_000, offset: 0}
})
end
@ -528,8 +534,7 @@ defmodule Plausible.Stats.Filters.QueryParserTest do
order_by: nil,
timezone: site.timezone,
include: %{imports: false, time_labels: false, total_rows: false, comparisons: nil},
pagination: %{limit: 10_000, offset: 0},
preloaded_goals: []
pagination: %{limit: 10_000, offset: 0}
})
end
@ -576,8 +581,7 @@ defmodule Plausible.Stats.Filters.QueryParserTest do
order_by: nil,
timezone: site.timezone,
include: %{imports: false, time_labels: false, total_rows: false, comparisons: nil},
pagination: %{limit: 10_000, offset: 0},
preloaded_goals: []
pagination: %{limit: 10_000, offset: 0}
})
end
@ -612,8 +616,7 @@ defmodule Plausible.Stats.Filters.QueryParserTest do
order_by: nil,
timezone: site.timezone,
include: %{imports: true, time_labels: true, total_rows: true, comparisons: nil},
pagination: %{limit: 10_000, offset: 0},
preloaded_goals: []
pagination: %{limit: 10_000, offset: 0}
})
end
@ -676,8 +679,7 @@ defmodule Plausible.Stats.Filters.QueryParserTest do
time_labels: false,
total_rows: false
},
pagination: %{limit: 10_000, offset: 0},
preloaded_goals: []
pagination: %{limit: 10_000, offset: 0}
},
:internal
)
@ -707,8 +709,7 @@ defmodule Plausible.Stats.Filters.QueryParserTest do
time_labels: false,
total_rows: false
},
pagination: %{limit: 10_000, offset: 0},
preloaded_goals: []
pagination: %{limit: 10_000, offset: 0}
},
:internal
)
@ -741,8 +742,7 @@ defmodule Plausible.Stats.Filters.QueryParserTest do
time_labels: false,
total_rows: false
},
pagination: %{limit: 10_000, offset: 0},
preloaded_goals: []
pagination: %{limit: 10_000, offset: 0}
},
:internal
)
@ -799,8 +799,7 @@ defmodule Plausible.Stats.Filters.QueryParserTest do
order_by: nil,
timezone: site.timezone,
include: %{imports: false, time_labels: false, total_rows: false, comparisons: nil},
pagination: %{limit: 100, offset: 200},
preloaded_goals: []
pagination: %{limit: 100, offset: 200}
})
end
@ -1125,8 +1124,7 @@ defmodule Plausible.Stats.Filters.QueryParserTest do
order_by: nil,
timezone: site.timezone,
include: %{imports: false, time_labels: false, total_rows: false, comparisons: nil},
pagination: %{limit: 10_000, offset: 0},
preloaded_goals: []
pagination: %{limit: 10_000, offset: 0}
})
end
end
@ -1147,8 +1145,7 @@ defmodule Plausible.Stats.Filters.QueryParserTest do
order_by: nil,
timezone: site.timezone,
include: %{imports: false, time_labels: false, total_rows: false, comparisons: nil},
pagination: %{limit: 10_000, offset: 0},
preloaded_goals: []
pagination: %{limit: 10_000, offset: 0}
})
end
end
@ -1168,8 +1165,7 @@ defmodule Plausible.Stats.Filters.QueryParserTest do
order_by: nil,
timezone: site.timezone,
include: %{imports: false, time_labels: false, total_rows: false, comparisons: nil},
pagination: %{limit: 10_000, offset: 0},
preloaded_goals: []
pagination: %{limit: 10_000, offset: 0}
})
end
@ -1230,8 +1226,7 @@ defmodule Plausible.Stats.Filters.QueryParserTest do
order_by: [{:events, :desc}, {:visitors, :asc}],
timezone: site.timezone,
include: %{imports: false, time_labels: false, total_rows: false, comparisons: nil},
pagination: %{limit: 10_000, offset: 0},
preloaded_goals: []
pagination: %{limit: 10_000, offset: 0}
})
end
@ -1251,8 +1246,7 @@ defmodule Plausible.Stats.Filters.QueryParserTest do
order_by: [{"event:name", :desc}],
timezone: site.timezone,
include: %{imports: false, time_labels: false, total_rows: false, comparisons: nil},
pagination: %{limit: 10_000, offset: 0},
preloaded_goals: []
pagination: %{limit: 10_000, offset: 0}
})
end
@ -1344,45 +1338,51 @@ defmodule Plausible.Stats.Filters.QueryParserTest do
)
end
# test "succeeds with event:goal filter", %{site: site} do
# insert(:goal, %{site: site, event_name: "Signup"})
test "succeeds with event:goal filter", %{site: site} do
insert(:goal, %{site: site, event_name: "Signup"})
insert(:goal, %{site: site, event_name: "Purchase", currency: "USD"})
# %{
# "metrics" => ["conversion_rate"],
# "date_range" => "all",
# "filters" => [["is", "event:goal", ["Signup"]]]
# }
# |> check_success(site, %{
# metrics: [:conversion_rate],
# utc_time_range: @date_range_day,
# filters: [[:is, "event:goal", [event: "Signup"]]],
# dimensions: [],
# order_by: nil,
# timezone: site.timezone,
# include: %{imports: false, time_labels: false},
# preloaded_goals: [event: "Signup"]
# })
# end
%{
"site_id" => site.domain,
"metrics" => ["conversion_rate"],
"date_range" => "all",
"filters" => [["is", "event:goal", ["Signup"]]]
}
|> check_success(site, %{
metrics: [:conversion_rate],
utc_time_range: @date_range_day,
filters: [[:is, "event:goal", ["Signup"]]],
dimensions: [],
order_by: nil,
timezone: site.timezone,
include: %{imports: false, time_labels: false, total_rows: false, comparisons: nil},
pagination: %{limit: 10_000, offset: 0}
})
|> check_goals(preloaded_goals: ["Signup"], revenue_currencies: %{})
end
# test "succeeds with event:goal dimension", %{site: site} do
# goal = insert(:goal, %{site: site, event_name: "Signup"})
test "succeeds with event:goal dimension", %{site: site} do
insert(:goal, %{site: site, event_name: "Purchase", currency: "USD"})
insert(:goal, %{site: site, event_name: "Signup"})
# %{
# "metrics" => ["conversion_rate"],
# "date_range" => "all",
# "dimensions" => ["event:goal"]
# }
# |> check_success(site, %{
# metrics: [:conversion_rate],
# utc_time_range: @date_range_day,
# filters: [],
# dimensions: ["event:goal"],
# order_by: nil,
# timezone: site.timezone,
# include: %{imports: false, time_labels: false},
# preloaded_goals: [goal]
# })
# end
%{
"site_id" => site.domain,
"metrics" => ["conversion_rate"],
"date_range" => "all",
"dimensions" => ["event:goal"]
}
|> check_success(site, %{
metrics: [:conversion_rate],
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}
})
|> check_goals(preloaded_goals: ["Purchase", "Signup"], revenue_currencies: %{})
end
test "custom properties filter with special metric", %{site: site} do
%{
@ -1402,8 +1402,7 @@ defmodule Plausible.Stats.Filters.QueryParserTest do
order_by: nil,
timezone: site.timezone,
include: %{imports: false, time_labels: false, total_rows: false, comparisons: nil},
pagination: %{limit: 10_000, offset: 0},
preloaded_goals: []
pagination: %{limit: 10_000, offset: 0}
})
end
@ -1423,25 +1422,27 @@ defmodule Plausible.Stats.Filters.QueryParserTest do
end
describe "views_per_visit metric" do
# test "succeeds with normal filters", %{site: site} do
# insert(:goal, %{site: site, event_name: "Signup"})
test "succeeds with normal filters", %{site: site} do
insert(:goal, %{site: site, event_name: "Signup"})
# %{
# "metrics" => ["views_per_visit"],
# "date_range" => "all",
# "filters" => [["is", "event:goal", ["Signup"]]]
# }
# |> check_success(site, %{
# metrics: [:views_per_visit],
# utc_time_range: @date_range_day,
# filters: [[:is, "event:goal", [event: "Signup"]]],
# dimensions: [],
# order_by: nil,
# timezone: site.timezone,
# include: %{imports: false, time_labels: false},
# preloaded_goals: [event: "Signup"]
# })
# end
%{
"site_id" => site.domain,
"metrics" => ["views_per_visit"],
"date_range" => "all",
"filters" => [["is", "event:goal", ["Signup"]]]
}
|> check_success(site, %{
metrics: [:views_per_visit],
utc_time_range: @date_range_day,
filters: [[:is, "event:goal", ["Signup"]]],
dimensions: [],
order_by: nil,
timezone: site.timezone,
include: %{imports: false, time_labels: false, total_rows: false, comparisons: nil},
pagination: %{limit: 10_000, offset: 0}
})
|> check_goals(preloaded_goals: ["Signup"], revenue_currencies: %{})
end
test "fails validation if event:page filter specified", %{site: site} do
%{
@ -1470,6 +1471,209 @@ defmodule Plausible.Stats.Filters.QueryParserTest do
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
test "single session metric succeeds", %{site: site} do
%{
@ -1486,8 +1690,7 @@ defmodule Plausible.Stats.Filters.QueryParserTest do
order_by: nil,
timezone: site.timezone,
include: %{imports: false, time_labels: false, total_rows: false, comparisons: nil},
pagination: %{limit: 10_000, offset: 0},
preloaded_goals: []
pagination: %{limit: 10_000, offset: 0}
})
end
@ -1519,8 +1722,7 @@ defmodule Plausible.Stats.Filters.QueryParserTest do
order_by: nil,
timezone: site.timezone,
include: %{imports: false, time_labels: false, total_rows: false, comparisons: nil},
pagination: %{limit: 10_000, offset: 0},
preloaded_goals: []
pagination: %{limit: 10_000, offset: 0}
})
end
@ -1539,8 +1741,7 @@ defmodule Plausible.Stats.Filters.QueryParserTest do
order_by: nil,
timezone: site.timezone,
include: %{imports: false, time_labels: false, total_rows: false, comparisons: nil},
pagination: %{limit: 10_000, offset: 0},
preloaded_goals: []
pagination: %{limit: 10_000, offset: 0}
})
end
end

View File

@ -1451,6 +1451,7 @@ defmodule PlausibleWeb.Api.StatsController.TopStatsTest do
} in top_stats
end
@tag :ee_only
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")
@ -1488,6 +1489,93 @@ defmodule PlausibleWeb.Api.StatsController.TopStatsTest do
refute "Total revenue" in metrics
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",
%{conn: conn, site: site, user: user} do
insert(:growth_subscription, user: user)