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

View File

@ -9,7 +9,7 @@
- Image optimization pipeline (AVIF/WebP/JPEG responsive variants) - Image optimization pipeline (AVIF/WebP/JPEG responsive variants)
- Shop pages (home, collections, products, cart, about, contact, error, delivery, privacy, terms) - Shop pages (home, collections, products, cart, about, contact, error, delivery, privacy, terms)
- Mobile-first design with bottom navigation - Mobile-first design with bottom navigation
- 898 tests passing, 100% PageSpeed score - 972 tests passing, 100% PageSpeed score
- SQLite production tuning (IMMEDIATE transactions, mmap, WAL journal limit) - SQLite production tuning (IMMEDIATE transactions, mmap, WAL journal limit)
- Variant selector with color swatches and size buttons - Variant selector with color swatches and size buttons
- Session-based cart with real variant data (add/remove/quantity, cross-tab sync) - Session-based cart with real variant data (add/remove/quantity, cross-tab sync)
@ -61,7 +61,7 @@ Plans: [admin-redesign.md](docs/plans/admin-redesign.md) | [admin-font-loading.m
| ~~29~~ | ~~Printful webhooks~~ | 25 | 1.5h | done | | ~~29~~ | ~~Printful webhooks~~ | 25 | 1.5h | done |
| | **Next up** | | | | | | **Next up** | | | |
| ~~30~~ | ~~Admin UI tweaks for Printful~~ | 25 | 30m | done | | ~~30~~ | ~~Admin UI tweaks for Printful~~ | 25 | 30m | done |
| 31 | Printful tests + integration testing | 24-30 | 4.5h | | | ~~31~~ | ~~Printful + Printify client tests with Req.Test stubs~~ | 24-30 | 4.5h | done |
| | **Setup and launch readiness** ([plan](docs/plans/setup-and-launch.md)) | | | | | | **Setup and launch readiness** ([plan](docs/plans/setup-and-launch.md)) | | | |
| ~~41~~ | ~~Provider + payment registries~~ | — | 30m | done | | ~~41~~ | ~~Provider + payment registries~~ | — | 30m | done |
| ~~42~~ | ~~Make Setup provider-agnostic + add checklist fields~~ | 41 | 45m | done | | ~~42~~ | ~~Make Setup provider-agnostic + add checklist fields~~ | 41 | 45m | done |
@ -87,7 +87,7 @@ Plans: [admin-redesign.md](docs/plans/admin-redesign.md) | [admin-font-loading.m
| | **Bugs / polish** | | | | | | **Bugs / polish** | | | |
| ~~49~~ | ~~Admin font loading + cache miss path resolver ([plan](docs/plans/admin-font-loading.md))~~ | — | 1h | done | | ~~49~~ | ~~Admin font loading + cache miss path resolver ([plan](docs/plans/admin-font-loading.md))~~ | — | 1h | done |
**Total remaining: ~4.5 hours** (Printful tests) **All tasks complete.** No remaining work in the task list.
See [css-migration.md](docs/plans/css-migration.md) for full plan with architecture, visual regression testing strategy, and acceptance criteria per phase. See [css-migration.md](docs/plans/css-migration.md) for full plan with architecture, visual regression testing strategy, and acceptance criteria per phase.
@ -374,7 +374,7 @@ See: [plan](docs/plans/shipping-sync.md) for implementation details
- [x] `mix bench.sqlite` task with `--prod`, `--scale`, `--pool-size`, `--busy-timeout` options - [x] `mix bench.sqlite` task with `--prod`, `--scale`, `--pool-size`, `--busy-timeout` options
- [x] PRAGMA tuning across dev/test/prod: `default_transaction_mode: :immediate`, `journal_size_limit: 64MB`, `custom_pragmas: [mmap_size: 128MB]` - [x] PRAGMA tuning across dev/test/prod: `default_transaction_mode: :immediate`, `journal_size_limit: 64MB`, `custom_pragmas: [mmap_size: 128MB]`
- [x] Benchmarks confirmed: IMMEDIATE mode eliminates transaction upgrade BUSY errors (0% vs 73-80% failure rate under contention), prod mode 5-12x faster than dev, 300 concurrent mixed requests with zero errors - [x] Benchmarks confirmed: IMMEDIATE mode eliminates transaction upgrade BUSY errors (0% vs 73-80% failure rate under contention), prod mode 5-12x faster than dev, 300 concurrent mixed requests with zero errors
- [x] 898 tests total (6 correctness + 3 benchmarks excluded by default) - [x] 972 tests total (5 excluded: 3 benchmarks + 2 correctness)
### Page Editor ### Page Editor
**Status:** Future (Tier 4) **Status:** Future (Tier 4)
@ -389,6 +389,7 @@ See: [docs/plans/page-builder.md](docs/plans/page-builder.md) for design
| Feature | Commit | Notes | | Feature | Commit | Notes |
|---------|--------|-------| |---------|--------|-------|
| Printify + Printful client tests | b0aed4c, a45e85e | Req.Test stubs for both HTTP clients, provider integration tests, mockup enricher tests, 972 tests |
| SQLite production tuning | 162bf4c, 19d8c7d | Concurrency tests, `mix bench.sqlite` task, IMMEDIATE transactions, mmap 128MB, journal_size_limit 64MB, 898 tests | | SQLite production tuning | 162bf4c, 19d8c7d | Concurrency tests, `mix bench.sqlite` task, IMMEDIATE transactions, mmap 128MB, journal_size_limit 64MB, 898 tests |
| Per-colour images + gallery filtering | 0fe48ba | colour column on product_images, per-colour mockup enrichment, PDP gallery filtering, Printify option filtering, hero colour ordering, 821 tests | | Per-colour images + gallery filtering | 0fe48ba | colour column on product_images, per-colour mockup enrichment, PDP gallery filtering, Printify option filtering, hero colour ordering, 821 tests |
| Printful catalog colours | 4e19d4c | Fetch hex codes from catalog product API during sync, cached per catalog_product_id | | Printful catalog colours | 4e19d4c | Fetch hex codes from catalog product API during sync, cached per catalog_product_id |

View File

@ -509,10 +509,17 @@ const CardRadioScroll = {
} }
} }
// Analytics: send screen width for device classification (Layer 3)
const AnalyticsInit = {
mounted() {
this.pushEvent("analytics:screen", { width: window.innerWidth })
}
}
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},
hooks: {...colocatedHooks, ColorSync, Lightbox, CartPersist, CartDrawer, ProductImageScroll, SearchModal, CollectionFilters, CardRadioScroll}, hooks: {...colocatedHooks, ColorSync, Lightbox, CartPersist, CartDrawer, ProductImageScroll, SearchModal, CollectionFilters, CardRadioScroll, AnalyticsInit},
}) })
// Show progress bar on live navigation and form submits // Show progress bar on live navigation and form submits

View File

@ -93,7 +93,8 @@ config :berrypod, Oban,
{Oban.Plugins.Cron, {Oban.Plugins.Cron,
crontab: [ crontab: [
{"*/30 * * * *", Berrypod.Orders.FulfilmentStatusWorker}, {"*/30 * * * *", Berrypod.Orders.FulfilmentStatusWorker},
{"0 */6 * * *", Berrypod.Sync.ScheduledSyncWorker} {"0 */6 * * *", Berrypod.Sync.ScheduledSyncWorker},
{"0 3 * * *", Berrypod.Analytics.RetentionWorker}
]} ]}
], ],
queues: [images: 2, sync: 1, checkout: 1] queues: [images: 2, sync: 1, checkout: 1]

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}, {Phoenix.PubSub, name: Berrypod.PubSub},
# Background job processing # Background job processing
{Oban, Application.fetch_env!(:berrypod, Oban)}, {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 # Image variant cache - ensures all variants exist on startup
Berrypod.Images.VariantCache, Berrypod.Images.VariantCache,
# Start to serve requests # 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 <.icon name="hero-home" class="size-5" /> Dashboard
</.link> </.link>
</li> </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> <li>
<.link <.link
navigate={~p"/admin/orders"} navigate={~p"/admin/orders"}

View File

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

View File

@ -1,7 +1,7 @@
defmodule BerrypodWeb.CheckoutController do defmodule BerrypodWeb.CheckoutController do
use BerrypodWeb, :controller use BerrypodWeb, :controller
alias Berrypod.Cart alias Berrypod.{Analytics, Cart}
alias Berrypod.Orders alias Berrypod.Orders
alias Berrypod.Shipping alias Berrypod.Shipping
@ -16,6 +16,7 @@ defmodule BerrypodWeb.CheckoutController do
|> put_flash(:error, "Your basket is empty") |> put_flash(:error, "Your basket is empty")
|> redirect(to: ~p"/cart") |> redirect(to: ~p"/cart")
else else
track_checkout_start(conn)
create_checkout(conn, hydrated) create_checkout(conn, hydrated)
end end
end end
@ -126,4 +127,15 @@ defmodule BerrypodWeb.CheckoutController do
end end
defp maybe_add_option(options, _result, _name, _min, _max), do: options 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 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 defmodule BerrypodWeb.Shop.CheckoutSuccess do
use BerrypodWeb, :live_view use BerrypodWeb, :live_view
alias Berrypod.Orders alias Berrypod.{Analytics, Orders}
@impl true @impl true
def mount(%{"session_id" => session_id}, _session, socket) do 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") Phoenix.PubSub.subscribe(Berrypod.PubSub, "order:#{order.id}:status")
end 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 # Clear the cart after successful checkout
socket = socket =
if order && connected?(socket) do if order && connected?(socket) do

View File

@ -1,7 +1,7 @@
defmodule BerrypodWeb.Shop.ProductShow do defmodule BerrypodWeb.Shop.ProductShow do
use BerrypodWeb, :live_view use BerrypodWeb, :live_view
alias Berrypod.Cart alias Berrypod.{Analytics, Cart}
alias Berrypod.Images.Optimizer alias Berrypod.Images.Optimizer
alias Berrypod.Products alias Berrypod.Products
alias Berrypod.Products.{Product, ProductImage} alias Berrypod.Products.{Product, ProductImage}
@ -41,6 +41,13 @@ defmodule BerrypodWeb.Shop.ProductShow do
display_price = variant_price(selected_variant, product) display_price = variant_price(selected_variant, product)
gallery_images = filter_gallery_images(all_images, selected_options["Color"]) 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 =
socket socket
|> assign(:page_title, product.title) |> assign(:page_title, product.title)
@ -177,6 +184,13 @@ defmodule BerrypodWeb.Shop.ProductShow do
if variant do if variant do
cart = Cart.add_item(socket.assigns.raw_cart, variant.id, socket.assigns.quantity) 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 =
socket socket
|> BerrypodWeb.CartHook.broadcast_and_update(cart) |> 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 pipeline :shop do
plug :put_root_layout, html: {BerrypodWeb.Layouts, :shop_root} plug :put_root_layout, html: {BerrypodWeb.Layouts, :shop_root}
plug BerrypodWeb.Plugs.LoadTheme plug BerrypodWeb.Plugs.LoadTheme
plug BerrypodWeb.Plugs.Analytics
end end
pipeline :admin do pipeline :admin do
@ -63,7 +64,8 @@ defmodule BerrypodWeb.Router do
{BerrypodWeb.ThemeHook, :mount_theme}, {BerrypodWeb.ThemeHook, :mount_theme},
{BerrypodWeb.ThemeHook, :require_site_live}, {BerrypodWeb.ThemeHook, :require_site_live},
{BerrypodWeb.CartHook, :mount_cart}, {BerrypodWeb.CartHook, :mount_cart},
{BerrypodWeb.SearchHook, :mount_search} {BerrypodWeb.SearchHook, :mount_search},
{BerrypodWeb.AnalyticsHook, :track}
] do ] do
live "/", Shop.Home, :index live "/", Shop.Home, :index
live "/about", Shop.Content, :about live "/about", Shop.Content, :about
@ -170,6 +172,7 @@ defmodule BerrypodWeb.Router do
{BerrypodWeb.AdminLayoutHook, :assign_current_path} {BerrypodWeb.AdminLayoutHook, :assign_current_path}
] do ] do
live "/", Admin.Dashboard, :index live "/", Admin.Dashboard, :index
live "/analytics", Admin.Analytics, :index
live "/orders", Admin.Orders, :index live "/orders", Admin.Orders, :index
live "/orders/:id", Admin.OrderShow, :show live "/orders/:id", Admin.OrderShow, :show
live "/products", Admin.Products, :index live "/products", Admin.Products, :index

View File

@ -0,0 +1,30 @@
defmodule Berrypod.Repo.Migrations.CreateAnalyticsEvents do
use Ecto.Migration
def change do
create table(:analytics_events, primary_key: false) do
add :id, :binary_id, primary_key: true
add :name, :string, null: false
add :pathname, :string, null: false
add :visitor_hash, :binary, null: false
add :session_hash, :binary, null: false
add :referrer, :string
add :referrer_source, :string
add :utm_source, :string
add :utm_medium, :string
add :utm_campaign, :string
add :country_code, :string, size: 2
add :screen_size, :string
add :browser, :string
add :os, :string
add :revenue, :integer
timestamps(type: :utc_datetime, updated_at: false)
end
create index(:analytics_events, [:inserted_at])
create index(:analytics_events, [:name, :inserted_at])
create index(:analytics_events, [:session_hash, :inserted_at])
create index(:analytics_events, [:pathname, :inserted_at])
end
end

View File

@ -0,0 +1,87 @@
defmodule Berrypod.Analytics.BufferTest do
use Berrypod.DataCase, async: false
alias Berrypod.Analytics.Buffer
alias Berrypod.Analytics.Event
alias Berrypod.Repo
import Ecto.Query
setup do
# Flush any pending events from previous tests
send(Buffer, :flush)
:timer.sleep(50)
:ok
end
describe "record/1 and flush" do
test "buffered events are flushed to the database" do
visitor_hash = :crypto.strong_rand_bytes(8)
Buffer.record(%{
name: "pageview",
pathname: "/buffer-test",
visitor_hash: visitor_hash,
browser: "Chrome",
os: "macOS"
})
send(Buffer, :flush)
:timer.sleep(50)
events =
from(e in Event, where: e.visitor_hash == ^visitor_hash)
|> Repo.all()
assert length(events) == 1
event = hd(events)
assert event.name == "pageview"
assert event.pathname == "/buffer-test"
assert event.visitor_hash == visitor_hash
assert event.browser == "Chrome"
assert event.os == "macOS"
assert event.session_hash != nil
assert byte_size(event.session_hash) == 8
end
test "events within 30 min get the same session_hash" do
visitor_hash = :crypto.strong_rand_bytes(8)
Buffer.record(%{name: "pageview", pathname: "/", visitor_hash: visitor_hash})
Buffer.record(%{name: "pageview", pathname: "/products", visitor_hash: visitor_hash})
send(Buffer, :flush)
:timer.sleep(50)
events =
from(e in Event, where: e.visitor_hash == ^visitor_hash)
|> Repo.all()
assert length(events) == 2
session_hashes = Enum.map(events, & &1.session_hash) |> Enum.uniq()
assert length(session_hashes) == 1
end
test "different visitors get different session_hashes" do
visitor1 = :crypto.strong_rand_bytes(8)
visitor2 = :crypto.strong_rand_bytes(8)
Buffer.record(%{name: "pageview", pathname: "/", visitor_hash: visitor1})
Buffer.record(%{name: "pageview", pathname: "/", visitor_hash: visitor2})
send(Buffer, :flush)
:timer.sleep(50)
events =
from(e in Event, where: e.visitor_hash in [^visitor1, ^visitor2])
|> Repo.all()
assert length(events) == 2
session_hashes = Enum.map(events, & &1.session_hash) |> Enum.uniq()
assert length(session_hashes) == 2
end
end
end

View File

@ -0,0 +1,74 @@
defmodule Berrypod.Analytics.ReferrerTest do
use ExUnit.Case, async: true
alias Berrypod.Analytics.Referrer
describe "parse/1" do
test "extracts Google search" do
assert Referrer.parse("https://www.google.com/search?q=test") == {"google.com", "Google"}
end
test "extracts Google with country TLD" do
assert Referrer.parse("https://www.google.co.uk/") == {"google.co.uk", "Google"}
end
test "extracts Facebook" do
assert Referrer.parse("https://www.facebook.com/some-page") == {"facebook.com", "Facebook"}
end
test "extracts Twitter / X" do
assert Referrer.parse("https://t.co/abc123") == {"t.co", "Telegram"}
assert Referrer.parse("https://twitter.com/user") == {"twitter.com", "Twitter"}
assert Referrer.parse("https://x.com/user") == {"x.com", "Twitter"}
end
test "extracts Reddit" do
assert Referrer.parse("https://www.reddit.com/r/elixir") == {"reddit.com", "Reddit"}
end
test "returns nil source for unknown domain" do
assert Referrer.parse("https://myblog.example.com/post/1") ==
{"myblog.example.com", nil}
end
test "strips www prefix" do
assert Referrer.parse("https://www.example.com/page") == {"example.com", nil}
end
test "returns nil for nil input" do
assert Referrer.parse(nil) == {nil, nil}
end
test "returns nil for empty string" do
assert Referrer.parse("") == {nil, nil}
end
test "returns nil for invalid URL" do
assert Referrer.parse("not a url") == {nil, nil}
end
test "extracts DuckDuckGo" do
assert Referrer.parse("https://duckduckgo.com/?q=test") == {"duckduckgo.com", "DuckDuckGo"}
end
test "extracts YouTube" do
assert Referrer.parse("https://www.youtube.com/watch?v=abc") == {"youtube.com", "YouTube"}
end
test "extracts Instagram" do
assert Referrer.parse("https://www.instagram.com/user") == {"instagram.com", "Instagram"}
end
test "extracts LinkedIn" do
assert Referrer.parse("https://www.linkedin.com/feed") == {"linkedin.com", "LinkedIn"}
end
test "extracts Pinterest" do
assert Referrer.parse("https://www.pinterest.com/pin/123") == {"pinterest.com", "Pinterest"}
end
test "extracts Bing" do
assert Referrer.parse("https://www.bing.com/search?q=test") == {"bing.com", "Bing"}
end
end
end

View File

@ -0,0 +1,40 @@
defmodule Berrypod.Analytics.RetentionWorkerTest do
use Berrypod.DataCase, async: false
alias Berrypod.Analytics.{Event, RetentionWorker}
alias Berrypod.Repo
test "deletes events older than 12 months" do
old = DateTime.add(DateTime.utc_now(), -400, :day) |> DateTime.truncate(:second)
recent = DateTime.utc_now() |> DateTime.truncate(:second)
Repo.insert_all(Event, [
[
id: Ecto.UUID.generate(),
name: "pageview",
pathname: "/old",
visitor_hash: :crypto.strong_rand_bytes(8),
session_hash: :crypto.strong_rand_bytes(8),
inserted_at: old
],
[
id: Ecto.UUID.generate(),
name: "pageview",
pathname: "/recent",
visitor_hash: :crypto.strong_rand_bytes(8),
session_hash: :crypto.strong_rand_bytes(8),
inserted_at: recent
]
])
assert :ok = RetentionWorker.perform(%Oban.Job{})
events = Repo.all(Event)
assert length(events) == 1
assert hd(events).pathname == "/recent"
end
test "no-op when no old events exist" do
assert :ok = RetentionWorker.perform(%Oban.Job{})
end
end

View File

@ -0,0 +1,37 @@
defmodule Berrypod.Analytics.SaltTest do
use ExUnit.Case, async: true
alias Berrypod.Analytics.Salt
describe "hash_visitor/2" do
test "returns an 8-byte binary" do
hash = Salt.hash_visitor({127, 0, 0, 1}, "Mozilla/5.0")
assert is_binary(hash)
assert byte_size(hash) == 8
end
test "same inputs produce the same hash" do
hash1 = Salt.hash_visitor({127, 0, 0, 1}, "Mozilla/5.0")
hash2 = Salt.hash_visitor({127, 0, 0, 1}, "Mozilla/5.0")
assert hash1 == hash2
end
test "different IPs produce different hashes" do
hash1 = Salt.hash_visitor({127, 0, 0, 1}, "Mozilla/5.0")
hash2 = Salt.hash_visitor({192, 168, 1, 1}, "Mozilla/5.0")
assert hash1 != hash2
end
test "different user agents produce different hashes" do
hash1 = Salt.hash_visitor({127, 0, 0, 1}, "Mozilla/5.0 Chrome")
hash2 = Salt.hash_visitor({127, 0, 0, 1}, "Mozilla/5.0 Firefox")
assert hash1 != hash2
end
test "accepts IP as a binary string" do
hash1 = Salt.hash_visitor("127.0.0.1", "Mozilla/5.0")
hash2 = Salt.hash_visitor({127, 0, 0, 1}, "Mozilla/5.0")
assert hash1 == hash2
end
end
end

View File

@ -0,0 +1,81 @@
defmodule Berrypod.Analytics.UAParserTest do
use ExUnit.Case, async: true
alias Berrypod.Analytics.UAParser
describe "parse/1" do
test "detects Chrome on macOS" do
ua =
"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"
assert UAParser.parse(ua) == {"Chrome", "macOS"}
end
test "detects Firefox on Windows" do
ua = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:121.0) Gecko/20100101 Firefox/121.0"
assert UAParser.parse(ua) == {"Firefox", "Windows"}
end
test "detects Safari on iOS (iPhone)" do
ua =
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_2_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Mobile/15E148 Safari/604.1"
assert UAParser.parse(ua) == {"Safari", "iOS"}
end
test "detects Chrome on Android" do
ua =
"Mozilla/5.0 (Linux; Android 14; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36"
assert UAParser.parse(ua) == {"Chrome", "Android"}
end
test "detects Edge on Windows" do
ua =
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 Edg/120.0.0.0"
assert UAParser.parse(ua) == {"Edge", "Windows"}
end
test "detects Chrome on Linux" do
ua =
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
assert UAParser.parse(ua) == {"Chrome", "Linux"}
end
test "detects bots" do
ua = "Googlebot/2.1 (+http://www.google.com/bot.html)"
assert UAParser.parse(ua) == {"Bot", "Other"}
end
test "detects Bingbot" do
ua =
"Mozilla/5.0 (compatible; bingbot/2.0; +http://www.bing.com/bingbot.htm)"
assert UAParser.parse(ua) == {"Bot", "Other"}
end
test "returns Other/Other for nil" do
assert UAParser.parse(nil) == {"Other", "Other"}
end
test "returns Other/Other for empty string" do
assert UAParser.parse("") == {"Other", "Other"}
end
test "detects Opera" do
ua =
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 OPR/106.0.0.0"
assert UAParser.parse(ua) == {"Opera", "Windows"}
end
test "detects Safari on iPad (iOS)" do
ua =
"Mozilla/5.0 (iPad; CPU OS 17_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Mobile/15E148 Safari/604.1"
assert UAParser.parse(ua) == {"Safari", "iOS"}
end
end
end

View File

@ -0,0 +1,250 @@
defmodule Berrypod.AnalyticsTest do
use Berrypod.DataCase, async: false
import Ecto.Query
alias Berrypod.Analytics
alias Berrypod.Analytics.{Buffer, Event}
alias Berrypod.Repo
setup do
# Flush any pending events then clear the table so each test starts clean
send(Buffer, :flush)
:timer.sleep(50)
Repo.delete_all(Event)
:ok
end
# Helper to insert events directly (bypassing the buffer for query tests)
defp insert_event(attrs) do
now = DateTime.utc_now() |> DateTime.truncate(:second)
defaults = %{
id: Ecto.UUID.generate(),
name: "pageview",
pathname: "/",
visitor_hash: :crypto.strong_rand_bytes(8),
session_hash: :crypto.strong_rand_bytes(8),
inserted_at: now
}
event = Map.merge(defaults, attrs)
Repo.insert_all(Event, [Map.to_list(event)])
event
end
defp today_range do
today = Date.utc_today()
start_dt = DateTime.new!(today, ~T[00:00:00], "Etc/UTC")
end_dt = DateTime.new!(Date.add(today, 1), ~T[00:00:00], "Etc/UTC")
{start_dt, end_dt}
end
describe "track_pageview/1" do
test "records a pageview through the buffer" do
visitor_hash = :crypto.strong_rand_bytes(8)
Analytics.track_pageview(%{pathname: "/test", visitor_hash: visitor_hash})
send(Buffer, :flush)
:timer.sleep(50)
[event] =
from(e in Event, where: e.visitor_hash == ^visitor_hash) |> Repo.all()
assert event.name == "pageview"
assert event.pathname == "/test"
end
end
describe "track_event/2" do
test "records a named event through the buffer" do
visitor_hash = :crypto.strong_rand_bytes(8)
Analytics.track_event("add_to_cart", %{
pathname: "/products/tee",
visitor_hash: visitor_hash
})
send(Buffer, :flush)
:timer.sleep(50)
[event] =
from(e in Event, where: e.visitor_hash == ^visitor_hash) |> Repo.all()
assert event.name == "add_to_cart"
assert event.pathname == "/products/tee"
end
end
describe "count_visitors/1" do
test "counts distinct visitors" do
v1 = :crypto.strong_rand_bytes(8)
v2 = :crypto.strong_rand_bytes(8)
insert_event(%{visitor_hash: v1, pathname: "/"})
insert_event(%{visitor_hash: v1, pathname: "/about"})
insert_event(%{visitor_hash: v2, pathname: "/"})
assert Analytics.count_visitors(today_range()) == 2
end
test "returns 0 for empty range" do
assert Analytics.count_visitors(today_range()) == 0
end
end
describe "count_pageviews/1" do
test "counts all pageview events" do
v1 = :crypto.strong_rand_bytes(8)
insert_event(%{visitor_hash: v1, pathname: "/"})
insert_event(%{visitor_hash: v1, pathname: "/about"})
insert_event(%{visitor_hash: v1, pathname: "/products/tee", name: "product_view"})
assert Analytics.count_pageviews(today_range()) == 2
end
end
describe "bounce_rate/1" do
test "100% bounce rate when all sessions have 1 pageview" do
s1 = :crypto.strong_rand_bytes(8)
s2 = :crypto.strong_rand_bytes(8)
insert_event(%{session_hash: s1, pathname: "/"})
insert_event(%{session_hash: s2, pathname: "/about"})
assert Analytics.bounce_rate(today_range()) == 100
end
test "0% bounce rate when all sessions have multiple pageviews" do
session = :crypto.strong_rand_bytes(8)
insert_event(%{session_hash: session, pathname: "/"})
insert_event(%{session_hash: session, pathname: "/about"})
assert Analytics.bounce_rate(today_range()) == 0
end
test "returns 0 for no data" do
assert Analytics.bounce_rate(today_range()) == 0
end
end
describe "top_pages/2" do
test "returns pages sorted by visitor count" do
v1 = :crypto.strong_rand_bytes(8)
v2 = :crypto.strong_rand_bytes(8)
insert_event(%{visitor_hash: v1, pathname: "/"})
insert_event(%{visitor_hash: v2, pathname: "/"})
insert_event(%{visitor_hash: v1, pathname: "/about"})
pages = Analytics.top_pages(today_range())
assert hd(pages).pathname == "/"
assert hd(pages).visitors == 2
end
end
describe "top_sources/2" do
test "returns sources sorted by visitor count" do
v1 = :crypto.strong_rand_bytes(8)
v2 = :crypto.strong_rand_bytes(8)
insert_event(%{visitor_hash: v1, referrer_source: "Google"})
insert_event(%{visitor_hash: v2, referrer_source: "Google"})
insert_event(%{visitor_hash: v1, referrer_source: "Facebook"})
sources = Analytics.top_sources(today_range())
assert hd(sources).source == "Google"
assert hd(sources).visitors == 2
end
end
describe "top_countries/2" do
test "returns countries sorted by visitor count" do
v1 = :crypto.strong_rand_bytes(8)
v2 = :crypto.strong_rand_bytes(8)
insert_event(%{visitor_hash: v1, country_code: "GB"})
insert_event(%{visitor_hash: v2, country_code: "GB"})
insert_event(%{visitor_hash: v1, country_code: "US"})
countries = Analytics.top_countries(today_range())
assert hd(countries).country_code == "GB"
assert hd(countries).visitors == 2
end
end
describe "device_breakdown/2" do
test "returns browser breakdown" do
v1 = :crypto.strong_rand_bytes(8)
v2 = :crypto.strong_rand_bytes(8)
insert_event(%{visitor_hash: v1, browser: "Chrome"})
insert_event(%{visitor_hash: v2, browser: "Chrome"})
insert_event(%{visitor_hash: v1, browser: "Firefox"})
browsers = Analytics.device_breakdown(today_range(), :browser)
assert hd(browsers).name == "Chrome"
assert hd(browsers).visitors == 2
end
end
describe "funnel/1" do
test "returns counts for each funnel step" do
v1 = :crypto.strong_rand_bytes(8)
v2 = :crypto.strong_rand_bytes(8)
insert_event(%{visitor_hash: v1, name: "product_view", pathname: "/products/tee"})
insert_event(%{visitor_hash: v2, name: "product_view", pathname: "/products/tee"})
insert_event(%{visitor_hash: v1, name: "add_to_cart", pathname: "/products/tee"})
insert_event(%{visitor_hash: v1, name: "checkout_start", pathname: "/checkout"})
insert_event(%{
visitor_hash: v1,
name: "purchase",
pathname: "/checkout/success",
revenue: 2500
})
funnel = Analytics.funnel(today_range())
assert funnel.product_views == 2
assert funnel.add_to_carts == 1
assert funnel.checkouts == 1
assert funnel.purchases == 1
end
end
describe "total_revenue/1" do
test "sums revenue from purchase events" do
v1 = :crypto.strong_rand_bytes(8)
insert_event(%{visitor_hash: v1, name: "purchase", pathname: "/", revenue: 2500})
insert_event(%{visitor_hash: v1, name: "purchase", pathname: "/", revenue: 1500})
assert Analytics.total_revenue(today_range()) == 4000
end
test "returns 0 when no purchases" do
assert Analytics.total_revenue(today_range()) == 0
end
end
describe "delete_events_before/1" do
test "deletes old events" do
old = DateTime.add(DateTime.utc_now(), -400, :day) |> DateTime.truncate(:second)
recent = DateTime.utc_now() |> DateTime.truncate(:second)
insert_event(%{inserted_at: old, pathname: "/old"})
insert_event(%{inserted_at: recent, pathname: "/recent"})
cutoff = DateTime.add(DateTime.utc_now(), -365, :day) |> DateTime.truncate(:second)
{deleted, _} = Analytics.delete_events_before(cutoff)
assert deleted == 1
assert [event] = Repo.all(Event)
assert event.pathname == "/recent"
end
end
end

View File

@ -0,0 +1,106 @@
defmodule BerrypodWeb.Admin.AnalyticsTest do
use BerrypodWeb.ConnCase, async: false
import Phoenix.LiveViewTest
import Berrypod.AccountsFixtures
alias Berrypod.Analytics.{Buffer, Event}
alias Berrypod.Repo
setup do
send(Buffer, :flush)
:timer.sleep(50)
user = user_fixture()
%{user: user}
end
describe "unauthenticated" do
test "redirects to login", %{conn: conn} do
{:error, redirect} = live(conn, ~p"/admin/analytics")
assert {:redirect, %{to: path}} = redirect
assert path == ~p"/users/log-in"
end
end
describe "analytics dashboard" do
setup %{conn: conn, user: user} do
%{conn: log_in_user(conn, user)}
end
test "renders the analytics page", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/admin/analytics")
assert html =~ "Analytics"
assert html =~ "Unique visitors"
assert html =~ "Total pageviews"
assert html =~ "Bounce rate"
assert html =~ "Visit duration"
end
test "shows zero state with no data", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/admin/analytics")
assert html =~ "No data for this period"
end
test "shows data when events exist", %{conn: conn} do
now = DateTime.utc_now() |> DateTime.truncate(:second)
Repo.insert_all(Event, [
[
id: Ecto.UUID.generate(),
name: "pageview",
pathname: "/",
visitor_hash: :crypto.strong_rand_bytes(8),
session_hash: :crypto.strong_rand_bytes(8),
browser: "Chrome",
os: "macOS",
inserted_at: now
]
])
{:ok, view, _html} = live(conn, ~p"/admin/analytics")
assert has_element?(view, "rect")
end
test "changes period", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/admin/analytics")
html = render_click(view, "change_period", %{"period" => "7d"})
assert html =~ "Analytics"
end
test "changes tab to sources", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/admin/analytics")
html = render_click(view, "change_tab", %{"tab" => "sources"})
assert html =~ "Top sources"
assert html =~ "Top referrers"
end
test "changes tab to countries", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/admin/analytics")
html = render_click(view, "change_tab", %{"tab" => "countries"})
assert html =~ "Countries"
end
test "changes tab to devices", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/admin/analytics")
html = render_click(view, "change_tab", %{"tab" => "devices"})
assert html =~ "Browsers"
assert html =~ "Operating systems"
assert html =~ "Screen sizes"
end
test "changes tab to funnel", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/admin/analytics")
html = render_click(view, "change_tab", %{"tab" => "funnel"})
assert html =~ "Conversion funnel"
end
end
end

View File

@ -0,0 +1,115 @@
defmodule BerrypodWeb.Plugs.AnalyticsTest do
use BerrypodWeb.ConnCase, async: false
import Ecto.Query
alias Berrypod.Analytics.{Buffer, Event}
alias Berrypod.Repo
setup do
send(Buffer, :flush)
:timer.sleep(50)
:ok
end
describe "analytics plug" do
test "records a pageview on GET request", %{conn: conn} do
conn
|> put_req_header("user-agent", "Mozilla/5.0 Chrome/120.0")
|> get(~p"/")
send(Buffer, :flush)
:timer.sleep(50)
events =
from(e in Event, where: e.pathname == "/" and e.name == "pageview")
|> Repo.all()
assert length(events) >= 1
event = hd(events)
assert event.browser == "Chrome"
end
test "does not record on POST request", %{conn: conn} do
count_before = Repo.aggregate(Event, :count)
conn
|> put_req_header("user-agent", "Mozilla/5.0 Chrome/120.0")
|> post(~p"/checkout", %{})
send(Buffer, :flush)
:timer.sleep(50)
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
end
test "skips bots", %{conn: conn} do
count_before = Repo.aggregate(Event, :count)
conn
|> put_req_header("user-agent", "Googlebot/2.1 (+http://www.google.com/bot.html)")
|> get(~p"/")
send(Buffer, :flush)
:timer.sleep(50)
count_after = Repo.aggregate(Event, :count)
assert count_after == count_before
end
test "stores analytics data in session", %{conn: conn} do
conn =
conn
|> put_req_header("user-agent", "Mozilla/5.0 Firefox/121.0")
|> get(~p"/")
assert get_session(conn, "analytics_visitor_hash") |> is_binary()
assert get_session(conn, "analytics_browser") == "Firefox"
end
test "extracts referrer", %{conn: conn} do
conn
|> put_req_header("user-agent", "Mozilla/5.0 Chrome/120.0")
|> put_req_header("referer", "https://www.google.com/search?q=test")
|> get(~p"/")
send(Buffer, :flush)
:timer.sleep(50)
event =
from(e in Event,
where: e.referrer == "google.com",
order_by: [desc: e.inserted_at],
limit: 1
)
|> Repo.one()
assert event
assert event.referrer_source == "Google"
end
test "extracts UTM params", %{conn: conn} do
conn
|> put_req_header("user-agent", "Mozilla/5.0 Chrome/120.0")
|> get(~p"/?utm_source=newsletter&utm_medium=email&utm_campaign=spring")
send(Buffer, :flush)
:timer.sleep(50)
event =
from(e in Event,
where: e.utm_source == "newsletter",
order_by: [desc: e.inserted_at],
limit: 1
)
|> Repo.one()
assert event
assert event.utm_medium == "email"
assert event.utm_campaign == "spring"
end
end
end