reis-mobile 0.7.3 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +39 -0
- package/README.md +100 -26
- package/agents/android-architect.md +3 -3
- package/agents/android-performance-engineer.md +1 -1
- package/agents/flutter-architect.md +3 -3
- package/agents/flutter-performance-engineer.md +1 -1
- package/agents/ios-architect.md +3 -3
- package/agents/ios-performance-engineer.md +1 -1
- package/agents/kmp-architect.md +3 -3
- package/agents/kmp-performance-engineer.md +1 -1
- package/agents/lead-mobile.md +3 -0
- package/agents/mobile-release-engineer.md +3 -3
- package/agents/mobile-staff-engineer.md +1 -1
- package/agents/rn-architect.md +3 -3
- package/agents/rn-performance-engineer.md +1 -1
- package/bin/reis-mobile.mjs +123 -1
- package/commands/consult.md +95 -0
- package/commands/debate.md +15 -11
- package/commands/reis-mobile.md +7 -2
- package/commands/review.md +2 -0
- package/core/config/project.mjs +1 -1
- package/core/diagnostics/doctor.mjs +23 -1
- package/core/providers/catalog.mjs +168 -0
- package/core/providers/consult.mjs +220 -0
- package/core/providers/providers.mjs +156 -0
- package/core/router/intents.mjs +15 -0
- package/package.json +1 -1
- package/skills/mobile-accessibility/SKILL.md +3 -0
- package/skills/mobile-accessibility/references/localization.md +77 -0
- package/skills/mobile-observability/SKILL.md +137 -0
- package/skills/mobile-observability/references/android.md +105 -0
- package/skills/mobile-observability/references/flutter.md +86 -0
- package/skills/mobile-observability/references/ios.md +88 -0
- package/skills/mobile-observability/references/kotlin-multiplatform.md +76 -0
- package/skills/mobile-observability/references/react-native.md +62 -0
- package/skills/mobile-offline-sync/SKILL.md +132 -0
- package/skills/mobile-offline-sync/references/android.md +123 -0
- package/skills/mobile-offline-sync/references/flutter.md +103 -0
- package/skills/mobile-offline-sync/references/ios.md +103 -0
- package/skills/mobile-offline-sync/references/kotlin-multiplatform.md +114 -0
- package/skills/mobile-offline-sync/references/react-native.md +108 -0
- package/skills/mobile-performance/SKILL.md +88 -0
- package/skills/mobile-performance/references/android.md +96 -0
- package/skills/mobile-performance/references/background.md +87 -0
- package/skills/mobile-performance/references/flutter.md +101 -0
- package/skills/mobile-performance/references/ios.md +82 -0
- package/skills/mobile-performance/references/kotlin-multiplatform.md +62 -0
- package/skills/mobile-performance/references/react-native.md +85 -0
- package/skills/mobile-release/SKILL.md +2 -1
- package/skills/mobile-release/references/billing.md +92 -0
- package/skills/mobile-security/SKILL.md +3 -0
- package/skills/mobile-security/references/auth.md +92 -0
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# Observability on native Android
|
|
2
|
+
|
|
3
|
+
Android is the only mobile platform that gives you meaningful field data **without installing anything**: Play Console's Android vitals reports crashes, ANRs, wakeups, wake locks and rendering for your installed base, sliced by version, device and OS. Start there, then add an SDK for the context vitals cannot give you.
|
|
4
|
+
|
|
5
|
+
## What you get for free
|
|
6
|
+
|
|
7
|
+
| Source | Gives |
|
|
8
|
+
|---|---|
|
|
9
|
+
| Play Console → Android vitals | Crash rate, **ANR rate** and user-perceived ANR rate, excessive wakeups, stuck partial wake locks, slow/frozen frames, app size — all per version and per device model, with bad-behaviour thresholds the store itself acts on |
|
|
10
|
+
| `ApplicationExitInfo` | Why the process last died — crash, ANR, low memory, user request, excessive resource use — readable **on the next launch**, including the ANR trace |
|
|
11
|
+
| Play Console → Reach and devices | The device/OS distribution you actually ship to, which decides what a "rare" crash really costs |
|
|
12
|
+
|
|
13
|
+
`ApplicationExitInfo` is the cheapest large win in Android observability and is frequently missing: it is the only way to see background terminations and OOM kills, which produce no crash report at all.
|
|
14
|
+
|
|
15
|
+
```kotlin
|
|
16
|
+
val am = getSystemService(ActivityManager::class.java)
|
|
17
|
+
am.getHistoricalProcessExitReasons(packageName, /* pid = */ 0, /* maxNum = */ 5)
|
|
18
|
+
.firstOrNull()
|
|
19
|
+
?.let { info ->
|
|
20
|
+
crashReporter.log(
|
|
21
|
+
"last_exit reason=${info.reason} status=${info.status} " +
|
|
22
|
+
"importance=${info.importance} description=${info.description}",
|
|
23
|
+
)
|
|
24
|
+
// REASON_ANR carries the ANR trace in info.traceInputStream
|
|
25
|
+
}
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Report it on the next launch, attached to the session, so an OOM kill becomes a searchable event rather than an unexplained gap.
|
|
29
|
+
|
|
30
|
+
## Crash and error reporting
|
|
31
|
+
|
|
32
|
+
Crashlytics (see [mobile-firebase](../../mobile-firebase/SKILL.md)), Sentry, Bugsnag and Embrace all cover JVM crashes, native (NDK) crashes and non-fatals. What matters is the configuration, not the vendor:
|
|
33
|
+
|
|
34
|
+
- **Mapping file upload for every release variant.** With R8/Proguard enabled, an un-uploaded mapping file produces an unreadable stack trace. The Crashlytics Gradle plugin uploads automatically when `mappingFileUploadEnabled` is on; other SDKs have a Gradle task. Verify it in CI by failing the build when the upload task did not run, and prove it once by symbolicating a deliberate release crash.
|
|
35
|
+
- **Native symbols** need a separate upload (`firebaseCrashlytics { nativeSymbolUploadEnabled = true }`, or the vendor's equivalent) or every NDK/Kotlin-Native frame is an address.
|
|
36
|
+
- **Custom keys** on every session: screen, auth state, feature-flag values, outbox depth, connectivity class.
|
|
37
|
+
- **Non-fatals** for caught failures that the user saw: `FirebaseCrashlytics.getInstance().recordException(e)` — and *not* for every caught exception, or the important one disappears.
|
|
38
|
+
- **Preserve the cause chain.** `throw AppException("failed", cause = e)` keeps grouping meaningful; `throw AppException("failed")` collapses every cause into one issue.
|
|
39
|
+
|
|
40
|
+
## ANRs and hangs
|
|
41
|
+
|
|
42
|
+
ANRs are the signal Android developers most often miss, because they produce no crash callback.
|
|
43
|
+
|
|
44
|
+
- Play Console's ANR rate is authoritative and is a store policy threshold.
|
|
45
|
+
- `ApplicationExitInfo` with `REASON_ANR` gives the trace on the device.
|
|
46
|
+
- Crash reporters detect ANRs with their own watchdogs; treat their counts as an early indicator and Play Console as the truth.
|
|
47
|
+
- Diagnose the cause with a system trace and the main-thread stack — see [mobile-performance/references/android.md](../../mobile-performance/references/android.md) and the ANR guide in [mobile-debug](../../mobile-debug/SKILL.md).
|
|
48
|
+
|
|
49
|
+
## Logs, traces and metrics
|
|
50
|
+
|
|
51
|
+
- **Structured logs.** `Log.d` goes nowhere in production. Use the reporter's breadcrumb API, or a `Timber`-style tree that forwards structured events to the reporter, and keep the tags bounded.
|
|
52
|
+
- **Traces.** Firebase Performance Monitoring gives automatic app-start, screen-rendering and HTTP traces plus `Trace` for custom spans, with no backend work. OpenTelemetry's Android instrumentation is the option when you already run an OTel backend and want `traceparent` propagation into the server traces; check the current stability of the artifacts before adopting them.
|
|
53
|
+
- **Custom metrics.** Prefer a small number of counters and durations with bounded attributes (version, screen, result) over free-form events.
|
|
54
|
+
- **Network correlation.** An OkHttp `Interceptor` is the natural place to generate and attach the request id, record the span, and add a breadcrumb — one implementation covers Retrofit, Coil and anything else on the same client.
|
|
55
|
+
|
|
56
|
+
```kotlin
|
|
57
|
+
class ObservabilityInterceptor(private val reporter: Reporter) : Interceptor {
|
|
58
|
+
override fun intercept(chain: Interceptor.Chain): Response {
|
|
59
|
+
val requestId = UUID.randomUUID().toString()
|
|
60
|
+
val request = chain.request().newBuilder().header("X-Request-Id", requestId).build()
|
|
61
|
+
val start = SystemClock.elapsedRealtime()
|
|
62
|
+
return try {
|
|
63
|
+
chain.proceed(request).also { response ->
|
|
64
|
+
reporter.breadcrumb(
|
|
65
|
+
"network",
|
|
66
|
+
mapOf(
|
|
67
|
+
"method" to request.method,
|
|
68
|
+
"host" to request.url.host, // host, not the full URL
|
|
69
|
+
"status" to response.code,
|
|
70
|
+
"ms" to SystemClock.elapsedRealtime() - start,
|
|
71
|
+
"request_id" to requestId,
|
|
72
|
+
),
|
|
73
|
+
)
|
|
74
|
+
}
|
|
75
|
+
} catch (e: IOException) {
|
|
76
|
+
reporter.breadcrumb("network_error", mapOf("host" to request.url.host, "request_id" to requestId))
|
|
77
|
+
throw e
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Note what is absent: no full URL (query strings carry tokens), no headers, no bodies.
|
|
84
|
+
|
|
85
|
+
## Privacy and consent
|
|
86
|
+
|
|
87
|
+
- Play requires an accurate **Data safety** declaration covering everything the SDKs collect, including the ones you did not configure yourself. Audit the merged manifest and the dependency list, not just your own code.
|
|
88
|
+
- Gate initialization on consent. Analytics collection can be disabled by default in the manifest and enabled after consent (`firebase_analytics_collection_enabled`, `firebase_crashlytics_collection_enabled` and the equivalent runtime setters) — initializing and then hoping the SDK stays quiet is not the same thing.
|
|
89
|
+
- Use a resettable id (the reporter's install id, or `AdvertisingIdClient` only if advertising is genuinely the purpose and it is declared). `ANDROID_ID` and any hardware identifier are not acceptable install ids.
|
|
90
|
+
- Register a redaction callback / `beforeSend` equivalent and unit-test it against a payload containing a token.
|
|
91
|
+
|
|
92
|
+
## CI
|
|
93
|
+
|
|
94
|
+
- Fail the build when the mapping or native-symbol upload task did not run for a release variant.
|
|
95
|
+
- Post the build's version name and code into the reporter so a release appears in the dashboard the moment it ships.
|
|
96
|
+
- Check Android vitals as part of the staged-rollout gate — see [mobile-release](../../mobile-release/SKILL.md).
|
|
97
|
+
|
|
98
|
+
## Official documentation
|
|
99
|
+
|
|
100
|
+
- Android vitals: https://developer.android.com/topic/performance/vitals
|
|
101
|
+
- ApplicationExitInfo: https://developer.android.com/reference/android/app/ApplicationExitInfo
|
|
102
|
+
- ANRs: https://developer.android.com/topic/performance/vitals/anr
|
|
103
|
+
- Crashlytics for Android: https://firebase.google.com/docs/crashlytics/get-started?platform=android
|
|
104
|
+
- Firebase Performance Monitoring: https://firebase.google.com/docs/perf-mon/get-started-android
|
|
105
|
+
- Play Data safety: https://support.google.com/googleplay/android-developer/answer/10787469
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# Observability in Flutter
|
|
2
|
+
|
|
3
|
+
Flutter's specific problem is that a failure can happen in three places with three different capture mechanisms: **Dart** (uncaught exceptions in the framework, in your code, or in an isolate), the **Flutter engine/native layer** (a real native crash), and the **platform channels** between them. A setup that only wires `FlutterError.onError` misses two of the three.
|
|
4
|
+
|
|
5
|
+
## Capture all three layers
|
|
6
|
+
|
|
7
|
+
```dart
|
|
8
|
+
Future<void> main() async {
|
|
9
|
+
WidgetsFlutterBinding.ensureInitialized();
|
|
10
|
+
await Firebase.initializeApp();
|
|
11
|
+
|
|
12
|
+
// 1. Errors the Flutter framework catches (build, layout, paint, gestures).
|
|
13
|
+
FlutterError.onError = FirebaseCrashlytics.instance.recordFlutterFatalError;
|
|
14
|
+
|
|
15
|
+
// 2. Errors from the Dart zone that the framework does not catch:
|
|
16
|
+
// async gaps, futures without a catch, platform-channel replies.
|
|
17
|
+
PlatformDispatcher.instance.onError = (error, stack) {
|
|
18
|
+
FirebaseCrashlytics.instance.recordError(error, stack, fatal: true);
|
|
19
|
+
return true;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
runApp(const App());
|
|
23
|
+
}
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
- `FlutterError.onError` alone leaves every unhandled async error unreported.
|
|
27
|
+
- A background `Isolate` has its own error handling: add an `Isolate.current.addErrorListener` (or the reporter's helper) inside the isolate entry point, or its crashes are silent.
|
|
28
|
+
- Native crashes in the engine or in a plugin's Android/iOS code are captured by the native SDK the Flutter package installs — which is why the plugin's native setup steps matter, not just the Dart ones.
|
|
29
|
+
|
|
30
|
+
Sentry, Bugsnag, Embrace and Crashlytics all follow this shape; the reference for the Firebase path is [mobile-firebase](../../mobile-firebase/SKILL.md).
|
|
31
|
+
|
|
32
|
+
## Symbols
|
|
33
|
+
|
|
34
|
+
A release Flutter build is AOT-compiled, and its Dart stack traces are **obfuscated addresses** unless the debug info is uploaded.
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
flutter build appbundle --obfuscate --split-debug-info=build/symbols
|
|
38
|
+
flutter build ipa --obfuscate --split-debug-info=build/symbols
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Then upload `build/symbols` with the vendor's command (`firebase crashlytics:symbols:upload`, `sentry-cli debug-files upload`, …) **for every build you ship**, and keep the directory per version — symbols from a different build do not resolve. On Android you still need the R8 mapping file for the Kotlin side, and on iOS the dSYMs for the native side: three uploads, not one.
|
|
42
|
+
|
|
43
|
+
The most common production gap in Flutter apps is exactly this: Dart symbols uploaded during a manual first setup, never wired into CI, and unreadable stack traces from then on. Verify by crashing a release build on purpose once.
|
|
44
|
+
|
|
45
|
+
## Context and breadcrumbs
|
|
46
|
+
|
|
47
|
+
- **Navigation breadcrumbs** come free from a `NavigatorObserver`:
|
|
48
|
+
|
|
49
|
+
```dart
|
|
50
|
+
class ObservabilityObserver extends NavigatorObserver {
|
|
51
|
+
@override
|
|
52
|
+
void didPush(Route route, Route? previous) =>
|
|
53
|
+
reporter.breadcrumb('screen_view', {'name': route.settings.name ?? 'unnamed'});
|
|
54
|
+
}
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Route names must be set for this to be useful — an app built entirely from anonymous `MaterialPageRoute`s produces `unnamed` breadcrumbs.
|
|
58
|
+
|
|
59
|
+
- **Custom keys** per session: `setCustomKey` for the screen, auth state, flag values, outbox depth, connectivity.
|
|
60
|
+
- **Network breadcrumbs** belong in a Dio `Interceptor` or an `http` `BaseClient` wrapper, where the request id is also generated and attached. Record method, host, status and duration — never the full URL or the body.
|
|
61
|
+
- **State-management errors:** `Bloc.observer` (`onError`), Riverpod's `ProviderObserver`, or the equivalent hook forwards state-layer failures with the state that caused them, which a bare stack trace does not carry.
|
|
62
|
+
- Keep the cause chain: `Error.throwWithStackTrace(AppException(e), stack)` preserves the original trace, while `throw AppException('failed')` in a `catch` discards the only useful part of the report.
|
|
63
|
+
|
|
64
|
+
## Performance signals
|
|
65
|
+
|
|
66
|
+
- **Firebase Performance Monitoring** for Flutter gives app-start and custom traces; HTTP traces are collected automatically on the native side, and Dart-side calls need explicit `HttpMetric` instrumentation.
|
|
67
|
+
- The lab counterparts — frame timings, startup traces, `integration_test` timeline summaries — are in [mobile-performance/references/flutter.md](../../mobile-performance/references/flutter.md). Use the same metric names in both so a CI regression and a field regression are comparable.
|
|
68
|
+
- `SchedulerBinding.instance.addTimingsCallback` exposes real frame timings in a release build; sampled and aggregated, it is a usable field jank metric when a vendor SDK does not provide one.
|
|
69
|
+
|
|
70
|
+
## Platform field data still applies
|
|
71
|
+
|
|
72
|
+
Flutter does not replace Android vitals, Play Console ANR data, Xcode Organizer or MetricKit — the app is still an Android and an iOS process. ANRs and watchdog terminations in a Flutter app show up there and nowhere in the Dart layer. Read [android.md](android.md) and [ios.md](ios.md) alongside this file.
|
|
73
|
+
|
|
74
|
+
## Privacy and consent
|
|
75
|
+
|
|
76
|
+
- Both stores' declarations (Data safety, privacy nutrition label and `PrivacyInfo.xcprivacy`) must cover what the Flutter plugins collect natively, not just what your Dart code sends.
|
|
77
|
+
- Gate collection: `FirebaseCrashlytics.instance.setCrashlyticsCollectionEnabled(false)` until consent, or the vendor's equivalent, set **before** the reporter would send anything.
|
|
78
|
+
- Implement the SDK's redaction hook (`beforeSend` in Sentry, a record filter elsewhere) and unit-test it with a payload containing a token.
|
|
79
|
+
|
|
80
|
+
## Official documentation
|
|
81
|
+
|
|
82
|
+
- Handling errors in Flutter: https://docs.flutter.dev/testing/errors
|
|
83
|
+
- Obfuscating Dart code: https://docs.flutter.dev/deployment/obfuscate
|
|
84
|
+
- Crashlytics for Flutter: https://firebase.google.com/docs/crashlytics/get-started?platform=flutter
|
|
85
|
+
- Firebase Performance Monitoring for Flutter: https://firebase.google.com/docs/perf-mon/flutter/get-started
|
|
86
|
+
- Flutter DevTools: https://docs.flutter.dev/tools/devtools/overview
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# Observability on native iOS
|
|
2
|
+
|
|
3
|
+
Apple's own telemetry is good and under-used: **MetricKit** delivers per-device diagnostics and metrics to your app, and **Xcode Organizer** aggregates crashes, hangs, launch time, memory, disk writes and battery per release from users who opted into sharing. Both are free, privacy-reviewed and need no SDK. Add a third-party reporter for the context they do not carry — breadcrumbs, custom keys, correlation ids.
|
|
4
|
+
|
|
5
|
+
## What you get from Apple
|
|
6
|
+
|
|
7
|
+
| Source | Gives |
|
|
8
|
+
|---|---|
|
|
9
|
+
| Xcode Organizer → Crashes | Symbolicated crash reports per version, grouped, from opted-in users |
|
|
10
|
+
| Xcode Organizer → Hangs, Launches, Memory, Disk Writes, Battery | Field metrics per version, with a comparison to the previous release |
|
|
11
|
+
| MetricKit (`MXMetricManager`) | Daily metric payloads (`MXAppLaunchMetric`, `MXAnimationMetric`, `MXAppExitMetric`, `MXSignpostMetric`, and more) and diagnostic payloads (crash, hang, disk-write, CPU exception) delivered **into your app**, so you can forward them to your own backend |
|
|
12
|
+
|
|
13
|
+
`MXAppExitMetric` is the one to wire up first: it reports how sessions ended, including **memory-pressure terminations and watchdog kills**, which produce no crash report and are otherwise invisible.
|
|
14
|
+
|
|
15
|
+
```swift
|
|
16
|
+
final class MetricsSubscriber: NSObject, MXMetricManagerSubscriber {
|
|
17
|
+
func didReceive(_ payloads: [MXMetricPayload]) {
|
|
18
|
+
for payload in payloads {
|
|
19
|
+
backend.upload(payload.jsonRepresentation(), kind: "metric")
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
func didReceive(_ payloads: [MXDiagnosticPayload]) {
|
|
24
|
+
for payload in payloads {
|
|
25
|
+
backend.upload(payload.jsonRepresentation(), kind: "diagnostic") // hangs and crashes
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// In application(_:didFinishLaunchingWithOptions:)
|
|
31
|
+
MXMetricManager.shared.add(subscriber)
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Payloads arrive **once a day, about the previous day** — plan the dashboard around that delay rather than expecting live data.
|
|
35
|
+
|
|
36
|
+
## Crash and error reporting
|
|
37
|
+
|
|
38
|
+
- **dSYMs must reach the reporter for every build.** With bitcode gone this is a straightforward upload, but it still breaks constantly: Xcode Cloud, a CI cache, or `DEBUG_INFORMATION_FORMAT` set to `dwarf` instead of `dwarf-with-dsym` will all produce unreadable release crashes. Run the vendor's upload script as a build phase *and* verify in CI, then prove it once with a deliberate release crash.
|
|
39
|
+
- **Swift errors are not exceptions.** A crash reporter sees `fatalError`, force-unwraps, array bounds, and signals — not a `throw` you caught. User-visible failures from `catch` blocks must be reported explicitly as non-fatals, with the state that explains them.
|
|
40
|
+
- **Custom keys** per session: scene/screen, auth state, flag values, outbox depth, connectivity (`NWPathMonitor`'s current path).
|
|
41
|
+
- **Grouping** follows the stack signature. A generic `AppError.wrapped(Error)` rethrown from one place collapses unrelated failures into one issue; keep the original error type and domain in the report.
|
|
42
|
+
|
|
43
|
+
## Hangs
|
|
44
|
+
|
|
45
|
+
Apple treats the main thread being unresponsive beyond roughly 250 ms as a hang, and Organizer reports a hang rate per hour of use.
|
|
46
|
+
|
|
47
|
+
- Organizer → Hangs and MetricKit's hang diagnostics give the field data with a stack.
|
|
48
|
+
- The Thread Performance Checker (a debug-time diagnostic) catches main-thread file I/O and priority inversions before they ship.
|
|
49
|
+
- Diagnose with Instruments — see [mobile-performance/references/ios.md](../../mobile-performance/references/ios.md).
|
|
50
|
+
|
|
51
|
+
Watchdog terminations (`0x8badf00d`) are hangs that lasted long enough to be killed; they appear in `MXAppExitMetric` and in Organizer, not as ordinary crashes.
|
|
52
|
+
|
|
53
|
+
## Logs, traces and metrics
|
|
54
|
+
|
|
55
|
+
- **`OSLog` / `Logger` is the system logger**: structured, privacy-aware, and cheap. It does **not** leave the device — treat it as what you read on a connected device or in a sysdiagnose, and forward what you need to your reporter separately.
|
|
56
|
+
- Mark values explicitly: `logger.info("order \(id, privacy: .private) failed with \(code, privacy: .public)")`. Dynamic strings default to private and are redacted in collected logs — deliberately marking a secret `.public` is the mistake to look for in review.
|
|
57
|
+
- **Signposts** (`OSSignposter`) mark intervals that Instruments can read *and* that MetricKit aggregates as `MXSignpostMetric` from the field — the same instrumentation serves the lab and production.
|
|
58
|
+
- **Network correlation:** a `URLProtocol`, a custom `URLSessionTaskDelegate`, or a thin client wrapper generates the request id, adds the header, records the duration and drops a breadcrumb with **host and status only** — never the full URL or the body.
|
|
59
|
+
|
|
60
|
+
```swift
|
|
61
|
+
var request = URLRequest(url: url)
|
|
62
|
+
let requestID = UUID().uuidString
|
|
63
|
+
request.setValue(requestID, forHTTPHeaderField: "X-Request-Id")
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
- **OpenTelemetry** has a Swift implementation for teams already running an OTel backend and wanting `traceparent` propagation into server traces; check the current maturity of the package before adopting it.
|
|
67
|
+
|
|
68
|
+
## Privacy and consent
|
|
69
|
+
|
|
70
|
+
- The **privacy nutrition label** and the **privacy manifest** (`PrivacyInfo.xcprivacy`) must cover everything collected, including by third-party SDKs — and SDKs on Apple's required-reason list must ship their own manifest and signature. A reporter added for observability changes your declared data collection.
|
|
71
|
+
- **App Tracking Transparency** applies to tracking across apps and websites, not to first-party crash reporting; do not conflate them, but do check what your SDK's default configuration sends.
|
|
72
|
+
- Gate SDK initialization on consent where consent is required, and use the reporter's own resettable install id rather than `identifierForVendor` if the id must be resettable independently of app removal.
|
|
73
|
+
- Implement the vendor's `beforeSend` callback and test it against a payload containing a token.
|
|
74
|
+
|
|
75
|
+
## CI
|
|
76
|
+
|
|
77
|
+
- Fail the archive job when dSYM upload did not run, and when `DEBUG_INFORMATION_FORMAT` is not `dwarf-with-dsym` for Release.
|
|
78
|
+
- Upload the build's version and build number to the reporter so the release appears immediately.
|
|
79
|
+
- Check Organizer's hang rate and crash rate for the previous release before promoting a new one — see [mobile-release](../../mobile-release/SKILL.md).
|
|
80
|
+
|
|
81
|
+
## Official documentation
|
|
82
|
+
|
|
83
|
+
- MetricKit: https://developer.apple.com/documentation/metrickit
|
|
84
|
+
- Diagnosing issues using crash reports and device logs: https://developer.apple.com/documentation/xcode/diagnosing-issues-using-crash-reports-and-device-logs
|
|
85
|
+
- Understanding hangs in your app: https://developer.apple.com/documentation/xcode/understanding-hangs-in-your-app
|
|
86
|
+
- Logging with OSLog: https://developer.apple.com/documentation/os/logging
|
|
87
|
+
- Recording performance data with signposts: https://developer.apple.com/documentation/os/recording-performance-data
|
|
88
|
+
- Privacy manifest files: https://developer.apple.com/documentation/bundleresources/privacy-manifest-files
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# Observability in Kotlin Multiplatform
|
|
2
|
+
|
|
3
|
+
Two things are shared and one is not. The **instrumentation** — what you record, the event names, the attributes, the sampling decision, the redaction rules — belongs in `commonMain`, so both apps report the same names and a dashboard can compare them. The **reporting SDK** is per host: Crashlytics/Sentry on Android through the Android SDK, the same vendor on iOS through its Apple SDK. There is no cross-platform crash reporter that covers Kotlin/Native crashes as well as each platform's native reporter does.
|
|
4
|
+
|
|
5
|
+
## The boundary
|
|
6
|
+
|
|
7
|
+
```kotlin
|
|
8
|
+
// commonMain — the only API the shared code knows about
|
|
9
|
+
interface Telemetry {
|
|
10
|
+
fun breadcrumb(name: String, attributes: Map<String, String> = emptyMap())
|
|
11
|
+
fun setKey(key: String, value: String)
|
|
12
|
+
fun recordError(throwable: Throwable, fatal: Boolean = false)
|
|
13
|
+
fun startSpan(name: String): Span
|
|
14
|
+
}
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
`androidMain` implements it over the Android SDK; `iosMain` implements it over the Apple SDK, or the iOS app injects a Swift implementation through the interface. Prefer injection from the host over `expect`/`actual` here: it keeps the vendor out of the shared dependency graph and makes `commonTest` a matter of passing a recording fake.
|
|
18
|
+
|
|
19
|
+
Everything else — which events exist, their attribute keys, the sampling rate, the redaction — lives in `commonMain` and is tested once.
|
|
20
|
+
|
|
21
|
+
## Kotlin/Native crashes on iOS
|
|
22
|
+
|
|
23
|
+
This is the part that surprises teams:
|
|
24
|
+
|
|
25
|
+
- An **unhandled Kotlin exception on iOS terminates the process**. By default it is reported as a native crash, and the report's usefulness depends entirely on the framework's dSYM being uploaded alongside the app's.
|
|
26
|
+
- `setUnhandledExceptionHook` lets you record the Kotlin exception through the host reporter *before* the process dies — use it to attach the shared-layer context (the screen, the operation, the outbox depth) that a raw native crash report will not carry. Do not use it to swallow the exception.
|
|
27
|
+
- Kotlin frames appear as addresses unless the framework is built with debug symbols and those symbols are uploaded. Check `binaryOptions` and the framework build configuration, and verify once with a deliberate release crash from shared code.
|
|
28
|
+
- `kotlin.native.binary.` options that strip symbols will make every shared-code crash unreadable. Confirm what the release framework actually ships.
|
|
29
|
+
|
|
30
|
+
On Android the shared code is ordinary JVM code: Kotlin exceptions reach the default uncaught handler and the reporter sees them normally, symbolicated through the R8 mapping file.
|
|
31
|
+
|
|
32
|
+
## Instrumenting the shared layer
|
|
33
|
+
|
|
34
|
+
- **Ktor:** a client plugin is the single place to generate the request id, add the header, record the span and drop a network breadcrumb — for both platforms at once.
|
|
35
|
+
|
|
36
|
+
```kotlin
|
|
37
|
+
val client = HttpClient {
|
|
38
|
+
install(createClientPlugin("Observability") {
|
|
39
|
+
onRequest { request, _ ->
|
|
40
|
+
request.headers.append("X-Request-Id", uuid4().toString())
|
|
41
|
+
}
|
|
42
|
+
onResponse { response ->
|
|
43
|
+
telemetry.breadcrumb("network", mapOf(
|
|
44
|
+
"host" to response.request.url.host, // host only, never the full URL
|
|
45
|
+
"status" to response.status.value.toString(),
|
|
46
|
+
))
|
|
47
|
+
}
|
|
48
|
+
})
|
|
49
|
+
}
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
- **Repository and sync errors:** the shared sync engine ([mobile-offline-sync](../../mobile-offline-sync/SKILL.md)) is where the most valuable context lives — outbox depth, attempt counts, conflict outcomes. Record it as keys and breadcrumbs from `commonMain` so both apps report it identically.
|
|
53
|
+
- **Coroutine failures:** an exception in a shared `CoroutineScope` with no handler is lost. Install a `CoroutineExceptionHandler` that routes to `Telemetry.recordError`, and remember that a failing child in a plain `Job` cancels the scope — the report must say which.
|
|
54
|
+
- **Spans:** `OSSignposter` on iOS and trace sections on Android are host concerns; expose `startSpan` and let each `actual` map it, so the same span name shows up in Instruments and in a system trace.
|
|
55
|
+
|
|
56
|
+
## Segmentation
|
|
57
|
+
|
|
58
|
+
The dimensions in the skill's §4 apply, plus one KMP-specific pair worth setting as keys: the **shared module version** (if it is versioned independently of the apps) and the **target**. "Only on iOS" is the single most useful filter in a KMP dashboard, and it only exists if the target is an attribute on every signal.
|
|
59
|
+
|
|
60
|
+
## Platform field data
|
|
61
|
+
|
|
62
|
+
Android vitals, Play Console ANRs, Xcode Organizer and MetricKit all apply unchanged — the shared code runs inside two ordinary apps. See [android.md](android.md) and [ios.md](ios.md). The lab counterpart, including how to tell shared cost from host cost, is [mobile-performance/references/kotlin-multiplatform.md](../../mobile-performance/references/kotlin-multiplatform.md).
|
|
63
|
+
|
|
64
|
+
## Testing
|
|
65
|
+
|
|
66
|
+
- A recording fake `Telemetry` in `commonTest` asserts that a failing sync drain records the error once, with the expected keys, and does not log the payload.
|
|
67
|
+
- The redaction rules are shared code: test them against a payload containing a token, on both JVM and Native test targets.
|
|
68
|
+
- Sampling decisions should be deterministic given an injected seed or clock, so the test can assert "a failed session is always kept".
|
|
69
|
+
|
|
70
|
+
## Official documentation
|
|
71
|
+
|
|
72
|
+
- Kotlin Multiplatform: https://kotlinlang.org/docs/multiplatform.html
|
|
73
|
+
- Kotlin/Native and Swift/Objective-C interop: https://kotlinlang.org/docs/native-objc-interop.html
|
|
74
|
+
- Ktor client plugins: https://ktor.io/docs/client-custom-plugins.html
|
|
75
|
+
- Crashlytics: https://firebase.google.com/docs/crashlytics
|
|
76
|
+
- MetricKit: https://developer.apple.com/documentation/metrickit
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# Observability in React Native and Expo
|
|
2
|
+
|
|
3
|
+
A React Native app fails in two runtimes. A **JavaScript error** produces a red screen in development and, in production, either a caught error boundary or a fatal that the native crash reporter never sees on its own. A **native crash** produces a normal Android/iOS crash with no JavaScript stack. A setup that covers only one of them explains half the incidents — and the half it misses is usually the harder one.
|
|
4
|
+
|
|
5
|
+
## Cover both runtimes
|
|
6
|
+
|
|
7
|
+
Use a reporter with a React Native SDK (`@sentry/react-native`, Bugsnag, Crashlytics via `@react-native-firebase/crashlytics`, Embrace). What to check in the configuration:
|
|
8
|
+
|
|
9
|
+
- **JavaScript fatals are reported as fatals**, not swallowed by a global handler that logs and continues. `ErrorUtils.setGlobalHandler` is the underlying mechanism; the SDK wraps it, and an app that installs its own handler afterwards silently disables reporting.
|
|
10
|
+
- **Unhandled promise rejections** are captured. In Hermes they do not reach the global error handler by default, and an SDK option or a rejection tracker is needed.
|
|
11
|
+
- **Native crashes** are captured by the SDK's Android and iOS layers — which means the native install steps (autolinking plus any Gradle/Podfile lines the SDK documents) must actually be present. Verify on a release build, not in Metro.
|
|
12
|
+
- **React error boundaries** report and then render a fallback. A boundary that catches and shows a blank screen without reporting is the most common invisible failure in RN apps.
|
|
13
|
+
|
|
14
|
+
## Source maps are the whole game
|
|
15
|
+
|
|
16
|
+
A production JavaScript stack trace is minified Hermes bytecode offsets. Without source maps uploaded per build, every report is unreadable:
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
# The release build emits the bundle and its map; upload both, per platform, per version.
|
|
20
|
+
npx react-native bundle --platform android --dev false \
|
|
21
|
+
--entry-file index.js \
|
|
22
|
+
--bundle-output android/app/build/generated/.../index.android.bundle \
|
|
23
|
+
--sourcemap-output index.android.bundle.map
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
- The SDK's Gradle plugin / Xcode build phase usually automates the upload; confirm it ran for the shipped variant, and that the **release identifier** (version + build number + bundle hash) matches what the app reports at runtime. A mismatched release is the usual reason a correctly uploaded map still does not symbolicate.
|
|
27
|
+
- **Expo:** `eas build` uploads source maps when the SDK's config plugin is installed; **`eas update` publishes a new bundle without a new build**, so each OTA update needs its own source map upload and its own release identifier. An OTA-heavy app with build-time-only uploads has unreadable traces for every update after the first.
|
|
28
|
+
- Android also needs the R8 mapping file and iOS the dSYMs for native frames. Three artifacts, as in every cross-platform stack.
|
|
29
|
+
|
|
30
|
+
## OTA updates change what "app version" means
|
|
31
|
+
|
|
32
|
+
With `expo-updates` or CodePush, the native build and the JavaScript bundle version independently. Every signal must carry **both** — the native version/build and the update/bundle id — or you cannot tell which JavaScript is actually running on a device reporting a crash. Set the update id as a custom key at startup and include it in the release identifier used for source maps.
|
|
33
|
+
|
|
34
|
+
This is the single most important RN-specific item in the skill's §4 segmentation list.
|
|
35
|
+
|
|
36
|
+
## Context and breadcrumbs
|
|
37
|
+
|
|
38
|
+
- **Navigation:** React Navigation's `onStateChange`, or Expo Router's equivalent, produces a screen breadcrumb per transition. Most SDKs ship an integration that does it for you.
|
|
39
|
+
- **Network:** wrap `fetch`/`axios` once to generate the request id, add the `X-Request-Id` header, and record method, host, status and duration. SDK network breadcrumbs are on by default in some vendors and capture full URLs — review and restrict that before shipping, since query strings carry tokens.
|
|
40
|
+
- **Custom keys:** screen, auth state, flag values, update id, connectivity class from NetInfo, outbox depth.
|
|
41
|
+
- **Redux/Zustand/TanStack Query:** an action or query-error middleware that forwards failures with the state slice that caused them. Beware the vendor Redux integrations that attach the **entire state tree** to every event — that is both a cost and a privacy problem.
|
|
42
|
+
|
|
43
|
+
## Performance and native field data
|
|
44
|
+
|
|
45
|
+
- The RN SDKs report app-start, slow/frozen frames and, in newer versions, some JS-thread metrics. Treat them as indicators and read the platform's own data as the truth.
|
|
46
|
+
- Android vitals and Play Console ANR data, Xcode Organizer and MetricKit all still apply and still see the real process — see [android.md](android.md) and [ios.md](ios.md). An ANR in a React Native app is an Android ANR, reported by Play, and invisible from JavaScript.
|
|
47
|
+
- The lab counterparts are in [mobile-performance/references/react-native.md](../../mobile-performance/references/react-native.md).
|
|
48
|
+
|
|
49
|
+
## Privacy and consent
|
|
50
|
+
|
|
51
|
+
- The Play Data safety form and the iOS privacy nutrition label plus `PrivacyInfo.xcprivacy` must cover the native SDKs the JS packages pull in. Expo config plugins add native SDKs you never wrote a line against.
|
|
52
|
+
- Gate initialization on consent; most SDKs support initializing with sending disabled and enabling it later.
|
|
53
|
+
- Implement `beforeSend` (or the vendor's equivalent) to strip tokens, bodies and query strings, and unit-test it — it is plain JavaScript, so this is a cheap test with real value.
|
|
54
|
+
- `console.log` left in production ships strings into the device log and, with some SDK configurations, into breadcrumbs. Strip logs in release (`babel-plugin-transform-remove-console` or equivalent) or make the logger deliberate.
|
|
55
|
+
|
|
56
|
+
## Official documentation
|
|
57
|
+
|
|
58
|
+
- React Native source maps and symbolication: https://reactnative.dev/docs/symbolication
|
|
59
|
+
- Expo EAS Update: https://docs.expo.dev/eas-update/introduction/
|
|
60
|
+
- React Native Firebase Crashlytics: https://rnfirebase.io/crashlytics/usage
|
|
61
|
+
- Sentry for React Native: https://docs.sentry.io/platforms/react-native/
|
|
62
|
+
- NetInfo: https://github.com/react-native-netinfo/react-native-netinfo
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: mobile-offline-sync
|
|
3
|
+
description: Designs and reviews offline-first mobile data on every stack — Flutter, native Android, native iOS, React Native and Kotlin Multiplatform. Covers the local store, the outbox of pending mutations, idempotency keys, retry and backoff, conflict resolution, reconnection, and how to test a flaky network deterministically. Use when an app must work without connectivity, when writes are lost or duplicated after reconnecting, or when local and server data disagree.
|
|
4
|
+
intents: [offline]
|
|
5
|
+
stacks: ["*"]
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# Mobile Offline Sync
|
|
9
|
+
|
|
10
|
+
One design for offline-capable mobile data, on every stack. This file holds the model and the failure cases; each platform's storage engines, queue runners and network-condition tooling live in its reference.
|
|
11
|
+
|
|
12
|
+
## 1. Pick the reference
|
|
13
|
+
|
|
14
|
+
| Project | Read |
|
|
15
|
+
|---|---|
|
|
16
|
+
| Flutter | [references/flutter.md](references/flutter.md) |
|
|
17
|
+
| Native Android | [references/android.md](references/android.md) |
|
|
18
|
+
| Native iOS | [references/ios.md](references/ios.md) |
|
|
19
|
+
| React Native or Expo | [references/react-native.md](references/react-native.md) |
|
|
20
|
+
| Kotlin Multiplatform | [references/kotlin-multiplatform.md](references/kotlin-multiplatform.md) |
|
|
21
|
+
|
|
22
|
+
Background scheduling — when the sync is actually allowed to run — is in [mobile-performance/references/background.md](../mobile-performance/references/background.md). Where the queue and the store belong in the app's layers is in [mobile-architecture](../mobile-architecture/SKILL.md).
|
|
23
|
+
|
|
24
|
+
## 2. Decide what "offline" means here, first
|
|
25
|
+
|
|
26
|
+
Most offline bugs come from building more than the product needs, or less. Pick one of these three before designing anything:
|
|
27
|
+
|
|
28
|
+
| Level | The app can… | Cost |
|
|
29
|
+
|---|---|---|
|
|
30
|
+
| **Read cache** | Show previously fetched data while offline; writes fail with a clear message | Low. A store and a freshness policy |
|
|
31
|
+
| **Queued writes** | Accept writes offline, apply them optimistically, send them on reconnect | Medium. Everything in §3–§6 |
|
|
32
|
+
| **Full replication** | Work indefinitely offline, with concurrent edits on several devices merged | High. Needs a server designed for it: versioning, per-record change history, or a CRDT/sync engine |
|
|
33
|
+
|
|
34
|
+
Level 2 is what most apps need and what this skill is written for. Do not build level 3 machinery for a product whose users are offline for minutes; do not sell level 1 as "works offline" when users expect their edits to survive.
|
|
35
|
+
|
|
36
|
+
## 3. The local store is the source of truth for the UI
|
|
37
|
+
|
|
38
|
+
Offline-first inverts the usual flow. The UI never reads from the network; it reads from the local store, and the network's only job is to fill and drain that store.
|
|
39
|
+
|
|
40
|
+
```
|
|
41
|
+
UI ──reads──▶ local store ◀──writes── sync engine ◀──▶ server
|
|
42
|
+
▲ ▲
|
|
43
|
+
└──writes────────┘ (optimistic, immediately visible)
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
What this requires:
|
|
47
|
+
|
|
48
|
+
- **A real database, not a cache of responses.** Rows with your own primary keys, queryable, with a schema you migrate. Caching JSON blobs keyed by URL cannot answer "show me this user's unsent orders".
|
|
49
|
+
- **Reactive reads.** The store emits on change (Flow, Stream, publisher, subscription) so an applied mutation or a completed sync updates every screen without a manual refresh.
|
|
50
|
+
- **Per-record sync state**, stored next to the record: `synced`, `pending`, `failed`, plus the server version/ETag it was last based on and the time it was fetched. Without it the UI cannot show "sending…" and the engine cannot tell a local edit from a server row.
|
|
51
|
+
- **A freshness policy.** How stale is too stale, per entity. A price list and a chat message do not have the same answer, and "refresh everything on launch" is what makes launches slow.
|
|
52
|
+
|
|
53
|
+
## 4. The outbox: every write is a durable, replayable intent
|
|
54
|
+
|
|
55
|
+
A mutation made offline is not a paused HTTP call. It is a record in a table.
|
|
56
|
+
|
|
57
|
+
An outbox entry carries: a **stable client-generated id** (UUID, created on the device), the **operation** and its payload, the **entity it targets**, the **attempt count**, the **next attempt time**, the **last error**, and the **creation time**.
|
|
58
|
+
|
|
59
|
+
Consequences that are easy to get wrong:
|
|
60
|
+
|
|
61
|
+
- **Generate ids on the client.** A record created offline needs an id immediately, because the UI shows it and later operations reference it. Use a UUID (or ULID, which sorts by time) as the real primary key and let the server accept it, rather than mapping a temporary id to a server id afterwards. If the server must assign the id, you need an explicit id-mapping table and every queued operation that references the new record must be rewritten when the id arrives — design for that deliberately or avoid it.
|
|
62
|
+
- **Order matters, per entity.** Create-then-update-then-delete on the same record must reach the server in that order. Process the queue in order per entity key; parallelize across unrelated entities only.
|
|
63
|
+
- **Collapse redundant operations before sending.** Three edits to the same field while offline are one PUT. A create followed by a delete of a record the server never saw is *nothing at all* — both entries are dropped. Doing this at drain time, not at enqueue time, keeps the UI history honest.
|
|
64
|
+
- **A failed entry must not block the queue forever.** After N attempts, move it to a dead-letter state, surface it to the user, and continue with the rest. A permanent 400 retried forever stops every later write.
|
|
65
|
+
- **The queue survives everything.** Process death, reboot, app update, low storage. That means a database table, not an in-memory list, and a migration story for the payload format when the app updates while entries are pending.
|
|
66
|
+
|
|
67
|
+
## 5. Idempotency: the network will lie to you
|
|
68
|
+
|
|
69
|
+
The dangerous failure is not "the request failed". It is "the request succeeded and the response was lost" — the device retries, and the user has two orders.
|
|
70
|
+
|
|
71
|
+
- **Send an idempotency key with every mutating request.** The client-generated operation id is that key; put it in a header (`Idempotency-Key` is the common spelling) or in the body. The server stores the key with the result and returns the *original* result for a repeat, instead of performing the operation again.
|
|
72
|
+
- **Without server support, retrying a POST is unsafe.** If the API has no idempotency mechanism, say so explicitly in the review: the choice is to add one server-side, to make the endpoint naturally idempotent (a PUT to a client-chosen id), or to accept duplicates. There is no client-only fix — a client that "checks whether it already exists" before retrying still races.
|
|
73
|
+
- **Only delete the outbox entry after a confirmed success.** Acknowledge, then delete, in that order; a crash between the two costs a retry, which idempotency makes harmless. The reverse order costs a lost write.
|
|
74
|
+
- **Deletes and increments need the same care.** `DELETE` is naturally idempotent for a given id; a relative operation ("add 1 to the counter") is not, and must be sent as an absolute value or carry a key.
|
|
75
|
+
|
|
76
|
+
## 6. Conflict resolution: name the strategy, per entity
|
|
77
|
+
|
|
78
|
+
A conflict is a server-side change to a record the device edited based on an older version. Detecting one requires a version: an ETag, a `version` integer, or an `updated_at` the server controls. **If the API returns no version, conflicts cannot be detected at all** — the last writer silently wins, and that must be stated as a finding rather than assumed to be the design.
|
|
79
|
+
|
|
80
|
+
| Strategy | Use when | Cost |
|
|
81
|
+
|---|---|---|
|
|
82
|
+
| **Server wins** | Reference data the user does not edit — catalogues, settings pushed by an admin | None; discard the local change |
|
|
83
|
+
| **Client wins (last write wins)** | Single-user data edited on one device at a time — a draft, a local preference | Silent data loss if the assumption breaks |
|
|
84
|
+
| **Field-level merge** | Records where different fields change independently — a profile, a form | Needs per-field dirty tracking in the outbox, not a whole-object PUT |
|
|
85
|
+
| **Ask the user** | The conflict is meaningful and rare — a document, an appointment | UI work, and a place to hold both versions until it is resolved |
|
|
86
|
+
| **Append-only / event log** | Values that accumulate rather than replace — comments, measurements, a ledger | Changes the data model; removes most conflicts by construction |
|
|
87
|
+
| **CRDT / sync engine** | Genuine multi-device concurrent editing | A library and a compatible backend; do not hand-roll it |
|
|
88
|
+
|
|
89
|
+
Two rules regardless of strategy: **send only the fields that changed** (a full-object PUT turns every concurrent edit into a conflict), and **never resolve a conflict by silently discarding user input** — if the local version loses, the user is told, or the version is kept somewhere recoverable.
|
|
90
|
+
|
|
91
|
+
## 7. Reconnection
|
|
92
|
+
|
|
93
|
+
- **Connectivity APIs report the interface, not reachability.** "Connected to Wi-Fi" is true on a captive portal, behind a dead VPN, and on a hotel network that resolves DNS to a login page. Treat the connectivity event as a *hint to try*, and treat a successful request as the only proof of connectivity.
|
|
94
|
+
- **Backoff with jitter.** Exponential, capped (a minute or two), with randomization. Without jitter, every device that lost the same backend retries in lockstep and re-creates the outage.
|
|
95
|
+
- **Distinguish the error classes.** Retry on timeouts, 5xx, and connection failures. Do not retry a 4xx other than 408/429 — it will fail identically forever. Honour `Retry-After` on 429 and 503.
|
|
96
|
+
- **Drain on the right triggers:** app foreground, connectivity regained, a periodic constrained background job, and an explicit user pull-to-refresh. Not a polling timer.
|
|
97
|
+
- **Single-flight the drain.** Two concurrent drains after a fast reconnect/disconnect/reconnect send everything twice; a mutex or a single worker with unique work policy prevents it.
|
|
98
|
+
|
|
99
|
+
## 8. Tell the user the truth
|
|
100
|
+
|
|
101
|
+
Offline UX fails in predictable ways, and each one is reviewable:
|
|
102
|
+
|
|
103
|
+
- [ ] The user can tell the difference between "saved on this device" and "saved on the server". A pending indicator per item, not one global banner.
|
|
104
|
+
- [ ] An optimistic update that later fails is **rolled back visibly**, with the reason and a retry — never silently reverted, never left looking saved.
|
|
105
|
+
- [ ] Stale data shows its age ("updated 2 hours ago") where staleness matters.
|
|
106
|
+
- [ ] Actions that genuinely cannot work offline are disabled with an explanation, not offered and then failed.
|
|
107
|
+
- [ ] Dead-lettered operations are reachable and retryable or discardable by the user; nothing is lost in silence.
|
|
108
|
+
- [ ] Logging out, or clearing data, with a non-empty outbox asks the user first.
|
|
109
|
+
|
|
110
|
+
## 9. Test the failure cases, not the happy path
|
|
111
|
+
|
|
112
|
+
An offline feature that was only tested by toggling airplane mode is untested. The cases that break real implementations:
|
|
113
|
+
|
|
114
|
+
1. Write offline → kill the process → relaunch → reconnect. The write must still be sent.
|
|
115
|
+
2. Write → the response is lost after the server committed → retry. Exactly one record must exist.
|
|
116
|
+
3. Two writes to the same record offline → reconnect. The server must end up with the final state, in order.
|
|
117
|
+
4. Write offline → the same record is changed on the server → reconnect. The configured conflict strategy must be what actually happens.
|
|
118
|
+
5. Reconnect with a large queue → the app is backgrounded mid-drain. The drain resumes and does not duplicate.
|
|
119
|
+
6. A permanently rejected operation (validation error). The queue continues; the user is told.
|
|
120
|
+
7. Clock skew: the device's clock is hours off. Nothing that decides ordering or expiry may depend on the device clock.
|
|
121
|
+
8. App update with pending entries whose payload format changed.
|
|
122
|
+
|
|
123
|
+
Each reference names the platform's tools for forcing these conditions — network link conditioners, emulator network profiles, and the fake/test doubles that make cases 2 and 4 deterministic instead of a race. Test-harness patterns are in [mobile-test](../mobile-test/SKILL.md).
|
|
124
|
+
|
|
125
|
+
## 10. Evidence and severity
|
|
126
|
+
|
|
127
|
+
Point to `file:line`, and name which of the nine cases in §9 the code fails.
|
|
128
|
+
|
|
129
|
+
- **Critical:** data loss or duplication — writes dropped on process death, retries without idempotency, a conflict strategy that discards user input silently, an outbox held in memory.
|
|
130
|
+
- **High:** the feature does not work as promised — the queue blocked by one failed entry, no backoff (battery and a self-inflicted outage), no conflict detection where concurrent edits are expected, optimistic updates never rolled back.
|
|
131
|
+
- **Medium:** friction and waste — no operation collapsing, refetching everything on every launch, a connectivity check trusted as reachability.
|
|
132
|
+
- **Low:** polish — missing per-item pending state, no age on stale data.
|