Skip to content

Commit

Permalink
Average Scroll Depth Metric: put scroll depth on the dashboard under …
Browse files Browse the repository at this point in the history
…a feature flag (#4832)

* migration: add scroll_depth to events_v2

* (cherry-pick) ingest scroll depth

* replace convoluted test with more concise ones

* QueryParser: parse internal scroll_depth metric + validation

* turn QueryComparisonsTest into QueryInternalTest

* rename file

* (cherry pick) query scroll depth 15b14d3

...and move the tests into `internal_query_test.exs`

* review feedback

* Get rid of unnecessary separation between aggregate and group scroll depth
* Drop irrelevant other metrics in tests

* add test ensuring scroll depth unavailable in Stats API v1

* Put scroll depth on the dashboard

* Top Stats
* Main Graph
* Top Pages > Details

* feature flag for dashboard scroll depth access

* ignore credo warning

* enable scroll_depth flag in tests

* remove duplication

* write timestamps explicitly in a test

* revert moving tests around

* Add query_comparisons_test back
* Move scroll_depth tests into query_test
* Delete query_internal_test

* rename setup util (got updated on master)

* use pageleave_factory where applicable

* Use the correct generated query-api.d.ts

* npm format
  • Loading branch information
RobertJoonas authored Nov 20, 2024
1 parent a29eb3d commit 6822b29
Show file tree
Hide file tree
Showing 26 changed files with 711 additions and 67 deletions.
3 changes: 2 additions & 1 deletion assets/js/dashboard/stats/graph/graph-util.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ export function getGraphableMetrics(query, site) {
} else if (isGoalFilter) {
return ["visitors", "events", "conversion_rate"]
} else if (isPageFilter) {
return ["visitors", "visits", "pageviews", "bounce_rate"]
const pageFilterMetrics = ["visitors", "visits", "pageviews", "bounce_rate"]
return site.flags.scroll_depth ? [...pageFilterMetrics, "scroll_depth"] : pageFilterMetrics
} else {
return ["visitors", "visits", "pageviews", "views_per_visit", "bounce_rate", "visit_duration"]
}
Expand Down
4 changes: 3 additions & 1 deletion assets/js/dashboard/stats/modals/pages.js
Original file line number Diff line number Diff line change
Expand Up @@ -47,12 +47,14 @@ function PagesModal() {
]
}

return [
const defaultMetrics = [
metrics.createVisitors({renderLabel: (_query) => "Visitors" }),
metrics.createPageviews(),
metrics.createBounceRate(),
metrics.createTimeOnPage()
]

return site.flags.scroll_depth ? [...defaultMetrics, metrics.createScrollDepth()] : defaultMetrics
}

return (
Expand Down
2 changes: 2 additions & 0 deletions assets/js/dashboard/stats/reports/metric-formatter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ export const MetricFormatterShort: Record<

bounce_rate: percentageFormatter,
conversion_rate: percentageFormatter,
scroll_depth: percentageFormatter,
exit_rate: percentageFormatter,
group_conversion_rate: percentageFormatter,
percentage: percentageFormatter,
Expand Down Expand Up @@ -65,6 +66,7 @@ export const MetricFormatterLong: Record<

bounce_rate: percentageFormatter,
conversion_rate: percentageFormatter,
scroll_depth: percentageFormatter,
exit_rate: percentageFormatter,
group_conversion_rate: percentageFormatter,
percentage: percentageFormatter,
Expand Down
15 changes: 13 additions & 2 deletions assets/js/dashboard/stats/reports/metrics.js
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,7 @@ export const createVisitDuration = (props) => {
export const createBounceRate = (props) => {
const renderLabel = (_query) => 'Bounce Rate'
return new Metric({
width: 'w-32',
width: 'w-28',
...props,
key: 'bounce_rate',
renderLabel,
Expand All @@ -194,7 +194,7 @@ export const createPageviews = (props) => {
export const createTimeOnPage = (props) => {
const renderLabel = (_query) => 'Time on Page'
return new Metric({
width: 'w-32',
width: 'w-28',
...props,
key: 'time_on_page',
renderLabel,
Expand All @@ -212,3 +212,14 @@ export const createExitRate = (props) => {
sortable: false
})
}

export const createScrollDepth = (props) => {
const renderLabel = (_query) => 'Scroll Depth'
return new Metric({
width: 'w-28',
...props,
key: 'scroll_depth',
renderLabel,
sortable: false
})
}
3 changes: 2 additions & 1 deletion assets/js/types/query-api.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@ export type Metric =
| "group_conversion_rate"
| "time_on_page"
| "total_revenue"
| "average_revenue";
| "average_revenue"
| "scroll_depth";
export type DateRangeShorthand = "30m" | "realtime" | "all" | "day" | "7d" | "30d" | "month" | "6mo" | "12mo" | "year";
/**
* @minItems 2
Expand Down
11 changes: 11 additions & 0 deletions lib/plausible/stats/filters/query_parser.ex
Original file line number Diff line number Diff line change
Expand Up @@ -541,6 +541,17 @@ defmodule Plausible.Stats.Filters.QueryParser do
end
end

defp validate_metric(:scroll_depth = metric, query) do
page_dimension? = Enum.member?(query.dimensions, "event:page")
toplevel_page_filter? = not is_nil(Filters.get_toplevel_filter(query, "event:page"))

if page_dimension? or toplevel_page_filter? do
:ok
else
{:error, "Metric `#{metric}` can only be queried with event:page filters or dimensions."}
end
end

defp validate_metric(:views_per_visit = metric, query) do
cond do
Filters.filtering_on_dimension?(query, "event:page") ->
Expand Down
3 changes: 2 additions & 1 deletion lib/plausible/stats/metrics.ex
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@ defmodule Plausible.Stats.Metrics do
:conversion_rate,
:group_conversion_rate,
:time_on_page,
:percentage
:percentage,
:scroll_depth
] ++ on_ee(do: Plausible.Stats.Goal.Revenue.revenue_metrics(), else: [])

@metric_mappings Enum.into(@all_metrics, %{}, fn metric -> {to_string(metric), metric} end)
Expand Down
1 change: 1 addition & 0 deletions lib/plausible/stats/sql/expression.ex
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,7 @@ defmodule Plausible.Stats.SQL.Expression do

def event_metric(:percentage), do: %{}
def event_metric(:conversion_rate), do: %{}
def event_metric(:scroll_depth), do: %{}
def event_metric(:group_conversion_rate), do: %{}
def event_metric(:total_visitors), do: %{}

Expand Down
2 changes: 1 addition & 1 deletion lib/plausible/stats/sql/query_builder.ex
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ defmodule Plausible.Stats.SQL.QueryBuilder do
|> Enum.reduce(%{}, &Map.merge/2)
end

defp build_group_by(q, table, query) do
def build_group_by(q, table, query) do
Enum.reduce(query.dimensions, q, &dimension_group_by(&2, table, query, &1))
end

Expand Down
50 changes: 50 additions & 0 deletions lib/plausible/stats/sql/special_metrics.ex
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ defmodule Plausible.Stats.SQL.SpecialMetrics do
|> maybe_add_percentage_metric(site, query)
|> maybe_add_global_conversion_rate(site, query)
|> maybe_add_group_conversion_rate(site, query)
|> maybe_add_scroll_depth(site, query)
end

defp maybe_add_percentage_metric(q, site, query) do
Expand Down Expand Up @@ -121,6 +122,55 @@ defmodule Plausible.Stats.SQL.SpecialMetrics do
end
end

def maybe_add_scroll_depth(q, site, query) do
if :scroll_depth in query.metrics do
max_per_visitor_q =
Base.base_event_query(site, query)
|> where([e], e.name == "pageleave")
|> select([e], %{
user_id: e.user_id,
max_scroll_depth: max(e.scroll_depth)
})
|> SQL.QueryBuilder.build_group_by(:events, query)
|> group_by([e], e.user_id)

dim_shortnames = Enum.map(query.dimensions, fn dim -> shortname(query, dim) end)

dim_select =
dim_shortnames
|> Enum.map(fn dim -> {dim, dynamic([p], field(p, ^dim))} end)
|> Map.new()

dim_group_by =
dim_shortnames
|> Enum.map(fn dim -> dynamic([p], field(p, ^dim)) end)

scroll_depth_q =
subquery(max_per_visitor_q)
|> select([p], %{
scroll_depth: fragment("toUInt8(round(ifNotFinite(avg(?), 0)))", p.max_scroll_depth)
})
|> select_merge(^dim_select)
|> group_by(^dim_group_by)

join_on_dim_condition =
if dim_shortnames == [] do
true
else
dim_shortnames
|> Enum.map(fn dim -> dynamic([_e, ..., s], selected_as(^dim) == field(s, ^dim)) end)
# credo:disable-for-next-line Credo.Check.Refactor.Nesting
|> Enum.reduce(fn condition, acc -> dynamic([], ^acc and ^condition) end)
end

q
|> join(:left, [e], s in subquery(scroll_depth_q), on: ^join_on_dim_condition)
|> select_merge_as([_e, ..., s], %{scroll_depth: fragment("any(?)", s.scroll_depth)})
else
q
end
end

# `total_visitors_subquery` returns a subquery which selects `total_visitors` -
# the number used as the denominator in the calculation of `conversion_rate` and
# `percentage` metrics.
Expand Down
1 change: 1 addition & 0 deletions lib/plausible/stats/table_decider.ex
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ defmodule Plausible.Stats.TableDecider do

defp metric_partitioner(_, :average_revenue), do: :event
defp metric_partitioner(_, :total_revenue), do: :event
defp metric_partitioner(_, :scroll_depth), do: :event
defp metric_partitioner(_, :pageviews), do: :event
defp metric_partitioner(_, :events), do: :event
defp metric_partitioner(_, :bounce_rate), do: :session
Expand Down
1 change: 1 addition & 0 deletions lib/plausible/stats/timeseries.ex
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ defmodule Plausible.Stats.Timeseries do
:views_per_visit -> Map.merge(row, %{views_per_visit: 0.0})
:conversion_rate -> Map.merge(row, %{conversion_rate: 0.0})
:group_conversion_rate -> Map.merge(row, %{group_conversion_rate: 0.0})
:scroll_depth -> Map.merge(row, %{scroll_depth: 0})
:bounce_rate -> Map.merge(row, %{bounce_rate: 0.0})
:visit_duration -> Map.merge(row, %{visit_duration: nil})
:average_revenue -> Map.merge(row, %{average_revenue: nil})
Expand Down
63 changes: 48 additions & 15 deletions lib/plausible_web/controllers/api/stats_controller.ex
Original file line number Diff line number Diff line change
Expand Up @@ -193,12 +193,13 @@ defmodule PlausibleWeb.Api.StatsController do

def top_stats(conn, params) do
site = conn.assigns[:site]
current_user = conn.assigns[:current_user]

params = realtime_period_to_30m(params)

query = Query.from(site, params, debug_metadata(conn))

{top_stats, sample_percent} = fetch_top_stats(site, query)
{top_stats, sample_percent} = fetch_top_stats(site, query, current_user)
comparison_query = comparison_query(query)

json(conn, %{
Expand Down Expand Up @@ -293,7 +294,7 @@ defmodule PlausibleWeb.Api.StatsController do
end
end

defp fetch_top_stats(site, query) do
defp fetch_top_stats(site, query, current_user) do
goal_filter? = Filters.filtering_on_dimension?(query, "event:goal")

cond do
Expand All @@ -307,7 +308,7 @@ defmodule PlausibleWeb.Api.StatsController do
fetch_goal_top_stats(site, query)

true ->
fetch_other_top_stats(site, query)
fetch_other_top_stats(site, query, current_user)
end
end

Expand Down Expand Up @@ -391,16 +392,24 @@ defmodule PlausibleWeb.Api.StatsController do
|> then(&{&1, 100})
end

defp fetch_other_top_stats(site, query) do
defp fetch_other_top_stats(site, query, current_user) do
page_filter? = Filters.filtering_on_dimension?(query, "event:page")

metrics = [:visitors, :visits, :pageviews, :sample_percent]

metrics =
cond do
page_filter? && query.include_imported -> metrics
page_filter? -> metrics ++ [:bounce_rate, :time_on_page]
true -> metrics ++ [:views_per_visit, :bounce_rate, :visit_duration]
page_filter? && query.include_imported ->
metrics

page_filter? && scroll_depth_enabled?(site, current_user) ->
metrics ++ [:bounce_rate, :scroll_depth, :time_on_page]

page_filter? ->
metrics ++ [:bounce_rate, :time_on_page]

true ->
metrics ++ [:views_per_visit, :bounce_rate, :visit_duration]
end

current_results = Stats.aggregate(site, query, metrics)
Expand All @@ -418,7 +427,8 @@ defmodule PlausibleWeb.Api.StatsController do
nil -> 0
value -> value
end
)
),
top_stats_entry(current_results, "Scroll depth", :scroll_depth)
]
|> Enum.filter(& &1)

Expand Down Expand Up @@ -819,13 +829,22 @@ defmodule PlausibleWeb.Api.StatsController do

def pages(conn, params) do
site = conn.assigns[:site]
current_user = conn.assigns[:current_user]

params = Map.put(params, "property", "event:page")
query = Query.from(site, params, debug_metadata(conn))

extra_metrics =
if params["detailed"],
do: [:pageviews, :bounce_rate, :time_on_page],
else: []
cond do
params["detailed"] && !query.include_imported && scroll_depth_enabled?(site, current_user) ->
[:pageviews, :bounce_rate, :time_on_page, :scroll_depth]

params["detailed"] ->
[:pageviews, :bounce_rate, :time_on_page]

true ->
[]
end

metrics = breakdown_metrics(query, extra_metrics)
pagination = parse_pagination(params)
Expand Down Expand Up @@ -1532,11 +1551,20 @@ defmodule PlausibleWeb.Api.StatsController do
end

requires_goal_filter? = metric in [:conversion_rate, :events]
has_goal_filter? = Filters.filtering_on_dimension?(query, "event:goal")

if requires_goal_filter? and !Filters.filtering_on_dimension?(query, "event:goal") do
{:error, "Metric `#{metric}` can only be queried with a goal filter"}
else
{:ok, metric}
requires_page_filter? = metric == :scroll_depth
has_page_filter? = Filters.filtering_on_dimension?(query, "event:page")

cond do
requires_goal_filter? and not has_goal_filter? ->
{:error, "Metric `#{metric}` can only be queried with a goal filter"}

requires_page_filter? and not has_page_filter? ->
{:error, "Metric `#{metric}` can only be queried with a page filter"}

true ->
{:ok, metric}
end
end

Expand Down Expand Up @@ -1588,4 +1616,9 @@ defmodule PlausibleWeb.Api.StatsController do
end

defp realtime_period_to_30m(params), do: params

defp scroll_depth_enabled?(site, user) do
FunWithFlags.enabled?(:scroll_depth, for: user) ||
FunWithFlags.enabled?(:scroll_depth, for: site)
end
end
2 changes: 1 addition & 1 deletion lib/plausible_web/controllers/stats_controller.ex
Original file line number Diff line number Diff line change
Expand Up @@ -375,7 +375,7 @@ defmodule PlausibleWeb.StatsController do

defp get_flags(user, site),
do:
[:channels, :saved_segments]
[:channels, :saved_segments, :scroll_depth]
|> Enum.map(fn flag ->
{flag, FunWithFlags.enabled?(flag, for: user) || FunWithFlags.enabled?(flag, for: site)}
end)
Expand Down
4 changes: 4 additions & 0 deletions priv/json-schemas/query-api-schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,10 @@
{
"const": "average_revenue",
"$comment": "only :internal"
},
{
"const": "scroll_depth",
"$comment": "only :internal"
}
]
},
Expand Down
2 changes: 1 addition & 1 deletion test/plausible/billing/quota_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -721,7 +721,7 @@ defmodule Plausible.Billing.QuotaTest do
populate_stats(site, [
build(:event, timestamp: Timex.shift(now, days: -8), name: "custom"),
build(:pageview, user_id: 199, timestamp: Timex.shift(now, days: -5, minutes: -2)),
build(:event, user_id: 199, timestamp: Timex.shift(now, days: -5), name: "pageleave")
build(:pageleave, user_id: 199, timestamp: Timex.shift(now, days: -5))
])

assert %{
Expand Down
2 changes: 1 addition & 1 deletion test/plausible/stats/goal_suggestions_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ defmodule Plausible.Stats.GoalSuggestionsTest do
user_id: 1,
timestamp: NaiveDateTime.utc_now() |> NaiveDateTime.add(-1, :minute)
),
build(:event, name: "pageleave", user_id: 1, timestamp: NaiveDateTime.utc_now())
build(:pageleave, user_id: 1, timestamp: NaiveDateTime.utc_now())
])

assert GoalSuggestions.suggest_event_names(site, "") == ["Signup"]
Expand Down
Loading

0 comments on commit 6822b29

Please sign in to comment.