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
15 changes: 15 additions & 0 deletions .github/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,21 @@ Prefer up-to-date sources over training data. Search for error messages, check o
- **Dev server:** `npm run dev` (Vite on `:5000`, Wrangler on `:8787`)
- **Stop:** `npm stop`

## iOS UI Verification

For changes under `ios/WingDex/Views/` or shared iOS UI code, inspect the corresponding web implementation under `src/components/`, `src/styles/`, and `src/index.css` before editing. Preserve feature, state, copy, content order, and palette parity with the web source of truth while using native SwiftUI and Apple HIG conventions rather than pixel-for-pixel cloning.

Before styling a new iOS control, find an existing control with the same function and reuse its component, modifier, symbol, label structure, font, spacing, and interaction pattern. Prefer styling in this order:

1. An existing WingDex component or established nearby pattern.
2. Standard SwiftUI controls, styles, semantic fonts and colors, button roles, materials, and system spacing.
3. New custom styling only when the product design requires behavior or appearance the first two options cannot provide.

Do not hand-style native controls by default. Avoid fixed font sizes, explicit foreground colors, custom padding, custom shapes, or separate icon/text styling when a standard control, `Label`, semantic role, tint, or existing modifier already provides the intended result. When custom styling is necessary, keep it minimal, Dynamic Type-safe, and consistent across every control with the same function.

Before finishing, build the active iOS scheme with Xcode MCP, render every affected `#Preview`, and validate the installed app with iOS Simulator MCP. Exercise the changed flow with `--auto-sign-in --auto-demo-data` when authenticated data is needed, and check light and dark appearance, navigation, scrolling, sheets, Dynamic Type, and visible overlap. Report any simulator or local-server validation that could not be performed.


## Observability (Structured Logging)

Full schema and reference in **[docs/OBSERVABILITY.md](../docs/OBSERVABILITY.md)**.
Expand Down
201 changes: 99 additions & 102 deletions docs/IOS_APP_PLAN.md

Large diffs are not rendered by default.

30 changes: 23 additions & 7 deletions docs/OBSERVABILITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ Standard 6-level hierarchy, controlled by `LOG_LEVEL` env var:

| Level | Purpose | When to use |
|---|---|---|
| `Trace` | Ultra-verbose data dumps | Full candidate arrays, range prior maps. Deep debugging only. |
| `Trace` | Ultra-verbose operational diagnostics | Safe pipeline state and aggregate maps. Never credentials or user content. |
| `Debug` | Sub-step diagnostic detail | Bird-id pipeline stages, batch counts, import parsing. Local dev. |
| `Info` | Significant business events | Request completion (1 per request), audit events. **Production baseline.** |
| `Warning` | Client errors, degraded paths | 4xx responses, validation failures. Emitted at `warn` level and above. |
Expand Down Expand Up @@ -84,7 +84,24 @@ LOG_FORMAT=pretty
You see sub-step detail (bird-id pipeline, import parsing, batch counts) in a compact terminal-friendly format.

### Deep debugging
Temporarily set `LOG_LEVEL=trace` to see full data dumps (candidate arrays, range prior maps). Revert when done.
Temporarily set `LOG_LEVEL=trace` to see additional safe pipeline diagnostics. Trace level does not permit credentials, response bodies, user-authored content, or provider/database exception text. Revert when done.

## Safe metadata policy

Logs may include stable operational metadata needed to correlate and diagnose requests:

- top-level `userId`, internal `resourceId`, trace/span IDs
- internal outing, observation, or photo IDs when they identify the affected resource
- HTTP status, method, route, duration, response byte count
- aggregate counts, booleans, enums, configured limits, and retry durations

Logs must never include:

- raw or signed session tokens, cookies, authorization headers, OAuth callback URLs, passkey credential IDs, or challenge payloads
- email addresses, profile image URLs/data, filenames, notes, outing/location names, species-name arrays, CSV contents, or image data
- request/response bodies, provider payloads, database/provider exception messages, or stack traces

Use a stable operation summary plus safe metadata in catch blocks, for example: `Outing deletion failed; inspect the trace and database operation` with `{ outingId }`. The trace ID is the correlation handle; raw exception text is not a logging shortcut.

### Tracing a specific user in production
Query CF Workers Logs (or log analytics) by `userId` at Info level - it's a top-level field on every log line. If you need sub-step detail for a production issue, temporarily set `LOG_LEVEL=debug` in Cloudflare Workers env vars and redeploy. Revert after investigation.
Expand Down Expand Up @@ -192,7 +209,7 @@ Middleware resolves the session (and therefore `userId`) for non-`/api/auth/*` r
5. **Include entity context** in error messages: outing IDs, observation IDs, counts. Use the `detail` parameter for rich descriptions and `properties` for machine-queryable data.
6. **Scope resourceId** when operating on a specific entity: `createRouteResponder(log?.withResourceId('outings/' + outingId), ...)`
7. **Propagate `traceparent`** on outbound calls (web -> API, iOS -> API).
8. **Read `response.text()`** on client-side errors to surface server error details to the user.
8. **Expose only safe expected 4xx details** to clients. Never surface or log raw 5xx bodies, HTML, exception text, or provider/database payloads.

### Route handler template

Expand All @@ -212,13 +229,12 @@ export const onRequestPost: PagesFunction<Env> = async context => {
// ... DB operations ...
route.debug('Created outing', { outingId: body.id })
return Response.json(result)
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
return route.fail(500, 'Internal server error', `Operation failed: ${message}`, { error: message })
} catch {
return route.fail(500, 'Internal server error', 'Outing creation failed; inspect the trace and database operation', { outingId: body.id })
}
}
```

### Choosing info vs debug

Ask: "Would an ops engineer need to see this for every request in production?" If yes -> `route.info()`. If only useful when debugging -> `route.debug()`. If it's a giant data dump -> `route.trace()`.
Ask: "Would an ops engineer need to see this for every request in production?" If yes -> `route.info()`. If only useful when debugging -> `route.debug()`. Use `route.trace()` only for additional safe operational metadata, never for raw payloads or user content.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
62 changes: 61 additions & 1 deletion e2e/api-smoke.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,12 +81,72 @@ test.describe('API smoke (request context)', () => {
})
expect(createOuting.status()).toBe(200)

const replayOuting = await api.post('/api/data/outings', {
headers: { cookie: authCookie },
data: {
id: outingId,
startTime: '2026-02-20T08:00:00.000Z',
endTime: '2026-02-20T09:30:00.000Z',
locationName: 'Renamed API Smoke Park',
createdAt: '2099-01-01T00:00:00.000Z',
},
})
expect(replayOuting.status()).toBe(200)
await expect(replayOuting.json()).resolves.toMatchObject({
id: outingId,
locationName: 'Renamed API Smoke Park',
createdAt: '2026-02-20T09:00:00.000Z',
})

const postCreateData = await api.get('/api/data/all', {
headers: { cookie: authCookie },
})
expect(postCreateData.status()).toBe(200)
const postCreateJson = await postCreateData.json()
expect(postCreateJson.outings.some((outing: { id: string }) => outing.id === outingId)).toBe(true)
const savedOuting = postCreateJson.outings.find((outing: { id: string }) => outing.id === outingId)
expect(savedOuting).toMatchObject({
id: outingId,
locationName: 'Renamed API Smoke Park',
endTime: '2026-02-20T09:30:00.000Z',
createdAt: '2026-02-20T09:00:00.000Z',
})

const cardinalId = `${outingId}-cardinal`
const blueJayId = `${outingId}-blue-jay`
const createObservations = await api.post('/api/data/observations', {
headers: { cookie: authCookie },
data: [
{
id: cardinalId,
outingId,
speciesName: 'Northern Cardinal (Cardinalis cardinalis)',
count: 1,
certainty: 'confirmed',
notes: '',
},
{
id: blueJayId,
outingId,
speciesName: 'Blue Jay (Cyanocitta cristata)',
count: 1,
certainty: 'confirmed',
notes: '',
},
],
})
expect(createObservations.status()).toBe(200)

const rejectObservation = await api.patch('/api/data/observations', {
headers: { cookie: authCookie },
data: { ids: [cardinalId], patch: { certainty: 'rejected' } },
})
expect(rejectObservation.status()).toBe(200)
const rejectPayload = await rejectObservation.json()
const remainingBlueJay = rejectPayload.dexUpdates.find(
(entry: { speciesName: string }) => entry.speciesName.startsWith('Blue Jay'),
)
expect(remainingBlueJay?.wikiTitle).toBeTruthy()
expect(remainingBlueJay?.thumbnailUrl).toMatch(/^https:\/\//)

await api.dispose()
})
Expand Down
37 changes: 6 additions & 31 deletions functions/_middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ export const onRequest: PagesFunction<Env> = async (context) => {

// --- HTTP method validation ---
if (!ALLOWED_METHODS.has(method)) {
log.warn('requests/validation/validate', { category: 'Request', resultType: 'Failed', resultSignature: 405, resultDescription: `Method ${method} is not allowed; supported methods are GET, POST, PATCH, DELETE, OPTIONS` })
log.warn('requests/validation/validate', { category: 'Request', resultType: 'Failed', resultSignature: 405, resultDescription: `Method ${method} is not allowed; supported methods are GET, POST, PATCH, DELETE, OPTIONS`, durationMs: Date.now() - start })
const methodResponse = errorResponse('Method Not Allowed', 405, {
Allow: Array.from(ALLOWED_METHODS).join(', '),
})
Expand All @@ -141,7 +141,7 @@ export const onRequest: PagesFunction<Env> = async (context) => {
if (hasBodyMethod && rawContentLength !== null) {
const parsedLength = Number(rawContentLength)
if (!Number.isFinite(parsedLength) || parsedLength < 0) {
log.warn('requests/validation/validate', { category: 'Request', resultType: 'Failed', resultSignature: 400, resultDescription: 'Content-Length header is not a valid non-negative number' })
log.warn('requests/validation/validate', { category: 'Request', resultType: 'Failed', resultSignature: 400, resultDescription: 'Content-Length header is not a valid non-negative number', durationMs: Date.now() - start })
const clResponse = errorResponse('Invalid Content-Length', 400)
addTraceHeaders(clResponse, traceCtx)
return clResponse
Expand All @@ -150,7 +150,7 @@ export const onRequest: PagesFunction<Env> = async (context) => {
const limit =
BODY_LIMITS.find((b) => pathname.startsWith(b.prefix))?.maxBytes ?? DEFAULT_BODY_LIMIT
if (parsedLength > limit) {
log.warn('requests/validation/validate', { category: 'Request', resultType: 'Failed', resultSignature: 413, resultDescription: `Request body of ${parsedLength} bytes exceeds the ${limit}-byte limit`, properties: { contentLength: parsedLength, limit } })
log.warn('requests/validation/validate', { category: 'Request', resultType: 'Failed', resultSignature: 413, resultDescription: `Request body exceeded the configured route limit`, durationMs: Date.now() - start, properties: { contentLength: parsedLength, limit } })
const sizeResponse = errorResponse('Payload Too Large', 413)
addTraceHeaders(sizeResponse, traceCtx)
return sizeResponse
Expand Down Expand Up @@ -225,32 +225,8 @@ export const onRequest: PagesFunction<Env> = async (context) => {
/** Emit the single request-lifecycle completion log with dynamic level. */
async function emitCompletionLog(log: ReturnType<typeof createLogger>, op: string, response: Response, durationMs: number, method: string, pathname: string): Promise<void> {
const status = response.status
// For error responses, try to extract a meaningful description from the body
let description = `HTTP ${status}`
if (status >= 400) {
try {
const contentType = response.headers.get('content-type') || ''
const contentLength = parseInt(response.headers.get('content-length') || '', 10)
// Only read small text/json bodies with a known size to avoid logging large or binary payloads
if ((contentType.includes('json') || contentType.includes('text')) && !isNaN(contentLength) && contentLength <= 4096) {
const cloned = response.clone()
const body = await cloned.text()
if (body) {
try {
const json = JSON.parse(body) as Record<string, unknown>
const msg = json.message || json.error || json.detail
if (typeof msg === 'string') description = msg.slice(0, 200)
} catch {
if (body.length <= 200) description = body
}
}
}
} catch {
// Ignore read failures; keep default description
}
}
const resultType = status < 400 ? 'Succeeded' : 'Failed'
const fields = { category: 'Request' as const, resultType: resultType as 'Succeeded' | 'Failed', resultSignature: status, resultDescription: description, durationMs, properties: { 'http.method': method, 'http.route': pathname } }
const fields = { category: 'Request' as const, resultType: resultType as 'Succeeded' | 'Failed', resultSignature: status, resultDescription: `HTTP ${status}`, durationMs, properties: { 'http.method': method, 'http.route': pathname } }
if (status >= 500) {
log.error(op, fields)
} else if (status >= 400) {
Expand All @@ -274,14 +250,13 @@ function handleUnexpectedError(
operationName: string,
start: number,
): Response {
const message = err instanceof Error ? err.message : String(err)
void err
log.error(operationName, {
category: 'Request',
resultType: 'Failed',
resultSignature: 500,
resultDescription: `Unhandled error: ${message}`,
resultDescription: 'Unhandled route error; inspect the result signature and trace ID, then retry or investigate the route implementation',
durationMs: Date.now() - start,
properties: { error: message },
})
const response = errorResponse('Internal Server Error', 500)
addTraceHeaders(response, traceCtx)
Expand Down
7 changes: 3 additions & 4 deletions functions/api/auth/finalize-passkey.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ export const onRequestPost: PagesFunction<Env> = async context => {
passkeyId || undefined,
)
if (!ownsPasskey) {
return route.fail(403, 'Passkey required', `User does not own passkey ${passkeyId || '(none)'} or no passkey found for this user`, { passkeyId: passkeyId || undefined })
return route.fail(403, 'Passkey required', 'No owned passkey matched the finalization request', { passkeyIdSupplied: passkeyId.length > 0 })
}

try {
Expand All @@ -49,8 +49,7 @@ export const onRequestPost: PagesFunction<Env> = async context => {
route.info('User finalized passkey upgrade and is no longer anonymous')

return Response.json({ success: true })
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
return route.fail(500, 'Internal server error', `Passkey finalization failed: ${message}`, { error: message, userId: session.user.id })
} catch {
return route.fail(500, 'Internal server error', 'Passkey finalization failed; inspect the trace and database operation', { userId: session.user.id })
}
}
5 changes: 2 additions & 3 deletions functions/api/auth/linked-providers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,7 @@ export const onRequestGet: PagesFunction<Env> = async context => {
return Response.json({ providers }, {
headers: { 'cache-control': 'no-store' },
})
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
return route.fail(500, 'Internal server error', `Linked providers fetch failed: ${message}`, { error: message })
} catch {
return route.fail(500, 'Internal server error', 'Linked provider lookup failed; inspect the trace and authentication database operation')
}
}
19 changes: 4 additions & 15 deletions functions/api/data/all.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { computeDex } from '../../lib/dex-query'
import { computeDex, enrichDexEntries } from '../../lib/dex-query'
import { hasObservationColumn } from '../../lib/schema'
import { getWikiMetadata } from '../../lib/taxonomy'
import { createRouteResponder } from '../../lib/log'

type OutingRow = {
Expand Down Expand Up @@ -109,19 +108,9 @@ export const onRequestGet: PagesFunction<Env> = async context => {
outings,
photos,
observations,
dex: dex.map(entry => {
const { wikiTitle, thumbnailUrl } = getWikiMetadata(entry.speciesName)
return {
...entry,
addedDate: entry.addedDate || undefined,
bestPhotoId: entry.bestPhotoId || undefined,
wikiTitle,
thumbnailUrl,
}
}),
dex: enrichDexEntries(dex),
})
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
return route.fail(500, 'Internal server error', `Data fetch failed: ${message}`, { error: message })
} catch {
return route.fail(500, 'Internal server error', 'Bulk data fetch failed; inspect the trace and database operations')
}
}
5 changes: 2 additions & 3 deletions functions/api/data/clear.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,7 @@ export const onRequestDelete: PagesFunction<Env> = async context => {
route.info('Deleted all outings and dex metadata for user')

return Response.json({ cleared: true })
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
return route.fail(500, 'Internal server error', `Data clear failed: ${message}`, { error: message })
} catch {
return route.fail(500, 'Internal server error', 'Data clear failed; inspect the trace and database batch')
}
}
28 changes: 8 additions & 20 deletions functions/api/data/dex.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { computeDex } from '../../lib/dex-query'
import { computeDex, enrichDexEntries } from '../../lib/dex-query'
import { createRouteResponder } from '../../lib/log'

type DexMetaPatch = {
Expand Down Expand Up @@ -53,16 +53,9 @@ export const onRequestGet: PagesFunction<Env> = async context => {
try {
const dex = await computeDex(context.env.DB, userId)
route.debug(`Computed dex with ${dex.length} species`, { speciesCount: dex.length })
return Response.json(
dex.map(entry => ({
...entry,
addedDate: entry.addedDate || undefined,
bestPhotoId: entry.bestPhotoId || undefined,
}))
)
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
return route.fail(500, 'Internal server error', `Dex read failed: ${message}`, { error: message })
return Response.json(enrichDexEntries(dex))
} catch {
return route.fail(500, 'Internal server error', 'Dex read failed; inspect the trace and database query')
}
}

Expand All @@ -89,18 +82,13 @@ export const onRequestPatch: PagesFunction<Env> = async context => {
for (const patch of patches) {
await upsertDexMetaPatch(context.env.DB, userId, patch)
}
route.debug(`Upserted ${patches.length} dex metadata patches`, { patchCount: patches.length, speciesNames: patches.map(p => p.speciesName) })
route.debug(`Upserted ${patches.length} dex metadata patches`, { patchCount: patches.length })

const dexUpdates = await computeDex(context.env.DB, userId)
return Response.json({
dexUpdates: dexUpdates.map(entry => ({
...entry,
addedDate: entry.addedDate || undefined,
bestPhotoId: entry.bestPhotoId || undefined,
})),
dexUpdates: enrichDexEntries(dexUpdates),
})
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
return route.fail(500, 'Internal server error', `Dex patch failed: ${message}`, { error: message, patchCount: patches.length })
} catch {
return route.fail(500, 'Internal server error', 'Dex patch failed; inspect the trace and database batch', { patchCount: patches.length })
}
}
Loading
Loading