From d51ea8963aa748ae9b07e9642f391d47f7d44777 Mon Sep 17 00:00:00 2001 From: Adrian Gruntkowski Date: Thu, 9 Apr 2026 18:17:20 +0200 Subject: [PATCH 01/21] Implement basics of exploration logic --- extra/lib/plausible/stats/exploration.ex | 169 ++++++++++++++++++++++ test/plausible/stats/exploration_test.exs | 101 +++++++++++++ 2 files changed, 270 insertions(+) create mode 100644 extra/lib/plausible/stats/exploration.ex create mode 100644 test/plausible/stats/exploration_test.exs diff --git a/extra/lib/plausible/stats/exploration.ex b/extra/lib/plausible/stats/exploration.ex new file mode 100644 index 000000000000..b28603f1d2de --- /dev/null +++ b/extra/lib/plausible/stats/exploration.ex @@ -0,0 +1,169 @@ +defmodule Plausible.Stats.Exploration do + @moduledoc """ + Query logic for user journey exploration. + """ + + defmodule Journey.Step do + @moduledoc false + + @type t() :: %__MODULE__{} + + defstruct [:name, :pathname] + end + + import Ecto.Query + import Plausible.Stats.SQL.Fragments + + alias Plausible.ClickhouseRepo + alias Plausible.Stats.Base + alias Plausible.Stats.Query + + @type journey() :: [Journey.Step.t()] + + @type funnel_step() :: %{ + step: Journey.Step.t(), + visitors: pos_integer() + } + + @spec next_steps(Query.t(), journey()) :: + {:ok, [funnel_step()]} | {:error, :empty_journey} + def next_steps(_query, []), do: {:error, :empty_journey} + + def next_steps(query, journey) do + query + |> Base.base_event_query() + |> next_steps_query(journey) + |> ClickhouseRepo.all() + |> then(&{:ok, &1}) + end + + @spec journey_funnel(Query.t(), journey()) :: + {:ok, [funnel_step()]} | {:error, :empty_journey} + def journey_funnel(_query, []), do: {:error, :empty_journey} + + def journey_funnel(query, journey) do + query + |> Base.base_event_query() + |> journey_funnel_query(journey) + |> ClickhouseRepo.all() + |> to_steps(journey) + |> then(&{:ok, &1}) + end + + defp next_steps_query(query, steps) do + next_step_idx = length(steps) + 1 + q_steps = steps_query(query, next_step_idx) + + next_name = :"name#{next_step_idx}" + next_pathname = :"pathname#{next_step_idx}" + + q_next = + from(s in subquery(q_steps), + where: + field(s, ^next_name) != "" and + # avoid cycling back to the beginning of the exploration + (field(s, ^next_name) != s.name1 or field(s, ^next_pathname) != s.pathname1), + select: %{ + step: %Journey.Step{ + name: selected_as(field(s, ^next_name), :next_name), + pathname: selected_as(field(s, ^next_pathname), :next_pathname) + }, + visitors: selected_as(scale_sample(fragment("uniq(?)", s.user_id)), :count) + }, + group_by: [selected_as(:next_name), selected_as(:next_pathname)], + order_by: [ + desc: selected_as(:count), + asc: selected_as(:next_pathname), + asc: selected_as(:next_name) + ], + limit: 10 + ) + + steps + |> Enum.with_index() + |> Enum.reduce(q_next, fn {step, idx}, q -> + name = :"name#{idx + 1}" + pathname = :"pathname#{idx + 1}" + + from(s in q, + where: field(s, ^name) == ^step.name and field(s, ^pathname) == ^step.pathname + ) + end) + end + + defp journey_funnel_query(query, steps) do + q_steps = steps_query(query, length(steps)) + + [first_step | steps] = steps + + q_funnel = + from(s in subquery(q_steps), + where: s.name1 == ^first_step.name and s.pathname1 == ^first_step.pathname, + select: %{ + 1 => scale_sample(fragment("uniq(?)", s.user_id)) + } + ) + + steps + |> Enum.with_index() + |> Enum.reduce(q_funnel, fn {_step, idx}, q -> + current_steps = Enum.take(steps, idx + 1) + + step_conditions = + current_steps + |> Enum.with_index() + |> Enum.reduce(dynamic(true), fn {step, idx}, acc -> + step_condition = step_condition(step, idx + 2) + dynamic([q], fragment("? and ?", ^acc, ^step_condition)) + end) + + step_count = + dynamic([e], scale_sample(fragment("uniqIf(?, ?)", e.user_id, ^step_conditions))) + + from(e in q, select_merge: ^%{(idx + 2) => step_count}) + end) + end + + defp steps_query(query, steps) when is_integer(steps) do + q_steps = + from(e in query, + windows: [step_window: [partition_by: e.user_id, order_by: e.timestamp]], + select: %{ + user_id: e.user_id, + _sample_factor: e._sample_factor, + name1: e.name, + pathname1: e.pathname + }, + where: e.name != "engagement", + order_by: e.timestamp + ) + + Enum.reduce(1..(steps - 1), q_steps, fn idx, q -> + from(e in q, + select_merge: %{ + ^:"name#{idx + 1}" => lead(e.name, ^idx) |> over(:step_window), + ^:"pathname#{idx + 1}" => lead(e.pathname, ^idx) |> over(:step_window) + } + ) + end) + end + + defp step_condition(step, count) do + dynamic( + [s], + field(s, ^:"name#{count}") == ^step.name and + field(s, ^:"pathname#{count}") == ^step.pathname + ) + end + + defp to_steps([result], journey) do + journey + |> Enum.with_index() + |> Enum.map(fn {step, idx} -> + %{ + step: step, + visitors: Map.get(result, idx + 1, 0) + } + end) + end +end diff --git a/test/plausible/stats/exploration_test.exs b/test/plausible/stats/exploration_test.exs new file mode 100644 index 000000000000..adca5e85510a --- /dev/null +++ b/test/plausible/stats/exploration_test.exs @@ -0,0 +1,101 @@ +defmodule Plausible.Stats.ExplorationTest do + use Plausible.DataCase + + alias Plausible.Stats.Exploration + alias Plausible.Stats.QueryBuilder + + setup do + site = new_site() + + now = DateTime.utc_now() + + populate_stats(site, [ + build(:pageview, + user_id: 123, + pathname: "/home", + timestamp: DateTime.shift(now, minute: -300) + ), + build(:pageview, + user_id: 123, + pathname: "/login", + timestamp: DateTime.shift(now, minute: -270) + ), + build(:pageview, + user_id: 123, + pathname: "/home", + timestamp: DateTime.shift(now, minute: -30) + ), + build(:pageview, + user_id: 123, + pathname: "/login", + timestamp: DateTime.shift(now, minute: -25) + ), + build(:pageview, + user_id: 123, + pathname: "/logout", + timestamp: DateTime.shift(now, minute: -20) + ), + build(:pageview, + user_id: 124, + pathname: "/home", + timestamp: DateTime.shift(now, minute: -30) + ), + build(:pageview, + user_id: 124, + pathname: "/login", + timestamp: DateTime.shift(now, minute: -25) + ), + build(:pageview, + user_id: 124, + pathname: "/docs", + timestamp: DateTime.shift(now, minute: -20) + ), + build(:pageview, + user_id: 124, + pathname: "/logout", + timestamp: DateTime.shift(now, minute: -15) + ) + ]) + + {:ok, site: site} + end + + describe "journey_funnel" do + test "it works", %{site: site} do + query = QueryBuilder.build!(site, input_date_range: :all) + + journey = [ + %Exploration.Journey.Step{name: "pageview", pathname: "/home"}, + %Exploration.Journey.Step{name: "pageview", pathname: "/login"}, + %Exploration.Journey.Step{name: "pageview", pathname: "/logout"} + ] + + assert {:ok, [step1, step2, step3]} = Exploration.journey_funnel(query, journey) + + assert step1.step.pathname == "/home" + assert step1.visitors == 2 + assert step2.step.pathname == "/login" + assert step2.visitors == 2 + assert step3.step.pathname == "/logout" + assert step3.visitors == 1 + end + end + + describe "next_steps" do + test "it works", %{site: site} do + query = QueryBuilder.build!(site, input_date_range: :all) + + journey = [ + %Exploration.Journey.Step{name: "pageview", pathname: "/home"}, + %Exploration.Journey.Step{name: "pageview", pathname: "/login"} + ] + + assert {:ok, [next_step1, next_step2]} = Exploration.next_steps(query, journey) + + assert next_step1.step.pathname == "/docs" + assert next_step1.visitors == 1 + assert next_step2.step.pathname == "/logout" + assert next_step2.visitors == 1 + end + end +end From 6fdc6b6cf8502c703de57b587a3ad6d6d9d00802 Mon Sep 17 00:00:00 2001 From: Adrian Gruntkowski Date: Thu, 9 Apr 2026 21:32:13 +0200 Subject: [PATCH 02/21] Add simplistic search capability --- extra/lib/plausible/stats/exploration.ex | 27 +++++++++++++++++------ test/plausible/stats/exploration_test.exs | 14 ++++++++++++ 2 files changed, 34 insertions(+), 7 deletions(-) diff --git a/extra/lib/plausible/stats/exploration.ex b/extra/lib/plausible/stats/exploration.ex index b28603f1d2de..4679cbeef8dd 100644 --- a/extra/lib/plausible/stats/exploration.ex +++ b/extra/lib/plausible/stats/exploration.ex @@ -25,14 +25,14 @@ defmodule Plausible.Stats.Exploration do visitors: pos_integer() } - @spec next_steps(Query.t(), journey()) :: + @spec next_steps(Query.t(), journey(), String.t()) :: {:ok, [funnel_step()]} | {:error, :empty_journey} def next_steps(_query, []), do: {:error, :empty_journey} - def next_steps(query, journey) do + def next_steps(query, journey, search_term \\ "") do query |> Base.base_event_query() - |> next_steps_query(journey) + |> next_steps_query(journey, search_term) |> ClickhouseRepo.all() |> then(&{:ok, &1}) end @@ -50,7 +50,7 @@ defmodule Plausible.Stats.Exploration do |> then(&{:ok, &1}) end - defp next_steps_query(query, steps) do + defp next_steps_query(query, steps, search_term) do next_step_idx = length(steps) + 1 q_steps = steps_query(query, next_step_idx) @@ -59,10 +59,10 @@ defmodule Plausible.Stats.Exploration do q_next = from(s in subquery(q_steps), + # avoid cycling back to the beginning of the exploration where: - field(s, ^next_name) != "" and - # avoid cycling back to the beginning of the exploration - (field(s, ^next_name) != s.name1 or field(s, ^next_pathname) != s.pathname1), + selected_as(:next_name) != "" and + (selected_as(:next_name) != s.name1 or selected_as(:next_pathname) != s.pathname1), select: %{ step: %Journey.Step{ name: selected_as(field(s, ^next_name), :next_name), @@ -79,6 +79,19 @@ defmodule Plausible.Stats.Exploration do limit: 10 ) + q_next = + case String.trim(search_term) do + term when byte_size(term) > 2 -> + from(s in q_next, + where: + ilike(selected_as(:next_name), ^"%#{term}%") or + ilike(selected_as(:next_pathname), ^"%#{term}%") + ) + + _ -> + q_next + end + steps |> Enum.with_index() |> Enum.reduce(q_next, fn {step, idx}, q -> diff --git a/test/plausible/stats/exploration_test.exs b/test/plausible/stats/exploration_test.exs index adca5e85510a..ad30e78154bf 100644 --- a/test/plausible/stats/exploration_test.exs +++ b/test/plausible/stats/exploration_test.exs @@ -97,5 +97,19 @@ defmodule Plausible.Stats.ExplorationTest do assert next_step2.step.pathname == "/logout" assert next_step2.visitors == 1 end + + test "it filters", %{site: site} do + query = QueryBuilder.build!(site, input_date_range: :all) + + journey = [ + %Exploration.Journey.Step{name: "pageview", pathname: "/home"}, + %Exploration.Journey.Step{name: "pageview", pathname: "/login"} + ] + + assert {:ok, [next_step]} = Exploration.next_steps(query, journey, "doc") + + assert next_step.step.pathname == "/docs" + assert next_step.visitors == 1 + end end end From 773cbb9a88f6a8093cd5660afd6e995320476f9b Mon Sep 17 00:00:00 2001 From: Adrian Gruntkowski Date: Fri, 10 Apr 2026 11:33:41 +0200 Subject: [PATCH 03/21] Implement calculation of dropoff as a number and a percentage --- extra/lib/plausible/stats/exploration.ex | 61 +++++++++++++++++++---- test/plausible/stats/exploration_test.exs | 6 +++ 2 files changed, 58 insertions(+), 9 deletions(-) diff --git a/extra/lib/plausible/stats/exploration.ex b/extra/lib/plausible/stats/exploration.ex index 4679cbeef8dd..710a709ed6e0 100644 --- a/extra/lib/plausible/stats/exploration.ex +++ b/extra/lib/plausible/stats/exploration.ex @@ -20,13 +20,20 @@ defmodule Plausible.Stats.Exploration do @type journey() :: [Journey.Step.t()] - @type funnel_step() :: %{ + @type next_step() :: %{ step: Journey.Step.t(), visitors: pos_integer() } + @type funnel_step() :: %{ + step: Journey.Step.t(), + visitors: non_neg_integer(), + dropoff: non_neg_integer(), + dropoff_percentage: String.t() + } + @spec next_steps(Query.t(), journey(), String.t()) :: - {:ok, [funnel_step()]} | {:error, :empty_journey} + {:ok, [next_step()]} | {:error, :empty_journey} def next_steps(_query, []), do: {:error, :empty_journey} def next_steps(query, journey, search_term \\ "") do @@ -46,7 +53,7 @@ defmodule Plausible.Stats.Exploration do |> Base.base_event_query() |> journey_funnel_query(journey) |> ClickhouseRepo.all() - |> to_steps(journey) + |> to_funnel(journey) |> then(&{:ok, &1}) end @@ -169,14 +176,50 @@ defmodule Plausible.Stats.Exploration do ) end - defp to_steps([result], journey) do + defp to_funnel([result], journey) do journey |> Enum.with_index() - |> Enum.map(fn {step, idx} -> - %{ - step: step, - visitors: Map.get(result, idx + 1, 0) - } + |> Enum.reduce(%{funnel: [], visitors_at_previous: nil}, fn {step, idx}, acc -> + current_visitors = Map.get(result, idx + 1, 0) + + dropoff = + if acc.visitors_at_previous, do: acc.visitors_at_previous - current_visitors, else: 0 + + dropoff_percentage = percentage(dropoff, acc.visitors_at_previous) + + funnel = [ + %{ + step: step, + visitors: current_visitors, + dropoff: dropoff, + dropoff_percentage: dropoff_percentage + } + | acc.funnel + ] + + %{acc | funnel: funnel, visitors_at_previous: current_visitors} end) + |> Map.fetch!(:funnel) + |> Enum.reverse() + end + + defp percentage(x, y) when x in [0, nil] or y in [0, nil] do + "0" + end + + defp percentage(x, y) do + result = + x + |> Decimal.div(y) + |> Decimal.mult(100) + |> Decimal.round(2) + |> Decimal.to_string() + + case result do + <> -> compact + <> -> compact + <> -> compact + decimal -> decimal + end end end diff --git a/test/plausible/stats/exploration_test.exs b/test/plausible/stats/exploration_test.exs index ad30e78154bf..0231e9876529 100644 --- a/test/plausible/stats/exploration_test.exs +++ b/test/plausible/stats/exploration_test.exs @@ -74,10 +74,16 @@ defmodule Plausible.Stats.ExplorationTest do assert step1.step.pathname == "/home" assert step1.visitors == 2 + assert step1.dropoff == 0 + assert step1.dropoff_percentage == "0" assert step2.step.pathname == "/login" assert step2.visitors == 2 + assert step2.dropoff == 0 + assert step2.dropoff_percentage == "0" assert step3.step.pathname == "/logout" assert step3.visitors == 1 + assert step3.dropoff == 1 + assert step3.dropoff_percentage == "50" end end From e1789702a7f8fe6db31fbad32eba5b002ecd1464 Mon Sep 17 00:00:00 2001 From: Adrian Gruntkowski Date: Fri, 10 Apr 2026 12:46:30 +0200 Subject: [PATCH 04/21] Support querying for the first step --- extra/lib/plausible/stats/exploration.ex | 85 ++++++++++++++++------- test/plausible/stats/exploration_test.exs | 16 +++++ 2 files changed, 77 insertions(+), 24 deletions(-) diff --git a/extra/lib/plausible/stats/exploration.ex b/extra/lib/plausible/stats/exploration.ex index 710a709ed6e0..9df9599556fa 100644 --- a/extra/lib/plausible/stats/exploration.ex +++ b/extra/lib/plausible/stats/exploration.ex @@ -33,10 +33,18 @@ defmodule Plausible.Stats.Exploration do } @spec next_steps(Query.t(), journey(), String.t()) :: - {:ok, [next_step()]} | {:error, :empty_journey} - def next_steps(_query, []), do: {:error, :empty_journey} + {:ok, [next_step()]} + def next_steps(query, journey, search_term \\ "") - def next_steps(query, journey, search_term \\ "") do + def next_steps(query, [], search_term) do + query + |> Base.base_event_query() + |> next_steps_first_query(search_term) + |> ClickhouseRepo.all() + |> then(&{:ok, &1}) + end + + def next_steps(query, journey, search_term) do query |> Base.base_event_query() |> next_steps_query(journey, search_term) @@ -57,6 +65,29 @@ defmodule Plausible.Stats.Exploration do |> then(&{:ok, &1}) end + defp next_steps_first_query(query, search_term) do + q_steps = steps_query(query, 1) + + from(s in subquery(q_steps), + where: selected_as(:next_name) != "", + select: %{ + step: %Journey.Step{ + name: selected_as(s.name1, :next_name), + pathname: selected_as(s.pathname1, :next_pathname) + }, + visitors: selected_as(scale_sample(fragment("uniq(?)", s.user_id)), :count) + }, + group_by: [selected_as(:next_name), selected_as(:next_pathname)], + order_by: [ + desc: selected_as(:count), + asc: selected_as(:next_pathname), + asc: selected_as(:next_name) + ], + limit: 10 + ) + |> maybe_search(search_term) + end + defp next_steps_query(query, steps, search_term) do next_step_idx = length(steps) + 1 q_steps = steps_query(query, next_step_idx) @@ -85,19 +116,7 @@ defmodule Plausible.Stats.Exploration do ], limit: 10 ) - - q_next = - case String.trim(search_term) do - term when byte_size(term) > 2 -> - from(s in q_next, - where: - ilike(selected_as(:next_name), ^"%#{term}%") or - ilike(selected_as(:next_pathname), ^"%#{term}%") - ) - - _ -> - q_next - end + |> maybe_search(search_term) steps |> Enum.with_index() @@ -158,14 +177,18 @@ defmodule Plausible.Stats.Exploration do order_by: e.timestamp ) - Enum.reduce(1..(steps - 1), q_steps, fn idx, q -> - from(e in q, - select_merge: %{ - ^:"name#{idx + 1}" => lead(e.name, ^idx) |> over(:step_window), - ^:"pathname#{idx + 1}" => lead(e.pathname, ^idx) |> over(:step_window) - } - ) - end) + if steps > 1 do + Enum.reduce(1..(steps - 1), q_steps, fn idx, q -> + from(e in q, + select_merge: %{ + ^:"name#{idx + 1}" => lead(e.name, ^idx) |> over(:step_window), + ^:"pathname#{idx + 1}" => lead(e.pathname, ^idx) |> over(:step_window) + } + ) + end) + else + q_steps + end end defp step_condition(step, count) do @@ -176,6 +199,20 @@ defmodule Plausible.Stats.Exploration do ) end + defp maybe_search(query, search_term) do + case String.trim(search_term) do + term when byte_size(term) > 2 -> + from(s in query, + where: + ilike(selected_as(:next_name), ^"%#{term}%") or + ilike(selected_as(:next_pathname), ^"%#{term}%") + ) + + _ -> + query + end + end + defp to_funnel([result], journey) do journey |> Enum.with_index() diff --git a/test/plausible/stats/exploration_test.exs b/test/plausible/stats/exploration_test.exs index 0231e9876529..b53d4df53f03 100644 --- a/test/plausible/stats/exploration_test.exs +++ b/test/plausible/stats/exploration_test.exs @@ -104,6 +104,22 @@ defmodule Plausible.Stats.ExplorationTest do assert next_step2.visitors == 1 end + test "it works for an empty journey", %{site: site} do + query = QueryBuilder.build!(site, input_date_range: :all) + + assert {:ok, [next_step1, next_step2, next_step3, next_step4]} = + Exploration.next_steps(query, []) + + assert next_step1.step.pathname == "/home" + assert next_step1.visitors == 2 + assert next_step2.step.pathname == "/login" + assert next_step2.visitors == 2 + assert next_step3.step.pathname == "/logout" + assert next_step3.visitors == 2 + assert next_step4.step.pathname == "/docs" + assert next_step4.visitors == 1 + end + test "it filters", %{site: site} do query = QueryBuilder.build!(site, input_date_range: :all) From 9baa2f3c14e7792cf7284a48b35cc560486050ba Mon Sep 17 00:00:00 2001 From: Adrian Gruntkowski Date: Fri, 10 Apr 2026 13:36:48 +0200 Subject: [PATCH 05/21] Expose exploration via internal API --- extra/lib/plausible/stats/exploration.ex | 1 + .../controllers/api/stats_controller.ex | 52 +++++++ lib/plausible_web/router.ex | 3 + .../api/stats_controller/exploration_test.exs | 130 ++++++++++++++++++ 4 files changed, 186 insertions(+) create mode 100644 test/plausible_web/controllers/api/stats_controller/exploration_test.exs diff --git a/extra/lib/plausible/stats/exploration.ex b/extra/lib/plausible/stats/exploration.ex index 9df9599556fa..13426fd2c758 100644 --- a/extra/lib/plausible/stats/exploration.ex +++ b/extra/lib/plausible/stats/exploration.ex @@ -8,6 +8,7 @@ defmodule Plausible.Stats.Exploration do @type t() :: %__MODULE__{} + @derive {Jason.Encoder, only: [:name, :pathname]} defstruct [:name, :pathname] end diff --git a/lib/plausible_web/controllers/api/stats_controller.ex b/lib/plausible_web/controllers/api/stats_controller.ex index 68b8e22d6de6..cd8732ecae0d 100644 --- a/lib/plausible_web/controllers/api/stats_controller.ex +++ b/lib/plausible_web/controllers/api/stats_controller.ex @@ -9,6 +9,7 @@ defmodule PlausibleWeb.Api.StatsController do alias Plausible.Stats.{ Query, Comparisons, + Exploration, Filters, Time, TableDecider, @@ -332,6 +333,57 @@ defmodule PlausibleWeb.Api.StatsController do 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), + query = Query.from(site, params, debug_metadata: debug_metadata(conn)), + {:ok, next_steps} <- Exploration.next_steps(query, journey, search_term) do + json(conn, next_steps) + else + _ -> + bad_request(conn, "There was an error with your request") + end + end + + def exploration_funnel(conn, %{"journey" => steps} = params) do + site = conn.assigns.site + + with {:ok, journey} <- parse_journey(steps), + query = Query.from(site, params, debug_metadata: debug_metadata(conn)), + {:ok, funnel} <- Exploration.journey_funnel(query, journey) do + json(conn, funnel) + else + {:error, :empty_journey} -> + bad_request( + conn, + "We are unable to show funnels when journey is empty", + %{ + level: :normal + } + ) + + _ -> + bad_request(conn, "There was an error with your request") + end + end + + defp parse_journey(input) when is_binary(input) do + input + |> Jason.decode!() + |> Enum.map(&parse_journey_step/1) + |> Enum.reject(&is_nil/1) + |> then(&{:ok, &1}) + end + + defp parse_journey_step(%{"name" => name, "pathname" => pathname}) do + %Exploration.Journey.Step{ + name: name, + pathname: pathname + } + end + on_ee do def funnel(conn, %{"id" => funnel_id} = params) do site = Plausible.Repo.preload(conn.assigns.site, :team) diff --git a/lib/plausible_web/router.ex b/lib/plausible_web/router.ex index b7ce2e7885c4..d353f2838033 100644 --- a/lib/plausible_web/router.ex +++ b/lib/plausible_web/router.ex @@ -282,6 +282,9 @@ defmodule PlausibleWeb.Router do get "/:domain/funnels/:id", StatsController, :funnel end + get "/:domain/exploration/next", StatsController, :exploration_next + get "/:domain/exploration/funnel", StatsController, :exploration_funnel + scope private: %{allow_consolidated_views: true} do post "/:domain/query", StatsController, :query get "/:domain/current-visitors", StatsController, :current_visitors diff --git a/test/plausible_web/controllers/api/stats_controller/exploration_test.exs b/test/plausible_web/controllers/api/stats_controller/exploration_test.exs new file mode 100644 index 000000000000..253fac165778 --- /dev/null +++ b/test/plausible_web/controllers/api/stats_controller/exploration_test.exs @@ -0,0 +1,130 @@ +defmodule PlausibleWeb.Api.StatsController.ExplorationTest do + use PlausibleWeb.ConnCase, async: true + + setup [:create_user, :log_in, :create_site] + + setup %{site: site} do + now = DateTime.utc_now() + + populate_stats(site, [ + build(:pageview, + user_id: 123, + pathname: "/home", + timestamp: DateTime.shift(now, minute: -300) + ), + build(:pageview, + user_id: 123, + pathname: "/login", + timestamp: DateTime.shift(now, minute: -270) + ), + build(:pageview, + user_id: 123, + pathname: "/home", + timestamp: DateTime.shift(now, minute: -30) + ), + build(:pageview, + user_id: 123, + pathname: "/login", + timestamp: DateTime.shift(now, minute: -25) + ), + build(:pageview, + user_id: 123, + pathname: "/logout", + timestamp: DateTime.shift(now, minute: -20) + ), + build(:pageview, + user_id: 124, + pathname: "/home", + timestamp: DateTime.shift(now, minute: -30) + ), + build(:pageview, + user_id: 124, + pathname: "/login", + timestamp: DateTime.shift(now, minute: -25) + ), + build(:pageview, + user_id: 124, + pathname: "/docs", + timestamp: DateTime.shift(now, minute: -20) + ), + build(:pageview, + user_id: 124, + pathname: "/logout", + timestamp: DateTime.shift(now, minute: -15) + ) + ]) + + {:ok, site: site} + end + + describe "exploration_next/2" do + test "it works", %{conn: conn, site: site} do + journey = + Jason.encode!([ + %{name: "pageview", pathname: "/home"}, + %{name: "pageview", pathname: "/login"} + ]) + + resp = + conn + |> get("/api/stats/#{site.domain}/exploration/next/?journey=#{journey}&period=24h") + |> json_response(200) + + assert [next_step1, next_step2] = resp + assert next_step1["step"]["pathname"] == "/docs" + assert next_step1["visitors"] == 1 + assert next_step2["step"]["pathname"] == "/logout" + assert next_step2["visitors"] == 1 + end + + test "it filters", %{conn: conn, site: site} do + journey = + Jason.encode!([ + %{name: "pageview", pathname: "/home"}, + %{name: "pageview", pathname: "/login"} + ]) + + resp = + conn + |> get( + "/api/stats/#{site.domain}/exploration/next/?journey=#{journey}&search_term=doc&period=24h" + ) + |> json_response(200) + + assert [next_step] = resp + assert next_step["step"]["pathname"] == "/docs" + assert next_step["visitors"] == 1 + end + end + + describe "exploration_funnel/2" do + test "it works", %{conn: conn, site: site} do + journey = + Jason.encode!([ + %{name: "pageview", pathname: "/home"}, + %{name: "pageview", pathname: "/login"}, + %{name: "pageview", pathname: "/logout"} + ]) + + resp = + conn + |> get("/api/stats/#{site.domain}/exploration/funnel/?journey=#{journey}&period=24h") + |> json_response(200) + + assert [step1, step2, step3] = resp + + assert step1["step"]["pathname"] == "/home" + assert step1["visitors"] == 2 + assert step1["dropoff"] == 0 + assert step1["dropoff_percentage"] == "0" + assert step2["step"]["pathname"] == "/login" + assert step2["visitors"] == 2 + assert step2["dropoff"] == 0 + assert step2["dropoff_percentage"] == "0" + assert step3["step"]["pathname"] == "/logout" + assert step3["visitors"] == 1 + assert step3["dropoff"] == 1 + assert step3["dropoff_percentage"] == "50" + end + end +end From 0d11b2e34d20095a20ab1c370e79a17b0a0824ee Mon Sep 17 00:00:00 2001 From: Adrian Gruntkowski Date: Fri, 10 Apr 2026 14:50:29 +0200 Subject: [PATCH 06/21] Only allow superadmins to query exploration API for now --- lib/plausible_web/controllers/api/stats_controller.ex | 2 ++ .../controllers/api/stats_controller/exploration_test.exs | 6 ++++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/lib/plausible_web/controllers/api/stats_controller.ex b/lib/plausible_web/controllers/api/stats_controller.ex index cd8732ecae0d..612b2c1781df 100644 --- a/lib/plausible_web/controllers/api/stats_controller.ex +++ b/lib/plausible_web/controllers/api/stats_controller.ex @@ -26,6 +26,8 @@ defmodule PlausibleWeb.Api.StatsController do @revenue_metrics on_ee(do: Plausible.Stats.Goal.Revenue.revenue_metrics(), else: []) @not_set "(not set)" + plug PlausibleWeb.SuperAdminOnlyPlug when action in [:exploration_next, :exploration_funnel] + plug(:date_validation_plug when action not in [:query]) plug(:validate_required_filters_plug when action not in [:current_visitors]) 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 253fac165778..a45ebfd910dc 100644 --- a/test/plausible_web/controllers/api/stats_controller/exploration_test.exs +++ b/test/plausible_web/controllers/api/stats_controller/exploration_test.exs @@ -1,9 +1,11 @@ defmodule PlausibleWeb.Api.StatsController.ExplorationTest do - use PlausibleWeb.ConnCase, async: true + use PlausibleWeb.ConnCase, async: false setup [:create_user, :log_in, :create_site] - setup %{site: site} do + setup %{user: user, site: site} do + patch_env(:super_admin_user_ids, [user.id]) + now = DateTime.utc_now() populate_stats(site, [ From 1a8b7c395de0a571b836d1dd0e43ec08f341aa2e Mon Sep 17 00:00:00 2001 From: Adrian Gruntkowski Date: Sun, 12 Apr 2026 17:03:00 +0200 Subject: [PATCH 07/21] Hook up a very basic UI adapted from earlier prototype --- assets/js/dashboard/stats/behaviours/index.js | 15 ++ .../stats/behaviours/modes-context.tsx | 10 +- assets/js/dashboard/stats/exploration.js | 196 ++++++++++++++++++ 3 files changed, 219 insertions(+), 2 deletions(-) create mode 100644 assets/js/dashboard/stats/exploration.js diff --git a/assets/js/dashboard/stats/behaviours/index.js b/assets/js/dashboard/stats/behaviours/index.js index 0ca81e0b1f70..cc81a8a48a9f 100644 --- a/assets/js/dashboard/stats/behaviours/index.js +++ b/assets/js/dashboard/stats/behaviours/index.js @@ -2,6 +2,7 @@ import React, { useState, useEffect, useCallback } from 'react' import * as storage from '../../util/storage' import ImportedQueryUnsupportedWarning from '../imported-query-unsupported-warning' import Properties from './props' +import { FunnelExploration } from '../exploration' import { FeatureSetupNotice } from '../../components/notice' import { hasConversionGoalFilter, @@ -290,6 +291,10 @@ function Behaviours({ importedDataInView, setMode, mode }) { } } + function renderExploration() { + return + } + function renderFunnels() { if (Funnel === null) { return featureUnavailable() @@ -380,6 +385,8 @@ function Behaviours({ importedDataInView, setMode, mode }) { return renderProps() case Mode.FUNNELS: return renderFunnels() + case Mode.EXPLORATION: + return renderExploration() } } @@ -518,6 +525,14 @@ function Behaviours({ importedDataInView, setMode, mode }) { Funnels ))} + {!site.isConsolidatedView && (user.role === 'super_admin' || user.role === 'owner') && ( + + Exploration + + )} {isRealtime() && last 30min} {renderImportedQueryUnsupportedWarning()} diff --git a/assets/js/dashboard/stats/behaviours/modes-context.tsx b/assets/js/dashboard/stats/behaviours/modes-context.tsx index 5daf6c93ffb5..de7452fe47b1 100644 --- a/assets/js/dashboard/stats/behaviours/modes-context.tsx +++ b/assets/js/dashboard/stats/behaviours/modes-context.tsx @@ -5,7 +5,8 @@ import { UserContextValue, useUserContext } from '../../user-context' export enum Mode { CONVERSIONS = 'conversions', PROPS = 'props', - FUNNELS = 'funnels' + FUNNELS = 'funnels', + EXPLORATION = 'exploration' } export const MODES = { @@ -23,6 +24,11 @@ export const MODES = { title: 'Funnels', isAvailableKey: `${Mode.FUNNELS}Available`, optedOutKey: `${Mode.FUNNELS}OptedOut` + }, + [Mode.EXPLORATION]: { + title: 'Exploration', + isAvailableKey: null, // always available + optedOutKey: null } } as const @@ -48,7 +54,7 @@ function getInitiallyAvailableModes({ }): Mode[] { return Object.entries(MODES) .filter(([_, { isAvailableKey, optedOutKey }]) => { - const isOptedOut = site[optedOutKey] + const isOptedOut = optedOutKey ? site[optedOutKey] : false const isAvailable = isAvailableKey ? site[isAvailableKey] : true // If the feature is not supported by the site owner's subscription, diff --git a/assets/js/dashboard/stats/exploration.js b/assets/js/dashboard/stats/exploration.js new file mode 100644 index 000000000000..373ff1fa2289 --- /dev/null +++ b/assets/js/dashboard/stats/exploration.js @@ -0,0 +1,196 @@ +import React, { useState, useEffect } from 'react' +import * as api from '../api' +import * as url from '../util/url' +import { useSiteContext } from '../site-context' +import { useDashboardStateContext } from '../dashboard-state-context' +import { numberShortFormatter } from '../util/number-formatter' + +const PAGE_FILTER_KEYS = ['page', 'entry_page', 'exit_page'] + +function fetchColumnData(site, dashboardState, steps) { + // Page filters only apply to the first step — strip them for subsequent columns + const stateToUse = + steps.length > 0 + ? { + ...dashboardState, + filters: dashboardState.filters.filter( + ([_op, key]) => !PAGE_FILTER_KEYS.includes(key) + ) + } + : dashboardState + + const journey = [] + if (steps.length > 0) { + for (const s of steps) { + journey.push({ name: s.name, pathname: s.pathname }) + } + } + + return api.get(url.apiPath(site, '/exploration/next'), stateToUse, { + journey: JSON.stringify(journey) + }) +} + +function ExplorationColumn({ + header, + steps, + selected, + onSelect, + dashboardState +}) { + const site = useSiteContext() + const [loading, setLoading] = useState(steps !== null) + const [results, setResults] = useState([]) + + useEffect(() => { + if (steps === null) { + setResults([]) + setLoading(false) + return + } + + setLoading(true) + setResults([]) + + fetchColumnData(site, dashboardState, steps) + .then((response) => { + setResults(response || []) + }) + .catch(() => { + setResults([]) + }) + .finally(() => { + setLoading(false) + }) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [dashboardState, steps === null ? null : steps.join('|||')]) + + const maxVisitors = results.length > 0 ? results[0].visitors : 1 + + return ( +
+
+ + {header} + + {selected && ( + + )} +
+ + {loading ? ( +
+
+
+
+
+ ) : results.length === 0 ? ( +
+ {steps === null ? 'Select an event to continue' : 'No data'} +
+ ) : ( +
    + {(selected + ? results.filter( + ({ step }) => + step.name === selected.name && + step.pathname === selected.pathname + ) + : results.slice(0, 10) + ).map(({ step, visitors }) => { + const label = `${step.name} ${step.pathname}` + const pct = Math.round((visitors / maxVisitors) * 100) + const isSelected = + !!selected && step.name === selected.name && step.pathname === selected.pathname + + return ( +
  • + +
  • + ) + })} +
+ )} +
+ ) +} + +function columnHeader(index) { + if (index === 0) return 'Start' + return `${index} step${index === 1 ? '' : 's'} after` +} + +export function FunnelExploration() { + const { dashboardState } = useDashboardStateContext() + const [steps, setSteps] = useState([]) + + function handleSelect(columnIndex, selected) { + if (selected === null) { + setSteps(steps.slice(0, columnIndex)) + } else { + setSteps([...steps.slice(0, columnIndex), selected]) + } + } + + const numColumns = Math.max(steps.length, 3) + + return ( +
+

+ Explore user journeys +

+
+ {Array.from({ length: numColumns }, (_, i) => ( + = i ? steps.slice(0, i) : null} + selected={steps[i] || null} + onSelect={(selected) => handleSelect(i, selected)} + dashboardState={dashboardState} + /> + ))} +
+
+ ) +} + +export default FunnelExploration From 3f991b220038d1e88556c24b26066322d5e6d37b Mon Sep 17 00:00:00 2001 From: Adrian Gruntkowski Date: Sun, 12 Apr 2026 21:13:04 +0200 Subject: [PATCH 08/21] Change the approach to gating access to exploration in UI --- assets/js/dashboard/site-context.test.tsx | 2 ++ assets/js/dashboard/site-context.tsx | 2 ++ assets/js/dashboard/stats/behaviours/index.js | 2 +- assets/test-utils/app-context-providers.tsx | 1 + lib/plausible_web/controllers/stats_controller.ex | 3 +++ lib/plausible_web/templates/stats/stats.html.heex | 1 + 6 files changed, 10 insertions(+), 1 deletion(-) diff --git a/assets/js/dashboard/site-context.test.tsx b/assets/js/dashboard/site-context.test.tsx index 183ec47b741e..d8329a39514e 100644 --- a/assets/js/dashboard/site-context.test.tsx +++ b/assets/js/dashboard/site-context.test.tsx @@ -15,6 +15,7 @@ describe('parseSiteFromDataset', () => { data-funnels-opted-out="false" data-props-opted-out="false" data-funnels-available="true" + data-exploration-available="false" data-site-segments-available="true" data-props-available="true" data-revenue-goals='[{"currency":"USD","display_name":"Purchase"}]' @@ -43,6 +44,7 @@ describe('parseSiteFromDataset', () => { propsOptedOut: false, funnelsAvailable: true, propsAvailable: true, + explorationAvailable: false, siteSegmentsAvailable: true, revenueGoals: [{ currency: 'USD', display_name: 'Purchase' }], funnels: [{ id: 1, name: 'From homepage to login', steps_count: 3 }], diff --git a/assets/js/dashboard/site-context.tsx b/assets/js/dashboard/site-context.tsx index ac6213b21c16..ada56d7a8128 100644 --- a/assets/js/dashboard/site-context.tsx +++ b/assets/js/dashboard/site-context.tsx @@ -8,6 +8,7 @@ export function parseSiteFromDataset(dataset: DOMStringMap): PlausibleSite { hasProps: dataset.hasProps === 'true', funnelsAvailable: dataset.funnelsAvailable === 'true', propsAvailable: dataset.propsAvailable === 'true', + explorationAvailable: dataset.explorationAvailable === 'true', siteSegmentsAvailable: dataset.siteSegmentsAvailable === 'true', conversionsOptedOut: dataset.conversionsOptedOut === 'true', funnelsOptedOut: dataset.funnelsOptedOut === 'true', @@ -36,6 +37,7 @@ export const siteContextDefaultValue = { hasGoals: false, hasProps: false, funnelsAvailable: false, + explorationAvailable: false, propsAvailable: false, siteSegmentsAvailable: false, conversionsOptedOut: false, diff --git a/assets/js/dashboard/stats/behaviours/index.js b/assets/js/dashboard/stats/behaviours/index.js index cc81a8a48a9f..60e15a606ac2 100644 --- a/assets/js/dashboard/stats/behaviours/index.js +++ b/assets/js/dashboard/stats/behaviours/index.js @@ -525,7 +525,7 @@ function Behaviours({ importedDataInView, setMode, mode }) { Funnels ))} - {!site.isConsolidatedView && (user.role === 'super_admin' || user.role === 'owner') && ( + {!site.isConsolidatedView && site.explorationAvailable && ( From 741c5ec335b7efed62c487acd1dbd05860166c34 Mon Sep 17 00:00:00 2001 From: Adrian Gruntkowski Date: Sun, 12 Apr 2026 21:21:37 +0200 Subject: [PATCH 09/21] Fix exploration funnel length condition --- assets/js/dashboard/stats/exploration.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/assets/js/dashboard/stats/exploration.js b/assets/js/dashboard/stats/exploration.js index 373ff1fa2289..09069c92e964 100644 --- a/assets/js/dashboard/stats/exploration.js +++ b/assets/js/dashboard/stats/exploration.js @@ -170,7 +170,7 @@ export function FunnelExploration() { } } - const numColumns = Math.max(steps.length, 3) + const numColumns = Math.max(steps.length + 1, 3) return (
From 4332a11fbed791bbd6d33f5d3401442bf2bdec31 Mon Sep 17 00:00:00 2001 From: Adrian Gruntkowski Date: Sun, 12 Apr 2026 22:27:18 +0200 Subject: [PATCH 10/21] Add primitive filtering support --- assets/js/dashboard/stats/exploration.js | 33 +++++++++++++++++++----- 1 file changed, 27 insertions(+), 6 deletions(-) diff --git a/assets/js/dashboard/stats/exploration.js b/assets/js/dashboard/stats/exploration.js index 09069c92e964..2f4a20bd6bbc 100644 --- a/assets/js/dashboard/stats/exploration.js +++ b/assets/js/dashboard/stats/exploration.js @@ -1,13 +1,14 @@ -import React, { useState, useEffect } from 'react' +import React, { useState, useEffect, useCallback } from 'react' import * as api from '../api' import * as url from '../util/url' +import { useDebounce } from '../custom-hooks' import { useSiteContext } from '../site-context' import { useDashboardStateContext } from '../dashboard-state-context' import { numberShortFormatter } from '../util/number-formatter' const PAGE_FILTER_KEYS = ['page', 'entry_page', 'exit_page'] -function fetchColumnData(site, dashboardState, steps) { +function fetchColumnData(site, dashboardState, steps, filter) { // Page filters only apply to the first step — strip them for subsequent columns const stateToUse = steps.length > 0 @@ -27,7 +28,8 @@ function fetchColumnData(site, dashboardState, steps) { } return api.get(url.apiPath(site, '/exploration/next'), stateToUse, { - journey: JSON.stringify(journey) + journey: JSON.stringify(journey), + search_term: filter }) } @@ -41,9 +43,17 @@ function ExplorationColumn({ const site = useSiteContext() const [loading, setLoading] = useState(steps !== null) const [results, setResults] = useState([]) + const [filter, setFilter] = useState('') + + const onSearchInputChange = useCallback((event) => { + setFilter(event.target.value) + }, []) + + const debouncedOnSearchInputChange = useDebounce(onSearchInputChange) useEffect(() => { if (steps === null) { + setFilter('') setResults([]) setLoading(false) return @@ -52,7 +62,7 @@ function ExplorationColumn({ setLoading(true) setResults([]) - fetchColumnData(site, dashboardState, steps) + fetchColumnData(site, dashboardState, steps, filter) .then((response) => { setResults(response || []) }) @@ -63,7 +73,7 @@ function ExplorationColumn({ setLoading(false) }) // eslint-disable-next-line react-hooks/exhaustive-deps - }, [dashboardState, steps === null ? null : steps.join('|||')]) + }, [dashboardState, steps, filter]) const maxVisitors = results.length > 0 ? results[0].visitors : 1 @@ -73,6 +83,15 @@ function ExplorationColumn({ {header} + {!selected && steps !== null && ( + + )} {selected && (