@@ -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