From 5d3e37f5abc2ce2cd82edfa3846bfadbb186b783 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 26 May 2026 15:13:09 +0000 Subject: [PATCH 1/2] Implement app logging across client and functions Agent-Logs-Url: https://github.com/ClementLSW/osps/sessions/66c1d38a-c804-40d8-a27a-565ccef438bd Co-authored-by: ClementLSW <24468433+ClementLSW@users.noreply.github.com> --- netlify/functions/_utils/logger.mjs | 20 ++++++++++++++++ netlify/functions/auth-claim-invites.mjs | 6 +++++ netlify/functions/auth-invite.mjs | 6 +++++ netlify/functions/parse-receipt.mjs | 30 +++++++++++++++++++++--- src/hooks/useAuth.jsx | 5 ++++ src/lib/logger.js | 29 +++++++++++++++++++++++ src/pages/AddExpense.jsx | 30 ++++++++++++++++++++++++ supabase/migrations/add_app_logs.sql | 23 ++++++++++++++++++ 8 files changed, 146 insertions(+), 3 deletions(-) create mode 100644 netlify/functions/_utils/logger.mjs create mode 100644 src/lib/logger.js create mode 100644 supabase/migrations/add_app_logs.sql diff --git a/netlify/functions/_utils/logger.mjs b/netlify/functions/_utils/logger.mjs new file mode 100644 index 0000000..c5706e6 --- /dev/null +++ b/netlify/functions/_utils/logger.mjs @@ -0,0 +1,20 @@ +export async function writeLog(event, payload, level = 'error') { + const supabaseUrl = process.env.SUPABASE_URL + const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY + if (!supabaseUrl || !serviceRoleKey) return + + try { + await fetch(`${supabaseUrl}/rest/v1/app_logs`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'apikey': serviceRoleKey, + 'Authorization': `Bearer ${serviceRoleKey}`, + 'Prefer': 'return=minimal', + }, + body: JSON.stringify({ event, level, payload }), + }) + } catch { + // Silently swallow + } +} diff --git a/netlify/functions/auth-claim-invites.mjs b/netlify/functions/auth-claim-invites.mjs index 4841af4..58ad07b 100644 --- a/netlify/functions/auth-claim-invites.mjs +++ b/netlify/functions/auth-claim-invites.mjs @@ -13,6 +13,7 @@ */ import { parseCookies, decrypt } from './_utils/cookies.mjs' +import { writeLog } from './_utils/logger.mjs' export default async (request) => { const supabaseUrl = process.env.SUPABASE_URL @@ -101,6 +102,11 @@ export default async (request) => { claimed.push({ groupId: invite.group_id, groupName: invite.groups?.name }) } else { console.error('Failed to add to group:', invite.group_id, await addRes.text()) + await writeLog('invite.error', { + stage: 'claim', + group_id: invite.group_id, + error: `HTTP ${addRes.status}`, + }) } } diff --git a/netlify/functions/auth-invite.mjs b/netlify/functions/auth-invite.mjs index 8dc9b1a..7078610 100644 --- a/netlify/functions/auth-invite.mjs +++ b/netlify/functions/auth-invite.mjs @@ -15,6 +15,7 @@ */ import { parseCookies, decrypt } from './_utils/cookies.mjs' +import { writeLog } from './_utils/logger.mjs' export default async (request) => { const supabaseUrl = process.env.SUPABASE_URL @@ -118,6 +119,11 @@ export default async (request) => { if (!addMember.ok) { const err = await addMember.text() console.error('Failed to add member:', err) + await writeLog('invite.error', { + stage: 'add_existing_member', + group_id: groupId, + error: err?.message ?? String(err), + }) return respond(500, { error: 'Failed to add member to group' }) } diff --git a/netlify/functions/parse-receipt.mjs b/netlify/functions/parse-receipt.mjs index be75a7c..56b1aa9 100644 --- a/netlify/functions/parse-receipt.mjs +++ b/netlify/functions/parse-receipt.mjs @@ -20,6 +20,7 @@ */ import { parseCookies, decrypt } from './_utils/cookies.mjs' +import { writeLog } from './_utils/logger.mjs' const PRIMARY_MODEL = 'google/gemini-2.0-flash-001' const FALLBACK_MODEL = 'qwen/qwen3-vl-30b-a3b-thinking' @@ -88,21 +89,39 @@ export default async (request) => { return respond(400, { error: 'No image provided' }) } + const startTime = Date.now() // Try primary model with auto-retry, then fall back let result = await callWithRetry(openrouterKey, PRIMARY_MODEL, image) + const hadFallback = result.error && result.fallback - if (result.error && result.fallback) { + if (hadFallback) { console.log('Primary model failed, trying fallback...') result = await callWithRetry(openrouterKey, FALLBACK_MODEL, image) } if (result.error) { + await writeLog('ocr.error', { + model: hadFallback ? FALLBACK_MODEL : PRIMARY_MODEL, + stage: 'model_call', + error: result.error, + raw_snippet: result.raw_snippet ?? null, + }) return respond(result.status || 500, { error: result.error }) } // Distribute tax + service charge proportionally into item amounts const normalized = normalizeItems(result.data) + await writeLog('ocr.success', { + model: hadFallback ? FALLBACK_MODEL : PRIMARY_MODEL, + currency: normalized.currency, + date: normalized.date, + total: normalized.total, + item_count: normalized.items.length, + had_fallback: hadFallback, + duration_ms: Date.now() - startTime, + }, 'info') + return respond(200, normalized) } catch (err) { console.error('Parse receipt error:', err) @@ -128,6 +147,7 @@ async function callWithRetry(apiKey, model, image, retries = 1) { const REQUEST_TIMEOUT = 30_000 // 30 seconds async function callOpenRouter(apiKey, model, imageBase64) { + let content try { const controller = new AbortController() const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT) @@ -177,7 +197,7 @@ async function callOpenRouter(apiKey, model, imageBase64) { } const data = await response.json() - const content = data.choices?.[0]?.message?.content + content = data.choices?.[0]?.message?.content if (!content) { return { error: 'No response from vision model', fallback: true } @@ -204,7 +224,11 @@ async function callOpenRouter(apiKey, model, imageBase64) { } if (err instanceof SyntaxError) { console.error(`JSON parse failed (${model}):`, err.message) - return { error: 'Could not parse model response', fallback: true } + return { + error: 'Could not parse model response', + fallback: true, + raw_snippet: content?.slice(0, 200), + } } console.error(`OpenRouter call failed (${model}):`, err) return { error: 'Vision model request failed', fallback: true } diff --git a/src/hooks/useAuth.jsx b/src/hooks/useAuth.jsx index 92c78bc..7a7e475 100644 --- a/src/hooks/useAuth.jsx +++ b/src/hooks/useAuth.jsx @@ -36,6 +36,7 @@ import { createContext, useContext, useEffect, useState } from 'react' import { auth } from '@/lib/api' import { setAccessToken, getSupabase } from '@/lib/supabase' +import { log } from '@/lib/logger' import toast from 'react-hot-toast' const AuthContext = createContext(null) @@ -84,6 +85,7 @@ export function AuthProvider({ children }) { } } catch (err) { console.error('Auth init error:', err) + await log('auth.error', { stage: 'init', error: err?.message ?? String(err) }) if (mounted) { setUser(null) setProfile(null) @@ -107,10 +109,12 @@ export function AuthProvider({ children }) { if (error) { console.error('Error fetching profile:', error) + await log('auth.error', { stage: 'fetchProfile', error: error?.message ?? String(error) }) } setProfile(data) } catch (err) { console.error('fetchProfile failed:', err) + await log('auth.error', { stage: 'fetchProfile', error: err?.message ?? String(err) }) } finally { setLoading(false) } @@ -131,6 +135,7 @@ export function AuthProvider({ children }) { } catch (err) { // Non-critical — don't block the UI console.error('Claim invites failed:', err) + await log('auth.error', { stage: 'claimInvites', error: err?.message ?? String(err) }) } } diff --git a/src/lib/logger.js b/src/lib/logger.js new file mode 100644 index 0000000..ded69e7 --- /dev/null +++ b/src/lib/logger.js @@ -0,0 +1,29 @@ +import { getSupabase } from '@/lib/supabase' + +/** + * Event catalogue: + * - ocr.success (info): model, currency, date, total, item_count, had_fallback, duration_ms + * - ocr.error (error): model, stage, error, raw_snippet + * - fx.error (error): from, to, date, is_historical, http_status, error + * - expense.save_error (error): stage ('expense'|'splits'), group_id, error + * - invite.error (error): stage, group_id, error + * - auth.error (error): stage, error + */ + +/** + * Write a log entry to app_logs. Fire-and-forget — never throws. + * + * @param {string} event + * @param {object} payload + * @param {'info'|'warn'|'error'} [level='error'] + */ +export async function log(event, payload, level = 'error') { + try { + await getSupabase() + .from('app_logs') + .insert({ event, level, payload }) + // user_id is resolved server-side via auth.uid() from the JWT + } catch { + // Silently swallow — logging must never surface errors to the user + } +} diff --git a/src/pages/AddExpense.jsx b/src/pages/AddExpense.jsx index 9e3f075..6345efa 100644 --- a/src/pages/AddExpense.jsx +++ b/src/pages/AddExpense.jsx @@ -6,6 +6,7 @@ import { splitEqual, splitExact, splitPercentage, splitShares, splitLineItems } import { CURRENCIES } from '@/lib/currencies' import { formatCurrency } from '@/lib/formatCurrency' import { fetchExchangeRate } from '@/lib/exchangeRate' +import { log } from '@/lib/logger' import toast from 'react-hot-toast' const SPLIT_MODES = [ @@ -78,6 +79,15 @@ export default function AddExpense() { ) if (!cancelled) setExchangeRate(result) } catch (err) { + const isHistorical = expenseDate !== new Date().toISOString().slice(0, 10) + await log('fx.error', { + from: expenseCurrency, + to: group?.currency || 'SGD', + date: expenseDate, + is_historical: isHistorical, + http_status: err.status ?? null, + error: err.message, + }) console.error('Exchange rate fetch failed:', err) if (!cancelled) toast.error('Could not fetch exchange rate') } finally { @@ -358,6 +368,11 @@ export default function AddExpense() { .single() if (expError) { + await log('expense.save_error', { + stage: 'expense', + group_id: groupId, + error: expError?.message ?? String(expError), + }) toast.error('Failed to create expense') console.error(expError) setSaving(false) @@ -374,6 +389,11 @@ export default function AddExpense() { }))) if (splitError) { + await log('expense.save_error', { + stage: 'splits', + group_id: groupId, + error: splitError?.message ?? String(splitError), + }) toast.error('Failed to save splits') console.error(splitError) setSaving(false) @@ -425,6 +445,11 @@ export default function AddExpense() { .eq('id', editId) if (expError) { + await log('expense.save_error', { + stage: 'expense', + group_id: groupId, + error: expError?.message ?? String(expError), + }) toast.error('Failed to update expense') console.error(expError) setSaving(false) @@ -453,6 +478,11 @@ export default function AddExpense() { }))) if (splitError) { + await log('expense.save_error', { + stage: 'splits', + group_id: groupId, + error: splitError?.message ?? String(splitError), + }) toast.error('Failed to save splits') console.error(splitError) setSaving(false) diff --git a/supabase/migrations/add_app_logs.sql b/supabase/migrations/add_app_logs.sql new file mode 100644 index 0000000..f1010b0 --- /dev/null +++ b/supabase/migrations/add_app_logs.sql @@ -0,0 +1,23 @@ +create table public.app_logs ( + id uuid primary key default gen_random_uuid(), + event text not null, + level text not null default 'error' check (level in ('info', 'warn', 'error')), + user_id uuid references public.profiles(id) on delete set null, + payload jsonb, + created_at timestamptz default now() +); + +alter table public.app_logs enable row level security; + +-- Users can write their own logs (client-side events) +create policy "Users can insert own logs" + on public.app_logs for insert + with check (user_id = (select auth.uid()) or user_id is null); + +-- No client reads — query via Dashboard SQL editor only +create policy "No client reads" + on public.app_logs for select + using (false); + +create index idx_app_logs_event on public.app_logs(event); +create index idx_app_logs_created on public.app_logs(created_at desc); From 678a9181170efbdd9b0846e07613698c1cebd028 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 26 May 2026 15:15:20 +0000 Subject: [PATCH 2/2] Add unified app_logs error logging across client and functions Agent-Logs-Url: https://github.com/ClementLSW/osps/sessions/66c1d38a-c804-40d8-a27a-565ccef438bd Co-authored-by: ClementLSW <24468433+ClementLSW@users.noreply.github.com> --- package-lock.json | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/package-lock.json b/package-lock.json index 39b01e2..66aa613 100644 --- a/package-lock.json +++ b/package-lock.json @@ -67,7 +67,6 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -1465,7 +1464,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -1583,8 +1581,7 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/debug": { "version": "4.4.3", @@ -1888,7 +1885,6 @@ "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "dev": true, "license": "MIT", - "peer": true, "bin": { "jiti": "bin/jiti.js" } @@ -2142,7 +2138,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -2312,7 +2307,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -2325,7 +2319,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -2684,7 +2677,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -2768,7 +2760,6 @@ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43",