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

284
lib/berrypod/analytics.ex Normal file
View File

@@ -0,0 +1,284 @@
defmodule Berrypod.Analytics do
@moduledoc """
Privacy-first analytics for the storefront.
Inspired by Plausible Analytics: no cookies, no personal data stored,
GDPR-friendly by default. Events are buffered in ETS and flushed to
SQLite in batches.
## Event types
- `pageview` — recorded by the plug (Layer 1) and LiveView hook (Layer 2)
- `product_view` — recorded when a product detail page is viewed
- `add_to_cart` — recorded when an item is added to the cart
- `checkout_start` — recorded when checkout is initiated
- `purchase` — recorded when a purchase is completed (includes revenue)
"""
import Ecto.Query
alias Berrypod.Repo
alias Berrypod.Analytics.{Buffer, Event}
# =====================================================================
# Event recording
# =====================================================================
@doc """
Records a pageview event via the buffer.
Expects a map with at minimum `:pathname` and `:visitor_hash`.
Other fields (referrer, UTMs, browser, os, country_code, screen_size)
are optional.
"""
def track_pageview(attrs) when is_map(attrs) do
attrs
|> Map.put(:name, "pageview")
|> Buffer.record()
end
@doc """
Records a named event via the buffer.
The `name` should be one of: `product_view`, `add_to_cart`,
`checkout_start`, `purchase`.
"""
def track_event(name, attrs) when is_binary(name) and is_map(attrs) do
attrs
|> Map.put(:name, name)
|> Buffer.record()
end
# =====================================================================
# Query helpers
# =====================================================================
@doc """
Counts unique visitors in the given date range.
"""
def count_visitors(date_range) do
base_query(date_range)
|> where([e], e.name == "pageview")
|> select([e], count(e.visitor_hash, :distinct))
|> Repo.one()
end
@doc """
Counts total pageviews in the given date range.
"""
def count_pageviews(date_range) do
base_query(date_range)
|> where([e], e.name == "pageview")
|> select([e], count())
|> Repo.one()
end
@doc """
Calculates bounce rate as a percentage (0-100).
Bounce = a session with only one pageview.
"""
def bounce_rate(date_range) do
sessions_query =
base_query(date_range)
|> where([e], e.name == "pageview")
|> group_by([e], e.session_hash)
|> select([e], %{
session_hash: e.session_hash,
pageviews: count()
})
result =
from(s in subquery(sessions_query),
select: %{
total: count(),
bounces: sum(fragment("CASE WHEN ? = 1 THEN 1 ELSE 0 END", s.pageviews))
}
)
|> Repo.one()
case result do
%{total: 0} -> 0
%{total: nil} -> 0
%{total: total, bounces: bounces} -> round(bounces / total * 100)
end
end
@doc """
Average visit duration in seconds.
"""
def avg_duration(date_range) do
durations_query =
base_query(date_range)
|> where([e], e.name == "pageview")
|> group_by([e], e.session_hash)
|> having([e], count() > 1)
|> select([e], %{
duration:
fragment(
"CAST(strftime('%s', MAX(?)) AS INTEGER) - CAST(strftime('%s', MIN(?)) AS INTEGER)",
e.inserted_at,
e.inserted_at
)
})
result =
from(d in subquery(durations_query),
select: fragment("COALESCE(AVG(?), 0)", d.duration)
)
|> Repo.one()
round(result)
end
@doc """
Daily visitor counts for the trend chart.
Returns a list of `%{date: ~D[], visitors: integer}` maps.
"""
def visitors_by_date(date_range) do
base_query(date_range)
|> where([e], e.name == "pageview")
|> group_by([e], fragment("date(?)", e.inserted_at))
|> select([e], %{
date: fragment("date(?)", e.inserted_at),
visitors: count(e.visitor_hash, :distinct)
})
|> order_by([e], fragment("date(?)", e.inserted_at))
|> Repo.all()
end
@doc """
Top pages by unique visitors.
"""
def top_pages(date_range, limit \\ 10) do
base_query(date_range)
|> where([e], e.name == "pageview")
|> group_by([e], e.pathname)
|> select([e], %{
pathname: e.pathname,
visitors: count(e.visitor_hash, :distinct),
pageviews: count()
})
|> order_by([e], desc: count(e.visitor_hash, :distinct))
|> limit(^limit)
|> Repo.all()
end
@doc """
Top referrer sources by unique visitors.
"""
def top_sources(date_range, limit \\ 10) do
base_query(date_range)
|> where([e], e.name == "pageview")
|> where([e], not is_nil(e.referrer_source))
|> group_by([e], e.referrer_source)
|> select([e], %{
source: e.referrer_source,
visitors: count(e.visitor_hash, :distinct)
})
|> order_by([e], desc: count(e.visitor_hash, :distinct))
|> limit(^limit)
|> Repo.all()
end
@doc """
Top referrer domains by unique visitors.
"""
def top_referrers(date_range, limit \\ 10) do
base_query(date_range)
|> where([e], e.name == "pageview")
|> where([e], not is_nil(e.referrer))
|> group_by([e], e.referrer)
|> select([e], %{
referrer: e.referrer,
visitors: count(e.visitor_hash, :distinct)
})
|> order_by([e], desc: count(e.visitor_hash, :distinct))
|> limit(^limit)
|> Repo.all()
end
@doc """
Country breakdown by unique visitors.
"""
def top_countries(date_range, limit \\ 10) do
base_query(date_range)
|> where([e], e.name == "pageview")
|> where([e], not is_nil(e.country_code))
|> group_by([e], e.country_code)
|> select([e], %{
country_code: e.country_code,
visitors: count(e.visitor_hash, :distinct)
})
|> order_by([e], desc: count(e.visitor_hash, :distinct))
|> limit(^limit)
|> Repo.all()
end
@doc """
Device breakdown by the given dimension (:browser, :os, or :screen_size).
"""
def device_breakdown(date_range, dimension) when dimension in [:browser, :os, :screen_size] do
field = dimension
base_query(date_range)
|> where([e], e.name == "pageview")
|> where([e], not is_nil(field(e, ^field)))
|> group_by([e], field(e, ^field))
|> select([e], %{
name: field(e, ^field),
visitors: count(e.visitor_hash, :distinct)
})
|> order_by([e], desc: count(e.visitor_hash, :distinct))
|> Repo.all()
end
@doc """
E-commerce funnel: counts for each step.
Returns `%{product_views: n, add_to_carts: n, checkouts: n, purchases: n}`.
"""
def funnel(date_range) do
counts =
base_query(date_range)
|> where([e], e.name in ["product_view", "add_to_cart", "checkout_start", "purchase"])
|> group_by([e], e.name)
|> select([e], {e.name, count(e.visitor_hash, :distinct)})
|> Repo.all()
|> Map.new()
%{
product_views: Map.get(counts, "product_view", 0),
add_to_carts: Map.get(counts, "add_to_cart", 0),
checkouts: Map.get(counts, "checkout_start", 0),
purchases: Map.get(counts, "purchase", 0)
}
end
@doc """
Total revenue in the given date range (pence).
"""
def total_revenue(date_range) do
base_query(date_range)
|> where([e], e.name == "purchase")
|> select([e], coalesce(sum(e.revenue), 0))
|> Repo.one()
end
@doc """
Deletes events older than the given datetime. Used by the retention worker.
"""
def delete_events_before(datetime) do
from(e in Event, where: e.inserted_at < ^datetime)
|> Repo.delete_all()
end
# ── Private ──
defp base_query({start_date, end_date}) do
from(e in Event,
where: e.inserted_at >= ^start_date and e.inserted_at < ^end_date
)
end
end

View File

@@ -0,0 +1,151 @@
defmodule Berrypod.Analytics.Buffer do
@moduledoc """
ETS-backed event buffer for analytics.
Events are written to ETS via `record/1` (fast, no DB round trip) and
flushed to SQLite every 10 seconds in a single `Repo.insert_all` call.
This prevents write contention on SQLite from individual event inserts.
Also tracks active sessions: events from the same visitor within 30 minutes
get the same `session_hash`. After 30 min of inactivity, a new session starts.
"""
use GenServer
alias Berrypod.Repo
alias Berrypod.Analytics.Event
@flush_interval_ms 10_000
@session_timeout_ms 30 * 60 * 1_000
@event_fields [
:id,
:name,
:pathname,
:visitor_hash,
:session_hash,
:referrer,
:referrer_source,
:utm_source,
:utm_medium,
:utm_campaign,
:country_code,
:screen_size,
:browser,
:os,
:revenue,
:inserted_at
]
@event_defaults Map.new(@event_fields, &{&1, nil})
# ── Public API ──
def start_link(opts \\ []) do
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
end
@doc """
Buffers an analytics event for batch writing.
Accepts a map of event attributes (must include `:visitor_hash` and `:pathname`).
Assigns a `session_hash` based on the visitor's active session.
Returns `:ok` immediately.
"""
def record(attrs) when is_map(attrs) do
GenServer.cast(__MODULE__, {:record, attrs})
end
# ── GenServer callbacks ──
@impl true
def init(_opts) do
table = :ets.new(:analytics_buffer, [:set, :private])
schedule_flush()
{:ok, %{table: table, counter: 0, sessions: %{}}}
end
@impl true
def handle_cast({:record, attrs}, state) do
now = DateTime.utc_now()
visitor_hash = Map.fetch!(attrs, :visitor_hash)
{session_hash, sessions} = resolve_session(visitor_hash, now, state.sessions)
event =
attrs
|> Map.put(:session_hash, session_hash)
|> Map.put(:id, Ecto.UUID.generate())
|> Map.put(:inserted_at, DateTime.truncate(now, :second))
counter = state.counter + 1
:ets.insert(state.table, {counter, event})
{:noreply, %{state | counter: counter, sessions: sessions}}
end
@impl true
def handle_info(:flush, state) do
state = flush_events(state)
schedule_flush()
{:noreply, state}
end
@impl true
def terminate(_reason, state) do
flush_events(state)
:ok
end
# ── Private ──
defp resolve_session(visitor_hash, now, sessions) do
now_ms = DateTime.to_unix(now, :millisecond)
case Map.get(sessions, visitor_hash) do
%{hash: hash, last_at: last_at} when now_ms - last_at < @session_timeout_ms ->
sessions = Map.put(sessions, visitor_hash, %{hash: hash, last_at: now_ms})
{hash, sessions}
_ ->
hash = :crypto.strong_rand_bytes(8)
sessions = Map.put(sessions, visitor_hash, %{hash: hash, last_at: now_ms})
{hash, sessions}
end
end
defp flush_events(state) do
events = drain_ets(state.table)
if events != [] do
rows =
Enum.map(events, fn {_key, event} ->
@event_defaults
|> Map.merge(Map.take(event, @event_fields))
|> Enum.into([])
end)
Repo.insert_all(Event, rows)
end
# Prune expired sessions
now_ms = DateTime.to_unix(DateTime.utc_now(), :millisecond)
sessions =
Map.filter(state.sessions, fn {_hash, %{last_at: last_at}} ->
now_ms - last_at < @session_timeout_ms
end)
%{state | counter: 0, sessions: sessions}
end
defp drain_ets(table) do
events = :ets.tab2list(table)
:ets.delete_all_objects(table)
events
end
defp schedule_flush do
Process.send_after(self(), :flush, @flush_interval_ms)
end
end

View File

@@ -0,0 +1,30 @@
defmodule Berrypod.Analytics.Event do
@moduledoc """
Schema for analytics events (pageviews, e-commerce events, etc.).
Events are immutable — inserted once, never updated.
"""
use Ecto.Schema
@primary_key {:id, :binary_id, autogenerate: true}
schema "analytics_events" do
field :name, :string
field :pathname, :string
field :visitor_hash, :binary
field :session_hash, :binary
field :referrer, :string
field :referrer_source, :string
field :utm_source, :string
field :utm_medium, :string
field :utm_campaign, :string
field :country_code, :string
field :screen_size, :string
field :browser, :string
field :os, :string
field :revenue, :integer
timestamps(type: :utc_datetime, updated_at: false)
end
end

View File

@@ -0,0 +1,72 @@
defmodule Berrypod.Analytics.Referrer do
@moduledoc """
Referrer cleaning and source categorisation.
Extracts the domain from a referrer URL and maps known domains to
human-readable source names (Google, Facebook, etc.).
"""
@source_map %{
"google" => "Google",
"bing" => "Bing",
"duckduckgo" => "DuckDuckGo",
"yahoo" => "Yahoo",
"baidu" => "Baidu",
"yandex" => "Yandex",
"ecosia" => "Ecosia",
"facebook" => "Facebook",
"instagram" => "Instagram",
"twitter" => "Twitter",
"x" => "Twitter",
"reddit" => "Reddit",
"linkedin" => "LinkedIn",
"pinterest" => "Pinterest",
"tiktok" => "TikTok",
"youtube" => "YouTube",
"t" => "Telegram"
}
@doc """
Parses a referrer URL into `{domain, source}`.
Returns `{nil, nil}` for empty/invalid referrers.
iex> Berrypod.Analytics.Referrer.parse("https://www.google.com/search?q=test")
{"google.com", "Google"}
iex> Berrypod.Analytics.Referrer.parse("https://myblog.example.com/post/1")
{"myblog.example.com", nil}
iex> Berrypod.Analytics.Referrer.parse(nil)
{nil, nil}
"""
@spec parse(String.t() | nil) :: {String.t() | nil, String.t() | nil}
def parse(nil), do: {nil, nil}
def parse(""), do: {nil, nil}
def parse(referrer) when is_binary(referrer) do
case URI.parse(referrer) do
%URI{host: host} when is_binary(host) and host != "" ->
domain = strip_www(host)
source = categorise(domain)
{domain, source}
_ ->
{nil, nil}
end
end
defp strip_www("www." <> rest), do: rest
defp strip_www(host), do: host
defp categorise(domain) do
# Extract the base name from the domain (e.g. "google" from "google.co.uk")
base =
domain
|> String.split(".")
|> List.first()
Map.get(@source_map, base)
end
end

View File

@@ -0,0 +1,30 @@
defmodule Berrypod.Analytics.RetentionWorker do
@moduledoc """
Oban worker that deletes analytics events older than the retention period.
Runs daily via cron. Default retention is 12 months.
"""
use Oban.Worker, queue: :default, max_attempts: 1
alias Berrypod.Analytics
@default_retention_months 12
@impl Oban.Worker
def perform(%Oban.Job{}) do
cutoff =
DateTime.utc_now()
|> DateTime.add(-@default_retention_months * 30, :day)
|> DateTime.truncate(:second)
{deleted, _} = Analytics.delete_events_before(cutoff)
if deleted > 0 do
require Logger
Logger.info("Analytics retention: deleted #{deleted} events older than #{cutoff}")
end
:ok
end
end

View File

@@ -0,0 +1,75 @@
defmodule Berrypod.Analytics.Salt do
@moduledoc """
Daily-rotating salt for privacy-friendly visitor hashing.
Generates a random 32-byte salt on startup and rotates it at midnight UTC.
The visitor_hash is SHA256(salt + IP + UA) truncated to 8 bytes — enough
for unique visitor counting without being reversible. Because the salt
changes daily, the same visitor gets a different hash each day.
"""
use GenServer
@salt_bytes 32
@hash_bytes 8
# ── Public API ──
def start_link(opts \\ []) do
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
end
@doc """
Hashes a visitor's IP and user agent into an 8-byte binary.
"""
def hash_visitor(ip, user_agent) when is_tuple(ip) do
hash_visitor(:inet.ntoa(ip) |> to_string(), user_agent)
end
def hash_visitor(ip, user_agent) when is_binary(ip) and is_binary(user_agent) do
salt = GenServer.call(__MODULE__, :get_salt)
:crypto.hash(:sha256, [salt, ip, user_agent])
|> binary_part(0, @hash_bytes)
end
# ── GenServer callbacks ──
@impl true
def init(_opts) do
schedule_rotation()
{:ok, %{salt: generate_salt()}}
end
@impl true
def handle_call(:get_salt, _from, state) do
{:reply, state.salt, state}
end
@impl true
def handle_info(:rotate, _state) do
schedule_rotation()
{:noreply, %{salt: generate_salt()}}
end
# ── Private ──
defp generate_salt, do: :crypto.strong_rand_bytes(@salt_bytes)
defp schedule_rotation do
ms_until_midnight = ms_until_next_midnight()
Process.send_after(self(), :rotate, ms_until_midnight)
end
defp ms_until_next_midnight do
now = DateTime.utc_now()
midnight =
now
|> DateTime.to_date()
|> Date.add(1)
|> DateTime.new!(~T[00:00:00], "Etc/UTC")
DateTime.diff(midnight, now, :millisecond)
end
end

View File

@@ -0,0 +1,75 @@
defmodule Berrypod.Analytics.UAParser do
@moduledoc """
Lightweight user agent parsing — extracts browser and OS names.
No external dependencies. Handles 95%+ of real-world traffic correctly.
Order matters: more specific checks (Edge before Chrome, iOS before macOS) come first.
"""
@doc """
Returns `{browser, os}` tuple from a user agent string.
iex> Berrypod.Analytics.UAParser.parse("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
{"Chrome", "macOS"}
"""
@spec parse(String.t()) :: {String.t(), String.t()}
def parse(ua) when is_binary(ua) do
{parse_browser(ua), parse_os(ua)}
end
def parse(_), do: {"Other", "Other"}
defp parse_browser(ua) do
cond do
String.contains?(ua, "bot") or String.contains?(ua, "Bot") or
String.contains?(ua, "crawl") or String.contains?(ua, "Crawl") or
String.contains?(ua, "spider") or String.contains?(ua, "Spider") ->
"Bot"
String.contains?(ua, "Firefox/") ->
"Firefox"
String.contains?(ua, "Edg/") ->
"Edge"
String.contains?(ua, "OPR/") or String.contains?(ua, "Opera") ->
"Opera"
String.contains?(ua, "Chrome/") ->
"Chrome"
String.contains?(ua, "Safari/") ->
"Safari"
true ->
"Other"
end
end
defp parse_os(ua) do
cond do
String.contains?(ua, "iPhone") or String.contains?(ua, "iPad") or
String.contains?(ua, "iPod") ->
"iOS"
String.contains?(ua, "Android") ->
"Android"
String.contains?(ua, "Mac OS X") or String.contains?(ua, "Macintosh") ->
"macOS"
String.contains?(ua, "Windows") ->
"Windows"
String.contains?(ua, "Linux") ->
"Linux"
String.contains?(ua, "CrOS") ->
"ChromeOS"
true ->
"Other"
end
end
end

View File

@@ -22,6 +22,9 @@ defmodule Berrypod.Application do
{Phoenix.PubSub, name: Berrypod.PubSub},
# Background job processing
{Oban, Application.fetch_env!(:berrypod, Oban)},
# Analytics: daily-rotating salt and ETS event buffer
Berrypod.Analytics.Salt,
Berrypod.Analytics.Buffer,
# Image variant cache - ensures all variants exist on startup
Berrypod.Images.VariantCache,
# Start to serve requests

View File

@@ -0,0 +1,100 @@
defmodule BerrypodWeb.AnalyticsHook do
@moduledoc """
LiveView on_mount hook for analytics — Layer 2 of the progressive pipeline.
Reads analytics data from the session (set by the Plugs.Analytics plug) and
tracks subsequent SPA navigations via handle_params. Skips the initial mount
since the plug already recorded that pageview.
Also handles the `analytics:screen` event from the JS hook (Layer 3) to
capture screen width for device classification.
"""
import Phoenix.Component, only: [assign: 3]
import Phoenix.LiveView, only: [attach_hook: 4, connected?: 1]
alias Berrypod.Analytics
def on_mount(:track, _params, session, socket) do
socket =
socket
|> assign(:analytics_visitor_hash, session["analytics_visitor_hash"])
|> assign(:analytics_browser, session["analytics_browser"])
|> assign(:analytics_os, session["analytics_os"])
|> assign(:analytics_referrer, session["analytics_referrer"])
|> assign(:analytics_referrer_source, session["analytics_referrer_source"])
|> assign(:analytics_utm_source, session["analytics_utm_source"])
|> assign(:analytics_utm_medium, session["analytics_utm_medium"])
|> assign(:analytics_utm_campaign, session["analytics_utm_campaign"])
|> assign(:analytics_screen_size, session["analytics_screen_size"])
|> assign(:analytics_country_code, session["country_code"])
|> assign(:analytics_initial_mount, true)
socket =
if connected?(socket) and socket.assigns.analytics_visitor_hash do
socket
|> attach_hook(:analytics_params, :handle_params, &handle_analytics_params/3)
|> attach_hook(:analytics_events, :handle_event, &handle_analytics_event/3)
else
socket
end
{:cont, socket}
end
defp handle_analytics_params(_params, uri, socket) do
# Skip the initial mount — the plug already recorded this pageview
if socket.assigns.analytics_initial_mount do
{:cont, assign(socket, :analytics_initial_mount, false)}
else
# Skip if admin user is browsing
if admin?(socket) do
{:cont, socket}
else
pathname = URI.parse(uri).path
Analytics.track_pageview(%{
pathname: pathname,
visitor_hash: socket.assigns.analytics_visitor_hash,
referrer: socket.assigns.analytics_referrer,
referrer_source: socket.assigns.analytics_referrer_source,
utm_source: socket.assigns.analytics_utm_source,
utm_medium: socket.assigns.analytics_utm_medium,
utm_campaign: socket.assigns.analytics_utm_campaign,
country_code: socket.assigns.analytics_country_code,
screen_size: socket.assigns.analytics_screen_size,
browser: socket.assigns.analytics_browser,
os: socket.assigns.analytics_os
})
# Clear referrer/UTMs after first SPA navigation — they only apply to the entry
{:cont,
socket
|> assign(:analytics_referrer, nil)
|> assign(:analytics_referrer_source, nil)
|> assign(:analytics_utm_source, nil)
|> assign(:analytics_utm_medium, nil)
|> assign(:analytics_utm_campaign, nil)}
end
end
end
defp handle_analytics_event("analytics:screen", %{"width" => width}, socket)
when is_integer(width) do
screen_size = classify_screen(width)
{:cont, assign(socket, :analytics_screen_size, screen_size)}
end
defp handle_analytics_event(_event, _params, socket), do: {:cont, socket}
defp classify_screen(width) when width < 768, do: "mobile"
defp classify_screen(width) when width < 1024, do: "tablet"
defp classify_screen(_width), do: "desktop"
defp admin?(socket) do
case socket.assigns[:current_scope] do
%{user: %{}} -> true
_ -> false
end
end
end

View File

@@ -62,6 +62,14 @@
<.icon name="hero-home" class="size-5" /> Dashboard
</.link>
</li>
<li>
<.link
navigate={~p"/admin/analytics"}
class={admin_nav_active?(@current_path, "/admin/analytics")}
>
<.icon name="hero-chart-bar" class="size-5" /> Analytics
</.link>
</li>
<li>
<.link
navigate={~p"/admin/orders"}

View File

@@ -1,2 +1,3 @@
<.shop_flash_group flash={@flash} />
<div id="analytics-init" phx-hook="AnalyticsInit" phx-update="ignore" style="display:none"></div>
{@inner_content}

View File

@@ -1,7 +1,7 @@
defmodule BerrypodWeb.CheckoutController do
use BerrypodWeb, :controller
alias Berrypod.Cart
alias Berrypod.{Analytics, Cart}
alias Berrypod.Orders
alias Berrypod.Shipping
@@ -16,6 +16,7 @@ defmodule BerrypodWeb.CheckoutController do
|> put_flash(:error, "Your basket is empty")
|> redirect(to: ~p"/cart")
else
track_checkout_start(conn)
create_checkout(conn, hydrated)
end
end
@@ -126,4 +127,15 @@ defmodule BerrypodWeb.CheckoutController do
end
defp maybe_add_option(options, _result, _name, _min, _max), do: options
defp track_checkout_start(conn) do
visitor_hash = get_session(conn, "analytics_visitor_hash")
if visitor_hash do
Analytics.track_event("checkout_start", %{
pathname: "/checkout",
visitor_hash: visitor_hash
})
end
end
end

View File

@@ -0,0 +1,459 @@
defmodule BerrypodWeb.Admin.Analytics do
use BerrypodWeb, :live_view
alias Berrypod.Analytics
alias Berrypod.Cart
@periods %{
"today" => 0,
"7d" => 6,
"30d" => 29,
"12m" => 364
}
@impl true
def mount(_params, _session, socket) do
{:ok,
socket
|> assign(:page_title, "Analytics")
|> assign(:period, "30d")
|> assign(:tab, "pages")
|> load_analytics("30d")}
end
@impl true
def handle_event("change_period", %{"period" => period}, socket)
when is_map_key(@periods, period) do
{:noreply,
socket
|> assign(:period, period)
|> load_analytics(period)}
end
def handle_event("change_tab", %{"tab" => tab}, socket)
when tab in ["pages", "sources", "countries", "devices", "funnel"] do
{:noreply, assign(socket, :tab, tab)}
end
# ── Data loading ──
defp load_analytics(socket, period) do
range = date_range(period)
socket
|> assign(:visitors, Analytics.count_visitors(range))
|> assign(:pageviews, Analytics.count_pageviews(range))
|> assign(:bounce_rate, Analytics.bounce_rate(range))
|> assign(:avg_duration, Analytics.avg_duration(range))
|> assign(:visitors_by_date, Analytics.visitors_by_date(range))
|> assign(:top_pages, Analytics.top_pages(range))
|> assign(:top_sources, Analytics.top_sources(range))
|> assign(:top_referrers, Analytics.top_referrers(range))
|> assign(:top_countries, Analytics.top_countries(range))
|> assign(:browsers, Analytics.device_breakdown(range, :browser))
|> assign(:oses, Analytics.device_breakdown(range, :os))
|> assign(:screen_sizes, Analytics.device_breakdown(range, :screen_size))
|> assign(:funnel, Analytics.funnel(range))
|> assign(:revenue, Analytics.total_revenue(range))
end
defp date_range(period) do
days = Map.fetch!(@periods, period)
today = Date.utc_today()
start_date = Date.add(today, -days)
end_date = Date.add(today, 1)
{DateTime.new!(start_date, ~T[00:00:00], "Etc/UTC"),
DateTime.new!(end_date, ~T[00:00:00], "Etc/UTC")}
end
# ── Render ──
@impl true
def render(assigns) do
~H"""
<.header>Analytics</.header>
<%!-- Period selector --%>
<div class="analytics-periods" style="display: flex; gap: 0.25rem; margin-top: 1rem;">
<button
:for={period <- ["today", "7d", "30d", "12m"]}
phx-click="change_period"
phx-value-period={period}
class={["admin-btn admin-btn-sm", @period == period && "admin-btn-primary"]}
>
{period_label(period)}
</button>
</div>
<%!-- Stat cards --%>
<div class="admin-stats-grid" style="margin-top: 1.5rem;">
<.stat_card label="Unique visitors" value={format_number(@visitors)} icon="hero-users" />
<.stat_card label="Total pageviews" value={format_number(@pageviews)} icon="hero-eye" />
<.stat_card label="Bounce rate" value={"#{@bounce_rate}%"} icon="hero-arrow-uturn-left" />
<.stat_card label="Visit duration" value={format_duration(@avg_duration)} icon="hero-clock" />
</div>
<%!-- Visitor trend chart --%>
<div class="admin-card" style="margin-top: 1.5rem; padding: 1rem;">
<h3 style="font-size: 0.875rem; font-weight: 600; margin-bottom: 0.75rem;">
Visitors over time
</h3>
<.bar_chart data={@visitors_by_date} />
</div>
<%!-- Detail tabs --%>
<div style="display: flex; gap: 0.25rem; margin-top: 1.5rem; flex-wrap: wrap;">
<button
:for={
tab <- [
{"pages", "Pages"},
{"sources", "Sources"},
{"countries", "Countries"},
{"devices", "Devices"},
{"funnel", "Funnel"}
]
}
phx-click="change_tab"
phx-value-tab={elem(tab, 0)}
class={["admin-btn admin-btn-sm", @tab == elem(tab, 0) && "admin-btn-primary"]}
>
{elem(tab, 1)}
</button>
</div>
<%!-- Tab content --%>
<div class="admin-card" style="margin-top: 0.75rem; padding: 1rem;">
<.tab_content tab={@tab} {assigns} />
</div>
"""
end
# ── Stat card component ──
attr :label, :string, required: true
attr :value, :any, required: true
attr :icon, :string, required: true
defp stat_card(assigns) do
~H"""
<div class="admin-card">
<div style="display: flex; align-items: center; gap: 0.75rem; padding: 1rem;">
<div style="background: var(--color-base-200, #e5e5e5); border-radius: 0.5rem; padding: 0.5rem;">
<.icon name={@icon} class="size-5" />
</div>
<div>
<p style="font-size: 1.5rem; font-weight: 700;">{@value}</p>
<p style="font-size: 0.875rem; color: color-mix(in oklch, var(--color-base-content) 60%, transparent);">
{@label}
</p>
</div>
</div>
</div>
"""
end
# ── Bar chart (server-rendered SVG) ──
attr :data, :list, required: true
defp bar_chart(assigns) do
data = assigns.data
max_val = data |> Enum.map(& &1.visitors) |> Enum.max(fn -> 1 end)
chart_height = 120
bar_count = max(length(data), 1)
bars =
data
|> Enum.with_index()
|> Enum.map(fn {%{date: date, visitors: visitors}, i} ->
bar_height = if max_val > 0, do: visitors / max_val * chart_height, else: 0
bar_width = max(800 / bar_count - 2, 1)
x = i * (800 / bar_count) + 1
%{
x: x,
y: chart_height - bar_height,
width: bar_width,
height: max(bar_height, 1),
date: date,
visitors: visitors
}
end)
assigns = assign(assigns, bars: bars, chart_height: chart_height)
~H"""
<div
:if={@data == []}
style="text-align: center; padding: 2rem; color: color-mix(in oklch, var(--color-base-content) 40%, transparent);"
>
No data for this period
</div>
<svg
:if={@data != []}
viewBox={"0 0 800 #{@chart_height}"}
style="width: 100%; height: auto; max-height: 160px;"
aria-label="Visitor trend chart"
>
<rect
:for={bar <- @bars}
x={bar.x}
y={bar.y}
width={bar.width}
height={bar.height}
rx="2"
fill="var(--color-primary, #4f46e5)"
opacity="0.8"
>
<title>{bar.date}: {bar.visitors} visitors</title>
</rect>
</svg>
"""
end
# ── Tab content ──
defp tab_content(%{tab: "pages"} = assigns) do
~H"""
<h3 style="font-size: 0.875rem; font-weight: 600; margin-bottom: 0.75rem;">Top pages</h3>
<.detail_table
rows={@top_pages}
empty_message="No page data yet"
columns={[
%{label: "Page", key: :pathname},
%{label: "Visitors", key: :visitors, align: :right},
%{label: "Pageviews", key: :pageviews, align: :right}
]}
/>
"""
end
defp tab_content(%{tab: "sources"} = assigns) do
~H"""
<h3 style="font-size: 0.875rem; font-weight: 600; margin-bottom: 0.75rem;">Top sources</h3>
<.detail_table
rows={@top_sources}
empty_message="No referrer data yet"
columns={[
%{label: "Source", key: :source},
%{label: "Visitors", key: :visitors, align: :right}
]}
/>
<h3 style="font-size: 0.875rem; font-weight: 600; margin: 1.5rem 0 0.75rem;">Top referrers</h3>
<.detail_table
rows={@top_referrers}
empty_message="No referrer data yet"
columns={[
%{label: "Referrer", key: :referrer},
%{label: "Visitors", key: :visitors, align: :right}
]}
/>
"""
end
defp tab_content(%{tab: "countries"} = assigns) do
~H"""
<h3 style="font-size: 0.875rem; font-weight: 600; margin-bottom: 0.75rem;">Countries</h3>
<.detail_table
rows={Enum.map(@top_countries, fn c -> %{c | country_code: country_name(c.country_code)} end)}
empty_message="No country data yet"
columns={[
%{label: "Country", key: :country_code},
%{label: "Visitors", key: :visitors, align: :right}
]}
/>
"""
end
defp tab_content(%{tab: "devices"} = assigns) do
~H"""
<h3 style="font-size: 0.875rem; font-weight: 600; margin-bottom: 0.75rem;">Browsers</h3>
<.detail_table
rows={@browsers}
empty_message="No browser data yet"
columns={[
%{label: "Browser", key: :name},
%{label: "Visitors", key: :visitors, align: :right}
]}
/>
<h3 style="font-size: 0.875rem; font-weight: 600; margin: 1.5rem 0 0.75rem;">
Operating systems
</h3>
<.detail_table
rows={@oses}
empty_message="No OS data yet"
columns={[
%{label: "OS", key: :name},
%{label: "Visitors", key: :visitors, align: :right}
]}
/>
<h3 style="font-size: 0.875rem; font-weight: 600; margin: 1.5rem 0 0.75rem;">Screen sizes</h3>
<.detail_table
rows={@screen_sizes}
empty_message="No screen data yet"
columns={[
%{label: "Size", key: :name},
%{label: "Visitors", key: :visitors, align: :right}
]}
/>
"""
end
defp tab_content(%{tab: "funnel"} = assigns) do
~H"""
<h3 style="font-size: 0.875rem; font-weight: 600; margin-bottom: 0.75rem;">
Conversion funnel
</h3>
<.funnel_chart funnel={@funnel} revenue={@revenue} />
"""
end
# ── Detail table ──
attr :rows, :list, required: true
attr :columns, :list, required: true
attr :empty_message, :string, default: "No data"
defp detail_table(assigns) do
~H"""
<div
:if={@rows == []}
style="text-align: center; padding: 1.5rem; color: color-mix(in oklch, var(--color-base-content) 40%, transparent);"
>
{@empty_message}
</div>
<table :if={@rows != []} class="admin-table" style="width: 100%;">
<thead>
<tr>
<th
:for={col <- @columns}
style={col[:align] == :right && "text-align: right;"}
>
{col.label}
</th>
</tr>
</thead>
<tbody>
<tr :for={row <- @rows}>
<td
:for={col <- @columns}
style={col[:align] == :right && "text-align: right; font-variant-numeric: tabular-nums;"}
>
{Map.get(row, col.key)}
</td>
</tr>
</tbody>
</table>
"""
end
# ── Funnel chart ──
attr :funnel, :map, required: true
attr :revenue, :integer, required: true
defp funnel_chart(assigns) do
steps = [
{"Product views", assigns.funnel.product_views},
{"Add to cart", assigns.funnel.add_to_carts},
{"Checkout", assigns.funnel.checkouts},
{"Purchase", assigns.funnel.purchases}
]
max_val = steps |> Enum.map(&elem(&1, 1)) |> Enum.max(fn -> 1 end)
steps_with_rates =
steps
|> Enum.with_index()
|> Enum.map(fn {{label, count}, i} ->
prev_count = if i > 0, do: elem(Enum.at(steps, i - 1), 1), else: count
rate = if prev_count > 0, do: round(count / prev_count * 100), else: 0
width_pct = if max_val > 0, do: max(count / max_val * 100, 5), else: 5
%{label: label, count: count, rate: rate, width_pct: width_pct, index: i}
end)
assigns = assign(assigns, steps: steps_with_rates)
~H"""
<div
:if={@funnel.product_views == 0}
style="text-align: center; padding: 1.5rem; color: color-mix(in oklch, var(--color-base-content) 40%, transparent);"
>
No funnel data yet
</div>
<div :if={@funnel.product_views > 0} style="display: flex; flex-direction: column; gap: 0.5rem;">
<div :for={step <- @steps} style="display: flex; align-items: center; gap: 0.75rem;">
<div style="width: 7rem; font-size: 0.8125rem; text-align: right; flex-shrink: 0;">
{step.label}
</div>
<div style={"flex: 0 0 #{step.width_pct}%; height: 2rem; background: var(--color-primary, #4f46e5); border-radius: 0.25rem; opacity: #{1 - step.index * 0.15}; display: flex; align-items: center; padding-left: 0.5rem;"}>
<span style="font-size: 0.75rem; font-weight: 600; color: white;">
{step.count}
</span>
</div>
<span
:if={step.index > 0}
style="font-size: 0.75rem; color: color-mix(in oklch, var(--color-base-content) 60%, transparent);"
>
{step.rate}%
</span>
</div>
<div :if={@revenue > 0} style="margin-top: 0.5rem; font-size: 0.875rem; font-weight: 600;">
Revenue: {Cart.format_price(@revenue)}
</div>
</div>
"""
end
# ── Helpers ──
defp period_label("today"), do: "Today"
defp period_label("7d"), do: "7 days"
defp period_label("30d"), do: "30 days"
defp period_label("12m"), do: "12 months"
defp format_number(n) when n >= 1_000_000, do: "#{Float.round(n / 1_000_000, 1)}M"
defp format_number(n) when n >= 1_000, do: "#{Float.round(n / 1_000, 1)}k"
defp format_number(n), do: to_string(n)
defp format_duration(seconds) when seconds < 60, do: "#{seconds}s"
defp format_duration(seconds) do
mins = div(seconds, 60)
secs = rem(seconds, 60)
"#{mins}m #{secs}s"
end
@country_names %{
"GB" => "United Kingdom",
"US" => "United States",
"CA" => "Canada",
"AU" => "Australia",
"DE" => "Germany",
"FR" => "France",
"NL" => "Netherlands",
"IE" => "Ireland",
"AT" => "Austria",
"BE" => "Belgium",
"IT" => "Italy",
"ES" => "Spain",
"PT" => "Portugal",
"SE" => "Sweden",
"NO" => "Norway",
"DK" => "Denmark",
"FI" => "Finland",
"PL" => "Poland",
"CH" => "Switzerland",
"NZ" => "New Zealand",
"JP" => "Japan",
"IN" => "India",
"BR" => "Brazil",
"MX" => "Mexico"
}
defp country_name(code) do
Map.get(@country_names, code, code)
end
end

View File

@@ -1,7 +1,7 @@
defmodule BerrypodWeb.Shop.CheckoutSuccess do
use BerrypodWeb, :live_view
alias Berrypod.Orders
alias Berrypod.{Analytics, Orders}
@impl true
def mount(%{"session_id" => session_id}, _session, socket) do
@@ -12,6 +12,15 @@ defmodule BerrypodWeb.Shop.CheckoutSuccess do
Phoenix.PubSub.subscribe(Berrypod.PubSub, "order:#{order.id}:status")
end
# Track purchase event
if order && connected?(socket) && socket.assigns[:analytics_visitor_hash] do
Analytics.track_event("purchase", %{
pathname: "/checkout/success",
visitor_hash: socket.assigns.analytics_visitor_hash,
revenue: order.total
})
end
# Clear the cart after successful checkout
socket =
if order && connected?(socket) do

View File

@@ -1,7 +1,7 @@
defmodule BerrypodWeb.Shop.ProductShow do
use BerrypodWeb, :live_view
alias Berrypod.Cart
alias Berrypod.{Analytics, Cart}
alias Berrypod.Images.Optimizer
alias Berrypod.Products
alias Berrypod.Products.{Product, ProductImage}
@@ -41,6 +41,13 @@ defmodule BerrypodWeb.Shop.ProductShow do
display_price = variant_price(selected_variant, product)
gallery_images = filter_gallery_images(all_images, selected_options["Color"])
if connected?(socket) and socket.assigns[:analytics_visitor_hash] do
Analytics.track_event("product_view", %{
pathname: "/products/#{slug}",
visitor_hash: socket.assigns.analytics_visitor_hash
})
end
socket =
socket
|> assign(:page_title, product.title)
@@ -177,6 +184,13 @@ defmodule BerrypodWeb.Shop.ProductShow do
if variant do
cart = Cart.add_item(socket.assigns.raw_cart, variant.id, socket.assigns.quantity)
if socket.assigns[:analytics_visitor_hash] do
Analytics.track_event("add_to_cart", %{
pathname: "/products/#{socket.assigns.product.slug}",
visitor_hash: socket.assigns.analytics_visitor_hash
})
end
socket =
socket
|> BerrypodWeb.CartHook.broadcast_and_update(cart)

View File

@@ -0,0 +1,112 @@
defmodule BerrypodWeb.Plugs.Analytics do
@moduledoc """
Plug that records a pageview event on every shop HTTP request.
This is Layer 1 of the progressive analytics pipeline — it fires on every
GET request regardless of whether JS is enabled. Computes a privacy-friendly
visitor hash, parses the user agent, extracts referrer and UTM params, and
buffers the event for batch writing to SQLite.
Also stores analytics data in the session so the LiveView hook (Layer 2)
can read it for subsequent SPA navigations.
"""
import Plug.Conn
alias Berrypod.Analytics
alias Berrypod.Analytics.{Salt, UAParser, Referrer}
def init(opts), do: opts
def call(%{method: "GET"} = conn, _opts) do
ua = get_req_header(conn, "user-agent") |> List.first("")
{browser, os} = UAParser.parse(ua)
# Skip bots
if browser == "Bot" do
conn
else
# Skip if the logged-in admin is browsing
if admin?(conn) do
prepare_session(conn, ua, browser, os)
else
record_and_prepare(conn, ua, browser, os)
end
end
end
def call(conn, _opts), do: conn
defp record_and_prepare(conn, ua, browser, os) do
visitor_hash = Salt.hash_visitor(conn.remote_ip, ua)
{referrer, referrer_source} = extract_referrer(conn)
utm = extract_utms(conn)
country_code = get_session(conn, "country_code")
screen_size = get_session(conn, "analytics_screen_size")
Analytics.track_pageview(%{
pathname: conn.request_path,
visitor_hash: visitor_hash,
referrer: referrer,
referrer_source: referrer_source,
utm_source: utm.source,
utm_medium: utm.medium,
utm_campaign: utm.campaign,
country_code: country_code,
screen_size: screen_size,
browser: browser,
os: os
})
conn
|> put_session("analytics_visitor_hash", visitor_hash)
|> put_session("analytics_browser", browser)
|> put_session("analytics_os", os)
|> put_session("analytics_referrer", referrer)
|> put_session("analytics_referrer_source", referrer_source)
|> put_session("analytics_utm_source", utm.source)
|> put_session("analytics_utm_medium", utm.medium)
|> put_session("analytics_utm_campaign", utm.campaign)
end
# Store session data for the hook even when we skip recording for admins
defp prepare_session(conn, ua, browser, os) do
visitor_hash = Salt.hash_visitor(conn.remote_ip, ua)
conn
|> put_session("analytics_visitor_hash", visitor_hash)
|> put_session("analytics_browser", browser)
|> put_session("analytics_os", os)
end
defp admin?(conn) do
case conn.assigns[:current_scope] do
%{user: %{}} -> true
_ -> false
end
end
defp extract_referrer(conn) do
referrer_url = get_req_header(conn, "referer") |> List.first()
{referrer, source} = Referrer.parse(referrer_url)
# Don't count self-referrals
host = conn.host
if referrer && referrer == host do
{nil, nil}
else
{referrer, source}
end
end
defp extract_utms(conn) do
params = conn.query_params || %{}
%{
source: Map.get(params, "utm_source"),
medium: Map.get(params, "utm_medium"),
campaign: Map.get(params, "utm_campaign")
}
end
end

View File

@@ -37,6 +37,7 @@ defmodule BerrypodWeb.Router do
pipeline :shop do
plug :put_root_layout, html: {BerrypodWeb.Layouts, :shop_root}
plug BerrypodWeb.Plugs.LoadTheme
plug BerrypodWeb.Plugs.Analytics
end
pipeline :admin do
@@ -63,7 +64,8 @@ defmodule BerrypodWeb.Router do
{BerrypodWeb.ThemeHook, :mount_theme},
{BerrypodWeb.ThemeHook, :require_site_live},
{BerrypodWeb.CartHook, :mount_cart},
{BerrypodWeb.SearchHook, :mount_search}
{BerrypodWeb.SearchHook, :mount_search},
{BerrypodWeb.AnalyticsHook, :track}
] do
live "/", Shop.Home, :index
live "/about", Shop.Content, :about
@@ -170,6 +172,7 @@ defmodule BerrypodWeb.Router do
{BerrypodWeb.AdminLayoutHook, :assign_current_path}
] do
live "/", Admin.Dashboard, :index
live "/analytics", Admin.Analytics, :index
live "/orders", Admin.Orders, :index
live "/orders/:id", Admin.OrderShow, :show
live "/products", Admin.Products, :index