From 00577058460885d69c7c20921be71efe07154877 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 09:55:31 +0000 Subject: [PATCH 1/2] =?UTF-8?q?feat(admin):=20/admin/scans=20=E2=80=94=20m?= =?UTF-8?q?etadata-only=20scan=20feed=20with=20errorClass=20filter?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the /admin/scans item from KNOWN_ISSUES 0b. The PII design decision the item was blocked on: metadata only. Photos were never stored (analyze writes imageUrl: null by design), rows show truncated userIds and item counts — no emails, no food-name lists. Read-only, same philosophy as /admin/users. To make the page useful for its stated purpose (spotting AI-pipeline regressions), /api/analyze now persists a failure row at the single 503 funnel with errorClass derived from the failure (timeout / parse_error / provider_error / binding_missing) — the errorClass column existed since the first schema cut but had zero writers, so failures were only ever visible in the ephemeral CF log stream. Best-effort insert: a DB hiccup can never mask the 503 the user is receiving. https://claude.ai/code/session_01RaziyjCxHgKcqvTqss7bXS --- frontend/src/app/[locale]/admin/layout.tsx | 1 + .../src/app/[locale]/admin/scans/page.tsx | 185 ++++++++++++++++++ frontend/src/app/api/analyze/route.ts | 31 +++ 3 files changed, 217 insertions(+) create mode 100644 frontend/src/app/[locale]/admin/scans/page.tsx diff --git a/frontend/src/app/[locale]/admin/layout.tsx b/frontend/src/app/[locale]/admin/layout.tsx index 01551b6..2d8c5b7 100644 --- a/frontend/src/app/[locale]/admin/layout.tsx +++ b/frontend/src/app/[locale]/admin/layout.tsx @@ -27,6 +27,7 @@ export const dynamic = 'force-dynamic'; const NAV = [ { href: '', label: 'Overview' }, { href: '/users', label: 'Users' }, + { href: '/scans', label: 'Scans' }, { href: '/promo', label: 'Promo codes' }, { href: '/health', label: 'Health' }, ]; diff --git a/frontend/src/app/[locale]/admin/scans/page.tsx b/frontend/src/app/[locale]/admin/scans/page.tsx new file mode 100644 index 0000000..1eafbef --- /dev/null +++ b/frontend/src/app/[locale]/admin/scans/page.tsx @@ -0,0 +1,185 @@ +/** + * /admin/scans — recent scans across all users (KNOWN_ISSUES 0b). + * + * Purpose: spot AI-pipeline regressions from the product side — a spike + * of `timeout` / `parse_error` rows, or every scan suddenly landing on + * the Gemini fallback instead of the CF primary, is visible here in one + * glance without Cloudflare log access. + * + * PII posture (the design decision 0b asked for): METADATA ONLY. + * - Photos were never stored (`/api/analyze` writes imageUrl: null by + * design — no R2 integration), so there is nothing visual to leak. + * - No email column. Rows show a truncated userId; cross-reference in + * /admin/users if an investigation truly needs the account. Food + * names (detectedItems) stay summarised as a count, not listed. + * + * Read-only: no actions, no mutations — same "read-mostly" philosophy + * as /admin/users, with zero rows at risk. + */ +import { desc, eq } from 'drizzle-orm'; +import { requireAdmin } from '@/lib/admin-auth'; +import { getEnvSafe } from '@/lib/cloudflare'; +import { getDb } from '@/db'; +import { foodScans } from '@/db/schema'; + +export const dynamic = 'force-dynamic'; + +const PAGE_SIZE = 50; + +type ScanRow = { + id: string; + userId: string | null; + detectedItems: unknown; + scoreOverall: number | null; + modelUsed: string | null; + scanMode: string | null; + errorClass: string | null; + createdAt: Date | null; +}; + +async function loadScans(offset: number, errorClass?: string): Promise { + let env; + try { + env = await getEnvSafe(); + } catch { + return []; + } + const db = getDb(env); + const base = db + .select({ + id: foodScans.id, + userId: foodScans.userId, + detectedItems: foodScans.detectedItems, + scoreOverall: foodScans.scoreOverall, + modelUsed: foodScans.modelUsed, + scanMode: foodScans.scanMode, + errorClass: foodScans.errorClass, + createdAt: foodScans.createdAt, + }) + .from(foodScans); + const filtered = errorClass ? base.where(eq(foodScans.errorClass, errorClass)) : base; + const rows = await filtered + .orderBy(desc(foodScans.createdAt)) + .limit(PAGE_SIZE + 1) + .offset(offset); + return rows as ScanRow[]; +} + +function formatWhen(d: Date | null): string { + if (!d) return '—'; + return d.toISOString().slice(0, 16).replace('T', ' '); +} + +function itemCount(detectedItems: unknown): number { + return Array.isArray(detectedItems) ? detectedItems.length : 0; +} + +const ERROR_FILTERS = ['timeout', 'parse_error', 'provider_error', 'binding_missing'] as const; + +export default async function AdminScansPage({ + params: { locale }, + searchParams, +}: { + params: { locale: string }; + searchParams?: { page?: string; errorClass?: string }; +}) { + await requireAdmin(locale); + + const page = Math.max(1, Number(searchParams?.page) || 1); + const offset = (page - 1) * PAGE_SIZE; + const errorClass = searchParams?.errorClass || undefined; + + const rows = await loadScans(offset, errorClass); + const hasNext = rows.length > PAGE_SIZE; + const visible = rows.slice(0, PAGE_SIZE); + + const base = `/${locale}/admin/scans`; + + return ( +
+
+

Scans

+

+ Page {page} · {visible.length} shown{errorClass ? ` · filter: ${errorClass}` : ''} +

+
+ + {/* errorClass filter chips */} +
+ + all + + {ERROR_FILTERS.map((ec) => ( + + {ec} + + ))} +
+ +
+ + + + + + + + + + + + + + {visible.length === 0 && ( + + + + )} + {visible.map((s) => ( + + + + + + + + + + ))} + +
When (UTC)UserModeItemsScoreModelStatus
+ No scans on this page. +
{formatWhen(s.createdAt)}{s.userId ? s.userId.substring(0, 8) : 'anon'}{s.scanMode ?? '—'}{itemCount(s.detectedItems)}{s.errorClass ? '—' : `${Math.round(s.scoreOverall ?? 0)}/100`}{s.modelUsed ?? '—'} + {s.errorClass ? ( + {s.errorClass} + ) : ( + ok + )} +
+
+ + {/* Pagination */} +
+ {page > 1 ? ( + + ← Newer + + ) : ( + + )} + {hasNext && ( + + Older → + + )} +
+
+ ); +} diff --git a/frontend/src/app/api/analyze/route.ts b/frontend/src/app/api/analyze/route.ts index b264ac1..6dfab0c 100644 --- a/frontend/src/app/api/analyze/route.ts +++ b/frontend/src/app/api/analyze/route.ts @@ -588,6 +588,37 @@ export async function POST(req: NextRequest) { durationMs: aiDurationMs }); + // Persist a metadata-only failure row so /admin/scans can + // surface AI-pipeline regressions (KNOWN_ISSUES 0b). The + // errorClass column existed since the schema's first cut but + // nothing ever wrote it — failures were only visible in the + // ephemeral CF log stream. Best-effort: a DB hiccup here must + // never mask the real 503 the user is about to receive. + try { + if (db) { + const msg = String(aiError.message ?? ''); + const errorClass = + msg === 'AI_BINDING_MISSING' ? 'binding_missing' + : /abort|timeout|timed out/i.test(msg) ? 'timeout' + : /json|parse/i.test(msg) ? 'parse_error' + : 'provider_error'; + await db.insert(foodScans).values({ + id: generateId(), + userId: activeUser?.id ?? null, + imageUrl: null, + detectedItems: [], + nutritionSummary: {}, + scoreOverall: null, + modelUsed: null, + scanMode, + errorClass, + createdAt: new Date(), + }); + } + } catch (persistErr: any) { + logger.scanApiStage('FAILURE_ROW_PERSIST_ERROR', { requestId, error: persistErr.message }); + } + if (aiError.message === 'AI_BINDING_MISSING') { return jsonResponse({ error: 'AI not available', From f48d82a2f46cfdfe3938fe034d216710dc4c089f Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 09:55:31 +0000 Subject: [PATCH 2/2] fix(logger): handled scan timeouts log at warn, not error MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit KNOWN_ISSUES 'benign console-error noise' pattern 2: the scan UI fully absorbs cascade timeouts (renders the retry card), but the logger reported them via console.error, polluting every console-error assertion — the e2e suites carried a dedicated filter entry just for this line. Timeout-category telemetry now emits at warn; genuinely unhandled categories stay at error. KNOWN_ISSUES updated for both Round-15 items. https://claude.ai/code/session_01RaziyjCxHgKcqvTqss7bXS --- docs/KNOWN_ISSUES.md | 6 +++--- frontend/src/lib/logger.ts | 10 +++++++++- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/docs/KNOWN_ISSUES.md b/docs/KNOWN_ISSUES.md index 97c00b2..4bb8b52 100644 --- a/docs/KNOWN_ISSUES.md +++ b/docs/KNOWN_ISSUES.md @@ -53,10 +53,10 @@ This document lists currently identified bugs, limitations, and ongoing technica ### 5. Known-benign console-error noise (documented, filtered in e2e) - **Current Status**: three console-error patterns appear in production that are *not* user-facing failures, and the e2e suites deliberately filter them (`user-journey.spec.ts → isBenignConsoleError()`): 1. **Next.js RSC-prefetch fallback** — "Failed to fetch RSC payload … Falling back to browser navigation". A `` prefetch races navigation (most visible under parallel e2e load); the router falls back to a normal browser navigation and the page renders fine. Cosmetic console noise from the framework. - 2. **Scan logger instrumentation** — the unified logger reports handled cascade timeouts via `console.error` ("SCAN ERROR … category=timeout"). The scan UI absorbs the error (renders the retry card); the console line is telemetry, not a failure. + 2. **Scan logger instrumentation** — ✅ resolved Round 15: handled timeouts now emit via `console.warn` (the scan UI absorbs them and renders the retry card; warn keeps the telemetry without crying wolf). The e2e filter entry stays for old cached bundles but should no longer fire. 3. **Cold-start 404 resource lines** — occasional one-off asset 404s on a cold edge that succeed on retry. - **Why documented here**: anyone adding console-error assertions to a new spec should reuse the shared filter rather than rediscovering these three classes as "flakes". If a *new* pattern shows up, treat it as real until proven benign — don't extend the filter casually. -- **Priority**: Low (cosmetic). A future pass could suppress pattern 1 by tuning Link prefetch and route pattern 2 through `console.warn`. +- **Priority**: Low (cosmetic). Pattern 2 closed in Round 15; a future pass could still suppress pattern 1 by tuning Link prefetch. ## 📋 Ongoing Investigations @@ -72,7 +72,7 @@ This document lists currently identified bugs, limitations, and ongoing technica ### 0b. Admin console — next-phase additions - **Status**: The `/admin` console (shipped with `is_admin` migration + UI) covers users, promo codes, and health. Remaining gaps on the original spec: - - **`/admin/scans`** — recent scans across all users, filtered by `errorClass` / `modelUsed`. Useful for spotting AI-pipeline regressions but requires a read-only design decision on PII exposure (photos). + - **`/admin/scans`** — ✅ shipped Round 15, metadata-only (the design decision: photos were never stored — `/api/analyze` writes `imageUrl: null` — and the page shows truncated userIds + item counts, no emails, no food-name lists). Filterable by `errorClass`; `/api/analyze` now also persists a failure row (`timeout` / `parse_error` / `provider_error` / `binding_missing`) at the 503 funnel, so the previously dead `errorClass` column finally has writers and pipeline regressions are visible without CF log access. - **`/admin/logs`** — tail of Cloudflare logs via the GraphQL API. Needs the Cloudflare Account API token surfaced as a Pages secret. - **Audit table** — right now `[ADMIN_ACTION]` entries live only in the CF log stream. Persisting them to a dedicated `admin_actions` table would give a queryable audit trail. Requires a schema migration + retention policy. - **Rate limit on `/api/admin/*`** — ✅ closed Round 14: all four admin mutation routes throttle at 30/min per IP, pinned by the route-wiring suite in `tests/rate-limit.test.ts`. diff --git a/frontend/src/lib/logger.ts b/frontend/src/lib/logger.ts index 799fe14..2e77fe1 100644 --- a/frontend/src/lib/logger.ts +++ b/frontend/src/lib/logger.ts @@ -128,7 +128,15 @@ class Logger { const errorMessage = error?.message || String(error); const errorType = error?.name || 'UnknownError'; const errorCategory = classifyError(error); - this.error( + // Timeouts are a HANDLED outcome — the scan UI absorbs them and + // renders the retry card, so the console line is telemetry, not a + // failure. Emitting them at error level polluted every console-error + // assertion (KNOWN_ISSUES "benign console-error noise" pattern 2; the + // e2e suites carried a dedicated filter entry for it). warn keeps the + // telemetry visible without crying wolf; genuinely unhandled + // categories stay at error level (Round 15). + const log = errorCategory === 'timeout' ? this.warn.bind(this) : this.error.bind(this); + log( `❌ SCAN ERROR [${phase}] | category=${errorCategory} type=${errorType} message="${errorMessage}"`, { phase, errorType, errorCategory, errorMessage, ...context, stack: error?.stack } );