From f92e9fd840a257b056864eb5516189929862ce5f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 5 Jul 2026 13:59:24 +0200 Subject: [PATCH] feat(google-health): surface the API error body and add a structure probe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - On a non-2xx data-point response, extract the AIP-193 error envelope into a redacted STATUS: message detail (bearer tokens stripped, URL query strings dropped, 200-char cap) and thread it into the thrown GoogleHealthApiError plus the external-call annotation, so a field-grammar 400 is diagnosable from operator logs. The OAuth token endpoint keeps its flat invalid_grant handling. - POST /api/integrations/google-health/test with {"probe":"structure"} now fetches one recent page/window per data type and returns only the payload STRUCTURE (field names + typeof leaves, never values) plus the per-type error verdict — paste-safe diagnostics for payload-shape drift. Same auth and rate limit as the plain connection check. --- .../integrations/google-health/test/route.ts | 141 +++++++++++++++++- src/lib/google-health/client.ts | 70 ++++++++- 2 files changed, 206 insertions(+), 5 deletions(-) diff --git a/src/app/api/integrations/google-health/test/route.ts b/src/app/api/integrations/google-health/test/route.ts index da8ee2b9e..8cb8359cf 100644 --- a/src/app/api/integrations/google-health/test/route.ts +++ b/src/app/api/integrations/google-health/test/route.ts @@ -5,7 +5,15 @@ import { checkRateLimit } from "@/lib/rate-limit"; import { annotate } from "@/lib/logging/context"; import { prisma } from "@/lib/db"; import { getValidToken } from "@/lib/google-health/sync"; -import { GOOGLE_HEALTH_API_BASE } from "@/lib/google-health/client"; +import { + GOOGLE_HEALTH_ACTIVITY_PAGE_SIZE, + GOOGLE_HEALTH_API_BASE, + GOOGLE_HEALTH_DATA_TYPES, + fetchDailyRollUp, + fetchDataPoints, +} from "@/lib/google-health/client"; +import { GoogleHealthApiError } from "@/lib/google-health/response-classifier"; +import { resolveUserTimezone } from "@/lib/tz/resolver"; import { safeFetch, SafeFetchError } from "@/lib/safe-fetch"; export const dynamic = "force-dynamic"; @@ -43,8 +51,106 @@ function categoriseHttpStatus(status: number): CategorisedError { }; } +/** + * Reduce an arbitrary JSON value to its STRUCTURE: object keys survive, every + * leaf collapses to its `typeof` label (`"string"`, `"number"`, …; null → + * `"null"`), arrays keep only their first element's structure. No health value, + * timestamp, or identifier survives — the output is safe for a self-hoster to + * paste into a public diagnostics thread. + */ +function describeStructure(v: unknown, depth = 0): unknown { + if (depth > 8) return "…"; + if (v === null) return "null"; + if (Array.isArray(v)) { + return v.length > 0 ? [describeStructure(v[0], depth + 1)] : []; + } + if (typeof v === "object") { + return Object.fromEntries( + Object.entries(v as Record).map(([k, val]) => [ + k, + describeStructure(val, depth + 1), + ]), + ); + } + return typeof v; +} + +type ProbeResult = + | { ok: true; count: number; structure: unknown } + | { + ok: false; + httpStatus: number | null; + classification: string | null; + detail: string | null; + }; + +/** + * Fetch ONE recent page/window per data type and reduce the first data point to + * its structure. Errors are captured per type (a 400's AIP-193 detail included) + * so a broken type doesn't hide the others — that per-type verdict is the whole + * point of the probe. + */ +async function runStructureProbe( + accessToken: string, + tz: string | undefined, +): Promise> { + const now = Date.now(); + const listStart = new Date(now - 14 * 24 * 60 * 60 * 1000); + const rollupStart = new Date(now - 7 * 24 * 60 * 60 * 1000); + + const results: Record = {}; + for (const [name, dataType] of Object.entries(GOOGLE_HEALTH_DATA_TYPES)) { + try { + let points: Record[]; + if (dataType.timeField === "rollup") { + points = await fetchDailyRollUp( + dataType, + accessToken, + `probe:${name}`, + { + start: rollupStart, + tz, + }, + ); + } else { + const pageSize = + dataType.timeField === "sessionEnd" || + dataType.timeField === "civilStart" + ? GOOGLE_HEALTH_ACTIVITY_PAGE_SIZE + : 5; + points = await fetchDataPoints(dataType, accessToken, `probe:${name}`, { + start: listStart, + pageSize, + maxPages: 1, + tz, + }); + } + results[name] = { + ok: true, + count: points.length, + structure: points.length > 0 ? describeStructure(points[0]) : null, + }; + } catch (e) { + results[name] = + e instanceof GoogleHealthApiError + ? { + ok: false, + httpStatus: e.httpStatus ?? null, + classification: e.classification, + detail: e.upstreamError ?? null, + } + : { + ok: false, + httpStatus: null, + classification: null, + detail: "fetch_failed", + }; + } + } + return results; +} + export const POST = apiHandler(async (request: NextRequest) => { - void request; const { user } = await requireAuth(); annotate({ action: { name: "integrations.google-health.test" } }); @@ -55,6 +161,21 @@ export const POST = apiHandler(async (request: NextRequest) => { }); } + // Flag-only payload — `{ "probe": "structure" }` switches to the per-type + // structure probe; anything else runs the plain connection check. Cap the + // parse cost (mirrors safeJson maxBytes). + let structureProbe = false; + try { + const raw = await request.text(); + if (raw.length > 64 * 1024) { + return apiError(`Request body exceeds ${64 * 1024} bytes`, 413); + } + const body = JSON.parse(raw); + structureProbe = body?.probe === "structure"; + } catch { + // no body provided -> plain connection check + } + const tokenInfo = await getValidToken(user.id); if (!tokenInfo) { return apiError("Google Health not connected", 422, { @@ -62,6 +183,22 @@ export const POST = apiHandler(async (request: NextRequest) => { }); } + if (structureProbe) { + // Per-data-type structure probe: one page/window per type, reduced to field + // names + leaf types (never values) so a self-hoster can paste the output + // as diagnostics without leaking a single reading. + annotate({ + action: { name: "integrations.google-health.structure_probe" }, + }); + const tz = await resolveUserTimezone(user.id); + const types = await runStructureProbe(tokenInfo.accessToken, tz); + return apiSuccess({ + probe: "structure", + probedAt: new Date().toISOString(), + types, + }); + } + const connection = await prisma.googleHealthConnection.findUnique({ where: { userId: user.id }, select: { lastSyncedAt: true }, diff --git a/src/lib/google-health/client.ts b/src/lib/google-health/client.ts index 8eb755520..b409a4c43 100644 --- a/src/lib/google-health/client.ts +++ b/src/lib/google-health/client.ts @@ -345,12 +345,21 @@ export async function fetchProfile( .json() .catch(() => null)) as GoogleHealthProfile | null; const verdict = classifyGoogleHealthResponse(res.status); + const apiErrorDetail = + verdict.classification === "success" + ? undefined + : extractGoogleApiErrorDetail(json); getEvent()?.addExternalCall({ service: "google-health", method: "fetchProfile", duration_ms: Math.round(performance.now() - start), status: res.status, - error: verdict.classification === "success" ? undefined : verdict.reason, + error: + verdict.classification === "success" + ? undefined + : apiErrorDetail + ? `${verdict.reason} ${apiErrorDetail}` + : verdict.reason, }); if (verdict.classification !== "success") { throw new GoogleHealthApiError({ @@ -358,6 +367,7 @@ export async function fetchProfile( classification: verdict.classification, httpStatus: verdict.httpStatus, reason: verdict.reason, + upstreamError: apiErrorDetail, }); } return (json ?? {}) as GoogleHealthProfile; @@ -653,6 +663,41 @@ export function incrementalFilter( } } +/** + * Redact + bound an upstream Google API error message before it reaches an + * error object / wide event: strip bearer tokens, drop query strings from any + * embedded URL (they can carry filters echoing user data or tokens), cap at + * 200 chars. + */ +function redactApiErrorMessage(msg: string): string { + return msg + .replace(/Bearer\s+\S+/gi, "Bearer [REDACTED]") + .replace(/(https?:\/\/[^\s"'?]+)\?[^\s"']*/gi, "$1?[REDACTED]") + .slice(0, 200); +} + +/** + * 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`.) + */ +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 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(": "); +} + /** * Walk every `DataPoint` for one data type since the incremental cursor via * `dataPoints.list` with `nextPageToken` pagination. The data-type id is @@ -697,12 +742,21 @@ export async function fetchDataPoints( .json() .catch(() => null)) as GoogleHealthDataPointPage | null; const verdict = classifyGoogleHealthResponse(res.status); + const apiErrorDetail = + verdict.classification === "success" + ? undefined + : extractGoogleApiErrorDetail(json); getEvent()?.addExternalCall({ service: "google-health", method: `${verb}(page=${pageCount})`, duration_ms: Math.round(performance.now() - pageStart), status: res.status, - error: verdict.classification === "success" ? undefined : verdict.reason, + error: + verdict.classification === "success" + ? undefined + : apiErrorDetail + ? `${verdict.reason} ${apiErrorDetail}` + : verdict.reason, }); if (verdict.classification !== "success") { throw new GoogleHealthApiError({ @@ -710,6 +764,7 @@ export async function fetchDataPoints( classification: verdict.classification, httpStatus: verdict.httpStatus, reason: verdict.reason, + upstreamError: apiErrorDetail, }); } @@ -873,13 +928,21 @@ export async function fetchDailyRollUp( .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: - verdict.classification === "success" ? undefined : verdict.reason, + verdict.classification === "success" + ? undefined + : apiErrorDetail + ? `${verdict.reason} ${apiErrorDetail}` + : verdict.reason, }); if (verdict.classification !== "success") { throw new GoogleHealthApiError({ @@ -887,6 +950,7 @@ export async function fetchDailyRollUp( classification: verdict.classification, httpStatus: verdict.httpStatus, reason: verdict.reason, + upstreamError: apiErrorDetail, }); }