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/*` 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] = 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/__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/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 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