Three new pieces, all composable through the existing Log API
(`Log.configure(sink: ConsoleSink() + HttpSink(...))`) — no new
facade, no install side-effects.
HttpSink (lib/src/log_http.dart)
- Extends LogSink. Batches records and POSTs them as a JSON array
to a configurable endpoint with bearer auth.
- Defaults: batchSize=25, flushInterval=2s, queueCapacity=2000,
initialBackoff=1s capped at maxBackoff=30s.
- Drops oldest on queue overflow (single console warning).
- Retries 5xx and network errors with exponential backoff; drops
on 4xx with a single console warning.
- Pluggable `HttpSender` typedef for tests; default uses
dart:io.HttpClient.
CrashPlugin (ios/Classes/CrashPlugin.swift,
android/src/main/kotlin/.../CrashPlugin.kt)
- Installs uncaught-exception handlers
(NSSetUncaughtExceptionHandler / Thread.UncaughtExceptionHandler),
chains to the prior handler so the platform's default kill path
still runs.
- Writes one JSON file per crash to <cacheDir>/ux_crashes/<uuid>.json.
iOS captures NSException.name/reason/userInfo + call-stack symbols
and return addresses. Android captures thread name, exception
class, message, full stack (including cause chain).
- Caps the directory at 50 files; drops oldest by mtime on overflow.
- Exposes method channel `ux/crash` with drainPending / ackCrash /
triggerTestCrash. Registered in UxPlugin on both platforms.
UxCrash.drainAndReport (lib/src/crash.dart)
- Pulls persisted crash records on boot, re-emits each via Log.f
(tag `ux.crash`) so they flow out through whatever sink chain
the app installed, then acks each id.
- Tolerates MissingPluginException silently; PlatformException is
logged as a single warn without throwing.
Tests:
- log_http_test.dart: payload shape, batching, retry doubling on 5xx,
drop on 4xx, queue overflow ordering, non-encodable field
stringification, real loopback HTTP round-trip with the default
sender.
- log_http_e2e_test.dart: opt-in real-server round-trip gated by
--dart-define=E2E_LOG_ENDPOINT/E2E_LOG_TOKEN.
- crash_test.dart: drain + re-emit + ack across iOS and Android
shapes, MissingPluginException tolerance, PlatformException
warn-not-throw.
109 lines
3.4 KiB
Dart
109 lines
3.4 KiB
Dart
/// Drains native uncaught-exception JSON records persisted by the platform
|
|
/// `CrashPlugin` (iOS / Android) and re-emits them through [Log.f], so they
|
|
/// flow out via whatever sink chain `Log.configure` was given (Console + File
|
|
/// + Http + …). The drain is a no-op on platforms without a CrashPlugin.
|
|
library;
|
|
|
|
import 'package:flutter/services.dart';
|
|
|
|
import 'log.dart';
|
|
|
|
/// Pull persisted native crash records and re-emit them as [Log.f]. Call once
|
|
/// during app boot, after `Log.configure(...)`, before [runApp].
|
|
class UxCrash {
|
|
UxCrash._();
|
|
|
|
static const _channel = MethodChannel('ux/crash');
|
|
|
|
/// Drains persisted native crashes from prior runs and re-emits each through
|
|
/// [Log.f]. Each record is acked back to the plugin after successful re-emit
|
|
/// so the underlying file is deleted. Errors talking to the channel are
|
|
/// swallowed — crash drain must never block app start.
|
|
static Future<void> drainAndReport({MethodChannel? channel}) async {
|
|
final ch = channel ?? _channel;
|
|
final List<Object?> raw;
|
|
try {
|
|
final res = await ch.invokeMethod<List<Object?>>('drainPending');
|
|
raw = res ?? const [];
|
|
} on MissingPluginException {
|
|
return;
|
|
} catch (e, st) {
|
|
Log.tag('ux.crash').w('drainPending failed', error: e, stackTrace: st);
|
|
return;
|
|
}
|
|
|
|
for (final entry in raw) {
|
|
if (entry is! Map) continue;
|
|
final m = entry.cast<Object?, Object?>();
|
|
final id = m['id']?.toString();
|
|
_report(m);
|
|
if (id == null) continue;
|
|
try {
|
|
await ch.invokeMethod<void>('ackCrash', id);
|
|
} catch (e, st) {
|
|
Log.tag('ux.crash').w('ackCrash failed', error: e, stackTrace: st);
|
|
}
|
|
}
|
|
}
|
|
|
|
static void _report(Map<Object?, Object?> m) {
|
|
final platform = m['platform']?.toString() ?? 'native';
|
|
final summary = StringBuffer('native crash ($platform)');
|
|
final type = (m['name'] ?? m['type'])?.toString();
|
|
if (type != null && type.isNotEmpty) {
|
|
summary
|
|
..write(': ')
|
|
..write(type);
|
|
}
|
|
final reason = (m['reason'] ?? m['message'])?.toString();
|
|
if (reason != null && reason.isNotEmpty) {
|
|
summary
|
|
..write(' — ')
|
|
..write(reason);
|
|
}
|
|
|
|
final stack = _stack(m);
|
|
Log.tag('ux.crash').f(
|
|
summary.toString(),
|
|
error: reason ?? type,
|
|
stackTrace: stack,
|
|
fields: {
|
|
for (final k in m.keys)
|
|
if (k != null && _passThrough.contains(k.toString()))
|
|
k.toString(): m[k],
|
|
},
|
|
);
|
|
}
|
|
|
|
static StackTrace? _stack(Map<Object?, Object?> m) {
|
|
final stack = m['stack']?.toString();
|
|
if (stack != null && stack.isNotEmpty) return StackTrace.fromString(stack);
|
|
final symbols = m['callStackSymbols'];
|
|
if (symbols is List && symbols.isNotEmpty) {
|
|
return StackTrace.fromString(symbols.join('\n'));
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/// Asks the native plugin to raise an uncaught exception, crashing the app.
|
|
/// On the next launch, [drainAndReport] should pick the record up. Intended
|
|
/// for end-to-end testing of the crash pipeline; do not ship buttons that
|
|
/// call this in release builds.
|
|
static Future<void> triggerNativeTestCrash({MethodChannel? channel}) async {
|
|
final ch = channel ?? _channel;
|
|
await ch.invokeMethod<void>('triggerTestCrash');
|
|
}
|
|
|
|
static const _passThrough = {
|
|
'id',
|
|
'platform',
|
|
'time',
|
|
'bundleId',
|
|
'packageName',
|
|
'systemVersion',
|
|
'sdkInt',
|
|
'thread',
|
|
'cause',
|
|
};
|
|
}
|