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