berrypod/priv/repo/seeds/analytics.exs
jamey 65e646a7eb add analytics v2 plan, demo seed data, and improved funnel display
- analytics-v2 plan with prioritised improvements (comparison mode, filtering, CSV export, entry/exit pages)
- seed script generating ~35k realistic events over 12 months (weighted traffic, referrers, devices, e-commerce funnel)
- funnel chart now shows overall conversion rate from product views instead of step-to-step percentages
- summary line with overall conversion rate and revenue

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 22:30:24 +00:00

399 lines
11 KiB
Elixir

# Generates realistic analytics demo data spanning 12 months.
#
# mix run priv/repo/seeds/analytics.exs
#
# Clears existing analytics events first, then creates ~50k events with
# realistic traffic patterns, referrers, device mix, and e-commerce funnel.
alias Berrypod.Repo
alias Berrypod.Analytics.Event
# ── Config ──
# How many unique "visitors" to simulate per day (base — actual varies by day)
base_daily_visitors = 40
# Date range: 12 months back from today
end_date = Date.utc_today()
start_date = Date.add(end_date, -364)
# ── Reference data ──
# Shop pages and their relative popularity (weights)
pages = [
{"/", 30},
{"/collections/t-shirts", 15},
{"/collections/hoodies", 12},
{"/collections/mugs", 10},
{"/collections/stickers", 8},
{"/products/1", 8},
{"/products/2", 7},
{"/products/3", 6},
{"/products/4", 5},
{"/products/5", 5},
{"/products/6", 4},
{"/products/7", 3},
{"/products/8", 3},
{"/products/9", 2},
{"/products/10", 2},
{"/about", 4},
{"/delivery", 3},
{"/contact", 2},
{"/privacy", 1},
{"/terms", 1},
{"/cart", 6}
]
# Referrer sources with weights (nil = direct traffic)
sources = [
{nil, nil, 35},
{"google.com", "Google", 25},
{"instagram.com", "Instagram", 10},
{"facebook.com", "Facebook", 8},
{"tiktok.com", "TikTok", 6},
{"twitter.com", "Twitter", 4},
{"pinterest.com", "Pinterest", 4},
{"reddit.com", "Reddit", 3},
{"youtube.com", "YouTube", 2},
{"bing.com", "Bing", 1},
{"etsy.com", nil, 1},
{"blogpost.example.com", nil, 1}
]
# UTM campaigns (only applied to social/search traffic, not direct)
campaigns = [
{nil, nil, nil, 60},
{"instagram", "social", "summer_sale", 10},
{"facebook", "cpc", "retarget_q4", 8},
{"google", "cpc", "brand_terms", 7},
{"tiktok", "social", "viral_hoodie", 5},
{"newsletter", "email", "weekly_digest", 5},
{"pinterest", "social", "pin_collection", 3},
{"twitter", "social", "launch_promo", 2}
]
# Countries with weights (UK-focused shop)
countries = [
{"GB", 40},
{"US", 15},
{"DE", 7},
{"FR", 5},
{"CA", 4},
{"AU", 4},
{"NL", 3},
{"IE", 3},
{"SE", 2},
{"IT", 2},
{"ES", 2},
{"BE", 2},
{"NO", 1},
{"DK", 1},
{"PL", 1},
{"CH", 1},
{"NZ", 1},
{"JP", 1},
{"IN", 2},
{"BR", 2},
{"PT", 1}
]
# Browsers (matches UAParser output)
browsers = [
{"Chrome", 55},
{"Safari", 25},
{"Firefox", 10},
{"Edge", 8},
{"Opera", 2}
]
# Operating systems (matches UAParser output)
oses = [
{"iOS", 30},
{"Android", 25},
{"Windows", 22},
{"macOS", 15},
{"Linux", 5},
{"ChromeOS", 3}
]
# Screen sizes (matches classify_screen/1 in AnalyticsHook)
screen_sizes = [
{"mobile", 55},
{"tablet", 12},
{"desktop", 33}
]
# ── Helpers ──
# Weighted random pick from a list of {item, weight} or {a, b, weight} tuples
weighted_pick = fn items ->
total = items |> Enum.map(&elem(&1, tuple_size(&1) - 1)) |> Enum.sum()
roll = :rand.uniform() * total
Enum.reduce_while(items, 0.0, fn item, acc ->
weight = elem(item, tuple_size(item) - 1)
acc = acc + weight
if acc >= roll, do: {:halt, item}, else: {:cont, acc}
end)
end
# Day-of-week multiplier (weekends get more traffic for a consumer shop)
day_multiplier = fn date ->
case Date.day_of_week(date) do
6 -> 1.3
7 -> 1.2
1 -> 0.85
_ -> 1.0
end
end
# Monthly growth curve — traffic grows over time (new shop ramping up)
month_multiplier = fn date ->
months_ago = Date.diff(end_date, date) / 30.0
# Start at 0.3x and grow to 1.0x over the year
max(0.3, 1.0 - months_ago * 0.06)
end
# Seasonal bumps (Nov-Dec holiday shopping, Jan sale)
seasonal_multiplier = fn date ->
case date.month do
11 -> 1.4
12 -> 1.8
1 -> 1.3
6 -> 1.1
7 -> 1.1
_ -> 1.0
end
end
# Random time of day (weighted toward daytime UK hours)
random_time = fn ->
# Bell curve centered around 14:00 UTC (afternoon UK time)
hour = min(23, max(0, round(:rand.normal(14.0, 4.0))))
minute = :rand.uniform(60) - 1
second = :rand.uniform(60) - 1
Time.new!(hour, minute, second)
end
# ── Clear existing data ──
IO.puts("Clearing existing analytics events...")
{deleted, _} = Repo.delete_all(Event)
IO.puts(" Deleted #{deleted} existing events")
# ── Generate events ──
IO.puts("Generating analytics data from #{start_date} to #{end_date}...")
dates = Date.range(start_date, end_date)
all_events =
Enum.flat_map(dates, fn date ->
# Calculate visitor count for this day
base = base_daily_visitors
multiplied =
base * day_multiplier.(date) * month_multiplier.(date) * seasonal_multiplier.(date)
# Add some randomness (+/- 20%)
jitter = 0.8 + :rand.uniform() * 0.4
visitor_count = round(multiplied * jitter)
# Generate visitors for this day
Enum.flat_map(1..visitor_count, fn _v ->
visitor_hash = :crypto.strong_rand_bytes(8)
session_hash = :crypto.strong_rand_bytes(8)
# Pick visitor attributes (consistent within a visit)
{referrer, referrer_source, _} = weighted_pick.(sources)
{country_code, _} = weighted_pick.(countries)
{browser, _} = weighted_pick.(browsers)
{os, _} = weighted_pick.(oses)
{screen_size, _} = weighted_pick.(screen_sizes)
# Maybe assign UTM params (only if has a referrer source)
{utm_source, utm_medium, utm_campaign} =
if referrer_source do
{src, med, camp, _} = weighted_pick.(campaigns)
{src, med, camp}
else
{nil, nil, nil}
end
# How many pages does this visitor view? (1-6, weighted toward fewer)
page_count = min(6, max(1, round(:rand.normal(2.0, 1.2))))
# Pick pages for this session
session_pages =
Enum.map(1..page_count, fn _ ->
{page, _} = weighted_pick.(pages)
page
end)
# Generate pageview events with increasing timestamps
base_time = random_time.()
base_dt = DateTime.new!(date, base_time, "Etc/UTC")
pageview_events =
session_pages
|> Enum.with_index()
|> Enum.map(fn {pathname, i} ->
# Each subsequent page is 15-120 seconds later
offset = if i == 0, do: 0, else: Enum.sum(for _ <- 1..i, do: 15 + :rand.uniform(105))
ts = DateTime.add(base_dt, offset, :second)
[
id: Ecto.UUID.generate(),
name: "pageview",
pathname: pathname,
visitor_hash: visitor_hash,
session_hash: session_hash,
referrer: referrer,
referrer_source: referrer_source,
utm_source: utm_source,
utm_medium: utm_medium,
utm_campaign: utm_campaign,
country_code: country_code,
browser: browser,
os: os,
screen_size: screen_size,
revenue: nil,
inserted_at: DateTime.truncate(ts, :second)
]
end)
# E-commerce funnel events (progressive drop-off)
# Only trigger if visitor viewed a product page
viewed_product = Enum.any?(session_pages, &String.starts_with?(&1, "/products/"))
ecommerce_events =
if viewed_product do
product_path = Enum.find(session_pages, &String.starts_with?(&1, "/products/"))
last_ts = pageview_events |> List.last() |> Keyword.get(:inserted_at)
base_attrs = [
visitor_hash: visitor_hash,
session_hash: session_hash,
referrer: referrer,
referrer_source: referrer_source,
utm_source: utm_source,
utm_medium: utm_medium,
utm_campaign: utm_campaign,
country_code: country_code,
browser: browser,
os: os,
screen_size: screen_size
]
# product_view: 100% of product page viewers
product_view = [
[
id: Ecto.UUID.generate(),
name: "product_view",
pathname: product_path,
revenue: nil,
inserted_at: DateTime.add(last_ts, 5, :second)
] ++ base_attrs
]
# add_to_cart: ~35% of product viewers
add_to_cart =
if :rand.uniform() < 0.35 do
[
[
id: Ecto.UUID.generate(),
name: "add_to_cart",
pathname: product_path,
revenue: nil,
inserted_at: DateTime.add(last_ts, 30, :second)
] ++ base_attrs
]
else
[]
end
# checkout_start: ~60% of add-to-carters
checkout_start =
if add_to_cart != [] and :rand.uniform() < 0.60 do
[
[
id: Ecto.UUID.generate(),
name: "checkout_start",
pathname: "/cart",
revenue: nil,
inserted_at: DateTime.add(last_ts, 60, :second)
] ++ base_attrs
]
else
[]
end
# purchase: ~70% of checkout starters
purchase =
if checkout_start != [] and :rand.uniform() < 0.70 do
# Revenue between 1500 (GBP 15.00) and 8500 (GBP 85.00)
revenue = 1500 + :rand.uniform(7000)
[
[
id: Ecto.UUID.generate(),
name: "purchase",
pathname: "/checkout/success",
revenue: revenue,
inserted_at: DateTime.add(last_ts, 120, :second)
] ++ base_attrs
]
else
[]
end
product_view ++ add_to_cart ++ checkout_start ++ purchase
else
[]
end
pageview_events ++ ecommerce_events
end)
end)
# ── Batch insert ──
total = length(all_events)
IO.puts("Inserting #{total} events in batches...")
all_events
|> Enum.chunk_every(1000)
|> Enum.with_index(1)
|> Enum.each(fn {batch, i} ->
Repo.insert_all(Event, batch)
progress = min(i * 1000, total)
IO.write("\r #{progress}/#{total}")
end)
IO.puts("\n Done!")
# ── Summary stats ──
pageviews = Enum.count(all_events, fn e -> Keyword.get(e, :name) == "pageview" end)
product_views = Enum.count(all_events, fn e -> Keyword.get(e, :name) == "product_view" end)
add_to_carts = Enum.count(all_events, fn e -> Keyword.get(e, :name) == "add_to_cart" end)
checkouts = Enum.count(all_events, fn e -> Keyword.get(e, :name) == "checkout_start" end)
purchases = Enum.count(all_events, fn e -> Keyword.get(e, :name) == "purchase" end)
total_revenue =
all_events
|> Enum.filter(fn e -> Keyword.get(e, :name) == "purchase" end)
|> Enum.map(fn e -> Keyword.get(e, :revenue) end)
|> Enum.sum()
IO.puts("""
Summary:
Pageviews: #{pageviews}
Product views: #{product_views}
Add to cart: #{add_to_carts}
Checkouts: #{checkouts}
Purchases: #{purchases}
Revenue: GBP #{Float.round(total_revenue / 100, 2)}
""")