Skip to content

Commit

Permalink
APIv2: Revenue metrics (plausible#4659)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
macobo authored Oct 9, 2024
1 parent 5fec52a commit 141eea8
Show file tree
Hide file tree
Showing 14 changed files with 586 additions and 287 deletions.
6 changes: 4 additions & 2 deletions assets/js/types/query-api.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
*/

export type Metric =
| "time_on_page"
| "visitors"
| "visits"
| "pageviews"
Expand All @@ -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
Expand Down
101 changes: 45 additions & 56 deletions extra/lib/plausible/stats/goal/revenue.ex
Original file line number Diff line number Diff line change
Expand Up @@ -2,83 +2,72 @@ 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]

def revenue_metrics() 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.
"""
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
_ -> []
end
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
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
The resulting data structure is attached to a `Query` and used below in `format_revenue_metric/3`.
"""
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)

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
)
currencies = goal_currency_map |> Map.values() |> Enum.uniq()
goal_dimension? = "event:goal" in dimensions

if length(revenue_goals_currencies) == 1,
do: {List.first(revenue_goals_currencies), metrics},
else: {nil, metrics -- @revenue_metrics}
case {currencies, goal_dimension?} do
{[currency], false} -> %{default: currency}
{_, true} -> goal_currency_map
_ -> %{}
end
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
def format_revenue_metric(value, query, dimension_values) do
currency =
query.revenue_currencies[:default] ||
get_goal_dimension_revenue_currency(query, dimension_values)

if currency do
Money.new!(value || 0, currency)
else
value
end
end

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
def available?(site) do
site = Plausible.Repo.preload(site, :owner)
Plausible.Billing.Feature.RevenueGoals.check_availability(site.owner) == :ok
end

def cast_revenue_metrics_to_money(results, _), do: results
# :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))

def maybe_cast_metric_to_money(value, metric, currency) do
if currency && metric in @revenue_metrics do
Money.new!(value || 0, currency)
else
value
end
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
35 changes: 28 additions & 7 deletions lib/plausible/goals/filters.ex
Original file line number Diff line number Diff line change
Expand Up @@ -34,18 +34,39 @@ defmodule Plausible.Goals.Filters do
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
Enum.filter(preloaded_goals, fn goal ->
case operation do
:is ->
Plausible.Goal.display_name(goal) == clause
Enum.filter(preloaded_goals, fn goal -> matches?(goal, operation, clause) end)
end

:contains ->
String.contains?(Plausible.Goal.display_name(goal), clause)
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
Expand Down
40 changes: 13 additions & 27 deletions lib/plausible/stats/aggregate.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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(%{})
Expand All @@ -38,40 +34,30 @@ 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]

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
48 changes: 5 additions & 43 deletions lib/plausible/stats/breakdown.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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:
Expand All @@ -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
Expand Down Expand Up @@ -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
Loading

0 comments on commit 141eea8

Please sign in to comment.