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

View File

@@ -0,0 +1,149 @@
package io.swipelab.ux
import android.content.Context
import android.os.Build
import io.flutter.embedding.engine.plugins.FlutterPlugin
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import org.json.JSONArray
import org.json.JSONObject
import java.io.File
import java.io.PrintWriter
import java.io.StringWriter
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.TimeZone
import java.util.UUID
class CrashPlugin : NativePlugin, MethodChannel.MethodCallHandler {
private var methodChannel: MethodChannel? = null
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
val ctx = binding.applicationContext
methodChannel = MethodChannel(binding.binaryMessenger, "ux/crash").also {
it.setMethodCallHandler(this)
}
installHandlerOnce(ctx)
}
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
methodChannel?.setMethodCallHandler(null)
methodChannel = null
}
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) {
"drainPending" -> handleDrain(result)
"ackCrash" -> handleAck(call, result)
"triggerTestCrash" -> handleTriggerTestCrash(result)
else -> result.notImplemented()
}
}
private fun handleTriggerTestCrash(result: MethodChannel.Result) {
result.success(null)
Thread({ throw RuntimeException("ux/crash triggerTestCrash") }, "ux-crash-test").start()
}
private fun handleDrain(result: MethodChannel.Result) {
val dir = crashDir
if (dir == null || !dir.isDirectory) return result.success(emptyList<Map<String, Any?>>())
val files = dir.listFiles { _, name -> name.endsWith(".json") }?.toList().orEmpty()
.sortedBy { it.lastModified() }
val out = ArrayList<Map<String, Any?>>(files.size)
for (f in files) {
val entry = try {
val json = JSONObject(f.readText(Charsets.UTF_8))
jsonToMap(json).toMutableMap().apply { put("id", f.nameWithoutExtension) }
} catch (_: Exception) {
f.delete()
continue
}
out.add(entry)
}
result.success(out)
}
private fun handleAck(call: MethodCall, result: MethodChannel.Result) {
val id = call.arguments as? String
?: return result.error("bad_args", "expected String id", null)
crashDir?.let { File(it, "$id.json").delete() }
result.success(null)
}
companion object {
private var installed = false
private var priorHandler: Thread.UncaughtExceptionHandler? = null
private var crashDir: File? = null
private fun installHandlerOnce(ctx: Context) {
if (installed) return
installed = true
crashDir = File(ctx.cacheDir, "ux_crashes").apply { mkdirs() }
priorHandler = Thread.getDefaultUncaughtExceptionHandler()
Thread.setDefaultUncaughtExceptionHandler(::uxCrashHandler)
}
private fun uxCrashHandler(thread: Thread, throwable: Throwable) {
val dir = crashDir
if (dir != null) {
runCatching {
val jsons = dir.listFiles { _, name -> name.endsWith(".json") }?.toList().orEmpty()
if (jsons.size >= 50) {
jsons.sortedBy { it.lastModified() }
.take(jsons.size - 49)
.forEach { it.delete() }
}
val sw = StringWriter()
throwable.printStackTrace(PrintWriter(sw))
val payload = JSONObject().apply {
put("platform", "android")
put("time", iso8601(Date()))
put("thread", thread.name)
put("type", throwable.javaClass.name)
put("message", throwable.message ?: "")
put("stack", sw.toString())
put("cause", throwable.cause?.toString() ?: "")
put("sdkInt", Build.VERSION.SDK_INT)
put("packageName", dir.parentFile?.parentFile?.name ?: "")
}
val tmp = File(dir, "${UUID.randomUUID()}.json.tmp")
tmp.writeText(payload.toString(), Charsets.UTF_8)
tmp.renameTo(File(dir, tmp.name.removeSuffix(".tmp")))
}
}
// Hand off to the prior handler so the platform's default crash
// behavior (process kill, ANR dialog, etc.) still runs.
priorHandler?.uncaughtException(thread, throwable)
}
private fun iso8601(d: Date): String {
val fmt = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US)
fmt.timeZone = TimeZone.getTimeZone("UTC")
return fmt.format(d)
}
private fun jsonToMap(o: JSONObject): Map<String, Any?> {
val out = LinkedHashMap<String, Any?>(o.length())
val it = o.keys()
while (it.hasNext()) {
val k = it.next()
out[k] = jsonValue(o.opt(k))
}
return out
}
private fun jsonValue(v: Any?): Any? = when (v) {
null, JSONObject.NULL -> null
is JSONObject -> jsonToMap(v)
is JSONArray -> {
val list = ArrayList<Any?>(v.length())
for (i in 0 until v.length()) list.add(jsonValue(v.opt(i)))
list
}
else -> v
}
}
}

View File

@@ -12,6 +12,7 @@ class UxPlugin : FlutterPlugin, ActivityAware {
ScannerPlugin(),
ClipboardPlugin(),
GalleryPlugin(),
CrashPlugin(),
)
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) =

View File

@@ -0,0 +1,142 @@
import Flutter
import Foundation
import UIKit
public class CrashPlugin: NSObject, NativePlugin {
private var channel: FlutterMethodChannel?
public func register(with registrar: FlutterPluginRegistrar) {
let c = FlutterMethodChannel(name: "ux/crash", binaryMessenger: registrar.messenger())
c.setMethodCallHandler { [weak self] call, result in
self?.handle(call, result: result)
}
channel = c
CrashPlugin.installHandlerOnce()
}
private func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
switch call.method {
case "drainPending": handleDrain(result: result)
case "ackCrash": handleAck(call: call, result: result)
case "triggerTestCrash": handleTriggerTestCrash(result: result)
default: result(FlutterMethodNotImplemented)
}
}
private func handleTriggerTestCrash(result: @escaping FlutterResult) {
// Acknowledge first so the Dart side doesn't sit on a pending result
// while the process winds down.
result(nil)
DispatchQueue.main.async {
NSException(
name: .invalidArgumentException,
reason: "ux/crash triggerTestCrash",
userInfo: nil
).raise()
}
}
private func handleDrain(result: @escaping FlutterResult) {
let dir = CrashPlugin.crashDirURL()
let fm = FileManager.default
guard let names = try? fm.contentsOfDirectory(atPath: dir.path) else {
return result([])
}
// Oldest first so the receiver re-emits in original order.
let sorted = names
.filter { $0.hasSuffix(".json") }
.sorted { lhs, rhs in
let l = (try? fm.attributesOfItem(atPath: dir.appendingPathComponent(lhs).path)[.modificationDate]) as? Date ?? .distantPast
let r = (try? fm.attributesOfItem(atPath: dir.appendingPathComponent(rhs).path)[.modificationDate]) as? Date ?? .distantPast
return l < r
}
var out: [[String: Any]] = []
for name in sorted {
let url = dir.appendingPathComponent(name)
guard let data = try? Data(contentsOf: url),
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
try? fm.removeItem(at: url)
continue
}
var entry = json
entry["id"] = (name as NSString).deletingPathExtension
out.append(entry)
}
result(out)
}
private func handleAck(call: FlutterMethodCall, result: @escaping FlutterResult) {
guard let id = call.arguments as? String else {
return result(FlutterError(code: "bad_args", message: "expected String id", details: nil))
}
let url = CrashPlugin.crashDirURL().appendingPathComponent("\(id).json")
try? FileManager.default.removeItem(at: url)
result(nil)
}
// MARK: - Handler install (process-global, idempotent)
private static var installed = false
fileprivate static var priorHandler: (@convention(c) (NSException) -> Void)?
private static func installHandlerOnce() {
guard !installed else { return }
installed = true
// Ensure cache dir exists eagerly so the C handler can assume it.
_ = try? FileManager.default.createDirectory(at: crashDirURL(), withIntermediateDirectories: true)
priorHandler = NSGetUncaughtExceptionHandler()
NSSetUncaughtExceptionHandler(uxCrashHandler)
}
fileprivate static func crashDirURL() -> URL {
let caches = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first
?? URL(fileURLWithPath: NSTemporaryDirectory())
return caches.appendingPathComponent("ux_crashes", isDirectory: true)
}
}
// MARK: - Free C handler
// Process is going down; this function must finish on the crashing thread.
// No async dispatch, no Swift captures.
private func uxCrashHandler(_ exception: NSException) {
let dir = CrashPlugin.crashDirURL()
let fm = FileManager.default
// Cap the directory at 50 files: drop oldest by mtime before writing.
if let names = try? fm.contentsOfDirectory(atPath: dir.path) {
let jsons = names.filter { $0.hasSuffix(".json") }
if jsons.count >= 50 {
let sorted = jsons.sorted { lhs, rhs in
let l = (try? fm.attributesOfItem(atPath: dir.appendingPathComponent(lhs).path)[.modificationDate]) as? Date ?? .distantPast
let r = (try? fm.attributesOfItem(atPath: dir.appendingPathComponent(rhs).path)[.modificationDate]) as? Date ?? .distantPast
return l < r
}
for old in sorted.prefix(jsons.count - 49) {
try? fm.removeItem(at: dir.appendingPathComponent(old))
}
}
} else {
_ = try? fm.createDirectory(at: dir, withIntermediateDirectories: true)
}
let iso = ISO8601DateFormatter()
iso.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
let payload: [String: Any] = [
"platform": "ios",
"time": iso.string(from: Date()),
"name": exception.name.rawValue,
"reason": exception.reason ?? "",
"userInfo": (exception.userInfo ?? [:]).description,
"callStackSymbols": exception.callStackSymbols,
"callStackReturnAddresses": exception.callStackReturnAddresses.map { $0.intValue },
"bundleId": Bundle.main.bundleIdentifier ?? "",
"systemVersion": UIDevice.current.systemVersion,
]
if let data = try? JSONSerialization.data(withJSONObject: payload, options: []) {
let url = dir.appendingPathComponent("\(UUID().uuidString).json")
try? data.write(to: url, options: .atomic)
}
CrashPlugin.priorHandler?(exception)
}

View File

@@ -12,6 +12,7 @@ public class UxPlugin: NSObject, FlutterPlugin {
ScannerPlugin(),
ClipboardPlugin(),
GalleryPlugin(),
CrashPlugin(),
]
for plugin in plugins {
plugin.register(with: registrar)

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);
}
}

View File

@@ -16,4 +16,6 @@ export 'src/auto_map.dart';
export 'src/scanner.dart';
export 'src/sensor.dart';
export 'src/functional.dart';
export 'src/log.dart';
export 'src/crash.dart';
export 'src/log.dart';
export 'src/log_http.dart';

100
test/crash_test.dart Normal file
View 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'));
});
}

View 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
View 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'});
});
});
}