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:
agra
2026-05-11 12:07:26 +03:00
parent a587a7a967
commit 1d00f16122
10 changed files with 954 additions and 1 deletions

108
lib/src/crash.dart Normal file
View 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
View 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);
}
}