Skip to content

Latest commit

 

History

History
849 lines (615 loc) · 27.4 KB

File metadata and controls

849 lines (615 loc) · 27.4 KB

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.

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.

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

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

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