mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 06:06:10 +00:00
fix(polar): log envelope-shape drift instead of silently reading it as no data
The collection fetchers degrade an unexpected 200 body to an empty list, so a Polar-side envelope rename would read as "the user has no data" and stamp a clean sync while importing nothing. Emit a fixed-shape polar.envelope.drift breadcrumb (verb, HTTP status, top-level key names only, never values) at each silent-empty site in the collection fetcher and the exercises fetcher, keeping the log-and-degrade behavior so a live account stays alive while the gap becomes visible in the event stream. Truth up the client docstrings against the verified AccessLink contract: each vitals list is a bare array or a named wrapper over a server-fixed 28-day window with no pagination, and exercises is a bare array over the last 30 days of uploads. Every hourly tick re-reads the full window, so a late-synced device or a short outage self-heals with no cursor or watermark; history beyond the window is an upstream cap. No request parameters added.
This commit is contained in:
1 parent
4574155ce6
commit
1d31c51d01
3 files changed
+201
-15
No files matched your search
@@ -1,4 +1,10 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { annotateMock } = vi.hoisted(() => ({ annotateMock: vi.fn() }));
|
||||
vi.mock("@/lib/logging/context", () => ({
|
||||
annotate: annotateMock,
|
||||
getEvent: () => null,
|
||||
}));
|
||||
|
||||
import {
|
||||
combinePolarStart,
|
||||
@@ -25,11 +31,21 @@ function installFetchMock(pages: Array<{ status: number; body?: unknown }>) {
|
||||
return fetchMock;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
annotateMock.mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
function driftAnnotations() {
|
||||
return annotateMock.mock.calls
|
||||
.map((c) => c[0])
|
||||
.filter((f) => f?.action?.name === "polar.envelope.drift");
|
||||
}
|
||||
|
||||
describe("fetchExercises — request path", () => {
|
||||
// #568 regression guard: the exercises collection GET carries NO userId in
|
||||
// the path (the user is identified by the Bearer token), exactly the shape
|
||||
@@ -118,6 +134,44 @@ describe("fetchExercises — body shape (assumption #3)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchExercises — envelope drift logging (E-77)", () => {
|
||||
it("logs a drift breadcrumb and degrades to [] on an object body with no recognised list key", async () => {
|
||||
installFetchMock([{ status: 200, body: { nope: [{ id: "a" }] } }]);
|
||||
expect(await fetchExercises("tok")).toEqual([]);
|
||||
const drift = driftAnnotations();
|
||||
expect(drift).toHaveLength(1);
|
||||
expect(drift[0].action.details).toMatchObject({
|
||||
verb: "fetchExercises",
|
||||
httpStatus: 200,
|
||||
keys: ["nope"],
|
||||
});
|
||||
});
|
||||
|
||||
it("does NOT drift on a bare array", async () => {
|
||||
installFetchMock([{ status: 200, body: [{ id: "a" }] }]);
|
||||
expect(await fetchExercises("tok")).toHaveLength(1);
|
||||
expect(driftAnnotations()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("does NOT drift on a recognised envelope key (`data`)", async () => {
|
||||
installFetchMock([{ status: 200, body: { data: [{ id: "a" }] } }]);
|
||||
expect(await fetchExercises("tok")).toHaveLength(1);
|
||||
expect(driftAnnotations()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("does NOT drift on an empty array under a recognised key", async () => {
|
||||
installFetchMock([{ status: 200, body: { exercises: [] } }]);
|
||||
expect(await fetchExercises("tok")).toEqual([]);
|
||||
expect(driftAnnotations()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("does NOT drift on a 204 empty window", async () => {
|
||||
installFetchMock([{ status: 204 }]);
|
||||
expect(await fetchExercises("tok")).toEqual([]);
|
||||
expect(driftAnnotations()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseIsoDuration", () => {
|
||||
it("parses hours/minutes/seconds", () => {
|
||||
expect(parseIsoDuration("PT2H44M30S")).toBe(2 * 3600 + 44 * 60 + 30);
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { annotateMock } = vi.hoisted(() => ({ annotateMock: vi.fn() }));
|
||||
vi.mock("@/lib/logging/context", () => ({
|
||||
annotate: annotateMock,
|
||||
getEvent: () => null,
|
||||
}));
|
||||
|
||||
import {
|
||||
POLAR_OAUTH_SCOPE,
|
||||
exchangeCode,
|
||||
@@ -43,12 +49,19 @@ function installFetchMock(pages: Array<{ status: number; body?: unknown }>) {
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
annotateMock.mockClear();
|
||||
process.env.POLAR_CLIENT_ID = "env-cid";
|
||||
process.env.POLAR_CLIENT_SECRET = "env-secret";
|
||||
process.env.NEXT_PUBLIC_APP_URL = "https://app.example";
|
||||
delete process.env.POLAR_REDIRECT_URI;
|
||||
});
|
||||
|
||||
function driftAnnotations() {
|
||||
return annotateMock.mock.calls
|
||||
.map((c) => c[0])
|
||||
.filter((f) => f?.action?.name === "polar.envelope.drift");
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
@@ -209,6 +222,55 @@ describe("fetchNightlyRecharges", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchCollection — envelope drift logging", () => {
|
||||
it("logs a drift breadcrumb (verb + top-level keys) and degrades to [] on an unexpected wrapped shape", async () => {
|
||||
installFetchMock([
|
||||
{ status: 200, body: { unexpected: [{ date: "2026-06-10" }] } },
|
||||
]);
|
||||
expect(await fetchNightlyRecharges("tok")).toEqual([]);
|
||||
const drift = driftAnnotations();
|
||||
expect(drift).toHaveLength(1);
|
||||
expect(drift[0].action.details).toMatchObject({
|
||||
verb: "fetchNightlyRecharges",
|
||||
httpStatus: 200,
|
||||
keys: ["unexpected"],
|
||||
});
|
||||
});
|
||||
|
||||
it("does NOT drift on the correctly wrapped envelope", async () => {
|
||||
installFetchMock([
|
||||
{ status: 200, body: { recharges: [{ date: "2026-06-10" }] } },
|
||||
]);
|
||||
expect(await fetchNightlyRecharges("tok")).toHaveLength(1);
|
||||
expect(driftAnnotations()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("does NOT drift on a bare array for a bare-array endpoint", async () => {
|
||||
installFetchMock([
|
||||
{ status: 200, body: [{ date: "2026-06-10", cardio_load: 12 }] },
|
||||
]);
|
||||
expect(await fetchCardioLoads("tok")).toHaveLength(1);
|
||||
expect(driftAnnotations()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("drifts when a bare-array endpoint returns a non-array object", async () => {
|
||||
installFetchMock([{ status: 200, body: { rows: [{ cardio_load: 1 }] } }]);
|
||||
expect(await fetchCardioLoads("tok")).toEqual([]);
|
||||
const drift = driftAnnotations();
|
||||
expect(drift).toHaveLength(1);
|
||||
expect(drift[0].action.details).toMatchObject({
|
||||
verb: "fetchCardioLoads",
|
||||
keys: ["rows"],
|
||||
});
|
||||
});
|
||||
|
||||
it("does NOT drift on a 204 empty window", async () => {
|
||||
installFetchMock([{ status: 204 }]);
|
||||
expect(await fetchSleeps("tok")).toEqual([]);
|
||||
expect(driftAnnotations()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("mapNightlyRecharge", () => {
|
||||
it("maps the recovery band to a 0-100 RECOVERY_SCORE", () => {
|
||||
const rec: PolarNightlyRecharge = {
|
||||
|
||||
+84
-14
@@ -1,6 +1,9 @@
|
||||
/**
|
||||
* v1.17.0 (F4) — Polar AccessLink v3 client for OAuth + data fetching.
|
||||
* Docs: https://www.polar.com/accesslink-api/ (re-verify at build).
|
||||
* Envelope shapes + windows below verified against the live AccessLink swagger
|
||||
* (`accesslink-api/swagger.yaml`) on 2026-07-24; a body that no longer matches
|
||||
* emits a `polar.envelope.drift` breadcrumb rather than silently reading empty.
|
||||
*
|
||||
* Mirrors the WHOOP client structure (`src/lib/whoop/client.ts`): hand-rolled
|
||||
* fetch over `safeFetch` (no SDK), an OAuth handshake, typed collection
|
||||
@@ -20,7 +23,7 @@
|
||||
* reads work; a 409 from registration means "already registered" and is
|
||||
* treated as success.
|
||||
*/
|
||||
import { getEvent } from "@/lib/logging/context";
|
||||
import { annotate, getEvent } from "@/lib/logging/context";
|
||||
import { canonicalDailyTimestamp } from "@/lib/measurements/consolidation-tz";
|
||||
import { safeFetch } from "@/lib/safe-fetch";
|
||||
import { reconstructContiguousSleepTimeline } from "@/lib/sleep/reconstruct-timeline";
|
||||
@@ -250,6 +253,30 @@ interface CollectionResult<T> {
|
||||
records: T[];
|
||||
}
|
||||
|
||||
/** Top-level key NAMES of a drifted response envelope, capped at 10. NAMES
|
||||
* only, never values — no health data can ride the drift annotation. */
|
||||
function topLevelKeys(body: unknown): string[] {
|
||||
if (!body || typeof body !== "object" || Array.isArray(body)) return [];
|
||||
return Object.keys(body as Record<string, unknown>).slice(0, 10);
|
||||
}
|
||||
|
||||
/** Emit the fixed-shape envelope-drift breadcrumb, then let the caller degrade
|
||||
* to `[]`. Pinned `{verb, httpStatus, keys}` shape so the wide-event dashboard
|
||||
* stays stable; a Polar-side envelope rename shows up here instead of silently
|
||||
* reading as "no data". */
|
||||
function annotateEnvelopeDrift(
|
||||
verb: string,
|
||||
httpStatus: number,
|
||||
body: unknown,
|
||||
): void {
|
||||
annotate({
|
||||
action: {
|
||||
name: "polar.envelope.drift",
|
||||
details: { verb, httpStatus, keys: topLevelKeys(body) },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchCollection<T>(
|
||||
path: string,
|
||||
accessToken: string,
|
||||
@@ -284,13 +311,30 @@ async function fetchCollection<T>(
|
||||
if (res.status === 204) return [];
|
||||
const json = (await res.json().catch(() => null)) as
|
||||
CollectionResult<T> | Record<string, T[]> | T[] | null;
|
||||
if (!json) return [];
|
||||
// A 200 with a null/unparseable body where the contract documents 204 for an
|
||||
// empty window: log the drift, then degrade to [] (behavior unchanged).
|
||||
if (json === null) {
|
||||
annotateEnvelopeDrift(verb, res.status, null);
|
||||
return [];
|
||||
}
|
||||
if (Array.isArray(json)) return json;
|
||||
if (!recordsKey) return [];
|
||||
if (!recordsKey) {
|
||||
// A bare array is the verified shape for this endpoint (activities /
|
||||
// cardio-load / spo2) but the body is a non-array object — an envelope
|
||||
// rename would read as "no data". Log, then degrade.
|
||||
annotateEnvelopeDrift(verb, res.status, json);
|
||||
return [];
|
||||
}
|
||||
const list = (json as Record<string, unknown>)[recordsKey];
|
||||
return Array.isArray(list) ? (list as T[]) : [];
|
||||
if (Array.isArray(list)) return list as T[];
|
||||
// The expected records key (`recharges` / `nights`) is missing or not an
|
||||
// array — the same silent-"no data" drift class. Log, then degrade.
|
||||
annotateEnvelopeDrift(verb, res.status, json);
|
||||
return [];
|
||||
}
|
||||
|
||||
/** Nightly Recharge collection. Verified envelope: wrapped `{recharges: [...]}`.
|
||||
* Window: the last 28 days, server-fixed (no request params, no pagination). */
|
||||
export function fetchNightlyRecharges(
|
||||
accessToken: string,
|
||||
): Promise<PolarNightlyRecharge[]> {
|
||||
@@ -302,6 +346,8 @@ export function fetchNightlyRecharges(
|
||||
);
|
||||
}
|
||||
|
||||
/** Sleep collection. Verified envelope: wrapped `{nights: [...]}`. Window: the
|
||||
* last 28 days, server-fixed (no request params, no pagination). */
|
||||
export function fetchSleeps(accessToken: string): Promise<PolarSleep[]> {
|
||||
return fetchCollection<PolarSleep>(
|
||||
"/v3/users/sleep",
|
||||
@@ -311,6 +357,11 @@ export function fetchSleeps(accessToken: string): Promise<PolarSleep[]> {
|
||||
);
|
||||
}
|
||||
|
||||
/** Daily-activity collection. Verified envelope: a BARE array (no wrapper key).
|
||||
* Window: defaults to the last 28 days; optional `from`/`to` (max 28-day range,
|
||||
* `from` ≤ 365 days back) are deliberately not sent — the default equals the
|
||||
* in-window maximum, so pinning them buys no coverage and adds a request-surface
|
||||
* failure mode. No pagination. */
|
||||
export function fetchActivities(accessToken: string): Promise<PolarActivity[]> {
|
||||
return fetchCollection<PolarActivity>(
|
||||
"/v3/users/activities",
|
||||
@@ -319,8 +370,10 @@ export function fetchActivities(accessToken: string): Promise<PolarActivity[]> {
|
||||
);
|
||||
}
|
||||
|
||||
/** Training Load Pro collection — `cardio_load` strain figures for the recent
|
||||
* window. */
|
||||
/** Training Load Pro collection — `cardio_load` strain figures. Verified
|
||||
* envelope: a BARE array (no wrapper key). Window: the last 28 days on the bare
|
||||
* list (ranged `/date` and `/period/days/{1..180}` variants exist for deeper
|
||||
* history but are backfill machinery, not sent here). No pagination. */
|
||||
export function fetchCardioLoads(
|
||||
accessToken: string,
|
||||
): Promise<PolarCardioLoad[]> {
|
||||
@@ -331,7 +384,9 @@ export function fetchCardioLoads(
|
||||
);
|
||||
}
|
||||
|
||||
/** SpO2 (Elixir pulse-ox) collection — nightly blood-oxygen readings. */
|
||||
/** SpO2 (Elixir pulse-ox) collection — nightly blood-oxygen readings. Verified
|
||||
* envelope: a BARE array (no wrapper key). Window: defaults to the last 28 days
|
||||
* (optional `from`/`to`, max 28-day range, not sent). No pagination. */
|
||||
export function fetchSpo2(accessToken: string): Promise<PolarSpo2[]> {
|
||||
return fetchCollection<PolarSpo2>(
|
||||
"/v3/users/biosensing/spo2",
|
||||
@@ -697,13 +752,13 @@ export interface PolarExercise {
|
||||
}
|
||||
|
||||
/**
|
||||
* Candidate envelope keys a paged `/v3/exercises` response might nest the list
|
||||
* under. Polar's pagination shape on this endpoint is not verified; the fetcher
|
||||
* tolerates a bare array OR any of these envelope shapes so a paged response
|
||||
* degrades to "the rows on this page" rather than an empty result. The 30-day
|
||||
* window is small enough that a single page covers a normal user; if Polar
|
||||
* paginates, the hourly poll re-reads the whole window every tick so nothing is
|
||||
* lost between pages.
|
||||
* Candidate envelope keys the `/v3/exercises` list might nest under. The
|
||||
* verified shape (AccessLink swagger, 2026-07-24) is a BARE array covering the
|
||||
* last 30 days OF UPLOADS, with NO pagination token or page parameter — so the
|
||||
* bare-array branch is the live path. This tolerant key list is kept purely as
|
||||
* defense against a future envelope rename; when it engages (an object body
|
||||
* matching none of these keys), the fetch site logs a `polar.envelope.drift`
|
||||
* breadcrumb rather than silently reading empty.
|
||||
*/
|
||||
const EXERCISE_LIST_KEYS = ["exercises", "data", "items", "records"] as const;
|
||||
|
||||
@@ -746,6 +801,21 @@ export async function fetchExercises(
|
||||
// 204 No Content — a window with no uploads. Polar returns an empty body.
|
||||
if (res.status === 204) return [];
|
||||
const json = (await res.json().catch(() => null)) as unknown;
|
||||
// Drift: a non-null object body carrying no recognised list array (none of
|
||||
// EXERCISE_LIST_KEYS holds an array) would silently read as "no uploads". Log
|
||||
// at the fetch site — `normaliseExerciseList` stays a pure function — then
|
||||
// degrade to its []. A correctly-shaped `{exercises: []}` finds an (empty)
|
||||
// array under a known key and does NOT drift.
|
||||
if (
|
||||
json !== null &&
|
||||
typeof json === "object" &&
|
||||
!Array.isArray(json) &&
|
||||
!EXERCISE_LIST_KEYS.some((k) =>
|
||||
Array.isArray((json as Record<string, unknown>)[k]),
|
||||
)
|
||||
) {
|
||||
annotateEnvelopeDrift("fetchExercises", res.status, json);
|
||||
}
|
||||
return normaliseExerciseList(json);
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user