| defmodule PlausibleWeb.Api.StatsController do |
| use Plausible |
| use PlausibleWeb, :controller |
| use Plausible.Repo |
| use PlausibleWeb.Plugs.ErrorHandler |
|
|
| alias Plausible.Stats |
|
|
| alias Plausible.Stats.{Query, Dashboard, ParsedQueryParams, QueryBuilder, QueryError} |
|
|
| alias Plausible.Stats.Dashboard.CsvExport |
| alias PlausibleWeb.Api.Helpers, as: H |
|
|
| require Logger |
|
|
| plug(:date_validation_plug when action not in [:query, :csv_export_v2]) |
| plug(:validate_required_filters_plug when action not in [:current_visitors]) |
|
|
| def query(conn, params) do |
| site = conn.assigns.site |
| now = conn.private[:now] |
|
|
| with {:ok, %ParsedQueryParams{} = params} <- Dashboard.QueryParser.parse(params, now: now), |
| {:ok, %Query{} = query} <- QueryBuilder.build(site, params, debug_metadata(conn)) do |
| query = |
| if query.include.time_labels do |
| Query.set_include(query, :time_label_result_indices, true) |
| else |
| query |
| end |
|
|
| json(conn, Plausible.Stats.query(site, query)) |
| else |
| {:error, %QueryError{message: message}} -> H.bad_request(conn, message) |
| end |
| end |
|
|
| def csv_export(conn, params) do |
| site = conn.assigns.site |
|
|
| case CsvExport.get_csvs(site, params, debug_metadata(conn)) do |
| {:ok, csvs} -> |
| {:ok, {_, zip_content}} = :zip.create(~c"export.zip", csvs, [:memory]) |
|
|
| conn |
| |> put_resp_content_type("application/zip") |
| |> put_resp_header( |
| "content-disposition", |
| Plausible.Exports.content_disposition("export.zip") |
| ) |
| |> send_resp(200, zip_content) |
|
|
| {:error, %QueryError{message: message}} -> |
| H.bad_request(conn, message) |
| end |
| end |
|
|
| on_ee do |
| alias Plausible.Stats.Exploration |
|
|
| if Mix.env() == :e2e_test do |
| @exploration_hourly_limit 100_000 |
| @exploration_burst_limit 100_000 |
| else |
| @exploration_hourly_limit 600 |
| @exploration_burst_limit 10 |
| end |
|
|
| defp check_exploration_rate_limit(site) do |
| key = "exploration:#{site.id}" |
|
|
| with {:allow, _} <- |
| Plausible.RateLimit.check_rate(key, :timer.hours(1), @exploration_hourly_limit), |
| {:allow, _} <- |
| Plausible.RateLimit.check_rate(key, :timer.seconds(10), @exploration_burst_limit) do |
| :ok |
| else |
| {:deny, _} -> {:error, :rate_limit} |
| end |
| end |
|
|
| def exploration_next(conn, %{"journey" => steps} = params) do |
| site = conn.assigns.site |
| search_term = params["search_term"] || "" |
|
|
| with :ok <- check_exploration_rate_limit(site), |
| {:ok, journey} <- parse_journey(steps), |
| {:ok, direction} <- parse_exploration_direction(params["direction"]), |
| query = Query.from(site, params, debug_metadata: debug_metadata(conn)), |
| {:ok, next_steps} <- |
| Exploration.next_steps(site, query, journey, |
| search_term: search_term, |
| direction: direction |
| ) do |
| json(conn, next_steps) |
| else |
| {:error, :rate_limit} -> |
| H.too_many_requests(conn, "Too many exploration requests") |
|
|
| {:error, :journey_too_long} -> |
| H.bad_request(conn, "The journey is too long") |
| end |
| end |
|
|
| def exploration_funnel(conn, %{"journey" => steps} = params) do |
| site = conn.assigns.site |
|
|
| with :ok <- check_exploration_rate_limit(site), |
| {:ok, journey} <- parse_journey(steps), |
| {:ok, direction} <- parse_exploration_direction(params["direction"]), |
| query = Query.from(site, params, debug_metadata: debug_metadata(conn)), |
| {:ok, funnel} <- |
| Exploration.journey_funnel(query, journey, direction) do |
| json(conn, funnel) |
| else |
| {:error, :rate_limit} -> |
| H.too_many_requests(conn, "Too many exploration requests") |
|
|
| {:error, :empty_journey} -> |
| H.bad_request(conn, "We are unable to show funnels when journey is empty") |
|
|
| {:error, :journey_too_long} -> |
| H.bad_request(conn, "The journey is too long") |
| end |
| end |
|
|
| @exploration_max_candidates 50 |
|
|
| def exploration_next_with_funnel(conn, %{"journey" => steps} = params) do |
| site = conn.assigns.site |
| search_term = params["search_term"] || "" |
| include_funnel? = params["include_funnel"] == true |
|
|
| with :ok <- check_exploration_rate_limit(site), |
| {:ok, journey} <- parse_journey(steps), |
| {:ok, direction} <- parse_exploration_direction(params["direction"]), |
| query = Query.from(site, params, debug_metadata: debug_metadata(conn)), |
| {:ok, next_steps} <- |
| Exploration.next_steps(site, query, journey, |
| search_term: search_term, |
| direction: direction, |
| max_candidates: @exploration_max_candidates |
| ), |
| funnel <- maybe_include_funnel(include_funnel?, query, journey, direction) do |
| json(conn, %{next: next_steps, funnel: funnel}) |
| else |
| {:error, :rate_limit} -> |
| H.too_many_requests(conn, "Too many exploration requests") |
|
|
| _ -> |
| H.bad_request(conn, "There was an error with your request") |
| end |
| end |
|
|
| defp maybe_include_funnel(true, query, journey, direction) do |
| case Exploration.journey_funnel(query, journey, direction) do |
| {:ok, funnel_data} -> funnel_data |
| {:error, :empty_journey} -> [] |
| end |
| end |
|
|
| defp maybe_include_funnel(false, _, _, _), do: [] |
|
|
| defp parse_journey(input) when is_binary(input) do |
| input |
| |> Jason.decode!() |
| |> Enum.map(&parse_journey_step/1) |
| |> then(&{:ok, &1}) |
| end |
|
|
| defp parse_journey_step(%{ |
| "name" => name, |
| "pathname" => pathname, |
| "includes_subpaths" => includes_subpaths, |
| "subpaths_count" => subpaths_count, |
| "is_goal" => is_goal |
| }) do |
| Exploration.Journey.Step.new( |
| name, |
| pathname, |
| includes_subpaths, |
| subpaths_count, |
| is_goal |
| ) |
| end |
|
|
| defp parse_exploration_direction("backward"), do: {:ok, :backward} |
| defp parse_exploration_direction("forward"), do: {:ok, :forward} |
| defp parse_exploration_direction(_), do: {:ok, :forward} |
| end |
|
|
| on_ee do |
| def funnel(conn, %{"id" => funnel_id} = params) do |
| site = Plausible.Repo.preload(conn.assigns.site, :team) |
|
|
| with :ok <- Plausible.Billing.Feature.Funnels.check_availability(site.team), |
| query <- Query.from(site, params, debug_metadata: debug_metadata(conn)), |
| :ok <- validate_funnel_query(query), |
| {funnel_id, ""} <- Integer.parse(funnel_id), |
| {:ok, funnel} <- Stats.funnel(site, query, funnel_id) do |
| json(conn, funnel) |
| else |
| {:error, {:invalid_funnel_query, due_to}} -> |
| H.bad_request( |
| conn, |
| "We are unable to show funnels when the dashboard is filtered by #{due_to}", |
| %{ |
| level: :normal |
| } |
| ) |
|
|
| {:error, :funnel_not_found} -> |
| conn |
| |> put_status(404) |
| |> json(%{error: "Funnel not found"}) |
| |> halt() |
|
|
| {:error, :upgrade_required} -> |
| H.payment_required( |
| conn, |
| "#{Plausible.Billing.Feature.Funnels.display_name()} is part of the Plausible Business plan. To get access to this feature, please upgrade your account." |
| ) |
|
|
| _ -> |
| H.bad_request(conn, "There was an error with your request") |
| end |
| end |
|
|
| defp validate_funnel_query(query) do |
| cond do |
| toplevel_goal_filter?(query) -> |
| {:error, {:invalid_funnel_query, "goals"}} |
|
|
| Plausible.Stats.Filters.filtering_on_dimension?(query, "event:page") -> |
| {:error, {:invalid_funnel_query, "pages"}} |
|
|
| query.input_date_range == :realtime -> |
| {:error, {:invalid_funnel_query, "realtime period"}} |
|
|
| true -> |
| :ok |
| end |
| end |
| end |
|
|
| def google_search_terms(conn, params) do |
| site = conn.assigns[:site] |
|
|
| query = Query.from(site, params, debug_metadata: debug_metadata(conn)) |
|
|
| is_admin = |
| if current_user = conn.assigns[:current_user] do |
| Plausible.Teams.Memberships.has_editor_access?(site, current_user) |
| else |
| false |
| end |
|
|
| pagination = { |
| to_int(params["limit"], 9), |
| to_int(params["page"], 0) |
| } |
|
|
| search = params["search"] || "" |
|
|
| not_configured_error_payload = %{error_code: :not_configured, is_admin: is_admin} |
|
|
| search_terms = google_api().fetch_stats(site, query, pagination, search) |
| period_too_recent? = DateTime.diff(query.now, query.utc_time_range.first, :hour) < 72 |
|
|
| case {search_terms, period_too_recent?} do |
| {{:error, :google_property_not_configured}, _} -> |
| conn |
| |> put_status(422) |
| |> json(not_configured_error_payload) |
|
|
| {{:error, :unsupported_filters}, _} -> |
| conn |
| |> put_status(422) |
| |> json(%{error_code: :unsupported_filters}) |
|
|
| {{:ok, []}, _period_too_recent? = true} -> |
| |
| |
| conn |
| |> put_status(422) |
| |> json(%{error_code: :period_too_recent}) |
|
|
| {{:ok, terms}, _} -> |
| json(conn, %{results: terms}) |
|
|
| {{:error, error}, _} -> |
| Logger.error("Plausible.Google.API.fetch_stats failed with error: `#{inspect(error)}`") |
|
|
| conn |
| |> put_status(502) |
| |> json(not_configured_error_payload) |
| end |
| end |
|
|
| def current_visitors(conn, _) do |
| site = conn.assigns[:site] |
| json(conn, Stats.current_visitors(site)) |
| end |
|
|
| defp google_api(), do: Application.fetch_env!(:plausible, :google_api) |
|
|
| def filter_suggestions(conn, params) do |
| site = conn.assigns[:site] |
| search_query = params["q"] |
|
|
| if is_nil(search_query) do |
| conn |
| |> put_status(:bad_request) |
| |> json(%{"error" => "Search parameter 'q' is required"}) |
| else |
| query = Query.from(site, params, debug_metadata: debug_metadata(conn)) |
|
|
| json( |
| conn, |
| Stats.filter_suggestions(site, query, params["filter_name"], search_query) |
| ) |
| end |
| end |
|
|
| def custom_prop_value_filter_suggestions(conn, %{"prop_key" => prop_key} = params) do |
| site = conn.assigns[:site] |
|
|
| query = Query.from(site, params, debug_metadata: debug_metadata(conn)) |
|
|
| json( |
| conn, |
| Stats.custom_prop_value_filter_suggestions(site, query, prop_key, params["q"]) |
| ) |
| end |
|
|
| defp to_int(string, default) when is_binary(string) do |
| case Integer.parse(string) do |
| {i, ""} when is_integer(i) -> |
| i |
|
|
| _ -> |
| default |
| end |
| end |
|
|
| defp to_int(_, default), do: default |
|
|
| defp date_validation_plug(conn, _opts) do |
| case parse_date_params(conn.params) do |
| {:ok, _dates} -> conn |
| {:error, message} when is_binary(message) -> H.bad_request(conn, message) |
| end |
| end |
|
|
| defp validate_required_filters_plug( |
| %Plug.Conn{assigns: %{shared_link: %Plausible.Site.SharedLink{segment_id: segment_id}}} = |
| conn, |
| _opts |
| ) |
| when is_integer(segment_id) do |
| case conn.params |
| |> get_filters_param() |
| |> ensure_expected_segment_filter_present(segment_id) do |
| :ok -> |
| conn |
|
|
| :error -> |
| H.bad_request( |
| conn, |
| "The first filter must be for the segment with id #{segment_id}" |
| ) |
| end |
| end |
|
|
| defp validate_required_filters_plug(conn, _opts), do: conn |
|
|
| defp get_filters_param(%{"filters" => filters} = _params) when is_binary(filters) do |
| JSON.decode!(filters) |
| end |
|
|
| defp get_filters_param(%{"filters" => filters} = _params) when is_list(filters) do |
| filters |
| end |
|
|
| defp get_filters_param(_params) do |
| nil |
| end |
|
|
| defp ensure_expected_segment_filter_present( |
| filters, |
| expected_segment_id |
| ) |
| when is_list(filters) do |
| case filters do |
| [["is", "segment", [segment_id]] | _other_filters] when segment_id == expected_segment_id -> |
| :ok |
|
|
| _ -> |
| :error |
| end |
| end |
|
|
| defp ensure_expected_segment_filter_present(_filters, _expected_segment_id) do |
| :error |
| end |
|
|
| defp parse_date_params(params) do |
| params |
| |> Map.take(["from", "to", "date"]) |
| |> Enum.reduce_while({:ok, %{}}, fn {key, value}, {:ok, acc} -> |
| case Date.from_iso8601(value) do |
| {:ok, date} -> |
| {:cont, {:ok, Map.put(acc, key, date)}} |
|
|
| _ -> |
| {:halt, |
| {:error, |
| "Failed to parse '#{key}' argument. Only ISO 8601 dates are allowed, e.g. `2019-09-07`, `2020-01-01`"}} |
| end |
| end) |
| end |
|
|
| on_ee do |
| defp toplevel_goal_filter?(query) do |
| Plausible.Stats.Filters.filtering_on_dimension?(query, "event:goal", |
| max_depth: 0, |
| behavioral_filters: :ignore |
| ) |
| end |
| end |
| end |
|
|