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

@@ -55,6 +55,20 @@ defmodule Berrypod.Analytics.Buffer do
GenServer.cast(__MODULE__, {:record, attrs})
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 ──
@impl true
@@ -75,7 +89,7 @@ defmodule Berrypod.Analytics.Buffer do
event =
attrs
|> 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))
counter = state.counter + 1
@@ -84,6 +98,29 @@ defmodule Berrypod.Analytics.Buffer do
{:noreply, %{state | counter: counter, sessions: sessions}}
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
def handle_info(:flush, state) do
state = flush_events(state)