analytics/lib/plausible/stats/goals.ex

263 lines
8.2 KiB
Elixir

defmodule Plausible.Stats.Goals do
@moduledoc """
Stats code related to filtering and grouping by goals.
"""
import Ecto.Query
import Plausible.Stats.Filters.Utils, only: [page_regex: 1]
alias Plausible.Stats.Filters
@doc """
Preloads goals data if needed for query-building and related work.
"""
def preload_needed_goals(site, dimensions, filters) do
if Enum.member?(dimensions, "event:goal") or
Filters.filtering_on_dimension?(filters, "event:goal") do
goals = Plausible.Goals.for_site(site)
%{
# When grouping by event:goal, later pipeline needs to know which goals match filters exactly.
# This can affect both calculations whether all goals have the same revenue currency and
# whether we should skip imports.
matching_toplevel_filters: goals_matching_toplevel_filters(goals, filters),
all: goals
}
else
%{
all: [],
matching_toplevel_filters: []
}
end
end
@doc """
Translates an event:goal filter into SQL. Similarly to other `add_filter` clauses in
`Plausible.Stats.SQL.WhereBuilder`, returns an `Ecto.Query.dynamic` expression.
Compared to other dimensions, filtering by goals works differently. First, we expect
all goals to be preloaded into the query - this list of goals is what actually gets
filtered by `operator` and `clauses`.
With the resulting filtered list of goals, we build conditions for whether that goal
was completed or not, and join those conditions with a logical OR.
### Options
* `imported?` - when `true`, builds conditions on the `page` db field rather than
`pathname`, and also skips the `e.name == "pageview"` check.
"""
def add_filter(query, [operation, "event:goal", clauses | _] = filter, opts \\ [])
when operation in [:is, :contains] do
imported? = Keyword.get(opts, :imported?, false)
Enum.reduce(clauses, false, fn clause, dynamic_statement ->
condition =
query.preloaded_goals.all
|> filter_preloaded(filter, clause)
|> build_condition(imported?)
dynamic([e], ^condition or ^dynamic_statement)
end)
end
@type goal_join_data() :: %{
indices: [non_neg_integer()],
types: [String.t()],
event_names_imports: [String.t()],
event_names_by_type: [String.t()],
page_regexes: [String.t()],
scroll_thresholds: [non_neg_integer()],
custom_props_keys: [[String.t()]],
custom_props_values: [[String.t()]]
}
@doc """
Returns data needed to perform a GROUP BY on goals in an ecto query.
"""
@spec goal_join_data(Plausible.Stats.Query.t()) :: goal_join_data()
def goal_join_data(query) do
goals = query.preloaded_goals.matching_toplevel_filters
goals
|> Enum.with_index(1)
|> Enum.reduce(
%{
indices: [],
types: [],
event_names_imports: [],
event_names_by_type: [],
page_regexes: [],
scroll_thresholds: [],
custom_props_keys: [],
custom_props_values: []
},
fn {goal, idx}, acc ->
goal_type = Plausible.Goal.type(goal)
{prop_keys, prop_values} = Enum.unzip(goal.custom_props)
%{
indices: [idx | acc.indices],
types: [to_string(goal_type) | acc.types],
# This will contain "" for non-event goals
event_names_imports: [to_string(goal.event_name) | acc.event_names_imports],
event_names_by_type: [event_name_by_type(goal_type, goal) | acc.event_names_by_type],
# Event goals are considered to match everything for the sake of efficient queries in query_builder.ex
# See also Plausible.Stats.SQL.Expression.event_goal_join/1
page_regexes: [page_regex_for_goal(goal_type, goal) | acc.page_regexes],
scroll_thresholds: [goal.scroll_threshold | acc.scroll_thresholds],
custom_props_keys: [prop_keys | acc.custom_props_keys],
custom_props_values: [prop_values | acc.custom_props_values]
}
end
)
|> Enum.map(fn {key, list} -> {key, Enum.reverse(list)} end)
|> Map.new()
end
defp event_name_by_type(:event, goal), do: goal.event_name
defp event_name_by_type(:page, _goal), do: "pageview"
defp event_name_by_type(:scroll, _goal), do: "engagement"
defp page_regex_for_goal(:event, _goal), do: ".?"
defp page_regex_for_goal(:page, goal), do: Filters.Utils.page_regex(goal.page_path)
defp page_regex_for_goal(:scroll, goal), do: Filters.Utils.page_regex(goal.page_path)
def toplevel_scroll_goal_filters?(query) do
goal_filters? =
Enum.any?(query.filters, fn
[_, "event:goal", _] -> true
_ -> false
end)
any_scroll_goals_preloaded? =
query.preloaded_goals.matching_toplevel_filters
|> Enum.any?(fn goal -> Plausible.Goal.type(goal) == :scroll end)
goal_filters? and any_scroll_goals_preloaded?
end
defp filter_preloaded(goals, filter, clause) do
Enum.filter(goals, fn goal -> matches?(goal, filter, clause) end)
end
defp goals_matching_toplevel_filters(goals, filters) do
Enum.reduce(filters, goals, fn
[_, "event:goal" | _] = filter, goals ->
goals_matching_any_clause(goals, filter)
_filter, goals ->
goals
end)
end
defp goals_matching_any_clause(goals, [_, _, clauses | _] = filter) do
goals
|> Enum.filter(fn goal ->
Enum.any?(clauses, fn clause -> matches?(goal, filter, clause) end)
end)
end
defp matches?(goal, [operation | _rest] = filter, clause) do
goal_name =
goal
|> Plausible.Goal.display_name()
|> mod(filter)
clause = mod(clause, filter)
case operation do
:is ->
goal_name == clause
:contains ->
String.contains?(goal_name, clause)
end
end
defp mod(str, filter) do
case filter do
[_, _, _, %{case_sensitive: false}] -> String.downcase(str)
_ -> str
end
end
defp build_condition(filtered_goals, imported?) do
Enum.reduce(filtered_goals, false, fn goal, dynamic_statement ->
if is_nil(goal) do
dynamic([e], ^dynamic_statement)
else
dynamic([e], ^goal_condition(goal, imported?) or ^dynamic_statement)
end
end)
end
def goal_condition(goal, imported? \\ false) do
type = Plausible.Goal.type(goal)
goal_condition(type, goal, imported?)
end
defp goal_condition(:event, goal, _) do
name_condition = dynamic([e], e.name == ^goal.event_name)
if map_size(goal.custom_props) > 0 do
custom_props_condition = build_custom_props_condition(goal.custom_props)
dynamic([e], ^name_condition and ^custom_props_condition)
else
name_condition
end
end
defp goal_condition(:scroll, goal, false = _imported?) do
pathname_condition = page_path_condition(goal.page_path, _imported? = false)
name_condition = dynamic([e], e.name == "engagement")
scroll_condition =
dynamic([e], e.scroll_depth <= 100 and e.scroll_depth >= ^goal.scroll_threshold)
dynamic([e], ^pathname_condition and ^name_condition and ^scroll_condition)
end
defp goal_condition(:page, goal, true = _imported?) do
page_path_condition(goal.page_path, _imported? = true)
end
defp goal_condition(:page, goal, false = _imported?) do
name_condition = dynamic([e], e.name == "pageview")
pathname_condition = page_path_condition(goal.page_path, _imported? = false)
dynamic([e], ^pathname_condition and ^name_condition)
end
defp page_path_condition(page_path, imported?) do
db_field = page_path_db_field(imported?)
if String.contains?(page_path, "*") do
dynamic([e], fragment("match(?, ?)", field(e, ^db_field), ^page_regex(page_path)))
else
dynamic([e], field(e, ^db_field) == ^page_path)
end
end
defp build_custom_props_condition(custom_props) do
Enum.reduce(custom_props, true, fn {prop_key, prop_value}, acc ->
condition =
dynamic(
[e],
fragment(
"?[indexOf(?, ?)] = ?",
field(e, :"meta.value"),
field(e, :"meta.key"),
^prop_key,
^prop_value
)
)
dynamic([e], ^acc and ^condition)
end)
end
def page_path_db_field(true = _imported?), do: :page
def page_path_db_field(false = _imported?), do: :pathname
end