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(),
|
ScannerPlugin(),
|
||||||
ClipboardPlugin(),
|
ClipboardPlugin(),
|
||||||
GalleryPlugin(),
|
GalleryPlugin(),
|
||||||
|
CrashPlugin(),
|
||||||
)
|
)
|
||||||
|
|
||||||
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) =
|
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(),
|
ScannerPlugin(),
|
||||||
ClipboardPlugin(),
|
ClipboardPlugin(),
|
||||||
GalleryPlugin(),
|
GalleryPlugin(),
|
||||||
|
CrashPlugin(),
|
||||||
]
|
]
|
||||||
for plugin in plugins {
|
for plugin in plugins {
|
||||||
plugin.register(with: registrar)
|
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/scanner.dart';
|
||||||
export 'src/sensor.dart';
|
export 'src/sensor.dart';
|
||||||
export 'src/functional.dart';
|
export 'src/functional.dart';
|
||||||
|
export 'src/crash.dart';
|
||||||
export 'src/log.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