Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
ed8ca11
Merge pull request #400 from slaveofcode/develop
slaveofcode Sep 23, 2026
eb7ec47
docs(report): design spec for opt-in tool error reporting
slaveofcode Sep 26, 2026
a7d5c24
docs(report): MVP implementation plan (Sub-project A)
slaveofcode Sep 26, 2026
bab1b96
feat(report): shared types + file magic-byte sniffer
slaveofcode Sep 26, 2026
f095d77
test(report): minimize jsdom File/FileReader bridge in test-setup
slaveofcode Sep 26, 2026
7982fdd
feat(report): breadcrumb ring buffer + safe serialize + global capture
slaveofcode Sep 26, 2026
85d8d52
feat(report): diagnostics collector + build SHA injection
slaveofcode Sep 26, 2026
68dbe70
fix(report): guard window/navigator in diagnostics capabilities()
slaveofcode Sep 26, 2026
1c69a3a
feat(report): reporter bus (context, lastError, open events)
slaveofcode Sep 26, 2026
4e8c848
feat(report): report form builder + client submit (with dev E2E stub)
slaveofcode Sep 26, 2026
56bc08b
fix(test): mock FormData to accept Node Blobs in jsdom
slaveofcode Sep 26, 2026
062a152
feat(report): useReportable hook
slaveofcode Sep 26, 2026
9a8f698
fix(test): correct jsdom FormData shim (multi-value, filename, iterat…
slaveofcode Sep 26, 2026
bc6303c
feat(report): /api/report worker endpoint + R2 binding
slaveofcode Sep 26, 2026
c3e01d5
fix(report): harden /api/report (server-gen key, field type guards)
slaveofcode Sep 26, 2026
de89ed8
feat(report): ReportDialog island (consent, Turnstile, thank-you)
slaveofcode Sep 26, 2026
1ce8d0f
fix(report): reset Turnstile widget/token on dialog re-open
slaveofcode Sep 26, 2026
8db8133
feat(report): ReportButton on every tool page + crash-report + global…
slaveofcode Sep 26, 2026
c26af27
feat(report): wire image-compress to the reporter (file + error capture)
slaveofcode Sep 26, 2026
ada4625
test(report): e2e happy path for the report dialog
slaveofcode Sep 26, 2026
9364f84
fix(report): sanitize R2 file ext, precheck body size, reconcile repo…
slaveofcode Sep 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions astro.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,20 @@ import react from '@astrojs/react';
import tailwind from '@astrojs/tailwind';
import AstroPWA from '@vite-pwa/astro';
import sitemap from '@astrojs/sitemap';
import { execSync } from 'node:child_process';

// --- Build SHA ----------------------------------------------------------------
// Short commit SHA identifying the deployed build, surfaced in tool error
// reports so "works on my machine" issues can be tied to a specific build.
// Prefers an explicitly-set env var, then Cloudflare's own commit env, then
// falls back to `git rev-parse` — guarded so a missing git (or shallow clone
// without .git) never breaks the build.
const BUILD_SHA = (() => {
if (process.env.PUBLIC_BUILD_SHA) return process.env.PUBLIC_BUILD_SHA;
if (process.env.CF_PAGES_COMMIT_SHA) return process.env.CF_PAGES_COMMIT_SHA.slice(0, 7);
try { return execSync('git rev-parse --short HEAD').toString().trim(); } catch { return 'dev'; }
})();
process.env.PUBLIC_BUILD_SHA = BUILD_SHA;

// --- Deploy-context gating ---------------------------------------------------
// Production and staging share one Cloudflare Worker, so we can't tell them apart
Expand Down
1,461 changes: 1,461 additions & 0 deletions docs/superpowers/plans/2026-09-26-tool-error-reporting-mvp.md

Large diffs are not rendered by default.

238 changes: 238 additions & 0 deletions docs/superpowers/specs/2026-09-26-tool-error-reporting-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,238 @@
# Tool Error Reporting — Design Spec

**Date:** 2026-09-26
**Status:** Approved (design); pending spec review before planning
**Type:** Architectural (cross-cutting subsystem + new Worker endpoint + new R2 bucket)

## Problem

When a tool fails for a real user, we currently have no way to learn about it. The
trigger case: a user tried the **image-compress** tool with a `.jpeg` and got an
error instead of a result — and we only heard about it by word of mouth, with no
environment info, no logs, and no failing file. Most tool failures surface as a
handled error (`catch (e) { setError(…) }` → `<Alert variant="error">`), a few as
uncaught render/hydration crashes (caught by `ToolErrorBoundary`). Neither is
reported anywhere.

We want an **opt-in, consent-gated problem reporter** available on every tool page
that, on any failure, lets a willing user send a rich diagnostic report — browser,
device, capabilities, a full log/breadcrumb trail, the caught error, and
(optionally) the exact failing file — so we can reproduce and fix it. It must
specifically close the **"works on my machine"** gap by capturing enough
environment + log detail to explain failures we can't reproduce locally.

## Goals

- A "Report a problem" entry point on **every** tool page (one `ToolHost` change).
- Capture **both** failure classes: handled operational errors and uncaught crashes,
plus global `window` errors / unhandled rejections.
- A **comprehensive diagnostics + log** payload that closes "works on my machine".
- The **exact failing file** auto-attached for wired hot tools; manual attach for the rest.
- Strong **privacy posture**: nothing sent without explicit consent; the file is a
separate, extra-warned, opt-in attachment; reports are private, retention-limited.
- Abuse-resistant public endpoint (Turnstile + size cap + rate limit).
- A warm **thank-you** confirmation after submit.
- We (the maintainer) can **triage** reports: private R2 + a token-gated admin viewer + optional webhook ping.

## Non-goals

- No auto-upload of anything without an explicit click + consent.
- No scanning/scrubbing of file **contents** for PII — we **warn** instead.
- No public GitHub issues (repo is public; reports can contain PII/files).
- No third-party analytics or error-tracking SaaS (privacy; keep it same-origin).
- Not retrofitting all ~192 tools' handled-error paths in one sweep (see Rollout).

## Decisions (from brainstorming)

1. **Destination:** Worker ingest → **private R2**. File attachment optional-on-consent.
2. **Coverage:** Entry point on every page + global/crash/breadcrumb capture everywhere;
auto file+error capture only for **wired** hot tools (image-compress first, then
redact, OCR, receipt, converters). New tools must wire in (enforced by gwt-add-tool).
3. **Triage:** R2 + token-gated in-app **admin viewer** + optional webhook ping
(webhook URL is a Cloudflare secret, never committed).
4. **Abuse protection:** Cloudflare **Turnstile** + file size cap (**10 MB**) + per-IP rate limit.
5. **Retention (default, open to change at spec review):** auto-delete reports after **30 days**.
6. **Rollout (default):** ship **Sub-project A (MVP)** first; **Sub-project B** (admin viewer,
webhook, retention lifecycle, remaining wiring, enforcement) follows.

> Open for spec review: (a) retention window (30 days?), (b) whether the admin viewer
> must be in the first release or can follow in Sub-project B.

## Architecture & data flow

```
Tool island ──(breadcrumbs + registers current File via useReportable)──▶ reporter bus (client)
│ handled error (setError) ┐
ToolErrorBoundary crash ─────────┤► openReportDialog(prefill)
window.onerror/unhandledrejection┘
ReportDialog ──multipart POST──▶ Worker /api/report
├─ verify Turnstile token
├─ enforce size cap + per-IP rate limit (Durable Object counter)
├─ write reports/<yyyy>/<mm>/<id>/report.json (+ file.<ext>)
└─ optional webhook ping (REPORT_WEBHOOK_URL secret)
Maintainer ──token──▶ /admin/reports (noindex) ──▶ /api/admin/reports (bearer) ──▶ list/read/delete R2
```

New infra:
- **R2 bucket** `goodwebtools-reports` (+ `goodwebtools-reports-staging`), binding `REPORTS`.
- **Secrets** (Cloudflare, never in repo): `TURNSTILE_SECRET`, `ADMIN_REPORT_TOKEN`,
optional `REPORT_WEBHOOK_URL`.
- **Public build var** `PUBLIC_TURNSTILE_SITE_KEY` (public; fine to commit reference), and a
new **`PUBLIC_BUILD_SHA`** injected at build for the diagnostics `build` field.
- Everything else reuses the existing `worker/index.js` prefix-dispatch pattern
(precedent: `/api/llm-proxy` POST ingest, `/models/*` R2 serving).

## Components

### Client services (SSR-safe; client-only init, all `typeof window` guarded)

- **`src/services/report/breadcrumbs.ts`** — bounded ring buffer (cap ~200 entries).
Captures `console.warn`/`console.error` (patched, message length-capped), `window`
`error` and `unhandledrejection`, and exposes `breadcrumb(action: string, data?: Record<string, unknown>)`
for structured step logging. Stores **metadata only**, never file bytes. Pure,
bounded, unit-tested for eviction + size cap.
- **`src/services/report/diagnostics.ts`** — `collectDiagnostics(): Diagnostics`
(schema below). Reads `navigator`/`window`/`screen` behind guards; each probe
wrapped so one failure can't blank the whole payload.
- **`src/services/report/fileMeta.ts`** — `sniffFileMeta(file: File): Promise<FileMeta>`:
claimed MIME + **actual format via magic bytes** (JPEG/PNG/GIF/WEBP/HEIC/PDF/…),
size, lastModified; for images, attempts `createImageBitmap` and records
decode success + dimensions. Pure logic + table-driven tests (covers the
`.jpeg`-that-is-actually-HEIC/CMYK/corrupt cases).
- **`src/services/report/reporter.ts`** — the bus. Holds current tool context
`{ toolId, getFile?: () => File | null, extra?: Record<string, unknown> }`,
`setLastError(err)`, and `openReportDialog(prefill?)` (drives a lightweight store the
dialog subscribes to). Lets the report UI (rendered in `ToolHost`, outside the island)
fetch the exact current `File`.
- **`src/services/report/submit.ts`** — `buildReportForm()` (assembles multipart:
`report.json` + optional file + turnstile token) and `submitReport()` (POST, returns
`{ id }` or a typed error). No secrets client-side.

### Client hooks / UI

- **`src/hooks/useReportable.ts`** — one-line opt-in for a tool island:
`useReportable({ toolId, file, extra })` registers the current `File` getter + extra
context and clears on unmount. Wired tools call it; also route caught errors through
`reporter.setLastError(e)` (or a shared `<ErrorAlert onReport>`).
- **`src/islands/report/ReportButton.tsx`** — the persistent "Report a problem"
affordance. **Rendered by `ToolHost` on every tool page.**
- **`src/islands/report/ReportDialog.tsx`** — the modal: optional "What were you doing?"
textarea; collapsible **"See exactly what will be sent"** (renders diagnostics JSON);
**consent checkbox** (send diagnostics + logs); a **separate, pre-unchecked "Attach my
file"** with a sensitivity warning; invisible Turnstile widget; Submit; and the
**thank-you** success state.
- **`src/islands/ToolHost.tsx`** — (1) render `<ReportButton>` under every tool;
(2) the `ToolErrorBoundary` fallback gets a prominent "Report this crash" button that
calls `openReportDialog({ error })`; (3) install the global `error`/`unhandledrejection`
listeners once (feeding breadcrumbs + `setLastError`).

### Worker (`worker/index.js`)

- **`POST /api/report`** (multipart/form-data):
1. Verify Turnstile token against `TURNSTILE_SECRET` (Cloudflare siteverify).
2. Enforce `file ≤ 10 MB` + a total-body cap; **per-IP rate limit** via a small
Durable Object counter (reuse the DO pattern already used by `SignalRoom`).
3. Write `reports/<yyyy>/<mm>/<reportId>/report.json` and, if attached,
`…/file.<ext>` to `REPORTS`. Never log bytes (mirrors the llm-proxy no-log rule).
4. Optional `REPORT_WEBHOOK_URL` ping (title/toolId/error summary — no PII/file).
5. Return `{ id: reportId }`.
The request handler is factored as a **pure `handleReport(request, env)`** so it can be
unit-tested with mocked `env` (Turnstile fetch + R2 `put`).
- **`GET/DELETE /api/admin/reports`** (Sub-project B): bearer `ADMIN_REPORT_TOKEN`;
list (R2 `list` with prefix + pagination), read one, delete one.

### Admin viewer (Sub-project B)

- **`/admin/reports`** — a `noindex`, non-registry Astro page. Prompts for the admin
token (stored in `localStorage`, never shipped in the build). Lists reports (newest
first), opens a report's diagnostics JSON, downloads the attached file, and
deletes / marks resolved. Talks only to `/api/admin/reports`.

## Diagnostics schema (the "works on my machine" killer)

`Diagnostics` (all fields best-effort; a failed probe is omitted, never fatal):

- **app**: `reportId`, `timestamp`, `build` (`PUBLIC_BUILD_SHA`), `toolId`, `route`, `locale`.
- **browser/os**: `userAgent`, `userAgentData` (platform, mobile, full version list),
`deviceMemory`, `hardwareConcurrency`, `languages`, `timezone`.
- **display**: `screen` (w/h), `devicePixelRatio`, `viewport` (w/h), `orientation`,
`colorScheme`, `prefersReducedMotion`.
- **capabilities**: `wasm`, `wasmSimd`, `wasmThreads`/`crossOriginIsolated`
(`SharedArrayBuffer`), `offscreenCanvas`, `webgl`, `webgl2`, `webgpu`, `webCodecs`,
`createImageBitmap`, `storage` (localStorage + IndexedDB availability → private-mode
signal), `connection` (effectiveType, downlink, saveData), `online`.
- **error**: `name`, `message`, trimmed `stack`, `causeChain[]`.
- **file** (present whenever a `File` is in context, **even if not attached**): from
`sniffFileMeta` — `claimedType`, `actualFormat` (magic bytes), `size`, `lastModified`,
and for images `decodeOk` + `width`/`height`.
- **logs**: `breadcrumbs[]` (timestamped actions + captured console) and
`consoleErrors[]` since load.
- **user**: optional free-text `message`.

> The **file** block alone frequently explains image failures (e.g. a `.jpeg` that is
> actually HEIC/CMYK/corrupt, or a browser lacking a decoder) **without uploading the file**.

## Consent, privacy & thank-you

- **Two-tier consent**: (1) always-shown consent to send diagnostics + logs;
(2) a **separate, pre-unchecked** "Attach my file" with a plain-language warning that
the file may contain personal/financial data.
- **Transparency**: collapsible "See exactly what will be sent" renders the diagnostics JSON.
- **Consent copy** states plainly: reports are private, never shared or sold, used only
to fix the error, and auto-deleted after the retention window.
- **Thank-you state** on success: warm, appreciative — e.g. *"Thank you — your report
helps us fix this for everyone hitting the same error."* — plus the report id.
- Reflect the reporter in the **Privacy page** copy (EN + ID): what's collected, when,
that it's opt-in, and retention.

## Security & abuse

- Turnstile gate + size cap + per-IP rate limit on the public endpoint.
- Private bucket; admin bearer token; all secrets via Cloudflare (never committed —
honors the repo identity rules).
- File **bytes never logged**; retention auto-purge (lifecycle rule).
- Magic-byte sniffing is **client-side** (diagnostics), so the Worker never parses file
contents — no server-side file-parsing attack surface. The Worker only streams bytes to R2.

## Testing

- **Unit (Vitest, `src/**`)**: breadcrumb ring buffer (eviction, size cap, redaction);
`collectDiagnostics` (mock `navigator`/`window`/`screen`); `sniffFileMeta`
(table-driven magic-byte cases incl. JPEG/HEIC/CMYK/corrupt); reporter bus
(register/getFile/setLastError/open); `buildReportForm`; and a **pure
`handleReport(request, env)`** with mocked Turnstile + R2.
- **E2E (Playwright, `e2e/`)**: on a tool page, trigger an error → open the dialog →
submit against a **dev-only stubbed endpoint** (`?e2e` hook, `import.meta.env.DEV`,
stripped from prod) → assert the thank-you state; plus a global-crash path via
`ToolErrorBoundary`. Wired-tool case: image-compress auto-attaches the file.
- **Manual**: real submit to staging R2; confirm object layout + retention rule + admin read.

## Rollout (YAGNI — staged, each PR shippable)

- **Sub-project A (MVP — this spec's first plan)**: breadcrumbs + diagnostics + fileMeta
+ reporter bus + `ReportButton`/`ReportDialog` + `ToolHost` integration +
`POST /api/report` (Turnstile + caps + R2) + consent + thank-you + **wire
image-compress first**, then redact/OCR/receipt/converters. Reports inspected via
`wrangler` initially.
- **Sub-project B**: admin viewer + `/api/admin/reports` + webhook ping + R2 retention
lifecycle + wire remaining hot tools + gwt-add-tool enforcement + Privacy-page copy.

## File inventory (new/changed)

New: `src/services/report/{breadcrumbs,diagnostics,fileMeta,reporter,submit}.ts` (+ tests),
`src/hooks/useReportable.ts`, `src/islands/report/{ReportButton,ReportDialog}.tsx`,
`e2e/tools/report.spec.ts` (+ generic fixture), `docs/superpowers/specs/2026-09-26-tool-error-reporting-design.md`.
Changed: `worker/index.js` (+ `/api/report`, later `/api/admin/reports`),
`wrangler.jsonc` (+ `REPORTS` R2 binding, prod + staging), `astro.config.mjs`
(inject `PUBLIC_BUILD_SHA`), `src/islands/ToolHost.tsx`, `src/islands/image/*` (wire
image-compress first), the Privacy page (EN + ID, Sub-project B).

## Success criteria

- From a tool error, a consenting user can submit a report in a few clicks; the
thank-you state confirms it.
- A text-only report (no file) already carries enough environment + log detail to
diagnose a "works on my machine" failure — demonstrated on the image-compress case.
- Nothing leaves the device without explicit consent; the file is a distinct opt-in.
- The public endpoint resists bot spam (Turnstile) and oversized/abusive uploads.
- Reports are retrievable and triageable by the maintainer; auto-purged after retention.
40 changes: 40 additions & 0 deletions e2e/tools/report.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { test, expect } from '@playwright/test';

// The reporter uses a DEV-only stub (window.__E2E_REPORT__) so submit bypasses
// the network + Turnstile. The Report button is rendered by ToolHost on every page.
test('opens the report dialog, consents, submits, and shows the thank-you', async ({ page }) => {
await page.addInitScript(() => { (window as unknown as { __E2E_REPORT__?: unknown }).__E2E_REPORT__ = { id: 'e2e-123' }; });
await page.goto('/tools/image-compress');
await page.waitForLoadState('networkidle').catch(() => {});

await page.getByRole('button', { name: 'Report a problem' }).click();
await expect(page.getByRole('dialog')).toBeVisible();

// Transparency: the diagnostics JSON is viewable and names the tool.
await page.getByText('See exactly what will be sent').click();
await expect(page.getByTestId('report-diag')).toContainText('image-compress');

// Submitting without consent is blocked.
await page.getByRole('button', { name: 'Send report' }).click();
await expect(page.getByText('Please tick the consent box to send.')).toBeVisible();

// Consent → submit → thank-you.
await page.getByTestId('report-consent').check();
await page.getByRole('button', { name: 'Send report' }).click();
await expect(page.getByTestId('report-thanks')).toBeVisible();
await expect(page.getByTestId('report-thanks')).toContainText('e2e-123');
});

// Failure case: a rejected submit shows an error and never reaches the thank-you.
test('a failed submit shows an error, not the thank-you', async ({ page }) => {
await page.addInitScript(() => { (window as unknown as { __E2E_REPORT__?: unknown }).__E2E_REPORT__ = { fail: true }; });
await page.goto('/tools/image-compress');
await page.waitForLoadState('networkidle').catch(() => {});

await page.getByRole('button', { name: 'Report a problem' }).click();
await page.getByTestId('report-consent').check();
await page.getByRole('button', { name: 'Send report' }).click();

await expect(page.getByText('Sorry — the report could not be sent. Please try again.')).toBeVisible();
await expect(page.getByTestId('report-thanks')).toHaveCount(0);
});
8 changes: 8 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,11 @@ export const GA_ALLOWED_HOSTS = ['goodwebtools.com', 'www.goodwebtools.com'];
*/
export const NOINDEX =
import.meta.env.PUBLIC_NOINDEX === '1' || import.meta.env.PUBLIC_NOINDEX === 'true';

/** Short git SHA of the deployed build, injected at build time (see astro.config.mjs).
* Falls back to 'dev' for local/dev where it isn't set. Public, non-secret. */
export const BUILD_SHA = import.meta.env.PUBLIC_BUILD_SHA || 'dev';

/** Cloudflare Turnstile PUBLIC site key (not a secret). Defaults to Cloudflare's
* always-pass test key so self-host/staging work before a real key is set. */
export const TURNSTILE_SITE_KEY = import.meta.env.PUBLIC_TURNSTILE_SITE_KEY || '1x00000000000000000000AA';
19 changes: 19 additions & 0 deletions src/hooks/useReportable.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { describe, it, expect } from 'vitest';
import { renderHook } from '@testing-library/react';
import { useReportable } from './useReportable';
import { getCurrentFile } from '@/services/report/reporter';

describe('useReportable', () => {
it('registers the current file and updates on change', () => {
const a = new File(['a'], 'a.png', { type: 'image/png' });
const b = new File(['b'], 'b.png', { type: 'image/png' });
const { rerender, unmount } = renderHook(({ f }) => useReportable({ toolId: 'image-compress', file: f }), {
initialProps: { f: a as File | null },
});
expect(getCurrentFile()).toBe(a);
rerender({ f: b });
expect(getCurrentFile()).toBe(b);
unmount();
expect(getCurrentFile()).toBeNull();
});
});
16 changes: 16 additions & 0 deletions src/hooks/useReportable.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { useEffect, useRef } from 'react';
import { setContext, clearContext } from '@/services/report/reporter';

/** Register a tool's current input file + context with the reporter bus, so the
* global Report dialog can attach the exact failing file. Clears on unmount. */
export function useReportable(opts: { toolId: string; file?: File | null; extra?: Record<string, unknown> }): void {
const fileRef = useRef<File | null>(opts.file ?? null);
fileRef.current = opts.file ?? null;

useEffect(() => {
setContext({ toolId: opts.toolId, getFile: () => fileRef.current, extra: opts.extra });
return () => clearContext(opts.toolId);
// Re-register only if the toolId changes; the file is read live via the ref.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [opts.toolId]);
}
Loading
Loading