From 55186ccd3e5c0f93ada1019f73166482cb99dba0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 18 Jul 2026 01:56:54 +0200 Subject: [PATCH] feat(observability): fireAndForget breadcrumb for background side effects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Best-effort background work was swallowed with `.catch(() => {})` — a persistent failure (a broken reminder enqueue, a lost re-auth transition) left no annotate() or logger trail, against the project's own 'annotate() on every interesting path' rule. Add `fireAndForget(promise, { action })`: it annotates the rejection into the current wide event under a per-action meta key (so it rides the central redaction at emit time — the raw message never hits the console) and warns the action label only for failures that settle after the event flushed. Control flow is unchanged; the promise stays fire-and-forget. Adopt it at the meaningful sites: reminder-satisfy enqueues (measurement + labs writes and the five integration sync workers), the post-write safety-floor dispatch, and the consent audit-log write. The Withings re-auth mark stays awaited (its persisted state feeds the same response) but now breadcrumbs its failure instead of swallowing it. --- src/app/api/consent/ai/route.ts | 12 ++- src/app/api/labs/ocr/commit/route.ts | 5 +- src/app/api/labs/route.ts | 5 +- src/app/api/measurements/batch/route.ts | 5 +- src/app/api/measurements/route.ts | 67 +++++++++------- src/app/api/withings/status/route.ts | 17 ++++- src/lib/jobs/reminder/fitbit-sync.ts | 5 +- src/lib/jobs/reminder/google-health-sync.ts | 5 +- src/lib/jobs/reminder/poll-cohort.ts | 5 +- src/lib/jobs/reminder/whoop-sync.ts | 5 +- src/lib/jobs/reminder/withings-sync.ts | 13 +++- .../logging/__tests__/fire-and-forget.test.ts | 76 +++++++++++++++++++ src/lib/logging/fire-and-forget.ts | 51 +++++++++++++ 13 files changed, 228 insertions(+), 43 deletions(-) create mode 100644 src/lib/logging/__tests__/fire-and-forget.test.ts create mode 100644 src/lib/logging/fire-and-forget.ts diff --git a/src/app/api/consent/ai/route.ts b/src/app/api/consent/ai/route.ts index 8679be132..630a7aa86 100644 --- a/src/app/api/consent/ai/route.ts +++ b/src/app/api/consent/ai/route.ts @@ -9,6 +9,7 @@ */ import { NextRequest } from "next/server"; import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { fireAndForget } from "@/lib/logging/fire-and-forget"; import { apiError, apiSuccess, @@ -56,10 +57,13 @@ export const POST = apiHandler(async (request: NextRequest) => { // reconstruct "who minted this and when" even if the receipts table // is rebuilt from a backup. Fire-and-forget; audit failures must // never bubble into a user-facing 500. - auditLog("consent.ai.grant", { - userId: user.id, - details: { kind, receiptId: receipt.id }, - }).catch(() => {}); + fireAndForget( + auditLog("consent.ai.grant", { + userId: user.id, + details: { kind, receiptId: receipt.id }, + }), + { action: "consent.ai.audit" }, + ); annotate({ action: { name: "consent.ai.grant" }, diff --git a/src/app/api/labs/ocr/commit/route.ts b/src/app/api/labs/ocr/commit/route.ts index 50a3dae50..4f7decad4 100644 --- a/src/app/api/labs/ocr/commit/route.ts +++ b/src/app/api/labs/ocr/commit/route.ts @@ -11,6 +11,7 @@ * `data` object is built field-by-field (no mass assignment). */ import { NextRequest } from "next/server"; +import { fireAndForget } from "@/lib/logging/fire-and-forget"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { @@ -210,7 +211,9 @@ async function commitOcrRows(request: NextRequest) { // A lab panel just landed — resolve any "annual blood panel" reminders now // rather than waiting on the cron. Fire-and-forget. if (inserted.length > 0) { - void enqueueReminderSatisfy(user.id).catch(() => {}); + fireAndForget(enqueueReminderSatisfy(user.id), { + action: "reminder.satisfy.enqueue", + }); } return apiSuccess({ inserted, skipped }); diff --git a/src/app/api/labs/route.ts b/src/app/api/labs/route.ts index 0d2ea94c7..1bab6a5fb 100644 --- a/src/app/api/labs/route.ts +++ b/src/app/api/labs/route.ts @@ -1,4 +1,5 @@ import { NextRequest } from "next/server"; +import { fireAndForget } from "@/lib/logging/fire-and-forget"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { @@ -271,7 +272,9 @@ async function postLabResult(request: NextRequest) { // v1.18.1 (D2) — eventful Lab↔Vorsorge satisfaction. A lab panel just // landed; resolve the user's free-text "annual blood panel" reminders // now rather than waiting on the 15-min cron. Fire-and-forget. - void enqueueReminderSatisfy(user.id).catch(() => {}); + fireAndForget(enqueueReminderSatisfy(user.id), { + action: "reminder.satisfy.enqueue", + }); return apiSuccess(serialiseLabResult(created, biomarker), 201); } diff --git a/src/app/api/measurements/batch/route.ts b/src/app/api/measurements/batch/route.ts index 24e4e9131..0a49e997f 100644 --- a/src/app/api/measurements/batch/route.ts +++ b/src/app/api/measurements/batch/route.ts @@ -34,6 +34,7 @@ * contract. */ import { NextRequest } from "next/server"; +import { fireAndForget } from "@/lib/logging/fire-and-forget"; import { z } from "zod/v4"; import { prisma } from "@/lib/db"; @@ -682,7 +683,9 @@ async function postBatch(request: NextRequest): Promise { // v1.18.1 — eventful Vorsorge satisfaction. A matching reading just // landed; resolve the user's reminders now rather than waiting on the // 15-min cron. Fire-and-forget — the cron reconciles on enqueue miss. - void enqueueReminderSatisfy(user.id).catch(() => {}); + fireAndForget(enqueueReminderSatisfy(user.id), { + action: "reminder.satisfy.enqueue", + }); try { await enqueuePrDetection(user.id, { silent }); await auditLog("personal_records.detection_enqueued", { diff --git a/src/app/api/measurements/route.ts b/src/app/api/measurements/route.ts index 52fbac848..6aaf88a23 100644 --- a/src/app/api/measurements/route.ts +++ b/src/app/api/measurements/route.ts @@ -1,6 +1,7 @@ import { prisma } from "@/lib/db"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; +import { fireAndForget } from "@/lib/logging/fire-and-forget"; import { auditLog } from "@/lib/auth/audit"; import { apiSuccess, @@ -820,7 +821,9 @@ async function postMeasurement(request: NextRequest) { // v1.18.1 — eventful Vorsorge satisfaction. Resolve the user's // reminders against the just-landed readings now. Fire-and-forget. - void enqueueReminderSatisfy(user.id).catch(() => {}); + fireAndForget(enqueueReminderSatisfy(user.id), { + action: "reminder.satisfy.enqueue", + }); // v1.18.6 — absolute clinical safety-floor check on the just-written // readings (confirm-gated, module-gated, never diagnoses). The combined @@ -828,19 +831,22 @@ async function postMeasurement(request: NextRequest) { // The transient per-entry `symptomsPresent` flag (ORed across the batch) // lifts a confirmed breach to the symptom-coupled emergency copy. // Fire-and-forget — a notification failure never fails the write. - void runSafetyFloorCheck({ - userId: user.id, - written: results.map((r) => ({ - type: r.type, - value: r.value, - measuredAt: r.measuredAt, - glucoseContext: r.glucoseContext, - })), - symptomsPresent: parsed.data.measurements.some( - (m) => m.symptomsPresent === true, - ), - timezone: user.timezone ?? undefined, - }).catch(() => {}); + fireAndForget( + runSafetyFloorCheck({ + userId: user.id, + written: results.map((r) => ({ + type: r.type, + value: r.value, + measuredAt: r.measuredAt, + glucoseContext: r.glucoseContext, + })), + symptomsPresent: parsed.data.measurements.some( + (m) => m.symptomsPresent === true, + ), + timezone: user.timezone ?? undefined, + }), + { action: "measurement.safety_floor.check" }, + ); // v1.5.0 — refresh the persistent rollup table for every distinct // (type, day) the batch touched so the next analytics / coach read @@ -967,7 +973,9 @@ async function postMeasurement(request: NextRequest) { // v1.18.1 — eventful Vorsorge satisfaction. Resolve the user's reminders // against the just-landed reading now. Fire-and-forget. - void enqueueReminderSatisfy(user.id).catch(() => {}); + fireAndForget(enqueueReminderSatisfy(user.id), { + action: "reminder.satisfy.enqueue", + }); // v1.18.6 — absolute clinical safety-floor check (confirm-gated, // module-gated, never diagnoses). A single-entry POST carries only one arm @@ -975,19 +983,22 @@ async function postMeasurement(request: NextRequest) { // batch path; a lone glucose reading is evaluated here. The transient // `symptomsPresent` flag lifts a confirmed breach to the emergency copy. // Fire-and-forget — a notification failure never fails the write. - void runSafetyFloorCheck({ - userId: user.id, - written: [ - { - type: measurement.type, - value: measurement.value, - measuredAt: measurement.measuredAt, - glucoseContext: measurement.glucoseContext, - }, - ], - symptomsPresent, - timezone: user.timezone ?? undefined, - }).catch(() => {}); + fireAndForget( + runSafetyFloorCheck({ + userId: user.id, + written: [ + { + type: measurement.type, + value: measurement.value, + measuredAt: measurement.measuredAt, + glucoseContext: measurement.glucoseContext, + }, + ], + symptomsPresent, + timezone: user.timezone ?? undefined, + }), + { action: "measurement.safety_floor.check" }, + ); // v1.5.0 — refresh the persistent rollup row for the affected // (type, day) tuple. Runs inline so the next read of the diff --git a/src/app/api/withings/status/route.ts b/src/app/api/withings/status/route.ts index 68c306513..7e4311481 100644 --- a/src/app/api/withings/status/route.ts +++ b/src/app/api/withings/status/route.ts @@ -83,7 +83,22 @@ export const GET = apiHandler(async () => { // the sync path does. const message = error instanceof Error ? error.message : String(error); if (isWithingsRefreshReauthFailure(message)) { - await markReauthRequired(user.id, "withings", message).catch(() => {}); + // Awaited so the persisted re-auth state is reflected in this same + // status response — but a persistence failure must not fail the read. + // Breadcrumb it instead of swallowing silently (the message rides the + // central annotate/redact path, never the raw console). + await markReauthRequired(user.id, "withings", message).catch( + (error: unknown) => { + annotate({ + meta: { + "fire_and_forget.integration.reauth.mark": { + error: error instanceof Error ? error.message : String(error), + provider: "withings", + }, + }, + }); + }, + ); } } } diff --git a/src/lib/jobs/reminder/fitbit-sync.ts b/src/lib/jobs/reminder/fitbit-sync.ts index b591bfcd9..d9a45b895 100644 --- a/src/lib/jobs/reminder/fitbit-sync.ts +++ b/src/lib/jobs/reminder/fitbit-sync.ts @@ -5,6 +5,7 @@ * schedules, and boss.work registrations. */ import { type Job } from "pg-boss"; +import { fireAndForget } from "@/lib/logging/fire-and-forget"; import { recordError } from "@/lib/jobs/worker-status"; import { withBackgroundEvent } from "@/lib/logging/background"; import { runFitbitPollCohort } from "@/lib/fitbit/sync"; @@ -50,7 +51,9 @@ export async function handleFitbitSync(jobs: Job[]) { // reminders eventfully. Fire-and-forget; the cron is the net. onUserSynced: (userId, imported) => { if (imported > 0) { - void enqueueReminderSatisfy(userId).catch(() => {}); + fireAndForget(enqueueReminderSatisfy(userId), { + action: "reminder.satisfy.enqueue", + }); } }, }, diff --git a/src/lib/jobs/reminder/google-health-sync.ts b/src/lib/jobs/reminder/google-health-sync.ts index 66b921332..0006f5ca8 100644 --- a/src/lib/jobs/reminder/google-health-sync.ts +++ b/src/lib/jobs/reminder/google-health-sync.ts @@ -5,6 +5,7 @@ * queue names, cron schedules, and boss.work registrations. */ import { type Job } from "pg-boss"; +import { fireAndForget } from "@/lib/logging/fire-and-forget"; import { recordError } from "@/lib/jobs/worker-status"; import { withBackgroundEvent } from "@/lib/logging/background"; import { runGoogleHealthPollCohort } from "@/lib/google-health/sync"; @@ -56,7 +57,9 @@ export async function handleGoogleHealthSync( // eventfully. Fire-and-forget; the cron is the net. onUserSynced: (userId, imported) => { if (imported > 0) { - void enqueueReminderSatisfy(userId).catch(() => {}); + fireAndForget(enqueueReminderSatisfy(userId), { + action: "reminder.satisfy.enqueue", + }); } }, }, diff --git a/src/lib/jobs/reminder/poll-cohort.ts b/src/lib/jobs/reminder/poll-cohort.ts index a0e086aca..2e716552b 100644 --- a/src/lib/jobs/reminder/poll-cohort.ts +++ b/src/lib/jobs/reminder/poll-cohort.ts @@ -18,6 +18,7 @@ * from this sequential pass. */ import { type Job } from "pg-boss"; +import { fireAndForget } from "@/lib/logging/fire-and-forget"; import { recordError } from "@/lib/jobs/worker-status"; import { withBackgroundEvent } from "@/lib/logging/background"; @@ -76,7 +77,9 @@ export function makePollCohortHandler({ // v1.18.1 — a fresh reading landed; resolve this user's Vorsorge // reminders eventfully. Fire-and-forget; the cron is the net. if (imported > 0) { - void enqueueReminderSatisfy(userId).catch(() => {}); + fireAndForget(enqueueReminderSatisfy(userId), { + action: "reminder.satisfy.enqueue", + }); } } catch (err) { evt.addWarning(`${taskName} failed for user ${userId}: ${err}`); diff --git a/src/lib/jobs/reminder/whoop-sync.ts b/src/lib/jobs/reminder/whoop-sync.ts index 92a1e2ec3..130740e65 100644 --- a/src/lib/jobs/reminder/whoop-sync.ts +++ b/src/lib/jobs/reminder/whoop-sync.ts @@ -5,6 +5,7 @@ * schedules, and boss.work registrations. */ import { type Job } from "pg-boss"; +import { fireAndForget } from "@/lib/logging/fire-and-forget"; import { recordError } from "@/lib/jobs/worker-status"; import { withBackgroundEvent } from "@/lib/logging/background"; import { @@ -101,7 +102,9 @@ export async function runWhoopResourceSync( // v1.18.1 — a fresh reading landed; resolve this user's Vorsorge // reminders eventfully. Fire-and-forget; the cron is the net. if (imported > 0) { - void enqueueReminderSatisfy(userId).catch(() => {}); + fireAndForget(enqueueReminderSatisfy(userId), { + action: "reminder.satisfy.enqueue", + }); } } catch (err) { evt.addWarning(`${taskName} failed for user ${userId}: ${err}`); diff --git a/src/lib/jobs/reminder/withings-sync.ts b/src/lib/jobs/reminder/withings-sync.ts index 0ebf2d182..24320b06d 100644 --- a/src/lib/jobs/reminder/withings-sync.ts +++ b/src/lib/jobs/reminder/withings-sync.ts @@ -5,6 +5,7 @@ * schedules, and boss.work registrations. */ import { type Job } from "pg-boss"; +import { fireAndForget } from "@/lib/logging/fire-and-forget"; import { recordError, recordWithingsSync } from "@/lib/jobs/worker-status"; import { withBackgroundEvent } from "@/lib/logging/background"; import { syncUserMeasurements } from "@/lib/withings/sync"; @@ -68,7 +69,9 @@ export async function handleWithingsFallbackSync( // v1.18.1 — a fresh reading landed; resolve this user's Vorsorge // reminders eventfully. Fire-and-forget; the cron is the net. if (imported > 0) { - void enqueueReminderSatisfy(connection.userId).catch(() => {}); + fireAndForget(enqueueReminderSatisfy(connection.userId), { + action: "reminder.satisfy.enqueue", + }); } } catch (err) { evt.addWarning( @@ -138,7 +141,9 @@ export async function handleWithingsActivitySync( usersSynced++; measurementsImported += imported; if (imported > 0) { - void enqueueReminderSatisfy(userId).catch(() => {}); + fireAndForget(enqueueReminderSatisfy(userId), { + action: "reminder.satisfy.enqueue", + }); } } catch (err) { evt.addWarning( @@ -196,7 +201,9 @@ export async function handleWithingsSleepSync( usersSynced++; measurementsImported += imported; if (imported > 0) { - void enqueueReminderSatisfy(userId).catch(() => {}); + fireAndForget(enqueueReminderSatisfy(userId), { + action: "reminder.satisfy.enqueue", + }); } } catch (err) { evt.addWarning( diff --git a/src/lib/logging/__tests__/fire-and-forget.test.ts b/src/lib/logging/__tests__/fire-and-forget.test.ts new file mode 100644 index 000000000..a0fbb3e23 --- /dev/null +++ b/src/lib/logging/__tests__/fire-and-forget.test.ts @@ -0,0 +1,76 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { fireAndForget } from "../fire-and-forget"; +import { eventStorage } from "../context"; +import { WideEventBuilder } from "../event-builder"; + +const flush = () => new Promise((resolve) => setTimeout(resolve, 0)); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("fireAndForget", () => { + it("does not throw and does not reject when the promise resolves", async () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + fireAndForget(Promise.resolve("ok"), { action: "test.noop.run" }); + await flush(); + expect(warn).not.toHaveBeenCalled(); + }); + + it("annotates the current wide event with the redacted error breadcrumb", async () => { + vi.spyOn(console, "warn").mockImplementation(() => {}); + const builder = new WideEventBuilder(); + + await eventStorage.run(builder, async () => { + fireAndForget(Promise.reject(new Error("boom")), { + action: "reminder.satisfy.enqueue", + meta: { userScope: "u_1" }, + }); + await flush(); + }); + + const event = builder.toJSON(); + const crumb = event.meta?.["fire_and_forget.reminder.satisfy.enqueue"] as + { error: string; userScope?: string } | undefined; + expect(crumb).toBeDefined(); + expect(crumb?.error).toBe("boom"); + expect(crumb?.userScope).toBe("u_1"); + }); + + it("warns with the action label only — never the error payload", async () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + fireAndForget(Promise.reject(new Error("secret-token-xyz")), { + action: "integration.reauth.mark", + }); + await flush(); + expect(warn).toHaveBeenCalledTimes(1); + const logged = warn.mock.calls[0].join(" "); + expect(logged).toContain("integration.reauth.mark"); + expect(logged).not.toContain("secret-token-xyz"); + }); + + it("is a safe no-op outside a request context", async () => { + vi.spyOn(console, "warn").mockImplementation(() => {}); + expect(() => { + fireAndForget(Promise.reject(new Error("no context")), { + action: "test.orphan.run", + }); + }).not.toThrow(); + await flush(); + }); + + it("coerces a non-Error rejection to a string message", async () => { + vi.spyOn(console, "warn").mockImplementation(() => {}); + const builder = new WideEventBuilder(); + await eventStorage.run(builder, async () => { + fireAndForget(Promise.reject("plain-string"), { + action: "test.reject.string", + }); + await flush(); + }); + const crumb = builder.toJSON().meta?.[ + "fire_and_forget.test.reject.string" + ] as { error: string } | undefined; + expect(crumb?.error).toBe("plain-string"); + }); +}); diff --git a/src/lib/logging/fire-and-forget.ts b/src/lib/logging/fire-and-forget.ts new file mode 100644 index 000000000..93fdc0fb7 --- /dev/null +++ b/src/lib/logging/fire-and-forget.ts @@ -0,0 +1,51 @@ +import { annotate } from "./context"; + +/** + * Run a best-effort background promise and leave an observable breadcrumb + * when it rejects — the disciplined replacement for `.catch(() => {})`, which + * swallows a background failure with no `annotate()` or logger call. + * + * Use it for work whose failure must not fail the request but SHOULD be + * visible: background enqueues (reminder satisfaction, rollup refresh), + * re-auth state transitions, and post-write sync side effects. + * + * Observability without a control-flow change: + * - The rejection reason is annotated into the current wide event under a + * per-action `meta` key, so it flows through the SAME central redaction + * the event builder applies at emit time — the raw message is never + * logged unredacted. When the work settles after the event has already + * flushed (or runs outside a request context, e.g. a pg-boss worker), + * `annotate()` is a safe no-op. + * - A payload-free `console.warn` (SWC keeps `warn` in prod) records the + * action name so a late-settling failure still surfaces in the logs. + * Only the caller-supplied action label — never the error text — reaches + * the raw console sink, keeping it redaction-safe by construction. + * + * The promise stays fire-and-forget: the caller does not await it and its + * resolution is discarded exactly as before. + * + * @param promise The background work. May already be running. + * @param context.action A stable `..` label (never user + * input) identifying which background task failed. + * @param context.meta Optional extra fields to attach to the breadcrumb; they + * ride the annotate/redact path with the error message. + */ +export function fireAndForget( + promise: Promise, + context: { action: string; meta?: Record }, +): void { + void Promise.resolve(promise).catch((error: unknown) => { + const message = error instanceof Error ? error.message : String(error); + annotate({ + meta: { + [`fire_and_forget.${context.action}`]: { + error: message, + ...context.meta, + }, + }, + }); + // Action label only — the error payload rides the redacted annotate path + // above, so nothing sensitive reaches the raw console sink. + console.warn(`[fire-and-forget] background task failed: ${context.action}`); + }); +}