mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
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:
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 },
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user