0.8.0: pretty logger (Log) + crash capture
- Log: static entry + scoped Loggers (Log.tag), six levels, lazy messages,
structured fields, ANSI ConsoleSink, DeveloperSink, MemorySink, NoopSink.
Sinks compose via +; throwing sinks are isolated.
- Log.configure wires FlutterError/PlatformDispatcher/isolate errors through
Log.e by default; log-then-rethrow deduped via Expando.
- UxKeyboard: migrate kDebugMode print() to Log.tag('KB').d lazily.
- Depend on package:clock for testable timestamps.
This commit is contained in:
15
CHANGELOG.md
15
CHANGELOG.md
@@ -1,3 +1,18 @@
|
||||
### 0.8.0
|
||||
- `Log`: pretty, production-ready logger. Static entry (`Log.d/i/w/e/f/t`),
|
||||
scoped loggers via `Log.tag('KB')`, lazy messages (`Log.d(() => expensive)`),
|
||||
structured `fields`, ANSI-colored `ConsoleSink`, `DeveloperSink` for DevTools,
|
||||
`MemorySink` ring buffer (tests + breadcrumbs), `NoopSink`. Sinks fan out via
|
||||
`+` with per-sink `minLevel`; throwing sinks can't take down the pipeline.
|
||||
- `Log.configure`: one-call setup that also wires `FlutterError.onError`,
|
||||
`PlatformDispatcher.onError`, and isolate errors through `Log.e`. Pair of
|
||||
log-then-rethrow is deduped via an `Expando` mark so crash handlers don't
|
||||
double-report. Override the `captureCrashes` hook to customise or pass
|
||||
`() {}` to opt out.
|
||||
- `UxKeyboard`: adaptive-learning debug output now uses `Log.tag('KB').d`
|
||||
instead of `print`, lazy-built so the formatted line is only constructed
|
||||
when debug level is enabled.
|
||||
|
||||
### 0.7.0
|
||||
- `AutoMap<K, V>`: insertion-ordered collection with O(1) lookup by key
|
||||
**and** by index. Backed by a `List<V>` + `Map<K, int>` kept in sync by
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"info":"This is a generated file; do not edit or check into version control.","plugins":{"ios":[{"name":"ux","path":"/Users/agra/projects/ux/","native_build":true,"dependencies":[],"dev_dependency":false}],"android":[{"name":"ux","path":"/Users/agra/projects/ux/","native_build":true,"dependencies":[],"dev_dependency":false}],"macos":[],"linux":[],"windows":[],"web":[]},"dependencyGraph":[{"name":"ux","dependencies":[]}],"date_created":"2026-04-16 18:49:44.385595","version":"3.41.5","swift_package_manager_enabled":{"ios":false,"macos":false}}
|
||||
{"info":"This is a generated file; do not edit or check into version control.","plugins":{"ios":[{"name":"ux","path":"/Users/agra/projects/ux/","native_build":true,"dependencies":[],"dev_dependency":false}],"android":[{"name":"ux","path":"/Users/agra/projects/ux/","native_build":true,"dependencies":[],"dev_dependency":false}],"macos":[{"name":"ux","path":"/Users/agra/projects/ux/","native_build":true,"dependencies":[],"dev_dependency":false}],"linux":[],"windows":[],"web":[]},"dependencyGraph":[{"name":"ux","dependencies":[]}],"date_created":"2026-04-24 12:21:00.100805","version":"3.41.5","swift_package_manager_enabled":{"ios":false,"macos":false}}
|
||||
@@ -5,6 +5,8 @@
|
||||
import FlutterMacOS
|
||||
import Foundation
|
||||
|
||||
import ux
|
||||
|
||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||
UxPlugin.register(with: registry.registrar(forPlugin: "UxPlugin"))
|
||||
}
|
||||
|
||||
@@ -502,7 +502,7 @@ packages:
|
||||
path: ".."
|
||||
relative: true
|
||||
source: path
|
||||
version: "0.4.0"
|
||||
version: "0.7.0"
|
||||
vector_math:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
@@ -76,7 +76,7 @@ class AutoMap<K, V> extends Iterable<V> {
|
||||
}
|
||||
|
||||
/// Adds each item. Use `..clear()..addAll(xs)` for replace-all semantics.
|
||||
void addAll(Iterable<V> items) {
|
||||
void addAll(Iterable<V> items) {
|
||||
for (final v in items) {
|
||||
add(v);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,9 @@ import 'dart:math' as math;
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/scheduler.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:ux/src/log.dart';
|
||||
|
||||
final _kbLog = Log.tag('KB');
|
||||
|
||||
DynamicLibrary? _initLib() {
|
||||
if (Platform.isIOS) return DynamicLibrary.process();
|
||||
@@ -352,11 +355,12 @@ class UxKeyboard with ChangeNotifier {
|
||||
|
||||
_converged = maxError < _kAdaptThreshold;
|
||||
|
||||
if (kDebugMode) {
|
||||
print('[KB] adapt #$_learnCount headStart=${(_headStart * 1000).toStringAsFixed(1)}ms '
|
||||
_kbLog.d(
|
||||
() => 'adapt #$_learnCount '
|
||||
'headStart=${(_headStart * 1000).toStringAsFixed(1)}ms '
|
||||
'maxErr=${(maxError * 100).toStringAsFixed(1)}% '
|
||||
'${_converged ? "CONVERGED" : "learning"}');
|
||||
}
|
||||
'${_converged ? "CONVERGED" : "learning"}',
|
||||
);
|
||||
}
|
||||
|
||||
_obs.clear();
|
||||
|
||||
561
lib/src/log.dart
Normal file
561
lib/src/log.dart
Normal file
@@ -0,0 +1,561 @@
|
||||
/// Pretty, production-ready logger.
|
||||
///
|
||||
/// Static entry point [Log] for ad-hoc calls; [Log.tag] for scoped [Logger]s.
|
||||
/// Sinks fan out via `+`. Crash capture via [Log.installCrashHandlers].
|
||||
library;
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:developer' as developer;
|
||||
import 'dart:io' show stderr, stdout;
|
||||
import 'dart:isolate';
|
||||
|
||||
import 'package:clock/clock.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
/// Severity. Numeric values align with `package:logging` so DevTools filters
|
||||
/// map 1:1.
|
||||
enum LogLevel {
|
||||
/// Finest-grained tracing. Typically disabled in every build.
|
||||
trace(500, 'T'),
|
||||
|
||||
/// Developer diagnostics. Default minimum in debug builds.
|
||||
debug(700, 'D'),
|
||||
|
||||
/// Normal operational events.
|
||||
info(800, 'I'),
|
||||
|
||||
/// Something unexpected but recoverable. Default minimum in release.
|
||||
warn(900, 'W'),
|
||||
|
||||
/// A failure that the app kept running through.
|
||||
error(1000, 'E'),
|
||||
|
||||
/// A failure that the app may not recover from.
|
||||
fatal(1200, 'F');
|
||||
|
||||
const LogLevel(this.value, this.letter);
|
||||
|
||||
/// Numeric severity matching `package:logging` / `dart:developer`.
|
||||
final int value;
|
||||
|
||||
/// Single-character label used in console output.
|
||||
final String letter;
|
||||
}
|
||||
|
||||
/// A single log event passed to [LogSink.emit].
|
||||
class LogRecord {
|
||||
/// Creates a record. Callers are [Logger] / [Log]; most users never construct.
|
||||
LogRecord({
|
||||
required this.time,
|
||||
required this.level,
|
||||
required this.message,
|
||||
this.tag,
|
||||
this.error,
|
||||
this.stackTrace,
|
||||
this.fields,
|
||||
});
|
||||
|
||||
/// Wall-clock time at emit, taken from `package:clock`.
|
||||
final DateTime time;
|
||||
|
||||
/// Severity.
|
||||
final LogLevel level;
|
||||
|
||||
/// Rendered message text. Lazy thunks are resolved before construction.
|
||||
final String message;
|
||||
|
||||
/// Scope name from [Log.tag] / [Logger.tag], if any.
|
||||
final String? tag;
|
||||
|
||||
/// Associated error object, if one was passed.
|
||||
final Object? error;
|
||||
|
||||
/// Stack trace captured at the call site, if one was passed.
|
||||
final StackTrace? stackTrace;
|
||||
|
||||
/// Structured key/value pairs for this record.
|
||||
final Map<String, Object?>? fields;
|
||||
}
|
||||
|
||||
/// Destination for emitted records. Implementations must not throw; they
|
||||
/// swallow their own errors so one failing sink cannot take down the pipeline.
|
||||
abstract class LogSink {
|
||||
/// Subclass constructor.
|
||||
const LogSink();
|
||||
|
||||
/// Records below this level are dropped before [emit].
|
||||
LogLevel get minLevel;
|
||||
|
||||
/// Delivers [record]. Must not throw.
|
||||
void emit(LogRecord record);
|
||||
|
||||
/// Awaits any pending delivery. Override for buffered sinks.
|
||||
Future<void> flush() async {}
|
||||
|
||||
/// Fan out to `this` then `other`. Each sink applies its own [minLevel].
|
||||
LogSink operator +(LogSink other) {
|
||||
final left = this is _CompositeSink
|
||||
? (this as _CompositeSink)._sinks
|
||||
: [this];
|
||||
final right = other is _CompositeSink ? other._sinks : [other];
|
||||
return _CompositeSink([...left, ...right]);
|
||||
}
|
||||
}
|
||||
|
||||
class _CompositeSink extends LogSink {
|
||||
_CompositeSink(this._sinks);
|
||||
|
||||
final List<LogSink> _sinks;
|
||||
|
||||
@override
|
||||
LogLevel get minLevel => _sinks
|
||||
.map((s) => s.minLevel)
|
||||
.reduce((a, b) => a.value < b.value ? a : b);
|
||||
|
||||
@override
|
||||
void emit(LogRecord r) {
|
||||
for (final s in _sinks) {
|
||||
if (r.level.value < s.minLevel.value) continue;
|
||||
try {
|
||||
s.emit(r);
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> flush() async {
|
||||
for (final s in _sinks) {
|
||||
try {
|
||||
await s.flush();
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Scoped logger. Obtain via [Log.tag].
|
||||
class Logger {
|
||||
Logger._(this.name);
|
||||
|
||||
/// Tag prefix applied to every record from this logger. Empty for the root.
|
||||
final String name;
|
||||
|
||||
/// Returns a child logger whose [name] is `this.name.child`.
|
||||
Logger tag(String child) => Logger._(name.isEmpty ? child : '$name.$child');
|
||||
|
||||
/// Logs at [LogLevel.trace].
|
||||
void t(Object message, {Map<String, Object?>? fields}) =>
|
||||
_log(LogLevel.trace, message, fields: fields);
|
||||
|
||||
/// Logs at [LogLevel.debug].
|
||||
void d(Object message, {Map<String, Object?>? fields}) =>
|
||||
_log(LogLevel.debug, message, fields: fields);
|
||||
|
||||
/// Logs at [LogLevel.info].
|
||||
void i(Object message, {Map<String, Object?>? fields}) =>
|
||||
_log(LogLevel.info, message, fields: fields);
|
||||
|
||||
/// Logs at [LogLevel.warn].
|
||||
void w(
|
||||
Object message, {
|
||||
Map<String, Object?>? fields,
|
||||
Object? error,
|
||||
StackTrace? stackTrace,
|
||||
}) => _log(
|
||||
LogLevel.warn,
|
||||
message,
|
||||
fields: fields,
|
||||
error: error,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
|
||||
/// Logs at [LogLevel.error].
|
||||
void e(
|
||||
Object message, {
|
||||
Map<String, Object?>? fields,
|
||||
Object? error,
|
||||
StackTrace? stackTrace,
|
||||
}) => _log(
|
||||
LogLevel.error,
|
||||
message,
|
||||
fields: fields,
|
||||
error: error,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
|
||||
/// Logs at [LogLevel.fatal].
|
||||
void f(
|
||||
Object message, {
|
||||
Map<String, Object?>? fields,
|
||||
Object? error,
|
||||
StackTrace? stackTrace,
|
||||
}) => _log(
|
||||
LogLevel.fatal,
|
||||
message,
|
||||
fields: fields,
|
||||
error: error,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
|
||||
void _log(
|
||||
LogLevel level,
|
||||
Object message, {
|
||||
Map<String, Object?>? fields,
|
||||
Object? error,
|
||||
StackTrace? stackTrace,
|
||||
}) {
|
||||
if (level.value < Log._minLevel.value) return;
|
||||
final text = message is Function
|
||||
? '${(message as Function)()}'
|
||||
: '$message';
|
||||
Log._markReported(error);
|
||||
final record = LogRecord(
|
||||
time: clock.now(),
|
||||
level: level,
|
||||
message: text,
|
||||
tag: name.isEmpty ? null : name,
|
||||
error: error,
|
||||
stackTrace: stackTrace,
|
||||
fields: fields,
|
||||
);
|
||||
try {
|
||||
Log._sink.emit(record);
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Global entry point for logging.
|
||||
class Log {
|
||||
Log._();
|
||||
|
||||
static final Logger _root = Logger._('');
|
||||
static LogSink _sink = const NoopSink();
|
||||
static LogLevel _minLevel = kReleaseMode ? LogLevel.warn : LogLevel.debug;
|
||||
static final Expando<bool> _reported = Expando<bool>('ux.log.reported');
|
||||
|
||||
/// The lowest level that will reach any sink.
|
||||
static LogLevel get minLevel => _minLevel;
|
||||
|
||||
/// The sink currently receiving records (composite if `+` was used).
|
||||
static LogSink get sink => _sink;
|
||||
|
||||
/// Configures the global logger.
|
||||
///
|
||||
/// By default, invokes [captureCrashes] so uncaught Flutter / async /
|
||||
/// isolate errors flow through [e]. Pass a different hook (or `() {}` for
|
||||
/// no-op) to customise: test environments or apps that own
|
||||
/// `FlutterError.onError` should pass a no-op.
|
||||
static void configure({
|
||||
LogLevel? minLevel,
|
||||
LogSink? sink,
|
||||
VoidCallback captureCrashes = Log.captureCrashes,
|
||||
}) {
|
||||
if (minLevel != null) _minLevel = minLevel;
|
||||
if (sink != null) _sink = sink;
|
||||
captureCrashes();
|
||||
}
|
||||
|
||||
/// Returns a scoped [Logger] that tags every record with [name].
|
||||
static Logger tag(String name) => Logger._(name);
|
||||
|
||||
/// Logs at [LogLevel.trace].
|
||||
static void t(Object message, {Map<String, Object?>? fields}) =>
|
||||
_root.t(message, fields: fields);
|
||||
|
||||
/// Logs at [LogLevel.debug].
|
||||
static void d(Object message, {Map<String, Object?>? fields}) =>
|
||||
_root.d(message, fields: fields);
|
||||
|
||||
/// Logs at [LogLevel.info].
|
||||
static void i(Object message, {Map<String, Object?>? fields}) =>
|
||||
_root.i(message, fields: fields);
|
||||
|
||||
/// Logs at [LogLevel.warn].
|
||||
static void w(
|
||||
Object message, {
|
||||
Map<String, Object?>? fields,
|
||||
Object? error,
|
||||
StackTrace? stackTrace,
|
||||
}) => _root.w(
|
||||
message,
|
||||
fields: fields,
|
||||
error: error,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
|
||||
/// Logs at [LogLevel.error].
|
||||
static void e(
|
||||
Object message, {
|
||||
Map<String, Object?>? fields,
|
||||
Object? error,
|
||||
StackTrace? stackTrace,
|
||||
}) => _root.e(
|
||||
message,
|
||||
fields: fields,
|
||||
error: error,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
|
||||
/// Logs at [LogLevel.fatal].
|
||||
static void f(
|
||||
Object message, {
|
||||
Map<String, Object?>? fields,
|
||||
Object? error,
|
||||
StackTrace? stackTrace,
|
||||
}) => _root.f(
|
||||
message,
|
||||
fields: fields,
|
||||
error: error,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
|
||||
/// Awaits pending delivery on the active sink. Call before exiting the
|
||||
/// isolate or after logging a fatal error that may terminate the app.
|
||||
static Future<void> flush() => _sink.flush();
|
||||
|
||||
/// Routes [FlutterError.onError], [PlatformDispatcher]'s `onError`, and
|
||||
/// isolate errors through [e]. Invoked by [configure] by default.
|
||||
///
|
||||
/// Errors seen previously by [e]/[w]/[f] (caught, logged, rethrown) are
|
||||
/// suppressed here via an [Expando] mark, so `try {...} catch (err) { Log.e(
|
||||
/// ..., error: err); rethrow; }` does not double-report.
|
||||
static void captureCrashes() {
|
||||
FlutterError.onError = (details) {
|
||||
if (_isReported(details.exception)) return;
|
||||
e(
|
||||
details.exceptionAsString(),
|
||||
error: details.exception,
|
||||
stackTrace: details.stack,
|
||||
fields: {
|
||||
if (details.library != null) 'library': details.library,
|
||||
if (details.context != null) 'context': details.context.toString(),
|
||||
},
|
||||
);
|
||||
};
|
||||
PlatformDispatcher.instance.onError = (error, stack) {
|
||||
if (_isReported(error)) return true;
|
||||
e('uncaught async', error: error, stackTrace: stack);
|
||||
return true;
|
||||
};
|
||||
Isolate.current.addErrorListener(
|
||||
RawReceivePort((dynamic pair) {
|
||||
final list = pair as List;
|
||||
e(
|
||||
'isolate error: ${list[0]}',
|
||||
stackTrace: StackTrace.fromString('${list[1]}'),
|
||||
);
|
||||
}).sendPort,
|
||||
);
|
||||
}
|
||||
|
||||
static bool _canTag(Object? o) =>
|
||||
o != null && o is! num && o is! String && o is! bool;
|
||||
|
||||
static bool _isReported(Object? o) => _canTag(o) && _reported[o!] == true;
|
||||
|
||||
static void _markReported(Object? o) {
|
||||
if (_canTag(o)) _reported[o!] = true;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── sinks ────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Discards everything. Default until [Log.configure] is called.
|
||||
class NoopSink extends LogSink {
|
||||
/// Creates a sink that drops every record.
|
||||
const NoopSink();
|
||||
|
||||
@override
|
||||
LogLevel get minLevel => LogLevel.fatal;
|
||||
|
||||
@override
|
||||
void emit(LogRecord record) {}
|
||||
}
|
||||
|
||||
/// ANSI-colored single-line output to stderr (or a custom writer).
|
||||
class ConsoleSink extends LogSink {
|
||||
/// Creates a console sink. Set [color] explicitly for tests or non-tty
|
||||
/// writers; otherwise it's auto-detected from `stdout.supportsAnsiEscapes`.
|
||||
/// [stackFrames] caps how many stack lines are printed per record.
|
||||
ConsoleSink({
|
||||
LogLevel minLevel = LogLevel.trace,
|
||||
bool? color,
|
||||
this.stackFrames = 8,
|
||||
void Function(String line)? write,
|
||||
}) : _minLevel = minLevel,
|
||||
_color = color ?? _detectAnsi(),
|
||||
_write = write ?? _defaultConsoleWrite;
|
||||
|
||||
final LogLevel _minLevel;
|
||||
final bool _color;
|
||||
|
||||
/// Maximum stack-trace lines printed beneath an error. Extras are dropped.
|
||||
final int stackFrames;
|
||||
final void Function(String) _write;
|
||||
|
||||
@override
|
||||
LogLevel get minLevel => _minLevel;
|
||||
|
||||
@override
|
||||
void emit(LogRecord r) {
|
||||
_write(_formatRecord(r, _color, stackFrames));
|
||||
}
|
||||
}
|
||||
|
||||
/// Forwards to `dart:developer.log`. Silent in release on non-web (VM service
|
||||
/// not attached); active on web where it routes to the browser console.
|
||||
class DeveloperSink extends LogSink {
|
||||
/// Creates a sink that hands records to `dart:developer.log`.
|
||||
DeveloperSink({LogLevel minLevel = LogLevel.trace}) : _minLevel = minLevel;
|
||||
|
||||
final LogLevel _minLevel;
|
||||
|
||||
@override
|
||||
LogLevel get minLevel => _minLevel;
|
||||
|
||||
@override
|
||||
void emit(LogRecord r) {
|
||||
if (kReleaseMode && !kIsWeb) return;
|
||||
developer.log(
|
||||
r.message,
|
||||
time: r.time,
|
||||
level: r.level.value,
|
||||
name: r.tag ?? 'ux',
|
||||
error: r.error,
|
||||
stackTrace: r.stackTrace,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// In-memory ring buffer. Useful as breadcrumbs for a crash reporter or as a
|
||||
/// deterministic sink in tests.
|
||||
class MemorySink extends LogSink {
|
||||
/// Creates a ring buffer that keeps the most recent [capacity] records.
|
||||
MemorySink({this.capacity = 100, LogLevel minLevel = LogLevel.trace})
|
||||
: _minLevel = minLevel;
|
||||
|
||||
/// Maximum number of records kept. Older records are evicted FIFO.
|
||||
final int capacity;
|
||||
final LogLevel _minLevel;
|
||||
final List<LogRecord> _records = [];
|
||||
|
||||
@override
|
||||
LogLevel get minLevel => _minLevel;
|
||||
|
||||
@override
|
||||
void emit(LogRecord r) {
|
||||
_records.add(r);
|
||||
if (_records.length > capacity) _records.removeAt(0);
|
||||
}
|
||||
|
||||
/// Unmodifiable view of the current buffer, oldest first.
|
||||
List<LogRecord> snapshot() => List.unmodifiable(_records);
|
||||
|
||||
/// Drops all buffered records.
|
||||
void clear() => _records.clear();
|
||||
}
|
||||
|
||||
// ─── formatting ───────────────────────────────────────────────────────────
|
||||
|
||||
bool _detectAnsi() {
|
||||
if (kIsWeb) return false;
|
||||
try {
|
||||
return stdout.supportsAnsiEscapes;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
void _defaultConsoleWrite(String line) {
|
||||
if (kIsWeb) {
|
||||
developer.log(line, name: 'ux');
|
||||
return;
|
||||
}
|
||||
stderr.writeln(line);
|
||||
}
|
||||
|
||||
const _ansiReset = '\x1B[0m';
|
||||
const _ansiDim = '\x1B[2m';
|
||||
const _ansiBold = '\x1B[1m';
|
||||
|
||||
String _colored(String s, int code, bool on) =>
|
||||
on ? '\x1B[${code}m$s$_ansiReset' : s;
|
||||
|
||||
String _dim(String s, bool on) => on ? '$_ansiDim$s$_ansiReset' : s;
|
||||
|
||||
String _bold(String s, bool on) => on ? '$_ansiBold$s$_ansiReset' : s;
|
||||
|
||||
int _levelColor(LogLevel l) {
|
||||
switch (l) {
|
||||
case LogLevel.trace:
|
||||
return 90;
|
||||
case LogLevel.debug:
|
||||
return 36;
|
||||
case LogLevel.info:
|
||||
return 32;
|
||||
case LogLevel.warn:
|
||||
return 33;
|
||||
case LogLevel.error:
|
||||
return 31;
|
||||
case LogLevel.fatal:
|
||||
return 35;
|
||||
}
|
||||
}
|
||||
|
||||
String _formatTime(DateTime t) {
|
||||
String two(int n) => n.toString().padLeft(2, '0');
|
||||
String three(int n) => n.toString().padLeft(3, '0');
|
||||
return '${two(t.hour)}:${two(t.minute)}:${two(t.second)}'
|
||||
'.${three(t.millisecond)}';
|
||||
}
|
||||
|
||||
String _formatFields(Map<String, Object?> fields) {
|
||||
final buf = StringBuffer('{');
|
||||
var first = true;
|
||||
fields.forEach((k, v) {
|
||||
if (!first) buf.write(', ');
|
||||
buf
|
||||
..write(k)
|
||||
..write(': ')
|
||||
..write(v);
|
||||
first = false;
|
||||
});
|
||||
buf.write('}');
|
||||
return buf.toString();
|
||||
}
|
||||
|
||||
String _formatRecord(LogRecord r, bool color, int stackFrames) {
|
||||
final buf = StringBuffer()
|
||||
..write(_dim(_formatTime(r.time), color))
|
||||
..write(' ')
|
||||
..write(_colored(r.level.letter, _levelColor(r.level), color))
|
||||
..write(' ');
|
||||
if (r.tag != null) {
|
||||
buf
|
||||
..write(_bold(r.tag!, color))
|
||||
..write(' ');
|
||||
}
|
||||
buf.write(r.message);
|
||||
if (r.fields != null && r.fields!.isNotEmpty) {
|
||||
buf
|
||||
..write(' ')
|
||||
..write(_dim(_formatFields(r.fields!), color));
|
||||
}
|
||||
if (r.error != null) {
|
||||
buf
|
||||
..write('\n └─ ')
|
||||
..write(_colored('${r.error}', _levelColor(r.level), color));
|
||||
}
|
||||
if (r.stackTrace != null) {
|
||||
final frames = r.stackTrace!
|
||||
.toString()
|
||||
.split('\n')
|
||||
.where((l) => l.isNotEmpty)
|
||||
.take(stackFrames);
|
||||
for (final f in frames) {
|
||||
buf
|
||||
..write('\n ')
|
||||
..write(_dim(f, color));
|
||||
}
|
||||
}
|
||||
return buf.toString();
|
||||
}
|
||||
@@ -12,4 +12,5 @@ export 'src/file.dart';
|
||||
export 'src/keyboard.dart';
|
||||
export 'src/auto_map.dart';
|
||||
export 'src/sensor.dart';
|
||||
export 'src/functional.dart';
|
||||
export 'src/functional.dart';
|
||||
export 'src/log.dart';
|
||||
@@ -122,7 +122,7 @@ packages:
|
||||
source: hosted
|
||||
version: "2.0.4"
|
||||
clock:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: clock
|
||||
sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b
|
||||
|
||||
@@ -3,7 +3,7 @@ description: >-
|
||||
Flutter toolkit for fluid, native-feeling UIs. Includes frame-accurate
|
||||
keyboard height tracking via FFI with interactive dismiss, bezier utilities,
|
||||
and layout primitives.
|
||||
version: 0.7.0
|
||||
version: 0.8.0
|
||||
homepage: https://swipelab.co/ux.html
|
||||
repository: https://github.com/swipelab/ux
|
||||
issue_tracker: https://github.com/swipelab/ux/issues
|
||||
@@ -21,6 +21,7 @@ dependencies:
|
||||
sdk: flutter
|
||||
matcher: ^0.12.16
|
||||
build: ^2.4.0
|
||||
clock: ^1.1.1
|
||||
|
||||
dev_dependencies:
|
||||
flutter_lints: ^6.0.0
|
||||
|
||||
289
test/log_test.dart
Normal file
289
test/log_test.dart
Normal file
@@ -0,0 +1,289 @@
|
||||
import 'package:clock/clock.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:ux/ux.dart';
|
||||
|
||||
void _noop() {}
|
||||
|
||||
MemorySink _installMemorySink({LogLevel minLevel = LogLevel.trace}) {
|
||||
final sink = MemorySink();
|
||||
Log.configure(minLevel: minLevel, sink: sink, captureCrashes: _noop);
|
||||
return sink;
|
||||
}
|
||||
|
||||
void main() {
|
||||
group('LogLevel', () {
|
||||
test('numeric values align with package:logging', () {
|
||||
expect(LogLevel.trace.value, 500);
|
||||
expect(LogLevel.debug.value, 700);
|
||||
expect(LogLevel.info.value, 800);
|
||||
expect(LogLevel.warn.value, 900);
|
||||
expect(LogLevel.error.value, 1000);
|
||||
expect(LogLevel.fatal.value, 1200);
|
||||
});
|
||||
|
||||
test('letters are distinct and uppercase', () {
|
||||
final letters = LogLevel.values.map((l) => l.letter).toSet();
|
||||
expect(letters.length, LogLevel.values.length);
|
||||
for (final l in letters) {
|
||||
expect(l, l.toUpperCase());
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
group('MemorySink', () {
|
||||
test('records events in order', () {
|
||||
final sink = _installMemorySink();
|
||||
Log.d('one');
|
||||
Log.i('two');
|
||||
Log.w('three');
|
||||
expect(sink.snapshot().map((r) => r.message).toList(), [
|
||||
'one',
|
||||
'two',
|
||||
'three',
|
||||
]);
|
||||
expect(sink.snapshot().map((r) => r.level).toList(), [
|
||||
LogLevel.debug,
|
||||
LogLevel.info,
|
||||
LogLevel.warn,
|
||||
]);
|
||||
});
|
||||
|
||||
test('evicts oldest past capacity', () {
|
||||
final sink = MemorySink(capacity: 3);
|
||||
Log.configure(minLevel: LogLevel.trace, sink: sink);
|
||||
for (var i = 0; i < 5; i++) {
|
||||
Log.i('msg $i');
|
||||
}
|
||||
expect(sink.snapshot().map((r) => r.message).toList(), [
|
||||
'msg 2',
|
||||
'msg 3',
|
||||
'msg 4',
|
||||
]);
|
||||
});
|
||||
|
||||
test('snapshot is unmodifiable', () {
|
||||
final sink = _installMemorySink();
|
||||
Log.i('x');
|
||||
expect(() => sink.snapshot().clear(), throwsUnsupportedError);
|
||||
});
|
||||
|
||||
test('clear empties the buffer', () {
|
||||
final sink = _installMemorySink();
|
||||
Log.i('x');
|
||||
sink.clear();
|
||||
expect(sink.snapshot(), isEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
group('level filtering', () {
|
||||
test('records below Log.minLevel are dropped', () {
|
||||
final sink = _installMemorySink(minLevel: LogLevel.warn);
|
||||
Log.t('trace');
|
||||
Log.d('debug');
|
||||
Log.i('info');
|
||||
Log.w('warn');
|
||||
Log.e('error');
|
||||
expect(sink.snapshot().map((r) => r.level).toList(), [
|
||||
LogLevel.warn,
|
||||
LogLevel.error,
|
||||
]);
|
||||
});
|
||||
|
||||
test('per-sink minLevel filters independently', () {
|
||||
final verbose = MemorySink();
|
||||
final errorsOnly = MemorySink(minLevel: LogLevel.error);
|
||||
Log.configure(minLevel: LogLevel.trace, sink: verbose + errorsOnly);
|
||||
|
||||
Log.i('info');
|
||||
Log.e('error');
|
||||
|
||||
expect(verbose.snapshot().map((r) => r.message), ['info', 'error']);
|
||||
expect(errorsOnly.snapshot().map((r) => r.message), ['error']);
|
||||
});
|
||||
});
|
||||
|
||||
group('lazy messages', () {
|
||||
test('thunk is not invoked when filtered', () {
|
||||
_installMemorySink(minLevel: LogLevel.warn);
|
||||
var called = 0;
|
||||
Log.d(() {
|
||||
called++;
|
||||
return 'expensive';
|
||||
});
|
||||
expect(called, 0);
|
||||
});
|
||||
|
||||
test('thunk is invoked and rendered when enabled', () {
|
||||
final sink = _installMemorySink();
|
||||
var called = 0;
|
||||
Log.d(() {
|
||||
called++;
|
||||
return 'computed';
|
||||
});
|
||||
expect(called, 1);
|
||||
expect(sink.snapshot().single.message, 'computed');
|
||||
});
|
||||
});
|
||||
|
||||
group('tagging', () {
|
||||
test('Log.tag sets the record tag', () {
|
||||
final sink = _installMemorySink();
|
||||
Log.tag('KB').i('hello');
|
||||
expect(sink.snapshot().single.tag, 'KB');
|
||||
});
|
||||
|
||||
test('root logger emits with null tag', () {
|
||||
final sink = _installMemorySink();
|
||||
Log.i('rootless');
|
||||
expect(sink.snapshot().single.tag, isNull);
|
||||
});
|
||||
|
||||
test('nested tag joins with a dot', () {
|
||||
final sink = _installMemorySink();
|
||||
Log.tag('A').tag('B').i('x');
|
||||
expect(sink.snapshot().single.tag, 'A.B');
|
||||
});
|
||||
});
|
||||
|
||||
group('errors and stack traces', () {
|
||||
test('error and stack pass through to the record', () {
|
||||
final sink = _installMemorySink();
|
||||
final err = Exception('boom');
|
||||
final st = StackTrace.current;
|
||||
Log.e('fail', error: err, stackTrace: st);
|
||||
final r = sink.snapshot().single;
|
||||
expect(r.error, same(err));
|
||||
expect(r.stackTrace, same(st));
|
||||
});
|
||||
|
||||
test('fields pass through', () {
|
||||
final sink = _installMemorySink();
|
||||
Log.i('hit', fields: {'userId': 42, 'path': '/x'});
|
||||
expect(sink.snapshot().single.fields, {'userId': 42, 'path': '/x'});
|
||||
});
|
||||
});
|
||||
|
||||
group('dedupe via crash handler', () {
|
||||
test('FlutterError.onError skips an error already seen by Log.e', () {
|
||||
final prevFlutter = FlutterError.onError;
|
||||
addTearDown(() => FlutterError.onError = prevFlutter);
|
||||
|
||||
final sink = MemorySink();
|
||||
Log.configure(minLevel: LogLevel.trace, sink: sink);
|
||||
|
||||
final err = Exception('once');
|
||||
Log.e('caught', error: err);
|
||||
FlutterError.onError!(FlutterErrorDetails(exception: err));
|
||||
|
||||
expect(sink.snapshot().map((r) => r.message), ['caught']);
|
||||
});
|
||||
|
||||
test('FlutterError.onError still reports a fresh error', () {
|
||||
final prevFlutter = FlutterError.onError;
|
||||
addTearDown(() => FlutterError.onError = prevFlutter);
|
||||
|
||||
final sink = MemorySink();
|
||||
Log.configure(minLevel: LogLevel.trace, sink: sink);
|
||||
|
||||
FlutterError.onError!(FlutterErrorDetails(exception: Exception('new')));
|
||||
|
||||
expect(sink.snapshot().single.level, LogLevel.error);
|
||||
expect(sink.snapshot().single.error, isA<Exception>());
|
||||
});
|
||||
});
|
||||
|
||||
group('clock', () {
|
||||
test('record time comes from package:clock', () {
|
||||
final sink = _installMemorySink();
|
||||
final fixed = DateTime.utc(2026, 1, 2, 3, 4, 5, 678);
|
||||
withClock(Clock.fixed(fixed), () {
|
||||
Log.i('pinned');
|
||||
});
|
||||
expect(sink.snapshot().single.time, fixed);
|
||||
});
|
||||
});
|
||||
|
||||
group('composite sink', () {
|
||||
test('chained + flattens into a single composite', () {
|
||||
final a = MemorySink();
|
||||
final b = MemorySink();
|
||||
final c = MemorySink();
|
||||
Log.configure(minLevel: LogLevel.trace, sink: a + b + c);
|
||||
Log.i('fan');
|
||||
expect(a.snapshot().single.message, 'fan');
|
||||
expect(b.snapshot().single.message, 'fan');
|
||||
expect(c.snapshot().single.message, 'fan');
|
||||
});
|
||||
|
||||
test('a throwing sink does not stop others', () {
|
||||
final recorded = MemorySink();
|
||||
final bomb = _ThrowingSink();
|
||||
Log.configure(minLevel: LogLevel.trace, sink: bomb + recorded);
|
||||
Log.i('survive');
|
||||
expect(recorded.snapshot().single.message, 'survive');
|
||||
});
|
||||
});
|
||||
|
||||
group('ConsoleSink formatting', () {
|
||||
test('writes a single line with level letter and message', () {
|
||||
final lines = <String>[];
|
||||
Log.configure(
|
||||
minLevel: LogLevel.trace,
|
||||
sink: ConsoleSink(color: false, write: lines.add),
|
||||
);
|
||||
withClock(Clock.fixed(DateTime.utc(2026, 1, 1, 12, 34, 56, 789)), () {
|
||||
Log.tag('T').i('hello');
|
||||
});
|
||||
expect(lines.single, contains('12:34:56.789'));
|
||||
expect(lines.single, contains(' I '));
|
||||
expect(lines.single, contains('T hello'));
|
||||
});
|
||||
|
||||
test('renders fields inline', () {
|
||||
final lines = <String>[];
|
||||
Log.configure(
|
||||
minLevel: LogLevel.trace,
|
||||
sink: ConsoleSink(color: false, write: lines.add),
|
||||
);
|
||||
Log.i('hit', fields: {'n': 3});
|
||||
expect(lines.single, contains('{n: 3}'));
|
||||
});
|
||||
|
||||
test('renders error on a follow-up line', () {
|
||||
final lines = <String>[];
|
||||
Log.configure(
|
||||
minLevel: LogLevel.trace,
|
||||
sink: ConsoleSink(color: false, write: lines.add),
|
||||
);
|
||||
Log.e('oops', error: Exception('bad'));
|
||||
expect(lines.single.split('\n').length, greaterThan(1));
|
||||
expect(lines.single, contains('└─ Exception: bad'));
|
||||
});
|
||||
|
||||
test('caps stack frames', () {
|
||||
final lines = <String>[];
|
||||
Log.configure(
|
||||
minLevel: LogLevel.trace,
|
||||
sink: ConsoleSink(color: false, stackFrames: 2, write: lines.add),
|
||||
);
|
||||
final longStack = StackTrace.fromString(
|
||||
List.generate(10, (i) => '#$i frame$i').join('\n'),
|
||||
);
|
||||
Log.e('x', error: Exception('e'), stackTrace: longStack);
|
||||
final stackLines = lines.single
|
||||
.split('\n')
|
||||
.where((l) => l.contains('#'))
|
||||
.toList();
|
||||
expect(stackLines.length, 2);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
class _ThrowingSink extends LogSink {
|
||||
@override
|
||||
LogLevel get minLevel => LogLevel.trace;
|
||||
|
||||
@override
|
||||
void emit(LogRecord record) => throw StateError('no');
|
||||
}
|
||||
Reference in New Issue
Block a user