From 383a6ca778954f4561e5de5c05d705faf8d01fb4 Mon Sep 17 00:00:00 2001 From: Adam Rutkowski Date: Mon, 11 May 2026 06:59:19 +0200 Subject: [PATCH 01/19] Extract Journey.Step to its own module --- extra/lib/plausible/stats/exploration.ex | 41 +---------------------- extra/lib/plausible/stats/journey/step.ex | 39 +++++++++++++++++++++ 2 files changed, 40 insertions(+), 40 deletions(-) create mode 100644 extra/lib/plausible/stats/journey/step.ex diff --git a/extra/lib/plausible/stats/exploration.ex b/extra/lib/plausible/stats/exploration.ex index 287499953d8a..e8f676116cab 100644 --- a/extra/lib/plausible/stats/exploration.ex +++ b/extra/lib/plausible/stats/exploration.ex @@ -3,50 +3,11 @@ defmodule Plausible.Stats.Exploration do Query logic for user journey exploration. """ - defmodule Journey.Step do - @moduledoc false - - @type t() :: %__MODULE__{} - - @derive {Jason.Encoder, - only: [:name, :pathname, :label, :includes_subpaths, :subpaths_count, :is_goal]} - defstruct name: nil, - pathname: "", - label: nil, - includes_subpaths: false, - subpaths_count: 0, - is_goal: false - - @spec from(map()) :: t() - def from(step) do - new(step.name, step.pathname, step.includes_subpaths, step.subpaths_count, step.is_goal) - end - - @spec new(String.t(), String.t(), boolean(), non_neg_integer(), boolean()) :: t() - def new(name, pathname, includes_subpaths \\ false, subpaths_count \\ 0, is_goal \\ false) - when is_boolean(includes_subpaths) and is_integer(subpaths_count) do - label = - if name != "pageview" do - name - else - pathname - end - - %__MODULE__{ - label: label, - name: name, - pathname: pathname, - includes_subpaths: includes_subpaths, - subpaths_count: subpaths_count, - is_goal: is_goal - } - end - end - import Ecto.Query import Plausible.Stats.SQL.Fragments import Plausible.Stats.Util, only: [percentage: 2] + alias Plausible.Stats.Exploration.Journey alias Plausible.ClickhouseRepo alias Plausible.Stats.Base alias Plausible.Stats.Filters diff --git a/extra/lib/plausible/stats/journey/step.ex b/extra/lib/plausible/stats/journey/step.ex new file mode 100644 index 000000000000..3ee3ede69ec3 --- /dev/null +++ b/extra/lib/plausible/stats/journey/step.ex @@ -0,0 +1,39 @@ +defmodule Plausible.Stats.Exploration.Journey.Step do + @moduledoc false + + @type t() :: %__MODULE__{} + + @derive {Jason.Encoder, + only: [:name, :pathname, :label, :includes_subpaths, :subpaths_count, :is_goal]} + defstruct name: nil, + pathname: "", + label: nil, + includes_subpaths: false, + subpaths_count: 0, + is_goal: false + + @spec from(map()) :: t() + def from(step) do + new(step.name, step.pathname, step.includes_subpaths, step.subpaths_count, step.is_goal) + end + + @spec new(String.t(), String.t(), boolean(), non_neg_integer(), boolean()) :: t() + def new(name, pathname, includes_subpaths \\ false, subpaths_count \\ 0, is_goal \\ false) + when is_boolean(includes_subpaths) and is_integer(subpaths_count) do + label = + if name != "pageview" do + name + else + pathname + end + + %__MODULE__{ + label: label, + name: name, + pathname: pathname, + includes_subpaths: includes_subpaths, + subpaths_count: subpaths_count, + is_goal: is_goal + } + end +end From 2259d07d1a5a048d530817daf8ffd01c990ba26f Mon Sep 17 00:00:00 2001 From: Adam Rutkowski Date: Mon, 11 May 2026 07:03:31 +0200 Subject: [PATCH 02/19] interesting->featured funnel sounds better --- assets/js/dashboard/extra/exploration.js | 10 +++++----- extra/lib/plausible/stats/exploration.ex | 10 +++++----- .../controllers/api/stats_controller.ex | 6 +++--- lib/plausible_web/router.ex | 4 ++-- test/plausible/stats/exploration_test.exs | 14 +++++++------- 5 files changed, 22 insertions(+), 22 deletions(-) diff --git a/assets/js/dashboard/extra/exploration.js b/assets/js/dashboard/extra/exploration.js index e962985628eb..0d4b67414a34 100644 --- a/assets/js/dashboard/extra/exploration.js +++ b/assets/js/dashboard/extra/exploration.js @@ -135,9 +135,9 @@ function fetchNextWithFunnel( ) } -function fetchInterestingFunnel(site, dashboardState) { +function fetchFeaturedFunnel(site, dashboardState) { return api.post( - url.apiPath(site, '/exploration/interesting-funnel'), + url.apiPath(site, '/exploration/featured-funnel'), dashboardState, { max_steps: PRELOAD_MAX_STEPS, max_candidates: PRELOAD_MAX_CANDIDATES } ) @@ -732,13 +732,13 @@ function useExplorationData(site, dashboardState, inViewport) { setActiveLoading(true) - // On first render fire the interesting-funnel preload. Once the preload + // On first render fire the featured-funnel preload. Once the preload // resolves it sets steps and funnel, which re-triggers this effect for // the active-column candidate fetch. if (!preloadFiredRef.current) { preloadFiredRef.current = true - fetchInterestingFunnel(site, dashboardState) + fetchFeaturedFunnel(site, dashboardState) .then((response) => { if (isStale()) return if (response?.funnel?.length > 0) { @@ -752,7 +752,7 @@ function useExplorationData(site, dashboardState, inViewport) { // The preload populates steps, which re-triggers this effect for // the active-column candidate fetch, so leave loading=true. } else { - // No interesting funnel found; fall back to plain candidates for column 0. + // No featured funnel found; fall back to plain candidates for column 0. fetchNextWithFunnel( site, dashboardState, diff --git a/extra/lib/plausible/stats/exploration.ex b/extra/lib/plausible/stats/exploration.ex index e8f676116cab..667c999056c3 100644 --- a/extra/lib/plausible/stats/exploration.ex +++ b/extra/lib/plausible/stats/exploration.ex @@ -98,7 +98,7 @@ defmodule Plausible.Stats.Exploration do @doc """ Builds a "teaser" funnel by greedily selecting steps. - We currently don't know what the "interesting" funnel might be, + We currently don't know what the "featured" funnel might be, but blindly following the most visited cascade, oftentimes results with a repetitive back and forth between two pages. @@ -117,9 +117,9 @@ defmodule Plausible.Stats.Exploration do to include implicit wildcard pathnames in suggestions or not (default: true) """ - @spec interesting_funnel(Plausible.Site.t(), Query.t(), keyword()) :: + @spec featured_funnel(Plausible.Site.t(), Query.t(), keyword()) :: {:ok, %{funnel: [funnel_step()], candidates: [next_step()]}} | {:error, :not_found} - def interesting_funnel(site, query, opts \\ []) do + def featured_funnel(site, query, opts \\ []) do max_steps = min(Keyword.get(opts, :max_steps, 6), @max_steps) max_candidates = min(Keyword.get(opts, :max_candidates, 10), @max_candidates) @@ -131,13 +131,13 @@ defmodule Plausible.Stats.Exploration do ) with {:ok, result} <- - build_interesting_journey(site, query, max_steps, max_candidates, include_wildcard?), + build_featured_journey(site, query, max_steps, max_candidates, include_wildcard?), {:ok, funnel} <- journey_funnel(query, result.journey) do {:ok, %{funnel: funnel, candidates: result.candidates}} end end - defp build_interesting_journey(site, query, max_steps, max_candidates, include_wildcard?) do + defp build_featured_journey(site, query, max_steps, max_candidates, include_wildcard?) do case do_build_journey( site, query, diff --git a/lib/plausible_web/controllers/api/stats_controller.ex b/lib/plausible_web/controllers/api/stats_controller.ex index 8d723d33e733..b666b67d5eb8 100644 --- a/lib/plausible_web/controllers/api/stats_controller.ex +++ b/lib/plausible_web/controllers/api/stats_controller.ex @@ -30,7 +30,7 @@ defmodule PlausibleWeb.Api.StatsController do :exploration_next, :exploration_funnel, :exploration_next_with_funnel, - :exploration_interesting_funnel + :exploration_featured_funnel ] end @@ -181,14 +181,14 @@ defmodule PlausibleWeb.Api.StatsController do end end - def exploration_interesting_funnel(conn, params) do + def exploration_featured_funnel(conn, params) do site = conn.assigns.site query = Query.from(site, params, debug_metadata: debug_metadata(conn)) include_wildcard? = not FunWithFlags.enabled?(@exploration_wildcard_disabled_flag, for: site) - case Exploration.interesting_funnel(site, query, + case Exploration.featured_funnel(site, query, max_steps: params["max_steps"], max_candidates: params["max_candidates"], include_wildcard?: include_wildcard? diff --git a/lib/plausible_web/router.ex b/lib/plausible_web/router.ex index dbf2882e8adb..b8213b3a9a68 100644 --- a/lib/plausible_web/router.ex +++ b/lib/plausible_web/router.ex @@ -278,9 +278,9 @@ defmodule PlausibleWeb.Router do StatsController, :exploration_next_with_funnel - post "/:domain/exploration/interesting-funnel", + post "/:domain/exploration/featured-funnel", StatsController, - :exploration_interesting_funnel + :exploration_featured_funnel end scope private: %{allow_consolidated_views: true} do diff --git a/test/plausible/stats/exploration_test.exs b/test/plausible/stats/exploration_test.exs index 16746f8663c7..25c37e0b4bbf 100644 --- a/test/plausible/stats/exploration_test.exs +++ b/test/plausible/stats/exploration_test.exs @@ -346,12 +346,12 @@ defmodule Plausible.Stats.ExplorationTest do end end - describe "interesting_funnel" do + describe "featured_funnel" do test "builds a funnel starting with the most visited step", %{site: site} do query = QueryBuilder.build!(site, input_date_range: :all) assert {:ok, %{funnel: [step1, step2, step3, step4]}} = - Exploration.interesting_funnel(site, query) + Exploration.featured_funnel(site, query) assert step1.step.pathname == "/home" assert step1.visitors == 2 @@ -370,7 +370,7 @@ defmodule Plausible.Stats.ExplorationTest do query = QueryBuilder.build!(site, input_date_range: :all) assert {:ok, %{funnel: [step1, step2]}} = - Exploration.interesting_funnel(site, query, max_steps: 2) + Exploration.featured_funnel(site, query, max_steps: 2) assert step1.step.pathname == "/home" assert step2.step.pathname == "/login" @@ -380,7 +380,7 @@ defmodule Plausible.Stats.ExplorationTest do empty_site = new_site() query = QueryBuilder.build!(empty_site, input_date_range: :all) - assert {:error, :not_found} = Exploration.interesting_funnel(empty_site, query) + assert {:error, :not_found} = Exploration.featured_funnel(empty_site, query) end test "stops when no more unseen steps are available" do @@ -398,7 +398,7 @@ defmodule Plausible.Stats.ExplorationTest do query = QueryBuilder.build!(site, input_date_range: :all) assert {:ok, %{funnel: [step1]}} = - Exploration.interesting_funnel(site, query, max_steps: 6) + Exploration.featured_funnel(site, query, max_steps: 6) assert step1.step.pathname == "/only-page" assert step1.visitors == 1 @@ -411,7 +411,7 @@ defmodule Plausible.Stats.ExplorationTest do filters: [[:is, "visit:browser", ["Firefox"]]] ) - assert {:ok, result} = Exploration.interesting_funnel(site, query) + assert {:ok, result} = Exploration.featured_funnel(site, query) pathnames = Enum.map(result.funnel, & &1.step.pathname) assert pathnames == ["/docs", "/logout"] @@ -468,7 +468,7 @@ defmodule Plausible.Stats.ExplorationTest do query = QueryBuilder.build!(site, input_date_range: :all) - assert {:ok, result} = Exploration.interesting_funnel(site, query) + assert {:ok, result} = Exploration.featured_funnel(site, query) pathnames = Enum.map(result.funnel, & &1.step.pathname) assert pathnames == ["/a", "/b", "/c"] From c61bfbc7d48f9e812e84fcc45410e1d3c955274c Mon Sep 17 00:00:00 2001 From: Adam Rutkowski Date: Mon, 11 May 2026 07:31:49 +0200 Subject: [PATCH 03/19] Store http status in ApiError --- assets/js/dashboard/api.ts | 6 ++++-- assets/js/dashboard/segments/segment-modals.tsx | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/assets/js/dashboard/api.ts b/assets/js/dashboard/api.ts index 91e76fab6627..482af38483cb 100644 --- a/assets/js/dashboard/api.ts +++ b/assets/js/dashboard/api.ts @@ -45,10 +45,12 @@ export type QueryApiResponse = { export class ApiError extends Error { payload: unknown - constructor(message: string, payload: unknown) { + status: number + constructor(message: string, payload: unknown, status: number) { super(message) this.name = 'ApiError' this.payload = payload + this.status = status } } @@ -129,7 +131,7 @@ function getHeaders(): Record { async function handleApiResponse(response: Response) { const payload = await response.json() if (!response.ok) { - throw new ApiError(payload.error, payload) + throw new ApiError(payload.error, payload, response.status) } return payload diff --git a/assets/js/dashboard/segments/segment-modals.tsx b/assets/js/dashboard/segments/segment-modals.tsx index 63cd230038c2..647bc4b2c6d3 100644 --- a/assets/js/dashboard/segments/segment-modals.tsx +++ b/assets/js/dashboard/segments/segment-modals.tsx @@ -464,7 +464,7 @@ export const SegmentModal = ({ id }: { id: SavedSegment['id'] }) => { if (!segment) { error = new ApiError(`Segment not found with with ID "${id}"`, { error: `Segment not found with with ID "${id}"` - }) + }, 404) } const data = !error ? segment : null From d3d3c222a7f5f7acd641f1a8b95a1ab8d93b734d Mon Sep 17 00:00:00 2001 From: Adam Rutkowski Date: Mon, 11 May 2026 07:36:24 +0200 Subject: [PATCH 04/19] Rate limit exploration UI calls --- assets/js/dashboard/extra/exploration.js | 95 +++++++++++++++---- .../controllers/api/stats_controller.ex | 71 +++++++++++--- 2 files changed, 133 insertions(+), 33 deletions(-) diff --git a/assets/js/dashboard/extra/exploration.js b/assets/js/dashboard/extra/exploration.js index 0d4b67414a34..4803216f917d 100644 --- a/assets/js/dashboard/extra/exploration.js +++ b/assets/js/dashboard/extra/exploration.js @@ -7,6 +7,11 @@ import React, { } from 'react' import LazyLoader from '../components/lazy-loader' import * as api from '../api' +import { ApiError } from '../api' + +function isRateLimitedError(err) { + return err instanceof ApiError && err.status === 429 +} import * as url from '../util/url' import { Tooltip } from '../util/tooltip' import { useDebounce } from '../custom-hooks' @@ -42,7 +47,8 @@ const EMPTY_JOURNEY_STATE = { activeFilter: '', // list of suggestions the user saw when picking step frozen: {}, - provisional: {} + provisional: {}, + rateLimited: false } const EMPTY_SVG_DATA = { @@ -444,7 +450,21 @@ function VisitorsMetric({ visitors }) { } } -function ColumnEmptyState({ active, filter, colIndex, direction }) { +function ColumnEmptyState({ + active, + filter, + colIndex, + direction, + rateLimited +}) { + if (active && rateLimited) { + return ( + + Too many requests, please wait a moment and try again + + ) + } + if (!active) { const prompt = colIndex === 1 @@ -493,7 +513,8 @@ function ExplorationColumn({ maxVisitors, filter, onFilterChange, - onSelect + onSelect, + rateLimited }) { const debouncedFilterChange = useDebounce((e) => onFilterChange(e.target.value) @@ -559,6 +580,7 @@ function ExplorationColumn({ filter={filter} colIndex={colIndex} direction={direction} + rateLimited={rateLimited} /> ) : ( @@ -679,6 +701,7 @@ function useExplorationData(site, dashboardState, inViewport) { const reset = useCallback(() => { ++journeyVersionRef.current + preloadFiredRef.current = false setState(EMPTY_JOURNEY_STATE) }, []) @@ -747,7 +770,8 @@ function useExplorationData(site, dashboardState, inViewport) { ...prev, steps: response.funnel.map(({ step }) => step), funnel: response.funnel, - frozen: response.candidates ?? {} + frozen: response.candidates ?? {}, + rateLimited: false })) // The preload populates steps, which re-triggers this effect for // the active-column candidate fetch, so leave loading=true. @@ -765,20 +789,31 @@ function useExplorationData(site, dashboardState, inViewport) { if (!isStale()) setState((prev) => ({ ...prev, - activeResults: r?.next ?? [] + activeResults: r?.next ?? [], + rateLimited: false })) }) - .catch(() => { - if (!isStale()) - setState((prev) => ({ ...prev, activeResults: [] })) + .catch((err) => { + if (!isStale()) { + if (isRateLimitedError(err)) { + setState((prev) => ({ ...prev, rateLimited: true })) + } else { + setState((prev) => ({ ...prev, activeResults: [] })) + } + } }) .finally(() => { if (!isStale()) setActiveLoading(false) }) } }) - .catch(() => { + .catch((err) => { if (isStale()) return + if (isRateLimitedError(err)) { + setState((prev) => ({ ...prev, rateLimited: true })) + setActiveLoading(false) + return + } fetchNextWithFunnel( site, dashboardState, @@ -789,11 +824,20 @@ function useExplorationData(site, dashboardState, inViewport) { ) .then((r) => { if (!isStale()) - setState((prev) => ({ ...prev, activeResults: r?.next ?? [] })) + setState((prev) => ({ + ...prev, + activeResults: r?.next ?? [], + rateLimited: false + })) }) - .catch(() => { - if (!isStale()) - setState((prev) => ({ ...prev, activeResults: [] })) + .catch((err) => { + if (!isStale()) { + if (isRateLimitedError(err)) { + setState((prev) => ({ ...prev, rateLimited: true })) + } else { + setState((prev) => ({ ...prev, activeResults: [] })) + } + } }) .finally(() => { if (!isStale()) setActiveLoading(false) @@ -827,7 +871,11 @@ function useExplorationData(site, dashboardState, inViewport) { .then((response) => { if (isStale()) return setState((prev) => { - const next = { ...prev, activeResults: response?.next ?? [] } + const next = { + ...prev, + activeResults: response?.next ?? [], + rateLimited: false + } if (includeFunnel) { let newFunnel = response?.funnel ?? [] next.provisional = {} @@ -870,13 +918,17 @@ function useExplorationData(site, dashboardState, inViewport) { return next }) }) - .catch(() => { + .catch((err) => { if (isStale()) return - setState((prev) => ({ - ...prev, - activeResults: [], - ...(includeFunnel ? { funnel: [] } : {}) - })) + if (isRateLimitedError(err)) { + setState((prev) => ({ ...prev, rateLimited: true })) + } else { + setState((prev) => ({ + ...prev, + activeResults: [], + ...(includeFunnel ? { funnel: [] } : {}) + })) + } }) .finally(() => { if (!isStale()) setActiveLoading(false) @@ -891,6 +943,7 @@ function useExplorationData(site, dashboardState, inViewport) { direction: directionRef.current, activeLoading, layoutKey, + rateLimited: state.rateLimited, selectStep, reset, setDirection, @@ -940,6 +993,7 @@ export function FunnelExploration() { direction, activeLoading, layoutKey, + rateLimited, selectStep, reset, setDirection, @@ -1069,6 +1123,7 @@ export function FunnelExploration() { filter={isActive ? activeFilter : ''} onFilterChange={isActive ? setActiveFilter : () => {}} onSelect={(step) => selectStep(i, step)} + rateLimited={isActive && rateLimited} /> ) })} diff --git a/lib/plausible_web/controllers/api/stats_controller.ex b/lib/plausible_web/controllers/api/stats_controller.ex index b666b67d5eb8..0fac050b5673 100644 --- a/lib/plausible_web/controllers/api/stats_controller.ex +++ b/lib/plausible_web/controllers/api/stats_controller.ex @@ -140,12 +140,28 @@ defmodule PlausibleWeb.Api.StatsController do alias Plausible.Stats.Exploration @exploration_wildcard_disabled_flag :exploration_wildcard_disabled + @exploration_hourly_limit 600 + @exploration_burst_limit 10 + + 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, journey} <- parse_journey(steps), + 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)), include_wildcard? = @@ -158,6 +174,12 @@ defmodule PlausibleWeb.Api.StatsController do ) do json(conn, next_steps) else + {:error, :rate_limit} -> + conn + |> put_status(429) + |> json(%{error: "Too many exploration requests"}) + |> halt() + {:error, :journey_too_long} -> bad_request(conn, "The journey is too long") end @@ -166,13 +188,20 @@ defmodule PlausibleWeb.Api.StatsController do def exploration_funnel(conn, %{"journey" => steps} = params) do site = conn.assigns.site - with {:ok, journey} <- parse_journey(steps), + 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} -> + conn + |> put_status(429) + |> json(%{error: "Too many exploration requests"}) + |> halt() + {:error, :empty_journey} -> bad_request(conn, "We are unable to show funnels when journey is empty") @@ -183,18 +212,27 @@ defmodule PlausibleWeb.Api.StatsController do def exploration_featured_funnel(conn, params) do site = conn.assigns.site - query = Query.from(site, params, debug_metadata: debug_metadata(conn)) - include_wildcard? = - not FunWithFlags.enabled?(@exploration_wildcard_disabled_flag, for: site) + with :ok <- check_exploration_rate_limit(site) do + query = Query.from(site, params, debug_metadata: debug_metadata(conn)) + + include_wildcard? = + not FunWithFlags.enabled?(@exploration_wildcard_disabled_flag, for: site) - case Exploration.featured_funnel(site, query, - max_steps: params["max_steps"], - max_candidates: params["max_candidates"], - include_wildcard?: include_wildcard? - ) do - {:ok, funnel_and_candidates} -> json(conn, funnel_and_candidates) - {:error, :not_found} -> json(conn, []) + case Exploration.featured_funnel(site, query, + max_steps: params["max_steps"], + max_candidates: params["max_candidates"], + include_wildcard?: include_wildcard? + ) do + {:ok, funnel_and_candidates} -> json(conn, funnel_and_candidates) + {:error, :not_found} -> json(conn, []) + end + else + {:error, :rate_limit} -> + conn + |> put_status(429) + |> json(%{error: "Too many exploration requests"}) + |> halt() end end @@ -203,7 +241,8 @@ defmodule PlausibleWeb.Api.StatsController do search_term = params["search_term"] || "" include_funnel? = params["include_funnel"] == true - with {:ok, journey} <- parse_journey(steps), + 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)), include_wildcard? = @@ -217,6 +256,12 @@ defmodule PlausibleWeb.Api.StatsController do funnel <- maybe_include_funnel(include_funnel?, query, journey, direction) do json(conn, %{next: next_steps, funnel: funnel}) else + {:error, :rate_limit} -> + conn + |> put_status(429) + |> json(%{error: "Too many exploration requests"}) + |> halt() + _ -> bad_request(conn, "There was an error with your request") end From 737bb9735901163caa654aac1e1ed760387e84f0 Mon Sep 17 00:00:00 2001 From: Adam Rutkowski Date: Mon, 11 May 2026 07:38:40 +0200 Subject: [PATCH 05/19] Add rate limiting tests --- .../api/stats_controller/exploration_test.exs | 81 +++++++++++++++++++ 1 file changed, 81 insertions(+) diff --git a/test/plausible_web/controllers/api/stats_controller/exploration_test.exs b/test/plausible_web/controllers/api/stats_controller/exploration_test.exs index 589a0cc99cf1..b69617f2a955 100644 --- a/test/plausible_web/controllers/api/stats_controller/exploration_test.exs +++ b/test/plausible_web/controllers/api/stats_controller/exploration_test.exs @@ -438,5 +438,86 @@ defmodule PlausibleWeb.Api.StatsController.ExplorationTest do assert resp["funnel"] == [] end end + + describe "rate limiting" do + defp exhaust_exploration_rate_limit(site) do + Plausible.RateLimit.check_rate( + Plausible.RateLimit, + "exploration:#{site.id}", + :timer.hours(1), + 600, + 601 + ) + end + + test "returns 429 for exploration_next when rate limit exceeded", %{ + conn: conn, + site: site + } do + exhaust_exploration_rate_limit(site) + + journey = Jason.encode!([Journey.Step.new("pageview", "/home")]) + + resp = + conn + |> post("/api/stats/#{site.domain}/exploration/next/", %{ + "journey" => journey, + "period" => "all" + }) + + assert json_response(resp, 429) == %{"error" => "Too many exploration requests"} + end + + test "returns 429 for exploration_funnel when rate limit exceeded", %{ + conn: conn, + site: site + } do + exhaust_exploration_rate_limit(site) + + journey = Jason.encode!([Journey.Step.new("pageview", "/home")]) + + resp = + conn + |> post("/api/stats/#{site.domain}/exploration/funnel/", %{ + "journey" => journey, + "period" => "all" + }) + + assert json_response(resp, 429) == %{"error" => "Too many exploration requests"} + end + + test "returns 429 for exploration_featured_funnel when rate limit exceeded", %{ + conn: conn, + site: site + } do + exhaust_exploration_rate_limit(site) + + resp = + conn + |> post("/api/stats/#{site.domain}/exploration/featured-funnel/", %{ + "period" => "all" + }) + + assert json_response(resp, 429) == %{"error" => "Too many exploration requests"} + end + + test "returns 429 for exploration_next_with_funnel when rate limit exceeded", %{ + conn: conn, + site: site + } do + exhaust_exploration_rate_limit(site) + + journey = Jason.encode!([Journey.Step.new("pageview", "/home")]) + + resp = + conn + |> post("/api/stats/#{site.domain}/exploration/next-with-funnel/", %{ + "journey" => journey, + "period" => "all" + }) + + assert json_response(resp, 429) == %{"error" => "Too many exploration requests"} + end + end end end From fe0de40bbeb3f490774cda74e0e0dd85be824dd8 Mon Sep 17 00:00:00 2001 From: Adam Rutkowski Date: Mon, 11 May 2026 07:53:14 +0200 Subject: [PATCH 06/19] Format --- assets/js/dashboard/segments/segment-modals.tsx | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/assets/js/dashboard/segments/segment-modals.tsx b/assets/js/dashboard/segments/segment-modals.tsx index 647bc4b2c6d3..30e9175dc855 100644 --- a/assets/js/dashboard/segments/segment-modals.tsx +++ b/assets/js/dashboard/segments/segment-modals.tsx @@ -462,9 +462,13 @@ export const SegmentModal = ({ id }: { id: SavedSegment['id'] }) => { let error: ApiError | null = null if (!segment) { - error = new ApiError(`Segment not found with with ID "${id}"`, { - error: `Segment not found with with ID "${id}"` - }, 404) + error = new ApiError( + `Segment not found with with ID "${id}"`, + { + error: `Segment not found with with ID "${id}"` + }, + 404 + ) } const data = !error ? segment : null From 7bfff3de1287574dfab835f6e0e8c9fc79e180f0 Mon Sep 17 00:00:00 2001 From: Adam Rutkowski Date: Mon, 11 May 2026 07:54:11 +0200 Subject: [PATCH 07/19] Move function --- assets/js/dashboard/extra/exploration.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/assets/js/dashboard/extra/exploration.js b/assets/js/dashboard/extra/exploration.js index 4803216f917d..69ae662c888b 100644 --- a/assets/js/dashboard/extra/exploration.js +++ b/assets/js/dashboard/extra/exploration.js @@ -8,10 +8,6 @@ import React, { import LazyLoader from '../components/lazy-loader' import * as api from '../api' import { ApiError } from '../api' - -function isRateLimitedError(err) { - return err instanceof ApiError && err.status === 429 -} import * as url from '../util/url' import { Tooltip } from '../util/tooltip' import { useDebounce } from '../custom-hooks' @@ -66,6 +62,10 @@ function roundedPercentage(value, total) { return Math.round((percentage + Number.EPSILON) * 100) / 100 } +function isRateLimitedError(err) { + return err instanceof ApiError && err.status === 429 +} + // Two steps are identical when their identity fields match. function stepsEqual(a, b) { return ( From f5943225e84196766aa2d0c9ec43c9a190c646ad Mon Sep 17 00:00:00 2001 From: Adam Rutkowski Date: Tue, 12 May 2026 07:48:51 +0200 Subject: [PATCH 08/19] credo --- .../controllers/api/stats_controller.ex | 29 ++++++++++--------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/lib/plausible_web/controllers/api/stats_controller.ex b/lib/plausible_web/controllers/api/stats_controller.ex index 0fac050b5673..ef88d3e76eb8 100644 --- a/lib/plausible_web/controllers/api/stats_controller.ex +++ b/lib/plausible_web/controllers/api/stats_controller.ex @@ -213,21 +213,22 @@ defmodule PlausibleWeb.Api.StatsController do def exploration_featured_funnel(conn, params) do site = conn.assigns.site - with :ok <- check_exploration_rate_limit(site) do - query = Query.from(site, params, debug_metadata: debug_metadata(conn)) + case check_exploration_rate_limit(site) do + :ok -> + query = Query.from(site, params, debug_metadata: debug_metadata(conn)) + + include_wildcard? = + not FunWithFlags.enabled?(@exploration_wildcard_disabled_flag, for: site) + + case Exploration.featured_funnel(site, query, + max_steps: params["max_steps"], + max_candidates: params["max_candidates"], + include_wildcard?: include_wildcard? + ) do + {:ok, funnel_and_candidates} -> json(conn, funnel_and_candidates) + {:error, :not_found} -> json(conn, []) + end - include_wildcard? = - not FunWithFlags.enabled?(@exploration_wildcard_disabled_flag, for: site) - - case Exploration.featured_funnel(site, query, - max_steps: params["max_steps"], - max_candidates: params["max_candidates"], - include_wildcard?: include_wildcard? - ) do - {:ok, funnel_and_candidates} -> json(conn, funnel_and_candidates) - {:error, :not_found} -> json(conn, []) - end - else {:error, :rate_limit} -> conn |> put_status(429) From b6074d0fc83422cb5e881bb577ea7e0012f953a9 Mon Sep 17 00:00:00 2001 From: Adam Rutkowski Date: Tue, 12 May 2026 11:08:55 +0200 Subject: [PATCH 09/19] set active results to [] when rate limited --- assets/js/dashboard/extra/exploration.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/assets/js/dashboard/extra/exploration.js b/assets/js/dashboard/extra/exploration.js index 69ae662c888b..77c2c7989c28 100644 --- a/assets/js/dashboard/extra/exploration.js +++ b/assets/js/dashboard/extra/exploration.js @@ -796,7 +796,11 @@ function useExplorationData(site, dashboardState, inViewport) { .catch((err) => { if (!isStale()) { if (isRateLimitedError(err)) { - setState((prev) => ({ ...prev, rateLimited: true })) + setState((prev) => ({ + ...prev, + rateLimited: true, + activeResults: [] + })) } else { setState((prev) => ({ ...prev, activeResults: [] })) } From 5659dc61cdcea29bc3649bc24381742464bf434f Mon Sep 17 00:00:00 2001 From: Adam Rutkowski Date: Tue, 12 May 2026 11:12:07 +0200 Subject: [PATCH 10/19] !fixup --- assets/js/dashboard/extra/exploration.js | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/assets/js/dashboard/extra/exploration.js b/assets/js/dashboard/extra/exploration.js index 77c2c7989c28..16bfe2dfd165 100644 --- a/assets/js/dashboard/extra/exploration.js +++ b/assets/js/dashboard/extra/exploration.js @@ -814,7 +814,11 @@ function useExplorationData(site, dashboardState, inViewport) { .catch((err) => { if (isStale()) return if (isRateLimitedError(err)) { - setState((prev) => ({ ...prev, rateLimited: true })) + setState((prev) => ({ + ...prev, + rateLimited: true, + activeResults: [] + })) setActiveLoading(false) return } @@ -837,7 +841,11 @@ function useExplorationData(site, dashboardState, inViewport) { .catch((err) => { if (!isStale()) { if (isRateLimitedError(err)) { - setState((prev) => ({ ...prev, rateLimited: true })) + setState((prev) => ({ + ...prev, + rateLimited: true, + activeResults: [] + })) } else { setState((prev) => ({ ...prev, activeResults: [] })) } @@ -925,7 +933,11 @@ function useExplorationData(site, dashboardState, inViewport) { .catch((err) => { if (isStale()) return if (isRateLimitedError(err)) { - setState((prev) => ({ ...prev, rateLimited: true })) + setState((prev) => ({ + ...prev, + rateLimited: true, + activeResults: [] + })) } else { setState((prev) => ({ ...prev, From cb537e942455debf18175faaceb9265bd6a4ad89 Mon Sep 17 00:00:00 2001 From: Adam Rutkowski Date: Tue, 12 May 2026 12:35:25 +0200 Subject: [PATCH 11/19] Fix deselect all re-firing featured funnel preload --- assets/js/dashboard/extra/exploration.js | 1 - 1 file changed, 1 deletion(-) diff --git a/assets/js/dashboard/extra/exploration.js b/assets/js/dashboard/extra/exploration.js index 16bfe2dfd165..0d4345b92cbd 100644 --- a/assets/js/dashboard/extra/exploration.js +++ b/assets/js/dashboard/extra/exploration.js @@ -701,7 +701,6 @@ function useExplorationData(site, dashboardState, inViewport) { const reset = useCallback(() => { ++journeyVersionRef.current - preloadFiredRef.current = false setState(EMPTY_JOURNEY_STATE) }, []) From 64b1625b0b1eff059a467c2fb2d816adb27fecfa Mon Sep 17 00:00:00 2001 From: Adam Rutkowski Date: Tue, 12 May 2026 12:35:45 +0200 Subject: [PATCH 12/19] Prevent no-data flash on journey reset --- assets/js/dashboard/extra/exploration.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/assets/js/dashboard/extra/exploration.js b/assets/js/dashboard/extra/exploration.js index 0d4345b92cbd..4a69c3ea8aac 100644 --- a/assets/js/dashboard/extra/exploration.js +++ b/assets/js/dashboard/extra/exploration.js @@ -701,6 +701,7 @@ function useExplorationData(site, dashboardState, inViewport) { const reset = useCallback(() => { ++journeyVersionRef.current + setActiveLoading(true) setState(EMPTY_JOURNEY_STATE) }, []) @@ -1040,7 +1041,8 @@ export function FunnelExploration() { steps.length === 0 && funnel.length === 0 && activeResults.length === 0 && - !activeFilter + !activeFilter && + !rateLimited const lastFunnelStep = funnel.length >= 2 ? funnel[funnel.length - 1] : null const overallConversionRate = lastFunnelStep?.conversion_rate ?? null From c172424b073d917897ce532d1985245e25244c3a Mon Sep 17 00:00:00 2001 From: Adam Rutkowski Date: Tue, 12 May 2026 12:36:00 +0200 Subject: [PATCH 13/19] Clear rate limit flag on step select/deselect --- assets/js/dashboard/extra/exploration.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/assets/js/dashboard/extra/exploration.js b/assets/js/dashboard/extra/exploration.js index 4a69c3ea8aac..759a325aa3cc 100644 --- a/assets/js/dashboard/extra/exploration.js +++ b/assets/js/dashboard/extra/exploration.js @@ -665,7 +665,8 @@ function useExplorationData(site, dashboardState, inViewport) { activeResults: [], activeFilter: '', frozen: truncateFrozenAt(prev.frozen, columnIndex), - provisional: {} + provisional: {}, + rateLimited: false } } @@ -694,7 +695,8 @@ function useExplorationData(site, dashboardState, inViewport) { columnIndex, sourceResults, prev.funnel - ) + ), + rateLimited: false } }) }, []) From 1528b00aebdc9e6a8a3d7522a8df23f4da5d5555 Mon Sep 17 00:00:00 2001 From: Adam Rutkowski Date: Tue, 12 May 2026 12:37:12 +0200 Subject: [PATCH 14/19] Add try again link to rate limit message --- assets/js/dashboard/extra/exploration.js | 28 +++++++++++++++++++----- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/assets/js/dashboard/extra/exploration.js b/assets/js/dashboard/extra/exploration.js index 759a325aa3cc..c77f81b589af 100644 --- a/assets/js/dashboard/extra/exploration.js +++ b/assets/js/dashboard/extra/exploration.js @@ -455,12 +455,19 @@ function ColumnEmptyState({ filter, colIndex, direction, - rateLimited + rateLimited, + onRetry }) { if (active && rateLimited) { return ( - - Too many requests, please wait a moment and try again + + Too many requests, please wait a moment and{' '} + ) } @@ -514,7 +521,8 @@ function ExplorationColumn({ filter, onFilterChange, onSelect, - rateLimited + rateLimited, + onRetry }) { const debouncedFilterChange = useDebounce((e) => onFilterChange(e.target.value) @@ -581,6 +589,7 @@ function ExplorationColumn({ colIndex={colIndex} direction={direction} rateLimited={rateLimited} + onRetry={onRetry} /> ) : ( @@ -626,6 +635,7 @@ function provisionalEntry(step, columnIndex, sourceResults, existingFunnel) { function useExplorationData(site, dashboardState, inViewport) { const [state, setState] = useState(EMPTY_JOURNEY_STATE) const [activeLoading, setActiveLoading] = useState(false) + const [retryCount, setRetryCount] = useState(0) // Incremented whenever the dashboardState or site changes so that // PathConnectors re-runs its layout effect and recalculates connector // geometry against the freshly rendered DOM. Steps alone do not change @@ -951,11 +961,16 @@ function useExplorationData(site, dashboardState, inViewport) { .finally(() => { if (!isStale()) setActiveLoading(false) }) - }, [site, dashboardState, state.steps, state.activeFilter, inViewport]) + }, [site, dashboardState, state.steps, state.activeFilter, inViewport, retryCount]) // direction is intentionally excluded from the dep array. It lives in a ref // and resets state, which does appear above, so the state update itself // drives the re-run without double-firing. + const retry = useCallback(() => { + setState((prev) => ({ ...prev, rateLimited: false })) + setRetryCount((c) => c + 1) + }, []) + return { state, direction: directionRef.current, @@ -964,6 +979,7 @@ function useExplorationData(site, dashboardState, inViewport) { rateLimited: state.rateLimited, selectStep, reset, + retry, setDirection, setActiveFilter } @@ -1014,6 +1030,7 @@ export function FunnelExploration() { rateLimited, selectStep, reset, + retry, setDirection, setActiveFilter } = useExplorationData(site, dashboardState, inViewport) @@ -1143,6 +1160,7 @@ export function FunnelExploration() { onFilterChange={isActive ? setActiveFilter : () => {}} onSelect={(step) => selectStep(i, step)} rateLimited={isActive && rateLimited} + onRetry={retry} /> ) })} From a799e5af1b7eb3dfe60674d05b5f5dac44f24b63 Mon Sep 17 00:00:00 2001 From: Adam Rutkowski Date: Tue, 12 May 2026 12:39:36 +0200 Subject: [PATCH 15/19] Clear provisional on rate limit when funnel was expected --- assets/js/dashboard/extra/exploration.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/assets/js/dashboard/extra/exploration.js b/assets/js/dashboard/extra/exploration.js index c77f81b589af..99b7024f3b53 100644 --- a/assets/js/dashboard/extra/exploration.js +++ b/assets/js/dashboard/extra/exploration.js @@ -948,7 +948,8 @@ function useExplorationData(site, dashboardState, inViewport) { setState((prev) => ({ ...prev, rateLimited: true, - activeResults: [] + activeResults: [], + ...(includeFunnel ? { provisional: {} } : {}) })) } else { setState((prev) => ({ From badfff6ece5d61b0cde2ba8e4a9ad495c7c8c578 Mon Sep 17 00:00:00 2001 From: Adam Rutkowski Date: Tue, 12 May 2026 12:42:44 +0200 Subject: [PATCH 16/19] Format --- assets/js/dashboard/extra/exploration.js | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/assets/js/dashboard/extra/exploration.js b/assets/js/dashboard/extra/exploration.js index 99b7024f3b53..ee9f85b23298 100644 --- a/assets/js/dashboard/extra/exploration.js +++ b/assets/js/dashboard/extra/exploration.js @@ -962,7 +962,14 @@ function useExplorationData(site, dashboardState, inViewport) { .finally(() => { if (!isStale()) setActiveLoading(false) }) - }, [site, dashboardState, state.steps, state.activeFilter, inViewport, retryCount]) + }, [ + site, + dashboardState, + state.steps, + state.activeFilter, + inViewport, + retryCount + ]) // direction is intentionally excluded from the dep array. It lives in a ref // and resets state, which does appear above, so the state update itself // drives the re-run without double-firing. From 59f4afcfe7bc51014701e2c6d8d3e9f71fc167b6 Mon Sep 17 00:00:00 2001 From: Adam Rutkowski Date: Tue, 12 May 2026 12:46:55 +0200 Subject: [PATCH 17/19] Extract too_many_requests/2 helper --- .../controllers/api/stats_controller.ex | 27 ++++++++----------- 1 file changed, 11 insertions(+), 16 deletions(-) diff --git a/lib/plausible_web/controllers/api/stats_controller.ex b/lib/plausible_web/controllers/api/stats_controller.ex index ef88d3e76eb8..2b40f755faaf 100644 --- a/lib/plausible_web/controllers/api/stats_controller.ex +++ b/lib/plausible_web/controllers/api/stats_controller.ex @@ -175,10 +175,7 @@ defmodule PlausibleWeb.Api.StatsController do json(conn, next_steps) else {:error, :rate_limit} -> - conn - |> put_status(429) - |> json(%{error: "Too many exploration requests"}) - |> halt() + too_many_requests(conn, "Too many exploration requests") {:error, :journey_too_long} -> bad_request(conn, "The journey is too long") @@ -197,10 +194,7 @@ defmodule PlausibleWeb.Api.StatsController do json(conn, funnel) else {:error, :rate_limit} -> - conn - |> put_status(429) - |> json(%{error: "Too many exploration requests"}) - |> halt() + too_many_requests(conn, "Too many exploration requests") {:error, :empty_journey} -> bad_request(conn, "We are unable to show funnels when journey is empty") @@ -230,10 +224,7 @@ defmodule PlausibleWeb.Api.StatsController do end {:error, :rate_limit} -> - conn - |> put_status(429) - |> json(%{error: "Too many exploration requests"}) - |> halt() + too_many_requests(conn, "Too many exploration requests") end end @@ -258,10 +249,7 @@ defmodule PlausibleWeb.Api.StatsController do json(conn, %{next: next_steps, funnel: funnel}) else {:error, :rate_limit} -> - conn - |> put_status(429) - |> json(%{error: "Too many exploration requests"}) - |> halt() + too_many_requests(conn, "Too many exploration requests") _ -> bad_request(conn, "There was an error with your request") @@ -1453,6 +1441,13 @@ defmodule PlausibleWeb.Api.StatsController do |> halt() end + defp too_many_requests(conn, message) do + conn + |> put_status(429) + |> json(%{error: message}) + |> halt() + end + def comparison_query(query) do if query.include.compare do Comparisons.get_comparison_query(query) From 64f6f7d7a9bb4ab80849a9a5d955c62e289ef8ba Mon Sep 17 00:00:00 2001 From: Adam Rutkowski Date: Tue, 12 May 2026 12:51:17 +0200 Subject: [PATCH 18/19] Clean up error helpers usage --- lib/plausible_web/controllers/api/helpers.ex | 6 ++- .../controllers/api/stats_controller.ex | 42 ++++++------------- 2 files changed, 17 insertions(+), 31 deletions(-) diff --git a/lib/plausible_web/controllers/api/helpers.ex b/lib/plausible_web/controllers/api/helpers.ex index 109e3a1263dc..abc09449aaa3 100644 --- a/lib/plausible_web/controllers/api/helpers.ex +++ b/lib/plausible_web/controllers/api/helpers.ex @@ -15,10 +15,12 @@ defmodule PlausibleWeb.Api.Helpers do |> halt() end - def bad_request(conn, msg) do + def bad_request(conn, msg, extra \\ %{}) do + payload = Map.merge(extra, %{error: msg}) + conn |> put_status(400) - |> Phoenix.Controller.json(%{error: msg}) + |> Phoenix.Controller.json(payload) |> halt() end diff --git a/lib/plausible_web/controllers/api/stats_controller.ex b/lib/plausible_web/controllers/api/stats_controller.ex index 2b40f755faaf..eebc398dd77f 100644 --- a/lib/plausible_web/controllers/api/stats_controller.ex +++ b/lib/plausible_web/controllers/api/stats_controller.ex @@ -52,7 +52,7 @@ defmodule PlausibleWeb.Api.StatsController do json(conn, Plausible.Stats.query(site, query)) else - {:error, %QueryError{message: message}} -> bad_request(conn, message) + {:error, %QueryError{message: message}} -> H.bad_request(conn, message) end end @@ -175,10 +175,10 @@ defmodule PlausibleWeb.Api.StatsController do json(conn, next_steps) else {:error, :rate_limit} -> - too_many_requests(conn, "Too many exploration requests") + H.too_many_requests(conn, "Too many exploration requests") {:error, :journey_too_long} -> - bad_request(conn, "The journey is too long") + H.bad_request(conn, "The journey is too long") end end @@ -194,13 +194,13 @@ defmodule PlausibleWeb.Api.StatsController do json(conn, funnel) else {:error, :rate_limit} -> - too_many_requests(conn, "Too many exploration requests") + H.too_many_requests(conn, "Too many exploration requests") {:error, :empty_journey} -> - bad_request(conn, "We are unable to show funnels when journey is empty") + H.bad_request(conn, "We are unable to show funnels when journey is empty") {:error, :journey_too_long} -> - bad_request(conn, "The journey is too long") + H.bad_request(conn, "The journey is too long") end end @@ -224,7 +224,7 @@ defmodule PlausibleWeb.Api.StatsController do end {:error, :rate_limit} -> - too_many_requests(conn, "Too many exploration requests") + H.too_many_requests(conn, "Too many exploration requests") end end @@ -249,10 +249,10 @@ defmodule PlausibleWeb.Api.StatsController do json(conn, %{next: next_steps, funnel: funnel}) else {:error, :rate_limit} -> - too_many_requests(conn, "Too many exploration requests") + H.too_many_requests(conn, "Too many exploration requests") _ -> - bad_request(conn, "There was an error with your request") + H.bad_request(conn, "There was an error with your request") end end @@ -305,7 +305,7 @@ defmodule PlausibleWeb.Api.StatsController do json(conn, funnel) else {:error, {:invalid_funnel_query, due_to}} -> - bad_request( + H.bad_request( conn, "We are unable to show funnels when the dashboard is filtered by #{due_to}", %{ @@ -326,7 +326,7 @@ defmodule PlausibleWeb.Api.StatsController do ) _ -> - bad_request(conn, "There was an error with your request") + H.bad_request(conn, "There was an error with your request") end end @@ -1360,7 +1360,7 @@ defmodule PlausibleWeb.Api.StatsController do defp date_validation_plug(conn, _opts) do case parse_date_params(conn.params) do {:ok, _dates} -> conn - {:error, message} when is_binary(message) -> bad_request(conn, message) + {:error, message} when is_binary(message) -> H.bad_request(conn, message) end end @@ -1377,7 +1377,7 @@ defmodule PlausibleWeb.Api.StatsController do conn :error -> - bad_request( + H.bad_request( conn, "The first filter must be for the segment with id #{segment_id}" ) @@ -1432,22 +1432,6 @@ defmodule PlausibleWeb.Api.StatsController do end) end - defp bad_request(conn, message, extra \\ %{}) do - payload = Map.merge(extra, %{error: message}) - - conn - |> put_status(400) - |> json(payload) - |> halt() - end - - defp too_many_requests(conn, message) do - conn - |> put_status(429) - |> json(%{error: message}) - |> halt() - end - def comparison_query(query) do if query.include.compare do Comparisons.get_comparison_query(query) From f5f0ccc309b39543516ade24fab636832fb56155 Mon Sep 17 00:00:00 2001 From: Adrian Gruntkowski Date: Tue, 12 May 2026 13:44:38 +0200 Subject: [PATCH 19/19] Add missing @moduledoc --- lib/plausible_web/controllers/api/helpers.ex | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/lib/plausible_web/controllers/api/helpers.ex b/lib/plausible_web/controllers/api/helpers.ex index abc09449aaa3..2e8bb0162a59 100644 --- a/lib/plausible_web/controllers/api/helpers.ex +++ b/lib/plausible_web/controllers/api/helpers.ex @@ -1,4 +1,8 @@ defmodule PlausibleWeb.Api.Helpers do + @moduledoc """ + Helper functions for API controllers. + """ + import Plug.Conn def unauthorized(conn, msg) do