From 40900c7653684d36135b4955b0a0ccfa6b7a304c Mon Sep 17 00:00:00 2001 From: Uku Taht Date: Wed, 28 Oct 2020 11:09:04 +0200 Subject: [PATCH] Ability to add event metadata (#381) * Ability to add event metadata * Close Dropdown on outside click * Show (none) value in metadata breakdown * Allow filtering for metadata key/val pairs * Use correct clickhouse_ecto * Better naming for meta filter * Add tests * Add changelog entry * Remove change made for testing --- CHANGELOG.md | 3 + assets/js/dashboard/api.js | 21 ++- assets/js/dashboard/filters.js | 13 +- assets/js/dashboard/query.js | 1 + .../{conversions.js => conversions/index.js} | 32 +++-- .../stats/conversions/meta-breakdown.js | 131 ++++++++++++++++++ config/config.exs | 2 +- lib/plausible/event/clickhouse_schema.ex | 3 + lib/plausible/stats/clickhouse.ex | 105 +++++++++++++- .../controllers/api/external_controller.ex | 14 +- .../controllers/api/stats_controller.ex | 14 +- lib/plausible_web/router.ex | 1 + mix.lock | 2 +- .../20201020083739_add_event_metadata.exs | 9 ++ priv/tracker/js/analytics.js | 2 +- priv/tracker/js/plausible.hash.js | 2 +- priv/tracker/js/plausible.js | 2 +- .../api/stats_controller/conversions_test.exs | 27 +++- .../api/stats_controller/main_graph_test.exs | 12 +- .../api/stats_controller/sources_test.exs | 14 +- test/support/clickhouse_setup.ex | 12 +- test/support/factory.ex | 4 +- tracker/src/plausible.js | 4 + 23 files changed, 377 insertions(+), 53 deletions(-) rename assets/js/dashboard/stats/{conversions.js => conversions/index.js} (63%) create mode 100644 assets/js/dashboard/stats/conversions/meta-breakdown.js create mode 100644 priv/clickhouse_repo/migrations/20201020083739_add_event_metadata.exs diff --git a/CHANGELOG.md b/CHANGELOG.md index b0be9b397675..760eed524b1f 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 0319a53d90a5..d6fca56a2d2a 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 36bad839adb6..8c8163c60583 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 Completed goal {value} } + if (key === "meta") { + const [metaKey, metaValue] = Object.entries(value)[0] + const eventName = query.filters["goal"] ? query.filters["goal"] : 'event' + return {eventName}.{metaKey} is {metaValue} + } if (key === "source") { return Source: {value} } @@ -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 ( - {filterText(key, value)} + {filterText(key, value, query)} ) } @@ -61,7 +66,7 @@ function Filters({query, history, location}) { if (appliedFilters.length > 0) { return (
- { appliedFilters.map((filter) => renderFilter(history, filter)) } + { appliedFilters.map((filter) => renderFilter(history, filter, query)) }
) } diff --git a/assets/js/dashboard/query.js b/assets/js/dashboard/query.js index e27f95b7c594..bd8200ddcf6b 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 8b00340e31d1..f24b1c53a041 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 ( - + { goalName } ) @@ -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 ( -
-
- - {this.renderGoalText(goal.name)} -
-
- {numberFormatter(goal.count)} - {numberFormatter(goal.total_count)} +
+
+
+ + {this.renderGoalText(goal.name)} +
+
+ {numberFormatter(goal.count)} + {numberFormatter(goal.total_count)} +
+ { renderMeta && }
) } 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 000000000000..9d25ee960e25 --- /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 ( +
+
+ + + { value.name } + +
+
+ {numberFormatter(value.count)} + {numberFormatter(value.total_count)} +
+
+ ) + } + + 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 ( + + {key} + + ) + } + + renderDropdown() { + return ( +
+ { this.props.goal.meta_keys.map(this.renderMetaKeyOption.bind(this)) } +
+ ) + } + + toggleDropdown() { + this.setState({dropdownOpen: !this.state.dropdownOpen}) + } + + renderBody() { + if (this.state.loading) { + return
+ } else { + return this.state.breakdown.map((metaValue) => this.renderMetadataValue(metaValue)) + } + } + + render() { + return ( +
+
+ Breakdown by + + +
this.dropDownNode = node} > +
+ { this.renderDropdown() } +
+
+
+
+ { this.renderBody() } +
+ ) + } +} diff --git a/config/config.exs b/config/config.exs index 73790589499e..e12bae358982 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 e4cd90bb4800..518d6c5edc17 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 ac5a5b7fe8da..215e9fe89bb1 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 bb36aaf9105c..20762e51067e 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 aea312fdf35b..14d48db17e95 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 b489367d496c..23569aa0f231 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 6fa0325a0e49..9c6e6b75ef0d 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 000000000000..32f2372dfda9 --- /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 a184ad146047..e53ae0c1116c 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"); \ 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"); \ No newline at end of file diff --git a/priv/tracker/js/plausible.hash.js b/priv/tracker/js/plausible.hash.js index 36f80c372a30..d67c0818fabb 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"); \ 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"); \ No newline at end of file diff --git a/priv/tracker/js/plausible.js b/priv/tracker/js/plausible.js index a184ad146047..e53ae0c1116c 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"); \ 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"); \ 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 b25309ce990b..51ccb5d946ea 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 f0c3cb607a83..55cc16a32b73 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 caa2e0e6712b..fc19920724d3 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 4e6de6ee6910..3446283ada05 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 82f3a6b93e72..3a01cf325c64 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 28df34954db4..aa5265374f37 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 %>');