add privacy-first analytics with progressive event collection
All checks were successful
deploy / deploy (push) Successful in 3m20s
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:
112
lib/berrypod_web/plugs/analytics.ex
Normal file
112
lib/berrypod_web/plugs/analytics.ex
Normal 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
|
||||
Reference in New Issue
Block a user