Visitor intelligence, link tracking, source attribution, and behavioral analytics for Phoenix.
GoodAnalytics is a pluggable Elixir/Phoenix library that adds a visitor identity graph to any existing Phoenix application. Every tracking event enriches the same visitor record — anonymous visits, clicks, leads, and sales build a complete attribution picture over time. Short links act as identity bridges, connecting marketing channels to real visitors across sessions and devices.
- No extra infrastructure — All data lives in PostgreSQL in a
good_analyticsschema managed via ecto_evolver - Visitor identity graph — Anonymous visitors progressively enrich into identified leads and customers
- Click-to-conversion attribution — Short link clicks, pageviews, leads, and sales all tie back to the same visitor
- Source classification — Automatic detection of UTMs, ad platform click IDs (gclid, fbclid, li_fat_id, ttclid), referrers, and GA params
- Server-side conversion dispatch — Built-in connectors for Meta CAPI, Google Ads, LinkedIn, and TikTok
- Event hooks — Sync and async hooks let downstream consumers react to clicks, sales, and identity changes
- Library pattern — Borrows your app's Ecto repo. No separate database
- Elixir 1.17+
- PostgreSQL 14+
- An existing Phoenix application with an Ecto repository
Add good_analytics to your dependencies in mix.exs:
def deps do
[
{:good_analytics, github: "agoodway/goodanalytics"}
]
endFor local development against a checkout of this repo:
def deps do
[
{:good_analytics, path: "../goodanalytics"}
]
endThen fetch dependencies:
mix deps.getAdd the minimum required configuration to your Phoenix app:
# config/config.exs
config :good_analytics,
repo: MyApp.Repo,
api_key_secret: System.get_env("GA_API_KEY_SECRET")Configuration options:
| Key | Required | Default | Description |
|---|---|---|---|
:repo |
Yes | — | Your app's Ecto repo module |
:api_key_secret |
Yes | — | Secret key for API key encryption |
:schema_prefix |
No | "good_analytics" |
PostgreSQL schema name for all ga_ tables |
:connectors |
No | [] |
List of connector adapter modules |
:connectors_enabled |
No | true |
Global kill switch for connector dispatch |
:dispatch_policy |
No | — | {Module, :function} tuple for dispatch gating |
:auto_create_partitions |
No | true |
Whether to auto-create time partitions |
:links |
No | [] |
Link configuration, e.g. [domains: ["mybrand.link"]] |
:api_authenticate |
No | — | Auth callback for REST API ({Module, :function} or fn/2) |
Cache configuration (optional):
config :good_analytics, GoodAnalytics.Cache,
gc_interval: :timer.hours(1),
max_size: 10_000Generate the Ecto migration that creates the good_analytics schema and all ga_ tables:
mix good_analytics.setup
mix ecto.migrateThis creates tables for visitors, events, links, link clicks, connectors, and settings — all namespaced under the good_analytics PostgreSQL schema.
GoodAnalytics uses ua_inspector to parse user-agent strings into device, browser, and OS details. Download the detection databases:
mix ua_inspector.downloadOr run both setup steps at once:
mix setupThe UA databases persist in _build and only need to be downloaded once.
Populates visitor.geo with country/region/city/timezone/coordinates and enables country-routed redirects via link.geo_targeting. Off by default; Geo.lookup/1 returns {:error, :geo_disabled} until configured.
Add :locus to your host app:
{:locus, "~> 2.3"}Get a MaxMind license key (free signup at https://www.maxmind.com/en/geolite2/signup) and configure:
config :good_analytics, :geo,
provider: GoodAnalytics.Geo.Locus,
loader: {:maxmind, "GeoLite2-City"}
config :locus, license_key: System.get_env("MAXMIND_LICENSE_KEY")Locus auto-downloads the MMDB on boot and caches it in ~/.cache/locus_erlang. Lookups are :persistent_term-backed and sub-millisecond. Look for GoodAnalytics.Geo loader registered in the logs to confirm; use :ok = GoodAnalytics.Geo.Loader.await(30_000) to block in release tasks.
To use a non-MaxMind MMDB (DB-IP Lite, IPLocate, IP2Location LITE), configure a custom :loader and implement GoodAnalytics.Geo.Normalizer for that provider's fields. Only the MaxMind normalizer ships.
Add GoodAnalytics routes to your Phoenix router:
# lib/my_app_web/router.ex
defmodule MyAppWeb.Router do
use MyAppWeb, :router
# Tracking beacon endpoints (POST /ga/t/event, POST /ga/t/click)
forward "/ga/t", GoodAnalytics.Core.Tracking.Router
pipeline :browser do
# ... your existing plugs ...
# Add the tracking plug — classifies traffic source, manages identity
# cookies (_ga_good, _ga_anon), and assigns tracking signals
plug GoodAnalytics.Core.Tracking.Plug
end
# Short link QR code endpoint
pipeline :short_link_qr do
plug :fetch_query_params
end
# Short link redirect endpoint
pipeline :short_link do
plug :accepts, ["html"]
plug :fetch_query_params
end
scope "/" do
pipe_through :short_link_qr
get "/:key/qr", GoodAnalytics.Core.Links.QRController, :show
end
scope "/" do
pipe_through :short_link
get "/:key", GoodAnalytics.Core.Links.RedirectController, :show
end
endImportant: Place the short link catch-all routes (/:key) last in your router to avoid intercepting other routes.
GoodAnalytics includes a server-side REST API for event tracking, link management, and visitor queries. To enable it, configure an authentication callback and mount the API router.
Configure authentication:
# config/config.exs
config :good_analytics, :api_authenticate, {MyApp.Auth, :authenticate_ga_api}The callback receives (token, type) where type is :bearer or :api_key, and must return {:ok, %{workspace_id: uuid}} on success or {:error, reason} on failure:
# lib/my_app/auth.ex
defmodule MyApp.Auth do
def authenticate_ga_api(token, _type) do
case MyApp.ApiKeys.verify(token) do
{:ok, api_key} -> {:ok, %{workspace_id: api_key.workspace_id}}
:error -> {:error, :unauthorized}
end
end
endMount the router:
# lib/my_app_web/router.ex
forward "/ga/api", GoodAnalytics.Api.RouterServe the OpenAPI spec and Swagger UI (optional):
forward "/api/docs", GoodAnalytics.ApiSpec.RouterConfigure your endpoint to serve the JS tracking client:
# lib/my_app_web/endpoint.ex
plug Plug.Static,
at: "/ga/js",
from: {:good_analytics, "priv/static/js"},
gzip: falseInclude it in your root layout:
<script src="/ga/js/good-analytics.js"></script>
<script>GoodAnalytics.init({ endpoint: "/ga/t" });</script>JS Client options:
| Option | Default | Description |
|---|---|---|
endpoint |
— | Path to the tracking beacon endpoint |
autoSpaNavigation |
true |
Automatically track SPA navigation (pushState, popstate, hashchange) |
Disable automatic SPA tracking if your app sends manual pageviews:
<script>GoodAnalytics.init({ endpoint: "/ga/t", autoSpaNavigation: false });</script>Every beacon payload includes a UUIDv4 event_id idempotency key that host applications can use for retry deduplication.
# Resolve tracking signals to a visitor
{:ok, visitor} = GoodAnalytics.resolve_visitor(signals, workspace_id: ws_id)
# Associate a visitor with known person attributes
{:ok, visitor} = GoodAnalytics.identify(visitor, %{
person_external_id: "cust_123",
person_email: "alice@example.com",
person_name: "Alice"
})
# GDPR: Remove all PII and events for a visitor
:ok = GoodAnalytics.forget_visitor(visitor_id)Identity resolution progressively merges visitor records as signals accumulate:
- Strong signals (external ID, email,
ga_idcookie) can trigger merges on their own - Weak signals (fingerprint, anonymous cookie) require corroboration from other signals
# Record a pageview
GoodAnalytics.track(visitor, "pageview", %{url: "/pricing"})
# Record a lead conversion
GoodAnalytics.track_lead(visitor, %{person_external_id: "cust_123"})
# Record a sale
GoodAnalytics.track_sale(visitor, %{amount_cents: 4900, currency: "USD"})Submit conversions that also trigger connector dispatch (Meta CAPI, Google Ads, etc.):
# Lead conversion with connector signals
{:ok, event} = GoodAnalytics.submit_lead(visitor, %{
properties: %{"form" => "contact"}
}, connector_signals: %{"_fbp" => "fb.1.123", "gclid" => "abc"})
# Sale conversion with connector signals
{:ok, event} = GoodAnalytics.submit_sale(visitor, %{
amount_cents: 4900,
currency: "USD"
}, connector_signals: %{"_fbp" => "fb.1.123"})Both functions record a canonical internal event first, then trigger connector dispatch planning for all enabled connectors that have the required signals.
# Create a short link
{:ok, link} = GoodAnalytics.create_link(%{
workspace_id: "00000000-0000-0000-0000-000000000000",
domain: "mybrand.link",
key: "gw-launch",
url: "https://example.com/pricing",
# Optional
link_type: "campaign", # "short", "referral", or "campaign"
utm_source: "twitter",
utm_medium: "social",
utm_campaign: "launch-2026",
utm_content: "hero-link",
utm_term: "analytics",
ios_url: "myapp://pricing",
android_url: "myapp://pricing",
expires_at: ~U[2026-12-31 23:59:59Z],
tags: ["launch", "social"],
external_id: "campaign_123",
metadata: %{"owner" => "growth"}
})
# List links for a workspace
GoodAnalytics.list_links(workspace_id, limit: 50, offset: 0)
# Get link stats (aggregate counters)
GoodAnalytics.link_stats(link_id)
# Get recent click events for a link
GoodAnalytics.link_clicks(link_id, limit: 10)
# Soft-delete a link (frees domain+key for reuse)
GoodAnalytics.archive_link(link_id)Required link attributes: :workspace_id, :domain, :key, :url
PubSub topics: Click events broadcast {:link_click, link_id, unique?} on:
"good_analytics:link_clicks"— global topic"good_analytics:link_clicks:#{workspace_id}"— workspace-scoped
Recorded events broadcast {:event_recorded, event} on:
"good_analytics:events:#{workspace_id}"— workspace-scoped
The REST API provides server-side access to events, links, and visitors. All requests require a Bearer token or X-Api-Key header. The workspace is derived from the auth callback response — it never appears in the URL.
# Record a single event
curl -X POST /ga/api/events \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"visitor_id": "uuid", "event_type": "sale", "amount_cents": 4999, "currency": "USD"}'
# => 201 {"event_id": "uuid"}
# Idempotent submission (returns 200 if key already used)
curl -X POST /ga/api/events \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"visitor_id": "uuid", "event_type": "sale", "idempotency_key": "order-123"}'
# Batch events (up to 100, partial success returns 207)
curl -X POST /ga/api/events/batch \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"events": [
{"visitor_id": "uuid-1", "event_type": "sale", "amount_cents": 100},
{"person_external_id": "cust_123", "event_type": "lead"}
]}'
# => 201 {"results": [{"index": 0, "status": "ok", "event_id": "..."}, ...]}Visitor resolution: pass visitor_id (UUID, direct lookup) or person_external_id (resolved via workspace). If both are provided, visitor_id takes precedence. Returns 404 if the visitor is not found — server-side events never create visitors implicitly.
# Create a link
curl -X POST /ga/api/links \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"domain": "mybrand.link", "key": "promo", "url": "https://example.com/sale"}'
# => 201 {"id": "uuid", "domain": "mybrand.link", ...}
# List links (paginated)
curl /ga/api/links?limit=10&offset=0 -H "Authorization: Bearer $TOKEN"
# Get / Update / Archive a link
curl /ga/api/links/:id -H "Authorization: Bearer $TOKEN"
curl -X PATCH /ga/api/links/:id -d '{"url": "https://new.com"}' ...
curl -X DELETE /ga/api/links/:id ... # => 204 (soft delete)
# Link analytics
curl /ga/api/links/:id/stats ...
# => {"total_clicks": 42, "unique_clicks": 38, "total_leads": 5, "total_sales": 2, "total_revenue_cents": 9800}
curl /ga/api/links/:id/clicks?limit=20 ...# List visitors (excludes merged, paginated)
curl /ga/api/visitors?limit=20&offset=0 -H "Authorization: Bearer $TOKEN"
# Get by ID or external ID
curl /ga/api/visitors/:id ...
curl /ga/api/visitors/by-external-id/cust_123 ...
# Timeline and attribution
curl /ga/api/visitors/:id/timeline ...
curl /ga/api/visitors/:id/attribution ...
# => {"attribution_path": [...], "first_source": {...}, "last_source": {...}, ...}| Header | Type | Example |
|---|---|---|
Authorization |
Bearer token | Authorization: Bearer sk_live_abc123 |
X-Api-Key |
API key | X-Api-Key: gak_abc123 |
If both headers are present, Bearer takes precedence. Error responses:
| Status | Condition |
|---|---|
| 401 | Missing/invalid credentials, or {:error, :unauthorized} from callback |
| 403 | {:error, :forbidden} from callback |
| 503 | :api_authenticate config not set |
| Key | Required | Description |
|---|---|---|
:api_authenticate |
Yes | {Module, :function} tuple or fn token, type -> result end |
The callback receives (token, type) where type is :bearer or :api_key. Must return {:ok, %{workspace_id: uuid, ...}} or {:error, reason}.
GoodAnalytics.get_visitor(id)
GoodAnalytics.get_visitor_by_external_id(workspace_id, "cust_123")
GoodAnalytics.visitor_timeline(visitor_id)
GoodAnalytics.visitor_attribution(visitor_id)Register callbacks that fire on specific event types:
# Sync hook — runs during redirect (50ms timeout)
GoodAnalytics.register_hook(:link_click, fn event, visitor ->
{:ok, %{set_cookies: [{"partner_id", "abc", 30}]}}
end)Generate social sharing URLs for a link:
GoodAnalytics.share_urls("https://mybrand.link/gw-launch",
title: "Check this out",
text: "GoodAnalytics launch"
)
# => %{twitter: "https://twitter.com/intent/tweet?...", facebook: "https://www.facebook.com/sharer/...", ...}Register connector adapters at compile time:
# config/config.exs
config :good_analytics,
connectors: [
GoodAnalytics.Connectors.Adapters.Meta,
GoodAnalytics.Connectors.Adapters.Google,
GoodAnalytics.Connectors.Adapters.LinkedIn,
GoodAnalytics.Connectors.Adapters.TikTok
],
dispatch_policy: {MyApp.ConnectorPolicy, :evaluate}
# runtime.exs — global kill switch
config :good_analytics, :connectors_enabled, trueEnable connectors and store encrypted credentials per workspace:
alias GoodAnalytics.Connectors.Settings
# Enable Meta for a workspace
Settings.enable_connector(workspace_id, :meta)
# Store encrypted credentials
Settings.put_credential(workspace_id, :meta, "access_token", "EAAx...")
Settings.put_credential(workspace_id, :meta, "pixel_id", "123456")| Connector | Required Signals | Credential Keys |
|---|---|---|
| Meta CAPI | _fbp, _fbc, or fbclid |
access_token, pixel_id |
| Google Ads | gclid, gbraid, or wbraid |
customer_id, conversion_action_id, access_token |
li_fat_id |
access_token, conversion_rule_id, ad_account_id |
|
| TikTok | ttclid |
access_token, pixel_code |
Implement the GoodAnalytics.Connectors.Connector behaviour:
defmodule MyApp.Connectors.Custom do
@behaviour GoodAnalytics.Connectors.Connector
@impl true
def connector_type, do: :custom
@impl true
def supported_event_types, do: [:lead, :sale]
@impl true
def required_signals, do: [["my_signal"]]
@impl true
def credential_keys, do: ["api_key"]
@impl true
def build_payload(dispatch, credentials), do: {:ok, %{}}
@impl true
def deliver(payload, credentials), do: {:ok, %{status: 200}}
@impl true
def classify_error(%{status: 429}), do: :rate_limited
def classify_error(%{status: 401}), do: :credential
def classify_error(_), do: :transient
end| Task | Description |
|---|---|
mix good_analytics.setup |
Generate Ecto migration for all GoodAnalytics tables |
mix good_analytics.gen.migration |
Generate a new migration file |
mix ua_inspector.download |
Download UA detection databases |
mix setup |
Run deps.get + ua_inspector.download |
mix deps.get
mix test.setup # creates the test database
mix testTests use a dedicated GoodAnalytics.TestRepo pointing at a local PostgreSQL database:
# config/test.exs
config :good_analytics, GoodAnalytics.TestRepo,
username: "postgres",
password: "postgres",
hostname: "localhost",
database: "good_analytics_test",
pool: Ecto.Adapters.SQL.SandboxBackground partition creation is disabled in tests to avoid sandbox conflicts. Suites that need partitions call PartitionManager.create_partitions_direct/0 explicitly.
mix qualityThis runs: compile --warnings-as-errors, deps.unlock --unused, format --check-formatted, sobelow, ex_dna, doctor, and credo --strict.
MIT