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
This commit is contained in:
Uku Taht 2020-10-28 11:09:04 +02:00 committed by GitHub
parent c533562eaa
commit 40900c7653
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 378 additions and 54 deletions

View File

@ -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

View File

@ -16,15 +16,22 @@ export function cancelAll() {
abortController = new AbortController()
}
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)
function serializeFilters(filters) {
const cleaned = {}
Object.entries(filters).forEach(([key, val]) => val ? cleaned[key] = val : null);
return JSON.stringify(cleaned)
}
return '?' + serialize(query)
export function serializeQuery(query, 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(queryObj)
}
export function get(url, query, ...extraQuery) {

View File

@ -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>
)
}

View File

@ -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'),

View File

@ -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,9 +45,12 @@ 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)'}}>
<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>
@ -55,6 +59,8 @@ export default class Conversions extends React.Component {
<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>
)
}

View File

@ -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>
)
}
}

View File

@ -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,

View File

@ -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

View File

@ -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

View File

@ -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 || "/"

View File

@ -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

View File

@ -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

View File

@ -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"},

View File

@ -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

View File

@ -1 +1 @@
!function(i,r){"use strict";var s=i.location,o=i.document,e=o.querySelector('[src*="'+r+'"]'),l=e&&e.getAttribute("data-domain"),t=!1;function n(e,t){if(/^localhost$|^127(?:\.[0-9]+){0,2}\.[0-9]+$|^(?:0*\:)*?:?0*1$/.test(s.hostname)||"file:"===s.protocol)return console.warn("Ignoring event on localhost");var n={};n.n=e,n.u=s.href,n.d=l,n.r=o.referrer||null,n.w=i.innerWidth;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 %>");
!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 %>");

View File

@ -1 +1 @@
!function(i,r){"use strict";var s=i.location,o=i.document,e=o.querySelector('[src*="'+r+'"]'),l=e&&e.getAttribute("data-domain"),t=!1;function n(e,t){if(/^localhost$|^127(?:\.[0-9]+){0,2}\.[0-9]+$|^(?:0*\:)*?:?0*1$/.test(s.hostname)||"file:"===s.protocol)return console.warn("Ignoring event on localhost");var n={};n.n=e,n.u=s.href,n.d=l,n.r=o.referrer||null,n.w=i.innerWidth,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 %>");
!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 %>");

View File

@ -1 +1 @@
!function(i,r){"use strict";var s=i.location,o=i.document,e=o.querySelector('[src*="'+r+'"]'),l=e&&e.getAttribute("data-domain"),t=!1;function n(e,t){if(/^localhost$|^127(?:\.[0-9]+){0,2}\.[0-9]+$|^(?:0*\:)*?:?0*1$/.test(s.hostname)||"file:"===s.protocol)return console.warn("Ignoring event on localhost");var n={};n.n=e,n.u=s.href,n.d=l,n.r=o.referrer||null,n.w=i.innerWidth;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 %>");
!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 %>");

View File

@ -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,7 +34,28 @@ 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

View File

@ -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

View File

@ -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

View File

@ -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",

View File

@ -84,7 +84,9 @@ defmodule Plausible.Factory do
browser: "",
country_code: "",
screen_size: "",
operating_system: ""
operating_system: "",
"meta.key": [],
"meta.value": []
}
end

View File

@ -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 %>');