feat(integrations): migrate google-health from Fit REST to Google Health API v4

Google Fit REST API was closed to new sign-ups on 2024-05-01 and shuts down
end of 2026, surfacing as "Access blocked: this app's request is invalid"
when starting the OAuth flow.

- Swap the 10 fitness.* OAuth scopes for the 3 googlehealth.*.readonly
  scopes (activity_and_fitness, health_metrics_and_measurements, sleep).
- Replace fitness/v1 dataset:aggregate + sessions calls with
  health.googleapis.com/v4/users/me/dataTypes/{steps,total-calories,
  heart-rate,sleep}/dataPoints, filtered to today's window.
- Read the v4 DataPoint union defensively (the per-type schema is sparsely
  documented) and log the first raw sample at debug so we can refine field
  paths after the first real OAuth.
- Output Signal contract is unchanged — agents and downstream consumers
  see the same steps/activity/heart_rate/sleep signals.

Cloud Console still needs: enable Google Health API, add the 3 scopes to
the consent screen, add test user (all googlehealth scopes are Restricted).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-15 05:42:05 +00:00
parent 2159d4cbd1
commit ac1226c367
2 changed files with 135 additions and 151 deletions

View File

@@ -17,17 +17,9 @@ const GOOGLE_TOKEN_URL = 'https://oauth2.googleapis.com/token';
const GOOGLE_REVOKE_URL = 'https://oauth2.googleapis.com/revoke';
const GOOGLE_HEALTH_SCOPES = [
'https://www.googleapis.com/auth/fitness.activity.read',
'https://www.googleapis.com/auth/fitness.body.read',
'https://www.googleapis.com/auth/fitness.sleep.read',
'https://www.googleapis.com/auth/fitness.heart_rate.read',
'https://www.googleapis.com/auth/fitness.nutrition.read',
'https://www.googleapis.com/auth/fitness.location.read',
'https://www.googleapis.com/auth/fitness.blood_glucose.read',
'https://www.googleapis.com/auth/fitness.blood_pressure.read',
'https://www.googleapis.com/auth/fitness.body_temperature.read',
'https://www.googleapis.com/auth/fitness.oxygen_saturation.read',
'https://www.googleapis.com/auth/fitness.reproductive_health.read',
'https://www.googleapis.com/auth/googlehealth.activity_and_fitness.readonly',
'https://www.googleapis.com/auth/googlehealth.health_metrics_and_measurements.readonly',
'https://www.googleapis.com/auth/googlehealth.sleep.readonly',
].join(' ');
// In-memory CSRF state store

View File

@@ -7,33 +7,20 @@ import { config } from '../config.js';
import { logger } from '../logger.js';
const CACHE_TTL_MS = 5 * 60_000;
const FIT_AGGREGATE_URL = 'https://www.googleapis.com/fitness/v1/users/me/dataset:aggregate';
const FIT_SESSIONS_URL = 'https://www.googleapis.com/fitness/v1/users/me/sessions';
const HEALTH_API_BASE = 'https://health.googleapis.com/v4/users/me/dataTypes';
const GOOGLE_TOKEN_URL = 'https://oauth2.googleapis.com/token';
const STEP_DAILY_GOAL = 7_000;
const SLEEP_GOAL_HOURS = 7;
interface FitBucket {
dataset: Array<{
dataSourceId: string;
point: Array<{ value: Array<{ intVal?: number; fpVal?: number }> }>;
}>;
// v4 DataPoint shape is a union keyed by data type; we read defensively.
interface DataPoint {
[key: string]: unknown;
}
interface FitAggregateResponse {
bucket?: FitBucket[];
}
interface FitSession {
name: string;
startTimeMillis: string;
endTimeMillis: string;
activityType: number;
}
interface FitSessionsResponse {
session?: FitSession[];
interface DataPointsResponse {
dataPoints?: DataPoint[];
nextPageToken?: string;
}
async function refreshGoogleToken(
@@ -66,81 +53,62 @@ async function refreshGoogleToken(
return data.access_token;
}
function todayMidnightMs(): number {
function todayMidnightIso(): string {
const d = new Date();
d.setHours(0, 0, 0, 0);
return d.getTime();
return d.toISOString();
}
function yesterdayIso(): string {
return new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
}
async function fetchAggregates(
async function fetchDataPoints(
token: string,
startMs: number,
endMs: number,
): Promise<FitAggregateResponse> {
const res = await fetch(FIT_AGGREGATE_URL, {
method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({
aggregateBy: [
{ dataTypeName: 'com.google.step_count.delta' },
{ dataTypeName: 'com.google.calories.expended' },
{ dataTypeName: 'com.google.active_minutes' },
{ dataTypeName: 'com.google.heart_rate.bpm' },
],
bucketByTime: { durationMillis: endMs - startMs },
startTimeMillis: String(startMs),
endTimeMillis: String(endMs),
}),
});
if (!res.ok) throw new Error(`Fit aggregate: ${res.status}`);
return res.json() as Promise<FitAggregateResponse>;
}
async function fetchSleepSessions(token: string): Promise<FitSessionsResponse> {
const url = new URL(FIT_SESSIONS_URL);
url.searchParams.set('activityType', '72');
url.searchParams.set('startTime', yesterdayIso());
url.searchParams.set('endTime', new Date().toISOString());
dataType: string,
filter: string,
): Promise<DataPoint[]> {
const url = new URL(`${HEALTH_API_BASE}/${dataType}/dataPoints`);
url.searchParams.set('filter', filter);
const res = await fetch(url.toString(), {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) throw new Error(`Fit sessions: ${res.status}`);
return res.json() as Promise<FitSessionsResponse>;
if (!res.ok) throw new Error(`health ${dataType}: ${res.status}`);
const data = (await res.json()) as DataPointsResponse;
return data.dataPoints ?? [];
}
function extractMetric(
bucket: FitBucket,
dataTypeName: string,
valueKey: 'intVal' | 'fpVal',
): number {
for (const ds of bucket.dataset) {
if (!ds.dataSourceId.includes(dataTypeName.replace('com.google.', '').replace('.', '_'))) continue;
for (const pt of ds.point) {
const v = pt.value[0];
if (v) return valueKey === 'intVal' ? (v.intVal ?? 0) : (v.fpVal ?? 0);
// Defensive numeric reader — probes likely field names in a v4 DataPoint payload.
function readNumber(point: DataPoint, paths: string[][]): number {
for (const path of paths) {
let cur: unknown = point;
for (const key of path) {
if (cur && typeof cur === 'object' && key in (cur as object)) {
cur = (cur as Record<string, unknown>)[key];
} else {
cur = undefined;
break;
}
}
if (typeof cur === 'number') return cur;
}
return 0;
}
function extractAnyMetric(
bucket: FitBucket,
typeSuffix: string,
valueKey: 'intVal' | 'fpVal',
): number {
for (const ds of bucket.dataset) {
if (!ds.dataSourceId.includes(typeSuffix)) continue;
const pt = ds.point[0];
if (pt?.value[0]) {
const v = pt.value[0];
return valueKey === 'intVal' ? (v.intVal ?? 0) : (v.fpVal ?? 0);
function readString(point: DataPoint, paths: string[][]): string | undefined {
for (const path of paths) {
let cur: unknown = point;
for (const key of path) {
if (cur && typeof cur === 'object' && key in (cur as object)) {
cur = (cur as Record<string, unknown>)[key];
} else {
cur = undefined;
break;
}
}
if (typeof cur === 'string') return cur;
}
return 0;
return undefined;
}
export class GoogleHealthSignalSource implements SignalSource {
@@ -187,57 +155,76 @@ export class GoogleHealthSignalSource implements SignalSource {
}
try {
const startMs = todayMidnightMs();
const endMs = Date.now();
const dayStartIso = todayMidnightIso();
const dayEndIso = new Date().toISOString();
const yIso = yesterdayIso();
const [aggData, sleepData] = await Promise.all([
fetchAggregates(token, startMs, endMs),
fetchSleepSessions(token),
const stepsFilter = `steps.interval.start_time >= "${dayStartIso}" AND steps.interval.start_time < "${dayEndIso}"`;
const caloriesFilter = `total_calories.interval.start_time >= "${dayStartIso}" AND total_calories.interval.start_time < "${dayEndIso}"`;
const hrFilter = `heart_rate.sample_time.physical_time >= "${dayStartIso}" AND heart_rate.sample_time.physical_time < "${dayEndIso}"`;
const sleepFilter = `sleep.interval.start_time >= "${yIso}" AND sleep.interval.start_time < "${dayEndIso}"`;
const [stepsPts, caloriesPts, hrPts, sleepPts] = await Promise.all([
fetchDataPoints(token, 'steps', stepsFilter),
fetchDataPoints(token, 'total-calories', caloriesFilter),
fetchDataPoints(token, 'heart-rate', hrFilter),
fetchDataPoints(token, 'sleep', sleepFilter),
]);
const bucket = aggData.bucket?.[0];
// One-time peek at raw shape so we can refine field paths after first real OAuth.
logger.debug(
{ userId, samples: { stepsPts: stepsPts.slice(0, 1), caloriesPts: caloriesPts.slice(0, 1), hrPts: hrPts.slice(0, 1), sleepPts: sleepPts.slice(0, 1) } },
'google-health: v4 dataPoints sample',
);
const signals: Signal[] = [];
const now = new Date().toISOString();
if (bucket) {
// Steps
const steps = extractAnyMetric(bucket, 'step_count', 'intVal');
const stepGoalPct = Math.round((steps / STEP_DAILY_GOAL) * 100);
signals.push({
id: `google-health:steps`,
source: 'google-health',
kind: 'health',
content: `${steps.toLocaleString()} steps today (${stepGoalPct}% of ${STEP_DAILY_GOAL.toLocaleString()} goal)`,
metadata: { dataType: 'steps' },
features: {
step_count: steps,
step_goal_pct: stepGoalPct,
step_goal: STEP_DAILY_GOAL,
below_step_goal: steps < STEP_DAILY_GOAL,
},
timestamp: now,
});
const steps = stepsPts.reduce(
(sum, p) => sum + readNumber(p, [['steps', 'count'], ['count']]),
0,
);
const stepGoalPct = Math.round((steps / STEP_DAILY_GOAL) * 100);
signals.push({
id: `google-health:steps`,
source: 'google-health',
kind: 'health',
content: `${steps.toLocaleString()} steps today (${stepGoalPct}% of ${STEP_DAILY_GOAL.toLocaleString()} goal)`,
metadata: { dataType: 'steps' },
features: {
step_count: steps,
step_goal_pct: stepGoalPct,
step_goal: STEP_DAILY_GOAL,
below_step_goal: steps < STEP_DAILY_GOAL,
},
timestamp: now,
});
// Calories + active minutes
const calories = Math.round(extractAnyMetric(bucket, 'calories', 'fpVal'));
const activeMinutes = extractAnyMetric(bucket, 'active_minutes', 'intVal');
signals.push({
id: `google-health:activity`,
source: 'google-health',
kind: 'health',
content: `${activeMinutes} active minutes, ${calories} calories burned today`,
metadata: { dataType: 'activity' },
features: {
active_minutes: activeMinutes,
calories_burned: calories,
sedentary: activeMinutes < 20,
},
timestamp: now,
});
const calories = Math.round(
caloriesPts.reduce(
(sum, p) =>
sum + readNumber(p, [['totalCalories', 'kilocalories'], ['kilocalories'], ['energy', 'kilocalories']]),
0,
),
);
signals.push({
id: `google-health:activity`,
source: 'google-health',
kind: 'health',
content: `${calories} calories burned today`,
metadata: { dataType: 'activity' },
features: {
calories_burned: calories,
},
timestamp: now,
});
// Heart rate
const bpm = Math.round(extractAnyMetric(bucket, 'heart_rate', 'fpVal'));
if (bpm > 0) {
if (hrPts.length > 0) {
const hrValues = hrPts
.map((p) => readNumber(p, [['heartRate', 'beatsPerMinute'], ['beatsPerMinute']]))
.filter((v) => v > 0);
if (hrValues.length > 0) {
const bpm = Math.round(hrValues.reduce((a, b) => a + b, 0) / hrValues.length);
signals.push({
id: `google-health:heart_rate`,
source: 'google-health',
@@ -250,29 +237,34 @@ export class GoogleHealthSignalSource implements SignalSource {
}
}
// Sleep — find the most recent sleep session
if (sleepData.session?.length) {
const sorted = [...sleepData.session].sort(
(a, b) => Number(b.endTimeMillis) - Number(a.endTimeMillis),
);
const last = sorted[0]!;
const durationMs = Number(last.endTimeMillis) - Number(last.startTimeMillis);
const sleepHours = Math.round((durationMs / 3_600_000) * 10) / 10;
const belowGoal = sleepHours < SLEEP_GOAL_HOURS;
signals.push({
id: `google-health:sleep`,
source: 'google-health',
kind: 'health',
content: `${sleepHours}h sleep last night (${belowGoal ? 'below' : 'meets'} ${SLEEP_GOAL_HOURS}h goal)`,
metadata: { dataType: 'sleep', sessionName: last.name },
features: {
sleep_hours: sleepHours,
sleep_goal_hours: SLEEP_GOAL_HOURS,
sleep_deficit_hours: Math.max(0, SLEEP_GOAL_HOURS - sleepHours),
below_sleep_goal: belowGoal,
},
timestamp: now,
});
if (sleepPts.length > 0) {
const sleepSessions = sleepPts
.map((p) => ({
start: readString(p, [['sleep', 'interval', 'startTime'], ['interval', 'startTime'], ['startTime']]),
end: readString(p, [['sleep', 'interval', 'endTime'], ['interval', 'endTime'], ['endTime']]),
}))
.filter((s): s is { start: string; end: string } => !!s.start && !!s.end)
.sort((a, b) => Date.parse(b.end) - Date.parse(a.end));
const last = sleepSessions[0];
if (last) {
const durationMs = Date.parse(last.end) - Date.parse(last.start);
const sleepHours = Math.round((durationMs / 3_600_000) * 10) / 10;
const belowGoal = sleepHours < SLEEP_GOAL_HOURS;
signals.push({
id: `google-health:sleep`,
source: 'google-health',
kind: 'health',
content: `${sleepHours}h sleep last night (${belowGoal ? 'below' : 'meets'} ${SLEEP_GOAL_HOURS}h goal)`,
metadata: { dataType: 'sleep' },
features: {
sleep_hours: sleepHours,
sleep_goal_hours: SLEEP_GOAL_HOURS,
sleep_deficit_hours: Math.max(0, SLEEP_GOAL_HOURS - sleepHours),
below_sleep_goal: belowGoal,
},
timestamp: now,
});
}
}
this.cache.set(userId, { signals, fetchedAt: Date.now() });