From 1c3c733fabf35aaef29c44da41b7500f21ccbfff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 6 Jul 2026 20:24:24 +0200 Subject: [PATCH 1/4] fix(google-health): build dailyRollUp ranges the way the docs demonstrate The first live sync on v1.27.4 imported every list-based type but died on the first cumulative chunk: fetchSteps(chunk=0,page=0) returned HTTP 400 INVALID_ARGUMENT "Invalid argument in request.". Re-verified the request contract against the v4 reference: - Request body (users.dataTypes.dataPoints/dailyRollUp): range { start/end: CivilDateTime }, windowSizeDays, pageSize, pageToken, dataSourceFamily - every field the client sends is legal under that exact name. - CivilDateTime (inlined on the DataPoint page): nested {date:{year,month,day}, time:{hours,minutes,seconds,nanos}} objects. - range: "Required. Closed-open range of data points that will be rolled up. The start time must be aligned with the aggregation window. The maximum range for calories-in-heart-rate-zone, heart-rate, active-minutes and total-calories is 14 days. The maximum range for all other data types is 90 days." - steps / distance / active-energy / floors sit in the 90-day class, so the 90-day chunk constant stands (the in-file "14-day-max" note applies to heart-rate only). - The worked dailyRollUp example (developers.google.com/health/endpoints) rolls up ONE civil day as start = {date: day, time: 00:00:00} and end = {date: SAME day, time: 23:59:59} - the end bound names the last covered day, not the next day's midnight. The previous body sent end = {date: } with no time object, so a maximal 90-day chunk touched 91 civil dates - the one divergence from the documented example, and the prime suspect for the 400 (the docs do not spell out how the cap counts days). - buildDailyRollUpBody() pins the documented shape: explicit date+time on both bounds, end = last covered day at 23:59:59, windowSizeDays: 1, no pageSize (the documented default of 1440 covers the <=90 windows a chunk can produce), pageToken threaded on follow-up pages. - fetchDailyRollUp() keeps 90-day chunking; if the very FIRST request of a walk is rejected with a 400 it re-walks the range once at the most conservative documented span (14 days, the tightest cap the docs state for any type) and annotates googleHealth.rollup.shape with the shape that succeeded, so the next live sync reports which constraint binds. - extractGoogleApiErrorDetail() surfaces AIP-193 error.details[] BadRequest fieldViolations as "field: description" fragments (field paths and descriptions only, redacted and capped at three), so the next 400 names the offending request field in the logs instead of the bare "Invalid argument in request.". - mapping.md documents the verified range semantics; unit tests pin the exact body JSON, the 90/91-day boundary, the fallback walk and the violation extraction. --- .../__tests__/rollup-request.test.ts | 290 ++++++++++++++++++ src/lib/google-health/client.ts | 270 +++++++++++----- src/lib/google-health/mapping.md | 11 +- 3 files changed, 494 insertions(+), 77 deletions(-) create mode 100644 src/lib/google-health/__tests__/rollup-request.test.ts diff --git a/src/lib/google-health/__tests__/rollup-request.test.ts b/src/lib/google-health/__tests__/rollup-request.test.ts new file mode 100644 index 000000000..54e23c31b --- /dev/null +++ b/src/lib/google-health/__tests__/rollup-request.test.ts @@ -0,0 +1,290 @@ +/** + * Pins the dailyRollUp request contract against the documented v4 shape. + * + * The request body mirrors the official example (developers.google.com/health/ + * endpoints): both range bounds are explicit CivilDateTime objects + * (`{date:{year,month,day}, time:{hours,minutes,seconds,nanos}}`) and the + * `end` bound is the LAST covered civil day at 23:59:59 — NOT the next day's + * midnight. The range validator counts the civil days a range touches against + * the documented cap ("The maximum range for all other data types is 90 + * days"), so an exclusive next-day-midnight end makes a maximal 90-day chunk + * read as 91 days and 400s — the shape that broke the first live backfill + * chunk. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const { safeFetchMock } = vi.hoisted(() => ({ safeFetchMock: vi.fn() })); +vi.mock("@/lib/safe-fetch", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, safeFetch: safeFetchMock }; +}); + +import { + GOOGLE_HEALTH_DATA_TYPES, + GOOGLE_HEALTH_ROLLUP_FALLBACK_RANGE_DAYS, + GOOGLE_HEALTH_ROLLUP_RANGE_DAYS, + buildDailyRollUpBody, + extractGoogleApiErrorDetail, + fetchDailyRollUp, + type GoogleHealthRollupShape, +} from "../client"; +import { GoogleHealthApiError } from "../response-classifier"; + +function jsonResponse(status: number, body: unknown): Response { + return { + status, + ok: status >= 200 && status < 300, + json: async () => body, + } as unknown as Response; +} + +/** Inclusive civil-day count a captured request-body range touches. */ +function touchedDays(body: { + range: { + start: { date: { year: number; month: number; day: number } }; + end: { date: { year: number; month: number; day: number } }; + }; +}): number { + const s = body.range.start.date; + const e = body.range.end.date; + return ( + (Date.UTC(e.year, e.month - 1, e.day) - + Date.UTC(s.year, s.month - 1, s.day)) / + (24 * 60 * 60 * 1000) + + 1 + ); +} + +function capturedBodies(): Array<{ + range: { + start: { date: { year: number; month: number; day: number } }; + end: { date: { year: number; month: number; day: number } }; + }; +}> { + return safeFetchMock.mock.calls.map((c) => + JSON.parse((c[1] as { body: string }).body), + ); +} + +beforeEach(() => { + safeFetchMock.mockReset(); + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-06-30T12:00:00Z")); +}); + +afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); +}); + +describe("buildDailyRollUpBody — documented request shape", () => { + it("matches the documented single-day example byte-for-byte", () => { + // Doc example: one civil day rolls up as [day 00:00:00, same day 23:59:59]. + expect( + buildDailyRollUpBody({ + start: { year: 2026, month: 2, day: 26 }, + end: { year: 2026, month: 2, day: 27 }, + }), + ).toEqual({ + range: { + start: { + date: { year: 2026, month: 2, day: 26 }, + time: { hours: 0, minutes: 0, seconds: 0, nanos: 0 }, + }, + end: { + date: { year: 2026, month: 2, day: 26 }, + time: { hours: 23, minutes: 59, seconds: 59, nanos: 0 }, + }, + }, + windowSizeDays: 1, + }); + }); + + it("keeps a maximal 90-day chunk inside the documented 90-day cap", () => { + const body = buildDailyRollUpBody({ + start: { year: 2026, month: 1, day: 1 }, + end: { year: 2026, month: 4, day: 1 }, // exclusive: 90 civil days + }) as Parameters[0]; + // End bound lands on the last covered day (Mar 31), not Apr 1 midnight. + expect(body.range.end.date).toEqual({ year: 2026, month: 3, day: 31 }); + expect(touchedDays(body)).toBe(GOOGLE_HEALTH_ROLLUP_RANGE_DAYS); + }); + + it("omits pageSize and carries pageToken only when paginating", () => { + const first = buildDailyRollUpBody({ + start: { year: 2026, month: 6, day: 1 }, + end: { year: 2026, month: 6, day: 8 }, + }); + expect(first).not.toHaveProperty("pageSize"); + expect(first).not.toHaveProperty("pageToken"); + const next = buildDailyRollUpBody( + { + start: { year: 2026, month: 6, day: 1 }, + end: { year: 2026, month: 6, day: 8 }, + }, + "tok", + ); + expect(next.pageToken).toBe("tok"); + }); +}); + +describe("fetchDailyRollUp — chunk walk + conservative fallback", () => { + it("walks 90-day chunks and reports the standard shape", async () => { + safeFetchMock.mockResolvedValue( + jsonResponse(200, { + rollupDataPoints: [{ steps: { countSum: "100" } }], + }), + ); + let shape: GoogleHealthRollupShape | undefined; + const points = await fetchDailyRollUp( + GOOGLE_HEALTH_DATA_TYPES.steps, + "token", + "fetchSteps", + { + start: new Date("2026-01-01T00:00:00Z"), + onShape: (s) => { + shape = s; + }, + }, + ); + expect(shape).toBe("days90"); + // 2026-01-01 → 2026-07-01 (exclusive tomorrow) = 181 days → 3 chunks. + expect(safeFetchMock).toHaveBeenCalledTimes(3); + expect(points).toHaveLength(3); + for (const body of capturedBodies()) { + expect(touchedDays(body)).toBeLessThanOrEqual( + GOOGLE_HEALTH_ROLLUP_RANGE_DAYS, + ); + } + }); + + it("re-walks at 14 days when the FIRST request is rejected with a 400", async () => { + safeFetchMock.mockResolvedValueOnce( + jsonResponse(400, { + error: { code: 400, status: "INVALID_ARGUMENT", message: "bad range" }, + }), + ); + safeFetchMock.mockResolvedValue( + jsonResponse(200, { rollupDataPoints: [] }), + ); + let shape: GoogleHealthRollupShape | undefined; + await fetchDailyRollUp( + GOOGLE_HEALTH_DATA_TYPES.steps, + "token", + "fetchSteps", + { + start: new Date("2026-06-01T00:00:00Z"), + onShape: (s) => { + shape = s; + }, + }, + ); + expect(shape).toBe("days14"); + // First body was a single 30-day chunk; the retry re-chunks at ≤14 days. + const bodies = capturedBodies(); + expect(bodies.length).toBeGreaterThan(1); + for (const body of bodies.slice(1)) { + expect(touchedDays(body)).toBeLessThanOrEqual( + GOOGLE_HEALTH_ROLLUP_FALLBACK_RANGE_DAYS, + ); + } + }); + + it("propagates a 400 past the first request instead of re-walking", async () => { + safeFetchMock.mockResolvedValueOnce( + jsonResponse(200, { rollupDataPoints: [] }), + ); + safeFetchMock.mockResolvedValueOnce( + jsonResponse(400, { + error: { code: 400, status: "INVALID_ARGUMENT", message: "nope" }, + }), + ); + await expect( + fetchDailyRollUp(GOOGLE_HEALTH_DATA_TYPES.steps, "token", "fetchSteps", { + start: new Date("2026-01-01T00:00:00Z"), + }), + ).rejects.toBeInstanceOf(GoogleHealthApiError); + expect(safeFetchMock).toHaveBeenCalledTimes(2); + }); + + it("propagates a non-400 first-request failure without a fallback walk", async () => { + safeFetchMock.mockResolvedValueOnce(jsonResponse(500, {})); + await expect( + fetchDailyRollUp(GOOGLE_HEALTH_DATA_TYPES.steps, "token", "fetchSteps", { + start: new Date("2026-06-01T00:00:00Z"), + }), + ).rejects.toBeInstanceOf(GoogleHealthApiError); + expect(safeFetchMock).toHaveBeenCalledTimes(1); + }); +}); + +describe("extractGoogleApiErrorDetail — AIP-193 field violations", () => { + it("keeps the plain STATUS: message shape without details", () => { + expect( + extractGoogleApiErrorDetail({ + error: { code: 400, status: "INVALID_ARGUMENT", message: "Invalid" }, + }), + ).toBe("INVALID_ARGUMENT: Invalid"); + }); + + it("appends fieldViolations as field: description fragments", () => { + expect( + extractGoogleApiErrorDetail({ + error: { + code: 400, + status: "INVALID_ARGUMENT", + message: "Invalid argument in request.", + details: [ + { + "@type": "type.googleapis.com/google.rpc.BadRequest", + fieldViolations: [ + { field: "range", description: "Range too wide." }, + ], + }, + ], + }, + }), + ).toBe( + "INVALID_ARGUMENT: Invalid argument in request. [range: Range too wide.]", + ); + }); + + it("caps at three violations and tolerates a details-only envelope", () => { + const detail = extractGoogleApiErrorDetail({ + error: { + details: [ + { + fieldViolations: [ + { field: "a", description: "1" }, + { field: "b", description: "2" }, + { field: "c", description: "3" }, + { field: "d", description: "4" }, + ], + }, + ], + }, + }); + expect(detail).toBe("[a: 1; b: 2; c: 3]"); + }); + + it("redacts tokens and URL queries inside violation descriptions", () => { + const detail = extractGoogleApiErrorDetail({ + error: { + status: "INVALID_ARGUMENT", + details: [ + { + fieldViolations: [ + { + field: "range.start", + description: + "Bearer abc123 rejected at https://health.googleapis.com/v4/x?filter=secret", + }, + ], + }, + ], + }, + }); + expect(detail).not.toContain("abc123"); + expect(detail).not.toContain("filter=secret"); + }); +}); diff --git a/src/lib/google-health/client.ts b/src/lib/google-health/client.ts index b409a4c43..73b63c45d 100644 --- a/src/lib/google-health/client.ts +++ b/src/lib/google-health/client.ts @@ -27,7 +27,7 @@ * a refresh token (and force one on every re-consent). */ import { createHash, randomBytes } from "node:crypto"; -import { getEvent } from "@/lib/logging/context"; +import { annotate, getEvent } from "@/lib/logging/context"; import { safeFetch } from "@/lib/safe-fetch"; import { wallClockInTz, zonedWallClockToUtc } from "@/lib/tz/wall-clock"; import { @@ -677,25 +677,63 @@ function redactApiErrorMessage(msg: string): string { } /** - * Extract the AIP-193 error envelope (`{"error":{code,message,status}}`) from a - * non-2xx Google Health body into a short redacted `STATUS: message` detail - * string, or undefined when the body carries no such envelope. This is what - * makes a field-grammar 400 (`INVALID_ARGUMENT: Invalid filter …`) diagnosable - * from operator logs. (The OAuth token endpoint uses the flat - * `{"error":"invalid_grant"}` shape instead — handled in `postToken`.) + * Collect the `google.rpc.BadRequest` field violations out of an AIP-193 + * `error.details[]` array as `field: description` fragments. Only the field + * PATH and the description sentence survive — both run through the message + * redactor and a per-fragment cap, and at most three violations are kept, so + * no request value can ride an error string into the logs. + */ +function extractFieldViolations(details: unknown): string[] { + if (!Array.isArray(details)) return []; + const out: string[] = []; + for (const d of details) { + if (!d || typeof d !== "object") continue; + const fv = (d as { fieldViolations?: unknown }).fieldViolations; + if (!Array.isArray(fv)) continue; + for (const v of fv) { + if (out.length >= 3) return out; + if (!v || typeof v !== "object") continue; + const o = v as { field?: unknown; description?: unknown }; + const field = + typeof o.field === "string" + ? redactApiErrorMessage(o.field).slice(0, 80) + : undefined; + const description = + typeof o.description === "string" + ? redactApiErrorMessage(o.description).slice(0, 120) + : undefined; + if (!field && !description) continue; + out.push([field, description].filter(Boolean).join(": ")); + } + } + return out; +} + +/** + * Extract the AIP-193 error envelope (`{"error":{code,message,status,details}}`) + * from a non-2xx Google Health body into a short redacted + * `STATUS: message [field: description]` detail string, or undefined when the + * body carries no such envelope. This is what makes a field-grammar 400 + * (`INVALID_ARGUMENT: Invalid filter …`) diagnosable from operator logs: the + * `details[]` BadRequest field violations name the exact offending request + * field. (The OAuth token endpoint uses the flat `{"error":"invalid_grant"}` + * shape instead — handled in `postToken`.) */ export function extractGoogleApiErrorDetail(json: unknown): string | undefined { if (!json || typeof json !== "object") return undefined; const e = (json as { error?: unknown }).error; if (!e || typeof e !== "object") return undefined; - const o = e as { status?: unknown; message?: unknown }; + const o = e as { status?: unknown; message?: unknown; details?: unknown }; const status = typeof o.status === "string" ? o.status : undefined; const message = typeof o.message === "string" ? redactApiErrorMessage(o.message) : undefined; - if (!status && !message) return undefined; - return [status, message].filter(Boolean).join(": "); + const violations = extractFieldViolations(o.details); + if (!status && !message && violations.length === 0) return undefined; + const head = [status, message].filter(Boolean).join(": "); + const tail = violations.length > 0 ? `[${violations.join("; ")}]` : ""; + return [head, tail].filter(Boolean).join(" ").slice(0, 500); } /** @@ -778,9 +816,25 @@ export async function fetchDataPoints( // ─── Daily roll-up reads (`POST …/dataPoints:dailyRollUp`) ───────────────── -/** Max civil days one dailyRollUp request range may span (per the v4 docs). */ +/** + * Max civil days one dailyRollUp request range may span. Per the v4 reference + * (`users.dataTypes.dataPoints/dailyRollUp`, `range`): "The maximum range for + * `calories-in-heart-rate-zone`, `heart-rate`, `active-minutes` and + * `total-calories` is 14 days. The maximum range for all other data types is + * 90 days." The four types read here (steps / distance / active-energy-burned / + * floors) all sit in the 90-day class; the 14-day cap would only bind if + * `heart-rate` or `total-calories` ever moved onto the rollup path. + */ export const GOOGLE_HEALTH_ROLLUP_RANGE_DAYS = 90; +/** + * Conservative chunk span for the one-shot fallback walk: the tightest range + * cap the dailyRollUp docs state for ANY data type (the 14-day class above). + * Used only after the standard 90-day first request is rejected with a 400 — + * see `fetchDailyRollUp`. + */ +export const GOOGLE_HEALTH_ROLLUP_FALLBACK_RANGE_DAYS = 14; + /** * Full-sync horizon for the rollup types, in civil days. The rollup read needs * an explicit range (unlike the unbounded list walk), so the backfill is @@ -872,15 +926,70 @@ interface RollupQuery { start?: Date; /** The user's IANA zone — the civil range is user-local. */ tz?: string; + /** + * Observes which request shape the walk settled on (`days90` standard vs + * `days14` fallback) — the structure probe surfaces it so a live account + * reports which shape Google actually accepted. + */ + onShape?: (shape: GoogleHealthRollupShape) => void; +} + +/** The dailyRollUp request shape a walk settled on. */ +export type GoogleHealthRollupShape = "days90" | "days14"; + +/** + * Build the documented dailyRollUp request body for one closed-open civil-day + * chunk (`end` exclusive). Doc-example parity (developers.google.com/health/ + * endpoints, dailyRollUp sample): both range bounds carry explicit + * `{date, time}` CivilDateTime objects, and the `end` bound is the LAST civil + * day INSIDE the chunk at 23:59:59 — NOT the next day's midnight. The range + * validator counts the civil days the range touches against the documented + * cap, so an exclusive next-day-midnight end makes a maximal chunk read one + * day too wide. `windowSizeDays: 1` is the documented daily-total window; + * `pageSize` is omitted (the documented default of 1440 already covers the + * ≤90 daily windows a chunk can produce); `pageToken` rides along on + * follow-up pages ("All other request fields need to be the same as in the + * initial request when the page token is specified"). + */ +export function buildDailyRollUpBody( + chunk: { start: GoogleHealthCivilDate; end: GoogleHealthCivilDate }, + pageToken?: string, +): Record { + const lastDay = utcMsToCivil(civilToUtcMs(chunk.end) - DAY_MS); + const body: Record = { + range: { + start: { + date: chunk.start, + time: { hours: 0, minutes: 0, seconds: 0, nanos: 0 }, + }, + end: { + date: lastDay, + time: { hours: 23, minutes: 59, seconds: 59, nanos: 0 }, + }, + }, + windowSizeDays: 1, + }; + if (pageToken) body.pageToken = pageToken; + return body; } /** * Read one cumulative data type's daily totals via `POST …/dataPoints:dailyRollUp` - * with `windowSizeDays: 1`. The civil range is closed-open and user-local, - * chunked at ≤90 days per request; without an incremental `start` the walk - * covers the pinned backfill horizon. A `nextPageToken` is honoured defensively - * (the response is documented without one, but the request accepts page - * tokens). + * with `windowSizeDays: 1`. The civil range is user-local, chunked at ≤90 days + * per request (the documented cap for these types); without an incremental + * `start` the walk covers the pinned backfill horizon. A `nextPageToken` is + * honoured defensively (the dailyRollUp response is documented without one, + * but the request accepts page tokens and the sibling `:rollUp` documents the + * response field). + * + * FALLBACK: if the very first request of a walk is rejected with a 400 + * (INVALID_ARGUMENT — a range/shape constraint, since the body already + * mirrors the documented example), the whole range is re-walked once with the + * most conservative documented span (14 days, the tightest cap the docs state + * for any type). The shape that succeeded is annotated as + * `googleHealth.rollup.shape` so live behaviour reports which constraint + * actually binds. Any other failure, or a 400 past the first request, + * propagates. */ export async function fetchDailyRollUp( dataType: GoogleHealthDataType, @@ -896,71 +1005,84 @@ export async function fetchDailyRollUp( const startCivil = civilDateInTz(from, query.tz); const endCivil = civilDateInTz(new Date(now.getTime() + DAY_MS), query.tz); - const points: GoogleHealthRollupPoint[] = []; - let chunkIndex = 0; - for (const chunk of chunkCivilRange(startCivil, endCivil)) { - let pageToken: string | null | undefined; - let pageCount = 0; - do { - const body: Record = { - range: { - start: { date: chunk.start }, - end: { date: chunk.end }, - }, - windowSizeDays: 1, - pageSize: 100, - }; - if (pageToken) body.pageToken = pageToken; + let requestCount = 0; - const reqStart = performance.now(); - const res = await safeFetch( - `${GOOGLE_HEALTH_API_BASE}/users/me/dataTypes/${dataType.path}/dataPoints:dailyRollUp`, - { - method: "POST", - headers: { - Authorization: `Bearer ${accessToken}`, - "Content-Type": "application/json", + const walk = async (maxDays: number): Promise => { + const points: GoogleHealthRollupPoint[] = []; + let chunkIndex = 0; + for (const chunk of chunkCivilRange(startCivil, endCivil, maxDays)) { + let pageToken: string | null | undefined; + let pageCount = 0; + do { + const body = buildDailyRollUpBody(chunk, pageToken ?? undefined); + + requestCount += 1; + const reqStart = performance.now(); + const res = await safeFetch( + `${GOOGLE_HEALTH_API_BASE}/users/me/dataTypes/${dataType.path}/dataPoints:dailyRollUp`, + { + method: "POST", + headers: { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(body), }, - body: JSON.stringify(body), - }, - ); - const json = (await res - .json() - .catch(() => null)) as GoogleHealthRollupPage | null; - const verdict = classifyGoogleHealthResponse(res.status); - const apiErrorDetail = - verdict.classification === "success" - ? undefined - : extractGoogleApiErrorDetail(json); - getEvent()?.addExternalCall({ - service: "google-health", - method: `${verb}(chunk=${chunkIndex},page=${pageCount})`, - duration_ms: Math.round(performance.now() - reqStart), - status: res.status, - error: + ); + const json = (await res + .json() + .catch(() => null)) as GoogleHealthRollupPage | null; + const verdict = classifyGoogleHealthResponse(res.status); + const apiErrorDetail = verdict.classification === "success" ? undefined - : apiErrorDetail - ? `${verdict.reason} ${apiErrorDetail}` - : verdict.reason, - }); - if (verdict.classification !== "success") { - throw new GoogleHealthApiError({ - verb, - classification: verdict.classification, - httpStatus: verdict.httpStatus, - reason: verdict.reason, - upstreamError: apiErrorDetail, + : extractGoogleApiErrorDetail(json); + getEvent()?.addExternalCall({ + service: "google-health", + method: `${verb}(chunk=${chunkIndex},page=${pageCount})`, + duration_ms: Math.round(performance.now() - reqStart), + status: res.status, + error: + verdict.classification === "success" + ? undefined + : apiErrorDetail + ? `${verdict.reason} ${apiErrorDetail}` + : verdict.reason, }); - } + if (verdict.classification !== "success") { + throw new GoogleHealthApiError({ + verb, + classification: verdict.classification, + httpStatus: verdict.httpStatus, + reason: verdict.reason, + upstreamError: apiErrorDetail, + }); + } - for (const p of json?.rollupDataPoints ?? []) points.push(p); - pageToken = json?.nextPageToken ?? null; - pageCount += 1; - } while (pageToken && pageCount < 100); - chunkIndex += 1; + for (const p of json?.rollupDataPoints ?? []) points.push(p); + pageToken = json?.nextPageToken ?? null; + pageCount += 1; + } while (pageToken && pageCount < 100); + chunkIndex += 1; + } + return points; + }; + + let shape: GoogleHealthRollupShape = "days90"; + let points: GoogleHealthRollupPoint[]; + try { + points = await walk(GOOGLE_HEALTH_ROLLUP_RANGE_DAYS); + } catch (err) { + const firstRequestRejected = + requestCount === 1 && + err instanceof GoogleHealthApiError && + err.httpStatus === 400; + if (!firstRequestRejected) throw err; + shape = "days14"; + points = await walk(GOOGLE_HEALTH_ROLLUP_FALLBACK_RANGE_DAYS); } - + annotate({ meta: { "googleHealth.rollup.shape": shape } }); + query.onShape?.(shape); return points; } diff --git a/src/lib/google-health/mapping.md b/src/lib/google-health/mapping.md index 694c9e022..276a9b14e 100644 --- a/src/lib/google-health/mapping.md +++ b/src/lib/google-health/mapping.md @@ -44,9 +44,14 @@ Two read methods are in use — NOT every type supports `dataPoints.list`: - **`dataPoints:dailyRollUp`** (POST, `windowSizeDays: 1`) — the cumulative activity totals (steps, distance, active-energy-burned, floors). Their list surface returns minute-grain observation buckets (≈1440/day), NOT daily - totals — and **floors has no list method at all**. The request range is - CivilDateTime, closed-open, user-local, max **90 days** per request; the - client chunks longer ranges and pins the full-sync horizon + totals — and **floors has no list method at all**. The request range is a + pair of CivilDateTime bounds (`{date:{year,month,day}, time:{hours,minutes, +seconds,nanos}}`), user-local; per the documented example, the `end` bound + is the LAST covered civil day at 23:59:59 — not the next day's midnight. + Max **90 days** per request for these types (14 days only for + heart-rate / total-calories / active-minutes / calories-in-heart-rate-zone); + the client chunks longer ranges (`buildDailyRollUpBody`), falls back once to + 14-day chunks if the first request 400s, and pins the full-sync horizon (`GOOGLE_HEALTH_ROLLUP_BACKFILL_DAYS`). Legal incremental `filter` fields are per-shape — anything else is an HTTP 400 From de86ee26f2b885b7c3965c1e3ecbc5479ffd7758 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 6 Jul 2026 20:24:40 +0200 Subject: [PATCH 2/4] fix(google-health): one broken collection no longer kills its siblings The live 400 on fetchSteps suppressed fetchDistance / fetchFloors / fetchActiveEnergy entirely: handleCollectionFetchError rethrew anything that was not a per-class 403, and the rethrow escaped the rollup loop in sync-activity, emptying the whole cumulative group after the first bad collection. - handleCollectionFetchError records the classified failure, warns with the per-collection detail, and returns instead of rethrowing; every call site already sits in a per-collection catch, so the sibling collections keep fetching. - A per-cycle hard-fail ledger (AsyncLocalStorage, mirroring the 403 soft-skip tracker) still fails the cycle: the run counts as a partial error, the watermark is not stamped, and the next tick refetches the window - a broken collection cannot silently advance past its gap. - The orchestrator's per-resource warning names the resource explicitly; fn.name minifies to a single letter in the production bundle (the live log read "google-health c failed"). - Tests pin the no-rethrow contract per classification and the activity loop reaching all four rollup types plus VO2 max after a steps failure. --- .../__tests__/sync-activity-failsoft.test.ts | 84 +++++++++++++++++ .../__tests__/sync-failsoft.test.ts | 91 +++++++++++++++++++ src/lib/google-health/sync.ts | 74 +++++++++++---- 3 files changed, 230 insertions(+), 19 deletions(-) create mode 100644 src/lib/google-health/__tests__/sync-activity-failsoft.test.ts create mode 100644 src/lib/google-health/__tests__/sync-failsoft.test.ts diff --git a/src/lib/google-health/__tests__/sync-activity-failsoft.test.ts b/src/lib/google-health/__tests__/sync-activity-failsoft.test.ts new file mode 100644 index 000000000..2e3034c40 --- /dev/null +++ b/src/lib/google-health/__tests__/sync-activity-failsoft.test.ts @@ -0,0 +1,84 @@ +/** + * Pins the cumulative-group independence contract: each of the four rollup + * types (steps / distance / active-energy / floors) and the VO2-max summary + * fetches independently inside `syncUserActivity`. A hard failure on ONE type + * routes through `handleCollectionFetchError` and the loop continues — the + * siblings still fetch (live regression: steps' 400 killed the whole group). + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { fetchDailyRollUpMock, fetchDataPointsMock, handleErrorMock } = + vi.hoisted(() => ({ + fetchDailyRollUpMock: vi.fn(), + fetchDataPointsMock: vi.fn(async () => []), + handleErrorMock: vi.fn(async () => 0), + })); + +vi.mock("../client", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + fetchDailyRollUp: fetchDailyRollUpMock, + fetchDataPoints: fetchDataPointsMock, + }; +}); +vi.mock("../sync", () => ({ + getValidToken: vi.fn(async () => ({ + accessToken: "token", + connection: { id: "c1", googleUserId: "g1" }, + })), + handleCollectionFetchError: handleErrorMock, + upsertGoogleHealthMeasurements: vi.fn(async () => ({ + imported: 0, + touched: [], + })), +})); +vi.mock("@/lib/tz/resolver", () => ({ + resolveUserTimezone: vi.fn(async () => "UTC"), +})); + +import { syncUserActivity } from "../sync-activity"; +import { GoogleHealthApiError } from "../response-classifier"; + +beforeEach(() => { + fetchDailyRollUpMock.mockReset(); + fetchDataPointsMock.mockClear(); + handleErrorMock.mockClear(); +}); + +describe("syncUserActivity — one rollup failure never suppresses siblings", () => { + it("keeps fetching distance / active-energy / floors / vo2max after steps 400s", async () => { + fetchDailyRollUpMock.mockImplementation( + async (_dt: unknown, _token: string, verb: string) => { + if (verb === "fetchSteps") { + throw new GoogleHealthApiError({ + verb, + classification: "persistent", + httpStatus: 400, + reason: "HTTP 400", + }); + } + return []; + }, + ); + + await expect(syncUserActivity("user-1")).resolves.toBe(0); + + const verbs = fetchDailyRollUpMock.mock.calls.map((c) => c[2]); + expect(verbs).toEqual([ + "fetchSteps", + "fetchDistance", + "fetchActiveEnergy", + "fetchFloors", + ]); + // VO2 max (list read) still ran too. + expect(fetchDataPointsMock).toHaveBeenCalledTimes(1); + // The steps failure was routed through the shared handler exactly once. + expect(handleErrorMock).toHaveBeenCalledTimes(1); + expect(handleErrorMock).toHaveBeenCalledWith( + "fetchSteps", + "user-1", + expect.any(GoogleHealthApiError), + ); + }); +}); diff --git a/src/lib/google-health/__tests__/sync-failsoft.test.ts b/src/lib/google-health/__tests__/sync-failsoft.test.ts new file mode 100644 index 000000000..e6c67f096 --- /dev/null +++ b/src/lib/google-health/__tests__/sync-failsoft.test.ts @@ -0,0 +1,91 @@ +/** + * Pins the fail-soft contract of `handleCollectionFetchError`: a hard failure + * (anything but the per-class 403 soft-skip) records the classified sync + * failure and RETURNS — it must not rethrow, because every call site sits in a + * per-collection catch and a rethrow would abort the sibling collections + * (live: steps 400ing suppressed distance / floors / active-energy entirely). + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { recordSyncFailureMock } = vi.hoisted(() => ({ + recordSyncFailureMock: vi.fn(async () => {}), +})); + +vi.mock("@/lib/db", () => ({ prisma: {} })); +vi.mock("@/lib/crypto", () => ({ encrypt: vi.fn(), decrypt: vi.fn() })); +vi.mock("@/lib/integrations/status", () => ({ + isReauthRequired: vi.fn(async () => false), + recordSyncFailure: recordSyncFailureMock, + recordSyncSuccess: vi.fn(async () => {}), +})); +vi.mock("@/lib/rollups/measurement-rollups", () => ({ + collapseToTypeDayKeys: vi.fn(() => []), + recomputeBucketsForMeasurement: vi.fn(async () => {}), + recomputeUserRollups: vi.fn(async () => {}), +})); +vi.mock("@/lib/insights/comprehensive-generate", () => ({ + invalidateStatusInsightsForTypes: vi.fn(async () => {}), +})); +vi.mock("../credentials", () => ({ + getUserGoogleHealthCredentials: vi.fn(async () => null), +})); +vi.mock("../client", () => ({ refreshAccessToken: vi.fn() })); + +import { handleCollectionFetchError } from "../sync"; +import { GoogleHealthApiError } from "../response-classifier"; + +beforeEach(() => { + recordSyncFailureMock.mockClear(); +}); + +describe("handleCollectionFetchError — fail-soft grouping", () => { + it("soft-skips a per-class 403 without recording a failure", async () => { + const err = new GoogleHealthApiError({ + verb: "fetchSteps", + classification: "reauth_required", + httpStatus: 403, + reason: "HTTP 403", + }); + await expect( + handleCollectionFetchError("fetchSteps", "user-1", err), + ).resolves.toBe(0); + expect(recordSyncFailureMock).not.toHaveBeenCalled(); + }); + + it("records a 400 as a persistent failure and returns instead of rethrowing", async () => { + const err = new GoogleHealthApiError({ + verb: "fetchSteps", + classification: "persistent", + httpStatus: 400, + reason: "HTTP 400", + upstreamError: "INVALID_ARGUMENT: Invalid argument in request.", + }); + await expect( + handleCollectionFetchError("fetchSteps", "user-1", err), + ).resolves.toBe(0); + expect(recordSyncFailureMock).toHaveBeenCalledTimes(1); + expect(recordSyncFailureMock).toHaveBeenCalledWith( + expect.objectContaining({ + userId: "user-1", + integration: "google-health", + kind: "persistent", + errorCode: "400", + }), + ); + }); + + it("records a transient failure (5xx) and returns as well", async () => { + const err = new GoogleHealthApiError({ + verb: "fetchDistance", + classification: "transient", + httpStatus: 503, + reason: "HTTP 503", + }); + await expect( + handleCollectionFetchError("fetchDistance", "user-1", err), + ).resolves.toBe(0); + expect(recordSyncFailureMock).toHaveBeenCalledWith( + expect.objectContaining({ kind: "transient" }), + ); + }); +}); diff --git a/src/lib/google-health/sync.ts b/src/lib/google-health/sync.ts index b161bd269..1198fed8d 100644 --- a/src/lib/google-health/sync.ts +++ b/src/lib/google-health/sync.ts @@ -190,6 +190,20 @@ interface SoftSkipTracker { } const softSkipStorage = new AsyncLocalStorage(); +/** + * Per-cycle ledger of collection fetches that HARD-failed (non-403). A hard + * failure on one collection must not abort its siblings — steps 400ing must + * not suppress distance / floors / active-energy — so + * `handleCollectionFetchError` records the failure here and returns instead of + * rethrowing. The orchestrator reads the ledger to keep the cycle's verdict + * honest: any entry ⇒ the run counts as failed (partial error), the watermark + * is NOT stamped, and the next tick refetches the window. + */ +interface HardFailTracker { + failures: string[]; +} +const hardFailStorage = new AsyncLocalStorage(); + /** * Per-cycle accumulator of the `(type, day)` keys every resource's writes * touched. Only populated on a `fullSync` backfill (when the inline per-day @@ -204,10 +218,17 @@ const rollupDeferStorage = new AsyncLocalStorage(); /** * Single-source the per-resource collection-fetch error handling. A 403 on one - * data class soft-skips it (warn + return 0) so sibling resources still sync; - * anything else records a classified sync failure and rethrows. A soft-skip - * increments the ambient tracker so `syncUserGoogleHealth` can refuse to stamp - * success on an all-403 grant-revoke cycle that imported nothing. + * data class soft-skips it (warn + return 0) so sibling resources still sync; a + * soft-skip increments the ambient tracker so `syncUserGoogleHealth` can refuse + * to stamp success on an all-403 grant-revoke cycle that imported nothing. + * + * Anything else records a classified sync failure, warns with the per-type + * detail, notes the collection on the ambient hard-fail ledger, and RETURNS — + * it does not rethrow. Every call site sits in a per-collection catch, so + * returning lets the sibling collections in the same resource keep fetching + * (one 400 on steps must not kill distance / floors / active-energy), while + * the ledger still fails the cycle so the watermark is not stamped past the + * broken collection. */ export async function handleCollectionFetchError( resource: string, @@ -223,7 +244,12 @@ export async function handleCollectionFetchError( return 0; } await recordGoogleHealthSyncFailure(userId, err); - throw err; + getEvent()?.addWarning( + `google-health ${resource} failed for ${userId}: ${err}`, + ); + const hardTracker = hardFailStorage.getStore(); + if (hardTracker) hardTracker.failures.push(resource); + return 0; } /** @@ -562,31 +588,41 @@ export async function syncUserGoogleHealth( import("./sync-workout"), ]); + // Explicit labels — the production bundle minifies `fn.name` into a useless + // single letter ("google-health c failed"), so the warning names the resource + // itself. const resources = [ - syncUserMetrics, - syncUserActivity, - syncUserSleep, - syncUserWorkout, + { name: "metrics", fn: syncUserMetrics }, + { name: "activity", fn: syncUserActivity }, + { name: "sleep", fn: syncUserSleep }, + { name: "workout", fn: syncUserWorkout }, ]; const tracker: SoftSkipTracker = { count: 0 }; + const hardFailTracker: HardFailTracker = { failures: [] }; const deferTracker: RollupDeferTracker = { keys: [] }; let total = 0; let anyFailed = false; await softSkipStorage.run(tracker, async () => { - await rollupDeferStorage.run(deferTracker, async () => { - for (const fn of resources) { - try { - total += await fn(userId, resourceOpts); - } catch (err) { - anyFailed = true; - getEvent()?.addWarning( - `google-health ${fn.name} failed for ${userId}: ${err}`, - ); + await hardFailStorage.run(hardFailTracker, async () => { + await rollupDeferStorage.run(deferTracker, async () => { + for (const { name, fn } of resources) { + try { + total += await fn(userId, resourceOpts); + } catch (err) { + anyFailed = true; + getEvent()?.addWarning( + `google-health ${name} sync failed for ${userId}: ${err}`, + ); + } } - } + }); }); }); + // A collection that hard-failed inside a resource (recorded on the ledger by + // `handleCollectionFetchError` without aborting its siblings) still fails the + // cycle: partial error, no watermark stamp. + if (hardFailTracker.failures.length > 0) anyFailed = true; // On a `fullSync` backfill the per-write inline rollup hook was deferred; the // accumulated touched type-days collapse into ONE range-recompute spanning the From 3653402d4a265334914bf4d75f6240c3c0156e7c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 6 Jul 2026 20:24:50 +0200 Subject: [PATCH 3/4] feat(google-health): report the accepted rollup shape from the structure probe The structure probe (POST /api/integrations/google-health/test with probe:"structure") already exercises one 7-day dailyRollUp per cumulative type; it now also reports which documented request shape Google accepted (days90 standard chunking vs days14 conservative fallback) as requestShape, next to the field-name skeleton. A self-hoster can settle the range-constraint question from the UI without server logs; no value, timestamp, or identifier is added to the output. --- .../integrations/google-health/test/route.ts | 21 ++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/src/app/api/integrations/google-health/test/route.ts b/src/app/api/integrations/google-health/test/route.ts index 8cb8359cf..650349594 100644 --- a/src/app/api/integrations/google-health/test/route.ts +++ b/src/app/api/integrations/google-health/test/route.ts @@ -76,7 +76,17 @@ function describeStructure(v: unknown, depth = 0): unknown { } type ProbeResult = - | { ok: true; count: number; structure: unknown } + | { + ok: true; + count: number; + structure: unknown; + /** + * Rollup types only: which documented dailyRollUp request shape Google + * accepted (`days90` standard chunking vs `days14` conservative + * fallback) — the live verdict on the range constraint. + */ + requestShape?: string; + } | { ok: false; httpStatus: number | null; @@ -102,7 +112,12 @@ async function runStructureProbe( for (const [name, dataType] of Object.entries(GOOGLE_HEALTH_DATA_TYPES)) { try { let points: Record[]; + let requestShape: string | undefined; if (dataType.timeField === "rollup") { + // One minimal dailyRollUp exercise per rollup type (7-day range) — the + // probe reports the response skeleton AND which documented request + // shape Google accepted, so a live account settles the range-constraint + // question from the UI. points = await fetchDailyRollUp( dataType, accessToken, @@ -110,6 +125,9 @@ async function runStructureProbe( { start: rollupStart, tz, + onShape: (shape) => { + requestShape = shape; + }, }, ); } else { @@ -129,6 +147,7 @@ async function runStructureProbe( ok: true, count: points.length, structure: points.length > 0 ? describeStructure(points[0]) : null, + ...(requestShape ? { requestShape } : {}), }; } catch (e) { results[name] = From 8e2fff4e7f5f9ad109454b1606dc3036867a5cff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 6 Jul 2026 20:29:17 +0200 Subject: [PATCH 4/4] =?UTF-8?q?chore(release):=20v1.27.12=20=E2=80=94=20Go?= =?UTF-8?q?ogle=20Health=20daily=20totals?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 8 ++++++++ docs/api/openapi.yaml | 2 +- package.json | 2 +- public/sw.js | 2 +- 4 files changed, 11 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d29107675..7ca1c3a1d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ ## [Unreleased] +## [1.27.12] — 2026-07-06 — Google Health daily totals + +### Fixed + +- Steps, distance, floors, and active energy now import from Google Health. The daily-totals request counted one calendar day too many per window (an exclusive end bound where the API expects the last covered day), which the API rejected outright; the request now matches the documented example exactly, with a self-reporting fallback to the tighter window size should a stricter limit apply. +- One data type's failure no longer blocks the remaining ones — each cumulative type fetches independently, failures are reported per type, and the sync watermark is withheld so the next run refetches what failed. +- Google API errors now carry the offending field names in the logs (redacted to field paths), so a future rejection explains itself; the connection test's structure probe also exercises a minimal daily-totals request. + ## [1.27.11] — 2026-07-05 — Consistency, colour, and locale fixes ### Fixed diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 750a8da58..fc2a1abab 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.27.11 + version: 1.27.12 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. diff --git a/package.json b/package.json index bd1d8ff2f..14beda31d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.27.11", + "version": "1.27.12", "description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.", "license": "PolyForm-Noncommercial-1.0.0", "homepage": "https://healthlog.dev", diff --git a/public/sw.js b/public/sw.js index 9383fd92d..3bcf0d986 100644 --- a/public/sw.js +++ b/public/sw.js @@ -36,7 +36,7 @@ try { // v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild. const CACHE_VERSION = (typeof self !== "undefined" && self.__APP_VERSION__) || - /* @sw-version-fallback */ "v1.27.11"; + /* @sw-version-fallback */ "v1.27.12"; const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`; const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`; // v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*`