@flusterduck/mcp-server 0.2.0 → 0.5.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/chunk-EWAM6KBJ.js +1135 -0
- package/dist/cli.js +1 -1
- package/dist/index.js +1 -1
- package/package.json +5 -1
- package/dist/chunk-UDB3HZEI.js +0 -733
|
@@ -0,0 +1,1135 @@
|
|
|
1
|
+
// src/index.ts
|
|
2
|
+
import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
|
|
5
|
+
// src/api-client.ts
|
|
6
|
+
var MACHINE_KEY_PATTERN = /^fd_(sec|mcp)_[A-Za-z0-9_-]{48}$/;
|
|
7
|
+
var UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
8
|
+
function assertMachineKey(value) {
|
|
9
|
+
const key = value.trim();
|
|
10
|
+
if (!MACHINE_KEY_PATTERN.test(key)) {
|
|
11
|
+
throw new Error("Invalid API key format. Expected a high-entropy fd_sec_ or fd_mcp_ key.");
|
|
12
|
+
}
|
|
13
|
+
return key;
|
|
14
|
+
}
|
|
15
|
+
function assertUuid(value, name) {
|
|
16
|
+
if (!UUID_PATTERN.test(value)) throw new Error(`${name} must be a UUID.`);
|
|
17
|
+
return value;
|
|
18
|
+
}
|
|
19
|
+
function buildQueryUrl(config, route, params = {}) {
|
|
20
|
+
const base = assertSafeApiBase(config.baseUrl);
|
|
21
|
+
const url = new URL(route.replace(/^\/+/, ""), base);
|
|
22
|
+
url.searchParams.set("site_id", assertUuid(config.siteId, "site_id"));
|
|
23
|
+
for (const [key, value] of Object.entries(params)) {
|
|
24
|
+
if (value === void 0 || value === null || value === "") continue;
|
|
25
|
+
url.searchParams.set(key, String(value));
|
|
26
|
+
}
|
|
27
|
+
return url;
|
|
28
|
+
}
|
|
29
|
+
function assertSafeApiBase(baseUrl) {
|
|
30
|
+
const base = new URL(baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`);
|
|
31
|
+
if (base.protocol !== "https:" && !["localhost", "127.0.0.1", "[::1]"].includes(base.hostname)) {
|
|
32
|
+
throw new Error("HTTP is only allowed for local Flusterduck API testing.");
|
|
33
|
+
}
|
|
34
|
+
return base;
|
|
35
|
+
}
|
|
36
|
+
var FlusterduckAPI = class {
|
|
37
|
+
config;
|
|
38
|
+
constructor(config) {
|
|
39
|
+
this.config = {
|
|
40
|
+
baseUrl: config.baseUrl,
|
|
41
|
+
apiKey: assertMachineKey(config.apiKey),
|
|
42
|
+
siteId: assertUuid(config.siteId, "site_id"),
|
|
43
|
+
orgId: config.orgId ? assertUuid(config.orgId, "org_id") : void 0
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
getContext() {
|
|
47
|
+
return this.request("query/mcp/context");
|
|
48
|
+
}
|
|
49
|
+
getScores() {
|
|
50
|
+
return this.request("query/scores");
|
|
51
|
+
}
|
|
52
|
+
getPageDetail(page) {
|
|
53
|
+
return this.request("query/page", { page });
|
|
54
|
+
}
|
|
55
|
+
getAlerts(status) {
|
|
56
|
+
return this.request("query/alerts", { status });
|
|
57
|
+
}
|
|
58
|
+
getIssues(status) {
|
|
59
|
+
return this.request("query/issues", { status });
|
|
60
|
+
}
|
|
61
|
+
getElements(page) {
|
|
62
|
+
return this.request("query/elements", { page });
|
|
63
|
+
}
|
|
64
|
+
getSessionDetail(sessionId) {
|
|
65
|
+
return this.request("query/session", { session_id: sessionId });
|
|
66
|
+
}
|
|
67
|
+
getFlows(limit) {
|
|
68
|
+
return this.request("query/flows", { limit });
|
|
69
|
+
}
|
|
70
|
+
getTrends(days = 7, page) {
|
|
71
|
+
const safeDays = Math.max(1, Math.min(Math.trunc(Number(days) || 7), 90));
|
|
72
|
+
return this.request("query/trends", { days: safeDays, page });
|
|
73
|
+
}
|
|
74
|
+
getDeployImpact() {
|
|
75
|
+
return this.request("query/deploys");
|
|
76
|
+
}
|
|
77
|
+
getRecommendations() {
|
|
78
|
+
return this.request("query/recommendations");
|
|
79
|
+
}
|
|
80
|
+
compare(a, b) {
|
|
81
|
+
return this.request("query/compare", { a, b });
|
|
82
|
+
}
|
|
83
|
+
getRevenueImpact() {
|
|
84
|
+
return this.request("query/revenue");
|
|
85
|
+
}
|
|
86
|
+
getHeuristics() {
|
|
87
|
+
return this.request("query/heuristics");
|
|
88
|
+
}
|
|
89
|
+
getJourneyFriction(params = {}) {
|
|
90
|
+
return this.request("query/journeys/friction", params);
|
|
91
|
+
}
|
|
92
|
+
queryRawRows(params) {
|
|
93
|
+
return this.request("query/raw", params);
|
|
94
|
+
}
|
|
95
|
+
downloadEventsCsv(params = {}) {
|
|
96
|
+
return this.requestText("query/export/events.csv", params, "text/csv");
|
|
97
|
+
}
|
|
98
|
+
getAuditLog(limit = 100) {
|
|
99
|
+
if (!this.config.orgId) throw new Error("orgId is required for audit log queries.");
|
|
100
|
+
return this.request("query/audit", { org_id: this.config.orgId, limit }, false);
|
|
101
|
+
}
|
|
102
|
+
getDegradation() {
|
|
103
|
+
if (!this.config.orgId) throw new Error("orgId is required for degradation queries.");
|
|
104
|
+
return this.request("query/degradation", { org_id: this.config.orgId }, false);
|
|
105
|
+
}
|
|
106
|
+
getWebhookDeliveries(limit = 100) {
|
|
107
|
+
if (!this.config.orgId) throw new Error("orgId is required for webhook delivery queries.");
|
|
108
|
+
return this.request("query/webhook-deliveries", { org_id: this.config.orgId, limit }, false);
|
|
109
|
+
}
|
|
110
|
+
getIssueDetail(issueId) {
|
|
111
|
+
return this.request(`query/issues/${assertUuid(issueId, "issue_id")}`, {});
|
|
112
|
+
}
|
|
113
|
+
getAlertRules() {
|
|
114
|
+
return this.request("manage/alert-rules", {});
|
|
115
|
+
}
|
|
116
|
+
updateAlertRule(ruleId, update) {
|
|
117
|
+
return this.mutate("PATCH", `manage/alert-rules/${assertUuid(ruleId, "rule_id")}`, update);
|
|
118
|
+
}
|
|
119
|
+
updateIssue(issueId, update) {
|
|
120
|
+
return this.mutate("PATCH", `manage/issues/${assertUuid(issueId, "issue_id")}`, update);
|
|
121
|
+
}
|
|
122
|
+
updateAlert(alertId, status, resolvedReason) {
|
|
123
|
+
const body = { status };
|
|
124
|
+
if (resolvedReason) body.resolved_reason = resolvedReason;
|
|
125
|
+
return this.mutate("PATCH", `manage/alerts/${assertUuid(alertId, "alert_id")}`, body);
|
|
126
|
+
}
|
|
127
|
+
addAnnotation(message) {
|
|
128
|
+
return this.mutate("POST", "manage/annotations", {
|
|
129
|
+
site_id: this.config.siteId,
|
|
130
|
+
message,
|
|
131
|
+
type: "mcp"
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
createAlertRule(rule) {
|
|
135
|
+
return this.mutate("POST", "manage/alert-rules", {
|
|
136
|
+
site_id: this.config.siteId,
|
|
137
|
+
...rule
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
deleteAlertRule(ruleId) {
|
|
141
|
+
return this.mutate("DELETE", `manage/alert-rules/${assertUuid(ruleId, "rule_id")}`);
|
|
142
|
+
}
|
|
143
|
+
async mutate(method, route, body) {
|
|
144
|
+
const base = assertSafeApiBase(this.config.baseUrl);
|
|
145
|
+
const url = new URL(route.replace(/^\/+/, ""), base);
|
|
146
|
+
const response = await fetch(url, {
|
|
147
|
+
method,
|
|
148
|
+
headers: {
|
|
149
|
+
authorization: `Bearer ${this.config.apiKey}`,
|
|
150
|
+
"content-type": "application/json",
|
|
151
|
+
accept: "application/json"
|
|
152
|
+
},
|
|
153
|
+
body: body !== void 0 ? JSON.stringify(body) : void 0
|
|
154
|
+
});
|
|
155
|
+
let envelope;
|
|
156
|
+
try {
|
|
157
|
+
envelope = await response.json();
|
|
158
|
+
} catch {
|
|
159
|
+
throw new Error(`Flusterduck API returned non-JSON response (${response.status}).`);
|
|
160
|
+
}
|
|
161
|
+
if (!response.ok || envelope.error) {
|
|
162
|
+
const status = envelope.error?.status ?? response.status;
|
|
163
|
+
const code = envelope.error?.code ?? "unknown";
|
|
164
|
+
const message = envelope.error?.message ?? "Flusterduck API request failed.";
|
|
165
|
+
throw new Error(`Flusterduck API error (${status}: ${code}): ${message}`);
|
|
166
|
+
}
|
|
167
|
+
return envelope.data;
|
|
168
|
+
}
|
|
169
|
+
async request(route, params = {}, includeSiteId = true) {
|
|
170
|
+
const url = includeSiteId ? buildQueryUrl(this.config, route, params) : buildOrgUrl(this.config, route, params);
|
|
171
|
+
const response = await fetch(url, {
|
|
172
|
+
headers: {
|
|
173
|
+
authorization: `Bearer ${this.config.apiKey}`,
|
|
174
|
+
accept: "application/json"
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
let envelope;
|
|
178
|
+
try {
|
|
179
|
+
envelope = await response.json();
|
|
180
|
+
} catch {
|
|
181
|
+
throw new Error(`Flusterduck API returned non-JSON response (${response.status}).`);
|
|
182
|
+
}
|
|
183
|
+
if (!response.ok || envelope.error) {
|
|
184
|
+
const status = envelope.error?.status ?? response.status;
|
|
185
|
+
const code = envelope.error?.code ?? "unknown";
|
|
186
|
+
throw new Error(`Flusterduck API request failed (${status}: ${code}).`);
|
|
187
|
+
}
|
|
188
|
+
return envelope.data;
|
|
189
|
+
}
|
|
190
|
+
async requestText(route, params = {}, accept = "text/plain") {
|
|
191
|
+
const url = buildQueryUrl(this.config, route, params);
|
|
192
|
+
const response = await fetch(url, {
|
|
193
|
+
headers: {
|
|
194
|
+
authorization: `Bearer ${this.config.apiKey}`,
|
|
195
|
+
accept
|
|
196
|
+
}
|
|
197
|
+
});
|
|
198
|
+
const text = await response.text();
|
|
199
|
+
if (!response.ok) {
|
|
200
|
+
throw new Error(`Flusterduck API request failed (${response.status}).`);
|
|
201
|
+
}
|
|
202
|
+
return text;
|
|
203
|
+
}
|
|
204
|
+
};
|
|
205
|
+
function buildOrgUrl(config, route, params) {
|
|
206
|
+
const base = assertSafeApiBase(config.baseUrl);
|
|
207
|
+
const url = new URL(route.replace(/^\/+/, ""), base);
|
|
208
|
+
for (const [key, value] of Object.entries(params)) {
|
|
209
|
+
if (value === void 0 || value === null || value === "") continue;
|
|
210
|
+
url.searchParams.set(key, String(value));
|
|
211
|
+
}
|
|
212
|
+
return url;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// src/docs.generated.ts
|
|
216
|
+
var DOCS = [
|
|
217
|
+
{
|
|
218
|
+
"slug": "what-flusterduck-does",
|
|
219
|
+
"title": "What Flusterduck does",
|
|
220
|
+
"description": "A plain-English explanation of what Flusterduck does, with no jargon.",
|
|
221
|
+
"group": "Start here",
|
|
222
|
+
"content": "# What Flusterduck does\n\nFlusterduck tells you where people get stuck on your website or app, why it is probably happening, and whether the change you made actually fixed it.\n\nThat is the whole thing. The rest of this page explains it in plain terms.\n\n## The problem it solves\n\nYou can already see *that* something is wrong. Your numbers dip. People leave the page. A sign-up does not get finished. Sales slip.\n\nWhat you usually cannot see is *why*. Where exactly did the person get confused? Which button looked clickable but was not? Which step made them give up? Today, finding that out means either guessing, or sitting and watching recordings of real people using your site one by one. That is slow, and most teams never do it.\n\nFlusterduck does that watching for you, automatically, and hands you the answer.\n\n## How it works, in everyday language\n\n1. **It watches for the signs of a frustrated person.** Not what they type, not their personal details, not a video of their screen. Just the *behaviour* that means someone is struggling: clicking the same button over and over because nothing happens, clicking something that is not actually a link, pausing for a long time because they do not know what to do next, bouncing back and forth between pages looking for something.\n\n2. **It groups the struggles into a clear list of problems.** If one person fumbles, that is noise. If forty people fumble on the same button, that is a real issue. Flusterduck waits for the pattern, then writes it up: what is wrong, where it is, how many people it hit, and a suggested fix.\n\n3. **It checks whether your fix worked.** This is the part most tools skip. After you change something and put it live, Flusterduck compares how confused people were before and after. If the confusion dropped, the problem is marked solved. If it creeps back, you get told.\n\n## A real example\n\nA team ran Flusterduck on their pension calculator. It showed them the exact pages where people sat still, stuck, not knowing what to do, including one visitor who spent 28 seconds frozen on a results page. That pointed straight at 24 specific improvements that made the pages clearer. They did not have to watch a single recording to find them.\n\n## What it does not do\n\n- It does not record your visitors' screens or sessions.\n- It does not capture what people type into forms.\n- It does not collect personal information.\n\nIt only pays attention to behaviour, the digital version of noticing someone frown and look lost, and turns that into something you can fix.\n"
|
|
223
|
+
},
|
|
224
|
+
{
|
|
225
|
+
"slug": "what-makes-us-different",
|
|
226
|
+
"title": "What makes us different",
|
|
227
|
+
"description": "How Flusterduck differs from analytics, session replay, and heatmaps, in plain terms.",
|
|
228
|
+
"group": "Start here",
|
|
229
|
+
"content": '# What makes us different\n\nThere are a lot of tools that tell you something about your visitors. Here is how Flusterduck is different, in plain terms.\n\n## Analytics tells you *where*. We tell you *why*.\n\nNormal analytics is great at counting. It will tell you that 8% of people dropped off on the checkout page. It will not tell you that they dropped off because the "Pay" button looked greyed out and they thought it was disabled. Flusterduck names the actual reason, so you can fix the cause instead of guessing.\n\n## Replay makes you watch. We just hand you the answer.\n\nSession replay tools record people using your site so you can play the videos back. That can be useful, but it is slow: something goes wrong, you open the tool, you filter, you watch session after session, and you try to spot the pattern yourself. Flusterduck watches everyone for you and gives you the finished conclusion. No videos to sit through.\n\n## Heatmaps show colour. We show problems.\n\nA heatmap shows you where clicks land as a wash of colour. It looks insightful, but you still have to interpret it. Flusterduck does not hand you a picture to puzzle over. It hands you a plain list: here is the problem, here is where, here is who it affected, here is what to change.\n\n## We prove the fix worked\n\nThis is the big one, and almost nobody else does it. Finding problems is only half the job. After you ship a change, Flusterduck measures whether confusion actually went down on that page. If it did, the issue closes itself. If the problem comes back later, you get an alert. You are never left wondering whether your fix landed.\n\n## We have an opinion, and it is based on confidence\n\nFlusterduck does not drown you in charts and leave you to figure it out. When it is sure something is a real, repeated problem, it tells you plainly: fix this, it is costing you. When it is only seeing the early hint of a problem, it says: keep an eye on this one. You always know what deserves your attention first.\n\n## Privacy is built in, not bolted on\n\nWe made a deliberate choice: behaviour only. No screen recordings, no reading what people type, no personal data. That is not a setting you turn on. It is how the product is built. You get the insight without collecting things you would rather not be responsible for.\n'
|
|
230
|
+
},
|
|
231
|
+
{
|
|
232
|
+
"slug": "overview",
|
|
233
|
+
"title": "What is Flusterduck?",
|
|
234
|
+
"description": "Automatic issue tracking for UX friction: what it measures, what it refuses to record, and where to start.",
|
|
235
|
+
"group": "Getting started",
|
|
236
|
+
"content": `# What is Flusterduck?
|
|
237
|
+
|
|
238
|
+
Flusterduck is automatic issue tracking for UX friction.
|
|
239
|
+
|
|
240
|
+
It watches real user behavior, detects where people get stuck, clusters repeated friction into fixable issues, and verifies whether each fix worked after you ship. No session replay. No watching recordings. No manual digging.
|
|
241
|
+
|
|
242
|
+
## The problem it solves
|
|
243
|
+
|
|
244
|
+
Most analytics tools tell you where users dropped off. Session replay shows you recordings you have to watch. Neither one automatically tells you what's wrong, why, or what to fix first.
|
|
245
|
+
|
|
246
|
+
There's a gap between "conversion dropped 8% on checkout" and "the continue button looks disabled because of the grey border, and 140 users have dead-clicked it in the last two weeks." Flusterduck fills that gap.
|
|
247
|
+
|
|
248
|
+
## How it works
|
|
249
|
+
|
|
250
|
+
The SDK runs in the browser. It listens for behavioral signals -- rage clicks, dead clicks, form abandonment, scroll patterns, keyboard confusion, mobile tap misses, navigation loops, 27 more -- and sends them to the scoring engine. It never records keystrokes, form values, or page text.
|
|
251
|
+
|
|
252
|
+
The scoring engine does four things with those signals:
|
|
253
|
+
|
|
254
|
+
- Computes a confusion score for each page based on signal frequency, weight, and recency
|
|
255
|
+
- Clusters repeated signals from different users into UX issues with session evidence
|
|
256
|
+
- Estimates revenue impact when you wire conversion events with \`track()\`
|
|
257
|
+
- Runs verification after each deploy to check whether a fix actually reduced friction
|
|
258
|
+
|
|
259
|
+
## What you get
|
|
260
|
+
|
|
261
|
+
**Issues** are the main output. Each one has a title, root cause hypothesis, affected element, evidence sessions, severity score, and status. They work like Linear tickets -- assign them, triage them, mark them resolved. The scoring engine re-checks them after each deploy to confirm the fix held.
|
|
262
|
+
|
|
263
|
+
**Scores** give you a per-page confusion rating. Useful for prioritizing what to look at first, and for tracking whether a page is getting better or worse over time.
|
|
264
|
+
|
|
265
|
+
**Alerts** fire when a score spikes after a deploy, when a new friction pattern emerges, or when a page crosses a threshold you set. You can route them to email, Slack, PagerDuty, or a webhook.
|
|
266
|
+
|
|
267
|
+
**MCP tools** give your AI assistant direct access to your friction data. Instead of opening a dashboard, you ask Claude what's broken. It queries live scores, pulls open issues, and answers you. You can also run post-deploy checks and generate weekly summaries without touching a UI.
|
|
268
|
+
|
|
269
|
+
## What Flusterduck never does
|
|
270
|
+
|
|
271
|
+
No session replay. No DOM recording. No form values. No text content. No keystroke capture. No raw IP storage. No PII of any kind. It measures behavior patterns, not personal data.
|
|
272
|
+
|
|
273
|
+
The SDK's privacy constraints are enforced at the code level, not just by policy. It cannot record what users type because it doesn't attach the kind of listeners that would capture it.
|
|
274
|
+
|
|
275
|
+
## Pricing
|
|
276
|
+
|
|
277
|
+
Paid product. No free tier. No open-source version.
|
|
278
|
+
|
|
279
|
+
| Plan | Monthly | Sessions |
|
|
280
|
+
|---|---|---|
|
|
281
|
+
| Grow | $49 | Up to 50,000 |
|
|
282
|
+
| Scale | $149 | Up to 250,000 |
|
|
283
|
+
| Enterprise | Custom | Custom |
|
|
284
|
+
|
|
285
|
+
All plans include a 7-day free trial. No credit card required.
|
|
286
|
+
`
|
|
287
|
+
},
|
|
288
|
+
{
|
|
289
|
+
"slug": "quickstart",
|
|
290
|
+
"title": "Quickstart",
|
|
291
|
+
"description": "The CLI does everything. Run it, paste your publishable key, deploy.",
|
|
292
|
+
"group": "Getting started",
|
|
293
|
+
"content": "# Quickstart\n\nThe CLI does everything. Run it, paste your `fd_pub_` key, deploy.\n\n```bash\nnpx flusterduck-cli init\n```\n\nIt detects your framework (Next.js, React, Vue, SvelteKit, Nuxt), installs the right packages, and injects the init call into the correct entry file. Pass `--key fd_pub_xxxx` to skip the prompt entirely.\n\nThat's the whole quickstart for most apps. The rest of this page is for manual setup and verification.\n\n---\n\n## Manual setup\n\n```bash\npnpm add flusterduck\n```\n\nCall `init()` once at app startup, before any user interaction:\n\n```ts\nimport { init } from 'flusterduck'\n\ninit({ key: process.env.NEXT_PUBLIC_FLUSTERDUCK_KEY! })\n```\n\nThe SDK starts detecting behavioral signals from that point. No other configuration required to start collecting.\n\nIf you're using a framework, there's a wrapper that handles lifecycle automatically. See [Framework guides](./frameworks) for Next.js, React, Vue, SvelteKit, and Nuxt.\n\n## Check the connection\n\nOpen your app and click around for 30 seconds. Hit a form, click some buttons. Signals appear in the dashboard under **Live Events** within a few seconds of being emitted.\n\nTo confirm the connection without deploying, fire a test signal from the browser console:\n\n```ts\nimport { signal } from 'flusterduck'\nsignal('dead_click', { selector: 'button[type=\"submit\"]' })\n```\n\nIf it shows up in Live Events, you're live.\n\nOne thing that trips people up: the SDK only runs client-side. If you're server-rendering and checking for signals immediately, open the page in a browser first.\n\n## Your key\n\n`fd_pub_` keys are safe in client-side code. They can only emit signals -- they can't read your data or change anything.\n\n```bash\n# Next.js\nNEXT_PUBLIC_FLUSTERDUCK_KEY=fd_pub_xxxxxxxxxxxx\n\n# Vite / Vue / SvelteKit\nVITE_FLUSTERDUCK_KEY=fd_pub_xxxxxxxxxxxx\n\n# Nuxt\nNUXT_PUBLIC_FLUSTERDUCK_KEY=fd_pub_xxxxxxxxxxxx\n```\n\n`fd_sec_` and `fd_mcp_` keys are different. Both are server-side only. Secret keys can read your site data through the query API; MCP keys can read through the MCP bridge and may be granted additional tool scopes. Keep both out of the browser.\n\n## What you'll see first\n\nWithin a few sessions of real traffic, you'll have confusion scores on your tracked pages. The first issues typically surface within 24-48 hours, once the scoring engine has enough signal clusters.\n\nThe signals you'll see most in early data: `rage_click`, `dead_click`, and `form_abandonment`. They're the clearest indicators of friction and tend to accumulate quickly on any app with real users.\n\n## Where to go from here\n\n- [Signals](./signals) -- what each of the 33 signal types means and when to act on it\n- [SDK reference](./sdk) -- every `init()` option and method, with guidance on what actually matters\n- [Alerts](./alerts) -- get notified when a deploy breaks something before your users tell you\n- [MCP setup](./mcp) -- ask your AI assistant about friction data directly instead of checking a dashboard\n"
|
|
294
|
+
},
|
|
295
|
+
{
|
|
296
|
+
"slug": "install",
|
|
297
|
+
"title": "Installation",
|
|
298
|
+
"description": "Install the core SDK plus any framework wrapper that matches your stack.",
|
|
299
|
+
"group": "Getting started",
|
|
300
|
+
"content": "# Installation\n\n## Packages\n\nInstall the core SDK plus any framework wrapper that matches your stack.\n\n### Core SDK\n\n```bash\npnpm add flusterduck\n```\n\nRequired for every project. The framework wrappers depend on it.\n\n### Framework wrappers\n\n```bash\n# Next.js (App Router or Pages Router)\npnpm add flusterduck @flusterduck/next\n\n# React (Vite, CRA, or any React app)\npnpm add flusterduck @flusterduck/react\n\n# Vue 3\npnpm add flusterduck @flusterduck/vue\n\n# SvelteKit\npnpm add flusterduck @flusterduck/svelte\n\n# Nuxt 3\npnpm add flusterduck @flusterduck/nuxt\n```\n\nNot using a framework wrapper? Initialize the SDK directly with `init()`. It works in any browser environment.\n\n## Publishable key\n\nYour publishable key starts with `fd_pub_`. Find it in the dashboard under Settings > API Keys.\n\nSet it as an environment variable:\n\n```bash\n# Next.js\nNEXT_PUBLIC_FLUSTERDUCK_KEY=fd_pub_xxxxxxxxxxxx\n\n# Vite / Vue / SvelteKit\nVITE_FLUSTERDUCK_KEY=fd_pub_xxxxxxxxxxxx\n\n# Nuxt\nNUXT_PUBLIC_FLUSTERDUCK_KEY=fd_pub_xxxxxxxxxxxx\n```\n\nThe `fd_pub_` key is safe in client-side code. It can only send signals -- it can't read data or modify your account.\n\n## What each package does\n\n| Package | What it does |\n|---|---|\n| `flusterduck` | Core browser SDK. On-device signal detection/classification and event buffering. |\n| `@flusterduck/next` | `FlusterduckScript` component + `useFlusterduck` hook for Next.js App Router and Pages Router. |\n| `@flusterduck/react` | `FlusterduckProvider` component + `useFlusterduck` hook. |\n| `@flusterduck/vue` | Vue 3 plugin + `useFlusterduck` composable. |\n| `@flusterduck/svelte` | SvelteKit module + Svelte store. |\n| `@flusterduck/nuxt` | Nuxt 3 module. Auto-initializes on the client. |\n\n## Using the CLI instead\n\nThe CLI handles installation and code injection in one command:\n\n```bash\nnpx flusterduck-cli init\n```\n\nSee [CLI reference](./cli) for options.\n\n## Requirements\n\n- Browser environment (the SDK doesn't run server-side)\n- HTTPS in production\n- A site and publishable key from the dashboard\n"
|
|
301
|
+
},
|
|
302
|
+
{
|
|
303
|
+
"slug": "start",
|
|
304
|
+
"title": "Your first data",
|
|
305
|
+
"description": "The SDK is installed and signals are flowing. Here is what to do with them.",
|
|
306
|
+
"group": "Getting started",
|
|
307
|
+
"content": "# Your First Data\n\nThe SDK is installed and signals are flowing. Here's what to do with them.\n\n## Read your scores\n\nOpen the dashboard. Every tracked page has a confusion score from 0 to 100. Higher is worse. A page at 15 is fine. A page at 60 has real friction. A page at 80 is actively costing you conversions.\n\nThe score accounts for signal type, frequency, and recency. A single `rage_click` on your checkout button today matters more to the score than 10 `passive_drift` signals from last month.\n\nNew sites take 24-48 hours to produce meaningful scores. You need enough signal volume for the clustering to kick in.\n\n## Look at your first issues\n\nIssues are what you act on. Each one has a title, an affected element, a signal count, and a severity score. Sort by severity first -- those are the ones where friction is concentrated on a specific element, not just diffuse across a page.\n\nThe hypothesis on each issue is the scoring engine's best guess at root cause. It's not always right. Use it as a starting point, not a verdict.\n\nIssues work like tickets. Assign them, update the status as you investigate, mark them resolved after you deploy a fix. The scoring engine re-checks resolved issues after each deploy to confirm the fix held.\n\n## Wire conversion data\n\nIf you want revenue impact estimates, add `track()` calls around your key conversion events:\n\n```ts\ntrack('plan_intent', { plan_id: 'scale', amount_cents: 9900, billing: 'monthly' })\ntrack('subscription_started', { plan_id: 'scale', amount_cents: 9900, billing: 'monthly' })\ntrack('checkout_abandoned', { plan_id: 'grow', amount_cents: 3900 })\n```\n\nThe scoring engine correlates friction signals with abandonment and estimates how much revenue each open issue is likely costing. It takes a few days of data to produce stable estimates.\n\n## Set up at least two alerts\n\nDon't wait until users complain. Set up a spike alert for your most critical page, and a budget alert as a ceiling:\n\n**Spike alert** -- fires when a page score increases sharply after a deploy. Catches regressions before they compound.\n\n**Budget alert** -- fires when a page crosses a score threshold you set. Your checkout page probably shouldn't ever be above 50.\n\nBoth take 2 minutes to configure under Settings > Alert Rules. Route the spike alert to Slack or email. Add PagerDuty to the budget alert if the page is critical enough.\n\n## Tag your deploys\n\nWhen you ship a fix, tag the deploy with a version string:\n\n```ts\ninit({\n key: process.env.NEXT_PUBLIC_FLUSTERDUCK_KEY!,\n segment: { app_version: '2026.06.10' },\n})\n```\n\nFlusterduck records the confusion score before and after each tagged deploy. This is how you confirm a fix worked -- the scoring engine shows you the before/after delta and verifies that the affected issues actually resolved.\n\n## Connect your AI assistant\n\nOnce your site has a few days of data, the MCP integration pays off fast. Instead of checking the dashboard, ask Claude:\n\n> \"What's the worst-performing page right now, and what's causing it?\"\n\nSee [MCP setup](./mcp) for a 5-minute configuration walkthrough.\n\n## Share access\n\nAdd your teammates under Settings > Members. Engineers can triage issues. PMs can read scores and run weekly summaries. Admins can manage alert rules and integrations.\n\nIf your team uses the AI assistant heavily, create a read-only `fd_mcp_` key so teammates can query friction data without access to your write operations.\n"
|
|
308
|
+
},
|
|
309
|
+
{
|
|
310
|
+
"slug": "testing",
|
|
311
|
+
"title": "Testing your integration",
|
|
312
|
+
"description": "Simulate user frustration against a running app to verify signals, scoring, and issue creation before production.",
|
|
313
|
+
"group": "Getting started",
|
|
314
|
+
"content": '# Testing Your Integration\n\n`@flusterduck/test-suite` is a Playwright-based CLI that simulates user frustration patterns against a running app. It verifies your Flusterduck setup before you go to production: signals are being detected, the scoring engine is receiving them, and issues will be created from real friction.\n\n## Install\n\n```bash\npnpm add -D @flusterduck/test-suite\n```\n\nOr run without installing:\n\n```bash\nnpx @flusterduck/test-suite run --url http://localhost:3000\n```\n\n## Running a simulation\n\nPoint it at your dev server:\n\n```bash\nnpx @flusterduck/test-suite run \\\n --url http://localhost:3000 \\\n --key fd_pub_xxxxxxxxxxxx \\\n --scenarios all\n```\n\nThe CLI opens a headless Chromium browser, navigates through your app, and executes frustration scenarios on each page. Each scenario generates specific signal types. After the run, it reports which signals were emitted, whether they were received by the scoring engine, and which scenarios produced no signals.\n\n## Scenarios\n\n### rage-click\n\nClicks the same element 5 times in rapid succession. Targets interactive elements: buttons, links, form submits.\n\n### dead-click\n\nClicks non-interactive elements: labels, images, decorative containers, text nodes near interactive elements.\n\n### form-abandon\n\nFills in one or more fields of a form and navigates away without submitting.\n\n### navigation-loop\n\nNavigates forward in a multi-step flow, then immediately back. Repeats 3 times.\n\n### scroll-thrash\n\nScrolls down rapidly, then back up, then down again, within a 3-second window.\n\n### tap-miss\n\nOn mobile viewport (375px), taps adjacent to interactive elements rather than on them. Simulates small touch targets.\n\n### idle-confusion\n\nLoads a page and stays inactive for 15 seconds without interacting.\n\n## Running specific scenarios\n\n```bash\nnpx @flusterduck/test-suite run \\\n --url http://localhost:3000/checkout \\\n --key fd_pub_xxxxxxxxxxxx \\\n --scenarios rage-click,form-abandon,dead-click\n```\n\n## Output\n\n```\nFlusterduck Test Suite v0.4.1\nTarget: http://localhost:3000\nScenarios: rage-click, form-abandon, dead-click\n\nRunning rage-click on /...\n Found 3 interactive elements\n Executed rage-click on button[type="submit"] signal received\n Executed rage-click on [data-cta="upgrade"] signal received\n Executed rage-click on nav a:nth-child(2) signal received\n\nRunning form-abandon on /checkout...\n Found 2 forms\n Executed form-abandon on #checkout-form signal received\n Executed form-abandon on #coupon-form signal received (low confidence)\n\nRunning dead-click on /pricing...\n Found 6 non-interactive elements with click affordance\n Executed dead-click on .plan-card signal received\n Executed dead-click on .feature-badge no signal (expected: element filtered)\n\nSummary\n Scenarios run: 3\n Signals emitted: 6\n Signals received by engine: 5\n Missed signals: 1 (.feature-badge matched ignoreElements pattern)\n Issues detected: 0 (volume too low for clustering)\n\nAll critical scenarios passed.\n```\n\n"Issues detected: 0" is expected during testing. Issue clustering requires signals from multiple distinct sessions. A single simulation run won\'t cross that threshold. The important check is that signals are emitted and received.\n\n## CI integration\n\nRun the test suite after your dev server starts to verify the integration hasn\'t broken:\n\n```yaml\n# .github/workflows/test.yml\n- name: Start dev server\n run: pnpm dev &\n \n- name: Wait for server\n run: npx wait-on http://localhost:3000\n\n- name: Run Flusterduck test suite\n run: |\n npx @flusterduck/test-suite run \\\n --url http://localhost:3000 \\\n --key ${{ secrets.FLUSTERDUCK_TEST_KEY }} \\\n --scenarios rage-click,form-abandon \\\n --fail-on-missed-signals\n```\n\n`--fail-on-missed-signals` exits with code 1 if any expected signals weren\'t received by the engine. Use this to catch SDK initialization regressions.\n\nUse a separate `fd_pub_` key for CI testing. Create one under Settings > API Keys and label it "test." Data from this key won\'t affect your production scores if you scope your dashboard view to the production key.\n\n## Testing consent flows\n\nSimulate a session where tracking starts paused:\n\n```bash\nnpx @flusterduck/test-suite run \\\n --url http://localhost:3000 \\\n --key fd_pub_xxxxxxxxxxxx \\\n --scenarios rage-click \\\n --consent-state declined\n```\n\nWith `--consent-state declined`, the runner calls `setConsent(false)` after page load. Signals should not be received by the engine. The test fails if they are.\n\n```bash\n--consent-state accepted # setConsent(true), signals should flow (default)\n--consent-state declined # setConsent(false), signals should not flow\n--consent-state unset # no consent call, tests default behavior\n```\n\n## Testing with identify()\n\nPass segment properties to simulate an identified session:\n\n```bash\nnpx @flusterduck/test-suite run \\\n --url http://localhost:3000/checkout \\\n --key fd_pub_xxxxxxxxxxxx \\\n --scenarios rage-click \\\n --identify \'{"plan": "scale", "role": "admin"}\'\n```\n\nThe runner calls `identify()` with the provided properties before executing scenarios. Verify in your dashboard that the session has the expected segment properties attached.\n\n## Debugging a missed signal\n\nIf a signal shows "no signal" in the output:\n\n1. Check `ignoreElements` in your SDK config. The element selector might match a suppression rule.\n2. Check `ignorePages`. The page path might be on the skip list.\n3. Check `sampleRate`. If it\'s set below 1.0, some sessions won\'t be tracked. The test runner uses a fixed seed to ensure consistent sampling, but a very low sample rate can still produce skipped sessions.\n4. Run with `--debug` to see the full SDK console output in the terminal.\n\n```bash\nnpx @flusterduck/test-suite run \\\n --url http://localhost:3000 \\\n --key fd_pub_xxxxxxxxxxxx \\\n --scenarios rage-click \\\n --debug\n```\n'
|
|
315
|
+
},
|
|
316
|
+
{
|
|
317
|
+
"slug": "sdk",
|
|
318
|
+
"title": "SDK reference",
|
|
319
|
+
"description": "Configuration options and the full method surface of the browser SDK.",
|
|
320
|
+
"group": "SDK & frameworks",
|
|
321
|
+
"content": "# SDK Reference\n\nOne required config option: `key`. The SDK handles everything else automatically.\n\n## Installation\n\n```bash\npnpm add flusterduck\n```\n\nFramework wrappers install alongside the core SDK:\n\n```bash\npnpm add flusterduck @flusterduck/next # Next.js\npnpm add flusterduck @flusterduck/react # React\npnpm add flusterduck @flusterduck/vue # Vue\npnpm add flusterduck @flusterduck/svelte # SvelteKit\npnpm add flusterduck @flusterduck/nuxt # Nuxt\n```\n\n## What you get automatically\n\nOnce `init()` runs, the SDK starts detecting all 33 behavioral signal types without any additional code. You don't call `signal()` for rage clicks, dead clicks, form abandonment, scroll confusion, or mobile tap misses -- those just work.\n\nSignal classification runs locally in the browser. The SDK sends classified signals and safe metadata to the ingest API; scoring, alerting, and issue correlation happen after ingest.\n\nYou only call `signal()` manually for signals tied to application logic the SDK can't observe (a specific element that isn't interactive by default, a custom form flow, etc.), or to add metadata to auto-detected signals.\n\n## init()\n\nCall once at app startup. Calling it multiple times after initialization is a no-op.\n\n```ts\nimport { init } from 'flusterduck'\n\ninit({ key: process.env.NEXT_PUBLIC_FLUSTERDUCK_KEY! })\n```\n\n### Config options\n\n| Option | Type | Default | Notes |\n|---|---|---|---|\n| `key` | `string` | **required** | Your `fd_pub_` publishable key. Passing a `fd_sec_` key here logs an error and refuses to initialize. |\n| `environment` | `string` | `\"production\"` | Attached to every signal. Use `\"staging\"` or `\"development\"` to keep non-prod data out of your production scores. |\n| `sampleRate` | `number` | `1` | Fraction of sessions to track. Most teams don't need this until they're at 50k+ sessions/month. At that point, `0.5` cuts your ingestion in half with negligible impact on signal quality. |\n| `debug` | `boolean` | `false` | Logs every signal to the console as it's emitted. Don't ship with this on -- it's noisy. |\n| `segment` | `Record<string, string>` | `undefined` | Static tags attached to every signal in the session, such as release version or experiment cohort. |\n\nMost teams ship with only `key`, `environment`, and a small `segment` object. That's enough.\n\n### Example\n\n```ts\ninit({\n key: process.env.NEXT_PUBLIC_FLUSTERDUCK_KEY!,\n environment: process.env.NODE_ENV === 'production' ? 'production' : 'staging',\n segment: {\n app_version: process.env.NEXT_PUBLIC_APP_VERSION ?? 'unknown',\n },\n})\n```\n\n## signal()\n\nEmit a behavioral signal manually. Use this when you need to tie a signal to application state the SDK can't observe automatically, or to attach metadata.\n\n```ts\nimport { signal } from 'flusterduck'\n\nsignal(type, metadata?)\n```\n\n```ts\n// Attach metadata to a signal the SDK might also auto-detect\nsignal('dead_click', {\n selector: '[data-plan=\"enterprise\"]',\n label: 'Enterprise CTA',\n page_section: 'pricing-table',\n})\n\n// Signal for a custom multi-step flow\nsignal('form_abandonment', {\n form: 'onboarding',\n step: 3,\n last_field: 'company_size',\n})\n```\n\nNever put PII, form values, or anything user-typed into metadata. Safe fields: element selectors, labels, page section names, plan IDs, product IDs.\n\n## track()\n\nRecord business events. The scoring engine uses these to estimate revenue impact from friction.\n\n```ts\nimport { track } from 'flusterduck'\n\ntrack(event, properties?)\n```\n\n```ts\n// User expressed intent to purchase\ntrack('plan_intent', {\n plan_id: 'scale',\n amount_cents: 9900,\n billing: 'monthly',\n})\n\n// Purchase completed\ntrack('subscription_started', {\n plan_id: 'scale',\n amount_cents: 9900,\n billing: 'monthly',\n})\n\n// Cart or checkout abandoned\ntrack('checkout_abandoned', {\n plan_id: 'grow',\n amount_cents: 3900,\n})\n```\n\nSafe properties: `plan_id`, `amount_cents`, `billing`, `currency`, `quantity`, `product_id`. Never pass names, emails, addresses, or any text the user typed.\n\n## identify()\n\nTag the current session with user or account properties. Values must be safe strings. Use opaque IDs only -- a database primary key or UUID is fine, but not an email or name.\n\n```ts\nimport { identify } from 'flusterduck'\n\n// After login\nidentify({\n user_id: 'usr_8f3a2c91',\n plan: 'scale',\n account_age_days: '42',\n})\n```\n\nDo not pass `user@example.com` or `\"Alice Johnson\"` as any property. Flusterduck doesn't know which of your values are personally identifying, so that's on you.\n\n## setConsent()\n\nUpdate consent state after initialization. Called when a user accepts or rejects your cookie/tracking prompt.\n\n```ts\nimport { setConsent } from 'flusterduck'\n\nsetConsent(true) // start tracking\nsetConsent(false) // pause tracking, flush buffer\n```\n\nFor strict pre-consent gating, wait to call `init()` until the user accepts, or use a framework wrapper with `enabled: false`. Calling `setConsent(false)` on an active session flushes the buffer and stops collection immediately.\n\n## optOut()\n\nOpt the current user out for the session. Semantically clearer than `setConsent(false)` when the user has explicitly requested no tracking, versus consent not yet collected.\n\n```ts\nimport { optOut } from 'flusterduck'\n\noptOut()\n```\n\n## destroy()\n\nShut down the SDK, remove all listeners, clear the session buffer. Use this if you need to completely tear down Flusterduck from a running app instance -- for example, in a test environment or an embedded widget that gets unmounted.\n\n```ts\nimport { destroy } from 'flusterduck'\n\ndestroy()\n```\n\nCalling `init()` after `destroy()` reinitializes a fresh session.\n\n## TypeScript\n\nThe package ships full type definitions. If you want compile-time safety on signal types:\n\n```ts\nimport { signal } from 'flusterduck'\nimport type { SignalType } from 'flusterduck'\n\nconst type: SignalType = 'rage_click' // autocomplete across all 33 types\nsignal(type, { selector: '#submit-btn' })\n```\n\n## Common mistakes\n\n**Passing a secret key.** The SDK checks the key prefix on `init()` and will refuse to initialize with `fd_sec_` or `fd_mcp_` keys. It logs a console error pointing to this page.\n\n**Calling `init()` in a server component.** The SDK is browser-only. Calling `init()` in a Next.js Server Component, an edge function, or any non-browser context will throw. Gate it with `typeof window !== 'undefined'` if needed, or use the `@flusterduck/next` wrapper which handles this automatically.\n\n**Calling `identify()` before initialization.** `identify()` is a no-op until `init()` has completed. Call it as early as possible after login and after the SDK is initialized.\n"
|
|
322
|
+
},
|
|
323
|
+
{
|
|
324
|
+
"slug": "react-to-signals",
|
|
325
|
+
"title": "Reacting to signals",
|
|
326
|
+
"description": "Run code the instant a friction signal fires with onSignal and the flusterduck:signal window event.",
|
|
327
|
+
"group": "SDK & frameworks",
|
|
328
|
+
"content": "# Reacting to signals\n\nFlusterduck normally works in the background: it watches behavior, clusters friction into issues, and verifies your fixes. But sometimes you want to act on a friction signal **the moment it happens** in the browser, before it is ever sent or aggregated.\n\n`onSignal` gives you that. The instant a detector fires (a rage click, a dead click, form hesitation, scroll thrash, and so on) your code runs, synchronously, with the signal in hand.\n\n## Why react in real time\n\nA detected signal is a person struggling **right now**. That is a moment to help:\n\n- Pop a help widget or live chat when someone rage-clicks a checkout button.\n- Surface an inline hint when a form field triggers hesitation.\n- Offer a discount or a callback when an exit-intent signal fires on pricing.\n- Power a live, in-page demo of Flusterduck (this is how our own site does it).\n\nThe signal stays on the device until you decide what to do with it. No round trip, no waiting for aggregation.\n\n## The callback\n\nPass `onSignal` to `init`:\n\n```ts\nimport { init } from 'flusterduck'\n\ninit({\n key: process.env.NEXT_PUBLIC_FLUSTERDUCK_KEY!,\n onSignal(signal) {\n if (signal.name === 'rage_click') {\n openHelpWidget({ near: signal.element })\n }\n },\n})\n```\n\nOr subscribe at any time after init, which returns an unsubscribe function:\n\n```ts\nimport { onSignal } from 'flusterduck'\n\nconst off = onSignal((signal) => {\n console.log(signal.name, 'on', signal.element)\n})\n\n// later\noff()\n```\n\nBoth run synchronously when the signal is detected, before it is batched or sent. Listeners are torn down automatically on `destroy()`.\n\n## The signal payload\n\nYour listener receives an `EmittedSignal`:\n\n| Field | Type | Description |\n| --- | --- | --- |\n| `name` | `string` | The signal type, e.g. `rage_click`, `dead_click`, `form_hesitation`. |\n| `element` | `string` | CSS selector of the element involved (empty if not element-bound). |\n| `weight` | `number` | Severity contribution, 0 to 100. |\n| `meta` | `object` | Detector metadata (counts, distances, timings). |\n| `ts` | `number` | Timestamp in milliseconds. |\n\nIt is behavioral only. It never contains form values, text content, or any PII, the same guarantee as everything else Flusterduck collects.\n\n## The window event\n\nIf you would rather listen without wiring a callback through `init`, opt in with `emitSignalEvents` and listen for a `flusterduck:signal` event on `window`:\n\n```ts\ninit({\n key: process.env.NEXT_PUBLIC_FLUSTERDUCK_KEY!,\n emitSignalEvents: true,\n})\n\nwindow.addEventListener('flusterduck:signal', (event) => {\n const signal = event.detail // EmittedSignal\n showToast(`${signal.name} on ${signal.element}`)\n})\n```\n\nThis is handy for decoupled code, analytics bridges, or dropping a listener in from a separate script. It is **off by default** so signals are not exposed to other scripts on the page unless you ask for it.\n\n## Notes\n\n- `onSignal` fires for automatically detected friction signals and for manual `signal()` calls.\n- It does not change what gets sent to Flusterduck. Reacting in the browser and tracking the issue are independent.\n- Keep listeners fast. They run on the detection path, so a slow listener can affect responsiveness. Flusterduck swallows listener errors so a thrown error can never break ingestion.\n- If `sampleRate` is below 1, sampled-out sessions emit nothing at all, including `onSignal`. Use `sampleRate: 1` when you depend on it for UX.\n"
|
|
329
|
+
},
|
|
330
|
+
{
|
|
331
|
+
"slug": "cli",
|
|
332
|
+
"title": "CLI reference",
|
|
333
|
+
"description": "Detect your framework, install the right packages, and inject the init call automatically.",
|
|
334
|
+
"group": "SDK & frameworks",
|
|
335
|
+
"content": "# CLI Reference\n\n```bash\nnpx flusterduck-cli init\n```\n\nDetects your framework, installs the right packages, and injects the init call into the correct entry file. Prompts for your `fd_pub_` key unless you pass it inline.\n\nYou don't need to install the CLI globally. `npx` pulls the latest version each time.\n\n## Options\n\n### `--key fd_pub_xxxx`\n\nSkip the key prompt:\n\n```bash\nnpx flusterduck-cli init --key fd_pub_xxxxxxxxxxxx\n```\n\nUseful in CI pipelines, onboarding scripts, and anywhere you want a non-interactive run.\n\n### `--skip-install`\n\nInject the setup code without installing packages. Use this if you've already installed the SDK separately or if your package manager workflow requires a separate step.\n\n```bash\nnpx flusterduck-cli init --skip-install --key fd_pub_xxxxxxxxxxxx\n```\n\n## Framework detection\n\nThe CLI reads your `package.json` and project structure to determine your framework, then installs and injects accordingly:\n\n| Detected | Packages installed | File injected |\n|---|---|---|\n| Next.js | `flusterduck` `@flusterduck/next` | `app/layout.tsx` (App Router) or `pages/_app.tsx` (Pages Router) |\n| SvelteKit | `flusterduck` `@flusterduck/svelte` | `src/routes/+layout.svelte` |\n| Nuxt | `flusterduck` `@flusterduck/nuxt` | `nuxt.config.ts` |\n| React (Vite / CRA) | `flusterduck` | `src/main.tsx` or `src/index.tsx` |\n| Generic / unknown | `flusterduck` | No injection |\n\nIf the CLI can't determine your framework, it installs the core SDK and exits with a code snippet to add manually.\n\n## What gets injected\n\nThe injected code is a single import and a single init call. For Next.js App Router:\n\n```tsx\n// app/layout.tsx\nimport { FlusterduckScript } from '@flusterduck/next'\n\n// Added inside your RootLayout body:\n<FlusterduckScript apiKey={process.env.NEXT_PUBLIC_FLUSTERDUCK_KEY!} />\n```\n\nFor vanilla React:\n\n```tsx\n// src/main.tsx\nimport { init } from 'flusterduck'\ninit({ key: process.env.VITE_FLUSTERDUCK_KEY! })\n```\n\nThe CLI doesn't rewrite existing imports or touch your component tree. If Flusterduck is already present in the target file, it skips injection and tells you.\n\n## Environment variable\n\nThe injected code references an environment variable, not a literal key. After running the CLI, set the variable:\n\n```bash\n# .env.local (Next.js)\nNEXT_PUBLIC_FLUSTERDUCK_KEY=fd_pub_xxxxxxxxxxxx\n\n# .env (Vite / SvelteKit)\nVITE_FLUSTERDUCK_KEY=fd_pub_xxxxxxxxxxxx\n```\n\nThe CLI output tells you the exact variable name for your framework.\n\n## Troubleshooting\n\n**\"Could not automatically inject code\"** -- the CLI found the entry file but couldn't parse it (unusual syntax, non-standard structure). Wire the init call manually following the [quickstart](./quickstart).\n\n**\"Flusterduck is already configured\"** -- the SDK was already detected in the target file. Nothing changed.\n\n**\"Failed to install dependencies\"** -- package installation failed. The CLI prints the exact install command to run yourself.\n"
|
|
336
|
+
},
|
|
337
|
+
{
|
|
338
|
+
"slug": "frameworks",
|
|
339
|
+
"title": "Framework guides",
|
|
340
|
+
"description": "Framework-specific wrappers that handle initialization and lifecycle automatically.",
|
|
341
|
+
"group": "SDK & frameworks",
|
|
342
|
+
"content": "# Framework Guides\n\nFramework-specific wrappers handle initialization and lifecycle automatically. They all install alongside the core SDK.\n\n## Next.js\n\n### App Router\n\n```bash\npnpm add flusterduck @flusterduck/next\n```\n\nAdd `FlusterduckScript` to your root layout. It renders a script tag that initializes the SDK after the page loads.\n\n```tsx\n// app/layout.tsx\nimport { FlusterduckScript } from '@flusterduck/next'\n\nexport default function RootLayout({ children }: { children: React.ReactNode }) {\n return (\n <html lang=\"en\">\n <body>\n {children}\n <FlusterduckScript apiKey={process.env.NEXT_PUBLIC_FLUSTERDUCK_KEY!} />\n </body>\n </html>\n )\n}\n```\n\n```bash\n# .env.local\nNEXT_PUBLIC_FLUSTERDUCK_KEY=fd_pub_xxxxxxxxxxxx\n```\n\n`FlusterduckScript` accepts all the same options as `init()`:\n\n```tsx\n<FlusterduckScript\n apiKey={process.env.NEXT_PUBLIC_FLUSTERDUCK_KEY!}\n environment=\"production\"\n sampleRate={0.5}\n segment={{ app_version: process.env.NEXT_PUBLIC_APP_VERSION ?? 'unknown' }}\n/>\n```\n\n### Pages Router\n\nSame approach, but add it to `_app.tsx`:\n\n```tsx\n// pages/_app.tsx\nimport type { AppProps } from 'next/app'\nimport { FlusterduckScript } from '@flusterduck/next'\n\nexport default function App({ Component, pageProps }: AppProps) {\n return (\n <>\n <Component {...pageProps} />\n <FlusterduckScript apiKey={process.env.NEXT_PUBLIC_FLUSTERDUCK_KEY!} />\n </>\n )\n}\n```\n\n### useFlusterduck hook\n\nThe `@flusterduck/next` package exports a `useFlusterduck` hook for accessing SDK methods in client components:\n\n```tsx\n'use client'\nimport { useFlusterduck } from '@flusterduck/next'\n\nexport function UpgradeButton() {\n const { signal, track, setConsent, optOut } = useFlusterduck()\n\n const handleClick = () => {\n track('plan_intent', { plan_id: 'scale', amount_cents: 9900, billing: 'monthly' })\n }\n\n return <button onClick={handleClick}>Upgrade to Scale</button>\n}\n```\n\n---\n\n## React\n\n```bash\npnpm add flusterduck @flusterduck/react\n```\n\nWrap your app with `FlusterduckProvider`:\n\n```tsx\n// src/main.tsx\nimport { createRoot } from 'react-dom/client'\nimport { FlusterduckProvider } from '@flusterduck/react'\nimport { App } from './App'\n\ncreateRoot(document.getElementById('root')!).render(\n <FlusterduckProvider apiKey={import.meta.env.VITE_FLUSTERDUCK_KEY}>\n <App />\n </FlusterduckProvider>\n)\n```\n\n```bash\n# .env\nVITE_FLUSTERDUCK_KEY=fd_pub_xxxxxxxxxxxx\n```\n\nUse the `useFlusterduck` hook anywhere inside the provider:\n\n```tsx\nimport { useFlusterduck } from '@flusterduck/react'\n\nfunction CheckoutForm() {\n const { signal, track } = useFlusterduck()\n\n const handleAbandon = () => {\n signal('form_abandonment', { form: 'checkout' })\n }\n\n const handleComplete = () => {\n track('subscription_started', {\n plan_id: 'grow',\n amount_cents: 3900,\n billing: 'monthly',\n })\n }\n\n return (\n <form onBlur={handleAbandon} onSubmit={handleComplete}>\n {/* ... */}\n </form>\n )\n}\n```\n\nThe provider accepts the same config options as `init()`:\n\n```tsx\n<FlusterduckProvider\n apiKey={import.meta.env.VITE_FLUSTERDUCK_KEY}\n sampleRate={0.5}\n enabled={userHasConsented}\n segment={{ app_version: __APP_VERSION__ }}\n>\n```\n\n---\n\n## Vue 3\n\n```bash\npnpm add flusterduck @flusterduck/vue\n```\n\nRegister the plugin in your app entry:\n\n```ts\n// src/main.ts\nimport { createApp } from 'vue'\nimport { FlusterduckPlugin } from '@flusterduck/vue'\nimport App from './App.vue'\n\nconst app = createApp(App)\n\napp.use(FlusterduckPlugin, {\n apiKey: import.meta.env.VITE_FLUSTERDUCK_KEY,\n segment: { app_version: import.meta.env.VITE_APP_VERSION ?? 'unknown' },\n})\n\napp.mount('#app')\n```\n\n```bash\n# .env\nVITE_FLUSTERDUCK_KEY=fd_pub_xxxxxxxxxxxx\n```\n\nUse the `useFlusterduck` composable in your components:\n\n```vue\n<script setup lang=\"ts\">\nimport { useFlusterduck } from '@flusterduck/vue'\n\nconst { signal, track, setConsent } = useFlusterduck()\n\nfunction onUpgradeClick() {\n track('plan_intent', { plan_id: 'scale', amount_cents: 9900 })\n}\n</script>\n\n<template>\n <button @click=\"onUpgradeClick\">Upgrade</button>\n</template>\n```\n\n---\n\n## SvelteKit\n\n```bash\npnpm add flusterduck @flusterduck/svelte\n```\n\nInitialize in your root layout:\n\n```svelte\n<!-- src/routes/+layout.svelte -->\n<script>\n import { initFlusterduck } from '@flusterduck/svelte'\n import { browser } from '$app/environment'\n\n if (browser) {\n initFlusterduck({\n apiKey: import.meta.env.PUBLIC_FLUSTERDUCK_KEY,\n })\n }\n</script>\n\n<slot />\n```\n\n```bash\n# .env\nPUBLIC_FLUSTERDUCK_KEY=fd_pub_xxxxxxxxxxxx\n```\n\nUse the tracking functions in any component:\n\n```svelte\n<script>\n import { track } from '@flusterduck/svelte'\n\n function handleUpgrade() {\n track('plan_intent', { plan_id: 'scale', amount_cents: 9900 })\n }\n</script>\n\n<button on:click={handleUpgrade}>Upgrade</button>\n```\n\n---\n\n## Nuxt 3\n\n```bash\npnpm add flusterduck @flusterduck/nuxt\n```\n\nAdd the module to your Nuxt config:\n\n```ts\n// nuxt.config.ts\nexport default defineNuxtConfig({\n modules: ['@flusterduck/nuxt'],\n\n flusterduck: {\n apiKey: process.env.NUXT_PUBLIC_FLUSTERDUCK_KEY,\n segment: { app_version: process.env.NUXT_PUBLIC_APP_VERSION ?? 'unknown' },\n },\n})\n```\n\n```bash\n# .env\nNUXT_PUBLIC_FLUSTERDUCK_KEY=fd_pub_xxxxxxxxxxxx\n```\n\nUse the composable in your pages and components:\n\n```vue\n<script setup lang=\"ts\">\nconst { signal, track } = useFlusterduck()\n\nfunction onCheckout() {\n track('plan_intent', { plan_id: 'grow', amount_cents: 3900, billing: 'monthly' })\n}\n</script>\n```\n\nThe module auto-initializes on the client side. No additional setup needed.\n\n---\n\n## Vanilla JS / any framework\n\n```bash\npnpm add flusterduck\n```\n\nCall `init()` once, as early as possible in the browser lifecycle:\n\n```ts\nimport { init, signal, track } from 'flusterduck'\n\ninit({\n key: 'fd_pub_xxxxxxxxxxxx',\n segment: { app_version: '2026.06.10' },\n})\n```\n\nOr via script tag (CDN):\n\n```html\n<script src=\"https://cdn.flusterduck.com/sdk/latest/flusterduck.min.js\"></script>\n<script>\n Flusterduck.init({ key: 'fd_pub_xxxxxxxxxxxx' })\n</script>\n```\n\n---\n\n## Environment variables by framework\n\n| Framework | Variable name pattern |\n|---|---|\n| Next.js | `NEXT_PUBLIC_FLUSTERDUCK_KEY` |\n| Vite / React / Vue | `VITE_FLUSTERDUCK_KEY` |\n| SvelteKit | `PUBLIC_FLUSTERDUCK_KEY` |\n| Nuxt | `NUXT_PUBLIC_FLUSTERDUCK_KEY` |\n| Node / server | `FLUSTERDUCK_SECRET_KEY` (use `fd_sec_`) |\n\nThe `fd_pub_` key is the only key safe for client-side environment variables. Everything else stays on the server.\n"
|
|
343
|
+
},
|
|
344
|
+
{
|
|
345
|
+
"slug": "react",
|
|
346
|
+
"title": "React",
|
|
347
|
+
"description": "Wrap your app root with FlusterduckProvider and signal detection starts across the whole tree.",
|
|
348
|
+
"group": "SDK & frameworks",
|
|
349
|
+
"content": "# React\n\nWrap your app root with `FlusterduckProvider`. Signal detection starts immediately across the entire tree. That's the whole setup for most apps.\n\nReach for `useFlusterduck` when initialization needs to be conditional: consent flows, feature flags, auth-gated tracking. Don't use both patterns in the same app.\n\n## Install\n\n```bash\npnpm add flusterduck @flusterduck/react\n```\n\n## Setup\n\n```tsx\n// src/main.tsx\nimport { createRoot } from 'react-dom/client'\nimport { FlusterduckProvider } from '@flusterduck/react'\nimport { App } from './App'\n\ncreateRoot(document.getElementById('root')!).render(\n <FlusterduckProvider apiKey={import.meta.env.VITE_FLUSTERDUCK_KEY}>\n <App />\n </FlusterduckProvider>\n)\n```\n\n```bash\n# .env\nVITE_FLUSTERDUCK_KEY=fd_pub_xxxxxxxxxxxx\n```\n\nNote `apiKey`, not `key`. React reserves the `key` prop for reconciliation. It gets stripped before it reaches the component. `apiKey` maps internally to `key` in the SDK config.\n\n## FlusterduckProvider\n\n### Props\n\n| Prop | Type | Default | Description |\n|---|---|---|---|\n| `apiKey` | `string` | - | Required. Your `fd_pub_` key. |\n| `environment` | `string` | - | `\"production\"`, `\"staging\"`, `\"development\"` |\n| `sampleRate` | `number` | `1.0` | `0.1` tracks 10% of sessions. Useful for high-traffic apps where full coverage is expensive. |\n| `domMode` | `'off' \\| 'metadata' \\| 'snapshot'` | `'off'` | `'metadata'` captures element attributes with each signal. `'snapshot'` adds computed layout and styles. |\n| `cookieless` | `boolean` | `false` | Memory-only session IDs instead of cookies. |\n| `respectDoNotTrack` | `boolean` | `false` | Honor `navigator.doNotTrack`. |\n| `ignoreElements` | `string[]` | `[]` | CSS selectors to suppress signals on. Good for admin toolbars or debug overlays. |\n| `ignorePages` | `string[]` | `[]` | Page paths to skip entirely. |\n| `segment` | `Record<string, string>` | - | Static tags on every event: app version, experiment variant, user cohort. |\n| `debug` | `boolean` | `false` | Logs every signal and flush to the console. |\n| `enabled` | `boolean` | `true` | `false` skips initialization. Flip to `true` and it initializes then. |\n\n## useFlusterduck hook\n\nInitializes the SDK and returns tracking methods. Use this instead of `FlusterduckProvider` when the init decision isn't static.\n\n```tsx\n// src/App.tsx\nimport { useFlusterduck } from '@flusterduck/react'\n\nexport function App() {\n const { signal, track, identify, setConsent, optOut } = useFlusterduck({\n key: import.meta.env.VITE_FLUSTERDUCK_KEY,\n })\n\n return <Router />\n}\n```\n\nOptions are identical to `FlusterduckProvider` props, but uses `key` instead of `apiKey` (no JSX involved, no prop stripping).\n\n### Return value\n\n| Method | Description |\n|---|---|\n| `signal(name, data?)` | Emit a friction signal manually. |\n| `track(name, metadata?)` | Track a business event. |\n| `identify(segment)` | Tag the session with user properties. |\n| `setConsent(consented)` | Pause or resume collection. |\n| `optOut()` | Stop collection permanently for this session. |\n\n## Tracking in components\n\nInitialize once at the root. After that, import from `flusterduck` directly in any component. No hook call needed:\n\n```tsx\nimport { signal } from 'flusterduck'\n\nexport function MultiStepForm({ step }: { step: number }) {\n return (\n <div onMouseLeave={() => signal('task_abandonment', { metadata: { step } })}>\n {/* form content */}\n </div>\n )\n}\n```\n\n```tsx\nimport { track } from 'flusterduck'\n\nexport function PublishButton({ docId }: { docId: string }) {\n return (\n <button onClick={() => track('document_published', { doc_id: docId })}>\n Publish\n </button>\n )\n}\n```\n\nCore SDK functions buffer events until the SDK initializes. Calling them before the provider mounts is safe.\n\n## Conditional initialization\n\n`enabled` controls whether the SDK initializes at all. Set it to `false` and nothing runs. Flip it to `true` and the SDK initializes at that point.\n\n```tsx\nexport function Root() {\n const [consented, setConsented] = useState(false)\n\n return (\n <FlusterduckProvider\n apiKey={import.meta.env.VITE_FLUSTERDUCK_KEY}\n enabled={consented}\n >\n <App onConsentChange={setConsented} />\n </FlusterduckProvider>\n )\n}\n```\n\n`enabled: false` skips initialization entirely. No session, no buffering, nothing recorded. `setConsent(false)` initializes but pauses collection. Anonymous session data accumulates until consent flips. Use `enabled: false` if your legal requirement is zero data before consent. Use `setConsent(false)` if anonymous pre-consent analytics are acceptable and you want to resume without a full re-init.\n\n## Consent flow\n\n```tsx\nimport { setConsent } from 'flusterduck'\n\nexport function ConsentBanner({ onDecide }: { onDecide: (accepted: boolean) => void }) {\n return (\n <div>\n <p>We use behavioral analytics to improve this product.</p>\n <button onClick={() => { setConsent(true); onDecide(true) }}>Accept</button>\n <button onClick={() => { setConsent(false); onDecide(false) }}>Decline</button>\n </div>\n )\n}\n```\n\n`setConsent(false)` pauses immediately. `setConsent(true)` resumes. The consent state doesn't persist across reloads. The SDK initializes fresh each session, so you'll re-apply it on mount from whatever storage you use.\n\n## Identifying users\n\n```tsx\nimport { useEffect } from 'react'\nimport { identify } from 'flusterduck'\n\nexport function IdentityBridge({ userId, plan }: { userId?: string; plan?: string }) {\n useEffect(() => {\n if (!userId) return\n identify({ user_id: userId, plan: plan ?? 'trial' })\n }, [userId, plan])\n\n return null\n}\n```\n\nRender this inside your auth provider where `userId` is reliably available. Pass an opaque internal ID, not an email, name, or anything PII.\n\n## Deploy tagging\n\n```tsx\n<FlusterduckProvider\n apiKey={import.meta.env.VITE_FLUSTERDUCK_KEY}\n segment={{ app_version: import.meta.env.VITE_APP_VERSION ?? 'unknown' }}\n>\n <App />\n</FlusterduckProvider>\n```\n\nEvery session gets tagged with the version that was running when they landed. Confusion score comparisons in the dashboard then map cleanly to your deploy history.\n\n## React StrictMode\n\nStrictMode double-invokes effects in development to surface side effects. The `useFlusterduck` hook handles this correctly. A `useRef` flag prevents double initialization, and the cleanup function cancels any in-flight dynamic import. You won't get duplicate `init()` calls.\n\n## Gotchas\n\n**Use `apiKey`, not `key`.** React strips the `key` prop from elements before they reach the component. `<FlusterduckProvider key=\"fd_pub_...\">` silently passes nothing. This is by design. The fix is already baked in, but if you see the SDK not initializing, check that you're passing `apiKey`.\n\n**Don't call `useFlusterduck` in child components.** There's no reason to. Initialize once at the root, import directly from `flusterduck` everywhere else. Calling `useFlusterduck` in multiple components won't double-initialize, but it creates unnecessary hooks.\n\n**`FlusterduckProvider` and `useFlusterduck` both call `init()` internally.** Don't wrap your app with `FlusterduckProvider` AND call `useFlusterduck` somewhere in the tree. Pick one approach.\n\n## TypeScript\n\n```ts\nimport type { SignalData } from 'flusterduck'\n\nconst data: SignalData = {\n element: '[data-role=\"submit\"]',\n metadata: { validation_errors: ['email_invalid', 'name_required'] },\n}\nsignal('form_error', data)\n```\n"
|
|
350
|
+
},
|
|
351
|
+
{
|
|
352
|
+
"slug": "next",
|
|
353
|
+
"title": "Next.js",
|
|
354
|
+
"description": "Add FlusterduckScript to your root layout. A Server Component with no client JS added to your bundle.",
|
|
355
|
+
"group": "SDK & frameworks",
|
|
356
|
+
"content": "# Next.js\n\nFor most apps: add `FlusterduckScript` to your root layout. Done. It's a Server Component that renders a non-blocking script tag with no client JS added to your bundle and no extra render cycle.\n\nReach for `useFlusterduck` when you need to gate on consent state, flip tracking on/off based on auth, or access methods like `setConsent` in a component.\n\nDon't use both in the same app.\n\n## Install\n\n```bash\npnpm add flusterduck @flusterduck/next\n```\n\n## App Router\n\n```tsx\n// app/layout.tsx\nimport { FlusterduckScript } from '@flusterduck/next'\n\nexport default function RootLayout({ children }: { children: React.ReactNode }) {\n return (\n <html lang=\"en\">\n <body>\n {children}\n <FlusterduckScript apiKey={process.env.NEXT_PUBLIC_FLUSTERDUCK_KEY!} />\n </body>\n </html>\n )\n}\n```\n\n```bash\n# .env.local\nNEXT_PUBLIC_FLUSTERDUCK_KEY=fd_pub_xxxxxxxxxxxx\n```\n\n`FlusterduckScript` is a Server Component. Don't add `'use client'` to your layout for this. That defeats the purpose. The script executes in the browser after delivery.\n\n## Pages Router\n\n```tsx\n// pages/_app.tsx\nimport type { AppProps } from 'next/app'\nimport { FlusterduckScript } from '@flusterduck/next'\n\nexport default function App({ Component, pageProps }: AppProps) {\n return (\n <>\n <Component {...pageProps} />\n <FlusterduckScript apiKey={process.env.NEXT_PUBLIC_FLUSTERDUCK_KEY!} />\n </>\n )\n}\n```\n\n## FlusterduckScript\n\nRenders a `next/script` tag with `strategy=\"afterInteractive\"`. Doesn't block rendering or hydration. Configuration is carried as `data-*` attributes on the tag, no runtime bundle cost.\n\n### Props\n\n| Prop | Type | Default | Description |\n|---|---|---|---|\n| `apiKey` | `string` | - | Required. Your `fd_pub_` key. |\n| `environment` | `string` | - | `\"production\"`, `\"staging\"`, `\"development\"` |\n| `sampleRate` | `number` | `1.0` | `0.5` tracks half of sessions. |\n| `cookieless` | `boolean` | `false` | Use memory-only session IDs instead of cookies. |\n| `debug` | `boolean` | `false` | Logs every signal and flush to the console. |\n| `segment` | `Record<string, string>` | - | Key/value pairs attached to every event. Use for deploy tagging, A/B variants, feature flags. |\n\n`domMode`, `ignoreElements`, `batchInterval`, and other advanced options aren't available on `FlusterduckScript`. It's a script tag, not a programmatic init call. Use `useFlusterduck` if you need them.\n\nPassing a secret key (`fd_sec_`) logs an error in development and renders nothing in production.\n\n## useFlusterduck\n\nA Client Component hook. Initializes the full SDK with all options and returns tracking methods. Use this instead of `FlusterduckScript` when initialization needs to be conditional.\n\n```tsx\n'use client'\nimport { useFlusterduck } from '@flusterduck/next'\n\nexport function Analytics() {\n useFlusterduck({ key: process.env.NEXT_PUBLIC_FLUSTERDUCK_KEY! })\n return null\n}\n```\n\nDrop this component in your root layout as a `'use client'` island.\n\n### Options\n\nEverything from the core `Config` type plus `enabled`:\n\n| Option | Type | Default | Description |\n|---|---|---|---|\n| `key` | `string` | - | Required. Your `fd_pub_` key. |\n| `environment` | `string` | - | Deployment environment. |\n| `sampleRate` | `number` | `1.0` | Fraction of sessions to track. |\n| `domMode` | `'off' \\| 'metadata' \\| 'snapshot'` | `'off'` | `'metadata'` captures element attributes. `'snapshot'` adds layout and computed styles. |\n| `cookieless` | `boolean` | `false` | Memory-only session IDs instead of cookies. |\n| `respectDoNotTrack` | `boolean` | `false` | Honor `navigator.doNotTrack`. |\n| `ignoreElements` | `string[]` | `[]` | CSS selectors to suppress signals on. Useful for admin overlays or debug panels. |\n| `ignorePages` | `string[]` | `[]` | Page paths to skip. Match is prefix-based. |\n| `segment` | `Record<string, string>` | - | Static tags on every event. |\n| `debug` | `boolean` | `false` | Verbose console logging. |\n| `batchInterval` | `number` | - | Milliseconds between flushes. Default flushes on idle and before unload. |\n| `compression` | `'auto' \\| 'off'` | `'auto'` | `'auto'` uses compression when the browser supports it. |\n| `elementImpressionSelectors` | `string[]` | - | CSS selectors to emit an impression signal when they enter the viewport. |\n| `enabled` | `boolean` | `true` | `false` skips initialization entirely. Flip to `true` and it initializes then. |\n\n### Return value\n\n```ts\nconst { signal, track, identify, setConsent, optOut } = useFlusterduck({ key: '...' })\n```\n\n| Method | Description |\n|---|---|\n| `signal(name, data?)` | Emit a friction signal manually. |\n| `track(name, metadata?)` | Track a business event. |\n| `identify(segment)` | Tag the session with user properties. |\n| `setConsent(consented)` | Pause or resume collection. |\n| `optOut()` | Stop collection permanently for this session. |\n\n## Tracking in Client Components\n\nInitialize once at the root. After that, import from `flusterduck` directly. No hook needed:\n\n```tsx\n'use client'\nimport { track } from 'flusterduck'\n\nexport function PlanCard({ planId, price }: { planId: string; price: number }) {\n return (\n <button onClick={() => track('plan_intent', { plan_id: planId, price_cents: price })}>\n Get started\n </button>\n )\n}\n```\n\n```tsx\n'use client'\nimport { signal } from 'flusterduck'\n\nexport function OnboardingStep({ step }: { step: number }) {\n return (\n <div onMouseLeave={() => signal('task_abandonment', { metadata: { step } })}>\n {/* step content */}\n </div>\n )\n}\n```\n\nCore SDK functions buffer until the SDK is ready. Calling them before initialization resolves is safe.\n\n## Consent flow\n\nThe recommended pattern: initialize the SDK, call `setConsent(false)` before showing your banner, then flip it based on the user's choice. Collection is paused until they respond.\n\n```tsx\n// app/layout.tsx\nimport { FlusterduckScript } from '@flusterduck/next'\nimport { ConsentBanner } from '@/components/consent-banner'\n\nexport default function RootLayout({ children }: { children: React.ReactNode }) {\n return (\n <html lang=\"en\">\n <body>\n {children}\n <FlusterduckScript apiKey={process.env.NEXT_PUBLIC_FLUSTERDUCK_KEY!} />\n <ConsentBanner />\n </body>\n </html>\n )\n}\n```\n\n```tsx\n// components/consent-banner.tsx\n'use client'\nimport { useEffect, useState } from 'react'\nimport { setConsent } from 'flusterduck'\n\nexport function ConsentBanner() {\n const [visible, setVisible] = useState(false)\n\n useEffect(() => {\n const stored = localStorage.getItem('fd_consent')\n if (stored === null) {\n setConsent(false)\n setVisible(true)\n } else {\n setConsent(stored === 'true')\n }\n }, [])\n\n if (!visible) return null\n\n const accept = () => {\n localStorage.setItem('fd_consent', 'true')\n setConsent(true)\n setVisible(false)\n }\n\n const decline = () => {\n localStorage.setItem('fd_consent', 'false')\n setConsent(false)\n setVisible(false)\n }\n\n return (\n <div>\n <p>We use behavioral analytics to improve this site.</p>\n <button onClick={accept}>Accept</button>\n <button onClick={decline}>Decline</button>\n </div>\n )\n}\n```\n\nIf you want full React-driven consent gating with no collection until consent is granted, use `useFlusterduck` with `enabled` instead:\n\n```tsx\n'use client'\nimport { useFlusterduck } from '@flusterduck/next'\n\nexport function Analytics({ consented }: { consented: boolean }) {\n useFlusterduck({\n key: process.env.NEXT_PUBLIC_FLUSTERDUCK_KEY!,\n enabled: consented,\n })\n return null\n}\n```\n\nThe difference matters for compliance. `setConsent(false)` initializes but pauses. Anonymous session data is captured and held until consent is granted. `enabled: false` skips initialization entirely. Nothing is recorded until it flips to `true`. If your legal requirement is zero data before consent, use `enabled: false`. If anonymous pre-consent analytics are acceptable, `setConsent(false)` is simpler to wire up.\n\n## Identifying users\n\n```tsx\n'use client'\nimport { useEffect } from 'react'\nimport { identify } from 'flusterduck'\n\nexport function IdentifyUser({\n userId,\n orgId,\n plan,\n}: {\n userId?: string\n orgId?: string\n plan?: string\n}) {\n useEffect(() => {\n if (!userId) return\n identify({ user_id: userId, org_id: orgId ?? '', plan: plan ?? 'trial' })\n }, [userId, orgId, plan])\n\n return null\n}\n```\n\nRender this inside your auth provider where user state is available. Works with Clerk, NextAuth, and any library that exposes user state as props.\n\nPass an opaque internal ID, not an email, name, or anything PII.\n\n## Deploy tagging\n\n```tsx\n<FlusterduckScript\n apiKey={process.env.NEXT_PUBLIC_FLUSTERDUCK_KEY!}\n environment={process.env.NODE_ENV}\n segment={{ app_version: process.env.NEXT_PUBLIC_APP_VERSION ?? 'unknown' }}\n/>\n```\n\nIn Vercel, set `NEXT_PUBLIC_APP_VERSION` to `$VERCEL_GIT_COMMIT_SHA`. Every session gets tagged with the commit that was live when they landed. Before/after confusion score comparisons then align to actual deploys.\n\n## Gotchas\n\n**`FlusterduckScript` is a Server Component.** If your `layout.tsx` has `'use client'` at the top for an unrelated reason, move `FlusterduckScript` to a separate server-rendered layout wrapper, or switch to `useFlusterduck`.\n\n**`useFlusterduck` and `signal`/`track` from `flusterduck` must be in Client Components.** The SDK doesn't run server-side. Calls in Server Components or during SSR are no-ops: no error, no data.\n\n**Don't use both `FlusterduckScript` and `useFlusterduck` in the same app.** The script loads the SDK via CDN; `useFlusterduck` imports the npm package. They're two separate init paths and you'll get duplicate initialization.\n\n**`useFlusterduck` in multiple components won't double-initialize.** The SDK guards against it, but there's no reason to call it more than once. Initialize at the root, use direct imports everywhere else.\n\n## TypeScript\n\n```ts\nimport type { SignalData } from 'flusterduck'\n\nconst data: SignalData = {\n element: '[data-action=\"publish\"]',\n metadata: { blocked_by: 'missing_title' },\n}\nsignal('dead_click', data)\n```\n"
|
|
357
|
+
},
|
|
358
|
+
{
|
|
359
|
+
"slug": "vue",
|
|
360
|
+
"title": "Vue 3",
|
|
361
|
+
"description": "Register FlusterduckPlugin once in your app entry and signal detection starts immediately.",
|
|
362
|
+
"group": "SDK & frameworks",
|
|
363
|
+
"content": "# Vue 3\n\nRegister `FlusterduckPlugin` once in your app entry. Signal detection starts immediately for the entire app. After that, import directly from `flusterduck` in any component. No composable needed for most cases.\n\n## Install\n\n```bash\npnpm add flusterduck @flusterduck/vue\n```\n\n## Setup\n\n```ts\n// src/main.ts\nimport { createApp } from 'vue'\nimport { FlusterduckPlugin } from '@flusterduck/vue'\nimport App from './App.vue'\n\nconst app = createApp(App)\n\napp.use(FlusterduckPlugin, {\n key: import.meta.env.VITE_FLUSTERDUCK_KEY,\n})\n\napp.mount('#app')\n```\n\n```bash\n# .env\nVITE_FLUSTERDUCK_KEY=fd_pub_xxxxxxxxxxxx\n```\n\n## FlusterduckPlugin options\n\n| Option | Type | Default | Description |\n|---|---|---|---|\n| `key` | `string` | - | Required. Your `fd_pub_` key. |\n| `environment` | `string` | - | `\"production\"`, `\"staging\"`, `\"development\"` |\n| `sampleRate` | `number` | `1.0` | `0.25` tracks one in four sessions. |\n| `domMode` | `'off' \\| 'metadata' \\| 'snapshot'` | `'off'` | `'metadata'` captures element attributes. `'snapshot'` adds layout and computed styles. |\n| `cookieless` | `boolean` | `false` | Memory-only session IDs instead of cookies. |\n| `respectDoNotTrack` | `boolean` | `false` | Honor `navigator.doNotTrack`. |\n| `ignoreElements` | `string[]` | `[]` | CSS selectors to suppress signals on. |\n| `ignorePages` | `string[]` | `[]` | Page paths to skip. |\n| `segment` | `Record<string, string>` | - | Static tags on every event. |\n| `debug` | `boolean` | `false` | Verbose console logging. |\n\n## useFlusterduck composable\n\nReturns tracking methods. Takes no arguments. The plugin handles initialization.\n\n```vue\n<script setup lang=\"ts\">\nimport { useFlusterduck } from '@flusterduck/vue'\n\nconst { signal, track, identify, setConsent, optOut } = useFlusterduck()\n</script>\n```\n\nIf you're coming from React: `useFlusterduck()` in Vue takes no options. Don't pass a key here. It won't do anything. Configuration lives entirely in `app.use(FlusterduckPlugin, { ... })`.\n\n### Return value\n\n| Method | Description |\n|---|---|\n| `signal(name, data?)` | Emit a friction signal manually. |\n| `track(name, metadata?)` | Track a business event. |\n| `identify(segment)` | Tag the session with user properties. |\n| `setConsent(consented)` | Pause or resume collection. |\n| `optOut()` | Stop collection permanently for this session. |\n\n## Tracking in components\n\nFor most components, skip `useFlusterduck` entirely and import from `flusterduck` directly:\n\n```vue\n<script setup lang=\"ts\">\nimport { signal, track } from 'flusterduck'\n</script>\n\n<template>\n <button @click=\"track('export_triggered', { format: 'csv', rows: rowCount })\">\n Export CSV\n </button>\n</template>\n```\n\n```vue\n<script setup lang=\"ts\">\nimport { signal } from 'flusterduck'\n\nfunction onStepLeave(step: number) {\n signal('task_abandonment', { metadata: { step, form: 'onboarding' } })\n}\n</script>\n\n<template>\n <div @mouseleave=\"onStepLeave(currentStep)\">\n <!-- wizard step content -->\n </div>\n</template>\n```\n\nCore SDK functions buffer events until the SDK is ready. Safe to call before initialization resolves.\n\n## Consent flow\n\n```vue\n<!-- components/ConsentBanner.vue -->\n<script setup lang=\"ts\">\nimport { ref, onMounted } from 'vue'\nimport { setConsent } from 'flusterduck'\n\nconst visible = ref(false)\n\nonMounted(() => {\n const stored = localStorage.getItem('fd_consent')\n if (stored === null) {\n setConsent(false)\n visible.value = true\n } else {\n setConsent(stored === 'true')\n }\n})\n\nfunction accept() {\n localStorage.setItem('fd_consent', 'true')\n setConsent(true)\n visible.value = false\n}\n\nfunction decline() {\n localStorage.setItem('fd_consent', 'false')\n setConsent(false)\n visible.value = false\n}\n</script>\n\n<template>\n <div v-if=\"visible\">\n <p>We use behavioral analytics to improve this product.</p>\n <button @click=\"accept\">Accept</button>\n <button @click=\"decline\">Decline</button>\n </div>\n</template>\n```\n\n## Identifying users\n\n```vue\n<!-- components/IdentifyUser.vue -->\n<script setup lang=\"ts\">\nimport { watch } from 'vue'\nimport { identify } from 'flusterduck'\n\nconst props = defineProps<{\n userId?: string\n orgId?: string\n plan?: string\n}>()\n\nwatch(\n () => props.userId,\n (userId) => {\n if (!userId) return\n identify({\n user_id: userId,\n org_id: props.orgId ?? '',\n plan: props.plan ?? 'trial',\n })\n },\n { immediate: true },\n)\n</script>\n```\n\nRender this inside whatever component owns your auth state. Pass an opaque internal ID, not an email or display name.\n\n## Vue Router\n\nRoute-to-route navigation is tracked automatically via the history API. No router hooks needed.\n\nIf you want to attach route metadata to sessions:\n\n```ts\nrouter.afterEach((to) => {\n if (to.meta.product) {\n identify({ product_area: String(to.meta.product) })\n }\n})\n```\n\n## Deploy tagging\n\n```ts\napp.use(FlusterduckPlugin, {\n key: import.meta.env.VITE_FLUSTERDUCK_KEY,\n segment: {\n app_version: import.meta.env.VITE_APP_VERSION ?? 'unknown',\n },\n})\n```\n\n## Gotchas\n\n**`useFlusterduck()` takes no arguments.** Options go in `app.use`, not the composable. Passing config to `useFlusterduck` has no effect. It won't initialize a second instance and it won't override anything.\n\n**The plugin doesn't support conditional initialization.** If you need to gate on consent state before initializing, call `init` from `flusterduck` directly with a Pinia action or composable that owns your consent state.\n\n**Server-side rendering.** If you're using Vue with SSR (Nuxt, Vite SSR), the plugin's browser guard prevents SDK calls on the server. `app.use(FlusterduckPlugin, ...)` itself can run on the server, but it exits early without initializing.\n\n## TypeScript\n\n```ts\nimport type { SignalData } from 'flusterduck'\n\nconst data: SignalData = {\n element: '[data-testid=\"submit-payment\"]',\n metadata: { error_code: 'card_declined', attempt: 2 },\n}\nsignal('interaction_error', data)\n```\n"
|
|
364
|
+
},
|
|
365
|
+
{
|
|
366
|
+
"slug": "svelte",
|
|
367
|
+
"title": "SvelteKit",
|
|
368
|
+
"description": "Call initFlusterduck once in your root layout behind a browser guard, then import the helpers you need.",
|
|
369
|
+
"group": "SDK & frameworks",
|
|
370
|
+
"content": "# SvelteKit\n\nNo store, no plugin object. `@flusterduck/svelte` exports individual functions. Call `initFlusterduck` once in your root layout behind a `browser` guard, then import `signal`, `track`, `identify`, `setConsent`, and `optOut` wherever you need them.\n\n## Install\n\n```bash\npnpm add flusterduck @flusterduck/svelte\n```\n\n## Setup\n\n```svelte\n<!-- src/routes/+layout.svelte -->\n<script>\n import { initFlusterduck } from '@flusterduck/svelte'\n import { browser } from '$app/environment'\n\n if (browser) {\n initFlusterduck({\n key: import.meta.env.PUBLIC_FLUSTERDUCK_KEY,\n })\n }\n</script>\n\n<slot />\n```\n\n```bash\n# .env\nPUBLIC_FLUSTERDUCK_KEY=fd_pub_xxxxxxxxxxxx\n```\n\nThe `browser` guard isn't optional. SvelteKit runs your layout on the server during SSR, and the SDK is browser-only. Remove the guard and you get a runtime error on first load.\n\n## initFlusterduck\n\n```ts\ninitFlusterduck({\n key: import.meta.env.PUBLIC_FLUSTERDUCK_KEY,\n environment: 'production',\n sampleRate: 1.0,\n})\n```\n\nCalling it a second time is a no-op. A module-level `initialized` flag blocks re-entry, so hot reloads and layout remounts don't cause duplicate initialization.\n\n### Options\n\n| Option | Type | Default | Description |\n|---|---|---|---|\n| `key` | `string` | - | Required. Your `fd_pub_` key. |\n| `environment` | `string` | - | `\"production\"`, `\"staging\"`, `\"development\"` |\n| `sampleRate` | `number` | `1.0` | `0.5` tracks half of sessions. |\n| `domMode` | `'off' \\| 'metadata' \\| 'snapshot'` | `'off'` | `'metadata'` captures element attributes. `'snapshot'` adds layout and computed styles. |\n| `cookieless` | `boolean` | `false` | Memory-only session IDs instead of cookies. |\n| `respectDoNotTrack` | `boolean` | `false` | Honor `navigator.doNotTrack`. |\n| `ignoreElements` | `string[]` | `[]` | CSS selectors to suppress signals on. |\n| `ignorePages` | `string[]` | `[]` | Page paths to skip. |\n| `segment` | `Record<string, string>` | - | Static tags on every event. |\n| `debug` | `boolean` | `false` | Verbose console logging. |\n\n`destroyFlusterduck()` tears down the SDK and resets the `initialized` flag. Use it in tests or if you're dynamically switching sites.\n\n## Exports\n\n| Export | Signature | Description |\n|---|---|---|\n| `initFlusterduck` | `(config: Config) => void` | Initialize the SDK. Call once. |\n| `destroyFlusterduck` | `() => void` | Tear down and reset. |\n| `signal` | `(name: string, data?: SignalData) => void` | Emit a friction signal manually. |\n| `track` | `(name: string, metadata?: Record<string, unknown>) => void` | Track a business event. |\n| `identify` | `(segment: Record<string, string>) => void` | Tag the session with user properties. |\n| `setConsent` | `(consented: boolean) => void` | Pause or resume collection. |\n| `optOut` | `() => void` | Stop collection permanently for this session. |\n\n## Tracking in components\n\n```svelte\n<script lang=\"ts\">\n import { track } from '@flusterduck/svelte'\n\n export let planId: string\n export let priceCents: number\n</script>\n\n<button on:click={() => track('plan_intent', { plan_id: planId, price_cents: priceCents })}>\n Get started\n</button>\n```\n\n```svelte\n<script lang=\"ts\">\n import { signal } from '@flusterduck/svelte'\n\n let currentStep = 0\n</script>\n\n<div on:mouseleave={() => signal('task_abandonment', { metadata: { step: currentStep } })}>\n <!-- wizard step content -->\n</div>\n```\n\nImporting from `flusterduck` directly works just as well. Both go through the same initialized SDK instance.\n\n## Consent flow\n\n```svelte\n<!-- src/lib/components/ConsentBanner.svelte -->\n<script lang=\"ts\">\n import { onMount } from 'svelte'\n import { setConsent } from '@flusterduck/svelte'\n\n let visible = false\n\n onMount(() => {\n const stored = localStorage.getItem('fd_consent')\n if (stored === null) {\n setConsent(false)\n visible = true\n } else {\n setConsent(stored === 'true')\n }\n })\n\n function accept() {\n localStorage.setItem('fd_consent', 'true')\n setConsent(true)\n visible = false\n }\n\n function decline() {\n localStorage.setItem('fd_consent', 'false')\n setConsent(false)\n visible = false\n }\n</script>\n\n{#if visible}\n <div>\n <p>We use behavioral analytics to improve this product.</p>\n <button on:click={accept}>Accept</button>\n <button on:click={decline}>Decline</button>\n </div>\n{/if}\n```\n\nThe `setConsent(false)` call before showing the banner pauses collection immediately. The user's choice then either resumes it or confirms the pause.\n\n## Identifying users\n\n```svelte\n<!-- src/lib/components/IdentifyUser.svelte -->\n<script lang=\"ts\">\n import { onMount } from 'svelte'\n import { identify } from '@flusterduck/svelte'\n\n export let userId: string | undefined = undefined\n export let orgId: string | undefined = undefined\n export let plan: string | undefined = undefined\n\n onMount(() => {\n if (!userId) return\n identify({ user_id: userId, org_id: orgId ?? '', plan: plan ?? 'trial' })\n })\n</script>\n```\n\nMount this inside whatever layout has access to your auth state. Pass an opaque internal ID, not an email or display name.\n\n## Deploy tagging\n\n```svelte\n<!-- src/routes/+layout.svelte -->\n<script>\n import { initFlusterduck } from '@flusterduck/svelte'\n import { browser } from '$app/environment'\n\n if (browser) {\n initFlusterduck({\n key: import.meta.env.PUBLIC_FLUSTERDUCK_KEY,\n segment: {\n app_version: import.meta.env.PUBLIC_APP_VERSION ?? 'unknown',\n },\n })\n }\n</script>\n```\n\n## Gotchas\n\n**The `browser` guard is required, not optional.** If you skip it, SvelteKit's SSR pass throws on first request. The error won't always be obvious. It can manifest as a hydration mismatch rather than an explicit SDK error.\n\n**There's no Svelte store.** If you expected `$flusterduck.track(...)`, that's not how this works. The package exports plain functions. Import them directly where you need them.\n\n**`initFlusterduck` in a `+layout.svelte` runs on both client and server** unless guarded. The `browser` import from `$app/environment` is the canonical way to guard it. Don't use `typeof window !== 'undefined'`. It works but it's not idiomatic SvelteKit.\n\n**Hot module replacement.** During development, Vite's HMR can re-execute your layout script. The `initialized` flag means `initFlusterduck` won't re-run, which is the right behavior. You don't want a new session mid-development.\n\n## TypeScript\n\n```ts\nimport type { Config, SignalData } from 'flusterduck'\nimport { initFlusterduck, signal } from '@flusterduck/svelte'\n\nconst config: Config = {\n key: import.meta.env.PUBLIC_FLUSTERDUCK_KEY,\n environment: 'production',\n}\ninitFlusterduck(config)\n\nconst data: SignalData = {\n element: '[data-action=\"publish\"]',\n metadata: { blocked_by: 'missing_required_fields', field_count: 3 },\n}\nsignal('dead_click', data)\n```\n"
|
|
371
|
+
},
|
|
372
|
+
{
|
|
373
|
+
"slug": "nuxt",
|
|
374
|
+
"title": "Nuxt 3",
|
|
375
|
+
"description": "Create a client-side plugin returning createFlusterduckPlugin. All functions are SSR-safe.",
|
|
376
|
+
"group": "SDK & frameworks",
|
|
377
|
+
"content": "# Nuxt 3\n\nCreate a client-side plugin, return `createFlusterduckPlugin` from it, done. After that, import `signal`, `track`, `identify`, `setConsent`, and `optOut` anywhere in your app. All functions are SSR-safe. Calls that happen server-side are silently swallowed.\n\n## Install\n\n```bash\npnpm add flusterduck @flusterduck/nuxt\n```\n\n## Setup\n\n```ts\n// plugins/flusterduck.client.ts\nimport { createFlusterduckPlugin } from '@flusterduck/nuxt'\n\nexport default defineNuxtPlugin(() => {\n return createFlusterduckPlugin({\n key: useRuntimeConfig().public.flusterduckKey,\n })\n})\n```\n\n```ts\n// nuxt.config.ts\nexport default defineNuxtConfig({\n runtimeConfig: {\n public: {\n flusterduckKey: '',\n },\n },\n})\n```\n\n```bash\n# .env\nNUXT_PUBLIC_FLUSTERDUCK_KEY=fd_pub_xxxxxxxxxxxx\n```\n\nNuxt maps `NUXT_PUBLIC_FLUSTERDUCK_KEY` to `runtimeConfig.public.flusterduckKey` automatically. The naming convention handles the wiring. No manual `process.env` access needed in production.\n\nThe `.client.ts` suffix is what makes this browser-only. Without it, Nuxt runs the plugin during SSR and the SDK throws. The suffix is not optional.\n\n## createFlusterduckPlugin\n\nBuilds the plugin object returned to `defineNuxtPlugin`. Initializes the SDK via `configureApp` and injects the tracking script into the document head.\n\n### Options\n\n| Option | Type | Default | Description |\n|---|---|---|---|\n| `key` | `string` | - | Required. Your `fd_pub_` key. |\n| `environment` | `string` | - | `\"production\"`, `\"staging\"`, `\"development\"` |\n| `sampleRate` | `number` | `1.0` | `0.1` tracks 10% of sessions. |\n| `cookieless` | `boolean` | `false` | Memory-only session IDs instead of cookies. |\n| `respectDoNotTrack` | `boolean` | `false` | Honor `navigator.doNotTrack`. |\n| `segment` | `Record<string, string>` | - | Static tags on every event. |\n| `debug` | `boolean` | `false` | Verbose console logging. |\n\nPassing a secret key (`fd_sec_`) logs an error and returns an inert plugin. Nothing initializes.\n\n## defineFlusterduckConfig\n\nA typed helper that validates your config shape. Useful when you want to define config once and reference it in multiple places (plugin, tests, Storybook):\n\n```ts\n// flusterduck.config.ts\nimport { defineFlusterduckConfig } from '@flusterduck/nuxt'\n\nexport default defineFlusterduckConfig({\n key: process.env.NUXT_PUBLIC_FLUSTERDUCK_KEY!,\n environment: process.env.NODE_ENV,\n sampleRate: 1.0,\n})\n```\n\n```ts\n// plugins/flusterduck.client.ts\nimport { createFlusterduckPlugin } from '@flusterduck/nuxt'\nimport config from '../flusterduck.config'\n\nexport default defineNuxtPlugin(() => createFlusterduckPlugin(config))\n```\n\n## Tracking in components\n\nNuxt auto-imports Vue composition API, so you don't need explicit `import` statements for `ref`, `onMounted`, `watch`, etc. in `.vue` files.\n\n```vue\n<!-- pages/billing.vue -->\n<script setup lang=\"ts\">\nimport { track, signal } from '@flusterduck/nuxt'\n\nfunction onPlanSelect(planId: string, priceCents: number) {\n track('plan_intent', { plan_id: planId, price_cents: priceCents })\n}\n\nfunction onPaymentError(code: string) {\n signal('interaction_error', { metadata: { error_code: code } })\n}\n</script>\n\n<template>\n <div>\n <button @click=\"onPlanSelect('scale', 9900)\">Scale plan</button>\n </div>\n</template>\n```\n\nImporting from `flusterduck` directly works too. Both go through the same SDK instance.\n\n## Exports\n\n| Export | Signature | Description |\n|---|---|---|\n| `createFlusterduckPlugin` | `(options) => object` | Plugin factory. Return from `defineNuxtPlugin`. |\n| `defineFlusterduckConfig` | `(options) => options` | Typed config helper. |\n| `signal` | `(name: string, data?: SignalData) => void` | Emit a friction signal manually. |\n| `track` | `(name: string, metadata?: Record<string, unknown>) => void` | Track a business event. |\n| `identify` | `(segment: Record<string, string>) => void` | Tag the session with user properties. |\n| `setConsent` | `(consented: boolean) => void` | Pause or resume collection. |\n| `optOut` | `() => void` | Stop collection permanently for this session. |\n\n## Consent flow\n\n```vue\n<!-- components/ConsentBanner.vue -->\n<script setup lang=\"ts\">\nimport { setConsent } from '@flusterduck/nuxt'\n\nconst visible = ref(false)\n\nonMounted(() => {\n const stored = localStorage.getItem('fd_consent')\n if (stored === null) {\n setConsent(false)\n visible.value = true\n } else {\n setConsent(stored === 'true')\n }\n})\n\nfunction accept() {\n localStorage.setItem('fd_consent', 'true')\n setConsent(true)\n visible.value = false\n}\n\nfunction decline() {\n localStorage.setItem('fd_consent', 'false')\n setConsent(false)\n visible.value = false\n}\n</script>\n\n<template>\n <div v-if=\"visible\">\n <p>We use behavioral analytics to improve this product.</p>\n <button @click=\"accept\">Accept</button>\n <button @click=\"decline\">Decline</button>\n </div>\n</template>\n```\n\n## Identifying users\n\n```vue\n<!-- components/IdentifyUser.vue -->\n<script setup lang=\"ts\">\nimport { identify } from '@flusterduck/nuxt'\n\nconst props = defineProps<{\n userId?: string\n orgId?: string\n plan?: string\n}>()\n\nwatchEffect(() => {\n if (!props.userId) return\n identify({\n user_id: props.userId,\n org_id: props.orgId ?? '',\n plan: props.plan ?? 'trial',\n })\n})\n</script>\n```\n\nRender this inside whatever layout has access to your auth state. Pass an opaque internal ID, not an email or display name.\n\n## Deploy tagging\n\n```ts\n// plugins/flusterduck.client.ts\nimport { createFlusterduckPlugin } from '@flusterduck/nuxt'\n\nexport default defineNuxtPlugin(() => {\n const config = useRuntimeConfig()\n return createFlusterduckPlugin({\n key: config.public.flusterduckKey,\n segment: {\n app_version: config.public.appVersion ?? 'unknown',\n },\n })\n})\n```\n\n```ts\n// nuxt.config.ts\nexport default defineNuxtConfig({\n runtimeConfig: {\n public: {\n flusterduckKey: '',\n appVersion: '',\n },\n },\n})\n```\n\nIn Vercel, set `NUXT_PUBLIC_APP_VERSION` to `$VERCEL_GIT_COMMIT_SHA` in your project environment variables.\n\n## Gotchas\n\n**`.client.ts` is required.** Name your plugin file `flusterduck.client.ts`, not `flusterduck.ts`. Without the suffix, Nuxt runs the plugin on the server and the SDK init throws. The error surfaces as a hydration mismatch, which can be confusing to trace back.\n\n**`useRuntimeConfig()` only works inside Nuxt context.** Don't call it at module top-level in your plugin file. The `defineNuxtPlugin(() => { ... })` callback is the right place -- `useRuntimeConfig` is available there. The pattern in the setup section above is correct.\n\n**`NUXT_PUBLIC_*` env vars need the right key casing.** Nuxt maps `NUXT_PUBLIC_FLUSTERDUCK_KEY` to `runtimeConfig.public.flusterduckKey` using camelCase conversion. If you see `undefined`, check that the env var prefix and the `runtimeConfig.public` key name match the convention.\n\n**Tracking functions in Server Components or `server/` routes do nothing.** The `typeof window` guard in every function exits silently on the server. This is intentional -- you won't get errors, but you also won't get data.\n\n## TypeScript\n\n```ts\nimport type { SignalData } from 'flusterduck'\n\nconst data: SignalData = {\n element: '[data-action=\"confirm-delete\"]',\n metadata: { resource_type: 'workspace', confirmed: false },\n}\nsignal('dead_click', data)\n```\n"
|
|
378
|
+
},
|
|
379
|
+
{
|
|
380
|
+
"slug": "identify",
|
|
381
|
+
"title": "Identifying sessions",
|
|
382
|
+
"description": "Tag sessions with safe user, account, or cohort properties so friction data is segmentable.",
|
|
383
|
+
"group": "SDK & frameworks",
|
|
384
|
+
"content": "# Identifying Sessions\n\nTag sessions with safe user, account, or cohort properties so friction data is segmentable. Once you're calling `identify()`, you can ask whether checkout friction is worse for Grow users than Scale users, whether a redesign cohort is hitting the same issues as the control group, or whether enterprise accounts behave differently during onboarding.\n\nWithout it, you're looking at aggregate confusion scores with no way to slice them.\n\n## identify()\n\nCall it after login, when you have stable properties:\n\n```ts\nimport { identify } from 'flusterduck'\n\nidentify({\n user_id: 'usr_8f3a2c91',\n plan: 'scale',\n org_id: 'org_4b2e9f1c',\n account_age_days: '42',\n})\n```\n\n`identify()` accepts one object: `Record<string, string>`. Values are stringified, truncated, and attached to the current session. Keep IDs opaque. A database primary key or UUID is fine. An email address, display name, or phone number is not.\n\n## What properties to pass\n\nPass things you'd want to filter by when investigating a friction pattern:\n\n```ts\nidentify({\n user_id: 'usr_8f3a2c91',\n plan: 'scale',\n billing: 'annual',\n org_size: '11-50',\n role: 'admin',\n account_age_days: '42',\n experiment_variant: 'checkout-v2',\n feature_flag_new_nav: 'true',\n})\n```\n\nNever pass: email, name, username, IP address, display handle, or any field that links directly to a real person.\n\n## When to call it\n\nCall `identify()` as early as possible after login and after the SDK has initialized. Properties apply to the current session metadata used for future flushes.\n\nIn practice: put it in a component or hook that runs immediately after your auth state resolves.\n\n### React\n\n```tsx\nimport { useEffect } from 'react'\nimport { identify } from 'flusterduck'\n\nexport function IdentityBridge({ userId, plan, orgId }: {\n userId?: string\n plan?: string\n orgId?: string\n}) {\n useEffect(() => {\n if (!userId) return\n identify({\n user_id: userId,\n plan: plan ?? 'trial',\n org_id: orgId ?? '',\n })\n }, [userId, plan, orgId])\n\n return null\n}\n```\n\nRender this inside your auth provider where `userId` is reliably available.\n\n### Next.js\n\n```tsx\n'use client'\nimport { useEffect } from 'react'\nimport { identify } from 'flusterduck'\n\nexport function IdentifyUser({ userId, plan }: { userId?: string; plan?: string }) {\n useEffect(() => {\n if (!userId) return\n identify({ user_id: userId, plan: plan ?? 'trial' })\n }, [userId, plan])\n\n return null\n}\n```\n\n### Vue\n\n```vue\n<script setup lang=\"ts\">\nimport { watch } from 'vue'\nimport { identify } from 'flusterduck'\n\nconst props = defineProps<{ userId?: string; plan?: string }>()\n\nwatch(\n () => props.userId,\n (userId) => {\n if (!userId) return\n identify({ user_id: userId, plan: props.plan ?? 'trial' })\n },\n { immediate: true },\n)\n</script>\n```\n\n## The segment config option\n\nFor properties that apply to every session regardless of who's logged in, use `segment` in your SDK config instead of `identify()`:\n\n```ts\ninit({\n key: process.env.NEXT_PUBLIC_FLUSTERDUCK_KEY!,\n segment: {\n app_version: process.env.NEXT_PUBLIC_APP_VERSION ?? 'unknown',\n region: 'us-east-1',\n experiment_group: 'pricing-v2-b',\n },\n})\n```\n\n`segment` properties are attached from the first signal in the session. `identify()` properties are attached after the call.\n\nUse `segment` for: deploy tags, region, A/B test cohorts, feature flag variants at the session level.\n\nUse `identify()` for: plan tier, organization, user role, account age, anything tied to a specific authenticated user.\n\nIf you pass the same key in both `segment` and `identify()`, the `identify()` value wins after the call.\n\n## Using segments in investigations\n\nWith properties in place, you can filter friction data by segment values. Via MCP:\n\n```\nAre rage clicks on the checkout button worse for Grow plan users than Scale?\nWhich sessions hitting form abandonment on onboarding are enterprise accounts?\nCompare confusion scores between experiment_group: pricing-v2-a and pricing-v2-b.\nShow me sessions where role: admin hit the dead click on the settings page.\n```\n\n## Multiple identify() calls\n\nCalling `identify()` multiple times in the same session merges properties.\n\n```ts\n// On login\nidentify({ user_id: 'usr_8f3a2c91', plan: 'scale' })\n\n// After loading org data\nidentify({ org_id: 'org_4b2e9f1c', org_size: '11-50' })\n```\n\nThe session ends up with all four properties. Use this pattern when you load user data in stages.\n"
|
|
385
|
+
},
|
|
386
|
+
{
|
|
387
|
+
"slug": "build-plugins",
|
|
388
|
+
"title": "Build plugins",
|
|
389
|
+
"description": "Vite and webpack plugins that handle deploy tagging and source map upload at build time.",
|
|
390
|
+
"group": "SDK & frameworks",
|
|
391
|
+
"content": "# Build Plugins\n\nThe Vite and webpack plugins handle deploy tagging and source map upload at build time. You don't need them to use Flusterduck. Add them when you want automatic deploy recording from your build pipeline or when you want stack traces in issue evidence to resolve to original source.\n\n## Vite\n\n```bash\npnpm add -D flusterduck-vite-plugin\n```\n\n```ts\n// vite.config.ts\nimport { defineConfig } from 'vite'\nimport { flusterduck } from 'flusterduck-vite-plugin'\n\nexport default defineConfig({\n plugins: [\n flusterduck({\n secretKey: process.env.FLUSTERDUCK_SECRET_KEY,\n }),\n ],\n})\n```\n\n## webpack\n\n```bash\npnpm add -D flusterduck-webpack-plugin\n```\n\n```js\n// webpack.config.js\nconst { FlusterduckPlugin } = require('flusterduck-webpack-plugin')\n\nmodule.exports = {\n plugins: [\n new FlusterduckPlugin({\n secretKey: process.env.FLUSTERDUCK_SECRET_KEY,\n }),\n ],\n}\n```\n\nBoth plugins accept the same options. The examples below use Vite syntax, but the options are identical for webpack.\n\n## Options\n\n| Option | Type | Default | Description |\n|---|---|---|---|\n| `secretKey` | `string` | required | Your `fd_sec_` secret key. Never expose this client-side. |\n| `release` | `string` | git SHA | Version string attached to the deploy record. Defaults to `git rev-parse HEAD`. |\n| `environment` | `string` | `NODE_ENV` | `\"production\"`, `\"staging\"`, `\"development\"` |\n| `sourceMaps` | `boolean` | `true` | Upload source maps after build. |\n| `deleteSourceMaps` | `boolean` | `false` | Delete uploaded source maps from the output directory after upload. |\n| `include` | `string[]` | `[\"dist\"]` | Directories to scan for source map files. |\n| `dryRun` | `boolean` | `false` | Run the plugin without uploading or recording a deploy. Useful for verifying config. |\n\n## Deploy recording\n\nThe plugin records a deploy automatically when the build completes. You don't need the CI curl command from [Deploy Correlation](./deploy-correlation) if you're using the build plugin. Use one or the other, not both.\n\n```ts\nflusterduck({\n secretKey: process.env.FLUSTERDUCK_SECRET_KEY,\n release: process.env.VITE_APP_VERSION || 'unknown',\n environment: 'production',\n})\n```\n\nThe plugin POSTs to `/v1/deploys` at the end of the build with `version` set to `release` and `environment` set accordingly. `confusion_before` is captured at that moment.\n\n## Source maps\n\nWhen `sourceMaps: true` (the default), the plugin uploads `.map` files after the build. Issue evidence in the dashboard will show original file names and line numbers instead of minified bundle references.\n\nSource maps are uploaded to Flusterduck's servers and stored against the `release` version string. They're used only to resolve stack traces in issue evidence. They aren't accessible from the client.\n\n### Keeping source maps off your CDN\n\nSet `deleteSourceMaps: true` to remove map files from the output directory after upload. They'll be used for stack trace resolution but won't be served to browsers or end up in your CDN cache.\n\n```ts\nflusterduck({\n secretKey: process.env.FLUSTERDUCK_SECRET_KEY,\n sourceMaps: true,\n deleteSourceMaps: true,\n})\n```\n\n### Including only specific directories\n\n```ts\nflusterduck({\n secretKey: process.env.FLUSTERDUCK_SECRET_KEY,\n include: ['dist/assets'],\n})\n```\n\n## Environment variable setup\n\nStore `FLUSTERDUCK_SECRET_KEY` in your build environment, not in your codebase. The secret key should never appear in client bundles.\n\nFor local development, add it to `.env.local` (which your `.gitignore` should exclude):\n\n```bash\n# .env.local\nFLUSTERDUCK_SECRET_KEY=fd_sec_xxxxxxxxxxxx\n```\n\nIn CI, add it as an environment secret:\n- GitHub Actions: Settings > Secrets > Actions > New repository secret\n- Vercel: Project Settings > Environment Variables\n- Netlify: Site settings > Build > Environment variables\n\n## Dry run\n\nVerify plugin configuration without uploading anything:\n\n```ts\nflusterduck({\n secretKey: process.env.FLUSTERDUCK_SECRET_KEY,\n dryRun: process.env.NODE_ENV !== 'production',\n})\n```\n\nWith `dryRun: true`, the plugin logs what it would upload and what deploy record it would create, then exits without sending any requests. Useful for testing your config in staging before enabling in production.\n\n## Next.js\n\nNext.js uses webpack under the hood. Use the webpack plugin in `next.config.js`:\n\n```js\n// next.config.js\nconst { FlusterduckPlugin } = require('flusterduck-webpack-plugin')\n\n/** @type {import('next').NextConfig} */\nconst nextConfig = {\n webpack: (config, { isServer, buildId }) => {\n if (!isServer) {\n config.plugins.push(\n new FlusterduckPlugin({\n secretKey: process.env.FLUSTERDUCK_SECRET_KEY,\n release: buildId,\n environment: process.env.NODE_ENV,\n deleteSourceMaps: true,\n })\n )\n }\n return config\n },\n}\n\nmodule.exports = nextConfig\n```\n\nScope it to `!isServer` to avoid running the plugin twice per build (Next.js runs separate webpack compilations for client and server).\n\nUse `buildId` as the release version. Next.js generates a stable, unique build ID per deployment.\n"
|
|
392
|
+
},
|
|
393
|
+
{
|
|
394
|
+
"slug": "signals",
|
|
395
|
+
"title": "Signals",
|
|
396
|
+
"description": "The raw evidence of user confusion. Every friction behavior the SDK emits as a signal.",
|
|
397
|
+
"group": "Product",
|
|
398
|
+
"content": "# Signals\n\nSignals are the raw evidence of user confusion. Every time a user does something that indicates friction, the SDK emits a signal. The scoring engine weighs signals by type, frequency, and recency to compute page confusion scores and cluster repeated patterns into issues.\n\nThere are 33 built-in signal types across four tiers.\n\n## Tier 1: Direct friction\n\nHigh-confidence indicators. A single occurrence is meaningful -- you don't need volume to act. These move your page score the most and surface issues fastest.\n\nIf you see tier 1 signals on a critical element (a checkout button, a signup form, an upgrade CTA), investigate before waiting for a cluster.\n\n| Signal | What it means |\n|---|---|\n| `rage_click` | User clicked the same element 3 or more times rapidly. Usually means the element appears clickable but isn't responding as expected. |\n| `dead_click` | User clicked a non-interactive element. Often a label, image, or decorative element the user thought was a button. |\n| `layout_shift_rage` | A rage click followed by a significant layout shift. The interface moved under the user's cursor. |\n| `active_funnel_rejection` | User initiated a multi-step flow and exited before the first meaningful step. Not a casual bounce -- they started, then left. |\n| `dead_end_submit` | User submitted a form and received no visible feedback. No success state, no error message, nothing. |\n| `accidental_click_bounce` | User clicked an element and immediately navigated back. Likely an accidental tap or misclick. |\n| `error_recovery_loop` | User encountered an error and tried the same action multiple times in quick succession without changing their input. |\n| `silent_failure_retry` | User repeated an action that appeared to succeed but produced no visible result. Common with async operations that fail silently. |\n| `form_abandonment` | User focused one or more form fields and left without submitting. Threshold: at least one meaningful field interaction. |\n\n## Tier 2: Navigation and flow friction\n\nPatterns that emerge across a session. Each single occurrence is moderate-confidence. Clusters are high-confidence.\n\nIf a tier 2 signal appears 20+ times on the same page, or you're seeing 5+ different tier 2 types on the same page simultaneously, treat it like tier 1.\n\n| Signal | What it means |\n|---|---|\n| `u_turn_navigation` | User went forward in a flow, then immediately went back. Repeated occurrences suggest unclear copy or a missing expectation-setter. |\n| `information_maze` | User visited the same page or section multiple times without completing anything. They're looking for something they can't find. |\n| `load_abandonment` | User waited for a page to load and left before it finished. Indicates a performance problem on a high-friction path. |\n| `multi_step_reset` | User restarted a multi-step form or wizard from the beginning. Could be data entry confusion or unclear field validation. |\n| `query_thrashing` | User made multiple search or filter queries in rapid succession. They're not finding what they're looking for. |\n| `filter_spiral` | User applied and removed filters repeatedly without settling. Likely an expectation mismatch between filters and results. |\n| `post_action_anxiety` | User completed an action but immediately checked for confirmation or navigated away. Suggests missing confirmation feedback. |\n| `settings_hop` | User visited settings or preferences multiple times in a session without finding what they needed. |\n| `copy_paste_rework` | User selected text and then immediately re-typed it. Often indicates a pre-fill or autocomplete that didn't work. |\n| `repeat_form_input` | User cleared and re-entered the same field more than once. Usually a validation message that's unclear or delayed. |\n\n## Tier 3: Passive confusion\n\nLower individual weight, but meaningful in aggregate. These often surface chronic friction that lacks the dramatic edge of rage clicks -- the kind of friction users tolerate rather than abandon over.\n\nA page with dense tier 3 signals and no tier 1 or 2 signals is worth investigating. It usually means users are managing confusion rather than hitting a wall.\n\n| Signal | What it means |\n|---|---|\n| `confidence_collapse` | User paused on a form field for an unusually long time without typing. Hesitation before a field that's unclear. |\n| `target_near_miss` | User's tap or click landed very close to an interactive element but missed it. Common on mobile with small touch targets. |\n| `user_confusion_idle` | User stopped interacting entirely for longer than expected. Could be reading carefully, or could be stuck. |\n| `thrash_hover` | User rapidly moved the cursor back and forth over an element without clicking. Indecision or unclear affordance. |\n| `text_selection_thrash` | User selected text multiple times rapidly. May be trying to copy something that's being blocked. |\n| `jerky_scrolling` | User's scroll pattern was erratic: fast then stopped, or reversed frequently. Usually indicates a busy page layout. |\n| `thrash_cursor` | Rapid cursor movement across the page with no clear direction. A general confusion indicator. |\n| `viewport_thrashing` | User zoomed in and out multiple times. On mobile, this usually means text is too small or layout is not responsive. |\n| `visibility_thrashing` | User repeatedly scrolled elements in and out of view, possibly trying to compare content across sections. |\n| `passive_drift` | User stayed on a page longer than average but generated very few events. Could be reading carefully or completely lost. |\n| `help_hunt` | User navigated to FAQ, support, documentation, or help pages during an active conversion flow. |\n\n## Revenue and close behavior\n\nSignals that correlate directly with lost conversions. These feed the revenue impact estimate.\n\n| Signal | What it means |\n|---|---|\n| `close_click_reversal` | User moved toward closing, dismissing, or canceling something, then stopped and stayed. They were about to leave but didn't. Use this to find moments where users reconsider. |\n| `value_collapse_downgrade` | User selected a higher plan or tier, then downgraded before completing purchase. Usually indicates a pricing page clarity problem. |\n| `layout_exhaustion_settling` | User scrolled through the full page, returned to the top, and selected an option. They were overwhelmed and had to start over to decide. |\n\n## Custom signals\n\nEmit your own signals with `signal()`:\n\n```ts\nimport { signal } from 'flusterduck'\n\n// A CTA that looks active but is gated by a required previous step\nsignal('dead_click', {\n selector: '[data-action=\"publish\"]',\n label: 'Publish',\n blocked_by: 'missing_title',\n})\n\n// A custom multi-step flow step abandonment\nsignal('form_abandonment', {\n form: 'workspace-setup',\n step: 'invite-members',\n fields_touched: 0,\n})\n```\n\nThe first argument is the signal type -- any of the 33 built-in types or a custom string. The second is optional metadata. Never include PII, form values, or anything a user typed.\n\nCustom signal types appear in the dashboard and scoring engine alongside built-in types. They don't get tier weights assigned automatically -- the scoring engine treats them as tier 2 by default until enough data accumulates for it to assess their actual impact.\n\n## Business events\n\nWire conversion outcomes with `track()` for revenue impact estimation:\n\n```ts\nimport { track } from 'flusterduck'\n\ntrack('plan_intent', {\n plan_id: 'scale',\n amount_cents: 9900,\n billing: 'monthly',\n})\n\ntrack('subscription_started', {\n plan_id: 'scale',\n amount_cents: 9900,\n billing: 'monthly',\n})\n\ntrack('checkout_abandoned', {\n plan_id: 'grow',\n amount_cents: 3900,\n})\n```\n\nSafe properties: `plan_id`, `amount_cents`, `billing`, `currency`, `quantity`, `product_id`. Never pass names, emails, addresses, or any user-typed text.\n\n## Legacy aliases\n\nThese older signal names still work:\n\n| Alias | Resolves to |\n|---|---|\n| `speed_frustration` | `silent_failure_retry` |\n| `loop_nav` | `u_turn_navigation` |\n| `scroll_bounce` | `jerky_scrolling` |\n| `form_hesitation` | `confidence_collapse` |\n| `form_abandon` | `form_abandonment` |\n| `tap_miss` | `target_near_miss` |\n| `pinch_zoom` | `viewport_thrashing` |\n\n## How scores and issues work\n\nSignals don't directly create issues. The scoring engine clusters signals by element, page, and user population. When a cluster crosses a confidence threshold, it becomes a UX issue. The confusion score for a page reflects the weighted sum of recent signals, normalized for session volume.\n\nTier weighting means a single `rage_click` moves a page score more than a single `passive_drift`. But 200 `passive_drift` signals on the same element will create an issue regardless of tier -- frequency overrides weight at scale.\n"
|
|
399
|
+
},
|
|
400
|
+
{
|
|
401
|
+
"slug": "scoring",
|
|
402
|
+
"title": "Confusion scores",
|
|
403
|
+
"description": "A 0 to 100 number measuring how much friction a page generates relative to its session volume.",
|
|
404
|
+
"group": "Product",
|
|
405
|
+
"content": '# Confusion Scores\n\nA confusion score is a number from 0 to 100 that measures how much friction a page generates relative to its session volume. Direct measurement of how often users hit behavioral friction patterns. Not a satisfaction proxy, not survey data.\n\nMost pages sit between 10 and 40. Above 50 is a page worth prioritizing. Above 70 is a page that\'s actively costing you.\n\n## How scores are calculated\n\nEvery signal has a weight based on its tier. Tier 1 signals (rage clicks, dead clicks, form abandonment on a conversion element) carry roughly 3.5x the weight of tier 3 signals (passive drift, hover thrash). The engine sums the weighted signals for a page, normalizes for session volume, and applies recency decay so old signals fade out.\n\nVolume normalization matters because raw counts don\'t. Ten rage clicks across 20 sessions is a different problem than ten rage clicks across 2,000 sessions. The rate is what counts, not the total.\n\nRecency decay means last week\'s friction matters more than the same friction pattern from three months ago. The half-life is 14 days. A page that used to be bad but isn\'t anymore will show that in the score.\n\n## Score components\n\nThe engine weighs five factors:\n\n**Signal frequency**: signals per session on this page. The rate, not the total.\n\n**Tier weighting**: tier 1 signals multiply by 3.5x relative to tier 3. A single rage click on your checkout button moves the score more than fifty passive drifts.\n\n**Recency factor**: 14-day half-life. Recent signals are weighted more heavily.\n\n**Session coverage**: the percentage of sessions that contained at least one signal on this page. A page where 40% of sessions produce friction is different from a page where 4% do, even if the total signal count is the same.\n\n**Co-occurrence bonus**: when multiple signal types cluster on the same element (rage clicks and dead clicks both on the same button), the score increases beyond what each signal type would contribute individually. Co-occurrence is usually more meaningful than either signal type alone.\n\n## Baselines\n\nEach page has a rolling 7-day baseline. The baseline is used to calculate:\n\n- `trend`: whether the page is `up`, `down`, or `stable` relative to recent history\n- Spike alert thresholds (a `spike` alert fires when the current score exceeds baseline + threshold)\n- Anomaly detection (the `anomaly` trigger compares current score to historical variance, not a fixed number)\n\nNew pages don\'t have a baseline until they\'ve accumulated 7 days of data. During that window, relative alerts won\'t fire for that page. Budget and new-page alerts still will.\n\n## Confusion budgets\n\nA confusion budget is an absolute ceiling for a specific page. Set it and forget it. When the page crosses that number, a `budget` alert fires regardless of whether the score changed recently.\n\nBudgets are set per-page in the dashboard under Settings > Alert Rules, or via the API:\n\n```json\n{\n "trigger_type": "budget",\n "threshold": 50,\n "page_pattern": "/checkout*",\n "channels": ["email", "pagerduty"],\n "name": "Checkout budget"\n}\n```\n\nMost teams set budgets on their highest-value pages. Reasonable starting numbers:\n\n| Page | Budget |\n|---|---|\n| Checkout | 50 |\n| Pricing | 45 |\n| Onboarding | 40 |\n| Account/billing settings | 35 |\n\nThese are starting points, not rules. Set them based on your own baseline data once you\'ve been running for a few weeks.\n\nThe budget concept is simpler than anomaly detection. You don\'t need historical data. You\'re just defining what "too broken" looks like for a specific page.\n\n## Score interpretation\n\n| Score | What it means |\n|---|---|\n| 0-20 | Clean. No meaningful friction patterns. |\n| 21-40 | Normal. Some friction, nothing alarming. |\n| 41-60 | Elevated. At least one issue worth investigating. |\n| 61-80 | High. Multiple users hitting the same problems. |\n| 81-100 | Critical. Something is broken or completely baffling to users. |\n\nThese are rough guides, not absolute thresholds. A score of 45 on a low-traffic onboarding page reads differently than a score of 45 on checkout with 10,000 daily sessions. Use scores for comparison and trend tracking.\n\n## What moves scores quickly\n\n**`rage_click` and `dead_click`** on a critical element will move a page score significantly in a single session. That\'s by design. Those signals are high-confidence.\n\n**`form_abandonment`** on a conversion form gets treated as tier 1 regardless of the page tier. If users are abandoning your checkout form, the engine weights it accordingly.\n\n**`error_recovery_loop`** and `silent_failure_retry` are also fast movers. They indicate users who hit a broken state and keep trying the same thing, which is a very specific kind of friction.\n\n**Tier 3 signals** (passive drift, thrash hover) need volume to move the needle. Twenty instances on the same element from different sessions will. Five won\'t.\n\n## Scores via the API\n\n```bash\ncurl "https://api.flusterduck.com/v1/scores" \\\n -H "Authorization: Bearer fd_sec_xxxxxxxxxxxx"\n```\n\n```json\n{\n "scores": [\n {\n "page": "/checkout",\n "score": 72,\n "trend": "up",\n "top_signal": "rage_click",\n "issue_count": 3,\n "updated_at": "2026-06-10T14:22:05Z"\n },\n {\n "page": "/pricing",\n "score": 41,\n "trend": "stable",\n "top_signal": "dead_click",\n "issue_count": 1,\n "updated_at": "2026-06-10T14:21:47Z"\n }\n ]\n}\n```\n\nGet score history for a specific page:\n\n```bash\ncurl "https://api.flusterduck.com/v1/trends?page=/checkout&days=30" \\\n -H "Authorization: Bearer fd_sec_xxxxxxxxxxxx"\n```\n\n## Scores via MCP\n\n```\nWhat pages have the highest confusion scores right now?\nHow has the /checkout score trended over the past two weeks?\nWhich pages crossed their confusion budgets this week?\nShow me all pages with a score above 50.\n```\n\n## How scores relate to issues\n\nScores are a summary. Issues are the detail. A high score means there\'s friction on the page. Issues tell you specifically what the friction is, which element it\'s on, how many users hit it, and what the likely cause is.\n\nA page can have a high score with no open issues if the signals are dispersed across many elements without enough clustering for the engine to create an issue. That\'s unusual but possible. It usually means several small problems rather than one big one.\n\nA page can also have open issues and a moderate score if the issues are being managed and the fix is in progress. The score reflects live signal volume, not the issue count.\n\nStart with scores to prioritize pages. Then read issues to know what to fix.\n'
|
|
406
|
+
},
|
|
407
|
+
{
|
|
408
|
+
"slug": "issues",
|
|
409
|
+
"title": "Issues",
|
|
410
|
+
"description": "A clustered, evidence-backed problem with a location, a root cause hypothesis, and a lifecycle.",
|
|
411
|
+
"group": "Product",
|
|
412
|
+
"content": '# Issues\n\nAn issue is what Flusterduck produces when enough users hit the same friction pattern on the same element. Not a raw signal count. A clustered, evidence-backed problem with a specific location, a root cause hypothesis, and a lifecycle that tracks whether it got fixed.\n\nIssues work like tickets. They get statuses, triage notes, assignees, and verification after you ship a fix.\n\n## How issues are created\n\nThe scoring engine watches for signal clusters: the same signal type on the same element, repeated across sessions from different users. When a cluster crosses the confidence threshold, an issue is created.\n\nOne user rage-clicking a button 40 times doesn\'t create an issue. Forty different users rage-clicking the same button over three days does.\n\nThe clustering algorithm weighs:\n- Signal type match (same type, or co-occurring types on the same element)\n- Element selector match (fuzzy-matched to handle minor DOM changes between deploys)\n- Session diversity (signals from distinct sessions, not a single frustrated user)\n- Time window (7 days for initial creation, longer for ongoing patterns)\n\n## Issue fields\n\n| Field | Description |\n|---|---|\n| `id` | Unique identifier with `iss_` prefix |\n| `title` | Generated description of the problem |\n| `page` | Page path where the issue occurs |\n| `selector` | CSS selector of the affected element |\n| `signal_type` | Dominant signal type driving the cluster |\n| `signal_count` | Total signals in the cluster |\n| `severity` | 0-100 score based on signal tier, volume, page importance, and revenue exposure |\n| `status` | Current lifecycle state |\n| `hypothesis` | Engine-generated root cause guess |\n| `sessions` | Session IDs containing this friction pattern |\n| `verifications` | Deploy-correlated verification records |\n\n## Issue lifecycle\n\n**`open`**: the issue exists and nobody has acted on it. New issues land here automatically.\n\n**`triaged`**: someone reviewed it and confirmed it\'s real. Add a note with context before moving it here. "Confirmed on mobile Safari. The submit button is obscured by the cookie banner at 375px." is more useful than just a status change.\n\n**`in_progress`**: someone is actively working a fix. This prevents duplicate effort when multiple engineers are looking at the same issue list.\n\n**`verified`**: the issue was resolved and the scoring engine confirmed the fix held after the next deploy. The friction pattern didn\'t return.\n\n**`regressed`**: the issue was resolved, but the confusion pattern came back. The engine re-opens it automatically. This happens when a fix gets reverted, a related change reintroduces the problem, or the original fix was partial.\n\n**`ignored`**: known issue, won\'t fix. Alert rules won\'t fire for ignored issues, and they won\'t count toward your open issue total. Use this deliberately, not as a way to clear your queue.\n\n### Moving issues forward\n\nVia the API:\n\n```bash\ncurl -X POST https://api.flusterduck.com/v1/issues/iss_xxxxxxxxxxxx \\\n -H "Authorization: Bearer fd_sec_xxxxxxxxxxxx" \\\n -H "Content-Type: application/json" \\\n -d \'{\n "status": "triaged",\n "note": "Confirmed on mobile. Submit button overlaps cookie banner at 375px.",\n "assigned_to": "maya"\n }\'\n```\n\nVia MCP:\n\n```\nTriage the open issues and tell me what to fix first.\nMark issue iss_xxxxxxxxxxxx as in_progress and assign it to alex.\n```\n\n## Severity scoring\n\nSeverity (0-100) reflects how much a specific issue is hurting your users. Higher severity means more sessions affected, higher-tier signals, more revenue exposure.\n\nIt incorporates:\n- Signal tier of the dominant signal type\n- Signal volume and percentage of sessions affected\n- Page importance (checkout and pricing have higher baseline weights)\n- Revenue exposure (whether conversion events were tracked near this element in affected sessions)\n- Recency (signals from the last 48 hours weight more than older ones)\n\nUse severity for relative prioritization within your open issue list. Don\'t treat it as an absolute scale. A severity 40 issue on /checkout probably matters more than a severity 70 issue on your admin changelog page.\n\n## Verification\n\nAfter each deploy, the scoring engine runs verification on all open and recently-resolved issues.\n\nFor **resolved** issues: checks whether the signal cluster has declined. If signals dropped, it marks the issue `verified`. If signals are still at pre-fix levels, it marks it `regressed`.\n\nFor **open** issues: recalculates evidence, updates `signal_count` and `severity`, and checks whether the pattern is intensifying or fading.\n\nVerification needs deploy records to work correctly. Without them, the engine can\'t know when to run the verification cycle. See [Deploy Correlation](./deploy-correlation).\n\n## Evidence sessions\n\nEvery issue includes a `sessions` array: session IDs where the friction pattern appears. These are the most useful thing in an issue for diagnosing root cause.\n\n```bash\ncurl "https://api.flusterduck.com/v1/session?session_id=ses_xxxxxxxxxxxx" \\\n -H "Authorization: Bearer fd_sec_xxxxxxxxxxxx"\n```\n\nThe session endpoint returns the full chronological event timeline: page views, signal types, selectors, timestamps. You can see exactly what the user did before and after the friction moment.\n\nVia MCP:\n\n```\nInvestigate session ses_xxxxxxxxxxxx.\nWhich sessions show rage clicks on the upgrade button?\n```\n\n## Root cause hypotheses\n\nThe engine generates a hypothesis for every issue based on signal type and element context:\n\n- Dead clicks on a button: "Element appears clickable but navigates to an unexpected destination or produces no visible response."\n- Rage clicks on a form field: "Field appears interactive but input is blocked or significantly delayed."\n- Form abandonment at a specific step: "Required information may be unclear, the step label may not match user expectation, or a validation message is obscured."\n\nHypotheses are starting points. They\'re generated from behavioral signals, not from visual inspection of your UI. Confirm or reject them with the session evidence and your own knowledge of the page.\n\n## Getting all issues via the API\n\n```bash\ncurl "https://api.flusterduck.com/v1/issues?status=open" \\\n -H "Authorization: Bearer fd_sec_xxxxxxxxxxxx"\n```\n\n```json\n{\n "issues": [\n {\n "id": "iss_xxxxxxxxxxxx",\n "title": "Dead clicks on complete purchase button",\n "page": "/checkout",\n "selector": "button[type=\'submit\']",\n "signal_type": "dead_click",\n "signal_count": 47,\n "severity": 84,\n "status": "open",\n "created_at": "2026-06-08T11:30:00Z"\n }\n ],\n "total": 1\n}\n```\n\nFilter by any status: `open`, `triaged`, `in_progress`, `verified`, `resolved`, `regressed`, `ignored`.\n\n## Getting full issue detail\n\n```bash\ncurl "https://api.flusterduck.com/v1/issues/iss_xxxxxxxxxxxx" \\\n -H "Authorization: Bearer fd_sec_xxxxxxxxxxxx"\n```\n\nReturns the full issue object including `hypothesis`, `sessions`, and `verifications`.\n\n## What issues don\'t cover\n\nIssues require signal clusters from multiple users. Single-session bugs, one-off failures, and problems that affect a small percentage of users on a specific device or browser may not generate enough signal volume to create an issue automatically.\n\nFor those cases, use `signal()` manually to attach extra metadata to auto-detected signals. The more context you give the engine, the faster it can cluster related signals:\n\n```ts\nsignal(\'error_recovery_loop\', {\n form: \'payment\',\n step: \'card-entry\',\n error_code: \'card_declined\',\n})\n```\n\nCustom metadata gets attached to the issue evidence when a cluster forms.\n'
|
|
413
|
+
},
|
|
414
|
+
{
|
|
415
|
+
"slug": "alerts",
|
|
416
|
+
"title": "Alerts",
|
|
417
|
+
"description": "Catch both spikes and slow drift in confusion scores before they cost you conversions.",
|
|
418
|
+
"group": "Product",
|
|
419
|
+
"content": '# Alerts\n\nConfusion scores fail in two distinct ways. They spike -- a deploy breaks something and the score jumps 30 points overnight. Or they drift -- friction accumulates gradually until a page that was at 35 six weeks ago is now at 62 and nobody noticed. Most monitoring catches the first type. You need both.\n\nEach rule watches your scores against a condition you define. When the condition\'s met, Flusterduck fires an alert to whatever channels you\'ve configured. Once an alert is open, it won\'t fire again for the same rule until the cooldown passes.\n\nConfigure rules in the dashboard under Settings > Alert Rules, via the API, or through the MCP server.\n\n## Trigger types\n\n### spike\n\nFires when a page\'s confusion score increases by more than `threshold` points in a short window.\n\n```json\n{\n "trigger_type": "spike",\n "threshold": 25\n}\n```\n\nThe default window is one scoring cycle. Use this for deploy monitoring -- if you ship code and the checkout score jumps 30 points, you want to know immediately.\n\n### anomaly\n\nFires when a page\'s score is statistically anomalous relative to its historical baseline. No fixed threshold needed -- the engine calculates expected ranges from the page\'s own history.\n\n```json\n{\n "trigger_type": "anomaly",\n "threshold": 80\n}\n```\n\n`threshold` here is anomaly confidence (0-100). At `80`, the score must be in the top 20% of unusual values before the alert fires. Useful for pages where the "normal" range shifts with traffic volume or time of year.\n\n### new_page\n\nFires when a previously untracked page crosses a confusion threshold for the first time. Catches new pages that ship with friction already baked in.\n\n```json\n{\n "trigger_type": "new_page",\n "threshold": 40\n}\n```\n\n### trend\n\nFires when a page\'s score has been trending upward for a sustained period. Less sensitive than `spike` but catches gradual degradation that spike detection misses.\n\n```json\n{\n "trigger_type": "trend",\n "threshold": 50\n}\n```\n\nIf a page climbs from 30 to 50 over three weeks without triggering a spike, `trend` catches it.\n\n### co_occurrence\n\nFires when two or more signal types cluster on the same element. A button that\'s getting both dead clicks and rage clicks simultaneously is a different problem than either alone.\n\n```json\n{\n "trigger_type": "co_occurrence",\n "threshold": 30\n}\n```\n\n`threshold` is the signal count that triggers the co-occurrence check.\n\n### positive\n\nFires when a score drops below `threshold`. An improvement detector -- use it to confirm a fix worked.\n\n```json\n{\n "trigger_type": "positive",\n "threshold": 20\n}\n```\n\nRoute positive alerts to a different channel than your incident alerts. They don\'t belong in `#incidents`. PagerDuty should never wake anyone up at 2am because a score improved.\n\n### budget\n\nFires when a page\'s confusion score exceeds an absolute ceiling you\'ve set. Unlike `spike` (which is relative to recent history), `budget` doesn\'t care about rate of change -- only whether you\'ve crossed the line.\n\n```json\n{\n "trigger_type": "budget",\n "threshold": 60\n}\n```\n\nYour checkout page probably shouldn\'t ever be above 50. Set a budget alert and you don\'t have to remember to check.\n\n## Channels\n\n| Channel | Config |\n|---|---|\n| `email` | Fires to the addresses configured under Settings > Notifications |\n| `slack` | Fires to the channel configured under Settings > Integrations > Slack |\n| `webhook` | Delivers a POST to your registered webhook endpoint |\n| `pagerduty` | Creates and resolves PagerDuty incidents via your Events API v2 key |\n\nCombine channels. Most teams use `["email", "slack"]` for spike rules and add `"pagerduty"` for budget rules on critical pages.\n\n## Page pattern\n\nScope a rule to specific pages with glob-style patterns:\n\n```\n/checkout exact match\n/checkout* /checkout and any sub-path\n/pricing exact match\n/* all pages (default if omitted)\n/app/* all pages under /app\n```\n\n## Rule configuration\n\nFull schema for creating or updating a rule:\n\n```json\n{\n "name": "Checkout rage click spike",\n "trigger_type": "spike",\n "threshold": 25,\n "cooldown_minutes": 60,\n "channels": ["email", "slack"],\n "page_pattern": "/checkout*",\n "slack_channel": "#incidents",\n "enabled": true\n}\n```\n\n| Field | Type | Constraints |\n|---|---|---|\n| `name` | string | Required |\n| `trigger_type` | string | One of the 7 types above |\n| `threshold` | number | 0-1000 |\n| `cooldown_minutes` | number | 1-1440 |\n| `channels` | array | Any combination of supported channel types |\n| `page_pattern` | string | Glob pattern, defaults to `/*` |\n| `slack_channel` | string | Optional `#channel-name` or Slack channel ID for Slack alerts |\n| `enabled` | boolean | Defaults to `true` |\n\n## Alert lifecycle\n\nAlerts move through four states: `open`, `acknowledged`, `investigating`, `resolved`.\n\n`open` means the rule fired and nobody\'s acted on it. You\'ll keep getting notifications until someone acknowledges it.\n\n`acknowledged` means someone\'s aware. Escalation stops -- no repeat notifications -- but the alert stays open. Acknowledge it as soon as someone takes ownership, even before you know the cause.\n\n`investigating` is optional but useful for team coordination. It signals that someone is actively working the problem.\n\n`resolved` closes the alert. If you tagged the deploy that fixed the underlying issue, the scoring engine verifies the fix held and marks related issues as `verified`. If the condition is met again later, a new alert fires.\n\nTo suppress a signal you\'ve decided not to fix, use `ignored` status on the underlying issue. Alert rules won\'t fire for issues in `ignored` status.\n\n## Managing rules\n\nWith an MCP key carrying `manage:write` scope, your AI assistant manages rules directly:\n\n```\nList all my alert rules and show me which ones are disabled.\nCreate a rage click spike alert for the pricing page.\nDisable the checkout budget alert until the redesign ships.\n```\n\nSee [API reference](./api) for request examples.\n\n## Suggested starting rules\n\nA set that covers the most common failure modes:\n\n1. **Post-deploy spike** -- `spike`, threshold 20, all pages, 60-minute cooldown, email + slack\n2. **Critical page budget** -- `budget`, threshold 50, `/checkout*` and `/pricing*`, email + pagerduty\n3. **New page check** -- `new_page`, threshold 40, all pages, 24-hour cooldown, email\n4. **Fix confirmation** -- `positive`, threshold 20, all pages, email only (route to a wins channel)\n'
|
|
420
|
+
},
|
|
421
|
+
{
|
|
422
|
+
"slug": "deploy-correlation",
|
|
423
|
+
"title": "Deploy correlation",
|
|
424
|
+
"description": "Record every deploy so the engine can attribute friction changes and verify your fixes.",
|
|
425
|
+
"group": "Product",
|
|
426
|
+
"content": '# Deploy Correlation\n\nRecord every deploy. One API call. Without it, the engine can\'t distinguish a friction spike caused by a broken release from a spike caused by a traffic surge or a bad email campaign. It also can\'t verify that your fixes actually worked after you ship.\n\n## Recording a deploy\n\n```bash\ncurl -X POST https://api.flusterduck.com/v1/deploys \\\n -H "Authorization: Bearer fd_sec_xxxxxxxxxxxx" \\\n -H "Content-Type: application/json" \\\n -d \'{\n "version": "v2.14.3",\n "environment": "production"\n }\'\n```\n\nThe engine captures `confusion_before` at the moment of the call. After 5 or more minutes of post-deploy traffic, it calculates `confusion_after` and `delta`. Both fields are `null` until that data accumulates.\n\n`delta` is `confusion_after - confusion_before`. Positive means friction went up. Negative means it went down.\n\n## CI integration\n\nRun the record call after your deploy is live and serving traffic, not before. Calling it before traffic hits means `confusion_before` will be accurate but `confusion_after` will include signals from the old version.\n\n### GitHub Actions\n\n```yaml\n- name: Record deploy\n run: |\n curl -X POST https://api.flusterduck.com/v1/deploys \\\n -H "Authorization: Bearer ${{ secrets.FLUSTERDUCK_SECRET_KEY }}" \\\n -H "Content-Type: application/json" \\\n -d "{\\"version\\": \\"${{ github.sha }}\\", \\"environment\\": \\"production\\"}"\n```\n\nStore your `fd_sec_` key in GitHub Secrets. Never put it in the workflow file itself.\n\n### Vercel post-deploy hook\n\nIn your Vercel project settings, add a Deploy Hook. Create a lightweight serverless function that calls the Flusterduck API and point the hook at it. The function receives a POST from Vercel when the deploy completes, then records the deploy:\n\n```ts\n// api/deploy-hook.ts\nexport async function POST() {\n await fetch(\'https://api.flusterduck.com/v1/deploys\', {\n method: \'POST\',\n headers: {\n \'Authorization\': `Bearer ${process.env.FLUSTERDUCK_SECRET_KEY}`,\n \'Content-Type\': \'application/json\',\n },\n body: JSON.stringify({\n version: process.env.VERCEL_GIT_COMMIT_SHA,\n environment: \'production\',\n }),\n })\n return new Response(\'ok\')\n}\n```\n\n### SDK-level version tagging\n\nIf CI integration isn\'t available, set `segment.app_version` in your SDK config. The engine uses it to associate sessions with deploy records and improve correlation accuracy:\n\n```ts\ninit({\n key: process.env.NEXT_PUBLIC_FLUSTERDUCK_KEY!,\n segment: {\n app_version: process.env.NEXT_PUBLIC_APP_VERSION ?? \'unknown\',\n },\n})\n```\n\nSet `NEXT_PUBLIC_APP_VERSION` to your git SHA or version string at build time. This doesn\'t replace the API call for recording deploys, but it improves session-to-deploy correlation when version strings are consistent.\n\n## What happens after a deploy is recorded\n\n1. `confusion_before` is captured immediately from the current site score.\n2. After 5 minutes of post-deploy traffic, `confusion_after` and `delta` are calculated.\n3. All open issues are queued for verification in the next scoring cycle.\n4. Issues where the signal cluster declined significantly are marked `verified`.\n5. Issues that were previously resolved but show renewed signal activity are marked `regressed`.\n6. If `delta` exceeds your spike alert threshold, an alert fires.\n\n## Reading deploy data\n\n```bash\ncurl "https://api.flusterduck.com/v1/deploys" \\\n -H "Authorization: Bearer fd_sec_xxxxxxxxxxxx"\n```\n\n```json\n{\n "deploys": [\n {\n "id": "dep_xxxxxxxxxxxx",\n "version": "v2.14.3",\n "environment": "production",\n "confusion_before": 41,\n "confusion_after": 67,\n "delta": 26,\n "recorded_at": "2026-06-10T09:00:00Z"\n },\n {\n "id": "dep_xxxxxxxxxxxx",\n "version": "v2.14.2",\n "environment": "production",\n "confusion_before": 45,\n "confusion_after": 41,\n "delta": -4,\n "recorded_at": "2026-06-08T14:00:00Z"\n }\n ]\n}\n```\n\nA delta of +26 on a site averaging 41 would trigger most spike rules. That\'s a deploy you want to look at immediately. A delta of -4 means the fix you shipped last time worked.\n\n## Issue verification details\n\nEach issue includes a `verifications` array that shows the verification history:\n\n```json\n{\n "id": "iss_xxxxxxxxxxxx",\n "title": "Dead clicks on complete purchase button",\n "status": "verified",\n "verifications": [\n {\n "deploy_id": "dep_xxxxxxxxxxxx",\n "deploy_version": "v2.14.4",\n "result": "verified",\n "signal_count_before": 47,\n "signal_count_after": 3,\n "verified_at": "2026-06-11T10:15:00Z"\n }\n ]\n}\n```\n\n`signal_count_after` of 3 vs 47 before is a verified fix. The few remaining signals are likely residual noise.\n\nIf a fix looks verified but `signal_count_after` is higher than expected, check whether a related issue was created separately. The engine clusters by element and signal type, so a fix that resolves rage clicks but introduces dead clicks on the same button would close the rage click issue and open a new dead click issue.\n\n## Post-deploy checks via MCP\n\nThe `post_deploy_check` prompt runs the full correlation workflow automatically: finds your latest deploy, compares scores, identifies affected pages, and returns PASS / WARN / FAIL:\n\n```\nRun a post-deploy check.\nDid the deploy this morning cause any regressions?\nShow me confusion_before and confusion_after for my last 5 deploys.\n```\n\nSee [MCP](./mcp) for setup.\n\n## Staging environments\n\nPass `"environment": "staging"` when recording deploys to your staging environment. Staging deploy data is tracked separately from production. Your production scores won\'t be affected by staging friction.\n\n```bash\ncurl -X POST https://api.flusterduck.com/v1/deploys \\\n -H "Authorization: Bearer fd_sec_xxxxxxxxxxxx" \\\n -H "Content-Type: application/json" \\\n -d \'{\n "version": "v2.14.4-rc1",\n "environment": "staging"\n }\'\n```\n\nFilter deploys by environment when reading:\n\n```bash\ncurl "https://api.flusterduck.com/v1/deploys?environment=production" \\\n -H "Authorization: Bearer fd_sec_xxxxxxxxxxxx"\n```\n'
|
|
427
|
+
},
|
|
428
|
+
{
|
|
429
|
+
"slug": "revenue",
|
|
430
|
+
"title": "Revenue impact",
|
|
431
|
+
"description": "Wire conversion events with track() and Flusterduck puts dollar amounts on UX issues.",
|
|
432
|
+
"group": "Product",
|
|
433
|
+
"content": '# Revenue Impact\n\nWire conversion events with `track()` and Flusterduck starts putting dollar amounts on UX issues. Instead of "47 users are rage-clicking the checkout button," you get "47 users are rage-clicking the checkout button and we estimate $5,200/month in lost revenue from sessions with that pattern."\n\nThat\'s the number that gets engineers to prioritize UX bugs over feature work.\n\n## What to track\n\nThree events power revenue estimates:\n\n```ts\nimport { track } from \'flusterduck\'\n\n// User selects a plan or clicks an upgrade CTA\ntrack(\'plan_intent\', {\n plan_id: \'scale\',\n amount_cents: 9900,\n billing: \'monthly\',\n})\n\n// Purchase completed\ntrack(\'subscription_started\', {\n plan_id: \'scale\',\n amount_cents: 9900,\n billing: \'monthly\',\n})\n\n// Checkout started but not completed\ntrack(\'checkout_abandoned\', {\n plan_id: \'grow\',\n amount_cents: 3900,\n})\n```\n\nWhere each one belongs in your code:\n\n**`plan_intent`**: when a user clicks a pricing CTA or selects a plan tier. Before the payment form. This tells the engine what was at stake in sessions that didn\'t convert.\n\n**`subscription_started`**: in your post-payment success handler or your Stripe webhook. This establishes the baseline conversion rate.\n\n**`checkout_abandoned`**: when a user leaves the checkout flow. Use your payment provider\'s abandon detection or wire it to `onbeforeunload` on your checkout page.\n\n## Safe properties\n\nNever pass PII, form values, or any user-typed content. The engine only needs these:\n\n| Property | Type | Notes |\n|---|---|---|\n| `plan_id` | string | Your internal plan identifier (`"grow"`, `"scale"`, `"enterprise"`) |\n| `amount_cents` | number | Integer. In cents, not dollars. `9900` = $99.00 |\n| `billing` | string | `"monthly"` or `"annual"` |\n| `currency` | string | ISO 4217 code. Defaults to `"USD"` |\n| `quantity` | number | Seat count, unit count |\n| `product_id` | string | For multi-product apps |\n\n## How the estimate works\n\nThe engine correlates friction patterns with conversion outcomes:\n\n1. For each open issue, it identifies the sessions containing that friction pattern.\n2. It compares the conversion rate of those sessions against sessions on the same page with no friction signals.\n3. The conversion gap gets monetized using the average `amount_cents` from recent `subscription_started` events.\n4. That\'s projected across the monthly session volume for that page.\n\nThe result is directionally accurate, not exact. It assumes the friction is causing the conversion gap, which is usually right but not always. A page that\'s down or slow will produce high friction signals and low conversions, but the fix isn\'t a UX change. Use the revenue estimate as a prioritization signal, not a forecast.\n\n## Reading revenue estimates\n\n```bash\ncurl "https://api.flusterduck.com/v1/revenue" \\\n -H "Authorization: Bearer fd_sec_xxxxxxxxxxxx"\n```\n\n```json\n{\n "total_at_risk_monthly": 8400,\n "currency": "USD",\n "issues": [\n {\n "issue_id": "iss_xxxxxxxxxxxx",\n "title": "Dead clicks on complete purchase button",\n "page": "/checkout",\n "revenue_at_risk_monthly": 5200,\n "affected_sessions_pct": 14,\n "conversion_gap_pct": 8.3\n },\n {\n "issue_id": "iss_xxxxxxxxxxxx",\n "title": "Form abandonment at billing step",\n "page": "/checkout",\n "revenue_at_risk_monthly": 3200,\n "affected_sessions_pct": 9,\n "conversion_gap_pct": 5.1\n }\n ]\n}\n```\n\n`affected_sessions_pct` is the percentage of checkout sessions that contain this friction pattern. `conversion_gap_pct` is the difference in conversion rate between affected and unaffected sessions.\n\nRevenue estimates are only populated when:\n- You\'ve called `track(\'subscription_started\', ...)` at least 50 times in the past 30 days (the engine needs enough data to establish a baseline conversion rate)\n- There are open issues on pages where conversion events were tracked\n- The issue\'s affected sessions have a statistically meaningful conversion gap\n\nIf the `/revenue` endpoint returns empty `issues`, you either haven\'t tracked enough conversions yet or there aren\'t enough sessions to compute a meaningful gap.\n\n## Revenue via MCP\n\n```\nWhat\'s the revenue impact of the current open issues?\nWhich issue is costing us the most?\nRank the open issues by revenue at risk.\n```\n\nThe `get_revenue_impact` tool returns the same data as the API endpoint, formatted for your AI assistant to reason about.\n\n## Annual vs monthly\n\nThe estimate uses `billing` to annualize correctly. If most of your conversions are annual, the per-session value is higher and the revenue-at-risk number will reflect that. Pass `billing: "annual"` in `subscription_started` events for annual subscribers and `billing: "monthly"` for monthly subscribers.\n\nIf you don\'t pass `billing`, the engine assumes monthly.\n\n## When estimates look too low or too high\n\n**Too low**: You may not be calling `track(\'checkout_abandoned\', ...)`. Without explicit abandonment events, the engine has to infer them from sessions that had `plan_intent` but no `subscription_started`. That inference is less precise than an explicit event.\n\n**Too high**: Check whether your `subscription_started` events are firing in test or CI sessions. If they are, the engine\'s baseline conversion rate will be inflated and the gap will appear larger than it is. Pass `environment: "development"` to your SDK init in non-production environments to keep test data out of production scores.\n'
|
|
434
|
+
},
|
|
435
|
+
{
|
|
436
|
+
"slug": "mcp",
|
|
437
|
+
"title": "MCP integration",
|
|
438
|
+
"description": "Give your AI assistant read access to scores, issues, journeys, and revenue leakage.",
|
|
439
|
+
"group": "Integrations",
|
|
440
|
+
"content": '# MCP Integration\n\nSet this up once and you stop opening the dashboard to check on friction data. Ask your AI assistant:\n\n> "What\'s broken on checkout right now?"\n\nIt calls `get_site_context`, pulls your live scores and open issues, and tells you. Post-deploy checks, issue triage, weekly summaries. All without touching a UI.\n\n## Fastest install\n\nNo global install is needed \u2014 `npx` fetches the server on first run. Grab an MCP key and your site ID from the dashboard (Settings \u2192 API keys), then:\n\n```bash\nclaude mcp add flusterduck \\\n --env FLUSTERDUCK_MCP_KEY=fd_mcp_xxxxxxxxxxxx \\\n --env FLUSTERDUCK_SITE_ID=your-site-id \\\n -- npx -y @flusterduck/mcp-server\n```\n\nPrefer the hosted server? Sign in with OAuth, no key to copy:\n\n```bash\nclaude mcp add --transport http flusterduck https://mcp.flusterduck.com/mcp\n```\n\n## Setup\n\n### Claude Desktop\n\nAdd to `~/Library/Application Support/Claude/claude_desktop_config.json` (npx pulls the server automatically \u2014 nothing to install first):\n\n```json\n{\n "mcpServers": {\n "flusterduck": {\n "command": "npx",\n "args": ["-y", "@flusterduck/mcp-server"],\n "env": {\n "FLUSTERDUCK_MCP_KEY": "fd_mcp_xxxxxxxxxxxx",\n "FLUSTERDUCK_SITE_ID": "7f2c9d4a-4b5e-4c3a-9b1d-2e6a4f8c7d5b"\n }\n }\n }\n}\n```\n\nRestart Claude Desktop. Tools are available immediately.\n\n### Claude Code CLI\n\n```bash\nclaude mcp add flusterduck \\\n --env FLUSTERDUCK_MCP_KEY=fd_mcp_xxxxxxxxxxxx \\\n --env FLUSTERDUCK_SITE_ID=your-site-id \\\n -- npx -y @flusterduck/mcp-server\n```\n\nOr add directly to `~/.claude/settings.json`:\n\n```json\n{\n "mcpServers": {\n "flusterduck": {\n "command": "npx",\n "args": ["-y", "@flusterduck/mcp-server"],\n "env": {\n "FLUSTERDUCK_MCP_KEY": "fd_mcp_xxxxxxxxxxxx",\n "FLUSTERDUCK_SITE_ID": "7f2c9d4a-4b5e-4c3a-9b1d-2e6a4f8c7d5b"\n }\n }\n }\n}\n```\n\n### Cursor\n\n`~/.cursor/mcp.json` (global) or `.cursor/mcp.json` in the project root:\n\n```json\n{\n "mcpServers": {\n "flusterduck": {\n "command": "npx",\n "args": ["-y", "@flusterduck/mcp-server"],\n "env": {\n "FLUSTERDUCK_MCP_KEY": "fd_mcp_xxxxxxxxxxxx",\n "FLUSTERDUCK_SITE_ID": "7f2c9d4a-4b5e-4c3a-9b1d-2e6a4f8c7d5b"\n }\n }\n }\n}\n```\n\nReload the Cursor window after saving.\n\n### Windsurf\n\n`~/.codeium/windsurf/mcp_config.json`:\n\n```json\n{\n "mcpServers": {\n "flusterduck": {\n "command": "npx",\n "args": ["-y", "@flusterduck/mcp-server"],\n "env": {\n "FLUSTERDUCK_MCP_KEY": "fd_mcp_xxxxxxxxxxxx",\n "FLUSTERDUCK_SITE_ID": "7f2c9d4a-4b5e-4c3a-9b1d-2e6a4f8c7d5b"\n }\n }\n }\n}\n```\n\n### VS Code (GitHub Copilot)\n\n`.vscode/mcp.json` in the workspace:\n\n```json\n{\n "servers": {\n "flusterduck": {\n "type": "stdio",\n "command": "npx",\n "args": ["-y", "@flusterduck/mcp-server"],\n "env": {\n "FLUSTERDUCK_MCP_KEY": "fd_mcp_xxxxxxxxxxxx",\n "FLUSTERDUCK_SITE_ID": "7f2c9d4a-4b5e-4c3a-9b1d-2e6a4f8c7d5b"\n }\n }\n }\n}\n```\n\n### Remote server\n\nConnect to the hosted Cloudflare Worker endpoint using your `fd_mcp_` key. The worker handles OAuth and scope validation automatically. Get the endpoint URL from Settings > Integrations > MCP.\n\n## Keys\n\nMCP keys start with `fd_mcp_`. Create one under Settings > API Keys.\n\n**Read-only** (`mcp:read`): all query tools and prompts. Can\'t write anything. Start here.\n\n**Read + write** (`mcp:read` + `manage:write`): adds `update_issue`, `update_alert`, `add_annotation`, `create_alert_rule`, `update_alert_rule`, and `delete_alert_rule`. Needed for triage and annotation workflows.\n\n## Prompts\n\nThe prompts are the highest-value part. They\'re multi-step workflows built into the server. The AI calls several tools in sequence and synthesizes the result into a useful answer. Use prompts for the common cases; use raw tools when you need something specific.\n\n### post_deploy_check\n\nFinds your most recent deploy, compares confusion scores before and after, identifies pages where friction spiked, and returns PASS / WARN / FAIL. Writes an annotation automatically on FAIL.\n\nRun this after every deploy. Takes about 30 seconds.\n\n### triage_open_issues\n\nScans open issues by signal count, moves each to the right status (`triaged`, `in_progress`, or `ignored`), adds a triage note to each, and writes a summary annotation. Requires `manage:write`.\n\nMost useful first thing Monday morning before standup.\n\n### diagnose_page\n\nFive-step diagnosis for a specific page. Returns: current friction score, top signal sources by volume, worst-performing element, likely root cause, and one concrete fix recommendation.\n\n```\npage_path: "/settings/billing"\n```\n\n### investigate_session\n\nFull investigation of a single session: event timeline in order, dominant signal types, matching open issues, and a verdict on whether the session represents a recurring pattern or an outlier.\n\n```\nsession_id: "ses_xxxxxxxxxxxx"\n```\n\n### weekly_summary\n\nA weekly UX friction digest formatted for a PM or eng lead: score trends, newly opened issues, open alerts, revenue at risk, top three recommendations. Under 300 words.\n\nGood to run before Monday standup.\n\n## What a real investigation looks like\n\nYou ask: "Did the deploy yesterday break anything on checkout?"\n\nThe AI runs this automatically:\n\n1. `get_deploys`: finds yesterday\'s deploy and its timestamp\n2. `get_page` with `/checkout`: sees the confusion score jumped from 14 to 38 in the hour after\n3. `get_issues` filtered to `open`: two issues opened within 90 minutes of the deploy, a rage click spike on `#place-order` and elevated form abandonment on the payment step\n4. `get_elements` scoped to `/checkout`: `#place-order` has 52 rage clicks in 24 hours, baseline is 5/day\n5. `diagnose_page`: returns root cause. The button appears inactive during payment processing with no visual indication, causing repeated clicking.\n6. `add_annotation`: "Deploy 2026-06-09: /checkout confusion +171%. #place-order rage clicks 10x baseline. Root cause: missing loading state on payment submit."\n\nThe whole sequence runs in about 30 seconds and leaves a permanent timeline marker.\n\n## Read tools\n\nAll require `mcp:read` scope. Start with `get_site_context`. It gives you the full picture in one call and tells you where to dig next.\n\n**Documentation** \u2014 the full Flusterduck docs are bundled into the server, so your assistant can read them before acting on data.\n\n- `list_docs`: Lists every documentation page (slug, title, group). Start here to see what\'s available.\n- `search_docs`: Full-text search across all docs. Pass `query`; returns matching pages with excerpts.\n- `get_doc`: Returns a full documentation page by `slug` (e.g. `alerts`, `scoring`, `webhooks`, `react`, `mcp`). Read a feature\'s page before acting \u2014 for example, read `alerts` before creating a rule to understand threshold semantics.\n\n**Start here**\n\n- `get_site_context`: Full snapshot of scores, open issues, active alerts, recent deploys, and top recommendations. The right first call for any investigation.\n- `get_scores`: Every tracked page ranked by current confusion score.\n- `get_recommendations`: Prioritized fix list ranked by estimated confusion reduction.\n\n**Issues and alerts**\n\n- `get_issues`: All UX issues. Filter by status: `open`, `triaged`, `in_progress`, `verified`, `resolved`, `regressed`.\n- `get_issue`: Full detail on one issue, including evidence, session links, verification history, and deploy correlation.\n- `get_alerts`: Active and resolved alerts. Filter by `open`, `acknowledged`, or `resolved`.\n- `list_alert_rules`: All configured rules with thresholds, channels, and enabled state. Call this before creating or modifying rules.\n\n**Page deep-dives**\n\n- `get_page`: Score history, active issues, element friction, annotations, and confusion budget for one page. Pass `page: "/checkout"`.\n- `get_elements`: Element-level breakdown showing which buttons, forms, and links generate the most signals. Scope by `page` or pull site-wide.\n- `get_trends`: Confusion score over time. Pass `days` (1-90) and optionally `page`.\n- `compare_pages`: Side-by-side confusion score comparison. Pass `a` and `b` as page paths.\n- `diagnose_journey_friction`: High-friction navigation edges from recent sessions. Filter by `signal_type` or `min_friction_weight`.\n\n**Sessions and raw data**\n\n- `get_session_detail`: Full event timeline for one session in chronological order.\n- `get_heuristics`: The complete signal catalog with all 33 types, scoring weights, and thresholds.\n- `query_raw_rows`: SQL-style access to allowlisted tables (`events`, `signals`, `sessions`, `page_scores`, `score_history`, `ux_issues`, `alerts`, `deploys`).\n- `download_events_csv`: Raw event export as CSV.\n\n**Deploy correlation**\n\n- `get_deploys`: All deploys Flusterduck knows about, with `confusion_before` and `confusion_after` populated for deploys older than 5 minutes.\n- `get_revenue_impact`: Revenue impact estimates for active friction. Only populated when conversion tracking is wired.\n- `get_flows`: Page-to-page navigation edges from recent sessions.\n\n**Org-level** (requires org-scoped key)\n\n- `get_audit_log`: Organization audit log.\n- `get_degradation`: Active and recent backend degradation events. Also available to site-scoped keys, since degradation is operational health rather than tenant data.\n- `get_webhook_deliveries`: Outbound webhook delivery history and failure details.\n\n## Write tools\n\nAll require `manage:write` scope.\n\n**`update_issue`**: Change status, add a triage note, or assign an issue.\n\n```\nissue_id: "iss_3a7f2c9d4e1b"\nstatus: "triaged"\nnote: "Confirmed on Safari iOS 17. Disabled-state styling on #place-order doesn\'t communicate that payment is processing."\nassigned_to: "eng-lead"\n```\n\nStatus options: `open`, `triaged`, `in_progress`, `verified`, `resolved`, `ignored`.\n\n**`update_alert`**: Acknowledge, mark investigating, or resolve an alert.\n\n```\nalert_id: "alt_9b5e1f4c2d8a"\nstatus: "resolved"\nresolved_reason: "Deploy 2026-06-09 added a spinner and disabled state to the payment submit button."\n```\n\n**`add_annotation`**: Write a timeline marker visible to the whole team.\n\n```\nmessage: "Redesigned billing flow launched. Monitoring confusion score on /settings/billing."\n```\n\n**`create_alert_rule`**: Create a new alert rule.\n\n```\nname: "Checkout rage click spike"\ntrigger_type: "spike"\nthreshold: 25\ncooldown_minutes: 60\nchannels: ["email", "slack"]\npage_pattern: "/checkout*"\n```\n\n**`update_alert_rule`**: Update an existing rule. Use `enabled: false` to silence it temporarily rather than deleting.\n\n**`delete_alert_rule`**: Permanently remove a rule. Can\'t be undone.\n\n## Questions that work well\n\n- What\'s broken on checkout right now?\n- Did the last deploy make things worse?\n- Which page has the most dead clicks this week?\n- Triage the open issues and tell me what to fix first.\n- Write a friction summary for the PM standup.\n- Which sessions show rage clicks on the upgrade button?\n- How does this week\'s confusion score compare to last week?\n- Create a spike alert for the pricing page and notify Slack.\n- What\'s the revenue impact of the current open issues?\n- Which elements on `/onboarding` are generating the most friction?\n'
|
|
441
|
+
},
|
|
442
|
+
{
|
|
443
|
+
"slug": "api",
|
|
444
|
+
"title": "REST API reference",
|
|
445
|
+
"description": "Read scores, issues, alerts, deploys, and trends through authenticated edge endpoints.",
|
|
446
|
+
"group": "Integrations",
|
|
447
|
+
"content": '# REST API Reference\n\nBase URL: `https://api.flusterduck.com/v1`\n\n## Authentication\n\nPass your key in the Authorization header:\n\n```\nAuthorization: Bearer fd_sec_your_key_here\n```\n\n`fd_sec_` keys are scoped to a single site and work for read endpoints with `query:read`. Write endpoints require a logged-in user JWT; browser code must never use `fd_sec_` keys.\n\nYou can also authenticate with a user JWT issued during login. Same header format.\n\n## Rate limits\n\n| Surface | Limit |\n|---|---|\n| Read API | 100 requests/minute per org |\n| Write API | 30 requests/minute per org |\n\nResponses over the limit return `429` with a `Retry-After` header in seconds.\n\n---\n\n## Read endpoints\n\n### GET /scores\n\nAll tracked pages with their current confusion scores.\n\n```bash\ncurl https://api.flusterduck.com/v1/scores \\\n -H "Authorization: Bearer fd_sec_xxxx"\n```\n\n```json\n{\n "scores": [\n {\n "page": "/checkout",\n "score": 72,\n "trend": "up",\n "top_signal": "rage_click",\n "issue_count": 3,\n "updated_at": "2026-06-10T14:22:05Z"\n },\n {\n "page": "/pricing",\n "score": 41,\n "trend": "stable",\n "top_signal": "dead_click",\n "issue_count": 1,\n "updated_at": "2026-06-10T14:21:47Z"\n }\n ]\n}\n```\n\n`trend` is `up`, `down`, or `stable` relative to the 7-day baseline.\n\n### GET /page\n\nFull data for one page.\n\n```bash\ncurl "https://api.flusterduck.com/v1/page?page=/checkout" \\\n -H "Authorization: Bearer fd_sec_xxxx"\n```\n\n```json\n{\n "page": "/checkout",\n "score": 72,\n "score_history": [\n { "date": "2026-06-10", "score": 72 },\n { "date": "2026-06-09", "score": 61 },\n { "date": "2026-06-08", "score": 58 }\n ],\n "issues": [\n {\n "id": "iss_xxxxxxxxxxxx",\n "title": "Dead clicks on complete purchase button",\n "selector": "button[type=\'submit\']",\n "signal_type": "dead_click",\n "signal_count": 47,\n "severity": 84,\n "status": "open"\n }\n ],\n "elements": [\n {\n "selector": "button[type=\'submit\']",\n "label": "Complete Purchase",\n "signals": { "dead_click": 47, "rage_click": 12 },\n "friction_score": 84\n }\n ],\n "budget": {\n "limit": 50,\n "current": 72,\n "exceeded": true\n }\n}\n```\n\n### GET /issues\n\nUX issues, filterable by status.\n\n```bash\ncurl "https://api.flusterduck.com/v1/issues?status=open" \\\n -H "Authorization: Bearer fd_sec_xxxx"\n```\n\n```json\n{\n "issues": [\n {\n "id": "iss_xxxxxxxxxxxx",\n "title": "Dead clicks on upgrade CTA",\n "page": "/pricing",\n "selector": "[data-cta=\'upgrade\']",\n "signal_type": "dead_click",\n "signal_count": 47,\n "severity": 72,\n "status": "open",\n "created_at": "2026-06-10T14:22:05Z"\n }\n ],\n "total": 1\n}\n```\n\nStatus options: `open`, `triaged`, `in_progress`, `verified`, `resolved`, `regressed`\n\n### GET /issues/:id\n\nFull detail on one issue.\n\n```json\n{\n "id": "iss_xxxxxxxxxxxx",\n "title": "Dead clicks on upgrade CTA",\n "page": "/pricing",\n "selector": "[data-cta=\'upgrade\']",\n "signal_type": "dead_click",\n "signal_count": 47,\n "severity": 72,\n "status": "open",\n "hypothesis": "Element appears clickable but either navigates to an unexpected destination or produces no visible response.",\n "sessions": [\n { "id": "ses_xxxxxxxxxxxx", "signals": 8, "timestamp": "2026-06-10T13:41:22Z" }\n ],\n "verifications": [],\n "created_at": "2026-06-10T14:22:05Z",\n "updated_at": "2026-06-10T14:22:05Z"\n}\n```\n\n### GET /alerts\n\n```bash\ncurl "https://api.flusterduck.com/v1/alerts?status=open" \\\n -H "Authorization: Bearer fd_sec_xxxx"\n```\n\n```json\n{\n "alerts": [\n {\n "id": "alt_xxxxxxxxxxxx",\n "rule_name": "Checkout rage click spike",\n "page": "/checkout",\n "trigger_type": "spike",\n "score_before": 31,\n "score_after": 68,\n "status": "open",\n "triggered_at": "2026-06-10T14:22:05Z"\n }\n ]\n}\n```\n\n### GET /trends\n\nConfusion score history. `days` accepts 1-90, defaults to 7.\n\n```bash\ncurl "https://api.flusterduck.com/v1/trends?page=/checkout&days=14" \\\n -H "Authorization: Bearer fd_sec_xxxx"\n```\n\n```json\n{\n "page": "/checkout",\n "days": 14,\n "data": [\n { "date": "2026-06-10", "score": 72 },\n { "date": "2026-06-09", "score": 61 }\n ]\n}\n```\n\n### GET /deploys\n\n```json\n{\n "deploys": [\n {\n "id": "dep_xxxxxxxxxxxx",\n "version": "2026.06.10",\n "environment": "production",\n "confusion_before": 58,\n "confusion_after": 72,\n "delta": 14,\n "recorded_at": "2026-06-10T09:00:00Z"\n }\n ]\n}\n```\n\n`confusion_after` is `null` until the scoring engine has enough post-deploy data (typically 5 minutes).\n\n### GET /session\n\nFull event timeline for a session.\n\n```bash\ncurl "https://api.flusterduck.com/v1/session?session_id=ses_xxxxxxxxxxxx" \\\n -H "Authorization: Bearer fd_sec_xxxx"\n```\n\n```json\n{\n "session_id": "ses_xxxxxxxxxxxx",\n "page_count": 4,\n "signal_count": 11,\n "started_at": "2026-06-10T13:38:10Z",\n "events": [\n {\n "type": "page_view",\n "page": "/pricing",\n "timestamp": "2026-06-10T13:38:10Z"\n },\n {\n "type": "signal",\n "signal": "dead_click",\n "selector": "[data-cta=\'upgrade\']",\n "page": "/pricing",\n "timestamp": "2026-06-10T13:38:44Z"\n }\n ]\n}\n```\n\n### Other read endpoints\n\n| Endpoint | Returns |\n|---|---|\n| `GET /elements?page=/checkout` | Element-level friction breakdown |\n| `GET /flows` | Page-to-page navigation edges |\n| `GET /compare?a=/pricing&b=/checkout` | Side-by-side score comparison |\n| `GET /recommendations` | Prioritized fix list |\n| `GET /revenue` | Revenue impact estimates (requires conversion tracking) |\n| `GET /raw?table=signals&limit=100` | Raw table rows |\n| `GET /export/events.csv` | CSV export of event rows |\n| `GET /mcp/context` | Full site context snapshot |\n\n---\n\n## Write endpoints\n\nWrite endpoints require `Content-Type: application/json` and a user JWT.\n\n### POST /issues/:id\n\nUpdate status, add a note, or assign.\n\n```bash\ncurl -X POST https://api.flusterduck.com/v1/issues/iss_xxxxxxxxxxxx \\\n -H "Authorization: Bearer user_jwt_here" \\\n -H "Content-Type: application/json" \\\n -d \'{\n "status": "triaged",\n "note": "Confirmed on Safari iOS 17. Related to the disabled-state border color.",\n "assigned_to": "alex"\n }\'\n```\n\nStatus options: `open`, `triaged`, `in_progress`, `verified`, `resolved`, `ignored`\n\n### POST /alerts/:id\n\n```json\n{\n "status": "resolved",\n "resolved_reason": "Deploy #4421 fixed the broken CTA selector"\n}\n```\n\nStatus options: `acknowledged`, `investigating`, `resolved`\n\n### POST /annotations\n\n```json\n{\n "message": "Redesigned checkout flow -- monitoring score"\n}\n```\n\n### POST /alert-rules\n\n```json\n{\n "name": "Checkout rage click spike",\n "trigger_type": "spike",\n "threshold": 25,\n "cooldown_minutes": 60,\n "channels": ["email", "slack"],\n "page_pattern": "/checkout*",\n "slack_channel": "#incidents",\n "enabled": true\n}\n```\n\nSee [Alerts](./alerts) for all trigger types and channel options.\n\n### PATCH /alert-rules/:id\n\nPartial update. Use `"enabled": false` to silence a rule without deleting it.\n\n```json\n{\n "enabled": false\n}\n```\n\n### DELETE /alert-rules/:id\n\nPermanent. Use PATCH with `enabled: false` if you might want it back.\n\n---\n\n## Errors\n\n```json\n{\n "code": "not_found",\n "message": "Issue iss_xxxx not found or does not belong to this site"\n}\n```\n\n| Status | Code | Meaning |\n|---|---|---|\n| 400 | `invalid_input` | Missing required field or invalid value |\n| 401 | `unauthorized` | Missing, expired, or malformed key |\n| 403 | `forbidden` | Key doesn\'t have the required scope |\n| 404 | `not_found` | Resource doesn\'t exist or belongs to a different site |\n| 429 | `rate_limited` | Slow down. Check `Retry-After`. |\n| 500 | `server_error` | Something went wrong on our end |\n'
|
|
448
|
+
},
|
|
449
|
+
{
|
|
450
|
+
"slug": "webhooks",
|
|
451
|
+
"title": "Webhooks",
|
|
452
|
+
"description": "Push events to your own server as they happen, signed and retried.",
|
|
453
|
+
"group": "Integrations",
|
|
454
|
+
"content": '# Webhooks\n\nFlusterduck can push events to your own server as they happen. Configure a webhook endpoint in the dashboard under Settings > Integrations > Webhooks.\n\n## Events\n\n| Event | Fires when |\n|---|---|\n| `issue.created` | A new UX issue is detected |\n| `issue.updated` | An issue\'s status changes (triaged, resolved, regressed, etc.) |\n| `alert.triggered` | An alert rule fires |\n| `alert.acknowledged` | An alert is acknowledged |\n| `alert.resolved` | An alert is resolved |\n| `score.spike` | A page confusion score increases sharply |\n| `deploy.recorded` | A deploy is recorded and scored |\n\n## Payload shape\n\nEvery webhook delivery is a POST request with a JSON body:\n\n```json\n{\n "event": "issue.created",\n "site_id": "7f2c9d4a-4b5e-4c3a-9b1d-2e6a4f8c7d5b",\n "timestamp": "2026-06-10T14:22:05Z",\n "data": { ... }\n}\n```\n\nThe `data` object contains the full resource that changed. For `issue.created` it\'s the full issue object. For `alert.triggered` it\'s the full alert with the rule that fired it.\n\n### issue.created\n\n```json\n{\n "event": "issue.created",\n "site_id": "7f2c9d4a-4b5e-4c3a-9b1d-2e6a4f8c7d5b",\n "timestamp": "2026-06-10T14:22:05Z",\n "data": {\n "id": "iss_3a7f2c9d4e1b",\n "title": "Dead clicks on upgrade button",\n "page": "/pricing",\n "selector": "[data-cta=\'upgrade\']",\n "signal_type": "dead_click",\n "signal_count": 47,\n "severity": 72,\n "status": "open",\n "created_at": "2026-06-10T14:22:05Z"\n }\n}\n```\n\n### alert.triggered\n\n```json\n{\n "event": "alert.triggered",\n "site_id": "7f2c9d4a-4b5e-4c3a-9b1d-2e6a4f8c7d5b",\n "timestamp": "2026-06-10T14:22:05Z",\n "data": {\n "id": "alt_9b5e1f4c2d8a",\n "rule_id": "rul_4c8b2e7a5f1d",\n "rule_name": "Checkout rage click spike",\n "page": "/checkout",\n "trigger_type": "spike",\n "score_before": 31,\n "score_after": 68,\n "status": "open",\n "triggered_at": "2026-06-10T14:22:05Z"\n }\n}\n```\n\n### deploy.recorded\n\n```json\n{\n "event": "deploy.recorded",\n "site_id": "7f2c9d4a-4b5e-4c3a-9b1d-2e6a4f8c7d5b",\n "timestamp": "2026-06-10T14:22:05Z",\n "data": {\n "id": "dep_6e3d1a8f7c2b",\n "version": "2026.06.10",\n "environment": "production",\n "confusion_before": 42,\n "confusion_after": 31,\n "recorded_at": "2026-06-10T14:22:05Z"\n }\n}\n```\n\n## Signature verification\n\nEvery webhook delivery includes two headers:\n\n```\nX-Flusterduck-Signature: sha256=<hex>\nX-Flusterduck-Timestamp: <unix timestamp seconds>\n```\n\nThe signature is HMAC-SHA256 over `timestamp.raw_body` using your webhook secret. Verify it on your server before trusting the payload.\n\n### Node.js (Express)\n\n```ts\nimport crypto from \'crypto\'\nimport express from \'express\'\n\nconst app = express()\n\napp.post(\'/webhooks/flusterduck\', express.raw({ type: \'application/json\' }), (req, res) => {\n const signature = req.headers[\'x-flusterduck-signature\'] as string\n const timestamp = req.headers[\'x-flusterduck-timestamp\'] as string\n\n if (!verifySignature(req.body, timestamp, signature)) {\n return res.status(401).send(\'Invalid signature\')\n }\n\n const event = JSON.parse(req.body.toString())\n // handle event...\n\n res.status(200).send(\'ok\')\n})\n\nfunction verifySignature(body: Buffer, timestamp: string, signature: string): boolean {\n const secret = process.env.FLUSTERDUCK_WEBHOOK_SECRET!\n const payload = `${timestamp}.${body.toString()}`\n const expected = \'sha256=\' + crypto\n .createHmac(\'sha256\', secret)\n .update(payload)\n .digest(\'hex\')\n\n // Constant-time comparison prevents timing attacks\n return crypto.timingSafeEqual(\n Buffer.from(signature),\n Buffer.from(expected)\n )\n}\n```\n\n### Next.js API route (App Router)\n\n```ts\n// app/api/webhooks/flusterduck/route.ts\nimport crypto from \'crypto\'\nimport { NextRequest, NextResponse } from \'next/server\'\n\nexport async function POST(req: NextRequest) {\n const body = await req.text()\n const signature = req.headers.get(\'x-flusterduck-signature\') ?? \'\'\n const timestamp = req.headers.get(\'x-flusterduck-timestamp\') ?? \'\'\n\n const secret = process.env.FLUSTERDUCK_WEBHOOK_SECRET!\n const payload = `${timestamp}.${body}`\n const expected = \'sha256=\' + crypto\n .createHmac(\'sha256\', secret)\n .update(payload)\n .digest(\'hex\')\n\n const valid = crypto.timingSafeEqual(\n Buffer.from(signature),\n Buffer.from(expected)\n )\n\n if (!valid) {\n return NextResponse.json({ error: \'Invalid signature\' }, { status: 401 })\n }\n\n const event = JSON.parse(body)\n // handle event...\n\n return NextResponse.json({ received: true })\n}\n```\n\n### Replay protection\n\nThe timestamp in `X-Flusterduck-Timestamp` is Unix seconds. Reject requests where the timestamp is more than 5 minutes old:\n\n```ts\nconst fiveMinutes = 5 * 60\nconst age = Math.floor(Date.now() / 1000) - parseInt(timestamp, 10)\nif (age > fiveMinutes) {\n return res.status(401).send(\'Request too old\')\n}\n```\n\n## Retry behavior\n\nFailed deliveries (non-2xx response or timeout) are retried up to 5 times with exponential backoff:\n\n| Attempt | Delay |\n|---|---|\n| 1 | Immediate |\n| 2 | 1 minute |\n| 3 | 5 minutes |\n| 4 | 30 minutes |\n| 5 | 2 hours |\n\nAfter 5 failures the delivery is marked failed and won\'t be retried automatically. View failed deliveries and retry them manually under Settings > Integrations > Webhooks > Delivery History.\n\nDeliveries are deduplicated: the same event won\'t be delivered twice to the same endpoint within a 10-minute window, even across retries.\n\n## Testing\n\nSend a test event from the dashboard to verify your endpoint before going live. It uses the same signature mechanism as live events.\n\nYour endpoint must return a 2xx status within 10 seconds. Longer than that counts as a timeout and is treated as a failure.\n'
|
|
455
|
+
},
|
|
456
|
+
{
|
|
457
|
+
"slug": "slack",
|
|
458
|
+
"title": "Slack integration",
|
|
459
|
+
"description": "Friction alerts in Slack with interactive triage buttons and a slash command for live data.",
|
|
460
|
+
"group": "Integrations",
|
|
461
|
+
"content": '# Slack Integration\n\nConnect once and your team gets friction alerts in Slack, interactive buttons to triage them without opening a dashboard, and a slash command to pull live scores and open issues from any channel.\n\n## Setup\n\n1. Go to Settings > Integrations > Slack in your Flusterduck dashboard.\n2. Click "Connect Slack."\n3. Authorize the Flusterduck app for your workspace and select the default alert channel.\n\nAfter connecting, you can route individual alert rules to specific channels. The `/flusterduck` slash command becomes available workspace-wide.\n\nSlack incoming webhooks are tied to the default channel selected during install. Per-rule channel routing uses the Flusterduck bot token and Slack\'s `chat.postMessage` API instead.\n\n## Slash commands\n\n### /flusterduck scores\n\nReturns your top pages ranked by current confusion score with trend indicators:\n\n```\n/flusterduck scores\n\n/checkout 72 \u2191 (was 58, +14)\n/pricing 41 \u2192\n/onboarding 38 \u2191 (was 31, +7)\n/account 24 \u2193 (was 29, -5)\n/dashboard 19 \u2192\n```\n\n### /flusterduck issues\n\nLists open and triaged issues ranked by severity:\n\n```\n/flusterduck issues\n\n[HIGH] Dead clicks on #place-order /checkout 47 signals\n[HIGH] Form abandonment at billing step /checkout 31 signals\n[MED] Rage clicks on upgrade CTA /pricing 22 signals\n[LOW] Navigation loop on /settings/team 8 signals\n```\n\n### /flusterduck issues [page]\n\nScopes the list to a specific page:\n\n```\n/flusterduck issues /checkout\n```\n\n### /flusterduck help\n\nPrints available commands and syntax.\n\n## Alert messages\n\nWhen an alert rule fires, Flusterduck posts to the configured channel:\n\n```\nALERT: Checkout confusion spike\nPage: /checkout\nScore: 72 (was 46, +26)\nTrigger: spike (+20 threshold)\nRule: Post-deploy spike\nTop signal: rage_click (31 new)\n\n[Acknowledge] [Start investigating] [View in dashboard]\n```\n\nEach message includes interactive buttons to move the alert through its lifecycle without leaving Slack.\n\n## Interactive buttons\n\n**Acknowledge**: marks the alert `acknowledged`. Escalation stops. Use this the moment someone takes ownership, even before you know the cause.\n\n**Start investigating**: moves the alert to `investigating`. Signals to the rest of the team that it\'s actively being worked.\n\n**Resolve**: closes the alert. If a deploy fixed the underlying issue, the scoring engine will verify it on the next cycle and mark related issues `verified`.\n\nThese buttons require the Flusterduck bot to be a member of the channel the alert routes to. If you add a new private channel or a public channel where your workspace requires membership, invite it: `/invite @Flusterduck`.\n\n## Alert routing\n\nRoute individual alert rules to specific channels. Most teams separate noise from urgency:\n\n| Channel | Alert types |\n|---|---|\n| `#incidents` | Spike alerts on critical pages (`/checkout`, `/pricing`) |\n| `#product` | New issue alerts, weekly summaries, positive (improvement) alerts |\n| `#eng` | Budget alerts, anomaly alerts, regression alerts |\n\nSet the channel per-rule when creating or editing in Settings > Alert Rules, or via the API:\n\n```json\n{\n "name": "Checkout spike",\n "trigger_type": "spike",\n "threshold": 20,\n "channels": ["slack"],\n "page_pattern": "/checkout*",\n "slack_channel": "#incidents"\n}\n```\n\nPositive alerts should never go to `#incidents`. Route them to a wins channel so they don\'t get lost in incident noise.\n\nIf a rule has `slack` as a channel but no `slack_channel`, Flusterduck falls back to the Slack incoming webhook and posts to the install-selected default channel.\n\n## Bot permissions\n\nThe Flusterduck Slack app requests three permissions:\n\n- `chat:write`: post alert messages and slash command responses\n- `commands`: respond to `/flusterduck`\n- `reactions:write`: react to alert messages when their status changes (a checkmark when an alert is resolved)\n\nIt doesn\'t read message history, access DMs, or join channels it hasn\'t been invited to.\n\n## Disconnecting\n\nSettings > Integrations > Slack > Disconnect.\n\nAlert rules that had `slack` as a channel will stop delivering to Slack. The rules themselves stay active. If you have other channels configured (email, webhook), those keep firing.\n\nReconnecting re-enables Slack delivery for all rules that had it configured. No rule changes needed.\n'
|
|
462
|
+
},
|
|
463
|
+
{
|
|
464
|
+
"slug": "privacy",
|
|
465
|
+
"title": "Privacy and data collection",
|
|
466
|
+
"description": "The full accounting of what gets collected. No session replay, no form values, no raw IPs.",
|
|
467
|
+
"group": "Privacy & security",
|
|
468
|
+
"content": "# Privacy and Data Collection\n\nFlusterduck measures behavioral patterns, not personal data. No session replay, no form values, no text content, no raw IPs. This page is the full accounting of what actually gets collected.\n\n## What the SDK collects\n\n**Interaction events.** Clicks, taps, scrolls, focus events, and keyboard interactions. Specifically: which element was interacted with (CSS selector), coordinates relative to the element, and timestamp. Not what was typed.\n\n**Timing data.** How long users spend on pages, how long they pause on form fields, how long loads take. Millisecond durations.\n\n**Navigation data.** Page paths and the sequence of pages within a session. Query string values are not captured by default.\n\n**Derived signals.** The SDK processes raw interaction events locally to detect friction patterns before sending anything. What leaves the browser is the signal type and any metadata you provide -- not the raw event stream.\n\n**Session identifiers.** A random string used to group signals. By default it is stored in a Flusterduck session cookie for continuity; with `cookieless: true`, it is memory-only and resets on page load. Not tied to any user identity unless you call `identify()`.\n\n## What the SDK never collects\n\nNo passwords, credit card numbers, names, addresses, or any input a user types. This is enforced at the SDK level -- the SDK doesn't attach the kind of listeners that could capture keystrokes or form values.\n\nNo text content. The SDK doesn't read what's on your pages. No labels, headings, paragraphs, or DOM text of any kind.\n\nNo session replay. No video recording, no screenshot capture, no DOM serialization.\n\nNo raw IP addresses. IPs are hashed before storage and are never written to disk in recoverable form.\n\nNo emails or names via `identify()`. If you attach an identifier, use an opaque internal ID.\n\n## IP addresses\n\nWhen events reach the server, the IP is hashed using a one-way function. The original is never stored. The hash is used only for session deduplication and rate limiting.\n\n## Session IDs\n\nSession IDs are random strings the SDK generates. They're not tied to device fingerprints. In default mode the SDK stores the ID in a first-party Flusterduck cookie; in cookieless mode it stays in memory only.\n\n## The identify() method\n\n`identify()` tags the current session with safe segment properties. If you include a user identifier, use an opaque value -- a database primary key or UUID, not an email or full name.\n\n```ts\n// Do this\nidentify({ user_id: 'usr_8f3a2c91', plan: 'scale' })\n\n// Not this\nidentify({ user_id: 'alice@example.com' })\nidentify({ name: 'Alice Johnson' })\n```\n\nFlusterduck doesn't validate what you pass. That's your responsibility.\n\n## Consent and opt-out\n\n### Cookie consent flow\n\nFor zero collection before consent, wait to initialize until the user accepts, or use a framework wrapper with `enabled: false`:\n\n```ts\nif (userAcceptedAnalytics) {\n init({ key: process.env.NEXT_PUBLIC_FLUSTERDUCK_KEY! })\n}\n```\n\nIf the SDK is already initialized, call `setConsent(false)` to flush the buffer and stop collection. Call `setConsent(true)` only when you intentionally want to reinitialize with the previous config.\n\n```ts\nsetConsent(true) // user accepted\nsetConsent(false) // user rejected or revoked\n```\n\n### User opt-out\n\n```ts\nimport { optOut } from 'flusterduck'\noptOut()\n```\n\nStops collection immediately, clears the session buffer. The SDK stays loaded but inactive for the rest of the session.\n\n## GDPR\n\nFlusterduck doesn't process personal data as defined under GDPR because it doesn't collect any. IP addresses are hashed at the edge before storage. No name, email, or identifier is collected unless you pass one to `identify()`.\n\nIf you use `identify()` with internal user IDs that trace back to real people, include Flusterduck behavioral data in your data processing records and honor deletion requests by contacting support with the IDs to purge.\n\n## What you put into track() and signal()\n\nThe `track()` and `signal()` methods accept arbitrary metadata. Don't put PII in them.\n\nSafe for `track()`: `plan_id`, `amount_cents`, `billing`, `currency`, `quantity`, `product_id`.\n\nSafe for `signal()`: CSS selectors, element labels, page section names, plan IDs, product IDs.\n\nNever pass: names, emails, phone numbers, addresses, order notes, or any text the user typed.\n\n## Subprocessors\n\nSupabase (database and edge functions), Cloudflare (MCP worker and CDN), Resend (transactional email), Stripe (billing). None receive your users' personal data from Flusterduck's data pipeline.\n\n## Data retention\n\nRaw events: 90 days. Aggregated scores and issue history: life of your account. On cancellation, all data deleted within 30 days.\n\n## What to tell your users\n\nThis is accurate for most privacy policies:\n\n> We use Flusterduck to detect usability issues on our site. Flusterduck collects anonymized behavioral signals (clicks, scroll patterns, navigation) but never records session replay, form values, or personal information.\n\nIf you're in a jurisdiction with specific disclosure requirements, check with your legal team.\n"
|
|
469
|
+
},
|
|
470
|
+
{
|
|
471
|
+
"slug": "consent",
|
|
472
|
+
"title": "Consent and GDPR",
|
|
473
|
+
"description": "Two ways to handle consent with Flusterduck and how the choice affects your compliance posture.",
|
|
474
|
+
"group": "Privacy & security",
|
|
475
|
+
"content": "# Consent and GDPR\n\nThere are two ways to handle consent with Flusterduck. They behave differently and the choice affects your compliance posture.\n\n**`setConsent(false)`** stops the active SDK session, clears the buffer, and removes the session cookie. When the user accepts, `setConsent(true)` reinitializes with the previous config if the SDK had already been configured.\n\n**`enabled: false`** (React/Next.js) skips initialization entirely. No SDK, no listeners, nothing. When the user accepts, you flip `enabled` to `true` and the SDK initializes fresh with no prior session data.\n\nIf your legal requirement is zero data collection before consent, use `enabled: false` or wait to call `init()` until consent is granted.\n\nWhen in doubt, `enabled: false` is the safer choice.\n\n## setConsent pattern\n\n```tsx\n// components/ConsentBanner.tsx\n'use client'\nimport { useEffect, useState } from 'react'\nimport { setConsent } from 'flusterduck'\n\nexport function ConsentBanner() {\n const [visible, setVisible] = useState(false)\n\n useEffect(() => {\n const stored = localStorage.getItem('fd_consent')\n if (stored === null) {\n setConsent(false) // pause before user decides\n setVisible(true)\n } else {\n setConsent(stored === 'true') // restore previous decision\n }\n }, [])\n\n const accept = () => {\n localStorage.setItem('fd_consent', 'true')\n setConsent(true)\n setVisible(false)\n }\n\n const decline = () => {\n localStorage.setItem('fd_consent', 'false')\n setConsent(false)\n setVisible(false)\n }\n\n if (!visible) return null\n\n return (\n <div>\n <p>We use behavioral analytics to improve this product.</p>\n <button onClick={accept}>Accept</button>\n <button onClick={decline}>Decline</button>\n </div>\n )\n}\n```\n\nCall `setConsent(false)` before showing the banner if the SDK may already be initialized. Collection stops at that point. Don't wait for the user to see the banner before stopping collection.\n\nConsent state doesn't survive page loads. Re-apply it from localStorage (or your consent management platform) on every mount.\n\n## enabled: false pattern\n\n```tsx\n// app/layout.tsx or src/main.tsx\nexport function Root() {\n const [consented, setConsented] = useState(() =>\n localStorage.getItem('fd_consent') === 'true'\n )\n\n return (\n <FlusterduckProvider\n apiKey={process.env.NEXT_PUBLIC_FLUSTERDUCK_KEY!}\n enabled={consented}\n >\n <App onConsentChange={setConsented} />\n </FlusterduckProvider>\n )\n}\n```\n\nWhen `enabled` flips to `true`, the SDK initializes for the first time. There's no prior session, no prior buffer. The session starts at consent.\n\nFor `useFlusterduck`:\n\n```tsx\n'use client'\nimport { useFlusterduck } from '@flusterduck/next'\n\nexport function Analytics({ consented }: { consented: boolean }) {\n useFlusterduck({\n key: process.env.NEXT_PUBLIC_FLUSTERDUCK_KEY!,\n enabled: consented,\n })\n return null\n}\n```\n\n## Cookieless mode\n\nBy default, Flusterduck uses a session cookie to maintain session continuity across page loads. In cookie-restricted environments, set `cookieless: true`:\n\n```ts\ninit({\n key: process.env.NEXT_PUBLIC_FLUSTERDUCK_KEY!,\n cookieless: true,\n})\n```\n\nCookieless mode uses a memory-only session ID. It avoids setting the Flusterduck session cookie, but a new session starts after a page load or tab restart. Signal collection works the same way.\n\nFor the strictest GDPR posture: use `enabled: false` before consent and `cookieless: true` after. No data collection of any kind before the user accepts, and no persistent identifiers after.\n\n## respectDoNotTrack\n\n```ts\ninit({\n key: process.env.NEXT_PUBLIC_FLUSTERDUCK_KEY!,\n respectDoNotTrack: true,\n})\n```\n\nWhen set, the SDK checks `navigator.doNotTrack` at initialization. If it's `\"1\"`, the SDK doesn't initialize. Equivalent to never calling `init()`.\n\nOff by default. Most legal frameworks don't require honoring DNT. It's a browser preference signal, not a legal standard in most jurisdictions. Turn it on if your privacy policy commits to it.\n\n## optOut()\n\n`optOut()` is for users who explicitly request no tracking, separate from the consent flow:\n\n```ts\nimport { optOut } from 'flusterduck'\n\n// In a \"stop tracking\" button in your account settings\noptOut()\n```\n\n`optOut()` stops collection immediately and clears the session. Keep your own preference state if the user has asked not to be tracked, and don't reinitialize the SDK while that preference is active.\n\nUse `optOut()` for explicit user requests (\"don't track me\"). Use `setConsent(false)` for consent banner declines, which can be reconsidered.\n\n## GDPR compliance checklist\n\n- Initialize with `enabled: false` or call `setConsent(false)` before consent is collected\n- Restore the user's previous consent decision from localStorage on every page load\n- Wire a \"manage preferences\" or \"withdraw consent\" option to `setConsent(false)` or `optOut()`\n- Never pass PII via `identify()`, `track()`, or `signal()` metadata\n- Use `cookieless: true` if you aren't separately collecting cookie consent\n- Pass `environment: \"development\"` in non-production environments so test sessions don't pollute production data\n\nSee [Privacy](./privacy) for the full privacy architecture and data handling details.\n"
|
|
476
|
+
},
|
|
477
|
+
{
|
|
478
|
+
"slug": "security",
|
|
479
|
+
"title": "Security",
|
|
480
|
+
"description": "Collection rules, data access boundaries, hashing, input limits, and the OWASP checklist.",
|
|
481
|
+
"group": "Privacy & security",
|
|
482
|
+
"content": "# Security\n\n## Collection rules\n\nFlusterDuck measures behavior, not private content.\n\n- No session replay.\n- No DOM recording.\n- No form values.\n- No text content.\n- No passwords or tokens.\n- No raw IP storage.\n- No direct browser access to analytics tables.\n\n## Data access\n\nBrowser code only sends telemetry to the ingest edge function with `fd_pub_` publishable keys. Reads and writes go through authenticated edge functions.\n\n- `ingest` accepts publishable site keys.\n- `query` accepts user JWTs, `fd_sec_` keys with `query:read`, or `fd_mcp_` keys with `mcp:read`.\n- `manage` accepts user JWTs only.\n- Clients do not query Supabase tables directly.\n\n## Hashing and keys\n\n- API keys are stored as HMAC-SHA256 hashes with `KEY_HASH_SALT`.\n- IP addresses are hashed at the edge with `IP_HASH_SALT` and truncated before storage.\n- Secret keys are compared with timing-safe comparison.\n- Webhooks use HMAC signatures with timestamp replay checks.\n\n## Input limits\n\n- Edge request bodies are limited to 64KB.\n- Gzip payloads are decompressed server-side and checked again after decompression.\n- User-controlled strings are stripped of dangerous characters and truncated.\n- UUIDs, trigger types, alert channels, thresholds, and config keys use allowlists.\n\n## Browser SDK hygiene\n\nUse attributes or stable selectors for context. Do not send private user data in metadata.\n\n```ts\nsignal('dead_click', {\n page: '/checkout',\n selector: '[data-action=\"continue\"]',\n})\n```\n\nNever do this:\n\n```ts\ntrack('checkout_note', {\n email: user.email,\n message: form.message,\n})\n```\n\n## OWASP checklist\n\n- Broken access control: every read route verifies org and site access.\n- Cryptographic failures: raw keys and raw IP addresses are not stored.\n- Injection: user input is sanitized and query params are validated before database access.\n- Security misconfiguration: Vercel apps ship security headers and no powered-by header.\n- Identification failures: middleware uses `getUser()` for protected routes.\n- Logging and monitoring: edge functions record degradation and delivery failures without PII.\n"
|
|
483
|
+
},
|
|
484
|
+
{
|
|
485
|
+
"slug": "pkg-sdk",
|
|
486
|
+
"title": "flusterduck",
|
|
487
|
+
"description": "The core browser SDK that detects friction signals.",
|
|
488
|
+
"group": "Packages",
|
|
489
|
+
"content": "# flusterduck\n\nThe browser SDK. It detects friction signals in the browser and sends them to Flusterduck. This is the core package every integration depends on.\n\n## Install\n\n```bash\nnpm install flusterduck\n```\n\n## Usage\n\n```ts\nimport { init, signal, track, identify, onSignal } from 'flusterduck'\n\ninit({\n publishableKey: 'fd_pub_xxxxxxxxxxxx',\n})\n\n// Identify the current session with safe, non-PII properties.\nidentify({ plan: 'scale', cohort: 'beta' })\n\n// Track a conversion event for revenue attribution.\ntrack('checkout_completed', { value: 49 })\n\n// React the instant a friction signal fires.\nonSignal((s) => {\n console.log('friction detected:', s.type)\n})\n\n// Emit a custom signal when you detect friction yourself.\nsignal('search_no_results', { query: 'pricing' })\n```\n\nUse `setConsent(true)` to gate collection behind consent, or `optOut()` to stop collection for the current session.\n\n## Links\n\nPublished on npm as `flusterduck`, version 0.5.2.\n"
|
|
490
|
+
},
|
|
491
|
+
{
|
|
492
|
+
"slug": "pkg-cli",
|
|
493
|
+
"title": "flusterduck-cli",
|
|
494
|
+
"description": "CLI that detects your framework, installs, and wires up init.",
|
|
495
|
+
"group": "Packages",
|
|
496
|
+
"content": "# flusterduck-cli\n\nThe command line installer. It detects your framework, installs the right packages, and injects the init call automatically.\n\n## Install\n\n```bash\nnpx flusterduck-cli init\n```\n\nNo global install is required. Run it from your project root with `npx`.\n\n## Usage\n\n```bash\n# Detect framework, install packages, inject init.\nnpx flusterduck-cli init\n\n# Provide your publishable key non-interactively.\nnpx flusterduck-cli init --key fd_pub_xxxxxxxxxxxx\n```\n\nThe CLI inspects your `package.json` and project files, picks the matching wrapper (React, Next.js, Vue, Svelte, Nuxt, or the core SDK), installs it with your package manager, and wires up initialization in the correct entry point.\n\n## Links\n\nPublished on npm as `flusterduck-cli`, version 0.5.2.\n"
|
|
497
|
+
},
|
|
498
|
+
{
|
|
499
|
+
"slug": "pkg-create",
|
|
500
|
+
"title": "create-flusterduck",
|
|
501
|
+
"description": "Scaffolder for a new Flusterduck-instrumented project.",
|
|
502
|
+
"group": "Packages",
|
|
503
|
+
"content": "# create-flusterduck\n\nThe project scaffolder. It creates a new app preconfigured with Flusterduck so signals flow from the first run.\n\n## Install\n\n```bash\nnpm create flusterduck@latest\n```\n\nYou can also invoke it directly with your package manager of choice:\n\n```bash\nnpm create flusterduck@latest my-app\npnpm create flusterduck my-app\nyarn create flusterduck my-app\n```\n\n## Usage\n\n```bash\n# Scaffold into a new directory and answer the prompts.\nnpm create flusterduck@latest my-app\n\ncd my-app\nnpm install\nnpm run dev\n```\n\nThe scaffolder asks for your framework and publishable key, generates the project, and includes the matching Flusterduck wrapper already initialized.\n\n## Links\n\nPublished on npm as `create-flusterduck`, version 0.5.2.\n"
|
|
504
|
+
},
|
|
505
|
+
{
|
|
506
|
+
"slug": "pkg-react",
|
|
507
|
+
"title": "@flusterduck/react",
|
|
508
|
+
"description": "React provider and useFlusterduck hook.",
|
|
509
|
+
"group": "Packages",
|
|
510
|
+
"content": "# @flusterduck/react\n\nThe React wrapper. Wrap your app root with `FlusterduckProvider` and signal detection starts across the whole tree. Read the SDK from any component with `useFlusterduck`.\n\n## Install\n\n```bash\nnpm install @flusterduck/react flusterduck\n```\n\n## Usage\n\n```tsx\n// main.tsx\nimport { FlusterduckProvider } from '@flusterduck/react'\nimport App from './App'\n\nexport function Root() {\n return (\n <FlusterduckProvider publishableKey=\"fd_pub_xxxxxxxxxxxx\">\n <App />\n </FlusterduckProvider>\n )\n}\n```\n\n```tsx\n// Any component.\nimport { useFlusterduck } from '@flusterduck/react'\n\nexport function CheckoutButton() {\n const { track } = useFlusterduck()\n return (\n <button onClick={() => track('checkout_completed', { value: 49 })}>\n Pay\n </button>\n )\n}\n```\n\n## Links\n\nPublished on npm as `@flusterduck/react`, version 0.5.2.\n"
|
|
511
|
+
},
|
|
512
|
+
{
|
|
513
|
+
"slug": "pkg-next",
|
|
514
|
+
"title": "@flusterduck/next",
|
|
515
|
+
"description": "Next.js script component and hook.",
|
|
516
|
+
"group": "Packages",
|
|
517
|
+
"content": "# @flusterduck/next\n\nThe Next.js wrapper. Add `FlusterduckScript` to your root layout and signal detection starts app-wide. Use `useFlusterduck` in client components for tracking, consent, and opt-out.\n\n## Install\n\n```bash\nnpm install @flusterduck/next flusterduck\n```\n\n## Usage\n\n```tsx\n// app/layout.tsx\nimport { FlusterduckScript } from '@flusterduck/next'\n\nexport default function RootLayout({ children }: { children: React.ReactNode }) {\n return (\n <html lang=\"en\">\n <body>\n <FlusterduckScript publishableKey=\"fd_pub_xxxxxxxxxxxx\" />\n {children}\n </body>\n </html>\n )\n}\n```\n\n```tsx\n// A client component.\n'use client'\nimport { useFlusterduck } from '@flusterduck/next'\n\nexport function ConsentToggle() {\n const { setConsent, optOut } = useFlusterduck()\n return (\n <>\n <button onClick={() => setConsent(true)}>Allow</button>\n <button onClick={() => optOut()}>Opt out</button>\n </>\n )\n}\n```\n\n## Links\n\nPublished on npm as `@flusterduck/next`, version 0.5.2.\n"
|
|
518
|
+
},
|
|
519
|
+
{
|
|
520
|
+
"slug": "pkg-vue",
|
|
521
|
+
"title": "@flusterduck/vue",
|
|
522
|
+
"description": "Vue plugin and composable.",
|
|
523
|
+
"group": "Packages",
|
|
524
|
+
"content": "# @flusterduck/vue\n\nThe Vue wrapper. Register the plugin once in your app entry and signal detection starts immediately. Access the SDK in any component with the `useFlusterduck` composable.\n\n## Install\n\n```bash\nnpm install @flusterduck/vue flusterduck\n```\n\n## Usage\n\n```ts\n// main.ts\nimport { createApp } from 'vue'\nimport { FlusterduckPlugin } from '@flusterduck/vue'\nimport App from './App.vue'\n\ncreateApp(App)\n .use(FlusterduckPlugin, { publishableKey: 'fd_pub_xxxxxxxxxxxx' })\n .mount('#app')\n```\n\n```vue\n<!-- Any component. -->\n<script setup lang=\"ts\">\nimport { useFlusterduck } from '@flusterduck/vue'\n\nconst { track } = useFlusterduck()\n</script>\n\n<template>\n <button @click=\"track('checkout_completed', { value: 49 })\">Pay</button>\n</template>\n```\n\n## Links\n\nPublished on npm as `@flusterduck/vue`, version 0.5.2.\n"
|
|
525
|
+
},
|
|
526
|
+
{
|
|
527
|
+
"slug": "pkg-svelte",
|
|
528
|
+
"title": "@flusterduck/svelte",
|
|
529
|
+
"description": "SvelteKit wrapper.",
|
|
530
|
+
"group": "Packages",
|
|
531
|
+
"content": "# @flusterduck/svelte\n\nThe SvelteKit wrapper. Initialize once in your root layout behind a browser guard, then import the helpers you need anywhere.\n\n## Install\n\n```bash\nnpm install @flusterduck/svelte flusterduck\n```\n\n## Usage\n\n```svelte\n<!-- src/routes/+layout.svelte -->\n<script lang=\"ts\">\n import { browser } from '$app/environment'\n import { initFlusterduck } from '@flusterduck/svelte'\n\n if (browser) {\n initFlusterduck({ publishableKey: 'fd_pub_xxxxxxxxxxxx' })\n }\n</script>\n\n<slot />\n```\n\n```ts\n// Any module.\nimport { track } from '@flusterduck/svelte'\n\ntrack('checkout_completed', { value: 49 })\n```\n\n## Links\n\nPublished on npm as `@flusterduck/svelte`, version 0.5.2.\n"
|
|
532
|
+
},
|
|
533
|
+
{
|
|
534
|
+
"slug": "pkg-nuxt",
|
|
535
|
+
"title": "@flusterduck/nuxt",
|
|
536
|
+
"description": "Nuxt module.",
|
|
537
|
+
"group": "Packages",
|
|
538
|
+
"content": "# @flusterduck/nuxt\n\nThe Nuxt module. Add it to your modules list and configure your publishable key. All functions are SSR-safe and only run in the browser.\n\n## Install\n\n```bash\nnpm install @flusterduck/nuxt flusterduck\n```\n\n## Usage\n\n```ts\n// nuxt.config.ts\nexport default defineNuxtConfig({\n modules: ['@flusterduck/nuxt'],\n flusterduck: {\n publishableKey: 'fd_pub_xxxxxxxxxxxx',\n },\n})\n```\n\n```vue\n<!-- Any component. -->\n<script setup lang=\"ts\">\nconst { track } = useFlusterduck()\n</script>\n\n<template>\n <button @click=\"track('checkout_completed', { value: 49 })\">Pay</button>\n</template>\n```\n\n## Links\n\nPublished on npm as `@flusterduck/nuxt`, version 0.5.2.\n"
|
|
539
|
+
},
|
|
540
|
+
{
|
|
541
|
+
"slug": "pkg-mcp-server",
|
|
542
|
+
"title": "@flusterduck/mcp-server",
|
|
543
|
+
"description": "Local stdio MCP server for AI assistants.",
|
|
544
|
+
"group": "Packages",
|
|
545
|
+
"content": '# @flusterduck/mcp-server\n\nThe local stdio MCP server. It connects an AI assistant to your Flusterduck data so it can read scores, issues, journeys, and revenue leakage on your behalf.\n\n## Install\n\n```bash\nnpx -y @flusterduck/mcp-server\n```\n\nNo install step is required. The `npx` command runs the server over stdio. The published binary is `flusterduck-mcp`.\n\n## Usage\n\nAdd it to your assistant\'s MCP configuration. The server authenticates with a scoped MCP key:\n\n```json\n{\n "mcpServers": {\n "flusterduck": {\n "command": "npx",\n "args": ["-y", "@flusterduck/mcp-server"],\n "env": {\n "FLUSTERDUCK_API_KEY": "fd_mcp_xxxxxxxxxxxx"\n }\n }\n }\n}\n```\n\nOnce connected, your assistant can query confusion scores, open issues, user journeys, and revenue impact through the server\'s tools.\n\n## Links\n\nPublished on npm as `@flusterduck/mcp-server`, version 0.5.2.\n'
|
|
546
|
+
},
|
|
547
|
+
{
|
|
548
|
+
"slug": "pkg-vite-plugin",
|
|
549
|
+
"title": "flusterduck-vite-plugin",
|
|
550
|
+
"description": "Vite plugin.",
|
|
551
|
+
"group": "Packages",
|
|
552
|
+
"content": "# flusterduck-vite-plugin\n\nThe Vite plugin. It handles deploy tagging and source map upload at build time so issue evidence resolves to your original source.\n\n## Install\n\n```bash\nnpm install -D flusterduck-vite-plugin\n```\n\n## Usage\n\n```ts\n// vite.config.ts\nimport { defineConfig } from 'vite'\nimport { flusterduck } from 'flusterduck-vite-plugin'\n\nexport default defineConfig({\n plugins: [\n flusterduck({\n secretKey: process.env.FLUSTERDUCK_SECRET_KEY,\n release: process.env.VITE_APP_VERSION,\n environment: 'production',\n }),\n ],\n})\n```\n\nThe plugin records a deploy when the build completes and uploads source maps so stack traces in issue evidence map back to original file names and line numbers. Keep `FLUSTERDUCK_SECRET_KEY` in your build environment, never in client code.\n\n## Links\n\nPublished on npm as `flusterduck-vite-plugin`, version 0.5.2.\n"
|
|
553
|
+
},
|
|
554
|
+
{
|
|
555
|
+
"slug": "pkg-webpack-plugin",
|
|
556
|
+
"title": "flusterduck-webpack-plugin",
|
|
557
|
+
"description": "webpack plugin.",
|
|
558
|
+
"group": "Packages",
|
|
559
|
+
"content": "# flusterduck-webpack-plugin\n\nThe webpack plugin. It handles deploy tagging and source map upload at build time so issue evidence resolves to your original source.\n\n## Install\n\n```bash\nnpm install -D flusterduck-webpack-plugin\n```\n\n## Usage\n\n```js\n// webpack.config.js\nconst { FlusterduckPlugin } = require('flusterduck-webpack-plugin')\n\nmodule.exports = {\n plugins: [\n new FlusterduckPlugin({\n secretKey: process.env.FLUSTERDUCK_SECRET_KEY,\n release: process.env.APP_VERSION,\n environment: 'production',\n }),\n ],\n}\n```\n\nThe plugin records a deploy when the build completes and uploads source maps so stack traces in issue evidence map back to original file names and line numbers. Keep `FLUSTERDUCK_SECRET_KEY` in your build environment, never in client code.\n\n## Links\n\nPublished on npm as `flusterduck-webpack-plugin`, version 0.5.2.\n"
|
|
560
|
+
},
|
|
561
|
+
{
|
|
562
|
+
"slug": "demo",
|
|
563
|
+
"title": "Local development",
|
|
564
|
+
"description": "Run the product stack locally with live query API wiring.",
|
|
565
|
+
"group": "Resources",
|
|
566
|
+
"content": "# Local development\n\nRun the product stack locally:\n\n```bash\npnpm --filter @flusterduck/web dev\npnpm --filter @flusterduck/docs dev\n```\n\n- Product site: `http://localhost:3000`\n- Docs: `http://localhost:3003`\n\n## Local API env\n\nSet server-only API keys in local server environments only. Never expose `fd_sec_` or `fd_mcp_` keys in client env vars.\n\n```bash\nFLUSTERDUCK_SITE_ID=your-site-uuid\nFLUSTERDUCK_SECRET_KEY=fd_sec_your_read_key\nSUPABASE_URL=https://rhwhnkrqjlzyzcdhvyky.supabase.co\n```\n\nWhen keys are set, local server tools can pull scores, trends, revenue, issues, and live signal data through the query API.\n\n## Verify the install\n\n1. Add the SDK to your app with a publishable key.\n2. Trigger friction on pricing and checkout flows.\n3. Query scores through the API or MCP tools and confirm metrics, journeys, revenue leakage, and notes update.\n"
|
|
567
|
+
}
|
|
568
|
+
];
|
|
569
|
+
|
|
570
|
+
// src/index.ts
|
|
571
|
+
function jsonText(data) {
|
|
572
|
+
return JSON.stringify(data, null, 2);
|
|
573
|
+
}
|
|
574
|
+
function toolResult(data) {
|
|
575
|
+
return { content: [{ type: "text", text: jsonText(data) }] };
|
|
576
|
+
}
|
|
577
|
+
function toolError(error) {
|
|
578
|
+
const message = error instanceof Error ? error.message : "Unknown Flusterduck MCP error.";
|
|
579
|
+
return { isError: true, content: [{ type: "text", text: message }] };
|
|
580
|
+
}
|
|
581
|
+
function markdownText(text) {
|
|
582
|
+
return { content: [{ type: "text", text }] };
|
|
583
|
+
}
|
|
584
|
+
function docExcerpt(content, query, length = 240) {
|
|
585
|
+
const haystack = content.toLowerCase();
|
|
586
|
+
const index = haystack.indexOf(query.toLowerCase());
|
|
587
|
+
const start = index >= 0 ? Math.max(0, index - 40) : 0;
|
|
588
|
+
const slice = content.slice(start, start + length).replace(/\s+/g, " ").trim();
|
|
589
|
+
return `${start > 0 ? "\u2026" : ""}${slice}${start + length < content.length ? "\u2026" : ""}`;
|
|
590
|
+
}
|
|
591
|
+
function createFlusterduckMCPServer(config) {
|
|
592
|
+
const api = new FlusterduckAPI(config);
|
|
593
|
+
const server = new McpServer({ name: "flusterduck-local", version: "0.3.0" });
|
|
594
|
+
const readOnly = { readOnlyHint: true, destructiveHint: false, openWorldHint: true };
|
|
595
|
+
const writeHint = { readOnlyHint: false, destructiveHint: false, openWorldHint: true };
|
|
596
|
+
const mutateHint = { readOnlyHint: false, destructiveHint: true, openWorldHint: true };
|
|
597
|
+
const deleteHint = { readOnlyHint: false, destructiveHint: true, openWorldHint: true };
|
|
598
|
+
const rawTable = z.enum(["events", "signals", "sessions", "page_scores", "score_history", "ux_issues", "alerts", "deploys"]);
|
|
599
|
+
const sortDir = z.enum(["asc", "desc"]).optional();
|
|
600
|
+
const rawFilters = {
|
|
601
|
+
page: z.string().min(1).max(500).optional(),
|
|
602
|
+
since: z.string().datetime().optional(),
|
|
603
|
+
until: z.string().datetime().optional(),
|
|
604
|
+
event_type: z.string().min(1).max(80).optional(),
|
|
605
|
+
signal_type: z.string().min(1).max(80).optional(),
|
|
606
|
+
status: z.string().min(1).max(80).optional(),
|
|
607
|
+
session_id: z.string().min(16).max(128).optional()
|
|
608
|
+
};
|
|
609
|
+
server.tool("get_site_context", "Get the full Flusterduck MCP snapshot for the configured site: scores, open issues, active alerts, deploys, and recommendations. Start here for any investigation.", {}, readOnly, async () => {
|
|
610
|
+
try {
|
|
611
|
+
return toolResult(await api.getContext());
|
|
612
|
+
} catch (error) {
|
|
613
|
+
return toolError(error);
|
|
614
|
+
}
|
|
615
|
+
});
|
|
616
|
+
server.tool("get_scores", "Get current confusion scores for every tracked page, sorted by friction intensity.", {}, readOnly, async () => {
|
|
617
|
+
try {
|
|
618
|
+
return toolResult(await api.getScores());
|
|
619
|
+
} catch (error) {
|
|
620
|
+
return toolError(error);
|
|
621
|
+
}
|
|
622
|
+
});
|
|
623
|
+
server.tool("get_page", "Get the full context for one page: score history, active issues, element friction, deploys, annotations, and confusion budgets.", {
|
|
624
|
+
page: z.string().min(1).max(500)
|
|
625
|
+
}, readOnly, async ({ page }) => {
|
|
626
|
+
try {
|
|
627
|
+
return toolResult(await api.getPageDetail(page));
|
|
628
|
+
} catch (error) {
|
|
629
|
+
return toolError(error);
|
|
630
|
+
}
|
|
631
|
+
});
|
|
632
|
+
server.tool("get_elements", "Get element-level friction summaries: which buttons, forms, and links generate the most behavioral signals.", {
|
|
633
|
+
page: z.string().min(1).max(500).optional()
|
|
634
|
+
}, readOnly, async ({ page }) => {
|
|
635
|
+
try {
|
|
636
|
+
return toolResult(await api.getElements(page));
|
|
637
|
+
} catch (error) {
|
|
638
|
+
return toolError(error);
|
|
639
|
+
}
|
|
640
|
+
});
|
|
641
|
+
server.tool("get_issues", "Get UX issues for the configured site. Filter by status to focus on what needs attention.", {
|
|
642
|
+
status: z.enum(["open", "triaged", "in_progress", "ignored", "resolved", "regressed"]).optional()
|
|
643
|
+
}, readOnly, async ({ status }) => {
|
|
644
|
+
try {
|
|
645
|
+
return toolResult(await api.getIssues(status));
|
|
646
|
+
} catch (error) {
|
|
647
|
+
return toolError(error);
|
|
648
|
+
}
|
|
649
|
+
});
|
|
650
|
+
server.tool("get_issue", "Get a single UX issue with full evidence, session links, verification history, and deploy correlation. Use this after get_issues to drill into a specific issue.", {
|
|
651
|
+
issue_id: z.string().uuid()
|
|
652
|
+
}, readOnly, async ({ issue_id }) => {
|
|
653
|
+
try {
|
|
654
|
+
return toolResult(await api.getIssueDetail(issue_id));
|
|
655
|
+
} catch (error) {
|
|
656
|
+
return toolError(error);
|
|
657
|
+
}
|
|
658
|
+
});
|
|
659
|
+
server.tool("get_alerts", "Get alerts for the configured site. Filter by status to see what is firing vs already resolved.", {
|
|
660
|
+
status: z.enum(["open", "acknowledged", "resolved"]).optional()
|
|
661
|
+
}, readOnly, async ({ status }) => {
|
|
662
|
+
try {
|
|
663
|
+
return toolResult(await api.getAlerts(status));
|
|
664
|
+
} catch (error) {
|
|
665
|
+
return toolError(error);
|
|
666
|
+
}
|
|
667
|
+
});
|
|
668
|
+
server.tool(
|
|
669
|
+
"list_alert_rules",
|
|
670
|
+
"List all alert rules configured for the site: trigger types, thresholds, channels, and enabled status. Call this before create_alert_rule or delete_alert_rule to see what already exists. Requires manage:write scope on the API key.",
|
|
671
|
+
{},
|
|
672
|
+
readOnly,
|
|
673
|
+
async () => {
|
|
674
|
+
try {
|
|
675
|
+
return toolResult(await api.getAlertRules());
|
|
676
|
+
} catch (error) {
|
|
677
|
+
return toolError(error);
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
);
|
|
681
|
+
server.tool("get_session_detail", "Get the full event timeline for a specific session: every signal, click, scroll, and page view in order.", {
|
|
682
|
+
session_id: z.string().min(16).max(128)
|
|
683
|
+
}, readOnly, async ({ session_id }) => {
|
|
684
|
+
try {
|
|
685
|
+
return toolResult(await api.getSessionDetail(session_id));
|
|
686
|
+
} catch (error) {
|
|
687
|
+
return toolError(error);
|
|
688
|
+
}
|
|
689
|
+
});
|
|
690
|
+
server.tool("get_flows", "Get page-to-page flow edges from recent sessions, showing where users come from and where they go.", {
|
|
691
|
+
limit: z.number().int().min(1).max(5e3).optional()
|
|
692
|
+
}, readOnly, async ({ limit }) => {
|
|
693
|
+
try {
|
|
694
|
+
return toolResult(await api.getFlows(limit));
|
|
695
|
+
} catch (error) {
|
|
696
|
+
return toolError(error);
|
|
697
|
+
}
|
|
698
|
+
});
|
|
699
|
+
server.tool("get_trends", "Get confusion score history for the configured site or a specific page. Use to spot regressions after deploys.", {
|
|
700
|
+
days: z.number().int().min(1).max(90).optional(),
|
|
701
|
+
page: z.string().min(1).max(500).optional()
|
|
702
|
+
}, readOnly, async ({ days, page }) => {
|
|
703
|
+
try {
|
|
704
|
+
return toolResult(await api.getTrends(days, page));
|
|
705
|
+
} catch (error) {
|
|
706
|
+
return toolError(error);
|
|
707
|
+
}
|
|
708
|
+
});
|
|
709
|
+
server.tool("get_deploys", "Get deploys known to Flusterduck for this site, including confusion_before/after scores for each.", {}, readOnly, async () => {
|
|
710
|
+
try {
|
|
711
|
+
return toolResult(await api.getDeployImpact());
|
|
712
|
+
} catch (error) {
|
|
713
|
+
return toolError(error);
|
|
714
|
+
}
|
|
715
|
+
});
|
|
716
|
+
server.tool("compare_pages", "Compare confusion score records side-by-side for two page paths.", {
|
|
717
|
+
a: z.string().min(1).max(500),
|
|
718
|
+
b: z.string().min(1).max(500)
|
|
719
|
+
}, readOnly, async ({ a, b }) => {
|
|
720
|
+
try {
|
|
721
|
+
return toolResult(await api.compare(a, b));
|
|
722
|
+
} catch (error) {
|
|
723
|
+
return toolError(error);
|
|
724
|
+
}
|
|
725
|
+
});
|
|
726
|
+
server.tool("get_recommendations", "Get prioritized fix recommendations ranked by estimated confusion reduction.", {}, readOnly, async () => {
|
|
727
|
+
try {
|
|
728
|
+
return toolResult(await api.getRecommendations());
|
|
729
|
+
} catch (error) {
|
|
730
|
+
return toolError(error);
|
|
731
|
+
}
|
|
732
|
+
});
|
|
733
|
+
server.tool("get_revenue_impact", "Get revenue impact estimates for active friction when conversion data is connected.", {}, readOnly, async () => {
|
|
734
|
+
try {
|
|
735
|
+
return toolResult(await api.getRevenueImpact());
|
|
736
|
+
} catch (error) {
|
|
737
|
+
return toolError(error);
|
|
738
|
+
}
|
|
739
|
+
});
|
|
740
|
+
server.tool("get_heuristics", "Get the full Flusterduck friction heuristic catalog: all signal types, thresholds, and scoring weights.", {}, readOnly, async () => {
|
|
741
|
+
try {
|
|
742
|
+
return toolResult(await api.getHeuristics());
|
|
743
|
+
} catch (error) {
|
|
744
|
+
return toolError(error);
|
|
745
|
+
}
|
|
746
|
+
});
|
|
747
|
+
server.tool("diagnose_journey_friction", "Find high-friction journey edges from recent sessions. Filter by signal type or minimum friction weight to focus on the worst paths.", {
|
|
748
|
+
journey_id: z.string().uuid().optional(),
|
|
749
|
+
signal_type: z.string().min(1).max(80).optional(),
|
|
750
|
+
min_friction_weight: z.number().int().min(0).max(1e3).optional(),
|
|
751
|
+
limit: z.number().int().min(1).max(1e3).optional()
|
|
752
|
+
}, readOnly, async ({ journey_id, signal_type, min_friction_weight, limit }) => {
|
|
753
|
+
try {
|
|
754
|
+
return toolResult(await api.getJourneyFriction({ journey_id, signal_type, min_friction_weight, limit }));
|
|
755
|
+
} catch (error) {
|
|
756
|
+
return toolError(error);
|
|
757
|
+
}
|
|
758
|
+
});
|
|
759
|
+
server.tool("query_raw_rows", "Get SQL-style raw rows from an allowlisted Flusterduck table, scoped to the configured site.", {
|
|
760
|
+
table: rawTable,
|
|
761
|
+
limit: z.number().int().min(1).max(1e3).optional(),
|
|
762
|
+
sort_by: z.string().min(1).max(80).optional(),
|
|
763
|
+
sort_dir: sortDir,
|
|
764
|
+
...rawFilters
|
|
765
|
+
}, readOnly, async ({ table, limit, sort_by, sort_dir, page, since, until, event_type, signal_type, status, session_id }) => {
|
|
766
|
+
try {
|
|
767
|
+
return toolResult(await api.queryRawRows({ table, limit, sort_by, sort_dir, page, since, until, event_type, signal_type, status, session_id }));
|
|
768
|
+
} catch (error) {
|
|
769
|
+
return toolError(error);
|
|
770
|
+
}
|
|
771
|
+
});
|
|
772
|
+
server.tool("download_events_csv", "Return a CSV export of raw event rows for the configured site. Save the returned text as a .csv file.", {
|
|
773
|
+
limit: z.number().int().min(1).max(1e4).optional(),
|
|
774
|
+
sort_by: z.string().min(1).max(80).optional(),
|
|
775
|
+
sort_dir: sortDir,
|
|
776
|
+
page: z.string().min(1).max(500).optional(),
|
|
777
|
+
since: z.string().datetime().optional(),
|
|
778
|
+
until: z.string().datetime().optional(),
|
|
779
|
+
event_type: z.string().min(1).max(80).optional(),
|
|
780
|
+
signal_type: z.string().min(1).max(80).optional(),
|
|
781
|
+
session_id: z.string().min(16).max(128).optional()
|
|
782
|
+
}, readOnly, async ({ limit, sort_by, sort_dir, page, since, until, event_type, signal_type, session_id }) => {
|
|
783
|
+
try {
|
|
784
|
+
return { content: [{ type: "text", text: await api.downloadEventsCsv({ limit, sort_by, sort_dir, page, since, until, event_type, signal_type, session_id }) }] };
|
|
785
|
+
} catch (error) {
|
|
786
|
+
return toolError(error);
|
|
787
|
+
}
|
|
788
|
+
});
|
|
789
|
+
server.tool("get_audit_log", "Get organization audit log rows. Requires orgId in local MCP config.", {
|
|
790
|
+
limit: z.number().int().min(1).max(500).optional()
|
|
791
|
+
}, readOnly, async ({ limit }) => {
|
|
792
|
+
try {
|
|
793
|
+
return toolResult(await api.getAuditLog(limit));
|
|
794
|
+
} catch (error) {
|
|
795
|
+
return toolError(error);
|
|
796
|
+
}
|
|
797
|
+
});
|
|
798
|
+
server.tool("get_degradation", "Get active and recent backend degradation events. Requires orgId in local MCP config.", {}, readOnly, async () => {
|
|
799
|
+
try {
|
|
800
|
+
return toolResult(await api.getDegradation());
|
|
801
|
+
} catch (error) {
|
|
802
|
+
return toolError(error);
|
|
803
|
+
}
|
|
804
|
+
});
|
|
805
|
+
server.tool("get_webhook_deliveries", "Get outbound webhook delivery attempts and failure details. Requires orgId in local MCP config.", {
|
|
806
|
+
limit: z.number().int().min(1).max(500).optional()
|
|
807
|
+
}, readOnly, async ({ limit }) => {
|
|
808
|
+
try {
|
|
809
|
+
return toolResult(await api.getWebhookDeliveries(limit));
|
|
810
|
+
} catch (error) {
|
|
811
|
+
return toolError(error);
|
|
812
|
+
}
|
|
813
|
+
});
|
|
814
|
+
server.tool(
|
|
815
|
+
"update_issue",
|
|
816
|
+
"Update a UX issue: change its status (open/triaged/in_progress/verified/resolved/ignored), add a triage note, or set an assignee. Resolving or ignoring removes the issue from active triage views. Requires manage:write scope on the API key.",
|
|
817
|
+
{
|
|
818
|
+
issue_id: z.string().uuid(),
|
|
819
|
+
status: z.enum(["open", "triaged", "in_progress", "verified", "resolved", "ignored"]).optional(),
|
|
820
|
+
note: z.string().max(1e3).nullable().optional(),
|
|
821
|
+
assigned_to: z.string().max(256).nullable().optional()
|
|
822
|
+
},
|
|
823
|
+
mutateHint,
|
|
824
|
+
async ({ issue_id, status, note, assigned_to }) => {
|
|
825
|
+
try {
|
|
826
|
+
return toolResult(await api.updateIssue(issue_id, { status, note, assigned_to }));
|
|
827
|
+
} catch (error) {
|
|
828
|
+
return toolError(error);
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
);
|
|
832
|
+
server.tool(
|
|
833
|
+
"update_alert",
|
|
834
|
+
"Acknowledge, mark as investigating, or resolve an alert. Resolving stops all channel delivery including PagerDuty. Optionally include a resolved_reason when closing. Requires manage:write scope on the API key.",
|
|
835
|
+
{
|
|
836
|
+
alert_id: z.string().uuid(),
|
|
837
|
+
status: z.enum(["acknowledged", "investigating", "resolved"]),
|
|
838
|
+
resolved_reason: z.string().max(500).optional()
|
|
839
|
+
},
|
|
840
|
+
mutateHint,
|
|
841
|
+
async ({ alert_id, status, resolved_reason }) => {
|
|
842
|
+
try {
|
|
843
|
+
return toolResult(await api.updateAlert(alert_id, status, resolved_reason));
|
|
844
|
+
} catch (error) {
|
|
845
|
+
return toolError(error);
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
);
|
|
849
|
+
server.tool(
|
|
850
|
+
"add_annotation",
|
|
851
|
+
"Add a timeline annotation to the site to mark deploys, investigation findings, resolved incidents, or any notable event for the team. Requires manage:write scope on the API key.",
|
|
852
|
+
{
|
|
853
|
+
message: z.string().min(1).max(1e3)
|
|
854
|
+
},
|
|
855
|
+
writeHint,
|
|
856
|
+
async ({ message }) => {
|
|
857
|
+
try {
|
|
858
|
+
return toolResult(await api.addAnnotation(message));
|
|
859
|
+
} catch (error) {
|
|
860
|
+
return toolError(error);
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
);
|
|
864
|
+
server.tool(
|
|
865
|
+
"create_alert_rule",
|
|
866
|
+
"Create a new alert rule. Trigger types: spike, anomaly, new_page, trend, co_occurrence, positive, budget. Channels: email, slack, webhook, mcp, pagerduty. Requires manage:write scope on the API key.",
|
|
867
|
+
{
|
|
868
|
+
name: z.string().min(1).max(120),
|
|
869
|
+
trigger_type: z.enum(["spike", "anomaly", "new_page", "trend", "co_occurrence", "positive", "budget"]),
|
|
870
|
+
threshold: z.number().min(0).max(1e3).optional(),
|
|
871
|
+
cooldown_minutes: z.number().int().min(1).max(1440).optional(),
|
|
872
|
+
channels: z.array(z.enum(["email", "slack", "webhook", "mcp", "pagerduty"])).optional(),
|
|
873
|
+
page_pattern: z.string().max(2048).nullable().optional(),
|
|
874
|
+
enabled: z.boolean().optional()
|
|
875
|
+
},
|
|
876
|
+
writeHint,
|
|
877
|
+
async ({ name, trigger_type, threshold, cooldown_minutes, channels, page_pattern, enabled }) => {
|
|
878
|
+
try {
|
|
879
|
+
return toolResult(await api.createAlertRule({ name, trigger_type, threshold, cooldown_minutes, channels, page_pattern, enabled }));
|
|
880
|
+
} catch (error) {
|
|
881
|
+
return toolError(error);
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
);
|
|
885
|
+
server.tool(
|
|
886
|
+
"update_alert_rule",
|
|
887
|
+
"Update an existing alert rule: toggle enabled, change threshold, adjust channels, rename, or update page_pattern. Prefer this over delete_alert_rule when you want to temporarily silence a rule. Requires manage:write scope on the API key.",
|
|
888
|
+
{
|
|
889
|
+
rule_id: z.string().uuid(),
|
|
890
|
+
name: z.string().min(1).max(120).optional(),
|
|
891
|
+
trigger_type: z.enum(["spike", "anomaly", "new_page", "trend", "co_occurrence", "positive", "budget"]).optional(),
|
|
892
|
+
threshold: z.number().min(0).max(1e3).optional(),
|
|
893
|
+
cooldown_minutes: z.number().int().min(1).max(1440).optional(),
|
|
894
|
+
channels: z.array(z.enum(["email", "slack", "webhook", "mcp", "pagerduty"])).optional(),
|
|
895
|
+
page_pattern: z.string().max(2048).nullable().optional(),
|
|
896
|
+
enabled: z.boolean().optional()
|
|
897
|
+
},
|
|
898
|
+
writeHint,
|
|
899
|
+
async ({ rule_id, name, trigger_type, threshold, cooldown_minutes, channels, page_pattern, enabled }) => {
|
|
900
|
+
try {
|
|
901
|
+
return toolResult(await api.updateAlertRule(rule_id, { name, trigger_type, threshold, cooldown_minutes, channels, page_pattern, enabled }));
|
|
902
|
+
} catch (error) {
|
|
903
|
+
return toolError(error);
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
);
|
|
907
|
+
server.tool(
|
|
908
|
+
"delete_alert_rule",
|
|
909
|
+
"Permanently delete an alert rule by ID. This cannot be undone. Requires manage:write scope on the API key.",
|
|
910
|
+
{
|
|
911
|
+
rule_id: z.string().uuid()
|
|
912
|
+
},
|
|
913
|
+
deleteHint,
|
|
914
|
+
async ({ rule_id }) => {
|
|
915
|
+
try {
|
|
916
|
+
return toolResult(await api.deleteAlertRule(rule_id));
|
|
917
|
+
} catch (error) {
|
|
918
|
+
return toolError(error);
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
);
|
|
922
|
+
server.tool("list_docs", "List every Flusterduck documentation page with its slug, title, and group. Use this to discover what docs are available, then call get_doc with a slug to read one.", {}, readOnly, async () => {
|
|
923
|
+
return toolResult(DOCS.map(({ slug, title, group }) => ({ slug, title, group })));
|
|
924
|
+
});
|
|
925
|
+
server.tool("search_docs", "Search the bundled Flusterduck documentation by keyword. Matches against title, description, and full content, and returns a short excerpt for each hit. Use get_doc to read the full page.", {
|
|
926
|
+
query: z.string().min(1).max(200)
|
|
927
|
+
}, readOnly, async ({ query }) => {
|
|
928
|
+
const needle = query.toLowerCase();
|
|
929
|
+
const matches = DOCS.filter(
|
|
930
|
+
(doc) => doc.title.toLowerCase().includes(needle) || doc.description.toLowerCase().includes(needle) || doc.content.toLowerCase().includes(needle)
|
|
931
|
+
).map((doc) => ({
|
|
932
|
+
slug: doc.slug,
|
|
933
|
+
title: doc.title,
|
|
934
|
+
group: doc.group,
|
|
935
|
+
excerpt: docExcerpt(doc.content, query)
|
|
936
|
+
}));
|
|
937
|
+
return toolResult({ query, count: matches.length, results: matches });
|
|
938
|
+
});
|
|
939
|
+
server.tool("get_doc", "Get the full markdown of one Flusterduck documentation page by slug. Call list_docs or search_docs first to find the slug.", {
|
|
940
|
+
slug: z.string().min(1).max(120)
|
|
941
|
+
}, readOnly, async ({ slug }) => {
|
|
942
|
+
const doc = DOCS.find((d) => d.slug === slug);
|
|
943
|
+
if (!doc) {
|
|
944
|
+
const available = DOCS.map((d) => d.slug).join(", ");
|
|
945
|
+
return toolError(new Error(`Unknown doc slug "${slug}". Available slugs: ${available}`));
|
|
946
|
+
}
|
|
947
|
+
return markdownText(doc.content);
|
|
948
|
+
});
|
|
949
|
+
for (const doc of DOCS) {
|
|
950
|
+
server.resource(
|
|
951
|
+
doc.title,
|
|
952
|
+
`flusterduck-docs:///${doc.slug}`,
|
|
953
|
+
{ description: doc.description, mimeType: "text/markdown", title: doc.title },
|
|
954
|
+
async (uri) => ({
|
|
955
|
+
contents: [{ uri: uri.href, mimeType: "text/markdown", text: doc.content }]
|
|
956
|
+
})
|
|
957
|
+
);
|
|
958
|
+
}
|
|
959
|
+
server.resource("current_site_context", "flusterduck://site/context", { description: "Current Flusterduck site context" }, async (uri) => ({
|
|
960
|
+
contents: [{ uri: uri.href, mimeType: "application/json", text: jsonText(await api.getContext()) }]
|
|
961
|
+
}));
|
|
962
|
+
server.resource("page", new ResourceTemplate("flusterduck://page/{path}", { list: void 0 }), { description: "Detailed page context" }, async (uri, variables) => ({
|
|
963
|
+
contents: [{ uri: uri.href, mimeType: "application/json", text: jsonText(await api.getPageDetail(String(variables.path))) }]
|
|
964
|
+
}));
|
|
965
|
+
server.prompt(
|
|
966
|
+
"diagnose_page",
|
|
967
|
+
"Step-by-step diagnosis of UX friction on a specific page: signals, elements, trends, and a concrete fix recommendation.",
|
|
968
|
+
{ page_path: z.string().min(1).max(500) },
|
|
969
|
+
({ page_path }) => ({
|
|
970
|
+
messages: [{
|
|
971
|
+
role: "user",
|
|
972
|
+
content: {
|
|
973
|
+
type: "text",
|
|
974
|
+
text: `Diagnose the UX friction on "${page_path}" using Flusterduck.
|
|
975
|
+
|
|
976
|
+
Step 1: Call get_page with page="${page_path}" to get score, trend, active issues, elements, and deploys.
|
|
977
|
+
Step 2: Call get_elements with page="${page_path}" to see which specific elements have the highest friction signal counts.
|
|
978
|
+
Step 3: Call diagnose_journey_friction to find the highest-friction flow edges across the site. Look for edges where the source or destination path matches "${page_path}" to identify where users struggle entering and exiting this page.
|
|
979
|
+
Step 4: Review the score trend. Is this page getting worse, stable, or recovering? How fast?
|
|
980
|
+
Step 5: Check recent deploys. Did confusion spike after a specific release?
|
|
981
|
+
|
|
982
|
+
Deliver a diagnosis with:
|
|
983
|
+
- The three worst friction sources on this page (signal type + element label + count)
|
|
984
|
+
- Whether the friction is acute (appeared recently) or chronic (long-standing)
|
|
985
|
+
- One specific, actionable fix recommendation with estimated confusion reduction
|
|
986
|
+
- Whether any open issues should be triaged or escalated based on the evidence
|
|
987
|
+
|
|
988
|
+
Be concrete. Reference actual signal names, element labels, and numbers from the data.`
|
|
989
|
+
}
|
|
990
|
+
}]
|
|
991
|
+
})
|
|
992
|
+
);
|
|
993
|
+
server.prompt(
|
|
994
|
+
"triage_open_issues",
|
|
995
|
+
"Systematically triage all open UX issues: classify severity, update the highest-confidence ones, and leave an annotation summarizing what was done.",
|
|
996
|
+
{},
|
|
997
|
+
() => ({
|
|
998
|
+
messages: [{
|
|
999
|
+
role: "user",
|
|
1000
|
+
content: {
|
|
1001
|
+
type: "text",
|
|
1002
|
+
text: `Triage all open UX issues for this site.
|
|
1003
|
+
|
|
1004
|
+
Step 1: Call get_issues with status="open" to get the full list.
|
|
1005
|
+
Step 2: For the top 5 issues by signal count or confusion weight, call get_issue on each to get full evidence and session context.
|
|
1006
|
+
Step 3: For each reviewed issue, assess:
|
|
1007
|
+
- Severity: high (score impact > 15), medium (5-15), low (< 5)
|
|
1008
|
+
- Root cause: which signal type and element dominates
|
|
1009
|
+
- Confidence: do you have enough evidence to act?
|
|
1010
|
+
|
|
1011
|
+
Step 4: Use update_issue to transition issues where you're confident:
|
|
1012
|
+
- Noise or edge case with low evidence \u2192 status: "ignored", note: your reasoning
|
|
1013
|
+
- Reproducible with clear root cause \u2192 status: "triaged", note: specific reproduction steps and affected element
|
|
1014
|
+
- Acute regression affecting a critical flow \u2192 status: "in_progress", note: urgency rationale
|
|
1015
|
+
|
|
1016
|
+
Step 5: Call add_annotation to record what was done, e.g. "Agent triage: 2 issues moved to triaged, 1 ignored (noise), 2 left open pending more data."
|
|
1017
|
+
|
|
1018
|
+
Report: N issues reviewed, X triaged, Y ignored, Z escalated to in_progress, W left open. One sentence per transitioned issue.`
|
|
1019
|
+
}
|
|
1020
|
+
}]
|
|
1021
|
+
})
|
|
1022
|
+
);
|
|
1023
|
+
server.prompt(
|
|
1024
|
+
"post_deploy_check",
|
|
1025
|
+
"Check for UX confusion regression after the most recent deploy. Returns a go/no-go verdict with specific pages and signals affected.",
|
|
1026
|
+
{},
|
|
1027
|
+
() => ({
|
|
1028
|
+
messages: [{
|
|
1029
|
+
role: "user",
|
|
1030
|
+
content: {
|
|
1031
|
+
type: "text",
|
|
1032
|
+
text: `Run a post-deploy UX check for this site.
|
|
1033
|
+
|
|
1034
|
+
Step 1: Call get_deploys to find the most recent deploy. Note the commit SHA, deploy time, and confusion_before/confusion_after scores.
|
|
1035
|
+
Step 2: Call get_trends with days=3 to see score movement in the 72-hour window around the deploy.
|
|
1036
|
+
Step 3: Call get_issues with status="regressed" to find auto-detected regressions.
|
|
1037
|
+
Step 4: For any page where confusion_after > confusion_before by more than 10 points, call get_page for the full signal breakdown.
|
|
1038
|
+
|
|
1039
|
+
Evaluate:
|
|
1040
|
+
- Did overall site confusion increase after this deploy?
|
|
1041
|
+
- Which pages regressed, and by how many points?
|
|
1042
|
+
- What specific signals are driving the regression (rage clicks, dead clicks, form friction)?
|
|
1043
|
+
- Were any new UX issues created since the deploy?
|
|
1044
|
+
|
|
1045
|
+
Deliver a deploy health report:
|
|
1046
|
+
- Deploy: [SHA/label], deployed at [time]
|
|
1047
|
+
- Verdict: PASS / WARN / FAIL
|
|
1048
|
+
- Score delta: [before] \u2192 [after] per affected page
|
|
1049
|
+
- Root signal: the dominant friction type if WARN/FAIL
|
|
1050
|
+
- Recommendation: ship as-is / monitor closely / consider rollback
|
|
1051
|
+
|
|
1052
|
+
If FAIL (> 20 point regression on a critical page), call add_annotation with "Post-deploy check FAILED: [one-line summary of what broke and which page]".`
|
|
1053
|
+
}
|
|
1054
|
+
}]
|
|
1055
|
+
})
|
|
1056
|
+
);
|
|
1057
|
+
server.prompt(
|
|
1058
|
+
"investigate_session",
|
|
1059
|
+
"Investigate one session in full: event timeline, dominant signals, matching issues, and whether it represents a recurring pattern.",
|
|
1060
|
+
{ session_id: z.string().min(16).max(128) },
|
|
1061
|
+
({ session_id }) => ({
|
|
1062
|
+
messages: [{
|
|
1063
|
+
role: "user",
|
|
1064
|
+
content: {
|
|
1065
|
+
type: "text",
|
|
1066
|
+
text: `Investigate session "${session_id}" in Flusterduck.
|
|
1067
|
+
|
|
1068
|
+
Step 1: Call get_session_detail with session_id="${session_id}" to get the full event timeline.
|
|
1069
|
+
Step 2: Identify the dominant friction signals in this session (rage clicks, dead clicks, form abandonment, scroll thrash). Note which elements and pages generated them.
|
|
1070
|
+
Step 3: For the page with the most friction signals, call get_page to get its score, open issues, and element breakdown.
|
|
1071
|
+
Step 4: Call get_issues with status="open" to find any existing issues whose affected elements or pages match this session.
|
|
1072
|
+
|
|
1073
|
+
Deliver:
|
|
1074
|
+
- Session narrative: what this user tried to do, where they struggled, and how the session ended (2-3 sentences)
|
|
1075
|
+
- Dominant friction: signal type, element label, page path, and count
|
|
1076
|
+
- Issue match: does this session match an open issue? If yes, cite it by title. If no match, describe what a new issue would cover.
|
|
1077
|
+
- Representativeness: is this session isolated or part of a recurring pattern? Reference the open issue signal count or page score trend to support your answer.
|
|
1078
|
+
|
|
1079
|
+
Reference specific element labels, signal names, and counts from the data.`
|
|
1080
|
+
}
|
|
1081
|
+
}]
|
|
1082
|
+
})
|
|
1083
|
+
);
|
|
1084
|
+
server.prompt(
|
|
1085
|
+
"weekly_summary",
|
|
1086
|
+
"Generate the weekly UX friction digest: score trends, active issues, open alerts, and prioritized recommendations. Ready to share with a PM or eng lead.",
|
|
1087
|
+
{},
|
|
1088
|
+
() => ({
|
|
1089
|
+
messages: [{
|
|
1090
|
+
role: "user",
|
|
1091
|
+
content: {
|
|
1092
|
+
type: "text",
|
|
1093
|
+
text: `Generate the weekly UX friction summary for this site.
|
|
1094
|
+
|
|
1095
|
+
Step 1: Call get_site_context for the high-level overview.
|
|
1096
|
+
Step 2: Call get_trends with days=7 to see this week's score trajectory.
|
|
1097
|
+
Step 3: Call get_issues with status="open", then status="regressed" to get the full active problem set.
|
|
1098
|
+
Step 4: Call get_alerts with status="open" to find alerts still requiring attention.
|
|
1099
|
+
Step 5: Call get_recommendations for prioritized action items ranked by confusion impact.
|
|
1100
|
+
Step 6: Call get_revenue_impact to attach dollar estimates if conversion data is connected.
|
|
1101
|
+
|
|
1102
|
+
Format as a weekly digest:
|
|
1103
|
+
|
|
1104
|
+
## Weekly UX Friction Summary: [site name]
|
|
1105
|
+
|
|
1106
|
+
**Executive summary** (2-3 sentences: are things improving or degrading? What's the headline?)
|
|
1107
|
+
|
|
1108
|
+
**Score trend this week**
|
|
1109
|
+
- Site-wide confusion: [score] ([+/-X] vs last week), improving / stable / degrading
|
|
1110
|
+
- Notable pages: [page] +X, [page] -Y
|
|
1111
|
+
|
|
1112
|
+
**Active issues** (top 3 by impact)
|
|
1113
|
+
1. [Issue title] | [severity] | [page] | open [N] days | [one-line root cause]
|
|
1114
|
+
2. ...
|
|
1115
|
+
|
|
1116
|
+
**Alerts needing attention** (if any)
|
|
1117
|
+
|
|
1118
|
+
**Top 3 recommended fixes**
|
|
1119
|
+
1. [Specific fix], estimated [X]-point confusion reduction on [page]
|
|
1120
|
+
2. ...
|
|
1121
|
+
|
|
1122
|
+
**Revenue at risk**: $[amount] based on [conversion metric] (if available)
|
|
1123
|
+
|
|
1124
|
+
Keep it under 300 words. This should be readable in 90 seconds by someone who hasn't looked at Flusterduck all week.`
|
|
1125
|
+
}
|
|
1126
|
+
}]
|
|
1127
|
+
})
|
|
1128
|
+
);
|
|
1129
|
+
return server;
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
export {
|
|
1133
|
+
FlusterduckAPI,
|
|
1134
|
+
createFlusterduckMCPServer
|
|
1135
|
+
};
|