feat(google-health): surface the API error body and add a structure probe

- 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.
This commit is contained in:
Marc-André Bombeck committed 2026-07-05 13:59:24 +02:00
1 parent 295a6a8050
commit f92e9fd840
2 files changed
+206 -5

No files matched your search

@@ -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<string, unknown>).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<Record<string, ProbeResult>> {
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<string, ProbeResult> = {};
for (const [name, dataType] of Object.entries(GOOGLE_HEALTH_DATA_TYPES)) {
try {
let points: Record<string, unknown>[];
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 },
+67 -3
View File
@@ -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,
});
}