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:
parent
b0aed4c1d6
commit
2bd2e613c7
@ -9,7 +9,7 @@
|
||||
- Image optimization pipeline (AVIF/WebP/JPEG responsive variants)
|
||||
- Shop pages (home, collections, products, cart, about, contact, error, delivery, privacy, terms)
|
||||
- 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)
|
||||
- Variant selector with color swatches and size buttons
|
||||
- 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 |
|
||||
| | **Next up** | | | |
|
||||
| ~~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)) | | | |
|
||||
| ~~41~~ | ~~Provider + payment registries~~ | — | 30m | 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** | | | |
|
||||
| ~~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.
|
||||
|
||||
@ -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] 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] 898 tests total (6 correctness + 3 benchmarks excluded by default)
|
||||
- [x] 972 tests total (5 excluded: 3 benchmarks + 2 correctness)
|
||||
|
||||
### Page Editor
|
||||
**Status:** Future (Tier 4)
|
||||
@ -389,6 +389,7 @@ See: [docs/plans/page-builder.md](docs/plans/page-builder.md) for design
|
||||
|
||||
| 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 |
|
||||
| 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 |
|
||||
|
||||
@ -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 liveSocket = new LiveSocket("/live", Socket, {
|
||||
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
|
||||
|
||||
@ -93,7 +93,8 @@ config :berrypod, Oban,
|
||||
{Oban.Plugins.Cron,
|
||||
crontab: [
|
||||
{"*/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]
|
||||
|
||||
284
lib/berrypod/analytics.ex
Normal file
284
lib/berrypod/analytics.ex
Normal 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
|
||||
151
lib/berrypod/analytics/buffer.ex
Normal file
151
lib/berrypod/analytics/buffer.ex
Normal 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
|
||||
30
lib/berrypod/analytics/event.ex
Normal file
30
lib/berrypod/analytics/event.ex
Normal 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
|
||||
72
lib/berrypod/analytics/referrer.ex
Normal file
72
lib/berrypod/analytics/referrer.ex
Normal 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
|
||||
30
lib/berrypod/analytics/retention_worker.ex
Normal file
30
lib/berrypod/analytics/retention_worker.ex
Normal 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
|
||||
75
lib/berrypod/analytics/salt.ex
Normal file
75
lib/berrypod/analytics/salt.ex
Normal 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
|
||||
75
lib/berrypod/analytics/ua_parser.ex
Normal file
75
lib/berrypod/analytics/ua_parser.ex
Normal 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
|
||||
@ -22,6 +22,9 @@ defmodule Berrypod.Application do
|
||||
{Phoenix.PubSub, name: Berrypod.PubSub},
|
||||
# Background job processing
|
||||
{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
|
||||
Berrypod.Images.VariantCache,
|
||||
# Start to serve requests
|
||||
|
||||
100
lib/berrypod_web/analytics_hook.ex
Normal file
100
lib/berrypod_web/analytics_hook.ex
Normal 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
|
||||
@ -62,6 +62,14 @@
|
||||
<.icon name="hero-home" class="size-5" /> Dashboard
|
||||
</.link>
|
||||
</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>
|
||||
<.link
|
||||
navigate={~p"/admin/orders"}
|
||||
|
||||
@ -1,2 +1,3 @@
|
||||
<.shop_flash_group flash={@flash} />
|
||||
<div id="analytics-init" phx-hook="AnalyticsInit" phx-update="ignore" style="display:none"></div>
|
||||
{@inner_content}
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
defmodule BerrypodWeb.CheckoutController do
|
||||
use BerrypodWeb, :controller
|
||||
|
||||
alias Berrypod.Cart
|
||||
alias Berrypod.{Analytics, Cart}
|
||||
alias Berrypod.Orders
|
||||
alias Berrypod.Shipping
|
||||
|
||||
@ -16,6 +16,7 @@ defmodule BerrypodWeb.CheckoutController do
|
||||
|> put_flash(:error, "Your basket is empty")
|
||||
|> redirect(to: ~p"/cart")
|
||||
else
|
||||
track_checkout_start(conn)
|
||||
create_checkout(conn, hydrated)
|
||||
end
|
||||
end
|
||||
@ -126,4 +127,15 @@ defmodule BerrypodWeb.CheckoutController do
|
||||
end
|
||||
|
||||
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
|
||||
|
||||
459
lib/berrypod_web/live/admin/analytics.ex
Normal file
459
lib/berrypod_web/live/admin/analytics.ex
Normal 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
|
||||
@ -1,7 +1,7 @@
|
||||
defmodule BerrypodWeb.Shop.CheckoutSuccess do
|
||||
use BerrypodWeb, :live_view
|
||||
|
||||
alias Berrypod.Orders
|
||||
alias Berrypod.{Analytics, Orders}
|
||||
|
||||
@impl true
|
||||
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")
|
||||
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
|
||||
socket =
|
||||
if order && connected?(socket) do
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
defmodule BerrypodWeb.Shop.ProductShow do
|
||||
use BerrypodWeb, :live_view
|
||||
|
||||
alias Berrypod.Cart
|
||||
alias Berrypod.{Analytics, Cart}
|
||||
alias Berrypod.Images.Optimizer
|
||||
alias Berrypod.Products
|
||||
alias Berrypod.Products.{Product, ProductImage}
|
||||
@ -41,6 +41,13 @@ defmodule BerrypodWeb.Shop.ProductShow do
|
||||
display_price = variant_price(selected_variant, product)
|
||||
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
|
||||
|> assign(:page_title, product.title)
|
||||
@ -177,6 +184,13 @@ defmodule BerrypodWeb.Shop.ProductShow do
|
||||
if variant do
|
||||
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
|
||||
|> BerrypodWeb.CartHook.broadcast_and_update(cart)
|
||||
|
||||
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
|
||||
@ -37,6 +37,7 @@ defmodule BerrypodWeb.Router do
|
||||
pipeline :shop do
|
||||
plug :put_root_layout, html: {BerrypodWeb.Layouts, :shop_root}
|
||||
plug BerrypodWeb.Plugs.LoadTheme
|
||||
plug BerrypodWeb.Plugs.Analytics
|
||||
end
|
||||
|
||||
pipeline :admin do
|
||||
@ -63,7 +64,8 @@ defmodule BerrypodWeb.Router do
|
||||
{BerrypodWeb.ThemeHook, :mount_theme},
|
||||
{BerrypodWeb.ThemeHook, :require_site_live},
|
||||
{BerrypodWeb.CartHook, :mount_cart},
|
||||
{BerrypodWeb.SearchHook, :mount_search}
|
||||
{BerrypodWeb.SearchHook, :mount_search},
|
||||
{BerrypodWeb.AnalyticsHook, :track}
|
||||
] do
|
||||
live "/", Shop.Home, :index
|
||||
live "/about", Shop.Content, :about
|
||||
@ -170,6 +172,7 @@ defmodule BerrypodWeb.Router do
|
||||
{BerrypodWeb.AdminLayoutHook, :assign_current_path}
|
||||
] do
|
||||
live "/", Admin.Dashboard, :index
|
||||
live "/analytics", Admin.Analytics, :index
|
||||
live "/orders", Admin.Orders, :index
|
||||
live "/orders/:id", Admin.OrderShow, :show
|
||||
live "/products", Admin.Products, :index
|
||||
|
||||
@ -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
|
||||
87
test/berrypod/analytics/buffer_test.exs
Normal file
87
test/berrypod/analytics/buffer_test.exs
Normal 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
|
||||
74
test/berrypod/analytics/referrer_test.exs
Normal file
74
test/berrypod/analytics/referrer_test.exs
Normal 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
|
||||
40
test/berrypod/analytics/retention_worker_test.exs
Normal file
40
test/berrypod/analytics/retention_worker_test.exs
Normal 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
|
||||
37
test/berrypod/analytics/salt_test.exs
Normal file
37
test/berrypod/analytics/salt_test.exs
Normal 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
|
||||
81
test/berrypod/analytics/ua_parser_test.exs
Normal file
81
test/berrypod/analytics/ua_parser_test.exs
Normal 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
|
||||
250
test/berrypod/analytics_test.exs
Normal file
250
test/berrypod/analytics_test.exs
Normal 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
|
||||
106
test/berrypod_web/live/admin/analytics_test.exs
Normal file
106
test/berrypod_web/live/admin/analytics_test.exs
Normal 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
|
||||
115
test/berrypod_web/plugs/analytics_test.exs
Normal file
115
test/berrypod_web/plugs/analytics_test.exs
Normal 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
|
||||
Loading…
Reference in New Issue
Block a user