diff --git a/docs/integrations/google-health.md b/docs/integrations/google-health.md index 50c8890d5..0b187be94 100644 --- a/docs/integrations/google-health.md +++ b/docs/integrations/google-health.md @@ -265,7 +265,10 @@ reading: nothing left but a data type waiting for its second attempt and queued the next slice for when it is due (`delaySeconds`). - `meta["googleHealth.backfill.ended"]` appears on the slice where a data - type ended short, with the same entries. A walk stopped for lack of + type ended short: the `collection`, its measurement `type`, the `reason`, + whether the end is `final`, how many data types have ended short so far + (`endedCount`) and of those for good (`finalCount`), and their `types`. + The full entries are in the slice report's `ended`. A walk stopped for lack of progress also leaves a warning: `fetchHeartRate: 20 pages in a row reached no further back than `. - `meta["googleHealth.backfill.edgeFilterUnsupported"]` appears on the slice diff --git a/src/lib/google-health/__tests__/backfill-bounds.test.ts b/src/lib/google-health/__tests__/backfill-bounds.test.ts index cc76b51ba..7bc28ad46 100644 --- a/src/lib/google-health/__tests__/backfill-bounds.test.ts +++ b/src/lib/google-health/__tests__/backfill-bounds.test.ts @@ -9,9 +9,10 @@ */ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -const { prismaMock, safeFetchMock } = vi.hoisted(() => ({ +const { prismaMock, safeFetchMock, annotateMock } = vi.hoisted(() => ({ prismaMock: { googleHealthConnection: { update: vi.fn() } }, safeFetchMock: vi.fn(), + annotateMock: vi.fn(), })); vi.mock("@/lib/db", () => ({ prisma: prismaMock })); vi.mock("@/lib/safe-fetch", async (importOriginal) => ({ @@ -19,7 +20,7 @@ vi.mock("@/lib/safe-fetch", async (importOriginal) => ({ safeFetch: safeFetchMock, })); vi.mock("@/lib/logging/context", () => ({ - annotate: () => {}, + annotate: annotateMock, getEvent: () => null, })); @@ -321,6 +322,24 @@ describe("a collection ended short", () => { expect(googleHealthBackfillProgressAfterCompletion(ended)).toBeNull(); }); + it("annotates an end in one fixed shape", async () => { + await slice(stalls, new Map([["fetchWeight", HORIZON_END]])); + const notes = annotateMock.mock.calls + .map(([fields]) => fields.meta?.["googleHealth.backfill.ended"]) + .filter(Boolean); + expect(notes).toEqual([ + { + collection: "fetchHeartRate", + type: "PULSE", + reason: "no_progress", + final: false, + endedCount: 2, + finalCount: 1, + types: ["PULSE", "WEIGHT"], + }, + ]); + }); + it("is not walked again before its day is up", async () => { const walk = vi.fn(async () => 1); const { skipped } = await slice( diff --git a/src/lib/google-health/sync-core.ts b/src/lib/google-health/sync-core.ts index 25cb8b885..542755bc8 100644 --- a/src/lib/google-health/sync-core.ts +++ b/src/lib/google-health/sync-core.ts @@ -612,8 +612,24 @@ export async function recordGoogleHealthBackfillEnd( } : end, ); + // A fixed shape, not the whole note: the slice report carries every end in + // full (`googleHealth.backfill.slice`). This names the one that just ended + // and counts the rest. It is meta, not the event's action, because the job + // names the action once the slice is over and that would replace it. + const recorded = ended.get(key)!; + const final = [...ended.values()].filter(googleHealthBackfillEndIsFinal); annotate({ - meta: { "googleHealth.backfill.ended": Object.fromEntries(ended) }, + meta: { + "googleHealth.backfill.ended": { + collection: key, + type: recorded.type, + reason: recorded.reason, + final: googleHealthBackfillEndIsFinal(recorded), + endedCount: ended.size, + finalCount: final.length, + types: [...new Set([...ended.values()].map((e) => e.type))].sort(), + }, + }, }); try { await persistBackfillProgress(resume, {