mirror of
https://github.com/plausible/analytics.git
synced 2024-11-09 16:46:40 +03:00
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:
parent
c533562eaa
commit
40900c7653
@ -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
|
||||
|
||||
|
@ -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) {
|
||||
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
@ -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'),
|
||||
|
@ -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>
|
||||
)
|
||||
}
|
131
assets/js/dashboard/stats/conversions/meta-breakdown.js
Normal file
131
assets/js/dashboard/stats/conversions/meta-breakdown.js
Normal 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>
|
||||
)
|
||||
}
|
||||
}
|
@ -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,
|
||||
|
@ -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
|
||||
|
||||
|
@ -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
|
||||
|
@ -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 || "/"
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
2
mix.lock
2
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"},
|
||||
|
@ -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
|
@ -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 %>");
|
@ -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 %>");
|
@ -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 %>");
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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",
|
||||
|
@ -84,7 +84,9 @@ defmodule Plausible.Factory do
|
||||
browser: "",
|
||||
country_code: "",
|
||||
screen_size: "",
|
||||
operating_system: ""
|
||||
operating_system: "",
|
||||
"meta.key": [],
|
||||
"meta.value": []
|
||||
}
|
||||
end
|
||||
|
||||
|
@ -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 %>');
|
||||
|
Loading…
Reference in New Issue
Block a user