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