mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
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:
1 parent
a7c82eb582
commit
55186ccd3e
13 files changed
+228
-43
No files matched your search
@@ -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" },
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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", {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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}`);
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user