| defmodule PlausibleWeb.Api.ExternalStatsController do |
| use PlausibleWeb, :controller |
| use Plausible.Repo |
| use PlausibleWeb.Plugs.ErrorHandler |
| alias Plausible.Stats.{Query, Metrics, Filters, Interval, Legacy} |
|
|
| def realtime_visitors(conn, _params) do |
| site = conn.assigns.site |
| json(conn, Plausible.Stats.current_visitors(site)) |
| end |
|
|
| def aggregate(conn, params) do |
| site = Repo.preload(conn.assigns.site, :owners) |
|
|
| params = Map.put(params, "property", nil) |
|
|
| with :ok <- validate_period(params), |
| :ok <- validate_date(params), |
| query <- Query.from(site, params, debug_metadata: debug_metadata(conn)), |
| :ok <- validate_filters(site, query.filters), |
| {:ok, metrics} <- parse_and_validate_metrics(params, query), |
| :ok <- ensure_custom_props_access(site, query) do |
| %{results: results, meta: meta} = Legacy.Aggregate.aggregate(site, query, metrics) |
|
|
| payload = maybe_add_warning(%{results: results}, meta) |
|
|
| json(conn, payload) |
| else |
| err_tuple -> send_json_error_response(conn, err_tuple) |
| end |
| end |
|
|
| def breakdown(conn, params) do |
| site = Repo.preload(conn.assigns.site, :owners) |
|
|
| with :ok <- validate_period(params), |
| :ok <- validate_date(params), |
| :ok <- validate_property(params), |
| query <- Query.from(site, params, debug_metadata: debug_metadata(conn)), |
| :ok <- validate_filters(site, query.filters), |
| {:ok, metrics} <- parse_and_validate_metrics(params, query), |
| {:ok, limit} <- validate_or_default_limit(params), |
| :ok <- ensure_custom_props_access(site, query) do |
| page = String.to_integer(Map.get(params, "page", "1")) |
|
|
| %{results: results, meta: meta} = |
| Legacy.Breakdown.breakdown(site, query, metrics, {limit, page}) |
|
|
| payload = maybe_add_warning(%{results: results}, meta) |
|
|
| json(conn, payload) |
| else |
| err_tuple -> send_json_error_response(conn, err_tuple) |
| end |
| end |
|
|
| defp validate_property(%{"property" => property}) do |
| cond do |
| property == "event:hostname" -> |
| {:error, |
| "Property 'event:hostname' is currently not supported for breakdowns. Please provide a valid property for the breakdown endpoint: https://plausible.io/docs/stats-api#properties"} |
|
|
| Plausible.Stats.Legacy.Dimensions.valid?(property) -> |
| :ok |
|
|
| true -> |
| {:error, |
| "Invalid property '#{property}'. Please provide a valid property for the breakdown endpoint: https://plausible.io/docs/stats-api#properties"} |
| end |
| end |
|
|
| defp validate_property(_) do |
| {:error, |
| "The `property` parameter is required. Please provide at least one property to show a breakdown by."} |
| end |
|
|
| @max_breakdown_limit 1000 |
| defp validate_or_default_limit(%{"limit" => limit}) do |
| case Integer.parse(limit) do |
| {limit, ""} when limit > 0 and limit <= @max_breakdown_limit -> |
| {:ok, limit} |
|
|
| _ -> |
| {:error, "Please provide limit as a number between 1 and #{@max_breakdown_limit}."} |
| end |
| end |
|
|
| @default_breakdown_limit 100 |
| defp validate_or_default_limit(_), do: {:ok, @default_breakdown_limit} |
|
|
| defp parse_and_validate_metrics(params, query) do |
| metrics = |
| Map.get(params, "metrics", "visitors") |
| |> String.split(",") |
|
|
| case validate_metrics(metrics, query) do |
| {:error, reason} -> |
| {:error, reason} |
|
|
| metrics -> |
| {:ok, Enum.map(metrics, &Metrics.from_string!/1)} |
| end |
| end |
|
|
| @spec ensure_custom_props_access(Plausible.Site.t(), Query.t()) :: |
| :ok | {:error, {402, String.t()}} |
| defp ensure_custom_props_access(site, query) do |
| allowed_props = Plausible.Props.allowed_for(site, bypass_setup?: true) |
| prop_filter = Filters.get_toplevel_filter(query, "event:props:") |
|
|
| query_allowed? = |
| case {prop_filter, query.dimensions, allowed_props} do |
| {_, _, :all} -> |
| true |
|
|
| {[_, "event:props:" <> prop | _], _property, allowed_props} -> |
| prop in allowed_props |
|
|
| {_filter, ["event:props:" <> prop], allowed_props} -> |
| prop in allowed_props |
|
|
| _ -> |
| true |
| end |
|
|
| if query_allowed? do |
| :ok |
| else |
| msg = "The owner of this site does not have access to the custom properties feature" |
| {:error, {402, msg}} |
| end |
| end |
|
|
| defp validate_metrics(metrics, query) do |
| if length(metrics) == length(Enum.uniq(metrics)) do |
| validate_each_metric(metrics, query) |
| else |
| {:error, "Metrics cannot be queried multiple times."} |
| end |
| end |
|
|
| defp validate_each_metric(metrics, query) do |
| Enum.reduce_while(metrics, [], fn metric, acc -> |
| case validate_metric(metric, query) do |
| {:ok, metric} -> {:cont, acc ++ [metric]} |
| {:error, reason} -> {:halt, {:error, reason}} |
| end |
| end) |
| end |
|
|
| defp validate_metric("time_on_page" = metric, query) do |
| cond do |
| Filters.filtering_on_dimension?(query, "event:goal") -> |
| {:error, "Metric `#{metric}` cannot be queried when filtering by `event:goal`"} |
|
|
| Filters.filtering_on_dimension?(query, "event:name") -> |
| {:error, "Metric `#{metric}` cannot be queried when filtering by `event:name`"} |
|
|
| query.dimensions == ["event:page"] -> |
| {:ok, metric} |
|
|
| not Enum.empty?(query.dimensions) -> |
| {:error, |
| "Metric `#{metric}` is not supported in breakdown queries (except `event:page` breakdown)"} |
|
|
| Filters.filtering_on_dimension?(query, "event:page") -> |
| {:ok, metric} |
|
|
| true -> |
| {:error, |
| "Metric `#{metric}` can only be queried in a page breakdown or with a page filter."} |
| end |
| end |
|
|
| defp validate_metric("conversion_rate" = metric, query) do |
| cond do |
| query.dimensions == ["event:goal"] -> |
| {:ok, metric} |
|
|
| Filters.filtering_on_dimension?(query, "event:goal") -> |
| {:ok, metric} |
|
|
| true -> |
| {:error, |
| "Metric `#{metric}` can only be queried in a goal breakdown or with a goal filter"} |
| end |
| end |
|
|
| defp validate_metric(metric, _) when metric in ["visitors", "pageviews", "events"] do |
| {:ok, metric} |
| end |
|
|
| defp validate_metric("views_per_visit" = metric, query) do |
| cond do |
| Filters.filtering_on_dimension?(query, "event:page") -> |
| {:error, "Metric `#{metric}` cannot be queried with a filter on `event:page`."} |
|
|
| not Enum.empty?(query.dimensions) -> |
| {:error, "Metric `#{metric}` is not supported in breakdown queries."} |
|
|
| true -> |
| validate_session_metric(metric, query) |
| end |
| end |
|
|
| defp validate_metric(metric, query) |
| when metric in ["visits", "bounce_rate", "visit_duration"] do |
| validate_session_metric(metric, query) |
| end |
|
|
| defp validate_metric(metric, _) do |
| {:error, |
| "The metric `#{metric}` is not recognized. Find valid metrics from the documentation: https://plausible.io/docs/stats-api#metrics"} |
| end |
|
|
| defp validate_session_metric(metric, query) do |
| cond do |
| length(query.dimensions) == 1 and event_only_property?(hd(query.dimensions)) -> |
| {:error, |
| "Session metric `#{metric}` cannot be queried for breakdown by `#{query.dimensions}`."} |
|
|
| event_only_filter = find_event_only_filter(query) -> |
| {:error, |
| "Session metric `#{metric}` cannot be queried when using a filter on `#{event_only_filter}`."} |
|
|
| true -> |
| {:ok, metric} |
| end |
| end |
|
|
| defp find_event_only_filter(query) do |
| query.filters |
| |> Enum.map(fn [_op, prop | _] -> prop end) |
| |> Enum.find(&event_only_property?/1) |
| end |
|
|
| defp event_only_property?("event:name"), do: true |
| defp event_only_property?("event:goal"), do: true |
| defp event_only_property?("event:props:" <> _), do: true |
| defp event_only_property?(_), do: false |
|
|
| def timeseries(conn, params) do |
| site = Repo.preload(conn.assigns.site, :owners) |
|
|
| params = Map.put(params, "property", nil) |
|
|
| params = |
| if Map.get(params, "interval") == "date" do |
| %{params | "interval" => "day"} |
| else |
| params |
| end |
|
|
| with :ok <- validate_period(params), |
| :ok <- validate_date(params), |
| :ok <- validate_interval(params), |
| query <- Query.from(site, params, debug_metadata: debug_metadata(conn)), |
| :ok <- validate_filters(site, query.filters), |
| {:ok, metrics} <- parse_and_validate_metrics(params, query), |
| :ok <- ensure_custom_props_access(site, query) do |
| time_dimension = interval_to_time_dimension(Map.get(params, "interval"), query) |
| query = Query.set(query, dimensions: [time_dimension]) |
| {results, meta} = Legacy.Timeseries.timeseries(site, query, metrics) |
|
|
| payload = |
| case meta[:imports_warning] do |
| nil -> %{results: results} |
| warning -> %{results: results, warning: warning} |
| end |
|
|
| json(conn, payload) |
| else |
| err_tuple -> send_json_error_response(conn, err_tuple) |
| end |
| end |
|
|
| defp validate_date(%{"period" => "custom"} = params) do |
| with {:ok, date} <- Map.fetch(params, "date"), |
| [from, to] <- String.split(date, ","), |
| {:ok, _from} <- Date.from_iso8601(String.trim(from)), |
| {:ok, _to} <- Date.from_iso8601(String.trim(to)) do |
| :ok |
| else |
| :error -> |
| {:error, |
| "The `date` parameter is required when using a custom period. See https://plausible.io/docs/stats-api#time-periods"} |
|
|
| _ -> |
| {:error, |
| "Invalid format for `date` parameter. When using a custom period, please include two ISO-8601 formatted dates joined by a comma. See https://plausible.io/docs/stats-api#time-periods"} |
| end |
| end |
|
|
| defp validate_date(%{"date" => date}) do |
| case Date.from_iso8601(date) do |
| {:ok, _date} -> |
| :ok |
|
|
| {:error, msg} -> |
| {:error, |
| "Error parsing `date` parameter: #{msg}. Please specify a valid date in ISO-8601 format."} |
| end |
| end |
|
|
| defp validate_date(_), do: :ok |
|
|
| defp validate_period(%{"period" => period}) do |
| if period in ["day", "7d", "30d", "month", "6mo", "12mo", "custom"] do |
| :ok |
| else |
| {:error, |
| "Error parsing `period` parameter: invalid period `#{period}`. Please find accepted values in our docs: https://plausible.io/docs/stats-api#time-periods"} |
| end |
| end |
|
|
| defp validate_period(_), do: :ok |
|
|
| @valid_intervals ["day", "month"] |
| @valid_intervals_str Enum.map_join(@valid_intervals, ", ", &("`" <> &1 <> "`")) |
|
|
| defp validate_interval(%{"interval" => interval}) do |
| if interval in @valid_intervals do |
| :ok |
| else |
| {:error, |
| "Error parsing `interval` parameter: invalid interval `#{interval}`. Valid intervals are #{@valid_intervals_str}"} |
| end |
| end |
|
|
| defp validate_interval(_), do: :ok |
|
|
| @time_dimensions %{ |
| "minute" => "time:minute", |
| "hour" => "time:hour", |
| "day" => "time:day", |
| "week" => "time:week", |
| "month" => "time:month" |
| } |
|
|
| defp interval_to_time_dimension(interval, query) do |
| interval = interval || Interval.default_for_query(query) |
| Map.fetch!(@time_dimensions, interval) |
| end |
|
|
| defp validate_filters(site, filters) do |
| Enum.reduce_while(filters, :ok, fn filter, _ -> |
| case validate_filter(site, filter) do |
| :ok -> {:cont, :ok} |
| {:error, reason} -> {:halt, {:error, reason}} |
| end |
| end) |
| end |
|
|
| defp validate_filter(site, [_type, "event:goal", goal_filter | _rest]) do |
| site = Plausible.Repo.preload(site, :team) |
| props_available? = Plausible.Billing.Feature.Props.check_availability(site.team) == :ok |
|
|
| configured_goals = |
| site |
| |> Plausible.Goals.for_site(include_goals_with_custom_props?: props_available?) |
| |> Enum.map(& &1.display_name) |
|
|
| goals_in_filter = List.wrap(goal_filter) |
|
|
| if found = Enum.find(goals_in_filter, &(&1 not in configured_goals)) do |
| msg = |
| goal_not_configured_message(found) <> |
| "Find out how to configure goals here: https://plausible.io/docs/stats-api#filtering-by-goals" |
|
|
| {:error, msg} |
| else |
| :ok |
| end |
| end |
|
|
| defp validate_filter(_site, [_, property | _]) do |
| if Plausible.Stats.Legacy.Dimensions.valid?(property) do |
| :ok |
| else |
| {:error, |
| "Invalid filter property '#{property}'. Please provide a valid filter property: https://plausible.io/docs/stats-api#properties"} |
| end |
| end |
|
|
| defp goal_not_configured_message("Visit " <> page_path) do |
| "The pageview goal for the pathname `#{page_path}` is not configured for this site. " |
| end |
|
|
| defp goal_not_configured_message(goal) do |
| "The goal `#{goal}` is not configured for this site. " |
| end |
|
|
| @imported_query_unsupported_warning "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" |
|
|
| defp maybe_add_warning(payload, %Jason.OrderedObject{} = meta) do |
| case meta[:imports_skip_reason] do |
| :unsupported_query -> Map.put(payload, :warning, @imported_query_unsupported_warning) |
| _ -> payload |
| end |
| end |
|
|
| defp send_json_error_response(conn, {:error, {status, msg}}) do |
| conn |
| |> put_status(status) |
| |> json(%{error: msg}) |
| end |
|
|
| defp send_json_error_response(conn, {:error, msg}) do |
| conn |
| |> put_status(400) |
| |> json(%{error: msg}) |
| end |
| end |
|
|