Troubleshooting runbook
A symptom-first guide for support and operations. Each entry lists what the user sees, the likely cause, and where to look or what to fix.
Sign-in and access
Everyone is suddenly logged out / every request returns 401
- In production:
ADJUSTSQUARE_JWT_SECRETis unset or wrong, so no token validates. The backend logs a loudauth.secret_missingerror on startup. - In local mode: check that
APP_ENV=localandLOCAL_AUTH_USER_EMAILare set correctly in.env. The backend logsauth.local_bypasswhen bypass is active. - Where to look: backend logs for
auth.jwt_error,auth.secret_missing, orauth.local_bypass.
A user sees "Sign-in failed" on the callback page
- Likely cause: the token did not validate (
/api/auth/mereturned 401), often a secret mismatch or an expired token. - Where to look: the callback page shows the error detail outside production; backend logs show the reason.
A user cannot see a claim they expect (403 / not found)
- Likely cause: the claim belongs to a different team. Access is team-scoped.
- Where to look: confirm the user's team (
/api/auth/me) matches the claim's team. This is by design, not a bug.
Uploads and processing
A large video upload fails, hangs, or never creates a job
- Likely cause: the file exceeds a CDN/tunnel request-body cap. Cloudflare's tunnel rejects bodies over ~100 MB on Free/Pro, well below the app's
MAX_UPLOAD_SIZE_MB(2 GB). If no job appears in/api/jobs, the upload never reached the backend. - Mitigation (built in): the frontend uploads video and audio in small resumable chunks (
POST /api/uploads/init, thenPUT .../chunks/{i}, thencomplete), so each request stays under the cap. Confirm the deployed frontend is current. See the chunked upload protocol. - If it still fails: test a small clip to isolate size; check
docker compose logs nginx backendfor413; verifyMAX_UPLOAD_SIZE_MBand nginxclient_max_body_sizeboth allow the size.
A job is stuck or "frozen"
- Self-healing (already in place): pipelines run in the backend process, so a restart (e.g. a deploy) used to freeze in-flight jobs at their last progress forever. Now
services/job_recovery.pyfixes this automatically: on boot, every job still in a processing status is requeued and restarts from scratch (partial items are cleared first; the up-front charge is keyed on the job id, so no double-billing). A watchdog also sweeps every minute for jobs whose row has been quiet forSTUCK_JOB_MINUTES(default 15) and requeues them, or fails them if their in-process task hung. A job only gets one automatic retry; a second interruption fails it with a clear message, refunds the charge, and posts the failure to Slack. - Where to look: the job status and the Logs page (
/logs, admins only; system-wide with team/user filters), orGET /api/jobs/{id}/logs. Finished jobs keep their logs in thejob_log_archivestable, so the detail survives restarts. Map the status to a stage using the table in the user journey. A job that says "Restarting after an interruption" was recovered by the sweep (recovery_attemptson the job row counts the retries). - Likely causes by stage:
analyzingfor a long time: this is the slow stage; large videos take minutes. Check for OpenRouter rate-limit retries in the logs.transcribing: Deepgram is slow or the audio is long.
A job ended in error
- Where to look: the job's
error_messageand the pipeline logs (the failing stage is logged as[STAGE FAIL]). - Common causes: FFmpeg missing or a corrupt upload (extract stage), an unreadable image (photo upload), or an AI call exhausting its retries.
Photos uploaded but nothing is processing
- Likely cause: photo jobs wait in
awaiting_grouping. The user must group photos and press Process; AI does not start automatically. - Fix: finish grouping in the modal and start processing. See the photo pipeline.
"Upload failed" when uploading many or large photos
- Likely cause: historically the frontend sent every selected photo in one request, and the Cloudflare Tunnel caps a single request body at roughly 100MB. A big set of camera JPEGs blew past that and Cloudflare returned an HTML error page the UI could not parse, so it showed a bare "Upload failed".
- Fix (already in place): photos now upload in size-bounded batches (80MB budget), the first to
POST /api/upload-photosand the rest toPOST /api/upload-photos/{id}/append, all on one job. A single photo over the 100MB hard limit is caught in the browser with a clear message. See batched photo upload.
The inventory is "visual only" with no spoken details
- Likely cause: no
DEEPGRAM_API_KEY, so transcription was skipped (the pipeline logs a warning and proceeds visual-only). - Fix: set the Deepgram key.
Progress bar never updates in the UI
- Likely cause: the SSE stream is being buffered. In production this is handled by nginx (
proxy_buffering off); in dev the local SSE route handler owns the stream. - Where to look: confirm
nginx.confis the deployed one and thecloudflared/nginxcontainers are healthy. See deployment.
Output quality
Items look wrong, duplicated, or missing
- Likely causes:
- Quiet or unclear narration: transcription drives naming. Encourage clear narration.
- Photos not grouped: ungrouped same-item shots can become separate items.
- Low-confidence items never reviewed: items below 0.6 arrive flagged and need a human.
- Where to look: the item's confidence and evidence fields, and the job transcript.
Structural items appear (or expected items are excluded)
- By design: the AI is contents-only. It excludes walls, floors, built-in plumbing, and building systems, and includes movable property, cabinets, and appliances. A team can nudge behavior with a custom prompt add-on.
Duplicate items across a chunk boundary in a video
- Mechanism: cross-chunk dedup merges near-boundary duplicates within
VIDEO_DEDUP_WINDOW_SECONDS. Genuine duplicates far apart in time are not merged. Reviewers can merge them manually.
Submission to Adjust Square
"Your team has no Adjust Square API token" (409)
- Likely cause: with per-user auth, the team's
team_tokenis missing (it is set from the SSO login token). - Fix: ensure the user logged in via SSO so the token was captured; confirm the team record has a
team_token. See the Adjust Square integration.
Submission returns 502
- Likely cause: Adjust Square rejected the request or was unreachable.
- Where to look: backend logs for the
AdjustSquareErrorstatus and body. ConfirmADJUST_SQUARE_BASE_URLis set and reachable.
Some items submitted, some did not (re-submission)
- By design: re-submission posts items one at a time; a failed item stays unsubmitted so it can be retried, while the rest proceed. Re-run the submission for the stragglers.
Photos did not appear on the Adjust Square claim
- Likely cause: photo upload happens in a background task after the items are created; a failure there is logged but does not fail the submission.
- Where to look: backend logs for photo upload warnings. Re-submitting re-attempts only unsubmitted items, so badly-failed photos may need manual attention.
Deployment and ops
A deploy comes up unhealthy
- Where to look:
docker compose -f docker-compose.prod.yml logs -f backend. The frontend waits for the backend's/healthto pass. - Common causes: a missing required env var (AI key, JWT secret) or a bad
.env.
After adding a model column, production errors on that column
- Cause:
create_allonly creates whole new tables; existing tables need anALTER TABLEline ininit_db(). - Fix: add the
ALTER TABLE ... ADD COLUMNstatement. See data model.
Data loss after a rebuild
- Cause: the SQLite database and per-job files live in the
storage(anduploads) Docker volumes. If those volumes were removed, data is gone. - Prevention: back up the
storagevolume regularly.
Where the signals live
| Signal | Where |
|---|---|
| Live job progress and logs | /logs page, GET /api/jobs/{id}/stream, GET /api/jobs/{id}/logs |
| Job timings, tokens, cost | GET /api/jobs/{id}/metrics |
| Durable traces, metrics, and logs | Grafana (Tempo / Prometheus / Loki) when telemetry is enabled |
| Who changed what (and undo) | Settings activity log, GET /api/activity |
| Backend errors | docker compose logs backend / uvicorn output |
| Liveness | GET /health |
Reconstructing a past job after a restart
The in-memory /logs buffer is lost on restart, but when telemetry is enabled the full history persists in Grafana. Open the job.process trace in Tempo (search by job.id or claim.id), read the stage timeline, and follow the trace id into Loki for the log lines. Step-by-step queries are in the observability runbook.