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
54 changes: 54 additions & 0 deletions docs/PERFORMANCE_MONITORING.md
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,60 @@ ALERT` warning. In a real deployment this would fan out to a Slack channel or Pa

---

## Analytics Sampling Policy

Analytics events are sampled at the source to control volume and cost. The policy
is defined in `src/services/analytics/samplingPolicy.ts` and enforced in
`MobileAnalyticsService.trackEvent()` and `AnalyticsBatchQueue.enqueue()`.

### Event Frequency Classification

| Frequency | Sampling Rate | Events |
|---|---|---|
| `critical` | 100% | Session lifecycle, auth, course/quiz start/end, API errors, crashes |
| `high` | 20% | Screen views, content views/shares, search, form submits |
| `medium` | 10% | UI clicks, button clicks, content likes, review prompts |
| `low` | 5% | Performance metrics, React profiler, A/B tests, web vitals, app lifecycle |

Critical events bypass sampling entirely — they are always sent.

### High-Frequency Throttle

Events tagged with `event_category: 'high_frequency'` in their properties are
throttled to a maximum of **10 events per second** per event name, applied before
sampling. This prevents burst spam from cache stats or render profilers.

### Session Event Budget

The `AnalyticsBatchQueue` enforces a per-session budget of **500 events**
(`SESSION_EVENT_BUDGET`). Once exhausted, all subsequent events are silently
dropped. The drop count is tracked for observability:

```ts
import { mobileAnalyticsService } from '@/services/mobileAnalytics';

// After a session, check how many events were dropped
const dropped = mobileAnalyticsService.getDroppedCount();
```

Dropped events are logged at WARN level (throttled to every 50th drop) to avoid
log spam while remaining visible in production monitoring.

### Observability

- **Sampling drops**: logged at DEBUG level with `[${frequency}] dropped by sampling policy`
- **Throttle drops**: counted in `droppedCount` (no log — by design, these are expected)
- **Budget drops**: logged at WARN level every 50 drops
- **Total drops**: accessible via `mobileAnalyticsService.getDroppedCount()`

### Adjusting the Policy

To change sampling rates, edit `SAMPLING_RATES` in `samplingPolicy.ts`. To adjust
the session budget, change `SESSION_EVENT_BUDGET`. Both are module-level constants
that take effect without restart when the module is re-evaluated.

---

## Related Documents

- [PERFORMANCE_TESTING.md](./PERFORMANCE_TESTING.md) — component-level perf test guide
Expand Down
46 changes: 46 additions & 0 deletions docs/conflict-resolution-strategy.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,36 @@ Each tracked entity carries:
- `checksum`: a stable checksum of the entity payload for quick equality checks.
- `baseEntity`: the last server version the client saw before local edits.

## Architecture — Single Detection, Single Resolution

Conflict handling is consolidated into two modules:

```
HTTP path (axios.config.ts 409 handler)
→ sync/httpConflictDetection.ts — detection & ConflictData construction
→ store/conflictStore.ts — UI resolution queue

WebSocket path (socket/index.ts)
→ sync/syncEntityManager.ts — detection & resolution in one pass
→ sync/conflictResolver.ts — pure resolution functions
→ sync/versionStore.ts — persistent version state
```

`syncService.ts` is orchestration only — it delegates conflict detection to
`httpConflictDetection.isConflictError()` and resolution to
`syncEntityManager.resolveRawConflict()` / `handleServerEntity()`. It no
longer contains its own detection or resolution logic.

### Key invariants

1. **One detection path per transport.** HTTP conflicts are detected in the
axios interceptor (status 409). WebSocket conflicts are detected inside
`syncEntityManager.handleServerEntity()`.
2. **One resolution path.** All resolution goes through `conflictResolver.ts`
functions (`resolveConflict`, `processServerUpdate`).
3. **UI conflicts go through conflictStore.** The 409 handler writes a
`ConflictData` record to `conflictStore` for user-mediated resolution.

## Conflict Detection

An incoming server update is not a conflict when its payload matches the local
Expand All @@ -17,6 +47,9 @@ equal or newer.
An incoming server update is a conflict when the local payload differs from the
server payload while the client still has pending local edits.

For HTTP requests, a 409 status code indicates the client's `lastKnownVersion`
is behind the server's current version.

## Resolution Modes

`server-wins` accepts the server entity and clears `clientSeq`. Use this for
Expand Down Expand Up @@ -58,3 +91,16 @@ Versioned real-time messages use this shape:
The client stores accepted versions in `versionStore` and keeps a base copy while
local edits are pending. After any successful server update or conflict
resolution, the resolved entity becomes the new base.

## File Reference

| File | Responsibility |
|---|---|
| `sync/conflictResolver.ts` | Pure conflict detection + resolution functions |
| `sync/syncEntityManager.ts` | Versioned entity lifecycle, delegates to conflictResolver |
| `sync/httpConflictDetection.ts` | HTTP 409 detection, ConflictData construction |
| `sync/versionStore.ts` | In-memory version state persistence |
| `sync/types.ts` | Shared type definitions |
| `store/conflictStore.ts` | UI conflict queue (Zustand) |
| `syncService.ts` | Sync orchestration (delegates conflict handling) |
| `api/axios.config.ts` | HTTP interceptor (delegates to httpConflictDetection) |
40 changes: 39 additions & 1 deletion docs/queue-priority-strategy.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

## Overview

The `RequestQueue` service (`src/services/api/requestQueue.ts`) manages offline requests that fail due to network errors. It persists requests to AsyncStorage, supports priority levels, and batches similar requests during sync.
The `RequestQueue` service (`src/services/api/requestQueue.ts`) manages offline requests that fail due to network errors. It persists requests to AsyncStorage, supports priority levels, deduplicates identical requests, enforces a maximum queue size, and batches similar requests during sync.

## Priority Levels

Expand All @@ -15,6 +15,35 @@ The `RequestQueue` service (`src/services/api/requestQueue.ts`) manages offline

The queue is sorted by priority then FIFO within each priority level.

## Deduplication

Duplicate requests are suppressed at enqueue time using a deterministic fingerprint derived from `method + URL + serialized body`. Two requests sharing the same fingerprint produce only one queue entry.

### GET collapsing

GET requests are treated specially: when a duplicate GET is queued, the existing entry is **replaced** with the newest version rather than being suppressed. This ensures that reconnection replays only the most recent read for each endpoint, avoiding stale-data replays.

### Mutation deduplication

For POST/PUT/DELETE, duplicates are suppressed entirely (the existing entry is kept). Combined with the Idempotency-Key header generated by the axios interceptor, this prevents duplicate writes on reconnection.

## Queue Size Bound

The queue is capped at **100 entries** (`MAX_QUEUE_SIZE`). When the cap is exceeded:

1. The oldest **low-priority** entry is evicted first.
2. If no low-priority entries remain, **normal** entries are evicted.
3. If no normal entries remain, **high** entries are evicted.
4. **Critical** entries are **never** evicted.

Evicted entries are logged with a warning. The `getDroppedCount()` method exposes the total number of evictions since app start for observability.

### Eviction policy rationale

- GET requests are collapsed (replaced), so evicting an old GET loses nothing — the newer one remains.
- Mutation dedup means at most one entry per mutation fingerprint, so the queue stays small even during extended offline periods.
- Critical requests (payments, auth) are protected to ensure financial and security operations are never silently dropped.

## Persistence

- All queued requests are stored in AsyncStorage under `@teachlink_request_queue`
Expand All @@ -37,6 +66,12 @@ Queue events are tracked via `mobileAnalyticsService.trackEvent()`:
- `queue_batch_synced` — when a batch merge succeeds
- `queue_resumed` — on app restart with pending requests

## Observability

- **Dropped counter**: `requestQueue.getDroppedCount()` returns the number of evictions since app start.
- **Eviction logs**: Each eviction produces a `logger.warn` with the evicted request details.
- **Dedup logs**: Each suppressed duplicate or collapsed GET produces a `logger.info`.

## Usage

```ts
Expand All @@ -48,6 +83,9 @@ await requestQueue.addToQueue(config, 'high');
// Check status
const status = await requestQueue.getQueueStatus();

// Check evictions
const dropped = requestQueue.getDroppedCount();

// Monitor from hook
import { usePendingRequests } from '../hooks/usePendingRequests';
const count = usePendingRequests();
Expand Down
102 changes: 102 additions & 0 deletions src/components/mobile/AccountActionsSection.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import React, { memo, useCallback } from 'react';
import { Alert, Platform } from 'react-native';

import { SettingRow } from './SettingRow';
import { ICON_LOGOUT_RED, ICON_ALERT } from './settingsIcons';
import { SettingsSection } from './SettingsSection';
import { useRequireReauth } from '../../hooks';

interface AccountActionsSectionProps {
onSignOut: () => void;
}

/**
* Memoised Account Actions section — sign out and delete account.
* Both require confirmation dialogs and delete also needs reauth.
*/
export const AccountActionsSection = memo(function AccountActionsSection({
onSignOut,
}: AccountActionsSectionProps) {
const { performReauthCheck } = useRequireReauth();

const handleSignOut = useCallback(() => {
Alert.alert('Sign Out', 'Are you sure?', [
{ text: 'Cancel', style: 'cancel' },
{ text: 'Sign Out', style: 'destructive', onPress: onSignOut },
]);
}, [onSignOut]);

const handleDeleteAccount = useCallback(async () => {
const authorized = await performReauthCheck();
if (!authorized) {
Alert.alert('Re-authentication Failed', 'Verification required to delete your account.');
return;
}

Alert.alert(
'Delete Account',
'This action is irreversible. All your data, progress, and purchases will be permanently deleted.',
[
{ text: 'Cancel', style: 'cancel' },
{
text: 'Continue',
style: 'destructive',
onPress: () => {
if (Platform.OS === 'ios') {
Alert.alert(
'Are you absolutely sure?',
'Type DELETE in the next prompt to confirm account deletion.',
[
{ text: 'Cancel', style: 'cancel' },
{
text: 'Delete',
style: 'destructive',
onPress: () => {
Alert.alert('Account Deleted', 'Your account has been deleted.');
},
},
]
);
} else {
Alert.alert(
'Confirm Deletion',
'Please type DELETE to confirm',
[
{ text: 'Cancel', style: 'cancel' },
{
text: 'Delete',
style: 'destructive',
onPress: () => {
Alert.alert('Account Deleted', 'Your account has been deleted.');
},
},
],
{ cancelable: true }
);
}
},
},
]
);
}, [performReauthCheck]);

return (
<SettingsSection title="Account Actions">
<SettingRow
icon={ICON_LOGOUT_RED}
label="Sign Out"
onPress={handleSignOut}
destructive
accessibilityLabel="Sign Out"
/>
<SettingRow
icon={ICON_ALERT}
label="Delete Account"
description="Permanently delete your account and all data"
onPress={handleDeleteAccount}
destructive
accessibilityLabel="Delete Account"
/>
</SettingsSection>
);
});
Loading
Loading