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
6 changes: 4 additions & 2 deletions PRIVACY.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,11 @@ Events use a random anonymous installation identifier that is separate from your

Your choice and anonymous identifier stay on this device and are not synced or included in activity exports. Turn the switch off to stop capture, discard queued events, abort pending analytics requests and remove the stored anonymous identifier. Turning it on again creates a new identifier. Events already received by PostHog are not erased by withdrawal. The event queue is bounded and kept in memory, so offline use works normally and unsent events may be lost when the app is closed. Analytics are optional and do not affect local logging or cloud access. App consent and website consent are separate. Earlier releases without this setting do not collect these app events.

## Local sync diagnostics
## Local sync and onboarding diagnostics

The app keeps up to 100 recent sync diagnostic entries on this device. They contain attempt times, API route categories, request methods, response status codes, durations, error categories and queue counts. They exclude puppy records, names, notes, photos, account or record identifiers, full URLs, credentials and raw error messages. New entries replace older entries. These diagnostics are separate from optional usage analytics and are not automatically sent to Puptime or PostHog.
The app keeps up to 100 recent diagnostic entries on this device. Sync entries contain attempt times, API route categories, request methods, response status codes, durations, error categories and queue counts. Builds with onboarding diagnostics also record fixed setup-screen and guest-button states, navigation decisions and whether an animation finished, was interrupted or had an unknown result. Those onboarding entries use predefined categories and booleans, plus their time.

Entries exclude puppy records, names, emails, entered text, notes, photos, account/device/record identifiers, full URLs, credentials and raw error messages or stacks. New entries replace older entries. These diagnostics are separate from optional usage analytics and are not automatically sent to Puptime or PostHog.

**My account → Export sync diagnostics** creates a JSON report with those entries and the app version, build and platform, then opens the system share sheet. One report remains in the app's temporary cache so the chosen destination can read it, and the next export replaces it. The operating system may clear this cache. The app or destination you choose handles saved copies under its own privacy practices.

Expand Down
2 changes: 1 addition & 1 deletion docs/product-operating-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ Install over the existing app, open the journal and check **My account** for **U

Batch routine updates to at most one owner install per week. An urgent qualified recovery or security fix can justify an earlier install. Give an explicit **Install**, **Optional** or **Hold** verdict. Install/Optional includes version/build, immutable artifact link, source, checksum, reason, tested upgrade path and the smallest owner action. Hold names the actual blocking gate and next step; it needs no speculative download link.

The published diagnostic export is a bounded **transport and sync-queue report**, not a backup, general crash logger or complete local-storage report. Source change #197 adds optional finite handled-read/write/import failure codes to product telemetry for a future binary; it does not expand that export or diagnose the underlying data class. Neither missing telemetry nor a read-error code proves inactivity, corruption or a healthy cloud copy. Exclude raw values, records, SQL, identifiers and secrets.
The published diagnostic export covers transport and sync queues. Candidate source extends the same device-only, 100-entry ring with finite onboarding lifecycle, guest-button, navigation and animation observations; this requires a qualified new binary and does not add records to earlier APKs. The report is shared only through explicit export and is not automatically uploaded. It is not a backup, general crash logger or complete local-storage report. Source change #197 adds separate opt-in handled-read/write/import failure codes to product telemetry; those do not diagnose the underlying data class. Missing breadcrumbs or telemetry do not prove inactivity, corruption or a healthy cloud copy. Exclude raw values, records, SQL, identifiers and secrets; see the [diagnostics scope](sync-diagnostics.md).

## Candidate verification

Expand Down
8 changes: 6 additions & 2 deletions docs/sync-diagnostics.md
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
# Sync diagnostics
# Local sync and onboarding diagnostics

Native profile-photo uploads send validated JPEG bytes. Passing an Expo `File` directly to the authentication SDK sends its JSON metadata instead of image bytes, which can reject the photo and stop the queued activity upload that follows it. Reading the existing file's bytes preserves its photo ID and the pending activity operations.

Native builds keep the latest 100 structured diagnostic entries on the device, separately from opt-in product analytics. Open **My account → Export sync diagnostics** to create a JSON report through the system share sheet. Export is available without a network connection or an active account. Nothing is uploaded automatically.

The report includes its schema version, export time, native app version/build and platform. Entries contain allowlisted request categories (`config`, `account`, `enroll`, `push`, `pull`, `history`, `photo-upload`, `photo-download`), method, actual HTTP status where observed, duration, result/error category, batch size and sync state/queue counts. A missing status means no response status was observed; it is not a synthetic success. A successful HTTP response can still have an invalid-response category if its body fails validation.

Entries exclude account/puppy/activity/device identifiers, names, notes, photo bytes, request/response bodies, full URLs/query strings, credentials and raw errors. Unknown properties are removed both when recording and when loading persisted history. The bounded AsyncStorage write queue is best effort and cannot prevent logging or sync. Duplicate exports share one active operation. A single cache report remains available to the chosen share target and is replaced on the next export; the operating system may clear it. User-saved copies remain where the user chose to save them.
Builds with onboarding diagnostics add fixed observations of flow mount/unmount, committed welcome/account/profile screens and account-action locks, guest touch-start/handler entry, the profile-navigation decision and animation settlement (`finished`, `interrupted` or `unknown`). These entries use only predefined categories, booleans and the existing timestamp. They share the same 100-entry ring; there is no separate recorder, storage key or automatic upload.

A touch-start without handler entry can be gesture cancellation. An accepted navigation decision is not a committed screen, and animation settlement is not proof of visible pixels. Missing entries or a mount/unmount sequence do not establish the underlying cause. This is a bounded support report, not a crash logger or complete local-storage log.

Entries exclude account/puppy/activity/device identifiers, names, emails, entered text, notes, photo bytes, request/response bodies, full URLs/query strings, credentials and raw error messages or stacks. Unknown properties are removed both when recording and when loading persisted history. The bounded AsyncStorage write queue is best effort and cannot prevent logging or sync. Duplicate exports share one active operation. A single cache report remains available to the chosen share target and is replaced on the next export; the operating system may clear it. User-saved copies remain where the user chose to save them.

For a paused sync investigation, preserve a current external activity backup and the original installation first. Collect the diagnostic report after one retry. Read the last request category/status together with sync pending/blocked counts. A generic `invalid-request` or `conflict` during sync is not evidence of failed sign-in. Avoid signing out, clearing storage, relinking or importing over the live account to investigate an unknown failure.

Expand Down
3 changes: 2 additions & 1 deletion src/cloud/handle.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { AccountController } from './account-controller';
import type {RecordOnboardingDiagnostic} from './sync-diagnostics';
import type {SharingApi} from './sharing-api';

export type CloudHandle={available:boolean;controller:AccountController|null;problem:'configuration'|'local-storage'|null;getLocalName:()=>Promise<string>;retry?:()=>void;shareDiagnostics?:()=>Promise<void>;sharing?:SharingApi;sharingOrigin?:string};
export type CloudHandle={available:boolean;controller:AccountController|null;problem:'configuration'|'local-storage'|null;getLocalName:()=>Promise<string>;retry?:()=>void;shareDiagnostics?:()=>Promise<void>;recordOnboardingDiagnostic?:RecordOnboardingDiagnostic;sharing?:SharingApi;sharingOrigin?:string};
22 changes: 18 additions & 4 deletions src/cloud/sync-diagnostics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,10 @@ test('diagnostics retain only bounded allowlisted data and sanitize persisted hi
const reloaded = createSyncDiagnostics(storage);
const events = await reloaded.read();
assert.equal(events.length, 100);
assert.equal(events[0].pending, 5);
assert.equal(events.at(-1)?.pending, 104);
events[0].pending = 999;
assert.equal((await reloaded.read())[0].pending, 5);
assert.equal(events[0].event==='sync'&&events[0].pending, 5);
const last=events.at(-1);assert.equal(last?.event==='sync'&&last.pending, 104);
if(events[0].event==='sync')events[0].pending = 999;
const first=(await reloaded.read())[0];assert.equal(first.event==='sync'&&first.pending, 5);
});

test('logging during asynchronous load keeps both histories and recovers after storage failures', async () => {
Expand All @@ -47,3 +47,17 @@ test('logging during asynchronous load keeps both histories and recovers after s
const corrupt = createSyncDiagnostics({ getItem: async () => 'invalid JSON', setItem: storage.setItem });
await assert.doesNotReject(corrupt.read());
});


test('device-only onboarding records use the same bounded ring and strip private fields again after restart',async()=>{
let saved=JSON.stringify([{at:1,event:'onboarding',point:'guest-handler',step:'welcome',accountActionPending:false,email:'PRIVATE_EMAIL',input:'PRIVATE_NAME',sessionId:'PRIVATE_ID'}]);
const storage={getItem:async()=>saved,setItem:async(_key:string,value:string)=>{assert.equal(_key,'puptime.sync.diagnostics.v1');saved=value;}};
const ring=createSyncDiagnostics(storage,()=>2);
assert.deepEqual(await ring.read(),[{at:1,event:'onboarding',point:'guest-handler',step:'welcome',accountActionPending:false}]);
ring.record({event:'onboarding',point:'profile-decision',step:'welcome',decision:'accepted',...( {url:'PRIVATE_URL',error:new Error('PRIVATE_MESSAGE'),stack:'PRIVATE_STACK'} as {})});
for(const field of ['point','step','decision','animation','accountActionPending'])ring.record({event:'onboarding',point:'screen',step:'welcome',[field]:'PRIVATE_VALUE'} as never);
await ring.flush();assert.equal((await ring.read()).length,2);assert.ok(!saved.includes('PRIVATE'));
for(let i=0;i<105;i++)ring.record(i%2?{event:'sync',result:'success',pending:i}:{event:'onboarding',point:'screen',step:'profile',accountActionPending:false});
await ring.flush();const reopened=createSyncDiagnostics(storage,()=>3);const entries=await reopened.read();assert.equal(entries.length,100);assert.ok(entries.some(row=>row.event==='sync'));assert.ok(entries.some(row=>row.event==='onboarding'));
assert.ok(!saved.includes('PRIVATE'));assert.deepEqual(await reopened.read(),entries);
});
15 changes: 13 additions & 2 deletions src/cloud/sync-diagnostics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,20 @@ const diagnostic = z.object({
blocked: z.number().int().min(0).max(1_000_000).optional(),
batchCount: z.number().int().min(0).max(50).optional(),
});
const entry = diagnostic.extend({ at: z.number().int().min(0).max(Number.MAX_SAFE_INTEGER) });
const onboardingDiagnostic = z.object({
event: z.literal('onboarding'),
point: z.enum(['mount', 'unmount', 'screen', 'guest-press-in', 'guest-handler', 'profile-decision', 'animation-settled']),
step: z.enum(['welcome', 'account', 'profile', 'other']),
accountActionPending: z.boolean().optional(),
decision: z.enum(['account-action-pending', 'transitioning', 'same-step', 'accepted']).optional(),
animation: z.enum(['finished', 'interrupted', 'unknown']).optional(),
});
const timestamp = { at: z.number().int().min(0).max(Number.MAX_SAFE_INTEGER) };
const entry = z.union([diagnostic.extend(timestamp), onboardingDiagnostic.extend(timestamp)]);
export type CloudDiagnosticInput = z.input<typeof diagnostic>;
export type RecordCloudDiagnostic = (input: CloudDiagnosticInput) => void;
export type OnboardingDiagnosticInput = z.input<typeof onboardingDiagnostic>;
export type RecordOnboardingDiagnostic = (input: OnboardingDiagnosticInput) => void;
type DiagnosticEntry = z.infer<typeof entry>;
type DiagnosticStorage = { getItem(key: string): Promise<string | null>; setItem(key: string, value: string): Promise<unknown> };
const storageKey = 'puptime.sync.diagnostics.v1';
Expand All @@ -41,7 +52,7 @@ export function createSyncDiagnostics(storage: DiagnosticStorage, now = Date.now
}
}).finally(() => { saving = null; if (dirty) persist(); });
}
const record: RecordCloudDiagnostic = input => {
const record = (input: CloudDiagnosticInput | OnboardingDiagnosticInput) => {
try {
const parsed = entry.safeParse({ ...input, at: now() });
if (!parsed.success) return;
Expand Down
12 changes: 6 additions & 6 deletions src/cloud/use-cloud.native.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,29 +16,29 @@ export function useCloud(onRemoteChange:()=>unknown):CloudHandle {
const care=useCareSession();
const callback=useRef(onRemoteChange);callback.current=onRemoteChange;
const [attempt,setAttempt]=useState(0),retry=useCallback(()=>setAttempt(value=>value+1),[]);
const [handle,setHandle]=useState<CloudHandle>({available:true,controller:null,problem:null,getLocalName,retry,shareDiagnostics:shareCloudDiagnostics});
const [handle,setHandle]=useState<CloudHandle>({available:true,controller:null,problem:null,getLocalName,retry,shareDiagnostics:shareCloudDiagnostics,recordOnboardingDiagnostic:recordCloudDiagnostic});
useEffect(()=>{
setHandle({available:true,controller:null,problem:null,getLocalName,retry,shareDiagnostics:shareCloudDiagnostics});
setHandle({available:true,controller:null,problem:null,getLocalName,retry,shareDiagnostics:shareCloudDiagnostics,recordOnboardingDiagnostic:recordCloudDiagnostic});
let disposed=false,controller:AccountController|null=null,stopChanges:(()=>void)|null=null;
let active=AppState.currentState==='active',online=true,networkEvent=false;
const connectivity=(state:Network.NetworkState)=>state.isConnected!==false&&state.isInternetReachable!==false;
const appSubscription=AppState.addEventListener('change',state=>{active=state==='active';controller?.setActive(active);});
const networkSubscription=Network.addNetworkStateListener(state=>{networkEvent=true;online=connectivity(state);controller?.setOnline(online);});
void (async()=>{
let client:ReturnType<typeof createNativeCloudClient>;
try{client=createNativeCloudClient(care.selection??undefined);}catch{if(!disposed)setHandle({available:true,controller:null,problem:'configuration',getLocalName,retry,shareDiagnostics:shareCloudDiagnostics});return;}
if(!client){if(!disposed)setHandle({available:true,controller:null,problem:'configuration',getLocalName,retry,shareDiagnostics:shareCloudDiagnostics});return;}
try{client=createNativeCloudClient(care.selection??undefined);}catch{if(!disposed)setHandle({available:true,controller:null,problem:'configuration',getLocalName,retry,shareDiagnostics:shareCloudDiagnostics,recordOnboardingDiagnostic:recordCloudDiagnostic});return;}
if(!client){if(!disposed)setHandle({available:true,controller:null,problem:'configuration',getLocalName,retry,shareDiagnostics:shareCloudDiagnostics,recordOnboardingDiagnostic:recordCloudDiagnostic});return;}
try {
const [runtime,network]=await Promise.all([getLocalRuntime(),Network.getNetworkStateAsync().catch(()=>null)]);
if(disposed)return;if(network&&!networkEvent)online=connectivity(network);
controller=createAccountController({runtime,api:client.api,deletion:client.deletion,photos:createNativePhotoSync(client.photoTransfer),identity:client.identity,exportCloud:client.exportCloud,subscribeLocalWrites,diagnose:recordCloudDiagnostic,changed:()=>{if(care.isCurrent())void Promise.resolve(callback.current()).catch(()=>{});}});
if(care.selection){const timer=setInterval(()=>{if(active&&online)controller?.invalidate();},15000);stopChanges=()=>clearInterval(timer);}
else stopChanges=watchAccountChanges({controller,open:client.openChanges});
controller.setOnline(online);controller.setActive(active);
setHandle({available:true,controller,problem:null,getLocalName,retry,shareDiagnostics:shareCloudDiagnostics,sharing:client.sharing,sharingOrigin:client.origin});
setHandle({available:true,controller,problem:null,getLocalName,retry,shareDiagnostics:shareCloudDiagnostics,recordOnboardingDiagnostic:recordCloudDiagnostic,sharing:client.sharing,sharingOrigin:client.origin});
if(care.selection){controller.subscribe(()=>{const snapshot=controller?.read();if(snapshot?.problem==='account-deleted'||snapshot?.problem==='forbidden'||snapshot?.problem==='account-mismatch'||snapshot?.problem==='conflict'||snapshot?.problem==='auth-required'){void runtime.database.write(sql=>sql.runAsync("INSERT OR REPLACE INTO metadata(key,value) VALUES('care_blocked','1')")).then(()=>{if(care.isCurrent())return callback.current();}).catch(()=>{});}});}
await controller.refresh();
}catch{if(!disposed)setHandle({available:true,controller:null,problem:'local-storage',getLocalName,retry,shareDiagnostics:shareCloudDiagnostics});}
}catch{if(!disposed)setHandle({available:true,controller:null,problem:'local-storage',getLocalName,retry,shareDiagnostics:shareCloudDiagnostics,recordOnboardingDiagnostic:recordCloudDiagnostic});}
})();
return()=>{disposed=true;stopChanges?.();controller?.stop();appSubscription.remove();networkSubscription.remove();};
},[attempt,retry]);
Expand Down
Loading
Loading