| defmodule Plausible.Stats.SQL.SpecialMetrics do |
| @moduledoc """ |
| This module defines how special metrics like `conversion_rate` and |
| `percentage` are calculated. |
| """ |
|
|
| use Plausible.Stats.SQL.Fragments |
|
|
| alias Plausible.Stats.{Base, Query, SQL, Filters} |
|
|
| import Ecto.Query |
| import Plausible.Stats.Util |
|
|
| @special_metrics [ |
| :percentage, |
| :conversion_rate, |
| :group_conversion_rate, |
| :scroll_depth, |
| :exit_rate |
| ] |
|
|
| def add(q, site, query) do |
| Enum.reduce(@special_metrics, q, fn special_metric, q -> |
| if special_metric in query.metrics do |
| add_special_metric(q, special_metric, site, query) |
| else |
| q |
| end |
| end) |
| end |
|
|
| defp add_special_metric(q, :percentage, site, query) do |
| total_query = |
| query |
| |> remove_filters_ignored_in_totals_query() |
| |> Query.set( |
| dimensions: [], |
| include_imported: query.include_imported, |
| pagination: nil |
| ) |
|
|
| q |
| |> select_merge_as([], total_visitors_subquery(site, total_query, query.include_imported)) |
| |> select_merge_as([], %{ |
| percentage: |
| fragment( |
| "if(? > 0, round(? / ? * 100, 2), null)", |
| selected_as(:total_visitors), |
| selected_as(:visitors), |
| selected_as(:total_visitors) |
| ) |
| }) |
| end |
|
|
| |
| |
| |
| defp add_special_metric(q, :conversion_rate, site, query) do |
| total_query = |
| query |
| |> Query.remove_top_level_filters(["event:goal", "event:props"]) |
| |> remove_filters_ignored_in_totals_query() |
| |> Query.set( |
| dimensions: [], |
| include_imported: query.include_imported, |
| preloaded_goals: Map.put(query.preloaded_goals, :matching_toplevel_filters, []), |
| pagination: nil |
| ) |
|
|
| q |
| |> select_merge_as( |
| [], |
| total_visitors_subquery(site, total_query, query.include_imported) |
| ) |
| |> select_merge_as([e], %{ |
| conversion_rate: |
| fragment( |
| "if(? > 0, round(? / ? * 100, 2), 0)", |
| selected_as(:total_visitors), |
| selected_as(:visitors), |
| selected_as(:total_visitors) |
| ) |
| }) |
| end |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| defp add_special_metric(q, :group_conversion_rate, site, query) do |
| group_totals_query = |
| query |
| |> Query.remove_top_level_filters(["event:goal", "event:props"]) |
| |> remove_filters_ignored_in_totals_query() |
| |> Query.set( |
| metrics: [:visitors], |
| order_by: [], |
| include_imported: query.include_imported, |
| preloaded_goals: Map.put(query.preloaded_goals, :matching_toplevel_filters, []), |
| pagination: nil |
| ) |
|
|
| from(e in subquery(q), |
| left_join: c in subquery(SQL.QueryBuilder.build(group_totals_query, site)), |
| on: ^SQL.QueryBuilder.build_group_by_join(query) |
| ) |
| |> select_merge_as([e, c], %{ |
| total_visitors: c.visitors, |
| group_conversion_rate: |
| fragment( |
| "if(? > 0, round(? / ? * 100, 2), 0)", |
| c.visitors, |
| e.visitors, |
| c.visitors |
| ) |
| }) |
| |> select_join_fields(query, query.dimensions, e) |
| |> select_join_fields(query, List.delete(query.metrics, :group_conversion_rate), e) |
| end |
|
|
| defp add_special_metric(q, :scroll_depth, _site, query) do |
| max_per_session_q = |
| Base.base_event_query(query) |
| |> where([e], e.name == "engagement" and e.scroll_depth <= 100) |
| |> select([e], %{ |
| session_id: e.session_id, |
| max_scroll_depth: max(e.scroll_depth) |
| }) |
| |> SQL.QueryBuilder.build_group_by(:events, query) |
| |> group_by([e], e.session_id) |
|
|
| dim_shortnames = Enum.map(query.dimensions, fn dim -> shortname(query, dim) end) |
|
|
| dim_select = |
| dim_shortnames |
| |> Enum.map(fn dim -> {dim, dynamic([p], field(p, ^dim))} end) |
| |> Map.new() |
|
|
| dim_group_by = |
| dim_shortnames |
| |> Enum.map(fn dim -> dynamic([p], field(p, ^dim)) end) |
|
|
| total_scroll_depth_q = |
| subquery(max_per_session_q) |
| |> select([], %{}) |
| |> select_merge_as([p], %{ |
| |
| total_scroll_depth: fragment("sum(?)", p.max_scroll_depth), |
| total_scroll_depth_visits: fragment("uniq(?)", p.session_id) |
| }) |
| |> select_merge(^dim_select) |
| |> group_by(^dim_group_by) |
|
|
| join_on_dim_condition = |
| if dim_shortnames == [] do |
| true |
| else |
| dim_shortnames |
| |> Enum.map(fn dim -> dynamic([_e, ..., s], selected_as(^dim) == field(s, ^dim)) end) |
| |
| |> Enum.reduce(fn condition, acc -> dynamic([], ^acc and ^condition) end) |
| end |
|
|
| joined_q = |
| join(q, :left, [e], s in subquery(total_scroll_depth_q), on: ^join_on_dim_condition) |
|
|
| if query.include_imported do |
| joined_q |
| |> select_merge_as([..., s], %{ |
| scroll_depth: |
| fragment( |
| """ |
| if(? + ? > 0, toInt8(round((? + ?) / (? + ?))), NULL) |
| """, |
| s.total_scroll_depth_visits, |
| selected_as(:__imported_total_scroll_depth_visits), |
| s.total_scroll_depth, |
| selected_as(:__imported_total_scroll_depth), |
| s.total_scroll_depth_visits, |
| selected_as(:__imported_total_scroll_depth_visits) |
| ) |
| }) |
| else |
| joined_q |
| |> select_merge_as([..., s], %{ |
| scroll_depth: |
| fragment( |
| "if(any(?) > 0, toUInt8(round(any(?) / any(?))), NULL)", |
| s.total_scroll_depth_visits, |
| s.total_scroll_depth, |
| s.total_scroll_depth_visits |
| ) |
| }) |
| end |
| end |
|
|
| |
| |
| |
| defp add_special_metric(q, :exit_rate, site, query) do |
| total_pageviews_query = |
| query |
| |> Query.remove_top_level_filters(["visit:exit_page"]) |
| |> remove_filters_ignored_in_totals_query() |
| |> Query.set( |
| pagination: nil, |
| order_by: [], |
| metrics: [:pageviews], |
| include_imported: query.include_imported, |
| dimensions: ["event:page"] |
| ) |
|
|
| joined_q = |
| q |
| |> join(:left, [], p in subquery(SQL.QueryBuilder.build(total_pageviews_query, site)), |
| on: |
| selected_as(^shortname(query, "visit:exit_page")) == |
| field(p, ^shortname(total_pageviews_query, "event:page")) |
| ) |
|
|
| if query.include_imported do |
| joined_q |
| |> select_merge_as([..., p], %{ |
| exit_rate: |
| fragment( |
| "if(? > 0, round(? / ? * 100, 1), NULL)", |
| p.pageviews, |
| selected_as(:__internal_visits), |
| p.pageviews |
| ) |
| }) |
| else |
| joined_q |
| |> select_merge_as([..., p], %{ |
| exit_rate: |
| fragment( |
| "if(? > 0, round(? / ? * 100, 1), NULL)", |
| fragment("any(?)", p.pageviews), |
| selected_as(:__internal_visits), |
| fragment("any(?)", p.pageviews) |
| ) |
| }) |
| end |
| end |
|
|
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| defp total_visitors_subquery(site, query, include_imported) |
|
|
| defp total_visitors_subquery(site, query, true = _include_imported) do |
| wrap_alias([], %{ |
| total_visitors: |
| subquery(total_visitors(query)) + |
| subquery(Plausible.Stats.Imported.total_imported_visitors(site, query)) |
| }) |
| end |
|
|
| defp total_visitors_subquery(_site, query, false = _include_imported) do |
| wrap_alias([], %{ |
| total_visitors: subquery(total_visitors(query)) |
| }) |
| end |
|
|
| defp remove_filters_ignored_in_totals_query(query) do |
| totals_query_filters = |
| Filters.transform_filters(query.filters, fn |
| [:ignore_in_totals_query, _] -> [] |
| filter -> [filter] |
| end) |
|
|
| Query.set(query, filters: totals_query_filters) |
| end |
|
|
| defp total_visitors(query) do |
| Base.base_event_query(query) |
| |> select([e], |
| total_visitors: scale_sample(fragment("uniq(?)", e.user_id)) |
| ) |
| end |
| end |
|
|