Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
6 changes: 3 additions & 3 deletions docs/KNOWN_ISSUES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<Link>` 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

Expand All @@ -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`.
Expand Down
1 change: 1 addition & 0 deletions frontend/src/app/[locale]/admin/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
];
Expand Down
185 changes: 185 additions & 0 deletions frontend/src/app/[locale]/admin/scans/page.tsx
Original file line number Diff line number Diff line change
@@ -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<ScanRow[]> {
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 (
<div className="space-y-4">
<div className="flex items-baseline justify-between">
<h1 className="text-2xl font-black text-slate-900">Scans</h1>
<p className="text-xs text-slate-500">
Page {page} · {visible.length} shown{errorClass ? ` · filter: ${errorClass}` : ''}
</p>
</div>

{/* errorClass filter chips */}
<div className="flex gap-2 flex-wrap text-xs">
<a
href={base}
className={`px-2.5 py-1 rounded-md ring-1 ${!errorClass ? 'bg-slate-900 text-white ring-slate-900' : 'bg-white text-slate-700 ring-slate-200 hover:bg-slate-50'}`}
>
all
</a>
{ERROR_FILTERS.map((ec) => (
<a
key={ec}
href={`${base}?errorClass=${ec}`}
className={`px-2.5 py-1 rounded-md ring-1 ${errorClass === ec ? 'bg-rose-600 text-white ring-rose-600' : 'bg-white text-slate-700 ring-slate-200 hover:bg-slate-50'}`}
>
{ec}
</a>
))}
</div>

<div className="overflow-x-auto rounded-xl ring-1 ring-slate-200 bg-white">
<table className="min-w-full text-sm">
<thead className="bg-slate-50 text-xs uppercase tracking-wider text-slate-500">
<tr>
<th className="text-left px-4 py-2 font-medium">When (UTC)</th>
<th className="text-left px-4 py-2 font-medium">User</th>
<th className="text-left px-4 py-2 font-medium">Mode</th>
<th className="text-left px-4 py-2 font-medium">Items</th>
<th className="text-left px-4 py-2 font-medium">Score</th>
<th className="text-left px-4 py-2 font-medium">Model</th>
<th className="text-left px-4 py-2 font-medium">Status</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{visible.length === 0 && (
<tr>
<td colSpan={7} className="px-4 py-8 text-center text-slate-500 text-sm">
No scans on this page.
</td>
</tr>
)}
{visible.map((s) => (
<tr key={s.id} className="hover:bg-slate-50">
<td className="px-4 py-2 text-slate-600 whitespace-nowrap font-mono text-xs">{formatWhen(s.createdAt)}</td>
<td className="px-4 py-2 font-mono text-xs text-slate-500">{s.userId ? s.userId.substring(0, 8) : 'anon'}</td>
<td className="px-4 py-2 text-slate-700">{s.scanMode ?? '—'}</td>
<td className="px-4 py-2 text-slate-700">{itemCount(s.detectedItems)}</td>
<td className="px-4 py-2 text-slate-700">{s.errorClass ? '—' : `${Math.round(s.scoreOverall ?? 0)}/100`}</td>
<td className="px-4 py-2 text-xs text-slate-600">{s.modelUsed ?? '—'}</td>
<td className="px-4 py-2">
{s.errorClass ? (
<span className="text-xs font-bold px-2 py-1 rounded-md bg-rose-100 text-rose-700">{s.errorClass}</span>
) : (
<span className="text-xs font-semibold px-2 py-1 rounded-md bg-emerald-100 text-emerald-700">ok</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>

{/* Pagination */}
<div className="flex items-center justify-between text-sm">
{page > 1 ? (
<a className="text-slate-600 hover:text-slate-900 underline" href={`${base}?page=${page - 1}${errorClass ? `&errorClass=${errorClass}` : ''}`}>
← Newer
</a>
) : (
<span />
)}
{hasNext && (
<a className="text-slate-600 hover:text-slate-900 underline" href={`${base}?page=${page + 1}${errorClass ? `&errorClass=${errorClass}` : ''}`}>
Older →
</a>
)}
</div>
</div>
);
}
31 changes: 31 additions & 0 deletions frontend/src/app/api/analyze/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
10 changes: 9 additions & 1 deletion frontend/src/lib/logger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
);
Expand Down
Loading