| defmodule Plausible.Stats.QueryResult do |
| @moduledoc """ |
| This struct contains the (JSON-encodable) response for a query and |
| is responsible for building it from database query results. |
| |
| For the convenience of API docs and consumers, the JSON result |
| produced by Jason.encode(query_result) is ordered. |
| """ |
|
|
| use Plausible |
| alias Plausible.Stats.{Query, QueryRunner, Filters} |
|
|
| defstruct results: [], |
| comparison_results: nil, |
| meta: %{}, |
| query: nil |
|
|
| @imports_warnings %{ |
| unsupported_query: |
| "Imported stats are not included in the results because query parameters are not supported. " <> |
| "For more information, see: https://plausible.io/docs/stats-api#filtering-imported-stats", |
| unsupported_interval: |
| "Imported stats are not included because the time dimension (i.e. the interval) is too short." |
| } |
|
|
| def imports_warnings(), do: @imports_warnings |
|
|
| @no_imported_scroll_depth_warning %{ |
| code: :no_imported_scroll_depth, |
| warning: "No imports with scroll depth data were found" |
| } |
|
|
| def no_imported_scroll_depth_warning(), do: @no_imported_scroll_depth_warning |
|
|
| @no_imported_bounce_rate_warning %{ |
| code: :no_imported_bounce_rate, |
| warning: "imported bounce_rate is not available when using a page filter" |
| } |
|
|
| def no_imported_bounce_rate_warning(), do: @no_imported_bounce_rate_warning |
|
|
| @doc """ |
| Builds full JSON-serializable query response. |
| |
| `results` should already-built by Plausible.Stats.QueryRunner |
| """ |
| def from(%QueryRunner{results: results, comparison_results: comparison_results} = runner) do |
| struct!( |
| __MODULE__, |
| results: results, |
| comparison_results: comparison_results, |
| meta: meta(runner) |> Jason.OrderedObject.new(), |
| query: query(runner) |> Jason.OrderedObject.new() |
| ) |
| end |
|
|
| defp meta(%QueryRunner{} = runner) do |
| %{} |
| |> add_imports_meta(runner.main_query) |
| |> add_metric_warnings_meta(runner.main_query) |
| |> add_empty_metrics_meta(runner.main_query) |
| |> add_time_labels_meta(runner) |
| |> add_time_labels_result_indices_meta(runner) |
| |> add_comparison_time_labels_meta(runner) |
| |> add_comparison_time_label_result_indices_meta(runner) |
| |> add_present_index_meta(runner.main_query) |
| |> add_partial_time_labels_meta(runner.main_query) |
| |> add_comparison_partial_time_labels_meta(runner) |
| |> add_total_rows_meta(runner.main_query, runner.total_rows) |
| |> Enum.sort_by(&elem(&1, 0)) |
| end |
|
|
| defp add_imports_meta(meta, %Query{include: include} = query) do |
| if include.imports or include.imports_meta do |
| %{ |
| imports_included: query.include_imported, |
| imports_skip_reason: query.skip_imported_reason, |
| imports_warning: @imports_warnings[query.skip_imported_reason] |
| } |
| |> Map.reject(fn {_key, value} -> is_nil(value) end) |
| |> Map.merge(meta) |
| else |
| meta |
| end |
| end |
|
|
| defp add_metric_warnings_meta(meta, query) do |
| warnings = metric_warnings(query) |
|
|
| if map_size(warnings) > 0 do |
| Map.put(meta, :metric_warnings, warnings) |
| else |
| meta |
| end |
| end |
|
|
| defp add_empty_metrics_meta(meta, query) do |
| if query.include.empty_metrics and "event:goal" not in query.dimensions do |
| Map.put( |
| meta, |
| :empty_metrics, |
| Enum.map(query.metrics, &Plausible.Stats.Metrics.default_value(&1, query)) |
| ) |
| else |
| meta |
| end |
| end |
|
|
| defp add_time_labels_meta(meta, %QueryRunner{main_query: query}) do |
| if query.include.time_labels do |
| Map.put(meta, :time_labels, Plausible.Stats.Time.time_labels(query)) |
| else |
| meta |
| end |
| end |
|
|
| defp add_comparison_time_labels_meta(meta, %QueryRunner{main_query: query} = runner) do |
| if query.include.time_labels && query.include.compare do |
| Map.put( |
| meta, |
| :comparison_time_labels, |
| Plausible.Stats.Time.time_labels(runner.comparison_query) |
| ) |
| else |
| meta |
| end |
| end |
|
|
| defp add_time_labels_result_indices_meta(meta, %QueryRunner{main_query: query} = runner) do |
| time_labels = meta[:time_labels] |
|
|
| if query.include.time_label_result_indices and is_list(time_labels) do |
| Map.put( |
| meta, |
| :time_label_result_indices, |
| result_indices_for_time_labels(time_labels, runner.main_results) |
| ) |
| else |
| meta |
| end |
| end |
|
|
| defp add_comparison_time_label_result_indices_meta( |
| meta, |
| %QueryRunner{main_query: query} = runner |
| ) do |
| comp_time_labels = meta[:comparison_time_labels] |
|
|
| if query.include.time_label_result_indices and is_list(comp_time_labels) do |
| Map.put( |
| meta, |
| :comparison_time_label_result_indices, |
| result_indices_for_time_labels(comp_time_labels, runner.comparison_results) |
| ) |
| else |
| meta |
| end |
| end |
|
|
| defp add_present_index_meta(meta, query) do |
| time_labels = meta[:time_labels] |
|
|
| if query.include.present_index and is_list(time_labels) do |
| Map.put(meta, :present_index, Plausible.Stats.Time.present_index(time_labels, query)) |
| else |
| meta |
| end |
| end |
|
|
| defp add_partial_time_labels_meta(meta, query) do |
| time_labels = meta[:time_labels] |
|
|
| if query.include.partial_time_labels and is_list(time_labels) do |
| Map.put( |
| meta, |
| :partial_time_labels, |
| Plausible.Stats.Time.partial_time_labels(time_labels, query) |
| ) |
| else |
| meta |
| end |
| end |
|
|
| defp add_comparison_partial_time_labels_meta(meta, %QueryRunner{main_query: query} = runner) do |
| comparison_time_labels = meta[:comparison_time_labels] |
|
|
| if query.include.partial_time_labels and is_list(comparison_time_labels) do |
| Map.put( |
| meta, |
| :comparison_partial_time_labels, |
| Plausible.Stats.Time.partial_time_labels(comparison_time_labels, runner.comparison_query) |
| ) |
| else |
| meta |
| end |
| end |
|
|
| defp add_total_rows_meta(meta, query, total_rows) do |
| if query.include.total_rows do |
| Map.put(meta, :total_rows, total_rows) |
| else |
| meta |
| end |
| end |
|
|
| defp query(%QueryRunner{site: site, main_query: query}) do |
| [ |
| site_id: site.domain, |
| metrics: query.metrics, |
| date_range: [ |
| to_iso8601(query.utc_time_range.first, query.timezone), |
| to_iso8601(query.utc_time_range.last, query.timezone) |
| ], |
| comparison_date_range: |
| if(query.include.compare, |
| do: [ |
| to_iso8601(query.comparison_utc_time_range.first, query.timezone), |
| to_iso8601(query.comparison_utc_time_range.last, query.timezone) |
| ] |
| ), |
| filters: query.filters, |
| dimensions: query.dimensions, |
| order_by: query.order_by |> Enum.map(&Tuple.to_list/1), |
| include: include(query) |> Map.filter(fn {_key, val} -> val end), |
| pagination: query.pagination |
| ] |
| |> Enum.reject(fn {_k, v} -> is_nil(v) end) |
| end |
|
|
| defp include(query) do |
| case query.include.compare do |
| {:date_range, first, last} -> |
| struct!(query.include, compare: [Date.to_iso8601(first), Date.to_iso8601(last)]) |
|
|
| _ -> |
| query.include |
| end |
| |> Map.from_struct() |
| end |
|
|
| defp metric_warnings(%Query{} = query) do |
| Enum.reduce(query.metrics, %{}, fn metric, acc -> |
| case metric_warning(metric, query) do |
| nil -> acc |
| %{} = warning -> Map.put(acc, metric, warning) |
| end |
| end) |
| end |
|
|
| on_ee do |
| @revenue_metrics Plausible.Stats.Goal.Revenue.revenue_metrics() |
|
|
| @revenue_metrics_warnings %{ |
| revenue_goals_unavailable: |
| "The owner of this site does not have access to the revenue metrics feature.", |
| no_single_revenue_currency: |
| "Revenue metrics are null as there are multiple currencies for the selected event:goals.", |
| no_revenue_goals_matching: |
| "Revenue metrics are null as there are no matching revenue goals." |
| } |
|
|
| defp metric_warning(metric, %Query{} = query) |
| when metric in @revenue_metrics do |
| if query.revenue_warning do |
| %{ |
| code: query.revenue_warning, |
| warning: @revenue_metrics_warnings[query.revenue_warning] |
| } |
| else |
| nil |
| end |
| end |
| end |
|
|
| defp metric_warning(:scroll_depth, %Query{} = query) do |
| if query.include_imported and not Enum.any?(query.imports_in_range, & &1.has_scroll_depth) do |
| @no_imported_scroll_depth_warning |
| end |
| end |
|
|
| defp metric_warning(:time_on_page, %Query{} = query) do |
| case query.time_on_page_data do |
| %{new_metric_visible: true, include_legacy_metric: true, cutoff: cutoff} -> |
| cutoff_date = |
| cutoff |> DateTime.shift_zone!(query.timezone) |> Calendar.strftime("%Y-%m-%d") |
|
|
| %{ |
| code: :legacy_time_on_page_used, |
| message: |
| "This period includes data calculated with the legacy time on page method up to #{cutoff_date}" |
| } |
|
|
| _ -> |
| nil |
| end |
| end |
|
|
| |
| |
| |
| |
| defp metric_warning(:bounce_rate, %Query{} = query) do |
| page_filter_or_dimension? = |
| Filters.filtering_on_dimension?(query, "event:page", behavioral_filters: :ignore) or |
| "event:page" in query.dimensions |
|
|
| if query.include_imported and page_filter_or_dimension? do |
| @no_imported_bounce_rate_warning |
| end |
| end |
|
|
| defp metric_warning(_metric, _query), do: nil |
|
|
| defp result_indices_for_time_labels(time_labels, results_list) do |
| index_lookup_map = |
| results_list |
| |> Enum.with_index() |
| |> Map.new(fn {%{dimensions: [dim]}, idx} -> {dim, idx} end) |
|
|
| Enum.map(time_labels, &Map.get(index_lookup_map, &1)) |
| end |
|
|
| defp to_iso8601(datetime, timezone) do |
| datetime |
| |> DateTime.shift_zone!(timezone) |
| |> DateTime.to_iso8601(:extended) |
| end |
| end |
|
|
| defimpl Jason.Encoder, for: Plausible.Stats.QueryResult do |
| def encode( |
| %Plausible.Stats.QueryResult{ |
| results: results, |
| comparison_results: comparison_results, |
| meta: meta, |
| query: query |
| }, |
| opts |
| ) do |
| if comparison_results do |
| Jason.OrderedObject.new( |
| results: results, |
| comparison_results: comparison_results, |
| meta: meta, |
| query: query |
| ) |
| else |
| Jason.OrderedObject.new(results: results, meta: meta, query: query) |
| end |
| |> Jason.Encoder.encode(opts) |
| end |
| end |
|
|