Skip to content

Commit 8b16126

Browse files
committed
Implement support for open funnels
1 parent 73ce897 commit 8b16126

File tree

3 files changed

+322
-28
lines changed

3 files changed

+322
-28
lines changed

extra/lib/plausible/funnel.ex

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ defmodule Plausible.Funnel do
4141
@type t() :: %__MODULE__{}
4242
schema "funnels" do
4343
field :name, :string
44+
field :open, :boolean, default: false
4445
belongs_to :site, Plausible.Site
4546

4647
has_many :steps, Step,

extra/lib/plausible/stats/funnel.ex

Lines changed: 130 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ defmodule Plausible.Stats.Funnel do
55
"""
66

77
@funnel_window_duration 86_400
8+
@enter_offset 40
9+
@max_steps 32
810

911
alias Plausible.Funnel
1012
alias Plausible.Funnels
@@ -32,28 +34,33 @@ defmodule Plausible.Stats.Funnel do
3234
query
3335
|> Base.base_event_query()
3436
|> query_funnel(funnel)
37+
|> Enum.into(%{})
3538

3639
# Funnel definition steps are 1-indexed, if there's index 0 in the resulting query,
3740
# it signifies the number of visitors that haven't entered the funnel.
38-
not_entering_visitors =
39-
case funnel_data do
40-
[{0, count} | _] -> count
41-
_ -> 0
42-
end
41+
not_entering_visitors = funnel_data[@enter_offset] || 0
4342

44-
all_visitors = Enum.reduce(funnel_data, 0, fn {_, n}, acc -> acc + n end)
45-
steps = backfill_steps(funnel_data, funnel)
43+
all_visitors =
44+
Enum.reduce(funnel_data, 0, fn {step, n}, acc ->
45+
if step >= @enter_offset do
46+
acc + n
47+
else
48+
acc
49+
end
50+
end)
4651

47-
visitors_at_first_step = List.first(steps).visitors
52+
entering_visitors = all_visitors - not_entering_visitors
53+
54+
steps = backfill_steps(funnel_data, funnel, entering_visitors)
4855

4956
{:ok,
5057
%{
5158
name: funnel.name,
5259
steps: steps,
5360
all_visitors: all_visitors,
54-
entering_visitors: visitors_at_first_step,
55-
entering_visitors_percentage: percentage(visitors_at_first_step, all_visitors),
56-
never_entering_visitors: all_visitors - visitors_at_first_step,
61+
entering_visitors: entering_visitors,
62+
entering_visitors_percentage: percentage(entering_visitors, all_visitors),
63+
never_entering_visitors: all_visitors - entering_visitors,
5764
never_entering_visitors_percentage: percentage(not_entering_visitors, all_visitors)
5865
}}
5966
end
@@ -77,7 +84,15 @@ defmodule Plausible.Stats.Funnel do
7784
ClickhouseRepo.all(query)
7885
end
7986

87+
defp select_funnel(db_query, %{open: true} = funnel_definition) do
88+
select_open_funnel(db_query, funnel_definition)
89+
end
90+
8091
defp select_funnel(db_query, funnel_definition) do
92+
select_closed_funnel(db_query, funnel_definition)
93+
end
94+
95+
defp select_closed_funnel(db_query, funnel_definition) do
8196
window_funnel_steps =
8297
Enum.reduce(funnel_definition.steps, nil, fn step, acc ->
8398
goal_condition = Plausible.Stats.Goals.goal_condition(step.goal)
@@ -89,10 +104,25 @@ defmodule Plausible.Stats.Funnel do
89104
end
90105
end)
91106

107+
funnel_steps =
108+
dynamic(
109+
[q],
110+
fragment(
111+
"if(length(range(1, windowFunnel(?)(timestamp, ?) + 1) AS funArr) > 0, funArr, ?)",
112+
@funnel_window_duration,
113+
^window_funnel_steps,
114+
^[0]
115+
)
116+
)
117+
92118
dynamic_window_funnel =
93119
dynamic(
94120
[q],
95-
fragment("windowFunnel(?)(timestamp, ?)", @funnel_window_duration, ^window_funnel_steps)
121+
fragment(
122+
"arrayJoin(arrayConcat(?, [funArr[1] + ?]))",
123+
^funnel_steps,
124+
@enter_offset
125+
)
96126
)
97127

98128
from(q in db_query,
@@ -103,40 +133,112 @@ defmodule Plausible.Stats.Funnel do
103133
)
104134
end
105135

106-
defp backfill_steps(funnel_result, funnel) do
136+
defp select_open_funnel(db_query, funnel_definition) do
137+
steps_count = length(funnel_definition.steps)
138+
139+
window_funnel_steps =
140+
Enum.map(funnel_definition.steps, &Plausible.Stats.Goals.goal_condition(&1.goal))
141+
142+
offset_funnels =
143+
Enum.map(steps_count..1//-1, fn idx ->
144+
offset_steps =
145+
window_funnel_steps
146+
|> Enum.drop(idx - 1)
147+
|> Enum.reduce(nil, fn step, acc ->
148+
if acc do
149+
dynamic([q], fragment("?, ?", ^acc, ^step))
150+
else
151+
dynamic([q], fragment("?", ^step))
152+
end
153+
end)
154+
155+
{nested_funnel(offset_steps, idx), idx}
156+
end)
157+
158+
funnel_reduction =
159+
Enum.reduce(offset_funnels, dynamic([q], fragment("array()")), fn {funnel_expr, idx}, acc ->
160+
nested_funnel_conditional(funnel_expr, acc, idx, steps_count)
161+
end)
162+
163+
longest_funnel =
164+
dynamic([q], fragment("arraySort(x -> length(x), ?)[-1]", ^funnel_reduction))
165+
166+
dynamic_open_window_funnel =
167+
dynamic([q], fragment("? AS funSteps", ^longest_funnel))
168+
169+
full_open_window_funnel =
170+
dynamic(
171+
[q],
172+
fragment(
173+
"arrayJoin(arrayPushBack(?, funSteps[1] + ?))",
174+
^dynamic_open_window_funnel,
175+
@enter_offset
176+
)
177+
)
178+
179+
from(q in db_query,
180+
select_merge:
181+
^%{
182+
step: full_open_window_funnel
183+
}
184+
)
185+
end
186+
187+
for idx <- 1..@max_steps do
188+
fragment_str = "range(#{idx}, windowFunnel(?)(timestamp, ?) + #{idx}) as funArr#{idx}"
189+
190+
defp nested_funnel(steps, unquote(idx)) do
191+
dynamic(
192+
[q],
193+
fragment(
194+
unquote(fragment_str),
195+
@funnel_window_duration,
196+
^steps
197+
)
198+
)
199+
end
200+
end
201+
202+
for idx <- 1..@max_steps, steps <- 1..@max_steps do
203+
fragment_str =
204+
"if(length(?) >= #{steps - idx + 1}, [funArr#{idx}], arrayPushBack(?, funArr#{idx}))"
205+
206+
defp nested_funnel_conditional(current_expr, inner_expr, unquote(idx), unquote(steps)) do
207+
dynamic(
208+
[q],
209+
fragment(
210+
unquote(fragment_str),
211+
^current_expr,
212+
^inner_expr
213+
)
214+
)
215+
end
216+
end
217+
218+
defp backfill_steps(funnel_result, funnel, entering_visitors) do
107219
# Directly from ClickHouse we only get {step_idx(), visitor_count()} tuples.
108220
# but no totals including previous steps are aggregated.
109221
# Hence we need to perform the appropriate backfill
110222
# and also calculate dropoff and conversion rate for each step.
111223
# In case ClickHouse returns 0-index funnel result, we're going to ignore it
112224
# anyway, since we fold over steps as per definition, that are always
113225
# indexed starting from 1.
114-
funnel_result = Enum.into(funnel_result, %{})
115-
max_step = Enum.max_by(funnel.steps, & &1.step_order).step_order
116226

117227
funnel
118228
|> Map.fetch!(:steps)
119-
|> Enum.reduce({nil, nil, []}, fn step, {total_visitors, visitors_at_previous, acc} ->
229+
|> Enum.reduce({nil, []}, fn step, {visitors_at_previous, acc} ->
120230
# first step contains the total number of all visitors qualifying for the funnel,
121231
# with each subsequent step needing to accumulate sum of the previous one(s)
122-
visitors_at_step =
123-
step.step_order..max_step
124-
|> Enum.map(&Map.get(funnel_result, &1, 0))
125-
|> Enum.sum()
232+
visitors_at_step = Map.get(funnel_result, step.step_order, 0)
126233

127234
# accumulate current_visitors for the next iteration
128235
current_visitors = visitors_at_step
129236

130-
# First step contains the total number of visitors that we base percentage dropoff on
131-
total_visitors =
132-
total_visitors ||
133-
current_visitors
134-
135237
# Dropoff is 0 for the first step, otherwise we subtract current from previous
136238
dropoff = if visitors_at_previous, do: visitors_at_previous - current_visitors, else: 0
137239

138240
dropoff_percentage = percentage(dropoff, visitors_at_previous)
139-
conversion_rate = percentage(current_visitors, total_visitors)
241+
conversion_rate = percentage(current_visitors, entering_visitors)
140242
conversion_rate_step = percentage(current_visitors, visitors_at_previous)
141243

142244
step = %{
@@ -148,9 +250,9 @@ defmodule Plausible.Stats.Funnel do
148250
label: to_string(step.goal)
149251
}
150252

151-
{total_visitors, current_visitors, [step | acc]}
253+
{current_visitors, [step | acc]}
152254
end)
153-
|> elem(2)
255+
|> elem(1)
154256
|> Enum.reverse()
155257
end
156258

0 commit comments

Comments
 (0)