This repo now ships the code half of uptime/error monitoring: Sentry in the
API and frontend, and a real email alert on ingestion failure or stale data
(see api/main.py, frontend/instrumentation-client.ts /
frontend/sentry.server.config.ts / frontend/sentry.edge.config.ts, and
.github/workflows/ingest_prod.yml).
The uptime-checker half is external SaaS configuration, not code — do this once, by hand:
- Create a free org/project at https://sentry.io — one project for the API (Python/FastAPI platform), one for the frontend (Next.js platform).
- Set these as Railway environment variables (API project):
SENTRY_DSNSENTRY_ENVIRONMENT=productionSENTRY_TRACES_SAMPLE_RATE=0.1(optional, defaults to0.1)
- Set these as Vercel environment variables (frontend project):
NEXT_PUBLIC_SENTRY_DSNNEXT_PUBLIC_SENTRY_ENVIRONMENT=productionSENTRY_DSN(server-side; can be the same DSN as the public one)SENTRY_ENVIRONMENT=productionSENTRY_ORG,SENTRY_PROJECT,SENTRY_AUTH_TOKEN(build-time source map upload — generate the token under User Settings → Auth Tokens withproject:releasesandorg:readscopes)
- Redeploy both. Trigger a test error (e.g. a bad request) and confirm it shows up in each Sentry project with request context attached.
Add these as GitHub Actions repository secrets (Settings → Secrets and variables → Actions):
MAIL_SERVER,MAIL_PORT— your SMTP provider (e.g. Gmail SMTP, SendGrid, or any transactional email provider)MAIL_USERNAME,MAIL_PASSWORD— SMTP credentials (for Gmail, use an app password, not your normal password)NOTIFY_EMAIL_TO— where alerts should land
Until these are set, the Email failure alert step in ingest_prod.yml is
skipped silently — the existing job-log ::error:: and step-summary output
still fire either way. Once set, a failed ingestion step or a dbt source freshness failure (stale data past the thresholds in
transform/models/staging/_sources.yml) both trigger an email.
Create two monitors:
- API health —
GET https://monelu-production.up.railway.app/health- Assert HTTP status is
200. Note:/healthalso returns207when degraded (e.g. dbt marts absent) — treat207as a warning-level alert, not a hard failure, since it can be expected right after a fresh deploy. - If the provider supports a keyword/body assertion, also check the
response body contains
"status":"ok". - Check interval: 5 minutes (matches the acceptance criterion "downtime produces an alert within 5 minutes").
- Assert HTTP status is
- Frontend homepage — the production Vercel URL, plain HTTP-200 uptime check, same interval.
Set the alert contact to email for both monitors.
Both UptimeRobot and Better Stack offer a free public status page backed by these same monitors — worth turning on for customer-facing trust once the above two monitors are live and stable.
Every user signal lands in one generic feedback table (see
data/migrations/005_feedback.sql), discriminated by a type column:
type = 'chat'— thumbs up/down on a RAG chat answer (MON-70). Payload:{vote, question, answer, sources}.type = 'report'— a "Signaler une erreur" report from a data page (MON-101). Payload:{entity_type, entity_id, entity_label, page_url, message, email}.
There is no dashboard yet — review is a weekly manual query. Run this against prod Supabase to list the last week's feedback, newest first:
SELECT
created_at,
type,
payload ->> 'entity_type' AS entity_type,
payload ->> 'entity_label' AS entity_label,
payload ->> 'vote' AS chat_vote,
left(coalesce(payload ->> 'message', payload ->> 'question'), 200) AS text,
payload ->> 'email' AS reply_to
FROM feedback
WHERE created_at >= now() - interval '7 days'
ORDER BY created_at DESC;Error reports (type = 'report') that carry a reply_to e-mail are the ones a
user expects a response to. Build a dashboard only once weekly volume makes the
manual query impractical.
Per ADR-029: no Stripe/webhook billing is built until a real paying commercial
API customer exists. Until then, a heavy commercial user is moved to a paid
tier by hand, reusing MON-98's existing api_keys.rate_limit_multiplier
column and manual issuance flow. This is the runbook for that manual step.
A commercial user asking for a higher rate limit is pointed at one of:
- HelloAsso (default) — the same donation link used on the
/coutscost-transparency page. Fits a recurring or one-off contribution from an organisation comfortable giving through an association donation rail. - A one-off Stripe payment link, for a business that needs a proper invoice/receipt HelloAsso doesn't produce. Create a single Stripe Payment Link by hand in the Stripe Dashboard for the agreed amount — no MonÉlu-side Stripe integration, webhook, or API key is involved. Delete or let the link expire after use.
Either way, payment confirmation is a manual check (HelloAsso dashboard / Stripe Dashboard / bank statement) — there is no automated hook.
If the customer already has a free-tier key (issued per the process on
/developpeurs — email request to [email protected], then a manual
row insert), skip to Step 3. Otherwise, issue one first:
# Generate a raw key and its sha256 hash (never store the raw key).
# Run in a shell with history disabled/cleared afterward, e.g. `set +o history` first.
python3 -c "import secrets, hashlib; raw = secrets.token_urlsafe(32); print('raw:', raw); print('hash:', hashlib.sha256(raw.encode()).hexdigest())"INSERT INTO api_keys (key_hash, label, contact_email, rate_limit_multiplier)
VALUES ('<hash from above>', '<customer/org name>', '<their email>', 4);Send the customer the raw key over a private channel (email) — it is never recoverable from the database afterwards.
contact_email is neither unique nor required, so confirm the exact row
first rather than matching on email alone:
SELECT id, label, contact_email, rate_limit_multiplier
FROM api_keys
WHERE contact_email = '<their email>' AND revoked_at IS NULL;Once payment is confirmed, raise that key's multiplier by id (effective
limit is base * rate_limit_multiplier, see
data/migrations/004_api_keys.sql):
UPDATE api_keys
SET rate_limit_multiplier = <agreed multiplier>
WHERE id = <id from the SELECT above>;Pick the multiplier by agreement with the customer (e.g. 10x-20x for a small
commercial integration); there is no fixed pricing tier table yet — this is
one-off, negotiated per customer, consistent with ADR-029 not building a
plan column. The in-process key cache (api/auth.py) picks up the change
within 5 minutes (_CACHE_TTL_SECONDS), no deploy needed.
If a paid arrangement ends, either revoke the key entirely or drop it back to
the default multiplier. Again, confirm the id via the SELECT above first:
-- Revoke outright:
UPDATE api_keys SET revoked_at = now() WHERE id = <id>;
-- Or just drop back to the free-tier default:
UPDATE api_keys SET rate_limit_multiplier = 4 WHERE id = <id>;