GoodAnalytics

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.

Features

Prerequisites

Installation

From Hex

def deps do
[
{:good_analytics, "~> 0.1.2"}
]
end

As a Git Dependency

Add good_analytics to your dependencies in mix.exs:

def deps do
[
{:good_analytics, github: "agoodway/goodanalytics"}
]
end

As a Path Dependency

For local development against a checkout of this repo:

def deps do
[
{:good_analytics, path: "../goodanalytics"}
]
end

Then fetch dependencies:

mix deps.get

Quick Start

1. Configure the Library

Add 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_000

2. Run Database Setup

Generate the Ecto migration that creates the good_analytics schema and all ga_ tables:

mix good_analytics.setup
mix ecto.migrate

This creates tables for visitors, events, links, link clicks, connectors, and settings — all namespaced under the good_analytics PostgreSQL schema.

See Database Migrations for the full host-app workflow, including upgrades after library updates.

3. Download UA Inspector Databases

GoodAnalytics uses ua_inspector to parse user-agent strings into device, browser, and OS details. Download the detection databases:

mix ua_inspector.download

Or run both setup steps at once:

mix setup

The UA databases persist in _build and only need to be downloaded once.

4. (Optional) Set Up Geo Enrichment

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.

5. Mount Routes

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
end

Important: Place the short link catch-all routes (/:key) last in your router to avoid intercepting other routes.

6. Mount the REST API (Optional)

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
end

Mount the router:

# lib/my_app_web/router.ex
forward "/ga/api", GoodAnalytics.Api.Router

Serve the OpenAPI spec and Swagger UI (optional):

forward "/api/docs", GoodAnalytics.ApiSpec.Router

7. Serve the JavaScript Client

Configure 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: false

Include it in your root layout (recommended install — core script + fingerprint self-load):

<script src="/ga/js/good-analytics.js"></script>
<script>
GoodAnalytics.init({
endpoint: "/ga/t",
fingerprint: true
});
</script>

fingerprint: true self-loads thumbmark.js from the same tracking-host JS base path as good-analytics.js (derived from the script src, so cross-origin embeds work). Self-load is non-blocking: the initial pageview is not delayed for Thumbmark or vendor fingerprint generation. If the Thumbmark script fails to load (CSP, network, or host policy), the client logs a warning and continues without a live fingerprint.

CSP: hosts that use fingerprint: true must allow scripts from the tracking host in script-src (same host as good-analytics.js), because the client may inject thumbmark.js and the Thumbmark module may inject vendor/thumbmark.umd.js.

Advanced (explicit two-script install): the two-script + .use(ThumbmarkModule) path remains supported and is idempotent with fingerprint: true (no double-init):

<script src="/ga/js/good-analytics.js"></script>
<script src="/ga/js/thumbmark.js"></script>
<script>
GoodAnalytics.use(ThumbmarkModule).init({ endpoint: "/ga/t" });
</script>

JS Client options:

Option Default Description
endpoint — Path to the tracking beacon endpoint
fingerprint — true self-loads Thumbmark from the tracking host (non-blocking, after first pageview); a string is a precomputed fingerprint applied before the first pageview (no self-load)
clientAnonymousId false Mint and persist a durable client-side anonymous id (non-HttpOnly cookie + localStorage)
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.

Privacy: GoodAnalytics.forget()

Call GoodAnalytics.forget() on the client to purge all client-accessible, library-owned identity state. It best-effort expires non-HttpOnly cookies the library can write and clears matching localStorage keys and in-memory fields:

Surface Cleared?
Identity cookie (_ga_good) Yes (best-effort; expires with path=/;SameSite=Lax and Secure on HTTPS)
Referral cookie (_ga_ref) Yes
Identity localStorage (_ga_good_id) Yes
Fingerprint localStorage (_ga_good_fp) + in-memory fingerprint Yes
Client-anon cookie (_ga_good_anon) + storage (_ga_good_anon_id) + memory Yes
Click dedup sessionStorage (_ga_click_*) Yes
Server-owned HttpOnly _ga_anon No — not readable/deletable from JS

After forget(), setFingerprint no-ops for the rest of the page lifecycle (in-memory suppress only; a full page reload lifts it). Client forget() is complementary to server GoodAnalytics.forget_visitor/1 (which clears server-side visitor fingerprints and related state). On same-origin installs, post-forget beacons may still include HttpOnly _ga_anon until the server cookie is expired or rotated — call server forget_visitor/1 for a full continuity break. Client forget() does not permanently prevent browser fingerprint recomputation after reload when a fingerprint module remains installed.

Database Migrations

GoodAnalytics owns its schema. Versioned SQL lives in the library at priv/good_analytics/sql/versions/ and is applied through EctoEvolver. Your host app does not copy or edit that SQL — it adds thin Ecto migrations that delegate to GoodAnalytics.Migration.up/0 and GoodAnalytics.Migration.down/0.

All ga_* tables live in a dedicated PostgreSQL schema (default good_analytics, configurable via :schema_prefix). EctoEvolver tracks which library versions have been applied via a comment on the ga_version view, so each migration only runs pending versions.

Prerequisites

The Mix tasks read your host app's first configured Ecto repo from :ecto_repos:

# config/config.exs
config :my_app, ecto_repos: [MyApp.Repo]
config :good_analytics,
repo: MyApp.Repo

Migrations are written to priv/<repo_underscored>/migrations/ (for MyApp.Repo, that is priv/repo/migrations/).

First-time setup

Generate the bootstrap migration:

mix good_analytics.setup
mix ecto.migrate

mix good_analytics.setup creates a single *_setup_good_analytics.exs file:

defmodule MyApp.Repo.Migrations.SetupGoodAnalytics do
use Ecto.Migration
def up do
GoodAnalytics.Migration.up()
GoodAnalytics.PartitionManager.ensure_initial_partitions()
end
def down, do: GoodAnalytics.Migration.down()
end

The setup task is idempotent — re-running it skips generation if a setup migration already exists. If you accidentally have more than one *_setup_good_analytics.exs file, delete the extras and re-run mix ecto.migrate.

PartitionManager.ensure_initial_partitions/0 pre-creates the current and upcoming monthly partitions for ga_events so ingest works immediately. A background GenServer continues to maintain partitions after boot.

Upgrading after a library update

When you bump the good_analytics dependency and a new EctoEvolver version ships (v05, v06, etc.), generate a new host-app migration:

mix good_analytics.gen.migration
mix ecto.migrate

This creates an *_update_good_analytics.exs file that calls the same up/0 and down/0 functions. EctoEvolver compares the version comment on ga_version against its version list and applies only what is missing — existing databases skip already-applied versions.

You can also create the migration manually:

defmodule MyApp.Repo.Migrations.UpdateGoodAnalytics do
use Ecto.Migration
def up, do: GoodAnalytics.Migration.up()
def down, do: GoodAnalytics.Migration.down()
end

Run it with your normal Ecto workflow (mix ecto.migrate, release tasks, CI, etc.). GoodAnalytics schema changes ride alongside your own migrations in schema_migrations; only the SQL execution is library-owned.

Fresh dev environments

A common host-app alias chains your own migrations with the library setup:

# mix.exs
defp aliases do
[
"ecto.setup": [
"ecto.create",
"ecto.migrate",
"good_analytics.setup",
"ecto.migrate",
"run priv/repo/seeds.exs"
]
]
end

Run your app's migrations first so shared prerequisites exist, then generate and apply the GoodAnalytics migration.

Custom schema prefix

If you use a non-default schema name, set it before generating or running migrations:

config :good_analytics, schema_prefix: "my_analytics"

EctoEvolver substitutes $SCHEMA$ in the library SQL files with this value. Query with the matching prefix in application code:

MyApp.Repo.all(Visitor, prefix: "my_analytics")

Checking the applied version

To see which library schema version is on your database:

SELECT obj_description('good_analytics.ga_version'::regclass);
-- → 'GoodAnalytics version=12'

Replace good_analytics with your :schema_prefix if customized.

Background flows (PgFlow)

GoodAnalytics depends on {:pgflow, "~> 0.5.0"} and defines four flows: GoodAnalytics.Flows.CreatePartitions, GoodAnalytics.Flows.ConnectorPlanning, GoodAnalytics.Flows.ConnectorDelivery, and GoodAnalytics.Flows.ConnectorReconciliation. They run only when your application supervises PgFlow and registers them:

{PgFlow,
repo: MyApp.Repo,
flows: [
GoodAnalytics.Flows.CreatePartitions,
GoodAnalytics.Flows.ConnectorPlanning,
GoodAnalytics.Flows.ConnectorDelivery,
GoodAnalytics.Flows.ConnectorReconciliation
]}

Without a running PgFlow.Supervisor, partition creation falls back to direct SQL, and connector planning falls back to a supervised local task (it is skipped inside an open transaction).

If your application runs PgFlow.Supervisor but has not registered the GoodAnalytics flows, GoodAnalytics still routes partition creation and connector work through PgFlow. Partition creation falls back to direct SQL when PgFlow.start_flow/2 returns an error. Connector planning falls back to a local task on an error only outside a database transaction; when the call is made inside an open transaction, planning is skipped. A host that runs PgFlow should therefore register all four GoodAnalytics.Flows.* modules.

PgFlow 0.5 workers require the PgFlow core schema at version 2 and helpers at version 6; verify with mix pgflow.check_schema --repo MyApp.Repo. If your database was set up for an earlier GoodAnalytics build on PgFlow 0.4, stop every PgFlow 0.4 worker first, then run mix pgflow.setup --upgrade --repo MyApp.Repo and mix ecto.migrate before starting 0.5 workers. There is no mixed-version rolling upgrade.

What not to do

API Reference

Identity Resolution

# 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:

Event Tracking

# 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"})

Server-Side Tracking In Phoenix Controllers

GoodAnalytics.track/3 expects a resolved visitor. In Phoenix, resolve the visitor from the tracking signals assigned by GoodAnalytics.Core.Tracking.Plug, then pass that visitor to track/3, track_lead/3, or track_sale/3.

For browser GET requests that pass through the tracking plug:

def show(conn, _params) do
workspace_id = conn.assigns.workspace_id || GoodAnalytics.default_workspace_id()
{:ok, visitor} =
GoodAnalytics.resolve_visitor(conn.assigns.ga_signals,
workspace_id: workspace_id
)
{:ok, _event} =
GoodAnalytics.track(visitor, "custom", %{
event_name: "Viewed Dashboard",
url: Phoenix.Controller.current_url(conn)
})
render(conn, :show)
end

For POST requests or controller actions that may not run the tracking plug, build signals from the GoodAnalytics cookies:

def create(conn, params) do
conn = Plug.Conn.fetch_cookies(conn)
workspace_id = conn.assigns.workspace_id || GoodAnalytics.default_workspace_id()
signals = %{
ga_id: conn.cookies["_ga_good"],
anonymous_id: conn.cookies["_ga_anon"],
source: conn.assigns[:ga_source]
}
{:ok, visitor} =
GoodAnalytics.resolve_visitor(signals, workspace_id: workspace_id)
{:ok, _event} =
GoodAnalytics.track(visitor, "custom", %{
event_name: "Submitted Form",
properties: params
})
redirect(conn, to: ~p"/thanks")
end

If the user is authenticated, identify the visitor before recording the event:

{:ok, visitor} =
GoodAnalytics.identify(visitor, %{
person_external_id: to_string(conn.assigns.current_scope.user.id),
person_email: conn.assigns.current_scope.user.email
})
GoodAnalytics.track(visitor, "lead", %{url: Phoenix.Controller.current_url(conn)})

The server-side flow is:

conn/cookies -> signals -> GoodAnalytics.resolve_visitor/2 -> visitor -> GoodAnalytics.track/3

Server-Side Conversions

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:

Recorded events broadcast {:event_recorded, event} on:

REST API

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.

Events

# 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 ...

Visitors

# 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": {...}, ...}

Authentication

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

Configuration Reference

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}.

Visitors

GoodAnalytics.get_visitor(id)
GoodAnalytics.get_visitor_by_external_id(workspace_id, "cust_123")
GoodAnalytics.visitor_timeline(visitor_id)
GoodAnalytics.visitor_attribution(visitor_id)

Event Hooks

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)

Share URLs

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/...", ...}

Connector Configuration

Enabling Connectors

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, true

Per-Workspace Credentials

Enable 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")

Built-in Connectors

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
LinkedIn li_fat_id access_token, conversion_rule_id, ad_account_id
TikTok ttclid access_token, pixel_code

Custom Connectors

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

Mix Tasks

Task Description
mix good_analytics.setup Generate the initial *_setup_good_analytics.exs migration (first install only)
mix good_analytics.gen.migration Generate an *_update_good_analytics.exs migration for pending library versions
mix ua_inspector.download Download UA detection databases
mix setup Run deps.get + ua_inspector.download

See Database Migrations for when to use each task and how they fit into mix ecto.migrate.

Testing

Running the Test Suite

mix deps.get
mix test.setup # creates the test database
mix test

Test Configuration

Tests 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.Sandbox

Background partition creation is disabled in tests to avoid sandbox conflicts. Suites that need partitions call PartitionManager.create_partitions_direct/0 explicitly.

Quality Checks

mix quality

This runs: compile --warnings-as-errors, deps.unlock --unused, format --check-formatted, sobelow, ex_dna, doctor, and credo --strict.

License

MIT