Browser and OS version (#397)

* Collect browser and OS version

* Display browser version

* Show operating system versions

* Device categorization

* Treat headless chrome like a bot

* Ignore events from automated browsers

* Only take major and minor of the version

* Add tests

* Add CHANGELOG entry

* Add changelog entry for bots

* Store empty value as browser when unknown
This commit is contained in:
Uku Taht 2020-11-10 15:18:59 +02:00 committed by GitHub
parent f7885a93d7
commit d0d7b823f8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
25 changed files with 423 additions and 175 deletions

View File

@ -7,9 +7,11 @@ All notable changes to this project will be documented in this file.
- Ability to add event metadata plausible/analytics#381
- Add tracker module to automatically track outbound links plausible/analytics#389
- Display weekday on the visitor graph plausible/analytics#175
- Collect and display browser & OS versions plausible/analytics#397
### Changed
- Use alpine as base image to decrease Docker image size plausible/analytics#353
- Ignore automated browsers (Phantom, Selenium, Headless Chrome, etc)
### Fixed
- Do not error when activating an already activated account plausible/analytics#370

View File

@ -33,9 +33,17 @@ function filterText(key, value, query) {
if (key === "browser") {
return <span className="inline-block max-w-sm truncate">Browser: <b>{value}</b></span>
}
if (key === "browser_version") {
const browserName = query.filters["browser"] ? query.filters["browser"] : 'Browser'
return <span className="inline-block max-w-sm truncate">{browserName}.Version: <b>{value}</b></span>
}
if (key === "os") {
return <span className="inline-block max-w-sm truncate">Operating System: <b>{value}</b></span>
}
if (key === "os_version") {
const osName = query.filters["os"] ? query.filters["os"] : 'OS'
return <span className="inline-block max-w-sm truncate">{osName}.Version: <b>{value}</b></span>
}
if (key === "country") {
const allCountries = Datamap.prototype.worldTopo.objects.world.geometries;
const selectedCountry = allCountries.find((c) => c.id === value)

View File

@ -32,7 +32,9 @@ export function parseQuery(querystring, site) {
'referrer': q.get('referrer'),
'screen': q.get('screen'),
'browser': q.get('browser'),
'browser_version': q.get('browser_version'),
'os': q.get('os'),
'os_version': q.get('os_version'),
'country': q.get('country'),
'page': q.get('page')
}

View File

@ -0,0 +1,94 @@
import React from 'react';
import { Link } from 'react-router-dom'
import FadeIn from '../../fade-in'
import numberFormatter from '../../number-formatter'
import Bar from '../bar'
import * as api from '../../api'
export default class Browsers extends React.Component {
constructor(props) {
super(props)
this.state = {loading: true}
}
componentDidMount() {
this.fetchBrowsers()
if (this.props.timer) this.props.timer.onTick(this.fetchBrowsers.bind(this))
}
componentDidUpdate(prevProps) {
if (this.props.query !== prevProps.query) {
this.setState({loading: true, browsers: null})
this.fetchBrowsers()
}
}
fetchBrowsers() {
if (this.props.query.filters.browser) {
api.get(`/api/stats/${encodeURIComponent(this.props.site.domain)}/browser-versions`, this.props.query)
.then((res) => this.setState({loading: false, browsers: res}))
} else {
api.get(`/api/stats/${encodeURIComponent(this.props.site.domain)}/browsers`, this.props.query)
.then((res) => this.setState({loading: false, browsers: res}))
}
}
renderBrowser(browser) {
const query = new URLSearchParams(window.location.search)
if (this.props.query.filters.browser) {
query.set('browser_version', browser.name)
} else {
query.set('browser', browser.name)
}
return (
<div className="flex items-center justify-between my-1 text-sm" key={browser.name}>
<div className="w-full h-8" style={{maxWidth: 'calc(100% - 6rem)'}}>
<Bar count={browser.count} all={this.state.browsers} bg="bg-green-50" />
<span className="flex px-2" style={{marginTop: '-26px'}} >
<Link className="block truncate hover:underline" to={{search: query.toString()}}>
{browser.name}
</Link>
</span>
</div>
<span className="font-medium">{numberFormatter(browser.count)} <span className="inline-block text-xs w-8 text-right">({browser.percentage}%)</span></span>
</div>
)
}
label() {
return this.props.query.period === 'realtime' ? 'Current visitors' : 'Visitors'
}
renderList() {
const key = this.props.query.filters.browser ? this.props.query.filters.browser + ' version' : 'Browser'
if (this.state.browsers && this.state.browsers.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>{ key }</span>
<span>{ this.label() }</span>
</div>
{ this.state.browsers && this.state.browsers.map(this.renderBrowser.bind(this)) }
</React.Fragment>
)
} else {
return <div className="text-center mt-44 font-medium text-gray-500">No data yet</div>
}
}
render() {
return (
<React.Fragment>
{ this.state.loading && <div className="loading mt-44 mx-auto"><div></div></div> }
<FadeIn show={!this.state.loading}>
{ this.renderList() }
</FadeIn>
</React.Fragment>
)
}
}

View File

@ -1,18 +1,15 @@
import React from 'react';
import { Link } from 'react-router-dom'
import numberFormatter from '../number-formatter'
import Bar from './bar'
import MoreLink from './more-link'
import * as api from '../api'
import Browsers from './browsers'
import OperatingSystems from './operating-systems'
import FadeIn from '../../fade-in'
import numberFormatter from '../../number-formatter'
import Bar from '../bar'
import MoreLink from '../more-link'
import * as api from '../../api'
function FadeIn({show, children}) {
const className = show ? "fade-enter-active" : "fade-enter"
return <div className={className}>{children}</div>
}
const EXPLANATION = {
'Mobile': 'up to 576px',
'Tablet': '576px to 992px',
@ -114,154 +111,6 @@ class ScreenSizes extends React.Component {
}
}
class Browsers extends React.Component {
constructor(props) {
super(props)
this.state = {loading: true}
}
componentDidMount() {
this.fetchBrowsers()
if (this.props.timer) this.props.timer.onTick(this.fetchBrowsers.bind(this))
}
componentDidUpdate(prevProps) {
if (this.props.query !== prevProps.query) {
this.setState({loading: true, browsers: null})
this.fetchBrowsers()
}
}
fetchBrowsers() {
api.get(`/api/stats/${encodeURIComponent(this.props.site.domain)}/browsers`, this.props.query)
.then((res) => this.setState({loading: false, browsers: res}))
}
renderBrowser(browser) {
const query = new URLSearchParams(window.location.search)
query.set('browser', browser.name)
return (
<div className="flex items-center justify-between my-1 text-sm" key={browser.name}>
<div className="w-full h-8" style={{maxWidth: 'calc(100% - 6rem)'}}>
<Bar count={browser.count} all={this.state.browsers} bg="bg-green-50" />
<span className="flex px-2" style={{marginTop: '-26px'}} >
<Link className="block truncate hover:underline" to={{search: query.toString()}}>
{browser.name}
</Link>
</span>
</div>
<span className="font-medium">{numberFormatter(browser.count)} <span className="inline-block text-xs w-8 text-right">({browser.percentage}%)</span></span>
</div>
)
}
label() {
return this.props.query.period === 'realtime' ? 'Current visitors' : 'Visitors'
}
renderList() {
if (this.state.browsers && this.state.browsers.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>Browser</span>
<span>{ this.label() }</span>
</div>
{ this.state.browsers && this.state.browsers.map(this.renderBrowser.bind(this)) }
</React.Fragment>
)
} else {
return <div className="text-center mt-44 font-medium text-gray-500">No data yet</div>
}
}
render() {
return (
<React.Fragment>
{ this.state.loading && <div className="loading mt-44 mx-auto"><div></div></div> }
<FadeIn show={!this.state.loading}>
{ this.renderList() }
</FadeIn>
</React.Fragment>
)
}
}
class OperatingSystems extends React.Component {
constructor(props) {
super(props)
this.state = {loading: true}
}
componentDidMount() {
this.fetchOperatingSystems()
if (this.props.timer) this.props.timer.onTick(this.fetchOperatingSystems.bind(this))
}
componentDidUpdate(prevProps) {
if (this.props.query !== prevProps.query) {
this.setState({loading: true, operatingSystems: null})
this.fetchOperatingSystems()
}
}
fetchOperatingSystems() {
api.get(`/api/stats/${encodeURIComponent(this.props.site.domain)}/operating-systems`, this.props.query)
.then((res) => this.setState({loading: false, operatingSystems: res}))
}
renderOperatingSystem(os) {
const query = new URLSearchParams(window.location.search)
query.set('os', os.name)
return (
<div className="flex items-center justify-between my-1 text-sm" key={os.name}>
<div className="w-full h-8" style={{maxWidth: 'calc(100% - 6rem)'}}>
<Bar count={os.count} all={this.state.operatingSystems} bg="bg-green-50" />
<span className="flex px-2" style={{marginTop: '-26px'}}>
<Link className="block truncate hover:underline" to={{search: query.toString()}}>
{os.name}
</Link>
</span>
</div>
<span className="font-medium">{numberFormatter(os.count)} <span className="inline-block text-xs w-8 text-right">({os.percentage}%)</span></span>
</div>
)
}
label() {
return this.props.query.period === 'realtime' ? 'Current visitors' : 'Visitors'
}
renderList() {
if (this.state.operatingSystems && this.state.operatingSystems.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>Operating system</span>
<span>{ this.label() }</span>
</div>
{ this.state.operatingSystems && this.state.operatingSystems.map(this.renderOperatingSystem.bind(this)) }
</React.Fragment>
)
} else {
return <div className="text-center mt-44 font-medium text-gray-500">No data yet</div>
}
}
render() {
return (
<React.Fragment>
{ this.state.loading && <div className="loading mt-44 mx-auto"><div></div></div> }
<FadeIn show={!this.state.loading}>
{ this.renderList() }
</FadeIn>
</React.Fragment>
)
}
}
export default class Devices extends React.Component {
constructor(props) {
super(props)

View File

@ -0,0 +1,93 @@
import React from 'react';
import { Link } from 'react-router-dom'
import FadeIn from '../../fade-in'
import numberFormatter from '../../number-formatter'
import Bar from '../bar'
import * as api from '../../api'
export default class OperatingSystems extends React.Component {
constructor(props) {
super(props)
this.state = {loading: true}
}
componentDidMount() {
this.fetchOperatingSystems()
if (this.props.timer) this.props.timer.onTick(this.fetchOperatingSystems.bind(this))
}
componentDidUpdate(prevProps) {
if (this.props.query !== prevProps.query) {
this.setState({loading: true, operatingSystems: null})
this.fetchOperatingSystems()
}
}
fetchOperatingSystems() {
if (this.props.query.filters.os) {
api.get(`/api/stats/${encodeURIComponent(this.props.site.domain)}/operating-system-versions`, this.props.query)
.then((res) => this.setState({loading: false, operatingSystems: res}))
} else {
api.get(`/api/stats/${encodeURIComponent(this.props.site.domain)}/operating-systems`, this.props.query)
.then((res) => this.setState({loading: false, operatingSystems: res}))
}
}
renderOperatingSystem(os) {
const query = new URLSearchParams(window.location.search)
if (this.props.query.filters.os) {
query.set('os_version', os.name)
} else {
query.set('os', os.name)
}
return (
<div className="flex items-center justify-between my-1 text-sm" key={os.name}>
<div className="w-full h-8" style={{maxWidth: 'calc(100% - 6rem)'}}>
<Bar count={os.count} all={this.state.operatingSystems} bg="bg-green-50" />
<span className="flex px-2" style={{marginTop: '-26px'}}>
<Link className="block truncate hover:underline" to={{search: query.toString()}}>
{os.name}
</Link>
</span>
</div>
<span className="font-medium">{numberFormatter(os.count)} <span className="inline-block text-xs w-8 text-right">({os.percentage}%)</span></span>
</div>
)
}
label() {
return this.props.query.period === 'realtime' ? 'Current visitors' : 'Visitors'
}
renderList() {
const key = this.props.query.filters.os ? this.props.query.filters.os + ' version' : 'Operating system'
if (this.state.operatingSystems && this.state.operatingSystems.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>{ key }</span>
<span>{ this.label() }</span>
</div>
{ this.state.operatingSystems && this.state.operatingSystems.map(this.renderOperatingSystem.bind(this)) }
</React.Fragment>
)
} else {
return <div className="text-center mt-44 font-medium text-gray-500">No data yet</div>
}
}
render() {
return (
<React.Fragment>
{ this.state.loading && <div className="loading mt-44 mx-auto"><div></div></div> }
<FadeIn show={!this.state.loading}>
{ this.renderList() }
</FadeIn>
</React.Fragment>
)
}
}

View File

@ -20,7 +20,9 @@ defmodule Plausible.ClickhouseEvent do
field :country_code, :string
field :screen_size, :string
field :operating_system, :string
field :operating_system_version, :string
field :browser, :string
field :browser_version, :string
field :"meta.key", {:array, :string}
field :"meta.value", {:array, :string}
@ -37,7 +39,9 @@ defmodule Plausible.ClickhouseEvent do
:pathname,
:user_id,
:operating_system,
:operating_system_version,
:browser,
:browser_version,
:referrer,
:referrer_source,
:utm_medium,

View File

@ -27,7 +27,9 @@ defmodule Plausible.ClickhouseSession do
field :country_code, :string
field :screen_size, :string
field :operating_system, :string
field :operating_system_version, :string
field :browser, :string
field :browser_version, :string
field :timestamp, :naive_datetime
end
@ -47,7 +49,8 @@ defmodule Plausible.ClickhouseSession do
:length,
:is_bounce,
:operating_system,
:browser,
:operating_system_version,
:browser_version,
:referrer,
:referrer_source,
:utm_medium,

View File

@ -110,7 +110,9 @@ defmodule Plausible.Session.Store do
country_code: event.country_code,
screen_size: event.screen_size,
operating_system: event.operating_system,
operating_system_version: event.operating_system_version,
browser: event.browser,
browser_version: event.browser_version,
timestamp: event.timestamp,
start: event.timestamp
}

View File

@ -598,6 +598,21 @@ defmodule Plausible.Stats.Clickhouse do
|> Enum.take(limit)
end
def browser_versions(site, query, limit \\ 5) do
ClickhouseRepo.all(
from e in base_query_w_sessions(site, query),
group_by: e.browser_version,
where: e.browser_version != "",
order_by: [desc: fragment("count")],
select: %{
name: e.browser_version,
count: fragment("uniq(user_id) as count")
}
)
|> add_percentages
|> Enum.take(limit)
end
def operating_systems(site, query, limit \\ 5) do
ClickhouseRepo.all(
from e in base_query_w_sessions(site, query),
@ -613,6 +628,21 @@ defmodule Plausible.Stats.Clickhouse do
|> Enum.take(limit)
end
def operating_system_versions(site, query, limit \\ 5) do
ClickhouseRepo.all(
from e in base_query_w_sessions(site, query),
group_by: e.operating_system_version,
where: e.operating_system_version != "",
order_by: [desc: fragment("count")],
select: %{
name: e.operating_system_version,
count: fragment("uniq(user_id) as count")
}
)
|> add_percentages
|> Enum.take(limit)
end
def current_visitors(site, query) do
Plausible.ClickhouseRepo.one(
from s in base_query(site, query),
@ -841,6 +871,14 @@ defmodule Plausible.Stats.Clickhouse do
sessions_q
end
sessions_q =
if query.filters["browser_version"] do
version = query.filters["browser_version"]
from(s in sessions_q, where: s.browser_version == ^version)
else
sessions_q
end
sessions_q =
if query.filters["os"] do
os = query.filters["os"]
@ -849,6 +887,14 @@ defmodule Plausible.Stats.Clickhouse do
sessions_q
end
sessions_q =
if query.filters["os_version"] do
version = query.filters["os_version"]
from(s in sessions_q, where: s.operating_system_version == ^version)
else
sessions_q
end
sessions_q =
if query.filters["country"] do
country = Plausible.Stats.CountryName.to_alpha2(query.filters["country"])
@ -898,7 +944,8 @@ defmodule Plausible.Stats.Clickhouse do
q =
if query.filters["source"] || query.filters['referrer'] || query.filters["utm_medium"] ||
query.filters["utm_source"] || query.filters["utm_campaign"] || query.filters["screen"] ||
query.filters["browser"] || query.filters["os"] || query.filters["country"] do
query.filters["browser"] || query.filters["browser_version"] || query.filters["os"] ||
query.filters["os_version"] || query.filters["country"] do
from(
e in q,
join: sq in subquery(sessions_q),
@ -990,6 +1037,14 @@ defmodule Plausible.Stats.Clickhouse do
q
end
q =
if query.filters["browser_version"] do
version = query.filters["browser_version"]
from(s in q, where: s.browser_version == ^version)
else
q
end
q =
if query.filters["os"] do
os = query.filters["os"]
@ -998,6 +1053,14 @@ defmodule Plausible.Stats.Clickhouse do
q
end
q =
if query.filters["os_version"] do
version = query.filters["os_version"]
from(s in q, where: s.operating_system_version == ^version)
else
q
end
q =
if query.filters["country"] do
country = Plausible.Stats.CountryName.to_alpha2(query.filters["country"])
@ -1072,6 +1135,14 @@ defmodule Plausible.Stats.Clickhouse do
q
end
q =
if query.filters["browser_version"] do
version = query.filters["browser_version"]
from(s in q, where: s.browser_version == ^version)
else
q
end
q =
if query.filters["os"] do
os = query.filters["os"]
@ -1080,6 +1151,14 @@ defmodule Plausible.Stats.Clickhouse do
q
end
q =
if query.filters["os_version"] do
version = query.filters["os_version"]
from(s in q, where: s.operating_system_version == ^version)
else
q
end
q =
if query.filters["country"] do
country = Plausible.Stats.CountryName.to_alpha2(query.filters["country"])

View File

@ -65,19 +65,15 @@ defmodule PlausibleWeb.Api.ExternalController do
"meta" => parse_meta(params)
}
uri = params["url"] && URI.parse(URI.decode(params["url"]))
user_agent = Plug.Conn.get_req_header(conn, "user-agent") |> List.first()
ua = user_agent && UAInspector.parse(user_agent)
if UAInspector.bot?(user_agent) do
if is_bot?(ua) do
{:ok, nil}
else
uri = params["url"] && URI.parse(URI.decode(params["url"]))
query = if uri && uri.query, do: URI.decode_query(uri.query), else: %{}
ua =
if user_agent do
UAInspector.Parser.parse(user_agent)
end
ref = parse_referrer(uri, params["referrer"])
country_code = visitor_country(conn)
salts = Plausible.Session.Salts.fetch()
@ -96,7 +92,9 @@ defmodule PlausibleWeb.Api.ExternalController do
utm_campaign: query["utm_campaign"] || "",
country_code: country_code || "",
operating_system: (ua && os_name(ua)) || "",
operating_system_version: (ua && os_version(ua)) || "",
browser: (ua && browser_name(ua)) || "",
browser_version: (ua && browser_version(ua)) || "",
screen_size: calculate_screen_size(params["screen_width"]) || "",
"meta.key": Map.keys(params["meta"]),
"meta.value": Map.values(params["meta"])
@ -117,6 +115,11 @@ defmodule PlausibleWeb.Api.ExternalController do
end
end
defp is_bot?(%UAInspector.Result.Bot{}), do: true
defp is_bot?(%UAInspector.Result{client: %UAInspector.Result.Client{name: "Headless Chrome"}}), do: true
defp is_bot?(_), do: false
defp parse_meta(params) do
raw_meta = params["m"] || params["meta"] || params["p"] || params["props"]
@ -199,22 +202,48 @@ defmodule PlausibleWeb.Api.ExternalController do
defp browser_name(ua) do
case ua.client do
:unknown -> ""
%UAInspector.Result.Client{name: "Mobile Safari"} -> "Safari"
%UAInspector.Result.Client{name: "Chrome Mobile"} -> "Chrome"
%UAInspector.Result.Client{name: "Chrome Mobile iOS"} -> "Chrome"
%UAInspector.Result.Client{name: "Firefox Mobile"} -> "Firefox"
%UAInspector.Result.Client{name: "Firefox Mobile iOS"} -> "Firefox"
%UAInspector.Result.Client{name: "Chrome Webview"} -> "Mobile App"
%UAInspector.Result.Client{type: "mobile app"} -> "Mobile App"
:unknown -> nil
client -> client.name
end
end
defp major_minor(:unknown), do: ""
defp major_minor(version) do
version
|> String.split(".")
|> Enum.take(2)
|> Enum.join(".")
end
defp browser_version(ua) do
case ua.client do
:unknown -> ""
%UAInspector.Result.Client{type: "mobile app"} -> ""
client -> major_minor(client.version)
end
end
defp os_name(ua) do
case ua.os do
:unknown -> nil
:unknown -> ""
os -> os.name
end
end
defp os_version(ua) do
case ua.os do
:unknown -> ""
os -> major_minor(os.version)
end
end
defp get_referrer_source(query, ref) do
source = query["utm_source"] || query["source"] || query["ref"]
source || get_source_from_referrer(ref)

View File

@ -239,6 +239,13 @@ defmodule PlausibleWeb.Api.StatsController do
json(conn, Stats.browsers(site, query, params["limit"] || 9))
end
def browser_versions(conn, params) do
site = conn.assigns[:site]
query = Query.from(site.timezone, params)
json(conn, Stats.browser_versions(site, query, params["limit"] || 9))
end
def operating_systems(conn, params) do
site = conn.assigns[:site]
query = Query.from(site.timezone, params)
@ -246,6 +253,13 @@ defmodule PlausibleWeb.Api.StatsController do
json(conn, Stats.operating_systems(site, query, params["limit"] || 9))
end
def operating_system_versions(conn, params) do
site = conn.assigns[:site]
query = Query.from(site.timezone, params)
json(conn, Stats.operating_system_versions(site, query, params["limit"] || 9))
end
def screen_sizes(conn, params) do
site = conn.assigns[:site]
query = Query.from(site.timezone, params)

View File

@ -51,7 +51,9 @@ defmodule PlausibleWeb.Router do
get "/:domain/entry-pages", StatsController, :entry_pages
get "/:domain/countries", StatsController, :countries
get "/:domain/browsers", StatsController, :browsers
get "/:domain/browser-versions", StatsController, :browser_versions
get "/:domain/operating-systems", StatsController, :operating_systems
get "/:domain/operating-system-versions", StatsController, :operating_system_versions
get "/:domain/screen-sizes", StatsController, :screen_sizes
get "/:domain/conversions", StatsController, :conversions
get "/:domain/property/:prop_name", StatsController, :prop_breakdown

View File

@ -0,0 +1,15 @@
defmodule Plausible.ClickhouseRepo.Migrations.AddBrowserVersionAndOsVersion do
use Ecto.Migration
def change do
alter table(:events) do
add :browser_version, :"LowCardinality(String)"
add :operating_system_version, :"LowCardinality(String)"
end
alter table(:sessions) do
add :browser_version, :"LowCardinality(String)"
add :operating_system_version, :"LowCardinality(String)"
end
end
end

View File

@ -1 +1 @@
!function(i,r){"use strict";var s=i.location,o=i.document,e=o.querySelector('[src*="'+r+'"]'),l=e&&e.getAttribute("data-domain"),t=!1;function n(e,t){if(/^localhost$|^127(?:\.[0-9]+){0,2}\.[0-9]+$|^(?:0*\:)*?:?0*1$/.test(s.hostname)||"file:"===s.protocol)return console.warn("Ignoring event on localhost");var n={};n.n=e,n.u=s.href,n.d=l,n.r=o.referrer||null,n.w=i.innerWidth,t&&t.meta&&(n.m=JSON.stringify(t.meta)),t&&t.props&&(n.p=JSON.stringify(t.props));var a=new XMLHttpRequest;a.open("POST",r+"/api/event",!0),a.setRequestHeader("Content-Type","text/plain"),a.send(JSON.stringify(n)),a.onreadystatechange=function(){4==a.readyState&&t&&t.callback&&t.callback()}}function a(){n("pageview")}try{var p,c=i.history;c.pushState&&(p=c.pushState,c.pushState=function(){p.apply(this,arguments),a()},i.addEventListener("popstate",a));var u=i.plausible&&i.plausible.q||[];i.plausible=n;for(var d=0;d<u.length;d++)n.apply(this,u[d]);"prerender"===o.visibilityState?o.addEventListener("visibilitychange",function(){t||"visible"!==o.visibilityState||(t=!0,a())}):a()}catch(e){console.error(e),(new Image).src=r+"/api/error?message="+encodeURIComponent(e.message)}}(window,"<%= base_url %>");
!function(i,r){"use strict";var o=i.location,s=i.document,e=s.querySelector('[src*="'+r+'"]'),l=e&&e.getAttribute("data-domain"),t=!1;function n(e,t){if(/^localhost$|^127(?:\.[0-9]+){0,2}\.[0-9]+$|^(?:0*\:)*?:?0*1$/.test(o.hostname)||"file:"===o.protocol)return console.warn("Ignoring event on localhost");var n,a;i.phantom||i._phantom||i.__nightmare||i.navigator.webdriver||((n={}).n=e,n.u=o.href,n.d=l,n.r=s.referrer||null,n.w=i.innerWidth,t&&t.meta&&(n.m=JSON.stringify(t.meta)),t&&t.props&&(n.p=JSON.stringify(t.props)),(a=new XMLHttpRequest).open("POST",r+"/api/event",!0),a.setRequestHeader("Content-Type","text/plain"),a.send(JSON.stringify(n)),a.onreadystatechange=function(){4==a.readyState&&t&&t.callback&&t.callback()})}function a(){n("pageview")}try{var p,c=i.history;c.pushState&&(p=c.pushState,c.pushState=function(){p.apply(this,arguments),a()},i.addEventListener("popstate",a));var u=i.plausible&&i.plausible.q||[];i.plausible=n;for(var h=0;h<u.length;h++)n.apply(this,u[h]);"prerender"===s.visibilityState?s.addEventListener("visibilitychange",function(){t||"visible"!==s.visibilityState||(t=!0,a())}):a()}catch(e){console.error(e),(new Image).src=r+"/api/error?message="+encodeURIComponent(e.message)}}(window,"<%= base_url %>");

View File

@ -1 +1 @@
!function(i,r){"use strict";var s=i.location,o=i.document,e=o.querySelector('[src*="'+r+'"]'),l=e&&e.getAttribute("data-domain"),t=!1;function n(e,t){if(/^localhost$|^127(?:\.[0-9]+){0,2}\.[0-9]+$|^(?:0*\:)*?:?0*1$/.test(s.hostname)||"file:"===s.protocol)return console.warn("Ignoring event on localhost");var n={};n.n=e,n.u=s.href,n.d=l,n.r=o.referrer||null,n.w=i.innerWidth,t&&t.meta&&(n.m=JSON.stringify(t.meta)),t&&t.props&&(n.p=JSON.stringify(t.props)),n.h=1;var a=new XMLHttpRequest;a.open("POST",r+"/api/event",!0),a.setRequestHeader("Content-Type","text/plain"),a.send(JSON.stringify(n)),a.onreadystatechange=function(){4==a.readyState&&t&&t.callback&&t.callback()}}function a(){n("pageview")}try{var c,p=i.history;p.pushState&&(c=p.pushState,p.pushState=function(){c.apply(this,arguments),a()},i.addEventListener("popstate",a)),i.addEventListener("hashchange",a);var u=i.plausible&&i.plausible.q||[];i.plausible=n;for(var h=0;h<u.length;h++)n.apply(this,u[h]);"prerender"===o.visibilityState?o.addEventListener("visibilitychange",function(){t||"visible"!==o.visibilityState||(t=!0,a())}):a()}catch(e){console.error(e),(new Image).src=r+"/api/error?message="+encodeURIComponent(e.message)}}(window,"<%= base_url %>");
!function(i,r){"use strict";var s=i.location,o=i.document,e=o.querySelector('[src*="'+r+'"]'),l=e&&e.getAttribute("data-domain"),t=!1;function n(e,t){if(/^localhost$|^127(?:\.[0-9]+){0,2}\.[0-9]+$|^(?:0*\:)*?:?0*1$/.test(s.hostname)||"file:"===s.protocol)return console.warn("Ignoring event on localhost");var n,a;i.phantom||i._phantom||i.__nightmare||i.navigator.webdriver||((n={}).n=e,n.u=s.href,n.d=l,n.r=o.referrer||null,n.w=i.innerWidth,t&&t.meta&&(n.m=JSON.stringify(t.meta)),t&&t.props&&(n.p=JSON.stringify(t.props)),n.h=1,(a=new XMLHttpRequest).open("POST",r+"/api/event",!0),a.setRequestHeader("Content-Type","text/plain"),a.send(JSON.stringify(n)),a.onreadystatechange=function(){4==a.readyState&&t&&t.callback&&t.callback()})}function a(){n("pageview")}try{var p,c=i.history;c.pushState&&(p=c.pushState,c.pushState=function(){p.apply(this,arguments),a()},i.addEventListener("popstate",a)),i.addEventListener("hashchange",a);var h=i.plausible&&i.plausible.q||[];i.plausible=n;for(var u=0;u<h.length;u++)n.apply(this,h[u]);"prerender"===o.visibilityState?o.addEventListener("visibilitychange",function(){t||"visible"!==o.visibilityState||(t=!0,a())}):a()}catch(e){console.error(e),(new Image).src=r+"/api/error?message="+encodeURIComponent(e.message)}}(window,"<%= base_url %>");

View File

@ -1 +1 @@
!function(r,i){"use strict";var o=r.location,s=r.document,e=s.querySelector('[src*="'+i+'"]'),l=e&&e.getAttribute("data-domain"),t=!1;function a(e,t){if(/^localhost$|^127(?:\.[0-9]+){0,2}\.[0-9]+$|^(?:0*\:)*?:?0*1$/.test(o.hostname)||"file:"===o.protocol)return console.warn("Ignoring event on localhost");var a={};a.n=e,a.u=o.href,a.d=l,a.r=s.referrer||null,a.w=r.innerWidth,t&&t.meta&&(a.m=JSON.stringify(t.meta)),t&&t.props&&(a.p=JSON.stringify(t.props));var n=new XMLHttpRequest;n.open("POST",i+"/api/event",!0),n.setRequestHeader("Content-Type","text/plain"),n.send(JSON.stringify(a)),n.onreadystatechange=function(){4==n.readyState&&t&&t.callback&&t.callback()}}function n(){a("pageview")}function c(e){for(var t=e.target;t&&(void 0===t.tagName||"a"!=t.tagName.toLowerCase()||!t.href);)t=t.parentNode;t&&t.href&&plausible("Outbound Link: Click",{meta:{url:t.href}}),t.target&&!t.target.match(/^_(self|parent|top)$/i)||(setTimeout(function(){o.href=t.href},150),e.preventDefault())}try{var p,u=r.history;u.pushState&&(p=u.pushState,u.pushState=function(){p.apply(this,arguments),n()},r.addEventListener("popstate",n)),r.addEventListener("load",function(){for(var e=s.getElementsByTagName("a"),t=0;t<e.length;++t){var a=e[t];a.host!==o.host&&a.addEventListener("click",c)}});var f=r.plausible&&r.plausible.q||[];r.plausible=a;for(var d=0;d<f.length;d++)a.apply(this,f[d]);"prerender"===s.visibilityState?s.addEventListener("visibilitychange",function(){t||"visible"!==s.visibilityState||(t=!0,n())}):n()}catch(e){console.error(e),(new Image).src=i+"/api/error?message="+encodeURIComponent(e.message)}}(window,"<%= base_url %>");
!function(r,i){"use strict";var o=r.location,s=r.document,e=s.querySelector('[src*="'+i+'"]'),l=e&&e.getAttribute("data-domain"),t=!1;function a(e,t){if(/^localhost$|^127(?:\.[0-9]+){0,2}\.[0-9]+$|^(?:0*\:)*?:?0*1$/.test(o.hostname)||"file:"===o.protocol)return console.warn("Ignoring event on localhost");var a,n;r.phantom||r._phantom||r.__nightmare||r.navigator.webdriver||((a={}).n=e,a.u=o.href,a.d=l,a.r=s.referrer||null,a.w=r.innerWidth,t&&t.meta&&(a.m=JSON.stringify(t.meta)),t&&t.props&&(a.p=JSON.stringify(t.props)),(n=new XMLHttpRequest).open("POST",i+"/api/event",!0),n.setRequestHeader("Content-Type","text/plain"),n.send(JSON.stringify(a)),n.onreadystatechange=function(){4==n.readyState&&t&&t.callback&&t.callback()})}function n(){a("pageview")}function p(e){for(var t=e.target;t&&(void 0===t.tagName||"a"!=t.tagName.toLowerCase()||!t.href);)t=t.parentNode;t&&t.href&&plausible("Outbound Link: Click",{meta:{url:t.href}}),t.target&&!t.target.match(/^_(self|parent|top)$/i)||(setTimeout(function(){o.href=t.href},150),e.preventDefault())}try{var c,u=r.history;u.pushState&&(c=u.pushState,u.pushState=function(){c.apply(this,arguments),n()},r.addEventListener("popstate",n)),r.addEventListener("load",function(){for(var e=s.getElementsByTagName("a"),t=0;t<e.length;++t){var a=e[t];a.host!==o.host&&a.addEventListener("click",p)}});var h=r.plausible&&r.plausible.q||[];r.plausible=a;for(var f=0;f<h.length;f++)a.apply(this,h[f]);"prerender"===s.visibilityState?s.addEventListener("visibilitychange",function(){t||"visible"!==s.visibilityState||(t=!0,n())}):n()}catch(e){console.error(e),(new Image).src=i+"/api/error?message="+encodeURIComponent(e.message)}}(window,"<%= base_url %>");

View File

@ -1 +1 @@
!function(i,r){"use strict";var s=i.location,o=i.document,e=o.querySelector('[src*="'+r+'"]'),l=e&&e.getAttribute("data-domain"),t=!1;function n(e,t){if(/^localhost$|^127(?:\.[0-9]+){0,2}\.[0-9]+$|^(?:0*\:)*?:?0*1$/.test(s.hostname)||"file:"===s.protocol)return console.warn("Ignoring event on localhost");var n={};n.n=e,n.u=s.href,n.d=l,n.r=o.referrer||null,n.w=i.innerWidth,t&&t.meta&&(n.m=JSON.stringify(t.meta)),t&&t.props&&(n.p=JSON.stringify(t.props));var a=new XMLHttpRequest;a.open("POST",r+"/api/event",!0),a.setRequestHeader("Content-Type","text/plain"),a.send(JSON.stringify(n)),a.onreadystatechange=function(){4==a.readyState&&t&&t.callback&&t.callback()}}function a(){n("pageview")}try{var p,c=i.history;c.pushState&&(p=c.pushState,c.pushState=function(){p.apply(this,arguments),a()},i.addEventListener("popstate",a));var u=i.plausible&&i.plausible.q||[];i.plausible=n;for(var d=0;d<u.length;d++)n.apply(this,u[d]);"prerender"===o.visibilityState?o.addEventListener("visibilitychange",function(){t||"visible"!==o.visibilityState||(t=!0,a())}):a()}catch(e){console.error(e),(new Image).src=r+"/api/error?message="+encodeURIComponent(e.message)}}(window,"<%= base_url %>");
!function(i,r){"use strict";var o=i.location,s=i.document,e=s.querySelector('[src*="'+r+'"]'),l=e&&e.getAttribute("data-domain"),t=!1;function n(e,t){if(/^localhost$|^127(?:\.[0-9]+){0,2}\.[0-9]+$|^(?:0*\:)*?:?0*1$/.test(o.hostname)||"file:"===o.protocol)return console.warn("Ignoring event on localhost");var n,a;i.phantom||i._phantom||i.__nightmare||i.navigator.webdriver||((n={}).n=e,n.u=o.href,n.d=l,n.r=s.referrer||null,n.w=i.innerWidth,t&&t.meta&&(n.m=JSON.stringify(t.meta)),t&&t.props&&(n.p=JSON.stringify(t.props)),(a=new XMLHttpRequest).open("POST",r+"/api/event",!0),a.setRequestHeader("Content-Type","text/plain"),a.send(JSON.stringify(n)),a.onreadystatechange=function(){4==a.readyState&&t&&t.callback&&t.callback()})}function a(){n("pageview")}try{var p,c=i.history;c.pushState&&(p=c.pushState,c.pushState=function(){p.apply(this,arguments),a()},i.addEventListener("popstate",a));var u=i.plausible&&i.plausible.q||[];i.plausible=n;for(var h=0;h<u.length;h++)n.apply(this,u[h]);"prerender"===s.visibilityState?s.addEventListener("visibilitychange",function(){t||"visible"!==s.visibilityState||(t=!0,a())}):a()}catch(e){console.error(e),(new Image).src=r+"/api/error?message="+encodeURIComponent(e.message)}}(window,"<%= base_url %>");

View File

@ -1 +1 @@
!function(r,i){"use strict";var o=r.location,s=r.document,e=s.querySelector('[src*="'+i+'"]'),l=e&&e.getAttribute("data-domain"),t=!1;function a(e,t){if(/^localhost$|^127(?:\.[0-9]+){0,2}\.[0-9]+$|^(?:0*\:)*?:?0*1$/.test(o.hostname)||"file:"===o.protocol)return console.warn("Ignoring event on localhost");var a={};a.n=e,a.u=o.href,a.d=l,a.r=s.referrer||null,a.w=r.innerWidth,t&&t.meta&&(a.m=JSON.stringify(t.meta)),t&&t.props&&(a.p=JSON.stringify(t.props));var n=new XMLHttpRequest;n.open("POST",i+"/api/event",!0),n.setRequestHeader("Content-Type","text/plain"),n.send(JSON.stringify(a)),n.onreadystatechange=function(){4==n.readyState&&t&&t.callback&&t.callback()}}function n(){a("pageview")}function c(e){for(var t=e.target;t&&(void 0===t.tagName||"a"!=t.tagName.toLowerCase()||!t.href);)t=t.parentNode;t&&t.href&&plausible("Outbound Link: Click",{meta:{url:t.href}}),t.target&&!t.target.match(/^_(self|parent|top)$/i)||(setTimeout(function(){o.href=t.href},150),e.preventDefault())}try{var p,u=r.history;u.pushState&&(p=u.pushState,u.pushState=function(){p.apply(this,arguments),n()},r.addEventListener("popstate",n)),r.addEventListener("load",function(){for(var e=s.getElementsByTagName("a"),t=0;t<e.length;++t){var a=e[t];a.host!==o.host&&a.addEventListener("click",c)}});var f=r.plausible&&r.plausible.q||[];r.plausible=a;for(var d=0;d<f.length;d++)a.apply(this,f[d]);"prerender"===s.visibilityState?s.addEventListener("visibilitychange",function(){t||"visible"!==s.visibilityState||(t=!0,n())}):n()}catch(e){console.error(e),(new Image).src=i+"/api/error?message="+encodeURIComponent(e.message)}}(window,"<%= base_url %>");
!function(r,i){"use strict";var o=r.location,s=r.document,e=s.querySelector('[src*="'+i+'"]'),l=e&&e.getAttribute("data-domain"),t=!1;function a(e,t){if(/^localhost$|^127(?:\.[0-9]+){0,2}\.[0-9]+$|^(?:0*\:)*?:?0*1$/.test(o.hostname)||"file:"===o.protocol)return console.warn("Ignoring event on localhost");var a,n;r.phantom||r._phantom||r.__nightmare||r.navigator.webdriver||((a={}).n=e,a.u=o.href,a.d=l,a.r=s.referrer||null,a.w=r.innerWidth,t&&t.meta&&(a.m=JSON.stringify(t.meta)),t&&t.props&&(a.p=JSON.stringify(t.props)),(n=new XMLHttpRequest).open("POST",i+"/api/event",!0),n.setRequestHeader("Content-Type","text/plain"),n.send(JSON.stringify(a)),n.onreadystatechange=function(){4==n.readyState&&t&&t.callback&&t.callback()})}function n(){a("pageview")}function p(e){for(var t=e.target;t&&(void 0===t.tagName||"a"!=t.tagName.toLowerCase()||!t.href);)t=t.parentNode;t&&t.href&&plausible("Outbound Link: Click",{meta:{url:t.href}}),t.target&&!t.target.match(/^_(self|parent|top)$/i)||(setTimeout(function(){o.href=t.href},150),e.preventDefault())}try{var c,u=r.history;u.pushState&&(c=u.pushState,u.pushState=function(){c.apply(this,arguments),n()},r.addEventListener("popstate",n)),r.addEventListener("load",function(){for(var e=s.getElementsByTagName("a"),t=0;t<e.length;++t){var a=e[t];a.host!==o.host&&a.addEventListener("click",p)}});var h=r.plausible&&r.plausible.q||[];r.plausible=a;for(var f=0;f<h.length;f++)a.apply(this,h[f]);"prerender"===s.visibilityState?s.addEventListener("visibilitychange",function(){t||"visible"!==s.visibilityState||(t=!0,n())}):n()}catch(e){console.error(e),(new Image).src=i+"/api/error?message="+encodeURIComponent(e.message)}}(window,"<%= base_url %>");

View File

@ -101,6 +101,21 @@ defmodule PlausibleWeb.Api.ExternalControllerTest do
assert get_event("external-controller-test-5.com") == nil
end
test "Headless Chrome is ignored", %{conn: conn} do
params = %{
name: "pageview",
url: "http://www.example.com/",
domain: "headless-chrome-test.com"
}
conn
|> put_req_header("content-type", "text/plain")
|> put_req_header("user-agent", "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/85.0.4183.83 Safari/537.36")
|> post("/api/event", Jason.encode!(params))
assert get_event("headless-chrome-test.com") == nil
end
test "parses user_agent", %{conn: conn} do
params = %{
name: "pageview",
@ -118,7 +133,9 @@ defmodule PlausibleWeb.Api.ExternalControllerTest do
assert response(conn, 202) == ""
assert pageview.operating_system == "Mac"
assert pageview.operating_system_version == "10.13"
assert pageview.browser == "Chrome"
assert pageview.browser_version == "70.0"
end
test "parses referrer", %{conn: conn} do

View File

@ -14,4 +14,17 @@ defmodule PlausibleWeb.Api.StatsController.BrowsersTest do
]
end
end
describe "GET /api/stats/:domain/browser-versions" do
setup [:create_user, :log_in, :create_site]
test "returns top browser versions by unique visitors", %{conn: conn, site: site} do
filters = Jason.encode!(%{browser: "Chrome"})
conn = get(conn, "/api/stats/#{site.domain}/browser-versions?period=day&date=2019-01-01")
assert json_response(conn, 200) == [
%{"name" => "78.0", "count" => 2, "percentage" => 100}
]
end
end
end

View File

@ -14,4 +14,17 @@ defmodule PlausibleWeb.Api.StatsController.OperatingSystemsTest do
]
end
end
describe "GET /api/stats/:domain/operating-system-versions" do
setup [:create_user, :log_in, :create_site]
test "returns top OS versions by unique visitors", %{conn: conn, site: site} do
filters = Jason.encode!(%{os: "Mac"})
conn = get(conn, "/api/stats/#{site.domain}/browser-versions?period=day&date=2019-01-01")
assert json_response(conn, 200) == [
%{"name" => "10.15", "count" => 2, "percentage" => 100}
]
end
end
end

View File

@ -10,7 +10,9 @@ defmodule Plausible.Test.ClickhouseSetup do
pathname: "/",
country_code: "EE",
browser: "Chrome",
browser_version: "78.0",
operating_system: "Mac",
operating_system_version: "10.15",
screen_size: "Desktop",
referrer_source: "10words",
referrer: "10words.com/page1",
@ -22,7 +24,9 @@ defmodule Plausible.Test.ClickhouseSetup do
pathname: "/",
country_code: "EE",
browser: "Chrome",
browser_version: "78.0",
operating_system: "Mac",
operating_system_version: "10.15",
screen_size: "Desktop",
referrer_source: "10words",
referrer: "10words.com/page2",

View File

@ -51,9 +51,11 @@ defmodule Plausible.Factory do
timestamp: Timex.now(),
is_bounce: false,
browser: "",
browser_version: "",
country_code: "",
screen_size: "",
operating_system: ""
operating_system: "",
operating_system_version: ""
}
end
@ -82,9 +84,11 @@ defmodule Plausible.Factory do
utm_source: "",
utm_campaign: "",
browser: "",
browser_version: "",
country_code: "",
screen_size: "",
operating_system: "",
operating_system_version: "",
"meta.key": [],
"meta.value": []
}

View File

@ -10,6 +10,7 @@
function trigger(eventName, options) {
if (/^localhost$|^127(?:\.[0-9]+){0,2}\.[0-9]+$|^(?:0*\:)*?:?0*1$/.test(location.hostname) || location.protocol === 'file:') return console.warn('Ignoring event on localhost');
if (window.phantom || window._phantom || window.__nightmare || window.navigator.webdriver) return;
var payload = {}
payload.n = eventName