From 1cb54df80f8197e61ce218207f65916d71eb8c64 Mon Sep 17 00:00:00 2001 From: Adrian Gruntkowski Date: Thu, 30 Apr 2026 13:54:45 +0200 Subject: [PATCH 01/13] Add custom and pageview goals to exploration suggestions --- assets/js/dashboard/extra/exploration.js | 15 +- extra/lib/plausible/stats/exploration.ex | 203 ++++++++++++++---- .../controllers/api/stats_controller.ex | 12 +- test/plausible/stats/exploration_test.exs | 60 +++--- 4 files changed, 214 insertions(+), 76 deletions(-) diff --git a/assets/js/dashboard/extra/exploration.js b/assets/js/dashboard/extra/exploration.js index 70ce106835cd..b9b595acf9eb 100644 --- a/assets/js/dashboard/extra/exploration.js +++ b/assets/js/dashboard/extra/exploration.js @@ -76,12 +76,15 @@ function dashboardStateForQuery(dashboardState, steps) { // Serialize steps into the wire format expected by the API. function stepsToJourneyParam(steps) { return JSON.stringify( - steps.map(({ name, pathname, includes_subpaths, subpaths_count }) => ({ - name, - pathname, - includes_subpaths, - subpaths_count - })) + steps.map( + ({ name, pathname, includes_subpaths, subpaths_count, is_goal }) => ({ + name, + pathname, + includes_subpaths, + subpaths_count, + is_goal + }) + ) ) } diff --git a/extra/lib/plausible/stats/exploration.ex b/extra/lib/plausible/stats/exploration.ex index 6eaa5f77f292..02534a74f6e9 100644 --- a/extra/lib/plausible/stats/exploration.ex +++ b/extra/lib/plausible/stats/exploration.ex @@ -8,16 +8,22 @@ defmodule Plausible.Stats.Exploration do @type t() :: %__MODULE__{} - @derive {Jason.Encoder, only: [:name, :pathname, :label, :includes_subpaths, :subpaths_count]} - defstruct name: nil, pathname: "", label: nil, includes_subpaths: false, subpaths_count: 0 + @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) + 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()) :: t() - def new(name, pathname, includes_subpaths \\ false, subpaths_count \\ 0) + 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 @@ -31,7 +37,8 @@ defmodule Plausible.Stats.Exploration do name: name, pathname: pathname, includes_subpaths: includes_subpaths, - subpaths_count: subpaths_count + subpaths_count: subpaths_count, + is_goal: is_goal } end end @@ -42,6 +49,7 @@ defmodule Plausible.Stats.Exploration do alias Plausible.ClickhouseRepo alias Plausible.Stats.Base + alias Plausible.Stats.Filters alias Plausible.Stats.Query @type journey() :: [Journey.Step.t()] @@ -76,24 +84,29 @@ defmodule Plausible.Stats.Exploration do @spec max_steps() :: pos_integer() def max_steps, do: @max_steps - @spec next_steps(Query.t(), journey(), keyword()) :: + @spec next_steps(Plausible.Site.t(), Query.t(), journey(), keyword()) :: {:ok, [next_step()]} | {:error, :journey_too_long} - def next_steps(query, journey, opts \\ []) + def next_steps(site, query, journey, opts \\ []) - def next_steps(_query, journey, _opts) when length(journey) >= @max_steps do + def next_steps(_site, _query, journey, _opts) when length(journey) >= @max_steps do {:error, :journey_too_long} end - def next_steps(query, journey, opts) do + def next_steps(site, query, journey, opts) do opts = Keyword.merge(@next_steps_defaults, opts) direction = Keyword.fetch!(opts, :direction) search_term = Keyword.fetch!(opts, :search_term) max_candidates = min(Keyword.fetch!(opts, :max_candidates), @max_candidates) include_wilcard? = Keyword.fetch!(opts, :include_wildcard?) + goals = + site + |> Plausible.Goals.for_site(include_goals_with_custom_props?: false) + |> filter_eligible_goals() + query |> Base.base_event_query() - |> next_steps_query(journey, search_term, direction, max_candidates, include_wilcard?) + |> next_steps_query(journey, search_term, direction, max_candidates, include_wilcard?, goals) # We pass the query struct to record query metadata for # the CH debug console. |> ClickhouseRepo.all(query: query) @@ -143,9 +156,9 @@ defmodule Plausible.Stats.Exploration do to include implicit wildcard pathnames in suggestions or not (default: true) """ - @spec interesting_funnel(Query.t(), keyword()) :: + @spec interesting_funnel(Plausible.Site.t(), Query.t(), keyword()) :: {:ok, %{funnel: [funnel_step()], candidates: [next_step()]}} | {:error, :not_found} - def interesting_funnel(query, opts \\ []) do + def interesting_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) @@ -157,14 +170,15 @@ defmodule Plausible.Stats.Exploration do ) with {:ok, result} <- - build_interesting_journey(query, max_steps, max_candidates, include_wildcard?), + build_interesting_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(query, max_steps, max_candidates, include_wildcard?) do + defp build_interesting_journey(site, query, max_steps, max_candidates, include_wildcard?) do case do_build_journey( + site, query, [], [], @@ -179,6 +193,7 @@ defmodule Plausible.Stats.Exploration do end defp do_build_journey( + _site, _query, journey, step_candidates, @@ -192,6 +207,7 @@ defmodule Plausible.Stats.Exploration do end defp do_build_journey( + site, query, journey, step_candidates, @@ -201,7 +217,7 @@ defmodule Plausible.Stats.Exploration do include_wildcard? ) do {:ok, candidates} = - next_steps(query, journey, + next_steps(site, query, journey, max_candidates: max_candidates, include_wildcard?: include_wildcard? ) @@ -214,6 +230,7 @@ defmodule Plausible.Stats.Exploration do new_seen = MapSet.put(seen, normalize_step_key(step)) do_build_journey( + site, query, journey ++ [step], step_candidates ++ [candidates], @@ -238,7 +255,21 @@ defmodule Plausible.Stats.Exploration do defp normalize_pathname("/"), do: "/" defp normalize_pathname(pathname), do: String.trim_trailing(pathname, "/") - defp next_steps_query(query, steps, search_term, direction, max_candidates, include_wildcard?) + defp filter_eligible_goals(goals) do + Enum.filter(goals, fn g -> + is_nil(g.currency) and g.scroll_threshold == -1 and g.custom_props == %{} + end) + end + + defp next_steps_query( + query, + steps, + search_term, + direction, + max_candidates, + include_wildcard?, + goals + ) when is_direction(direction) do next_step_idx = length(steps) + 1 q_steps = steps_query(query, next_step_idx, direction) @@ -251,7 +282,8 @@ defmodule Plausible.Stats.Exploration do where: selected_as(:name) != "", select: %{ name: selected_as(field(s, ^next_name), :name), - pathname: selected_as(field(s, ^next_pathname), :pathname) + pathname: selected_as(field(s, ^next_pathname), :pathname), + _sample_factor: fragment("any(?)", s._sample_factor) } ) @@ -266,12 +298,10 @@ defmodule Plausible.Stats.Exploration do q_per_user_matches = from(m in q_matches, - select_merge: %{user_id: m.user_id, _sample_factor: fragment("any(?)", m._sample_factor)}, + select_merge: %{user_id: m.user_id}, group_by: [selected_as(:name), selected_as(:pathname), m.user_id] ) - q_combined = combined_query(q_per_user_matches, include_wildcard?) - # Fan out each q_combined row into up to two output rows (exact + wildcard) # using ARRAY JOIN over a small boolean array. # @@ -280,8 +310,10 @@ defmodule Plausible.Stats.Exploration do # subpath, or same visitor count as exact). ARRAY JOIN then emits one or more # rows per group. The joined boolean `is_wildcard` selects which values to # use for visitors / includes_subpaths / subpaths_count. - q_all_matches = - from(m in subquery(q_combined), + q_wildcard_combined = combined_wildcard_query(q_per_user_matches, include_wildcard?) + + q_wildcard_combined_matches = + from(m in subquery(q_wildcard_combined), join: is_wildcard in fragment( """ @@ -300,21 +332,6 @@ defmodule Plausible.Stats.Exploration do hints: "ARRAY", where: selected_as(:visitors) > 0, select: %{ - name: m.name, - pathname: m.pathname, - visitors: - selected_as( - fragment("if(?, ?, ?)", is_wildcard, m.wildcard_visitors, m.exact_visitors), - :visitors - ), - includes_subpaths: fragment("CAST(?, 'Bool')", is_wildcard), - subpaths_count: fragment("if(?, ?, 0)", is_wildcard, m.subpaths_count) - } - ) - - from(m in subquery(q_all_matches), - select: %{ - step: %Journey.Step{ label: selected_as( fragment( @@ -325,10 +342,38 @@ defmodule Plausible.Stats.Exploration do ), :label ), + name: selected_as(m.name, :name), + pathname: selected_as(m.pathname, :pathname), + visitors: + selected_as( + fragment("if(?, ?, ?)", is_wildcard, m.wildcard_visitors, m.exact_visitors), + :visitors + ), + includes_subpaths: + selected_as(fragment("CAST(?, 'Bool')", is_wildcard), :includes_subpaths), + subpaths_count: fragment("if(?, ?, 0)", is_wildcard, m.subpaths_count), + is_goal: fragment("CAST(?, 'Bool')", false) + } + ) + + q_all_combined_matches = + if q_goal_matches = goals_query(q_per_user_matches, goals) do + q_wildcard_combined_matches + |> exclude_goal_matches(goals) + |> union_all(^q_goal_matches) + else + q_wildcard_combined_matches + end + + from(m in subquery(q_all_combined_matches), + select: %{ + step: %Journey.Step{ + label: selected_as(m.label, :label), name: m.name, pathname: m.pathname, includes_subpaths: m.includes_subpaths, - subpaths_count: m.subpaths_count + subpaths_count: m.subpaths_count, + is_goal: m.is_goal }, visitors: m.visitors }, @@ -342,6 +387,84 @@ defmodule Plausible.Stats.Exploration do |> maybe_search(search_term) end + @goal_pathname_condition """ + if(? LIKE '^%$', match(?, ?), ? = ?) + """ + + defp goals_query(_, []), do: nil + + defp goals_query(q_matches, goals) do + values = + Enum.map(goals, fn g -> + pathname = g.page_path || "" + + pathname = + if String.contains?(pathname, "*") do + Filters.Utils.page_regex(pathname) + else + pathname + end + + %{ + label: g.display_name, + name: g.event_name || "pageview", + pathname: pathname + } + end) + + types = %{label: :string, name: :string, pathname: :string} + + query = + from(g in values(values, types), + inner_join: m in subquery(q_matches), + on: + g.name == m.name and + (g.name != "pageview" or + (g.name == "pageview" and + fragment( + @goal_pathname_condition, + g.pathname, + m.pathname, + g.pathname, + m.pathname, + g.pathname + ))), + select: %{ + label: selected_as(g.label, :label), + name: selected_as(g.name, :name), + pathname: selected_as(g.pathname, :pathname), + visitors: scale_sample(fragment("uniq(?)", m.user_id)), + includes_subpaths: fragment("CAST(?, 'Bool')", false), + subpaths_count: 0, + is_goal: fragment("CAST(?, 'Bool')", true) + }, + group_by: [selected_as(:label), selected_as(:name), selected_as(:pathname)] + ) + + from(m in subquery(query), + where: m.visitors > 0 + ) + end + + defp exclude_goal_matches(query, goals) do + to_exclude = + goals + |> Enum.filter(fn g -> is_nil(g.page_path) or not String.contains?(g.page_path, "*") end) + |> Enum.map(fn g -> + %{ + name: g.event_name || "pageview", + pathname: g.page_path || "" + } + end) + + types = %{name: :string, pathname: :string} + + from m in query, + left_join: g in values(to_exclude, types), + on: g.name == selected_as(:name) and g.pathname == selected_as(:pathname), + where: g.name == "" and not selected_as(:includes_subpaths) + end + # Expand each (name, pathname, user_id) row into all prefix paths via # ARRAY JOIN, then aggregate once to get both exact and wildcard visitor # counts in a single scan of events_v2. @@ -361,7 +484,7 @@ defmodule Plausible.Stats.Exploration do arraySlice(split_pathname, 1, 1)), [?]) """ - defp combined_query(q_matches, true = _include_wildcard?) do + defp combined_wildcard_query(q_matches, true = _include_wildcard?) do from(em in subquery(q_matches), join: pname in fragment(@wildcard_array_join, em.name, em.pathname, em.pathname), on: true, @@ -380,7 +503,7 @@ defmodule Plausible.Stats.Exploration do ) end - defp combined_query(q_matches, false = _include_wildcard?) do + defp combined_wildcard_query(q_matches, false = _include_wildcard?) do from(em in subquery(q_matches), where: em.name != "pageview" or selected_as(:pathname) != "", select: %{ diff --git a/lib/plausible_web/controllers/api/stats_controller.ex b/lib/plausible_web/controllers/api/stats_controller.ex index 3aeca5b42093..8d723d33e733 100644 --- a/lib/plausible_web/controllers/api/stats_controller.ex +++ b/lib/plausible_web/controllers/api/stats_controller.ex @@ -151,7 +151,7 @@ defmodule PlausibleWeb.Api.StatsController do include_wildcard? = not FunWithFlags.enabled?(@exploration_wildcard_disabled_flag, for: site), {:ok, next_steps} <- - Exploration.next_steps(query, journey, + Exploration.next_steps(site, query, journey, search_term: search_term, direction: direction, include_wildcard?: include_wildcard? @@ -188,7 +188,7 @@ defmodule PlausibleWeb.Api.StatsController do include_wildcard? = not FunWithFlags.enabled?(@exploration_wildcard_disabled_flag, for: site) - case Exploration.interesting_funnel(query, + case Exploration.interesting_funnel(site, query, max_steps: params["max_steps"], max_candidates: params["max_candidates"], include_wildcard?: include_wildcard? @@ -209,7 +209,7 @@ defmodule PlausibleWeb.Api.StatsController do include_wildcard? = not FunWithFlags.enabled?(@exploration_wildcard_disabled_flag, for: site), {:ok, next_steps} <- - Exploration.next_steps(query, journey, + Exploration.next_steps(site, query, journey, search_term: search_term, direction: direction, include_wildcard?: include_wildcard? @@ -242,13 +242,15 @@ defmodule PlausibleWeb.Api.StatsController do "name" => name, "pathname" => pathname, "includes_subpaths" => includes_subpaths, - "subpaths_count" => subpaths_count + "subpaths_count" => subpaths_count, + "is_goal" => is_goal }) do Exploration.Journey.Step.new( name, pathname, includes_subpaths, - subpaths_count + subpaths_count, + is_goal ) end diff --git a/test/plausible/stats/exploration_test.exs b/test/plausible/stats/exploration_test.exs index e2deaf5dbcea..1a59a636f8a3 100644 --- a/test/plausible/stats/exploration_test.exs +++ b/test/plausible/stats/exploration_test.exs @@ -245,7 +245,7 @@ defmodule Plausible.Stats.ExplorationTest do query = QueryBuilder.build!(site, input_date_range: :all) assert {:ok, %{funnel: [step1, step2, step3, step4]}} = - Exploration.interesting_funnel(query) + Exploration.interesting_funnel(site, query) assert step1.step.pathname == "/home" assert step1.visitors == 2 @@ -264,7 +264,7 @@ defmodule Plausible.Stats.ExplorationTest do query = QueryBuilder.build!(site, input_date_range: :all) assert {:ok, %{funnel: [step1, step2]}} = - Exploration.interesting_funnel(query, max_steps: 2) + Exploration.interesting_funnel(site, query, max_steps: 2) assert step1.step.pathname == "/home" assert step2.step.pathname == "/login" @@ -274,7 +274,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(query) + assert {:error, :not_found} = Exploration.interesting_funnel(empty_site, query) end test "stops when no more unseen steps are available" do @@ -291,7 +291,8 @@ defmodule Plausible.Stats.ExplorationTest do query = QueryBuilder.build!(site, input_date_range: :all) - assert {:ok, %{funnel: [step1]}} = Exploration.interesting_funnel(query, max_steps: 6) + assert {:ok, %{funnel: [step1]}} = + Exploration.interesting_funnel(site, query, max_steps: 6) assert step1.step.pathname == "/only-page" assert step1.visitors == 1 @@ -304,7 +305,7 @@ defmodule Plausible.Stats.ExplorationTest do filters: [[:is, "visit:browser", ["Firefox"]]] ) - assert {:ok, result} = Exploration.interesting_funnel(query) + assert {:ok, result} = Exploration.interesting_funnel(site, query) pathnames = Enum.map(result.funnel, & &1.step.pathname) assert pathnames == ["/docs", "/logout"] @@ -361,7 +362,7 @@ defmodule Plausible.Stats.ExplorationTest do query = QueryBuilder.build!(site, input_date_range: :all) - assert {:ok, result} = Exploration.interesting_funnel(query) + assert {:ok, result} = Exploration.interesting_funnel(site, query) pathnames = Enum.map(result.funnel, & &1.step.pathname) assert pathnames == ["/a", "/b", "/c"] @@ -378,7 +379,7 @@ defmodule Plausible.Stats.ExplorationTest do ] assert {:ok, [next_step1, next_step2, next_step3]} = - Exploration.next_steps(query, journey) + Exploration.next_steps(site, query, journey) assert next_step1.step.label == "/docs" assert next_step1.step.pathname == "/docs" @@ -400,7 +401,7 @@ defmodule Plausible.Stats.ExplorationTest do ] assert {:ok, [%{step: %{pathname: "/docs"}}]} = - Exploration.next_steps(query, journey, max_candidates: 1) + Exploration.next_steps(site, query, journey, max_candidates: 1) end test "returns error on too long journey", %{site: site} do @@ -411,14 +412,14 @@ defmodule Plausible.Stats.ExplorationTest do %Exploration.Journey.Step{name: "pageview", pathname: "/page#{idx}"} end) - assert {:error, :journey_too_long} = Exploration.next_steps(query, journey) + assert {:error, :journey_too_long} = Exploration.next_steps(site, query, journey) end test "suggests the first step in the 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, []) + Exploration.next_steps(site, query, []) assert next_step1.step.pathname == "/home" assert next_step1.visitors == 2 @@ -438,7 +439,7 @@ defmodule Plausible.Stats.ExplorationTest do ) assert {:ok, [next_step1, next_step2, next_step3, next_step4]} = - Exploration.next_steps(query, []) + Exploration.next_steps(site, query, []) assert next_step1.step.pathname == "/docs" assert next_step1.visitors == 1 @@ -458,7 +459,8 @@ defmodule Plausible.Stats.ExplorationTest do %Exploration.Journey.Step{name: "pageview", pathname: "/login"} ] - assert {:ok, [next_step]} = Exploration.next_steps(query, journey, search_term: "doc") + assert {:ok, [next_step]} = + Exploration.next_steps(site, query, journey, search_term: "doc") assert next_step.step.pathname == "/docs" assert next_step.visitors == 1 @@ -490,7 +492,7 @@ defmodule Plausible.Stats.ExplorationTest do query = QueryBuilder.build!(site, input_date_range: :all) - assert {:ok, [next_step1, next_step2]} = Exploration.next_steps(query, []) + assert {:ok, [next_step1, next_step2]} = Exploration.next_steps(site, query, []) assert next_step1.step.label == "/" assert next_step1.visitors == 2 @@ -530,7 +532,7 @@ defmodule Plausible.Stats.ExplorationTest do ] assert {:ok, [next_step]} = - Exploration.next_steps(query, journey, search_term: "up") + Exploration.next_steps(site, query, journey, search_term: "up") assert next_step.step.label == "Signup" assert next_step.step.name == "Signup" @@ -546,7 +548,7 @@ defmodule Plausible.Stats.ExplorationTest do ] assert {:ok, [next_step1, next_step2]} = - Exploration.next_steps(query, journey, direction: :backward) + Exploration.next_steps(site, query, journey, direction: :backward) assert next_step1.visitors == 1 assert next_step2.step.pathname == "/login" @@ -605,13 +607,19 @@ defmodule Plausible.Stats.ExplorationTest do [ %{step: %{pathname: "/:dashboard"}} ]} = - Exploration.next_steps(query, journey, search_term: "", direction: :forward) + Exploration.next_steps(site, query, journey, + search_term: "", + direction: :forward + ) assert {:ok, [ %{step: %{pathname: "/:dashboard"}} ]} = - Exploration.next_steps(query, journey, search_term: "", direction: :backward) + Exploration.next_steps(site, query, journey, + search_term: "", + direction: :backward + ) end test "treats identical sequence of events as a single step" do @@ -690,7 +698,7 @@ defmodule Plausible.Stats.ExplorationTest do ] assert {:ok, [next_step1, next_step2, next_step3]} = - Exploration.next_steps(query, journey) + Exploration.next_steps(site, query, journey) assert next_step1.step.pathname == "/docs" assert next_step1.visitors == 1 @@ -705,7 +713,7 @@ defmodule Plausible.Stats.ExplorationTest do %Exploration.Journey.Step{name: "pageview", pathname: "/docs"} ] - assert {:ok, [next_step]} = Exploration.next_steps(query, journey) + assert {:ok, [next_step]} = Exploration.next_steps(site, query, journey) assert next_step.step.pathname == "/logout" assert next_step.visitors == 1 @@ -760,7 +768,8 @@ defmodule Plausible.Stats.ExplorationTest do %Exploration.Journey.Step{name: "pageview", pathname: "/logout"} ] - assert {:ok, [next_step]} = Exploration.next_steps(query, journey, direction: :backward) + assert {:ok, [next_step]} = + Exploration.next_steps(site, query, journey, direction: :backward) assert next_step.step.pathname == "/login" assert next_step.visitors == 1 @@ -811,7 +820,8 @@ defmodule Plausible.Stats.ExplorationTest do query = QueryBuilder.build!(site, input_date_range: :all) - assert {:ok, [next_step1, next_step2, next_step3]} = Exploration.next_steps(query, []) + assert {:ok, [next_step1, next_step2, next_step3]} = + Exploration.next_steps(site, query, []) assert next_step1.step.pathname == "/home" assert next_step1.visitors == 2 @@ -824,7 +834,7 @@ defmodule Plausible.Stats.ExplorationTest do %Exploration.Journey.Step{name: "pageview", pathname: "/home"} ] - assert {:ok, [next_step]} = Exploration.next_steps(query, journey) + assert {:ok, [next_step]} = Exploration.next_steps(site, query, journey) assert next_step.step.pathname == "/login" assert next_step.visitors == 2 @@ -834,7 +844,7 @@ defmodule Plausible.Stats.ExplorationTest do %Exploration.Journey.Step{name: "pageview", pathname: "/login"} ] - assert {:ok, [next_step]} = Exploration.next_steps(query, journey) + assert {:ok, [next_step]} = Exploration.next_steps(site, query, journey) assert next_step.step.pathname == "/logout" assert next_step.visitors == 2 @@ -916,7 +926,7 @@ defmodule Plausible.Stats.ExplorationTest do %{site: site} do query = QueryBuilder.build!(site, input_date_range: :all) - result = Exploration.next_steps(query, []) + result = Exploration.next_steps(site, query, []) assert {:ok, [ @@ -956,7 +966,7 @@ defmodule Plausible.Stats.ExplorationTest do } do query = QueryBuilder.build!(site, input_date_range: :all) - result = Exploration.next_steps(query, [], include_wildcard?: false) + result = Exploration.next_steps(site, query, [], include_wildcard?: false) assert {:ok, [ From b9ef6ed7997c28e19828c0ea8c68ee9087b70f44 Mon Sep 17 00:00:00 2001 From: Adrian Gruntkowski Date: Mon, 4 May 2026 12:25:47 +0200 Subject: [PATCH 02/13] Support goal steps in exploration funnel query --- extra/lib/plausible/stats/exploration.ex | 54 +++++++++++++++--------- 1 file changed, 33 insertions(+), 21 deletions(-) diff --git a/extra/lib/plausible/stats/exploration.ex b/extra/lib/plausible/stats/exploration.ex index 02534a74f6e9..14c1214df92f 100644 --- a/extra/lib/plausible/stats/exploration.ex +++ b/extra/lib/plausible/stats/exploration.ex @@ -388,7 +388,7 @@ defmodule Plausible.Stats.Exploration do end @goal_pathname_condition """ - if(? LIKE '^%$', match(?, ?), ? = ?) + if(? != '', match(?, ?), ? = ?) """ defp goals_query(_, []), do: nil @@ -398,21 +398,22 @@ defmodule Plausible.Stats.Exploration do Enum.map(goals, fn g -> pathname = g.page_path || "" - pathname = + regex_pathname = if String.contains?(pathname, "*") do Filters.Utils.page_regex(pathname) else - pathname + "" end %{ label: g.display_name, name: g.event_name || "pageview", - pathname: pathname + pathname: pathname, + regex_pathname: regex_pathname } end) - types = %{label: :string, name: :string, pathname: :string} + types = %{label: :string, name: :string, pathname: :string, regex_pathname: :string} query = from(g in values(values, types), @@ -423,9 +424,9 @@ defmodule Plausible.Stats.Exploration do (g.name == "pageview" and fragment( @goal_pathname_condition, - g.pathname, + g.regex_pathname, m.pathname, - g.pathname, + g.regex_pathname, m.pathname, g.pathname ))), @@ -647,22 +648,33 @@ defmodule Plausible.Stats.Exploration do end defp step_condition(step, count) when count <= @max_steps do - if step.includes_subpaths do - escaped = Regex.escape(step.pathname) + cond do + step.includes_subpaths -> + escaped = Regex.escape(step.pathname) - pattern = "^#{escaped}(/.+)?$" + pattern = "^#{escaped}(/.+)?$" - dynamic( - [s], - field(s, ^:"name#{count}") == ^step.name and - fragment("match(?, ?)", field(s, ^:"pathname#{count}"), ^pattern) - ) - else - dynamic( - [s], - field(s, ^:"name#{count}") == ^step.name and - field(s, ^:"pathname#{count}") == ^step.pathname - ) + dynamic( + [s], + field(s, ^:"name#{count}") == ^step.name and + fragment("match(?, ?)", field(s, ^:"pathname#{count}"), ^pattern) + ) + + step.is_goal and step.name == "pageview" and String.contains?(step.pathname, "*") -> + pattern = Filters.Utils.page_regex(step.pathname) + + dynamic( + [s], + field(s, ^:"name#{count}") == ^step.name and + fragment("match(?, ?)", field(s, ^:"pathname#{count}"), ^pattern) + ) + + true -> + dynamic( + [s], + field(s, ^:"name#{count}") == ^step.name and + field(s, ^:"pathname#{count}") == ^step.pathname + ) end end From daced659de961992ac200a61a54d1f8e68f55028 Mon Sep 17 00:00:00 2001 From: Adrian Gruntkowski Date: Tue, 5 May 2026 10:10:47 +0200 Subject: [PATCH 03/13] WIP WIP WIP --- extra/lib/plausible/stats/exploration.ex | 82 +++++++++++++++++++++--- 1 file changed, 74 insertions(+), 8 deletions(-) diff --git a/extra/lib/plausible/stats/exploration.ex b/extra/lib/plausible/stats/exploration.ex index 14c1214df92f..2566f045a6e1 100644 --- a/extra/lib/plausible/stats/exploration.ex +++ b/extra/lib/plausible/stats/exploration.ex @@ -479,32 +479,98 @@ defmodule Plausible.Stats.Exploration do # single prefix (their exact pathname), so they naturally get # subpaths_count = 1 and are only emitted as exact rows. @wildcard_array_join """ - if(? = 'pageview', arrayFold( + if(? = 'pageview' and ? = '', arrayFold( acc, x -> arrayPushBack(acc, concat(acc[-1], '/', x)), arraySlice(splitByChar('/', ?) AS split_pathname, 2), arraySlice(split_pathname, 1, 1)), [?]) """ - defp combined_wildcard_query(q_matches, true = _include_wildcard?) do + @goal_pathname_condition """ + if(? != '', match(?, ?), ? = ?) + """ + + defp combined_wildcard_query(q_matches, goals, true = _include_wildcard?) do + goal_values = + Enum.map(goals, fn g -> + pathname = g.page_path || "" + + regex_pathname = + if String.contains?(pathname, "*") do + Filters.Utils.page_regex(pathname) + else + "" + end + + %{ + label: g.display_name, + name: g.event_name || "pageview", + pathname: pathname, + regex_pathname: regex_pathname + } + end) + + types = %{label: :string, name: :string, pathname: :string, regex_pathname: :string} + from(em in subquery(q_matches), - join: pname in fragment(@wildcard_array_join, em.name, em.pathname, em.pathname), + left_join: g in values(goal_values, goal_types), + on: + g.name == em.name and + (g.name != "pageview" or + (g.name == "pageview" and + fragment( + @goal_pathname_condition, + g.regex_pathname, + em.pathname, + g.regex_pathname, + em.pathname, + g.pathname + ))), + join: is_goal in fragment("[?, ? != '']", 0, g.name), + on: true, + hints: "ARRAY", + join: + pname in fragment( + @wildcard_array_join, + em.name, + g.regex_pathname, + em.pathname, + em.pathname + ), on: true, hints: "ARRAY", where: em.name != "pageview" or selected_as(:pathname) != "", select: %{ + label: + selected_as( + fragment( + "if(? = '', if(? != 'pageview', ?, ?), ?)", + g.name, + m.name, + m.name, + m.pathname, + g.label + ), + :label + ), name: em.name, - pathname: selected_as(fragment("?", pname), :pathname), + pathname: selected_as(fragment("if(?, ?, ?)", is_goal, g.pathname, pname), :pathname), + goal_visitors: scale_sample(fragment("uniqIf(?, ?)", em.user_id, is_goal)), exact_visitors: - scale_sample(fragment("uniqIf(?, ? = ?)", em.user_id, em.pathname, pname)), + scale_sample( + fragment("uniqIf(?, ? = ? and not ?)", em.user_id, em.pathname, pname, is_goal) + ), wildcard_visitors: - selected_as(scale_sample(fragment("uniq(?)", em.user_id)), :wildcard_visitors), - subpaths_count: scale_sample(fragment("uniq(?)", em.pathname)) + selected_as( + scale_sample(fragment("uniqIf(?, not ?)", em.user_id, is_goal)), + :wildcard_visitors + ), + subpaths_count: scale_sample(fragment("uniqIf(?, not ?)", em.pathname, is_goal)) }, group_by: [em.name, selected_as(:pathname)] ) end - defp combined_wildcard_query(q_matches, false = _include_wildcard?) do + defp combined_wildcard_query(q_matches, goals, false = _include_wildcard?) do from(em in subquery(q_matches), where: em.name != "pageview" or selected_as(:pathname) != "", select: %{ From 7f10265af4f6567832123bbb64e7bb073f0edff5 Mon Sep 17 00:00:00 2001 From: Adrian Gruntkowski Date: Tue, 5 May 2026 12:22:57 +0200 Subject: [PATCH 04/13] Revert "WIP WIP WIP" This reverts commit 183e723b5540460ad6f77d268dbe081859277db0. --- extra/lib/plausible/stats/exploration.ex | 82 +++--------------------- 1 file changed, 8 insertions(+), 74 deletions(-) diff --git a/extra/lib/plausible/stats/exploration.ex b/extra/lib/plausible/stats/exploration.ex index 2566f045a6e1..14c1214df92f 100644 --- a/extra/lib/plausible/stats/exploration.ex +++ b/extra/lib/plausible/stats/exploration.ex @@ -479,98 +479,32 @@ defmodule Plausible.Stats.Exploration do # single prefix (their exact pathname), so they naturally get # subpaths_count = 1 and are only emitted as exact rows. @wildcard_array_join """ - if(? = 'pageview' and ? = '', arrayFold( + if(? = 'pageview', arrayFold( acc, x -> arrayPushBack(acc, concat(acc[-1], '/', x)), arraySlice(splitByChar('/', ?) AS split_pathname, 2), arraySlice(split_pathname, 1, 1)), [?]) """ - @goal_pathname_condition """ - if(? != '', match(?, ?), ? = ?) - """ - - defp combined_wildcard_query(q_matches, goals, true = _include_wildcard?) do - goal_values = - Enum.map(goals, fn g -> - pathname = g.page_path || "" - - regex_pathname = - if String.contains?(pathname, "*") do - Filters.Utils.page_regex(pathname) - else - "" - end - - %{ - label: g.display_name, - name: g.event_name || "pageview", - pathname: pathname, - regex_pathname: regex_pathname - } - end) - - types = %{label: :string, name: :string, pathname: :string, regex_pathname: :string} - + defp combined_wildcard_query(q_matches, true = _include_wildcard?) do from(em in subquery(q_matches), - left_join: g in values(goal_values, goal_types), - on: - g.name == em.name and - (g.name != "pageview" or - (g.name == "pageview" and - fragment( - @goal_pathname_condition, - g.regex_pathname, - em.pathname, - g.regex_pathname, - em.pathname, - g.pathname - ))), - join: is_goal in fragment("[?, ? != '']", 0, g.name), - on: true, - hints: "ARRAY", - join: - pname in fragment( - @wildcard_array_join, - em.name, - g.regex_pathname, - em.pathname, - em.pathname - ), + join: pname in fragment(@wildcard_array_join, em.name, em.pathname, em.pathname), on: true, hints: "ARRAY", where: em.name != "pageview" or selected_as(:pathname) != "", select: %{ - label: - selected_as( - fragment( - "if(? = '', if(? != 'pageview', ?, ?), ?)", - g.name, - m.name, - m.name, - m.pathname, - g.label - ), - :label - ), name: em.name, - pathname: selected_as(fragment("if(?, ?, ?)", is_goal, g.pathname, pname), :pathname), - goal_visitors: scale_sample(fragment("uniqIf(?, ?)", em.user_id, is_goal)), + pathname: selected_as(fragment("?", pname), :pathname), exact_visitors: - scale_sample( - fragment("uniqIf(?, ? = ? and not ?)", em.user_id, em.pathname, pname, is_goal) - ), + scale_sample(fragment("uniqIf(?, ? = ?)", em.user_id, em.pathname, pname)), wildcard_visitors: - selected_as( - scale_sample(fragment("uniqIf(?, not ?)", em.user_id, is_goal)), - :wildcard_visitors - ), - subpaths_count: scale_sample(fragment("uniqIf(?, not ?)", em.pathname, is_goal)) + selected_as(scale_sample(fragment("uniq(?)", em.user_id)), :wildcard_visitors), + subpaths_count: scale_sample(fragment("uniq(?)", em.pathname)) }, group_by: [em.name, selected_as(:pathname)] ) end - defp combined_wildcard_query(q_matches, goals, false = _include_wildcard?) do + defp combined_wildcard_query(q_matches, false = _include_wildcard?) do from(em in subquery(q_matches), where: em.name != "pageview" or selected_as(:pathname) != "", select: %{ From 5e4fc20f29c505b3bc5109440994876bcdb4cdb6 Mon Sep 17 00:00:00 2001 From: Adrian Gruntkowski Date: Tue, 5 May 2026 13:07:49 +0200 Subject: [PATCH 05/13] Add basic tests for goals --- test/plausible/stats/exploration_test.exs | 207 ++++++++++++++++++++++ 1 file changed, 207 insertions(+) diff --git a/test/plausible/stats/exploration_test.exs b/test/plausible/stats/exploration_test.exs index 1a59a636f8a3..4cb916f594f3 100644 --- a/test/plausible/stats/exploration_test.exs +++ b/test/plausible/stats/exploration_test.exs @@ -238,6 +238,51 @@ defmodule Plausible.Stats.ExplorationTest do assert step3.conversion_rate == "50" assert step3.conversion_rate_step == "100" end + + test "handles goal with a pattern" do + journey = [ + %Exploration.Journey.Step{name: "pageview", pathname: "/site*", is_goal: true}, + %Exploration.Journey.Step{name: "pageview", pathname: "/dashboard"} + ] + + site = new_site() + now = DateTime.utc_now() + + populate_stats(site, [ + build(:pageview, + user_id: 123, + pathname: "/sites", + timestamp: DateTime.shift(now, minute: -50) + ), + build(:pageview, + user_id: 123, + pathname: "/dashboard", + timestamp: DateTime.shift(now, minute: -40) + ), + build(:pageview, + user_id: 124, + pathname: "/sites/settings", + timestamp: DateTime.shift(now, minute: -50) + ) + ]) + + query = QueryBuilder.build!(site, input_date_range: :all) + + assert {:ok, [step1, step2]} = Exploration.journey_funnel(query, journey) + + assert step1.step.pathname == "/site*" + assert step1.visitors == 2 + assert step1.dropoff == 0 + assert step1.dropoff_percentage == "0" + assert step1.conversion_rate == "100" + assert step1.conversion_rate_step == "0" + assert step2.step.pathname == "/dashboard" + assert step2.visitors == 1 + assert step2.dropoff == 1 + assert step2.dropoff_percentage == "50" + assert step2.conversion_rate == "50" + assert step2.conversion_rate_step == "50" + end end describe "interesting_funnel" do @@ -849,6 +894,168 @@ defmodule Plausible.Stats.ExplorationTest do assert next_step.step.pathname == "/logout" assert next_step.visitors == 2 end + + test "considers existing goals in the listing" do + now = DateTime.utc_now() + site = new_site() + + Plausible.Goals.create(site, %{"page_path" => "/home"}) + Plausible.Goals.create(site, %{"event_name" => "Signup"}) + + Plausible.Goals.create(site, %{ + "page_path" => "/sites/new", + "display_name" => "Create a site" + }) + + Plausible.Goals.create(site, %{"page_path" => "/site*"}) + + populate_stats(site, [ + build(:pageview, + user_id: 123, + pathname: "/home", + timestamp: DateTime.shift(now, minute: -300) + ), + build(:pageview, + user_id: 123, + pathname: "/register", + timestamp: DateTime.shift(now, minute: -290) + ), + build(:event, + user_id: 123, + name: "Signup", + pathname: "/register", + timestamp: DateTime.shift(now, minute: -280) + ), + build(:pageview, + user_id: 123, + pathname: "/activate", + timestamp: DateTime.shift(now, minute: -270) + ), + build(:pageview, + user_id: 123, + pathname: "/sites/new", + timestamp: DateTime.shift(now, minute: -260) + ), + build(:pageview, + user_id: 123, + pathname: "/sites", + timestamp: DateTime.shift(now, minute: -250) + ), + build(:pageview, + user_id: 124, + pathname: "/home", + timestamp: DateTime.shift(now, minute: -300) + ), + build(:pageview, + user_id: 124, + pathname: "/register", + timestamp: DateTime.shift(now, minute: -290) + ), + build(:event, + user_id: 124, + name: "Signup", + pathname: "/register", + timestamp: DateTime.shift(now, minute: -280) + ), + build(:pageview, + user_id: 124, + pathname: "/activate", + timestamp: DateTime.shift(now, minute: -270) + ), + build(:pageview, + user_id: 124, + pathname: "/sites", + timestamp: DateTime.shift(now, minute: -250) + ), + build(:pageview, + user_id: 125, + pathname: "/home", + timestamp: DateTime.shift(now, minute: -300) + ), + build(:pageview, + user_id: 125, + pathname: "/register", + timestamp: DateTime.shift(now, minute: -290) + ), + build(:event, + user_id: 125, + name: "Signup", + pathname: "/register", + timestamp: DateTime.shift(now, minute: -280) + ), + build(:pageview, + user_id: 126, + pathname: "/home", + timestamp: DateTime.shift(now, minute: -300) + ), + build(:pageview, + user_id: 126, + pathname: "/register", + timestamp: DateTime.shift(now, minute: -290) + ), + build(:pageview, + user_id: 127, + pathname: "/home", + timestamp: DateTime.shift(now, minute: -300) + ) + ]) + + query = QueryBuilder.build!(site, input_date_range: :all) + + assert {:ok, + [ + next_step1, + next_step2, + next_step3, + next_step4, + next_step5, + next_step6, + next_step7 + ]} = + Exploration.next_steps(site, query, []) + + assert next_step1.step.label == "Visit /home" + assert next_step1.step.name == "pageview" + assert next_step1.step.pathname == "/home" + assert next_step1.step.is_goal + assert next_step1.visitors == 5 + + assert next_step2.step.label == "/register" + assert next_step2.step.name == "pageview" + assert next_step2.step.pathname == "/register" + refute next_step2.step.is_goal + assert next_step2.visitors == 4 + + assert next_step3.step.label == "Signup" + assert next_step3.step.name == "Signup" + assert next_step3.step.pathname == "" + assert next_step3.step.is_goal + assert next_step3.visitors == 3 + + assert next_step4.step.label == "/activate" + assert next_step4.step.name == "pageview" + assert next_step4.step.pathname == "/activate" + refute next_step4.step.is_goal + assert next_step4.visitors == 2 + + assert next_step5.step.label == "Visit /site*" + assert next_step5.step.name == "pageview" + assert next_step5.step.pathname == "/site*" + assert next_step5.step.is_goal + assert next_step5.visitors == 2 + + assert next_step6.step.label == "/sites" + assert next_step6.step.name == "pageview" + assert next_step6.step.pathname == "/sites" + refute next_step6.step.is_goal + assert next_step6.visitors == 2 + + assert next_step7.step.label == "Create a site" + assert next_step7.step.name == "pageview" + assert next_step7.step.pathname == "/sites/new" + assert next_step7.step.is_goal + assert next_step7.visitors == 1 + end end describe "implicit wildcard pathnames" do From 2073a49a836bd67b87206f1129213b71194e6510 Mon Sep 17 00:00:00 2001 From: Adrian Gruntkowski Date: Tue, 5 May 2026 13:14:23 +0200 Subject: [PATCH 06/13] Do not exclude wildcards when adding goals --- extra/lib/plausible/stats/exploration.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/extra/lib/plausible/stats/exploration.ex b/extra/lib/plausible/stats/exploration.ex index 14c1214df92f..242e0702cc41 100644 --- a/extra/lib/plausible/stats/exploration.ex +++ b/extra/lib/plausible/stats/exploration.ex @@ -463,7 +463,7 @@ defmodule Plausible.Stats.Exploration do from m in query, left_join: g in values(to_exclude, types), on: g.name == selected_as(:name) and g.pathname == selected_as(:pathname), - where: g.name == "" and not selected_as(:includes_subpaths) + where: g.name == "" end # Expand each (name, pathname, user_id) row into all prefix paths via From 87353fa5434569dcdb87efd6c37805eb6fd6e249 Mon Sep 17 00:00:00 2001 From: Adrian Gruntkowski Date: Tue, 5 May 2026 13:35:26 +0200 Subject: [PATCH 07/13] Exclude goal matches from already combined result --- extra/lib/plausible/stats/exploration.ex | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/extra/lib/plausible/stats/exploration.ex b/extra/lib/plausible/stats/exploration.ex index 242e0702cc41..9965739c8924 100644 --- a/extra/lib/plausible/stats/exploration.ex +++ b/extra/lib/plausible/stats/exploration.ex @@ -460,9 +460,9 @@ defmodule Plausible.Stats.Exploration do types = %{name: :string, pathname: :string} - from m in query, + from m in subquery(query), left_join: g in values(to_exclude, types), - on: g.name == selected_as(:name) and g.pathname == selected_as(:pathname), + on: g.name == m.name and g.pathname == m.pathname, where: g.name == "" end From 6601b6c6b21973dc749556163687ca4df2327282 Mon Sep 17 00:00:00 2001 From: Adrian Gruntkowski Date: Tue, 5 May 2026 13:42:09 +0200 Subject: [PATCH 08/13] Ensure overlapping wildcard entries are not replaced with goals --- extra/lib/plausible/stats/exploration.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/extra/lib/plausible/stats/exploration.ex b/extra/lib/plausible/stats/exploration.ex index 9965739c8924..26a16058306e 100644 --- a/extra/lib/plausible/stats/exploration.ex +++ b/extra/lib/plausible/stats/exploration.ex @@ -463,7 +463,7 @@ defmodule Plausible.Stats.Exploration do from m in subquery(query), left_join: g in values(to_exclude, types), on: g.name == m.name and g.pathname == m.pathname, - where: g.name == "" + where: g.name == "" or m.includes_subpaths end # Expand each (name, pathname, user_id) row into all prefix paths via From 067609ec3b0ca40af645986888d994789a4d8864 Mon Sep 17 00:00:00 2001 From: Adrian Gruntkowski Date: Tue, 5 May 2026 14:14:18 +0200 Subject: [PATCH 09/13] Moves `values` to join --- extra/lib/plausible/stats/exploration.ex | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/extra/lib/plausible/stats/exploration.ex b/extra/lib/plausible/stats/exploration.ex index 26a16058306e..f0e839837897 100644 --- a/extra/lib/plausible/stats/exploration.ex +++ b/extra/lib/plausible/stats/exploration.ex @@ -416,8 +416,8 @@ defmodule Plausible.Stats.Exploration do types = %{label: :string, name: :string, pathname: :string, regex_pathname: :string} query = - from(g in values(values, types), - inner_join: m in subquery(q_matches), + from(m in subquery(q_matches), + inner_join: g in values(values, types), on: g.name == m.name and (g.name != "pageview" or From 43faf4e059807a3d220d982542d3e3a7933719be Mon Sep 17 00:00:00 2001 From: Adrian Gruntkowski Date: Tue, 5 May 2026 17:37:40 +0200 Subject: [PATCH 10/13] Clean up query logic --- extra/lib/plausible/stats/exploration.ex | 110 +++++++++++------------ 1 file changed, 53 insertions(+), 57 deletions(-) diff --git a/extra/lib/plausible/stats/exploration.ex b/extra/lib/plausible/stats/exploration.ex index f0e839837897..0ef696ab31cb 100644 --- a/extra/lib/plausible/stats/exploration.ex +++ b/extra/lib/plausible/stats/exploration.ex @@ -281,10 +281,12 @@ defmodule Plausible.Stats.Exploration do from(s in subquery(q_steps), where: selected_as(:name) != "", select: %{ + user_id: s.user_id, name: selected_as(field(s, ^next_name), :name), pathname: selected_as(field(s, ^next_pathname), :pathname), _sample_factor: fragment("any(?)", s._sample_factor) - } + }, + group_by: [selected_as(:name), selected_as(:pathname), s.user_id] ) q_matches = @@ -296,12 +298,6 @@ defmodule Plausible.Stats.Exploration do from(s in q, where: ^step_condition) end) - q_per_user_matches = - from(m in q_matches, - select_merge: %{user_id: m.user_id}, - group_by: [selected_as(:name), selected_as(:pathname), m.user_id] - ) - # Fan out each q_combined row into up to two output rows (exact + wildcard) # using ARRAY JOIN over a small boolean array. # @@ -310,54 +306,13 @@ defmodule Plausible.Stats.Exploration do # subpath, or same visitor count as exact). ARRAY JOIN then emits one or more # rows per group. The joined boolean `is_wildcard` selects which values to # use for visitors / includes_subpaths / subpaths_count. - q_wildcard_combined = combined_wildcard_query(q_per_user_matches, include_wildcard?) - q_wildcard_combined_matches = - from(m in subquery(q_wildcard_combined), - join: - is_wildcard in fragment( - """ - arrayFilter( - x -> x = false OR (? = 'pageview' AND ? != '/' AND ? > 1 AND ? != ?), - [false, true] - ) - """, - m.name, - m.pathname, - m.subpaths_count, - m.wildcard_visitors, - m.exact_visitors - ), - on: true, - hints: "ARRAY", - where: selected_as(:visitors) > 0, - select: %{ - label: - selected_as( - fragment( - "if(? != 'pageview', ?, ?)", - m.name, - m.name, - m.pathname - ), - :label - ), - name: selected_as(m.name, :name), - pathname: selected_as(m.pathname, :pathname), - visitors: - selected_as( - fragment("if(?, ?, ?)", is_wildcard, m.wildcard_visitors, m.exact_visitors), - :visitors - ), - includes_subpaths: - selected_as(fragment("CAST(?, 'Bool')", is_wildcard), :includes_subpaths), - subpaths_count: fragment("if(?, ?, 0)", is_wildcard, m.subpaths_count), - is_goal: fragment("CAST(?, 'Bool')", false) - } - ) + q_matches + |> combined_wildcard_query(include_wildcard?) + |> combined_wildcard_matches_query() q_all_combined_matches = - if q_goal_matches = goals_query(q_per_user_matches, goals) do + if q_goal_matches = goals_query(q_matches, goals) do q_wildcard_combined_matches |> exclude_goal_matches(goals) |> union_all(^q_goal_matches) @@ -387,10 +342,6 @@ defmodule Plausible.Stats.Exploration do |> maybe_search(search_term) end - @goal_pathname_condition """ - if(? != '', match(?, ?), ? = ?) - """ - defp goals_query(_, []), do: nil defp goals_query(q_matches, goals) do @@ -423,7 +374,7 @@ defmodule Plausible.Stats.Exploration do (g.name != "pageview" or (g.name == "pageview" and fragment( - @goal_pathname_condition, + "if(? != '', match(?, ?), ? = ?)", g.regex_pathname, m.pathname, g.regex_pathname, @@ -519,6 +470,51 @@ defmodule Plausible.Stats.Exploration do ) end + defp combined_wildcard_matches_query(q_wildcard_combined) do + from(m in subquery(q_wildcard_combined), + join: + is_wildcard in fragment( + """ + arrayFilter( + x -> x = false OR (? = 'pageview' AND ? != '/' AND ? > 1 AND ? != ?), + [false, true] + ) + """, + m.name, + m.pathname, + m.subpaths_count, + m.wildcard_visitors, + m.exact_visitors + ), + on: true, + hints: "ARRAY", + where: selected_as(:visitors) > 0, + select: %{ + label: + selected_as( + fragment( + "if(? != 'pageview', ?, ?)", + m.name, + m.name, + m.pathname + ), + :label + ), + name: selected_as(m.name, :name), + pathname: selected_as(m.pathname, :pathname), + visitors: + selected_as( + fragment("if(?, ?, ?)", is_wildcard, m.wildcard_visitors, m.exact_visitors), + :visitors + ), + includes_subpaths: + selected_as(fragment("CAST(?, 'Bool')", is_wildcard), :includes_subpaths), + subpaths_count: fragment("if(?, ?, 0)", is_wildcard, m.subpaths_count), + is_goal: fragment("CAST(?, 'Bool')", false) + } + ) + end + defp journey_funnel_query(query, steps, direction) do q_steps = steps_query(query, length(steps), direction) From f3e9d56bd0f89d7a7ce67047aa95832698f10a60 Mon Sep 17 00:00:00 2001 From: Adrian Gruntkowski Date: Wed, 6 May 2026 10:23:29 +0200 Subject: [PATCH 11/13] Update typespec --- extra/lib/plausible/stats/exploration.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/extra/lib/plausible/stats/exploration.ex b/extra/lib/plausible/stats/exploration.ex index 0ef696ab31cb..d1c31e0b8278 100644 --- a/extra/lib/plausible/stats/exploration.ex +++ b/extra/lib/plausible/stats/exploration.ex @@ -22,7 +22,7 @@ defmodule Plausible.Stats.Exploration 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()) :: t() + @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 = From a4ac0f44a8a209bc45b5bf583ec1355a71635001 Mon Sep 17 00:00:00 2001 From: Adrian Gruntkowski Date: Wed, 6 May 2026 10:26:23 +0200 Subject: [PATCH 12/13] Use proper predicate functions for filtering goals Co-authored-by: Adam Rutkowski --- extra/lib/plausible/stats/exploration.ex | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/extra/lib/plausible/stats/exploration.ex b/extra/lib/plausible/stats/exploration.ex index d1c31e0b8278..d6fd9b243537 100644 --- a/extra/lib/plausible/stats/exploration.ex +++ b/extra/lib/plausible/stats/exploration.ex @@ -256,8 +256,8 @@ defmodule Plausible.Stats.Exploration do defp normalize_pathname(pathname), do: String.trim_trailing(pathname, "/") defp filter_eligible_goals(goals) do - Enum.filter(goals, fn g -> - is_nil(g.currency) and g.scroll_threshold == -1 and g.custom_props == %{} + Enum.reject(goals, fn g -> + Plausible.Goal.Revenue.revenue?(g) or g.scroll_threshold > -1 or Plausible.Goal.has_custom_props?(g) end) end From f77c7ecf47199036d01f537f7c8fac70a4ffa73f Mon Sep 17 00:00:00 2001 From: Adrian Gruntkowski Date: Wed, 6 May 2026 10:30:39 +0200 Subject: [PATCH 13/13] Apply formatting --- extra/lib/plausible/stats/exploration.ex | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/extra/lib/plausible/stats/exploration.ex b/extra/lib/plausible/stats/exploration.ex index d6fd9b243537..1bf0a5437349 100644 --- a/extra/lib/plausible/stats/exploration.ex +++ b/extra/lib/plausible/stats/exploration.ex @@ -257,7 +257,8 @@ defmodule Plausible.Stats.Exploration do defp filter_eligible_goals(goals) do Enum.reject(goals, fn g -> - Plausible.Goal.Revenue.revenue?(g) or g.scroll_threshold > -1 or Plausible.Goal.has_custom_props?(g) + Plausible.Goal.Revenue.revenue?(g) or g.scroll_threshold > -1 or + Plausible.Goal.has_custom_props?(g) end) end