berrypod/lib/berrypod_web/plugs/analytics.ex
jamey 2bd2e613c7
All checks were successful
deploy / deploy (push) Successful in 3m20s
add privacy-first analytics with progressive event collection
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>
2026-02-22 12:50:55 +00:00

113 lines
3.2 KiB
Elixir

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