feat(observability): fireAndForget breadcrumb for background side effects

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.
This commit is contained in:
Marc-André Bombeck committed 2026-07-18 02:09:33 +02:00
1 parent a7c82eb582
commit 55186ccd3e
13 files changed
+228 -43

No files matched your search

+8 -4
View File
@@ -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" },
+4 -1
View File
@@ -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 });
+4 -1
View File
@@ -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);
}
+4 -1
View File
@@ -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<Response> {
// 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", {
+39 -28
View File
@@ -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
+16 -1
View File
@@ -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",
},
},
});
},
);
}
}
}
+4 -1
View File
@@ -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<FitbitSyncPayload>[]) {
// 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",
});
}
},
},
+4 -1
View File
@@ -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",
});
}
},
},
+4 -1
View File
@@ -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}`);
+4 -1
View File
@@ -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}`);
+10 -3
View File
@@ -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(
@@ -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");
});
});
+51
View File
@@ -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 `<surface>.<noun>.<verb>` 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<unknown>,
context: { action: string; meta?: Record<string, unknown> },
): 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}`);
});
}