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
17 changes: 15 additions & 2 deletions lib/app.dart
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import 'ui/activity/live_session_screen.dart';
import 'ui/ai/ai_breakdown_screen.dart';
import 'ui/journal/journal_compose_screen.dart';
import 'ui/stress/calm_breathing_screen.dart';
import 'telemetry/telemetry_service.dart';

class OpenStrapApp extends StatefulWidget {
const OpenStrapApp({super.key});
Expand Down Expand Up @@ -93,13 +94,18 @@ class _OpenStrapAppState extends State<OpenStrapApp> with WidgetsBindingObserver
themeMode: theme.materialThemeMode,
builder: (context, child) =>
ThemeSwitchOverlay(key: themeSwitchKey, child: child!),
navigatorObservers: [TelemetryNavigatorObserver()],
home: const _Gate(),
);
}
}

/// Onboarding gate: pairing → app. CLOUD EXCISED — the old backend / auth /
/// profile gate states are gone; once a band is paired we go straight to the shell.
/// Telemetry-only: the last AppRoute we logged, so _Gate's build() (which also
/// re-runs on a plain theme flip, not just a route change) doesn't double-log.
AppRoute? _telemetryLastRoute;

class _Gate extends StatelessWidget {
const _Gate();
@override
Expand All @@ -109,6 +115,11 @@ class _Gate extends StatelessWidget {
// here used to repaint the entire home stack every second, which starved the
// background BLE connection on long idle stretches (lost overnight data).
final route = context.select<AppState, AppRoute>((a) => a.route);
if (route != _telemetryLastRoute) {
_telemetryLastRoute = route;
TelemetryService.instance.setContext('app_route', route.name);
TelemetryService.instance.breadcrumb('route: ${route.name}');
}
// Depend on the theme too → the whole home stack (onboarding screens, the
// shell + its tabs) rebuilds with fresh colours the instant the mode flips.
context.watch<ThemeController>();
Expand Down Expand Up @@ -191,7 +202,8 @@ class _ShellState extends State<_Shell> {
_ => null,
};
if (screen == null) return;
Navigator.of(context).push(themedRoute((_) => screen));
Navigator.of(context)
.push(themedRoute((_) => screen, name: screen.runtimeType.toString()));
}

// Built fresh on every build (not const) so a theme flip re-colours every tab,
Expand Down Expand Up @@ -335,7 +347,8 @@ class _LiveBannerState extends State<_LiveBanner> with SingleTickerProviderState
onTap: () {
HapticFeedback.selectionClick();
Navigator.of(context).push(themedRoute(
(_) => LiveSessionScreen(workoutId: w.workoutId, type: w.type)));
(_) => LiveSessionScreen(workoutId: w.workoutId, type: w.type),
name: 'LiveSessionScreen'));
},
child: Container(
padding: const EdgeInsets.symmetric(horizontal: Sp.x4, vertical: Sp.x3),
Expand Down
82 changes: 67 additions & 15 deletions lib/compute/derivation_engine.dart
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,10 @@ import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_performance/firebase_performance.dart';

import '../data/db.dart';
import '../data/day_label.dart';
import '../notify/notification_center.dart';
import '../notify/notification_event.dart';
import '../telemetry/telemetry_service.dart';
import 'crossday_pipeline.dart';
import 'derive_prepare.dart';
import 'onehz_pipeline.dart';
Expand Down Expand Up @@ -1198,6 +1200,10 @@ class DerivationEngine {
/// skipped so the sweep always makes progress.
static const Duration _perDayTimeout = Duration(seconds: 90);

/// Throttle for the readiness-absent diagnostic log — one per calendar day
/// so repeated light-pass re-derives of today don't spam the outbox.
String? _loggedReadinessAbsentFor;

/// Bounded worker-pool size for concurrent per-day derivation. Days within
/// a single run share ONE frozen baseline snapshot (`_BaselineHistoryCache`
/// is loaded once before the loop, refreshed once after — see `run()`) and
Expand Down Expand Up @@ -1359,6 +1365,39 @@ class DerivationEngine {
() => deriveDayBundle(withHistory),
).timeout(_perDayTimeout);
_logSpo2Diagnostics(day, input, bundle);
// Readiness came back absent for TODAY specifically (not a historical
// backfill day, which would just be noise) — log why. This ran inside
// Isolate.run so it couldn't call Firebase itself; it just returned the
// per-input diagnostic (see onehz_pipeline.dart's readinessAbsentDiag).
// Throttled to once/day so repeated light-pass re-derives of today don't
// spam the outbox with the same finding.
final absentDiag = bundle['readiness_absent_diag'];
if (absentDiag != null &&
day.date == todayLabel() &&
_loggedReadinessAbsentFor != day.date) {
_loggedReadinessAbsentFor = day.date;
TelemetryService.instance.breadcrumb('readiness absent: $absentDiag');
// Flattened, not the raw nested map: record()'s Analytics forwarding
// only keeps num/String values as-is and stringifies everything else,
// so passing {'hrv': {'value': ..., 'baseline_n': ...}, ...} directly
// would turn each input into one unqueryable "{value: true, ...}"
// string instead of separately filterable fields.
final diag = (absentDiag as Map).cast<String, dynamic>();
final flat = <String, dynamic>{};
for (final key in ['hrv', 'rhr', 'resp', 'temp']) {
final v = (diag[key] as Map?)?.cast<String, dynamic>();
if (v == null) continue;
flat['${key}_value'] = v['value'];
flat['${key}_baseline_n'] = v['baseline_n'];
}
flat['note'] = diag['note'];
TelemetryService.instance.record(
kind: 'event',
level: 'warn',
message: 'readiness_absent',
context: flat,
);
}

// Where this day's sleep window came from (auto / auto_fallback / manual /
// confirmed) — drives the Sleep screen's "is this right?" prompt + the
Expand Down Expand Up @@ -1711,10 +1750,16 @@ class DerivationEngine {
return;
}
final profileMap = profile.toMap();
final bundle = await Isolate.run(
() => buildCrossDayBundle(days, profileMap),
// Encode INSIDE the isolate too — a real ~3.5-4.7s main-isolate hang was
// caught in production (Crashlytics jank_watchdog, correlated with a
// heavy derive pass) coming from jsonEncode-ing this bundle back on the
// main isolate after Isolate.run returned it. Returning the already-
// encoded string avoids both the main-isolate encode cost AND transfers
// a flat string across the isolate boundary instead of a large nested Map.
final bundleJson = await Isolate.run(
() => jsonEncode(buildCrossDayBundle(days, profileMap)),
).timeout(_crossDayTimeout);
await LocalDb.putBaseline('crossday', jsonEncode(bundle));
await LocalDb.putBaseline('crossday', bundleJson);
_log('crossday: stored over ${days.length} day(s)');
} catch (e) {
_log('crossday FAILED/skipped: $e');
Expand Down Expand Up @@ -1744,19 +1789,26 @@ class DerivationEngine {
}

Future<List<Map<String, dynamic>>> _refreshCrossDayInputArtifact() async {
// The DB read itself must stay on the main isolate (sqflite), but
// decoding up to _crossDayWindow (90) full day payloads + re-encoding
// them was previously ALL synchronous main-isolate work with zero
// offloading — this is the confirmed source of the ~3.5-4.7s production
// hang (Crashlytics jank_watchdog), since _refreshBaselines calls this
// unconditionally on every heavy pass. _decodeBundle/_crossDayRecord are
// both static, so this whole transform+encode step is isolate-safe.
final rows = await LocalDb.recentDayResults(_crossDayWindow);
final days = <Map<String, dynamic>>[];
for (final row in rows.reversed) {
final payload = _decodeBundle(row['payload_json']);
if (payload == null) continue;
if (payload['skipped'] == true) continue;
final rec = _crossDayRecord(row, payload);
if (rec != null) days.add(rec);
}
await LocalDb.putBaseline(
'crossday_input',
jsonEncode({'algo_version': kAlgoVersion, 'days': days}),
);
final (days, json) = await Isolate.run(() {
final days = <Map<String, dynamic>>[];
for (final row in rows.reversed) {
final payload = _decodeBundle(row['payload_json']);
if (payload == null) continue;
if (payload['skipped'] == true) continue;
final rec = _crossDayRecord(row, payload);
if (rec != null) days.add(rec);
}
return (days, jsonEncode({'algo_version': kAlgoVersion, 'days': days}));
});
await LocalDb.putBaseline('crossday_input', json);
return days;
}

Expand Down
18 changes: 18 additions & 0 deletions lib/compute/onehz_pipeline.dart
Original file line number Diff line number Diff line change
Expand Up @@ -360,6 +360,23 @@ Map<String, dynamic> deriveDayBundle(Map<String, dynamic> inputJson) {
// pass raw values + their raw baselines).
tempInput(skinTempAdc, d.skinTempAdcHistory),
]);
// Diagnostic only — populated when readiness comes back absent, so the main
// isolate can log WHY to Crashlytics instead of a bare null (this runs
// inside Isolate.run, so it can't call Firebase directly; it just returns
// data). Per-input value-presence + baseline length lets us distinguish
// "no value" / "baseline too short" from "everything present but MAD was
// degenerate" by elimination (readinessComposite doesn't surface the last
// case in its own note — see readiness_composite.dart's robustZ() null path).
Map<String, dynamic>? readinessAbsentDiag;
if (!composite.present) {
readinessAbsentDiag = {
'hrv': {'value': lnToday != null, 'baseline_n': d.lnRmssdHistory.length},
'rhr': {'value': rhrToday != null, 'baseline_n': d.rhrHistory.length},
'resp': {'value': respToday != null, 'baseline_n': d.respHistory.length},
'temp': {'value': skinTempAdc != null, 'baseline_n': d.skinTempAdcHistory.length},
'note': composite.note,
};
}
// Plews lnRMSSD readiness over the trailing history INCLUDING today.
final lnHist = [...d.lnRmssdHistory, ?lnToday];
final lnReadiness = lnHist.length >= 4
Expand Down Expand Up @@ -754,6 +771,7 @@ Map<String, dynamic> deriveDayBundle(Map<String, dynamic> inputJson) {
'clean_fraction': _round(corrected.cleanFraction, 4),
'sleep_seconds': inBedSec ?? 0,
},
'readiness_absent_diag': ?readinessAbsentDiag,
'scalars': {
'rhr': rhrScalar,
// Headline RMSSD (robust nocturnal, NREM). Whole-window kept separately.
Expand Down
4 changes: 4 additions & 0 deletions lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,10 @@ Future<void> main() async {
// catch framework + uncaught async errors on their own, and a custom root zone
// is a known source of release-startup fragility.
TelemetryService.instance.installErrorHandlers();
// Turns real frame-level jank into Crashlytics non-fatal reports — Crashlytics
// otherwise has zero visibility into "the app froze while scrolling" since
// freezing isn't a crash. See installJankWatchdog's doc for the threshold.
TelemetryService.instance.installJankWatchdog();

// Android: Cancel the two legacy WorkManager tasks by unique name. A previous
// version scheduled heavy derivation passes in the background, but they were
Expand Down
21 changes: 18 additions & 3 deletions lib/state/app_state.dart
Original file line number Diff line number Diff line change
Expand Up @@ -700,10 +700,18 @@ class AppState extends ChangeNotifier {
/// sweep. Best-effort + non-blocking — never throws into the BLE path.
/// Refreshes the UI when results land so screens re-read the fresh derived rows.
Future<void> _afterDrain({bool heavy = false}) async {
final mode = heavy ? 'heavy' : 'light';
try {
// Context for whatever crash/ANR report comes next — the derivation
// engine's heavy per-day compute is isolate-offloaded, but the
// assembly/UI-refresh wiring around it still runs on the main isolate,
// so this is real signal if a freeze/ANR correlates with a derive pass.
TelemetryService.instance.setContext('derive_mode', mode);
TelemetryService.instance.setContext('derive_active', true);
TelemetryService.instance.breadcrumb('derive: $mode start');
// Refresh the UI after EACH day so Today/trends fill in as the sweep runs,
// not only at the end (a multi-day backfill can be many days of work).
await _derive.run(
await TelemetryService.instance.traced('derive_$mode', () => _derive.run(
_profile,
heavy: heavy,
onDayDone: (day, index, total) async {
Expand All @@ -712,7 +720,8 @@ class AppState extends ChangeNotifier {
notifyListeners();
}
},
);
));
TelemetryService.instance.breadcrumb('derive: $mode done');
await LocalDb.refreshComputeFreshness();
_bumpInsightsRevision();
notifyListeners(); // screens re-fetch from the derived store
Expand Down Expand Up @@ -756,8 +765,14 @@ class AppState extends ChangeNotifier {
if (heavy && healthShareConsent) {
unawaited(HealthUploader.instance.maybeUpload(consented: true));
}
} catch (e) {
} catch (e, st) {
_log('[derive] post-drain failed: $e');
// Was silently swallowed before — this is a real pipeline failure
// (derive/health-export/etc.) that Firebase never saw. Non-fatal, not
// fatal: the app keeps running, but this is worth knowing about.
TelemetryService.instance.recordNonFatal(e, st, reason: 'post_drain_failed');
} finally {
TelemetryService.instance.setContext('derive_active', false);
}
}

Expand Down
Loading