replace analytics double-count prevention with buffer supersede
All checks were successful
deploy / deploy (push) Successful in 1m13s

The Plug records a pageview with a known ID (plug_ref) into the ETS
buffer. When JS connects, the LiveView hook supersedes that event by
ID and records its own with full data (screen_size from connect params).
If JS never connects, the Plug's event flushes normally after 10s.

Also fixes: admin browsing no longer leaks product_view events — the
Plug now sets no analytics session data for admins, so all downstream
visitor_hash guards naturally filter them out.

Replaces the previous time-based skip logic which was brittle and
race-prone. The supersede approach is deterministic and handles both
the ETS buffer and already-flushed DB cases.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
jamey 2026-02-23 14:48:50 +00:00
parent 7ceee9c814
commit 162a5bfe9a
6 changed files with 231 additions and 68 deletions

View File

@ -591,7 +591,7 @@ const ChartTooltip = {
const csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content") const csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")
const liveSocket = new LiveSocket("/live", Socket, { const liveSocket = new LiveSocket("/live", Socket, {
params: {_csrf_token: csrfToken}, params: {_csrf_token: csrfToken, screen_width: window.innerWidth},
hooks: {...colocatedHooks, ColorSync, Lightbox, CartPersist, CartDrawer, ProductImageScroll, SearchModal, CollectionFilters, CardRadioScroll, AnalyticsInit, ChartTooltip}, hooks: {...colocatedHooks, ColorSync, Lightbox, CartPersist, CartDrawer, ProductImageScroll, SearchModal, CollectionFilters, CardRadioScroll, AnalyticsInit, ChartTooltip},
}) })

View File

@ -55,6 +55,20 @@ defmodule Berrypod.Analytics.Buffer do
GenServer.cast(__MODULE__, {:record, attrs}) GenServer.cast(__MODULE__, {:record, attrs})
end end
@doc """
Removes the Plug's initial pageview so the hook can replace it.
Looks in the ETS buffer first. If the event was already flushed to the DB
(the 10s timer can fire between Plug record and hook connect), deletes it
from there instead. Either way, the hook then records its own event with
full data (screen_size etc.).
If JS never connects, the Plug's event stays and flushes normally.
"""
def supersede(ref) when is_binary(ref) do
GenServer.cast(__MODULE__, {:supersede, ref})
end
# ── GenServer callbacks ── # ── GenServer callbacks ──
@impl true @impl true
@ -75,7 +89,7 @@ defmodule Berrypod.Analytics.Buffer do
event = event =
attrs attrs
|> Map.put(:session_hash, session_hash) |> Map.put(:session_hash, session_hash)
|> Map.put(:id, Ecto.UUID.generate()) |> Map.put_new(:id, Ecto.UUID.generate())
|> Map.put(:inserted_at, DateTime.truncate(now, :second)) |> Map.put(:inserted_at, DateTime.truncate(now, :second))
counter = state.counter + 1 counter = state.counter + 1
@ -84,6 +98,29 @@ defmodule Berrypod.Analytics.Buffer do
{:noreply, %{state | counter: counter, sessions: sessions}} {:noreply, %{state | counter: counter, sessions: sessions}}
end end
@impl true
def handle_cast({:supersede, ref}, state) do
found_in_ets =
state.table
|> :ets.tab2list()
|> Enum.reduce(false, fn {key, event}, found ->
if Map.get(event, :id) == ref do
:ets.delete(state.table, key)
true
else
found
end
end)
# If the 10s flush already moved it to the DB, delete it there
unless found_in_ets do
import Ecto.Query
Repo.delete_all(from(e in Event, where: e.id == ^ref))
end
{:noreply, state}
end
@impl true @impl true
def handle_info(:flush, state) do def handle_info(:flush, state) do
state = flush_events(state) state = flush_events(state)

View File

@ -1,19 +1,25 @@
defmodule BerrypodWeb.AnalyticsHook do defmodule BerrypodWeb.AnalyticsHook do
@moduledoc """ @moduledoc """
LiveView on_mount hook for analytics Layer 2 of the progressive pipeline. LiveView on_mount hook for analytics.
Reads analytics data from the session (set by the Plugs.Analytics plug) and Reads session data prepared by the Plugs.Analytics plug (visitor hash,
tracks subsequent SPA navigations via handle_params. Skips the initial mount browser, OS, referrer, UTMs) and records pageviews for SPA navigations.
since the plug already recorded that pageview.
Also handles the `analytics:screen` event from the JS hook (Layer 3) to The Plug records an initial pageview into the ETS buffer with a unique
capture screen width for device classification. `plug_ref`. When JS connects, this hook tells the buffer to drop that
event (by ref) and records its own pageview with full data (screen_size).
If JS never connects (no-JS user), the Plug's event flushes to the DB
after the normal 10-second buffer interval. No timing heuristics needed.
Screen width is read from the LiveSocket connect params, so it's available
on every LiveView mount without relying on a separate JS hook event.
""" """
import Phoenix.Component, only: [assign: 3] import Phoenix.Component, only: [assign: 3]
import Phoenix.LiveView, only: [attach_hook: 4, connected?: 1] import Phoenix.LiveView, only: [attach_hook: 4, connected?: 1, get_connect_params: 1]
alias Berrypod.Analytics alias Berrypod.Analytics
alias Berrypod.Analytics.Buffer
def on_mount(:track, _params, session, socket) do def on_mount(:track, _params, session, socket) do
socket = socket =
@ -26,56 +32,71 @@ defmodule BerrypodWeb.AnalyticsHook do
|> assign(:analytics_utm_source, session["analytics_utm_source"]) |> assign(:analytics_utm_source, session["analytics_utm_source"])
|> assign(:analytics_utm_medium, session["analytics_utm_medium"]) |> assign(:analytics_utm_medium, session["analytics_utm_medium"])
|> assign(:analytics_utm_campaign, session["analytics_utm_campaign"]) |> 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_country_code, session["country_code"])
|> assign(:analytics_initial_mount, true) |> assign(:analytics_plug_ref, session["analytics_plug_ref"])
socket = socket =
if connected?(socket) and socket.assigns.analytics_visitor_hash do if connected?(socket) do
# Supersede the Plug's buffered event — we'll record our own with screen_size
if plug_ref = socket.assigns[:analytics_plug_ref] do
Buffer.supersede(plug_ref)
end
screen_size = screen_size_from_connect_params(socket)
socket socket
|> attach_hook(:analytics_params, :handle_params, &handle_analytics_params/3) |> assign(:analytics_screen_size, screen_size)
|> assign(:analytics_plug_ref, nil)
|> attach_hook(:analytics_events, :handle_event, &handle_analytics_event/3) |> attach_hook(:analytics_events, :handle_event, &handle_analytics_event/3)
|> then(fn s ->
if s.assigns.analytics_visitor_hash do
attach_hook(s, :analytics_params, :handle_params, &handle_analytics_params/3)
else
s
end
end)
else else
socket assign(socket, :analytics_screen_size, nil)
end end
{:cont, socket} {:cont, socket}
end end
defp screen_size_from_connect_params(socket) do
case get_connect_params(socket) do
%{"screen_width" => width} when is_integer(width) -> classify_screen(width)
_ -> nil
end
end
defp handle_analytics_params(_params, uri, socket) do defp handle_analytics_params(_params, uri, socket) do
# Skip the initial mount — the plug already recorded this pageview if admin?(socket) do
if socket.assigns.analytics_initial_mount do {:cont, socket}
{:cont, assign(socket, :analytics_initial_mount, false)}
else else
# Skip if admin user is browsing pathname = URI.parse(uri).path
if admin?(socket) do
{:cont, socket}
else
pathname = URI.parse(uri).path
Analytics.track_pageview(%{ Analytics.track_pageview(%{
pathname: pathname, pathname: pathname,
visitor_hash: socket.assigns.analytics_visitor_hash, visitor_hash: socket.assigns.analytics_visitor_hash,
referrer: socket.assigns.analytics_referrer, referrer: socket.assigns.analytics_referrer,
referrer_source: socket.assigns.analytics_referrer_source, referrer_source: socket.assigns.analytics_referrer_source,
utm_source: socket.assigns.analytics_utm_source, utm_source: socket.assigns.analytics_utm_source,
utm_medium: socket.assigns.analytics_utm_medium, utm_medium: socket.assigns.analytics_utm_medium,
utm_campaign: socket.assigns.analytics_utm_campaign, utm_campaign: socket.assigns.analytics_utm_campaign,
country_code: socket.assigns.analytics_country_code, country_code: socket.assigns.analytics_country_code,
screen_size: socket.assigns.analytics_screen_size, screen_size: socket.assigns.analytics_screen_size,
browser: socket.assigns.analytics_browser, browser: socket.assigns.analytics_browser,
os: socket.assigns.analytics_os os: socket.assigns.analytics_os
}) })
# Clear referrer/UTMs after first SPA navigation — they only apply to the entry # Clear referrer/UTMs after first tracked navigation — they only apply to the entry
{:cont, {:cont,
socket socket
|> assign(:analytics_referrer, nil) |> assign(:analytics_referrer, nil)
|> assign(:analytics_referrer_source, nil) |> assign(:analytics_referrer_source, nil)
|> assign(:analytics_utm_source, nil) |> assign(:analytics_utm_source, nil)
|> assign(:analytics_utm_medium, nil) |> assign(:analytics_utm_medium, nil)
|> assign(:analytics_utm_campaign, nil)} |> assign(:analytics_utm_campaign, nil)}
end
end end
end end
@ -96,7 +117,7 @@ defmodule BerrypodWeb.AnalyticsHook do
defp handle_analytics_event("analytics:screen", %{"width" => width}, socket) defp handle_analytics_event("analytics:screen", %{"width" => width}, socket)
when is_integer(width) do when is_integer(width) do
screen_size = classify_screen(width) screen_size = classify_screen(width)
{:cont, assign(socket, :analytics_screen_size, screen_size)} {:halt, assign(socket, :analytics_screen_size, screen_size)}
end end
defp handle_analytics_event(_event, _params, socket), do: {:cont, socket} defp handle_analytics_event(_event, _params, socket), do: {:cont, socket}

View File

@ -1,14 +1,16 @@
defmodule BerrypodWeb.Plugs.Analytics do defmodule BerrypodWeb.Plugs.Analytics do
@moduledoc """ @moduledoc """
Plug that records a pageview event on every shop HTTP request. Plug that records an initial pageview and prepares analytics session data.
This is Layer 1 of the progressive analytics pipeline it fires on every Fires on every GET request regardless of JS computes a privacy-friendly
GET request regardless of whether JS is enabled. Computes a privacy-friendly visitor hash, parses the user agent, extracts referrer and UTM params.
visitor hash, parses the user agent, extracts referrer and UTM params, and Records the pageview immediately (for no-JS support) and stores the data
buffers the event for batch writing to SQLite. in the session for the LiveView hook to use on SPA navigations.
Also stores analytics data in the session so the LiveView hook (Layer 2) The event is recorded with a known ID (plug_ref) stored in the session.
can read it for subsequent SPA navigations. When JS connects, the LiveView hook supersedes this event by ID and
records its own with full data (screen_size). If JS never connects,
the Plug's event flushes to the DB normally.
""" """
import Plug.Conn import Plug.Conn
@ -26,9 +28,11 @@ defmodule BerrypodWeb.Plugs.Analytics do
if browser == "Bot" do if browser == "Bot" do
conn conn
else else
# Skip if the logged-in admin is browsing # Skip recording for logged-in admin — don't set analytics session
# data either, so downstream guards (visitor_hash checks in LiveViews)
# naturally filter out admin browsing for all event types
if admin?(conn) do if admin?(conn) do
prepare_session(conn, ua, browser, os) conn
else else
record_and_prepare(conn, ua, browser, os) record_and_prepare(conn, ua, browser, os)
end end
@ -42,9 +46,10 @@ defmodule BerrypodWeb.Plugs.Analytics do
{referrer, referrer_source} = extract_referrer(conn) {referrer, referrer_source} = extract_referrer(conn)
utm = extract_utms(conn) utm = extract_utms(conn)
country_code = get_session(conn, "country_code") country_code = get_session(conn, "country_code")
screen_size = get_session(conn, "analytics_screen_size") plug_ref = Ecto.UUID.generate()
Analytics.track_pageview(%{ Analytics.track_pageview(%{
id: plug_ref,
pathname: conn.request_path, pathname: conn.request_path,
visitor_hash: visitor_hash, visitor_hash: visitor_hash,
referrer: referrer, referrer: referrer,
@ -53,7 +58,7 @@ defmodule BerrypodWeb.Plugs.Analytics do
utm_medium: utm.medium, utm_medium: utm.medium,
utm_campaign: utm.campaign, utm_campaign: utm.campaign,
country_code: country_code, country_code: country_code,
screen_size: screen_size, screen_size: nil,
browser: browser, browser: browser,
os: os os: os
}) })
@ -67,16 +72,7 @@ defmodule BerrypodWeb.Plugs.Analytics do
|> put_session("analytics_utm_source", utm.source) |> put_session("analytics_utm_source", utm.source)
|> put_session("analytics_utm_medium", utm.medium) |> put_session("analytics_utm_medium", utm.medium)
|> put_session("analytics_utm_campaign", utm.campaign) |> put_session("analytics_utm_campaign", utm.campaign)
end |> put_session("analytics_plug_ref", plug_ref)
# 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 end
defp admin?(conn) do defp admin?(conn) do

View File

@ -84,4 +84,104 @@ defmodule Berrypod.Analytics.BufferTest do
assert length(session_hashes) == 2 assert length(session_hashes) == 2
end end
end end
describe "supersede/1" do
test "removes a buffered event by id before flush" do
visitor_hash = :crypto.strong_rand_bytes(8)
ref = Ecto.UUID.generate()
Buffer.record(%{
id: ref,
name: "pageview",
pathname: "/",
visitor_hash: visitor_hash
})
Buffer.supersede(ref)
send(Buffer, :flush)
:timer.sleep(50)
events =
from(e in Event, where: e.visitor_hash == ^visitor_hash)
|> Repo.all()
assert events == []
end
test "only removes the event with the matching id" do
visitor_hash = :crypto.strong_rand_bytes(8)
ref = Ecto.UUID.generate()
Buffer.record(%{
id: ref,
name: "pageview",
pathname: "/",
visitor_hash: visitor_hash
})
Buffer.record(%{
name: "pageview",
pathname: "/about",
visitor_hash: visitor_hash
})
Buffer.supersede(ref)
send(Buffer, :flush)
:timer.sleep(50)
events =
from(e in Event, where: e.visitor_hash == ^visitor_hash)
|> Repo.all()
assert length(events) == 1
assert hd(events).pathname == "/about"
end
test "removes from DB if event was already flushed" do
visitor_hash = :crypto.strong_rand_bytes(8)
ref = Ecto.UUID.generate()
Buffer.record(%{
id: ref,
name: "pageview",
pathname: "/",
visitor_hash: visitor_hash
})
# Flush to DB first, simulating the 10s timer firing before supersede
send(Buffer, :flush)
:timer.sleep(50)
assert Repo.aggregate(from(e in Event, where: e.id == ^ref), :count) == 1
# Now supersede — should delete from DB
Buffer.supersede(ref)
:timer.sleep(50)
assert Repo.aggregate(from(e in Event, where: e.id == ^ref), :count) == 0
end
test "no-op when ref does not match any buffered event" do
visitor_hash = :crypto.strong_rand_bytes(8)
Buffer.record(%{
name: "pageview",
pathname: "/",
visitor_hash: visitor_hash
})
Buffer.supersede(Ecto.UUID.generate())
send(Buffer, :flush)
:timer.sleep(50)
events =
from(e in Event, where: e.visitor_hash == ^visitor_hash)
|> Repo.all()
assert length(events) == 1
end
end
end end

View File

@ -42,8 +42,6 @@ defmodule BerrypodWeb.Plugs.AnalyticsTest do
:timer.sleep(50) :timer.sleep(50)
count_after = Repo.aggregate(Event, :count) 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 assert count_after == count_before
end end
@ -71,6 +69,17 @@ defmodule BerrypodWeb.Plugs.AnalyticsTest do
assert get_session(conn, "analytics_browser") == "Firefox" assert get_session(conn, "analytics_browser") == "Firefox"
end end
test "stores plug_ref in session for buffer supersede", %{conn: conn} do
conn =
conn
|> put_req_header("user-agent", "Mozilla/5.0 Chrome/120.0")
|> get(~p"/")
plug_ref = get_session(conn, "analytics_plug_ref")
assert is_binary(plug_ref)
assert String.length(plug_ref) == 36
end
test "extracts referrer", %{conn: conn} do test "extracts referrer", %{conn: conn} do
conn conn
|> put_req_header("user-agent", "Mozilla/5.0 Chrome/120.0") |> put_req_header("user-agent", "Mozilla/5.0 Chrome/120.0")