Merge pull request #23 from plausible-insights/bounce-rate-by-referrer

Show bounce rate for referrers and pages
This commit is contained in:
Uku Taht 2020-01-07 15:04:05 +02:00 committed by GitHub
commit cfe3695ba8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 325 additions and 50 deletions

View File

@ -218,3 +218,7 @@ a {
width: 1em;
transform: translateY(0.15em);
}
.table-striped tbody tr:nth-child(odd) {
background-color: #f1f5f8;
}

View File

@ -4,7 +4,6 @@ import { withRouter } from 'react-router-dom'
import Modal from './modal'
import * as api from '../../api'
import numberFormatter from '../../number-formatter'
import Bar from '../bar'
import {parseQuery} from '../../query'
class PagesModal extends React.Component {
@ -16,19 +15,25 @@ class PagesModal extends React.Component {
componentDidMount() {
const query = parseQuery(this.props.location.search, this.props.site)
api.get(`/api/stats/${this.props.site.domain}/pages`, query, {limit: 100})
api.get(`/api/stats/${this.props.site.domain}/pages`, query, {limit: 100, include: 'bounce_rate'})
.then((res) => this.setState({loading: false, pages: res}))
}
formatBounceRate(page) {
if (page.bounce_rate) {
return page.bounce_rate + '%'
} else {
return '-'
}
}
renderPage(page) {
return (
<React.Fragment key={page.name}>
<div className="flex items-center justify-between my-2">
<span>{ page.name }</span>
<span>{numberFormatter(page.count)}</span>
</div>
<Bar count={page.count} all={this.state.pages} color="orange" />
</React.Fragment>
<tr className="text-sm" key={page.name}>
<td className="p-2 truncate">{page.name}</td>
<td className="p-2 w-32 font-medium" align="right">{numberFormatter(page.count)}</td>
<td className="p-2 w-32 font-medium" align="right">{this.formatBounceRate(page)}</td>
</tr>
)
}
@ -43,13 +48,21 @@ class PagesModal extends React.Component {
<header className="modal__header">
<h1>Top pages</h1>
</header>
<div className="text-grey-darker text-lg ml-1 mt-1">by pageviews</div>
<div className="my-4 border-b border-grey-light"></div>
<main className="modal__content">
<div className="mt-8">
{ this.state.pages.map(this.renderPage.bind(this)) }
</div>
<table className="w-full table-striped table-fixed">
<thead>
<tr>
<th className="p-2 text-xs tracking-wide font-bold text-grey-dark" align="left">Page url</th>
<th className="p-2 w-32 text-xs tracking-wide font-bold text-grey-dark" align="right">Pageviews</th>
<th className="p-2 w-32 text-xs tracking-wide font-bold text-grey-dark" align="right">Bounce rate</th>
</tr>
</thead>
<tbody>
{ this.state.pages.map(this.renderPage.bind(this)) }
</tbody>
</table>
</main>
</React.Fragment>
)

View File

@ -4,7 +4,6 @@ import { Link, withRouter } from 'react-router-dom'
import Modal from './modal'
import * as api from '../../api'
import numberFormatter from '../../number-formatter'
import Bar from '../bar'
import {parseQuery, toHuman} from '../../query'
class ReferrerDrilldownModal extends React.Component {
@ -17,19 +16,27 @@ class ReferrerDrilldownModal extends React.Component {
}
componentDidMount() {
api.get(`/api/stats/${this.props.site.domain}/referrers/${this.props.match.params.referrer}`, this.state.query, {limit: 100})
api.get(`/api/stats/${this.props.site.domain}/referrers/${this.props.match.params.referrer}`, this.state.query, {limit: 100, include: 'bounce_rate'})
.then((res) => this.setState({loading: false, referrers: res.referrers, totalVisitors: res.total_visitors}))
}
formatBounceRate(ref) {
if (ref.bounce_rate) {
return ref.bounce_rate + '%'
} else {
return '-'
}
}
renderReferrer(referrer) {
return (
<React.Fragment key={referrer.name}>
<div className="flex items-center justify-between my-2">
<a className="hover:underline truncate" target="_blank" style={{maxWidth: '80%'}} href={'//' + referrer.name}>{ referrer.name }</a>
<span>{numberFormatter(referrer.count)}</span>
</div>
<Bar count={referrer.count} all={this.state.referrers} color="blue" />
</React.Fragment>
<tr className="text-sm" key={referrer.name}>
<td className="p-2 truncate">
<a className="hover:underline" target="_blank" href={'//' + referrer.name}>{ referrer.name }</a>
</td>
<td className="p-2 w-32 font-medium" align="right">{numberFormatter(referrer.count)}</td>
<td className="p-2 w-32 font-medium" align="right">{this.formatBounceRate(referrer)}</td>
</tr>
)
}
@ -58,9 +65,18 @@ class ReferrerDrilldownModal extends React.Component {
<h1 className="mb-0 leading-none">{this.state.totalVisitors} visitors from {this.props.match.params.referrer}<br /> {toHuman(this.state.query)}</h1>
{this.renderGoalText()}
<div className="mt-8">
{ this.state.referrers.map(this.renderReferrer.bind(this)) }
</div>
<table className="w-full table-striped table-fixed mt-4">
<thead>
<tr>
<th className="p-2 text-xs tracking-wide font-bold text-grey-dark" align="left">Referrer</th>
<th className="p-2 w-32 text-xs tracking-wide font-bold text-grey-dark" align="right">Visitors</th>
<th className="p-2 w-32 text-xs tracking-wide font-bold text-grey-dark" align="right">Bounce rate</th>
</tr>
</thead>
<tbody>
{ this.state.referrers.map(this.renderReferrer.bind(this)) }
</tbody>
</table>
</main>
</React.Fragment>
)

View File

@ -4,31 +4,45 @@ import { Link, withRouter } from 'react-router-dom'
import Modal from './modal'
import * as api from '../../api'
import numberFormatter from '../../number-formatter'
import Bar from '../bar'
import {parseQuery} from '../../query'
class ReferrersModal extends React.Component {
constructor(props) {
super(props)
this.state = {loading: true}
this.state = {
loading: true,
query: parseQuery(props.location.search, props.site)
}
}
componentDidMount() {
const query = parseQuery(this.props.location.search, this.props.site)
const include = this.showBounceRate() ? 'bounce_rate' : null
api.get(`/api/stats/${this.props.site.domain}/referrers`, query, {limit: 100})
api.get(`/api/stats/${this.props.site.domain}/referrers`, this.state.query, {limit: 100, include: include})
.then((res) => this.setState({loading: false, referrers: res}))
}
showBounceRate() {
return !this.state.query.filters.goal
}
formatBounceRate(page) {
if (page.bounce_rate) {
return page.bounce_rate + '%'
} else {
return '-'
}
}
renderReferrer(referrer) {
return (
<React.Fragment key={referrer.name}>
<div className="flex items-center justify-between my-2">
<tr className="text-sm" key={referrer.name}>
<td className="p-2">
<Link className="hover:underline truncate" style={{maxWidth: '80%'}} to={`/${this.props.site.domain}/referrers/${referrer.name}${window.location.search}`}>{ referrer.name }</Link>
<span>{numberFormatter(referrer.count)}</span>
</div>
<Bar count={referrer.count} all={this.state.referrers} color="blue" />
</React.Fragment>
</td>
<td className="p-2 w-32 font-medium" align="right">{numberFormatter(referrer.count)}</td>
{this.showBounceRate() && <td className="p-2 w-32 font-medium" align="right">{this.formatBounceRate(referrer)}</td> }
</tr>
)
}
@ -41,15 +55,23 @@ class ReferrersModal extends React.Component {
return (
<React.Fragment>
<header className="modal__header">
<h1>Referrers</h1>
<h1>Top Referrers</h1>
</header>
<div className="text-grey-darker text-lg ml-1 mt-1">by visitors</div>
<div className="my-4 border-b border-grey-light"></div>
<main className="modal__content">
<div className="mt-8">
{ this.state.referrers.map(this.renderReferrer.bind(this)) }
</div>
<table className="w-full table-striped table-fixed">
<thead>
<tr>
<th className="p-2 text-xs tracking-wide font-bold text-grey-dark" align="left">Referrer</th>
<th className="p-2 w-32 text-xs tracking-wide font-bold text-grey-dark" align="right">Visitors</th>
{this.showBounceRate() && <th className="p-2 w-32 text-xs tracking-wide font-bold text-grey-dark" align="right">Bounce rate</th>}
</tr>
</thead>
<tbody>
{ this.state.referrers.map(this.renderReferrer.bind(this)) }
</tbody>
</table>
</main>
</React.Fragment>
)

View File

@ -49,6 +49,7 @@ defmodule Plausible.Ingest.Session do
hostname: event.hostname,
user_id: event.user_id,
new_visitor: event.new_visitor,
entry_page: event.pathname,
is_bounce: state[:is_bounce],
length: length,
referrer: event.referrer,

View File

@ -10,6 +10,7 @@ defmodule Plausible.Session do
field :start, :naive_datetime, null: false
field :length, :integer
field :is_bounce, :boolean
field :entry_page, :string
field :referrer, :string
field :referrer_source, :string
@ -23,7 +24,7 @@ defmodule Plausible.Session do
def changeset(session, attrs) do
session
|> cast(attrs, [:hostname, :referrer, :new_visitor, :user_id, :start, :length, :is_bounce, :operating_system, :browser, :referrer_source, :country_code, :screen_size])
|> cast(attrs, [:hostname, :entry_page, :referrer, :new_visitor, :user_id, :start, :length, :is_bounce, :operating_system, :browser, :referrer_source, :country_code, :screen_size])
|> validate_required([:hostname, :new_visitor, :user_id, :is_bounce, :start])
end
end

View File

@ -163,14 +163,60 @@ defmodule Plausible.Stats do
))
end
def top_referrers(site, query, limit \\ 5) do
Repo.all(from e in base_query(site, query),
def top_referrers(site, query, limit \\ 5, include \\ []) do
referrers = Repo.all(from e in base_query(site, query),
select: %{name: e.referrer_source, count: count(e.user_id, :distinct)},
group_by: e.referrer_source,
where: not is_nil(e.referrer_source),
order_by: [desc: 2],
limit: ^limit
)
if "bounce_rate" in include do
bounce_rates = bounce_rates_by_referrer_source(site, query, Enum.map(referrers, fn ref -> ref[:name] end))
Enum.map(referrers, fn referrer ->
Map.put(referrer, :bounce_rate, bounce_rates[referrer[:name]])
end)
else
referrers
end
end
defp bounce_rates_by_referrer_source(site, query, referrers) do
{first_datetime, last_datetime} = date_range_utc_boundaries(query.date_range, site.timezone)
total_sessions_by_referrer = Repo.all(
from s in Plausible.Session,
where: s.hostname == ^site.domain,
where: s.new_visitor,
where: s.start >= ^first_datetime and s.start < ^last_datetime,
where: s.referrer_source in ^referrers,
group_by: s.referrer_source,
select: {s.referrer_source, count(s.id)}
) |> Enum.into(%{})
bounced_sessions_by_referrer = Repo.all(
from s in Plausible.Session,
where: s.hostname == ^site.domain,
where: s.new_visitor,
where: s.start >= ^first_datetime and s.start < ^last_datetime,
where: s.is_bounce,
where: s.referrer_source in ^referrers,
group_by: s.referrer_source,
select: {s.referrer_source, count(s.id)}
) |> Enum.into(%{})
Enum.reduce(referrers, %{}, fn referrer, acc ->
total_sessions = Map.get(total_sessions_by_referrer, referrer, 0)
bounced_sessions = Map.get(bounced_sessions_by_referrer, referrer, 0)
bounce_rate = if total_sessions > 0 do
round(bounced_sessions / total_sessions * 100)
end
Map.put(acc, referrer, bounce_rate)
end)
end
def visitors_from_referrer(site, query, referrer) do
@ -181,23 +227,115 @@ defmodule Plausible.Stats do
)
end
def referrer_drilldown(site, query, referrer) do
Repo.all(from e in base_query(site, query),
def referrer_drilldown(site, query, referrer, include \\ []) do
referring_urls = Repo.all(from e in base_query(site, query),
select: %{name: e.referrer, count: count(e.user_id, :distinct)},
group_by: e.referrer,
where: e.referrer_source == ^referrer,
order_by: [desc: 2],
limit: 100
)
if "bounce_rate" in include do
bounce_rates = bounce_rates_by_referring_url(site, query, Enum.map(referring_urls, fn ref -> ref[:name] end))
Enum.map(referring_urls, fn url ->
Map.put(url, :bounce_rate, bounce_rates[url[:name]])
end)
else
referring_urls
end
end
def top_pages(site, query, limit \\ 5) do
Repo.all(from e in base_query(site, query),
defp bounce_rates_by_referring_url(site, query, referring_urls) do
{first_datetime, last_datetime} = date_range_utc_boundaries(query.date_range, site.timezone)
total_sessions_by_url = Repo.all(
from s in Plausible.Session,
where: s.hostname == ^site.domain,
where: s.new_visitor,
where: s.start >= ^first_datetime and s.start < ^last_datetime,
where: s.referrer in ^referring_urls,
group_by: s.referrer,
select: {s.referrer, count(s.id)}
) |> Enum.into(%{})
bounced_sessions_by_url = Repo.all(
from s in Plausible.Session,
where: s.hostname == ^site.domain,
where: s.new_visitor,
where: s.start >= ^first_datetime and s.start < ^last_datetime,
where: s.is_bounce,
where: s.referrer in ^referring_urls,
group_by: s.referrer,
select: {s.referrer, count(s.id)}
) |> Enum.into(%{})
Enum.reduce(referring_urls, %{}, fn url, acc ->
total_sessions = Map.get(total_sessions_by_url, url, 0)
bounced_sessions = Map.get(bounced_sessions_by_url, url, 0)
bounce_rate = if total_sessions > 0 do
round(bounced_sessions / total_sessions * 100)
end
Map.put(acc, url, bounce_rate)
end)
end
def top_pages(site, query, limit \\ 5, include \\ []) do
pages = Repo.all(from e in base_query(site, query),
select: %{name: e.pathname, count: count(e.pathname)},
group_by: e.pathname,
order_by: [desc: count(e.pathname)],
limit: ^limit
)
if "bounce_rate" in include do
bounce_rates = bounce_rates_by_page_url(site, query, Enum.map(pages, fn page -> page[:name] end))
Enum.map(pages, fn url ->
Map.put(url, :bounce_rate, bounce_rates[url[:name]])
end)
else
pages
end
end
defp bounce_rates_by_page_url(site, query, page_urls) do
{first_datetime, last_datetime} = date_range_utc_boundaries(query.date_range, site.timezone)
total_sessions_by_url = Repo.all(
from s in Plausible.Session,
where: s.hostname == ^site.domain,
where: s.new_visitor,
where: s.start >= ^first_datetime and s.start < ^last_datetime,
where: s.entry_page in ^page_urls,
group_by: s.entry_page,
select: {s.entry_page, count(s.id)}
) |> Enum.into(%{})
bounced_sessions_by_url = Repo.all(
from s in Plausible.Session,
where: s.hostname == ^site.domain,
where: s.new_visitor,
where: s.start >= ^first_datetime and s.start < ^last_datetime,
where: s.is_bounce,
where: s.entry_page in ^page_urls,
group_by: s.entry_page,
select: {s.entry_page, count(s.id)}
) |> Enum.into(%{})
Enum.reduce(page_urls, %{}, fn url, acc ->
total_sessions = Map.get(total_sessions_by_url, url, 0)
bounced_sessions = Map.get(bounced_sessions_by_url, url, 0)
bounce_rate = if total_sessions > 0 do
round(bounced_sessions / total_sessions * 100)
end
Map.put(acc, url, bounce_rate)
end)
end
@available_screen_sizes ["Desktop", "Laptop", "Tablet", "Mobile"]

View File

@ -68,8 +68,9 @@ defmodule PlausibleWeb.Api.StatsController do
def referrers(conn, params) do
site = conn.assigns[:site]
query = Stats.Query.from(site.timezone, params)
include = if params["include"], do: String.split(params["include"], ","), else: []
json(conn, Stats.top_referrers(site, query, params["limit"] || 5))
json(conn, Stats.top_referrers(site, query, params["limit"] || 5, include))
end
@ -101,8 +102,9 @@ defmodule PlausibleWeb.Api.StatsController do
def referrer_drilldown(conn, %{"referrer" => referrer} = params) do
site = conn.assigns[:site]
query = Stats.Query.from(site.timezone, params)
include = if params["include"], do: String.split(params["include"], ","), else: []
referrers = Stats.referrer_drilldown(site, query, referrer)
referrers = Stats.referrer_drilldown(site, query, referrer, include)
total_visitors = Stats.visitors_from_referrer(site, query, referrer)
json(conn, %{referrers: referrers, total_visitors: total_visitors})
end
@ -110,8 +112,9 @@ defmodule PlausibleWeb.Api.StatsController do
def pages(conn, params) do
site = conn.assigns[:site]
query = Stats.Query.from(site.timezone, params)
include = if params["include"], do: String.split(params["include"], ","), else: []
json(conn, Stats.top_pages(site, query, params["limit"] || 5))
json(conn, Stats.top_pages(site, query, params["limit"] || 5, include))
end
def countries(conn, params) do

View File

@ -0,0 +1,25 @@
defmodule Plausible.Repo.Migrations.AddEntryPageToSessions do
use Ecto.Migration
def change do
alter table(:sessions) do
add :entry_page, :text
end
execute """
UPDATE sessions SET entry_page = pathname
FROM events
WHERE events.user_id = sessions.user_id
AND events.name = 'pageview'
AND events.new_visitor
"""
execute """
DELETE FROM sessions WHERE entry_page is null
"""
alter table(:sessions) do
modify :entry_page, :text, null: false
end
end
end

View File

@ -17,5 +17,21 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do
%{"name" => "/contact", "count" => 1},
]
end
test "calculates bounce rate for pages", %{conn: conn, site: site} do
insert(:pageview, hostname: site.domain, pathname: "/", timestamp: ~N[2019-01-01 02:00:00])
insert(:pageview, hostname: site.domain, pathname: "/", timestamp: ~N[2019-01-01 02:00:00])
insert(:pageview, hostname: site.domain, pathname: "/contact", timestamp: ~N[2019-01-01 02:00:00])
insert(:session, hostname: site.domain, entry_page: "/", is_bounce: true, start: ~N[2019-01-01 02:00:00])
insert(:session, hostname: site.domain, entry_page: "/", is_bounce: false, start: ~N[2019-01-01 02:00:00])
conn = get(conn, "/api/stats/#{site.domain}/pages?period=day&date=2019-01-01&include=bounce_rate")
assert json_response(conn, 200) == [
%{"name" => "/", "count" => 2, "bounce_rate" => 50},
%{"name" => "/contact", "count" => 1, "bounce_rate" => nil},
]
end
end
end

View File

@ -19,6 +19,22 @@ defmodule PlausibleWeb.Api.StatsController.ReferrersTest do
]
end
test "calculates bounce rate for referrers", %{conn: conn, site: site} do
insert(:pageview, hostname: site.domain, referrer_source: "Google", timestamp: ~N[2019-01-01 02:00:00])
insert(:pageview, hostname: site.domain, referrer_source: "Google", timestamp: ~N[2019-01-01 02:00:00])
insert(:pageview, hostname: site.domain, referrer_source: "Bing", timestamp: ~N[2019-01-01 02:00:00])
insert(:session, hostname: site.domain, referrer_source: "Google", is_bounce: true, start: ~N[2019-01-01 02:00:00])
insert(:session, hostname: site.domain, referrer_source: "Google", is_bounce: false, start: ~N[2019-01-01 02:00:00])
conn = get(conn, "/api/stats/#{site.domain}/referrers?period=day&date=2019-01-01&include=bounce_rate")
assert json_response(conn, 200) == [
%{"name" => "Google", "count" => 2, "bounce_rate" => 50},
%{"name" => "Bing", "count" => 1, "bounce_rate" => nil},
]
end
test "filters referrers for a custom goal", %{conn: conn, site: site} do
insert(:event, name: "Signup", hostname: site.domain, referrer_source: "Google", timestamp: ~N[2019-01-01 01:00:00])
insert(:event, name: "Signup", hostname: site.domain, referrer_source: "Google", timestamp: ~N[2019-01-01 02:00:00])
@ -85,6 +101,25 @@ defmodule PlausibleWeb.Api.StatsController.ReferrersTest do
}
end
test "calculates bounce rate for referrer urls", %{conn: conn, site: site} do
insert(:pageview, hostname: site.domain, referrer_source: "10words", referrer: "10words.io/hello", timestamp: ~N[2019-01-01 02:00:00])
insert(:pageview, hostname: site.domain, referrer_source: "10words", referrer: "10words.io/hello", timestamp: ~N[2019-01-01 02:00:00])
insert(:pageview, hostname: site.domain, referrer_source: "10words", referrer: "10words.io/", timestamp: ~N[2019-01-01 02:00:00])
insert(:session, hostname: site.domain, referrer_source: "10words", referrer: "10words.io/hello", is_bounce: true, start: ~N[2019-01-01 02:00:00])
insert(:session, hostname: site.domain, referrer_source: "10words", referrer: "10words.io/hello",is_bounce: false, start: ~N[2019-01-01 02:00:00])
conn = get(conn, "/api/stats/#{site.domain}/referrers/10words?period=day&date=2019-01-01&include=bounce_rate")
assert json_response(conn, 200) == %{
"total_visitors" => 3,
"referrers" => [
%{"name" => "10words.io/hello", "count" => 2, "bounce_rate" => 50},
%{"name" => "10words.io/", "count" => 1, "bounce_rate" => nil},
]
}
end
test "gets keywords from Google", %{conn: conn, user: user, site: site} do
insert(:google_auth, user: user, user: user,site: site, property: "sc-domain:example.com")
insert(:pageview, hostname: site.domain, referrer: "google.com", referrer_source: "Google", timestamp: ~N[2019-01-01 01:00:00])

View File

@ -28,6 +28,7 @@ defmodule Plausible.Factory do
%Plausible.Session{
hostname: hostname,
new_visitor: true,
entry_page: "/",
user_id: UUID.uuid4(),
start: Timex.now(),
is_bounce: false