diff --git a/CHANGELOG.md b/CHANGELOG.md index 76778b7b9f35b2ac650ada3767f8186a9730a52b..e5214790acecc26a50b9678d87afb484f746c06c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/assets/js/dashboard/filters.js b/assets/js/dashboard/filters.js index 9e7e9ef15c4c4ac3571e9f33c2e8d02c0e4dd8b9..aabf2a2efe0b33c75e3669bfa2808370da777449 100644 --- a/assets/js/dashboard/filters.js +++ b/assets/js/dashboard/filters.js @@ -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) diff --git a/assets/js/dashboard/query.js b/assets/js/dashboard/query.js index 155f77e132072cd3361ce39f5716878c6b2ba85f..00e8b3799e950aee3840c2ed5a059c9bb983d708 100644 --- a/assets/js/dashboard/query.js +++ b/assets/js/dashboard/query.js @@ -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') } diff --git a/assets/js/dashboard/stats/devices/browsers.js b/assets/js/dashboard/stats/devices/browsers.js new file mode 100644 index 0000000000000000000000000000000000000000..8770776b6bc246fb0f4f7ece325a362193886985 --- /dev/null +++ b/assets/js/dashboard/stats/devices/browsers.js @@ -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> + ) + } +} + diff --git a/assets/js/dashboard/stats/devices.js b/assets/js/dashboard/stats/devices/index.js similarity index 54% rename from assets/js/dashboard/stats/devices.js rename to assets/js/dashboard/stats/devices/index.js index cc797c51b40a5051c78a3b5e60b8ae269ab3f056..ea515c82aab2edb59143675cf16f330299007075 100644 --- a/assets/js/dashboard/stats/devices.js +++ b/assets/js/dashboard/stats/devices/index.js @@ -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) diff --git a/assets/js/dashboard/stats/devices/operating-systems.js b/assets/js/dashboard/stats/devices/operating-systems.js new file mode 100644 index 0000000000000000000000000000000000000000..8df9d4ff6d1b9a03319c57e97acdca4403e6233c --- /dev/null +++ b/assets/js/dashboard/stats/devices/operating-systems.js @@ -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> + ) + } +} + diff --git a/lib/plausible/event/clickhouse_schema.ex b/lib/plausible/event/clickhouse_schema.ex index 518d6c5edc17b0d3e580d8938b6e1f2d00c65244..44298d27d23604609490ef722a354c5a29877051 100644 --- a/lib/plausible/event/clickhouse_schema.ex +++ b/lib/plausible/event/clickhouse_schema.ex @@ -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, diff --git a/lib/plausible/session/clickhouse_schema.ex b/lib/plausible/session/clickhouse_schema.ex index 6db45c6eb6ef91fc799a0902d69b85739aeb5ec5..8ecf992fa0cb12dbc377120b328a62e26f69fd7a 100644 --- a/lib/plausible/session/clickhouse_schema.ex +++ b/lib/plausible/session/clickhouse_schema.ex @@ -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, diff --git a/lib/plausible/session/store.ex b/lib/plausible/session/store.ex index 35d8c403fdf5852ac5132232b392e0784eab0baf..1b94103561f2f0693cee782afc44eeee109096f0 100644 --- a/lib/plausible/session/store.ex +++ b/lib/plausible/session/store.ex @@ -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 } diff --git a/lib/plausible/stats/clickhouse.ex b/lib/plausible/stats/clickhouse.ex index 63f6878f7ee165943394393f027d58cbef2d786f..ae7f22d41498cde1d1c06a7139a5bff3fe6c7e25 100644 --- a/lib/plausible/stats/clickhouse.ex +++ b/lib/plausible/stats/clickhouse.ex @@ -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"]) diff --git a/lib/plausible_web/controllers/api/external_controller.ex b/lib/plausible_web/controllers/api/external_controller.ex index 3c870eb55ac21260e5cfe9dca658ecd8a1ee3d2d..f3c094633acfa97e758df8f7bcbffbc83a56d5d8 100644 --- a/lib/plausible_web/controllers/api/external_controller.ex +++ b/lib/plausible_web/controllers/api/external_controller.ex @@ -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) diff --git a/lib/plausible_web/controllers/api/stats_controller.ex b/lib/plausible_web/controllers/api/stats_controller.ex index 3a4c1140152719e01d681b16400ff8937f56ac9b..cb4ec2e106c5ddfee2050eab4b83a6ab50534e53 100644 --- a/lib/plausible_web/controllers/api/stats_controller.ex +++ b/lib/plausible_web/controllers/api/stats_controller.ex @@ -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) diff --git a/lib/plausible_web/router.ex b/lib/plausible_web/router.ex index 322c5c24c911a5b4dfee82efae3201bd793b2f90..295f3c0cfa20bf911e4dd96fca5eb1a8c93ffa81 100644 --- a/lib/plausible_web/router.ex +++ b/lib/plausible_web/router.ex @@ -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 diff --git a/priv/clickhouse_repo/migrations/20201106125234_add_browser_version_and_os_version.exs b/priv/clickhouse_repo/migrations/20201106125234_add_browser_version_and_os_version.exs new file mode 100644 index 0000000000000000000000000000000000000000..b9ff82081f14b4e1f4f80e467ae9df88c4df0853 --- /dev/null +++ b/priv/clickhouse_repo/migrations/20201106125234_add_browser_version_and_os_version.exs @@ -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 diff --git a/priv/tracker/js/analytics.js b/priv/tracker/js/analytics.js index f874ee003cd6c418db54402e1a1c38334d666d13..9fbea71c7882d4dac8d784568b84e87cf4162a10 100644 --- a/priv/tracker/js/analytics.js +++ b/priv/tracker/js/analytics.js @@ -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 %>"); \ No newline at end of file +!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 %>"); \ No newline at end of file diff --git a/priv/tracker/js/plausible.hash.js b/priv/tracker/js/plausible.hash.js index a78921c2efa11b82e76123a668d5154c2313ca69..e0159acc511156974d29760806079a6965698c87 100644 --- a/priv/tracker/js/plausible.hash.js +++ b/priv/tracker/js/plausible.hash.js @@ -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 %>"); \ No newline at end of file +!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 %>"); \ No newline at end of file diff --git a/priv/tracker/js/plausible.hash.outbound-links.js b/priv/tracker/js/plausible.hash.outbound-links.js index f7b891f7b3d13f418ca3b57f5d0febaf3781a571..ed1f3b029054e5adb6d1a08e38f0536af788e5a1 100644 --- a/priv/tracker/js/plausible.hash.outbound-links.js +++ b/priv/tracker/js/plausible.hash.outbound-links.js @@ -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 %>"); \ No newline at end of file +!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 %>"); \ No newline at end of file diff --git a/priv/tracker/js/plausible.js b/priv/tracker/js/plausible.js index f874ee003cd6c418db54402e1a1c38334d666d13..9fbea71c7882d4dac8d784568b84e87cf4162a10 100644 --- a/priv/tracker/js/plausible.js +++ b/priv/tracker/js/plausible.js @@ -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 %>"); \ No newline at end of file +!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 %>"); \ No newline at end of file diff --git a/priv/tracker/js/plausible.outbound-links.js b/priv/tracker/js/plausible.outbound-links.js index f7b891f7b3d13f418ca3b57f5d0febaf3781a571..ed1f3b029054e5adb6d1a08e38f0536af788e5a1 100644 --- a/priv/tracker/js/plausible.outbound-links.js +++ b/priv/tracker/js/plausible.outbound-links.js @@ -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 %>"); \ No newline at end of file +!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 %>"); \ No newline at end of file diff --git a/test/plausible_web/controllers/api/external_controller_test.exs b/test/plausible_web/controllers/api/external_controller_test.exs index 7ccacffdc82996253abc2e350de75e4c15de6cd1..14ea3c89ecd812915bd67da0c046239efe9fc3df 100644 --- a/test/plausible_web/controllers/api/external_controller_test.exs +++ b/test/plausible_web/controllers/api/external_controller_test.exs @@ -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 diff --git a/test/plausible_web/controllers/api/stats_controller/browsers_test.exs b/test/plausible_web/controllers/api/stats_controller/browsers_test.exs index 8bbbbe445ef1c29391d1d284f33066fdccc7d3bd..291ced385aa9f4ab662b0811bde2b2d51b03b69b 100644 --- a/test/plausible_web/controllers/api/stats_controller/browsers_test.exs +++ b/test/plausible_web/controllers/api/stats_controller/browsers_test.exs @@ -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 diff --git a/test/plausible_web/controllers/api/stats_controller/operating_systems_test.exs b/test/plausible_web/controllers/api/stats_controller/operating_systems_test.exs index b842cb469f2f7fb42868cbcef19ad7c9ae984ace..10392834ae3c6860d0bf59f84cc731f0200e64d0 100644 --- a/test/plausible_web/controllers/api/stats_controller/operating_systems_test.exs +++ b/test/plausible_web/controllers/api/stats_controller/operating_systems_test.exs @@ -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 diff --git a/test/support/clickhouse_setup.ex b/test/support/clickhouse_setup.ex index 3446283ada05acdabe5ac074518c6bd6a6f4109e..4b171338d7f67daa99b7cfe3d2cb3154440e39f1 100644 --- a/test/support/clickhouse_setup.ex +++ b/test/support/clickhouse_setup.ex @@ -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", diff --git a/test/support/factory.ex b/test/support/factory.ex index 3a01cf325c643e3d8ba8197ba48fa1cdc51dc8c9..ec7cde25fc53d96c2d3bfcc951b62cdbea8260e8 100644 --- a/test/support/factory.ex +++ b/test/support/factory.ex @@ -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": [] } diff --git a/tracker/src/plausible.js b/tracker/src/plausible.js index 6ee5f13895463999f9eb3b95b1061b61bcb403e1..7c6267f0217b69ff9918b649d77180fb01d93300 100644 --- a/tracker/src/plausible.js +++ b/tracker/src/plausible.js @@ -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