analytics / lib /plausible_web /controllers /api /stats_controller.ex
Leon4gr45's picture
Upload folder using huggingface_hub (part 2)
8da2481 verified
Raw
History Blame Contribute Delete
12.9 kB
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} ->
# We consider this an error case because Google Search Console
# data is usually delayed 1-3 days.
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