Skip to content

AppSignal → Starter tier (250K/mo): ignore Oban + bot-filter + sample anonymous traffic, then measure the drop #1100

Description

@holden

Goal

Drive AppSignal request-billing on cinegraph/prod down to the Starter tier (250K req/mo ≈ 8,300 req/day) and hold it there for the rolling-30-day window required to downgrade.

Baseline + full audit: #1098. This issue is the implementation playbook — ship all levers in one pass, then watch the drop over several days and tune.

Where we are (measured 2026-06-09, 7-day window): ~18.4M req/mo · oban 54.5% · web 45.4% · graphql 0.06%. One open exception: DBConnection.ConnectionError × 40,916 (replica pool, #1018).
Target: 250K/mo = 8,300/day. That's a 73× cut — it requires sampling, not just filtering. This is a deliberate trade: keep all errors + all admin/api/auth + a representative sample of anonymous traffic; stop paying to record every bot page-view.


Why sampling is mandatory (the decisive data)

web broken down by action over 7 days — this is the entire ~279K/day web bill:

Action group 7-day % of web
People detail pages (/people/:slug, /classic, /legacy, /.../movies, /directors/:id, /.../movies/acting) 1,303,418 66.8%
Movie detail pages (/movies/:slug + /legacy) 476,192 24.4%
Collaborations (/collaborations) 132,322 6.8%
Companies (/companies/:slug) 31,227 1.6%
Auth + admin + api + index + health + sitemap (the stuff worth keeping) ~7,000 0.4%

Two findings that set the strategy:

  1. ~98% of the web bill is anonymous content-detail-page hits on a dead-flat 24/7 curve = crawler traffic indexing the slug space (1.15M movies + all people/directors). Real interactive traffic is a rounding error (~7K/wk for auth/admin/api combined).
  2. Zero Elixir.*Live actions appear → LiveView connected (WebSocket) mounts are not separately billed. All web billing is plain HTTP route transactions. (This kills the LiveView-leak worry from AppSignal: cut request-billing (~18.4M/mo measured — Oban 54%, bot-heavy web, replica-pool error storm) #1098 Step 2 — no mount/3 guard needed.)

Starter's 8,300/day budget is less than the real + uncaught crawler residue on these routes even after a bot filter, so we must sample anonymous traffic. Errors are unaffected: CinegraphWeb.Router already does use Honeybadger.Plug (router.ex:3), so dropping a transaction from AppSignal billing never loses error visibility.


The plan — 4 levers, shipped together (Phase A), tuned over days (Phase B)

# Lever Mechanism Expected effect
1 Ignore oban namespace + noise actions config −54.5% + health/sitemap/redirect noise → frees the whole budget for web
2 Drop bot/crawler User-Agents endpoint plug, Tracer.ignore/0 web is ~all crawler; large cut
3 Sample remaining anonymous content same plug, runtime-tunable sample_rate brings web under 8,300/day
4 Fix the error sources anyway code (#1018 replica pool, dead-slug 404s) cuts both real volume + lets us sample less aggressively

Levers 1–3 ship in one PR ("do all of them in one, see how it drops" — @razrfly). Lever 4 can follow but reduces how hard #3 must sample.


Phase A — implementation (one PR)

A1. AppSignal config — ignore Oban + noise actions

config/config.exs currently has a bare block at lines 328-331. Extend it (action names verified against the router):

config :appsignal, :config,
  otp_app: :cinegraph,
  name: "cinegraph",
  env: config_env(),
  # Oban errors are already captured via Honeybadger.Plug (router.ex:3) — nothing lost. ~54.5% of volume.
  ignore_namespaces: ["oban"],
  ignore_actions: [
    "CinegraphWeb.HealthController#index",
    "CinegraphWeb.HealthController#database",
    "CinegraphWeb.HealthController#metrics",
    "CinegraphWeb.PageController#redirect_to_movies",
    "CinegraphWeb.PageController#manifesto",
    "CinegraphWeb.SitemapController#index",
    "CinegraphWeb.SitemapController#show"
  ]

runtime.exs:87 separately merges push_api_key: into the same config key — that's fine, just don't overwrite the keyword list.

A2. Sampler plug — bot filter + anonymous sampling

lib/cinegraph_web/plugs/appsignal_sampler.ex:

defmodule CinegraphWeb.Plugs.AppsignalSampler do
  @moduledoc """
  Keeps AppSignal request-billing under the Starter tier (250K/mo). See #1098.

  Runs right after Plug.Telemetry (endpoint.ex:42), before the router.
  Order of decisions:
    1. Operational surfaces (/admin, /api, /health) -> always tracked, never sampled.
    2. Bot / crawler / empty User-Agent              -> dropped (Tracer.ignore/0).
    3. Everything else (anonymous public traffic)    -> keep only `sample_rate` of it.

  Errors are unaffected: they're captured by Honeybadger (router.ex:3), so dropping a
  transaction from AppSignal billing never loses error visibility.
  """
  @behaviour Plug
  import Plug.Conn, only: [get_req_header: 2]

  @excluded_prefixes ["/admin", "/api", "/health"]

  @bot_pattern ~r/bot|crawler|spider|slurp|googlebot|bingbot|duckduckbot|yandexbot|baiduspider|applebot|petalbot|sogou|facebookexternalhit|twitterbot|linkedinbot|slackbot|discordbot|telegrambot|whatsapp|pinterest|redditbot|embedly|mastodon|ia_archiver|archive\.org_bot|gptbot|chatgpt-user|oai-searchbot|claudebot|anthropic-ai|perplexitybot|amazonbot|bytespider|dataforseo|semrushbot|ahrefsbot|mj12bot|dotbot|headlesschrome|phantomjs|python-requests|go-http-client|node-fetch|axios|okhttp|libwww-perl|curl|wget|scrapy|uptimerobot|pingdom|statuscake/i

  @impl Plug
  def init(opts), do: opts

  @impl Plug
  def call(conn, _opts) do
    cfg = Application.get_env(:cinegraph, __MODULE__, [])

    cond do
      not Keyword.get(cfg, :enabled, false) -> conn
      not filterable_path?(conn.request_path) -> conn                  # keep ops surfaces 100%
      bot_request?(conn) -> ignore(conn)                               # drop all bots
      :rand.uniform() <= Keyword.get(cfg, :sample_rate, 1.0) -> conn   # keep sampled fraction
      true -> ignore(conn)                                             # drop the rest
    end
  end

  defp ignore(conn) do
    Appsignal.Tracer.ignore()
    conn
  end

  defp filterable_path?(path),
    do: not Enum.any?(@excluded_prefixes, &(path == &1 or String.starts_with?(path, &1 <> "/")))

  defp bot_request?(conn) do
    case get_req_header(conn, "user-agent") do
      [ua | _] -> ua == "" or Regex.match?(@bot_pattern, ua)
      [] -> true
    end
  end
end

A3. Endpoint wiring

lib/cinegraph_web/endpoint.ex — immediately after Plug.Telemetry (line 42):

plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint]
plug CinegraphWeb.Plugs.AppsignalSampler   # <-- add

A4. Config switches (off in dev/test, on in prod with a conservative initial rate)

# config/config.exs
config :cinegraph, CinegraphWeb.Plugs.AppsignalSampler, enabled: false, sample_rate: 1.0

# config/prod.exs  — start conservative; tune in Phase B
config :cinegraph, CinegraphWeb.Plugs.AppsignalSampler, enabled: true, sample_rate: 0.2

Why sample_rate is config, not hardcoded: we don't yet know the true bot-capture rate, so we ship at 0.2, measure the real post-filter number, then tune (one-line change, no code).

A5. Test

test/cinegraph_web/plugs/appsignal_sampler_test.exs — UA matrix (real browser passes, known bots dropped, empty/missing UA dropped), excluded-path pass-through (/admin, /api, /health never ignored), and sample_rate: 0.0 drops a non-bot anonymous request while 1.0 keeps it. Appsignal.Tracer.ignore/0 is a safe no-op when the agent isn't running, so the plug is testable without AppSignal active.


Phase B — measure & tune (the multi-day part)

Deploy A, then watch for 3–5 days (crawler traffic is steady, so a couple of full days is enough to read the new floor). Use the AppSignal Usage tab, or the MCP / dashboard query:

  • transaction_duration count, tag namespace=* → confirm oban is gone and read the new web total.
  • transaction_duration count, tags action=*, namespace=web → confirm the content routes collapsed and nothing unexpected is leaking.

Tuning rule: after the bot filter settles, read web_per_day. Set
sample_rate ≈ 7000 / (web_per_day_at_sample_rate_1.0).
Since A ships at 0.2, the un-sampled anon volume ≈ (observed_anon_per_day) / 0.2; solve for the rate that lands total ≈ 7,000/day (leaves headroom under 8,300). Adjust the one config line and redeploy.

Decision gates:

  • After A: oban namespace shows ~0 billable. (Confirms Lever 1.)
  • After A: web/day dropped sharply vs the ~279K/day baseline. (Confirms Lever 2.)
  • After tune: total /day sits ≤ ~7,500 with headroom. (Confirms Lever 3.)
  • Spot-check: a real logged-in session + an /admin page + a GraphQL call still appear as tracked transactions. (Confirms we didn't over-sample real signal.)

Phase C — fix the error sources (reduces required sampling + fixes real bugs)


Phase D — downgrade

AppSignal allows downgrade only after usage is below the lower plan's allowance for 30+ days (Usage docs).

  • Once the Usage tab shows rolling-30d ≤ 250K, flip the plan to Starter.

Checklist

  • A1 config: ignore_namespaces: ["oban"] + health/sitemap/redirect ignore_actions
  • A2 AppsignalSampler plug (bot filter + anonymous sampling)
  • A3 endpoint wiring after Plug.Telemetry
  • A4 config switches (dev/test off; prod on @ sample_rate: 0.2)
  • A5 plug test
  • B deploy, measure 3–5 days, tune sample_rate to land ≤ ~7,500/day
  • C fix replica DBConnection.ConnectionError (Cinegraph → PgBouncer (16GB tuning done; sibling apps deferred to separate follow-ups) #1018) + dead-slug 404s; bulk-close stale incidents
  • D after rolling-30d ≤ 250K, downgrade to Starter

Honest trade: at Starter we are sampling anonymous traffic — we keep 100% of errors (via Honeybadger), 100% of admin/api/auth, and a representative sample of anonymous page-views, while no longer paying to record every individual bot/anon hit. Given that ~98% of web volume is crawler traffic, the lost signal is negligible.

Refs: audit #1098 · replica pool #1018 · precedent razrfly/eventasaurus#5483.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions