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:
100
test/crash_test.dart
Normal file
100
test/crash_test.dart
Normal file
@@ -0,0 +1,100 @@
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:ux/ux.dart';
|
||||
|
||||
void _noop() {}
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
final messenger = TestWidgetsFlutterBinding.ensureInitialized().defaultBinaryMessenger;
|
||||
const channel = MethodChannel('ux/crash');
|
||||
|
||||
setUp(() {
|
||||
messenger.setMockMethodCallHandler(channel, null);
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
messenger.setMockMethodCallHandler(channel, null);
|
||||
});
|
||||
|
||||
test('drainAndReport re-emits records as Log.f and acks each id', () async {
|
||||
final sink = MemorySink();
|
||||
Log.configure(sink: sink, minLevel: LogLevel.trace, captureCrashes: _noop);
|
||||
|
||||
final acked = <String>[];
|
||||
messenger.setMockMethodCallHandler(channel, (call) async {
|
||||
switch (call.method) {
|
||||
case 'drainPending':
|
||||
return <Map<Object?, Object?>>[
|
||||
{
|
||||
'id': 'A',
|
||||
'platform': 'ios',
|
||||
'name': 'NSInvalidArgumentException',
|
||||
'reason': 'bad index',
|
||||
'callStackSymbols': <Object?>['0 frame'],
|
||||
'bundleId': 'im.bl.app',
|
||||
},
|
||||
{
|
||||
'id': 'B',
|
||||
'platform': 'android',
|
||||
'type': 'java.lang.RuntimeException',
|
||||
'message': 'boom',
|
||||
'stack': 'at Foo.bar()\nat Baz.qux()',
|
||||
'sdkInt': 34,
|
||||
},
|
||||
];
|
||||
case 'ackCrash':
|
||||
acked.add(call.arguments as String);
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
await UxCrash.drainAndReport();
|
||||
|
||||
final records = sink.snapshot();
|
||||
expect(records.length, 2);
|
||||
expect(records[0].level, LogLevel.fatal);
|
||||
expect(records[0].tag, 'ux.crash');
|
||||
expect(records[0].message, contains('NSInvalidArgumentException'));
|
||||
expect(records[0].message, contains('bad index'));
|
||||
expect(records[0].stackTrace.toString(), '0 frame');
|
||||
expect(records[0].fields?['platform'], 'ios');
|
||||
expect(records[0].fields?['bundleId'], 'im.bl.app');
|
||||
|
||||
expect(records[1].message, contains('java.lang.RuntimeException'));
|
||||
expect(records[1].message, contains('boom'));
|
||||
expect(records[1].stackTrace.toString(), contains('Foo.bar()'));
|
||||
expect(records[1].fields?['sdkInt'], 34);
|
||||
|
||||
expect(acked, ['A', 'B']);
|
||||
});
|
||||
|
||||
test('drainAndReport tolerates MissingPluginException', () async {
|
||||
final sink = MemorySink();
|
||||
Log.configure(sink: sink, minLevel: LogLevel.trace, captureCrashes: _noop);
|
||||
|
||||
messenger.setMockMethodCallHandler(channel, (call) async {
|
||||
throw MissingPluginException();
|
||||
});
|
||||
|
||||
await UxCrash.drainAndReport();
|
||||
|
||||
expect(sink.snapshot(), isEmpty);
|
||||
});
|
||||
|
||||
test('drainAndReport logs but does not throw on channel errors', () async {
|
||||
final sink = MemorySink();
|
||||
Log.configure(sink: sink, minLevel: LogLevel.trace, captureCrashes: _noop);
|
||||
|
||||
messenger.setMockMethodCallHandler(channel, (call) async {
|
||||
throw PlatformException(code: 'oops');
|
||||
});
|
||||
|
||||
await UxCrash.drainAndReport();
|
||||
|
||||
expect(sink.snapshot().length, 1);
|
||||
expect(sink.snapshot().single.level, LogLevel.warn);
|
||||
expect(sink.snapshot().single.message, contains('drainPending'));
|
||||
});
|
||||
}
|
||||
33
test/log_http_e2e_test.dart
Normal file
33
test/log_http_e2e_test.dart
Normal file
@@ -0,0 +1,33 @@
|
||||
@Tags(['e2e'])
|
||||
library;
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:ux/ux.dart';
|
||||
|
||||
/// End-to-end smoke against a running `tools/log_server` at $E2E_LOG_ENDPOINT.
|
||||
/// Skipped unless both `E2E_LOG_ENDPOINT` and `E2E_LOG_TOKEN` are provided.
|
||||
void main() {
|
||||
const endpoint = String.fromEnvironment('E2E_LOG_ENDPOINT');
|
||||
const token = String.fromEnvironment('E2E_LOG_TOKEN');
|
||||
final skip = endpoint.isEmpty || token.isEmpty
|
||||
? 'Set --dart-define=E2E_LOG_ENDPOINT and E2E_LOG_TOKEN to run'
|
||||
: false;
|
||||
|
||||
test('round-trip through HttpSink → log_server', () async {
|
||||
final sink = HttpSink(
|
||||
endpoint: Uri.parse('$endpoint/log'),
|
||||
token: token,
|
||||
device: {'platform': 'test'},
|
||||
app: {'version': 'e2e'},
|
||||
flushInterval: const Duration(milliseconds: 1),
|
||||
);
|
||||
|
||||
sink.emit(LogRecord(
|
||||
time: DateTime.utc(2026, 5, 10, 12, 0, 0),
|
||||
level: LogLevel.error,
|
||||
message: 'e2e-roundtrip',
|
||||
tag: 'smoke',
|
||||
));
|
||||
await sink.flush();
|
||||
}, skip: skip);
|
||||
}
|
||||
236
test/log_http_test.dart
Normal file
236
test/log_http_test.dart
Normal file
@@ -0,0 +1,236 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:ux/ux.dart';
|
||||
|
||||
void main() {
|
||||
group('HttpSink', () {
|
||||
final endpoint = Uri.parse('http://test.invalid/log');
|
||||
|
||||
test('emit serializes record to JSON envelope', () async {
|
||||
List<List<int>>? captured;
|
||||
String? capturedToken;
|
||||
final sink = HttpSink(
|
||||
endpoint: endpoint,
|
||||
token: 'abc',
|
||||
flushInterval: const Duration(milliseconds: 1),
|
||||
device: {'platform': 'ios'},
|
||||
app: {'version': '1.8.0', 'buildNumber': 11},
|
||||
sender: (url, token, body) async {
|
||||
captured ??= [];
|
||||
captured!.add(body);
|
||||
capturedToken = token;
|
||||
return 204;
|
||||
},
|
||||
);
|
||||
|
||||
sink.emit(LogRecord(
|
||||
time: DateTime.utc(2026, 5, 10, 12, 0, 0),
|
||||
level: LogLevel.error,
|
||||
message: 'boom',
|
||||
tag: 'net',
|
||||
fields: {'k': 1, 'nested': {'x': true}},
|
||||
error: StateError('bad'),
|
||||
stackTrace: StackTrace.fromString('frame'),
|
||||
));
|
||||
await sink.flush();
|
||||
|
||||
expect(capturedToken, 'abc');
|
||||
expect(captured, isNotNull);
|
||||
expect(captured!.length, 1);
|
||||
final batch = jsonDecode(utf8.decode(captured!.single)) as List;
|
||||
expect(batch.length, 1);
|
||||
final rec = batch.single as Map<String, Object?>;
|
||||
expect(rec['time'], '2026-05-10T12:00:00.000Z');
|
||||
expect(rec['level'], 'E');
|
||||
expect(rec['tag'], 'net');
|
||||
expect(rec['message'], 'boom');
|
||||
expect(rec['fields'], {'k': 1, 'nested': {'x': true}});
|
||||
expect(rec['error'], contains('bad'));
|
||||
expect(rec['stackTrace'], 'frame');
|
||||
expect(rec['device'], {'platform': 'ios'});
|
||||
expect(rec['app'], {'version': '1.8.0', 'buildNumber': 11});
|
||||
});
|
||||
|
||||
test('batches up to batchSize before flushing', () async {
|
||||
final batches = <int>[];
|
||||
final sink = HttpSink(
|
||||
endpoint: endpoint,
|
||||
token: 't',
|
||||
batchSize: 3,
|
||||
flushInterval: const Duration(seconds: 60),
|
||||
sender: (url, token, body) async {
|
||||
final list = jsonDecode(utf8.decode(body)) as List;
|
||||
batches.add(list.length);
|
||||
return 204;
|
||||
},
|
||||
);
|
||||
|
||||
for (var i = 0; i < 5; i++) {
|
||||
sink.emit(LogRecord(
|
||||
time: DateTime.utc(2026, 5, 10),
|
||||
level: LogLevel.info,
|
||||
message: 'm$i',
|
||||
));
|
||||
}
|
||||
await sink.flush();
|
||||
// First batch of 3 fires at batchSize; flush ships the remaining 2.
|
||||
expect(batches, [3, 2]);
|
||||
});
|
||||
|
||||
test('retries on 5xx with exponential backoff', () async {
|
||||
final attempts = <DateTime>[];
|
||||
final sink = HttpSink(
|
||||
endpoint: endpoint,
|
||||
token: 't',
|
||||
flushInterval: const Duration(milliseconds: 1),
|
||||
initialBackoff: const Duration(milliseconds: 10),
|
||||
maxBackoff: const Duration(milliseconds: 200),
|
||||
sender: (url, token, body) async {
|
||||
attempts.add(DateTime.now());
|
||||
if (attempts.length < 4) return 503;
|
||||
return 204;
|
||||
},
|
||||
);
|
||||
|
||||
sink.emit(LogRecord(
|
||||
time: DateTime.utc(2026, 5, 10),
|
||||
level: LogLevel.info,
|
||||
message: 'retry-me',
|
||||
));
|
||||
await sink.flush();
|
||||
|
||||
expect(attempts.length, 4);
|
||||
// 10ms → 20ms → 40ms gaps (within maxBackoff cap of 200ms). Each gap
|
||||
// should be roughly double the prior, but we leave slack for scheduler
|
||||
// jitter and just assert monotonically non-decreasing positive gaps.
|
||||
final gap1 = attempts[1].difference(attempts[0]).inMicroseconds;
|
||||
final gap2 = attempts[2].difference(attempts[1]).inMicroseconds;
|
||||
final gap3 = attempts[3].difference(attempts[2]).inMicroseconds;
|
||||
expect(gap1, greaterThan(0));
|
||||
expect(gap2, greaterThanOrEqualTo(gap1));
|
||||
expect(gap3, greaterThanOrEqualTo(gap2));
|
||||
});
|
||||
|
||||
test('drops batch on 4xx after one warning', () async {
|
||||
var calls = 0;
|
||||
final sink = HttpSink(
|
||||
endpoint: endpoint,
|
||||
token: 't',
|
||||
flushInterval: const Duration(milliseconds: 1),
|
||||
sender: (url, token, body) async {
|
||||
calls += 1;
|
||||
return 400;
|
||||
},
|
||||
);
|
||||
|
||||
sink.emit(LogRecord(
|
||||
time: DateTime.utc(2026, 5, 10),
|
||||
level: LogLevel.info,
|
||||
message: 'bad',
|
||||
));
|
||||
await sink.flush();
|
||||
|
||||
// Hit once and gave up — no retry on 4xx.
|
||||
expect(calls, 1);
|
||||
});
|
||||
|
||||
test('queue overflow drops oldest', () async {
|
||||
final shipped = <String>[];
|
||||
final sender = Completer<void>();
|
||||
var ready = false;
|
||||
|
||||
final sink = HttpSink(
|
||||
endpoint: endpoint,
|
||||
token: 't',
|
||||
batchSize: 1000,
|
||||
flushInterval: const Duration(seconds: 60),
|
||||
queueCapacity: 3,
|
||||
sender: (url, token, body) async {
|
||||
if (!ready) await sender.future;
|
||||
final batch = jsonDecode(utf8.decode(body)) as List;
|
||||
for (final m in batch) {
|
||||
shipped.add(((m as Map)['message']) as String);
|
||||
}
|
||||
return 204;
|
||||
},
|
||||
);
|
||||
|
||||
for (var i = 0; i < 5; i++) {
|
||||
sink.emit(LogRecord(
|
||||
time: DateTime.utc(2026, 5, 10),
|
||||
level: LogLevel.info,
|
||||
message: 'm$i',
|
||||
));
|
||||
}
|
||||
ready = true;
|
||||
sender.complete();
|
||||
await sink.flush();
|
||||
|
||||
// First 2 dropped (queue cap 3), so only m2..m4 ship.
|
||||
expect(shipped, ['m2', 'm3', 'm4']);
|
||||
});
|
||||
|
||||
test('default sender hits a real loopback HTTP server', () async {
|
||||
final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
|
||||
final received = <Map<String, Object?>>[];
|
||||
final tokens = <String?>[];
|
||||
unawaited(() async {
|
||||
await for (final req in server) {
|
||||
tokens.add(req.headers.value(HttpHeaders.authorizationHeader));
|
||||
final body = await utf8.decoder.bind(req).join();
|
||||
final list = jsonDecode(body) as List;
|
||||
for (final r in list) {
|
||||
received.add((r as Map).cast<String, Object?>());
|
||||
}
|
||||
req.response.statusCode = 204;
|
||||
await req.response.close();
|
||||
}
|
||||
}());
|
||||
|
||||
final sink = HttpSink(
|
||||
endpoint: Uri.parse('http://127.0.0.1:${server.port}/log'),
|
||||
token: 'sek',
|
||||
flushInterval: const Duration(milliseconds: 1),
|
||||
);
|
||||
sink.emit(LogRecord(
|
||||
time: DateTime.utc(2026, 5, 10, 12, 0, 0),
|
||||
level: LogLevel.info,
|
||||
message: 'roundtrip',
|
||||
));
|
||||
await sink.flush();
|
||||
await server.close(force: true);
|
||||
|
||||
expect(tokens, ['Bearer sek']);
|
||||
expect(received.length, 1);
|
||||
expect(received.single['message'], 'roundtrip');
|
||||
expect(received.single['level'], 'I');
|
||||
});
|
||||
|
||||
test('fields with non-encodable values are stringified', () async {
|
||||
Map<String, Object?>? rec;
|
||||
final sink = HttpSink(
|
||||
endpoint: endpoint,
|
||||
token: 't',
|
||||
flushInterval: const Duration(milliseconds: 1),
|
||||
sender: (url, token, body) async {
|
||||
final list = jsonDecode(utf8.decode(body)) as List;
|
||||
rec = (list.single as Map).cast<String, Object?>();
|
||||
return 204;
|
||||
},
|
||||
);
|
||||
|
||||
sink.emit(LogRecord(
|
||||
time: DateTime.utc(2026, 5, 10),
|
||||
level: LogLevel.info,
|
||||
message: 'm',
|
||||
fields: {'when': DateTime.utc(2026, 5, 10, 1, 2, 3)},
|
||||
));
|
||||
await sink.flush();
|
||||
|
||||
expect(rec!['fields'], {'when': '2026-05-10 01:02:03.000Z'});
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user