add privacy-first analytics with progressive event collection
All checks were successful
deploy / deploy (push) Successful in 3m20s

Three-layer pipeline: Plug for all HTTP requests (no JS needed), LiveView
hook for SPA navigations, JS hook for screen width. ETS-backed buffer
batches writes to SQLite every 10s. Daily-rotating salt for visitor hashing.
Includes admin dashboard with date ranges, visitor trends, top pages,
sources, devices, and e-commerce conversion funnel. Oban cron for 12-month
data retention.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
jamey
2026-02-22 12:50:55 +00:00
parent b0aed4c1d6
commit 2bd2e613c7
29 changed files with 2277 additions and 10 deletions

View File

@@ -0,0 +1,106 @@
defmodule BerrypodWeb.Admin.AnalyticsTest do
use BerrypodWeb.ConnCase, async: false
import Phoenix.LiveViewTest
import Berrypod.AccountsFixtures
alias Berrypod.Analytics.{Buffer, Event}
alias Berrypod.Repo
setup do
send(Buffer, :flush)
:timer.sleep(50)
user = user_fixture()
%{user: user}
end
describe "unauthenticated" do
test "redirects to login", %{conn: conn} do
{:error, redirect} = live(conn, ~p"/admin/analytics")
assert {:redirect, %{to: path}} = redirect
assert path == ~p"/users/log-in"
end
end
describe "analytics dashboard" do
setup %{conn: conn, user: user} do
%{conn: log_in_user(conn, user)}
end
test "renders the analytics page", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/admin/analytics")
assert html =~ "Analytics"
assert html =~ "Unique visitors"
assert html =~ "Total pageviews"
assert html =~ "Bounce rate"
assert html =~ "Visit duration"
end
test "shows zero state with no data", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/admin/analytics")
assert html =~ "No data for this period"
end
test "shows data when events exist", %{conn: conn} do
now = DateTime.utc_now() |> DateTime.truncate(:second)
Repo.insert_all(Event, [
[
id: Ecto.UUID.generate(),
name: "pageview",
pathname: "/",
visitor_hash: :crypto.strong_rand_bytes(8),
session_hash: :crypto.strong_rand_bytes(8),
browser: "Chrome",
os: "macOS",
inserted_at: now
]
])
{:ok, view, _html} = live(conn, ~p"/admin/analytics")
assert has_element?(view, "rect")
end
test "changes period", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/admin/analytics")
html = render_click(view, "change_period", %{"period" => "7d"})
assert html =~ "Analytics"
end
test "changes tab to sources", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/admin/analytics")
html = render_click(view, "change_tab", %{"tab" => "sources"})
assert html =~ "Top sources"
assert html =~ "Top referrers"
end
test "changes tab to countries", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/admin/analytics")
html = render_click(view, "change_tab", %{"tab" => "countries"})
assert html =~ "Countries"
end
test "changes tab to devices", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/admin/analytics")
html = render_click(view, "change_tab", %{"tab" => "devices"})
assert html =~ "Browsers"
assert html =~ "Operating systems"
assert html =~ "Screen sizes"
end
test "changes tab to funnel", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/admin/analytics")
html = render_click(view, "change_tab", %{"tab" => "funnel"})
assert html =~ "Conversion funnel"
end
end
end

View File

@@ -0,0 +1,115 @@
defmodule BerrypodWeb.Plugs.AnalyticsTest do
use BerrypodWeb.ConnCase, async: false
import Ecto.Query
alias Berrypod.Analytics.{Buffer, Event}
alias Berrypod.Repo
setup do
send(Buffer, :flush)
:timer.sleep(50)
:ok
end
describe "analytics plug" do
test "records a pageview on GET request", %{conn: conn} do
conn
|> put_req_header("user-agent", "Mozilla/5.0 Chrome/120.0")
|> get(~p"/")
send(Buffer, :flush)
:timer.sleep(50)
events =
from(e in Event, where: e.pathname == "/" and e.name == "pageview")
|> Repo.all()
assert length(events) >= 1
event = hd(events)
assert event.browser == "Chrome"
end
test "does not record on POST request", %{conn: conn} do
count_before = Repo.aggregate(Event, :count)
conn
|> put_req_header("user-agent", "Mozilla/5.0 Chrome/120.0")
|> post(~p"/checkout", %{})
send(Buffer, :flush)
:timer.sleep(50)
count_after = Repo.aggregate(Event, :count)
# POST to /checkout shouldn't create a pageview event via the plug
# (it may fail with a redirect, but the plug should have skipped)
assert count_after == count_before
end
test "skips bots", %{conn: conn} do
count_before = Repo.aggregate(Event, :count)
conn
|> put_req_header("user-agent", "Googlebot/2.1 (+http://www.google.com/bot.html)")
|> get(~p"/")
send(Buffer, :flush)
:timer.sleep(50)
count_after = Repo.aggregate(Event, :count)
assert count_after == count_before
end
test "stores analytics data in session", %{conn: conn} do
conn =
conn
|> put_req_header("user-agent", "Mozilla/5.0 Firefox/121.0")
|> get(~p"/")
assert get_session(conn, "analytics_visitor_hash") |> is_binary()
assert get_session(conn, "analytics_browser") == "Firefox"
end
test "extracts referrer", %{conn: conn} do
conn
|> put_req_header("user-agent", "Mozilla/5.0 Chrome/120.0")
|> put_req_header("referer", "https://www.google.com/search?q=test")
|> get(~p"/")
send(Buffer, :flush)
:timer.sleep(50)
event =
from(e in Event,
where: e.referrer == "google.com",
order_by: [desc: e.inserted_at],
limit: 1
)
|> Repo.one()
assert event
assert event.referrer_source == "Google"
end
test "extracts UTM params", %{conn: conn} do
conn
|> put_req_header("user-agent", "Mozilla/5.0 Chrome/120.0")
|> get(~p"/?utm_source=newsletter&utm_medium=email&utm_campaign=spring")
send(Buffer, :flush)
:timer.sleep(50)
event =
from(e in Event,
where: e.utm_source == "newsletter",
order_by: [desc: e.inserted_at],
limit: 1
)
|> Repo.one()
assert event
assert event.utm_medium == "email"
assert event.utm_campaign == "spring"
end
end
end