Skip to content

agoodway/goodanalytics

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

10 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

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.17+
  • PostgreSQL 14+
  • An existing Phoenix application with an Ecto repository

Installation

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.

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:

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

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:

  • 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

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

Link Management

# 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

# 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

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

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

About

Visitor intelligence, link tracking, source attribution, and behavioral analytics for Phoenix

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors