mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
Bundles the deferred v1.4.47 Mediums + Lows with the v1.4.48-deferred items into a single release. Headline: server-side suppression of MEDICATION_REMINDER APNs for iOS clients that own their local reminders (closes GH #206), a push_attempts table backing /api/admin/notifications/diagnostic, and a sweep of v1.4.48 QA forward-findings. Performance (the user-visible win): - /api/analytics cold-fallback path splits the heavy single-statement aggregate into two parallel queries. The linearly composable columns (count / min / max / mean) keep the full-partition scan; the windowed 7 / 30 / 90-day avg / slope / r² columns take a 90-day outer cap so the planner does an index range scan on (user_id, type, measured_at) instead of a full-partition sequential scan. Output is bit-identical; cold slim slice drops from multi-second to sub-second on tenants with large measurement partitions. Server-side notifications: - GET + PATCH /api/auth/me/notification-prefs with deep-merge semantics. Initial category medication.clientManaged: boolean (default false). Cron medicationReminderJob skips dose-due APNs when the flag is true, emitting a medication_reminder.suppressed_client_managed wide-event annotation per skip. - push_attempts table backs the diagnostic endpoint. Every APNS / Web-Push / Telegram / NTFY sender writes a fire-and-forget row. Daily 03:35 Europe/Berlin cleanup prunes rows older than 90 days. - /api/admin/notifications/diagnostic — admin-only endpoint surfacing device APNs token presence (masked), channel config presence, and recent push attempts. Registered in OpenAPI. - Per-channel APNs "Send test" button now fires with eventType: "MEDICATION_REMINDER" so the dispatcher's time-sensitive + priority-10 branch runs. A successful self-test now exercises the exact path production reminders take; an unsuccessful one points the user at iOS Settings → Time Sensitive Notifications rather than appearing to be a server-side bug. Withings reliability: - OAuth callback consumes the nonce atomically (delete-first with P2025 catch for the replay branch). The previous read-then-delete pattern allowed a race between two concurrent callbacks. - ?reason=state redirect differentiated into csrf1 / replay / expired / cross_user so operators can tell mismatch types apart without DB shell access. - connect endpoint rate-limited to 10/60s/user; row-create failures redirect to a consistent error surface. Observability hardening: - redactSensitiveFields(body) denylist helper covers password / token / secret / apiKey / authorization / csrfState / nonce, recursive, case-insensitive. Caught: /api/devices invalid_format issues were echoing apnsToken values verbatim into the audit log. - sanitiseZodIssues(issues, { stripValuesFromMessage: true }) overload applied to 14 audit-log sites. Migrations: 0079 (notification_prefs), 0080 (push_attempts). Tests: 4815 → 5272 unit + integration. Workout-batch integration suite that had been red on main since v1.4.45 (three release commits shipped through it) now green again — updated to match v1.4.42 cross-source dedup semantics. Resolves PR #207.
53 lines
2.5 KiB
Markdown
53 lines
2.5 KiB
Markdown
# `attemptNumber` audit-row vs alert-signal semantics
|
||
|
||
Two numbers describe a failing integration sync, and they can diverge.
|
||
On-call operators reading an audit-log row alongside an admin alert
|
||
should expect the alert number to be smaller than (or equal to) the
|
||
audit number, never the other way around.
|
||
|
||
## The two numbers
|
||
|
||
`integrations.sync.failed` audit rows write
|
||
`attemptNumber = transient + reauth_required + persistent` — the **sum**
|
||
of all three failure buckets. This is the historical "consecutive
|
||
failures" running total carried by the legacy
|
||
`IntegrationStatus.consecutiveFailures` column (dropped in migration
|
||
`0077`, v1.4.47). Auditors looking at a single row see one integer
|
||
that captures how many failures the bucket ledger has accumulated since
|
||
the last success.
|
||
|
||
The persistent-failure alert dispatcher in
|
||
`src/lib/integrations/status.ts:453` uses `Math.max(...buckets)` — the
|
||
**max** across the three buckets. A 3-deep persistent streak pages even
|
||
when the transient bucket is empty, and a flapping integration that
|
||
ping-pongs between transient and reauth_required never trips a
|
||
single-bucket threshold even if its sum looks alarming.
|
||
|
||
## When the two diverge
|
||
|
||
A typed-failure-streak edge case: integration fails 2× transient, then
|
||
2× reauth_required, then 1× persistent. Audit row shows
|
||
`attemptNumber: 5`. Alert signal is `max(2, 2, 1) = 2`, which is below
|
||
the default threshold of 3, so no page fires. From the audit log this
|
||
looks like a sustained 5-failure outage; from the alert dispatcher it
|
||
looks like three small bumps that never crossed a single-bucket
|
||
ceiling.
|
||
|
||
Both readings are correct for their purpose. The audit log records what
|
||
happened end-to-end; the alert dispatcher only fires when a single
|
||
failure mode persists, because mixed-bucket streaks usually indicate a
|
||
transient network blip layered on top of an unrelated reauth prompt —
|
||
two problems, neither of which is the "this integration is broken" page
|
||
we want to wake someone up for.
|
||
|
||
## Runbook implication
|
||
|
||
If you see an audit row with `attemptNumber >= threshold` but no alert
|
||
was dispatched, check the per-bucket counts on
|
||
`IntegrationStatus.failureBuckets` for that user/integration pair. The
|
||
sum may be high while every individual bucket sits below the threshold.
|
||
This is working as designed; manually investigate only if a single
|
||
bucket is at or above the threshold and `alertedAt` is unset (broken
|
||
dispatcher) or set within the last 24 hours (rate-limited, see the
|
||
`ALERT_REPEAT_WINDOW_MS` constant).
|