From 90af25c9cf5488cd47ec611028f3991cc2e2b5fa Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Mon, 24 Aug 2026 22:45:33 +0200 Subject: [PATCH 01/29] feat(llc)!: rebuild the logger as one any Stream SDK can write to MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The logger this replaces was unreachable: its `StreamLog` registry was never exported and its defaults dropped every record, so nothing in the repo had ever logged anything. `StreamLogger` is now the tagged handle you write with, matching what `Logger` means in `package:logging` and `package:logger`. It is const, so a component holds one as a field and a top-level function holds one in a file with no class — which pure injection cannot serve, and which is most of where a product logs from. Where records go is a `StreamLogHandler` an app installs once on `StreamLogger.handler`, resolved when a record is written rather than when the logger was built, so a logger created at class-load reaches whatever the app configures later. `StreamLogger.priority` sets the threshold in one line; `StreamLogFilter.prefix` holds one subsystem to a different one. `StreamLogger.detached` opts a component out of all of it, and `StreamLogger.reset` puts the defaults back for a test that installed something. A record is a `StreamLogRecord`, so fields can be added without breaking every handler. It is stamped once from `package:clock`: a composite reports one time for one record, and a test can pin it. It also carries the error and stack trace, which the previous interface accepted but nothing ever passed. `Priority` becomes `StreamLogPriority`, keeping its values and gaining `emoji` and `label`. The old name clashed with the one `package:flutter/scheduler.dart` exports, and was the only name in the logger without the prefix. Nothing is written until a handler is installed. Measured on that path: 4.7ns against a 2.0ns empty loop, with no heap growth over twenty million calls, because a message no handler wants is never built. BREAKING CHANGE: `StreamLogger` is the handle rather than the destination, and `Priority` is `StreamLogPriority`. `StreamLog`, `streamLog`, `TaggedLogger`, `IsLoggableValidator`, `Finder` and `FileStreamLogger` are gone; of those only the last three were exported. --- packages/stream_core/lib/src/logger.dart | 4 + .../lib/src/logger/impl/external_logger.dart | 27 -- .../lib/src/logger/impl/file_logger.dart | 313 ------------------ .../lib/src/logger/impl/tagged_logger.dart | 40 --- .../stream_core/lib/src/logger/logger.dart | 3 - .../lib/src/logger/stream_log.dart | 151 --------- .../lib/src/logger/stream_log_filter.dart | 80 +++++ .../lib/src/logger/stream_log_handler.dart | 164 +++++++++ .../lib/src/logger/stream_log_priority.dart | 52 +++ .../lib/src/logger/stream_log_record.dart | 54 +++ .../lib/src/logger/stream_logger.dart | 262 ++++++++++++--- packages/stream_core/test/helpers/logger.dart | 60 ++++ .../test/logger/stream_log_filter_test.dart | 85 +++++ .../test/logger/stream_log_handler_test.dart | 202 +++++++++++ .../test/logger/stream_log_priority_test.dart | 60 ++++ .../test/logger/stream_logger_test.dart | 300 +++++++++++++++++ 16 files changed, 1276 insertions(+), 581 deletions(-) delete mode 100644 packages/stream_core/lib/src/logger/impl/external_logger.dart delete mode 100644 packages/stream_core/lib/src/logger/impl/file_logger.dart delete mode 100644 packages/stream_core/lib/src/logger/impl/tagged_logger.dart delete mode 100644 packages/stream_core/lib/src/logger/logger.dart delete mode 100644 packages/stream_core/lib/src/logger/stream_log.dart create mode 100644 packages/stream_core/lib/src/logger/stream_log_filter.dart create mode 100644 packages/stream_core/lib/src/logger/stream_log_handler.dart create mode 100644 packages/stream_core/lib/src/logger/stream_log_priority.dart create mode 100644 packages/stream_core/lib/src/logger/stream_log_record.dart create mode 100644 packages/stream_core/test/helpers/logger.dart create mode 100644 packages/stream_core/test/logger/stream_log_filter_test.dart create mode 100644 packages/stream_core/test/logger/stream_log_handler_test.dart create mode 100644 packages/stream_core/test/logger/stream_log_priority_test.dart create mode 100644 packages/stream_core/test/logger/stream_logger_test.dart diff --git a/packages/stream_core/lib/src/logger.dart b/packages/stream_core/lib/src/logger.dart index 3b79d90a..bd57982f 100644 --- a/packages/stream_core/lib/src/logger.dart +++ b/packages/stream_core/lib/src/logger.dart @@ -1 +1,5 @@ +export 'logger/stream_log_filter.dart'; +export 'logger/stream_log_handler.dart'; +export 'logger/stream_log_priority.dart'; +export 'logger/stream_log_record.dart'; export 'logger/stream_logger.dart'; diff --git a/packages/stream_core/lib/src/logger/impl/external_logger.dart b/packages/stream_core/lib/src/logger/impl/external_logger.dart deleted file mode 100644 index 56a749f9..00000000 --- a/packages/stream_core/lib/src/logger/impl/external_logger.dart +++ /dev/null @@ -1,27 +0,0 @@ -import '../stream_logger.dart'; - -typedef ExternalFunction = - void Function( - Priority priority, - String tag, - MessageBuilder message, [ - Object? error, - StackTrace? stk, - ]); - -class ExternalStreamLogger extends StreamLogger { - const ExternalStreamLogger(this.external); - - final ExternalFunction external; - - @override - void log( - Priority priority, - String tag, - MessageBuilder message, [ - Object? error, - StackTrace? stk, - ]) { - return external.call(priority, tag, message, error, stk); - } -} diff --git a/packages/stream_core/lib/src/logger/impl/file_logger.dart b/packages/stream_core/lib/src/logger/impl/file_logger.dart deleted file mode 100644 index 880142e1..00000000 --- a/packages/stream_core/lib/src/logger/impl/file_logger.dart +++ /dev/null @@ -1,313 +0,0 @@ -import 'dart:async'; -import 'dart:io'; - -import 'package:collection/collection.dart'; -import 'package:intl/intl.dart'; - -import '../../utils/standard.dart'; -import '../stream_logger.dart'; - -const _tag = 'SV:FileLogger'; -const int _defaultSize = 12 * 1024 * 1024; - -const _shareableFilePrefix = 'stream_log_'; -const _internalFile0 = 'internal_0.txt'; -const _internalFile1 = 'internal_1.txt'; - -typedef FileLogSender = Future Function(File); - -final _timeFormat = DateFormat("yyyy-MM-dd HH:mm:ss''SSS"); -final _dateFormat = DateFormat('yyMMddHHmm_ss'); - -class FileStreamLogger extends StreamLogger { - FileStreamLogger( - this.config, { - this.sender, - this.console, - }); - - static final Finalizer _finalizer = Finalizer((ioSink) => ioSink.close()); - - final FileLogConfig config; - final FileLogSender? sender; - final StreamLogger? console; - - String get pathSeparator => Platform.pathSeparator; - - late final Directory _filesDir; - late final Directory _tempsDir; - late final File _file0; - late final File _file1; - - File? _currentFile; - IOSink? _currentIO; - - @override - Future log( - Priority priority, - String tag, - MessageBuilder message, [ - Object? error, - StackTrace? stk, - ]) async { - await _initIfNeeded(); - await _swapFiles(); - try { - _currentIO?.log(priority, tag, message, error, stk); - } catch (e, stk) { - _logE(() => '[log] failed: $e; $stk'); - } - } - - Future _initIfNeeded() async { - try { - if (_currentFile == null) { - _logD(() => '[initIfNeeded] no args'); - _filesDir = await config.filesDir; - _tempsDir = await config.tempsDir; - _file0 = File('${_filesDir.path}$pathSeparator$_internalFile0')..createSync(recursive: true); - _file1 = File('${_filesDir.path}$pathSeparator$_internalFile1')..createSync(recursive: true); - final File currentFile; - if (!_file0.existsSync() || !_file1.existsSync()) { - currentFile = _file0; - } else if (_file0.lastModifiedSync().isAfter(_file1.lastModifiedSync())) { - currentFile = _file0; - } else { - currentFile = _file1; - } - _currentFile = currentFile; - _currentIO = currentFile.openWrite(mode: FileMode.append).also((it) { - _finalizer.attach(this, it, detach: this); - }); - } - // ignore: empty_catches - } catch (e) {} - } - - Future _swapFiles() async { - try { - final curLen = _currentFile?.lengthSync() ?? 0; - final maxLogSize = config.maxLogSize; - if (curLen >= maxLogSize / 2) { - _logD(() => '[swapFiles] no args'); - final currentIO = _currentIO; - _currentIO = null; - await currentIO?.close(); - File currentFile; - if (_currentFile == _file0) { - currentFile = _file1; - } else { - currentFile = _file0; - } - currentFile - ..deleteSync() - ..createSync(recursive: true); - _currentFile = currentFile; - _currentIO = currentFile.openWrite(mode: FileMode.append).also((it) { - _finalizer.attach(this, it, detach: this); - }); - } - } catch (e, stk) { - _logE(() => '[swapFiles] failed: $e; $stk'); - } - } - - Future clear() async { - try { - _logD( - () => - '[clear] before; file0: ${_file0.lengthSync()}, ' - 'file1: ${_file1.lengthSync()}', - ); - final currentIO = _currentIO; - _currentIO = null; - await currentIO?.close(); - - _file0 - ..deleteSync() - ..createSync(recursive: true); - _file1 - ..deleteSync() - ..createSync(recursive: true); - - _currentFile = _file0; - _currentIO = _currentFile?.openWrite(mode: FileMode.append).also((it) { - _finalizer.attach(this, it, detach: this); - }); - _logV( - () => - '[clear] after; file0: ${_file0.lengthSync()}, ' - 'file1: ${_file1.lengthSync()}', - ); - } catch (e, stk) { - _logE(() => '[clear] failed: $e; $stk'); - rethrow; - } - } - - Future share() async { - _logD(() => '[share] no args'); - final sender = this.sender; - if (sender == null) { - _logW(() => '[share] rejected (sender is not provided)'); - throw const FileLoggerException('Sender is not provided'); - } - try { - final shareable = await prepareShareable(); - _logV(() => '[share] shareable: $shareable(${shareable.existsSync()})'); - return await sender.call(shareable); - } catch (e, stk) { - _logE(() => '[share] failed: $e; $stk'); - rethrow; - } - } - - Future prepareShareable() async { - final filename = - '$_shareableFilePrefix' - '${_dateFormat.format(DateTime.now())}.txt'; - final out = File('${_tempsDir.path}$pathSeparator$filename')..createSync(recursive: true); - _logD(() => '[prepareShareable] out: $out'); - - IOSink? writer; - try { - writer = out.openWrite(mode: FileMode.append); - writer.writeln(await _buildHeader()); - final filtered = [ - _file0, - _file1, - ].where((file) => file.existsSync()).sortedBy((file) => file.lastModifiedSync()); - for (final file in filtered) { - if (file.existsSync()) { - await writer.addStream(file.openRead()); - } - } - await writer.flush(); - } catch (e, stk) { - _logE(() => '[prepareShareable] failed: $e; $stk'); - } finally { - await writer?.close(); - } - return out; - } - - Future _buildHeader() async { - final buffer = StringBuffer(); - buffer - ..write('|=============================================================') - ..write('\n') - ..write('|Logs Collected: ') - ..write(_timeFormat.format(DateTime.now())) - ..write('\n') - ..write('|App Version: ') - ..write(await config.appVersion) - ..write('\n') - ..write('|Device Info: '); - - final deviceInfo = await config.deviceInfo; - if (deviceInfo is Map) { - buffer.write('\n'); - deviceInfo.forEach((key, value) { - buffer - ..write('| ') - ..write(key) - ..write(': ') - ..write(value) - ..write('\n'); - }); - } else { - buffer - ..write(deviceInfo) - ..write('\n'); - } - - buffer - ..write('|=============================================================') - ..write('\n') - ..write('|'); - - return buffer.toString(); - } - - void _logV(MessageBuilder message) { - console?.log(Priority.verbose, _tag, message); - } - - void _logD(MessageBuilder message) { - console?.log(Priority.debug, _tag, message); - } - - // ignore: unused_element - void _logI(MessageBuilder message) { - console?.log(Priority.info, _tag, message); - } - - void _logW(MessageBuilder message) { - console?.log(Priority.warning, _tag, message); - } - - void _logE(MessageBuilder message) { - console?.log(Priority.error, _tag, message); - } -} - -abstract class FileLogConfig { - int get maxLogSize => _defaultSize; - - Future get filesDir; - - Future get tempsDir; - - Future get appVersion; - - Future get deviceInfo; -} - -extension on IOSink { - void log( - Priority priority, - String tag, - MessageBuilder message, [ - Object? error, - StackTrace? stk, - ]) { - final formattedDateTime = _timeFormat.format(DateTime.now()); - final formattedPriority = priority.stringify(); - final formatterPrefix = '$formattedDateTime $formattedPriority [$tag]: '; - - write(formatterPrefix); - writeln(message()); - } -} - -extension on Priority { - String stringify() { - switch (this) { - case Priority.verbose: - return 'V'; - case Priority.debug: - return 'D'; - case Priority.info: - return 'I'; - case Priority.warning: - return 'W'; - case Priority.error: - return 'E'; - case Priority.none: - return 'X'; - } - } -} - -class FileLoggerException implements Exception { - const FileLoggerException([this.message]); - - final dynamic message; - - @override - String toString() { - final message = this.message; - if (message == null) return 'FileLoggerException'; - return 'FileLoggerException: $message'; - } -} diff --git a/packages/stream_core/lib/src/logger/impl/tagged_logger.dart b/packages/stream_core/lib/src/logger/impl/tagged_logger.dart deleted file mode 100644 index 5b9bb926..00000000 --- a/packages/stream_core/lib/src/logger/impl/tagged_logger.dart +++ /dev/null @@ -1,40 +0,0 @@ -import '../stream_log.dart'; -import '../stream_logger.dart'; - -TaggedLogger taggedLogger({required Tag tag}) { - return TaggedLogger(tag); -} - -class TaggedLogger { - const TaggedLogger(this.tag); - - final Tag tag; - - void v(MessageBuilder message) { - streamLog.v(tag, message); - } - - void d(MessageBuilder message) { - streamLog.d(tag, message); - } - - void i(MessageBuilder message) { - streamLog.i(tag, message); - } - - void w(MessageBuilder message) { - streamLog.w(tag, message); - } - - void e(MessageBuilder message) { - streamLog.e(tag, message); - } - - void log(Priority priority, MessageBuilder message) { - streamLog.log(priority, tag, message); - } - - void logConditional(String? Function(Priority priority) messageBuilder) { - streamLog.logConditional(tag, messageBuilder); - } -} diff --git a/packages/stream_core/lib/src/logger/logger.dart b/packages/stream_core/lib/src/logger/logger.dart deleted file mode 100644 index 3f614f87..00000000 --- a/packages/stream_core/lib/src/logger/logger.dart +++ /dev/null @@ -1,3 +0,0 @@ -export 'impl/tagged_logger.dart'; -export 'stream_log.dart'; -export 'stream_logger.dart'; diff --git a/packages/stream_core/lib/src/logger/stream_log.dart b/packages/stream_core/lib/src/logger/stream_log.dart deleted file mode 100644 index f4709d12..00000000 --- a/packages/stream_core/lib/src/logger/stream_log.dart +++ /dev/null @@ -1,151 +0,0 @@ -// ignore_for_file: omit_obvious_property_types - -import 'stream_logger.dart'; - -StreamLog get streamLog => StreamLog(); - -class StreamLog { - factory StreamLog() { - return _instance; - } - - StreamLog._(); - - static final _instance = StreamLog._(); - - StreamLogger _logger = const SilentStreamLogger(); - IsLoggableValidator _validator = (Priority priority, Tag tag) => false; - Finder _finder = _defaultFinder; - Priority _priority = Priority.none; - - static StreamLog get instance => _instance; - static List excludeTags = []; - static List includeOnlyTags = []; - - set logger(StreamLogger logger) { - _logger = logger; - } - - set priority(Priority priority) { - _priority = priority; - _validator = (logPriority, tag) { - if (excludeTags.isNotEmpty && excludeTags.contains(tag)) { - return false; - } - - if (includeOnlyTags.isNotEmpty && !includeOnlyTags.contains(tag)) { - return false; - } - - return logPriority.index >= priority.index; - }; - } - - set validator(IsLoggableValidator validator) { - _validator = validator; - } - - set finder(Finder finder) { - _finder = finder; - } - - T? find([dynamic criteria]) { - return _finder.call(criteria); - } - - void v(Tag tag, MessageBuilder message) { - if (_validator.call(Priority.verbose, tag)) { - _logger.log(Priority.verbose, tag, message); - } - } - - void d(Tag tag, MessageBuilder message) { - if (_validator.call(Priority.debug, tag)) { - _logger.log(Priority.debug, tag, message); - } - } - - void i(Tag tag, MessageBuilder message) { - if (_validator.call(Priority.info, tag)) { - _logger.log(Priority.info, tag, message); - } - } - - void w(Tag tag, MessageBuilder message) { - if (_validator.call(Priority.warning, tag)) { - _logger.log(Priority.warning, tag, message); - } - } - - void e(Tag tag, MessageBuilder message) { - if (_validator.call(Priority.error, tag)) { - _logger.log(Priority.error, tag, message); - } - } - - void log(Priority priority, Tag tag, MessageBuilder message) { - if (_validator.call(priority, tag)) { - _logger.log(priority, tag, message); - } - } - - void logConditional( - Tag tag, - String? Function(Priority priority) messageBuilder, - ) { - final message = messageBuilder(_priority); - if (message != null && message.isNotEmpty) { - _logger.log( - _priority, - tag, - () => message, - ); - } - } - - static T? _defaultFinder([dynamic criteria]) { - final logger = _instance._logger; - if (logger is T) return logger; - - if (logger is CompositeStreamLogger) { - for (final child in logger.children) { - if (child is T) return child; - } - } - return null; - } -} - -class SilentStreamLogger extends StreamLogger { - const SilentStreamLogger(); - - @override - void log( - Priority priority, - String tag, - MessageBuilder message, [ - Object? error, - StackTrace? stk, - ]) { - /* no-op */ - } -} - -class CompositeStreamLogger extends StreamLogger { - const CompositeStreamLogger(this.children); - - final List children; - - @override - void log( - Priority priority, - String tag, - MessageBuilder message, [ - Object? error, - StackTrace? stk, - ]) { - for (final child in children) { - child.log(priority, tag, message, error, stk); - } - } -} diff --git a/packages/stream_core/lib/src/logger/stream_log_filter.dart b/packages/stream_core/lib/src/logger/stream_log_filter.dart new file mode 100644 index 00000000..51240eb1 --- /dev/null +++ b/packages/stream_core/lib/src/logger/stream_log_filter.dart @@ -0,0 +1,80 @@ +import 'stream_log_priority.dart'; + +/// Decides which records are worth building, independently of where they end up. +/// +/// A filter answers the question a handler cannot answer cheaply: whether a record is wanted at +/// all. It is consulted before the message is built, so a record it rejects costs nothing. +/// +/// The default admits everything and leaves the decision to the handler, which is enough until an +/// app wants one subsystem louder than the rest: +/// +/// ```dart +/// StreamLogger.filter = const StreamLogFilter.prefix( +/// {'SC:Ws': StreamLogPriority.verbose}, +/// otherwise: StreamLogPriority.warning, +/// ); +/// ``` +abstract class StreamLogFilter { + /// Creates a [StreamLogFilter]. + const StreamLogFilter(); + + /// A filter admitting every record, leaving the decision to the handler. + const factory StreamLogFilter.always() = _AlwaysFilter; + + /// A filter admitting records at [priority] or above, whatever their tag. + const factory StreamLogFilter.minPriority(StreamLogPriority priority) = _MinPriorityFilter; + + /// A filter admitting records by the prefix of their tag. + /// + /// The longest prefix in [priorities] matching a tag decides it, so a broad rule can be narrowed by + /// a longer one. A tag matching no prefix is held to [otherwise]. + /// + /// What a record costs grows with the number of rules, so consider keeping [priorities] to the + /// subsystems actually being tuned. + const factory StreamLogFilter.prefix( + Map priorities, { + StreamLogPriority otherwise, + }) = _PrefixFilter; + + /// Whether a record at [priority] from [tag] is worth building. + bool isLoggable(StreamLogPriority priority, String tag); +} + +final class _AlwaysFilter extends StreamLogFilter { + const _AlwaysFilter(); + + @override + bool isLoggable(StreamLogPriority priority, String tag) => true; +} + +final class _MinPriorityFilter extends StreamLogFilter { + const _MinPriorityFilter(this.priority); + + final StreamLogPriority priority; + + @override + bool isLoggable(StreamLogPriority priority, String tag) => priority >= this.priority; +} + +final class _PrefixFilter extends StreamLogFilter { + const _PrefixFilter(this.priorities, {this.otherwise = StreamLogPriority.warning}); + + final Map priorities; + final StreamLogPriority otherwise; + + @override + bool isLoggable(StreamLogPriority priority, String tag) { + var matched = otherwise; + var matchedLength = -1; + + for (final MapEntry(key: prefix, value: threshold) in priorities.entries) { + if (prefix.length <= matchedLength) continue; + if (!tag.startsWith(prefix)) continue; + + matched = threshold; + matchedLength = prefix.length; + } + + return priority >= matched; + } +} diff --git a/packages/stream_core/lib/src/logger/stream_log_handler.dart b/packages/stream_core/lib/src/logger/stream_log_handler.dart new file mode 100644 index 00000000..a4cac4d7 --- /dev/null +++ b/packages/stream_core/lib/src/logger/stream_log_handler.dart @@ -0,0 +1,164 @@ +import 'stream_log_priority.dart'; +import 'stream_log_record.dart'; + +/// Receives a log record on behalf of a [StreamLogHandler.from] handler. +typedef StreamLogCallback = void Function(StreamLogRecord record); + +/// Where log records go. +/// +/// An app installs one on [StreamLogger.handler], or passes one to a single component. Nothing is +/// installed by default, so an SDK stays silent until asked. +/// +/// [StreamLogHandler.console] covers the common case. Subclass to send records somewhere else, +/// such as a crash reporter: +/// +/// ```dart +/// final class CrashReporterHandler extends StreamLogHandler { +/// const CrashReporterHandler(); +/// +/// @override +/// bool isLoggable(StreamLogPriority priority, String tag) => priority >= StreamLogPriority.warning; +/// +/// @override +/// void handle(StreamLogRecord record) => Crashlytics.instance.log('$record'); +/// } +/// ``` +abstract class StreamLogHandler { + /// Creates a [StreamLogHandler]. + const StreamLogHandler(); + + /// A handler writing records to the console. + /// + /// Reaches the console of whatever runs the SDK — stdout under the Dart VM, the device log + /// under Flutter, the browser console on the web — without depending on Flutter. + /// + /// Android discards console output that arrives in a burst of hundreds of lines. A connection + /// reporting itself comes nowhere near that, but a product logging heavily can, so consider + /// handing records to `debugPrint`, which paces them to stay under the limit: + /// + /// ```dart + /// StreamLogger.handler = StreamLogHandler.from((record) => debugPrint('$record')); + /// ``` + /// + /// Emits whatever [StreamLogger.priority] admits. Pass [minPriority] to hold this handler + /// quieter than the rest, which is the only direction a handler can move it. + const factory StreamLogHandler.console({StreamLogPriority? minPriority}) = _ConsoleHandler; + + /// A handler giving every record to each of [handlers], in order. + /// + /// Each decides for itself what to keep, so one composite can serve a verbose console during + /// development and a crash reporter that only wants failures. + const factory StreamLogHandler.composite(List handlers) = _CompositeHandler; + + /// A handler passing every record to [callback]. + /// + /// The shortest route into a logging facility an app already has. + const factory StreamLogHandler.from(StreamLogCallback callback) = _CallbackHandler; + + /// A handler giving records to [handler] only in a build that runs assertions. + /// + /// A Flutter debug build runs them; release and profile builds do not, and neither does a + /// plain `dart run`. Keeps a console for whoever is developing without leaving one in the + /// build a user runs: + /// + /// ```dart + /// StreamLogger.handler = const StreamLogHandler.debugOnly(StreamLogHandler.console()); + /// ``` + /// + /// Consider wrapping only what writes somewhere a user could see, and leaving a crash reporter + /// to receive records in every build. + const factory StreamLogHandler.debugOnly(StreamLogHandler handler) = _DebugOnlyHandler; + + /// A handler that discards every record. + /// + /// What [StreamLogger.handler] is until an app installs something else. + static const StreamLogHandler silent = _SilentHandler(); + + /// Whether this handler wants a record at [priority] from [tag]. + /// + /// Consulted before the record is built, so a handler that gates here never pays for a message + /// it would discard. Defaults to accepting everything `StreamLogger.priority` already admitted — + /// a handler narrows what reaches it, and cannot widen it. + bool isLoggable(StreamLogPriority priority, String tag) => true; + + /// Takes a record this handler has accepted. + void handle(StreamLogRecord record); +} + +final class _SilentHandler extends StreamLogHandler { + const _SilentHandler(); + + @override + bool isLoggable(StreamLogPriority priority, String tag) => false; + + @override + void handle(StreamLogRecord record) { + /* no-op */ + } +} + +final class _ConsoleHandler extends StreamLogHandler { + const _ConsoleHandler({this.minPriority}); + + final StreamLogPriority? minPriority; + + @override + bool isLoggable(StreamLogPriority priority, String tag) => minPriority == null || priority >= minPriority!; + + @override + void handle(StreamLogRecord record) { + print('${record.time} $record'); + if (record.error case final error?) print(error); + if (record.stackTrace case final stackTrace?) print(stackTrace); + } +} + +final class _CompositeHandler extends StreamLogHandler { + const _CompositeHandler(this.handlers); + + final List handlers; + + @override + bool isLoggable(StreamLogPriority priority, String tag) { + return handlers.any((it) => it.isLoggable(priority, tag)); + } + + @override + void handle(StreamLogRecord record) { + for (final handler in handlers) { + // Asked again, because the record only had to interest one of them to be built. + if (handler.isLoggable(record.priority, record.tag)) handler.handle(record); + } + } +} + +final class _DebugOnlyHandler extends StreamLogHandler { + const _DebugOnlyHandler(this.handler); + + final StreamLogHandler handler; + + // The one thing a release build can be asked about itself without depending on Flutter: an + // assertion that runs is a build that kept them. + static bool get _assertionsEnabled { + var enabled = false; + assert(enabled = true); + return enabled; + } + + @override + bool isLoggable(StreamLogPriority priority, String tag) { + return _assertionsEnabled && handler.isLoggable(priority, tag); + } + + @override + void handle(StreamLogRecord record) => handler.handle(record); +} + +final class _CallbackHandler extends StreamLogHandler { + const _CallbackHandler(this.callback); + + final StreamLogCallback callback; + + @override + void handle(StreamLogRecord record) => callback(record); +} diff --git a/packages/stream_core/lib/src/logger/stream_log_priority.dart b/packages/stream_core/lib/src/logger/stream_log_priority.dart new file mode 100644 index 00000000..5eca0517 --- /dev/null +++ b/packages/stream_core/lib/src/logger/stream_log_priority.dart @@ -0,0 +1,52 @@ +/// The severity of a log record. +/// +/// Ordered from least to most severe, so a threshold can be expressed by comparing a record's +/// priority against it. [none] outranks every real severity, and so admits nothing. +enum StreamLogPriority implements Comparable { + /// Fine-grained detail on a hot path, such as an individual heartbeat. + verbose(level: 2, emoji: '🔍', label: 'V'), + + /// The steps a subsystem takes, such as a connection changing state. + debug(level: 3, emoji: '🔧', label: 'D'), + + /// A milestone worth seeing without opting into the full trace. + info(level: 4, emoji: 'â„šī¸', label: 'I'), + + /// Something recoverable that the caller may still want to act on. + warning(level: 5, emoji: 'âš ī¸', label: 'W'), + + /// A failure. + error(level: 6, emoji: '🚨', label: 'E'), + + /// No severity, used as a threshold that admits nothing. + none(level: 7, emoji: 'đŸ“Ŗ', label: '*'); + + const StreamLogPriority({required this.level, required this.emoji, required this.label}); + + /// The rank of this priority, where a higher number is more severe. + final int level; + + /// A glyph identifying this priority at a glance, for handlers that render one. + final String emoji; + + /// A single-letter abbreviation of this priority, for handlers that render one. + final String label; + + @override + String toString() => name; + + @override + int compareTo(StreamLogPriority other) => level.compareTo(other.level); + + /// Whether this priority is less severe than [other]. + bool operator <(StreamLogPriority other) => level < other.level; + + /// Whether this priority is no more severe than [other]. + bool operator <=(StreamLogPriority other) => level <= other.level; + + /// Whether this priority is more severe than [other]. + bool operator >(StreamLogPriority other) => level > other.level; + + /// Whether this priority is at least as severe as [other]. + bool operator >=(StreamLogPriority other) => level >= other.level; +} diff --git a/packages/stream_core/lib/src/logger/stream_log_record.dart b/packages/stream_core/lib/src/logger/stream_log_record.dart new file mode 100644 index 00000000..68667b4a --- /dev/null +++ b/packages/stream_core/lib/src/logger/stream_log_record.dart @@ -0,0 +1,54 @@ +import 'package:clock/clock.dart'; + +import 'stream_log_priority.dart'; + +/// A single log record, as a handler receives it. +/// +/// Built only once a record has passed every gate, so nothing here is paid for by a record that +/// is filtered out. +/// +/// Fields may be added over time. Consider implementing `StreamLogHandler` rather than depending +/// on this constructor, which is called by the SDK and not by handlers. +final class StreamLogRecord { + /// Creates a [StreamLogRecord], stamping it with the current [time] and the next + /// [sequenceNumber]. + StreamLogRecord({ + required this.priority, + required this.tag, + required this.message, + this.error, + this.stackTrace, + }) : time = clock.now(), + sequenceNumber = _sequence++; + + static var _sequence = 0; + + /// The severity of this record. + final StreamLogPriority priority; + + /// The component this record came from. + final String tag; + + /// What happened. + final String message; + + /// When this record was created. + /// + /// The same instant for every handler that receives it, so one record does not turn up at two + /// slightly different times in two destinations. + final DateTime time; + + /// The position of this record in the order they were created, counting from zero. + /// + /// Records reaching a handler out of order, or with a gap, were reordered or dropped on the way. + final int sequenceNumber; + + /// The cause, when this record describes a failure. + final Object? error; + + /// Where the [error] was thrown, when it is known. + final StackTrace? stackTrace; + + @override + String toString() => '${priority.emoji} ${priority.label}/$tag: $message'; +} diff --git a/packages/stream_core/lib/src/logger/stream_logger.dart b/packages/stream_core/lib/src/logger/stream_logger.dart index e0c9f4fb..8afb0850 100644 --- a/packages/stream_core/lib/src/logger/stream_logger.dart +++ b/packages/stream_core/lib/src/logger/stream_logger.dart @@ -1,63 +1,231 @@ -final _priorityEmojiMapper = { - Priority.error: '🚨', - Priority.warning: 'âš ī¸', - Priority.info: 'â„šī¸', - Priority.debug: '🔧', - Priority.verbose: '🔍', -}; +import 'package:meta/meta.dart'; -final _priorityNameMapper = { - Priority.error: 'E', - Priority.warning: 'W', - Priority.info: 'I', - Priority.debug: 'D', - Priority.verbose: 'V', -}; +import 'stream_log_filter.dart'; +import 'stream_log_handler.dart'; +import 'stream_log_priority.dart'; +import 'stream_log_record.dart'; -abstract class StreamLogger { - const StreamLogger(); +/// Builds a log message on demand. +/// +/// Called only once a record is known to be wanted, so an interpolation this expensive is never +/// paid for by a record that is dropped. +typedef StreamLogMessage = String Function(); - String emoji(Priority priority) => _priorityEmojiMapper[priority] ?? 'đŸ“Ŗ'; +/// Writes log records under a tag. +/// +/// Holding one costs nothing and it can be created anywhere — a field, a constructor, or a +/// top-level `final` in a file with no class at all: +/// +/// ```dart +/// final _log = StreamLogger('SF:SdpEditor'); +/// +/// String editSdp(String sdp) { +/// _log.d(() => 'rewriting $sdp'); +/// ... +/// } +/// ``` +/// +/// Where records go is resolved when a record is written, not when the logger is created, so a +/// logger built at class-load picks up whatever the app installs later: +/// +/// ```dart +/// StreamLogger.handler = const StreamLogHandler.console(); +/// StreamLogger.priority = StreamLogPriority.debug; +/// ``` +/// +/// Records go to one place, so routing two SDKs apart is a matter of a [StreamLogHandler] reading +/// [StreamLogRecord.tag] rather than of finding every component that had to be handed something. +/// For the exception, see [StreamLogger.detached]. +final class StreamLogger { + /// Creates a [StreamLogger] writing under [tag] to whatever the app has installed. + const StreamLogger(this.tag) : _handler = null, _filter = null; - String name(Priority priority) => _priorityNameMapper[priority] ?? '*'; + /// Creates a [StreamLogger] that ignores what the app has installed. + /// + /// Records go to the given handler and are gated by the given filter alone, so a detached logger + /// neither reads nor disturbs [StreamLogger.handler]. Use one to capture a component's records in + /// a test, or to hold a subsystem to its own threshold and destination: + /// + /// ```dart + /// final _log = StreamLogger.detached( + /// 'SF:Upload', + /// handler: const StreamLogHandler.console(), + /// filter: const StreamLogFilter.minPriority(StreamLogPriority.debug), + /// ); + /// ``` + /// + /// A priority of its own is a [StreamLogFilter.minPriority], which is why there is no separate one. + /// [filter] defaults to the same threshold [StreamLogger.priority] starts at, so detaching a logger + /// changes where its records go without also changing how many there are. Pass + /// [StreamLogFilter.always] to leave the decision entirely to the handler. + const StreamLogger.detached( + this.tag, { + required StreamLogHandler this._handler, + StreamLogFilter this._filter = const .minPriority(.warning), + }); - void log( - Priority priority, - String tag, - MessageBuilder message, [ - Object? error, - StackTrace? stk, - ]); -} + // Null means the ambient one, read when a record is written rather than when this was built. + final StreamLogHandler? _handler; + final StreamLogFilter? _filter; + + /// The name records from this logger carry. + /// + /// Conventionally an SDK prefix and a component, such as `SC:WsClient`, so records from several + /// Stream SDKs stay apart in one log and a prefix can select a subsystem. + /// + /// See also: + /// + /// * [StreamLogFilter.prefix], which turns this convention into a threshold per subsystem. + final String tag; -typedef MessageBuilder = String Function(); -typedef Tag = String; -typedef IsLoggableValidator = bool Function(Priority, Tag); -typedef Finder = T? Function([dynamic criteria]); + static StreamLogHandler _handlerOrDefault = StreamLogHandler.silent; + static StreamLogFilter _filterOrDefault = const .minPriority(.warning); -enum Priority implements Comparable { - verbose(level: 2), - debug(level: 3), - info(level: 4), - warning(level: 5), - error(level: 6), - none(level: 7); + /// Installs where every record goes, other than those from a [StreamLogger.detached] logger. + /// + /// Everything is discarded until this is set, so an SDK is silent in an app that has not asked + /// for records. Setting it applies to loggers that already exist, including any built at + /// class-load, because a logger resolves this when it writes rather than when it was created. + /// + /// ```dart + /// StreamLogger.handler = const StreamLogHandler.console(); + /// ``` + /// + /// Write-only, so nothing can come to depend on what happens to be installed. Consider + /// [StreamLogHandler.composite] to send records to more than one place. + static set handler(StreamLogHandler handler) => _handlerOrDefault = handler; - const Priority({required this.level}); + /// Installs the lowest priority worth building a record for. + /// + /// Defaults to [StreamLogPriority.warning], so an app that installs a handler and nothing else + /// hears about failures and not the running commentary: + /// + /// ```dart + /// StreamLogger.priority = StreamLogPriority.debug; + /// ``` + /// + /// Shorthand for a [StreamLogFilter.minPriority], so this and [filter] are one setting: whichever + /// is written last decides. + static set priority(StreamLogPriority priority) => _filterOrDefault = .minPriority(priority); - final int level; + /// Installs which records are built at all, for a rule [priority] cannot express. + /// + /// ```dart + /// StreamLogger.filter = const StreamLogFilter.prefix( + /// {'SC:Ws': StreamLogPriority.verbose}, + /// otherwise: StreamLogPriority.warning, + /// ); + /// ``` + static set filter(StreamLogFilter filter) => _filterOrDefault = filter; - @override - String toString() => name; + /// Puts [handler] and [priority] back to what they were before anything was installed. + /// + /// What an app installs is process-wide, so a test that installs a handler and leaves it there + /// changes what every later test sees. Restoring by hand means naming the defaults, which a + /// write-only setter gives no way to read: + /// + /// ```dart + /// tearDown(StreamLogger.reset); + /// ``` + @visibleForTesting + static void reset() { + _handlerOrDefault = StreamLogHandler.silent; + _filterOrDefault = const .minPriority(.warning); + } - @override - int compareTo(Priority other) => level.compareTo(other.level); + /// Whether a record at [priority] would be kept by both the filter and the handler. + /// + /// Records are already gated, so this is only worth calling to guard a message that is + /// expensive to build beyond its interpolation: + /// + /// ```dart + /// if (_log.isLoggable(StreamLogPriority.verbose)) _log.v(() => describe(everyParticipant)); + /// ``` + bool isLoggable(StreamLogPriority priority) { + if (!(_filter ?? _filterOrDefault).isLoggable(priority, tag)) return false; + return (_handler ?? _handlerOrDefault).isLoggable(priority, tag); + } - bool operator <(Priority other) => level < other.level; + /// Writes a [StreamLogPriority.verbose] record. + void v( + StreamLogMessage message, { + Object? error, + StackTrace? stackTrace, + }) => log( + StreamLogPriority.verbose, + message, + error: error, + stackTrace: stackTrace, + ); + + /// Writes a [StreamLogPriority.debug] record. + void d( + StreamLogMessage message, { + Object? error, + StackTrace? stackTrace, + }) => log( + StreamLogPriority.debug, + message, + error: error, + stackTrace: stackTrace, + ); - bool operator <=(Priority other) => level <= other.level; + /// Writes a [StreamLogPriority.info] record. + void i( + StreamLogMessage message, { + Object? error, + StackTrace? stackTrace, + }) => log( + StreamLogPriority.info, + message, + error: error, + stackTrace: stackTrace, + ); + + /// Writes a [StreamLogPriority.warning] record. + void w( + StreamLogMessage message, { + Object? error, + StackTrace? stackTrace, + }) => log( + StreamLogPriority.warning, + message, + error: error, + stackTrace: stackTrace, + ); + + /// Writes a [StreamLogPriority.error] record. + void e( + StreamLogMessage message, { + Object? error, + StackTrace? stackTrace, + }) => log( + StreamLogPriority.error, + message, + error: error, + stackTrace: stackTrace, + ); + + /// Writes a record at [priority]. + /// + /// [message] is called only if the record is kept. [error] and [stackTrace] carry the cause + /// when the record describes a failure. + void log( + StreamLogPriority priority, + StreamLogMessage message, { + Object? error, + StackTrace? stackTrace, + }) { + if (!isLoggable(priority)) return; - bool operator >(Priority other) => level > other.level; + final record = StreamLogRecord( + priority: priority, + tag: tag, + message: message(), + error: error, + stackTrace: stackTrace, + ); - bool operator >=(Priority other) => level >= other.level; + return (_handler ?? _handlerOrDefault).handle(record); + } } diff --git a/packages/stream_core/test/helpers/logger.dart b/packages/stream_core/test/helpers/logger.dart new file mode 100644 index 00000000..896f4c01 --- /dev/null +++ b/packages/stream_core/test/helpers/logger.dart @@ -0,0 +1,60 @@ +import 'dart:async'; + +import 'package:stream_core/stream_core.dart'; + +/// A [StreamLogHandler] that keeps every record it is given, for a test to assert on. +final class RecordingLogHandler extends StreamLogHandler { + RecordingLogHandler(); + + final records = []; + + Iterable get messages => records.map((it) => it.message); + + Iterable get tags => records.map((it) => it.tag); + + @override + void handle(StreamLogRecord record) => records.add(record); +} + +/// Runs [body] with [handler] and [filter] installed as the ambient ones, restoring both after. +/// +/// What an app installs is process-wide, so a test that sets it without clearing up changes what +/// every later test sees. The defaults are put back rather than whatever was there before, which +/// a write-only setter cannot read. +/// +/// An asynchronous [body] is awaited before either is put back, so a handler stays installed for +/// the work it was meant to capture rather than only up to the first `await`. +/// +/// Consider [StreamLogger.detached] for a component that can be handed its own logger, which +/// needs no clearing up at all. +T withStreamLogger( + T Function() body, { + StreamLogHandler? handler, + StreamLogFilter? filter, +}) { + if (handler != null) StreamLogger.handler = handler; + // A test installing a handler wants to see what reached it, so nothing is held back unless the + // test says so. + StreamLogger.filter = filter ?? const StreamLogFilter.always(); + + final T result; + try { + result = body(); + } catch (_) { + StreamLogger.reset(); + rethrow; + } + + if (result is Future) return result.whenComplete(StreamLogger.reset) as T; + + StreamLogger.reset(); + return result; +} + +/// Runs [body] and returns everything it printed. +List capturePrints(void Function() body) { + final lines = []; + final spec = ZoneSpecification(print: (_, _, _, line) => lines.add(line)); + runZoned(body, zoneSpecification: spec); + return lines; +} diff --git a/packages/stream_core/test/logger/stream_log_filter_test.dart b/packages/stream_core/test/logger/stream_log_filter_test.dart new file mode 100644 index 00000000..0a3d02b6 --- /dev/null +++ b/packages/stream_core/test/logger/stream_log_filter_test.dart @@ -0,0 +1,85 @@ +import 'package:stream_core/stream_core.dart'; +import 'package:test/test.dart'; + +import '../helpers/logger.dart'; + +void main() { + group('StreamLogFilter.minPriority', () { + test('admits records at the level or above, whatever the tag', () { + const filter = StreamLogFilter.minPriority(StreamLogPriority.warning); + + expect(filter.isLoggable(StreamLogPriority.debug, 'SC:Anything'), isFalse); + expect(filter.isLoggable(StreamLogPriority.warning, 'SC:Anything'), isTrue); + expect(filter.isLoggable(StreamLogPriority.error, 'SF:Something'), isTrue); + }); + }); + + group('StreamLogFilter.prefix', () { + test('holds a matching tag to its own threshold', () { + const filter = StreamLogFilter.prefix({'SC:Ws': StreamLogPriority.verbose}); + + expect(filter.isLoggable(StreamLogPriority.verbose, 'SC:WsClient'), isTrue); + expect(filter.isLoggable(StreamLogPriority.verbose, 'SC:Http'), isFalse); + }); + + test('holds everything else to `otherwise`', () { + const filter = StreamLogFilter.prefix( + {'SC:Ws': StreamLogPriority.verbose}, + otherwise: StreamLogPriority.error, + ); + + expect(filter.isLoggable(StreamLogPriority.warning, 'SC:Http'), isFalse); + expect(filter.isLoggable(StreamLogPriority.error, 'SC:Http'), isTrue); + }); + + test('lets the longest prefix win, so a broad rule can be narrowed', () { + const filter = StreamLogFilter.prefix({ + 'SC:': StreamLogPriority.verbose, + 'SC:WsHealth': StreamLogPriority.warning, + }); + + expect(filter.isLoggable(StreamLogPriority.verbose, 'SC:WsClient'), isTrue); + expect(filter.isLoggable(StreamLogPriority.verbose, 'SC:WsHealth'), isFalse); + expect(filter.isLoggable(StreamLogPriority.warning, 'SC:WsHealth'), isTrue); + }); + + test('is independent of the order the rules were written in', () { + const broadFirst = StreamLogFilter.prefix({ + 'SC:': StreamLogPriority.verbose, + 'SC:WsHealth': StreamLogPriority.warning, + }); + const narrowFirst = StreamLogFilter.prefix({ + 'SC:WsHealth': StreamLogPriority.warning, + 'SC:': StreamLogPriority.verbose, + }); + + expect( + broadFirst.isLoggable(StreamLogPriority.verbose, 'SC:WsHealth'), + narrowFirst.isLoggable(StreamLogPriority.verbose, 'SC:WsHealth'), + ); + }); + + test('gates a logger before its message is built', () { + var built = 0; + final handler = RecordingLogHandler(); + const logger = StreamLogger('SC:WsHealth'); + + withStreamLogger( + handler: handler, + filter: const StreamLogFilter.prefix({'SC:WsHealth': StreamLogPriority.warning}), + () => logger.v(() => 'ping ${built++}'), + ); + + expect(built, 0); + expect(handler.records, isEmpty); + }); + }); + + group('StreamLogFilter.always', () { + test('leaves the decision to the handler', () { + const filter = StreamLogFilter.always(); + + expect(filter.isLoggable(StreamLogPriority.verbose, 'SC:Anything'), isTrue); + }); + }); +} diff --git a/packages/stream_core/test/logger/stream_log_handler_test.dart b/packages/stream_core/test/logger/stream_log_handler_test.dart new file mode 100644 index 00000000..94e4ab26 --- /dev/null +++ b/packages/stream_core/test/logger/stream_log_handler_test.dart @@ -0,0 +1,202 @@ +import 'package:stream_core/stream_core.dart'; +import 'package:test/test.dart'; + +import '../helpers/logger.dart'; + +const _logger = StreamLogger('SC:Component'); + +void main() { + group('StreamLogHandler.console', () { + test('reaches the console, rather than only a service listener', () { + final printed = withStreamLogger( + handler: const StreamLogHandler.console(), + () => capturePrints(() => _logger.e(() => 'a visible line')), + ); + + expect(printed.single, allOf(contains('a visible line'), contains('SC:Component'))); + }); + + test('reports failures and stays quiet about the rest, having been given only a handler', () { + // Deliberately not `withStreamLogger`, which opens the level up: this is about what an app + // gets from installing a handler and nothing else. + StreamLogger.handler = const StreamLogHandler.console(); + addTearDown(() { + StreamLogger.handler = StreamLogHandler.silent; + StreamLogger.priority = StreamLogPriority.warning; + }); + + final printed = capturePrints(() { + _logger + ..v(() => 'verbose') + ..d(() => 'debug') + ..i(() => 'info') + ..w(() => 'warning') + ..e(() => 'error'); + }); + + expect(printed, hasLength(2)); + expect(printed.join(), allOf(contains('warning'), contains('error'), isNot(contains('info')))); + }); + + test('writes whatever the level admits, once it has been opened up', () { + // The setup every migration guide shows, which silently dropped debug when the handler + // carried a competing threshold of its own. + StreamLogger.handler = const StreamLogHandler.console(); + StreamLogger.priority = StreamLogPriority.debug; + addTearDown(() { + StreamLogger.handler = StreamLogHandler.silent; + StreamLogger.priority = StreamLogPriority.warning; + }); + + final printed = capturePrints(() => _logger.d(() => 'a debug line')); + + expect(printed.single, contains('a debug line')); + }); + + test('can be held quieter than the level, but never louder', () { + final printed = withStreamLogger( + handler: const StreamLogHandler.console(minPriority: StreamLogPriority.error), + filter: const StreamLogFilter.minPriority(StreamLogPriority.debug), + () => capturePrints(() { + _logger + ..d(() => 'debug') + ..e(() => 'error'); + }), + ); + + expect(printed.single, contains('error')); + }); + + test('prints the cause of a failure after the message', () { + final printed = withStreamLogger( + handler: const StreamLogHandler.console(), + () => capturePrints( + () => _logger.e(() => 'failed', error: StateError('boom'), stackTrace: StackTrace.current), + ), + ); + + expect(printed, hasLength(3)); + expect(printed[0], contains('failed')); + expect(printed[1], contains('boom')); + }); + }); + + group('StreamLogHandler.composite', () { + test('gives every handler the same record', () { + final console = RecordingLogHandler(); + final crashReporter = RecordingLogHandler(); + + withStreamLogger( + handler: StreamLogHandler.composite([console, crashReporter]), + () => _logger.w(() => 'seen by both'), + ); + + expect(console.messages, ['seen by both']); + expect(crashReporter.messages, ['seen by both']); + }); + + test('lets each handler keep only what it wants', () { + final everything = RecordingLogHandler(); + + final printed = withStreamLogger( + handler: StreamLogHandler.composite([ + everything, + const StreamLogHandler.console(minPriority: StreamLogPriority.error), + ]), + () => capturePrints(() { + _logger + ..d(() => 'debug') + ..e(() => 'error'); + }), + ); + + expect(everything.messages, ['debug', 'error']); + expect(printed, hasLength(1)); + }); + + test('builds a record any one of them wants', () { + withStreamLogger( + handler: StreamLogHandler.composite([ + const StreamLogHandler.console(minPriority: StreamLogPriority.none), + RecordingLogHandler(), + ]), + () => expect(_logger.isLoggable(StreamLogPriority.verbose), isTrue), + ); + }); + + test('does nothing when it has no handlers', () { + withStreamLogger( + handler: const StreamLogHandler.composite([]), + () { + expect(() => _logger.e(() => 'nowhere to go'), returnsNormally); + expect(_logger.isLoggable(StreamLogPriority.error), isFalse); + }, + ); + }); + }); + + group('StreamLogHandler.from', () { + test('hands each record to the callback', () { + final seen = []; + + withStreamLogger( + handler: StreamLogHandler.from((it) => seen.add('${it.priority} ${it.tag} ${it.message}')), + () => _logger + ..v(() => 'verbose') + ..e(() => 'error'), + ); + + expect(seen, ['verbose SC:Component verbose', 'error SC:Component error']); + }); + }); + + group('StreamLogHandler.debugOnly', () { + // Only half of this handler can be tested here: `dart test` runs with assertions enabled, so + // the build where it goes quiet is by definition one this suite cannot be running in. That + // half was checked by compiling a probe with `dart compile exe`, which strips them. + + test('passes records on where assertions are enabled, as they are under test', () { + final inner = RecordingLogHandler(); + + withStreamLogger( + handler: StreamLogHandler.debugOnly(inner), + () => _logger.e(() => 'seen while developing'), + ); + + expect(inner.messages, ['seen while developing']); + }); + + test('still lets the handler it wraps keep only what it wants', () { + final printed = withStreamLogger( + handler: const StreamLogHandler.debugOnly( + StreamLogHandler.console(minPriority: StreamLogPriority.error), + ), + () => capturePrints(() { + _logger + ..d(() => 'debug') + ..e(() => 'error'); + }), + ); + + expect(printed.single, contains('error')); + }); + + test('is const constructible', () { + const handler = StreamLogHandler.debugOnly(StreamLogHandler.console()); + + expect(handler, isA()); + }); + }); + + group('StreamLogHandler.silent', () { + test('discards every record and admits none', () { + withStreamLogger( + handler: StreamLogHandler.silent, + () { + expect(capturePrints(() => _logger.e(() => 'discarded')), isEmpty); + expect(_logger.isLoggable(StreamLogPriority.error), isFalse); + }, + ); + }); + }); +} diff --git a/packages/stream_core/test/logger/stream_log_priority_test.dart b/packages/stream_core/test/logger/stream_log_priority_test.dart new file mode 100644 index 00000000..c1195bde --- /dev/null +++ b/packages/stream_core/test/logger/stream_log_priority_test.dart @@ -0,0 +1,60 @@ +import 'package:stream_core/stream_core.dart'; +import 'package:test/test.dart'; + +void main() { + group('StreamLogPriority', () { + test('runs from least to most severe', () { + // Every threshold in the logger is a comparison against one of these, so the order they sit + // in is what decides which records a filter admits. + expect(StreamLogPriority.values, [ + StreamLogPriority.verbose, + StreamLogPriority.debug, + StreamLogPriority.info, + StreamLogPriority.warning, + StreamLogPriority.error, + StreamLogPriority.none, + ]); + }); + + test('compares consistently in every direction', () { + for (var i = 1; i < StreamLogPriority.values.length; i++) { + final lower = StreamLogPriority.values[i - 1]; + final higher = StreamLogPriority.values[i]; + + expect(lower < higher, isTrue, reason: '$lower < $higher'); + expect(lower <= higher, isTrue, reason: '$lower <= $higher'); + expect(higher > lower, isTrue, reason: '$higher > $lower'); + expect(higher >= lower, isTrue, reason: '$higher >= $lower'); + expect(lower.compareTo(higher), isNegative, reason: '$lower before $higher'); + } + }); + + test('is neither above nor below itself', () { + expect(StreamLogPriority.info < StreamLogPriority.info, isFalse); + expect(StreamLogPriority.info > StreamLogPriority.info, isFalse); + expect(StreamLogPriority.info <= StreamLogPriority.info, isTrue); + expect(StreamLogPriority.info >= StreamLogPriority.info, isTrue); + expect(StreamLogPriority.info.compareTo(StreamLogPriority.info), isZero); + }); + + test('sorts by severity', () { + final shuffled = [ + StreamLogPriority.error, + StreamLogPriority.verbose, + StreamLogPriority.none, + StreamLogPriority.warning, + StreamLogPriority.debug, + StreamLogPriority.info, + ]; + expect(shuffled, isNot(orderedEquals(StreamLogPriority.values))); + + shuffled.sort(); + + expect(shuffled, orderedEquals(StreamLogPriority.values)); + }); + + test('admits nothing as a threshold, being the most severe there is', () { + expect(StreamLogPriority.values.every((it) => it <= StreamLogPriority.none), isTrue); + }); + }); +} diff --git a/packages/stream_core/test/logger/stream_logger_test.dart b/packages/stream_core/test/logger/stream_logger_test.dart new file mode 100644 index 00000000..93f2cd38 --- /dev/null +++ b/packages/stream_core/test/logger/stream_logger_test.dart @@ -0,0 +1,300 @@ +import 'package:clock/clock.dart'; +import 'package:stream_core/stream_core.dart'; +import 'package:test/test.dart'; + +import '../helpers/logger.dart'; + +const _logger = StreamLogger('SC:Component'); + +void main() { + group('StreamLogger', () { + test('writes every record under its own tag', () { + final handler = RecordingLogHandler(); + + withStreamLogger(handler: handler, () { + _logger + ..v(() => 'v') + ..d(() => 'd') + ..i(() => 'i') + ..w(() => 'w') + ..e(() => 'e'); + }); + + expect(handler.tags, everyElement('SC:Component')); + expect(handler.records.map((it) => it.priority), [ + StreamLogPriority.verbose, + StreamLogPriority.debug, + StreamLogPriority.info, + StreamLogPriority.warning, + StreamLogPriority.error, + ]); + }); + + test('is silent until an app installs a handler', () { + var built = 0; + + final printed = capturePrints(() => _logger.e(() => 'nobody is listening ${built++}')); + + expect(printed, isEmpty); + expect(built, 0, reason: 'a message no handler wants is never built'); + }); + + test('is const constructible, so a component can hold one as a static field', () { + // A logger needing construction would cost an allocation per component, and could not be + // held by a top-level function. + expect(_logger.tag, 'SC:Component'); + }); + + test('resolves the handler when it writes, not when it was created', () { + // `_logger` is a top-level const, created long before this handler existed. + final handler = RecordingLogHandler(); + + withStreamLogger(handler: handler, () => _logger.e(() => 'after configuration')); + + expect(handler.messages, ['after configuration']); + }); + + test('stops writing to a handler that has been replaced', () { + final first = RecordingLogHandler(); + + withStreamLogger(handler: first, () {}); + _logger.e(() => 'after the handler went away'); + + expect(first.records, isEmpty); + }); + + test('carries the cause of a failure, whichever level reports it', () { + final handler = RecordingLogHandler(); + final error = StateError('boom'); + final stackTrace = StackTrace.current; + + withStreamLogger(handler: handler, () { + _logger + ..v(() => 'v', error: error, stackTrace: stackTrace) + ..d(() => 'd', error: error, stackTrace: stackTrace) + ..i(() => 'i', error: error, stackTrace: stackTrace) + ..w(() => 'w', error: error, stackTrace: stackTrace) + ..e(() => 'e', error: error, stackTrace: stackTrace) + ..log(StreamLogPriority.error, () => 'log', error: error, stackTrace: stackTrace); + }); + + // Each of these forwards to `log` separately, so one that dropped an argument would go + // unnoticed if only the level it was most obviously needed for were checked. + expect(handler.records, hasLength(6)); + expect(handler.records.map((it) => it.error), everyElement(error)); + expect(handler.records.map((it) => it.stackTrace), everyElement(stackTrace)); + }); + + test('leaves the cause empty when a record describes no failure', () { + final handler = RecordingLogHandler(); + + withStreamLogger(handler: handler, () { + _logger + ..v(() => 'v') + ..d(() => 'd') + ..i(() => 'i') + ..w(() => 'w') + ..e(() => 'e') + ..log(StreamLogPriority.error, () => 'log'); + }); + + // A handler forwarding to a crash reporter decides what to report on whether there is a + // cause, so a record inventing one would file an incident for a routine line. + expect(handler.records, hasLength(6)); + expect(handler.records.map((it) => it.error), everyElement(isNull)); + expect(handler.records.map((it) => it.stackTrace), everyElement(isNull)); + }); + + test('never builds a message no handler wants', () { + var built = 0; + + withStreamLogger( + handler: const StreamLogHandler.console(minPriority: StreamLogPriority.error), + () => capturePrints(() => _logger.v(() => 'expensive ${built++}')), + ); + + expect(built, 0); + }); + + test('isLoggable answers for the filter and the handler together', () { + withStreamLogger( + handler: const StreamLogHandler.console(minPriority: StreamLogPriority.warning), + filter: const StreamLogFilter.minPriority(StreamLogPriority.debug), + () { + expect(_logger.isLoggable(StreamLogPriority.verbose), isFalse, reason: 'the filter rejects it'); + expect(_logger.isLoggable(StreamLogPriority.debug), isFalse, reason: 'the handler rejects it'); + expect(_logger.isLoggable(StreamLogPriority.warning), isTrue); + }, + ); + }); + }); + + group('StreamLogger.reset', () { + test('puts back both the handler and the priority', () { + final installed = RecordingLogHandler(); + StreamLogger.handler = installed; + StreamLogger.priority = StreamLogPriority.verbose; + + StreamLogger.reset(); + + // A consumer restoring by hand would have to name defaults a write-only setter gives no way + // to read, so both have to come back together. + final printed = capturePrints(() { + _logger + ..d(() => 'below the default threshold') + ..e(() => 'nowhere to go'); + }); + + expect(installed.records, isEmpty, reason: 'the handler was put back'); + expect(printed, isEmpty, reason: 'nothing is installed to print with'); + expect(_logger.isLoggable(StreamLogPriority.debug), isFalse, reason: 'the priority was put back'); + }); + }); + + group('StreamLogger.detached', () { + test('writes to its own handler, ignoring what the app installed', () { + final mine = RecordingLogHandler(); + final installed = RecordingLogHandler(); + final logger = StreamLogger.detached('SC:Detached', handler: mine); + + withStreamLogger(handler: installed, () => logger.e(() => 'to mine only')); + + expect(mine.messages, ['to mine only']); + expect(installed.records, isEmpty); + }); + + test('writes even when the app installed nothing', () { + final mine = RecordingLogHandler(); + final logger = StreamLogger.detached('SC:Detached', handler: mine); + + logger.e(() => 'nothing ambient is needed'); + + expect(mine.messages, ['nothing ambient is needed']); + }); + + test('is held to its own threshold, not the installed one', () { + final mine = RecordingLogHandler(); + final logger = StreamLogger.detached( + 'SC:Detached', + handler: mine, + filter: const StreamLogFilter.minPriority(StreamLogPriority.error), + ); + + withStreamLogger(filter: const StreamLogFilter.always(), () { + logger + ..w(() => 'below its own threshold, though the installed one admits it') + ..e(() => 'at it'); + }); + + expect(mine.messages, ['at it']); + }); + + test('leaves what the app installed alone', () { + final installed = RecordingLogHandler(); + final logger = StreamLogger.detached('SC:Detached', handler: RecordingLogHandler()); + + withStreamLogger(handler: installed, () { + logger.e(() => 'mine'); + const StreamLogger('SC:Component').e(() => 'theirs'); + }); + + expect(installed.messages, ['theirs']); + }); + + test('starts at the threshold an attached logger starts at', () { + final mine = RecordingLogHandler(); + final logger = StreamLogger.detached('SC:Detached', handler: mine); + + logger + ..d(() => 'below the default threshold') + ..w(() => 'at it'); + + // Detaching changes where a logger's records go. Left to admit everything, it would also + // quietly change how many there are. + expect(mine.messages, ['at it']); + }); + + test('admits everything when asked to', () { + final mine = RecordingLogHandler(); + final logger = StreamLogger.detached( + 'SC:Detached', + handler: mine, + filter: const StreamLogFilter.always(), + ); + + logger.v(() => 'the quietest record there is'); + + expect(mine.messages, ['the quietest record there is']); + }); + + test('is unmoved by the installed handler and priority changing after it was built', () { + final mine = RecordingLogHandler(); + final logger = StreamLogger.detached('SC:Detached', handler: mine); + + addTearDown(() { + StreamLogger.handler = StreamLogHandler.silent; + StreamLogger.priority = StreamLogPriority.warning; + }); + + // An attached logger resolves both of these every time it writes, so a detached one reading + // either would drift as an app reconfigured itself. + for (final installed in [RecordingLogHandler(), RecordingLogHandler()]) { + StreamLogger.handler = installed; + StreamLogger.priority = StreamLogPriority.verbose; + + logger + ..d(() => 'still below its own threshold') + ..e(() => 'still its own handler'); + + expect(installed.records, isEmpty, reason: 'a detached logger writes nowhere else'); + } + + expect(mine.messages, ['still its own handler', 'still its own handler']); + }); + + test('is const constructible', () { + const logger = StreamLogger.detached('SC:Detached', handler: StreamLogHandler.console()); + + expect(logger.tag, 'SC:Detached'); + }); + }); + + group('StreamLogRecord', () { + test('reads its time from the clock, so a test can pin it', () { + final handler = RecordingLogHandler(); + final instant = DateTime.utc(2026, 8, 24, 12); + + withStreamLogger( + handler: handler, + () => withClock(Clock.fixed(instant), () => _logger.e(() => 'at a known time')), + ); + + expect(handler.records.single.time, instant); + }); + + test('gives every handler in a composite the same instant', () { + final console = RecordingLogHandler(); + final crashReporter = RecordingLogHandler(); + + withStreamLogger( + handler: StreamLogHandler.composite([console, crashReporter]), + () => _logger.e(() => 'seen by both'), + ); + + expect(console.records.single.time, crashReporter.records.single.time); + }); + + test('numbers records in the order they were created', () { + final handler = RecordingLogHandler(); + + withStreamLogger(handler: handler, () { + _logger + ..e(() => 'first') + ..e(() => 'second'); + }); + + final [first, second] = handler.records; + expect(second.sequenceNumber, first.sequenceNumber + 1); + }); + }); +} From c700e42f2cffdac7843aadb86bcda699801e5ed1 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Mon, 24 Aug 2026 22:45:33 +0200 Subject: [PATCH 02/29] feat(llc): report the connection lifecycle through the logger MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The client, engine, health monitor, authentication handler and recovery handler each hold a logger, reporting under `SC:WsClient` and, for the three the client owns, `SC:WsClient:Engine`, `:Health` and `:Auth`. Each takes a `tag` rather than a logger — the destination is the app's business — so a second client's records stay apart from the first's, and one prefix still selects a whole family. Two of these were invisible before. The engine dropped a frame it could not decode without a word, so a codec mismatch looked like a server that had gone quiet; the authentication handler discarded the outcome of a superseded attempt just as silently. State transitions, connect and disconnect reasons, and the computed backoff delay sit at debug, ping and pong at verbose, so an app that installs a console handler sees only what is worth acting on until it asks for more. --- .../engine/stream_web_socket_engine.dart | 10 ++- .../connection_recovery_handler.dart | 7 ++ .../ws/client/stream_web_socket_client.dart | 38 +++++++++- .../web_socket_authentication_handler.dart | 16 ++++- .../ws/client/web_socket_health_monitor.dart | 19 ++++- .../test/helpers/ws_client_tester.dart | 2 + .../engine/stream_web_socket_engine_test.dart | 40 +++++++++++ .../client/stream_web_socket_client_test.dart | 72 +++++++++++++++++++ 8 files changed, 195 insertions(+), 9 deletions(-) diff --git a/packages/stream_core/lib/src/ws/client/engine/stream_web_socket_engine.dart b/packages/stream_core/lib/src/ws/client/engine/stream_web_socket_engine.dart index a908bdc8..74bb429f 100644 --- a/packages/stream_core/lib/src/ws/client/engine/stream_web_socket_engine.dart +++ b/packages/stream_core/lib/src/ws/client/engine/stream_web_socket_engine.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:web_socket_channel/web_socket_channel.dart'; +import '../../../logger.dart'; import '../../../utils.dart'; import 'web_socket_engine.dart'; @@ -36,8 +37,11 @@ class StreamWebSocketEngine implements WebSocketEngine { WebSocketProvider? wsProvider, this._listener, required this._messageCodec, - }) : _wsProvider = wsProvider ?? _createWebSocket; + String tag = 'SC:WsEngine', + }) : _logger = StreamLogger(tag), + _wsProvider = wsProvider ?? _createWebSocket; + final StreamLogger _logger; final WebSocketProvider _wsProvider; final WebSocketMessageCodec _messageCodec; @@ -88,6 +92,10 @@ class StreamWebSocketEngine implements WebSocketEngine { if (data == null) return; final result = runSafelySync(() => _messageCodec.decode(data)); + if (result case Failure(:final error, :final stackTrace)) { + return _logger.w(() => 'dropped an undecodable message', error: error, stackTrace: stackTrace); + } + final message = result.getOrNull(); // If decoding failed, we ignore the message. diff --git a/packages/stream_core/lib/src/ws/client/reconnect/connection_recovery_handler.dart b/packages/stream_core/lib/src/ws/client/reconnect/connection_recovery_handler.dart index 714fa916..6dc268ae 100644 --- a/packages/stream_core/lib/src/ws/client/reconnect/connection_recovery_handler.dart +++ b/packages/stream_core/lib/src/ws/client/reconnect/connection_recovery_handler.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:rxdart/utils.dart'; +import '../../../logger.dart'; import '../../../utils.dart'; import '../stream_web_socket_client.dart'; import '../web_socket_connection_state.dart'; @@ -47,7 +48,9 @@ class ConnectionRecoveryHandler extends Disposable { this._keepConnectionAliveInBackground = false, List? policies, RetryStrategy? retryStrategy, + String tag = 'SC:WsRecovery', }) : _client = client, + _logger = StreamLogger(tag), _reconnectStrategy = retryStrategy ?? RetryStrategy(), _policies = [ ...?policies, @@ -78,6 +81,7 @@ class ConnectionRecoveryHandler extends Disposable { } final StreamWebSocketClient _client; + final StreamLogger _logger; final RetryStrategy _reconnectStrategy; final bool _keepConnectionAliveInBackground; final List _policies; @@ -114,6 +118,9 @@ class ConnectionRecoveryHandler extends Disposable { Timer? _reconnectionTimer; void _scheduleReconnection() { final delay = _reconnectStrategy.getDelayAfterTheFailure(); + _logger.d( + () => 'reconnect #${_reconnectStrategy.consecutiveFailuresCount} scheduled in ${delay.inMilliseconds}ms', + ); _reconnectionTimer?.cancel(); _reconnectionTimer = Timer(delay, reconnectIfNeeded); diff --git a/packages/stream_core/lib/src/ws/client/stream_web_socket_client.dart b/packages/stream_core/lib/src/ws/client/stream_web_socket_client.dart index a10b61a4..d39d0f4f 100644 --- a/packages/stream_core/lib/src/ws/client/stream_web_socket_client.dart +++ b/packages/stream_core/lib/src/ws/client/stream_web_socket_client.dart @@ -1,5 +1,6 @@ import 'dart:async'; +import '../../logger.dart'; import '../../utils.dart'; import '../events/ws_event.dart'; import '../events/ws_request.dart'; @@ -48,6 +49,22 @@ typedef WebSocketOptionsBuilder = WebSocketOptions Function(); /// /// await client.connect(); /// ``` +/// +/// This client reports what it is doing under `SC:WsClient`, and the engine, health monitor and +/// authentication handler it owns under `SC:WsClient:Engine`, `:Health` and `:Auth`. Nothing is +/// written until an app installs a [StreamLogHandler]: +/// +/// ```dart +/// StreamLogger.handler = const StreamLogHandler.console(minPriority: StreamLogPriority.debug); +/// ``` +/// +/// Give a second client its own `tag` to tell the two apart. Its collaborators are tagged from +/// it, so one prefix still selects the whole family: +/// +/// ```dart +/// StreamWebSocketClient(tag: 'SC:Ws2', ...); +/// StreamLogger.filter = const StreamLogFilter.prefix({'SC:Ws2': StreamLogPriority.verbose}); +/// ``` class StreamWebSocketClient with Disposable implements WebSocketHealthListener, WebSocketEngineListener { /// Creates a new instance of [StreamWebSocketClient]. StreamWebSocketClient({ @@ -57,17 +74,22 @@ class StreamWebSocketClient with Disposable implements WebSocketHealthListener, this.pingRequestBuilder = _defaultPingRequestBuilder, required WebSocketMessageCodec messageCodec, Iterable>? eventResolvers, - }) { + String tag = 'SC:WsClient', + }) : _logger = StreamLogger(tag) { _events = MutableEventEmitter(resolvers: eventResolvers); _engine = StreamWebSocketEngine( listener: this, wsProvider: wsProvider, messageCodec: messageCodec, + tag: '$tag:Engine', ); + _healthMonitor = WebSocketHealthMonitor(listener: this, tag: '$tag:Health'); + _authenticationHandler = WebSocketAuthenticationHandler( send: send, authenticator: onAuthenticate, + tag: '$tag:Auth', onFailure: (error) => disconnect( source: .authenticationFailed(error: error), ), @@ -80,9 +102,11 @@ class StreamWebSocketClient with Disposable implements WebSocketHealthListener, /// The function used to build ping requests for health checks. final PingRequestBuilder pingRequestBuilder; + final StreamLogger _logger; + late final StreamWebSocketEngine _engine; late final WebSocketAuthenticationHandler _authenticationHandler; - late final _healthMonitor = WebSocketHealthMonitor(listener: this); + late final WebSocketHealthMonitor _healthMonitor; // Bounds an attempt while `Connecting` or `Authenticating`; the health monitor takes over after. Timer? _connectTimeoutTimer; @@ -118,7 +142,10 @@ class StreamWebSocketClient with Disposable implements WebSocketHealthListener, // Return early if the state hasn't changed. if (_connectionStateEmitter.value == connectionState) return; + final previous = _connectionStateEmitter.value; _connectionStateEmitter.value = connectionState; + _logger.d(() => 'state: $previous -> $connectionState'); + _healthMonitor.onConnectionStateChanged(connectionState); _authenticationHandler.onConnectionStateChanged(connectionState); } @@ -157,6 +184,7 @@ class StreamWebSocketClient with Disposable implements WebSocketHealthListener, // Open the connection using the engine, with options built for this attempt. final options = optionsBuilder.call(); + _logger.d(() => 'connect to ${options.url}'); // Bound the attempt, so one that never becomes usable is not waited on forever. _startConnectTimeout(options.connectTimeout); @@ -195,6 +223,8 @@ class StreamWebSocketClient with Disposable implements WebSocketHealthListener, if (connectionState.value case Disconnecting() when !forceDisconnect) return; if (connectionState.value case Disconnected() when !forceDisconnect) return; + _logger.d(() => 'disconnect with $closeCode, source: $source'); + // Update the connection state to 'disconnecting'. _connectionState = WebSocketConnectionState.disconnecting(source: source); @@ -238,6 +268,8 @@ class StreamWebSocketClient with Disposable implements WebSocketHealthListener, @override void onError(Object error, [StackTrace? stackTrace]) { + _logger.e(() => 'socket failed', error: error, stackTrace: stackTrace); + final source = ServerInitiated( error: WebSocketEngineException(error: error), ); @@ -265,6 +297,8 @@ class StreamWebSocketClient with Disposable implements WebSocketHealthListener, } void _handleErrorEvent(WsEvent event, Object error) { + _logger.w(() => 'server sent an error event', error: error); + final source = ServerInitiated( error: WebSocketEngineException(error: error), ); diff --git a/packages/stream_core/lib/src/ws/client/web_socket_authentication_handler.dart b/packages/stream_core/lib/src/ws/client/web_socket_authentication_handler.dart index 7d073d85..6f4270ec 100644 --- a/packages/stream_core/lib/src/ws/client/web_socket_authentication_handler.dart +++ b/packages/stream_core/lib/src/ws/client/web_socket_authentication_handler.dart @@ -1,4 +1,5 @@ import '../../errors.dart' show StreamApiError; +import '../../logger.dart'; import '../../utils.dart'; import '../events/ws_request.dart'; import 'web_socket_connection_state.dart'; @@ -34,7 +35,10 @@ class WebSocketAuthenticationHandler { required this._authenticator, required this._send, required this._onFailure, - }); + String tag = 'SC:WsAuth', + }) : _logger = StreamLogger(tag); + + final StreamLogger _logger; final WebSocketAuthenticator? _authenticator; final WsRequestSender _send; @@ -83,18 +87,24 @@ class WebSocketAuthenticationHandler { final attempt = _attempt; final previousError = _previousError; + _logger.d(() => 'authenticate attempt #$attempt, previousError: $previousError'); // Guarded because nothing awaits this: an error thrown here would go unhandled. final result = await runSafely(() => authenticate(_senderFor(attempt), previousError)); // Stale: its failure would close the connection that replaced it, and never be reconnected. - if (attempt != _attempt) return; + if (attempt != _attempt) { + return _logger.d(() => 'attempt #$attempt is stale, dropping its outcome: $result'); + } // Spent, unless the server refused something newer while the authenticator ran. By identity, // not equality: a newer refusal of the same kind compares equal to this one. if (identical(_previousError, previousError)) _previousError = null; - if (result case Failure(:final error)) return _onFailure(error); + if (result case Failure(:final error, :final stackTrace)) { + _logger.w(() => 'attempt #$attempt could not be authenticated', error: error, stackTrace: stackTrace); + return _onFailure(error); + } } // The sender is held across the authenticator's own awaits, so the attempt is checked on each send. diff --git a/packages/stream_core/lib/src/ws/client/web_socket_health_monitor.dart b/packages/stream_core/lib/src/ws/client/web_socket_health_monitor.dart index f4299c32..a6fb4722 100644 --- a/packages/stream_core/lib/src/ws/client/web_socket_health_monitor.dart +++ b/packages/stream_core/lib/src/ws/client/web_socket_health_monitor.dart @@ -1,5 +1,6 @@ import 'dart:async'; +import '../../logger.dart'; import 'web_socket_connection_state.dart'; /// Interface for receiving WebSocket health monitoring events. @@ -44,7 +45,10 @@ class WebSocketHealthMonitor { required this._listener, this.pingInterval = const Duration(seconds: 25), this.timeoutThreshold = const Duration(seconds: 3), - }); + String tag = 'SC:WsHealth', + }) : _logger = StreamLogger(tag); + + final StreamLogger _logger; /// The interval between ping requests for health checking. final Duration pingInterval; @@ -72,7 +76,10 @@ class WebSocketHealthMonitor { /// /// Cancels the current pong timeout timer, indicating the connection is healthy. /// Called automatically when pong events are received from the WebSocket. - void onPongReceived() => _pongTimer?.cancel(); + void onPongReceived() { + _logger.v(() => 'pong'); + return _pongTimer?.cancel(); + } /// Handles connection state changes. /// @@ -91,10 +98,16 @@ class WebSocketHealthMonitor { void _sendPing(Timer pingTimer) { if (!pingTimer.isActive) return; + _logger.v(() => 'ping'); _listener.onPingRequested(); _pongTimer?.cancel(); - _pongTimer = Timer(timeoutThreshold, _listener.onUnhealthy); + _pongTimer = Timer(timeoutThreshold, _onPongTimeout); + } + + void _onPongTimeout() { + _logger.w(() => 'no pong within $timeoutThreshold, connection is unhealthy'); + return _listener.onUnhealthy(); } /// Stops health monitoring and cancels all timers. diff --git a/packages/stream_core/test/helpers/ws_client_tester.dart b/packages/stream_core/test/helpers/ws_client_tester.dart index 57709269..19fb2641 100644 --- a/packages/stream_core/test/helpers/ws_client_tester.dart +++ b/packages/stream_core/test/helpers/ws_client_tester.dart @@ -209,6 +209,7 @@ WsClientTester buildTester({ bool handshakeHangs = false, bool holdClose = false, Object? closeError, + String tag = 'SC:WsClient', }) { final server = FakeServer(user: user); @@ -244,6 +245,7 @@ WsClientTester buildTester({ false => null, }, messageCodec: const JsonCodec(), + tag: tag, ); final network = TestNetworkStateProvider(); diff --git a/packages/stream_core/test/ws/client/engine/stream_web_socket_engine_test.dart b/packages/stream_core/test/ws/client/engine/stream_web_socket_engine_test.dart index b409f049..67f4ba44 100644 --- a/packages/stream_core/test/ws/client/engine/stream_web_socket_engine_test.dart +++ b/packages/stream_core/test/ws/client/engine/stream_web_socket_engine_test.dart @@ -1,6 +1,7 @@ import 'package:stream_core/stream_core.dart'; import 'package:test/test.dart'; +import '../../../helpers/logger.dart'; import '../../../helpers/web_socket.dart'; /// A codec that passes strings through untouched. @@ -287,6 +288,45 @@ void main() { expect(listener.messages, isEmpty); expect(listener.closures, isEmpty); }); + + test('reports a message it cannot decode to the logger, which is the only trace it arrived', () async { + final socket = FakeWebSocketChannel(); + addTearDown(socket.endStream); + + final handler = RecordingLogHandler(); + final engine = StreamWebSocketEngine( + wsProvider: (_) => socket, + listener: _RecordingListener(), + messageCodec: const _ThrowingCodec(), + ); + await engine.open(_options); + + await withStreamLogger(handler: handler, () async { + socket.emit('garbage'); + await pumpEventQueue(); + }); + + expect(handler.records.single.priority, StreamLogPriority.warning); + expect(handler.records.single.tag, 'SC:WsEngine'); + expect(handler.records.single.error, isA()); + }); + + test('says nothing when no handler is installed', () async { + final socket = FakeWebSocketChannel(); + addTearDown(socket.endStream); + + final engine = StreamWebSocketEngine( + wsProvider: (_) => socket, + listener: _RecordingListener(), + messageCodec: const _ThrowingCodec(), + ); + await engine.open(_options); + + final printed = capturePrints(() => socket.emit('garbage')); + await pumpEventQueue(); + + expect(printed, isEmpty); + }); } /// A codec that cannot decode anything, as one meeting an unknown frame cannot. diff --git a/packages/stream_core/test/ws/client/stream_web_socket_client_test.dart b/packages/stream_core/test/ws/client/stream_web_socket_client_test.dart index 2e1cef04..100893f9 100644 --- a/packages/stream_core/test/ws/client/stream_web_socket_client_test.dart +++ b/packages/stream_core/test/ws/client/stream_web_socket_client_test.dart @@ -5,6 +5,7 @@ import 'package:stream_core/stream_core.dart'; import 'package:test/test.dart'; import '../../helpers/fake_server.dart'; +import '../../helpers/logger.dart'; import '../../helpers/user_token.dart'; import '../../helpers/ws_client_tester.dart'; @@ -1301,4 +1302,75 @@ void main() { }); }); }); + + group('what the logger sees', () { + test('reaches every collaborator the client owns, each under its own tag', () { + fakeAsync((async) { + final handler = RecordingLogHandler(); + final tester = buildTester(recover: true); + + withStreamLogger(handler: handler, () { + tester.client.connect().ignore(); + async.flushMicrotasks(); + // Far enough for the health monitor to ping at least once. + async.elapse(const Duration(minutes: 1)); + }); + + // Every collaborator holds its own logger, so a tag missing here means one of them is not + // reporting what it does. + expect( + handler.records.map((it) => it.tag).toSet(), + containsAll(['SC:WsClient', 'SC:WsClient:Auth', 'SC:WsClient:Health']), + ); + }); + }); + + test('tags a client and everything it owns from the tag it was given', () { + fakeAsync((async) { + final handler = RecordingLogHandler(); + final tester = buildTester(tag: 'SC:Ws2'); + + withStreamLogger(handler: handler, () { + tester.client.connect().ignore(); + async.flushMicrotasks(); + }); + + // A second client is otherwise indistinguishable from the first in one log, and its + // collaborators are tagged from it so one prefix still selects the whole family. + expect(handler.tags, everyElement(startsWith('SC:Ws2'))); + expect(handler.tags, contains('SC:Ws2:Auth')); + }); + }); + + test('records the connection reaching Connected', () { + fakeAsync((async) { + final handler = RecordingLogHandler(); + final tester = buildTester(); + + withStreamLogger(handler: handler, () { + tester.client.connect().ignore(); + async.flushMicrotasks(); + }); + + expect(tester.connectionState, isA()); + expect( + handler.records.where((it) => it.tag == 'SC:WsClient').map((it) => it.message), + contains(contains('-> Connected')), + ); + }); + }); + + test('says nothing at all when no handler is installed', () { + fakeAsync((async) { + final tester = buildTester(); + + final printed = capturePrints(() { + tester.client.connect().ignore(); + async.flushMicrotasks(); + }); + + expect(printed, isEmpty); + }); + }); + }); } From c9756efc1825151c858c5abc23f69dd6e9b55436 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Mon, 24 Aug 2026 22:45:33 +0200 Subject: [PATCH 03/29] feat(llc): report what the auth interceptor decided Every branch in it was silent, including the ones that leave a refused request refused: no token to sign with, a provider with nothing fresher to give, a request signed for a user who has since changed, and a replacement the server refused too. That is the "why do my requests 401 and never recover" path, and until now it produced nothing to look at. --- packages/stream_core/CHANGELOG.md | 2 + .../api/interceptors/auth_interceptor.dart | 30 +++++++++-- .../interceptors/auth_interceptor_test.dart | 51 +++++++++++++++++++ 3 files changed, 80 insertions(+), 3 deletions(-) diff --git a/packages/stream_core/CHANGELOG.md b/packages/stream_core/CHANGELOG.md index 391270dd..8b41a3c9 100644 --- a/packages/stream_core/CHANGELOG.md +++ b/packages/stream_core/CHANGELOG.md @@ -15,9 +15,11 @@ - `WebSocketConnectionState.isAutomaticReconnectionEnabled` is now `true` for an expired token, and remains `false` for token errors a fresh token cannot fix - `StreamApiError.isTokenExpiredError` now means code 40 only; the other token codes and a wrong API key are `isInvalidTokenError`. `isClientError` compares the HTTP `statusCode` against 400..499, rather than the Stream error `code`, which never falls in that range - `Result.getOrElse`, `getOrDefault`, `recover` and `recoverCatching` return the result's own type and no longer take a type parameter. To widen, widen the result (`Result widened = intResult`) or use `fold` +- Replaced the logger: `StreamLogger` is the handle you write with and a `StreamLogHandler` is where records go, so `Priority`, `MessageBuilder`, `Tag`, `IsLoggableValidator` and `Finder` are renamed or gone ### ✨ Features +- Added a logger the SDK now reports itself through, silent until an app installs a `StreamLogHandler` - Added `TokenManager.setTokenProvider`, which points an existing manager at another user and expires the cached token; handed the identity it already has, it does nothing - Added optional `onTokenUpdated` callback to `TokenManager`, invoked after every successful token load - Added optional `rawValue` to `UserToken.anonymous`, so an anonymous token can carry a JWT granting restricted access; its `user_id` claim must be `!anon` diff --git a/packages/stream_core/lib/src/api/interceptors/auth_interceptor.dart b/packages/stream_core/lib/src/api/interceptors/auth_interceptor.dart index 4e5697fb..664d55ec 100644 --- a/packages/stream_core/lib/src/api/interceptors/auth_interceptor.dart +++ b/packages/stream_core/lib/src/api/interceptors/auth_interceptor.dart @@ -1,18 +1,23 @@ import 'package:dio/dio.dart'; import '../../errors.dart'; +import '../../logger.dart'; import '../../user.dart'; import '../stream_core_dio_error.dart'; /// Interceptor that signs every request with the caller's token. /// /// A request the server refuses for an expired token is retried once, carrying a replacement. +/// +/// Reports what it decided under `SC:HttpAuth`, including every reason it left a refused request +/// refused. Nothing is written until an app installs a [StreamLogHandler]. class AuthInterceptor extends Interceptor { /// Creates a new [AuthInterceptor]. - AuthInterceptor(this._dio, this._tokenManager); + AuthInterceptor(this._dio, this._tokenManager, {String tag = 'SC:HttpAuth'}) : _logger = StreamLogger(tag); final Dio _dio; final TokenManager _tokenManager; + final StreamLogger _logger; // Not a `QueuedInterceptor`: it frees a slot only once a handler completes, so the retry sent from // `onError` would wait behind the request holding it. `TokenManager` serialises the token loads. @@ -33,6 +38,8 @@ class AuthInterceptor extends Interceptor { return handler.next(options); } catch (e, stackTrace) { + _logger.w(() => 'no token to sign ${options.uri} with', error: e, stackTrace: stackTrace); + final error = ClientException( message: 'Failed to load auth token', stackTrace: stackTrace, @@ -62,9 +69,23 @@ class AuthInterceptor extends Interceptor { // A retry after a user switch would perform this request as the new user. final signedFor = options.queryParameters['user_id']; final canRefresh = signedFor == _tokenManager.userId && !_tokenManager.usesStaticProvider; - if (!canRefresh) return handler.next(err); + if (!canRefresh) { + _logger.d(() { + final reason = switch (_tokenManager.usesStaticProvider) { + true => 'the token provider is static and has nothing fresher to give', + false => 'it was signed for $signedFor, and the user is now ${_tokenManager.userId}', + }; - if (options.extra[_retriedKey] == true) return handler.next(err); + return 'not refreshing the token behind ${options.uri}: $reason'; + }); + + return handler.next(err); + } + + if (options.extra[_retriedKey] == true) { + _logger.w(() => 'the replacement token was refused too, leaving ${options.uri} failed'); + return handler.next(err); + } // Another request may have replaced it already, and expiring that would discard a valid token. if (options.headers['Authorization'] == _tokenManager.peekToken()?.rawValue) { @@ -78,11 +99,14 @@ class AuthInterceptor extends Interceptor { data: data is FormData ? data.clone() : data, ); + _logger.d(() => 'retrying ${options.uri} with a replacement token'); + try { // ignore: inference_failure_on_function_invocation final response = await _dio.fetch(retry); return handler.resolve(response); } on DioException catch (exception) { + _logger.w(() => 'the retry of ${options.uri} failed too', error: exception); return handler.reject(exception); } } diff --git a/packages/stream_core/test/api/interceptors/auth_interceptor_test.dart b/packages/stream_core/test/api/interceptors/auth_interceptor_test.dart index 72725aa1..f2758bd7 100644 --- a/packages/stream_core/test/api/interceptors/auth_interceptor_test.dart +++ b/packages/stream_core/test/api/interceptors/auth_interceptor_test.dart @@ -4,6 +4,7 @@ import 'dart:convert'; import 'package:stream_core/stream_core.dart'; import 'package:test/test.dart'; +import '../../helpers/logger.dart'; import '../../helpers/user_token.dart'; /// The body the API returns when the token it was given has run out. @@ -545,5 +546,55 @@ void main() { expect(loads(), 1); }); }); + + group('what the logger sees', () { + test('reports the retry and the token behind it', () async { + final handler = RecordingLogHandler(); + final (:dio, api: _, tokens: _, loads: _) = _subject(api: _FakeApi(refusals: 1)); + + await withStreamLogger(handler: handler, () => dio.get('/test')); + + expect(handler.tags, everyElement('SC:HttpAuth')); + expect(handler.messages.join(), contains('retrying')); + }); + + test('reports a refusal it will not retry, and why', () async { + final handler = RecordingLogHandler(); + final (:dio, api: _, tokens: _, loads: _) = _subject( + tokenProvider: TokenProvider.static(generateTestUserToken('user-1')), + api: _FakeApi(refusals: 1), + ); + + await withStreamLogger( + handler: handler, + () => expectLater(dio.get('/test'), throwsA(_expiredTokenError)), + ); + + // A refused request left refused is what someone debugging a stuck login is looking at, so + // the reason it was not retried has to be somewhere. + expect(handler.messages.join(), contains('static')); + }); + + test('reports a replacement that was refused too', () async { + final handler = RecordingLogHandler(); + final (:dio, api: _, tokens: _, loads: _) = _subject(api: _FakeApi(refusals: 2)); + + await withStreamLogger( + handler: handler, + () => expectLater(dio.get('/test'), throwsA(_expiredTokenError)), + ); + + expect(handler.messages.join(), contains('refused too')); + }); + + test('says nothing when no handler is installed', () async { + final (:dio, api: _, tokens: _, loads: _) = _subject(api: _FakeApi(refusals: 1)); + + final printed = capturePrints(() => dio.get('/test')); + await pumpEventQueue(); + + expect(printed, isEmpty); + }); + }); }); } From 6c1cde3ee56d276a6b0e960e327c9ebd07d03213 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Mon, 24 Aug 2026 23:11:15 +0200 Subject: [PATCH 04/29] fix(llc)!: stop the logging interceptor printing every request MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit It defaulted to bare `print`, so an SDK that installed it wrote every request and response to the console in every build, including release. With `requestHeader` on it wrote the `Authorization` header too — the interceptor runs after the request is signed, so a user's token went to the device log unasked. stream_feeds installs it exactly that way. Records now go through the logger, so nothing is written until an app installs a handler, and nothing is formatted either: the twenty-seven lines a request used to produce are not built while no handler wants them. BREAKING CHANGE: `LoggingInterceptor.logPrint` is now an optional, final `LogPrint?`. Leaving it unset routes lines to the logger rather than to `print`. --- packages/stream_core/CHANGELOG.md | 1 + .../api/interceptors/logging_interceptor.dart | 86 +++++++++---- .../logging_interceptor_test.dart | 117 ++++++++++++++++++ 3 files changed, 177 insertions(+), 27 deletions(-) create mode 100644 packages/stream_core/test/api/interceptors/logging_interceptor_test.dart diff --git a/packages/stream_core/CHANGELOG.md b/packages/stream_core/CHANGELOG.md index 8b41a3c9..5870922d 100644 --- a/packages/stream_core/CHANGELOG.md +++ b/packages/stream_core/CHANGELOG.md @@ -16,6 +16,7 @@ - `StreamApiError.isTokenExpiredError` now means code 40 only; the other token codes and a wrong API key are `isInvalidTokenError`. `isClientError` compares the HTTP `statusCode` against 400..499, rather than the Stream error `code`, which never falls in that range - `Result.getOrElse`, `getOrDefault`, `recover` and `recoverCatching` return the result's own type and no longer take a type parameter. To widen, widen the result (`Result widened = intResult`) or use `fold` - Replaced the logger: `StreamLogger` is the handle you write with and a `StreamLogHandler` is where records go, so `Priority`, `MessageBuilder`, `Tag`, `IsLoggableValidator` and `Finder` are renamed or gone +- `LoggingInterceptor` writes through the logger rather than printing, so it is silent until an app installs a handler. Its `logPrint` is now optional, and it takes a `tag` ### ✨ Features diff --git a/packages/stream_core/lib/src/api/interceptors/logging_interceptor.dart b/packages/stream_core/lib/src/api/interceptors/logging_interceptor.dart index 46d14e98..bcbda632 100644 --- a/packages/stream_core/lib/src/api/interceptors/logging_interceptor.dart +++ b/packages/stream_core/lib/src/api/interceptors/logging_interceptor.dart @@ -4,24 +4,31 @@ import 'dart:math' as math; import 'package:dio/dio.dart'; -/// Step where we're logging +import '../../logger.dart'; + +/// The stage of a request a record came from. enum InterceptStep { - /// Request + /// A request on its way out. request, - /// Response + /// A response that came back. response, - /// Error + /// A request that failed. error, } -/// Function used to print the log +/// Takes one line of the log, in place of the logger. typedef LogPrint = void Function(InterceptStep step, Object object); -void _defaultLogPrint(InterceptStep step, Object object) => print(object); - -/// Interceptor dedicated to logging +/// An interceptor that reports each request and the response it gets. +/// +/// Records go out under `SC:Http`, at [StreamLogPriority.debug], or [StreamLogPriority.warning] +/// for a request that failed. Nothing is written, or even formatted, until an app installs a +/// [StreamLogHandler]. +/// +/// [requestHeader] puts the `Authorization` header in the record along with the rest, so consider +/// what reads these before turning it on. class LoggingInterceptor extends Interceptor { /// Creates a new [LoggingInterceptor]. LoggingInterceptor({ @@ -33,46 +40,67 @@ class LoggingInterceptor extends Interceptor { this.error = true, this.maxWidth = 120, this.compact = true, - this.logPrint = _defaultLogPrint, - }); + this.logPrint, + String tag = 'SC:Http', + }) : _logger = StreamLogger(tag); + + final StreamLogger _logger; - /// Print request [Options] + /// Whether to report the request line. final bool request; - /// Print request header [Options.headers] + /// Whether to report the request's headers, query parameters and extras. final bool requestHeader; - /// Print request data [RequestOptions.data] + /// Whether to report the request body. final bool requestBody; - /// Print [Response.data] + /// Whether to report the response body. final bool responseBody; - /// Print [Response.headers] + /// Whether to report the response headers. final bool responseHeader; - /// Print error message + /// Whether to report a request that failed. final bool error; - /// InitialTab count to logPrint json response + /// The indent a nested value starts at. static const initialTab = 1; - /// 1 tab length + /// One level of indent. static const tabStep = ' '; - /// Print compact json response + /// Whether to report a nested map on one line, rather than one key per line. final bool compact; - /// Width size per logPrint + /// The width a line is wrapped at. final int maxWidth; - /// Log printer; defaults logPrint log to console. - /// In flutter, you'd better use debugPrint. - /// you can also write log in a file. - void Function(InterceptStep step, Object object) logPrint; + /// Takes each line instead of the logger, for a caller routing them somewhere of its own. + final LogPrint? logPrint; + + // Consulted before a line is formatted, so a request costs nothing while nothing wants it. + bool _wants(InterceptStep step) { + if (logPrint != null) return true; + return _logger.isLoggable(_priorityOf(step)); + } + + StreamLogPriority _priorityOf(InterceptStep step) { + return switch (step) { + InterceptStep.error => StreamLogPriority.warning, + InterceptStep.request || InterceptStep.response => StreamLogPriority.debug, + }; + } + + void _write(InterceptStep step, Object object) { + if (logPrint case final logPrint?) return logPrint(step, object); + return _logger.log(_priorityOf(step), () => '$object'); + } @override void onRequest(RequestOptions options, RequestInterceptorHandler handler) { + if (!_wants(InterceptStep.request)) return super.onRequest(options, handler); + if (request) { _printRequestHeader(_logPrintRequest, options); } @@ -119,6 +147,8 @@ class LoggingInterceptor extends Interceptor { @override void onError(DioException err, ErrorInterceptorHandler handler) { + if (!_wants(InterceptStep.error)) return super.onError(err, handler); + if (error) { if (err.type == DioExceptionType.badResponse) { final uri = err.response?.requestOptions.uri; @@ -150,6 +180,8 @@ class LoggingInterceptor extends Interceptor { Response response, ResponseInterceptorHandler handler, ) { + if (!_wants(InterceptStep.response)) return super.onResponse(response, handler); + _printResponseHeader(_logPrintResponse, response); if (responseHeader) { final responseHeaders = {}; @@ -348,9 +380,9 @@ class LoggingInterceptor extends Interceptor { _printLine(logPrint, '╚'); } - void _logPrintRequest(Object object) => logPrint(InterceptStep.request, object); + void _logPrintRequest(Object object) => _write(InterceptStep.request, object); - void _logPrintResponse(Object object) => logPrint(InterceptStep.response, object); + void _logPrintResponse(Object object) => _write(InterceptStep.response, object); - void _logPrintError(Object object) => logPrint(InterceptStep.error, object); + void _logPrintError(Object object) => _write(InterceptStep.error, object); } diff --git a/packages/stream_core/test/api/interceptors/logging_interceptor_test.dart b/packages/stream_core/test/api/interceptors/logging_interceptor_test.dart new file mode 100644 index 00000000..bf3531e0 --- /dev/null +++ b/packages/stream_core/test/api/interceptors/logging_interceptor_test.dart @@ -0,0 +1,117 @@ +import 'dart:convert'; + +import 'package:stream_core/stream_core.dart'; +import 'package:test/test.dart'; + +import '../../helpers/logger.dart'; +import '../../helpers/user_token.dart'; + +class _FakeApi implements HttpClientAdapter { + @override + Future fetch(RequestOptions o, Stream? s, Future? c) async { + return ResponseBody.fromString( + jsonEncode(const {'ok': true}), + 200, + headers: { + Headers.contentTypeHeader: [Headers.jsonContentType], + }, + ); + } + + @override + void close({bool force = false}) {} +} + +/// Builds the interceptor stack a Stream SDK puts on its client, in the same order. +Dio _subject({LogPrint? logPrint}) { + final tokens = TokenManager( + userId: 'user-1', + tokenProvider: TokenProvider.static(generateTestUserToken('user-1')), + ); + + final dio = Dio(BaseOptions(baseUrl: 'https://example.com'))..httpClientAdapter = _FakeApi(); + return dio + ..interceptors.addAll([ + AuthInterceptor(dio, tokens), + LoggingInterceptor(requestHeader: true, logPrint: logPrint), + ]); +} + +void main() { + group('LoggingInterceptor', () { + test('writes nothing until an app installs a handler', () async { + final dio = _subject(); + + final printed = capturePrints(() => dio.get('/test')); + await pumpEventQueue(); + + // It used to print every request, headers and all, in every build. + expect(printed, isEmpty); + }); + + test('leaves the credentials out of a log nobody asked for', () async { + final dio = _subject(); + final token = generateTestUserToken('user-1').rawValue; + + final printed = capturePrints(() => dio.get('/test')); + await pumpEventQueue(); + + // `requestHeader` puts `Authorization` in the record, and the request is signed before this + // interceptor sees it, so a log written unasked carries the user's token. + expect(printed.join(), isNot(contains(token))); + }); + + test('reports the request once a handler wants it', () async { + final handler = RecordingLogHandler(); + final dio = _subject(); + + await withStreamLogger( + handler: handler, + filter: const StreamLogFilter.minPriority(StreamLogPriority.debug), + () async { + await dio.get('/test'); + await pumpEventQueue(); + }, + ); + + expect(handler.tags, everyElement('SC:Http')); + expect(handler.messages.join(), contains('https://example.com/test')); + }); + + test('writes through the logger rather than printing, when given no printer', () async { + final handler = RecordingLogHandler(); + final dio = _subject(); + + final printed = await withStreamLogger( + handler: handler, + filter: const StreamLogFilter.always(), + () async { + final lines = capturePrints(() => dio.get('/test').ignore()); + await pumpEventQueue(); + return lines; + }, + ); + + expect(handler.records, isNotEmpty, reason: 'the records reached the handler'); + expect(printed, isEmpty, reason: 'and none of them went to the console'); + }); + + test('hands the lines to a printer a caller supplied, leaving the logger out of it', () async { + final handler = RecordingLogHandler(); + var lines = 0; + final dio = _subject(logPrint: (_, _) => lines++); + + await withStreamLogger( + handler: handler, + filter: const StreamLogFilter.always(), + () async { + await dio.get('/test'); + await pumpEventQueue(); + }, + ); + + expect(lines, greaterThan(0)); + expect(handler.records, isEmpty, reason: 'a supplied printer replaces the logger'); + }); + }); +} From 864a8236a5a7ace6215ced3dd2a9b24c608009c7 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Tue, 25 Aug 2026 00:23:09 +0200 Subject: [PATCH 05/29] feat(llc): let a product client settle logging in one step MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A product client took the two ambient setters itself, which meant every SDK reimplemented the same four rules: no config touches nothing, a priority alone writes to the console, a handler alone hears warnings, and none silences. Two SDKs remembering them differently would leave the shared logger holding whichever client was built last. StreamLogConfig carries them instead, and configure applies it. The config also carries the filter, because priority and filter are the same field underneath — a config that set only the priority flattened a prefix rule the app had installed. Co-Authored-By: Claude Opus 5 (1M context) --- packages/stream_core/CHANGELOG.md | 2 +- packages/stream_core/lib/src/logger.dart | 1 + .../lib/src/logger/stream_log_config.dart | 57 +++++++++++++++ .../lib/src/logger/stream_logger.dart | 17 +++++ .../logging_interceptor_test.dart | 24 ++++++- .../test/logger/stream_log_config_test.dart | 72 +++++++++++++++++++ 6 files changed, 170 insertions(+), 3 deletions(-) create mode 100644 packages/stream_core/lib/src/logger/stream_log_config.dart create mode 100644 packages/stream_core/test/logger/stream_log_config_test.dart diff --git a/packages/stream_core/CHANGELOG.md b/packages/stream_core/CHANGELOG.md index 5870922d..904147da 100644 --- a/packages/stream_core/CHANGELOG.md +++ b/packages/stream_core/CHANGELOG.md @@ -20,7 +20,7 @@ ### ✨ Features -- Added a logger the SDK now reports itself through, silent until an app installs a `StreamLogHandler` +- Added a logger the SDK now reports itself through, silent until an app installs a `StreamLogHandler` or a product client passes `StreamLogger.configure` the `StreamLogConfig` it was given - Added `TokenManager.setTokenProvider`, which points an existing manager at another user and expires the cached token; handed the identity it already has, it does nothing - Added optional `onTokenUpdated` callback to `TokenManager`, invoked after every successful token load - Added optional `rawValue` to `UserToken.anonymous`, so an anonymous token can carry a JWT granting restricted access; its `user_id` claim must be `!anon` diff --git a/packages/stream_core/lib/src/logger.dart b/packages/stream_core/lib/src/logger.dart index bd57982f..2e0e220d 100644 --- a/packages/stream_core/lib/src/logger.dart +++ b/packages/stream_core/lib/src/logger.dart @@ -1,3 +1,4 @@ +export 'logger/stream_log_config.dart'; export 'logger/stream_log_filter.dart'; export 'logger/stream_log_handler.dart'; export 'logger/stream_log_priority.dart'; diff --git a/packages/stream_core/lib/src/logger/stream_log_config.dart b/packages/stream_core/lib/src/logger/stream_log_config.dart new file mode 100644 index 00000000..047e0cf2 --- /dev/null +++ b/packages/stream_core/lib/src/logger/stream_log_config.dart @@ -0,0 +1,57 @@ +import 'stream_log_filter.dart'; +import 'stream_log_handler.dart'; +import 'stream_log_priority.dart'; + +/// How much a Stream SDK reports, and where those records go. +/// +/// What a product client takes in place of setting [StreamLogger.handler] and its neighbours +/// itself, so that every Stream SDK asks for logging the same way and settles it in one step: +/// +/// ```dart +/// StreamFeedsClient( +/// apiKey: 'your-api-key', +/// user: user, +/// config: const FeedsConfig( +/// logging: StreamLogConfig(priority: StreamLogPriority.debug), +/// ), +/// ); +/// ``` +/// +/// The logger is shared by every Stream SDK in a process, so a client that is given no config +/// installs nothing at all rather than deciding for the others. +class StreamLogConfig { + /// Creates a [StreamLogConfig]. + const StreamLogConfig({ + this.priority = StreamLogPriority.warning, + this.handler = defaultHandler, + this.filter, + }); + + /// Where records go when a config names no handler of its own. + static const defaultHandler = StreamLogHandler.console(); + + /// The lowest priority worth reporting. + /// + /// [StreamLogPriority.none] silences a logger another SDK configured. Ignored where [filter] is + /// given, which decides the same thing in more detail. + final StreamLogPriority priority; + + /// Where records go. + /// + /// Compose with [defaultHandler] to keep the console alongside a handler of your own. + final StreamLogHandler handler; + + /// Which records are built at all, for a rule [priority] cannot express. + /// + /// Holds one subsystem to a different threshold than the rest: + /// + /// ```dart + /// StreamLogConfig( + /// filter: StreamLogFilter.prefix( + /// {'SF:Ws': StreamLogPriority.verbose}, + /// otherwise: StreamLogPriority.warning, + /// ), + /// ) + /// ``` + final StreamLogFilter? filter; +} diff --git a/packages/stream_core/lib/src/logger/stream_logger.dart b/packages/stream_core/lib/src/logger/stream_logger.dart index 8afb0850..717eee21 100644 --- a/packages/stream_core/lib/src/logger/stream_logger.dart +++ b/packages/stream_core/lib/src/logger/stream_logger.dart @@ -1,5 +1,6 @@ import 'package:meta/meta.dart'; +import 'stream_log_config.dart'; import 'stream_log_filter.dart'; import 'stream_log_handler.dart'; import 'stream_log_priority.dart'; @@ -118,6 +119,22 @@ final class StreamLogger { /// ``` static set filter(StreamLogFilter filter) => _filterOrDefault = filter; + /// Installs [config] in one step, or leaves the logger untouched where it is null. + /// + /// What a product client calls with whatever its own config was given, so that an app running + /// two Stream SDKs gets the same answer from both, and neither decides logging for an app that + /// never asked: + /// + /// ```dart + /// StreamLogger.configure(config.logging); + /// ``` + static void configure(StreamLogConfig? config) { + if (config == null) return; + + _handlerOrDefault = config.handler; + _filterOrDefault = config.filter ?? .minPriority(config.priority); + } + /// Puts [handler] and [priority] back to what they were before anything was installed. /// /// What an app installs is process-wide, so a test that installs a handler and leaves it there diff --git a/packages/stream_core/test/api/interceptors/logging_interceptor_test.dart b/packages/stream_core/test/api/interceptors/logging_interceptor_test.dart index bf3531e0..7eed8dfb 100644 --- a/packages/stream_core/test/api/interceptors/logging_interceptor_test.dart +++ b/packages/stream_core/test/api/interceptors/logging_interceptor_test.dart @@ -23,7 +23,7 @@ class _FakeApi implements HttpClientAdapter { } /// Builds the interceptor stack a Stream SDK puts on its client, in the same order. -Dio _subject({LogPrint? logPrint}) { +Dio _subject({LogPrint? logPrint, bool requestHeader = true}) { final tokens = TokenManager( userId: 'user-1', tokenProvider: TokenProvider.static(generateTestUserToken('user-1')), @@ -33,7 +33,7 @@ Dio _subject({LogPrint? logPrint}) { return dio ..interceptors.addAll([ AuthInterceptor(dio, tokens), - LoggingInterceptor(requestHeader: true, logPrint: logPrint), + LoggingInterceptor(requestHeader: requestHeader, logPrint: logPrint), ]); } @@ -61,6 +61,26 @@ void main() { expect(printed.join(), isNot(contains(token))); }); + test('keeps the credentials out of a log that was asked for, left at its defaults', () async { + final handler = RecordingLogHandler(); + final dio = _subject(requestHeader: false); + final token = generateTestUserToken('user-1').rawValue; + + await withStreamLogger( + handler: handler, + filter: const StreamLogFilter.always(), + () async { + await dio.get('/test'); + await pumpEventQueue(); + }, + ); + + // What a product gets by constructing the interceptor without arguments: turning `requestHeader` + // on is what puts the signed `Authorization` in a record, and nothing else does. + expect(handler.records, isNotEmpty, reason: 'the request was reported'); + expect(handler.messages.join(), isNot(contains(token))); + }); + test('reports the request once a handler wants it', () async { final handler = RecordingLogHandler(); final dio = _subject(); diff --git a/packages/stream_core/test/logger/stream_log_config_test.dart b/packages/stream_core/test/logger/stream_log_config_test.dart new file mode 100644 index 00000000..d52fb6f1 --- /dev/null +++ b/packages/stream_core/test/logger/stream_log_config_test.dart @@ -0,0 +1,72 @@ +import 'package:stream_core/stream_core.dart'; +import 'package:test/test.dart'; + +import '../helpers/logger.dart'; + +const _logger = StreamLogger('SF:Component'); + +void main() { + group('StreamLogger.configure', () { + tearDown(StreamLogger.reset); + + test('leaves the logger alone when there is no config', () { + final installed = RecordingLogHandler(); + StreamLogger.handler = installed; + StreamLogger.priority = StreamLogPriority.verbose; + + StreamLogger.configure(null); + _logger.d(() => 'another SDK, still heard'); + + // A client the app gave no logging must not decide it for the SDK beside it. + expect(installed.messages, ['another SDK, still heard']); + }); + + test('writes to the console, given a priority and nowhere to put it', () { + final printed = capturePrints(() { + StreamLogger.configure(const StreamLogConfig(priority: StreamLogPriority.debug)); + _logger.d(() => 'to the console'); + }); + + expect(printed.single, contains('to the console')); + }); + + test('hears warnings, given a handler and no priority', () { + final mine = RecordingLogHandler(); + + StreamLogger.configure(StreamLogConfig(handler: mine)); + _logger + ..d(() => 'commentary') + ..w(() => 'worth acting on'); + + expect(mine.messages, ['worth acting on']); + }); + + test('silences everything when asked for none', () { + final mine = RecordingLogHandler(); + + StreamLogger.configure( + StreamLogConfig(priority: StreamLogPriority.none, handler: mine), + ); + _logger.e(() => 'not even an error'); + + expect(mine.records, isEmpty); + }); + + test('holds one subsystem apart from the rest, given a filter', () { + final mine = RecordingLogHandler(); + + StreamLogger.configure( + StreamLogConfig( + handler: mine, + filter: const StreamLogFilter.prefix({'SF:Ws': StreamLogPriority.verbose}), + ), + ); + const StreamLogger('SF:Ws').v(() => 'the subsystem I turned up'); + _logger.d(() => 'the commentary I did not'); + + // The filter has to survive the config that carries it: `priority` sets the same field, so a + // config applying both would flatten the rule it was given. + expect(mine.messages, ['the subsystem I turned up']); + }); + }); +} From 7e5b3c494118d5a7760b96664adbdea77dda4718 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Tue, 25 Aug 2026 00:25:34 +0200 Subject: [PATCH 06/29] docs(llc): say that a config replaces the filter rather than joining it Both settings are one field underneath, so a config naming only a priority still drops a rule installed through the filter setter. The place to put the rule is the config, which carries it. Co-Authored-By: Claude Opus 5 (1M context) --- .../stream_core/lib/src/logger/stream_log_config.dart | 1 + .../stream_core/lib/src/logger/stream_logger.dart | 4 ++++ .../test/logger/stream_log_config_test.dart | 11 +++++++++++ 3 files changed, 16 insertions(+) diff --git a/packages/stream_core/lib/src/logger/stream_log_config.dart b/packages/stream_core/lib/src/logger/stream_log_config.dart index 047e0cf2..8a43aa66 100644 --- a/packages/stream_core/lib/src/logger/stream_log_config.dart +++ b/packages/stream_core/lib/src/logger/stream_log_config.dart @@ -1,6 +1,7 @@ import 'stream_log_filter.dart'; import 'stream_log_handler.dart'; import 'stream_log_priority.dart'; +import 'stream_logger.dart'; /// How much a Stream SDK reports, and where those records go. /// diff --git a/packages/stream_core/lib/src/logger/stream_logger.dart b/packages/stream_core/lib/src/logger/stream_logger.dart index 717eee21..092a921d 100644 --- a/packages/stream_core/lib/src/logger/stream_logger.dart +++ b/packages/stream_core/lib/src/logger/stream_logger.dart @@ -128,6 +128,10 @@ final class StreamLogger { /// ```dart /// StreamLogger.configure(config.logging); /// ``` + /// + /// A config replaces both settings outright, so anything installed through [filter] before this + /// is lost — including to a config that named only a [priority]. Put the rule in + /// [StreamLogConfig.filter] instead, where a client carries it rather than flattening it. static void configure(StreamLogConfig? config) { if (config == null) return; diff --git a/packages/stream_core/test/logger/stream_log_config_test.dart b/packages/stream_core/test/logger/stream_log_config_test.dart index d52fb6f1..23c61362 100644 --- a/packages/stream_core/test/logger/stream_log_config_test.dart +++ b/packages/stream_core/test/logger/stream_log_config_test.dart @@ -68,5 +68,16 @@ void main() { // config applying both would flatten the rule it was given. expect(mine.messages, ['the subsystem I turned up']); }); + test('replaces a filter installed before it, even naming only a priority', () { + final mine = RecordingLogHandler(); + StreamLogger.filter = const StreamLogFilter.prefix({'SF:Ws': StreamLogPriority.verbose}); + + StreamLogger.configure(StreamLogConfig(priority: StreamLogPriority.debug, handler: mine)); + const StreamLogger('SF:Ws').v(() => 'below what the config asked for'); + + // A config is the whole story: its priority and filter are one field underneath, so there is + // no reading of it that keeps an earlier rule and the new threshold both. + expect(mine.records, isEmpty); + }); }); } From fc39dfc94e82cf907889e235ff04dec571973403 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Tue, 25 Aug 2026 00:26:41 +0200 Subject: [PATCH 07/29] docs(llc): say what a config means for the SDK beside the one it came from MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One logger serves the process, so configuring a client turns logging on for every Stream SDK in it, and two configured differently settle on whichever was built last. Neither is guessable from a per-client config, and the way out — filtering on the prefix the tags already carry — was only documented as a way to tune a subsystem. Co-Authored-By: Claude Opus 5 (1M context) --- .../lib/src/logger/stream_logger.dart | 14 ++++++ .../test/logger/stream_log_config_test.dart | 50 +++++++++++++++++++ 2 files changed, 64 insertions(+) diff --git a/packages/stream_core/lib/src/logger/stream_logger.dart b/packages/stream_core/lib/src/logger/stream_logger.dart index 092a921d..e2cf9491 100644 --- a/packages/stream_core/lib/src/logger/stream_logger.dart +++ b/packages/stream_core/lib/src/logger/stream_logger.dart @@ -132,6 +132,20 @@ final class StreamLogger { /// A config replaces both settings outright, so anything installed through [filter] before this /// is lost — including to a config that named only a [priority]. Put the rule in /// [StreamLogConfig.filter] instead, where a client carries it rather than flattening it. + /// + /// One logger serves the process, so this decides logging for every Stream SDK in it, not only + /// the one whose config it came from, and two clients configured differently settle on whichever + /// was constructed last. An app wanting one SDK's records and not another's says so by the prefix + /// their tags carry: + /// + /// ```dart + /// StreamLogConfig( + /// filter: StreamLogFilter.prefix( + /// {'SF:': StreamLogPriority.debug}, + /// otherwise: StreamLogPriority.none, + /// ), + /// ) + /// ``` static void configure(StreamLogConfig? config) { if (config == null) return; diff --git a/packages/stream_core/test/logger/stream_log_config_test.dart b/packages/stream_core/test/logger/stream_log_config_test.dart index 23c61362..d79bc17b 100644 --- a/packages/stream_core/test/logger/stream_log_config_test.dart +++ b/packages/stream_core/test/logger/stream_log_config_test.dart @@ -80,4 +80,54 @@ void main() { expect(mine.records, isEmpty); }); }); + + group('two Stream SDKs in one app', () { + const feeds = StreamLogger('SF:Ws'); + const video = StreamLogger('SV:Call'); + + tearDown(StreamLogger.reset); + + test('report together once either of them is configured', () { + final mine = RecordingLogHandler(); + + StreamLogger.configure(StreamLogConfig(priority: StreamLogPriority.debug, handler: mine)); + feeds.d(() => 'feeds'); + video.d(() => 'video'); + + // One logger serves the process, so configuring a client turns logging on for the SDK beside + // it too. The tags are what tell them apart afterwards. + expect(mine.messages, ['feeds', 'video']); + }); + + test('settle on whichever was configured last, rather than merging', () { + final first = RecordingLogHandler(); + final second = RecordingLogHandler(); + + StreamLogger.configure(StreamLogConfig(handler: first)); + StreamLogger.configure(StreamLogConfig(handler: second)); + feeds.w(() => 'a warning'); + + expect(first.records, isEmpty); + expect(second.messages, ['a warning']); + }); + + test('can be held to one SDK by the prefix its tags carry', () { + final mine = RecordingLogHandler(); + + StreamLogger.configure( + StreamLogConfig( + handler: mine, + filter: const StreamLogFilter.prefix( + {'SF:': StreamLogPriority.debug}, + otherwise: StreamLogPriority.none, + ), + ), + ); + feeds.d(() => 'feeds'); + video.e(() => 'video, not asked for'); + + // What an app reaches for when it wants one SDK's records and not the other's. + expect(mine.messages, ['feeds']); + }); + }); } From 617bce10cf55f8f987c2e958b72896921f4e4bd7 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Tue, 25 Aug 2026 00:36:09 +0200 Subject: [PATCH 08/29] feat(llc): let a product's config govern only that product's records Configuring a client replaced the handler and filter for the whole process, so a second Stream SDK lost whatever the first installed, and an app that had set a filter itself lost it to the next client it built. A config given a scope now settles only the tags starting with it. A config naming no handler writes wherever the app already installed one, so asking for records no longer redirects them away from a destination the app chose. Co-Authored-By: Claude Opus 5 (1M context) --- .../lib/src/logger/stream_log_config.dart | 10 +- .../lib/src/logger/stream_logger.dart | 96 +++++++++++++++---- .../test/logger/stream_log_config_test.dart | 93 ++++++++++++------ 3 files changed, 148 insertions(+), 51 deletions(-) diff --git a/packages/stream_core/lib/src/logger/stream_log_config.dart b/packages/stream_core/lib/src/logger/stream_log_config.dart index 8a43aa66..e86da724 100644 --- a/packages/stream_core/lib/src/logger/stream_log_config.dart +++ b/packages/stream_core/lib/src/logger/stream_log_config.dart @@ -24,11 +24,11 @@ class StreamLogConfig { /// Creates a [StreamLogConfig]. const StreamLogConfig({ this.priority = StreamLogPriority.warning, - this.handler = defaultHandler, + this.handler, this.filter, }); - /// Where records go when a config names no handler of its own. + /// Where records go when a config names no handler and the app installed none. static const defaultHandler = StreamLogHandler.console(); /// The lowest priority worth reporting. @@ -39,8 +39,10 @@ class StreamLogConfig { /// Where records go. /// - /// Compose with [defaultHandler] to keep the console alongside a handler of your own. - final StreamLogHandler handler; + /// Left out, records go wherever the app already installed a handler, or to [defaultHandler] if + /// it installed none — so asking for records never takes them away from a destination the app + /// chose. Compose with [defaultHandler] to keep the console alongside a handler of your own. + final StreamLogHandler? handler; /// Which records are built at all, for a rule [priority] cannot express. /// diff --git a/packages/stream_core/lib/src/logger/stream_logger.dart b/packages/stream_core/lib/src/logger/stream_logger.dart index e2cf9491..83e4129b 100644 --- a/packages/stream_core/lib/src/logger/stream_logger.dart +++ b/packages/stream_core/lib/src/logger/stream_logger.dart @@ -81,6 +81,40 @@ final class StreamLogger { static StreamLogHandler _handlerOrDefault = StreamLogHandler.silent; static StreamLogFilter _filterOrDefault = const .minPriority(.warning); + static var _handlerInstalled = false; + + // Keyed by the tag prefix a product's records carry, so one product's config governs its own + // records and no one else's. + static final _scopes = {}; + + static StreamLogConfig? _scopeFor(String tag) { + if (_scopes.isEmpty) return null; + + StreamLogConfig? matched; + var matchedLength = -1; + + for (final MapEntry(key: prefix, value: config) in _scopes.entries) { + if (prefix.length <= matchedLength) continue; + if (!tag.startsWith(prefix)) continue; + + matched = config; + matchedLength = prefix.length; + } + + return matched; + } + + static StreamLogFilter _filterFor(StreamLogConfig? scope) { + if (scope == null) return _filterOrDefault; + return scope.filter ?? .minPriority(scope.priority); + } + + static StreamLogHandler _handlerFor(StreamLogConfig? scope) { + if (scope?.handler case final handler?) return handler; + if (_handlerInstalled) return _handlerOrDefault; + // A scope asked for records with nowhere to put them, which would otherwise be silence. + return scope != null ? StreamLogConfig.defaultHandler : _handlerOrDefault; + } /// Installs where every record goes, other than those from a [StreamLogger.detached] logger. /// @@ -94,7 +128,10 @@ final class StreamLogger { /// /// Write-only, so nothing can come to depend on what happens to be installed. Consider /// [StreamLogHandler.composite] to send records to more than one place. - static set handler(StreamLogHandler handler) => _handlerOrDefault = handler; + static set handler(StreamLogHandler handler) { + _handlerOrDefault = handler; + _handlerInstalled = true; + } /// Installs the lowest priority worth building a record for. /// @@ -129,27 +166,37 @@ final class StreamLogger { /// StreamLogger.configure(config.logging); /// ``` /// - /// A config replaces both settings outright, so anything installed through [filter] before this - /// is lost — including to a config that named only a [priority]. Put the rule in - /// [StreamLogConfig.filter] instead, where a client carries it rather than flattening it. - /// - /// One logger serves the process, so this decides logging for every Stream SDK in it, not only - /// the one whose config it came from, and two clients configured differently settle on whichever - /// was constructed last. An app wanting one SDK's records and not another's says so by the prefix - /// their tags carry: + /// Given a [scope], the config governs only the records whose tag starts with it, and the rest + /// of the process is left alone. A product client passes the prefix its own records carry, so + /// two Stream SDKs configured differently each get what they asked for rather than the one built + /// last deciding for both: /// /// ```dart - /// StreamLogConfig( - /// filter: StreamLogFilter.prefix( - /// {'SF:': StreamLogPriority.debug}, - /// otherwise: StreamLogPriority.none, - /// ), - /// ) + /// StreamLogger.configure(config.logging, scope: 'SF:'); /// ``` - static void configure(StreamLogConfig? config) { + /// + /// Without a scope the config governs everything, replacing both settings outright — including a + /// rule installed through [filter], since [StreamLogConfig.priority] sets the same thing. + /// + /// A config naming no handler writes wherever the app already installed one, or to + /// [StreamLogConfig.defaultHandler] if it installed none, so asking for records never redirects + /// them away from a destination the app chose. + static void configure(StreamLogConfig? config, {String? scope}) { if (config == null) return; - _handlerOrDefault = config.handler; + if (scope != null) { + _scopes[scope] = config; + return; + } + + if (config.handler case final handler?) { + _handlerOrDefault = handler; + _handlerInstalled = true; + } else if (!_handlerInstalled) { + _handlerOrDefault = StreamLogConfig.defaultHandler; + _handlerInstalled = true; + } + _filterOrDefault = config.filter ?? .minPriority(config.priority); } @@ -166,6 +213,8 @@ final class StreamLogger { static void reset() { _handlerOrDefault = StreamLogHandler.silent; _filterOrDefault = const .minPriority(.warning); + _handlerInstalled = false; + _scopes.clear(); } /// Whether a record at [priority] would be kept by both the filter and the handler. @@ -177,8 +226,14 @@ final class StreamLogger { /// if (_log.isLoggable(StreamLogPriority.verbose)) _log.v(() => describe(everyParticipant)); /// ``` bool isLoggable(StreamLogPriority priority) { - if (!(_filter ?? _filterOrDefault).isLoggable(priority, tag)) return false; - return (_handler ?? _handlerOrDefault).isLoggable(priority, tag); + if (_handler case final handler?) { + if (!_filter!.isLoggable(priority, tag)) return false; + return handler.isLoggable(priority, tag); + } + + final scope = _scopeFor(tag); + if (!_filterFor(scope).isLoggable(priority, tag)) return false; + return _handlerFor(scope).isLoggable(priority, tag); } /// Writes a [StreamLogPriority.verbose] record. @@ -251,6 +306,7 @@ final class StreamLogger { Object? error, StackTrace? stackTrace, }) { + final handler = _handler ?? _handlerFor(_scopeFor(tag)); if (!isLoggable(priority)) return; final record = StreamLogRecord( @@ -261,6 +317,6 @@ final class StreamLogger { stackTrace: stackTrace, ); - return (_handler ?? _handlerOrDefault).handle(record); + return handler.handle(record); } } diff --git a/packages/stream_core/test/logger/stream_log_config_test.dart b/packages/stream_core/test/logger/stream_log_config_test.dart index d79bc17b..ad99d2ea 100644 --- a/packages/stream_core/test/logger/stream_log_config_test.dart +++ b/packages/stream_core/test/logger/stream_log_config_test.dart @@ -87,47 +87,86 @@ void main() { tearDown(StreamLogger.reset); - test('report together once either of them is configured', () { + test('keep to their own records, each given a scope', () { + final theirs = RecordingLogHandler(); final mine = RecordingLogHandler(); - StreamLogger.configure(StreamLogConfig(priority: StreamLogPriority.debug, handler: mine)); - feeds.d(() => 'feeds'); - video.d(() => 'video'); - - // One logger serves the process, so configuring a client turns logging on for the SDK beside - // it too. The tags are what tell them apart afterwards. - expect(mine.messages, ['feeds', 'video']); + StreamLogger.configure( + StreamLogConfig(priority: StreamLogPriority.debug, handler: mine), + scope: 'SF:', + ); + StreamLogger.configure(StreamLogConfig(handler: theirs), scope: 'SV:'); + feeds.d(() => 'feeds debug'); + video + ..d(() => 'video debug') + ..w(() => 'video warning'); + + // Neither client decides for the other: feeds asked for debug and video did not, so video's + // commentary stays out even though both were configured. + expect(mine.messages, ['feeds debug']); + expect(theirs.messages, ['video warning']); }); - test('settle on whichever was configured last, rather than merging', () { + test('do not silence one another, whichever was built last', () { final first = RecordingLogHandler(); final second = RecordingLogHandler(); - StreamLogger.configure(StreamLogConfig(handler: first)); - StreamLogger.configure(StreamLogConfig(handler: second)); - feeds.w(() => 'a warning'); + StreamLogger.configure(StreamLogConfig(handler: first), scope: 'SF:'); + StreamLogger.configure(StreamLogConfig(handler: second), scope: 'SV:'); + feeds.w(() => 'feeds'); + video.w(() => 'video'); + + // The complaint this scoping answers: configuring one client used to discard what the other + // had installed, silently and by construction order. + expect(first.messages, ['feeds']); + expect(second.messages, ['video']); + }); + + test('leave a scope alone that no config named', () { + StreamLogger.configure(const StreamLogConfig(priority: StreamLogPriority.debug), scope: 'SF:'); + final printed = capturePrints(() { + feeds.d(() => 'feeds'); + video.e(() => 'video, never asked for'); + }); + + // Video's error is louder than the threshold feeds asked for, and still goes nowhere. + expect(printed.single, contains('feeds')); + }); + + test('write where the app installed a handler, rather than replacing it', () { + final appWide = RecordingLogHandler(); + StreamLogger.handler = appWide; - expect(first.records, isEmpty); - expect(second.messages, ['a warning']); + StreamLogger.configure(const StreamLogConfig(priority: StreamLogPriority.debug), scope: 'SF:'); + feeds.d(() => 'feeds'); + + // A config naming only a priority must not take the records away from the destination the + // app chose for everything. + expect(appWide.messages, ['feeds']); }); - test('can be held to one SDK by the prefix its tags carry', () { + test('govern everything, given no scope at all', () { final mine = RecordingLogHandler(); - StreamLogger.configure( - StreamLogConfig( - handler: mine, - filter: const StreamLogFilter.prefix( - {'SF:': StreamLogPriority.debug}, - otherwise: StreamLogPriority.none, - ), - ), - ); + StreamLogger.configure(StreamLogConfig(priority: StreamLogPriority.debug, handler: mine)); feeds.d(() => 'feeds'); - video.e(() => 'video, not asked for'); + video.d(() => 'video'); + + expect(mine.messages, ['feeds', 'video']); + }); + + test('decide their own records, over a rule the app set for everything', () { + final mine = RecordingLogHandler(); + StreamLogger.handler = mine; + StreamLogger.filter = const StreamLogFilter.always(); + + StreamLogger.configure(const StreamLogConfig(), scope: 'SF:'); + feeds.v(() => 'below what the scope admits'); + video.v(() => 'still what the app asked for'); - // What an app reaches for when it wants one SDK's records and not the other's. - expect(mine.messages, ['feeds']); + // A scope is the narrower statement, so it settles its own tags. The app's rule keeps the + // ones no scope claimed. + expect(mine.messages, ['still what the app asked for']); }); }); } From 7b22162c23371bd73b47cd75340333b2ed20a552 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Tue, 25 Aug 2026 00:37:36 +0200 Subject: [PATCH 09/29] refactor(llc)!: name the tag tree what it is MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A tag was already a path — `SF:Ws:Engine` under `SF:Ws` under `SF:` — but the API called the branch a scope, which reads as an opaque key rather than as the parent every tag under it inherits from. Co-Authored-By: Claude Opus 5 (1M context) --- packages/stream_core/CHANGELOG.md | 2 +- .../lib/src/logger/stream_logger.dart | 64 ++++++++++--------- .../test/logger/stream_log_config_test.dart | 22 +++---- 3 files changed, 45 insertions(+), 43 deletions(-) diff --git a/packages/stream_core/CHANGELOG.md b/packages/stream_core/CHANGELOG.md index 904147da..762a73c4 100644 --- a/packages/stream_core/CHANGELOG.md +++ b/packages/stream_core/CHANGELOG.md @@ -20,7 +20,7 @@ ### ✨ Features -- Added a logger the SDK now reports itself through, silent until an app installs a `StreamLogHandler` or a product client passes `StreamLogger.configure` the `StreamLogConfig` it was given +- Added a logger the SDK now reports itself through, silent until an app installs a `StreamLogHandler`. Tags are paths, so a product client settles its own branch with `StreamLogger.configure(config, parent: 'SF:')` and two Stream SDKs in one app each report on their own terms - Added `TokenManager.setTokenProvider`, which points an existing manager at another user and expires the cached token; handed the identity it already has, it does nothing - Added optional `onTokenUpdated` callback to `TokenManager`, invoked after every successful token load - Added optional `rawValue` to `UserToken.anonymous`, so an anonymous token can carry a JWT granting restricted access; its `user_id` claim must be `!anon` diff --git a/packages/stream_core/lib/src/logger/stream_logger.dart b/packages/stream_core/lib/src/logger/stream_logger.dart index 83e4129b..1eff2a2e 100644 --- a/packages/stream_core/lib/src/logger/stream_logger.dart +++ b/packages/stream_core/lib/src/logger/stream_logger.dart @@ -34,9 +34,10 @@ typedef StreamLogMessage = String Function(); /// StreamLogger.priority = StreamLogPriority.debug; /// ``` /// -/// Records go to one place, so routing two SDKs apart is a matter of a [StreamLogHandler] reading -/// [StreamLogRecord.tag] rather than of finding every component that had to be handed something. -/// For the exception, see [StreamLogger.detached]. +/// A tag is a path: `SF:Ws:Engine` sits under `SF:Ws`, which sits under `SF:`. A product settles +/// its own branch through [configure], so two Stream SDKs in one app each report on their own +/// terms without either being handed a logger to pass around. For the exception, see +/// [StreamLogger.detached]. final class StreamLogger { /// Creates a [StreamLogger] writing under [tag] to whatever the app has installed. const StreamLogger(this.tag) : _handler = null, _filter = null; @@ -83,17 +84,17 @@ final class StreamLogger { static StreamLogFilter _filterOrDefault = const .minPriority(.warning); static var _handlerInstalled = false; - // Keyed by the tag prefix a product's records carry, so one product's config governs its own - // records and no one else's. - static final _scopes = {}; + // Keyed by the tag prefix a product's records share, so one product's config settles its own + // branch and no one else's. + static final _parents = {}; - static StreamLogConfig? _scopeFor(String tag) { - if (_scopes.isEmpty) return null; + static StreamLogConfig? _parentOf(String tag) { + if (_parents.isEmpty) return null; StreamLogConfig? matched; var matchedLength = -1; - for (final MapEntry(key: prefix, value: config) in _scopes.entries) { + for (final MapEntry(key: prefix, value: config) in _parents.entries) { if (prefix.length <= matchedLength) continue; if (!tag.startsWith(prefix)) continue; @@ -104,16 +105,16 @@ final class StreamLogger { return matched; } - static StreamLogFilter _filterFor(StreamLogConfig? scope) { - if (scope == null) return _filterOrDefault; - return scope.filter ?? .minPriority(scope.priority); + static StreamLogFilter _filterFor(StreamLogConfig? parent) { + if (parent == null) return _filterOrDefault; + return parent.filter ?? .minPriority(parent.priority); } - static StreamLogHandler _handlerFor(StreamLogConfig? scope) { - if (scope?.handler case final handler?) return handler; + static StreamLogHandler _handlerFor(StreamLogConfig? parent) { + if (parent?.handler case final handler?) return handler; if (_handlerInstalled) return _handlerOrDefault; - // A scope asked for records with nowhere to put them, which would otherwise be silence. - return scope != null ? StreamLogConfig.defaultHandler : _handlerOrDefault; + // A branch asked for records with nowhere to put them, which would otherwise be silence. + return parent != null ? StreamLogConfig.defaultHandler : _handlerOrDefault; } /// Installs where every record goes, other than those from a [StreamLogger.detached] logger. @@ -166,26 +167,27 @@ final class StreamLogger { /// StreamLogger.configure(config.logging); /// ``` /// - /// Given a [scope], the config governs only the records whose tag starts with it, and the rest - /// of the process is left alone. A product client passes the prefix its own records carry, so - /// two Stream SDKs configured differently each get what they asked for rather than the one built - /// last deciding for both: + /// Given a [parent], the config settles that branch of the tag tree and leaves the rest of the + /// process alone. A product client names the prefix its own records share, so two Stream SDKs + /// configured differently each get what they asked for rather than the one built last deciding + /// for both: /// /// ```dart - /// StreamLogger.configure(config.logging, scope: 'SF:'); + /// StreamLogger.configure(config.logging, parent: 'SF:'); /// ``` /// - /// Without a scope the config governs everything, replacing both settings outright — including a - /// rule installed through [filter], since [StreamLogConfig.priority] sets the same thing. + /// A branch is the narrower statement, so it decides its own tags over anything installed through + /// [filter] or [priority], which keep the tags no branch claimed. Without a [parent] the config + /// governs everything, replacing both outright. /// /// A config naming no handler writes wherever the app already installed one, or to /// [StreamLogConfig.defaultHandler] if it installed none, so asking for records never redirects /// them away from a destination the app chose. - static void configure(StreamLogConfig? config, {String? scope}) { + static void configure(StreamLogConfig? config, {String? parent}) { if (config == null) return; - if (scope != null) { - _scopes[scope] = config; + if (parent != null) { + _parents[parent] = config; return; } @@ -214,7 +216,7 @@ final class StreamLogger { _handlerOrDefault = StreamLogHandler.silent; _filterOrDefault = const .minPriority(.warning); _handlerInstalled = false; - _scopes.clear(); + _parents.clear(); } /// Whether a record at [priority] would be kept by both the filter and the handler. @@ -231,9 +233,9 @@ final class StreamLogger { return handler.isLoggable(priority, tag); } - final scope = _scopeFor(tag); - if (!_filterFor(scope).isLoggable(priority, tag)) return false; - return _handlerFor(scope).isLoggable(priority, tag); + final parent = _parentOf(tag); + if (!_filterFor(parent).isLoggable(priority, tag)) return false; + return _handlerFor(parent).isLoggable(priority, tag); } /// Writes a [StreamLogPriority.verbose] record. @@ -306,7 +308,7 @@ final class StreamLogger { Object? error, StackTrace? stackTrace, }) { - final handler = _handler ?? _handlerFor(_scopeFor(tag)); + final handler = _handler ?? _handlerFor(_parentOf(tag)); if (!isLoggable(priority)) return; final record = StreamLogRecord( diff --git a/packages/stream_core/test/logger/stream_log_config_test.dart b/packages/stream_core/test/logger/stream_log_config_test.dart index ad99d2ea..a4181ec0 100644 --- a/packages/stream_core/test/logger/stream_log_config_test.dart +++ b/packages/stream_core/test/logger/stream_log_config_test.dart @@ -87,15 +87,15 @@ void main() { tearDown(StreamLogger.reset); - test('keep to their own records, each given a scope', () { + test('keep to their own records, each given a parent', () { final theirs = RecordingLogHandler(); final mine = RecordingLogHandler(); StreamLogger.configure( StreamLogConfig(priority: StreamLogPriority.debug, handler: mine), - scope: 'SF:', + parent: 'SF:', ); - StreamLogger.configure(StreamLogConfig(handler: theirs), scope: 'SV:'); + StreamLogger.configure(StreamLogConfig(handler: theirs), parent: 'SV:'); feeds.d(() => 'feeds debug'); video ..d(() => 'video debug') @@ -111,8 +111,8 @@ void main() { final first = RecordingLogHandler(); final second = RecordingLogHandler(); - StreamLogger.configure(StreamLogConfig(handler: first), scope: 'SF:'); - StreamLogger.configure(StreamLogConfig(handler: second), scope: 'SV:'); + StreamLogger.configure(StreamLogConfig(handler: first), parent: 'SF:'); + StreamLogger.configure(StreamLogConfig(handler: second), parent: 'SV:'); feeds.w(() => 'feeds'); video.w(() => 'video'); @@ -122,8 +122,8 @@ void main() { expect(second.messages, ['video']); }); - test('leave a scope alone that no config named', () { - StreamLogger.configure(const StreamLogConfig(priority: StreamLogPriority.debug), scope: 'SF:'); + test('leave a branch alone that no config named', () { + StreamLogger.configure(const StreamLogConfig(priority: StreamLogPriority.debug), parent: 'SF:'); final printed = capturePrints(() { feeds.d(() => 'feeds'); video.e(() => 'video, never asked for'); @@ -137,7 +137,7 @@ void main() { final appWide = RecordingLogHandler(); StreamLogger.handler = appWide; - StreamLogger.configure(const StreamLogConfig(priority: StreamLogPriority.debug), scope: 'SF:'); + StreamLogger.configure(const StreamLogConfig(priority: StreamLogPriority.debug), parent: 'SF:'); feeds.d(() => 'feeds'); // A config naming only a priority must not take the records away from the destination the @@ -145,7 +145,7 @@ void main() { expect(appWide.messages, ['feeds']); }); - test('govern everything, given no scope at all', () { + test('govern everything, given no parent at all', () { final mine = RecordingLogHandler(); StreamLogger.configure(StreamLogConfig(priority: StreamLogPriority.debug, handler: mine)); @@ -160,11 +160,11 @@ void main() { StreamLogger.handler = mine; StreamLogger.filter = const StreamLogFilter.always(); - StreamLogger.configure(const StreamLogConfig(), scope: 'SF:'); + StreamLogger.configure(const StreamLogConfig(), parent: 'SF:'); feeds.v(() => 'below what the scope admits'); video.v(() => 'still what the app asked for'); - // A scope is the narrower statement, so it settles its own tags. The app's rule keeps the + // A branch is the narrower statement, so it settles its own tags. The app's rule keeps the // ones no scope claimed. expect(mine.messages, ['still what the app asked for']); }); From 4c06abcc0b4da4e231ec219e0160d107a6b87def Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Tue, 25 Aug 2026 00:44:11 +0200 Subject: [PATCH 10/29] Revert "feat(llc): let a product's config govern only that product's records" This reverts d87ef5a and 7f78698, returning to one logger for every product. Scoping a config to a branch of the tag tree bought isolation at the cost of a handler that resolved through three fallbacks before it found a destination, which is harder to follow than the behaviour it was protecting against. One logger serves the process and the docs say so, including that two clients configured differently settle on whichever was built last, and that a prefix filter is what holds one SDK apart from another. Co-Authored-By: Claude Opus 5 (1M context) --- packages/stream_core/CHANGELOG.md | 2 +- .../lib/src/logger/stream_log_config.dart | 10 +- .../lib/src/logger/stream_logger.dart | 104 ++++-------------- .../test/logger/stream_log_config_test.dart | 93 +++++----------- 4 files changed, 55 insertions(+), 154 deletions(-) diff --git a/packages/stream_core/CHANGELOG.md b/packages/stream_core/CHANGELOG.md index 762a73c4..904147da 100644 --- a/packages/stream_core/CHANGELOG.md +++ b/packages/stream_core/CHANGELOG.md @@ -20,7 +20,7 @@ ### ✨ Features -- Added a logger the SDK now reports itself through, silent until an app installs a `StreamLogHandler`. Tags are paths, so a product client settles its own branch with `StreamLogger.configure(config, parent: 'SF:')` and two Stream SDKs in one app each report on their own terms +- Added a logger the SDK now reports itself through, silent until an app installs a `StreamLogHandler` or a product client passes `StreamLogger.configure` the `StreamLogConfig` it was given - Added `TokenManager.setTokenProvider`, which points an existing manager at another user and expires the cached token; handed the identity it already has, it does nothing - Added optional `onTokenUpdated` callback to `TokenManager`, invoked after every successful token load - Added optional `rawValue` to `UserToken.anonymous`, so an anonymous token can carry a JWT granting restricted access; its `user_id` claim must be `!anon` diff --git a/packages/stream_core/lib/src/logger/stream_log_config.dart b/packages/stream_core/lib/src/logger/stream_log_config.dart index e86da724..8a43aa66 100644 --- a/packages/stream_core/lib/src/logger/stream_log_config.dart +++ b/packages/stream_core/lib/src/logger/stream_log_config.dart @@ -24,11 +24,11 @@ class StreamLogConfig { /// Creates a [StreamLogConfig]. const StreamLogConfig({ this.priority = StreamLogPriority.warning, - this.handler, + this.handler = defaultHandler, this.filter, }); - /// Where records go when a config names no handler and the app installed none. + /// Where records go when a config names no handler of its own. static const defaultHandler = StreamLogHandler.console(); /// The lowest priority worth reporting. @@ -39,10 +39,8 @@ class StreamLogConfig { /// Where records go. /// - /// Left out, records go wherever the app already installed a handler, or to [defaultHandler] if - /// it installed none — so asking for records never takes them away from a destination the app - /// chose. Compose with [defaultHandler] to keep the console alongside a handler of your own. - final StreamLogHandler? handler; + /// Compose with [defaultHandler] to keep the console alongside a handler of your own. + final StreamLogHandler handler; /// Which records are built at all, for a rule [priority] cannot express. /// diff --git a/packages/stream_core/lib/src/logger/stream_logger.dart b/packages/stream_core/lib/src/logger/stream_logger.dart index 1eff2a2e..e2cf9491 100644 --- a/packages/stream_core/lib/src/logger/stream_logger.dart +++ b/packages/stream_core/lib/src/logger/stream_logger.dart @@ -34,10 +34,9 @@ typedef StreamLogMessage = String Function(); /// StreamLogger.priority = StreamLogPriority.debug; /// ``` /// -/// A tag is a path: `SF:Ws:Engine` sits under `SF:Ws`, which sits under `SF:`. A product settles -/// its own branch through [configure], so two Stream SDKs in one app each report on their own -/// terms without either being handed a logger to pass around. For the exception, see -/// [StreamLogger.detached]. +/// Records go to one place, so routing two SDKs apart is a matter of a [StreamLogHandler] reading +/// [StreamLogRecord.tag] rather than of finding every component that had to be handed something. +/// For the exception, see [StreamLogger.detached]. final class StreamLogger { /// Creates a [StreamLogger] writing under [tag] to whatever the app has installed. const StreamLogger(this.tag) : _handler = null, _filter = null; @@ -82,40 +81,6 @@ final class StreamLogger { static StreamLogHandler _handlerOrDefault = StreamLogHandler.silent; static StreamLogFilter _filterOrDefault = const .minPriority(.warning); - static var _handlerInstalled = false; - - // Keyed by the tag prefix a product's records share, so one product's config settles its own - // branch and no one else's. - static final _parents = {}; - - static StreamLogConfig? _parentOf(String tag) { - if (_parents.isEmpty) return null; - - StreamLogConfig? matched; - var matchedLength = -1; - - for (final MapEntry(key: prefix, value: config) in _parents.entries) { - if (prefix.length <= matchedLength) continue; - if (!tag.startsWith(prefix)) continue; - - matched = config; - matchedLength = prefix.length; - } - - return matched; - } - - static StreamLogFilter _filterFor(StreamLogConfig? parent) { - if (parent == null) return _filterOrDefault; - return parent.filter ?? .minPriority(parent.priority); - } - - static StreamLogHandler _handlerFor(StreamLogConfig? parent) { - if (parent?.handler case final handler?) return handler; - if (_handlerInstalled) return _handlerOrDefault; - // A branch asked for records with nowhere to put them, which would otherwise be silence. - return parent != null ? StreamLogConfig.defaultHandler : _handlerOrDefault; - } /// Installs where every record goes, other than those from a [StreamLogger.detached] logger. /// @@ -129,10 +94,7 @@ final class StreamLogger { /// /// Write-only, so nothing can come to depend on what happens to be installed. Consider /// [StreamLogHandler.composite] to send records to more than one place. - static set handler(StreamLogHandler handler) { - _handlerOrDefault = handler; - _handlerInstalled = true; - } + static set handler(StreamLogHandler handler) => _handlerOrDefault = handler; /// Installs the lowest priority worth building a record for. /// @@ -167,38 +129,27 @@ final class StreamLogger { /// StreamLogger.configure(config.logging); /// ``` /// - /// Given a [parent], the config settles that branch of the tag tree and leaves the rest of the - /// process alone. A product client names the prefix its own records share, so two Stream SDKs - /// configured differently each get what they asked for rather than the one built last deciding - /// for both: + /// A config replaces both settings outright, so anything installed through [filter] before this + /// is lost — including to a config that named only a [priority]. Put the rule in + /// [StreamLogConfig.filter] instead, where a client carries it rather than flattening it. + /// + /// One logger serves the process, so this decides logging for every Stream SDK in it, not only + /// the one whose config it came from, and two clients configured differently settle on whichever + /// was constructed last. An app wanting one SDK's records and not another's says so by the prefix + /// their tags carry: /// /// ```dart - /// StreamLogger.configure(config.logging, parent: 'SF:'); + /// StreamLogConfig( + /// filter: StreamLogFilter.prefix( + /// {'SF:': StreamLogPriority.debug}, + /// otherwise: StreamLogPriority.none, + /// ), + /// ) /// ``` - /// - /// A branch is the narrower statement, so it decides its own tags over anything installed through - /// [filter] or [priority], which keep the tags no branch claimed. Without a [parent] the config - /// governs everything, replacing both outright. - /// - /// A config naming no handler writes wherever the app already installed one, or to - /// [StreamLogConfig.defaultHandler] if it installed none, so asking for records never redirects - /// them away from a destination the app chose. - static void configure(StreamLogConfig? config, {String? parent}) { + static void configure(StreamLogConfig? config) { if (config == null) return; - if (parent != null) { - _parents[parent] = config; - return; - } - - if (config.handler case final handler?) { - _handlerOrDefault = handler; - _handlerInstalled = true; - } else if (!_handlerInstalled) { - _handlerOrDefault = StreamLogConfig.defaultHandler; - _handlerInstalled = true; - } - + _handlerOrDefault = config.handler; _filterOrDefault = config.filter ?? .minPriority(config.priority); } @@ -215,8 +166,6 @@ final class StreamLogger { static void reset() { _handlerOrDefault = StreamLogHandler.silent; _filterOrDefault = const .minPriority(.warning); - _handlerInstalled = false; - _parents.clear(); } /// Whether a record at [priority] would be kept by both the filter and the handler. @@ -228,14 +177,8 @@ final class StreamLogger { /// if (_log.isLoggable(StreamLogPriority.verbose)) _log.v(() => describe(everyParticipant)); /// ``` bool isLoggable(StreamLogPriority priority) { - if (_handler case final handler?) { - if (!_filter!.isLoggable(priority, tag)) return false; - return handler.isLoggable(priority, tag); - } - - final parent = _parentOf(tag); - if (!_filterFor(parent).isLoggable(priority, tag)) return false; - return _handlerFor(parent).isLoggable(priority, tag); + if (!(_filter ?? _filterOrDefault).isLoggable(priority, tag)) return false; + return (_handler ?? _handlerOrDefault).isLoggable(priority, tag); } /// Writes a [StreamLogPriority.verbose] record. @@ -308,7 +251,6 @@ final class StreamLogger { Object? error, StackTrace? stackTrace, }) { - final handler = _handler ?? _handlerFor(_parentOf(tag)); if (!isLoggable(priority)) return; final record = StreamLogRecord( @@ -319,6 +261,6 @@ final class StreamLogger { stackTrace: stackTrace, ); - return handler.handle(record); + return (_handler ?? _handlerOrDefault).handle(record); } } diff --git a/packages/stream_core/test/logger/stream_log_config_test.dart b/packages/stream_core/test/logger/stream_log_config_test.dart index a4181ec0..d79bc17b 100644 --- a/packages/stream_core/test/logger/stream_log_config_test.dart +++ b/packages/stream_core/test/logger/stream_log_config_test.dart @@ -87,86 +87,47 @@ void main() { tearDown(StreamLogger.reset); - test('keep to their own records, each given a parent', () { - final theirs = RecordingLogHandler(); + test('report together once either of them is configured', () { final mine = RecordingLogHandler(); - StreamLogger.configure( - StreamLogConfig(priority: StreamLogPriority.debug, handler: mine), - parent: 'SF:', - ); - StreamLogger.configure(StreamLogConfig(handler: theirs), parent: 'SV:'); - feeds.d(() => 'feeds debug'); - video - ..d(() => 'video debug') - ..w(() => 'video warning'); - - // Neither client decides for the other: feeds asked for debug and video did not, so video's - // commentary stays out even though both were configured. - expect(mine.messages, ['feeds debug']); - expect(theirs.messages, ['video warning']); + StreamLogger.configure(StreamLogConfig(priority: StreamLogPriority.debug, handler: mine)); + feeds.d(() => 'feeds'); + video.d(() => 'video'); + + // One logger serves the process, so configuring a client turns logging on for the SDK beside + // it too. The tags are what tell them apart afterwards. + expect(mine.messages, ['feeds', 'video']); }); - test('do not silence one another, whichever was built last', () { + test('settle on whichever was configured last, rather than merging', () { final first = RecordingLogHandler(); final second = RecordingLogHandler(); - StreamLogger.configure(StreamLogConfig(handler: first), parent: 'SF:'); - StreamLogger.configure(StreamLogConfig(handler: second), parent: 'SV:'); - feeds.w(() => 'feeds'); - video.w(() => 'video'); - - // The complaint this scoping answers: configuring one client used to discard what the other - // had installed, silently and by construction order. - expect(first.messages, ['feeds']); - expect(second.messages, ['video']); - }); - - test('leave a branch alone that no config named', () { - StreamLogger.configure(const StreamLogConfig(priority: StreamLogPriority.debug), parent: 'SF:'); - final printed = capturePrints(() { - feeds.d(() => 'feeds'); - video.e(() => 'video, never asked for'); - }); - - // Video's error is louder than the threshold feeds asked for, and still goes nowhere. - expect(printed.single, contains('feeds')); - }); - - test('write where the app installed a handler, rather than replacing it', () { - final appWide = RecordingLogHandler(); - StreamLogger.handler = appWide; + StreamLogger.configure(StreamLogConfig(handler: first)); + StreamLogger.configure(StreamLogConfig(handler: second)); + feeds.w(() => 'a warning'); - StreamLogger.configure(const StreamLogConfig(priority: StreamLogPriority.debug), parent: 'SF:'); - feeds.d(() => 'feeds'); - - // A config naming only a priority must not take the records away from the destination the - // app chose for everything. - expect(appWide.messages, ['feeds']); + expect(first.records, isEmpty); + expect(second.messages, ['a warning']); }); - test('govern everything, given no parent at all', () { + test('can be held to one SDK by the prefix its tags carry', () { final mine = RecordingLogHandler(); - StreamLogger.configure(StreamLogConfig(priority: StreamLogPriority.debug, handler: mine)); + StreamLogger.configure( + StreamLogConfig( + handler: mine, + filter: const StreamLogFilter.prefix( + {'SF:': StreamLogPriority.debug}, + otherwise: StreamLogPriority.none, + ), + ), + ); feeds.d(() => 'feeds'); - video.d(() => 'video'); - - expect(mine.messages, ['feeds', 'video']); - }); - - test('decide their own records, over a rule the app set for everything', () { - final mine = RecordingLogHandler(); - StreamLogger.handler = mine; - StreamLogger.filter = const StreamLogFilter.always(); - - StreamLogger.configure(const StreamLogConfig(), parent: 'SF:'); - feeds.v(() => 'below what the scope admits'); - video.v(() => 'still what the app asked for'); + video.e(() => 'video, not asked for'); - // A branch is the narrower statement, so it settles its own tags. The app's rule keeps the - // ones no scope claimed. - expect(mine.messages, ['still what the app asked for']); + // What an app reaches for when it wants one SDK's records and not the other's. + expect(mine.messages, ['feeds']); }); }); } From 027df46c4b23b89cfa70eabd5c98a945a46f98b6 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Tue, 25 Aug 2026 00:48:04 +0200 Subject: [PATCH 11/29] docs(llc): show the console half of a composite wrapped in debugOnly Composing with the default handler is what keeps a console alongside a crash reporter, and it is also where `debugOnly` earns its place: the console is the half a user could see, and the crash reporter is the half worth having in every build. Co-Authored-By: Claude Opus 5 (1M context) --- packages/stream_core/lib/src/logger/stream_log_config.dart | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/stream_core/lib/src/logger/stream_log_config.dart b/packages/stream_core/lib/src/logger/stream_log_config.dart index 8a43aa66..ba6cff49 100644 --- a/packages/stream_core/lib/src/logger/stream_log_config.dart +++ b/packages/stream_core/lib/src/logger/stream_log_config.dart @@ -39,7 +39,8 @@ class StreamLogConfig { /// Where records go. /// - /// Compose with [defaultHandler] to keep the console alongside a handler of your own. + /// Compose with [defaultHandler] to keep the console alongside a handler of your own, and wrap + /// that half in [StreamLogHandler.debugOnly] to leave it out of the build your users run. final StreamLogHandler handler; /// Which records are built at all, for a rule [priority] cannot express. From d1ea5484dd6d118abb2e83dfe7769c88f6a74135 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Tue, 25 Aug 2026 01:06:40 +0200 Subject: [PATCH 12/29] refactor(llc): put the ambient handler and filter on a root logger MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three write-only statics said nothing about there being one logger for the process, and a test could not put back what it found because a write-only setter cannot be read — which is the only reason `reset` existed. `StreamLogger.root` holds both, readably, the way `Logger.root` does in package:logging. Setting a threshold there still needs a destination beside it, as it does there; `configure` remains the one call that takes both, so a product config naming only a priority still reports somewhere. Co-Authored-By: Claude Opus 5 (1M context) --- packages/stream_core/CHANGELOG.md | 2 +- .../lib/src/logger/stream_log_filter.dart | 2 +- .../lib/src/logger/stream_log_handler.dart | 4 +- .../lib/src/logger/stream_logger.dart | 132 +++++++++++------- .../ws/client/stream_web_socket_client.dart | 4 +- packages/stream_core/test/helpers/logger.dart | 23 +-- .../test/logger/stream_log_config_test.dart | 6 +- .../test/logger/stream_log_handler_test.dart | 14 +- .../test/logger/stream_logger_test.dart | 12 +- 9 files changed, 115 insertions(+), 84 deletions(-) diff --git a/packages/stream_core/CHANGELOG.md b/packages/stream_core/CHANGELOG.md index 904147da..7cf9c9d3 100644 --- a/packages/stream_core/CHANGELOG.md +++ b/packages/stream_core/CHANGELOG.md @@ -20,7 +20,7 @@ ### ✨ Features -- Added a logger the SDK now reports itself through, silent until an app installs a `StreamLogHandler` or a product client passes `StreamLogger.configure` the `StreamLogConfig` it was given +- Added a logger the SDK now reports itself through, silent until an app installs a handler on `StreamLogger.root` or a product client passes `StreamLogger.configure` the `StreamLogConfig` it was given - Added `TokenManager.setTokenProvider`, which points an existing manager at another user and expires the cached token; handed the identity it already has, it does nothing - Added optional `onTokenUpdated` callback to `TokenManager`, invoked after every successful token load - Added optional `rawValue` to `UserToken.anonymous`, so an anonymous token can carry a JWT granting restricted access; its `user_id` claim must be `!anon` diff --git a/packages/stream_core/lib/src/logger/stream_log_filter.dart b/packages/stream_core/lib/src/logger/stream_log_filter.dart index 51240eb1..8f941563 100644 --- a/packages/stream_core/lib/src/logger/stream_log_filter.dart +++ b/packages/stream_core/lib/src/logger/stream_log_filter.dart @@ -9,7 +9,7 @@ import 'stream_log_priority.dart'; /// app wants one subsystem louder than the rest: /// /// ```dart -/// StreamLogger.filter = const StreamLogFilter.prefix( +/// StreamLogger.root.filter = const StreamLogFilter.prefix( /// {'SC:Ws': StreamLogPriority.verbose}, /// otherwise: StreamLogPriority.warning, /// ); diff --git a/packages/stream_core/lib/src/logger/stream_log_handler.dart b/packages/stream_core/lib/src/logger/stream_log_handler.dart index a4cac4d7..c73186af 100644 --- a/packages/stream_core/lib/src/logger/stream_log_handler.dart +++ b/packages/stream_core/lib/src/logger/stream_log_handler.dart @@ -37,7 +37,7 @@ abstract class StreamLogHandler { /// handing records to `debugPrint`, which paces them to stay under the limit: /// /// ```dart - /// StreamLogger.handler = StreamLogHandler.from((record) => debugPrint('$record')); + /// StreamLogger.root.handler = StreamLogHandler.from((record) => debugPrint('$record')); /// ``` /// /// Emits whatever [StreamLogger.priority] admits. Pass [minPriority] to hold this handler @@ -62,7 +62,7 @@ abstract class StreamLogHandler { /// build a user runs: /// /// ```dart - /// StreamLogger.handler = const StreamLogHandler.debugOnly(StreamLogHandler.console()); + /// StreamLogger.root.handler = const StreamLogHandler.debugOnly(StreamLogHandler.console()); /// ``` /// /// Consider wrapping only what writes somewhere a user could see, and leaving a crash reporter diff --git a/packages/stream_core/lib/src/logger/stream_logger.dart b/packages/stream_core/lib/src/logger/stream_logger.dart index e2cf9491..fef8adf2 100644 --- a/packages/stream_core/lib/src/logger/stream_logger.dart +++ b/packages/stream_core/lib/src/logger/stream_logger.dart @@ -30,8 +30,9 @@ typedef StreamLogMessage = String Function(); /// logger built at class-load picks up whatever the app installs later: /// /// ```dart -/// StreamLogger.handler = const StreamLogHandler.console(); -/// StreamLogger.priority = StreamLogPriority.debug; +/// StreamLogger.root +/// ..handler = const StreamLogHandler.console() +/// ..priority = StreamLogPriority.debug; /// ``` /// /// Records go to one place, so routing two SDKs apart is a matter of a [StreamLogHandler] reading @@ -44,7 +45,7 @@ final class StreamLogger { /// Creates a [StreamLogger] that ignores what the app has installed. /// /// Records go to the given handler and are gated by the given filter alone, so a detached logger - /// neither reads nor disturbs [StreamLogger.handler]. Use one to capture a component's records in + /// neither reads nor disturbs [StreamLogger.root]. Use one to capture a component's records in /// a test, or to hold a subsystem to its own threshold and destination: /// /// ```dart @@ -56,7 +57,7 @@ final class StreamLogger { /// ``` /// /// A priority of its own is a [StreamLogFilter.minPriority], which is why there is no separate one. - /// [filter] defaults to the same threshold [StreamLogger.priority] starts at, so detaching a logger + /// [filter] defaults to the same threshold [StreamLoggerRoot.filter] starts at, so detaching a logger /// changes where its records go without also changing how many there are. Pass /// [StreamLogFilter.always] to leave the decision entirely to the handler. const StreamLogger.detached( @@ -79,47 +80,23 @@ final class StreamLogger { /// * [StreamLogFilter.prefix], which turns this convention into a threshold per subsystem. final String tag; - static StreamLogHandler _handlerOrDefault = StreamLogHandler.silent; - static StreamLogFilter _filterOrDefault = const .minPriority(.warning); - - /// Installs where every record goes, other than those from a [StreamLogger.detached] logger. + /// The logger every tagged logger reads its handler and filter from. /// - /// Everything is discarded until this is set, so an SDK is silent in an app that has not asked - /// for records. Setting it applies to loggers that already exist, including any built at - /// class-load, because a logger resolves this when it writes rather than when it was created. + /// One root serves the process, so what is installed here decides logging for every Stream SDK + /// in it: /// /// ```dart - /// StreamLogger.handler = const StreamLogHandler.console(); + /// StreamLogger.root + /// ..handler = const StreamLogHandler.console() + /// ..priority = StreamLogPriority.debug; /// ``` /// - /// Write-only, so nothing can come to depend on what happens to be installed. Consider - /// [StreamLogHandler.composite] to send records to more than one place. - static set handler(StreamLogHandler handler) => _handlerOrDefault = handler; + /// A destination and a threshold are two settings, and records need both: a threshold with + /// nowhere to write is silence, and the root writes nowhere until a handler is installed. + /// Consider [configure], which takes both at once. + static final root = StreamLoggerRoot._(); - /// Installs the lowest priority worth building a record for. - /// - /// Defaults to [StreamLogPriority.warning], so an app that installs a handler and nothing else - /// hears about failures and not the running commentary: - /// - /// ```dart - /// StreamLogger.priority = StreamLogPriority.debug; - /// ``` - /// - /// Shorthand for a [StreamLogFilter.minPriority], so this and [filter] are one setting: whichever - /// is written last decides. - static set priority(StreamLogPriority priority) => _filterOrDefault = .minPriority(priority); - - /// Installs which records are built at all, for a rule [priority] cannot express. - /// - /// ```dart - /// StreamLogger.filter = const StreamLogFilter.prefix( - /// {'SC:Ws': StreamLogPriority.verbose}, - /// otherwise: StreamLogPriority.warning, - /// ); - /// ``` - static set filter(StreamLogFilter filter) => _filterOrDefault = filter; - - /// Installs [config] in one step, or leaves the logger untouched where it is null. + /// Installs [config] on the [root], or leaves it untouched where the config is null. /// /// What a product client calls with whatever its own config was given, so that an app running /// two Stream SDKs gets the same answer from both, and neither decides logging for an app that @@ -129,13 +106,16 @@ final class StreamLogger { /// StreamLogger.configure(config.logging); /// ``` /// - /// A config replaces both settings outright, so anything installed through [filter] before this - /// is lost — including to a config that named only a [priority]. Put the rule in + /// Unlike setting the root's fields one at a time, a config carries a destination of its own, so + /// one naming only a priority still reports somewhere. + /// + /// A config replaces both settings outright, so anything installed through [StreamLoggerRoot.filter] + /// before this is lost — including to a config that named only a priority. Put the rule in /// [StreamLogConfig.filter] instead, where a client carries it rather than flattening it. /// - /// One logger serves the process, so this decides logging for every Stream SDK in it, not only - /// the one whose config it came from, and two clients configured differently settle on whichever - /// was constructed last. An app wanting one SDK's records and not another's says so by the prefix + /// One root serves the process, so this decides logging for every Stream SDK in it, not only the + /// one whose config it came from, and two clients configured differently settle on whichever was + /// constructed last. An app wanting one SDK's records and not another's says so by the prefix /// their tags carry: /// /// ```dart @@ -149,23 +129,27 @@ final class StreamLogger { static void configure(StreamLogConfig? config) { if (config == null) return; - _handlerOrDefault = config.handler; - _filterOrDefault = config.filter ?? .minPriority(config.priority); + root + ..handler = config.handler + ..filter = config.filter ?? .minPriority(config.priority); } - /// Puts [handler] and [priority] back to what they were before anything was installed. + /// Puts the [root] back to what it was before anything was installed. /// /// What an app installs is process-wide, so a test that installs a handler and leaves it there - /// changes what every later test sees. Restoring by hand means naming the defaults, which a - /// write-only setter gives no way to read: + /// changes what every later test sees: /// /// ```dart /// tearDown(StreamLogger.reset); /// ``` + /// + /// Consider saving and restoring [StreamLoggerRoot.handler] instead, for a test that has to run + /// inside one that already installed something. @visibleForTesting static void reset() { - _handlerOrDefault = StreamLogHandler.silent; - _filterOrDefault = const .minPriority(.warning); + root + ..handler = StreamLogHandler.silent + ..filter = const .minPriority(.warning); } /// Whether a record at [priority] would be kept by both the filter and the handler. @@ -177,8 +161,8 @@ final class StreamLogger { /// if (_log.isLoggable(StreamLogPriority.verbose)) _log.v(() => describe(everyParticipant)); /// ``` bool isLoggable(StreamLogPriority priority) { - if (!(_filter ?? _filterOrDefault).isLoggable(priority, tag)) return false; - return (_handler ?? _handlerOrDefault).isLoggable(priority, tag); + if (!(_filter ?? root.filter).isLoggable(priority, tag)) return false; + return (_handler ?? root.handler).isLoggable(priority, tag); } /// Writes a [StreamLogPriority.verbose] record. @@ -261,6 +245,46 @@ final class StreamLogger { stackTrace: stackTrace, ); - return (_handler ?? _handlerOrDefault).handle(record); + return (_handler ?? root.handler).handle(record); } } + +/// The handler and filter every tagged [StreamLogger] resolves against. +/// +/// Reached through [StreamLogger.root]; there is one for the process, and it is created there. +final class StreamLoggerRoot { + StreamLoggerRoot._(); + + /// Where every record goes, other than those from a [StreamLogger.detached] logger. + /// + /// Everything is discarded until this is set, so an SDK is silent in an app that has not asked + /// for records. Setting it applies to loggers that already exist, including any built at + /// class-load, because a logger resolves this when it writes rather than when it was created. + /// + /// Consider [StreamLogHandler.composite] to send records to more than one place. + StreamLogHandler handler = StreamLogHandler.silent; + + /// Which records are built at all. + /// + /// Consulted before a record's message is called, so one it rejects costs nothing beyond the + /// closure. Defaults to admitting [StreamLogPriority.warning] and above, so an app that installs + /// a handler and nothing else hears about failures and not the running commentary. + /// + /// ```dart + /// StreamLogger.root.filter = const StreamLogFilter.prefix( + /// {'SC:Ws': StreamLogPriority.verbose}, + /// otherwise: StreamLogPriority.warning, + /// ); + /// ``` + StreamLogFilter filter = const .minPriority(.warning); + + /// Sets [filter] to admit [priority] and above. + /// + /// ```dart + /// StreamLogger.root.priority = StreamLogPriority.debug; + /// ``` + /// + /// Write-only, because a [filter] can hold each subsystem to a threshold of its own, which no + /// single priority can report back. + set priority(StreamLogPriority priority) => filter = .minPriority(priority); +} diff --git a/packages/stream_core/lib/src/ws/client/stream_web_socket_client.dart b/packages/stream_core/lib/src/ws/client/stream_web_socket_client.dart index d39d0f4f..bc094ff1 100644 --- a/packages/stream_core/lib/src/ws/client/stream_web_socket_client.dart +++ b/packages/stream_core/lib/src/ws/client/stream_web_socket_client.dart @@ -55,7 +55,7 @@ typedef WebSocketOptionsBuilder = WebSocketOptions Function(); /// written until an app installs a [StreamLogHandler]: /// /// ```dart -/// StreamLogger.handler = const StreamLogHandler.console(minPriority: StreamLogPriority.debug); +/// StreamLogger.root.handler = const StreamLogHandler.console(minPriority: StreamLogPriority.debug); /// ``` /// /// Give a second client its own `tag` to tell the two apart. Its collaborators are tagged from @@ -63,7 +63,7 @@ typedef WebSocketOptionsBuilder = WebSocketOptions Function(); /// /// ```dart /// StreamWebSocketClient(tag: 'SC:Ws2', ...); -/// StreamLogger.filter = const StreamLogFilter.prefix({'SC:Ws2': StreamLogPriority.verbose}); +/// StreamLogger.root.filter = const StreamLogFilter.prefix({'SC:Ws2': StreamLogPriority.verbose}); /// ``` class StreamWebSocketClient with Disposable implements WebSocketHealthListener, WebSocketEngineListener { /// Creates a new instance of [StreamWebSocketClient]. diff --git a/packages/stream_core/test/helpers/logger.dart b/packages/stream_core/test/helpers/logger.dart index 896f4c01..cac2d320 100644 --- a/packages/stream_core/test/helpers/logger.dart +++ b/packages/stream_core/test/helpers/logger.dart @@ -16,11 +16,11 @@ final class RecordingLogHandler extends StreamLogHandler { void handle(StreamLogRecord record) => records.add(record); } -/// Runs [body] with [handler] and [filter] installed as the ambient ones, restoring both after. +/// Runs [body] with [handler] and [filter] installed on the root, restoring both after. /// /// What an app installs is process-wide, so a test that sets it without clearing up changes what -/// every later test sees. The defaults are put back rather than whatever was there before, which -/// a write-only setter cannot read. +/// every later test sees. Whatever was there before is put back, rather than the defaults, so this +/// nests inside a test that had already installed something. /// /// An asynchronous [body] is awaited before either is put back, so a handler stays installed for /// the work it was meant to capture rather than only up to the first `await`. @@ -32,22 +32,29 @@ T withStreamLogger( StreamLogHandler? handler, StreamLogFilter? filter, }) { - if (handler != null) StreamLogger.handler = handler; + final root = StreamLogger.root; + final previousHandler = root.handler; + final previousFilter = root.filter; + void restore() => root + ..handler = previousHandler + ..filter = previousFilter; + + if (handler != null) root.handler = handler; // A test installing a handler wants to see what reached it, so nothing is held back unless the // test says so. - StreamLogger.filter = filter ?? const StreamLogFilter.always(); + root.filter = filter ?? const StreamLogFilter.always(); final T result; try { result = body(); } catch (_) { - StreamLogger.reset(); + restore(); rethrow; } - if (result is Future) return result.whenComplete(StreamLogger.reset) as T; + if (result is Future) return result.whenComplete(restore) as T; - StreamLogger.reset(); + restore(); return result; } diff --git a/packages/stream_core/test/logger/stream_log_config_test.dart b/packages/stream_core/test/logger/stream_log_config_test.dart index d79bc17b..c8a875c6 100644 --- a/packages/stream_core/test/logger/stream_log_config_test.dart +++ b/packages/stream_core/test/logger/stream_log_config_test.dart @@ -11,8 +11,8 @@ void main() { test('leaves the logger alone when there is no config', () { final installed = RecordingLogHandler(); - StreamLogger.handler = installed; - StreamLogger.priority = StreamLogPriority.verbose; + StreamLogger.root.handler = installed; + StreamLogger.root.priority = StreamLogPriority.verbose; StreamLogger.configure(null); _logger.d(() => 'another SDK, still heard'); @@ -70,7 +70,7 @@ void main() { }); test('replaces a filter installed before it, even naming only a priority', () { final mine = RecordingLogHandler(); - StreamLogger.filter = const StreamLogFilter.prefix({'SF:Ws': StreamLogPriority.verbose}); + StreamLogger.root.filter = const StreamLogFilter.prefix({'SF:Ws': StreamLogPriority.verbose}); StreamLogger.configure(StreamLogConfig(priority: StreamLogPriority.debug, handler: mine)); const StreamLogger('SF:Ws').v(() => 'below what the config asked for'); diff --git a/packages/stream_core/test/logger/stream_log_handler_test.dart b/packages/stream_core/test/logger/stream_log_handler_test.dart index 94e4ab26..001ce15f 100644 --- a/packages/stream_core/test/logger/stream_log_handler_test.dart +++ b/packages/stream_core/test/logger/stream_log_handler_test.dart @@ -19,10 +19,10 @@ void main() { test('reports failures and stays quiet about the rest, having been given only a handler', () { // Deliberately not `withStreamLogger`, which opens the level up: this is about what an app // gets from installing a handler and nothing else. - StreamLogger.handler = const StreamLogHandler.console(); + StreamLogger.root.handler = const StreamLogHandler.console(); addTearDown(() { - StreamLogger.handler = StreamLogHandler.silent; - StreamLogger.priority = StreamLogPriority.warning; + StreamLogger.root.handler = StreamLogHandler.silent; + StreamLogger.root.priority = StreamLogPriority.warning; }); final printed = capturePrints(() { @@ -41,11 +41,11 @@ void main() { test('writes whatever the level admits, once it has been opened up', () { // The setup every migration guide shows, which silently dropped debug when the handler // carried a competing threshold of its own. - StreamLogger.handler = const StreamLogHandler.console(); - StreamLogger.priority = StreamLogPriority.debug; + StreamLogger.root.handler = const StreamLogHandler.console(); + StreamLogger.root.priority = StreamLogPriority.debug; addTearDown(() { - StreamLogger.handler = StreamLogHandler.silent; - StreamLogger.priority = StreamLogPriority.warning; + StreamLogger.root.handler = StreamLogHandler.silent; + StreamLogger.root.priority = StreamLogPriority.warning; }); final printed = capturePrints(() => _logger.d(() => 'a debug line')); diff --git a/packages/stream_core/test/logger/stream_logger_test.dart b/packages/stream_core/test/logger/stream_logger_test.dart index 93f2cd38..5044eabb 100644 --- a/packages/stream_core/test/logger/stream_logger_test.dart +++ b/packages/stream_core/test/logger/stream_logger_test.dart @@ -132,8 +132,8 @@ void main() { group('StreamLogger.reset', () { test('puts back both the handler and the priority', () { final installed = RecordingLogHandler(); - StreamLogger.handler = installed; - StreamLogger.priority = StreamLogPriority.verbose; + StreamLogger.root.handler = installed; + StreamLogger.root.priority = StreamLogPriority.verbose; StreamLogger.reset(); @@ -232,15 +232,15 @@ void main() { final logger = StreamLogger.detached('SC:Detached', handler: mine); addTearDown(() { - StreamLogger.handler = StreamLogHandler.silent; - StreamLogger.priority = StreamLogPriority.warning; + StreamLogger.root.handler = StreamLogHandler.silent; + StreamLogger.root.priority = StreamLogPriority.warning; }); // An attached logger resolves both of these every time it writes, so a detached one reading // either would drift as an app reconfigured itself. for (final installed in [RecordingLogHandler(), RecordingLogHandler()]) { - StreamLogger.handler = installed; - StreamLogger.priority = StreamLogPriority.verbose; + StreamLogger.root.handler = installed; + StreamLogger.root.priority = StreamLogPriority.verbose; logger ..d(() => 'still below its own threshold') From 3d3c37f7f52e7e04431a4e37fce260309d75c991 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Tue, 25 Aug 2026 01:20:57 +0200 Subject: [PATCH 13/29] Revert "refactor(llc): put the ambient handler and filter on a root logger" This reverts 470f7dd. `StreamLogger.root` was named after `Logger.root` in package:logging without being what that is: theirs is a logger you can write through, ours only held a handler and a filter, so the name promised something the type did not have. The three setters carry the same settings without claiming to be a logger. Co-Authored-By: Claude Opus 5 (1M context) --- packages/stream_core/CHANGELOG.md | 2 +- .../lib/src/logger/stream_log_filter.dart | 2 +- .../lib/src/logger/stream_log_handler.dart | 4 +- .../lib/src/logger/stream_logger.dart | 132 +++++++----------- .../ws/client/stream_web_socket_client.dart | 4 +- packages/stream_core/test/helpers/logger.dart | 23 ++- .../test/logger/stream_log_config_test.dart | 6 +- .../test/logger/stream_log_handler_test.dart | 14 +- .../test/logger/stream_logger_test.dart | 12 +- 9 files changed, 84 insertions(+), 115 deletions(-) diff --git a/packages/stream_core/CHANGELOG.md b/packages/stream_core/CHANGELOG.md index 7cf9c9d3..904147da 100644 --- a/packages/stream_core/CHANGELOG.md +++ b/packages/stream_core/CHANGELOG.md @@ -20,7 +20,7 @@ ### ✨ Features -- Added a logger the SDK now reports itself through, silent until an app installs a handler on `StreamLogger.root` or a product client passes `StreamLogger.configure` the `StreamLogConfig` it was given +- Added a logger the SDK now reports itself through, silent until an app installs a `StreamLogHandler` or a product client passes `StreamLogger.configure` the `StreamLogConfig` it was given - Added `TokenManager.setTokenProvider`, which points an existing manager at another user and expires the cached token; handed the identity it already has, it does nothing - Added optional `onTokenUpdated` callback to `TokenManager`, invoked after every successful token load - Added optional `rawValue` to `UserToken.anonymous`, so an anonymous token can carry a JWT granting restricted access; its `user_id` claim must be `!anon` diff --git a/packages/stream_core/lib/src/logger/stream_log_filter.dart b/packages/stream_core/lib/src/logger/stream_log_filter.dart index 8f941563..51240eb1 100644 --- a/packages/stream_core/lib/src/logger/stream_log_filter.dart +++ b/packages/stream_core/lib/src/logger/stream_log_filter.dart @@ -9,7 +9,7 @@ import 'stream_log_priority.dart'; /// app wants one subsystem louder than the rest: /// /// ```dart -/// StreamLogger.root.filter = const StreamLogFilter.prefix( +/// StreamLogger.filter = const StreamLogFilter.prefix( /// {'SC:Ws': StreamLogPriority.verbose}, /// otherwise: StreamLogPriority.warning, /// ); diff --git a/packages/stream_core/lib/src/logger/stream_log_handler.dart b/packages/stream_core/lib/src/logger/stream_log_handler.dart index c73186af..a4cac4d7 100644 --- a/packages/stream_core/lib/src/logger/stream_log_handler.dart +++ b/packages/stream_core/lib/src/logger/stream_log_handler.dart @@ -37,7 +37,7 @@ abstract class StreamLogHandler { /// handing records to `debugPrint`, which paces them to stay under the limit: /// /// ```dart - /// StreamLogger.root.handler = StreamLogHandler.from((record) => debugPrint('$record')); + /// StreamLogger.handler = StreamLogHandler.from((record) => debugPrint('$record')); /// ``` /// /// Emits whatever [StreamLogger.priority] admits. Pass [minPriority] to hold this handler @@ -62,7 +62,7 @@ abstract class StreamLogHandler { /// build a user runs: /// /// ```dart - /// StreamLogger.root.handler = const StreamLogHandler.debugOnly(StreamLogHandler.console()); + /// StreamLogger.handler = const StreamLogHandler.debugOnly(StreamLogHandler.console()); /// ``` /// /// Consider wrapping only what writes somewhere a user could see, and leaving a crash reporter diff --git a/packages/stream_core/lib/src/logger/stream_logger.dart b/packages/stream_core/lib/src/logger/stream_logger.dart index fef8adf2..e2cf9491 100644 --- a/packages/stream_core/lib/src/logger/stream_logger.dart +++ b/packages/stream_core/lib/src/logger/stream_logger.dart @@ -30,9 +30,8 @@ typedef StreamLogMessage = String Function(); /// logger built at class-load picks up whatever the app installs later: /// /// ```dart -/// StreamLogger.root -/// ..handler = const StreamLogHandler.console() -/// ..priority = StreamLogPriority.debug; +/// StreamLogger.handler = const StreamLogHandler.console(); +/// StreamLogger.priority = StreamLogPriority.debug; /// ``` /// /// Records go to one place, so routing two SDKs apart is a matter of a [StreamLogHandler] reading @@ -45,7 +44,7 @@ final class StreamLogger { /// Creates a [StreamLogger] that ignores what the app has installed. /// /// Records go to the given handler and are gated by the given filter alone, so a detached logger - /// neither reads nor disturbs [StreamLogger.root]. Use one to capture a component's records in + /// neither reads nor disturbs [StreamLogger.handler]. Use one to capture a component's records in /// a test, or to hold a subsystem to its own threshold and destination: /// /// ```dart @@ -57,7 +56,7 @@ final class StreamLogger { /// ``` /// /// A priority of its own is a [StreamLogFilter.minPriority], which is why there is no separate one. - /// [filter] defaults to the same threshold [StreamLoggerRoot.filter] starts at, so detaching a logger + /// [filter] defaults to the same threshold [StreamLogger.priority] starts at, so detaching a logger /// changes where its records go without also changing how many there are. Pass /// [StreamLogFilter.always] to leave the decision entirely to the handler. const StreamLogger.detached( @@ -80,23 +79,47 @@ final class StreamLogger { /// * [StreamLogFilter.prefix], which turns this convention into a threshold per subsystem. final String tag; - /// The logger every tagged logger reads its handler and filter from. + static StreamLogHandler _handlerOrDefault = StreamLogHandler.silent; + static StreamLogFilter _filterOrDefault = const .minPriority(.warning); + + /// Installs where every record goes, other than those from a [StreamLogger.detached] logger. /// - /// One root serves the process, so what is installed here decides logging for every Stream SDK - /// in it: + /// Everything is discarded until this is set, so an SDK is silent in an app that has not asked + /// for records. Setting it applies to loggers that already exist, including any built at + /// class-load, because a logger resolves this when it writes rather than when it was created. /// /// ```dart - /// StreamLogger.root - /// ..handler = const StreamLogHandler.console() - /// ..priority = StreamLogPriority.debug; + /// StreamLogger.handler = const StreamLogHandler.console(); /// ``` /// - /// A destination and a threshold are two settings, and records need both: a threshold with - /// nowhere to write is silence, and the root writes nowhere until a handler is installed. - /// Consider [configure], which takes both at once. - static final root = StreamLoggerRoot._(); + /// Write-only, so nothing can come to depend on what happens to be installed. Consider + /// [StreamLogHandler.composite] to send records to more than one place. + static set handler(StreamLogHandler handler) => _handlerOrDefault = handler; - /// Installs [config] on the [root], or leaves it untouched where the config is null. + /// Installs the lowest priority worth building a record for. + /// + /// Defaults to [StreamLogPriority.warning], so an app that installs a handler and nothing else + /// hears about failures and not the running commentary: + /// + /// ```dart + /// StreamLogger.priority = StreamLogPriority.debug; + /// ``` + /// + /// Shorthand for a [StreamLogFilter.minPriority], so this and [filter] are one setting: whichever + /// is written last decides. + static set priority(StreamLogPriority priority) => _filterOrDefault = .minPriority(priority); + + /// Installs which records are built at all, for a rule [priority] cannot express. + /// + /// ```dart + /// StreamLogger.filter = const StreamLogFilter.prefix( + /// {'SC:Ws': StreamLogPriority.verbose}, + /// otherwise: StreamLogPriority.warning, + /// ); + /// ``` + static set filter(StreamLogFilter filter) => _filterOrDefault = filter; + + /// Installs [config] in one step, or leaves the logger untouched where it is null. /// /// What a product client calls with whatever its own config was given, so that an app running /// two Stream SDKs gets the same answer from both, and neither decides logging for an app that @@ -106,16 +129,13 @@ final class StreamLogger { /// StreamLogger.configure(config.logging); /// ``` /// - /// Unlike setting the root's fields one at a time, a config carries a destination of its own, so - /// one naming only a priority still reports somewhere. - /// - /// A config replaces both settings outright, so anything installed through [StreamLoggerRoot.filter] - /// before this is lost — including to a config that named only a priority. Put the rule in + /// A config replaces both settings outright, so anything installed through [filter] before this + /// is lost — including to a config that named only a [priority]. Put the rule in /// [StreamLogConfig.filter] instead, where a client carries it rather than flattening it. /// - /// One root serves the process, so this decides logging for every Stream SDK in it, not only the - /// one whose config it came from, and two clients configured differently settle on whichever was - /// constructed last. An app wanting one SDK's records and not another's says so by the prefix + /// One logger serves the process, so this decides logging for every Stream SDK in it, not only + /// the one whose config it came from, and two clients configured differently settle on whichever + /// was constructed last. An app wanting one SDK's records and not another's says so by the prefix /// their tags carry: /// /// ```dart @@ -129,27 +149,23 @@ final class StreamLogger { static void configure(StreamLogConfig? config) { if (config == null) return; - root - ..handler = config.handler - ..filter = config.filter ?? .minPriority(config.priority); + _handlerOrDefault = config.handler; + _filterOrDefault = config.filter ?? .minPriority(config.priority); } - /// Puts the [root] back to what it was before anything was installed. + /// Puts [handler] and [priority] back to what they were before anything was installed. /// /// What an app installs is process-wide, so a test that installs a handler and leaves it there - /// changes what every later test sees: + /// changes what every later test sees. Restoring by hand means naming the defaults, which a + /// write-only setter gives no way to read: /// /// ```dart /// tearDown(StreamLogger.reset); /// ``` - /// - /// Consider saving and restoring [StreamLoggerRoot.handler] instead, for a test that has to run - /// inside one that already installed something. @visibleForTesting static void reset() { - root - ..handler = StreamLogHandler.silent - ..filter = const .minPriority(.warning); + _handlerOrDefault = StreamLogHandler.silent; + _filterOrDefault = const .minPriority(.warning); } /// Whether a record at [priority] would be kept by both the filter and the handler. @@ -161,8 +177,8 @@ final class StreamLogger { /// if (_log.isLoggable(StreamLogPriority.verbose)) _log.v(() => describe(everyParticipant)); /// ``` bool isLoggable(StreamLogPriority priority) { - if (!(_filter ?? root.filter).isLoggable(priority, tag)) return false; - return (_handler ?? root.handler).isLoggable(priority, tag); + if (!(_filter ?? _filterOrDefault).isLoggable(priority, tag)) return false; + return (_handler ?? _handlerOrDefault).isLoggable(priority, tag); } /// Writes a [StreamLogPriority.verbose] record. @@ -245,46 +261,6 @@ final class StreamLogger { stackTrace: stackTrace, ); - return (_handler ?? root.handler).handle(record); + return (_handler ?? _handlerOrDefault).handle(record); } } - -/// The handler and filter every tagged [StreamLogger] resolves against. -/// -/// Reached through [StreamLogger.root]; there is one for the process, and it is created there. -final class StreamLoggerRoot { - StreamLoggerRoot._(); - - /// Where every record goes, other than those from a [StreamLogger.detached] logger. - /// - /// Everything is discarded until this is set, so an SDK is silent in an app that has not asked - /// for records. Setting it applies to loggers that already exist, including any built at - /// class-load, because a logger resolves this when it writes rather than when it was created. - /// - /// Consider [StreamLogHandler.composite] to send records to more than one place. - StreamLogHandler handler = StreamLogHandler.silent; - - /// Which records are built at all. - /// - /// Consulted before a record's message is called, so one it rejects costs nothing beyond the - /// closure. Defaults to admitting [StreamLogPriority.warning] and above, so an app that installs - /// a handler and nothing else hears about failures and not the running commentary. - /// - /// ```dart - /// StreamLogger.root.filter = const StreamLogFilter.prefix( - /// {'SC:Ws': StreamLogPriority.verbose}, - /// otherwise: StreamLogPriority.warning, - /// ); - /// ``` - StreamLogFilter filter = const .minPriority(.warning); - - /// Sets [filter] to admit [priority] and above. - /// - /// ```dart - /// StreamLogger.root.priority = StreamLogPriority.debug; - /// ``` - /// - /// Write-only, because a [filter] can hold each subsystem to a threshold of its own, which no - /// single priority can report back. - set priority(StreamLogPriority priority) => filter = .minPriority(priority); -} diff --git a/packages/stream_core/lib/src/ws/client/stream_web_socket_client.dart b/packages/stream_core/lib/src/ws/client/stream_web_socket_client.dart index bc094ff1..d39d0f4f 100644 --- a/packages/stream_core/lib/src/ws/client/stream_web_socket_client.dart +++ b/packages/stream_core/lib/src/ws/client/stream_web_socket_client.dart @@ -55,7 +55,7 @@ typedef WebSocketOptionsBuilder = WebSocketOptions Function(); /// written until an app installs a [StreamLogHandler]: /// /// ```dart -/// StreamLogger.root.handler = const StreamLogHandler.console(minPriority: StreamLogPriority.debug); +/// StreamLogger.handler = const StreamLogHandler.console(minPriority: StreamLogPriority.debug); /// ``` /// /// Give a second client its own `tag` to tell the two apart. Its collaborators are tagged from @@ -63,7 +63,7 @@ typedef WebSocketOptionsBuilder = WebSocketOptions Function(); /// /// ```dart /// StreamWebSocketClient(tag: 'SC:Ws2', ...); -/// StreamLogger.root.filter = const StreamLogFilter.prefix({'SC:Ws2': StreamLogPriority.verbose}); +/// StreamLogger.filter = const StreamLogFilter.prefix({'SC:Ws2': StreamLogPriority.verbose}); /// ``` class StreamWebSocketClient with Disposable implements WebSocketHealthListener, WebSocketEngineListener { /// Creates a new instance of [StreamWebSocketClient]. diff --git a/packages/stream_core/test/helpers/logger.dart b/packages/stream_core/test/helpers/logger.dart index cac2d320..896f4c01 100644 --- a/packages/stream_core/test/helpers/logger.dart +++ b/packages/stream_core/test/helpers/logger.dart @@ -16,11 +16,11 @@ final class RecordingLogHandler extends StreamLogHandler { void handle(StreamLogRecord record) => records.add(record); } -/// Runs [body] with [handler] and [filter] installed on the root, restoring both after. +/// Runs [body] with [handler] and [filter] installed as the ambient ones, restoring both after. /// /// What an app installs is process-wide, so a test that sets it without clearing up changes what -/// every later test sees. Whatever was there before is put back, rather than the defaults, so this -/// nests inside a test that had already installed something. +/// every later test sees. The defaults are put back rather than whatever was there before, which +/// a write-only setter cannot read. /// /// An asynchronous [body] is awaited before either is put back, so a handler stays installed for /// the work it was meant to capture rather than only up to the first `await`. @@ -32,29 +32,22 @@ T withStreamLogger( StreamLogHandler? handler, StreamLogFilter? filter, }) { - final root = StreamLogger.root; - final previousHandler = root.handler; - final previousFilter = root.filter; - void restore() => root - ..handler = previousHandler - ..filter = previousFilter; - - if (handler != null) root.handler = handler; + if (handler != null) StreamLogger.handler = handler; // A test installing a handler wants to see what reached it, so nothing is held back unless the // test says so. - root.filter = filter ?? const StreamLogFilter.always(); + StreamLogger.filter = filter ?? const StreamLogFilter.always(); final T result; try { result = body(); } catch (_) { - restore(); + StreamLogger.reset(); rethrow; } - if (result is Future) return result.whenComplete(restore) as T; + if (result is Future) return result.whenComplete(StreamLogger.reset) as T; - restore(); + StreamLogger.reset(); return result; } diff --git a/packages/stream_core/test/logger/stream_log_config_test.dart b/packages/stream_core/test/logger/stream_log_config_test.dart index c8a875c6..d79bc17b 100644 --- a/packages/stream_core/test/logger/stream_log_config_test.dart +++ b/packages/stream_core/test/logger/stream_log_config_test.dart @@ -11,8 +11,8 @@ void main() { test('leaves the logger alone when there is no config', () { final installed = RecordingLogHandler(); - StreamLogger.root.handler = installed; - StreamLogger.root.priority = StreamLogPriority.verbose; + StreamLogger.handler = installed; + StreamLogger.priority = StreamLogPriority.verbose; StreamLogger.configure(null); _logger.d(() => 'another SDK, still heard'); @@ -70,7 +70,7 @@ void main() { }); test('replaces a filter installed before it, even naming only a priority', () { final mine = RecordingLogHandler(); - StreamLogger.root.filter = const StreamLogFilter.prefix({'SF:Ws': StreamLogPriority.verbose}); + StreamLogger.filter = const StreamLogFilter.prefix({'SF:Ws': StreamLogPriority.verbose}); StreamLogger.configure(StreamLogConfig(priority: StreamLogPriority.debug, handler: mine)); const StreamLogger('SF:Ws').v(() => 'below what the config asked for'); diff --git a/packages/stream_core/test/logger/stream_log_handler_test.dart b/packages/stream_core/test/logger/stream_log_handler_test.dart index 001ce15f..94e4ab26 100644 --- a/packages/stream_core/test/logger/stream_log_handler_test.dart +++ b/packages/stream_core/test/logger/stream_log_handler_test.dart @@ -19,10 +19,10 @@ void main() { test('reports failures and stays quiet about the rest, having been given only a handler', () { // Deliberately not `withStreamLogger`, which opens the level up: this is about what an app // gets from installing a handler and nothing else. - StreamLogger.root.handler = const StreamLogHandler.console(); + StreamLogger.handler = const StreamLogHandler.console(); addTearDown(() { - StreamLogger.root.handler = StreamLogHandler.silent; - StreamLogger.root.priority = StreamLogPriority.warning; + StreamLogger.handler = StreamLogHandler.silent; + StreamLogger.priority = StreamLogPriority.warning; }); final printed = capturePrints(() { @@ -41,11 +41,11 @@ void main() { test('writes whatever the level admits, once it has been opened up', () { // The setup every migration guide shows, which silently dropped debug when the handler // carried a competing threshold of its own. - StreamLogger.root.handler = const StreamLogHandler.console(); - StreamLogger.root.priority = StreamLogPriority.debug; + StreamLogger.handler = const StreamLogHandler.console(); + StreamLogger.priority = StreamLogPriority.debug; addTearDown(() { - StreamLogger.root.handler = StreamLogHandler.silent; - StreamLogger.root.priority = StreamLogPriority.warning; + StreamLogger.handler = StreamLogHandler.silent; + StreamLogger.priority = StreamLogPriority.warning; }); final printed = capturePrints(() => _logger.d(() => 'a debug line')); diff --git a/packages/stream_core/test/logger/stream_logger_test.dart b/packages/stream_core/test/logger/stream_logger_test.dart index 5044eabb..93f2cd38 100644 --- a/packages/stream_core/test/logger/stream_logger_test.dart +++ b/packages/stream_core/test/logger/stream_logger_test.dart @@ -132,8 +132,8 @@ void main() { group('StreamLogger.reset', () { test('puts back both the handler and the priority', () { final installed = RecordingLogHandler(); - StreamLogger.root.handler = installed; - StreamLogger.root.priority = StreamLogPriority.verbose; + StreamLogger.handler = installed; + StreamLogger.priority = StreamLogPriority.verbose; StreamLogger.reset(); @@ -232,15 +232,15 @@ void main() { final logger = StreamLogger.detached('SC:Detached', handler: mine); addTearDown(() { - StreamLogger.root.handler = StreamLogHandler.silent; - StreamLogger.root.priority = StreamLogPriority.warning; + StreamLogger.handler = StreamLogHandler.silent; + StreamLogger.priority = StreamLogPriority.warning; }); // An attached logger resolves both of these every time it writes, so a detached one reading // either would drift as an app reconfigured itself. for (final installed in [RecordingLogHandler(), RecordingLogHandler()]) { - StreamLogger.root.handler = installed; - StreamLogger.root.priority = StreamLogPriority.verbose; + StreamLogger.handler = installed; + StreamLogger.priority = StreamLogPriority.verbose; logger ..d(() => 'still below its own threshold') From 0bb956360dad9de1ebd6b5f48049c54e5d84b655 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Tue, 25 Aug 2026 01:26:42 +0200 Subject: [PATCH 14/29] refactor(llc): let a handler hold a filter rather than a priority `console` was the one handler carrying a threshold as a parameter, which is the shape that silently ate debug records when it defaulted to warning while the filter said otherwise. `StreamLogHandler.filtered` holds one destination to a threshold without the handler comparing priorities itself, so narrowing is written in the filter vocabulary and works for any handler, not just the console. It also narrows by tag, which a priority could not express: one SDK's records can go somewhere the rest do not. Co-Authored-By: Claude Opus 5 (1M context) --- .../lib/src/logger/stream_log_handler.dart | 55 +++++++++++--- .../ws/client/stream_web_socket_client.dart | 2 +- .../test/logger/stream_log_handler_test.dart | 73 ++++++++++++++++++- .../test/logger/stream_logger_test.dart | 10 ++- 4 files changed, 121 insertions(+), 19 deletions(-) diff --git a/packages/stream_core/lib/src/logger/stream_log_handler.dart b/packages/stream_core/lib/src/logger/stream_log_handler.dart index a4cac4d7..15a2b9e6 100644 --- a/packages/stream_core/lib/src/logger/stream_log_handler.dart +++ b/packages/stream_core/lib/src/logger/stream_log_handler.dart @@ -1,3 +1,4 @@ +import 'stream_log_filter.dart'; import 'stream_log_priority.dart'; import 'stream_log_record.dart'; @@ -17,12 +18,12 @@ typedef StreamLogCallback = void Function(StreamLogRecord record); /// const CrashReporterHandler(); /// /// @override -/// bool isLoggable(StreamLogPriority priority, String tag) => priority >= StreamLogPriority.warning; -/// -/// @override /// void handle(StreamLogRecord record) => Crashlytics.instance.log('$record'); /// } /// ``` +/// +/// Wrap it in [StreamLogHandler.filtered] to hold it to a threshold, rather than comparing +/// priorities inside it. abstract class StreamLogHandler { /// Creates a [StreamLogHandler]. const StreamLogHandler(); @@ -40,9 +41,9 @@ abstract class StreamLogHandler { /// StreamLogger.handler = StreamLogHandler.from((record) => debugPrint('$record')); /// ``` /// - /// Emits whatever [StreamLogger.priority] admits. Pass [minPriority] to hold this handler - /// quieter than the rest, which is the only direction a handler can move it. - const factory StreamLogHandler.console({StreamLogPriority? minPriority}) = _ConsoleHandler; + /// Emits whatever [StreamLogger.priority] admits. Wrap in [StreamLogHandler.filtered] to hold + /// this destination quieter than the rest. + const factory StreamLogHandler.console() = _ConsoleHandler; /// A handler giving every record to each of [handlers], in order. /// @@ -50,6 +51,26 @@ abstract class StreamLogHandler { /// development and a crash reporter that only wants failures. const factory StreamLogHandler.composite(List handlers) = _CompositeHandler; + /// A handler giving [handler] only the records [filter] admits. + /// + /// The way one destination is held to a threshold of its own, which is the only direction a + /// handler can move: a filter here narrows what `StreamLogger.filter` already admitted, and + /// cannot widen it. + /// + /// ```dart + /// StreamLogHandler.composite([ + /// fileLogger, + /// StreamLogHandler.filtered( + /// const StreamLogFilter.minPriority(StreamLogPriority.error), + /// const StreamLogHandler.console(), + /// ), + /// ]); + /// ``` + /// + /// [StreamLogFilter.prefix] narrows by tag rather than priority, which is how one SDK's records + /// are sent somewhere the rest are not. + const factory StreamLogHandler.filtered(StreamLogFilter filter, StreamLogHandler handler) = _FilteredHandler; + /// A handler passing every record to [callback]. /// /// The shortest route into a logging facility an app already has. @@ -98,12 +119,7 @@ final class _SilentHandler extends StreamLogHandler { } final class _ConsoleHandler extends StreamLogHandler { - const _ConsoleHandler({this.minPriority}); - - final StreamLogPriority? minPriority; - - @override - bool isLoggable(StreamLogPriority priority, String tag) => minPriority == null || priority >= minPriority!; + const _ConsoleHandler(); @override void handle(StreamLogRecord record) { @@ -154,6 +170,21 @@ final class _DebugOnlyHandler extends StreamLogHandler { void handle(StreamLogRecord record) => handler.handle(record); } +final class _FilteredHandler extends StreamLogHandler { + const _FilteredHandler(this.filter, this.handler); + + final StreamLogFilter filter; + final StreamLogHandler handler; + + @override + bool isLoggable(StreamLogPriority priority, String tag) { + return filter.isLoggable(priority, tag) && handler.isLoggable(priority, tag); + } + + @override + void handle(StreamLogRecord record) => handler.handle(record); +} + final class _CallbackHandler extends StreamLogHandler { const _CallbackHandler(this.callback); diff --git a/packages/stream_core/lib/src/ws/client/stream_web_socket_client.dart b/packages/stream_core/lib/src/ws/client/stream_web_socket_client.dart index d39d0f4f..accc23f8 100644 --- a/packages/stream_core/lib/src/ws/client/stream_web_socket_client.dart +++ b/packages/stream_core/lib/src/ws/client/stream_web_socket_client.dart @@ -55,7 +55,7 @@ typedef WebSocketOptionsBuilder = WebSocketOptions Function(); /// written until an app installs a [StreamLogHandler]: /// /// ```dart -/// StreamLogger.handler = const StreamLogHandler.console(minPriority: StreamLogPriority.debug); +/// StreamLogger.handler = const StreamLogHandler.filtered(StreamLogFilter.minPriority(StreamLogPriority.debug), StreamLogHandler.console()); /// ``` /// /// Give a second client its own `tag` to tell the two apart. Its collaborators are tagged from diff --git a/packages/stream_core/test/logger/stream_log_handler_test.dart b/packages/stream_core/test/logger/stream_log_handler_test.dart index 94e4ab26..2b2f85fb 100644 --- a/packages/stream_core/test/logger/stream_log_handler_test.dart +++ b/packages/stream_core/test/logger/stream_log_handler_test.dart @@ -55,7 +55,10 @@ void main() { test('can be held quieter than the level, but never louder', () { final printed = withStreamLogger( - handler: const StreamLogHandler.console(minPriority: StreamLogPriority.error), + handler: const StreamLogHandler.filtered( + StreamLogFilter.minPriority(StreamLogPriority.error), + StreamLogHandler.console(), + ), filter: const StreamLogFilter.minPriority(StreamLogPriority.debug), () => capturePrints(() { _logger @@ -101,7 +104,10 @@ void main() { final printed = withStreamLogger( handler: StreamLogHandler.composite([ everything, - const StreamLogHandler.console(minPriority: StreamLogPriority.error), + const StreamLogHandler.filtered( + StreamLogFilter.minPriority(StreamLogPriority.error), + StreamLogHandler.console(), + ), ]), () => capturePrints(() { _logger @@ -117,7 +123,10 @@ void main() { test('builds a record any one of them wants', () { withStreamLogger( handler: StreamLogHandler.composite([ - const StreamLogHandler.console(minPriority: StreamLogPriority.none), + const StreamLogHandler.filtered( + StreamLogFilter.minPriority(StreamLogPriority.none), + StreamLogHandler.console(), + ), RecordingLogHandler(), ]), () => expect(_logger.isLoggable(StreamLogPriority.verbose), isTrue), @@ -169,7 +178,7 @@ void main() { test('still lets the handler it wraps keep only what it wants', () { final printed = withStreamLogger( handler: const StreamLogHandler.debugOnly( - StreamLogHandler.console(minPriority: StreamLogPriority.error), + StreamLogHandler.filtered(StreamLogFilter.minPriority(StreamLogPriority.error), StreamLogHandler.console()), ), () => capturePrints(() { _logger @@ -188,6 +197,62 @@ void main() { }); }); + group('StreamLogHandler.filtered', () { + test('sends one SDK somewhere the rest do not go', () { + final feedsOnly = RecordingLogHandler(); + + final printed = withStreamLogger( + handler: StreamLogHandler.composite([ + StreamLogHandler.filtered( + const StreamLogFilter.prefix({'SF:': StreamLogPriority.verbose}, otherwise: StreamLogPriority.none), + feedsOnly, + ), + const StreamLogHandler.console(), + ]), + () => capturePrints(() { + const StreamLogger('SF:Ws').d(() => 'from feeds'); + const StreamLogger('SV:Call').d(() => 'from video'); + }), + ); + + // Routing by tag rather than by priority, which a threshold on the handler could not express. + expect(feedsOnly.messages, ['from feeds']); + expect(printed, hasLength(2)); + }); + + test('respects a threshold held by the handler it wraps', () { + final inner = RecordingLogHandler(); + + withStreamLogger( + handler: StreamLogHandler.filtered( + const StreamLogFilter.always(), + StreamLogHandler.filtered(const StreamLogFilter.minPriority(StreamLogPriority.error), inner), + ), + () => const StreamLogger('SC:Component') + ..d(() => 'debug') + ..e(() => 'error'), + ); + + // Handing a record on does not re-apply the filter, so the whole chain has to be consulted + // before the record is built rather than as it is delivered. + expect(inner.messages, ['error']); + }); + + test('never widens what the level already admitted', () { + final everything = RecordingLogHandler(); + + withStreamLogger( + handler: StreamLogHandler.filtered(const StreamLogFilter.always(), everything), + filter: const StreamLogFilter.minPriority(StreamLogPriority.error), + () => const StreamLogger('SC:Component') + ..d(() => 'debug') + ..e(() => 'error'), + ); + + expect(everything.messages, ['error']); + }); + }); + group('StreamLogHandler.silent', () { test('discards every record and admits none', () { withStreamLogger( diff --git a/packages/stream_core/test/logger/stream_logger_test.dart b/packages/stream_core/test/logger/stream_logger_test.dart index 93f2cd38..57196632 100644 --- a/packages/stream_core/test/logger/stream_logger_test.dart +++ b/packages/stream_core/test/logger/stream_logger_test.dart @@ -109,7 +109,10 @@ void main() { var built = 0; withStreamLogger( - handler: const StreamLogHandler.console(minPriority: StreamLogPriority.error), + handler: const StreamLogHandler.filtered( + StreamLogFilter.minPriority(StreamLogPriority.error), + StreamLogHandler.console(), + ), () => capturePrints(() => _logger.v(() => 'expensive ${built++}')), ); @@ -118,7 +121,10 @@ void main() { test('isLoggable answers for the filter and the handler together', () { withStreamLogger( - handler: const StreamLogHandler.console(minPriority: StreamLogPriority.warning), + handler: const StreamLogHandler.filtered( + StreamLogFilter.minPriority(StreamLogPriority.warning), + StreamLogHandler.console(), + ), filter: const StreamLogFilter.minPriority(StreamLogPriority.debug), () { expect(_logger.isLoggable(StreamLogPriority.verbose), isFalse, reason: 'the filter rejects it'); From b8456e22cff13c224978165ba18f14067bcdcd88 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Tue, 25 Aug 2026 01:40:59 +0200 Subject: [PATCH 15/29] fix(llc): refuse `none` as a record's priority MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `none` outranks every severity, so a record written at it passed every `minPriority` filter — including one set to `none`, making it the single record that shutting logging down could not silence. Also turns on `comment_references` for this package. Four dartdoc links were left pointing at members a rename had removed, and nothing noticed; the rule is off across the repository because `stream_core_flutter` has 642 violations, but this package was already clean but for five. Co-Authored-By: Claude Opus 5 (1M context) --- packages/stream_core/analysis_options.yaml | 14 +++++++ .../uploader/attachment_uploader.dart | 2 +- .../lib/src/logger/stream_log_handler.dart | 1 + .../lib/src/logger/stream_log_priority.dart | 4 ++ .../lib/src/user/token_manager.dart | 2 +- .../test/logger/stream_logger_test.dart | 41 +++++++++++++++++++ 6 files changed, 62 insertions(+), 2 deletions(-) create mode 100644 packages/stream_core/analysis_options.yaml diff --git a/packages/stream_core/analysis_options.yaml b/packages/stream_core/analysis_options.yaml new file mode 100644 index 00000000..5f9579f3 --- /dev/null +++ b/packages/stream_core/analysis_options.yaml @@ -0,0 +1,14 @@ +include: ../../analysis_options.yaml + +analyzer: + exclude: + # The repository excludes generated files by a path relative to its own options file, which + # stops matching once that file is included from here. + - lib/**/*.*.dart + +linter: + rules: + # Dangling dartdoc links are invisible to the analyzer everywhere else in the repo, so a rename + # leaves references pointing at members that no longer exist. Kept on here, where the public + # API is documented heavily enough for that to matter. + comment_references: true diff --git a/packages/stream_core/lib/src/attachment/uploader/attachment_uploader.dart b/packages/stream_core/lib/src/attachment/uploader/attachment_uploader.dart index a02b1182..dc4724e9 100644 --- a/packages/stream_core/lib/src/attachment/uploader/attachment_uploader.dart +++ b/packages/stream_core/lib/src/attachment/uploader/attachment_uploader.dart @@ -51,7 +51,7 @@ class AttachmentUploadException implements Exception { /// ); /// ``` class StreamAttachmentUploader { - /// Creates a [StreamAttachmentUploader] with the specified [cdn] client. + /// Creates a [StreamAttachmentUploader] uploading through the given [CdnClient]. const StreamAttachmentUploader({ required this._cdn, }); diff --git a/packages/stream_core/lib/src/logger/stream_log_handler.dart b/packages/stream_core/lib/src/logger/stream_log_handler.dart index 15a2b9e6..3890aad9 100644 --- a/packages/stream_core/lib/src/logger/stream_log_handler.dart +++ b/packages/stream_core/lib/src/logger/stream_log_handler.dart @@ -1,6 +1,7 @@ import 'stream_log_filter.dart'; import 'stream_log_priority.dart'; import 'stream_log_record.dart'; +import 'stream_logger.dart'; /// Receives a log record on behalf of a [StreamLogHandler.from] handler. typedef StreamLogCallback = void Function(StreamLogRecord record); diff --git a/packages/stream_core/lib/src/logger/stream_log_priority.dart b/packages/stream_core/lib/src/logger/stream_log_priority.dart index 5eca0517..a6003c5d 100644 --- a/packages/stream_core/lib/src/logger/stream_log_priority.dart +++ b/packages/stream_core/lib/src/logger/stream_log_priority.dart @@ -19,6 +19,10 @@ enum StreamLogPriority implements Comparable { error(level: 6, emoji: '🚨', label: 'E'), /// No severity, used as a threshold that admits nothing. + /// + /// Outranks every real severity, so a filter held to this admits no record. It is not a severity + /// a record can carry: `StreamLogger.log` discards one written at this priority, which would + /// otherwise be the only record that no threshold could suppress. none(level: 7, emoji: 'đŸ“Ŗ', label: '*'); const StreamLogPriority({required this.level, required this.emoji, required this.label}); diff --git a/packages/stream_core/lib/src/user/token_manager.dart b/packages/stream_core/lib/src/user/token_manager.dart index 6ff19bc8..39bdb8d8 100644 --- a/packages/stream_core/lib/src/user/token_manager.dart +++ b/packages/stream_core/lib/src/user/token_manager.dart @@ -36,7 +36,7 @@ class TokenManager { /// Creates a [TokenManager] for the specified [userId] with the given /// [tokenProvider]. /// - /// An optional [onTokenUpdated] callback is invoked whenever a loaded token is cached. Not for a + /// An optional `onTokenUpdated` callback is invoked whenever a loaded token is cached. Not for a /// caller served from the cache, and not for a load that [expireToken] or [setTokenProvider] /// invalidated while it ran: that token reaches its caller but is never cached. TokenManager({ diff --git a/packages/stream_core/test/logger/stream_logger_test.dart b/packages/stream_core/test/logger/stream_logger_test.dart index 57196632..82a3fc09 100644 --- a/packages/stream_core/test/logger/stream_logger_test.dart +++ b/packages/stream_core/test/logger/stream_logger_test.dart @@ -303,4 +303,45 @@ void main() { expect(second.sequenceNumber, first.sequenceNumber + 1); }); }); + group('StreamLogPriority.none', () { + test('is refused as a record priority, whatever the level admits', () { + final handler = RecordingLogHandler(); + + final printed = withStreamLogger( + handler: handler, + filter: const StreamLogFilter.always(), + () => capturePrints( + () => const StreamLogger('SC:Component').log(StreamLogPriority.none, () => 'no severity'), + ), + ); + + // `none` outranks every severity, so a filter comparing against it would admit the one record + // that shutting logging down cannot silence. + expect(handler.records, isEmpty); + expect(printed, isEmpty); + }); + + test('reports itself as not loggable', () { + withStreamLogger( + handler: RecordingLogHandler(), + filter: const StreamLogFilter.always(), + () => expect(const StreamLogger('SC:Component').isLoggable(StreamLogPriority.none), isFalse), + ); + }); + + test('does not build the message it was given', () { + var built = 0; + + withStreamLogger( + handler: RecordingLogHandler(), + filter: const StreamLogFilter.always(), + () => const StreamLogger('SC:Component').log(StreamLogPriority.none, () { + built++; + return 'never built'; + }), + ); + + expect(built, isZero); + }); + }); } From aa0caffc8e4c0173c3088dd04e837f5bdad781dd Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Tue, 25 Aug 2026 01:55:21 +0200 Subject: [PATCH 16/29] refactor(llc): fold the filter and handler into one validator Writing a record walked the two pieces that gate it and re-derived the same answer every time, and `priority` and `filter` wrote the same field by coincidence rather than by saying so. Installing either now compiles both into a single predicate, so the write path asks one question, and `priority` is visibly a way of building a filter rather than a second setting that happens to collide with one. A detached logger keeps its own pair, having nothing shared to compile. Co-Authored-By: Claude Opus 5 (1M context) --- .../lib/src/logger/stream_logger.dart | 40 ++++++++++++++++--- 1 file changed, 35 insertions(+), 5 deletions(-) diff --git a/packages/stream_core/lib/src/logger/stream_logger.dart b/packages/stream_core/lib/src/logger/stream_logger.dart index e2cf9491..aab3247a 100644 --- a/packages/stream_core/lib/src/logger/stream_logger.dart +++ b/packages/stream_core/lib/src/logger/stream_logger.dart @@ -12,6 +12,12 @@ import 'stream_log_record.dart'; /// paid for by a record that is dropped. typedef StreamLogMessage = String Function(); +/// Decides whether a record at [priority] from [tag] is worth building. +/// +/// A filter and a handler are folded into one of these when either is installed, so writing a +/// record asks a single question rather than walking the pieces that answered it. +typedef _StreamLogValidator = bool Function(StreamLogPriority priority, String tag); + /// Writes log records under a tag. /// /// Holding one costs nothing and it can be created anywhere — a field, a constructor, or a @@ -81,6 +87,21 @@ final class StreamLogger { static StreamLogHandler _handlerOrDefault = StreamLogHandler.silent; static StreamLogFilter _filterOrDefault = const .minPriority(.warning); + static _StreamLogValidator _validator = _compile(_filterOrDefault, _handlerOrDefault); + + // The one question the write path asks. `priority` and `filter` are two ways of describing the + // same half of it, which is why installing either replaces what the other left behind. + static _StreamLogValidator _compile(StreamLogFilter filter, StreamLogHandler handler) { + return (priority, tag) => _decide(filter, handler, priority, tag); + } + + static bool _decide(StreamLogFilter filter, StreamLogHandler handler, StreamLogPriority priority, String tag) { + // A threshold rather than a severity: comparing it against itself would otherwise admit the one + // record that shutting logging down cannot silence. + if (priority == StreamLogPriority.none) return false; + if (!filter.isLoggable(priority, tag)) return false; + return handler.isLoggable(priority, tag); + } /// Installs where every record goes, other than those from a [StreamLogger.detached] logger. /// @@ -94,7 +115,10 @@ final class StreamLogger { /// /// Write-only, so nothing can come to depend on what happens to be installed. Consider /// [StreamLogHandler.composite] to send records to more than one place. - static set handler(StreamLogHandler handler) => _handlerOrDefault = handler; + static set handler(StreamLogHandler handler) { + _handlerOrDefault = handler; + _validator = _compile(_filterOrDefault, handler); + } /// Installs the lowest priority worth building a record for. /// @@ -107,7 +131,7 @@ final class StreamLogger { /// /// Shorthand for a [StreamLogFilter.minPriority], so this and [filter] are one setting: whichever /// is written last decides. - static set priority(StreamLogPriority priority) => _filterOrDefault = .minPriority(priority); + static set priority(StreamLogPriority priority) => filter = .minPriority(priority); /// Installs which records are built at all, for a rule [priority] cannot express. /// @@ -117,7 +141,10 @@ final class StreamLogger { /// otherwise: StreamLogPriority.warning, /// ); /// ``` - static set filter(StreamLogFilter filter) => _filterOrDefault = filter; + static set filter(StreamLogFilter filter) { + _filterOrDefault = filter; + _validator = _compile(filter, _handlerOrDefault); + } /// Installs [config] in one step, or leaves the logger untouched where it is null. /// @@ -151,6 +178,7 @@ final class StreamLogger { _handlerOrDefault = config.handler; _filterOrDefault = config.filter ?? .minPriority(config.priority); + _validator = _compile(_filterOrDefault, _handlerOrDefault); } /// Puts [handler] and [priority] back to what they were before anything was installed. @@ -166,6 +194,7 @@ final class StreamLogger { static void reset() { _handlerOrDefault = StreamLogHandler.silent; _filterOrDefault = const .minPriority(.warning); + _validator = _compile(_filterOrDefault, _handlerOrDefault); } /// Whether a record at [priority] would be kept by both the filter and the handler. @@ -177,8 +206,9 @@ final class StreamLogger { /// if (_log.isLoggable(StreamLogPriority.verbose)) _log.v(() => describe(everyParticipant)); /// ``` bool isLoggable(StreamLogPriority priority) { - if (!(_filter ?? _filterOrDefault).isLoggable(priority, tag)) return false; - return (_handler ?? _handlerOrDefault).isLoggable(priority, tag); + // A detached logger carries its own pair, and so cannot share the compiled one. + if (_handler case final handler?) return _decide(_filter!, handler, priority, tag); + return _validator(priority, tag); } /// Writes a [StreamLogPriority.verbose] record. From a340b8b3701669d480e83e6b8707089f004a90ea Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Tue, 25 Aug 2026 02:00:10 +0200 Subject: [PATCH 17/29] refactor(llc)!: leave the filter as the only thing that gates a record MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A handler answering `isLoggable` meant two things decided what was logged, and predicting the output meant reasoning about both. A destination now takes what the filter admitted and discards on delivery what it does not want, which is what every logger surveyed does. `debugOnly` goes with it: guessing the build mode from whether assertions run was core's way of asking a question the app can answer, and an app naming its handler under `kDebugMode` says it plainly. Nothing installed is still free — that is not a decision a destination makes about a record, so the logger settles it rather than asking. Co-Authored-By: Claude Opus 5 (1M context) --- .../lib/src/logger/stream_log_config.dart | 10 +- .../lib/src/logger/stream_log_handler.dart | 69 ++----------- .../lib/src/logger/stream_logger.dart | 6 +- .../test/logger/stream_log_handler_test.dart | 96 +------------------ .../test/logger/stream_logger_test.dart | 23 ++++- 5 files changed, 40 insertions(+), 164 deletions(-) diff --git a/packages/stream_core/lib/src/logger/stream_log_config.dart b/packages/stream_core/lib/src/logger/stream_log_config.dart index ba6cff49..3040a395 100644 --- a/packages/stream_core/lib/src/logger/stream_log_config.dart +++ b/packages/stream_core/lib/src/logger/stream_log_config.dart @@ -39,8 +39,14 @@ class StreamLogConfig { /// Where records go. /// - /// Compose with [defaultHandler] to keep the console alongside a handler of your own, and wrap - /// that half in [StreamLogHandler.debugOnly] to leave it out of the build your users run. + /// Compose with [defaultHandler] to keep the console alongside a handler of your own. To leave + /// the console out of the build your users run, name it only where the app says it is developing: + /// + /// ```dart + /// handler: kDebugMode + /// ? StreamLogHandler.composite([StreamLogConfig.defaultHandler, myCrashReporterHandler]) + /// : myCrashReporterHandler, + /// ``` final StreamLogHandler handler; /// Which records are built at all, for a rule [priority] cannot express. diff --git a/packages/stream_core/lib/src/logger/stream_log_handler.dart b/packages/stream_core/lib/src/logger/stream_log_handler.dart index 3890aad9..45a6559d 100644 --- a/packages/stream_core/lib/src/logger/stream_log_handler.dart +++ b/packages/stream_core/lib/src/logger/stream_log_handler.dart @@ -1,5 +1,4 @@ import 'stream_log_filter.dart'; -import 'stream_log_priority.dart'; import 'stream_log_record.dart'; import 'stream_logger.dart'; @@ -48,7 +47,8 @@ abstract class StreamLogHandler { /// A handler giving every record to each of [handlers], in order. /// - /// Each decides for itself what to keep, so one composite can serve a verbose console during + /// Every record reaches every one of them, so wrap any that should see less in + /// [StreamLogHandler.filtered] — that is how one composite serves a verbose console during /// development and a crash reporter that only wants failures. const factory StreamLogHandler.composite(List handlers) = _CompositeHandler; @@ -77,42 +77,22 @@ abstract class StreamLogHandler { /// The shortest route into a logging facility an app already has. const factory StreamLogHandler.from(StreamLogCallback callback) = _CallbackHandler; - /// A handler giving records to [handler] only in a build that runs assertions. - /// - /// A Flutter debug build runs them; release and profile builds do not, and neither does a - /// plain `dart run`. Keeps a console for whoever is developing without leaving one in the - /// build a user runs: - /// - /// ```dart - /// StreamLogger.handler = const StreamLogHandler.debugOnly(StreamLogHandler.console()); - /// ``` - /// - /// Consider wrapping only what writes somewhere a user could see, and leaving a crash reporter - /// to receive records in every build. - const factory StreamLogHandler.debugOnly(StreamLogHandler handler) = _DebugOnlyHandler; - /// A handler that discards every record. /// /// What [StreamLogger.handler] is until an app installs something else. static const StreamLogHandler silent = _SilentHandler(); - /// Whether this handler wants a record at [priority] from [tag]. + /// Takes a record the filter admitted. /// - /// Consulted before the record is built, so a handler that gates here never pays for a message - /// it would discard. Defaults to accepting everything `StreamLogger.priority` already admitted — - /// a handler narrows what reaches it, and cannot widen it. - bool isLoggable(StreamLogPriority priority, String tag) => true; - - /// Takes a record this handler has accepted. + /// Whether a record is worth building at all is `StreamLogger.filter`'s decision, so a handler + /// receives whatever that admitted and discards here what it does not want. See + /// [StreamLogHandler.filtered] for holding one destination to less than the rest. void handle(StreamLogRecord record); } final class _SilentHandler extends StreamLogHandler { const _SilentHandler(); - @override - bool isLoggable(StreamLogPriority priority, String tag) => false; - @override void handle(StreamLogRecord record) { /* no-op */ @@ -135,42 +115,14 @@ final class _CompositeHandler extends StreamLogHandler { final List handlers; - @override - bool isLoggable(StreamLogPriority priority, String tag) { - return handlers.any((it) => it.isLoggable(priority, tag)); - } - @override void handle(StreamLogRecord record) { for (final handler in handlers) { - // Asked again, because the record only had to interest one of them to be built. - if (handler.isLoggable(record.priority, record.tag)) handler.handle(record); + handler.handle(record); } } } -final class _DebugOnlyHandler extends StreamLogHandler { - const _DebugOnlyHandler(this.handler); - - final StreamLogHandler handler; - - // The one thing a release build can be asked about itself without depending on Flutter: an - // assertion that runs is a build that kept them. - static bool get _assertionsEnabled { - var enabled = false; - assert(enabled = true); - return enabled; - } - - @override - bool isLoggable(StreamLogPriority priority, String tag) { - return _assertionsEnabled && handler.isLoggable(priority, tag); - } - - @override - void handle(StreamLogRecord record) => handler.handle(record); -} - final class _FilteredHandler extends StreamLogHandler { const _FilteredHandler(this.filter, this.handler); @@ -178,12 +130,9 @@ final class _FilteredHandler extends StreamLogHandler { final StreamLogHandler handler; @override - bool isLoggable(StreamLogPriority priority, String tag) { - return filter.isLoggable(priority, tag) && handler.isLoggable(priority, tag); + void handle(StreamLogRecord record) { + if (filter.isLoggable(record.priority, record.tag)) handler.handle(record); } - - @override - void handle(StreamLogRecord record) => handler.handle(record); } final class _CallbackHandler extends StreamLogHandler { diff --git a/packages/stream_core/lib/src/logger/stream_logger.dart b/packages/stream_core/lib/src/logger/stream_logger.dart index aab3247a..37ae018e 100644 --- a/packages/stream_core/lib/src/logger/stream_logger.dart +++ b/packages/stream_core/lib/src/logger/stream_logger.dart @@ -99,8 +99,10 @@ final class StreamLogger { // A threshold rather than a severity: comparing it against itself would otherwise admit the one // record that shutting logging down cannot silence. if (priority == StreamLogPriority.none) return false; - if (!filter.isLoggable(priority, tag)) return false; - return handler.isLoggable(priority, tag); + // Nowhere to write is not a decision a handler makes about a record, so it is settled here + // rather than by asking every destination what it wants. + if (identical(handler, StreamLogHandler.silent)) return false; + return filter.isLoggable(priority, tag); } /// Installs where every record goes, other than those from a [StreamLogger.detached] logger. diff --git a/packages/stream_core/test/logger/stream_log_handler_test.dart b/packages/stream_core/test/logger/stream_log_handler_test.dart index 2b2f85fb..758868ea 100644 --- a/packages/stream_core/test/logger/stream_log_handler_test.dart +++ b/packages/stream_core/test/logger/stream_log_handler_test.dart @@ -138,7 +138,7 @@ void main() { handler: const StreamLogHandler.composite([]), () { expect(() => _logger.e(() => 'nowhere to go'), returnsNormally); - expect(_logger.isLoggable(StreamLogPriority.error), isFalse); + expect(capturePrints(() => _logger.e(() => 'nowhere to go')), isEmpty); }, ); }); @@ -159,100 +159,6 @@ void main() { }); }); - group('StreamLogHandler.debugOnly', () { - // Only half of this handler can be tested here: `dart test` runs with assertions enabled, so - // the build where it goes quiet is by definition one this suite cannot be running in. That - // half was checked by compiling a probe with `dart compile exe`, which strips them. - - test('passes records on where assertions are enabled, as they are under test', () { - final inner = RecordingLogHandler(); - - withStreamLogger( - handler: StreamLogHandler.debugOnly(inner), - () => _logger.e(() => 'seen while developing'), - ); - - expect(inner.messages, ['seen while developing']); - }); - - test('still lets the handler it wraps keep only what it wants', () { - final printed = withStreamLogger( - handler: const StreamLogHandler.debugOnly( - StreamLogHandler.filtered(StreamLogFilter.minPriority(StreamLogPriority.error), StreamLogHandler.console()), - ), - () => capturePrints(() { - _logger - ..d(() => 'debug') - ..e(() => 'error'); - }), - ); - - expect(printed.single, contains('error')); - }); - - test('is const constructible', () { - const handler = StreamLogHandler.debugOnly(StreamLogHandler.console()); - - expect(handler, isA()); - }); - }); - - group('StreamLogHandler.filtered', () { - test('sends one SDK somewhere the rest do not go', () { - final feedsOnly = RecordingLogHandler(); - - final printed = withStreamLogger( - handler: StreamLogHandler.composite([ - StreamLogHandler.filtered( - const StreamLogFilter.prefix({'SF:': StreamLogPriority.verbose}, otherwise: StreamLogPriority.none), - feedsOnly, - ), - const StreamLogHandler.console(), - ]), - () => capturePrints(() { - const StreamLogger('SF:Ws').d(() => 'from feeds'); - const StreamLogger('SV:Call').d(() => 'from video'); - }), - ); - - // Routing by tag rather than by priority, which a threshold on the handler could not express. - expect(feedsOnly.messages, ['from feeds']); - expect(printed, hasLength(2)); - }); - - test('respects a threshold held by the handler it wraps', () { - final inner = RecordingLogHandler(); - - withStreamLogger( - handler: StreamLogHandler.filtered( - const StreamLogFilter.always(), - StreamLogHandler.filtered(const StreamLogFilter.minPriority(StreamLogPriority.error), inner), - ), - () => const StreamLogger('SC:Component') - ..d(() => 'debug') - ..e(() => 'error'), - ); - - // Handing a record on does not re-apply the filter, so the whole chain has to be consulted - // before the record is built rather than as it is delivered. - expect(inner.messages, ['error']); - }); - - test('never widens what the level already admitted', () { - final everything = RecordingLogHandler(); - - withStreamLogger( - handler: StreamLogHandler.filtered(const StreamLogFilter.always(), everything), - filter: const StreamLogFilter.minPriority(StreamLogPriority.error), - () => const StreamLogger('SC:Component') - ..d(() => 'debug') - ..e(() => 'error'), - ); - - expect(everything.messages, ['error']); - }); - }); - group('StreamLogHandler.silent', () { test('discards every record and admits none', () { withStreamLogger( diff --git a/packages/stream_core/test/logger/stream_logger_test.dart b/packages/stream_core/test/logger/stream_logger_test.dart index 82a3fc09..0d1b0348 100644 --- a/packages/stream_core/test/logger/stream_logger_test.dart +++ b/packages/stream_core/test/logger/stream_logger_test.dart @@ -105,10 +105,20 @@ void main() { expect(handler.records.map((it) => it.stackTrace), everyElement(isNull)); }); - test('never builds a message no handler wants', () { + test('never builds a message while nothing is installed to receive one', () { var built = 0; - withStreamLogger( + // Deliberately not `withStreamLogger`, which installs a handler: this is what an app that + // has not asked for logging pays. + capturePrints(() => _logger.e(() => 'expensive ${built++}')); + + expect(built, isZero); + }); + + test('builds a message a narrower destination goes on to discard', () { + var built = 0; + + final printed = withStreamLogger( handler: const StreamLogHandler.filtered( StreamLogFilter.minPriority(StreamLogPriority.error), StreamLogHandler.console(), @@ -116,10 +126,13 @@ void main() { () => capturePrints(() => _logger.v(() => 'expensive ${built++}')), ); - expect(built, 0); + // The filter is the only gate, so a destination narrower than it declines on delivery rather + // than before the message was built. + expect(built, 1); + expect(printed, isEmpty); }); - test('isLoggable answers for the filter and the handler together', () { + test('isLoggable answers for the filter, whatever the destination goes on to keep', () { withStreamLogger( handler: const StreamLogHandler.filtered( StreamLogFilter.minPriority(StreamLogPriority.warning), @@ -128,7 +141,7 @@ void main() { filter: const StreamLogFilter.minPriority(StreamLogPriority.debug), () { expect(_logger.isLoggable(StreamLogPriority.verbose), isFalse, reason: 'the filter rejects it'); - expect(_logger.isLoggable(StreamLogPriority.debug), isFalse, reason: 'the handler rejects it'); + expect(_logger.isLoggable(StreamLogPriority.debug), isTrue, reason: 'the filter admits it'); expect(_logger.isLoggable(StreamLogPriority.warning), isTrue); }, ); From d72eea71d736db33e5602a97ebac06d71d27a809 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Tue, 25 Aug 2026 02:01:00 +0200 Subject: [PATCH 18/29] docs(llc): drop the console from a composite rather than branching around it A ternary picking between a composite and a lone handler names the other handler twice, and the list already takes a condition. Co-Authored-By: Claude Opus 5 (1M context) --- .../stream_core/lib/src/logger/stream_log_config.dart | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/packages/stream_core/lib/src/logger/stream_log_config.dart b/packages/stream_core/lib/src/logger/stream_log_config.dart index 3040a395..acedd2a1 100644 --- a/packages/stream_core/lib/src/logger/stream_log_config.dart +++ b/packages/stream_core/lib/src/logger/stream_log_config.dart @@ -39,13 +39,14 @@ class StreamLogConfig { /// Where records go. /// - /// Compose with [defaultHandler] to keep the console alongside a handler of your own. To leave - /// the console out of the build your users run, name it only where the app says it is developing: + /// Compose with [defaultHandler] to keep the console alongside a handler of your own, and leave + /// it out of the build your users run by naming it only where the app says it is developing: /// /// ```dart - /// handler: kDebugMode - /// ? StreamLogHandler.composite([StreamLogConfig.defaultHandler, myCrashReporterHandler]) - /// : myCrashReporterHandler, + /// handler: StreamLogHandler.composite([ + /// if (kDebugMode) StreamLogConfig.defaultHandler, + /// myCrashReporterHandler, + /// ]), /// ``` final StreamLogHandler handler; From 5c47a03420124dadf8fbac4ddaa16d1b83208b05 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Tue, 25 Aug 2026 02:33:24 +0200 Subject: [PATCH 19/29] refactor(llc)!: let the filter alone say whether a record is built `isLoggable` asked the filter and then the handler, so predicting what was logged meant holding both in mind. It now asks the filter, and `priority` is the shorthand that installs one. The filter therefore starts admitting nothing rather than warnings: with no destination to consult, an open default would have had every SDK build failure records for an app that never asked, and formatted every failed request through `LoggingInterceptor`. A destination and a priority are now both needed, which is what `configure` supplies at once. Covers the field initialisers too. Every suite restores the defaults rather than observing them, so a change to what a fresh process starts with went unnoticed until a mutation exposed it. Co-Authored-By: Claude Opus 5 (1M context) --- packages/stream_core/CHANGELOG.md | 2 +- .../lib/src/logger/stream_logger.dart | 43 +++++++++---------- .../test/logger/stream_log_handler_test.dart | 17 +++++--- .../logger/stream_logger_defaults_test.dart | 31 +++++++++++++ 4 files changed, 63 insertions(+), 30 deletions(-) create mode 100644 packages/stream_core/test/logger/stream_logger_defaults_test.dart diff --git a/packages/stream_core/CHANGELOG.md b/packages/stream_core/CHANGELOG.md index 904147da..517ecda9 100644 --- a/packages/stream_core/CHANGELOG.md +++ b/packages/stream_core/CHANGELOG.md @@ -20,7 +20,7 @@ ### ✨ Features -- Added a logger the SDK now reports itself through, silent until an app installs a `StreamLogHandler` or a product client passes `StreamLogger.configure` the `StreamLogConfig` it was given +- Added a logger the SDK now reports itself through, silent until an app names both a destination and a priority on `StreamLogger`, or hands a product client a `StreamLogConfig` carrying both - Added `TokenManager.setTokenProvider`, which points an existing manager at another user and expires the cached token; handed the identity it already has, it does nothing - Added optional `onTokenUpdated` callback to `TokenManager`, invoked after every successful token load - Added optional `rawValue` to `UserToken.anonymous`, so an anonymous token can carry a JWT granting restricted access; its `user_id` claim must be `!anon` diff --git a/packages/stream_core/lib/src/logger/stream_logger.dart b/packages/stream_core/lib/src/logger/stream_logger.dart index 37ae018e..a2e01880 100644 --- a/packages/stream_core/lib/src/logger/stream_logger.dart +++ b/packages/stream_core/lib/src/logger/stream_logger.dart @@ -86,46 +86,45 @@ final class StreamLogger { final String tag; static StreamLogHandler _handlerOrDefault = StreamLogHandler.silent; - static StreamLogFilter _filterOrDefault = const .minPriority(.warning); - static _StreamLogValidator _validator = _compile(_filterOrDefault, _handlerOrDefault); + static StreamLogFilter _filterOrDefault = _closed; + static _StreamLogValidator _validator = _compile(_filterOrDefault); - // The one question the write path asks. `priority` and `filter` are two ways of describing the - // same half of it, which is why installing either replaces what the other left behind. - static _StreamLogValidator _compile(StreamLogFilter filter, StreamLogHandler handler) { - return (priority, tag) => _decide(filter, handler, priority, tag); - } + // Admits nothing, which is where an app that has not asked for records starts. + static const _closed = StreamLogFilter.minPriority(StreamLogPriority.none); + + // The one question the write path asks. `priority` and `filter` are two ways of describing it, + // which is why installing either replaces what the other left behind. + static _StreamLogValidator _compile(StreamLogFilter filter) => + (priority, tag) => _decide(filter, priority, tag); - static bool _decide(StreamLogFilter filter, StreamLogHandler handler, StreamLogPriority priority, String tag) { + static bool _decide(StreamLogFilter filter, StreamLogPriority priority, String tag) { // A threshold rather than a severity: comparing it against itself would otherwise admit the one // record that shutting logging down cannot silence. if (priority == StreamLogPriority.none) return false; - // Nowhere to write is not a decision a handler makes about a record, so it is settled here - // rather than by asking every destination what it wants. - if (identical(handler, StreamLogHandler.silent)) return false; return filter.isLoggable(priority, tag); } /// Installs where every record goes, other than those from a [StreamLogger.detached] logger. /// - /// Everything is discarded until this is set, so an SDK is silent in an app that has not asked - /// for records. Setting it applies to loggers that already exist, including any built at + /// A destination on its own reports nothing: name a [priority] beside it, or hand both to + /// [configure] at once. Setting it applies to loggers that already exist, including any built at /// class-load, because a logger resolves this when it writes rather than when it was created. /// /// ```dart /// StreamLogger.handler = const StreamLogHandler.console(); + /// StreamLogger.priority = StreamLogPriority.warning; /// ``` /// /// Write-only, so nothing can come to depend on what happens to be installed. Consider /// [StreamLogHandler.composite] to send records to more than one place. static set handler(StreamLogHandler handler) { _handlerOrDefault = handler; - _validator = _compile(_filterOrDefault, handler); } /// Installs the lowest priority worth building a record for. /// - /// Defaults to [StreamLogPriority.warning], so an app that installs a handler and nothing else - /// hears about failures and not the running commentary: + /// Nothing is admitted until this is named, so an SDK stays silent in an app that has not asked + /// for records, and a record it rejects is never built: /// /// ```dart /// StreamLogger.priority = StreamLogPriority.debug; @@ -145,7 +144,7 @@ final class StreamLogger { /// ``` static set filter(StreamLogFilter filter) { _filterOrDefault = filter; - _validator = _compile(filter, _handlerOrDefault); + _validator = _compile(filter); } /// Installs [config] in one step, or leaves the logger untouched where it is null. @@ -180,7 +179,7 @@ final class StreamLogger { _handlerOrDefault = config.handler; _filterOrDefault = config.filter ?? .minPriority(config.priority); - _validator = _compile(_filterOrDefault, _handlerOrDefault); + _validator = _compile(_filterOrDefault); } /// Puts [handler] and [priority] back to what they were before anything was installed. @@ -195,8 +194,8 @@ final class StreamLogger { @visibleForTesting static void reset() { _handlerOrDefault = StreamLogHandler.silent; - _filterOrDefault = const .minPriority(.warning); - _validator = _compile(_filterOrDefault, _handlerOrDefault); + _filterOrDefault = _closed; + _validator = _compile(_filterOrDefault); } /// Whether a record at [priority] would be kept by both the filter and the handler. @@ -208,8 +207,8 @@ final class StreamLogger { /// if (_log.isLoggable(StreamLogPriority.verbose)) _log.v(() => describe(everyParticipant)); /// ``` bool isLoggable(StreamLogPriority priority) { - // A detached logger carries its own pair, and so cannot share the compiled one. - if (_handler case final handler?) return _decide(_filter!, handler, priority, tag); + // A detached logger carries its own filter, and so cannot share the compiled one. + if (_filter case final filter?) return _decide(filter, priority, tag); return _validator(priority, tag); } diff --git a/packages/stream_core/test/logger/stream_log_handler_test.dart b/packages/stream_core/test/logger/stream_log_handler_test.dart index 758868ea..01aa531b 100644 --- a/packages/stream_core/test/logger/stream_log_handler_test.dart +++ b/packages/stream_core/test/logger/stream_log_handler_test.dart @@ -16,15 +16,16 @@ void main() { expect(printed.single, allOf(contains('a visible line'), contains('SC:Component'))); }); - test('reports failures and stays quiet about the rest, having been given only a handler', () { + test('stays quiet until a level is named beside it', () { // Deliberately not `withStreamLogger`, which opens the level up: this is about what an app // gets from installing a handler and nothing else. StreamLogger.handler = const StreamLogHandler.console(); - addTearDown(() { - StreamLogger.handler = StreamLogHandler.silent; - StreamLogger.priority = StreamLogPriority.warning; - }); + addTearDown(StreamLogger.reset); + + // A destination with no level is as silent as a level with no destination: records need both. + expect(capturePrints(() => _logger.e(() => 'error')), isEmpty); + StreamLogger.priority = StreamLogPriority.warning; final printed = capturePrints(() { _logger ..v(() => 'verbose') @@ -160,12 +161,14 @@ void main() { }); group('StreamLogHandler.silent', () { - test('discards every record and admits none', () { + test('discards every record it is given', () { withStreamLogger( handler: StreamLogHandler.silent, () { expect(capturePrints(() => _logger.e(() => 'discarded')), isEmpty); - expect(_logger.isLoggable(StreamLogPriority.error), isFalse); + // The filter decides what is built; where it goes afterwards is this handler's business, + // so it no longer has a say in what `isLoggable` answers. + expect(_logger.isLoggable(StreamLogPriority.error), isTrue); }, ); }); diff --git a/packages/stream_core/test/logger/stream_logger_defaults_test.dart b/packages/stream_core/test/logger/stream_logger_defaults_test.dart new file mode 100644 index 00000000..4184ec46 --- /dev/null +++ b/packages/stream_core/test/logger/stream_logger_defaults_test.dart @@ -0,0 +1,31 @@ +import 'dart:async'; + +import 'package:stream_core/stream_core.dart'; +import 'package:test/test.dart'; + +/// What a process that has never touched the logger starts with. +/// +/// Deliberately the only test in this file, and it neither installs anything nor calls +/// `StreamLogger.reset`: every other suite restores the defaults rather than observing them, so a +/// change to the field initialisers would otherwise go unnoticed. `dart test` gives each file its +/// own isolate, which is what keeps these statics pristine. +void main() { + test('an untouched logger admits nothing, at any priority', () { + const logger = StreamLogger('SC:Component'); + + for (final priority in StreamLogPriority.values) { + expect(logger.isLoggable(priority), isFalse, reason: '$priority'); + } + + expect( + capturePrints(() => logger.e(() => 'nobody asked for this', error: StateError('boom'))), + isEmpty, + ); + }); +} + +List capturePrints(void Function() body) { + final lines = []; + runZoned(body, zoneSpecification: ZoneSpecification(print: (_, _, _, line) => lines.add(line))); + return lines; +} From 7517429dd60bae8afad200bac889150488e2f91d Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Tue, 25 Aug 2026 02:35:08 +0200 Subject: [PATCH 20/29] refactor(llc): drop the compiled validator, which now wraps one call Folding the filter and the handler into a single predicate saved a second virtual call on every write. With the handler no longer deciding anything, the closure wrapped one filter call and cost a static field, a compile step, and three places that had to remember to rebuild it. Reading the filter directly is the same work without the bookkeeping. Co-Authored-By: Claude Opus 5 (1M context) --- .../lib/src/logger/stream_logger.dart | 35 ++++--------------- 1 file changed, 7 insertions(+), 28 deletions(-) diff --git a/packages/stream_core/lib/src/logger/stream_logger.dart b/packages/stream_core/lib/src/logger/stream_logger.dart index a2e01880..a597de29 100644 --- a/packages/stream_core/lib/src/logger/stream_logger.dart +++ b/packages/stream_core/lib/src/logger/stream_logger.dart @@ -12,12 +12,6 @@ import 'stream_log_record.dart'; /// paid for by a record that is dropped. typedef StreamLogMessage = String Function(); -/// Decides whether a record at [priority] from [tag] is worth building. -/// -/// A filter and a handler are folded into one of these when either is installed, so writing a -/// record asks a single question rather than walking the pieces that answered it. -typedef _StreamLogValidator = bool Function(StreamLogPriority priority, String tag); - /// Writes log records under a tag. /// /// Holding one costs nothing and it can be created anywhere — a field, a constructor, or a @@ -62,9 +56,10 @@ final class StreamLogger { /// ``` /// /// A priority of its own is a [StreamLogFilter.minPriority], which is why there is no separate one. - /// [filter] defaults to the same threshold [StreamLogger.priority] starts at, so detaching a logger - /// changes where its records go without also changing how many there are. Pass - /// [StreamLogFilter.always] to leave the decision entirely to the handler. + /// [filter] defaults to admitting [StreamLogPriority.warning] and above — unlike the ambient one, + /// which admits nothing until an app names a priority, since a detached logger was handed its + /// destination and so has already been asked for. Pass [StreamLogFilter.always] to leave the + /// decision entirely to the handler. const StreamLogger.detached( this.tag, { required StreamLogHandler this._handler, @@ -87,16 +82,11 @@ final class StreamLogger { static StreamLogHandler _handlerOrDefault = StreamLogHandler.silent; static StreamLogFilter _filterOrDefault = _closed; - static _StreamLogValidator _validator = _compile(_filterOrDefault); - // Admits nothing, which is where an app that has not asked for records starts. static const _closed = StreamLogFilter.minPriority(StreamLogPriority.none); // The one question the write path asks. `priority` and `filter` are two ways of describing it, // which is why installing either replaces what the other left behind. - static _StreamLogValidator _compile(StreamLogFilter filter) => - (priority, tag) => _decide(filter, priority, tag); - static bool _decide(StreamLogFilter filter, StreamLogPriority priority, String tag) { // A threshold rather than a severity: comparing it against itself would otherwise admit the one // record that shutting logging down cannot silence. @@ -117,9 +107,7 @@ final class StreamLogger { /// /// Write-only, so nothing can come to depend on what happens to be installed. Consider /// [StreamLogHandler.composite] to send records to more than one place. - static set handler(StreamLogHandler handler) { - _handlerOrDefault = handler; - } + static set handler(StreamLogHandler handler) => _handlerOrDefault = handler; /// Installs the lowest priority worth building a record for. /// @@ -142,10 +130,7 @@ final class StreamLogger { /// otherwise: StreamLogPriority.warning, /// ); /// ``` - static set filter(StreamLogFilter filter) { - _filterOrDefault = filter; - _validator = _compile(filter); - } + static set filter(StreamLogFilter filter) => _filterOrDefault = filter; /// Installs [config] in one step, or leaves the logger untouched where it is null. /// @@ -179,7 +164,6 @@ final class StreamLogger { _handlerOrDefault = config.handler; _filterOrDefault = config.filter ?? .minPriority(config.priority); - _validator = _compile(_filterOrDefault); } /// Puts [handler] and [priority] back to what they were before anything was installed. @@ -195,7 +179,6 @@ final class StreamLogger { static void reset() { _handlerOrDefault = StreamLogHandler.silent; _filterOrDefault = _closed; - _validator = _compile(_filterOrDefault); } /// Whether a record at [priority] would be kept by both the filter and the handler. @@ -206,11 +189,7 @@ final class StreamLogger { /// ```dart /// if (_log.isLoggable(StreamLogPriority.verbose)) _log.v(() => describe(everyParticipant)); /// ``` - bool isLoggable(StreamLogPriority priority) { - // A detached logger carries its own filter, and so cannot share the compiled one. - if (_filter case final filter?) return _decide(filter, priority, tag); - return _validator(priority, tag); - } + bool isLoggable(StreamLogPriority priority) => _decide(_filter ?? _filterOrDefault, priority, tag); /// Writes a [StreamLogPriority.verbose] record. void v( From cadaf9237bb6ec34b24fb77c4b6a26a617056d38 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Tue, 25 Aug 2026 02:40:33 +0200 Subject: [PATCH 21/29] fix(llc): put back the guard against `none` as a record's priority Folding the gate into `_effectiveFilter` dropped it, and the default filter compares `none` against itself, so a record written at the threshold that means silence was admitted by it. Also corrects what a detached logger's filter is said to default to: the ambient one now admits nothing until an app names a priority, so the two are no longer the same threshold. Co-Authored-By: Claude Opus 5 (1M context) --- .../lib/src/logger/stream_logger.dart | 33 ++++++++----------- 1 file changed, 14 insertions(+), 19 deletions(-) diff --git a/packages/stream_core/lib/src/logger/stream_logger.dart b/packages/stream_core/lib/src/logger/stream_logger.dart index a597de29..080e17ce 100644 --- a/packages/stream_core/lib/src/logger/stream_logger.dart +++ b/packages/stream_core/lib/src/logger/stream_logger.dart @@ -56,9 +56,8 @@ final class StreamLogger { /// ``` /// /// A priority of its own is a [StreamLogFilter.minPriority], which is why there is no separate one. - /// [filter] defaults to admitting [StreamLogPriority.warning] and above — unlike the ambient one, - /// which admits nothing until an app names a priority, since a detached logger was handed its - /// destination and so has already been asked for. Pass [StreamLogFilter.always] to leave the + /// [filter] defaults to admitting [StreamLogPriority.warning] and above, so a detached logger + /// reports without an app naming a priority for it. Pass [StreamLogFilter.always] to leave the /// decision entirely to the handler. const StreamLogger.detached( this.tag, { @@ -66,9 +65,8 @@ final class StreamLogger { StreamLogFilter this._filter = const .minPriority(.warning), }); - // Null means the ambient one, read when a record is written rather than when this was built. - final StreamLogHandler? _handler; final StreamLogFilter? _filter; + final StreamLogHandler? _handler; /// The name records from this logger carry. /// @@ -80,19 +78,11 @@ final class StreamLogger { /// * [StreamLogFilter.prefix], which turns this convention into a threshold per subsystem. final String tag; + static StreamLogFilter _filterOrDefault = const .minPriority(.none); static StreamLogHandler _handlerOrDefault = StreamLogHandler.silent; - static StreamLogFilter _filterOrDefault = _closed; - // Admits nothing, which is where an app that has not asked for records starts. - static const _closed = StreamLogFilter.minPriority(StreamLogPriority.none); - // The one question the write path asks. `priority` and `filter` are two ways of describing it, - // which is why installing either replaces what the other left behind. - static bool _decide(StreamLogFilter filter, StreamLogPriority priority, String tag) { - // A threshold rather than a severity: comparing it against itself would otherwise admit the one - // record that shutting logging down cannot silence. - if (priority == StreamLogPriority.none) return false; - return filter.isLoggable(priority, tag); - } + StreamLogFilter get _effectiveFilter => _filter ?? _filterOrDefault; + StreamLogHandler get _effectiveHandler => _handler ?? _handlerOrDefault; /// Installs where every record goes, other than those from a [StreamLogger.detached] logger. /// @@ -178,7 +168,7 @@ final class StreamLogger { @visibleForTesting static void reset() { _handlerOrDefault = StreamLogHandler.silent; - _filterOrDefault = _closed; + _filterOrDefault = const .minPriority(.none); } /// Whether a record at [priority] would be kept by both the filter and the handler. @@ -189,7 +179,12 @@ final class StreamLogger { /// ```dart /// if (_log.isLoggable(StreamLogPriority.verbose)) _log.v(() => describe(everyParticipant)); /// ``` - bool isLoggable(StreamLogPriority priority) => _decide(_filter ?? _filterOrDefault, priority, tag); + bool isLoggable(StreamLogPriority priority) { + // A threshold rather than a severity: `none >= none` would otherwise admit the one record that + // shutting logging down cannot silence. + if (priority == StreamLogPriority.none) return false; + return _effectiveFilter.isLoggable(priority, tag); + } /// Writes a [StreamLogPriority.verbose] record. void v( @@ -271,6 +266,6 @@ final class StreamLogger { stackTrace: stackTrace, ); - return (_handler ?? _handlerOrDefault).handle(record); + return _effectiveHandler.handle(record); } } From b904abd46637973c4797e70defbeffeacee183e4 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Tue, 25 Aug 2026 02:47:12 +0200 Subject: [PATCH 22/29] docs(llc): say what `none` is in two lines Co-Authored-By: Claude Opus 5 (1M context) --- .../stream_core/lib/src/logger/stream_log_priority.dart | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/stream_core/lib/src/logger/stream_log_priority.dart b/packages/stream_core/lib/src/logger/stream_log_priority.dart index a6003c5d..5ef6cddb 100644 --- a/packages/stream_core/lib/src/logger/stream_log_priority.dart +++ b/packages/stream_core/lib/src/logger/stream_log_priority.dart @@ -18,11 +18,9 @@ enum StreamLogPriority implements Comparable { /// A failure. error(level: 6, emoji: '🚨', label: 'E'), - /// No severity, used as a threshold that admits nothing. + /// A threshold that admits nothing, not a severity a record can carry. /// - /// Outranks every real severity, so a filter held to this admits no record. It is not a severity - /// a record can carry: `StreamLogger.log` discards one written at this priority, which would - /// otherwise be the only record that no threshold could suppress. + /// Outranks every real severity, so a record written at it is discarded. none(level: 7, emoji: 'đŸ“Ŗ', label: '*'); const StreamLogPriority({required this.level, required this.emoji, required this.label}); From 5a378c86f932c586dd7a70520cefc5a34b1a8ba4 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Tue, 25 Aug 2026 02:52:33 +0200 Subject: [PATCH 23/29] refactor(llc): let a filter decide what a threshold of `none` admits The logger refused `none` as a record's priority, which put a rule about thresholds in the one place that was supposed to delegate them. It also guarded the wrong end: what matters is that a threshold of `none` admits nothing, not that a record can never carry it. Each filter now rejects outright where its threshold is `none`, so `StreamLogFilter` and `StreamLogger` give the same answer where they used to disagree, and the logger is back to asking the filter and nothing else. Co-Authored-By: Claude Opus 5 (1M context) --- .../lib/src/logger/stream_log_filter.dart | 8 +++- .../lib/src/logger/stream_log_priority.dart | 2 +- .../lib/src/logger/stream_logger.dart | 17 +++---- .../test/logger/stream_logger_test.dart | 44 ++++++++++++------- 4 files changed, 42 insertions(+), 29 deletions(-) diff --git a/packages/stream_core/lib/src/logger/stream_log_filter.dart b/packages/stream_core/lib/src/logger/stream_log_filter.dart index 51240eb1..3e27f7cf 100644 --- a/packages/stream_core/lib/src/logger/stream_log_filter.dart +++ b/packages/stream_core/lib/src/logger/stream_log_filter.dart @@ -53,7 +53,12 @@ final class _MinPriorityFilter extends StreamLogFilter { final StreamLogPriority priority; @override - bool isLoggable(StreamLogPriority priority, String tag) => priority >= this.priority; + bool isLoggable(StreamLogPriority priority, String tag) { + // `none` outranks every severity, so comparing against it would admit the records a threshold + // of `none` exists to reject. + if (this.priority == StreamLogPriority.none) return false; + return priority >= this.priority; + } } final class _PrefixFilter extends StreamLogFilter { @@ -75,6 +80,7 @@ final class _PrefixFilter extends StreamLogFilter { matchedLength = prefix.length; } + if (matched == StreamLogPriority.none) return false; return priority >= matched; } } diff --git a/packages/stream_core/lib/src/logger/stream_log_priority.dart b/packages/stream_core/lib/src/logger/stream_log_priority.dart index 5ef6cddb..b89baa88 100644 --- a/packages/stream_core/lib/src/logger/stream_log_priority.dart +++ b/packages/stream_core/lib/src/logger/stream_log_priority.dart @@ -20,7 +20,7 @@ enum StreamLogPriority implements Comparable { /// A threshold that admits nothing, not a severity a record can carry. /// - /// Outranks every real severity, so a record written at it is discarded. + /// A filter held to it rejects every record, whatever its severity. none(level: 7, emoji: 'đŸ“Ŗ', label: '*'); const StreamLogPriority({required this.level, required this.emoji, required this.label}); diff --git a/packages/stream_core/lib/src/logger/stream_logger.dart b/packages/stream_core/lib/src/logger/stream_logger.dart index 080e17ce..222bc003 100644 --- a/packages/stream_core/lib/src/logger/stream_logger.dart +++ b/packages/stream_core/lib/src/logger/stream_logger.dart @@ -179,12 +179,7 @@ final class StreamLogger { /// ```dart /// if (_log.isLoggable(StreamLogPriority.verbose)) _log.v(() => describe(everyParticipant)); /// ``` - bool isLoggable(StreamLogPriority priority) { - // A threshold rather than a severity: `none >= none` would otherwise admit the one record that - // shutting logging down cannot silence. - if (priority == StreamLogPriority.none) return false; - return _effectiveFilter.isLoggable(priority, tag); - } + bool isLoggable(StreamLogPriority priority) => _effectiveFilter.isLoggable(priority, tag); /// Writes a [StreamLogPriority.verbose] record. void v( @@ -192,7 +187,7 @@ final class StreamLogger { Object? error, StackTrace? stackTrace, }) => log( - StreamLogPriority.verbose, + .verbose, message, error: error, stackTrace: stackTrace, @@ -204,7 +199,7 @@ final class StreamLogger { Object? error, StackTrace? stackTrace, }) => log( - StreamLogPriority.debug, + .debug, message, error: error, stackTrace: stackTrace, @@ -216,7 +211,7 @@ final class StreamLogger { Object? error, StackTrace? stackTrace, }) => log( - StreamLogPriority.info, + .info, message, error: error, stackTrace: stackTrace, @@ -228,7 +223,7 @@ final class StreamLogger { Object? error, StackTrace? stackTrace, }) => log( - StreamLogPriority.warning, + .warning, message, error: error, stackTrace: stackTrace, @@ -240,7 +235,7 @@ final class StreamLogger { Object? error, StackTrace? stackTrace, }) => log( - StreamLogPriority.error, + .error, message, error: error, stackTrace: stackTrace, diff --git a/packages/stream_core/test/logger/stream_logger_test.dart b/packages/stream_core/test/logger/stream_logger_test.dart index 0d1b0348..8f70f1f1 100644 --- a/packages/stream_core/test/logger/stream_logger_test.dart +++ b/packages/stream_core/test/logger/stream_logger_test.dart @@ -317,38 +317,50 @@ void main() { }); }); group('StreamLogPriority.none', () { - test('is refused as a record priority, whatever the level admits', () { + test('admits nothing when a filter is held to it', () { final handler = RecordingLogHandler(); - final printed = withStreamLogger( + withStreamLogger( handler: handler, - filter: const StreamLogFilter.always(), - () => capturePrints( - () => const StreamLogger('SC:Component').log(StreamLogPriority.none, () => 'no severity'), - ), + filter: const StreamLogFilter.minPriority(StreamLogPriority.none), + () { + for (final priority in StreamLogPriority.values) { + expect(const StreamLogger('SC:Component').isLoggable(priority), isFalse, reason: '$priority'); + } + const StreamLogger('SC:Component').e(() => 'a failure, while shut down'); + }, ); - // `none` outranks every severity, so a filter comparing against it would admit the one record - // that shutting logging down cannot silence. + // `none` outranks every severity, so a threshold comparing against it would admit the records + // it exists to reject. expect(handler.records, isEmpty); - expect(printed, isEmpty); }); - test('reports itself as not loggable', () { + test('admits nothing on the branch of a prefix rule held to it', () { + final handler = RecordingLogHandler(); + withStreamLogger( - handler: RecordingLogHandler(), - filter: const StreamLogFilter.always(), - () => expect(const StreamLogger('SC:Component').isLoggable(StreamLogPriority.none), isFalse), + handler: handler, + filter: const StreamLogFilter.prefix( + {'SV:': StreamLogPriority.none}, + otherwise: StreamLogPriority.debug, + ), + () { + const StreamLogger('SV:Call').e(() => 'silenced'); + const StreamLogger('SF:Ws').d(() => 'still heard'); + }, ); + + expect(handler.messages, ['still heard']); }); - test('does not build the message it was given', () { + test('does not build a message the threshold rejects', () { var built = 0; withStreamLogger( handler: RecordingLogHandler(), - filter: const StreamLogFilter.always(), - () => const StreamLogger('SC:Component').log(StreamLogPriority.none, () { + filter: const StreamLogFilter.minPriority(StreamLogPriority.none), + () => const StreamLogger('SC:Component').e(() { built++; return 'never built'; }), From 3e9c08cb8b6e19929886c94fa5c4cf94d2703d2e Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Tue, 25 Aug 2026 02:55:22 +0200 Subject: [PATCH 24/29] refactor(llc)!: call it a level, which is how it is used MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every use of the type is a threshold — a filter compares against it, a setter installs one, a config carries one — and `priority` reads as a property of the record rather than the bar it has to clear. `level` is also the word `package:logging`, `logger` and `talker` all use, so the type now matches the vocabulary a Flutter developer arrives with. The integer behind it becomes `value`, as `Level.value` is, since `level.level` said nothing. Co-Authored-By: Claude Opus 5 (1M context) --- packages/stream_core/CHANGELOG.md | 4 +- .../api/interceptors/logging_interceptor.dart | 12 ++-- packages/stream_core/lib/src/logger.dart | 2 +- .../lib/src/logger/stream_log_config.dart | 18 ++--- .../lib/src/logger/stream_log_filter.dart | 50 ++++++------- .../lib/src/logger/stream_log_handler.dart | 10 +-- .../lib/src/logger/stream_log_level.dart | 54 ++++++++++++++ .../lib/src/logger/stream_log_priority.dart | 54 -------------- .../lib/src/logger/stream_log_record.dart | 8 +-- .../lib/src/logger/stream_logger.dart | 70 +++++++++---------- .../ws/client/stream_web_socket_client.dart | 4 +- .../logging_interceptor_test.dart | 2 +- .../test/logger/stream_log_config_test.dart | 28 ++++---- .../test/logger/stream_log_filter_test.dart | 50 ++++++------- .../test/logger/stream_log_handler_test.dart | 20 +++--- .../test/logger/stream_log_level_test.dart | 60 ++++++++++++++++ .../test/logger/stream_log_priority_test.dart | 60 ---------------- .../logger/stream_logger_defaults_test.dart | 6 +- .../test/logger/stream_logger_test.dart | 56 +++++++-------- .../stream_core/test/query/filter_test.dart | 4 +- .../engine/stream_web_socket_engine_test.dart | 2 +- 21 files changed, 287 insertions(+), 287 deletions(-) create mode 100644 packages/stream_core/lib/src/logger/stream_log_level.dart delete mode 100644 packages/stream_core/lib/src/logger/stream_log_priority.dart create mode 100644 packages/stream_core/test/logger/stream_log_level_test.dart delete mode 100644 packages/stream_core/test/logger/stream_log_priority_test.dart diff --git a/packages/stream_core/CHANGELOG.md b/packages/stream_core/CHANGELOG.md index 517ecda9..cb9001b9 100644 --- a/packages/stream_core/CHANGELOG.md +++ b/packages/stream_core/CHANGELOG.md @@ -15,12 +15,12 @@ - `WebSocketConnectionState.isAutomaticReconnectionEnabled` is now `true` for an expired token, and remains `false` for token errors a fresh token cannot fix - `StreamApiError.isTokenExpiredError` now means code 40 only; the other token codes and a wrong API key are `isInvalidTokenError`. `isClientError` compares the HTTP `statusCode` against 400..499, rather than the Stream error `code`, which never falls in that range - `Result.getOrElse`, `getOrDefault`, `recover` and `recoverCatching` return the result's own type and no longer take a type parameter. To widen, widen the result (`Result widened = intResult`) or use `fold` -- Replaced the logger: `StreamLogger` is the handle you write with and a `StreamLogHandler` is where records go, so `Priority`, `MessageBuilder`, `Tag`, `IsLoggableValidator` and `Finder` are renamed or gone +- Replaced the logger: `StreamLogger` is the handle you write with and a `StreamLogHandler` is where records go, so `Level`, `MessageBuilder`, `Tag`, `IsLoggableValidator` and `Finder` are renamed or gone - `LoggingInterceptor` writes through the logger rather than printing, so it is silent until an app installs a handler. Its `logPrint` is now optional, and it takes a `tag` ### ✨ Features -- Added a logger the SDK now reports itself through, silent until an app names both a destination and a priority on `StreamLogger`, or hands a product client a `StreamLogConfig` carrying both +- Added a logger the SDK now reports itself through, silent until an app names both a destination and a level on `StreamLogger`, or hands a product client a `StreamLogConfig` carrying both - Added `TokenManager.setTokenProvider`, which points an existing manager at another user and expires the cached token; handed the identity it already has, it does nothing - Added optional `onTokenUpdated` callback to `TokenManager`, invoked after every successful token load - Added optional `rawValue` to `UserToken.anonymous`, so an anonymous token can carry a JWT granting restricted access; its `user_id` claim must be `!anon` diff --git a/packages/stream_core/lib/src/api/interceptors/logging_interceptor.dart b/packages/stream_core/lib/src/api/interceptors/logging_interceptor.dart index bcbda632..157a999b 100644 --- a/packages/stream_core/lib/src/api/interceptors/logging_interceptor.dart +++ b/packages/stream_core/lib/src/api/interceptors/logging_interceptor.dart @@ -23,7 +23,7 @@ typedef LogPrint = void Function(InterceptStep step, Object object); /// An interceptor that reports each request and the response it gets. /// -/// Records go out under `SC:Http`, at [StreamLogPriority.debug], or [StreamLogPriority.warning] +/// Records go out under `SC:Http`, at [StreamLogLevel.debug], or [StreamLogLevel.warning] /// for a request that failed. Nothing is written, or even formatted, until an app installs a /// [StreamLogHandler]. /// @@ -82,19 +82,19 @@ class LoggingInterceptor extends Interceptor { // Consulted before a line is formatted, so a request costs nothing while nothing wants it. bool _wants(InterceptStep step) { if (logPrint != null) return true; - return _logger.isLoggable(_priorityOf(step)); + return _logger.isLoggable(_levelOf(step)); } - StreamLogPriority _priorityOf(InterceptStep step) { + StreamLogLevel _levelOf(InterceptStep step) { return switch (step) { - InterceptStep.error => StreamLogPriority.warning, - InterceptStep.request || InterceptStep.response => StreamLogPriority.debug, + InterceptStep.error => StreamLogLevel.warning, + InterceptStep.request || InterceptStep.response => StreamLogLevel.debug, }; } void _write(InterceptStep step, Object object) { if (logPrint case final logPrint?) return logPrint(step, object); - return _logger.log(_priorityOf(step), () => '$object'); + return _logger.log(_levelOf(step), () => '$object'); } @override diff --git a/packages/stream_core/lib/src/logger.dart b/packages/stream_core/lib/src/logger.dart index 2e0e220d..005d8440 100644 --- a/packages/stream_core/lib/src/logger.dart +++ b/packages/stream_core/lib/src/logger.dart @@ -1,6 +1,6 @@ export 'logger/stream_log_config.dart'; export 'logger/stream_log_filter.dart'; export 'logger/stream_log_handler.dart'; -export 'logger/stream_log_priority.dart'; +export 'logger/stream_log_level.dart'; export 'logger/stream_log_record.dart'; export 'logger/stream_logger.dart'; diff --git a/packages/stream_core/lib/src/logger/stream_log_config.dart b/packages/stream_core/lib/src/logger/stream_log_config.dart index acedd2a1..9c676dd4 100644 --- a/packages/stream_core/lib/src/logger/stream_log_config.dart +++ b/packages/stream_core/lib/src/logger/stream_log_config.dart @@ -1,6 +1,6 @@ import 'stream_log_filter.dart'; import 'stream_log_handler.dart'; -import 'stream_log_priority.dart'; +import 'stream_log_level.dart'; import 'stream_logger.dart'; /// How much a Stream SDK reports, and where those records go. @@ -13,7 +13,7 @@ import 'stream_logger.dart'; /// apiKey: 'your-api-key', /// user: user, /// config: const FeedsConfig( -/// logging: StreamLogConfig(priority: StreamLogPriority.debug), +/// logging: StreamLogConfig(level: StreamLogLevel.debug), /// ), /// ); /// ``` @@ -23,7 +23,7 @@ import 'stream_logger.dart'; class StreamLogConfig { /// Creates a [StreamLogConfig]. const StreamLogConfig({ - this.priority = StreamLogPriority.warning, + this.level = StreamLogLevel.warning, this.handler = defaultHandler, this.filter, }); @@ -31,11 +31,11 @@ class StreamLogConfig { /// Where records go when a config names no handler of its own. static const defaultHandler = StreamLogHandler.console(); - /// The lowest priority worth reporting. + /// The lowest level worth reporting. /// - /// [StreamLogPriority.none] silences a logger another SDK configured. Ignored where [filter] is + /// [StreamLogLevel.none] silences a logger another SDK configured. Ignored where [filter] is /// given, which decides the same thing in more detail. - final StreamLogPriority priority; + final StreamLogLevel level; /// Where records go. /// @@ -50,15 +50,15 @@ class StreamLogConfig { /// ``` final StreamLogHandler handler; - /// Which records are built at all, for a rule [priority] cannot express. + /// Which records are built at all, for a rule [level] cannot express. /// /// Holds one subsystem to a different threshold than the rest: /// /// ```dart /// StreamLogConfig( /// filter: StreamLogFilter.prefix( - /// {'SF:Ws': StreamLogPriority.verbose}, - /// otherwise: StreamLogPriority.warning, + /// {'SF:Ws': StreamLogLevel.verbose}, + /// otherwise: StreamLogLevel.warning, /// ), /// ) /// ``` diff --git a/packages/stream_core/lib/src/logger/stream_log_filter.dart b/packages/stream_core/lib/src/logger/stream_log_filter.dart index 3e27f7cf..490d8729 100644 --- a/packages/stream_core/lib/src/logger/stream_log_filter.dart +++ b/packages/stream_core/lib/src/logger/stream_log_filter.dart @@ -1,4 +1,4 @@ -import 'stream_log_priority.dart'; +import 'stream_log_level.dart'; /// Decides which records are worth building, independently of where they end up. /// @@ -10,8 +10,8 @@ import 'stream_log_priority.dart'; /// /// ```dart /// StreamLogger.filter = const StreamLogFilter.prefix( -/// {'SC:Ws': StreamLogPriority.verbose}, -/// otherwise: StreamLogPriority.warning, +/// {'SC:Ws': StreamLogLevel.verbose}, +/// otherwise: StreamLogLevel.warning, /// ); /// ``` abstract class StreamLogFilter { @@ -21,58 +21,58 @@ abstract class StreamLogFilter { /// A filter admitting every record, leaving the decision to the handler. const factory StreamLogFilter.always() = _AlwaysFilter; - /// A filter admitting records at [priority] or above, whatever their tag. - const factory StreamLogFilter.minPriority(StreamLogPriority priority) = _MinPriorityFilter; + /// A filter admitting records at [level] or above, whatever their tag. + const factory StreamLogFilter.minLevel(StreamLogLevel level) = _MinLevelFilter; /// A filter admitting records by the prefix of their tag. /// - /// The longest prefix in [priorities] matching a tag decides it, so a broad rule can be narrowed by + /// The longest prefix in [levels] matching a tag decides it, so a broad rule can be narrowed by /// a longer one. A tag matching no prefix is held to [otherwise]. /// - /// What a record costs grows with the number of rules, so consider keeping [priorities] to the + /// What a record costs grows with the number of rules, so consider keeping [levels] to the /// subsystems actually being tuned. const factory StreamLogFilter.prefix( - Map priorities, { - StreamLogPriority otherwise, + Map levels, { + StreamLogLevel otherwise, }) = _PrefixFilter; - /// Whether a record at [priority] from [tag] is worth building. - bool isLoggable(StreamLogPriority priority, String tag); + /// Whether a record at [level] from [tag] is worth building. + bool isLoggable(StreamLogLevel level, String tag); } final class _AlwaysFilter extends StreamLogFilter { const _AlwaysFilter(); @override - bool isLoggable(StreamLogPriority priority, String tag) => true; + bool isLoggable(StreamLogLevel level, String tag) => true; } -final class _MinPriorityFilter extends StreamLogFilter { - const _MinPriorityFilter(this.priority); +final class _MinLevelFilter extends StreamLogFilter { + const _MinLevelFilter(this.level); - final StreamLogPriority priority; + final StreamLogLevel level; @override - bool isLoggable(StreamLogPriority priority, String tag) { + bool isLoggable(StreamLogLevel level, String tag) { // `none` outranks every severity, so comparing against it would admit the records a threshold // of `none` exists to reject. - if (this.priority == StreamLogPriority.none) return false; - return priority >= this.priority; + if (this.level == StreamLogLevel.none) return false; + return level >= this.level; } } final class _PrefixFilter extends StreamLogFilter { - const _PrefixFilter(this.priorities, {this.otherwise = StreamLogPriority.warning}); + const _PrefixFilter(this.levels, {this.otherwise = StreamLogLevel.warning}); - final Map priorities; - final StreamLogPriority otherwise; + final Map levels; + final StreamLogLevel otherwise; @override - bool isLoggable(StreamLogPriority priority, String tag) { + bool isLoggable(StreamLogLevel level, String tag) { var matched = otherwise; var matchedLength = -1; - for (final MapEntry(key: prefix, value: threshold) in priorities.entries) { + for (final MapEntry(key: prefix, value: threshold) in levels.entries) { if (prefix.length <= matchedLength) continue; if (!tag.startsWith(prefix)) continue; @@ -80,7 +80,7 @@ final class _PrefixFilter extends StreamLogFilter { matchedLength = prefix.length; } - if (matched == StreamLogPriority.none) return false; - return priority >= matched; + if (matched == StreamLogLevel.none) return false; + return level >= matched; } } diff --git a/packages/stream_core/lib/src/logger/stream_log_handler.dart b/packages/stream_core/lib/src/logger/stream_log_handler.dart index 45a6559d..7758d3d8 100644 --- a/packages/stream_core/lib/src/logger/stream_log_handler.dart +++ b/packages/stream_core/lib/src/logger/stream_log_handler.dart @@ -23,7 +23,7 @@ typedef StreamLogCallback = void Function(StreamLogRecord record); /// ``` /// /// Wrap it in [StreamLogHandler.filtered] to hold it to a threshold, rather than comparing -/// priorities inside it. +/// levels inside it. abstract class StreamLogHandler { /// Creates a [StreamLogHandler]. const StreamLogHandler(); @@ -41,7 +41,7 @@ abstract class StreamLogHandler { /// StreamLogger.handler = StreamLogHandler.from((record) => debugPrint('$record')); /// ``` /// - /// Emits whatever [StreamLogger.priority] admits. Wrap in [StreamLogHandler.filtered] to hold + /// Emits whatever [StreamLogger.level] admits. Wrap in [StreamLogHandler.filtered] to hold /// this destination quieter than the rest. const factory StreamLogHandler.console() = _ConsoleHandler; @@ -62,13 +62,13 @@ abstract class StreamLogHandler { /// StreamLogHandler.composite([ /// fileLogger, /// StreamLogHandler.filtered( - /// const StreamLogFilter.minPriority(StreamLogPriority.error), + /// const StreamLogFilter.minLevel(StreamLogLevel.error), /// const StreamLogHandler.console(), /// ), /// ]); /// ``` /// - /// [StreamLogFilter.prefix] narrows by tag rather than priority, which is how one SDK's records + /// [StreamLogFilter.prefix] narrows by tag rather than level, which is how one SDK's records /// are sent somewhere the rest are not. const factory StreamLogHandler.filtered(StreamLogFilter filter, StreamLogHandler handler) = _FilteredHandler; @@ -131,7 +131,7 @@ final class _FilteredHandler extends StreamLogHandler { @override void handle(StreamLogRecord record) { - if (filter.isLoggable(record.priority, record.tag)) handler.handle(record); + if (filter.isLoggable(record.level, record.tag)) handler.handle(record); } } diff --git a/packages/stream_core/lib/src/logger/stream_log_level.dart b/packages/stream_core/lib/src/logger/stream_log_level.dart new file mode 100644 index 00000000..929202eb --- /dev/null +++ b/packages/stream_core/lib/src/logger/stream_log_level.dart @@ -0,0 +1,54 @@ +/// The severity of a log record. +/// +/// Ordered from least to most severe, so a threshold can be expressed by comparing a record's +/// level against it. [none] outranks every real severity, and so admits nothing. +enum StreamLogLevel implements Comparable { + /// Fine-grained detail on a hot path, such as an individual heartbeat. + verbose(value: 2, emoji: '🔍', label: 'V'), + + /// The steps a subsystem takes, such as a connection changing state. + debug(value: 3, emoji: '🔧', label: 'D'), + + /// A milestone worth seeing without opting into the full trace. + info(value: 4, emoji: 'â„šī¸', label: 'I'), + + /// Something recoverable that the caller may still want to act on. + warning(value: 5, emoji: 'âš ī¸', label: 'W'), + + /// A failure. + error(value: 6, emoji: '🚨', label: 'E'), + + /// A threshold that admits nothing, not a severity a record can carry. + /// + /// A filter held to it rejects every record, whatever its severity. + none(value: 7, emoji: 'đŸ“Ŗ', label: '*'); + + const StreamLogLevel({required this.value, required this.emoji, required this.label}); + + /// The rank of this level, where a higher number is more severe. + final int value; + + /// A glyph identifying this level at a glance, for handlers that render one. + final String emoji; + + /// A single-letter abbreviation of this level, for handlers that render one. + final String label; + + @override + String toString() => name; + + @override + int compareTo(StreamLogLevel other) => value.compareTo(other.value); + + /// Whether this level is less severe than [other]. + bool operator <(StreamLogLevel other) => value < other.value; + + /// Whether this level is no more severe than [other]. + bool operator <=(StreamLogLevel other) => value <= other.value; + + /// Whether this level is more severe than [other]. + bool operator >(StreamLogLevel other) => value > other.value; + + /// Whether this level is at least as severe as [other]. + bool operator >=(StreamLogLevel other) => value >= other.value; +} diff --git a/packages/stream_core/lib/src/logger/stream_log_priority.dart b/packages/stream_core/lib/src/logger/stream_log_priority.dart deleted file mode 100644 index b89baa88..00000000 --- a/packages/stream_core/lib/src/logger/stream_log_priority.dart +++ /dev/null @@ -1,54 +0,0 @@ -/// The severity of a log record. -/// -/// Ordered from least to most severe, so a threshold can be expressed by comparing a record's -/// priority against it. [none] outranks every real severity, and so admits nothing. -enum StreamLogPriority implements Comparable { - /// Fine-grained detail on a hot path, such as an individual heartbeat. - verbose(level: 2, emoji: '🔍', label: 'V'), - - /// The steps a subsystem takes, such as a connection changing state. - debug(level: 3, emoji: '🔧', label: 'D'), - - /// A milestone worth seeing without opting into the full trace. - info(level: 4, emoji: 'â„šī¸', label: 'I'), - - /// Something recoverable that the caller may still want to act on. - warning(level: 5, emoji: 'âš ī¸', label: 'W'), - - /// A failure. - error(level: 6, emoji: '🚨', label: 'E'), - - /// A threshold that admits nothing, not a severity a record can carry. - /// - /// A filter held to it rejects every record, whatever its severity. - none(level: 7, emoji: 'đŸ“Ŗ', label: '*'); - - const StreamLogPriority({required this.level, required this.emoji, required this.label}); - - /// The rank of this priority, where a higher number is more severe. - final int level; - - /// A glyph identifying this priority at a glance, for handlers that render one. - final String emoji; - - /// A single-letter abbreviation of this priority, for handlers that render one. - final String label; - - @override - String toString() => name; - - @override - int compareTo(StreamLogPriority other) => level.compareTo(other.level); - - /// Whether this priority is less severe than [other]. - bool operator <(StreamLogPriority other) => level < other.level; - - /// Whether this priority is no more severe than [other]. - bool operator <=(StreamLogPriority other) => level <= other.level; - - /// Whether this priority is more severe than [other]. - bool operator >(StreamLogPriority other) => level > other.level; - - /// Whether this priority is at least as severe as [other]. - bool operator >=(StreamLogPriority other) => level >= other.level; -} diff --git a/packages/stream_core/lib/src/logger/stream_log_record.dart b/packages/stream_core/lib/src/logger/stream_log_record.dart index 68667b4a..d87ef073 100644 --- a/packages/stream_core/lib/src/logger/stream_log_record.dart +++ b/packages/stream_core/lib/src/logger/stream_log_record.dart @@ -1,6 +1,6 @@ import 'package:clock/clock.dart'; -import 'stream_log_priority.dart'; +import 'stream_log_level.dart'; /// A single log record, as a handler receives it. /// @@ -13,7 +13,7 @@ final class StreamLogRecord { /// Creates a [StreamLogRecord], stamping it with the current [time] and the next /// [sequenceNumber]. StreamLogRecord({ - required this.priority, + required this.level, required this.tag, required this.message, this.error, @@ -24,7 +24,7 @@ final class StreamLogRecord { static var _sequence = 0; /// The severity of this record. - final StreamLogPriority priority; + final StreamLogLevel level; /// The component this record came from. final String tag; @@ -50,5 +50,5 @@ final class StreamLogRecord { final StackTrace? stackTrace; @override - String toString() => '${priority.emoji} ${priority.label}/$tag: $message'; + String toString() => '${level.emoji} ${level.label}/$tag: $message'; } diff --git a/packages/stream_core/lib/src/logger/stream_logger.dart b/packages/stream_core/lib/src/logger/stream_logger.dart index 222bc003..85fb83b9 100644 --- a/packages/stream_core/lib/src/logger/stream_logger.dart +++ b/packages/stream_core/lib/src/logger/stream_logger.dart @@ -3,7 +3,7 @@ import 'package:meta/meta.dart'; import 'stream_log_config.dart'; import 'stream_log_filter.dart'; import 'stream_log_handler.dart'; -import 'stream_log_priority.dart'; +import 'stream_log_level.dart'; import 'stream_log_record.dart'; /// Builds a log message on demand. @@ -31,7 +31,7 @@ typedef StreamLogMessage = String Function(); /// /// ```dart /// StreamLogger.handler = const StreamLogHandler.console(); -/// StreamLogger.priority = StreamLogPriority.debug; +/// StreamLogger.level = StreamLogLevel.debug; /// ``` /// /// Records go to one place, so routing two SDKs apart is a matter of a [StreamLogHandler] reading @@ -51,18 +51,18 @@ final class StreamLogger { /// final _log = StreamLogger.detached( /// 'SF:Upload', /// handler: const StreamLogHandler.console(), - /// filter: const StreamLogFilter.minPriority(StreamLogPriority.debug), + /// filter: const StreamLogFilter.minLevel(StreamLogLevel.debug), /// ); /// ``` /// - /// A priority of its own is a [StreamLogFilter.minPriority], which is why there is no separate one. - /// [filter] defaults to admitting [StreamLogPriority.warning] and above, so a detached logger - /// reports without an app naming a priority for it. Pass [StreamLogFilter.always] to leave the + /// A level of its own is a [StreamLogFilter.minLevel], which is why there is no separate one. + /// [filter] defaults to admitting [StreamLogLevel.warning] and above, so a detached logger + /// reports without an app naming a level for it. Pass [StreamLogFilter.always] to leave the /// decision entirely to the handler. const StreamLogger.detached( this.tag, { required StreamLogHandler this._handler, - StreamLogFilter this._filter = const .minPriority(.warning), + StreamLogFilter this._filter = const .minLevel(.warning), }); final StreamLogFilter? _filter; @@ -78,7 +78,7 @@ final class StreamLogger { /// * [StreamLogFilter.prefix], which turns this convention into a threshold per subsystem. final String tag; - static StreamLogFilter _filterOrDefault = const .minPriority(.none); + static StreamLogFilter _filterOrDefault = const .minLevel(.none); static StreamLogHandler _handlerOrDefault = StreamLogHandler.silent; StreamLogFilter get _effectiveFilter => _filter ?? _filterOrDefault; @@ -86,38 +86,38 @@ final class StreamLogger { /// Installs where every record goes, other than those from a [StreamLogger.detached] logger. /// - /// A destination on its own reports nothing: name a [priority] beside it, or hand both to + /// A destination on its own reports nothing: name a [level] beside it, or hand both to /// [configure] at once. Setting it applies to loggers that already exist, including any built at /// class-load, because a logger resolves this when it writes rather than when it was created. /// /// ```dart /// StreamLogger.handler = const StreamLogHandler.console(); - /// StreamLogger.priority = StreamLogPriority.warning; + /// StreamLogger.level = StreamLogLevel.warning; /// ``` /// /// Write-only, so nothing can come to depend on what happens to be installed. Consider /// [StreamLogHandler.composite] to send records to more than one place. static set handler(StreamLogHandler handler) => _handlerOrDefault = handler; - /// Installs the lowest priority worth building a record for. + /// Installs the lowest level worth building a record for. /// /// Nothing is admitted until this is named, so an SDK stays silent in an app that has not asked /// for records, and a record it rejects is never built: /// /// ```dart - /// StreamLogger.priority = StreamLogPriority.debug; + /// StreamLogger.level = StreamLogLevel.debug; /// ``` /// - /// Shorthand for a [StreamLogFilter.minPriority], so this and [filter] are one setting: whichever + /// Shorthand for a [StreamLogFilter.minLevel], so this and [filter] are one setting: whichever /// is written last decides. - static set priority(StreamLogPriority priority) => filter = .minPriority(priority); + static set level(StreamLogLevel level) => filter = .minLevel(level); - /// Installs which records are built at all, for a rule [priority] cannot express. + /// Installs which records are built at all, for a rule [level] cannot express. /// /// ```dart /// StreamLogger.filter = const StreamLogFilter.prefix( - /// {'SC:Ws': StreamLogPriority.verbose}, - /// otherwise: StreamLogPriority.warning, + /// {'SC:Ws': StreamLogLevel.verbose}, + /// otherwise: StreamLogLevel.warning, /// ); /// ``` static set filter(StreamLogFilter filter) => _filterOrDefault = filter; @@ -133,7 +133,7 @@ final class StreamLogger { /// ``` /// /// A config replaces both settings outright, so anything installed through [filter] before this - /// is lost — including to a config that named only a [priority]. Put the rule in + /// is lost — including to a config that named only a [level]. Put the rule in /// [StreamLogConfig.filter] instead, where a client carries it rather than flattening it. /// /// One logger serves the process, so this decides logging for every Stream SDK in it, not only @@ -144,8 +144,8 @@ final class StreamLogger { /// ```dart /// StreamLogConfig( /// filter: StreamLogFilter.prefix( - /// {'SF:': StreamLogPriority.debug}, - /// otherwise: StreamLogPriority.none, + /// {'SF:': StreamLogLevel.debug}, + /// otherwise: StreamLogLevel.none, /// ), /// ) /// ``` @@ -153,10 +153,10 @@ final class StreamLogger { if (config == null) return; _handlerOrDefault = config.handler; - _filterOrDefault = config.filter ?? .minPriority(config.priority); + _filterOrDefault = config.filter ?? .minLevel(config.level); } - /// Puts [handler] and [priority] back to what they were before anything was installed. + /// Puts [handler] and [level] back to what they were before anything was installed. /// /// What an app installs is process-wide, so a test that installs a handler and leaves it there /// changes what every later test sees. Restoring by hand means naming the defaults, which a @@ -168,20 +168,20 @@ final class StreamLogger { @visibleForTesting static void reset() { _handlerOrDefault = StreamLogHandler.silent; - _filterOrDefault = const .minPriority(.none); + _filterOrDefault = const .minLevel(.none); } - /// Whether a record at [priority] would be kept by both the filter and the handler. + /// Whether a record at [level] would be kept by both the filter and the handler. /// /// Records are already gated, so this is only worth calling to guard a message that is /// expensive to build beyond its interpolation: /// /// ```dart - /// if (_log.isLoggable(StreamLogPriority.verbose)) _log.v(() => describe(everyParticipant)); + /// if (_log.isLoggable(StreamLogLevel.verbose)) _log.v(() => describe(everyParticipant)); /// ``` - bool isLoggable(StreamLogPriority priority) => _effectiveFilter.isLoggable(priority, tag); + bool isLoggable(StreamLogLevel level) => _effectiveFilter.isLoggable(level, tag); - /// Writes a [StreamLogPriority.verbose] record. + /// Writes a [StreamLogLevel.verbose] record. void v( StreamLogMessage message, { Object? error, @@ -193,7 +193,7 @@ final class StreamLogger { stackTrace: stackTrace, ); - /// Writes a [StreamLogPriority.debug] record. + /// Writes a [StreamLogLevel.debug] record. void d( StreamLogMessage message, { Object? error, @@ -205,7 +205,7 @@ final class StreamLogger { stackTrace: stackTrace, ); - /// Writes a [StreamLogPriority.info] record. + /// Writes a [StreamLogLevel.info] record. void i( StreamLogMessage message, { Object? error, @@ -217,7 +217,7 @@ final class StreamLogger { stackTrace: stackTrace, ); - /// Writes a [StreamLogPriority.warning] record. + /// Writes a [StreamLogLevel.warning] record. void w( StreamLogMessage message, { Object? error, @@ -229,7 +229,7 @@ final class StreamLogger { stackTrace: stackTrace, ); - /// Writes a [StreamLogPriority.error] record. + /// Writes a [StreamLogLevel.error] record. void e( StreamLogMessage message, { Object? error, @@ -241,20 +241,20 @@ final class StreamLogger { stackTrace: stackTrace, ); - /// Writes a record at [priority]. + /// Writes a record at [level]. /// /// [message] is called only if the record is kept. [error] and [stackTrace] carry the cause /// when the record describes a failure. void log( - StreamLogPriority priority, + StreamLogLevel level, StreamLogMessage message, { Object? error, StackTrace? stackTrace, }) { - if (!isLoggable(priority)) return; + if (!isLoggable(level)) return; final record = StreamLogRecord( - priority: priority, + level: level, tag: tag, message: message(), error: error, diff --git a/packages/stream_core/lib/src/ws/client/stream_web_socket_client.dart b/packages/stream_core/lib/src/ws/client/stream_web_socket_client.dart index accc23f8..36bf478e 100644 --- a/packages/stream_core/lib/src/ws/client/stream_web_socket_client.dart +++ b/packages/stream_core/lib/src/ws/client/stream_web_socket_client.dart @@ -55,7 +55,7 @@ typedef WebSocketOptionsBuilder = WebSocketOptions Function(); /// written until an app installs a [StreamLogHandler]: /// /// ```dart -/// StreamLogger.handler = const StreamLogHandler.filtered(StreamLogFilter.minPriority(StreamLogPriority.debug), StreamLogHandler.console()); +/// StreamLogger.handler = const StreamLogHandler.filtered(StreamLogFilter.minLevel(StreamLogLevel.debug), StreamLogHandler.console()); /// ``` /// /// Give a second client its own `tag` to tell the two apart. Its collaborators are tagged from @@ -63,7 +63,7 @@ typedef WebSocketOptionsBuilder = WebSocketOptions Function(); /// /// ```dart /// StreamWebSocketClient(tag: 'SC:Ws2', ...); -/// StreamLogger.filter = const StreamLogFilter.prefix({'SC:Ws2': StreamLogPriority.verbose}); +/// StreamLogger.filter = const StreamLogFilter.prefix({'SC:Ws2': StreamLogLevel.verbose}); /// ``` class StreamWebSocketClient with Disposable implements WebSocketHealthListener, WebSocketEngineListener { /// Creates a new instance of [StreamWebSocketClient]. diff --git a/packages/stream_core/test/api/interceptors/logging_interceptor_test.dart b/packages/stream_core/test/api/interceptors/logging_interceptor_test.dart index 7eed8dfb..ec620b2f 100644 --- a/packages/stream_core/test/api/interceptors/logging_interceptor_test.dart +++ b/packages/stream_core/test/api/interceptors/logging_interceptor_test.dart @@ -87,7 +87,7 @@ void main() { await withStreamLogger( handler: handler, - filter: const StreamLogFilter.minPriority(StreamLogPriority.debug), + filter: const StreamLogFilter.minLevel(StreamLogLevel.debug), () async { await dio.get('/test'); await pumpEventQueue(); diff --git a/packages/stream_core/test/logger/stream_log_config_test.dart b/packages/stream_core/test/logger/stream_log_config_test.dart index d79bc17b..60289b8c 100644 --- a/packages/stream_core/test/logger/stream_log_config_test.dart +++ b/packages/stream_core/test/logger/stream_log_config_test.dart @@ -12,7 +12,7 @@ void main() { test('leaves the logger alone when there is no config', () { final installed = RecordingLogHandler(); StreamLogger.handler = installed; - StreamLogger.priority = StreamLogPriority.verbose; + StreamLogger.level = StreamLogLevel.verbose; StreamLogger.configure(null); _logger.d(() => 'another SDK, still heard'); @@ -21,16 +21,16 @@ void main() { expect(installed.messages, ['another SDK, still heard']); }); - test('writes to the console, given a priority and nowhere to put it', () { + test('writes to the console, given a level and nowhere to put it', () { final printed = capturePrints(() { - StreamLogger.configure(const StreamLogConfig(priority: StreamLogPriority.debug)); + StreamLogger.configure(const StreamLogConfig(level: StreamLogLevel.debug)); _logger.d(() => 'to the console'); }); expect(printed.single, contains('to the console')); }); - test('hears warnings, given a handler and no priority', () { + test('hears warnings, given a handler and no level', () { final mine = RecordingLogHandler(); StreamLogger.configure(StreamLogConfig(handler: mine)); @@ -45,7 +45,7 @@ void main() { final mine = RecordingLogHandler(); StreamLogger.configure( - StreamLogConfig(priority: StreamLogPriority.none, handler: mine), + StreamLogConfig(level: StreamLogLevel.none, handler: mine), ); _logger.e(() => 'not even an error'); @@ -58,24 +58,24 @@ void main() { StreamLogger.configure( StreamLogConfig( handler: mine, - filter: const StreamLogFilter.prefix({'SF:Ws': StreamLogPriority.verbose}), + filter: const StreamLogFilter.prefix({'SF:Ws': StreamLogLevel.verbose}), ), ); const StreamLogger('SF:Ws').v(() => 'the subsystem I turned up'); _logger.d(() => 'the commentary I did not'); - // The filter has to survive the config that carries it: `priority` sets the same field, so a + // The filter has to survive the config that carries it: `level` sets the same field, so a // config applying both would flatten the rule it was given. expect(mine.messages, ['the subsystem I turned up']); }); - test('replaces a filter installed before it, even naming only a priority', () { + test('replaces a filter installed before it, even naming only a level', () { final mine = RecordingLogHandler(); - StreamLogger.filter = const StreamLogFilter.prefix({'SF:Ws': StreamLogPriority.verbose}); + StreamLogger.filter = const StreamLogFilter.prefix({'SF:Ws': StreamLogLevel.verbose}); - StreamLogger.configure(StreamLogConfig(priority: StreamLogPriority.debug, handler: mine)); + StreamLogger.configure(StreamLogConfig(level: StreamLogLevel.debug, handler: mine)); const StreamLogger('SF:Ws').v(() => 'below what the config asked for'); - // A config is the whole story: its priority and filter are one field underneath, so there is + // A config is the whole story: its level and filter are one field underneath, so there is // no reading of it that keeps an earlier rule and the new threshold both. expect(mine.records, isEmpty); }); @@ -90,7 +90,7 @@ void main() { test('report together once either of them is configured', () { final mine = RecordingLogHandler(); - StreamLogger.configure(StreamLogConfig(priority: StreamLogPriority.debug, handler: mine)); + StreamLogger.configure(StreamLogConfig(level: StreamLogLevel.debug, handler: mine)); feeds.d(() => 'feeds'); video.d(() => 'video'); @@ -118,8 +118,8 @@ void main() { StreamLogConfig( handler: mine, filter: const StreamLogFilter.prefix( - {'SF:': StreamLogPriority.debug}, - otherwise: StreamLogPriority.none, + {'SF:': StreamLogLevel.debug}, + otherwise: StreamLogLevel.none, ), ), ); diff --git a/packages/stream_core/test/logger/stream_log_filter_test.dart b/packages/stream_core/test/logger/stream_log_filter_test.dart index 0a3d02b6..4b632f2d 100644 --- a/packages/stream_core/test/logger/stream_log_filter_test.dart +++ b/packages/stream_core/test/logger/stream_log_filter_test.dart @@ -4,58 +4,58 @@ import 'package:test/test.dart'; import '../helpers/logger.dart'; void main() { - group('StreamLogFilter.minPriority', () { + group('StreamLogFilter.minLevel', () { test('admits records at the level or above, whatever the tag', () { - const filter = StreamLogFilter.minPriority(StreamLogPriority.warning); + const filter = StreamLogFilter.minLevel(StreamLogLevel.warning); - expect(filter.isLoggable(StreamLogPriority.debug, 'SC:Anything'), isFalse); - expect(filter.isLoggable(StreamLogPriority.warning, 'SC:Anything'), isTrue); - expect(filter.isLoggable(StreamLogPriority.error, 'SF:Something'), isTrue); + expect(filter.isLoggable(StreamLogLevel.debug, 'SC:Anything'), isFalse); + expect(filter.isLoggable(StreamLogLevel.warning, 'SC:Anything'), isTrue); + expect(filter.isLoggable(StreamLogLevel.error, 'SF:Something'), isTrue); }); }); group('StreamLogFilter.prefix', () { test('holds a matching tag to its own threshold', () { - const filter = StreamLogFilter.prefix({'SC:Ws': StreamLogPriority.verbose}); + const filter = StreamLogFilter.prefix({'SC:Ws': StreamLogLevel.verbose}); - expect(filter.isLoggable(StreamLogPriority.verbose, 'SC:WsClient'), isTrue); - expect(filter.isLoggable(StreamLogPriority.verbose, 'SC:Http'), isFalse); + expect(filter.isLoggable(StreamLogLevel.verbose, 'SC:WsClient'), isTrue); + expect(filter.isLoggable(StreamLogLevel.verbose, 'SC:Http'), isFalse); }); test('holds everything else to `otherwise`', () { const filter = StreamLogFilter.prefix( - {'SC:Ws': StreamLogPriority.verbose}, - otherwise: StreamLogPriority.error, + {'SC:Ws': StreamLogLevel.verbose}, + otherwise: StreamLogLevel.error, ); - expect(filter.isLoggable(StreamLogPriority.warning, 'SC:Http'), isFalse); - expect(filter.isLoggable(StreamLogPriority.error, 'SC:Http'), isTrue); + expect(filter.isLoggable(StreamLogLevel.warning, 'SC:Http'), isFalse); + expect(filter.isLoggable(StreamLogLevel.error, 'SC:Http'), isTrue); }); test('lets the longest prefix win, so a broad rule can be narrowed', () { const filter = StreamLogFilter.prefix({ - 'SC:': StreamLogPriority.verbose, - 'SC:WsHealth': StreamLogPriority.warning, + 'SC:': StreamLogLevel.verbose, + 'SC:WsHealth': StreamLogLevel.warning, }); - expect(filter.isLoggable(StreamLogPriority.verbose, 'SC:WsClient'), isTrue); - expect(filter.isLoggable(StreamLogPriority.verbose, 'SC:WsHealth'), isFalse); - expect(filter.isLoggable(StreamLogPriority.warning, 'SC:WsHealth'), isTrue); + expect(filter.isLoggable(StreamLogLevel.verbose, 'SC:WsClient'), isTrue); + expect(filter.isLoggable(StreamLogLevel.verbose, 'SC:WsHealth'), isFalse); + expect(filter.isLoggable(StreamLogLevel.warning, 'SC:WsHealth'), isTrue); }); test('is independent of the order the rules were written in', () { const broadFirst = StreamLogFilter.prefix({ - 'SC:': StreamLogPriority.verbose, - 'SC:WsHealth': StreamLogPriority.warning, + 'SC:': StreamLogLevel.verbose, + 'SC:WsHealth': StreamLogLevel.warning, }); const narrowFirst = StreamLogFilter.prefix({ - 'SC:WsHealth': StreamLogPriority.warning, - 'SC:': StreamLogPriority.verbose, + 'SC:WsHealth': StreamLogLevel.warning, + 'SC:': StreamLogLevel.verbose, }); expect( - broadFirst.isLoggable(StreamLogPriority.verbose, 'SC:WsHealth'), - narrowFirst.isLoggable(StreamLogPriority.verbose, 'SC:WsHealth'), + broadFirst.isLoggable(StreamLogLevel.verbose, 'SC:WsHealth'), + narrowFirst.isLoggable(StreamLogLevel.verbose, 'SC:WsHealth'), ); }); @@ -66,7 +66,7 @@ void main() { withStreamLogger( handler: handler, - filter: const StreamLogFilter.prefix({'SC:WsHealth': StreamLogPriority.warning}), + filter: const StreamLogFilter.prefix({'SC:WsHealth': StreamLogLevel.warning}), () => logger.v(() => 'ping ${built++}'), ); @@ -79,7 +79,7 @@ void main() { test('leaves the decision to the handler', () { const filter = StreamLogFilter.always(); - expect(filter.isLoggable(StreamLogPriority.verbose, 'SC:Anything'), isTrue); + expect(filter.isLoggable(StreamLogLevel.verbose, 'SC:Anything'), isTrue); }); }); } diff --git a/packages/stream_core/test/logger/stream_log_handler_test.dart b/packages/stream_core/test/logger/stream_log_handler_test.dart index 01aa531b..c57f4d74 100644 --- a/packages/stream_core/test/logger/stream_log_handler_test.dart +++ b/packages/stream_core/test/logger/stream_log_handler_test.dart @@ -25,7 +25,7 @@ void main() { // A destination with no level is as silent as a level with no destination: records need both. expect(capturePrints(() => _logger.e(() => 'error')), isEmpty); - StreamLogger.priority = StreamLogPriority.warning; + StreamLogger.level = StreamLogLevel.warning; final printed = capturePrints(() { _logger ..v(() => 'verbose') @@ -43,10 +43,10 @@ void main() { // The setup every migration guide shows, which silently dropped debug when the handler // carried a competing threshold of its own. StreamLogger.handler = const StreamLogHandler.console(); - StreamLogger.priority = StreamLogPriority.debug; + StreamLogger.level = StreamLogLevel.debug; addTearDown(() { StreamLogger.handler = StreamLogHandler.silent; - StreamLogger.priority = StreamLogPriority.warning; + StreamLogger.level = StreamLogLevel.warning; }); final printed = capturePrints(() => _logger.d(() => 'a debug line')); @@ -57,10 +57,10 @@ void main() { test('can be held quieter than the level, but never louder', () { final printed = withStreamLogger( handler: const StreamLogHandler.filtered( - StreamLogFilter.minPriority(StreamLogPriority.error), + StreamLogFilter.minLevel(StreamLogLevel.error), StreamLogHandler.console(), ), - filter: const StreamLogFilter.minPriority(StreamLogPriority.debug), + filter: const StreamLogFilter.minLevel(StreamLogLevel.debug), () => capturePrints(() { _logger ..d(() => 'debug') @@ -106,7 +106,7 @@ void main() { handler: StreamLogHandler.composite([ everything, const StreamLogHandler.filtered( - StreamLogFilter.minPriority(StreamLogPriority.error), + StreamLogFilter.minLevel(StreamLogLevel.error), StreamLogHandler.console(), ), ]), @@ -125,12 +125,12 @@ void main() { withStreamLogger( handler: StreamLogHandler.composite([ const StreamLogHandler.filtered( - StreamLogFilter.minPriority(StreamLogPriority.none), + StreamLogFilter.minLevel(StreamLogLevel.none), StreamLogHandler.console(), ), RecordingLogHandler(), ]), - () => expect(_logger.isLoggable(StreamLogPriority.verbose), isTrue), + () => expect(_logger.isLoggable(StreamLogLevel.verbose), isTrue), ); }); @@ -150,7 +150,7 @@ void main() { final seen = []; withStreamLogger( - handler: StreamLogHandler.from((it) => seen.add('${it.priority} ${it.tag} ${it.message}')), + handler: StreamLogHandler.from((it) => seen.add('${it.level} ${it.tag} ${it.message}')), () => _logger ..v(() => 'verbose') ..e(() => 'error'), @@ -168,7 +168,7 @@ void main() { expect(capturePrints(() => _logger.e(() => 'discarded')), isEmpty); // The filter decides what is built; where it goes afterwards is this handler's business, // so it no longer has a say in what `isLoggable` answers. - expect(_logger.isLoggable(StreamLogPriority.error), isTrue); + expect(_logger.isLoggable(StreamLogLevel.error), isTrue); }, ); }); diff --git a/packages/stream_core/test/logger/stream_log_level_test.dart b/packages/stream_core/test/logger/stream_log_level_test.dart new file mode 100644 index 00000000..90c74055 --- /dev/null +++ b/packages/stream_core/test/logger/stream_log_level_test.dart @@ -0,0 +1,60 @@ +import 'package:stream_core/stream_core.dart'; +import 'package:test/test.dart'; + +void main() { + group('StreamLogLevel', () { + test('runs from least to most severe', () { + // Every threshold in the logger is a comparison against one of these, so the order they sit + // in is what decides which records a filter admits. + expect(StreamLogLevel.values, [ + StreamLogLevel.verbose, + StreamLogLevel.debug, + StreamLogLevel.info, + StreamLogLevel.warning, + StreamLogLevel.error, + StreamLogLevel.none, + ]); + }); + + test('compares consistently in every direction', () { + for (var i = 1; i < StreamLogLevel.values.length; i++) { + final lower = StreamLogLevel.values[i - 1]; + final higher = StreamLogLevel.values[i]; + + expect(lower < higher, isTrue, reason: '$lower < $higher'); + expect(lower <= higher, isTrue, reason: '$lower <= $higher'); + expect(higher > lower, isTrue, reason: '$higher > $lower'); + expect(higher >= lower, isTrue, reason: '$higher >= $lower'); + expect(lower.compareTo(higher), isNegative, reason: '$lower before $higher'); + } + }); + + test('is neither above nor below itself', () { + expect(StreamLogLevel.info < StreamLogLevel.info, isFalse); + expect(StreamLogLevel.info > StreamLogLevel.info, isFalse); + expect(StreamLogLevel.info <= StreamLogLevel.info, isTrue); + expect(StreamLogLevel.info >= StreamLogLevel.info, isTrue); + expect(StreamLogLevel.info.compareTo(StreamLogLevel.info), isZero); + }); + + test('sorts by severity', () { + final shuffled = [ + StreamLogLevel.error, + StreamLogLevel.verbose, + StreamLogLevel.none, + StreamLogLevel.warning, + StreamLogLevel.debug, + StreamLogLevel.info, + ]; + expect(shuffled, isNot(orderedEquals(StreamLogLevel.values))); + + shuffled.sort(); + + expect(shuffled, orderedEquals(StreamLogLevel.values)); + }); + + test('admits nothing as a threshold, being the most severe there is', () { + expect(StreamLogLevel.values.every((it) => it <= StreamLogLevel.none), isTrue); + }); + }); +} diff --git a/packages/stream_core/test/logger/stream_log_priority_test.dart b/packages/stream_core/test/logger/stream_log_priority_test.dart deleted file mode 100644 index c1195bde..00000000 --- a/packages/stream_core/test/logger/stream_log_priority_test.dart +++ /dev/null @@ -1,60 +0,0 @@ -import 'package:stream_core/stream_core.dart'; -import 'package:test/test.dart'; - -void main() { - group('StreamLogPriority', () { - test('runs from least to most severe', () { - // Every threshold in the logger is a comparison against one of these, so the order they sit - // in is what decides which records a filter admits. - expect(StreamLogPriority.values, [ - StreamLogPriority.verbose, - StreamLogPriority.debug, - StreamLogPriority.info, - StreamLogPriority.warning, - StreamLogPriority.error, - StreamLogPriority.none, - ]); - }); - - test('compares consistently in every direction', () { - for (var i = 1; i < StreamLogPriority.values.length; i++) { - final lower = StreamLogPriority.values[i - 1]; - final higher = StreamLogPriority.values[i]; - - expect(lower < higher, isTrue, reason: '$lower < $higher'); - expect(lower <= higher, isTrue, reason: '$lower <= $higher'); - expect(higher > lower, isTrue, reason: '$higher > $lower'); - expect(higher >= lower, isTrue, reason: '$higher >= $lower'); - expect(lower.compareTo(higher), isNegative, reason: '$lower before $higher'); - } - }); - - test('is neither above nor below itself', () { - expect(StreamLogPriority.info < StreamLogPriority.info, isFalse); - expect(StreamLogPriority.info > StreamLogPriority.info, isFalse); - expect(StreamLogPriority.info <= StreamLogPriority.info, isTrue); - expect(StreamLogPriority.info >= StreamLogPriority.info, isTrue); - expect(StreamLogPriority.info.compareTo(StreamLogPriority.info), isZero); - }); - - test('sorts by severity', () { - final shuffled = [ - StreamLogPriority.error, - StreamLogPriority.verbose, - StreamLogPriority.none, - StreamLogPriority.warning, - StreamLogPriority.debug, - StreamLogPriority.info, - ]; - expect(shuffled, isNot(orderedEquals(StreamLogPriority.values))); - - shuffled.sort(); - - expect(shuffled, orderedEquals(StreamLogPriority.values)); - }); - - test('admits nothing as a threshold, being the most severe there is', () { - expect(StreamLogPriority.values.every((it) => it <= StreamLogPriority.none), isTrue); - }); - }); -} diff --git a/packages/stream_core/test/logger/stream_logger_defaults_test.dart b/packages/stream_core/test/logger/stream_logger_defaults_test.dart index 4184ec46..b97e2efb 100644 --- a/packages/stream_core/test/logger/stream_logger_defaults_test.dart +++ b/packages/stream_core/test/logger/stream_logger_defaults_test.dart @@ -10,11 +10,11 @@ import 'package:test/test.dart'; /// change to the field initialisers would otherwise go unnoticed. `dart test` gives each file its /// own isolate, which is what keeps these statics pristine. void main() { - test('an untouched logger admits nothing, at any priority', () { + test('an untouched logger admits nothing, at any level', () { const logger = StreamLogger('SC:Component'); - for (final priority in StreamLogPriority.values) { - expect(logger.isLoggable(priority), isFalse, reason: '$priority'); + for (final level in StreamLogLevel.values) { + expect(logger.isLoggable(level), isFalse, reason: '$level'); } expect( diff --git a/packages/stream_core/test/logger/stream_logger_test.dart b/packages/stream_core/test/logger/stream_logger_test.dart index 8f70f1f1..462975ed 100644 --- a/packages/stream_core/test/logger/stream_logger_test.dart +++ b/packages/stream_core/test/logger/stream_logger_test.dart @@ -21,12 +21,12 @@ void main() { }); expect(handler.tags, everyElement('SC:Component')); - expect(handler.records.map((it) => it.priority), [ - StreamLogPriority.verbose, - StreamLogPriority.debug, - StreamLogPriority.info, - StreamLogPriority.warning, - StreamLogPriority.error, + expect(handler.records.map((it) => it.level), [ + StreamLogLevel.verbose, + StreamLogLevel.debug, + StreamLogLevel.info, + StreamLogLevel.warning, + StreamLogLevel.error, ]); }); @@ -75,7 +75,7 @@ void main() { ..i(() => 'i', error: error, stackTrace: stackTrace) ..w(() => 'w', error: error, stackTrace: stackTrace) ..e(() => 'e', error: error, stackTrace: stackTrace) - ..log(StreamLogPriority.error, () => 'log', error: error, stackTrace: stackTrace); + ..log(StreamLogLevel.error, () => 'log', error: error, stackTrace: stackTrace); }); // Each of these forwards to `log` separately, so one that dropped an argument would go @@ -95,7 +95,7 @@ void main() { ..i(() => 'i') ..w(() => 'w') ..e(() => 'e') - ..log(StreamLogPriority.error, () => 'log'); + ..log(StreamLogLevel.error, () => 'log'); }); // A handler forwarding to a crash reporter decides what to report on whether there is a @@ -120,7 +120,7 @@ void main() { final printed = withStreamLogger( handler: const StreamLogHandler.filtered( - StreamLogFilter.minPriority(StreamLogPriority.error), + StreamLogFilter.minLevel(StreamLogLevel.error), StreamLogHandler.console(), ), () => capturePrints(() => _logger.v(() => 'expensive ${built++}')), @@ -135,24 +135,24 @@ void main() { test('isLoggable answers for the filter, whatever the destination goes on to keep', () { withStreamLogger( handler: const StreamLogHandler.filtered( - StreamLogFilter.minPriority(StreamLogPriority.warning), + StreamLogFilter.minLevel(StreamLogLevel.warning), StreamLogHandler.console(), ), - filter: const StreamLogFilter.minPriority(StreamLogPriority.debug), + filter: const StreamLogFilter.minLevel(StreamLogLevel.debug), () { - expect(_logger.isLoggable(StreamLogPriority.verbose), isFalse, reason: 'the filter rejects it'); - expect(_logger.isLoggable(StreamLogPriority.debug), isTrue, reason: 'the filter admits it'); - expect(_logger.isLoggable(StreamLogPriority.warning), isTrue); + expect(_logger.isLoggable(StreamLogLevel.verbose), isFalse, reason: 'the filter rejects it'); + expect(_logger.isLoggable(StreamLogLevel.debug), isTrue, reason: 'the filter admits it'); + expect(_logger.isLoggable(StreamLogLevel.warning), isTrue); }, ); }); }); group('StreamLogger.reset', () { - test('puts back both the handler and the priority', () { + test('puts back both the handler and the level', () { final installed = RecordingLogHandler(); StreamLogger.handler = installed; - StreamLogger.priority = StreamLogPriority.verbose; + StreamLogger.level = StreamLogLevel.verbose; StreamLogger.reset(); @@ -166,7 +166,7 @@ void main() { expect(installed.records, isEmpty, reason: 'the handler was put back'); expect(printed, isEmpty, reason: 'nothing is installed to print with'); - expect(_logger.isLoggable(StreamLogPriority.debug), isFalse, reason: 'the priority was put back'); + expect(_logger.isLoggable(StreamLogLevel.debug), isFalse, reason: 'the level was put back'); }); }); @@ -196,7 +196,7 @@ void main() { final logger = StreamLogger.detached( 'SC:Detached', handler: mine, - filter: const StreamLogFilter.minPriority(StreamLogPriority.error), + filter: const StreamLogFilter.minLevel(StreamLogLevel.error), ); withStreamLogger(filter: const StreamLogFilter.always(), () { @@ -246,20 +246,20 @@ void main() { expect(mine.messages, ['the quietest record there is']); }); - test('is unmoved by the installed handler and priority changing after it was built', () { + test('is unmoved by the installed handler and level changing after it was built', () { final mine = RecordingLogHandler(); final logger = StreamLogger.detached('SC:Detached', handler: mine); addTearDown(() { StreamLogger.handler = StreamLogHandler.silent; - StreamLogger.priority = StreamLogPriority.warning; + StreamLogger.level = StreamLogLevel.warning; }); // An attached logger resolves both of these every time it writes, so a detached one reading // either would drift as an app reconfigured itself. for (final installed in [RecordingLogHandler(), RecordingLogHandler()]) { StreamLogger.handler = installed; - StreamLogger.priority = StreamLogPriority.verbose; + StreamLogger.level = StreamLogLevel.verbose; logger ..d(() => 'still below its own threshold') @@ -316,16 +316,16 @@ void main() { expect(second.sequenceNumber, first.sequenceNumber + 1); }); }); - group('StreamLogPriority.none', () { + group('StreamLogLevel.none', () { test('admits nothing when a filter is held to it', () { final handler = RecordingLogHandler(); withStreamLogger( handler: handler, - filter: const StreamLogFilter.minPriority(StreamLogPriority.none), + filter: const StreamLogFilter.minLevel(StreamLogLevel.none), () { - for (final priority in StreamLogPriority.values) { - expect(const StreamLogger('SC:Component').isLoggable(priority), isFalse, reason: '$priority'); + for (final level in StreamLogLevel.values) { + expect(const StreamLogger('SC:Component').isLoggable(level), isFalse, reason: '$level'); } const StreamLogger('SC:Component').e(() => 'a failure, while shut down'); }, @@ -342,8 +342,8 @@ void main() { withStreamLogger( handler: handler, filter: const StreamLogFilter.prefix( - {'SV:': StreamLogPriority.none}, - otherwise: StreamLogPriority.debug, + {'SV:': StreamLogLevel.none}, + otherwise: StreamLogLevel.debug, ), () { const StreamLogger('SV:Call').e(() => 'silenced'); @@ -359,7 +359,7 @@ void main() { withStreamLogger( handler: RecordingLogHandler(), - filter: const StreamLogFilter.minPriority(StreamLogPriority.none), + filter: const StreamLogFilter.minLevel(StreamLogLevel.none), () => const StreamLogger('SC:Component').e(() { built++; return 'never built'; diff --git a/packages/stream_core/test/query/filter_test.dart b/packages/stream_core/test/query/filter_test.dart index 3ad814a2..3fd9616b 100644 --- a/packages/stream_core/test/query/filter_test.dart +++ b/packages/stream_core/test/query/filter_test.dart @@ -676,7 +676,7 @@ void main() { final itemWithMatchingNestedData = TestModel( metadata: { 'category': 'test', - 'priority': 1, + 'level': 1, 'config': {'enabled': true, 'timeout': 30}, }, ); @@ -687,7 +687,7 @@ void main() { }, ); final itemWithoutNestedMap = TestModel( - metadata: {'category': 'test', 'priority': 1}, + metadata: {'category': 'test', 'level': 1}, ); expect(filter.matches(itemWithMatchingNestedData), isTrue); diff --git a/packages/stream_core/test/ws/client/engine/stream_web_socket_engine_test.dart b/packages/stream_core/test/ws/client/engine/stream_web_socket_engine_test.dart index 67f4ba44..43509659 100644 --- a/packages/stream_core/test/ws/client/engine/stream_web_socket_engine_test.dart +++ b/packages/stream_core/test/ws/client/engine/stream_web_socket_engine_test.dart @@ -306,7 +306,7 @@ void main() { await pumpEventQueue(); }); - expect(handler.records.single.priority, StreamLogPriority.warning); + expect(handler.records.single.level, StreamLogLevel.warning); expect(handler.records.single.tag, 'SC:WsEngine'); expect(handler.records.single.error, isA()); }); From 684ec5c357c736b219a3f86f4f193c07829a20d0 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Tue, 25 Aug 2026 03:13:38 +0200 Subject: [PATCH 25/29] Revert "refactor(llc)!: call it a level, which is how it is used" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts 68c385d. The values and labels are `android.util.Log`'s, where the parameter is `int priority`, and Timber — which the tag and per-severity glyph design came from — calls it that too. Co-Authored-By: Claude Opus 5 (1M context) --- packages/stream_core/CHANGELOG.md | 4 +- .../api/interceptors/logging_interceptor.dart | 12 ++-- packages/stream_core/lib/src/logger.dart | 2 +- .../lib/src/logger/stream_log_config.dart | 18 ++--- .../lib/src/logger/stream_log_filter.dart | 50 ++++++------- .../lib/src/logger/stream_log_handler.dart | 10 +-- .../lib/src/logger/stream_log_level.dart | 54 -------------- .../lib/src/logger/stream_log_priority.dart | 54 ++++++++++++++ .../lib/src/logger/stream_log_record.dart | 8 +-- .../lib/src/logger/stream_logger.dart | 70 +++++++++---------- .../ws/client/stream_web_socket_client.dart | 4 +- .../logging_interceptor_test.dart | 2 +- .../test/logger/stream_log_config_test.dart | 28 ++++---- .../test/logger/stream_log_filter_test.dart | 50 ++++++------- .../test/logger/stream_log_handler_test.dart | 20 +++--- .../test/logger/stream_log_level_test.dart | 60 ---------------- .../test/logger/stream_log_priority_test.dart | 60 ++++++++++++++++ .../logger/stream_logger_defaults_test.dart | 6 +- .../test/logger/stream_logger_test.dart | 56 +++++++-------- .../stream_core/test/query/filter_test.dart | 4 +- .../engine/stream_web_socket_engine_test.dart | 2 +- 21 files changed, 287 insertions(+), 287 deletions(-) delete mode 100644 packages/stream_core/lib/src/logger/stream_log_level.dart create mode 100644 packages/stream_core/lib/src/logger/stream_log_priority.dart delete mode 100644 packages/stream_core/test/logger/stream_log_level_test.dart create mode 100644 packages/stream_core/test/logger/stream_log_priority_test.dart diff --git a/packages/stream_core/CHANGELOG.md b/packages/stream_core/CHANGELOG.md index cb9001b9..517ecda9 100644 --- a/packages/stream_core/CHANGELOG.md +++ b/packages/stream_core/CHANGELOG.md @@ -15,12 +15,12 @@ - `WebSocketConnectionState.isAutomaticReconnectionEnabled` is now `true` for an expired token, and remains `false` for token errors a fresh token cannot fix - `StreamApiError.isTokenExpiredError` now means code 40 only; the other token codes and a wrong API key are `isInvalidTokenError`. `isClientError` compares the HTTP `statusCode` against 400..499, rather than the Stream error `code`, which never falls in that range - `Result.getOrElse`, `getOrDefault`, `recover` and `recoverCatching` return the result's own type and no longer take a type parameter. To widen, widen the result (`Result widened = intResult`) or use `fold` -- Replaced the logger: `StreamLogger` is the handle you write with and a `StreamLogHandler` is where records go, so `Level`, `MessageBuilder`, `Tag`, `IsLoggableValidator` and `Finder` are renamed or gone +- Replaced the logger: `StreamLogger` is the handle you write with and a `StreamLogHandler` is where records go, so `Priority`, `MessageBuilder`, `Tag`, `IsLoggableValidator` and `Finder` are renamed or gone - `LoggingInterceptor` writes through the logger rather than printing, so it is silent until an app installs a handler. Its `logPrint` is now optional, and it takes a `tag` ### ✨ Features -- Added a logger the SDK now reports itself through, silent until an app names both a destination and a level on `StreamLogger`, or hands a product client a `StreamLogConfig` carrying both +- Added a logger the SDK now reports itself through, silent until an app names both a destination and a priority on `StreamLogger`, or hands a product client a `StreamLogConfig` carrying both - Added `TokenManager.setTokenProvider`, which points an existing manager at another user and expires the cached token; handed the identity it already has, it does nothing - Added optional `onTokenUpdated` callback to `TokenManager`, invoked after every successful token load - Added optional `rawValue` to `UserToken.anonymous`, so an anonymous token can carry a JWT granting restricted access; its `user_id` claim must be `!anon` diff --git a/packages/stream_core/lib/src/api/interceptors/logging_interceptor.dart b/packages/stream_core/lib/src/api/interceptors/logging_interceptor.dart index 157a999b..bcbda632 100644 --- a/packages/stream_core/lib/src/api/interceptors/logging_interceptor.dart +++ b/packages/stream_core/lib/src/api/interceptors/logging_interceptor.dart @@ -23,7 +23,7 @@ typedef LogPrint = void Function(InterceptStep step, Object object); /// An interceptor that reports each request and the response it gets. /// -/// Records go out under `SC:Http`, at [StreamLogLevel.debug], or [StreamLogLevel.warning] +/// Records go out under `SC:Http`, at [StreamLogPriority.debug], or [StreamLogPriority.warning] /// for a request that failed. Nothing is written, or even formatted, until an app installs a /// [StreamLogHandler]. /// @@ -82,19 +82,19 @@ class LoggingInterceptor extends Interceptor { // Consulted before a line is formatted, so a request costs nothing while nothing wants it. bool _wants(InterceptStep step) { if (logPrint != null) return true; - return _logger.isLoggable(_levelOf(step)); + return _logger.isLoggable(_priorityOf(step)); } - StreamLogLevel _levelOf(InterceptStep step) { + StreamLogPriority _priorityOf(InterceptStep step) { return switch (step) { - InterceptStep.error => StreamLogLevel.warning, - InterceptStep.request || InterceptStep.response => StreamLogLevel.debug, + InterceptStep.error => StreamLogPriority.warning, + InterceptStep.request || InterceptStep.response => StreamLogPriority.debug, }; } void _write(InterceptStep step, Object object) { if (logPrint case final logPrint?) return logPrint(step, object); - return _logger.log(_levelOf(step), () => '$object'); + return _logger.log(_priorityOf(step), () => '$object'); } @override diff --git a/packages/stream_core/lib/src/logger.dart b/packages/stream_core/lib/src/logger.dart index 005d8440..2e0e220d 100644 --- a/packages/stream_core/lib/src/logger.dart +++ b/packages/stream_core/lib/src/logger.dart @@ -1,6 +1,6 @@ export 'logger/stream_log_config.dart'; export 'logger/stream_log_filter.dart'; export 'logger/stream_log_handler.dart'; -export 'logger/stream_log_level.dart'; +export 'logger/stream_log_priority.dart'; export 'logger/stream_log_record.dart'; export 'logger/stream_logger.dart'; diff --git a/packages/stream_core/lib/src/logger/stream_log_config.dart b/packages/stream_core/lib/src/logger/stream_log_config.dart index 9c676dd4..acedd2a1 100644 --- a/packages/stream_core/lib/src/logger/stream_log_config.dart +++ b/packages/stream_core/lib/src/logger/stream_log_config.dart @@ -1,6 +1,6 @@ import 'stream_log_filter.dart'; import 'stream_log_handler.dart'; -import 'stream_log_level.dart'; +import 'stream_log_priority.dart'; import 'stream_logger.dart'; /// How much a Stream SDK reports, and where those records go. @@ -13,7 +13,7 @@ import 'stream_logger.dart'; /// apiKey: 'your-api-key', /// user: user, /// config: const FeedsConfig( -/// logging: StreamLogConfig(level: StreamLogLevel.debug), +/// logging: StreamLogConfig(priority: StreamLogPriority.debug), /// ), /// ); /// ``` @@ -23,7 +23,7 @@ import 'stream_logger.dart'; class StreamLogConfig { /// Creates a [StreamLogConfig]. const StreamLogConfig({ - this.level = StreamLogLevel.warning, + this.priority = StreamLogPriority.warning, this.handler = defaultHandler, this.filter, }); @@ -31,11 +31,11 @@ class StreamLogConfig { /// Where records go when a config names no handler of its own. static const defaultHandler = StreamLogHandler.console(); - /// The lowest level worth reporting. + /// The lowest priority worth reporting. /// - /// [StreamLogLevel.none] silences a logger another SDK configured. Ignored where [filter] is + /// [StreamLogPriority.none] silences a logger another SDK configured. Ignored where [filter] is /// given, which decides the same thing in more detail. - final StreamLogLevel level; + final StreamLogPriority priority; /// Where records go. /// @@ -50,15 +50,15 @@ class StreamLogConfig { /// ``` final StreamLogHandler handler; - /// Which records are built at all, for a rule [level] cannot express. + /// Which records are built at all, for a rule [priority] cannot express. /// /// Holds one subsystem to a different threshold than the rest: /// /// ```dart /// StreamLogConfig( /// filter: StreamLogFilter.prefix( - /// {'SF:Ws': StreamLogLevel.verbose}, - /// otherwise: StreamLogLevel.warning, + /// {'SF:Ws': StreamLogPriority.verbose}, + /// otherwise: StreamLogPriority.warning, /// ), /// ) /// ``` diff --git a/packages/stream_core/lib/src/logger/stream_log_filter.dart b/packages/stream_core/lib/src/logger/stream_log_filter.dart index 490d8729..3e27f7cf 100644 --- a/packages/stream_core/lib/src/logger/stream_log_filter.dart +++ b/packages/stream_core/lib/src/logger/stream_log_filter.dart @@ -1,4 +1,4 @@ -import 'stream_log_level.dart'; +import 'stream_log_priority.dart'; /// Decides which records are worth building, independently of where they end up. /// @@ -10,8 +10,8 @@ import 'stream_log_level.dart'; /// /// ```dart /// StreamLogger.filter = const StreamLogFilter.prefix( -/// {'SC:Ws': StreamLogLevel.verbose}, -/// otherwise: StreamLogLevel.warning, +/// {'SC:Ws': StreamLogPriority.verbose}, +/// otherwise: StreamLogPriority.warning, /// ); /// ``` abstract class StreamLogFilter { @@ -21,58 +21,58 @@ abstract class StreamLogFilter { /// A filter admitting every record, leaving the decision to the handler. const factory StreamLogFilter.always() = _AlwaysFilter; - /// A filter admitting records at [level] or above, whatever their tag. - const factory StreamLogFilter.minLevel(StreamLogLevel level) = _MinLevelFilter; + /// A filter admitting records at [priority] or above, whatever their tag. + const factory StreamLogFilter.minPriority(StreamLogPriority priority) = _MinPriorityFilter; /// A filter admitting records by the prefix of their tag. /// - /// The longest prefix in [levels] matching a tag decides it, so a broad rule can be narrowed by + /// The longest prefix in [priorities] matching a tag decides it, so a broad rule can be narrowed by /// a longer one. A tag matching no prefix is held to [otherwise]. /// - /// What a record costs grows with the number of rules, so consider keeping [levels] to the + /// What a record costs grows with the number of rules, so consider keeping [priorities] to the /// subsystems actually being tuned. const factory StreamLogFilter.prefix( - Map levels, { - StreamLogLevel otherwise, + Map priorities, { + StreamLogPriority otherwise, }) = _PrefixFilter; - /// Whether a record at [level] from [tag] is worth building. - bool isLoggable(StreamLogLevel level, String tag); + /// Whether a record at [priority] from [tag] is worth building. + bool isLoggable(StreamLogPriority priority, String tag); } final class _AlwaysFilter extends StreamLogFilter { const _AlwaysFilter(); @override - bool isLoggable(StreamLogLevel level, String tag) => true; + bool isLoggable(StreamLogPriority priority, String tag) => true; } -final class _MinLevelFilter extends StreamLogFilter { - const _MinLevelFilter(this.level); +final class _MinPriorityFilter extends StreamLogFilter { + const _MinPriorityFilter(this.priority); - final StreamLogLevel level; + final StreamLogPriority priority; @override - bool isLoggable(StreamLogLevel level, String tag) { + bool isLoggable(StreamLogPriority priority, String tag) { // `none` outranks every severity, so comparing against it would admit the records a threshold // of `none` exists to reject. - if (this.level == StreamLogLevel.none) return false; - return level >= this.level; + if (this.priority == StreamLogPriority.none) return false; + return priority >= this.priority; } } final class _PrefixFilter extends StreamLogFilter { - const _PrefixFilter(this.levels, {this.otherwise = StreamLogLevel.warning}); + const _PrefixFilter(this.priorities, {this.otherwise = StreamLogPriority.warning}); - final Map levels; - final StreamLogLevel otherwise; + final Map priorities; + final StreamLogPriority otherwise; @override - bool isLoggable(StreamLogLevel level, String tag) { + bool isLoggable(StreamLogPriority priority, String tag) { var matched = otherwise; var matchedLength = -1; - for (final MapEntry(key: prefix, value: threshold) in levels.entries) { + for (final MapEntry(key: prefix, value: threshold) in priorities.entries) { if (prefix.length <= matchedLength) continue; if (!tag.startsWith(prefix)) continue; @@ -80,7 +80,7 @@ final class _PrefixFilter extends StreamLogFilter { matchedLength = prefix.length; } - if (matched == StreamLogLevel.none) return false; - return level >= matched; + if (matched == StreamLogPriority.none) return false; + return priority >= matched; } } diff --git a/packages/stream_core/lib/src/logger/stream_log_handler.dart b/packages/stream_core/lib/src/logger/stream_log_handler.dart index 7758d3d8..45a6559d 100644 --- a/packages/stream_core/lib/src/logger/stream_log_handler.dart +++ b/packages/stream_core/lib/src/logger/stream_log_handler.dart @@ -23,7 +23,7 @@ typedef StreamLogCallback = void Function(StreamLogRecord record); /// ``` /// /// Wrap it in [StreamLogHandler.filtered] to hold it to a threshold, rather than comparing -/// levels inside it. +/// priorities inside it. abstract class StreamLogHandler { /// Creates a [StreamLogHandler]. const StreamLogHandler(); @@ -41,7 +41,7 @@ abstract class StreamLogHandler { /// StreamLogger.handler = StreamLogHandler.from((record) => debugPrint('$record')); /// ``` /// - /// Emits whatever [StreamLogger.level] admits. Wrap in [StreamLogHandler.filtered] to hold + /// Emits whatever [StreamLogger.priority] admits. Wrap in [StreamLogHandler.filtered] to hold /// this destination quieter than the rest. const factory StreamLogHandler.console() = _ConsoleHandler; @@ -62,13 +62,13 @@ abstract class StreamLogHandler { /// StreamLogHandler.composite([ /// fileLogger, /// StreamLogHandler.filtered( - /// const StreamLogFilter.minLevel(StreamLogLevel.error), + /// const StreamLogFilter.minPriority(StreamLogPriority.error), /// const StreamLogHandler.console(), /// ), /// ]); /// ``` /// - /// [StreamLogFilter.prefix] narrows by tag rather than level, which is how one SDK's records + /// [StreamLogFilter.prefix] narrows by tag rather than priority, which is how one SDK's records /// are sent somewhere the rest are not. const factory StreamLogHandler.filtered(StreamLogFilter filter, StreamLogHandler handler) = _FilteredHandler; @@ -131,7 +131,7 @@ final class _FilteredHandler extends StreamLogHandler { @override void handle(StreamLogRecord record) { - if (filter.isLoggable(record.level, record.tag)) handler.handle(record); + if (filter.isLoggable(record.priority, record.tag)) handler.handle(record); } } diff --git a/packages/stream_core/lib/src/logger/stream_log_level.dart b/packages/stream_core/lib/src/logger/stream_log_level.dart deleted file mode 100644 index 929202eb..00000000 --- a/packages/stream_core/lib/src/logger/stream_log_level.dart +++ /dev/null @@ -1,54 +0,0 @@ -/// The severity of a log record. -/// -/// Ordered from least to most severe, so a threshold can be expressed by comparing a record's -/// level against it. [none] outranks every real severity, and so admits nothing. -enum StreamLogLevel implements Comparable { - /// Fine-grained detail on a hot path, such as an individual heartbeat. - verbose(value: 2, emoji: '🔍', label: 'V'), - - /// The steps a subsystem takes, such as a connection changing state. - debug(value: 3, emoji: '🔧', label: 'D'), - - /// A milestone worth seeing without opting into the full trace. - info(value: 4, emoji: 'â„šī¸', label: 'I'), - - /// Something recoverable that the caller may still want to act on. - warning(value: 5, emoji: 'âš ī¸', label: 'W'), - - /// A failure. - error(value: 6, emoji: '🚨', label: 'E'), - - /// A threshold that admits nothing, not a severity a record can carry. - /// - /// A filter held to it rejects every record, whatever its severity. - none(value: 7, emoji: 'đŸ“Ŗ', label: '*'); - - const StreamLogLevel({required this.value, required this.emoji, required this.label}); - - /// The rank of this level, where a higher number is more severe. - final int value; - - /// A glyph identifying this level at a glance, for handlers that render one. - final String emoji; - - /// A single-letter abbreviation of this level, for handlers that render one. - final String label; - - @override - String toString() => name; - - @override - int compareTo(StreamLogLevel other) => value.compareTo(other.value); - - /// Whether this level is less severe than [other]. - bool operator <(StreamLogLevel other) => value < other.value; - - /// Whether this level is no more severe than [other]. - bool operator <=(StreamLogLevel other) => value <= other.value; - - /// Whether this level is more severe than [other]. - bool operator >(StreamLogLevel other) => value > other.value; - - /// Whether this level is at least as severe as [other]. - bool operator >=(StreamLogLevel other) => value >= other.value; -} diff --git a/packages/stream_core/lib/src/logger/stream_log_priority.dart b/packages/stream_core/lib/src/logger/stream_log_priority.dart new file mode 100644 index 00000000..b89baa88 --- /dev/null +++ b/packages/stream_core/lib/src/logger/stream_log_priority.dart @@ -0,0 +1,54 @@ +/// The severity of a log record. +/// +/// Ordered from least to most severe, so a threshold can be expressed by comparing a record's +/// priority against it. [none] outranks every real severity, and so admits nothing. +enum StreamLogPriority implements Comparable { + /// Fine-grained detail on a hot path, such as an individual heartbeat. + verbose(level: 2, emoji: '🔍', label: 'V'), + + /// The steps a subsystem takes, such as a connection changing state. + debug(level: 3, emoji: '🔧', label: 'D'), + + /// A milestone worth seeing without opting into the full trace. + info(level: 4, emoji: 'â„šī¸', label: 'I'), + + /// Something recoverable that the caller may still want to act on. + warning(level: 5, emoji: 'âš ī¸', label: 'W'), + + /// A failure. + error(level: 6, emoji: '🚨', label: 'E'), + + /// A threshold that admits nothing, not a severity a record can carry. + /// + /// A filter held to it rejects every record, whatever its severity. + none(level: 7, emoji: 'đŸ“Ŗ', label: '*'); + + const StreamLogPriority({required this.level, required this.emoji, required this.label}); + + /// The rank of this priority, where a higher number is more severe. + final int level; + + /// A glyph identifying this priority at a glance, for handlers that render one. + final String emoji; + + /// A single-letter abbreviation of this priority, for handlers that render one. + final String label; + + @override + String toString() => name; + + @override + int compareTo(StreamLogPriority other) => level.compareTo(other.level); + + /// Whether this priority is less severe than [other]. + bool operator <(StreamLogPriority other) => level < other.level; + + /// Whether this priority is no more severe than [other]. + bool operator <=(StreamLogPriority other) => level <= other.level; + + /// Whether this priority is more severe than [other]. + bool operator >(StreamLogPriority other) => level > other.level; + + /// Whether this priority is at least as severe as [other]. + bool operator >=(StreamLogPriority other) => level >= other.level; +} diff --git a/packages/stream_core/lib/src/logger/stream_log_record.dart b/packages/stream_core/lib/src/logger/stream_log_record.dart index d87ef073..68667b4a 100644 --- a/packages/stream_core/lib/src/logger/stream_log_record.dart +++ b/packages/stream_core/lib/src/logger/stream_log_record.dart @@ -1,6 +1,6 @@ import 'package:clock/clock.dart'; -import 'stream_log_level.dart'; +import 'stream_log_priority.dart'; /// A single log record, as a handler receives it. /// @@ -13,7 +13,7 @@ final class StreamLogRecord { /// Creates a [StreamLogRecord], stamping it with the current [time] and the next /// [sequenceNumber]. StreamLogRecord({ - required this.level, + required this.priority, required this.tag, required this.message, this.error, @@ -24,7 +24,7 @@ final class StreamLogRecord { static var _sequence = 0; /// The severity of this record. - final StreamLogLevel level; + final StreamLogPriority priority; /// The component this record came from. final String tag; @@ -50,5 +50,5 @@ final class StreamLogRecord { final StackTrace? stackTrace; @override - String toString() => '${level.emoji} ${level.label}/$tag: $message'; + String toString() => '${priority.emoji} ${priority.label}/$tag: $message'; } diff --git a/packages/stream_core/lib/src/logger/stream_logger.dart b/packages/stream_core/lib/src/logger/stream_logger.dart index 85fb83b9..222bc003 100644 --- a/packages/stream_core/lib/src/logger/stream_logger.dart +++ b/packages/stream_core/lib/src/logger/stream_logger.dart @@ -3,7 +3,7 @@ import 'package:meta/meta.dart'; import 'stream_log_config.dart'; import 'stream_log_filter.dart'; import 'stream_log_handler.dart'; -import 'stream_log_level.dart'; +import 'stream_log_priority.dart'; import 'stream_log_record.dart'; /// Builds a log message on demand. @@ -31,7 +31,7 @@ typedef StreamLogMessage = String Function(); /// /// ```dart /// StreamLogger.handler = const StreamLogHandler.console(); -/// StreamLogger.level = StreamLogLevel.debug; +/// StreamLogger.priority = StreamLogPriority.debug; /// ``` /// /// Records go to one place, so routing two SDKs apart is a matter of a [StreamLogHandler] reading @@ -51,18 +51,18 @@ final class StreamLogger { /// final _log = StreamLogger.detached( /// 'SF:Upload', /// handler: const StreamLogHandler.console(), - /// filter: const StreamLogFilter.minLevel(StreamLogLevel.debug), + /// filter: const StreamLogFilter.minPriority(StreamLogPriority.debug), /// ); /// ``` /// - /// A level of its own is a [StreamLogFilter.minLevel], which is why there is no separate one. - /// [filter] defaults to admitting [StreamLogLevel.warning] and above, so a detached logger - /// reports without an app naming a level for it. Pass [StreamLogFilter.always] to leave the + /// A priority of its own is a [StreamLogFilter.minPriority], which is why there is no separate one. + /// [filter] defaults to admitting [StreamLogPriority.warning] and above, so a detached logger + /// reports without an app naming a priority for it. Pass [StreamLogFilter.always] to leave the /// decision entirely to the handler. const StreamLogger.detached( this.tag, { required StreamLogHandler this._handler, - StreamLogFilter this._filter = const .minLevel(.warning), + StreamLogFilter this._filter = const .minPriority(.warning), }); final StreamLogFilter? _filter; @@ -78,7 +78,7 @@ final class StreamLogger { /// * [StreamLogFilter.prefix], which turns this convention into a threshold per subsystem. final String tag; - static StreamLogFilter _filterOrDefault = const .minLevel(.none); + static StreamLogFilter _filterOrDefault = const .minPriority(.none); static StreamLogHandler _handlerOrDefault = StreamLogHandler.silent; StreamLogFilter get _effectiveFilter => _filter ?? _filterOrDefault; @@ -86,38 +86,38 @@ final class StreamLogger { /// Installs where every record goes, other than those from a [StreamLogger.detached] logger. /// - /// A destination on its own reports nothing: name a [level] beside it, or hand both to + /// A destination on its own reports nothing: name a [priority] beside it, or hand both to /// [configure] at once. Setting it applies to loggers that already exist, including any built at /// class-load, because a logger resolves this when it writes rather than when it was created. /// /// ```dart /// StreamLogger.handler = const StreamLogHandler.console(); - /// StreamLogger.level = StreamLogLevel.warning; + /// StreamLogger.priority = StreamLogPriority.warning; /// ``` /// /// Write-only, so nothing can come to depend on what happens to be installed. Consider /// [StreamLogHandler.composite] to send records to more than one place. static set handler(StreamLogHandler handler) => _handlerOrDefault = handler; - /// Installs the lowest level worth building a record for. + /// Installs the lowest priority worth building a record for. /// /// Nothing is admitted until this is named, so an SDK stays silent in an app that has not asked /// for records, and a record it rejects is never built: /// /// ```dart - /// StreamLogger.level = StreamLogLevel.debug; + /// StreamLogger.priority = StreamLogPriority.debug; /// ``` /// - /// Shorthand for a [StreamLogFilter.minLevel], so this and [filter] are one setting: whichever + /// Shorthand for a [StreamLogFilter.minPriority], so this and [filter] are one setting: whichever /// is written last decides. - static set level(StreamLogLevel level) => filter = .minLevel(level); + static set priority(StreamLogPriority priority) => filter = .minPriority(priority); - /// Installs which records are built at all, for a rule [level] cannot express. + /// Installs which records are built at all, for a rule [priority] cannot express. /// /// ```dart /// StreamLogger.filter = const StreamLogFilter.prefix( - /// {'SC:Ws': StreamLogLevel.verbose}, - /// otherwise: StreamLogLevel.warning, + /// {'SC:Ws': StreamLogPriority.verbose}, + /// otherwise: StreamLogPriority.warning, /// ); /// ``` static set filter(StreamLogFilter filter) => _filterOrDefault = filter; @@ -133,7 +133,7 @@ final class StreamLogger { /// ``` /// /// A config replaces both settings outright, so anything installed through [filter] before this - /// is lost — including to a config that named only a [level]. Put the rule in + /// is lost — including to a config that named only a [priority]. Put the rule in /// [StreamLogConfig.filter] instead, where a client carries it rather than flattening it. /// /// One logger serves the process, so this decides logging for every Stream SDK in it, not only @@ -144,8 +144,8 @@ final class StreamLogger { /// ```dart /// StreamLogConfig( /// filter: StreamLogFilter.prefix( - /// {'SF:': StreamLogLevel.debug}, - /// otherwise: StreamLogLevel.none, + /// {'SF:': StreamLogPriority.debug}, + /// otherwise: StreamLogPriority.none, /// ), /// ) /// ``` @@ -153,10 +153,10 @@ final class StreamLogger { if (config == null) return; _handlerOrDefault = config.handler; - _filterOrDefault = config.filter ?? .minLevel(config.level); + _filterOrDefault = config.filter ?? .minPriority(config.priority); } - /// Puts [handler] and [level] back to what they were before anything was installed. + /// Puts [handler] and [priority] back to what they were before anything was installed. /// /// What an app installs is process-wide, so a test that installs a handler and leaves it there /// changes what every later test sees. Restoring by hand means naming the defaults, which a @@ -168,20 +168,20 @@ final class StreamLogger { @visibleForTesting static void reset() { _handlerOrDefault = StreamLogHandler.silent; - _filterOrDefault = const .minLevel(.none); + _filterOrDefault = const .minPriority(.none); } - /// Whether a record at [level] would be kept by both the filter and the handler. + /// Whether a record at [priority] would be kept by both the filter and the handler. /// /// Records are already gated, so this is only worth calling to guard a message that is /// expensive to build beyond its interpolation: /// /// ```dart - /// if (_log.isLoggable(StreamLogLevel.verbose)) _log.v(() => describe(everyParticipant)); + /// if (_log.isLoggable(StreamLogPriority.verbose)) _log.v(() => describe(everyParticipant)); /// ``` - bool isLoggable(StreamLogLevel level) => _effectiveFilter.isLoggable(level, tag); + bool isLoggable(StreamLogPriority priority) => _effectiveFilter.isLoggable(priority, tag); - /// Writes a [StreamLogLevel.verbose] record. + /// Writes a [StreamLogPriority.verbose] record. void v( StreamLogMessage message, { Object? error, @@ -193,7 +193,7 @@ final class StreamLogger { stackTrace: stackTrace, ); - /// Writes a [StreamLogLevel.debug] record. + /// Writes a [StreamLogPriority.debug] record. void d( StreamLogMessage message, { Object? error, @@ -205,7 +205,7 @@ final class StreamLogger { stackTrace: stackTrace, ); - /// Writes a [StreamLogLevel.info] record. + /// Writes a [StreamLogPriority.info] record. void i( StreamLogMessage message, { Object? error, @@ -217,7 +217,7 @@ final class StreamLogger { stackTrace: stackTrace, ); - /// Writes a [StreamLogLevel.warning] record. + /// Writes a [StreamLogPriority.warning] record. void w( StreamLogMessage message, { Object? error, @@ -229,7 +229,7 @@ final class StreamLogger { stackTrace: stackTrace, ); - /// Writes a [StreamLogLevel.error] record. + /// Writes a [StreamLogPriority.error] record. void e( StreamLogMessage message, { Object? error, @@ -241,20 +241,20 @@ final class StreamLogger { stackTrace: stackTrace, ); - /// Writes a record at [level]. + /// Writes a record at [priority]. /// /// [message] is called only if the record is kept. [error] and [stackTrace] carry the cause /// when the record describes a failure. void log( - StreamLogLevel level, + StreamLogPriority priority, StreamLogMessage message, { Object? error, StackTrace? stackTrace, }) { - if (!isLoggable(level)) return; + if (!isLoggable(priority)) return; final record = StreamLogRecord( - level: level, + priority: priority, tag: tag, message: message(), error: error, diff --git a/packages/stream_core/lib/src/ws/client/stream_web_socket_client.dart b/packages/stream_core/lib/src/ws/client/stream_web_socket_client.dart index 36bf478e..accc23f8 100644 --- a/packages/stream_core/lib/src/ws/client/stream_web_socket_client.dart +++ b/packages/stream_core/lib/src/ws/client/stream_web_socket_client.dart @@ -55,7 +55,7 @@ typedef WebSocketOptionsBuilder = WebSocketOptions Function(); /// written until an app installs a [StreamLogHandler]: /// /// ```dart -/// StreamLogger.handler = const StreamLogHandler.filtered(StreamLogFilter.minLevel(StreamLogLevel.debug), StreamLogHandler.console()); +/// StreamLogger.handler = const StreamLogHandler.filtered(StreamLogFilter.minPriority(StreamLogPriority.debug), StreamLogHandler.console()); /// ``` /// /// Give a second client its own `tag` to tell the two apart. Its collaborators are tagged from @@ -63,7 +63,7 @@ typedef WebSocketOptionsBuilder = WebSocketOptions Function(); /// /// ```dart /// StreamWebSocketClient(tag: 'SC:Ws2', ...); -/// StreamLogger.filter = const StreamLogFilter.prefix({'SC:Ws2': StreamLogLevel.verbose}); +/// StreamLogger.filter = const StreamLogFilter.prefix({'SC:Ws2': StreamLogPriority.verbose}); /// ``` class StreamWebSocketClient with Disposable implements WebSocketHealthListener, WebSocketEngineListener { /// Creates a new instance of [StreamWebSocketClient]. diff --git a/packages/stream_core/test/api/interceptors/logging_interceptor_test.dart b/packages/stream_core/test/api/interceptors/logging_interceptor_test.dart index ec620b2f..7eed8dfb 100644 --- a/packages/stream_core/test/api/interceptors/logging_interceptor_test.dart +++ b/packages/stream_core/test/api/interceptors/logging_interceptor_test.dart @@ -87,7 +87,7 @@ void main() { await withStreamLogger( handler: handler, - filter: const StreamLogFilter.minLevel(StreamLogLevel.debug), + filter: const StreamLogFilter.minPriority(StreamLogPriority.debug), () async { await dio.get('/test'); await pumpEventQueue(); diff --git a/packages/stream_core/test/logger/stream_log_config_test.dart b/packages/stream_core/test/logger/stream_log_config_test.dart index 60289b8c..d79bc17b 100644 --- a/packages/stream_core/test/logger/stream_log_config_test.dart +++ b/packages/stream_core/test/logger/stream_log_config_test.dart @@ -12,7 +12,7 @@ void main() { test('leaves the logger alone when there is no config', () { final installed = RecordingLogHandler(); StreamLogger.handler = installed; - StreamLogger.level = StreamLogLevel.verbose; + StreamLogger.priority = StreamLogPriority.verbose; StreamLogger.configure(null); _logger.d(() => 'another SDK, still heard'); @@ -21,16 +21,16 @@ void main() { expect(installed.messages, ['another SDK, still heard']); }); - test('writes to the console, given a level and nowhere to put it', () { + test('writes to the console, given a priority and nowhere to put it', () { final printed = capturePrints(() { - StreamLogger.configure(const StreamLogConfig(level: StreamLogLevel.debug)); + StreamLogger.configure(const StreamLogConfig(priority: StreamLogPriority.debug)); _logger.d(() => 'to the console'); }); expect(printed.single, contains('to the console')); }); - test('hears warnings, given a handler and no level', () { + test('hears warnings, given a handler and no priority', () { final mine = RecordingLogHandler(); StreamLogger.configure(StreamLogConfig(handler: mine)); @@ -45,7 +45,7 @@ void main() { final mine = RecordingLogHandler(); StreamLogger.configure( - StreamLogConfig(level: StreamLogLevel.none, handler: mine), + StreamLogConfig(priority: StreamLogPriority.none, handler: mine), ); _logger.e(() => 'not even an error'); @@ -58,24 +58,24 @@ void main() { StreamLogger.configure( StreamLogConfig( handler: mine, - filter: const StreamLogFilter.prefix({'SF:Ws': StreamLogLevel.verbose}), + filter: const StreamLogFilter.prefix({'SF:Ws': StreamLogPriority.verbose}), ), ); const StreamLogger('SF:Ws').v(() => 'the subsystem I turned up'); _logger.d(() => 'the commentary I did not'); - // The filter has to survive the config that carries it: `level` sets the same field, so a + // The filter has to survive the config that carries it: `priority` sets the same field, so a // config applying both would flatten the rule it was given. expect(mine.messages, ['the subsystem I turned up']); }); - test('replaces a filter installed before it, even naming only a level', () { + test('replaces a filter installed before it, even naming only a priority', () { final mine = RecordingLogHandler(); - StreamLogger.filter = const StreamLogFilter.prefix({'SF:Ws': StreamLogLevel.verbose}); + StreamLogger.filter = const StreamLogFilter.prefix({'SF:Ws': StreamLogPriority.verbose}); - StreamLogger.configure(StreamLogConfig(level: StreamLogLevel.debug, handler: mine)); + StreamLogger.configure(StreamLogConfig(priority: StreamLogPriority.debug, handler: mine)); const StreamLogger('SF:Ws').v(() => 'below what the config asked for'); - // A config is the whole story: its level and filter are one field underneath, so there is + // A config is the whole story: its priority and filter are one field underneath, so there is // no reading of it that keeps an earlier rule and the new threshold both. expect(mine.records, isEmpty); }); @@ -90,7 +90,7 @@ void main() { test('report together once either of them is configured', () { final mine = RecordingLogHandler(); - StreamLogger.configure(StreamLogConfig(level: StreamLogLevel.debug, handler: mine)); + StreamLogger.configure(StreamLogConfig(priority: StreamLogPriority.debug, handler: mine)); feeds.d(() => 'feeds'); video.d(() => 'video'); @@ -118,8 +118,8 @@ void main() { StreamLogConfig( handler: mine, filter: const StreamLogFilter.prefix( - {'SF:': StreamLogLevel.debug}, - otherwise: StreamLogLevel.none, + {'SF:': StreamLogPriority.debug}, + otherwise: StreamLogPriority.none, ), ), ); diff --git a/packages/stream_core/test/logger/stream_log_filter_test.dart b/packages/stream_core/test/logger/stream_log_filter_test.dart index 4b632f2d..0a3d02b6 100644 --- a/packages/stream_core/test/logger/stream_log_filter_test.dart +++ b/packages/stream_core/test/logger/stream_log_filter_test.dart @@ -4,58 +4,58 @@ import 'package:test/test.dart'; import '../helpers/logger.dart'; void main() { - group('StreamLogFilter.minLevel', () { + group('StreamLogFilter.minPriority', () { test('admits records at the level or above, whatever the tag', () { - const filter = StreamLogFilter.minLevel(StreamLogLevel.warning); + const filter = StreamLogFilter.minPriority(StreamLogPriority.warning); - expect(filter.isLoggable(StreamLogLevel.debug, 'SC:Anything'), isFalse); - expect(filter.isLoggable(StreamLogLevel.warning, 'SC:Anything'), isTrue); - expect(filter.isLoggable(StreamLogLevel.error, 'SF:Something'), isTrue); + expect(filter.isLoggable(StreamLogPriority.debug, 'SC:Anything'), isFalse); + expect(filter.isLoggable(StreamLogPriority.warning, 'SC:Anything'), isTrue); + expect(filter.isLoggable(StreamLogPriority.error, 'SF:Something'), isTrue); }); }); group('StreamLogFilter.prefix', () { test('holds a matching tag to its own threshold', () { - const filter = StreamLogFilter.prefix({'SC:Ws': StreamLogLevel.verbose}); + const filter = StreamLogFilter.prefix({'SC:Ws': StreamLogPriority.verbose}); - expect(filter.isLoggable(StreamLogLevel.verbose, 'SC:WsClient'), isTrue); - expect(filter.isLoggable(StreamLogLevel.verbose, 'SC:Http'), isFalse); + expect(filter.isLoggable(StreamLogPriority.verbose, 'SC:WsClient'), isTrue); + expect(filter.isLoggable(StreamLogPriority.verbose, 'SC:Http'), isFalse); }); test('holds everything else to `otherwise`', () { const filter = StreamLogFilter.prefix( - {'SC:Ws': StreamLogLevel.verbose}, - otherwise: StreamLogLevel.error, + {'SC:Ws': StreamLogPriority.verbose}, + otherwise: StreamLogPriority.error, ); - expect(filter.isLoggable(StreamLogLevel.warning, 'SC:Http'), isFalse); - expect(filter.isLoggable(StreamLogLevel.error, 'SC:Http'), isTrue); + expect(filter.isLoggable(StreamLogPriority.warning, 'SC:Http'), isFalse); + expect(filter.isLoggable(StreamLogPriority.error, 'SC:Http'), isTrue); }); test('lets the longest prefix win, so a broad rule can be narrowed', () { const filter = StreamLogFilter.prefix({ - 'SC:': StreamLogLevel.verbose, - 'SC:WsHealth': StreamLogLevel.warning, + 'SC:': StreamLogPriority.verbose, + 'SC:WsHealth': StreamLogPriority.warning, }); - expect(filter.isLoggable(StreamLogLevel.verbose, 'SC:WsClient'), isTrue); - expect(filter.isLoggable(StreamLogLevel.verbose, 'SC:WsHealth'), isFalse); - expect(filter.isLoggable(StreamLogLevel.warning, 'SC:WsHealth'), isTrue); + expect(filter.isLoggable(StreamLogPriority.verbose, 'SC:WsClient'), isTrue); + expect(filter.isLoggable(StreamLogPriority.verbose, 'SC:WsHealth'), isFalse); + expect(filter.isLoggable(StreamLogPriority.warning, 'SC:WsHealth'), isTrue); }); test('is independent of the order the rules were written in', () { const broadFirst = StreamLogFilter.prefix({ - 'SC:': StreamLogLevel.verbose, - 'SC:WsHealth': StreamLogLevel.warning, + 'SC:': StreamLogPriority.verbose, + 'SC:WsHealth': StreamLogPriority.warning, }); const narrowFirst = StreamLogFilter.prefix({ - 'SC:WsHealth': StreamLogLevel.warning, - 'SC:': StreamLogLevel.verbose, + 'SC:WsHealth': StreamLogPriority.warning, + 'SC:': StreamLogPriority.verbose, }); expect( - broadFirst.isLoggable(StreamLogLevel.verbose, 'SC:WsHealth'), - narrowFirst.isLoggable(StreamLogLevel.verbose, 'SC:WsHealth'), + broadFirst.isLoggable(StreamLogPriority.verbose, 'SC:WsHealth'), + narrowFirst.isLoggable(StreamLogPriority.verbose, 'SC:WsHealth'), ); }); @@ -66,7 +66,7 @@ void main() { withStreamLogger( handler: handler, - filter: const StreamLogFilter.prefix({'SC:WsHealth': StreamLogLevel.warning}), + filter: const StreamLogFilter.prefix({'SC:WsHealth': StreamLogPriority.warning}), () => logger.v(() => 'ping ${built++}'), ); @@ -79,7 +79,7 @@ void main() { test('leaves the decision to the handler', () { const filter = StreamLogFilter.always(); - expect(filter.isLoggable(StreamLogLevel.verbose, 'SC:Anything'), isTrue); + expect(filter.isLoggable(StreamLogPriority.verbose, 'SC:Anything'), isTrue); }); }); } diff --git a/packages/stream_core/test/logger/stream_log_handler_test.dart b/packages/stream_core/test/logger/stream_log_handler_test.dart index c57f4d74..01aa531b 100644 --- a/packages/stream_core/test/logger/stream_log_handler_test.dart +++ b/packages/stream_core/test/logger/stream_log_handler_test.dart @@ -25,7 +25,7 @@ void main() { // A destination with no level is as silent as a level with no destination: records need both. expect(capturePrints(() => _logger.e(() => 'error')), isEmpty); - StreamLogger.level = StreamLogLevel.warning; + StreamLogger.priority = StreamLogPriority.warning; final printed = capturePrints(() { _logger ..v(() => 'verbose') @@ -43,10 +43,10 @@ void main() { // The setup every migration guide shows, which silently dropped debug when the handler // carried a competing threshold of its own. StreamLogger.handler = const StreamLogHandler.console(); - StreamLogger.level = StreamLogLevel.debug; + StreamLogger.priority = StreamLogPriority.debug; addTearDown(() { StreamLogger.handler = StreamLogHandler.silent; - StreamLogger.level = StreamLogLevel.warning; + StreamLogger.priority = StreamLogPriority.warning; }); final printed = capturePrints(() => _logger.d(() => 'a debug line')); @@ -57,10 +57,10 @@ void main() { test('can be held quieter than the level, but never louder', () { final printed = withStreamLogger( handler: const StreamLogHandler.filtered( - StreamLogFilter.minLevel(StreamLogLevel.error), + StreamLogFilter.minPriority(StreamLogPriority.error), StreamLogHandler.console(), ), - filter: const StreamLogFilter.minLevel(StreamLogLevel.debug), + filter: const StreamLogFilter.minPriority(StreamLogPriority.debug), () => capturePrints(() { _logger ..d(() => 'debug') @@ -106,7 +106,7 @@ void main() { handler: StreamLogHandler.composite([ everything, const StreamLogHandler.filtered( - StreamLogFilter.minLevel(StreamLogLevel.error), + StreamLogFilter.minPriority(StreamLogPriority.error), StreamLogHandler.console(), ), ]), @@ -125,12 +125,12 @@ void main() { withStreamLogger( handler: StreamLogHandler.composite([ const StreamLogHandler.filtered( - StreamLogFilter.minLevel(StreamLogLevel.none), + StreamLogFilter.minPriority(StreamLogPriority.none), StreamLogHandler.console(), ), RecordingLogHandler(), ]), - () => expect(_logger.isLoggable(StreamLogLevel.verbose), isTrue), + () => expect(_logger.isLoggable(StreamLogPriority.verbose), isTrue), ); }); @@ -150,7 +150,7 @@ void main() { final seen = []; withStreamLogger( - handler: StreamLogHandler.from((it) => seen.add('${it.level} ${it.tag} ${it.message}')), + handler: StreamLogHandler.from((it) => seen.add('${it.priority} ${it.tag} ${it.message}')), () => _logger ..v(() => 'verbose') ..e(() => 'error'), @@ -168,7 +168,7 @@ void main() { expect(capturePrints(() => _logger.e(() => 'discarded')), isEmpty); // The filter decides what is built; where it goes afterwards is this handler's business, // so it no longer has a say in what `isLoggable` answers. - expect(_logger.isLoggable(StreamLogLevel.error), isTrue); + expect(_logger.isLoggable(StreamLogPriority.error), isTrue); }, ); }); diff --git a/packages/stream_core/test/logger/stream_log_level_test.dart b/packages/stream_core/test/logger/stream_log_level_test.dart deleted file mode 100644 index 90c74055..00000000 --- a/packages/stream_core/test/logger/stream_log_level_test.dart +++ /dev/null @@ -1,60 +0,0 @@ -import 'package:stream_core/stream_core.dart'; -import 'package:test/test.dart'; - -void main() { - group('StreamLogLevel', () { - test('runs from least to most severe', () { - // Every threshold in the logger is a comparison against one of these, so the order they sit - // in is what decides which records a filter admits. - expect(StreamLogLevel.values, [ - StreamLogLevel.verbose, - StreamLogLevel.debug, - StreamLogLevel.info, - StreamLogLevel.warning, - StreamLogLevel.error, - StreamLogLevel.none, - ]); - }); - - test('compares consistently in every direction', () { - for (var i = 1; i < StreamLogLevel.values.length; i++) { - final lower = StreamLogLevel.values[i - 1]; - final higher = StreamLogLevel.values[i]; - - expect(lower < higher, isTrue, reason: '$lower < $higher'); - expect(lower <= higher, isTrue, reason: '$lower <= $higher'); - expect(higher > lower, isTrue, reason: '$higher > $lower'); - expect(higher >= lower, isTrue, reason: '$higher >= $lower'); - expect(lower.compareTo(higher), isNegative, reason: '$lower before $higher'); - } - }); - - test('is neither above nor below itself', () { - expect(StreamLogLevel.info < StreamLogLevel.info, isFalse); - expect(StreamLogLevel.info > StreamLogLevel.info, isFalse); - expect(StreamLogLevel.info <= StreamLogLevel.info, isTrue); - expect(StreamLogLevel.info >= StreamLogLevel.info, isTrue); - expect(StreamLogLevel.info.compareTo(StreamLogLevel.info), isZero); - }); - - test('sorts by severity', () { - final shuffled = [ - StreamLogLevel.error, - StreamLogLevel.verbose, - StreamLogLevel.none, - StreamLogLevel.warning, - StreamLogLevel.debug, - StreamLogLevel.info, - ]; - expect(shuffled, isNot(orderedEquals(StreamLogLevel.values))); - - shuffled.sort(); - - expect(shuffled, orderedEquals(StreamLogLevel.values)); - }); - - test('admits nothing as a threshold, being the most severe there is', () { - expect(StreamLogLevel.values.every((it) => it <= StreamLogLevel.none), isTrue); - }); - }); -} diff --git a/packages/stream_core/test/logger/stream_log_priority_test.dart b/packages/stream_core/test/logger/stream_log_priority_test.dart new file mode 100644 index 00000000..c1195bde --- /dev/null +++ b/packages/stream_core/test/logger/stream_log_priority_test.dart @@ -0,0 +1,60 @@ +import 'package:stream_core/stream_core.dart'; +import 'package:test/test.dart'; + +void main() { + group('StreamLogPriority', () { + test('runs from least to most severe', () { + // Every threshold in the logger is a comparison against one of these, so the order they sit + // in is what decides which records a filter admits. + expect(StreamLogPriority.values, [ + StreamLogPriority.verbose, + StreamLogPriority.debug, + StreamLogPriority.info, + StreamLogPriority.warning, + StreamLogPriority.error, + StreamLogPriority.none, + ]); + }); + + test('compares consistently in every direction', () { + for (var i = 1; i < StreamLogPriority.values.length; i++) { + final lower = StreamLogPriority.values[i - 1]; + final higher = StreamLogPriority.values[i]; + + expect(lower < higher, isTrue, reason: '$lower < $higher'); + expect(lower <= higher, isTrue, reason: '$lower <= $higher'); + expect(higher > lower, isTrue, reason: '$higher > $lower'); + expect(higher >= lower, isTrue, reason: '$higher >= $lower'); + expect(lower.compareTo(higher), isNegative, reason: '$lower before $higher'); + } + }); + + test('is neither above nor below itself', () { + expect(StreamLogPriority.info < StreamLogPriority.info, isFalse); + expect(StreamLogPriority.info > StreamLogPriority.info, isFalse); + expect(StreamLogPriority.info <= StreamLogPriority.info, isTrue); + expect(StreamLogPriority.info >= StreamLogPriority.info, isTrue); + expect(StreamLogPriority.info.compareTo(StreamLogPriority.info), isZero); + }); + + test('sorts by severity', () { + final shuffled = [ + StreamLogPriority.error, + StreamLogPriority.verbose, + StreamLogPriority.none, + StreamLogPriority.warning, + StreamLogPriority.debug, + StreamLogPriority.info, + ]; + expect(shuffled, isNot(orderedEquals(StreamLogPriority.values))); + + shuffled.sort(); + + expect(shuffled, orderedEquals(StreamLogPriority.values)); + }); + + test('admits nothing as a threshold, being the most severe there is', () { + expect(StreamLogPriority.values.every((it) => it <= StreamLogPriority.none), isTrue); + }); + }); +} diff --git a/packages/stream_core/test/logger/stream_logger_defaults_test.dart b/packages/stream_core/test/logger/stream_logger_defaults_test.dart index b97e2efb..4184ec46 100644 --- a/packages/stream_core/test/logger/stream_logger_defaults_test.dart +++ b/packages/stream_core/test/logger/stream_logger_defaults_test.dart @@ -10,11 +10,11 @@ import 'package:test/test.dart'; /// change to the field initialisers would otherwise go unnoticed. `dart test` gives each file its /// own isolate, which is what keeps these statics pristine. void main() { - test('an untouched logger admits nothing, at any level', () { + test('an untouched logger admits nothing, at any priority', () { const logger = StreamLogger('SC:Component'); - for (final level in StreamLogLevel.values) { - expect(logger.isLoggable(level), isFalse, reason: '$level'); + for (final priority in StreamLogPriority.values) { + expect(logger.isLoggable(priority), isFalse, reason: '$priority'); } expect( diff --git a/packages/stream_core/test/logger/stream_logger_test.dart b/packages/stream_core/test/logger/stream_logger_test.dart index 462975ed..8f70f1f1 100644 --- a/packages/stream_core/test/logger/stream_logger_test.dart +++ b/packages/stream_core/test/logger/stream_logger_test.dart @@ -21,12 +21,12 @@ void main() { }); expect(handler.tags, everyElement('SC:Component')); - expect(handler.records.map((it) => it.level), [ - StreamLogLevel.verbose, - StreamLogLevel.debug, - StreamLogLevel.info, - StreamLogLevel.warning, - StreamLogLevel.error, + expect(handler.records.map((it) => it.priority), [ + StreamLogPriority.verbose, + StreamLogPriority.debug, + StreamLogPriority.info, + StreamLogPriority.warning, + StreamLogPriority.error, ]); }); @@ -75,7 +75,7 @@ void main() { ..i(() => 'i', error: error, stackTrace: stackTrace) ..w(() => 'w', error: error, stackTrace: stackTrace) ..e(() => 'e', error: error, stackTrace: stackTrace) - ..log(StreamLogLevel.error, () => 'log', error: error, stackTrace: stackTrace); + ..log(StreamLogPriority.error, () => 'log', error: error, stackTrace: stackTrace); }); // Each of these forwards to `log` separately, so one that dropped an argument would go @@ -95,7 +95,7 @@ void main() { ..i(() => 'i') ..w(() => 'w') ..e(() => 'e') - ..log(StreamLogLevel.error, () => 'log'); + ..log(StreamLogPriority.error, () => 'log'); }); // A handler forwarding to a crash reporter decides what to report on whether there is a @@ -120,7 +120,7 @@ void main() { final printed = withStreamLogger( handler: const StreamLogHandler.filtered( - StreamLogFilter.minLevel(StreamLogLevel.error), + StreamLogFilter.minPriority(StreamLogPriority.error), StreamLogHandler.console(), ), () => capturePrints(() => _logger.v(() => 'expensive ${built++}')), @@ -135,24 +135,24 @@ void main() { test('isLoggable answers for the filter, whatever the destination goes on to keep', () { withStreamLogger( handler: const StreamLogHandler.filtered( - StreamLogFilter.minLevel(StreamLogLevel.warning), + StreamLogFilter.minPriority(StreamLogPriority.warning), StreamLogHandler.console(), ), - filter: const StreamLogFilter.minLevel(StreamLogLevel.debug), + filter: const StreamLogFilter.minPriority(StreamLogPriority.debug), () { - expect(_logger.isLoggable(StreamLogLevel.verbose), isFalse, reason: 'the filter rejects it'); - expect(_logger.isLoggable(StreamLogLevel.debug), isTrue, reason: 'the filter admits it'); - expect(_logger.isLoggable(StreamLogLevel.warning), isTrue); + expect(_logger.isLoggable(StreamLogPriority.verbose), isFalse, reason: 'the filter rejects it'); + expect(_logger.isLoggable(StreamLogPriority.debug), isTrue, reason: 'the filter admits it'); + expect(_logger.isLoggable(StreamLogPriority.warning), isTrue); }, ); }); }); group('StreamLogger.reset', () { - test('puts back both the handler and the level', () { + test('puts back both the handler and the priority', () { final installed = RecordingLogHandler(); StreamLogger.handler = installed; - StreamLogger.level = StreamLogLevel.verbose; + StreamLogger.priority = StreamLogPriority.verbose; StreamLogger.reset(); @@ -166,7 +166,7 @@ void main() { expect(installed.records, isEmpty, reason: 'the handler was put back'); expect(printed, isEmpty, reason: 'nothing is installed to print with'); - expect(_logger.isLoggable(StreamLogLevel.debug), isFalse, reason: 'the level was put back'); + expect(_logger.isLoggable(StreamLogPriority.debug), isFalse, reason: 'the priority was put back'); }); }); @@ -196,7 +196,7 @@ void main() { final logger = StreamLogger.detached( 'SC:Detached', handler: mine, - filter: const StreamLogFilter.minLevel(StreamLogLevel.error), + filter: const StreamLogFilter.minPriority(StreamLogPriority.error), ); withStreamLogger(filter: const StreamLogFilter.always(), () { @@ -246,20 +246,20 @@ void main() { expect(mine.messages, ['the quietest record there is']); }); - test('is unmoved by the installed handler and level changing after it was built', () { + test('is unmoved by the installed handler and priority changing after it was built', () { final mine = RecordingLogHandler(); final logger = StreamLogger.detached('SC:Detached', handler: mine); addTearDown(() { StreamLogger.handler = StreamLogHandler.silent; - StreamLogger.level = StreamLogLevel.warning; + StreamLogger.priority = StreamLogPriority.warning; }); // An attached logger resolves both of these every time it writes, so a detached one reading // either would drift as an app reconfigured itself. for (final installed in [RecordingLogHandler(), RecordingLogHandler()]) { StreamLogger.handler = installed; - StreamLogger.level = StreamLogLevel.verbose; + StreamLogger.priority = StreamLogPriority.verbose; logger ..d(() => 'still below its own threshold') @@ -316,16 +316,16 @@ void main() { expect(second.sequenceNumber, first.sequenceNumber + 1); }); }); - group('StreamLogLevel.none', () { + group('StreamLogPriority.none', () { test('admits nothing when a filter is held to it', () { final handler = RecordingLogHandler(); withStreamLogger( handler: handler, - filter: const StreamLogFilter.minLevel(StreamLogLevel.none), + filter: const StreamLogFilter.minPriority(StreamLogPriority.none), () { - for (final level in StreamLogLevel.values) { - expect(const StreamLogger('SC:Component').isLoggable(level), isFalse, reason: '$level'); + for (final priority in StreamLogPriority.values) { + expect(const StreamLogger('SC:Component').isLoggable(priority), isFalse, reason: '$priority'); } const StreamLogger('SC:Component').e(() => 'a failure, while shut down'); }, @@ -342,8 +342,8 @@ void main() { withStreamLogger( handler: handler, filter: const StreamLogFilter.prefix( - {'SV:': StreamLogLevel.none}, - otherwise: StreamLogLevel.debug, + {'SV:': StreamLogPriority.none}, + otherwise: StreamLogPriority.debug, ), () { const StreamLogger('SV:Call').e(() => 'silenced'); @@ -359,7 +359,7 @@ void main() { withStreamLogger( handler: RecordingLogHandler(), - filter: const StreamLogFilter.minLevel(StreamLogLevel.none), + filter: const StreamLogFilter.minPriority(StreamLogPriority.none), () => const StreamLogger('SC:Component').e(() { built++; return 'never built'; diff --git a/packages/stream_core/test/query/filter_test.dart b/packages/stream_core/test/query/filter_test.dart index 3fd9616b..3ad814a2 100644 --- a/packages/stream_core/test/query/filter_test.dart +++ b/packages/stream_core/test/query/filter_test.dart @@ -676,7 +676,7 @@ void main() { final itemWithMatchingNestedData = TestModel( metadata: { 'category': 'test', - 'level': 1, + 'priority': 1, 'config': {'enabled': true, 'timeout': 30}, }, ); @@ -687,7 +687,7 @@ void main() { }, ); final itemWithoutNestedMap = TestModel( - metadata: {'category': 'test', 'level': 1}, + metadata: {'category': 'test', 'priority': 1}, ); expect(filter.matches(itemWithMatchingNestedData), isTrue); diff --git a/packages/stream_core/test/ws/client/engine/stream_web_socket_engine_test.dart b/packages/stream_core/test/ws/client/engine/stream_web_socket_engine_test.dart index 43509659..67f4ba44 100644 --- a/packages/stream_core/test/ws/client/engine/stream_web_socket_engine_test.dart +++ b/packages/stream_core/test/ws/client/engine/stream_web_socket_engine_test.dart @@ -306,7 +306,7 @@ void main() { await pumpEventQueue(); }); - expect(handler.records.single.level, StreamLogLevel.warning); + expect(handler.records.single.priority, StreamLogPriority.warning); expect(handler.records.single.tag, 'SC:WsEngine'); expect(handler.records.single.error, isA()); }); From a6be1482e5d7fbeb157b5bfcdac4a24c01eb82ad Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Tue, 25 Aug 2026 03:56:45 +0200 Subject: [PATCH 26/29] fix(llc): stop the HTTP interceptor writing empty records A blank line before each box, and either side of a response body, separated them when this wrote straight to a console. Every line is now a record carrying a timestamp and a tag, so the same blanks read as noise and cost as much to build as any other record. Co-Authored-By: Claude Opus 5 (1M context) --- .../lib/src/api/interceptors/logging_interceptor.dart | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/stream_core/lib/src/api/interceptors/logging_interceptor.dart b/packages/stream_core/lib/src/api/interceptors/logging_interceptor.dart index bcbda632..eb076757 100644 --- a/packages/stream_core/lib/src/api/interceptors/logging_interceptor.dart +++ b/packages/stream_core/lib/src/api/interceptors/logging_interceptor.dart @@ -162,7 +162,6 @@ class LoggingInterceptor extends Interceptor { _printResponse(_logPrintError, err.response!); } _printLine(_logPrintError, '╚'); - _logPrintError(''); } else { _printBoxed( _logPrintError, @@ -191,9 +190,7 @@ class LoggingInterceptor extends Interceptor { if (responseBody) { _logPrintResponse('╔ Body'); - _logPrintResponse('║'); _printResponse(_logPrintResponse, response); - _logPrintResponse('║'); _printLine(_logPrintResponse, '╚'); } super.onResponse(response, handler); @@ -204,7 +201,8 @@ class LoggingInterceptor extends Interceptor { String? header, String? text, }) { - logPrint(''); + // No blank line before the box: each one is a record of its own, carrying a timestamp and a + // tag, so what separated boxes on a console only pads the log here. logPrint('â•”â•Ŗ $header'); logPrint('║ $text'); _printLine(logPrint, '╚'); From 454c4849be6e06013e77b286fc217278679acab8 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Tue, 25 Aug 2026 03:58:34 +0200 Subject: [PATCH 27/29] Revert "fix(llc): stop the HTTP interceptor writing empty records" This reverts commit 7f7a24c0ee423382cbe5175138e97907d2db7231. --- .../lib/src/api/interceptors/logging_interceptor.dart | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/stream_core/lib/src/api/interceptors/logging_interceptor.dart b/packages/stream_core/lib/src/api/interceptors/logging_interceptor.dart index eb076757..bcbda632 100644 --- a/packages/stream_core/lib/src/api/interceptors/logging_interceptor.dart +++ b/packages/stream_core/lib/src/api/interceptors/logging_interceptor.dart @@ -162,6 +162,7 @@ class LoggingInterceptor extends Interceptor { _printResponse(_logPrintError, err.response!); } _printLine(_logPrintError, '╚'); + _logPrintError(''); } else { _printBoxed( _logPrintError, @@ -190,7 +191,9 @@ class LoggingInterceptor extends Interceptor { if (responseBody) { _logPrintResponse('╔ Body'); + _logPrintResponse('║'); _printResponse(_logPrintResponse, response); + _logPrintResponse('║'); _printLine(_logPrintResponse, '╚'); } super.onResponse(response, handler); @@ -201,8 +204,7 @@ class LoggingInterceptor extends Interceptor { String? header, String? text, }) { - // No blank line before the box: each one is a record of its own, carrying a timestamp and a - // tag, so what separated boxes on a console only pads the log here. + logPrint(''); logPrint('â•”â•Ŗ $header'); logPrint('║ $text'); _printLine(logPrint, '╚'); From 08ba72e922355946f932cefaaf74862d08127a69 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Tue, 25 Aug 2026 04:28:23 +0200 Subject: [PATCH 28/29] feat(llc): let `User.guest` carry an image The convenience constructor took an id and a name and dropped the avatar the unnamed one accepts, so a guest could only have one by not using it. Also says what becomes of the id it is given: the server assigns a guest `guest--` during connect, so the one passed here survives only as the tail of it. Co-Authored-By: Claude Opus 5 (1M context) --- packages/stream_core/CHANGELOG.md | 1 + packages/stream_core/lib/src/user/user.dart | 8 +++++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/stream_core/CHANGELOG.md b/packages/stream_core/CHANGELOG.md index 517ecda9..2d9bc2bc 100644 --- a/packages/stream_core/CHANGELOG.md +++ b/packages/stream_core/CHANGELOG.md @@ -26,6 +26,7 @@ - Added optional `rawValue` to `UserToken.anonymous`, so an anonymous token can carry a JWT granting restricted access; its `user_id` claim must be `!anon` - Added `UserToken.expiresAt`, from the token's `exp` claim, and `UserToken.isExpired`, which takes an optional `leeway` - Added `User.anonymousUserId`, the id every anonymous user has +- `User.guest` takes an `image`, which it previously dropped - Added `TokenManager.unconfigured`, for a client that exists before its user does, and `TokenManager.reset`, which drops the configured identity and its cached token - Added `teams` field to `User` class - Added `DioException.apiError`, the Stream API error a response carried, or `null` for anything else diff --git a/packages/stream_core/lib/src/user/user.dart b/packages/stream_core/lib/src/user/user.dart index 53eed28d..434a6043 100644 --- a/packages/stream_core/lib/src/user/user.dart +++ b/packages/stream_core/lib/src/user/user.dart @@ -27,8 +27,14 @@ class User extends Equatable { /// Creates a guest user with the provided id and an optional display name. /// - Parameter userId: the id of the user. /// - Parameter name: the display name of the user. Defaults to [userId] when not provided. + /// - Parameter image: the avatar of the user. /// - Returns: a guest `User`. - const User.guest(String userId, {String? name}) : this(id: userId, name: name, type: UserType.guest); + /// + /// The server assigns a guest its own id during `connect`, of the form `guest--`, + /// so [userId] survives only as the tail of it. Read `client.user` afterwards for the id that + /// identifies the session. + const User.guest(String userId, {String? name, String? image}) + : this(id: userId, name: name, image: image, type: UserType.guest); /// Creates an anonymous user. /// - Returns: an anonymous `User`. From 806a66c9f00e3ef3356051d2d5db0040b6126f97 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Tue, 25 Aug 2026 04:31:33 +0200 Subject: [PATCH 29/29] docs(changelog): say the interceptor waits to be asked, not to be handed a handler A destination alone reports nothing now that the level starts closed, so naming only the handler describes a state that never logs. Co-Authored-By: Claude Opus 5 (1M context) --- packages/stream_core/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/stream_core/CHANGELOG.md b/packages/stream_core/CHANGELOG.md index 2d9bc2bc..87a0b6a3 100644 --- a/packages/stream_core/CHANGELOG.md +++ b/packages/stream_core/CHANGELOG.md @@ -16,7 +16,7 @@ - `StreamApiError.isTokenExpiredError` now means code 40 only; the other token codes and a wrong API key are `isInvalidTokenError`. `isClientError` compares the HTTP `statusCode` against 400..499, rather than the Stream error `code`, which never falls in that range - `Result.getOrElse`, `getOrDefault`, `recover` and `recoverCatching` return the result's own type and no longer take a type parameter. To widen, widen the result (`Result widened = intResult`) or use `fold` - Replaced the logger: `StreamLogger` is the handle you write with and a `StreamLogHandler` is where records go, so `Priority`, `MessageBuilder`, `Tag`, `IsLoggableValidator` and `Finder` are renamed or gone -- `LoggingInterceptor` writes through the logger rather than printing, so it is silent until an app installs a handler. Its `logPrint` is now optional, and it takes a `tag` +- `LoggingInterceptor` writes through the logger rather than printing, so it is silent until an app asks for records. Its `logPrint` is now optional, and it takes a `tag` ### ✨ Features