Stats API (#679)

* WIP

* Add ability to filter by anything

* Add API keys

* Add version to api endpoint

* Fix API test route

* Fix API tests

* Allow 'date' parameter in '6mo' and '12mo'

* Rename session -> visit in API filters

* Filter expressions in the API

* Implement filters in aggregate call

* Add `compare` option to aggregate call

* Add way to manage API keys through the UI

* Authenticate with API key

* Use API key in tests
This commit is contained in:
Uku Taht 2021-02-05 11:23:30 +02:00 committed by GitHub
parent 60b99102a6
commit 5acb5b7039
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 1741 additions and 85 deletions

View File

@ -0,0 +1,45 @@
defmodule Plausible.Auth.ApiKey do
use Ecto.Schema
import Ecto.Changeset
@required [:user_id, :key, :name]
schema "api_keys" do
field :name, :string
field :key, :string, virtual: true
field :key_hash, :string
field :key_prefix, :string
belongs_to :user, Plausible.Auth.User
timestamps()
end
def changeset(schema, attrs \\ %{}) do
schema
|> cast(attrs, @required)
|> validate_required(@required)
|> process_key
end
def do_hash(key) do
:crypto.hash(:sha256, [secret_key_base(), key])
|> Base.encode16()
|> String.downcase()
end
def process_key(%{errors: [], changes: changes} = changeset) do
prefix = binary_part(changes[:key], 0, 6)
change(changeset,
key_hash: do_hash(changes[:key]),
key_prefix: prefix
)
end
def process_key(changeset), do: changeset
defp secret_key_base() do
Application.get_env(:plausible, PlausibleWeb.Endpoint)
|> Keyword.fetch!(:secret_key_base)
end
end

View File

@ -22,6 +22,7 @@ defmodule Plausible.Auth.User do
has_many :site_memberships, Plausible.Site.Membership
has_many :sites, through: [:site_memberships, :site]
has_many :api_keys, Plausible.Auth.ApiKey
has_one :google_auth, Plausible.Site.GoogleAuth
has_one :subscription, Plausible.Billing.Subscription

View File

@ -73,6 +73,28 @@ defmodule Plausible.Session.Store do
{:reply, session_id, %{state | sessions: updated_sessions}}
end
def reconcile_event(sessions, event) do
found_session = sessions[event.user_id]
active = is_active?(found_session, event)
updated_sessions =
cond do
found_session && active ->
new_session = update_session(found_session, event)
Map.put(sessions, event.user_id, new_session)
found_session && !active ->
new_session = new_session_from_event(event)
Map.put(sessions, event.user_id, new_session)
true ->
new_session = new_session_from_event(event)
Map.put(sessions, event.user_id, new_session)
end
updated_sessions
end
defp is_active?(session, event) do
session && Timex.diff(event.timestamp, session.timestamp, :second) < session_length_seconds()
end

View File

@ -23,9 +23,11 @@ defmodule Plausible.Stats.Clickhouse do
end
end
def calculate_plot(site, %Query{step_type: "month"} = query) do
def calculate_plot(site, %Query{interval: "month"} = query) do
n_steps = Timex.diff(query.date_range.last, query.date_range.first, :months)
steps =
Enum.map((query.steps - 1)..0, fn shift ->
Enum.map(n_steps..0, fn shift ->
Timex.now(site.timezone)
|> Timex.beginning_of_month()
|> Timex.shift(months: -shift)
@ -54,7 +56,7 @@ defmodule Plausible.Stats.Clickhouse do
{plot, labels, present_index}
end
def calculate_plot(site, %Query{step_type: "date"} = query) do
def calculate_plot(site, %Query{interval: "date"} = query) do
steps = Enum.into(query.date_range, [])
groups =
@ -78,7 +80,7 @@ defmodule Plausible.Stats.Clickhouse do
{plot, labels, present_index}
end
def calculate_plot(site, %Query{step_type: "hour"} = query) do
def calculate_plot(site, %Query{interval: "hour"} = query) do
steps = 0..23
groups =
@ -151,6 +153,13 @@ defmodule Plausible.Stats.Clickhouse do
)
end
def total_pageviews(site, query) do
ClickhouseRepo.one(
from e in base_query(site, query),
select: fragment("count(*)")
)
end
def total_events(site, query) do
ClickhouseRepo.one(
from e in base_query_w_sessions(site, query),

239
lib/plausible/stats/mod.ex Normal file
View File

@ -0,0 +1,239 @@
defmodule Plausible.Stats do
use Plausible.ClickhouseRepo
alias Plausible.Stats.Query
@no_ref "Direct / None"
def timeseries(site, query) do
steps = buckets(query)
groups =
from(e in base_event_query(site, query),
group_by: fragment("bucket"),
order_by: fragment("bucket")
)
|> select_bucket(site, query)
|> ClickhouseRepo.all()
|> Enum.into(%{})
plot = Enum.map(steps, fn step -> groups[step] || 0 end)
labels = Enum.map(steps, fn step -> Timex.format!(step, "{ISOdate}") end)
{plot, labels}
end
@event_metrics ["visitors", "pageviews"]
@session_metrics ["bounce_rate", "visit_duration"]
def aggregate(site, query, metrics) do
event_metrics = Enum.filter(metrics, &(&1 in @event_metrics))
event_task = Task.async(fn -> aggregate_events(site, query, event_metrics) end)
session_metrics = Enum.filter(metrics, &(&1 in @session_metrics))
session_task = Task.async(fn -> aggregate_sessions(site, query, session_metrics) end)
Map.merge(
Task.await(event_task),
Task.await(session_task)
)
|> Enum.map(fn {metric, value} ->
{metric, %{value: value}}
end)
|> Enum.into(%{})
end
defp aggregate_events(_, _, []), do: %{}
defp aggregate_events(site, query, metrics) do
q = from(e in base_event_query(site, query), select: %{})
Enum.reduce(metrics, q, &select_event_metric/2)
|> ClickhouseRepo.one()
end
defp select_event_metric("pageviews", q) do
from(e in q, select_merge: %{pageviews: fragment("count(*)")})
end
defp select_event_metric("visitors", q) do
from(e in q, select_merge: %{visitors: fragment("uniq(?)", e.user_id)})
end
defp aggregate_sessions(_, _, []), do: %{}
defp aggregate_sessions(site, query, metrics) do
q = from(e in query_sessions(site, query), select: %{})
Enum.reduce(metrics, q, &select_session_metric/2)
|> ClickhouseRepo.one()
end
defp select_session_metric("bounce_rate", q) do
from(s in q,
select_merge: %{bounce_rate: fragment("round(sum(is_bounce * sign) / sum(sign) * 100)")}
)
end
defp select_session_metric("visit_duration", q) do
from(s in q, select_merge: %{visit_duration: fragment("round(avg(duration * sign))")})
end
@session_props [
"source",
"referrer",
"utm_medium",
"utm_source",
"utm_campaign",
"device",
"browser",
"browser_version",
"os",
"os_version",
"country"
]
defp base_event_query(site, query) do
events_q = query_events(site, query)
if Enum.any?(@session_props, &query.filters["visit:" <> &1]) do
sessions_q =
from(
s in query_sessions(site, query),
select: %{session_id: s.session_id}
)
from(
e in events_q,
join: sq in subquery(sessions_q),
on: e.session_id == sq.session_id
)
else
events_q
end
end
defp query_events(site, query) do
{first_datetime, last_datetime} = utc_boundaries(query, site.timezone)
q =
from(e in "events",
where: e.domain == ^site.domain,
where: e.timestamp >= ^first_datetime and e.timestamp < ^last_datetime
)
q =
if query.filters["event:page"] do
page = query.filters["event:page"]
from(e in q, where: e.pathname == ^page)
else
q
end
if query.filters["props"] do
[{key, val}] = query.filters["props"] |> 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"),
as: :meta,
where: meta.key == ^key and meta.value == ^val
)
end
else
q
end
end
defp query_sessions(site, query) do
{first_datetime, last_datetime} = utc_boundaries(query, site.timezone)
sessions_q =
from(s in "sessions",
where: s.domain == ^site.domain,
where: s.timestamp >= ^first_datetime and s.start < ^last_datetime
)
sessions_q =
if query.filters["event:page"] do
page = query.filters["event:page"]
from(e in sessions_q, where: e.entry_page == ^page)
else
sessions_q
end
Enum.reduce(@session_props, sessions_q, fn prop_name, sessions_q ->
prop_val = query.filters["visit:" <> prop_name]
prop_name = if prop_name == "source", do: "referrer_source", else: prop_name
prop_name = if prop_name == "device", do: "screen_size", else: prop_name
prop_name = if prop_name == "os", do: "operating_system", else: prop_name
prop_name = if prop_name == "os_version", do: "operating_system_version", else: prop_name
prop_name = if prop_name == "country", do: "country_code", else: prop_name
prop_val =
if prop_name == "referrer_source" && prop_val == @no_ref do
""
else
prop_val
end
if prop_val do
where_target = [{String.to_existing_atom(prop_name), prop_val}]
from(s in sessions_q, where: ^where_target)
else
sessions_q
end
end)
end
defp buckets(%Query{interval: "month"} = query) do
n_buckets = Timex.diff(query.date_range.last, query.date_range.first, :months)
Enum.map(n_buckets..0, fn shift ->
query.date_range.last
|> Timex.beginning_of_month()
|> Timex.shift(months: -shift)
end)
end
defp buckets(%Query{interval: "date"} = query) do
Enum.into(query.date_range, [])
end
defp select_bucket(q, site, %Query{interval: "month"}) do
from(
e in q,
select:
{fragment("toStartOfMonth(toTimeZone(?, ?)) as bucket", e.timestamp, ^site.timezone),
fragment("uniq(?)", e.user_id)}
)
end
defp select_bucket(q, site, %Query{interval: "date"}) do
from(
e in q,
select:
{fragment("toDate(toTimeZone(?, ?)) as bucket", e.timestamp, ^site.timezone),
fragment("uniq(?)", e.user_id)}
)
end
defp utc_boundaries(%Query{date_range: date_range}, timezone) do
{:ok, first} = NaiveDateTime.new(date_range.first, ~T[00:00:00])
first_datetime =
Timex.to_datetime(first, timezone)
|> Timex.Timezone.convert("UTC")
{:ok, last} = NaiveDateTime.new(date_range.last |> Timex.shift(days: 1), ~T[00:00:00])
last_datetime =
Timex.to_datetime(last, timezone)
|> Timex.Timezone.convert("UTC")
{first_datetime, last_datetime}
end
end

View File

@ -1,5 +1,5 @@
defmodule Plausible.Stats.Query do
defstruct date_range: nil, step_type: nil, period: nil, steps: nil, filters: %{}
defstruct date_range: nil, interval: nil, period: nil, filters: %{}
def shift_back(%__MODULE__{period: "day"} = query) do
new_date = query.date_range.first |> Timex.shift(days: -1)
@ -41,30 +41,24 @@ defmodule Plausible.Stats.Query do
%__MODULE__{
period: "realtime",
step_type: "minute",
interval: "minute",
date_range: Date.range(date, date),
filters: parse_filters(params)
}
end
def from(_tz, %{"period" => "day", "date" => date} = params) do
date = Date.from_iso8601!(date)
%__MODULE__{
period: "day",
date_range: Date.range(date, date),
step_type: "hour",
filters: parse_filters(params)
}
end
def from(tz, %{"period" => "day"} = params) do
date = today(tz)
date =
if params["date"] do
Date.from_iso8601!(params["date"])
else
today(tz)
end
%__MODULE__{
period: "day",
date_range: Date.range(date, date),
step_type: "hour",
interval: "hour",
filters: parse_filters(params)
}
end
@ -76,7 +70,7 @@ defmodule Plausible.Stats.Query do
%__MODULE__{
period: "7d",
date_range: Date.range(start_date, end_date),
step_type: "date",
interval: "date",
filters: parse_filters(params)
}
end
@ -88,64 +82,87 @@ defmodule Plausible.Stats.Query do
%__MODULE__{
period: "30d",
date_range: Date.range(start_date, end_date),
step_type: "date",
interval: "date",
filters: parse_filters(params)
}
end
def from(_tz, %{"period" => "month", "date" => date} = params) do
start_date = Date.from_iso8601!(date) |> Timex.beginning_of_month()
end_date = Timex.end_of_month(start_date)
def from(tz, %{"period" => "month"} = params) do
date =
if params["date"] do
Date.from_iso8601!(params["date"])
else
today(tz)
end
start_date = Timex.beginning_of_month(date)
end_date = Timex.end_of_month(date)
%__MODULE__{
period: "month",
date_range: Date.range(start_date, end_date),
step_type: "date",
steps: Timex.diff(start_date, end_date, :days),
interval: "date",
filters: parse_filters(params)
}
end
def from(tz, %{"period" => "6mo"} = params) do
end_date =
case params["date"] do
nil -> today(tz)
date -> Date.from_iso8601!(date)
end
|> Timex.end_of_month()
start_date =
Timex.shift(today(tz), months: -5)
Timex.shift(end_date, months: -5)
|> Timex.beginning_of_month()
%__MODULE__{
period: "6mo",
date_range: Date.range(start_date, today(tz)),
step_type: "month",
steps: 6,
date_range: Date.range(start_date, end_date),
interval: Map.get(params, "interval", "month"),
filters: parse_filters(params)
}
end
def from(tz, %{"period" => "12mo"} = params) do
end_date =
case params["date"] do
nil -> today(tz)
date -> Date.from_iso8601!(date)
end
|> Timex.end_of_month()
start_date =
Timex.shift(today(tz), months: -11)
Timex.shift(end_date, months: -11)
|> Timex.beginning_of_month()
%__MODULE__{
period: "12mo",
date_range: Date.range(start_date, today(tz)),
step_type: "month",
steps: 12,
date_range: Date.range(start_date, end_date),
interval: Map.get(params, "interval", "month"),
filters: parse_filters(params)
}
end
def from(_tz, %{"period" => "custom", "from" => from, "to" => to} = params) do
def from(_tz, %{"period" => "custom", "date" => date} = params) do
[from, to] = String.split(date, ",")
from_date = Date.from_iso8601!(from)
to_date = Date.from_iso8601!(to)
%__MODULE__{
period: "custom",
date_range: Date.range(from_date, to_date),
step_type: "date",
interval: Map.get(params, "interval", "date"),
filters: parse_filters(params)
}
end
def from(tz, %{"period" => "custom", "from" => from, "to" => to} = params) do
from(tz, Map.merge(params, %{"period" => "custom", "date" => Enum.join([from, to], ",")}))
end
def from(tz, _) do
__MODULE__.from(tz, %{"period" => "30d"})
end
@ -154,11 +171,27 @@ defmodule Plausible.Stats.Query do
Timex.now(tz) |> Timex.to_date()
end
defp parse_filters(params) do
if params["filters"] do
Jason.decode!(params["filters"])
else
%{}
defp parse_filters(%{"filters" => filters}) when is_binary(filters) do
case Jason.decode(filters) do
{:ok, parsed} -> parsed
{:error, err} -> parse_filter_expression(err.data)
end
end
defp parse_filters(%{"filters" => filters}) when is_map(filters), do: filters
defp parse_filters(_), do: %{}
defp parse_filter_expression(str) do
filters = String.split(str, ";")
Enum.map(filters, &parse_single_filter/1)
|> Enum.into(%{})
end
defp parse_single_filter(str) do
String.trim(str)
|> String.split("==")
|> Enum.map(&String.trim/1)
|> List.to_tuple()
end
end

View File

@ -0,0 +1,79 @@
defmodule PlausibleWeb.Api.ExternalStatsController do
use PlausibleWeb, :controller
use Plausible.Repo
use Plug.ErrorHandler
alias Plausible.Stats.Query
def realtime_visitors(conn, _params) do
site = conn.assigns[:site]
query = Query.from(site.timezone, %{"period" => "realtime"})
json(conn, Plausible.Stats.Clickhouse.current_visitors(site, query))
end
def aggregate(conn, params) do
site = conn.assigns[:site]
query = Query.from(site.timezone, params)
metrics =
params["metrics"]
|> String.split(",")
|> Enum.map(&String.trim/1)
result =
if params["compare"] == "previous_period" do
prev_query = Query.shift_back(query)
[prev_result, curr_result] =
Task.await_many([
Task.async(fn -> Plausible.Stats.aggregate(site, prev_query, metrics) end),
Task.async(fn -> Plausible.Stats.aggregate(site, query, metrics) end)
])
Enum.map(curr_result, fn {metric, %{value: current_val}} ->
%{value: prev_val} = prev_result[metric]
{metric,
%{
value: current_val,
change: percent_change(prev_val, current_val)
}}
end)
|> Enum.into(%{})
else
Plausible.Stats.aggregate(site, query, metrics)
end
json(conn, result)
end
def timeseries(conn, params) do
site = conn.assigns[:site]
query = Query.from(site.timezone, params)
{plot, labels} = Plausible.Stats.timeseries(site, query)
graph =
Enum.zip(labels, plot)
|> Enum.map(fn {label, val} -> %{date: label, value: val} end)
|> Enum.into([])
json(conn, graph)
end
def handle_errors(conn, %{kind: kind, reason: reason}) do
json(conn, %{error: Exception.format_banner(kind, reason)})
end
defp percent_change(old_count, new_count) do
cond do
old_count == 0 and new_count > 0 ->
100
old_count == 0 and new_count == 0 ->
0
true ->
round((new_count - old_count) / old_count * 100)
end
end
end

View File

@ -3,7 +3,6 @@ defmodule PlausibleWeb.Api.StatsController do
use Plausible.Repo
alias Plausible.Stats.Clickhouse, as: Stats
alias Plausible.Stats.Query
plug PlausibleWeb.AuthorizeStatsPlug
def main_graph(conn, params) do
site = conn.assigns[:site]
@ -18,7 +17,7 @@ defmodule PlausibleWeb.Api.StatsController do
labels: labels,
present_index: present_index,
top_stats: top_stats,
interval: query.step_type
interval: query.interval
})
end

View File

@ -298,6 +298,7 @@ defmodule PlausibleWeb.AuthController do
Plausible.Billing.usage_breakdown(conn.assigns[:current_user])
render(conn, "user_settings.html",
user: conn.assigns[:current_user] |> Repo.preload(:api_keys),
changeset: changeset,
subscription: conn.assigns[:current_user].subscription,
theme: conn.assigns[:current_user].theme || "system",
@ -323,6 +324,43 @@ defmodule PlausibleWeb.AuthController do
end
end
def new_api_key(conn, _params) do
key = :crypto.strong_rand_bytes(64) |> Base.url_encode64() |> binary_part(0, 64)
changeset = Auth.ApiKey.changeset(%Auth.ApiKey{}, %{key: key})
render(conn, "new_api_key.html",
changeset: changeset,
layout: {PlausibleWeb.LayoutView, "focus.html"}
)
end
def create_api_key(conn, %{"api_key" => key_params}) do
api_key = %Auth.ApiKey{user_id: conn.assigns[:current_user].id}
changeset = Auth.ApiKey.changeset(api_key, key_params)
case Repo.insert(changeset) do
{:ok, _api_key} ->
conn
|> put_flash(:success, "API key created successfully")
|> redirect(to: "/settings#api-keys")
{:error, changeset} ->
render(conn, "new_api_key.html",
changeset: changeset,
layout: {PlausibleWeb.LayoutView, "focus.html"}
)
end
end
def delete_api_key(conn, %{"id" => id}) do
Repo.get_by(Auth.ApiKey, id: id)
|> Repo.delete!()
conn
|> put_flash(:success, "API key revoked successfully")
|> redirect(to: "/settings#api-keys")
end
def delete_me(conn, params) do
user =
conn.assigns[:current_user]

View File

@ -0,0 +1,46 @@
defmodule PlausibleWeb.AuthorizeApiStatsPlug do
import Plug.Conn
use Plausible.Repo
alias Plausible.Auth.ApiKey
def init(options) do
options
end
def call(conn, _opts) do
site = Repo.get_by(Plausible.Site, domain: conn.params["site_id"])
api_key = get_bearer_token(conn)
if !(site && api_key) do
not_found(conn)
else
hashed_key = ApiKey.do_hash(api_key)
found_key = Repo.get_by(ApiKey, key_hash: hashed_key)
can_access = found_key && Plausible.Sites.is_owner?(found_key.user_id, site)
if !can_access do
not_found(conn)
else
assign(conn, :site, site)
end
end
end
defp get_bearer_token(conn) do
authorization_header =
Plug.Conn.get_req_header(conn, "authorization")
|> List.first()
case authorization_header do
"Bearer " <> token -> String.trim(token)
_ -> nil
end
end
defp not_found(conn) do
conn
|> put_status(404)
|> Phoenix.Controller.json(%{error: "Not found"})
|> halt()
end
end

View File

@ -26,10 +26,17 @@ defmodule PlausibleWeb.Router do
plug PlausibleWeb.AuthPlug
end
pipeline :stats_api do
pipeline :internal_stats_api do
plug :accepts, ["json"]
plug PlausibleWeb.Firewall
plug :fetch_session
plug PlausibleWeb.AuthorizeStatsPlug
end
pipeline :external_stats_api do
plug :accepts, ["json"]
plug PlausibleWeb.Firewall
plug PlausibleWeb.AuthorizeApiStatsPlug
end
if Application.get_env(:plausible, :environment) == "dev" do
@ -39,7 +46,7 @@ defmodule PlausibleWeb.Router do
use Kaffy.Routes, scope: "/crm", pipe_through: [PlausibleWeb.CRMAuthPlug]
scope "/api/stats", PlausibleWeb.Api do
pipe_through :stats_api
pipe_through :internal_stats_api
get "/:domain/current-visitors", StatsController, :current_visitors
get "/:domain/main-graph", StatsController, :main_graph
@ -61,6 +68,14 @@ defmodule PlausibleWeb.Router do
get "/:domain/property/:prop_name", StatsController, :prop_breakdown
end
scope "/api/v1/stats", PlausibleWeb.Api do
pipe_through :external_stats_api
get "/realtime/visitors", ExternalStatsController, :realtime_visitors
get "/aggregate", ExternalStatsController, :aggregate
get "/timeseries", ExternalStatsController, :timeseries
end
scope "/api", PlausibleWeb do
pipe_through :api
@ -99,6 +114,9 @@ defmodule PlausibleWeb.Router do
get "/settings", AuthController, :user_settings
put "/settings", AuthController, :save_settings
delete "/me", AuthController, :delete_me
get "/settings/api-keys/new", AuthController, :new_api_key
post "/settings/api-keys", AuthController, :create_api_key
delete "/settings/api-keys/:id", AuthController, :delete_api_key
get "/auth/google/callback", AuthController, :google_auth_callback

View File

@ -0,0 +1,21 @@
<%= form_for @changeset, "/settings/api-keys", [class: "w-full max-w-md mx-auto bg-white dark:bg-gray-800 shadow-md rounded px-8 py-6 mt-8"], fn f -> %>
<h1 class="text-xl font-black dark:text-gray-100">Create new API key</h1>
<div class="my-4 mt-8">
<%= label f, :name, class: "block text-sm font-medium text-gray-700" %>
<div class="mt-1">
<%= text_input f, :name, class: "shadow-sm focus:ring-indigo-500 focus:border-indigo-500 block w-full sm:text-sm border-gray-300 rounded-md" %>
</div>
<%= error_tag f, :name %>
</div>
<div class="my-4">
<%= label f, :key, class: "block text-sm font-medium text-gray-700" %>
<div class="relative mt-1">
<%= text_input f, :key, id: "key-input", class: "shadow-sm bg-gray-50 focus:ring-indigo-500 focus:border-indigo-500 block w-full sm:text-sm border-gray-300 rounded-md pr-16", readonly: "readonly" %>
<a onclick="var textarea = document.getElementById('key-input'); textarea.focus(); textarea.select(); document.execCommand('copy');" href="javascript:void(0)" class="absolute flex items-center text-xs font-medium text-indigo-600 no-underline hover:underline" style="top: 12px; right: 12px;">
<svg class="pr-1 text-indigo-600" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg>COPY
</a>
<p class="mt-1 text-xs text-gray-500">Make sure to store the key in a secure place. Once created, we will not be able to show it again.</p>
</div>
</div>
<%= submit "Continue", class: "button mt-4 w-full" %>
<% end %>

View File

@ -1,5 +1,5 @@
<%= if !Application.get_env(:plausible, :is_selfhost) do %>
<div class="max-w-2xl mx-auto bg-white dark:bg-gray-800 shadow-md rounded rounded-t-none border-t-2 border-orange-200 dark:border-orange-200 px-8 pt-6 pb-8 mt-24 ">
<div class="max-w-2xl px-8 pt-6 pb-8 mx-auto mt-24 bg-white border-t-2 border-orange-200 rounded rounded-t-none shadow-md dark:bg-gray-800 dark:border-orange-200 ">
<div class="flex justify-between">
<h2 class="text-xl font-black dark:text-gray-100">Subscription Plan</h2>
<%= if @subscription do %>
@ -12,10 +12,10 @@
<div class="my-4 border-b border-gray-400"></div>
<%= if @subscription && @subscription.status == "deleted" do %>
<div class="p-2 rounded-lg bg-red-100 sm:p-3">
<div class="flex items-center justify-between flex-wrap">
<div class="w-0 flex-1 flex items-center">
<svg class="h-6 w-6 text-red-800" viewBox="0 0 24 24" stroke="currentColor" fill="none" xmlns="http://www.w3.org/2000/svg">
<div class="p-2 bg-red-100 rounded-lg sm:p-3">
<div class="flex flex-wrap items-center justify-between">
<div class="flex items-center flex-1 w-0">
<svg class="w-6 h-6 text-red-800" viewBox="0 0 24 24" stroke="currentColor" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 9V11M12 15H12.01M5.07183 19H18.9282C20.4678 19 21.4301 17.3333 20.6603 16L13.7321 4C12.9623 2.66667 11.0378 2.66667 10.268 4L3.33978 16C2.56998 17.3333 3.53223 19 5.07183 19Z" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
<p class="ml-3 font-medium text-red-800">
@ -30,76 +30,76 @@
</div>
<% end %>
<div class="flex flex-col items-center sm:flex-row sm:items-start justify-between mt-8">
<div class="text-center bg-gray-100 dark:bg-gray-900 py-4 px-2 rounded h-32 my-4" style="width: 11.75rem;">
<div class="flex flex-col items-center justify-between mt-8 sm:flex-row sm:items-start">
<div class="h-32 px-2 py-4 my-4 text-center bg-gray-100 rounded dark:bg-gray-900" style="width: 11.75rem;">
<h4 class="font-black dark:text-gray-100">Monthly quota</h4>
<%= if @subscription do %>
<div class="text-xl py-2 font-medium dark:text-gray-100"><%= subscription_quota(@subscription) %> pageviews</div>
<div class="py-2 text-xl font-medium dark:text-gray-100"><%= subscription_quota(@subscription) %> pageviews</div>
<%= case @subscription.status do %>
<% "active" -> %>
<%= link("Change plan", to: "/billing/change-plan", class: "text-sm text-indigo-500 font-medium") %>
<% "past_due" -> %>
<span class="text-sm text-gray-600 dark:text-gray-400 font-medium" tooltip="Please update your billing details before changing plans">Change plan</span>
<span class="text-sm font-medium text-gray-600 dark:text-gray-400" tooltip="Please update your billing details before changing plans">Change plan</span>
<% _ -> %>
<% end %>
<% else %>
<div class="text-xl py-2 font-medium dark:text-gray-100">Free trial</div>
<div class="py-2 text-xl font-medium dark:text-gray-100">Free trial</div>
<%= link("Upgrade", to: "/billing/upgrade", class: "text-sm text-indigo-500 font-medium") %>
<% end %>
</div>
<div class="text-center bg-gray-100 dark:bg-gray-900 py-4 px-2 rounded h-32 my-4" style="width: 11.75rem;">
<div class="h-32 px-2 py-4 my-4 text-center bg-gray-100 rounded dark:bg-gray-900" style="width: 11.75rem;">
<h4 class="font-black dark:text-gray-100">Next bill amount</h4>
<%= if @subscription && @subscription.status in ["active", "past_due"] do %>
<div class="text-xl py-2 font-medium dark:text-gray-100">$<%= @subscription.next_bill_amount %></div>
<div class="py-2 text-xl font-medium dark:text-gray-100">$<%= @subscription.next_bill_amount %></div>
<%= if @subscription.update_url do %>
<%= link("Update billing info", to: @subscription.update_url, class: "text-sm text-indigo-500 font-medium") %>
<% end %>
<% else %>
<div class="text-xl py-2 font-medium dark:text-gray-100">---</div>
<div class="py-2 text-xl font-medium dark:text-gray-100">---</div>
<% end %>
</div>
<div class="text-center bg-gray-100 dark:bg-gray-900 py-4 px-2 rounded h-32 my-4" style="width: 11.75rem;">
<div class="h-32 px-2 py-4 my-4 text-center bg-gray-100 rounded dark:bg-gray-900" style="width: 11.75rem;">
<h4 class="font-black dark:text-gray-100">Next bill date</h4>
<%= if @subscription && @subscription.next_bill_date && @subscription.status in ["active", "past_due"] do %>
<div class="text-xl py-2 font-medium dark:text-gray-100"><%= Timex.format!(@subscription.next_bill_date, "{Mshort} {D}, {YYYY}") %></div>
<div class="text-sm text-gray-600 dark:text-gray-400 font-medium">(<%= subscription_interval(@subscription) %> billing)</div>
<div class="py-2 text-xl font-medium dark:text-gray-100"><%= Timex.format!(@subscription.next_bill_date, "{Mshort} {D}, {YYYY}") %></div>
<div class="text-sm font-medium text-gray-600 dark:text-gray-400">(<%= subscription_interval(@subscription) %> billing)</div>
<% else %>
<div class="text-xl py-2 font-medium dark:text-gray-100">---</div>
<div class="py-2 text-xl font-medium dark:text-gray-100">---</div>
<% end %>
</div>
</div>
<h3 class="text-xl font-bold mt-8 dark:text-gray-100">Your usage</h3>
<p class="mt-1 text-sm leading-5 text-gray-500 dark:text-gray-200">Last 30 days total usage across all of your sites</p>
<h3 class="mt-8 text-xl font-bold dark:text-gray-100">Your usage</h3>
<p class="mt-1 text-sm text-gray-500 leading-5 dark:text-gray-200">Last 30 days total usage across all of your sites</p>
<div class="py-2">
<div class="flex flex-col">
<div class="-my-2 sm:-mx-6 lg:-mx-8">
<div class="py-2 align-middle inline-block min-w-full sm:px-6 lg:px-8">
<div class="inline-block min-w-full py-2 align-middle sm:px-6 lg:px-8">
<div>
<table class="min-w-full divide-y divide-gray-200 text-gray-900 dark:text-gray-100">
<table class="min-w-full text-gray-900 divide-y divide-gray-200 dark:text-gray-100">
<tbody class="bg-white dark:bg-gray-800 divide-y divide-gray-200">
<tr>
<td class="py-4 whitespace-nowrap text-sm">
<td class="py-4 text-sm whitespace-nowrap">
Pageviews
</td>
<td class="py-4 whitespace-nowrap text-sm">
<td class="py-4 text-sm whitespace-nowrap">
<%= delimit_integer(@usage_pageviews) %>
</td>
</tr>
<tr>
<td class="py-4 whitespace-nowrap text-sm">
<td class="py-4 text-sm whitespace-nowrap">
Custom events
</td>
<td class="py-4 whitespace-nowrap text-sm">
<td class="py-4 text-sm whitespace-nowrap">
<%= delimit_integer(@usage_custom_events) %>
</td>
</tr>
<tr>
<td class="py-4 whitespace-nowrap text-sm font-medium">
<td class="py-4 text-sm font-medium whitespace-nowrap">
Total billable pageviews
</td>
<td class="py-4 whitespace-nowrap text-sm font-medium">
<td class="py-4 text-sm font-medium whitespace-nowrap">
<%= delimit_integer(@usage_pageviews + @usage_custom_events) %>
</td>
</tr>
@ -125,7 +125,7 @@
</div>
<% end %>
<div class="max-w-2xl mx-auto bg-white dark:bg-gray-800 shadow-md rounded rounded-t-none border-t-2 border-green-500 px-8 pt-6 pb-8 mt-16">
<div class="max-w-2xl px-8 pt-6 pb-8 mx-auto mt-16 bg-white border-t-2 border-green-500 rounded rounded-t-none shadow-md dark:bg-gray-800">
<h2 class="text-xl font-black dark:text-gray-100">Dashboard Appearance</h2>
<div class="my-4 border-b border-gray-300 dark:border-gray-500"></div>
@ -139,7 +139,7 @@
<% end %>
</div>
<div class="max-w-2xl mx-auto bg-white dark:bg-gray-800 shadow-md rounded rounded-t-none border-t-2 border-indigo-100 dark:border-indigo-500 px-8 pt-6 pb-8 mt-16">
<div class="max-w-2xl px-8 pt-6 pb-8 mx-auto mt-16 bg-white border-t-2 border-indigo-100 rounded rounded-t-none shadow-md dark:bg-gray-800 dark:border-indigo-500">
<h2 class="text-xl font-black dark:text-gray-100">Account settings</h2>
<div class="my-4 border-b border-gray-300 dark:border-gray-500"></div>
@ -163,7 +163,80 @@
<% end %>
</div>
<div class="max-w-2xl mx-auto bg-white dark:bg-gray-800 shadow-md rounded rounded-t-none border-t-2 border-red-600 px-8 pt-6 pb-8 mt-16 mb-24">
<div id="api-keys" class="max-w-2xl px-8 pt-6 pb-8 mx-auto mt-16 bg-white border-t-2 border-indigo-100 rounded rounded-t-none shadow-md dark:bg-gray-800 dark:border-indigo-500">
<h2 class="text-xl font-black dark:text-gray-100">API keys</h2>
<div class="my-4 border-b border-gray-300 dark:border-gray-500"></div>
<div class="p-4 rounded-md bg-yellow-50">
<div class="flex">
<div class="flex-shrink-0">
<!-- Heroicon name: solid/exclamation -->
<svg class="w-5 h-5 text-yellow-400" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd" />
</svg>
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-yellow-800">
Beta feature
</h3>
<div class="mt-2 text-sm text-yellow-700">
<p>
The stats API is in public beta mode. Feel free to issue API keys and test your integrations.
However, we reserve the right to make breaking changes to the API until we exit the beta and release
v1.
</p>
</div>
</div>
</div>
</div>
<div class="flex flex-col">
<div class="-my-2 overflow-x-auto sm:-mx-6 lg:-mx-8">
<div class="inline-block min-w-full py-2 align-middle sm:px-6 lg:px-8">
<%= if Enum.any?(@user.api_keys) do %>
<div class="overflow-hidden border-b border-gray-200 shadow sm:rounded-lg">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th scope="col" class="px-6 py-3 text-xs font-medium tracking-wider text-left text-gray-500 uppercase">
Name
</th>
<th scope="col" class="px-6 py-3 text-xs font-medium tracking-wider text-left text-gray-500 uppercase">
Key
</th>
<th scope="col" class="relative px-6 py-3">
<span class="sr-only">Remove</span>
</th>
</tr>
</thead>
<tbody>
<%= for api_key <- @user.api_keys do %>
<tr class="bg-white">
<td class="px-6 py-4 text-sm font-medium text-gray-900 whitespace-nowrap">
<%= api_key.name %>
</td>
<td class="px-6 py-4 text-sm text-gray-500 whitespace-nowrap">
<%= api_key.key_prefix %><%= String.duplicate("*", 32 - 6) %>
</td>
<td class="px-6 py-4 text-sm font-medium text-right whitespace-nowrap">
<%= button("Revoke", to: "/settings/api-keys/#{api_key.id}", class: "text-red-600 hover:text-red-900", method: :delete, "data-confirm": "Are you sure you want to revoke this key? This action cannot be reversed.") %>
</td>
</tr>
<% end %>
</tbody>
</table>
</div>
<% end %>
<%= link "+ New API key", to: "/settings/api-keys/new", class: "button mt-4" %>
</div>
</div>
</div>
</div>
<div class="max-w-2xl px-8 pt-6 pb-8 mx-auto mt-16 mb-24 bg-white border-t-2 border-red-600 rounded rounded-t-none shadow-md dark:bg-gray-800">
<div class="flex items-center justify-between">
<h2 class="text-xl font-black dark:text-gray-100">Delete account</h2>
</div>
@ -173,8 +246,8 @@
<p class="dark:text-gray-100">Deleting your account removes all sites and stats you've collected</p>
<%= if @subscription && @subscription.status == "active" do %>
<span class="button bg-gray-300 dark:bg-gray-800 mt-6 hover:shadow-none">Delete my account</span>
<p class="text-gray-600 dark:text-gray-400 text-sm mt-2">Your account cannot be deleted because you have an active subscription. If you want to delete your account, please cancel your subscription first.</p>
<span class="mt-6 bg-gray-300 button dark:bg-gray-800 hover:shadow-none">Delete my account</span>
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">Your account cannot be deleted because you have an active subscription. If you want to delete your account, please cancel your subscription first.</p>
<% else %>
<%= link("Delete my account", to: "/me", class: "inline-block mt-4 px-4 py-2 border border-gray-300 dark:border-gray-500 text-sm leading-5 font-medium rounded-md text-red-700 bg-white dark:bg-gray-800 hover:text-red-500 dark:hover:text-red-400 focus:outline-none focus:border-blue-300 focus:ring active:text-red-800 active:bg-gray-50 transition ease-in-out duration-150", method: "delete", data: [confirm: "Deleting your account cannot be reversed. Are you sure?"]) %>
<% end %>

View File

@ -0,0 +1,14 @@
defmodule Plausible.Repo.Migrations.CreateApiKeys do
use Ecto.Migration
def change do
create table(:api_keys) do
add :user_id, references(:users, on_delete: :delete_all), null: false
add :name, :string, null: false
add :key_prefix, :string, null: false
add :key_hash, :string, null: false
timestamps()
end
end
end

View File

@ -9,7 +9,7 @@ defmodule Plausible.Stats.QueryTest do
assert q.date_range.first == ~D[2019-01-01]
assert q.date_range.last == ~D[2019-01-01]
assert q.step_type == "hour"
assert q.interval == "hour"
end
test "day fromat defaults to today" do
@ -17,7 +17,7 @@ defmodule Plausible.Stats.QueryTest do
assert q.date_range.first == Timex.today()
assert q.date_range.last == Timex.today()
assert q.step_type == "hour"
assert q.interval == "hour"
end
test "parses realtime format" do
@ -33,7 +33,7 @@ defmodule Plausible.Stats.QueryTest do
assert q.date_range.first == ~D[2019-01-01]
assert q.date_range.last == ~D[2019-01-31]
assert q.step_type == "date"
assert q.interval == "date"
end
test "parses 6 month format" do
@ -42,8 +42,8 @@ defmodule Plausible.Stats.QueryTest do
assert q.date_range.first ==
Timex.shift(Timex.today(), months: -5) |> Timex.beginning_of_month()
assert q.date_range.last == Timex.today()
assert q.step_type == "month"
assert q.date_range.last == Timex.today() |> Timex.end_of_month()
assert q.interval == "month"
end
test "parses 12 month format" do
@ -52,8 +52,8 @@ defmodule Plausible.Stats.QueryTest do
assert q.date_range.first ==
Timex.shift(Timex.today(), months: -11) |> Timex.beginning_of_month()
assert q.date_range.last == Timex.today()
assert q.step_type == "month"
assert q.date_range.last == Timex.today() |> Timex.end_of_month()
assert q.interval == "month"
end
test "defaults to 30 days format" do
@ -65,7 +65,7 @@ defmodule Plausible.Stats.QueryTest do
assert q.date_range.first == ~D[2019-01-01]
assert q.date_range.last == ~D[2019-01-15]
assert q.step_type == "date"
assert q.interval == "date"
end
describe "filters" do

View File

@ -0,0 +1,567 @@
defmodule PlausibleWeb.Api.ExternalStatsController.AggregateTest do
use PlausibleWeb.ConnCase
import Plausible.TestUtils
setup [:create_user, :create_new_site, :create_api_key, :use_api_key]
@user_id 123
test "aggregates a single metric", %{conn: conn, site: site} do
populate_stats([
build(:pageview, user_id: @user_id, domain: site.domain, timestamp: ~N[2021-01-01 00:00:00]),
build(:pageview, user_id: @user_id, domain: site.domain, timestamp: ~N[2021-01-01 00:25:00]),
build(:pageview, domain: site.domain, timestamp: ~N[2021-01-01 00:00:00])
])
conn =
get(conn, "/api/v1/stats/aggregate", %{
"site_id" => site.domain,
"period" => "day",
"date" => "2021-01-01",
"metrics" => "pageviews"
})
assert json_response(conn, 200) == %{
"pageviews" => %{"value" => 3}
}
end
test "aggregates visitors, pageviews, bounce rate and visit duration", %{conn: conn, site: site} do
populate_stats([
build(:pageview, user_id: @user_id, domain: site.domain, timestamp: ~N[2021-01-01 00:00:00]),
build(:pageview, user_id: @user_id, domain: site.domain, timestamp: ~N[2021-01-01 00:25:00]),
build(:pageview, domain: site.domain, timestamp: ~N[2021-01-01 00:00:00])
])
conn =
get(conn, "/api/v1/stats/aggregate", %{
"site_id" => site.domain,
"period" => "day",
"date" => "2021-01-01",
"metrics" => "pageviews,visitors,bounce_rate,visit_duration"
})
assert json_response(conn, 200) == %{
"pageviews" => %{"value" => 3},
"visitors" => %{"value" => 2},
"bounce_rate" => %{"value" => 50},
"visit_duration" => %{"value" => 750}
}
end
describe "comparisons" do
test "compare period=day with previous period", %{conn: conn, site: site} do
populate_stats([
build(:pageview, domain: site.domain, timestamp: ~N[2020-12-31 00:00:00]),
build(:pageview,
user_id: @user_id,
domain: site.domain,
timestamp: ~N[2021-01-01 00:00:00]
),
build(:pageview,
user_id: @user_id,
domain: site.domain,
timestamp: ~N[2021-01-01 00:25:00]
),
build(:pageview, domain: site.domain, timestamp: ~N[2021-01-01 00:00:00])
])
conn =
get(conn, "/api/v1/stats/aggregate", %{
"site_id" => site.domain,
"period" => "day",
"date" => "2021-01-01",
"metrics" => "pageviews,visitors,bounce_rate,visit_duration",
"compare" => "previous_period"
})
assert json_response(conn, 200) == %{
"pageviews" => %{"value" => 3, "change" => 200},
"visitors" => %{"value" => 2, "change" => 100},
"bounce_rate" => %{"value" => 50, "change" => -50},
"visit_duration" => %{"value" => 750, "change" => 100}
}
end
end
describe "filters" do
test "can filter by source", %{conn: conn, site: site} do
populate_stats([
build(:pageview,
referrer_source: "Google",
user_id: @user_id,
domain: site.domain,
timestamp: ~N[2021-01-01 00:00:00]
),
build(:pageview,
user_id: @user_id,
domain: site.domain,
timestamp: ~N[2021-01-01 00:25:00]
),
build(:pageview, domain: site.domain, timestamp: ~N[2021-01-01 00:00:00])
])
conn =
get(conn, "/api/v1/stats/aggregate", %{
"site_id" => site.domain,
"period" => "day",
"date" => "2021-01-01",
"metrics" => "pageviews,visitors,bounce_rate,visit_duration",
"filters" => "visit:source==Google"
})
assert json_response(conn, 200) == %{
"pageviews" => %{"value" => 2},
"visitors" => %{"value" => 1},
"bounce_rate" => %{"value" => 0},
"visit_duration" => %{"value" => 1500}
}
end
test "can filter by no source/referrer", %{conn: conn, site: site} do
populate_stats([
build(:pageview,
user_id: @user_id,
domain: site.domain,
timestamp: ~N[2021-01-01 00:00:00]
),
build(:pageview,
user_id: @user_id,
domain: site.domain,
timestamp: ~N[2021-01-01 00:25:00]
),
build(:pageview,
referrer_source: "Google",
domain: site.domain,
timestamp: ~N[2021-01-01 00:00:00]
)
])
conn =
get(conn, "/api/v1/stats/aggregate", %{
"site_id" => site.domain,
"period" => "day",
"date" => "2021-01-01",
"metrics" => "pageviews,visitors,bounce_rate,visit_duration",
"filters" => "visit:source==Direct / None"
})
assert json_response(conn, 200) == %{
"pageviews" => %{"value" => 2},
"visitors" => %{"value" => 1},
"bounce_rate" => %{"value" => 0},
"visit_duration" => %{"value" => 1500}
}
end
test "can filter by referrer", %{conn: conn, site: site} do
populate_stats([
build(:pageview,
referrer: "https://facebook.com",
user_id: @user_id,
domain: site.domain,
timestamp: ~N[2021-01-01 00:00:00]
),
build(:pageview,
user_id: @user_id,
domain: site.domain,
timestamp: ~N[2021-01-01 00:25:00]
),
build(:pageview, domain: site.domain, timestamp: ~N[2021-01-01 00:00:00])
])
conn =
get(conn, "/api/v1/stats/aggregate", %{
"site_id" => site.domain,
"period" => "day",
"date" => "2021-01-01",
"metrics" => "pageviews,visitors,bounce_rate,visit_duration",
"filters" => "visit:referrer==https://facebook.com"
})
assert json_response(conn, 200) == %{
"pageviews" => %{"value" => 2},
"visitors" => %{"value" => 1},
"bounce_rate" => %{"value" => 0},
"visit_duration" => %{"value" => 1500}
}
end
test "can filter by utm_medium", %{conn: conn, site: site} do
populate_stats([
build(:pageview,
utm_medium: "social",
user_id: @user_id,
domain: site.domain,
timestamp: ~N[2021-01-01 00:00:00]
),
build(:pageview,
user_id: @user_id,
domain: site.domain,
timestamp: ~N[2021-01-01 00:25:00]
),
build(:pageview, domain: site.domain, timestamp: ~N[2021-01-01 00:00:00])
])
conn =
get(conn, "/api/v1/stats/aggregate", %{
"site_id" => site.domain,
"period" => "day",
"date" => "2021-01-01",
"metrics" => "pageviews,visitors,bounce_rate,visit_duration",
"filters" => "visit:utm_medium==social"
})
assert json_response(conn, 200) == %{
"pageviews" => %{"value" => 2},
"visitors" => %{"value" => 1},
"bounce_rate" => %{"value" => 0},
"visit_duration" => %{"value" => 1500}
}
end
test "can filter by utm_source", %{conn: conn, site: site} do
populate_stats([
build(:pageview,
utm_source: "Twitter",
user_id: @user_id,
domain: site.domain,
timestamp: ~N[2021-01-01 00:00:00]
),
build(:pageview,
user_id: @user_id,
domain: site.domain,
timestamp: ~N[2021-01-01 00:25:00]
),
build(:pageview, domain: site.domain, timestamp: ~N[2021-01-01 00:00:00])
])
conn =
get(conn, "/api/v1/stats/aggregate", %{
"site_id" => site.domain,
"period" => "day",
"date" => "2021-01-01",
"metrics" => "pageviews,visitors,bounce_rate,visit_duration",
"filters" => "visit:utm_source==Twitter"
})
assert json_response(conn, 200) == %{
"pageviews" => %{"value" => 2},
"visitors" => %{"value" => 1},
"bounce_rate" => %{"value" => 0},
"visit_duration" => %{"value" => 1500}
}
end
test "can filter by utm_campaign", %{conn: conn, site: site} do
populate_stats([
build(:pageview,
utm_campaign: "profile",
user_id: @user_id,
domain: site.domain,
timestamp: ~N[2021-01-01 00:00:00]
),
build(:pageview,
user_id: @user_id,
domain: site.domain,
timestamp: ~N[2021-01-01 00:25:00]
),
build(:pageview, domain: site.domain, timestamp: ~N[2021-01-01 00:00:00])
])
conn =
get(conn, "/api/v1/stats/aggregate", %{
"site_id" => site.domain,
"period" => "day",
"date" => "2021-01-01",
"metrics" => "pageviews,visitors,bounce_rate,visit_duration",
"filters" => "visit:utm_campaign==profile"
})
assert json_response(conn, 200) == %{
"pageviews" => %{"value" => 2},
"visitors" => %{"value" => 1},
"bounce_rate" => %{"value" => 0},
"visit_duration" => %{"value" => 1500}
}
end
test "can filter by device type", %{conn: conn, site: site} do
populate_stats([
build(:pageview,
screen_size: "Desktop",
user_id: @user_id,
domain: site.domain,
timestamp: ~N[2021-01-01 00:00:00]
),
build(:pageview,
user_id: @user_id,
domain: site.domain,
timestamp: ~N[2021-01-01 00:25:00]
),
build(:pageview, domain: site.domain, timestamp: ~N[2021-01-01 00:00:00])
])
conn =
get(conn, "/api/v1/stats/aggregate", %{
"site_id" => site.domain,
"period" => "day",
"date" => "2021-01-01",
"metrics" => "pageviews,visitors,bounce_rate,visit_duration",
"filters" => "visit:device==Desktop"
})
assert json_response(conn, 200) == %{
"pageviews" => %{"value" => 2},
"visitors" => %{"value" => 1},
"bounce_rate" => %{"value" => 0},
"visit_duration" => %{"value" => 1500}
}
end
test "can filter by browser", %{conn: conn, site: site} do
populate_stats([
build(:pageview,
browser: "Chrome",
user_id: @user_id,
domain: site.domain,
timestamp: ~N[2021-01-01 00:00:00]
),
build(:pageview,
user_id: @user_id,
domain: site.domain,
timestamp: ~N[2021-01-01 00:25:00]
),
build(:pageview, domain: site.domain, timestamp: ~N[2021-01-01 00:00:00])
])
conn =
get(conn, "/api/v1/stats/aggregate", %{
"site_id" => site.domain,
"period" => "day",
"date" => "2021-01-01",
"metrics" => "pageviews,visitors,bounce_rate,visit_duration",
"filters" => "visit:browser==Chrome"
})
assert json_response(conn, 200) == %{
"pageviews" => %{"value" => 2},
"visitors" => %{"value" => 1},
"bounce_rate" => %{"value" => 0},
"visit_duration" => %{"value" => 1500}
}
end
test "can filter by browser version", %{conn: conn, site: site} do
populate_stats([
build(:pageview,
browser: "Chrome",
browser_version: "56",
user_id: @user_id,
domain: site.domain,
timestamp: ~N[2021-01-01 00:00:00]
),
build(:pageview,
user_id: @user_id,
domain: site.domain,
timestamp: ~N[2021-01-01 00:25:00]
),
build(:pageview, domain: site.domain, timestamp: ~N[2021-01-01 00:00:00])
])
conn =
get(conn, "/api/v1/stats/aggregate", %{
"site_id" => site.domain,
"period" => "day",
"date" => "2021-01-01",
"metrics" => "pageviews,visitors,bounce_rate,visit_duration",
"filters" => "visit:browser_version==56"
})
assert json_response(conn, 200) == %{
"pageviews" => %{"value" => 2},
"visitors" => %{"value" => 1},
"bounce_rate" => %{"value" => 0},
"visit_duration" => %{"value" => 1500}
}
end
test "can filter by operating system", %{conn: conn, site: site} do
populate_stats([
build(:pageview,
operating_system: "Mac",
user_id: @user_id,
domain: site.domain,
timestamp: ~N[2021-01-01 00:00:00]
),
build(:pageview,
user_id: @user_id,
domain: site.domain,
timestamp: ~N[2021-01-01 00:25:00]
),
build(:pageview, domain: site.domain, timestamp: ~N[2021-01-01 00:00:00])
])
conn =
get(conn, "/api/v1/stats/aggregate", %{
"site_id" => site.domain,
"period" => "day",
"date" => "2021-01-01",
"metrics" => "pageviews,visitors,bounce_rate,visit_duration",
"filters" => "visit:os==Mac"
})
assert json_response(conn, 200) == %{
"pageviews" => %{"value" => 2},
"visitors" => %{"value" => 1},
"bounce_rate" => %{"value" => 0},
"visit_duration" => %{"value" => 1500}
}
end
test "can filter by operating system version", %{conn: conn, site: site} do
populate_stats([
build(:pageview,
operating_system_version: "10.5",
user_id: @user_id,
domain: site.domain,
timestamp: ~N[2021-01-01 00:00:00]
),
build(:pageview,
user_id: @user_id,
domain: site.domain,
timestamp: ~N[2021-01-01 00:25:00]
),
build(:pageview, domain: site.domain, timestamp: ~N[2021-01-01 00:00:00])
])
conn =
get(conn, "/api/v1/stats/aggregate", %{
"site_id" => site.domain,
"period" => "day",
"date" => "2021-01-01",
"metrics" => "pageviews,visitors,bounce_rate,visit_duration",
"filters" => "visit:os_version==10.5"
})
assert json_response(conn, 200) == %{
"pageviews" => %{"value" => 2},
"visitors" => %{"value" => 1},
"bounce_rate" => %{"value" => 0},
"visit_duration" => %{"value" => 1500}
}
end
test "can filter by country", %{conn: conn, site: site} do
populate_stats([
build(:pageview,
country_code: "EE",
user_id: @user_id,
domain: site.domain,
timestamp: ~N[2021-01-01 00:00:00]
),
build(:pageview,
user_id: @user_id,
domain: site.domain,
timestamp: ~N[2021-01-01 00:25:00]
),
build(:pageview, domain: site.domain, timestamp: ~N[2021-01-01 00:00:00])
])
conn =
get(conn, "/api/v1/stats/aggregate", %{
"site_id" => site.domain,
"period" => "day",
"date" => "2021-01-01",
"metrics" => "pageviews,visitors,bounce_rate,visit_duration",
"filters" => "visit:country==EE"
})
assert json_response(conn, 200) == %{
"pageviews" => %{"value" => 2},
"visitors" => %{"value" => 1},
"bounce_rate" => %{"value" => 0},
"visit_duration" => %{"value" => 1500}
}
end
test "when filtering by page, session metrics treat is like entry_page", %{
conn: conn,
site: site
} do
populate_stats([
build(:pageview,
pathname: "/blogpost",
user_id: @user_id,
domain: site.domain,
timestamp: ~N[2021-01-01 00:00:00]
),
build(:pageview,
user_id: @user_id,
domain: site.domain,
timestamp: ~N[2021-01-01 00:25:00]
),
build(:pageview, domain: site.domain, timestamp: ~N[2021-01-01 00:00:00]),
build(:pageview,
pathname: "/blogpost",
domain: site.domain,
timestamp: ~N[2021-01-01 00:00:00]
)
])
conn =
get(conn, "/api/v1/stats/aggregate", %{
"site_id" => site.domain,
"period" => "day",
"date" => "2021-01-01",
"metrics" => "pageviews,visitors,bounce_rate,visit_duration",
"filters" => "event:page==/blogpost"
})
assert json_response(conn, 200) == %{
"pageviews" => %{"value" => 2},
"visitors" => %{"value" => 2},
"bounce_rate" => %{"value" => 50},
"visit_duration" => %{"value" => 750}
}
end
test "combining filters", %{conn: conn, site: site} do
populate_stats([
build(:pageview,
pathname: "/blogpost",
country_code: "EE",
user_id: @user_id,
domain: site.domain,
timestamp: ~N[2021-01-01 00:00:00]
),
build(:pageview,
user_id: @user_id,
domain: site.domain,
timestamp: ~N[2021-01-01 00:25:00]
),
build(:pageview, domain: site.domain, timestamp: ~N[2021-01-01 00:00:00]),
build(:pageview,
pathname: "/blogpost",
domain: site.domain,
timestamp: ~N[2021-01-01 00:00:00]
)
])
conn =
get(conn, "/api/v1/stats/aggregate", %{
"site_id" => site.domain,
"period" => "day",
"date" => "2021-01-01",
"metrics" => "pageviews,visitors,bounce_rate,visit_duration",
"filters" => "event:page==/blogpost;visit:country==EE"
})
assert json_response(conn, 200) == %{
"pageviews" => %{"value" => 1},
"visitors" => %{"value" => 1},
"bounce_rate" => %{"value" => 0},
"visit_duration" => %{"value" => 1500}
}
end
end
end

View File

@ -0,0 +1,397 @@
defmodule PlausibleWeb.Api.ExternalStatsController.TimeseriesTest do
use PlausibleWeb.ConnCase
import Plausible.TestUtils
setup [:create_user, :create_new_site, :create_api_key, :use_api_key]
test "shows last 6 months of visitors", %{conn: conn, site: site} do
populate_stats([
build(:pageview, domain: site.domain, timestamp: ~N[2020-12-31 00:00:00]),
build(:pageview, domain: site.domain, timestamp: ~N[2021-01-01 00:00:00]),
build(:pageview, domain: site.domain, timestamp: ~N[2021-01-01 00:00:00])
])
conn =
get(conn, "/api/v1/stats/timeseries", %{
"site_id" => site.domain,
"period" => "6mo",
"date" => "2021-01-01"
})
assert json_response(conn, 200) == [
%{"date" => "2020-08-01", "value" => 0},
%{"date" => "2020-09-01", "value" => 0},
%{"date" => "2020-10-01", "value" => 0},
%{"date" => "2020-11-01", "value" => 0},
%{"date" => "2020-12-01", "value" => 1},
%{"date" => "2021-01-01", "value" => 2}
]
end
test "shows last 12 months of visitors", %{conn: conn, site: site} do
populate_stats([
build(:pageview, domain: site.domain, timestamp: ~N[2020-02-01 00:00:00]),
build(:pageview, domain: site.domain, timestamp: ~N[2020-12-31 00:00:00]),
build(:pageview, domain: site.domain, timestamp: ~N[2021-01-01 00:00:00]),
build(:pageview, domain: site.domain, timestamp: ~N[2021-01-01 00:00:00])
])
conn =
get(conn, "/api/v1/stats/timeseries", %{
"site_id" => site.domain,
"period" => "12mo",
"date" => "2021-01-01"
})
assert json_response(conn, 200) == [
%{"date" => "2020-02-01", "value" => 1},
%{"date" => "2020-03-01", "value" => 0},
%{"date" => "2020-04-01", "value" => 0},
%{"date" => "2020-05-01", "value" => 0},
%{"date" => "2020-06-01", "value" => 0},
%{"date" => "2020-07-01", "value" => 0},
%{"date" => "2020-08-01", "value" => 0},
%{"date" => "2020-09-01", "value" => 0},
%{"date" => "2020-10-01", "value" => 0},
%{"date" => "2020-11-01", "value" => 0},
%{"date" => "2020-12-01", "value" => 1},
%{"date" => "2021-01-01", "value" => 2}
]
end
test "shows last 12 months of visitors with interval daily", %{conn: conn, site: site} do
populate_stats([
build(:pageview, domain: site.domain, timestamp: ~N[2020-02-01 00:00:00]),
build(:pageview, domain: site.domain, timestamp: ~N[2020-12-31 00:00:00]),
build(:pageview, domain: site.domain, timestamp: ~N[2021-01-01 00:00:00]),
build(:pageview, domain: site.domain, timestamp: ~N[2021-01-01 00:00:00])
])
conn =
get(conn, "/api/v1/stats/timeseries", %{
"site_id" => site.domain,
"period" => "12mo",
"interval" => "date"
})
res = json_response(conn, 200)
assert Enum.count(res) in [365, 366]
end
test "shows a custom range with daily interval", %{conn: conn, site: site} do
populate_stats([
build(:pageview, domain: site.domain, timestamp: ~N[2021-01-01 00:00:00]),
build(:pageview, domain: site.domain, timestamp: ~N[2021-01-01 00:00:00]),
build(:pageview, domain: site.domain, timestamp: ~N[2021-01-02 00:00:00])
])
conn =
get(conn, "/api/v1/stats/timeseries", %{
"site_id" => site.domain,
"period" => "custom",
"from" => "2021-01-01",
"to" => "2021-01-02"
})
assert json_response(conn, 200) == [
%{"date" => "2021-01-01", "value" => 2},
%{"date" => "2021-01-02", "value" => 1}
]
end
test "shows a custom range with monthly interval", %{conn: conn, site: site} do
populate_stats([
build(:pageview, domain: site.domain, timestamp: ~N[2020-12-01 00:00:00]),
build(:pageview, domain: site.domain, timestamp: ~N[2021-01-01 00:00:00]),
build(:pageview, domain: site.domain, timestamp: ~N[2021-01-02 00:00:00])
])
conn =
get(conn, "/api/v1/stats/timeseries", %{
"site_id" => site.domain,
"period" => "custom",
"from" => "2020-12-01",
"to" => "2021-01-02",
"interval" => "month"
})
assert json_response(conn, 200) == [
%{"date" => "2020-12-01", "value" => 1},
%{"date" => "2021-01-01", "value" => 2}
]
end
describe "filters" do
test "can filter by source", %{conn: conn, site: site} do
populate_stats([
build(:pageview,
referrer_source: "Google",
domain: site.domain,
timestamp: ~N[2021-01-01 00:00:00]
),
build(:pageview, domain: site.domain, timestamp: ~N[2021-01-01 00:00:00])
])
conn =
get(conn, "/api/v1/stats/timeseries", %{
"site_id" => site.domain,
"period" => "month",
"date" => "2021-01-01",
"filters" => "visit:source==Google"
})
res = json_response(conn, 200)
assert List.first(res) == %{"date" => "2021-01-01", "value" => 1}
end
test "can filter by no source/referrer", %{conn: conn, site: site} do
populate_stats([
build(:pageview, domain: site.domain, timestamp: ~N[2021-01-01 00:00:00]),
build(:pageview,
referrer_source: "Google",
domain: site.domain,
timestamp: ~N[2021-01-01 00:00:00]
)
])
conn =
get(conn, "/api/v1/stats/timeseries", %{
"site_id" => site.domain,
"period" => "month",
"date" => "2021-01-01",
"filters" => "visit:source==Direct / None"
})
res = json_response(conn, 200)
assert List.first(res) == %{"date" => "2021-01-01", "value" => 1}
end
test "can filter by referrer", %{conn: conn, site: site} do
populate_stats([
build(:pageview,
referrer: "https://facebook.com",
domain: site.domain,
timestamp: ~N[2021-01-01 00:00:00]
),
build(:pageview, domain: site.domain, timestamp: ~N[2021-01-01 00:00:00])
])
conn =
get(conn, "/api/v1/stats/timeseries", %{
"site_id" => site.domain,
"period" => "month",
"date" => "2021-01-01",
"filters" => "visit:referrer==https://facebook.com"
})
res = json_response(conn, 200)
assert List.first(res) == %{"date" => "2021-01-01", "value" => 1}
end
test "can filter by utm_medium", %{conn: conn, site: site} do
populate_stats([
build(:pageview,
utm_medium: "social",
domain: site.domain,
timestamp: ~N[2021-01-01 00:00:00]
),
build(:pageview, domain: site.domain, timestamp: ~N[2021-01-01 00:00:00])
])
conn =
get(conn, "/api/v1/stats/timeseries", %{
"site_id" => site.domain,
"period" => "month",
"date" => "2021-01-01",
"filters" => "visit:utm_medium==social"
})
res = json_response(conn, 200)
assert List.first(res) == %{"date" => "2021-01-01", "value" => 1}
end
test "can filter by utm_source", %{conn: conn, site: site} do
populate_stats([
build(:pageview,
utm_source: "Twitter",
domain: site.domain,
timestamp: ~N[2021-01-01 00:00:00]
),
build(:pageview, domain: site.domain, timestamp: ~N[2021-01-01 00:00:00])
])
conn =
get(conn, "/api/v1/stats/timeseries", %{
"site_id" => site.domain,
"period" => "month",
"date" => "2021-01-01",
"filters" => "visit:utm_source==Twitter"
})
res = json_response(conn, 200)
assert List.first(res) == %{"date" => "2021-01-01", "value" => 1}
end
test "can filter by utm_campaign", %{conn: conn, site: site} do
populate_stats([
build(:pageview,
utm_campaign: "profile",
domain: site.domain,
timestamp: ~N[2021-01-01 00:00:00]
),
build(:pageview, domain: site.domain, timestamp: ~N[2021-01-01 00:00:00])
])
conn =
get(conn, "/api/v1/stats/timeseries", %{
"site_id" => site.domain,
"period" => "month",
"date" => "2021-01-01",
"filters" => "visit:utm_campaign==profile"
})
res = json_response(conn, 200)
assert List.first(res) == %{"date" => "2021-01-01", "value" => 1}
end
test "can filter by device type", %{conn: conn, site: site} do
populate_stats([
build(:pageview,
screen_size: "Desktop",
domain: site.domain,
timestamp: ~N[2021-01-01 00:00:00]
),
build(:pageview, domain: site.domain, timestamp: ~N[2021-01-01 00:00:00])
])
conn =
get(conn, "/api/v1/stats/timeseries", %{
"site_id" => site.domain,
"period" => "month",
"date" => "2021-01-01",
"filters" => "visit:device==Desktop"
})
res = json_response(conn, 200)
assert List.first(res) == %{"date" => "2021-01-01", "value" => 1}
end
test "can filter by browser", %{conn: conn, site: site} do
populate_stats([
build(:pageview,
browser: "Chrome",
browser_version: "56.1",
domain: site.domain,
timestamp: ~N[2021-01-01 00:00:00]
),
build(:pageview,
browser: "Chrome",
browser_version: "55",
domain: site.domain,
timestamp: ~N[2021-01-01 00:00:00]
),
build(:pageview, domain: site.domain, timestamp: ~N[2021-01-01 00:00:00])
])
conn =
get(conn, "/api/v1/stats/timeseries", %{
"site_id" => site.domain,
"period" => "month",
"date" => "2021-01-01",
"filters" => "visit:browser==Chrome;visit:browser_version==56.1"
})
res = json_response(conn, 200)
assert List.first(res) == %{"date" => "2021-01-01", "value" => 1}
end
test "can filter by operating system", %{conn: conn, site: site} do
populate_stats([
build(:pageview,
operating_system: "Mac",
operating_system_version: "10.5",
domain: site.domain,
timestamp: ~N[2021-01-01 00:00:00]
),
build(:pageview,
operating_system: "Something else",
operating_system_version: "10.5",
domain: site.domain,
timestamp: ~N[2021-01-01 00:00:00]
),
build(:pageview,
operating_system: "Mac",
operating_system_version: "10.4",
domain: site.domain,
timestamp: ~N[2021-01-01 00:00:00]
),
build(:pageview, domain: site.domain, timestamp: ~N[2021-01-01 00:00:00])
])
conn =
get(conn, "/api/v1/stats/timeseries", %{
"site_id" => site.domain,
"period" => "month",
"date" => "2021-01-01",
"filters" => "visit:os == Mac;visit:os_version==10.5"
})
res = json_response(conn, 200)
assert List.first(res) == %{"date" => "2021-01-01", "value" => 1}
end
test "can filter by country", %{conn: conn, site: site} do
populate_stats([
build(:pageview,
country_code: "EE",
operating_system_version: "10.5",
domain: site.domain,
timestamp: ~N[2021-01-01 00:00:00]
),
build(:pageview, domain: site.domain, timestamp: ~N[2021-01-01 00:00:00])
])
conn =
get(conn, "/api/v1/stats/timeseries", %{
"site_id" => site.domain,
"period" => "month",
"date" => "2021-01-01",
"filters" => "visit:country==EE"
})
res = json_response(conn, 200)
assert List.first(res) == %{"date" => "2021-01-01", "value" => 1}
end
test "can filter by page", %{conn: conn, site: site} do
populate_stats([
build(:pageview,
pathname: "/hello",
domain: site.domain,
timestamp: ~N[2021-01-01 00:00:00]
),
build(:pageview,
pathname: "/hello",
domain: site.domain,
timestamp: ~N[2021-01-01 00:00:00]
),
build(:pageview,
pathname: "/goobye",
domain: site.domain,
timestamp: ~N[2021-01-01 00:00:00]
)
])
conn =
get(conn, "/api/v1/stats/timeseries", %{
"site_id" => site.domain,
"period" => "month",
"date" => "2021-01-01",
"filters" => "event:page==/hello"
})
res = json_response(conn, 200)
assert List.first(res) == %{"date" => "2021-01-01", "value" => 2}
end
end
end

View File

@ -151,6 +151,17 @@ defmodule Plausible.Factory do
}
end
def api_key_factory do
key = :crypto.strong_rand_bytes(64) |> Base.url_encode64() |> binary_part(0, 64)
%Plausible.Auth.ApiKey{
name: "api-key-name",
key: key,
key_hash: Plausible.Auth.ApiKey.do_hash(key),
key_prefix: binary_part(key, 0, 6)
}
end
defp hash_key() do
Keyword.fetch!(
Application.get_env(:plausible, PlausibleWeb.Endpoint),

View File

@ -11,6 +11,23 @@ defmodule Plausible.TestUtils do
{:ok, site: site}
end
def create_new_site(%{user: user}) do
site = Factory.insert(:site, members: [user])
{:ok, site: site}
end
def create_api_key(%{user: user}) do
api_key = Factory.insert(:api_key, user: user)
{:ok, api_key: api_key.key}
end
def use_api_key(%{conn: conn, api_key: api_key}) do
conn = Plug.Conn.put_req_header(conn, "authorization", "Bearer #{api_key}")
{:ok, conn: conn}
end
def create_pageviews(pageviews) do
pageviews =
Enum.map(pageviews, fn pageview ->
@ -61,4 +78,31 @@ defmodule Plausible.TestUtils do
|> Plug.Session.call(opts)
|> Plug.Conn.fetch_session()
end
def populate_stats(events) do
sessions =
Enum.reduce(events, %{}, fn event, sessions ->
Plausible.Session.Store.reconcile_event(sessions, event)
end)
events =
Enum.map(events, fn event ->
Map.put(event, :session_id, sessions[event.user_id].session_id)
end)
Plausible.ClickhouseRepo.insert_all(
Plausible.ClickhouseEvent,
Enum.map(events, &schema_to_map/1)
)
Plausible.ClickhouseRepo.insert_all(
Plausible.ClickhouseSession,
Enum.map(Map.values(sessions), &schema_to_map/1)
)
end
defp schema_to_map(schema) do
Map.from_struct(schema)
|> Map.delete(:__meta__)
end
end