Skip to content

Commit

Permalink
Adds Time on Page metric to Top Pages report (#1007)
Browse files Browse the repository at this point in the history
* First pass

Needs more testing & potentially cleanup

* Fixes tests, error handling

* Formatting

* Removes broken test

* Fixes inconsistent test

This was due to Clickhouse setup not inserting the sessions with the exact same timestamp consistently and making the test inconsistent

* Combines `include` param, asyncs time_on_page and bounce_rate

* Fixes CH error when no pageviews exist in period

* Format

* Changelog

* Increases await timeout to accomodate larger data sets

* Improves handling of timeout behavior

* Fixes session-based filtering on time on page queries

* Formatting

* Removes old forced entry page modal from top pages report
  • Loading branch information
Vigasaurus authored May 18, 2021
1 parent 7f3e554 commit 41e4690
Show file tree
Hide file tree
Showing 9 changed files with 120 additions and 43 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ All notable changes to this project will be documented in this file.
- New parameter `metrics` for the `/api/v1/stats/timeseries` endpoint plausible/analytics#952
- CSV export now includes pageviews, bounce rate and visit duration in addition to visitors plausible/analytics#952
- Send stats to multiple dashboards by configuring a comma-separated list of domains plausible/analytics#968
- Time on Page metric available in detailed Top Pages report plausible/analytics#1007

### Fixed
- Fix weekly report time range plausible/analytics#951
Expand Down
30 changes: 11 additions & 19 deletions assets/js/dashboard/stats/modals/pages.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { withRouter } from 'react-router-dom'

import Modal from './modal'
import * as api from '../../api'
import numberFormatter from '../../number-formatter'
import numberFormatter, {durationFormatter} from '../../number-formatter'
import {parseQuery} from '../../query'

class PagesModal extends React.Component {
Expand All @@ -24,24 +24,18 @@ class PagesModal extends React.Component {
}

loadPages() {
const include = this.showBounceRate() ? 'bounce_rate' : null
const detailed = this.showExtra()
const {query, page, pages} = this.state;

const {filters} = query
if (filters.source || filters.referrer) {
api.get(`/api/stats/${encodeURIComponent(this.props.site.domain)}/entry-pages`, query, {limit: 100, page, include})
.then((res) => this.setState((state) => ({loading: false, pages: state.pages.concat(res), moreResultsAvailable: res.length === 100})))
} else {
api.get(`/api/stats/${encodeURIComponent(this.props.site.domain)}/pages`, query, {limit: 100, page, include})
.then((res) => this.setState((state) => ({loading: false, pages: state.pages.concat(res), moreResultsAvailable: res.length === 100})))
}
api.get(`/api/stats/${encodeURIComponent(this.props.site.domain)}/pages`, query, {limit: 100, page, detailed})
.then((res) => this.setState((state) => ({loading: false, pages: state.pages.concat(res), moreResultsAvailable: res.length === 100})))
}

loadMore() {
this.setState({loading: true, page: this.state.page + 1}, this.loadPages.bind(this))
}

showBounceRate() {
showExtra() {
return this.state.query.period !== 'realtime' && !this.state.query.filters.goal
}

Expand All @@ -60,6 +54,7 @@ class PagesModal extends React.Component {

renderPage(page) {
const query = new URLSearchParams(window.location.search)
const timeOnPage = page['time_on_page'] ? durationFormatter(page['time_on_page']) : '-';
query.set('page', page.name)

return (
Expand All @@ -69,7 +64,8 @@ class PagesModal extends React.Component {
</td>
<td className="p-2 w-32 font-medium" align="right">{numberFormatter(page.count)}</td>
{this.showPageviews() && <td className="p-2 w-32 font-medium" align="right">{numberFormatter(page.pageviews)}</td> }
{this.showBounceRate() && <td className="p-2 w-32 font-medium" align="right">{this.formatBounceRate(page)}</td> }
{this.showExtra() && <td className="p-2 w-32 font-medium" align="right">{this.formatBounceRate(page)}</td> }
{this.showExtra() && <td className="p-2 w-32 font-medium" align="right">{timeOnPage}</td> }
</tr>
)
}
Expand All @@ -78,11 +74,6 @@ class PagesModal extends React.Component {
return this.state.query.period === 'realtime' ? 'Current visitors' : 'Visitors'
}

title() {
const {filters} = this.state.query
return (filters.source || filters.referrer) ? 'Entry Pages' : 'Top Pages'
}

renderLoading() {
if (this.state.loading) {
return <div className="loading my-16 mx-auto"><div></div></div>
Expand All @@ -101,7 +92,7 @@ class PagesModal extends React.Component {
if (this.state.pages) {
return (
<React.Fragment>
<h1 className="text-xl font-bold dark:text-gray-100">{this.title()}</h1>
<h1 className="text-xl font-bold dark:text-gray-100">Top Pages</h1>

<div className="my-4 border-b border-gray-300"></div>
<main className="modal__content">
Expand All @@ -111,7 +102,8 @@ class PagesModal extends React.Component {
<th className="p-2 text-xs tracking-wide font-bold text-gray-500 dark:text-gray-400" align="left">Page url</th>
<th className="p-2 w-32 text-xs tracking-wide font-bold text-gray-500 dark:text-gray-400" align="right">{ this.label() }</th>
{this.showPageviews() && <th className="p-2 w-32 text-xs tracking-wide font-bold text-gray-500 dark:text-gray-400" align="right">Pageviews</th>}
{this.showBounceRate() && <th className="p-2 w-32 text-xs tracking-wide font-bold text-gray-500 dark:text-gray-400" align="right">Bounce rate</th>}
{this.showExtra() && <th className="p-2 w-32 text-xs tracking-wide font-bold text-gray-500 dark:text-gray-400" align="right">Bounce rate</th>}
{this.showExtra() && <th className="p-2 w-32 text-xs tracking-wide font-bold text-gray-500 dark:text-gray-400" align="right">Time on Page</th>}
</tr>
</thead>
<tbody>
Expand Down
4 changes: 2 additions & 2 deletions assets/js/dashboard/stats/modals/referrer-drilldown.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,9 @@ class ReferrerDrilldownModal extends React.Component {
}

componentDidMount() {
const include = this.showExtra() ? 'bounce_rate,visit_duration' : null
const detailed = this.showExtra()

api.get(`/api/stats/${encodeURIComponent(this.props.site.domain)}/referrers/${this.props.match.params.referrer}`, this.state.query, {limit: 100, include: include})
api.get(`/api/stats/${encodeURIComponent(this.props.site.domain)}/referrers/${this.props.match.params.referrer}`, this.state.query, {limit: 100, detailed})
.then((res) => this.setState({loading: false, referrers: res.referrers, totalVisitors: res.total_visitors}))
}

Expand Down
4 changes: 2 additions & 2 deletions assets/js/dashboard/stats/modals/sources.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,8 @@ class SourcesModal extends React.Component {
const {site} = this.props
const {query, page, sources} = this.state

const include = this.showExtra() ? 'bounce_rate,visit_duration' : null
api.get(`/api/stats/${encodeURIComponent(site.domain)}/${this.currentFilter()}`, query, {limit: 100, page: page, include: include, show_noref: true})
const detailed = this.showExtra()
api.get(`/api/stats/${encodeURIComponent(site.domain)}/${this.currentFilter()}`, query, {limit: 100, page, detailed, show_noref: true})
.then((res) => this.setState({loading: false, sources: sources.concat(res), moreResultsAvailable: res.length === 100}))
}

Expand Down
98 changes: 89 additions & 9 deletions lib/plausible/stats/clickhouse.ex
Original file line number Diff line number Diff line change
Expand Up @@ -238,7 +238,7 @@ defmodule Plausible.Stats.Clickhouse do
end)
end

def top_sources(site, query, limit, page, show_noref \\ false, include \\ []) do
def top_sources(site, query, limit, page, show_noref \\ false, include_details) do
offset = (page - 1) * limit

referrers =
Expand Down Expand Up @@ -266,7 +266,7 @@ defmodule Plausible.Stats.Clickhouse do
end

referrers =
if "bounce_rate" in include do
if include_details do
from(
s in referrers,
select: %{
Expand Down Expand Up @@ -441,7 +441,7 @@ defmodule Plausible.Stats.Clickhouse do
)
end

def referrer_drilldown(site, query, referrer, include, limit) do
def referrer_drilldown(site, query, referrer, include_details, limit) do
referrer = if referrer == @no_ref, do: "", else: referrer

q =
Expand All @@ -455,7 +455,7 @@ defmodule Plausible.Stats.Clickhouse do
|> filter_converted_sessions(site, query)

q =
if "bounce_rate" in include do
if include_details do
from(
s in q,
select: %{
Expand Down Expand Up @@ -585,7 +585,7 @@ defmodule Plausible.Stats.Clickhouse do
end
end

def top_pages(site, %Query{period: "realtime"} = query, limit, page, _include) do
def top_pages(site, %Query{period: "realtime"} = query, limit, page, _include_details) do
offset = (page - 1) * limit

q = base_session_query(site, query) |> apply_page_as_entry_page(site, query)
Expand All @@ -603,7 +603,7 @@ defmodule Plausible.Stats.Clickhouse do
)
end

def top_pages(site, query, limit, page, include) do
def top_pages(site, query, limit, page, include_details) do
offset = (page - 1) * limit

q =
Expand All @@ -622,9 +622,52 @@ defmodule Plausible.Stats.Clickhouse do

pages = ClickhouseRepo.all(q)

if "bounce_rate" in include do
bounce_rates = bounce_rates_by_page_url(site, query)
Enum.map(pages, fn url -> Map.put(url, :bounce_rate, bounce_rates[url[:name]]) end)
if include_details do
[{bounce_state, bounce_result}, {time_state, time_result}] =
Task.yield_many(
[
Task.async(fn -> bounce_rates_by_page_url(site, query) end),
Task.async(fn ->
{:ok, page_times} =
page_times_by_page_url(site, query, Enum.map(pages, fn p -> p.name end))

page_times.rows |> Enum.map(fn [a, b] -> {a, b} end) |> Enum.into(%{})
end)
],
15000
)
|> Enum.map(fn {task, response} ->
case response do
nil ->
Task.shutdown(task, :brutal_kill)
{nil, nil}

{:ok, result} ->
{:ok, result}

_ ->
response
end
end)

Enum.map(pages, fn page ->
if bounce_state == :ok,
do: Map.put(page, :bounce_rate, bounce_result[page[:name]]),
else: page
end)
|> Enum.map(fn page ->
if time_state == :ok do
time = time_result[page[:name]]

Map.put(
page,
:time_on_page,
if(time, do: round(time), else: nil)
)
else
page
end
end)
else
pages
end
Expand All @@ -648,6 +691,43 @@ defmodule Plausible.Stats.Clickhouse do
|> Enum.into(%{})
end

defp page_times_by_page_url(site, query, page_list) do
q =
from(
e in base_query_w_sessions(site, %Query{
query
| filters: Map.delete(query.filters, "page")
}),
select: {
fragment("? as p", e.pathname),
fragment("? as t", e.timestamp),
fragment("? as s", e.session_id)
},
order_by: [e.session_id, e.timestamp]
)

{base_query_raw, base_query_raw_params} = ClickhouseRepo.to_sql(:all, q)

"SELECT
p,
sum(td)/count(case when p2 != p then 1 end) as avgTime
FROM
(SELECT
p,
p2,
sum(t2-t) as td
FROM
(SELECT
*,
neighbor(t, 1) as t2,
neighbor(p, 1) as p2,
neighbor(s, 1) as s2
FROM (#{base_query_raw}))
WHERE s=s2 AND p IN tuple(?)
GROUP BY p,p2,s)
GROUP BY p" |> ClickhouseRepo.query(base_query_raw_params ++ [page_list ++ ["/"]])
end

defp add_percentages(stat_list) do
total = Enum.reduce(stat_list, 0, fn %{count: count}, total -> total + count end)

Expand Down
12 changes: 6 additions & 6 deletions lib/plausible_web/controllers/api/stats_controller.ex
Original file line number Diff line number Diff line change
Expand Up @@ -140,11 +140,11 @@ defmodule PlausibleWeb.Api.StatsController do
def sources(conn, params) do
site = conn.assigns[:site]
query = Query.from(site.timezone, params)
include = if params["include"], do: String.split(params["include"], ","), else: []
include_details = params["detailed"] == "true"
limit = if params["limit"], do: String.to_integer(params["limit"])
page = if params["page"], do: String.to_integer(params["page"])
show_noref = params["show_noref"] == "true"
json(conn, Stats.top_sources(site, query, limit || 9, page || 1, show_noref, include))
json(conn, Stats.top_sources(site, query, limit || 9, page || 1, show_noref, include_details))
end

def utm_mediums(conn, params) do
Expand Down Expand Up @@ -203,10 +203,10 @@ defmodule PlausibleWeb.Api.StatsController do
def referrer_drilldown(conn, %{"referrer" => referrer} = params) do
site = conn.assigns[:site]
query = Query.from(site.timezone, params)
include = if params["include"], do: String.split(params["include"], ","), else: []
include_details = params["detailed"] == "true"
limit = params["limit"] || 9

referrers = Stats.referrer_drilldown(site, query, referrer, include, limit)
referrers = Stats.referrer_drilldown(site, query, referrer, include_details, limit)
{_, total_visitors} = Stats.pageviews_and_visitors(site, query)
json(conn, %{referrers: referrers, total_visitors: total_visitors})
end
Expand All @@ -223,11 +223,11 @@ defmodule PlausibleWeb.Api.StatsController do
def pages(conn, params) do
site = conn.assigns[:site]
query = Query.from(site.timezone, params)
include = if params["include"], do: String.split(params["include"], ","), else: []
include_details = params["detailed"] == "true"
limit = if params["limit"], do: String.to_integer(params["limit"])
page = if params["page"], do: String.to_integer(params["page"])

json(conn, Stats.top_pages(site, query, limit || 9, page || 1, include))
json(conn, Stats.top_pages(site, query, limit || 9, page || 1, include_details))
end

def entry_pages(conn, params) do
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,33 +16,37 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do
]
end

test "calculates bounce rate for pages", %{conn: conn, site: site} do
test "calculates bounce rate and time on page for pages", %{conn: conn, site: site} do
conn =
get(
conn,
"/api/stats/#{site.domain}/pages?period=day&date=2019-01-01&include=bounce_rate"
"/api/stats/#{site.domain}/pages?period=day&date=2019-01-01&detailed=true"
)

assert json_response(conn, 200) == [
%{
"time_on_page" => 82800,
"bounce_rate" => 33.0,
"count" => 3,
"pageviews" => 3,
"name" => "/"
},
%{
"time_on_page" => 1,
"bounce_rate" => nil,
"count" => 2,
"pageviews" => 2,
"name" => "/register"
},
%{
"time_on_page" => nil,
"bounce_rate" => nil,
"count" => 1,
"pageviews" => 1,
"name" => "/contact"
},
%{
"time_on_page" => nil,
"bounce_rate" => nil,
"count" => 1,
"pageviews" => 1,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ defmodule PlausibleWeb.Api.StatsController.SourcesTest do
conn =
get(
conn,
"/api/stats/#{site.domain}/sources?period=day&date=2019-01-01&include=bounce_rate,visit_duration"
"/api/stats/#{site.domain}/sources?period=day&date=2019-01-01&detailed=true"
)

assert json_response(conn, 200) == [
Expand Down Expand Up @@ -143,7 +143,7 @@ defmodule PlausibleWeb.Api.StatsController.SourcesTest do
conn,
"/api/stats/#{site.domain}/referrers/10words?period=day&date=2019-01-01&filters=#{
filters
}&include=bounce_rate,visit_duration"
}&detailed=true"
)

assert json_response(conn, 200) == %{
Expand Down
2 changes: 1 addition & 1 deletion test/support/clickhouse_setup.ex
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ defmodule Plausible.Test.ClickhouseSetup do
pathname: "/irrelevant",
domain: "test-site.com",
session_id: @conversion_1_session_id,
timestamp: ~N[2019-01-01 23:00:00]
timestamp: ~N[2019-01-01 23:00:01]
},
%{
name: "pageview",
Expand Down

0 comments on commit 41e4690

Please sign in to comment.