diff --git a/docs/production-incident-baseline.md b/docs/production-incident-baseline.md index d5bb400a8d..13564b7256 100644 --- a/docs/production-incident-baseline.md +++ b/docs/production-incident-baseline.md @@ -18405,3 +18405,38 @@ Drizzle schema and runtime Zod schemas. Findings and remediations: old worker reaches `Shutdown complete` without exit 137, verify the timed-out Strava job retries and completes, observe no fixed-release 4N/2K events across a subsequent rollout, then resolve both Sentry issues. + +## 2026-07-26 — Peloton Workout Response Drift Blocked Sync + +- **Status:** Direct source fix validated locally; merge and production + deployment pending. +- **Symptoms:** Scheduled Peloton syncs failed at the client response boundary + in + [Sentry issue DOFEK-SERVER-5E](https://east-bay-software.sentry.io/issues/DOFEK-SERVER-5E). +- **User impact:** The affected Peloton connection could not import any workout + page because validation rejected the complete page before parsing began. +- **Evidence:** Six consecutive production events from `14:30` through + `17:00` UTC reported the same response drift. Peloton returned `null` for + workout `title`, `end_time`, `metrics_type`, `peloton_id`, and `strava_id`, + and omitted `ride.instructor.id`; the runtime schema accepted only omitted + optional strings and required a numeric end time and instructor ID. The + latest observed payload and exact Zod paths are preserved in + [Sentry issue DOFEK-SERVER-5E](https://east-bay-software.sentry.io/issues/DOFEK-SERVER-5E). +- **Root cause:** The client boundary modeled several optional upstream fields + as undefined-only even though the observed API payload uses both JSON `null` + and omission for absence. The schema therefore rejected valid unfinished and + partially linked workouts before the provider could normalize them. +- **Fix / mitigation:** Accept only the observed nullability and omission: + nullable title and source-link fields, nullable-but-present `end_time`, and + an optional instructor ID. Keep every other identity and timestamp field + required, preserve the upstream nulls in the client response, and normalize + a null end time to the existing unfinished-workout behavior only in the + parsed provider DTO. +- **Validation:** A production-shaped regression first failed with the exact + six Zod paths reported by Sentry. After the schema correction, all 17 focused + client and parsing tests pass, the root TypeScript typecheck passes, targeted + Biome checks report no findings, and the changed parser kills all 13 + generated mutants. +- **Remaining risk / follow-up:** Merge through normal CI, deploy, observe a + successful scheduled Peloton sync on the fixed release, and then resolve + DOFEK-SERVER-5E. diff --git a/packages/peloton-client/src/client.test.ts b/packages/peloton-client/src/client.test.ts index 51160f78d9..d4dea69155 100644 --- a/packages/peloton-client/src/client.test.ts +++ b/packages/peloton-client/src/client.test.ts @@ -115,6 +115,45 @@ describe("PelotonClient", () => { await expect(unavailable.getWorkouts()).rejects.toBeInstanceOf(PelotonServiceError); }); + it("accepts observed nullable workout fields and missing instructor IDs", async () => { + const response = { + data: [ + { + id: "workout-1", + status: "COMPLETE", + fitness_discipline: "cycling", + title: null, + created_at: 1_709_280_000, + start_time: 1_709_280_000, + end_time: null, + total_work: 0, + is_total_work_personal_record: false, + metrics_type: null, + peloton_id: null, + strava_id: null, + ride: { + id: "ride-1", + title: "Recovery Ride", + duration: 1_800, + instructor: { name: "Coach" }, + }, + }, + ], + total: 1, + count: 1, + page: 0, + limit: 20, + page_count: 1, + sort_by: "-created_at", + show_next: false, + show_previous: false, + }; + const responses = [Response.json({ id: "user-123" }), Response.json(response)]; + const client = new PelotonClient("secret", async () => responses.shift() ?? Response.error()); + + await expect(client.getWorkouts()).resolves.toEqual(response); + }); + it("requests and validates performance graphs", async () => { const graph = { duration: 5, diff --git a/packages/peloton-client/src/parsing.test.ts b/packages/peloton-client/src/parsing.test.ts index db5c9d6cd6..f767d44808 100644 --- a/packages/peloton-client/src/parsing.test.ts +++ b/packages/peloton-client/src/parsing.test.ts @@ -117,6 +117,19 @@ describe("parseWorkout", () => { expect(parsed.raw.instructor).toBeUndefined(); }); + + it("treats nullable completion and source identifiers as absent", () => { + const parsed = parseWorkout({ + ...workout, + end_time: null, + peloton_id: null, + strava_id: null, + }); + + expect(parsed.endedAt).toBeUndefined(); + expect(parsed.stravaId).toBeUndefined(); + expect(parsed.raw.pelotonClassId).toBeUndefined(); + }); }); describe("parsePerformanceGraph", () => { diff --git a/packages/peloton-client/src/parsing.ts b/packages/peloton-client/src/parsing.ts index e07766830b..81fdd42663 100644 --- a/packages/peloton-client/src/parsing.ts +++ b/packages/peloton-client/src/parsing.ts @@ -34,7 +34,8 @@ export interface ParsedPelotonWorkout { export function parseWorkout(workout: PelotonWorkout): ParsedPelotonWorkout { const startedAt = new Date(workout.start_time * 1000); - const endedAt = workout.end_time > 0 ? new Date(workout.end_time * 1000) : undefined; + const endTime = workout.end_time ?? 0; + const endedAt = endTime > 0 ? new Date(endTime * 1000) : undefined; const stravaId = workout.strava_id && workout.strava_id !== "-1" ? workout.strava_id : undefined; return { diff --git a/packages/peloton-client/src/types.ts b/packages/peloton-client/src/types.ts index c031ad2768..27a23de92b 100644 --- a/packages/peloton-client/src/types.ts +++ b/packages/peloton-client/src/types.ts @@ -1,7 +1,7 @@ import { z } from "zod"; export const pelotonInstructorSchema = z.object({ - id: z.string(), + id: z.string().optional(), name: z.string(), image_url: z.string().optional(), }); @@ -21,21 +21,21 @@ export const pelotonWorkoutSchema = z.object({ status: z.string(), fitness_discipline: z.string(), name: z.string().optional(), - title: z.string().optional(), + title: z.string().nullish(), created_at: z.number(), start_time: z.number(), - end_time: z.number(), + end_time: z.number().nullable(), total_work: z.number(), is_total_work_personal_record: z.boolean(), - metrics_type: z.string().optional(), + metrics_type: z.string().nullish(), device_type: z.string().optional(), platform: z.string().optional(), - peloton_id: z.string().optional(), + peloton_id: z.string().nullish(), workout_type: z.string().optional(), has_pedaling_metrics: z.boolean().optional(), has_leaderboard_metrics: z.boolean().optional(), timezone: z.string().optional(), - strava_id: z.string().optional(), + strava_id: z.string().nullish(), ride: pelotonRideSchema.optional(), total_leaderboard_users: z.number().optional(), leaderboard_rank: z.number().optional(), diff --git a/src/providers/provider-api-contracts.test.ts b/src/providers/provider-api-contracts.test.ts index 1fba029a58..20710ab24e 100644 --- a/src/providers/provider-api-contracts.test.ts +++ b/src/providers/provider-api-contracts.test.ts @@ -6,6 +6,7 @@ * * Run: source /tmp/contract-test-env.sh && pnpm vitest run src/providers/provider-api-contracts.test.ts */ +import { pelotonWorkoutSchema } from "@dofek/peloton/types"; import { describe, expect, it } from "vitest"; import { z } from "zod"; import { @@ -411,18 +412,6 @@ describe.skipIf(!hasPolar)("Polar API contract", () => { const hasPeloton = envOrEmpty("PELOTON_REFRESH_TOKEN").length > 0; -const pelotonWorkoutSchema = z.object({ - id: z.string(), - status: z.string(), - fitness_discipline: z.string(), - name: z.string(), - created_at: z.number(), - start_time: z.number(), - end_time: z.number(), - total_work: z.number().optional(), - is_total_work_personal_record: z.boolean().optional(), -}); - const pelotonMeSchema = z.object({ id: z.string(), });