File size: 7,890 Bytes
8da2481 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 | defmodule Plausible.Stats.TableDecider do
@moduledoc """
This module contains logic for deciding which tables need to be queried given a query
and metrics, with the purpose of reducing the number of queries and JOINs needed to perform.
"""
use Plausible
import Enum, only: [empty?: 1]
import Plausible.Stats.Filters,
only: [dimensions_used_in_filters: 1, filtering_on_dimension?: 2]
alias Plausible.Stats.{Query, QueryError}
@revenue_metrics on_ee(do: Plausible.Stats.Goal.Revenue.revenue_metrics(), else: [])
def events_join_sessions?(query) do
session_dims_in_filters? =
query.filters
|> dimensions_used_in_filters()
|> Enum.any?(&(dimension_partitioner(query, &1) == :session))
session_dims? =
Enum.any?(query.dimensions, &(dimension_partitioner(query, &1) == :session))
session_dims? or session_dims_in_filters?
end
def sessions_join_events?(query) do
query.filters
|> dimensions_used_in_filters()
|> Enum.any?(&(dimension_partitioner(query, &1) == :event))
end
@doc """
Validates whether metrics and dimensions are compatible with each other.
During query building we split query into two: event and session queries. However dimensions need to be
present in both queries and hence must be compatible.
Used during query parsing
"""
def validate_no_metrics_dimensions_conflict(query) do
%{event: event_only_metrics, session: session_only_metrics} =
partition(query.metrics, query, &metric_partitioner/2)
%{event: event_only_dimensions, session: session_only_dimensions} =
partition(query.dimensions, query, &dimension_partitioner/2)
conflicting_event_metrics = event_only_metrics -- @revenue_metrics
cond do
# event:page (optionally with event:hostname) is a special case handled in QueryOptimizer.split_sessions_query
"event:page" in event_only_dimensions and
event_only_dimensions -- ["event:page", "event:hostname"] == [] ->
:ok
not empty?(session_only_metrics) and not empty?(event_only_dimensions) ->
{:error,
%QueryError{
code: :invalid_metrics,
message:
"Session metric(s) #{i(session_only_metrics)} cannot be queried along with event dimension(s) #{i(event_only_dimensions)}"
}}
not empty?(conflicting_event_metrics) and not empty?(session_only_dimensions) ->
{:error,
%QueryError{
code: :invalid_metrics,
message:
"Event metric(s) #{i(conflicting_event_metrics)} cannot be queried along with session dimension(s) #{i(session_only_dimensions)}"
}}
true ->
:ok
end
end
def partition_dimensions(query) do
partition(query.dimensions, query, &dimension_partitioner/2)
end
@type table_type() :: :events | :sessions
@type metric() :: String.t()
@spec partition_metrics(list(metric()), Query.t()) :: list({table_type(), list(metric())})
def partition_metrics(requested_metrics, query) do
metrics = partition(requested_metrics, query, &metric_partitioner/2)
filters =
query.filters
|> dimensions_used_in_filters()
|> partition(query, &dimension_partitioner/2)
dimensions = partition(query.dimensions, query, &dimension_partitioner/2)
cond do
# Only one table needs to be queried
empty?(metrics.event) && empty?(filters.event) && empty?(dimensions.event) ->
[sessions: metrics.session ++ metrics.either ++ metrics.sample_percent]
empty?(metrics.session) && empty?(filters.session) && empty?(dimensions.session) ->
[events: metrics.event ++ metrics.either ++ metrics.sample_percent]
# Filters and/or dimensions on both events and sessions, but only one kind of metric
empty?(metrics.event) && empty?(dimensions.event) ->
[sessions: metrics.session ++ metrics.either ++ metrics.sample_percent]
empty?(metrics.session) && empty?(dimensions.session) ->
[events: metrics.event ++ metrics.either ++ metrics.sample_percent]
# Default: prefer events
true ->
[
events: metrics.event ++ metrics.either ++ metrics.sample_percent,
sessions: metrics.session ++ metrics.sample_percent
]
end
|> Enum.flat_map(&smear_session_metrics(&1, query))
|> Enum.reject(fn {_table_type, metrics} -> empty?(metrics) end)
end
# :TRICKY: When counting session metrics, we want to count each visit/visitor across
# the length of the session, not just when events occurred or when session started.
# For this reason, we smear the session metrics across the length of the session.
# See `time_slots` usage in `Plausible.Stats.SQL.Expression` to understand how this is done.
@smearable_metrics [:visitors, :visits]
defp smear_session_metrics({:sessions, metrics} = value, query) do
if ("time:minute" in query.dimensions or "time:hour" in query.dimensions) and
not filtering_on_dimension?(query, "event:goal") do
# Split metrics into two groups: one with visitors and visits, and the remaining ones
{smearable_metrics, session_metrics} = Enum.split_with(metrics, &(&1 in @smearable_metrics))
[
{:sessions, session_metrics},
{:sessions_smeared, smearable_metrics}
]
else
[value]
end
end
defp smear_session_metrics(value, _query), do: [value]
# Note: This is inaccurate when filtering but required for old backwards compatibility
defp metric_partitioner(%Query{legacy_breakdown: true}, :pageviews), do: :either
defp metric_partitioner(%Query{legacy_breakdown: true}, :events), do: :either
# :TRICKY: For time:minute dimension we prefer sessions over events as there
# might be minutes where no events occurred but the session was active.
defp metric_partitioner(query, metric) when metric in [:visitors, :visits] do
if "time:minute" in query.dimensions and not filtering_on_dimension?(query, "event:goal") do
:session
else
:either
end
end
defp metric_partitioner(_, :conversion_rate), do: :either
defp metric_partitioner(_, :group_conversion_rate), do: :either
defp metric_partitioner(_, :percentage), do: :either
defp metric_partitioner(_, :average_revenue), do: :event
defp metric_partitioner(_, :total_revenue), do: :event
defp metric_partitioner(_, :scroll_depth), do: :event
defp metric_partitioner(_, :pageviews), do: :event
defp metric_partitioner(_, :events), do: :event
defp metric_partitioner(_, :bounce_rate), do: :session
defp metric_partitioner(_, :time_on_page), do: :event
defp metric_partitioner(_, :visit_duration), do: :session
defp metric_partitioner(_, :views_per_visit), do: :session
defp metric_partitioner(_, :exit_rate), do: :session
# Calculated metrics - handled on callsite separately from other metrics.
defp metric_partitioner(_, :total_visitors), do: :other
# Sample percentage is included in both tables if queried.
defp metric_partitioner(_, :sample_percent), do: :sample_percent
defp dimension_partitioner(_, "event:" <> _), do: :event
defp dimension_partitioner(_, "visit:entry_page"), do: :session
defp dimension_partitioner(_, "visit:entry_page_hostname"), do: :session
defp dimension_partitioner(_, "visit:exit_page"), do: :session
defp dimension_partitioner(_, "visit:exit_page_hostname"), do: :session
defp dimension_partitioner(_, "visit:" <> _), do: :either
defp dimension_partitioner(_, _), do: :either
@default %{event: [], session: [], either: [], other: [], sample_percent: []}
defp partition(values, query, partitioner) do
Enum.reduce(values, @default, fn value, acc ->
key = partitioner.(query, value)
Map.put(acc, key, Map.fetch!(acc, key) ++ [value])
end)
end
defp i(list) when is_list(list) do
Enum.map_join(list, ", ", &"`#{&1}`")
end
end
|