Packages
good_analytics
0.1.1
Visitor intelligence, link tracking, source attribution, and behavioral analytics for Phoenix
Current section
Files
Jump to
Current section
Files
good_analytics
README.md
README.md
# 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
- **No extra infrastructure** — All data lives in PostgreSQL in a `good_analytics` schema 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
## Prerequisites
- Elixir 1.18+
- PostgreSQL 14+
- An existing Phoenix application with an Ecto repository
## Installation
### From Hex
```elixir
def deps do
[
{:good_analytics, "~> 0.1.1"}
]
end
```
### As a Git Dependency
Add `good_analytics` to your dependencies in `mix.exs`:
```elixir
def deps do
[
{:good_analytics, github: "agoodway/goodanalytics"}
]
end
```
### As a Path Dependency
For local development against a checkout of this repo:
```elixir
def deps do
[
{:good_analytics, path: "../goodanalytics"}
]
end
```
Then fetch dependencies:
```bash
mix deps.get
```
## Quick Start
### 1. Configure the Library
Add the minimum required configuration to your Phoenix app:
```elixir
# 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):
```elixir
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:
```bash
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](#database-migrations) for the full host-app workflow, including upgrades after library updates.
### 3. Download UA Inspector Databases
GoodAnalytics uses [ua_inspector](https://hex.pm/packages/ua_inspector) to parse user-agent strings into device, browser, and OS details. Download the detection databases:
```bash
mix ua_inspector.download
```
Or run both setup steps at once:
```bash
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:
```elixir
{:locus, "~> 2.3"}
```
Get a MaxMind license key (free signup at https://www.maxmind.com/en/geolite2/signup) and configure:
```elixir
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:
```elixir
# 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:**
```elixir
# 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:
```elixir
# 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:**
```elixir
# lib/my_app_web/router.ex
forward "/ga/api", GoodAnalytics.Api.Router
```
**Serve the OpenAPI spec and Swagger UI** (optional):
```elixir
forward "/api/docs", GoodAnalytics.ApiSpec.Router
```
### 7. Serve the JavaScript Client
Configure your endpoint to serve the JS tracking client:
```elixir
# 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):
```html
<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):
```html
<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:
```html
<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](https://hex.pm/packages/ecto_evolver). 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`:
```elixir
# 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:
```bash
mix good_analytics.setup
mix ecto.migrate
```
`mix good_analytics.setup` creates a single `*_setup_good_analytics.exs` file:
```elixir
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:
```bash
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:
```elixir
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:
```elixir
# 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:
```elixir
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:
```elixir
MyApp.Repo.all(Visitor, prefix: "my_analytics")
```
### Checking the applied version
To see which library schema version is on your database:
```sql
SELECT obj_description('good_analytics.ga_version'::regclass);
-- → 'GoodAnalytics version=12'
```
Replace `good_analytics` with your `:schema_prefix` if customized.
### What not to do
- Do not edit files under `deps/good_analytics/priv/good_analytics/sql/` — changes are lost on the next `mix deps.get`.
- Do not hand-roll DDL for `ga_*` tables in host-app migrations — add a version in the library instead.
- Do not generate multiple setup migrations; use `mix good_analytics.gen.migration` for subsequent upgrades.
## API Reference
### Identity Resolution
```elixir
# 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_id` cookie) can trigger merges on their own
- **Weak signals** (fingerprint, anonymous cookie) require corroboration from other signals
### Event Tracking
```elixir
# 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:
```elixir
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:
```elixir
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:
```elixir
{: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:
```text
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.):
```elixir
# 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.
### Link Management
```elixir
# 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
### 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
```bash
# 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.
#### Links
```bash
# 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
```bash
# 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
```elixir
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:
```elixir
# 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:
```elixir
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:
```elixir
# 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:
```elixir
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:
```elixir
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](#database-migrations) for when to use each task and how they fit into `mix ecto.migrate`.
## Testing
### Running the Test Suite
```bash
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:
```elixir
# 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
```bash
mix quality
```
This runs: `compile --warnings-as-errors`, `deps.unlock --unused`, `format --check-formatted`, `sobelow`, `ex_dna`, `doctor`, and `credo --strict`.
## License
MIT