Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions packages/mobile/app/providers/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -433,13 +433,13 @@ export default function ProvidersScreen() {
);

const handleSyncProvider = useCallback(
async (providerId: string) => {
async (providerId: string, fullSync = false) => {
setSyncingProviders((prev) => new Set(prev).add(providerId));
setAnySyncing(true);
try {
const result = await syncMutation.mutateAsync({
providerId,
sinceDays: ROUTINE_SYNC_DAYS,
sinceDays: fullSync ? undefined : ROUTINE_SYNC_DAYS,
});
const providerResult = result.providerResults?.find(
(entry) => entry.providerId === providerId,
Expand Down Expand Up @@ -853,6 +853,7 @@ export default function ProvidersScreen() {
syncing={syncingProviders.has(provider.id)}
syncProgress={syncProgress[provider.id]}
onSync={() => handleSyncProvider(provider.id)}
onFullSync={() => handleSyncProvider(provider.id, true)}
onConnect={() => handleConnect(provider)}
onPress={() => router.push(`/providers/${provider.id}`)}
/>
Expand Down
15 changes: 15 additions & 0 deletions packages/mobile/app/providers/provider-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ export function ProviderCard({
importing = false,
syncProgress,
onSync,
onFullSync,
onConnect,
onImport,
onPress,
Expand All @@ -96,6 +97,7 @@ export function ProviderCard({
importing?: boolean;
syncProgress: { percentage?: number; message?: string; failedCount?: number } | undefined;
onSync: () => void;
onFullSync?: () => void;
onConnect: () => void;
onImport?: () => void;
onPress: () => void;
Expand Down Expand Up @@ -187,6 +189,19 @@ export function ProviderCard({
) : (
<Text style={styles.cardMetaText}>Never synced</Text>
))}
{canRunManualSync &&
provider.authStatus === "connected" &&
onFullSync !== undefined &&
!syncing && (
<TouchableOpacity
onPress={onFullSync}
activeOpacity={0.7}
accessibilityRole="button"
accessibilityLabel="Full sync"
>
<Text style={styles.fullSyncLink}>Full sync</Text>
</TouchableOpacity>
)}
</View>
)}

Expand Down
72 changes: 72 additions & 0 deletions packages/mobile/lib/background-health-kit-sync.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -356,6 +356,78 @@ describe("initBackgroundHealthKitSync", () => {
vi.useRealTimers();
});

it("does not report TRPCClientError background fetch timeouts to Sentry (DOFEK-MOBILE-19)", async () => {
vi.useFakeTimers();
const client = createMockClient();
await initBackgroundHealthKitSync(client);
await vi.runAllTimersAsync();
mockCaptureException.mockClear();
vi.mocked(queryDailyStatistics).mockResolvedValueOnce([{ date: "2026-03-22", value: 1_000 }]);
const timeoutError = new Error("fetch failed: UnexpectedException: The request timed out.");
client.healthKitSync.pushQuantitySamples.mutate.mockRejectedValueOnce(
new Error("TRPCClientError", { cause: timeoutError }),
);

const listener = mockAddSampleUpdateListener.mock.calls[0][0];
listener({
typeIdentifier: "HKQuantityTypeIdentifierStepCount",
updateId: "update-1",
});

await vi.advanceTimersByTimeAsync(5000);
await vi.runAllTimersAsync();

expect(mockCaptureException).not.toHaveBeenCalled();
expect(mockLoggerInfo).toHaveBeenCalledWith(
"bg-healthkit-sync",
"Background HealthKit upload timed out; retrying on next delivery",
);
expect(mockCompleteObserverUpdates).toHaveBeenCalledWith(["update-1"], false);
vi.useRealTimers();
});

it("does not report observer sync result errors that are only transient timeouts", async () => {
vi.useFakeTimers();
const client = createMockClient();
await initBackgroundHealthKitSync(client);
await vi.runAllTimersAsync();
mockCaptureException.mockClear();
vi.mocked(queryWorkouts).mockResolvedValueOnce([
{
uuid: "workout-1",
activityType: 1,
startDate: "2026-03-22T10:00:00Z",
endDate: "2026-03-22T11:00:00Z",
duration: 3600,
totalDistance: 10000,
sourceName: "Apple Watch",
},
]);
vi.mocked(queryWorkoutRoutes).mockResolvedValueOnce([
{ latitude: 37.77, longitude: -122.42, timestamp: "2026-03-22T10:00:00Z" },
]);
client.healthKitSync.pushWorkoutRoutes.mutate.mockRejectedValueOnce(
new Error("fetch failed: UnexpectedException: The request timed out."),
);

const listener = mockAddSampleUpdateListener.mock.calls[0][0];
listener({
typeIdentifier: "HKWorkoutTypeIdentifier",
updateId: "update-1",
});

await vi.advanceTimersByTimeAsync(5000);
await vi.runAllTimersAsync();

expect(mockCaptureException).not.toHaveBeenCalled();
expect(mockLoggerInfo).toHaveBeenCalledWith(
"bg-healthkit-sync",
"Background HealthKit upload timed out; retrying on next delivery",
);
expect(mockCompleteObserverUpdates).toHaveBeenCalledWith(["update-1"], false);
vi.useRealTimers();
});

it("marks native observer sync lifecycle while draining deliveries (DOFEK-MOBILE-1C)", async () => {
const client = createMockClient();
await initBackgroundHealthKitSync(client);
Expand Down
13 changes: 11 additions & 2 deletions packages/mobile/lib/background-health-kit-sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { AppleHealthAuthorizationService, AppleHealthSyncService } from "./apple
import {
isBackgroundHealthKitTransientNetworkError,
isHealthKitDatabaseInaccessible,
isTransientNetworkErrorMessage,
} from "./health-kit-errors";
import {
BACKGROUND_HEALTH_KIT_TYPES,
Expand Down Expand Up @@ -103,13 +104,21 @@ async function performHealthKitSync(
}

if (result.errors.length > 0) {
const actionableErrors = result.errors.filter(
(message) => !isTransientNetworkErrorMessage(message),
);
if (actionableErrors.length === 0) {
stageTelemetry.complete("failed");
logger.info(TAG, "Background HealthKit upload timed out; retrying on next delivery");
return false;
}
stageTelemetry.complete("failed");
const error = new Error(
`HealthKit observer sync completed with ${result.errors.length} error(s): ${result.errors.join("; ")}`,
`HealthKit observer sync completed with ${actionableErrors.length} error(s): ${actionableErrors.join("; ")}`,
);
logger.warn(TAG, error.message);
captureException(error, {
errorCount: result.errors.length,
errorCount: actionableErrors.length,
source: TAG,
});
return false;
Expand Down
38 changes: 38 additions & 0 deletions packages/mobile/lib/health-kit-errors.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { describe, expect, it } from "vitest";
import {
isBackgroundHealthKitTransientNetworkError,
isHealthKitSentrySource,
isTransientNetworkErrorMessage,
} from "./health-kit-errors";

describe("health-kit-errors", () => {
it("detects transient background fetch timeout messages", () => {
expect(
isTransientNetworkErrorMessage("fetch failed: UnexpectedException: The request timed out."),
).toBe(true);
expect(
isTransientNetworkErrorMessage(
"Push workout routes: fetch failed: UnexpectedException: The request timed out.",
),
).toBe(true);
expect(isTransientNetworkErrorMessage("network unreachable")).toBe(false);
expect(isTransientNetworkErrorMessage("fetch failed: connection reset")).toBe(false);
expect(isTransientNetworkErrorMessage("request timeout")).toBe(false);
});

it("detects transient network errors on Error instances and causes (DOFEK-MOBILE-19)", () => {
const timeoutError = new Error("fetch failed: UnexpectedException: The request timed out.");
const trpcError = new Error("TRPCClientError", { cause: timeoutError });

expect(isBackgroundHealthKitTransientNetworkError(timeoutError)).toBe(true);
expect(isBackgroundHealthKitTransientNetworkError(trpcError)).toBe(true);
expect(isBackgroundHealthKitTransientNetworkError(new Error("server error"))).toBe(false);
});

it("identifies HealthKit Sentry sources used for scoped timeout filtering", () => {
expect(isHealthKitSentrySource("bg-healthkit-sync")).toBe(true);
expect(isHealthKitSentrySource("health-kit-workout-route-push")).toBe(true);
expect(isHealthKitSentrySource("auto-sync-providers")).toBe(false);
expect(isHealthKitSentrySource(undefined)).toBe(false);
});
});
26 changes: 22 additions & 4 deletions packages/mobile/lib/health-kit-errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,29 @@ export function isHealthKitDatabaseInaccessible(error: unknown): boolean {
);
}

export function isTransientNetworkErrorMessage(message: string): boolean {
// Match the React Native fetch timeout shape seen in DOFEK-MOBILE-19, including
// when the message is prefixed by sync-stage labels or TRPC wrappers.
return /fetch failed.*the request timed out/i.test(message);
}

export function isBackgroundHealthKitTransientNetworkError(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error);
const normalized = message.toLowerCase();
if (error instanceof Error) {
if (isTransientNetworkErrorMessage(error.message)) {
return true;
}
if (error.cause !== undefined) {
return isBackgroundHealthKitTransientNetworkError(error.cause);
}
return false;
}
return isTransientNetworkErrorMessage(String(error));
}

export const HEALTHKIT_BACKGROUND_SENTRY_SOURCE = "bg-healthkit-sync";

export function isHealthKitSentrySource(source: string | undefined): boolean {
return (
normalized.includes("fetch failed") &&
(normalized.includes("timed out") || normalized.includes("timeout"))
source === HEALTHKIT_BACKGROUND_SENTRY_SOURCE || (source?.startsWith("health-kit-") ?? false)
);
}
21 changes: 21 additions & 0 deletions packages/mobile/lib/health-kit-sync.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -402,6 +402,27 @@ describe("syncHealthKitToServer", () => {
expect(client.healthKitSync.pushWorkoutRoutes.mutate).not.toHaveBeenCalled();
});

it("does not report transient TRPC timeout wrappers during route push", async () => {
const client = createMockClient();
const healthKit = createMockHealthKit();
healthKit.queryWorkoutRoutes.mockResolvedValue([
{ date: "2026-03-21T07:00:00Z", lat: 40.7128, lng: -74.006 },
]);
const timeoutCause = new Error("fetch failed: UnexpectedException: The request timed out.");
const trpcTimeoutError = new Error("TRPCClientError", { cause: timeoutCause });
client.healthKitSync.pushWorkoutRoutes.mutate.mockRejectedValue(trpcTimeoutError);
vi.mocked(captureException).mockClear();

const result = await syncHealthKitToServer({
trpcClient: client,
healthKit,
syncRangeDays: 1,
});

expect(result.errors.some((error) => error.includes("Push workout routes"))).toBe(true);
expect(captureException).not.toHaveBeenCalled();
});

it("records route push errors as non-fatal", async () => {
const client = createMockClient();
const healthKit = createMockHealthKit();
Expand Down
66 changes: 49 additions & 17 deletions packages/mobile/lib/health-kit-sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,10 @@ import type {
SleepSample,
WorkoutSample,
} from "../modules/health-kit";
import { isHealthKitDatabaseInaccessible } from "./health-kit-errors";
import {
isBackgroundHealthKitTransientNetworkError,
isHealthKitDatabaseInaccessible,
} from "./health-kit-errors";
import { captureException } from "./telemetry";

// Additive types use HKStatisticsCollectionQuery for proper source deduplication.
Expand Down Expand Up @@ -79,6 +82,33 @@ function normalizeWorkout(workout: WorkoutSample): WorkoutSample {
};
}

interface WorkoutRouteErrorContext {
errors: string[];
errorLabel: string;
source: string;
captureContext?: Record<string, unknown>;
suppressTransientNetworkErrors?: boolean;
}

function handleWorkoutRouteError(
error: unknown,
context: WorkoutRouteErrorContext,
): "locked" | undefined {
if (isHealthKitDatabaseInaccessible(error)) {
return "locked";
}
const shouldReport =
!context.suppressTransientNetworkErrors || !isBackgroundHealthKitTransientNetworkError(error);
if (shouldReport) {
captureException(error, {
source: context.source,
...context.captureContext,
});
}
const message = error instanceof Error ? error.message : String(error);
context.errors.push(`${context.errorLabel}: ${message}`);
}

function isAuthorizationNotDetermined(error: unknown): boolean {
if (typeof error !== "object" || error === null || !("code" in error)) {
return false;
Expand Down Expand Up @@ -327,12 +357,12 @@ export async function syncHealthKitToServer(options: SyncOptions): Promise<SyncR
if (isHealthKitDatabaseInaccessible(error)) {
throw error;
}
captureException(error, {
handleWorkoutRouteError(error, {
errors,
errorLabel: `Route query for workout ${workout.uuid}`,
source: "health-kit-workout-route-query",
workoutUuid: workout.uuid,
captureContext: { workoutUuid: workout.uuid },
});
const message = error instanceof Error ? error.message : String(error);
errors.push(`Route query for workout ${workout.uuid}: ${message}`);
}
}
return workerRoutes;
Expand All @@ -350,12 +380,13 @@ export async function syncHealthKitToServer(options: SyncOptions): Promise<SyncR
const routeResult = await trpcClient.healthKitSync.pushWorkoutRoutes.mutate({ routes });
totalInserted += routeResult.inserted;
} catch (error) {
captureException(error, {
handleWorkoutRouteError(error, {
errors,
errorLabel: "Push workout routes",
source: "health-kit-workout-route-push",
routeCount: routes.length,
captureContext: { routeCount: routes.length },
suppressTransientNetworkErrors: true,
});
const message = error instanceof Error ? error.message : String(error);
errors.push(`Push workout routes: ${message}`);
}
}
}
Expand Down Expand Up @@ -508,12 +539,12 @@ async function syncObserverWorkouts(
if (isHealthKitDatabaseInaccessible(error)) {
throw error;
}
captureException(error, {
handleWorkoutRouteError(error, {
errors,
errorLabel: `Route sync for workout ${workout.uuid}`,
source: "health-kit-workout-route-observer-sync",
workoutUuid: workout.uuid,
captureContext: { workoutUuid: workout.uuid },
});
const message = error instanceof Error ? error.message : String(error);
errors.push(`Route sync for workout ${workout.uuid}: ${message}`);
}
}

Expand All @@ -523,12 +554,13 @@ async function syncObserverWorkouts(
const routeResult = await trpcClient.healthKitSync.pushWorkoutRoutes.mutate({ routes });
inserted += routeResult.inserted;
} catch (error) {
captureException(error, {
handleWorkoutRouteError(error, {
errors,
errorLabel: "Push workout routes",
source: "health-kit-workout-route-observer-push",
routeCount: routes.length,
captureContext: { routeCount: routes.length },
suppressTransientNetworkErrors: true,
});
const message = error instanceof Error ? error.message : String(error);
errors.push(`Push workout routes: ${message}`);
}
}
return { deleted: 0, inserted, errors };
Expand Down
Loading
Loading