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
20 changes: 20 additions & 0 deletions netlify/functions/_utils/logger.mjs
Original file line number Diff line number Diff line change
@@ -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
}
}
6 changes: 6 additions & 0 deletions netlify/functions/auth-claim-invites.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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}`,
})
}
}

Expand Down
6 changes: 6 additions & 0 deletions netlify/functions/auth-invite.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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' })
}

Expand Down
30 changes: 27 additions & 3 deletions netlify/functions/parse-receipt.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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 }
Expand All @@ -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 }
Expand Down
11 changes: 1 addition & 10 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions src/hooks/useAuth.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand All @@ -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)
}
Expand All @@ -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) })
}
}

Expand Down
29 changes: 29 additions & 0 deletions src/lib/logger.js
Original file line number Diff line number Diff line change
@@ -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
}
}
30 changes: 30 additions & 0 deletions src/pages/AddExpense.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
23 changes: 23 additions & 0 deletions supabase/migrations/add_app_logs.sql
Original file line number Diff line number Diff line change
@@ -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);
Loading