Skip to content

Commit 9b07856

Browse files
committed
Add custom and pageview goals to exploration suggestions
1 parent 0bf625c commit 9b07856

4 files changed

Lines changed: 177 additions & 68 deletions

File tree

assets/js/dashboard/extra/exploration.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,8 @@ function toJourney(steps) {
4848
name: s.name,
4949
pathname: s.pathname,
5050
includes_subpaths: s.includes_subpaths,
51-
subpaths_count: s.subpaths_count
51+
subpaths_count: s.subpaths_count,
52+
is_goal: s.is_goal
5253
}))
5354
}
5455

extra/lib/plausible/stats/exploration.ex

Lines changed: 133 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -8,16 +8,22 @@ defmodule Plausible.Stats.Exploration do
88

99
@type t() :: %__MODULE__{}
1010

11-
@derive {Jason.Encoder, only: [:name, :pathname, :label, :includes_subpaths, :subpaths_count]}
12-
defstruct name: nil, pathname: "", label: nil, includes_subpaths: false, subpaths_count: 0
11+
@derive {Jason.Encoder,
12+
only: [:name, :pathname, :label, :includes_subpaths, :subpaths_count, :is_goal]}
13+
defstruct name: nil,
14+
pathname: "",
15+
label: nil,
16+
includes_subpaths: false,
17+
subpaths_count: 0,
18+
is_goal: false
1319

1420
@spec from(map()) :: t()
1521
def from(step) do
16-
new(step.name, step.pathname, step.includes_subpaths, step.subpaths_count)
22+
new(step.name, step.pathname, step.includes_subpaths, step.subpaths_count, step.is_goal)
1723
end
1824

1925
@spec new(String.t(), String.t(), boolean(), non_neg_integer()) :: t()
20-
def new(name, pathname, includes_subpaths \\ false, subpaths_count \\ 0)
26+
def new(name, pathname, includes_subpaths \\ false, subpaths_count \\ 0, is_goal \\ false)
2127
when is_boolean(includes_subpaths) and is_integer(subpaths_count) do
2228
label =
2329
if name != "pageview" do
@@ -31,7 +37,8 @@ defmodule Plausible.Stats.Exploration do
3137
name: name,
3238
pathname: pathname,
3339
includes_subpaths: includes_subpaths,
34-
subpaths_count: subpaths_count
40+
subpaths_count: subpaths_count,
41+
is_goal: is_goal
3542
}
3643
end
3744
end
@@ -76,24 +83,29 @@ defmodule Plausible.Stats.Exploration do
7683
@spec max_steps() :: pos_integer()
7784
def max_steps, do: @max_steps
7885

79-
@spec next_steps(Query.t(), journey(), keyword()) ::
86+
@spec next_steps(Plausible.Site.t(), Query.t(), journey(), keyword()) ::
8087
{:ok, [next_step()]} | {:error, :journey_too_long}
81-
def next_steps(query, journey, opts \\ [])
88+
def next_steps(site, query, journey, opts \\ [])
8289

83-
def next_steps(_query, journey, _opts) when length(journey) >= @max_steps do
90+
def next_steps(_site, _query, journey, _opts) when length(journey) >= @max_steps do
8491
{:error, :journey_too_long}
8592
end
8693

87-
def next_steps(query, journey, opts) do
94+
def next_steps(site, query, journey, opts) do
8895
opts = Keyword.merge(@next_steps_defaults, opts)
8996
direction = Keyword.fetch!(opts, :direction)
9097
search_term = Keyword.fetch!(opts, :search_term)
9198
max_candidates = min(Keyword.fetch!(opts, :max_candidates), @max_candidates)
9299
include_wilcard? = Keyword.fetch!(opts, :include_wildcard?)
93100

101+
goals =
102+
site
103+
|> Plausible.Goals.for_site(include_goals_with_custom_props?: false)
104+
|> filter_eligible_goals()
105+
94106
query
95107
|> Base.base_event_query()
96-
|> next_steps_query(journey, search_term, direction, max_candidates, include_wilcard?)
108+
|> next_steps_query(journey, search_term, direction, max_candidates, include_wilcard?, goals)
97109
# We pass the query struct to record query metadata for
98110
# the CH debug console.
99111
|> ClickhouseRepo.all(query: query)
@@ -143,9 +155,9 @@ defmodule Plausible.Stats.Exploration do
143155
to include implicit wildcard pathnames in suggestions or not
144156
(default: true)
145157
"""
146-
@spec interesting_funnel(Query.t(), keyword()) ::
158+
@spec interesting_funnel(Plausible.Site.t(), Query.t(), keyword()) ::
147159
{:ok, %{funnel: [funnel_step()], candidates: [next_step()]}} | {:error, :not_found}
148-
def interesting_funnel(query, opts \\ []) do
160+
def interesting_funnel(site, query, opts \\ []) do
149161
max_steps = min(Keyword.get(opts, :max_steps, 6), @max_steps)
150162
max_candidates = min(Keyword.get(opts, :max_candidates, 10), @max_candidates)
151163

@@ -157,14 +169,15 @@ defmodule Plausible.Stats.Exploration do
157169
)
158170

159171
with {:ok, result} <-
160-
build_interesting_journey(query, max_steps, max_candidates, include_wildcard?),
172+
build_interesting_journey(site, query, max_steps, max_candidates, include_wildcard?),
161173
{:ok, funnel} <- journey_funnel(query, result.journey) do
162174
{:ok, %{funnel: funnel, candidates: result.candidates}}
163175
end
164176
end
165177

166-
defp build_interesting_journey(query, max_steps, max_candidates, include_wildcard?) do
178+
defp build_interesting_journey(site, query, max_steps, max_candidates, include_wildcard?) do
167179
case do_build_journey(
180+
site,
168181
query,
169182
[],
170183
[],
@@ -179,6 +192,7 @@ defmodule Plausible.Stats.Exploration do
179192
end
180193

181194
defp do_build_journey(
195+
_site,
182196
_query,
183197
journey,
184198
step_candidates,
@@ -192,6 +206,7 @@ defmodule Plausible.Stats.Exploration do
192206
end
193207

194208
defp do_build_journey(
209+
site,
195210
query,
196211
journey,
197212
step_candidates,
@@ -201,7 +216,7 @@ defmodule Plausible.Stats.Exploration do
201216
include_wildcard?
202217
) do
203218
{:ok, candidates} =
204-
next_steps(query, journey,
219+
next_steps(site, query, journey,
205220
max_candidates: max_candidates,
206221
include_wildcard?: include_wildcard?
207222
)
@@ -214,6 +229,7 @@ defmodule Plausible.Stats.Exploration do
214229
new_seen = MapSet.put(seen, normalize_step_key(step))
215230

216231
do_build_journey(
232+
site,
217233
query,
218234
journey ++ [step],
219235
step_candidates ++ [candidates],
@@ -238,7 +254,22 @@ defmodule Plausible.Stats.Exploration do
238254
defp normalize_pathname("/"), do: "/"
239255
defp normalize_pathname(pathname), do: String.trim_trailing(pathname, "/")
240256

241-
defp next_steps_query(query, steps, search_term, direction, max_candidates, include_wildcard?)
257+
defp filter_eligible_goals(goals) do
258+
Enum.filter(goals, fn g ->
259+
is_nil(g.currency) and g.scroll_threshold == -1 and g.custom_props == %{} and
260+
is_nil(g.event_name)
261+
end)
262+
end
263+
264+
defp next_steps_query(
265+
query,
266+
steps,
267+
search_term,
268+
direction,
269+
max_candidates,
270+
include_wildcard?,
271+
goals
272+
)
242273
when is_direction(direction) do
243274
next_step_idx = length(steps) + 1
244275
q_steps = steps_query(query, next_step_idx, direction)
@@ -251,7 +282,8 @@ defmodule Plausible.Stats.Exploration do
251282
where: selected_as(:name) != "",
252283
select: %{
253284
name: selected_as(field(s, ^next_name), :name),
254-
pathname: selected_as(field(s, ^next_pathname), :pathname)
285+
pathname: selected_as(field(s, ^next_pathname), :pathname),
286+
_sample_factor: fragment("any(?)", s._sample_factor)
255287
}
256288
)
257289

@@ -266,12 +298,10 @@ defmodule Plausible.Stats.Exploration do
266298

267299
q_per_user_matches =
268300
from(m in q_matches,
269-
select_merge: %{user_id: m.user_id, _sample_factor: fragment("any(?)", m._sample_factor)},
301+
select_merge: %{user_id: m.user_id},
270302
group_by: [selected_as(:name), selected_as(:pathname), m.user_id]
271303
)
272304

273-
q_combined = combined_query(q_per_user_matches, include_wildcard?)
274-
275305
# Fan out each q_combined row into up to two output rows (exact + wildcard)
276306
# using ARRAY JOIN over a small boolean array.
277307
#
@@ -280,8 +310,10 @@ defmodule Plausible.Stats.Exploration do
280310
# subpath, or same visitor count as exact). ARRAY JOIN then emits one or more
281311
# rows per group. The joined boolean `is_wildcard` selects which values to
282312
# use for visitors / includes_subpaths / subpaths_count.
283-
q_all_matches =
284-
from(m in subquery(q_combined),
313+
q_wildcard_combined = combined_wildcard_query(q_per_user_matches, include_wildcard?)
314+
315+
q_wildcard_combined_matches =
316+
from(m in subquery(q_wildcard_combined),
285317
join:
286318
is_wildcard in fragment(
287319
"""
@@ -300,6 +332,16 @@ defmodule Plausible.Stats.Exploration do
300332
hints: "ARRAY",
301333
where: selected_as(:visitors) > 0,
302334
select: %{
335+
label:
336+
selected_as(
337+
fragment(
338+
"if(? != 'pageview', ?, ?)",
339+
m.name,
340+
m.name,
341+
m.pathname
342+
),
343+
:label
344+
),
303345
name: m.name,
304346
pathname: m.pathname,
305347
visitors:
@@ -308,27 +350,28 @@ defmodule Plausible.Stats.Exploration do
308350
:visitors
309351
),
310352
includes_subpaths: fragment("CAST(?, 'Bool')", is_wildcard),
311-
subpaths_count: fragment("if(?, ?, 0)", is_wildcard, m.subpaths_count)
353+
subpaths_count: fragment("if(?, ?, 0)", is_wildcard, m.subpaths_count),
354+
is_goal: fragment("CAST(?, 'Bool')", false)
312355
}
313356
)
314357

315-
from(m in subquery(q_all_matches),
358+
q_all_combined_matches =
359+
if q_goal_matches = goals_query(q_per_user_matches, goals) do
360+
q_wildcard_combined_matches
361+
|> union_all(^q_goal_matches)
362+
else
363+
q_wildcard_combined_matches
364+
end
365+
366+
from(m in subquery(q_all_combined_matches),
316367
select: %{
317368
step: %Journey.Step{
318-
label:
319-
selected_as(
320-
fragment(
321-
"if(? != 'pageview', ?, ?)",
322-
m.name,
323-
m.name,
324-
m.pathname
325-
),
326-
:label
327-
),
369+
label: selected_as(m.label, :label),
328370
name: m.name,
329371
pathname: m.pathname,
330372
includes_subpaths: m.includes_subpaths,
331-
subpaths_count: m.subpaths_count
373+
subpaths_count: m.subpaths_count,
374+
is_goal: m.is_goal
332375
},
333376
visitors: m.visitors
334377
},
@@ -342,6 +385,59 @@ defmodule Plausible.Stats.Exploration do
342385
|> maybe_search(search_term)
343386
end
344387

388+
@goal_pathname_condition """
389+
if(? LIKE '%*%',
390+
match(?, concat('^', replaceAll(?, '*', '.*'), '$')),
391+
? = ?
392+
)
393+
"""
394+
395+
defp goals_query(_, []), do: nil
396+
397+
defp goals_query(q_matches, goals) do
398+
values =
399+
Enum.map(goals, fn g ->
400+
%{
401+
label: g.display_name,
402+
name: g.event_name || "pageview",
403+
pathname: Regex.escape(g.page_path || "")
404+
}
405+
end)
406+
407+
types = %{label: :string, name: :string, pathname: :string}
408+
409+
query =
410+
from(g in values(values, types),
411+
inner_join: m in subquery(q_matches),
412+
on:
413+
g.name == m.name and
414+
(g.name != "pageview" or
415+
(g.name == "pageview" and
416+
fragment(
417+
@goal_pathname_condition,
418+
g.pathname,
419+
m.pathname,
420+
g.pathname,
421+
m.pathname,
422+
g.pathname
423+
))),
424+
select: %{
425+
label: selected_as(g.label, :label),
426+
name: selected_as(g.name, :name),
427+
pathname: selected_as(g.pathname, :pathname),
428+
visitors: scale_sample(fragment("uniq(?)", m.user_id)),
429+
includes_subpaths: fragment("CAST(?, 'Bool')", false),
430+
subpaths_count: 0,
431+
is_goal: fragment("CAST(?, 'Bool')", true)
432+
},
433+
group_by: [selected_as(:label), selected_as(:name), selected_as(:pathname)]
434+
)
435+
436+
from(m in subquery(query),
437+
where: m.visitors > 0
438+
)
439+
end
440+
345441
# Expand each (name, pathname, user_id) row into all prefix paths via
346442
# ARRAY JOIN, then aggregate once to get both exact and wildcard visitor
347443
# counts in a single scan of events_v2.
@@ -361,7 +457,7 @@ defmodule Plausible.Stats.Exploration do
361457
arraySlice(split_pathname, 1, 1)), [?])
362458
"""
363459

364-
defp combined_query(q_matches, true = _include_wildcard?) do
460+
defp combined_wildcard_query(q_matches, true = _include_wildcard?) do
365461
from(em in subquery(q_matches),
366462
join: pname in fragment(@wildcard_array_join, em.name, em.pathname, em.pathname),
367463
on: true,
@@ -380,7 +476,7 @@ defmodule Plausible.Stats.Exploration do
380476
)
381477
end
382478

383-
defp combined_query(q_matches, false = _include_wildcard?) do
479+
defp combined_wildcard_query(q_matches, false = _include_wildcard?) do
384480
from(em in subquery(q_matches),
385481
where: em.name != "pageview" or selected_as(:pathname) != "",
386482
select: %{

lib/plausible_web/controllers/api/stats_controller.ex

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -151,7 +151,7 @@ defmodule PlausibleWeb.Api.StatsController do
151151
include_wildcard? =
152152
not FunWithFlags.enabled?(@exploration_wildcard_disabled_flag, for: site),
153153
{:ok, next_steps} <-
154-
Exploration.next_steps(query, journey,
154+
Exploration.next_steps(site, query, journey,
155155
search_term: search_term,
156156
direction: direction,
157157
include_wildcard?: include_wildcard?
@@ -188,7 +188,7 @@ defmodule PlausibleWeb.Api.StatsController do
188188
include_wildcard? =
189189
not FunWithFlags.enabled?(@exploration_wildcard_disabled_flag, for: site)
190190

191-
case Exploration.interesting_funnel(query,
191+
case Exploration.interesting_funnel(site, query,
192192
max_steps: params["max_steps"],
193193
max_candidates: params["max_candidates"],
194194
include_wildcard?: include_wildcard?
@@ -209,7 +209,7 @@ defmodule PlausibleWeb.Api.StatsController do
209209
include_wildcard? =
210210
not FunWithFlags.enabled?(@exploration_wildcard_disabled_flag, for: site),
211211
{:ok, next_steps} <-
212-
Exploration.next_steps(query, journey,
212+
Exploration.next_steps(site, query, journey,
213213
search_term: search_term,
214214
direction: direction,
215215
include_wildcard?: include_wildcard?
@@ -242,13 +242,15 @@ defmodule PlausibleWeb.Api.StatsController do
242242
"name" => name,
243243
"pathname" => pathname,
244244
"includes_subpaths" => includes_subpaths,
245-
"subpaths_count" => subpaths_count
245+
"subpaths_count" => subpaths_count,
246+
"is_goal" => is_goal
246247
}) do
247248
Exploration.Journey.Step.new(
248249
name,
249250
pathname,
250251
includes_subpaths,
251-
subpaths_count
252+
subpaths_count,
253+
is_goal
252254
)
253255
end
254256

0 commit comments

Comments
 (0)