Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

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

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

About

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

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages