Show utm_medium, utm_source, and utm_campaign in sources modal (#321)

* Show utm_medium, utm_source, and utm_campaign in sources modal

* Allow filtering by UTM tags

* Integrate filters with URL bar

* Add CHANGELOG entry

* Refresh modal when filter changes

* Remove Direct / None from campaign results

* Add UTM tabs to top sources report

* Add pagination

* Remove dropdown from top sources popup

* Fix bug in clickhouse_ecto

* Remove referrers_for_goal

* Make sure UTM tags work OK with goals

* Make source tab selection sticky

* Consistent styling for devices and source tabs

* Add noref in realtime to utm tabs

* Fix tests
This commit is contained in:
Uku Taht 2020-09-28 11:29:24 +03:00 committed by GitHub
parent 10ac163aaa
commit 8d9667a949
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 488 additions and 151 deletions

View File

@ -3,5 +3,8 @@ All notable changes to this project will be documented in this file.
## [1.0.0] - Unreleased
### Added
- Collect and present link tags (`utm_medium`, `utm_source`, `utm_campaign`) in the dashboard
### Changed
- Replace configuration parameters `CLICKHOUSE_DATABASE_{HOST,NAME,USER,PASSWORD}` with a single `CLICKHOUSE_DATABASE_URL` [plausible/analytics#317](https://github.com/plausible/analytics/pull/317)

View File

@ -9,6 +9,15 @@ function filterText(key, value) {
if (key === "source") {
return <span className="inline-block max-w-sm truncate">Source: <b>{value}</b></span>
}
if (key === "utm_medium") {
return <span className="inline-block max-w-sm truncate">UTM medium: <b>{value}</b></span>
}
if (key === "utm_source") {
return <span className="inline-block max-w-sm truncate">UTM source: <b>{value}</b></span>
}
if (key === "utm_campaign") {
return <span className="inline-block max-w-sm truncate">UTM campaign: <b>{value}</b></span>
}
if (key === "referrer") {
return <span className="inline-block max-w-sm truncate">Referrer: <b>{value}</b></span>
}

View File

@ -25,6 +25,9 @@ export function parseQuery(querystring, site) {
filters: {
'goal': q.get('goal'),
'source': q.get('source'),
'utm_medium': q.get('utm_medium'),
'utm_source': q.get('utm_source'),
'utm_campaign': q.get('utm_campaign'),
'referrer': q.get('referrer'),
'page': q.get('page')
}

View File

@ -1,7 +1,6 @@
import React, { useEffect } from 'react';
import Dash from './index'
import Modal from './stats/modals/modal'
import ReferrersModal from './stats/modals/referrers'
import SourcesModal from './stats/modals/sources'
import ReferrersDrilldownModal from './stats/modals/referrer-drilldown'
import GoogleKeywordsModal from './stats/modals/google-keywords'
import PagesModal from './stats/modals/pages'
@ -28,8 +27,8 @@ export default function Router({site, loggedIn}) {
<ScrollToTop />
<Dash site={site} loggedIn={loggedIn} />
<Switch>
<Route exact path="/:domain/referrers">
<ReferrersModal site={site} />
<Route exact path={["/:domain/sources", "/:domain/utm_mediums", "/:domain/utm_sources", "/:domain/utm_campaigns"]}>
<SourcesModal site={site} />
</Route>
<Route exact path="/:domain/referrers/Google">
<GoogleKeywordsModal site={site} />

View File

@ -242,7 +242,11 @@ class OperatingSystems extends React.Component {
export default class Devices extends React.Component {
constructor(props) {
super(props)
this.state = {mode: 'size'}
this.tabKey = 'deviceTab__' + props.site.domain
const storedTab = window.localStorage[this.tabKey]
this.state = {
mode: storedTab || 'size'
}
}
renderContent() {
@ -257,6 +261,7 @@ export default class Devices extends React.Component {
setMode(mode) {
return () => {
window.localStorage[this.tabKey] = mode
this.setState({mode})
}
}
@ -266,9 +271,9 @@ export default class Devices extends React.Component {
const extraClass = name === 'OS' ? '' : ' border-r border-gray-300'
if (isActive) {
return <span className={"inline-block shadow-inner text-sm font-bold py-1 px-4" + extraClass}>{name}</span>
return <li className="inline-block h-5 text-indigo-700 font-bold border-b-2 border-indigo-700" onClick={this.setMode(mode)}>{name}</li>
} else {
return <span className={"inline-block cursor-pointer bg-gray-100 text-sm font-bold py-1 px-4" + extraClass} onClick={this.setMode(mode)}>{name}</span>
return <li className="hover:text-indigo-700 cursor-pointer" onClick={this.setMode(mode)}>{name}</li>
}
}
@ -276,12 +281,15 @@ export default class Devices extends React.Component {
return (
<div className="stats-item">
<div className="bg-white shadow-xl rounded p-4 relative" style={{height: '436px'}}>
<h3 className="font-bold">Devices</h3>
<div className="rounded border border-gray-300 absolute" style={{top: '1rem', right: '1rem'}}>
{ this.renderPill('Size', 'size') }
{ this.renderPill('Browser', 'browser') }
{ this.renderPill('OS', 'os') }
<div className="w-full flex justify-between">
<h3 className="font-bold">Devices</h3>
<ul className="flex font-medium text-xs text-gray-500 space-x-2">
{ this.renderPill('Size', 'size') }
{ this.renderPill('Browser', 'browser') }
{ this.renderPill('OS', 'os') }
</ul>
</div>
{ this.renderContent() }

View File

@ -1,40 +1,54 @@
import React from "react";
import { Link, withRouter } from 'react-router-dom'
import Transition from "../../../transition.js";
import FadeIn from '../../fade-in'
import Modal from './modal'
import * as api from '../../api'
import numberFormatter, {durationFormatter} from '../../number-formatter'
import {parseQuery} from '../../query'
class ReferrersModal extends React.Component {
const TITLES = {
sources: 'Top sources',
utm_mediums: 'Top UTM mediums',
utm_sources: 'Top UTM sources',
utm_campaigns: 'Top UTM campaigns'
}
class SourcesModal extends React.Component {
constructor(props) {
super(props)
this.state = {
loading: true,
referrers: [],
sources: [],
query: parseQuery(props.location.search, props.site),
page: 1,
moreResultsAvailable: false
}
}
loadReferrers() {
const {query, page, referrers} = this.state
loadSources() {
const {site} = this.props
const {query, page, sources} = this.state
if (query.filters.goal) {
api.get(`/api/stats/${encodeURIComponent(this.props.site.domain)}/goal/referrers`, query, {limit: 100, page: page})
.then((res) => this.setState({loading: false, referrers: referrers.concat(res), moreResultsAvailable: res.length === 100}))
} else {
const include = this.showExtra() ? 'bounce_rate,visit_duration' : null
api.get(`/api/stats/${encodeURIComponent(this.props.site.domain)}/referrers`, query, {limit: 100, page: page, include: include, show_noref: true})
.then((res) => this.setState({loading: false, referrers: referrers.concat(res), moreResultsAvailable: res.length === 100}))
}
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})
.then((res) => this.setState({loading: false, sources: sources.concat(res), moreResultsAvailable: res.length === 100}))
}
componentDidMount() {
this.loadReferrers()
this.loadSources()
}
componentDidUpdate(prevProps) {
if (this.props.location.pathname !== prevProps.location.pathname) {
this.setState({sources: [], loading: true}, this.loadSources.bind(this))
}
}
currentFilter() {
const urlparts = this.props.location.pathname.split('/')
return urlparts[urlparts.length - 1]
}
showExtra() {
@ -42,7 +56,7 @@ class ReferrersModal extends React.Component {
}
loadMore() {
this.setState({loading: true, page: this.state.page + 1}, this.loadReferrers.bind(this))
this.setState({loading: true, page: this.state.page + 1}, this.loadSources.bind(this))
}
formatBounceRate(page) {
@ -53,27 +67,31 @@ class ReferrersModal extends React.Component {
}
}
formatDuration(referrer) {
if (typeof(referrer.visit_duration) === 'number') {
return durationFormatter(referrer.visit_duration)
formatDuration(source) {
if (typeof(source.visit_duration) === 'number') {
return durationFormatter(source.visit_duration)
} else {
return '-'
}
}
renderReferrer(referrer) {
renderSource(source) {
const query = new URLSearchParams(window.location.search)
query.set('source', referrer.name)
const filter = this.currentFilter()
if (filter === 'sources') query.set('source', source.name)
if (filter === 'utm_mediums') query.set('utm_medium', source.name)
if (filter === 'utm_sources') query.set('utm_source', source.name)
if (filter === 'utm_campaigns') query.set('utm_campaign', source.name)
return (
<tr className="text-sm" key={referrer.name}>
<tr className="text-sm" key={source.name}>
<td className="p-2">
<img src={`https://icons.duckduckgo.com/ip3/${referrer.url}.ico`} className="h-4 w-4 mr-2 align-middle inline" />
<Link className="hover:underline truncate" style={{maxWidth: '80%'}} to={{search: query.toString(), pathname: '/' + encodeURIComponent(this.props.site.domain)}}>{ referrer.name }</Link>
<img src={`https://icons.duckduckgo.com/ip3/${source.url}.ico`} className="h-4 w-4 mr-2 align-middle inline" />
<Link className="hover:underline" to={{search: query.toString(), pathname: '/' + encodeURIComponent(this.props.site.domain)}}>{ source.name }</Link>
</td>
<td className="p-2 w-32 font-medium" align="right">{numberFormatter(referrer.count)}</td>
{this.showExtra() && <td className="p-2 w-32 font-medium" align="right">{this.formatBounceRate(referrer)}</td> }
{this.showExtra() && <td className="p-2 w-32 font-medium" align="right">{this.formatDuration(referrer)}</td> }
<td className="p-2 w-32 font-medium" align="right">{numberFormatter(source.count)}</td>
{this.showExtra() && <td className="p-2 w-32 font-medium" align="right">{this.formatBounceRate(source)}</td> }
{this.showExtra() && <td className="p-2 w-32 font-medium" align="right">{this.formatDuration(source)}</td> }
</tr>
)
}
@ -96,10 +114,14 @@ class ReferrersModal extends React.Component {
}
}
title() {
return TITLES[this.currentFilter()]
}
render() {
return (
<Modal site={this.props.site}>
<h1 className="text-xl font-bold">Top Sources</h1>
<h1 className="text-xl font-bold">{this.title()}</h1>
<div className="my-4 border-b border-gray-300"></div>
@ -107,14 +129,14 @@ class ReferrersModal extends React.Component {
<table className="w-full table-striped table-fixed">
<thead>
<tr>
<th className="p-2 text-xs tracking-wide font-bold text-gray-500" align="left">Referrer</th>
<th className="p-2 text-xs tracking-wide font-bold text-gray-500" align="left">Source</th>
<th className="p-2 w-32 text-xs tracking-wide font-bold text-gray-500" align="right">{this.label()}</th>
{this.showExtra() && <th className="p-2 w-32 text-xs tracking-wide font-bold text-gray-500" align="right">Bounce rate</th>}
{this.showExtra() && <th className="p-2 w-32 text-xs tracking-wide font-bold text-gray-500" align="right">Visit duration</th>}
</tr>
</thead>
<tbody>
{ this.state.referrers.map(this.renderReferrer.bind(this)) }
{ this.state.sources.map(this.renderSource.bind(this)) }
</tbody>
</table>
</main>
@ -125,4 +147,4 @@ class ReferrersModal extends React.Component {
}
}
export default withRouter(ReferrersModal)
export default withRouter(SourcesModal)

View File

@ -8,7 +8,7 @@ import MoreLink from '../more-link'
import numberFormatter from '../../number-formatter'
import * as api from '../../api'
export default class Referrers extends React.Component {
class AllSources extends React.Component {
constructor(props) {
super(props)
this.state = {loading: true}
@ -31,13 +31,8 @@ export default class Referrers extends React.Component {
}
fetchReferrers() {
if (this.props.query.filters.goal) {
api.get(`/api/stats/${encodeURIComponent(this.props.site.domain)}/goal/referrers`, this.props.query)
.then((res) => this.setState({loading: false, referrers: res}))
} else {
api.get(`/api/stats/${encodeURIComponent(this.props.site.domain)}/referrers`, this.props.query, {show_noref: this.showNoRef()})
.then((res) => this.setState({loading: false, referrers: res}))
}
api.get(`/api/stats/${encodeURIComponent(this.props.site.domain)}/sources`, this.props.query, {show_noref: this.showNoRef()})
.then((res) => this.setState({loading: false, referrers: res}))
}
renderReferrer(referrer) {
@ -87,9 +82,12 @@ export default class Referrers extends React.Component {
if (this.state.referrers) {
return (
<React.Fragment>
<h3 className="font-bold">Top sources</h3>
<div className="w-full flex justify-between">
<h3 className="font-bold">Top sources</h3>
{ this.props.renderTabs() }
</div>
{ this.renderList() }
<MoreLink site={this.props.site} list={this.state.referrers} endpoint="referrers" />
<MoreLink site={this.props.site} list={this.state.referrers} endpoint="sources" />
</React.Fragment>
)
}
@ -106,3 +104,149 @@ export default class Referrers extends React.Component {
)
}
}
const UTM_TAGS = {
utm_medium: {label: 'UTM Medium', endpoint: 'utm_mediums'},
utm_source: {label: 'UTM Source', endpoint: 'utm_sources'},
utm_campaign: {label: 'UTM Campaign', endpoint: 'utm_campaigns'},
}
class UTMSources extends React.Component {
constructor(props) {
super(props)
this.state = {loading: true}
}
componentDidMount() {
this.fetchReferrers()
if (this.props.timer) this.props.timer.onTick(this.fetchReferrers.bind(this))
}
componentDidUpdate(prevProps) {
if (this.props.query !== prevProps.query || this.props.tab !== prevProps.tab) {
this.setState({loading: true, referrers: null})
this.fetchReferrers()
}
}
showNoRef() {
return this.props.query.period === 'realtime'
}
fetchReferrers() {
const endpoint = UTM_TAGS[this.props.tab].endpoint
api.get(`/api/stats/${encodeURIComponent(this.props.site.domain)}/${endpoint}`, this.props.query, {show_noref: this.showNoRef()})
.then((res) => this.setState({loading: false, referrers: res}))
}
renderReferrer(referrer) {
const query = new URLSearchParams(window.location.search)
query.set(this.props.tab, referrer.name)
return (
<div className="flex items-center justify-between my-1 text-sm" key={referrer.name}>
<div className="w-full h-8" style={{maxWidth: 'calc(100% - 4rem)'}}>
<Bar count={referrer.count} all={this.state.referrers} bg="bg-blue-50" />
<span className="flex px-2" style={{marginTop: '-26px'}} >
<Link className="block truncate hover:underline" to={{search: query.toString()}}>
{ referrer.name }
</Link>
</span>
</div>
<span className="font-medium">{numberFormatter(referrer.count)}</span>
</div>
)
}
label() {
return this.props.query.period === 'realtime' ? 'Current visitors' : 'Visitors'
}
renderList() {
if (this.state.referrers.length > 0) {
return (
<React.Fragment>
<div className="flex items-center mt-3 mb-2 justify-between text-gray-500 text-xs font-bold tracking-wide">
<span>{UTM_TAGS[this.props.tab].label}</span>
<span>{this.label()}</span>
</div>
<FlipMove>
{this.state.referrers.map(this.renderReferrer.bind(this))}
</FlipMove>
</React.Fragment>
)
} else {
return <div className="text-center mt-44 font-medium text-gray-500">No data yet</div>
}
}
renderContent() {
if (this.state.referrers) {
return (
<React.Fragment>
<div className="w-full flex justify-between">
<h3 className="font-bold">Top sources</h3>
{ this.props.renderTabs() }
</div>
{ this.renderList() }
<MoreLink site={this.props.site} list={this.state.referrers} endpoint={UTM_TAGS[this.props.tab].endpoint} />
</React.Fragment>
)
}
}
render() {
return (
<div className="stats-item relative bg-white shadow-xl rounded p-4" style={{height: '436px'}}>
{ this.state.loading && <div className="loading mt-44 mx-auto"><div></div></div> }
<FadeIn show={!this.state.loading}>
{ this.renderContent() }
</FadeIn>
</div>
)
}
}
export default class SourceList extends React.Component {
constructor(props) {
super(props)
this.tabKey = 'sourceTab__' + props.site.domain
const storedTab = window.localStorage[this.tabKey]
this.state = {
tab: storedTab || 'all'
}
}
setTab(tab) {
return () => {
window.localStorage[this.tabKey] = tab
this.setState({tab})
}
}
renderTabs() {
const activeClass = 'inline-block h-5 text-indigo-700 font-bold border-b-2 border-indigo-700'
const defaultClass = 'hover:text-indigo-700 cursor-pointer'
return (
<ul className="flex font-medium text-xs text-gray-500 space-x-2">
<li className={this.state.tab === 'all' ? activeClass : defaultClass} onClick={this.setTab('all')}>All</li>
<li className={this.state.tab === 'utm_medium' ? activeClass : defaultClass} onClick={this.setTab('utm_medium')}>Medium</li>
<li className={this.state.tab === 'utm_source' ? activeClass : defaultClass} onClick={this.setTab('utm_source')}>Source</li>
<li className={this.state.tab === 'utm_campaign' ? activeClass : defaultClass} onClick={this.setTab('utm_campaign')}>Campaign</li>
</ul>
)
}
render() {
if (this.state.tab === 'all') {
return <AllSources tab={this.state.tab} setTab={this.setTab.bind(this)} renderTabs={this.renderTabs.bind(this)} {...this.props} />
} else if (this.state.tab === 'utm_medium') {
return <UTMSources tab={this.state.tab} setTab={this.setTab.bind(this)} renderTabs={this.renderTabs.bind(this)} {...this.props} />
} else if (this.state.tab === 'utm_source') {
return <UTMSources tab={this.state.tab} setTab={this.setTab.bind(this)} renderTabs={this.renderTabs.bind(this)} {...this.props} />
} else if (this.state.tab === 'utm_campaign') {
return <UTMSources tab={this.state.tab} setTab={this.setTab.bind(this)} renderTabs={this.renderTabs.bind(this)} {...this.props} />
}
}
}

View File

@ -194,7 +194,7 @@ defmodule Plausible.Stats.Clickhouse do
end)
end
def top_referrers(site, query, limit, page, show_noref \\ false, include \\ []) do
def top_sources(site, query, limit, page, show_noref \\ false, include \\ []) do
offset = (page - 1) * limit
referrers =
@ -203,7 +203,7 @@ defmodule Plausible.Stats.Clickhouse do
order_by: [desc: fragment("count"), asc: fragment("min(start)")],
limit: ^limit,
offset: ^offset
)
) |> filter_converted_sessions(site, query)
referrers = if show_noref do
referrers
@ -247,6 +247,102 @@ defmodule Plausible.Stats.Clickhouse do
end)
end
defp filter_converted_sessions(db_query, site, %Query{filters: %{"goal" => goal}} = query) when is_binary(goal) do
converted_sessions =
from(e in base_query(site, query),
select: %{session_id: e.session_id})
from(s in db_query,
join: cs in subquery(converted_sessions),
on: s.session_id == cs.session_id
)
end
defp filter_converted_sessions(db_query, _site, _query), do: db_query
def utm_mediums(site, query, limit \\ 9, page \\ 1, show_noref \\ false) do
offset = (page - 1) * limit
q = from(
s in base_session_query(site, query),
group_by: s.utm_medium,
order_by: [desc: fragment("count"), asc: fragment("min(start)")],
limit: ^limit,
offset: ^offset,
select: %{
name: fragment("if(empty(?), ?, ?) as name", s.utm_medium, @no_ref, s.utm_medium),
count: fragment("uniq(user_id) as count"),
bounce_rate: fragment("round(sum(is_bounce * sign) / sum(sign) * 100)"),
visit_duration: fragment("round(avg(duration * sign))")
}
)
q = if show_noref do
q
else
from(s in q, where: s.utm_medium != "")
end
q
|> filter_converted_sessions(site, query)
|> ClickhouseRepo.all()
end
def utm_campaigns(site, query, limit \\ 9, page \\ 1, show_noref \\ false) do
offset = (page - 1) * limit
q = from(
s in base_session_query(site, query),
group_by: s.utm_campaign,
order_by: [desc: fragment("count"), asc: fragment("min(start)")],
limit: ^limit,
offset: ^offset,
select: %{
name: fragment("if(empty(?), ?, ?) as name", s.utm_campaign, @no_ref, s.utm_campaign),
count: fragment("uniq(user_id) as count"),
bounce_rate: fragment("round(sum(is_bounce * sign) / sum(sign) * 100)"),
visit_duration: fragment("round(avg(duration * sign))")
}
)
q = if show_noref do
q
else
from(s in q, where: s.utm_campaign != "")
end
q
|> filter_converted_sessions(site, query)
|> ClickhouseRepo.all()
end
def utm_sources(site, query, limit \\ 9, page \\ 1, show_noref \\ false) do
offset = (page - 1) * limit
q = from(
s in base_session_query(site, query),
group_by: s.utm_source,
order_by: [desc: fragment("count"), asc: fragment("min(start)")],
limit: ^limit,
offset: ^offset,
select: %{
name: fragment("if(empty(?), ?, ?) as name", s.utm_source, @no_ref, s.utm_source),
count: fragment("uniq(user_id) as count"),
bounce_rate: fragment("round(sum(is_bounce * sign) / sum(sign) * 100)"),
visit_duration: fragment("round(avg(duration * sign))")
}
)
q = if show_noref do
q
else
from(s in q, where: s.utm_source != "")
end
q
|> filter_converted_sessions(site, query)
|> ClickhouseRepo.all()
end
def conversions_from_referrer(site, query, referrer) do
converted_sessions =
from(
@ -543,43 +639,16 @@ defmodule Plausible.Stats.Clickhouse do
|> Enum.filter(& &1)
if Enum.count(events) > 0 do
{first_datetime, last_datetime} = utc_boundaries(query, site.timezone)
q =
from(
e in "events",
where: e.domain == ^site.domain,
where: e.timestamp >= ^first_datetime and e.timestamp < ^last_datetime,
where: fragment("? IN tuple(?)", e.name, ^events),
group_by: e.name,
select: %{
name: e.name,
count: fragment("uniq(user_id) as count"),
total_count: fragment("count(*) as total_count")
}
)
q =
if query.filters["source"] do
filtered_sessions =
from(s in base_session_query(site, query), select: %{session_id: s.session_id})
from(
e in q,
join: cs in subquery(filtered_sessions),
on: e.session_id == cs.session_id
)
else
q
end
q =
if query.filters["page"] do
page = query.filters["page"]
from(e in q, where: e.pathname == ^page)
else
q
end
q = from(
e in base_query_w_sessions(site, query),
where: fragment("? IN tuple(?)", e.name, ^events),
group_by: e.name,
select: %{
name: e.name,
count: fragment("uniq(user_id) as count"),
total_count: fragment("count(*) as total_count")
}
)
ClickhouseRepo.all(q)
else
@ -593,43 +662,16 @@ defmodule Plausible.Stats.Clickhouse do
|> Enum.filter(& &1)
if Enum.count(pages) > 0 do
{first_datetime, last_datetime} = utc_boundaries(query, site.timezone)
q =
from(
e in "events",
where: e.domain == ^site.domain,
where: e.timestamp >= ^first_datetime and e.timestamp < ^last_datetime,
where: fragment("? IN tuple(?)", e.pathname, ^pages),
group_by: e.pathname,
select: %{
name: fragment("concat('Visit ', ?) as name", e.pathname),
count: fragment("uniq(user_id) as count"),
total_count: fragment("count(*) as total_count")
}
)
q =
if query.filters["source"] do
filtered_sessions =
from(s in base_session_query(site, query), select: %{session_id: s.session_id})
from(
e in q,
join: cs in subquery(filtered_sessions),
on: e.session_id == cs.session_id
)
else
q
end
q =
if query.filters["page"] do
page = query.filters["page"]
from(e in q, where: e.pathname == ^page)
else
q
end
q = from(
e in base_query_w_sessions(site, query),
where: fragment("? IN tuple(?)", e.pathname, ^pages),
group_by: e.pathname,
select: %{
name: fragment("concat('Visit ', ?) as name", e.pathname),
count: fragment("uniq(user_id) as count"),
total_count: fragment("count(*) as total_count")
}
)
ClickhouseRepo.all(q)
else
@ -659,6 +701,30 @@ defmodule Plausible.Stats.Clickhouse do
sessions_q
end
sessions_q =
if query.filters["utm_medium"] do
utm_medium = query.filters["utm_medium"]
from(s in sessions_q, where: s.utm_medium == ^utm_medium)
else
sessions_q
end
sessions_q =
if query.filters["utm_source"] do
utm_source = query.filters["utm_source"]
from(s in sessions_q, where: s.utm_source == ^utm_source)
else
sessions_q
end
sessions_q =
if query.filters["utm_campaign"] do
utm_campaign = query.filters["utm_campaign"]
from(s in sessions_q, where: s.utm_campaign == ^utm_campaign)
else
sessions_q
end
sessions_q = if query.filters["referrer"] do
ref = query.filters["referrer"]
from(s in sessions_q, where: s.referrer == ^ref)
@ -672,7 +738,7 @@ defmodule Plausible.Stats.Clickhouse do
where: e.timestamp >= ^first_datetime and e.timestamp < ^last_datetime
)
q = if query.filters["source"] || query.filters['referrer'] do
q = if query.filters["source"] || query.filters['referrer'] || query.filters["utm_medium"] || query.filters["utm_source"] || query.filters["utm_campaign"] do
from(
e in q,
join: sq in subquery(sessions_q),
@ -708,6 +774,30 @@ defmodule Plausible.Stats.Clickhouse do
q
end
q =
if query.filters["utm_medium"] do
utm_medium = query.filters["utm_medium"]
from(s in q, where: s.utm_medium == ^utm_medium)
else
q
end
q =
if query.filters["utm_source"] do
utm_source = query.filters["utm_source"]
from(s in q, where: s.utm_source == ^utm_source)
else
q
end
q =
if query.filters["utm_campaign"] do
utm_campaign = query.filters["utm_campaign"]
from(s in q, where: s.utm_campaign == ^utm_campaign)
else
q
end
q =
if query.filters["page"] do
page = query.filters["page"]
@ -718,7 +808,7 @@ defmodule Plausible.Stats.Clickhouse do
if query.filters["referrer"] do
ref = query.filters["referrer"]
from(e in q, where: e.referrer == ^ref)
from(s in q, where: s.referrer == ^ref)
else
q
end
@ -743,6 +833,30 @@ defmodule Plausible.Stats.Clickhouse do
q
end
q =
if query.filters["utm_medium"] do
utm_medium = query.filters["utm_medium"]
from(e in q, where: e.utm_medium == ^utm_medium)
else
q
end
q =
if query.filters["utm_source"] do
utm_source = query.filters["utm_source"]
from(e in q, where: e.utm_source == ^utm_source)
else
q
end
q =
if query.filters["utm_campaign"] do
utm_campaign = query.filters["utm_campaign"]
from(e in q, where: e.utm_campaign == ^utm_campaign)
else
q
end
q =
if query.filters["referrer"] do
ref = query.filters["referrer"]

View File

@ -125,23 +125,41 @@ defmodule PlausibleWeb.Api.StatsController do
end
end
def referrers(conn, params) 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: []
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_referrers(site, query, limit || 9, page || 1, show_noref, include))
json(conn, Stats.top_sources(site, query, limit || 9, page || 1, show_noref, include))
end
def referrers_for_goal(conn, params) do
def utm_mediums(conn, params) do
site = conn.assigns[:site]
query = Query.from(site.timezone, params)
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.utm_mediums(site, query, limit || 9, page || 1, show_noref))
end
json(conn, Stats.top_referrers_for_goal(site, query, limit || 9, page || 1))
def utm_campaigns(conn, params) do
site = conn.assigns[:site]
query = Query.from(site.timezone, params)
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.utm_campaigns(site, query, limit || 9, page || 1, show_noref))
end
def utm_sources(conn, params) do
site = conn.assigns[:site]
query = Query.from(site.timezone, params)
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.utm_sources(site, query, limit || 9, page || 1, show_noref))
end
@google_api Application.fetch_env!(:plausible, :google_api)

View File

@ -46,8 +46,10 @@ defmodule PlausibleWeb.Router do
get "/:domain/current-visitors", StatsController, :current_visitors
get "/:domain/main-graph", StatsController, :main_graph
get "/:domain/referrers", StatsController, :referrers
get "/:domain/goal/referrers", StatsController, :referrers_for_goal
get "/:domain/sources", StatsController, :sources
get "/:domain/utm_mediums", StatsController, :utm_mediums
get "/:domain/utm_sources", StatsController, :utm_sources
get "/:domain/utm_campaigns", StatsController, :utm_campaigns
get "/:domain/referrers/:referrer", StatsController, :referrer_drilldown
get "/:domain/goal/referrers/:referrer", StatsController, :referrer_drilldown_for_goal
get "/:domain/pages", StatsController, :pages

View File

@ -55,7 +55,7 @@ defmodule Plausible.Workers.SendEmailReport do
bounce_rate = Stats.bounce_rate(site, query)
prev_bounce_rate = Stats.bounce_rate(site, Query.shift_back(query))
change_bounce_rate = if prev_bounce_rate > 0, do: bounce_rate - prev_bounce_rate
referrers = Stats.top_referrers(site, query, 5, 1, [])
referrers = Stats.top_sources(site, query, 5, 1, [])
pages = Stats.top_pages(site, query, 5, [])
user = Plausible.Auth.find_user_by(email: email)
login_link = user && Plausible.Sites.is_owner?(user.id, site)

View File

@ -5,7 +5,7 @@
"bcrypt_elixir": {:hex, :bcrypt_elixir, "2.2.0", "3df902b81ce7fa8867a2ae30d20a1da6877a2c056bfb116fd0bc8a5f0190cea4", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "762be3fcb779f08207531bc6612cca480a338e4b4357abb49f5ce00240a77d1e"},
"browser": {:hex, :browser, "0.4.4", "bd6436961a6b2299c6cb38d0e49761c1161d869cd0db46369cef2bf6b77c3665", [:mix], [{:plug, "~> 1.2", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "d476ca309d4a4b19742b870380390aabbcb323c1f6f8745e2da2dfd079b4f8d7"},
"certifi": {:hex, :certifi, "2.5.1", "867ce347f7c7d78563450a18a6a28a8090331e77fa02380b4a21962a65d36ee5", [:rebar3], [{:parse_trans, "~>3.3", [hex: :parse_trans, repo: "hexpm", optional: false]}], "hexpm", "805abd97539caf89ec6d4732c91e62ba9da0cda51ac462380bbd28ee697a8c42"},
"clickhouse_ecto": {:git, "https://github.com/plausible/clickhouse_ecto.git", "bd644593820c6e9c19f25e2cedc8891c5a57f60f", []},
"clickhouse_ecto": {:git, "https://github.com/plausible/clickhouse_ecto.git", "2d79f932bdb7f8894668c650b9dcc124d2145aeb", []},
"clickhousex": {:git, "https://github.com/plausible/clickhousex", "89d58d4cb0cad2558e874f30e81a5c2c84ada95e", []},
"combine": {:hex, :combine, "0.10.0", "eff8224eeb56498a2af13011d142c5e7997a80c8f5b97c499f84c841032e429f", [:mix], [], "hexpm", "1b1dbc1790073076580d0d1d64e42eae2366583e7aecd455d1215b0d16f2451b"},
"comeonin": {:hex, :comeonin, "5.3.1", "7fe612b739c78c9c1a75186ef2d322ce4d25032d119823269d0aa1e2f1e20025", [:mix], [], "hexpm", "d6222483060c17f0977fad1b7401ef0c5863c985a64352755f366aee3799c245"},

View File

@ -1,12 +1,12 @@
defmodule PlausibleWeb.Api.StatsController.ReferrersTest do
defmodule PlausibleWeb.Api.StatsController.SourcesTest do
use PlausibleWeb.ConnCase
import Plausible.TestUtils
describe "GET /api/stats/:domain/referrers" do
describe "GET /api/stats/:domain/sources" do
setup [:create_user, :log_in, :create_site]
test "returns top referrer sources by user ids", %{conn: conn, site: site} do
conn = get(conn, "/api/stats/#{site.domain}/referrers?period=day&date=2019-01-01")
test "returns top sources by unique user ids", %{conn: conn, site: site} do
conn = get(conn, "/api/stats/#{site.domain}/sources?period=day&date=2019-01-01")
assert json_response(conn, 200) == [
%{"name" => "10words", "count" => 2, "url" => "10words.com"},
@ -14,11 +14,11 @@ defmodule PlausibleWeb.Api.StatsController.ReferrersTest do
]
end
test "calculates bounce rate and visit duration for referrers", %{conn: conn, site: site} do
test "calculates bounce rate and visit duration for sources", %{conn: conn, site: site} do
conn =
get(
conn,
"/api/stats/#{site.domain}/referrers?period=day&date=2019-01-01&include=bounce_rate,visit_duration"
"/api/stats/#{site.domain}/sources?period=day&date=2019-01-01&include=bounce_rate,visit_duration"
)
assert json_response(conn, 200) == [
@ -39,8 +39,8 @@ defmodule PlausibleWeb.Api.StatsController.ReferrersTest do
]
end
test "returns top referrer sources in realtime report", %{conn: conn, site: site} do
conn = get(conn, "/api/stats/#{site.domain}/referrers?period=realtime")
test "returns top sources in realtime report", %{conn: conn, site: site} do
conn = get(conn, "/api/stats/#{site.domain}/sources?period=realtime")
assert json_response(conn, 200) == [
%{"name" => "10words", "count" => 2, "url" => "10words.com"},
@ -49,7 +49,7 @@ defmodule PlausibleWeb.Api.StatsController.ReferrersTest do
end
test "can paginate the results", %{conn: conn, site: site} do
conn = get(conn, "/api/stats/#{site.domain}/referrers?period=day&date=2019-01-01&limit=1&page=2")
conn = get(conn, "/api/stats/#{site.domain}/sources?period=day&date=2019-01-01&limit=1&page=2")
assert json_response(conn, 200) == [
%{"name" => "Bing", "count" => 1, "url" => ""}
@ -57,6 +57,18 @@ defmodule PlausibleWeb.Api.StatsController.ReferrersTest do
end
end
describe "GET /api/stats/:domain/utm_mediums" do
setup [:create_user, :log_in, :create_site]
test "returns top utm_mediums by unique user ids", %{conn: conn, site: site} do
conn = get(conn, "/api/stats/#{site.domain}/utm_mediums?period=day&date=2019-01-01")
assert json_response(conn, 200) == [
%{"name" => "listing", "count" => 2, "bounce_rate" => 50.0, "visit_duration" => 50.0},
%{"name" => "search", "count" => 1, "bounce_rate" => 0.0, "visit_duration" => 100.0}
]
end
end
describe "GET /api/stats/:domain/goal/referrers" do
setup [:create_user, :log_in, :create_site]
@ -67,7 +79,7 @@ defmodule PlausibleWeb.Api.StatsController.ReferrersTest do
conn =
get(
conn,
"/api/stats/#{site.domain}/goal/referrers?period=day&date=2019-01-01&filters=#{filters}"
"/api/stats/#{site.domain}/sources?period=day&date=2019-01-01&filters=#{filters}"
)
assert json_response(conn, 200) == [
@ -81,7 +93,7 @@ defmodule PlausibleWeb.Api.StatsController.ReferrersTest do
conn =
get(
conn,
"/api/stats/#{site.domain}/goal/referrers?period=day&date=2019-01-01&filters=#{filters}"
"/api/stats/#{site.domain}/sources?period=day&date=2019-01-01&filters=#{filters}"
)
assert json_response(conn, 200) == [
@ -98,7 +110,7 @@ defmodule PlausibleWeb.Api.StatsController.ReferrersTest do
conn = get(conn, "/api/stats/#{site.domain}/referrers/10words?period=day&date=2019-01-01&filters=#{filters}")
assert json_response(conn, 200) == %{
"total_visitors" => 2,
"total_visitors" => 6,
"referrers" => [
%{"name" => "10words.com/page1", "url" => "10words.com", "count" => 2}
]
@ -114,7 +126,7 @@ defmodule PlausibleWeb.Api.StatsController.ReferrersTest do
)
assert json_response(conn, 200) == %{
"total_visitors" => 2,
"total_visitors" => 6,
"referrers" => [
%{
"name" => "10words.com/page1",

View File

@ -147,6 +147,7 @@ defmodule Plausible.Test.ClickhouseSetup do
exit_page: "/",
referrer_source: "10words",
referrer: "10words.com/page1",
utm_medium: "listing",
session_id: @conversion_1_session_id,
is_bounce: true,
duration: 100,
@ -159,6 +160,7 @@ defmodule Plausible.Test.ClickhouseSetup do
exit_page: "/",
referrer_source: "10words",
referrer: "10words.com/page1",
utm_medium: "listing",
session_id: @conversion_2_session_id,
is_bounce: false,
duration: 0,
@ -171,6 +173,7 @@ defmodule Plausible.Test.ClickhouseSetup do
exit_page: "/",
referrer_source: "Bing",
referrer: "",
utm_medium: "search",
is_bounce: false,
duration: 100,
start: ~N[2019-01-01 03:00:00],