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:
149
android/src/main/kotlin/io/swipelab/ux/CrashPlugin.kt
Normal file
149
android/src/main/kotlin/io/swipelab/ux/CrashPlugin.kt
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ class UxPlugin : FlutterPlugin, ActivityAware {
|
||||
ScannerPlugin(),
|
||||
ClipboardPlugin(),
|
||||
GalleryPlugin(),
|
||||
CrashPlugin(),
|
||||
)
|
||||
|
||||
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) =
|
||||
|
||||
142
ios/Classes/CrashPlugin.swift
Normal file
142
ios/Classes/CrashPlugin.swift
Normal 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)
|
||||
}
|
||||
@@ -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
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);
|
||||
}
|
||||
}
|
||||
@@ -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
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