Getting started
Architecture overview
The full edge-function data flow: SDK to ingest to scores to issues to alerts to delivery.
Flusterduck runs entirely on edge functions. The browser SDK collects behavioral signals, sends them to an ingestion endpoint, and a server-side pipeline turns raw events into scores, issues, and alerts. Your frontend never talks to the database.
This page maps the full data flow for teams doing compliance reviews, integration planning, or just wanting to know where their data goes.
The pipeline
Every piece of friction data moves through these stages:
- SDK collects behavioral signals in the browser
- ingest validates, deduplicates, and stores events
- compute-scores calculates per-page confusion scores
- process-issues clusters signals, refreshes evidence on known issues, and (only on sites that have turned AI detection off) mints new issues from clusters directly
- ai-detect is the default path for minting new issues: a nightly investigating agent that reads a night's sessions, opens the ones that look promising, and can only file an issue once its evidence passes server-side checks
- process-alerts evaluates alert rules and queues notifications
After alerts fire, two delivery paths carry notifications out: the webhooks function delivers signed HTTP payloads to your endpoints, and the slack function handles slash commands and interactive buttons.
Another function, deploy-correlation, sits outside the main pipeline. It records deploys and captures before/after confusion snapshots so the engine can verify whether a fix worked.
How data enters
The SDK batches behavioral events and POSTs them to the ingest edge function. Each batch contains a session ID, the publishable key (fd_pub_), the current URL, viewport dimensions, and up to 250 events.
// What the SDK sends (simplified)
{
sid: "session-uuid",
key: "fd_pub_xxxxxxxxxxxx",
url: "https://yourapp.com/checkout",
ts: 1719014400000,
events: [
{ type: "signal", signal_type: "dead_click", element: "button.submit", ts: 1719014399800 },
{ type: "signal", signal_type: "rage_click", element: "button.submit", ts: 1719014399950 }
]
}The SDK runs client-side only. It attaches listeners for clicks, scroll, keyboard, touch, form focus/blur, navigation, and errors. It does not capture keystrokes or form values. It may attach a short, PII-redacted accessible label to the element involved in an event; it does not serialize the page or send arbitrary page copy.
ingest
The first edge function in the chain. It does six things, in order:
- Validates the
fd_pub_key against theapi_keystable using HMAC-SHA256 lookup - Enforces rate limits (10,000 requests per minute per publishable key)
- Checks session limits against the org's billing plan
- Upserts the session record with device type, browser, country, and screen bucket
- Normalizes each event, hashes the IP address, generates a dedupe key, strips sensitive fields, and inserts into the
eventstable - Extracts typed signals from events, assigns weights from the signal registry, and inserts into the
signalstable
IP addresses are hashed with SHA-256 plus IP_HASH_SALT and truncated to 32 hex characters before storage. The raw IP never hits the database.
After writing signals, ingest fires an async call to compute-scores. It doesn't wait for a response. The browser gets back { ok: true, accepted: 12, rejected: 0 } within milliseconds.
compute-scores
Runs on a 15-minute window by default. For each active site, it:
- Loads all signals and sessions from the scoring window
- Groups signals by page and signal type
- Calculates a weighted score per page:
sum(signal_weight * confidence) / active_sessions - Compares the raw score against the page's baseline (rolling mean and standard deviation)
- Normalizes the score to a 0-100 scale using z-score math:
((raw - mean) / std_dev) * 25 + 50 - Writes scores to
page_scoresand appends toscore_history
Each score row includes a duck_state (calm, uneasy, frustrated, panicking), a trend direction, the dominant signal type, a signal breakdown with percentages, and a co-occurrence multiplier that boosts the score when many different users hit the same friction.
The baseline requires at least 7 samples before it kicks in. Until then, scores use raw values.
After scoring, compute-scores also back-fills confusion_after on recent deploys. Any deploy older than 5 minutes but younger than 48 hours gets a snapshot of current page scores, so deploy-correlation can compare before and after.
process-issues
Runs on a 60-minute window. It clusters signals by a composite key: page + signal_type + element. A cluster qualifies for issue treatment when it spans at least 2 distinct sessions, or when it's a captured technical failure (a JavaScript error or 5xx response) on a page whose confusion score is 60 or higher, in which case a single session is enough. A handful of clicks from one visitor is not an issue; the same friction hitting two different people, or a hard error on a page that already matters, is.
For each qualifying cluster, process-issues:
- Generates a fingerprint hash from
site_id:page:signal:element - Looks up existing open issues with the same fingerprint
- Updates the existing issue's counters, evidence, and receipts if one is found
- Calculates issue confidence from signal count, session breadth, and per-fire confidence
- Estimates revenue impact using the site's revenue config (if
track()is wired) - Writes signal evidence rows linking the issue to its source events
Whether it can also create a new issue depends on a per-site setting. AI detection (ai_detect_enabled, on by default) makes the AI investigator described below the sole authority on minting new customer-visible issues; process-issues then only refreshes counters and evidence on issues that already exist. If a site has turned AI detection off, process-issues mints new issues itself from qualifying clusters, using the same fingerprint-based dedup.
Issues have a lifecycle: open, triaged, in_progress, verified, resolved, ignored, regressed. If new signals match an issue that was previously verified, its status flips to regressed. That's how you know a fix didn't hold.
ai-detect
The default engine behind new issues. Once a night (and on demand from Settings, then AI), it reads a compressed brief of the site's recent sessions and decides what's worth investigating. For anything promising, it opens the actual sessions, checks page context, deploy timing, and cursor evidence, and can request a bounded, logged-out, read-only browser check of the live page when the current visible state matters.
It can only file an issue after clearing a real evidence bar: at least two sessions it actually opened, any cited detector signal verified as having occurred in those sessions, and severity clamped by how many users the evidence shows were actually affected. One angry session never becomes an issue on its own. If code evidence is connected for the site, the analyst can also search the repository and cite a file and line behind a diagnosis, pinned to the exact commit that was live when the friction happened.
AI detection runs inside your plan's AI allowance with no separate bill. If a site turns it off, process-issues falls back to minting issues from cluster thresholds directly, with no AI review of the evidence. See the AI detection page for the full picture of what it can and can't see.
process-alerts
Evaluates alert rules against the latest page scores. Each rule has a trigger type:
| Trigger | Fires when |
|---|---|
budget | Score exceeds threshold |
spike | Score jumps above baseline by threshold amount |
anomaly | Deviation factor exceeds threshold (encoded as threshold/10) |
trend | Score trend is "up" and score exceeds threshold |
new_page | Page has no baseline and score exceeds threshold |
co_occurrence | Multiple users affected and count exceeds threshold |
positive | Score drops below threshold (for tracking improvements) |
revenue_threshold | Estimated revenue at risk across open issues crosses a dollar amount you set |
stalled_signups | Visitors hit friction on an activation path and left without converting, above a count you set |
The first seven are per-page rules: they evaluate against one page's score. revenue_threshold and stalled_signups are site-level: they look at totals across the whole site rather than a single page.
Before firing, the function claims a cooldown lock using an atomic database RPC. Two overlapping runs can't fire the same alert twice. Cooldown is configurable per rule, from 1 minute to 24 hours.
When an alert fires, four things happen:
- An alert row is created with severity (warning, high, critical), score snapshot, and signal breakdown
- An incident event is logged
- Delivery queue rows are created for each configured channel (email, Slack, webhook, PagerDuty)
- Webhook delivery rows are created for any registered endpoints subscribed to
alert.fired
webhooks
Processes the webhook_deliveries queue. For each pending delivery:
- Claims the delivery with a processing lock (stale locks auto-expire after 10 minutes)
- Looks up the endpoint and decrypts its stored secret
- Signs the payload with HMAC-SHA256, including a timestamp for replay protection
- POSTs to the endpoint URL with an 8-second timeout
- On success, marks the delivery as sent and resets the endpoint's failure counter
- On failure, schedules a retry with exponential backoff (30s, 60s, 120s, ... up to 1 hour)
Deliveries are retried up to 10 times. After 10 failures, the delivery is marked dead.
Every webhook request includes these headers:
x-flusterduck-event: alert.fired
x-flusterduck-delivery: delivery-uuid
x-flusterduck-timestamp: 1719014400
x-flusterduck-signature: sha256=...deploy-correlation
Sits outside the scoring pipeline. You call it by POSTing to the deploy-correlation endpoint with an fd_sec_ key.
When a deploy is recorded:
- The current
page_scoresare captured asconfusion_before - A deploy row is written with commit hash, author, PR number, and provider
- Verification records are created for every open issue on the site
Later, when compute-scores runs, it fills in confusion_after on deploys that are at least 5 minutes old. Comparing the two snapshots tells you whether confusion went up or down after the deploy.
The build plugins for Vite and webpack call this endpoint automatically at the end of each production build.
slack
Handles two interaction types:
- Slash commands (
/flusterduck scores,/flusterduck issues,/flusterduck help) that query live data and respond ephemerally - Block actions from interactive buttons on alert messages (acknowledge, resolve)
All requests are verified against Slack's signing secret with a 5-minute timestamp tolerance.
Read and write APIs
Two more edge functions handle all external data access:
query is the read API. It accepts user JWTs, fd_sec_ secret keys, or fd_mcp_ MCP keys. Routes include scores, issues, alerts, deploys, trends, sessions, recommendations, revenue, raw event export, and MCP context. Rate limited to 100 requests per minute per org.
manage is the write API. JWT only, no API keys. It handles CRUD for orgs, sites, environments, API keys, SDK configs, alert rules, issues, members, webhooks, integrations, and billing. Rate limited to 30 requests per minute per org. Admin-only routes verify the user's role before proceeding.
What the frontend can and can't do
The frontend app (apps/web) uses the Supabase client for one thing: authentication. Every supabase.auth.* call is allowed. Every data query goes through the query edge function. Every write goes through manage.
This is enforced by RLS. All 23 tables have row-level security enabled. The anon role is revoked on all data tables. The authenticated role is revoked on service-only tables (alert delivery queue, Slack messages, scheduled tasks, Stripe events, waitlist). The only way to read or write data is through the edge functions, which authenticate every request and verify org membership before touching a row.
Key types
| Prefix | Where it's used | What it can do |
|---|---|---|
fd_pub_ | Browser SDK | Send signals to ingest. Nothing else. |
fd_sec_ | Your server | Read data via query API, record deploys |
fd_mcp_ | AI assistants | Read data via MCP bridge, scoped per key |
All keys are stored as HMAC-SHA256 hashes. The raw key is shown once at creation and never stored.
Data flow diagram
Browser SDK
|
| POST /ingest (fd_pub_ key, event batch)
v
[ingest] --> events table, signals table, sessions table
|
| async invoke
v
[compute-scores] --> page_scores table, score_history table
| |
| | back-fills confusion_after on recent deploys
| v
| deploys table
| async invoke
v
[process-issues] --> ux_issues table, issue_signal_evidence table
|
v
[process-alerts] --> alerts table, alert_delivery_queue, webhook_deliveries
[ai-detect] runs on its own nightly schedule (and on demand), reading
sessions and signals independently, and writes new issues into the
same ux_issues table. On sites with AI detection on (the default),
it is the only thing that mints a new issue; process-issues only
refreshes counters and evidence on issues that already exist.
| |
+-------------------+
| |
v v
[webhooks] [slack]
| |
v v
Your endpoint Slack channelWhere things run
| Component | Runtime | Location |
|---|---|---|
| Browser SDK | Browser JS | Your users' browsers |
| Edge functions | Deno (Supabase) | Supabase edge network |
| Database | PostgreSQL 15 | Supabase managed |
| Frontend shell | Next.js App Router | Vercel edge |
| MCP worker | Cloudflare Worker | Cloudflare edge |
The apps/web frontend on Vercel serves the auth callback, middleware, an /api/v1 proxy to edge functions, and a /d.js redirect to the CDN-hosted SDK script. It doesn't render a dashboard UI directly. The proxy layer means your frontend code never needs to know about Supabase URLs.