diff --git a/CHANGELOG.md b/CHANGELOG.md
index b0be9b3976755a4904813c6ec3a812caa603c258..760eed524b1fddb5e6726b68fb03f6b462d58c87 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -3,6 +3,9 @@ All notable changes to this project will be documented in this file.
 
 ## [1.1.2] - Unreleased
 
+### Added
+- Ability to add event metadata plausible/analytics#381
+
 ### Changed
 - Use alpine as base image to decrease Docker image size plausible/analytics#353
 
diff --git a/assets/js/dashboard/api.js b/assets/js/dashboard/api.js
index 0319a53d90a5d93e5b3bd83508e554afd28f9530..d6fca56a2d2ad4f72ab78d3cbcb76a56423644a0 100644
--- a/assets/js/dashboard/api.js
+++ b/assets/js/dashboard/api.js
@@ -16,15 +16,22 @@ export function cancelAll() {
   abortController = new AbortController()
 }
 
+function serializeFilters(filters) {
+  const cleaned = {}
+  Object.entries(filters).forEach(([key, val]) => val ? cleaned[key] = val : null);
+  return JSON.stringify(cleaned)
+}
+
 export function serializeQuery(query, extraQuery=[]) {
-  query = Object.assign({}, query, {
-    date: query.date ? formatISO(query.date) : undefined,
-    from: query.from ? formatISO(query.from) : undefined,
-    to: query.to ? formatISO(query.to) : undefined,
-    filters: query.filters ? JSON.stringify(query.filters) : undefined
-  }, ...extraQuery)
+  const queryObj = {}
+  if (query.period)  { queryObj.period = query.period  }
+  if (query.date)    { queryObj.date = formatISO(query.date)  }
+  if (query.from)    { queryObj.from = formatISO(query.from)  }
+  if (query.to)      { queryObj.to = formatISO(query.to)  }
+  if (query.filters) { queryObj.filters = serializeFilters(query.filters)  }
+  Object.assign(queryObj, ...extraQuery)
 
-  return '?' + serialize(query)
+  return '?' + serialize(queryObj)
 }
 
 export function get(url, query, ...extraQuery) {
diff --git a/assets/js/dashboard/filters.js b/assets/js/dashboard/filters.js
index 36bad839adb66e0bb79d91bac0a81d5f8bb8cddb..8c8163c60583e0a17d517add00640828d3b6d392 100644
--- a/assets/js/dashboard/filters.js
+++ b/assets/js/dashboard/filters.js
@@ -3,10 +3,15 @@ import { withRouter } from 'react-router-dom'
 import {removeQueryParam} from './query'
 import Datamap from 'datamaps'
 
-function filterText(key, value) {
+function filterText(key, value, query) {
   if (key === "goal") {
     return <span className="inline-block max-w-sm truncate">Completed goal <b>{value}</b></span>
   }
+  if (key === "meta") {
+    const [metaKey, metaValue] = Object.entries(value)[0]
+    const eventName = query.filters["goal"] ? query.filters["goal"] : 'event'
+    return <span className="inline-block max-w-sm truncate">{eventName}.{metaKey} is <b>{metaValue}</b></span>
+  }
   if (key === "source") {
     return <span className="inline-block max-w-sm truncate">Source: <b>{value}</b></span>
   }
@@ -41,14 +46,14 @@ function filterText(key, value) {
   }
 }
 
-function renderFilter(history, [key, value]) {
+function renderFilter(history, [key, value], query) {
   function removeFilter() {
     history.push({search: removeQueryParam(location.search, key)})
   }
 
   return (
     <span key={key} title={value} className="inline-flex bg-white text-gray-700 shadow text-sm rounded py-2 px-3 mr-4">
-      {filterText(key, value)} <b className="ml-1 cursor-pointer" onClick={removeFilter}>✕</b>
+      {filterText(key, value, query)} <b className="ml-1 cursor-pointer" onClick={removeFilter}>✕</b>
     </span>
   )
 }
@@ -61,7 +66,7 @@ function Filters({query, history, location}) {
   if (appliedFilters.length > 0) {
     return (
       <div className="mt-4">
-        { appliedFilters.map((filter) => renderFilter(history, filter)) }
+        { appliedFilters.map((filter) => renderFilter(history, filter, query)) }
       </div>
     )
   }
diff --git a/assets/js/dashboard/query.js b/assets/js/dashboard/query.js
index e27f95b7c59482512d6969f7dc428c6d6144c70e..bd8200ddcf6bdd0882374d6d9625795604b08131 100644
--- a/assets/js/dashboard/query.js
+++ b/assets/js/dashboard/query.js
@@ -24,6 +24,7 @@ export function parseQuery(querystring, site) {
     to: q.get('to') ? parseUTCDate(q.get('to')) : undefined,
     filters: {
       'goal': q.get('goal'),
+      'meta': JSON.parse(q.get('meta')),
       'source': q.get('source'),
       'utm_medium': q.get('utm_medium'),
       'utm_source': q.get('utm_source'),
diff --git a/assets/js/dashboard/stats/conversions.js b/assets/js/dashboard/stats/conversions/index.js
similarity index 63%
rename from assets/js/dashboard/stats/conversions.js
rename to assets/js/dashboard/stats/conversions/index.js
index 8b00340e31d1dd64f9c3643b74a521d7f4c9e9cb..f24b1c53a04136d1542210e7c7a4abfc6d74eab6 100644
--- a/assets/js/dashboard/stats/conversions.js
+++ b/assets/js/dashboard/stats/conversions/index.js
@@ -1,10 +1,11 @@
 import React from 'react';
 import { Link } from 'react-router-dom'
 
-import Bar from './bar'
-import MoreLink from './more-link'
-import numberFormatter from '../number-formatter'
-import * as api from '../api'
+import Bar from '../bar'
+import MoreLink from '../more-link'
+import MetaBreakdown from './meta-breakdown'
+import numberFormatter from '../../number-formatter'
+import * as api from '../../api'
 
 export default class Conversions extends React.Component {
   constructor(props) {
@@ -36,7 +37,7 @@ export default class Conversions extends React.Component {
       query.set('goal', goalName)
 
       return (
-        <Link to={{search: query.toString(), state: {scrollTop: true}}} style={{marginTop: '-26px'}} className="hover:underline block px-2">
+        <Link to={{search: query.toString()}} style={{marginTop: '-26px'}} className="hover:underline block px-2">
           { goalName }
         </Link>
       )
@@ -44,16 +45,21 @@ export default class Conversions extends React.Component {
   }
 
   renderGoal(goal) {
+    const renderMeta = this.props.query.filters['goal'] == goal.name && goal.meta_keys
+
     return (
-      <div className="flex items-center justify-between my-2 text-sm" key={goal.name}>
-        <div className="w-full h-8" style={{maxWidth: 'calc(100% - 14rem)'}}>
-          <Bar count={goal.count} all={this.state.goals} bg="bg-red-50" />
-          {this.renderGoalText(goal.name)}
-        </div>
-        <div>
-          <span className="font-medium inline-block w-20 text-right">{numberFormatter(goal.count)}</span>
-          <span className="font-medium inline-block w-36 text-right">{numberFormatter(goal.total_count)}</span>
+      <div className="my-2 text-sm" key={goal.name}>
+        <div className="flex items-center justify-between my-2">
+          <div className="w-full h-8 relative" style={{maxWidth: 'calc(100% - 14rem)'}}>
+            <Bar count={goal.count} all={this.state.goals} bg="bg-red-50" />
+            {this.renderGoalText(goal.name)}
+          </div>
+          <div>
+            <span className="font-medium inline-block w-20 text-right">{numberFormatter(goal.count)}</span>
+            <span className="font-medium inline-block w-36 text-right">{numberFormatter(goal.total_count)}</span>
+          </div>
         </div>
+        { renderMeta && <MetaBreakdown site={this.props.site} query={this.props.query} goal={goal} /> }
       </div>
     )
   }
diff --git a/assets/js/dashboard/stats/conversions/meta-breakdown.js b/assets/js/dashboard/stats/conversions/meta-breakdown.js
new file mode 100644
index 0000000000000000000000000000000000000000..9d25ee960e25a7d350ede3f6e3e90c705f472501
--- /dev/null
+++ b/assets/js/dashboard/stats/conversions/meta-breakdown.js
@@ -0,0 +1,131 @@
+import React from 'react';
+import { Link } from 'react-router-dom'
+
+import Transition from "../../../transition.js";
+import Bar from '../bar'
+import numberFormatter from '../../number-formatter'
+import * as api from '../../api'
+
+export default class MetaBreakdown extends React.Component {
+  constructor(props) {
+    super(props)
+    this.handleClick = this.handleClick.bind(this)
+    const metaFilter = props.query.filters['meta']
+    console.log(metaFilter)
+    const metaKey = metaFilter ? Object.keys(metaFilter)[0] : props.goal.meta_keys[0]
+    this.state = {
+      loading: true,
+      dropdownOpen: false,
+      metaKey: metaKey
+    }
+  }
+
+  componentDidMount() {
+    this.fetchMetaBreakdown()
+    document.addEventListener('mousedown', this.handleClick, false);
+  }
+
+  componentWillUnmount() {
+    document.removeEventListener('mousedown', this.handleClick, false);
+  }
+
+  handleClick(e) {
+    if (this.dropDownNode && this.dropDownNode.contains(e.target)) return;
+    if (!this.state.dropdownOpen) return;
+
+    this.setState({dropdownOpen: false})
+  }
+
+  fetchMetaBreakdown() {
+    if (this.props.query.filters['goal']) {
+      api.get(`/api/stats/${encodeURIComponent(this.props.site.domain)}/meta-breakdown/${encodeURIComponent(this.state.metaKey)}`, this.props.query)
+        .then((res) => this.setState({loading: false, breakdown: res}))
+    }
+  }
+
+  renderMetadataValue(value) {
+    const query = new URLSearchParams(window.location.search)
+    query.set('meta', JSON.stringify({[this.state.metaKey]: value.name}))
+
+    return (
+      <div className="flex items-center justify-between my-2" key={value.name}>
+        <div className="w-full h-8 relative" style={{maxWidth: 'calc(100% - 14rem)'}}>
+          <Bar count={value.count} all={this.state.breakdown} bg="bg-red-50" />
+          <Link to={{search: query.toString()}} style={{marginTop: '-26px'}} className="hover:underline block px-2">
+            { value.name }
+          </Link>
+        </div>
+        <div>
+          <span className="font-medium inline-block w-20 text-right">{numberFormatter(value.count)}</span>
+          <span className="font-medium inline-block w-36 text-right">{numberFormatter(value.total_count)}</span>
+        </div>
+      </div>
+    )
+  }
+
+  changeMetaKey(newKey) {
+    this.setState({metaKey: newKey, loading: true, dropdownOpen: false}, this.fetchMetaBreakdown)
+  }
+
+  renderMetaKeyOption(key) {
+    const extraClass = key === this.state.metaKey ? 'font-medium text-gray-900' : 'hover:bg-gray-100 hover:text-gray-900 focus:outline-none focus:bg-gray-100 focus:text-gray-900'
+
+    return (
+      <span onClick={this.changeMetaKey.bind(this, key)} key={key} className={`cursor-pointer block truncate px-4 py-2 text-sm leading-5 text-gray-700 ${extraClass}`}>
+        {key}
+      </span>
+    )
+  }
+
+  renderDropdown() {
+    return (
+      <div className="py-1">
+        { this.props.goal.meta_keys.map(this.renderMetaKeyOption.bind(this)) }
+      </div>
+    )
+  }
+
+  toggleDropdown() {
+    this.setState({dropdownOpen: !this.state.dropdownOpen})
+  }
+
+  renderBody() {
+    if (this.state.loading) {
+      return <div className="px-4 py-2"><div className="loading sm mx-auto"><div></div></div></div>
+    } else {
+      return this.state.breakdown.map((metaValue) => this.renderMetadataValue(metaValue))
+    }
+  }
+
+  render() {
+    return (
+      <div className="w-full pl-6 mt-4">
+        <div className="relative">
+          Breakdown by
+          <button onClick={this.toggleDropdown.bind(this)} className="ml-1 inline-flex items-center rounded-md leading-5 font-bold text-gray-700 focus:outline-none transition ease-in-out duration-150 hover:text-gray-500 focus:border-blue-300 focus:shadow-outline-blue">
+            { this.state.metaKey }
+            <svg className="mt-px h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
+              <path fillRule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clipRule="evenodd" />
+            </svg>
+          </button>
+          <Transition
+            show={this.state.dropdownOpen}
+            enter="transition ease-out duration-100 transform"
+            enterFrom="opacity-0 scale-95"
+            enterTo="opacity-100 scale-100"
+            leave="transition ease-in duration-75 transform"
+            leaveFrom="opacity-100 scale-100"
+            leaveTo="opacity-0 scale-95"
+          >
+            <div className="z-10 origin-top-left absolute left-0 mt-2 w-64 rounded-md shadow-lg" ref={node => this.dropDownNode = node} >
+              <div className="rounded-md bg-white shadow-xs">
+                { this.renderDropdown() }
+              </div>
+            </div>
+          </Transition>
+        </div>
+        { this.renderBody() }
+      </div>
+    )
+  }
+}
diff --git a/config/config.exs b/config/config.exs
index 73790589499e035e086cc53a696d6cedc5ef0d39..e12bae3589828404d0909b0af6c2e6deec612e8b 100644
--- a/config/config.exs
+++ b/config/config.exs
@@ -83,7 +83,7 @@ config :plausible, Plausible.ClickhouseRepo,
   loggers: [Ecto.LogEntry],
   url: System.get_env(
     "CLICKHOUSE_DATABASE_URL",
-    "http://127.0.0.1:8123/plausible_test"
+    "http://127.0.0.1:8123/plausible_dev"
   )
 
 config :plausible,
diff --git a/lib/plausible/event/clickhouse_schema.ex b/lib/plausible/event/clickhouse_schema.ex
index e4cd90bb48005e9afc50fa5ecdcb6a33ee1343a5..518d6c5edc17b0d3e580d8938b6e1f2d00c65244 100644
--- a/lib/plausible/event/clickhouse_schema.ex
+++ b/lib/plausible/event/clickhouse_schema.ex
@@ -22,6 +22,9 @@ defmodule Plausible.ClickhouseEvent do
     field :operating_system, :string
     field :browser, :string
 
+    field :"meta.key", {:array, :string}
+    field :"meta.value", {:array, :string}
+
     timestamps(inserted_at: :timestamp, updated_at: false)
   end
 
diff --git a/lib/plausible/stats/clickhouse.ex b/lib/plausible/stats/clickhouse.ex
index ac5a5b7fe8da0fa4dc457df1b654a54186ce8f1a..215e9fe89bb149f3ef86a3fd33866a2819d44920 100644
--- a/lib/plausible/stats/clickhouse.ex
+++ b/lib/plausible/stats/clickhouse.ex
@@ -370,7 +370,6 @@ defmodule Plausible.Stats.Clickhouse do
         order_by: [desc: fragment("count")],
         limit: ^limit
       ) |> filter_converted_sessions(site, query)
-    IO.inspect(q)
 
     q =
       if "bounce_rate" in include do
@@ -604,6 +603,91 @@ defmodule Plausible.Stats.Clickhouse do
     ClickhouseRepo.exists?(from e in "events", where: e.domain == ^site.domain)
   end
 
+  def all_seen_metadata_keys(site, %Query{filters: %{"meta" => meta}} = query) when is_map(meta) do
+    [{key, val}] = meta |> Enum.into([])
+
+    if val == "(none)" do
+      goal = query.filters["goal"]
+      %{goal => [key]}
+    else
+      ClickhouseRepo.all(
+        from [e, meta] in base_query_w_sessions_bare(site, query),
+        select: {e.name, meta.key},
+        distinct: true
+      ) |> Enum.reduce(%{}, fn {goal_name, meta_key}, acc ->
+        Map.update(acc, goal_name, [meta_key], fn list -> [meta_key | list] end)
+      end)
+    end
+  end
+
+  def all_seen_metadata_keys(site, query) do
+    ClickhouseRepo.all(
+      from e in base_query_w_sessions_bare(site, query),
+      inner_lateral_join: meta in fragment("meta as m"),
+      select: {e.name, meta.key},
+      distinct: true
+    ) |> Enum.reduce(%{}, fn {goal_name, meta_key}, acc ->
+      Map.update(acc, goal_name, [meta_key], fn list -> [meta_key | list] end)
+    end)
+  end
+
+  def metadata_breakdown(site, %Query{filters: %{"meta" => meta}} = query, key) when is_map(meta) do
+    [{_key, val}] = meta |> Enum.into([])
+
+    if val == "(none)" do
+     ClickhouseRepo.all(
+      from e in base_query_w_sessions(site, query),
+      where: fragment("not has(meta.key, ?)", ^key),
+      order_by: [desc: fragment("count")],
+      select: %{
+        name: "(none)",
+        count: fragment("uniq(user_id) as count"),
+        total_count: fragment("count(*) as total_count")
+      }
+    )
+    else
+     ClickhouseRepo.all(
+      from [e, meta] in base_query_w_sessions(site, query),
+      group_by: meta.value,
+      order_by: [desc: fragment("count")],
+      select: %{
+        name: meta.value,
+        count: fragment("uniq(user_id) as count"),
+        total_count: fragment("count(*) as total_count")
+      }
+    )
+    end
+  end
+
+  def metadata_breakdown(site, query, key) do
+    none = ClickhouseRepo.all(
+      from e in base_query_w_sessions(site, query),
+      where: fragment("not has(meta.key, ?)", ^key),
+      select: %{
+        name: "(none)",
+        count: fragment("uniq(?) as count", e.user_id),
+        total_count: fragment("count(*) as total_count")
+      }
+    )
+
+    values = ClickhouseRepo.all(
+      from e in base_query_w_sessions(site, query),
+      inner_lateral_join: meta in fragment("meta as m"),
+      where: meta.key == ^key,
+      group_by: meta.value,
+      order_by: [desc: fragment("count")],
+      select: %{
+        name: meta.value,
+        count: fragment("uniq(user_id) as count"),
+        total_count: fragment("count(*) as total_count")
+      }
+    )
+
+    values ++ none
+    |> Enum.sort(fn row1, row2 -> row1[:count] >= row2[:count] end)
+    |> Enum.filter(fn row -> row[:count] > 0 end)
+  end
+
   def goal_conversions(site, %Query{filters: %{"goal" => goal}} = query) when is_binary(goal) do
     ClickhouseRepo.all(
       from e in base_query_w_sessions(site, query),
@@ -779,6 +863,25 @@ defmodule Plausible.Stats.Clickhouse do
     else
       q
     end
+
+    if query.filters["meta"] do
+      [{key, val}] = query.filters["meta"] |> Enum.into([])
+
+      if val == "(none)" do
+        from(
+          e in q,
+          where: fragment("not has(meta.key, ?)", ^key)
+        )
+      else
+        from(
+          e in q,
+          inner_lateral_join: meta in fragment("meta as m"),
+          where: meta.key == ^key and meta.value == ^val,
+        )
+      end
+    else
+      q
+    end
   end
 
   defp base_query_w_sessions(site, query) do
diff --git a/lib/plausible_web/controllers/api/external_controller.ex b/lib/plausible_web/controllers/api/external_controller.ex
index bb36aaf9105c720207bed71f8ceaa13988086da9..20762e51067e3e51c8d1877ca775c27dc585735a 100644
--- a/lib/plausible_web/controllers/api/external_controller.ex
+++ b/lib/plausible_web/controllers/api/external_controller.ex
@@ -62,6 +62,7 @@ defmodule PlausibleWeb.Api.ExternalController do
       "domain" => params["d"] || params["domain"],
       "screen_width" => params["w"] || params["screen_width"],
       "hash_mode" => params["h"] || params["hashMode"],
+      "meta" => parse_meta(params)
     }
 
     uri = params["url"] && URI.parse(URI.decode(params["url"]))
@@ -95,7 +96,9 @@ defmodule PlausibleWeb.Api.ExternalController do
         country_code: country_code || "",
         operating_system: ua && os_name(ua) || "",
         browser: ua && browser_name(ua) || "",
-        screen_size: calculate_screen_size(params["screen_width"]) || ""
+        screen_size: calculate_screen_size(params["screen_width"]) || "",
+        "meta.key": Map.keys(params["meta"]),
+        "meta.value": Map.values(params["meta"])
       }
 
       changeset = Plausible.ClickhouseEvent.changeset(%Plausible.ClickhouseEvent{}, event_attrs)
@@ -113,6 +116,15 @@ defmodule PlausibleWeb.Api.ExternalController do
     end
   end
 
+  defp parse_meta(params) do
+    raw_meta = params["m"] || params["meta"]
+    if raw_meta do
+      Jason.decode!(raw_meta)
+    else
+      %{}
+    end
+  end
+
   defp get_pathname(nil, _), do: "/"
   defp get_pathname(uri, hash_mode) do
     pathname = uri.path || "/"
diff --git a/lib/plausible_web/controllers/api/stats_controller.ex b/lib/plausible_web/controllers/api/stats_controller.ex
index aea312fdf35b1b8c0b594959c1328d5b5fbf702e..14d48db17e95a5f4c0bed819903806bc4724fee4 100644
--- a/lib/plausible_web/controllers/api/stats_controller.ex
+++ b/lib/plausible_web/controllers/api/stats_controller.ex
@@ -36,7 +36,7 @@ defmodule PlausibleWeb.Api.StatsController do
   end
 
   defp fetch_top_stats(site, %Query{filters: %{"goal" => goal}} = query) when is_binary(goal) do
-    total_filter = Map.put(query.filters, "goal", nil)
+    total_filter = Map.merge(query.filters, %{"goal" => nil, "meta" => nil})
     prev_query = Query.shift_back(query)
     unique_visitors = Stats.unique_visitors(site, %{query | filters: total_filter})
     prev_unique_visitors = Stats.unique_visitors(site, %{prev_query | filters: total_filter})
@@ -260,8 +260,18 @@ defmodule PlausibleWeb.Api.StatsController do
   def conversions(conn, params) do
     site = conn.assigns[:site]
     query = Query.from(site.timezone, params)
+    metadata_keys = Stats.all_seen_metadata_keys(site, query)
+    conversions = Stats.goal_conversions(site, query)
+                  |> Enum.map(fn goal -> Map.put(goal, :meta_keys, metadata_keys[goal[:name]]) end)
 
-    json(conn, Stats.goal_conversions(site, query))
+    json(conn, conversions)
+  end
+
+  def meta_breakdown(conn, params) do
+    site = conn.assigns[:site]
+    query = Query.from(site.timezone, params)
+
+    json(conn, Stats.metadata_breakdown(site, query, params["meta_key"]))
   end
 
   def current_visitors(conn, _) do
diff --git a/lib/plausible_web/router.ex b/lib/plausible_web/router.ex
index b489367d496c7b48514ca1f5a8f615e19f3fb564..23569aa0f231cec7d9b39bd3470fff3a9bf5d91c 100644
--- a/lib/plausible_web/router.ex
+++ b/lib/plausible_web/router.ex
@@ -59,6 +59,7 @@ defmodule PlausibleWeb.Router do
     get "/:domain/operating-systems", StatsController, :operating_systems
     get "/:domain/screen-sizes", StatsController, :screen_sizes
     get "/:domain/conversions", StatsController, :conversions
+    get "/:domain/meta-breakdown/:meta_key", StatsController, :meta_breakdown
   end
 
   scope "/api", PlausibleWeb do
diff --git a/mix.lock b/mix.lock
index 6fa0325a0e49738d9822fee091e51f6a25093372..9c6e6b75ef0dc373023f35232d74cb1af7aecbe5 100644
--- a/mix.lock
+++ b/mix.lock
@@ -6,7 +6,7 @@
   "bertex": {:hex, :bertex, "1.3.0", "0ad0df9159b5110d9d2b6654f72fbf42a54884ef43b6b651e6224c0af30ba3cb", [:mix], [], "hexpm", "0a5d5e478bb5764b7b7bae37cae1ca491200e58b089df121a2fe1c223d8ee57a"},
   "browser": {:hex, :browser, "0.4.4", "bd6436961a6b2299c6cb38d0e49761c1161d869cd0db46369cef2bf6b77c3665", [:mix], [{:plug, "~> 1.2", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "d476ca309d4a4b19742b870380390aabbcb323c1f6f8745e2da2dfd079b4f8d7"},
   "certifi": {:hex, :certifi, "2.5.1", "867ce347f7c7d78563450a18a6a28a8090331e77fa02380b4a21962a65d36ee5", [:rebar3], [{:parse_trans, "~>3.3", [hex: :parse_trans, repo: "hexpm", optional: false]}], "hexpm", "805abd97539caf89ec6d4732c91e62ba9da0cda51ac462380bbd28ee697a8c42"},
-  "clickhouse_ecto": {:git, "https://github.com/plausible/clickhouse_ecto.git", "2cf83697c26cf87eadc09194434f744f3c7465d2", []},
+  "clickhouse_ecto": {:git, "https://github.com/plausible/clickhouse_ecto.git", "ac7514c9155378bde3be34c2a4598fb227367b15", []},
   "clickhousex": {:git, "https://github.com/plausible/clickhousex", "89d58d4cb0cad2558e874f30e81a5c2c84ada95e", []},
   "combine": {:hex, :combine, "0.10.0", "eff8224eeb56498a2af13011d142c5e7997a80c8f5b97c499f84c841032e429f", [:mix], [], "hexpm", "1b1dbc1790073076580d0d1d64e42eae2366583e7aecd455d1215b0d16f2451b"},
   "comeonin": {:hex, :comeonin, "5.3.1", "7fe612b739c78c9c1a75186ef2d322ce4d25032d119823269d0aa1e2f1e20025", [:mix], [], "hexpm", "d6222483060c17f0977fad1b7401ef0c5863c985a64352755f366aee3799c245"},
diff --git a/priv/clickhouse_repo/migrations/20201020083739_add_event_metadata.exs b/priv/clickhouse_repo/migrations/20201020083739_add_event_metadata.exs
new file mode 100644
index 0000000000000000000000000000000000000000..32f2372dfda90ff636994af11bb7e150e56c6d0a
--- /dev/null
+++ b/priv/clickhouse_repo/migrations/20201020083739_add_event_metadata.exs
@@ -0,0 +1,9 @@
+defmodule Plausible.ClickhouseRepo.Migrations.AddEventMetadata do
+  use Ecto.Migration
+
+  def change do
+    alter table(:events) do
+      add :meta, {:nested, {{:key, :string}, {:value, :string}}}
+    end
+  end
+end
diff --git a/priv/tracker/js/analytics.js b/priv/tracker/js/analytics.js
index a184ad146047738ce498f8a1b6c893c3c8f3e1c6..e53ae0c1116c512ac12b5eb7ceb8eb094d50d6e1 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;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));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){(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={};n.n=e,n.u=o.href,n.d=l,n.r=s.referrer||null,n.w=i.innerWidth,console.log(t),t&&t.meta&&(n.m=JSON.stringify(t.meta));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));var u=i.plausible&&i.plausible.q||[];i.plausible=n;for(var d=0;d<u.length;d++)n.apply(this,u[d]);"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 36f80c372a3069be71c59a84b5299cc8de3a149a..d67c0818fabb943d1db3df412b36dceb3f4ec3a6 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,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){(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={};n.n=e,n.u=s.href,n.d=l,n.r=o.referrer||null,n.w=i.innerWidth,console.log(t),t&&t.meta&&(n.m=JSON.stringify(t.meta)),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
diff --git a/priv/tracker/js/plausible.js b/priv/tracker/js/plausible.js
index a184ad146047738ce498f8a1b6c893c3c8f3e1c6..e53ae0c1116c512ac12b5eb7ceb8eb094d50d6e1 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;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));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){(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={};n.n=e,n.u=o.href,n.d=l,n.r=s.referrer||null,n.w=i.innerWidth,console.log(t),t&&t.meta&&(n.m=JSON.stringify(t.meta));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));var u=i.plausible&&i.plausible.q||[];i.plausible=n;for(var d=0;d<u.length;d++)n.apply(this,u[d]);"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/test/plausible_web/controllers/api/stats_controller/conversions_test.exs b/test/plausible_web/controllers/api/stats_controller/conversions_test.exs
index b25309ce990bedd8eddc085fa86626ffeaaac9f4..51ccb5d946ea47c1c670b7b7e1317ff9a9660e7e 100644
--- a/test/plausible_web/controllers/api/stats_controller/conversions_test.exs
+++ b/test/plausible_web/controllers/api/stats_controller/conversions_test.exs
@@ -12,8 +12,8 @@ defmodule PlausibleWeb.Api.StatsController.ConversionsTest do
       conn = get(conn, "/api/stats/#{site.domain}/conversions?period=day&date=2019-01-01")
 
       assert json_response(conn, 200) == [
-               %{"name" => "Signup", "count" => 3, "total_count" => 3},
-               %{"name" => "Visit /register", "count" => 2, "total_count" => 2}
+               %{"name" => "Signup", "count" => 3, "total_count" => 3, "meta_keys" => ["variant"]},
+               %{"name" => "Visit /register", "count" => 2, "total_count" => 2, "meta_keys" => nil}
              ]
     end
   end
@@ -34,8 +34,29 @@ defmodule PlausibleWeb.Api.StatsController.ConversionsTest do
         )
 
       assert json_response(conn, 200) == [
-               %{"name" => "Signup", "count" => 3, "total_count" => 3}
+               %{"name" => "Signup", "count" => 3, "total_count" => 3, "meta_keys" => ["variant"]}
              ]
     end
   end
+
+  describe "GET /api/stats/:domain/meta-breakdown/:key" do
+    setup [:create_user, :log_in, :create_site]
+
+    test "returns metadata breakdown for goal", %{conn: conn, site: site} do
+      insert(:goal, %{domain: site.domain, event_name: "Signup"})
+      filters = Jason.encode!(%{goal: "Signup"})
+      meta_key = "variant"
+
+      conn =
+        get(
+          conn,
+          "/api/stats/#{site.domain}/meta-breakdown/#{meta_key}?period=day&date=2019-01-01&filters=#{filters}"
+        )
+
+      assert json_response(conn, 200) == [
+        %{"count" => 2, "name" => "B", "total_count" => 2},
+        %{"count" => 1, "name" => "A", "total_count" => 1}
+      ]
+    end
+  end
 end
diff --git a/test/plausible_web/controllers/api/stats_controller/main_graph_test.exs b/test/plausible_web/controllers/api/stats_controller/main_graph_test.exs
index f0c3cb607a83aeb9c995c968a0f1c87a7d55ba7e..55cc16a32b73428f81d018b0cd1c31bf2e81702e 100644
--- a/test/plausible_web/controllers/api/stats_controller/main_graph_test.exs
+++ b/test/plausible_web/controllers/api/stats_controller/main_graph_test.exs
@@ -83,14 +83,14 @@ defmodule PlausibleWeb.Api.StatsController.MainGraphTest do
       conn = get(conn, "/api/stats/#{site.domain}/main-graph?period=day&date=2019-01-01")
 
       res = json_response(conn, 200)
-      assert %{"name" => "Unique visitors", "count" => 9, "change" => 100} in res["top_stats"]
+      assert %{"name" => "Unique visitors", "count" => 6, "change" => 100} in res["top_stats"]
     end
 
     test "counts total pageviews", %{conn: conn, site: site} do
       conn = get(conn, "/api/stats/#{site.domain}/main-graph?period=day&date=2019-01-01")
 
       res = json_response(conn, 200)
-      assert %{"name" => "Total pageviews", "count" => 9, "change" => 100} in res["top_stats"]
+      assert %{"name" => "Total pageviews", "count" => 6, "change" => 100} in res["top_stats"]
     end
 
     test "calculates bounce rate", %{conn: conn, site: site} do
@@ -167,7 +167,7 @@ defmodule PlausibleWeb.Api.StatsController.MainGraphTest do
         )
 
       res = json_response(conn, 200)
-      assert %{"name" => "Unique visitors", "count" => 4, "change" => 100} in res["top_stats"]
+      assert %{"name" => "Unique visitors", "count" => 2, "change" => 100} in res["top_stats"]
     end
 
     test "returns only visitors with specific screen size", %{conn: conn, site: site} do
@@ -180,7 +180,7 @@ defmodule PlausibleWeb.Api.StatsController.MainGraphTest do
         )
 
       res = json_response(conn, 200)
-      assert %{"name" => "Unique visitors", "count" => 4, "change" => 100} in res["top_stats"]
+      assert %{"name" => "Unique visitors", "count" => 2, "change" => 100} in res["top_stats"]
     end
 
     test "returns only visitors with specific browser", %{conn: conn, site: site} do
@@ -193,7 +193,7 @@ defmodule PlausibleWeb.Api.StatsController.MainGraphTest do
         )
 
       res = json_response(conn, 200)
-      assert %{"name" => "Unique visitors", "count" => 4, "change" => 100} in res["top_stats"]
+      assert %{"name" => "Unique visitors", "count" => 2, "change" => 100} in res["top_stats"]
     end
 
     test "returns only visitors with specific operating system", %{conn: conn, site: site} do
@@ -206,7 +206,7 @@ defmodule PlausibleWeb.Api.StatsController.MainGraphTest do
         )
 
       res = json_response(conn, 200)
-      assert %{"name" => "Unique visitors", "count" => 4, "change" => 100} in res["top_stats"]
+      assert %{"name" => "Unique visitors", "count" => 2, "change" => 100} in res["top_stats"]
     end
   end
 end
diff --git a/test/plausible_web/controllers/api/stats_controller/sources_test.exs b/test/plausible_web/controllers/api/stats_controller/sources_test.exs
index caa2e0e6712b4c9fa5145f1653dd220ea06de8ae..fc19920724d32f75eb2aac08b4d94a6e69dd2e2d 100644
--- a/test/plausible_web/controllers/api/stats_controller/sources_test.exs
+++ b/test/plausible_web/controllers/api/stats_controller/sources_test.exs
@@ -110,7 +110,7 @@ defmodule PlausibleWeb.Api.StatsController.SourcesTest do
       conn = get(conn, "/api/stats/#{site.domain}/referrers/10words?period=day&date=2019-01-01&filters=#{filters}")
 
       assert json_response(conn, 200) == %{
-               "total_visitors" => 6,
+               "total_visitors" => 3,
                "referrers" => [
                  %{"name" => "10words.com/page1", "url" => "10words.com", "count" => 2}
                ]
@@ -126,7 +126,7 @@ defmodule PlausibleWeb.Api.StatsController.SourcesTest do
         )
 
       assert json_response(conn, 200) == %{
-               "total_visitors" => 6,
+               "total_visitors" => 3,
                "referrers" => [
                  %{
                    "name" => "10words.com/page1",
@@ -178,15 +178,15 @@ defmodule PlausibleWeb.Api.StatsController.SourcesTest do
       conn =
         get(
           conn,
-          "/api/stats/#{site.domain}/goal/referrers/10words?period=day&date=2019-01-01&filters=#{
+          "/api/stats/#{site.domain}/referrers/10words?period=day&date=2019-01-01&filters=#{
             filters
           }"
         )
 
       assert json_response(conn, 200) == %{
-               "total_visitors" => 2,
+               "total_visitors" => 3,
                "referrers" => [
-                 %{"name" => "10words.com/page1", "count" => 2}
+                 %{"name" => "10words.com/page1", "url" => "10words.com", "count" => 2}
                ]
              }
     end
@@ -197,7 +197,7 @@ defmodule PlausibleWeb.Api.StatsController.SourcesTest do
       conn =
         get(
           conn,
-          "/api/stats/#{site.domain}/goal/referrers/10words?period=day&date=2019-01-01&filters=#{
+          "/api/stats/#{site.domain}/referrers/10words?period=day&date=2019-01-01&filters=#{
             filters
           }"
         )
@@ -205,7 +205,7 @@ defmodule PlausibleWeb.Api.StatsController.SourcesTest do
       assert json_response(conn, 200) == %{
                "total_visitors" => 2,
                "referrers" => [
-                 %{"name" => "10words.com/page1", "count" => 2}
+                 %{"name" => "10words.com/page1", "url" => "10words.com", "count" => 2}
                ]
              }
     end
diff --git a/test/support/clickhouse_setup.ex b/test/support/clickhouse_setup.ex
index 4e6de6ee69108e5eca4ef92eb1b483959559c44e..3446283ada05acdabe5ac074518c6bd6a6f4109e 100644
--- a/test/support/clickhouse_setup.ex
+++ b/test/support/clickhouse_setup.ex
@@ -44,19 +44,25 @@ defmodule Plausible.Test.ClickhouseSetup do
         name: "Signup",
         domain: "test-site.com",
         session_id: @conversion_1_session_id,
-        timestamp: ~N[2019-01-01 01:00:00]
+        timestamp: ~N[2019-01-01 01:00:00],
+        "meta.key": ["variant"],
+        "meta.value": ["A"]
       },
       %{
         name: "Signup",
         domain: "test-site.com",
         session_id: @conversion_1_session_id,
-        timestamp: ~N[2019-01-01 02:00:00]
+        timestamp: ~N[2019-01-01 02:00:00],
+        "meta.key": ["variant"],
+        "meta.value": ["B"]
       },
       %{
         name: "Signup",
         domain: "test-site.com",
         session_id: @conversion_2_session_id,
-        timestamp: ~N[2019-01-01 02:00:00]
+        timestamp: ~N[2019-01-01 02:00:00],
+        "meta.key": ["variant"],
+        "meta.value": ["B"]
       },
       %{
         name: "pageview",
diff --git a/test/support/factory.ex b/test/support/factory.ex
index 82f3a6b93e7259f482a569f55917caf552f69e8f..3a01cf325c643e3d8ba8197ba48fa1cdc51dc8c9 100644
--- a/test/support/factory.ex
+++ b/test/support/factory.ex
@@ -84,7 +84,9 @@ defmodule Plausible.Factory do
       browser: "",
       country_code: "",
       screen_size: "",
-      operating_system: ""
+      operating_system: "",
+      "meta.key": [],
+      "meta.value": []
     }
   end
 
diff --git a/tracker/src/plausible.js b/tracker/src/plausible.js
index 28df34954db4fc2ed465802079b5fd678e0bdb81..aa5265374f37e958d13e5822301c0c2d13158193 100644
--- a/tracker/src/plausible.js
+++ b/tracker/src/plausible.js
@@ -17,6 +17,9 @@
     payload.d = domain
     payload.r = document.referrer || null
     payload.w = window.innerWidth
+    if (options && options.meta) {
+      payload.m = JSON.stringify(options.meta)
+    }
     {{#if hashMode}}
     payload.h = 1
     {{/if}}
@@ -73,6 +76,7 @@
       page()
     }
   } catch (e) {
+    console.error(e)
     new Image().src = plausibleHost + '/api/error?message=' +  encodeURIComponent(e.message);
   }
 })(window, '<%= base_url %>');