log: HttpSink + native crash capture (iOS/Android)
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.
This commit is contained in:
108
lib/src/crash.dart
Normal file
108
lib/src/crash.dart
Normal file
@@ -0,0 +1,108 @@
|
||||
/// 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',
|
||||
};
|
||||
}
|
||||
181
lib/src/log_http.dart
Normal file
181
lib/src/log_http.dart
Normal file
@@ -0,0 +1,181 @@
|
||||
/// HTTP log sink — ships [LogRecord]s to a self-hosted endpoint.
|
||||
///
|
||||
/// Composes with the rest of the sink chain via [Log.configure]:
|
||||
/// `Log.configure(sink: ConsoleSink() + HttpSink(endpoint: ..., token: ...))`.
|
||||
library;
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:collection';
|
||||
import 'dart:convert';
|
||||
import 'dart:developer' as developer;
|
||||
import 'dart:io';
|
||||
|
||||
import 'log.dart';
|
||||
|
||||
/// Sends batched JSON records to an HTTP endpoint that accepts
|
||||
/// `POST <endpoint>/log` with `Content-Type: application/json` and an
|
||||
/// `Authorization: Bearer <token>` header. See `tools/log_server` for the
|
||||
/// reference receiver.
|
||||
class HttpSink extends LogSink {
|
||||
/// Creates a sink that POSTs batched records to [endpoint] with bearer
|
||||
/// [token] auth. [device] and [app] are merged into every record's JSON
|
||||
/// envelope. [sender] is overridable for tests.
|
||||
HttpSink({
|
||||
required Uri endpoint,
|
||||
required String token,
|
||||
LogLevel minLevel = LogLevel.info,
|
||||
this.batchSize = 25,
|
||||
this.flushInterval = const Duration(seconds: 2),
|
||||
this.queueCapacity = 2000,
|
||||
this.initialBackoff = const Duration(seconds: 1),
|
||||
this.maxBackoff = const Duration(seconds: 30),
|
||||
Map<String, Object?>? device,
|
||||
Map<String, Object?>? app,
|
||||
HttpSender? sender,
|
||||
}) : _endpoint = endpoint,
|
||||
_token = token,
|
||||
_minLevel = minLevel,
|
||||
_device = device,
|
||||
_app = app,
|
||||
_sender = sender ?? _defaultSender;
|
||||
|
||||
/// The full URL the batched POST hits (e.g. `http://nas:8000/log`).
|
||||
final Uri _endpoint;
|
||||
final String _token;
|
||||
final LogLevel _minLevel;
|
||||
final Map<String, Object?>? _device;
|
||||
final Map<String, Object?>? _app;
|
||||
final HttpSender _sender;
|
||||
|
||||
/// Flush when the queue reaches this many records.
|
||||
final int batchSize;
|
||||
|
||||
/// Flush at most this long after the first queued record.
|
||||
final Duration flushInterval;
|
||||
|
||||
/// Hard cap. Beyond this, oldest records are dropped on each new `emit`.
|
||||
final int queueCapacity;
|
||||
|
||||
/// First retry delay; doubles each attempt up to [maxBackoff].
|
||||
final Duration initialBackoff;
|
||||
|
||||
/// Retry backoff cap. Starts at [initialBackoff], doubles, clamped here.
|
||||
final Duration maxBackoff;
|
||||
|
||||
final Queue<Map<String, Object?>> _queue = Queue();
|
||||
Timer? _timer;
|
||||
Future<void>? _inFlight;
|
||||
bool _overflowWarned = false;
|
||||
|
||||
@override
|
||||
LogLevel get minLevel => _minLevel;
|
||||
|
||||
@override
|
||||
void emit(LogRecord record) {
|
||||
if (_queue.length >= queueCapacity) {
|
||||
_queue.removeFirst();
|
||||
if (!_overflowWarned) {
|
||||
_overflowWarned = true;
|
||||
developer.log(
|
||||
'HttpSink: queue at capacity ($queueCapacity), dropping oldest',
|
||||
name: 'ux.log',
|
||||
);
|
||||
}
|
||||
}
|
||||
_queue.add(_serialize(record));
|
||||
if (_queue.length >= batchSize) {
|
||||
_kick();
|
||||
} else {
|
||||
_timer ??= Timer(flushInterval, _kick);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> flush() async {
|
||||
_kick();
|
||||
while (_inFlight != null) {
|
||||
await _inFlight;
|
||||
}
|
||||
}
|
||||
|
||||
void _kick() {
|
||||
_timer?.cancel();
|
||||
_timer = null;
|
||||
if (_queue.isEmpty || _inFlight != null) return;
|
||||
final batch = List<Map<String, Object?>>.from(_queue);
|
||||
_queue.clear();
|
||||
_inFlight = _ship(batch).whenComplete(() {
|
||||
_inFlight = null;
|
||||
if (_queue.isNotEmpty) _kick();
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _ship(List<Map<String, Object?>> batch) async {
|
||||
final body = utf8.encode(jsonEncode(batch));
|
||||
var backoff = initialBackoff;
|
||||
while (true) {
|
||||
int? status;
|
||||
try {
|
||||
status = await _sender(_endpoint, _token, body);
|
||||
} catch (_) {
|
||||
// Network error — treat like 5xx and retry.
|
||||
}
|
||||
if (status != null && status >= 200 && status < 300) return;
|
||||
if (status != null && status >= 400 && status < 500) {
|
||||
developer.log(
|
||||
'HttpSink: dropped ${batch.length} record(s); server returned $status',
|
||||
name: 'ux.log',
|
||||
);
|
||||
return;
|
||||
}
|
||||
await Future.delayed(backoff);
|
||||
backoff = backoff * 2;
|
||||
if (backoff > maxBackoff) backoff = maxBackoff;
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, Object?> _serialize(LogRecord r) => {
|
||||
'time': r.time.toUtc().toIso8601String(),
|
||||
'level': r.level.letter,
|
||||
if (r.tag != null) 'tag': r.tag,
|
||||
'message': r.message,
|
||||
if (r.fields != null && r.fields!.isNotEmpty)
|
||||
'fields': _jsonify(r.fields!),
|
||||
if (r.error != null) 'error': r.error.toString(),
|
||||
if (r.stackTrace != null) 'stackTrace': r.stackTrace.toString(),
|
||||
if (_device != null) 'device': _device,
|
||||
if (_app != null) 'app': _app,
|
||||
};
|
||||
|
||||
static Object? _jsonify(Object? v) {
|
||||
if (v == null || v is num || v is bool || v is String) return v;
|
||||
if (v is Map) {
|
||||
return {for (final e in v.entries) e.key.toString(): _jsonify(e.value)};
|
||||
}
|
||||
if (v is Iterable) return v.map(_jsonify).toList();
|
||||
return v.toString();
|
||||
}
|
||||
}
|
||||
|
||||
/// Pluggable transport — return the HTTP status of the POST, or throw on
|
||||
/// network failure. The default uses `dart:io.HttpClient`.
|
||||
typedef HttpSender = Future<int> Function(
|
||||
Uri endpoint,
|
||||
String token,
|
||||
List<int> body,
|
||||
);
|
||||
|
||||
Future<int> _defaultSender(Uri endpoint, String token, List<int> body) async {
|
||||
final client = HttpClient();
|
||||
try {
|
||||
final req = await client.postUrl(endpoint);
|
||||
req.headers.set(HttpHeaders.authorizationHeader, 'Bearer $token');
|
||||
req.headers.set(HttpHeaders.contentTypeHeader, 'application/json');
|
||||
req.add(body);
|
||||
final res = await req.close();
|
||||
await res.drain<void>();
|
||||
return res.statusCode;
|
||||
} finally {
|
||||
client.close(force: false);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user