From 094a346dcfd7b8dedefc7a9aeceeeaf067ffb0b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=95=D0=BC=D0=B5=D0=BB=D1=8C=D1=8F=D0=BD=D0=BE=D0=B2=20?= =?UTF-8?q?=D0=90=D0=BB=D0=B5=D0=BA=D1=81=D0=B0=D0=BD=D0=B4=D1=80?= Date: Sat, 20 Dec 2025 04:26:55 +0500 Subject: [PATCH 1/7] feat: refactor logging architecture to support other log collectors --- lib/remote_logging.dart | 6 +- lib/src/model.dart | 8 +- lib/src/remote/collectors/http_collector.dart | 15 ++ .../remote/collectors/loggly_collector.dart | 30 +++ .../remote/collectors/splunk_collector.dart | 28 +++ lib/src/remote/loggly.dart | 26 --- lib/src/remote/mobile_tags.dart | 17 -- lib/src/remote/tags/impl/empty_tags.dart | 3 + lib/src/remote/tags/impl/io_tags.dart | 5 + lib/src/remote/{ => tags/impl}/web_tags.dart | 0 lib/src/remote/tags/tags.dart | 6 + lib/src/remote_logging.dart | 74 +++++-- pubspec.yaml | 4 +- test/legacy_remote_logging_test.dart | 104 ++++++++++ test/loggly_collector_test.dart | 28 +++ test/loggly_test.dart | 39 ---- test/remote_logging_test.dart | 192 +++++++++++------- test/splunk_collector_test.dart | 30 +++ 18 files changed, 439 insertions(+), 176 deletions(-) create mode 100644 lib/src/remote/collectors/http_collector.dart create mode 100644 lib/src/remote/collectors/loggly_collector.dart create mode 100644 lib/src/remote/collectors/splunk_collector.dart delete mode 100644 lib/src/remote/loggly.dart delete mode 100644 lib/src/remote/mobile_tags.dart create mode 100644 lib/src/remote/tags/impl/empty_tags.dart create mode 100644 lib/src/remote/tags/impl/io_tags.dart rename lib/src/remote/{ => tags/impl}/web_tags.dart (100%) create mode 100644 lib/src/remote/tags/tags.dart create mode 100644 test/legacy_remote_logging_test.dart create mode 100644 test/loggly_collector_test.dart delete mode 100644 test/loggly_test.dart create mode 100644 test/splunk_collector_test.dart diff --git a/lib/remote_logging.dart b/lib/remote_logging.dart index 146af9b..4d49fed 100644 --- a/lib/remote_logging.dart +++ b/lib/remote_logging.dart @@ -1,10 +1,10 @@ /// Created by alex@justprodev.com on 27.05.2022. -library remote_logging; +library; export 'package:logging/logging.dart'; export 'src/model.dart'; +export 'src/remote/collectors/loggly_collector.dart'; +export 'src/remote/collectors/splunk_collector.dart'; export 'src/remote_logging.dart'; -export 'src/remote/loggly.dart' show logglyTasks; - diff --git a/lib/src/model.dart b/lib/src/model.dart index b7671af..5c47655 100644 --- a/lib/src/model.dart +++ b/lib/src/model.dart @@ -2,4 +2,10 @@ import 'package:logging/logging.dart'; /// Created by alex@justprodev.com on 27.05.2022. -typedef TagsProvider = List Function(LogRecord record); \ No newline at end of file +/// Function that provides tags for a log record +typedef TagsProvider = List Function(LogRecord record); + +/// Collects logs and sends them to some remote service +abstract class LogCollector { + Future collect(String message, {List? tags}); +} \ No newline at end of file diff --git a/lib/src/remote/collectors/http_collector.dart b/lib/src/remote/collectors/http_collector.dart new file mode 100644 index 0000000..0105a9f --- /dev/null +++ b/lib/src/remote/collectors/http_collector.dart @@ -0,0 +1,15 @@ +// Created by alex@justprodev.com on 20.12.2025. + +import 'package:http/http.dart'; +import 'package:remote_logging/src/model.dart'; + +/// Base class for HTTP log collectors +abstract class HttpCollector implements LogCollector { + late final Client client; + final Uri url; + final Map headers; + + HttpCollector({required this.url, this.headers = const {}, Client? client}) { + this.client = client ?? Client(); + } +} diff --git a/lib/src/remote/collectors/loggly_collector.dart b/lib/src/remote/collectors/loggly_collector.dart new file mode 100644 index 0000000..45a0553 --- /dev/null +++ b/lib/src/remote/collectors/loggly_collector.dart @@ -0,0 +1,30 @@ +// Created by alex@justprodev.com on 27.05.2022. + +import 'http_collector.dart'; + +/// Loggly log collector +/// See: https://www.loggly.com/docs/http-endpoint/ +class LogglyCollector extends HttpCollector { + LogglyCollector( + String token, { + host = defaultHost, + super.client, + }) : super( + url: Uri.parse('https://$host/inputs/$token'), + headers: {'Content-type': 'text/plain; charset=utf-8'}, + ); + + @override + Future collect(String message, {List? tags}) { + return client.post( + url, + body: message, + headers: { + ...headers, + 'X-LOGGLY-TAG': tags?.join(',') ?? '', + }, + ); + } + + static const defaultHost = 'logs-01.loggly.com'; +} diff --git a/lib/src/remote/collectors/splunk_collector.dart b/lib/src/remote/collectors/splunk_collector.dart new file mode 100644 index 0000000..2e1a6ba --- /dev/null +++ b/lib/src/remote/collectors/splunk_collector.dart @@ -0,0 +1,28 @@ +// Created by alex@justprodev.com on 20.12.2025. + +import 'dart:convert'; +import 'http_collector.dart'; + +/// Splunk log collector +/// +/// See: https://docs.splunk.com/Documentation/Splunk/latest/Data/UsetheHTTPEventCollector +class SplunkCollector extends HttpCollector { + SplunkCollector(String token, {required String host, super.client}) + : super( + url: Uri.parse('https://$host/services/collector/event'), + headers: { + 'Authorization': 'Splunk $token', + 'Content-Type': 'application/json', + }, + ); + + @override + Future collect(String message, {List? tags}) { + final data = { + 'event': message, + if (tags != null && tags.isNotEmpty) 'fields': {'tags': tags}, + }; + + return client.post(url, body: jsonEncode(data), headers: headers); + } +} diff --git a/lib/src/remote/loggly.dart b/lib/src/remote/loggly.dart deleted file mode 100644 index 4711365..0000000 --- a/lib/src/remote/loggly.dart +++ /dev/null @@ -1,26 +0,0 @@ -import 'package:http/http.dart'; - -/// Created by alex@justprodev.com on 27.05.2022. - -import 'web_tags.dart' if (dart.library.io) 'mobile_tags.dart'; - -/// Non-completed tasks -final Set logglyTasks = {}; - -Future loggly(Uri url, String message, {List? tags}) async { - final headers = {"Content-type": "text/plain; charset=utf-8"}; - - headers["X-LOGGLY-TAG"] = [...defaultTags, ...(tags ?? [])].join(','); - - final task = post(url, body: message, headers: headers); - - try { - logglyTasks.add(task); - await task; - } catch (e, trace) { - // ignore: avoid_print - print("Error sending message to loggly $e $trace"); - } finally { - logglyTasks.remove(task); - } -} diff --git a/lib/src/remote/mobile_tags.dart b/lib/src/remote/mobile_tags.dart deleted file mode 100644 index 17d5e22..0000000 --- a/lib/src/remote/mobile_tags.dart +++ /dev/null @@ -1,17 +0,0 @@ -import 'dart:io' show Platform; - -/// Created by alex@justprodev.com on 23.05.2023. - -final List defaultTags = () { - final tags = []; - - if (Platform.isAndroid) { - tags.add('android'); - } else if (Platform.isIOS) { - tags.add('ios'); - } - - return tags; -}(); - - diff --git a/lib/src/remote/tags/impl/empty_tags.dart b/lib/src/remote/tags/impl/empty_tags.dart new file mode 100644 index 0000000..dc9655d --- /dev/null +++ b/lib/src/remote/tags/impl/empty_tags.dart @@ -0,0 +1,3 @@ +// Created by alex@justprodev.com on 19.12.2025. + +final List defaultTags = []; \ No newline at end of file diff --git a/lib/src/remote/tags/impl/io_tags.dart b/lib/src/remote/tags/impl/io_tags.dart new file mode 100644 index 0000000..64f5228 --- /dev/null +++ b/lib/src/remote/tags/impl/io_tags.dart @@ -0,0 +1,5 @@ +import 'dart:io' show Platform; + +/// Created by alex@justprodev.com on 23.05.2023. + +final List defaultTags = [Platform.operatingSystem]; diff --git a/lib/src/remote/web_tags.dart b/lib/src/remote/tags/impl/web_tags.dart similarity index 100% rename from lib/src/remote/web_tags.dart rename to lib/src/remote/tags/impl/web_tags.dart diff --git a/lib/src/remote/tags/tags.dart b/lib/src/remote/tags/tags.dart new file mode 100644 index 0000000..8175497 --- /dev/null +++ b/lib/src/remote/tags/tags.dart @@ -0,0 +1,6 @@ +// Created by alex@justprodev.com on 19.12.2025. + +// export by platform +export 'impl/empty_tags.dart' + if (dart.library.io) 'impl/io_tags.dart' + if (dart.library.html) 'impl/web_tags.dart'; diff --git a/lib/src/remote_logging.dart b/lib/src/remote_logging.dart index 4bdacc5..cb3bfbd 100644 --- a/lib/src/remote_logging.dart +++ b/lib/src/remote_logging.dart @@ -1,25 +1,31 @@ // Created by alex@justprodev.com on 27.05.2022. +import 'dart:async'; + import 'package:logging/logging.dart'; import 'package:remote_logging/src/model.dart'; +import 'package:remote_logging/src/remote/tags/tags.dart'; + +import 'remote/collectors/loggly_collector.dart' show LogglyCollector; -import 'remote/loggly.dart'; +/// Non-completed tasks +final Set tasks = {}; /// -/// Watch the root [Logger] and then send messages addressed [verboseLoggers] to loggly -/// [verboseLoggers] name of loggers that will be sent to loggly verbosely - i.e. INFO messages, etc +/// Watch the root [Logger] and then send messages addressed [verboseLoggers] to [collectors] +/// [verboseLoggers] name of loggers that will be sent to [collectors] verbosely - i.e. INFO messages, etc /// [tagsProvider] tags for loggly -/// [printToConsole] print message with [debugPrint] -void initLogging( - String logglyToken, { +/// [output] passes all messages to this function (e.g., for printing to console in custom way) +/// [preProcess] pre-process message before sending to collectors (e.g., hide sensitive info) +/// [includeStackTrace] include stack trace in the message sent to collectors +void initRemoteLogging( + List collectors, { List? verboseLoggers, TagsProvider? tagsProvider, - bool Function()? printToConsole, + Function(LogRecord, String)? output, String Function(String loggerName, String message)? preProcess, bool includeStackTrace = false, }) { - final logglyUrl = Uri.parse("https://logs-01.loggly.com/inputs/$logglyToken"); - processRecord(LogRecord record) { String message = preProcess != null ? preProcess(record.loggerName, record.message) : record.message; @@ -30,18 +36,30 @@ void initLogging( } } - final tags = [record.level.name, if (record.loggerName.isNotEmpty) record.loggerName]; - - if (tagsProvider != null) tags.addAll(tagsProvider.call(record)); + final tags = [ + record.level.name, + ...defaultTags, + if (record.loggerName.isNotEmpty) record.loggerName, + if (tagsProvider != null) ...tagsProvider.call(record), + ]; // SEVERE messages will be sent to loggly anyway in if (record.level == Level.SEVERE || (verboseLoggers?.contains(record.loggerName) == true)) { - loggly(logglyUrl, message, tags: tags); + for (final collector in collectors) { + final completer = Completer.sync(); + tasks.add(completer.future); + collector.collect(message, tags: tags).catchError((e, trace) { + // ignore: avoid_print + print("Error sending message to collector $e $trace"); + }).whenComplete(() { + completer.complete(); + tasks.remove(completer.future); + }); + } } - if (printToConsole?.call() == true) { - // ignore: avoid_print - print('${record.loggerName} $message ${record.stackTrace ?? ''}'); + if (output != null) { + output(record, '${record.loggerName} $message ${record.stackTrace ?? ''}'); } } @@ -50,3 +68,27 @@ void initLogging( Logger.root.level = Level.ALL; Logger.root.onRecord.listen(processRecord); } + +/// +/// Watch the root [Logger] and then send messages addressed [verboseLoggers] to loggly +/// [verboseLoggers] name of loggers that will be sent to loggly verbosely - i.e. INFO messages, etc +/// [tagsProvider] tags for loggly +/// [printToConsole] print message with [debugPrint] +@Deprecated('Use initRemoteLogging instead') +void initLogging( + String logglyToken, { + List? verboseLoggers, + TagsProvider? tagsProvider, + bool Function()? printToConsole, + String Function(String loggerName, String message)? preProcess, + bool includeStackTrace = false, +}) { + initRemoteLogging( + [LogglyCollector(logglyToken)], + verboseLoggers: verboseLoggers, + tagsProvider: tagsProvider, + output: printToConsole != null ? (_, message) => print(message) : null, + preProcess: preProcess, + includeStackTrace: includeStackTrace, + ); +} diff --git a/pubspec.yaml b/pubspec.yaml index ed9a960..99568dc 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,10 +1,10 @@ name: remote_logging description: Sending Logger() messages to loggly.com -version: 1.1.0 +version: 2.0.0 homepage: https://github.com/justprodev/flutter_remote_logging.git environment: - sdk: '>=2.18.2 <3.0.0' + sdk: '>=3.0.0 <4.0.0' dependencies: http: diff --git a/test/legacy_remote_logging_test.dart b/test/legacy_remote_logging_test.dart new file mode 100644 index 0000000..8df05dc --- /dev/null +++ b/test/legacy_remote_logging_test.dart @@ -0,0 +1,104 @@ +// Created by alex@justprodev.com on 22.07.2024. + +// ignore_for_file: deprecated_member_use_from_same_package + +import 'dart:io'; + +import 'package:remote_logging/remote_logging.dart'; +import 'package:remote_logging/src/remote/tags/impl/io_tags.dart'; +import 'package:test/test.dart'; +import 'package:fake_http_client/fake_http_client.dart'; + +String? lastBody; +Map>? lastHeaders; +Uri? lastUri; + +const loggers = ['logger1', 'logger2', 'logger3']; +const tags = ['tag1', 'tag2', 'tag3']; + +class TestHttpOverrides extends HttpOverrides { + @override + HttpClient createHttpClient(_) { + return FakeHttpClient((request, client) async { + lastUri = request.uri; + lastBody = request.bodyText; + lastHeaders = {}; + request.headers.forEach((key, value) { + lastHeaders![key] = value; + }); + return FakeHttpResponse(); + }); + } +} + +void main() { + setUpAll(() { + HttpOverrides.global = TestHttpOverrides(); + }); + + setUp(() { + Logger.root.clearListeners(); + lastBody = null; + lastHeaders = null; + lastUri = null; + }); + + test('token', () async { + initLogging('token'); + Logger.root.severe('123'); + await Future.delayed(Duration.zero); + expect(lastUri, Uri.parse('https://logs-01.loggly.com/inputs/token')); + }); + + test('root severe', () async { + initLogging('token'); + Logger.root.severe('severe message to root'); + await Future.delayed(Duration.zero); + expect(lastBody, 'severe message to root'); + expect(lastHeaders?.containsKey('X-LOGGLY-TAG'), true, reason: 'X-LOGGLY-TAG header not found'); + expect(lastHeaders!['X-LOGGLY-TAG']![0], 'SEVERE,${defaultTags.join(',')}'); + }); + + group('exception', () { + test('without trace', () async { + initLogging('token'); + Logger.root.severe('severe message to root', Exception('exception'), StackTrace.current); + await Future.delayed(Duration.zero); + expect(lastBody, 'severe message to root\nException: exception'); + expect(lastHeaders?.containsKey('X-LOGGLY-TAG'), true, reason: 'X-LOGGLY-TAG header not found'); + expect(lastHeaders!['X-LOGGLY-TAG']![0], 'SEVERE,${defaultTags.join(',')}'); + }); + + test('with trace', () async { + initLogging('token', includeStackTrace: true); + final currentTrace = StackTrace.current; + Logger.root.severe('severe message to root', Exception('exception'), currentTrace); + await Future.delayed(Duration.zero); + expect(lastBody, 'severe message to root\nException: exception\n$currentTrace'); + expect(lastHeaders!['X-LOGGLY-TAG']![0], 'SEVERE,${defaultTags.join(',')}'); + }); + }); + + test('tags', () async { + initLogging('token', tagsProvider: (_) => tags); + Logger.root.severe('123'); + await Future.delayed(Duration.zero); + expect( + lastHeaders!['X-LOGGLY-TAG']![0], + 'SEVERE,${[...defaultTags, ...tags].join(',')}', + ); + }); + + test('loggers', () async { + initLogging('token', verboseLoggers: loggers); + + for (final logger in loggers) { + Logger(logger).severe('123'); + await Future.delayed(Duration.zero); + expect( + lastHeaders!['X-LOGGLY-TAG']![0], + 'SEVERE,${[...defaultTags, logger].join(',')}', + ); + } + }); +} diff --git a/test/loggly_collector_test.dart b/test/loggly_collector_test.dart new file mode 100644 index 0000000..e52f84c --- /dev/null +++ b/test/loggly_collector_test.dart @@ -0,0 +1,28 @@ +// Created by alex@justprodev.com on 20.12.2025. + +import 'dart:io'; + +import 'package:http/http.dart'; +import 'package:http/testing.dart'; +import 'package:remote_logging/remote_logging.dart'; +import 'package:test/test.dart'; + +void main() { + test('loggly collector', () async { + Request? request; + final collector = LogglyCollector( + 'token', + host: 'test', + client: MockClient((r) async { + request = r; + return Response('', HttpStatus.ok); + }), + ); + await collector.collect('Test message', tags: ['tag1', 'tag2']); + expect(request, isNotNull); + expect(request!.url, Uri.parse('https://test/inputs/token')); + expect(request!.body, 'Test message'); + expect(request!.headers.containsKey('X-LOGGLY-TAG'), true, reason: 'X-LOGGLY-TAG header not found'); + expect(request!.headers['X-LOGGLY-TAG'], 'tag1,tag2'); + }); +} diff --git a/test/loggly_test.dart b/test/loggly_test.dart deleted file mode 100644 index cd44ee4..0000000 --- a/test/loggly_test.dart +++ /dev/null @@ -1,39 +0,0 @@ -// Created by alex@justprodev.com on 22.07.2024. - -import 'dart:io'; - -import 'package:remote_logging/src/remote/loggly.dart'; -import 'package:test/test.dart'; -import 'package:fake_http_client/fake_http_client.dart'; - -class TestHttpOverrides extends HttpOverrides { - @override - HttpClient createHttpClient(_) { - return FakeHttpClient((request, client) async { - final seconds = int.parse(request.bodyText); - await Future.delayed(Duration(seconds: seconds)); - // The default response is an empty 200. - return FakeHttpResponse(); - }); - } -} - -void main() { - setUp(() { - // Overrides all HttpClients. - HttpOverrides.global = TestHttpOverrides(); - }); - - test('logglyTasks', () async { - loggly(Uri.parse('https://test.com'), '1'); - loggly(Uri.parse('https://test.com'), '2'); - loggly(Uri.parse('https://test.com'), '3'); - expect(logglyTasks.length, 3); - await Future.delayed(const Duration(milliseconds: 1100)); - expect(logglyTasks.length, 2); - await Future.delayed(const Duration(seconds: 1)); - expect(logglyTasks.length, 1); - await Future.delayed(const Duration(seconds: 1)); - expect(logglyTasks.length, 0); - }); -} diff --git a/test/remote_logging_test.dart b/test/remote_logging_test.dart index 97f0ab5..29c1b41 100644 --- a/test/remote_logging_test.dart +++ b/test/remote_logging_test.dart @@ -1,95 +1,143 @@ -// Created by alex@justprodev.com on 22.07.2024. - -import 'dart:io'; +// Created by alex@justprodev.com on 19.12.2025. import 'package:remote_logging/remote_logging.dart'; +import 'package:remote_logging/src/remote/tags/tags.dart'; import 'package:test/test.dart'; -import 'package:fake_http_client/fake_http_client.dart'; -String? lastBody; -Map>? lastHeaders; -Uri? lastUri; +void main() { + setUp(() { + Logger.root.clearListeners(); + }); -const loggers = ['logger1', 'logger2', 'logger3']; -const tags = ['tag1', 'tag2', 'tag3']; + test('collector verbose', () async { + final collector = _TestLogCollector(); + initRemoteLogging([collector], verboseLoggers: ['test']); + Logger('test').info('Test message'); + expect(collector.messages.length, 1); + expect(collector.messages.first, 'Test message'); + }); -class TestHttpOverrides extends HttpOverrides { - @override - HttpClient createHttpClient(_) { - return FakeHttpClient((request, client) async { - lastUri = request.uri; - lastBody = request.bodyText; - lastHeaders = {}; - request.headers.forEach((key, value) { - lastHeaders![key] = value; - }); - return FakeHttpResponse(); - }); - } -} + test('collector severe', () async { + final collector = _TestLogCollector(); + initRemoteLogging([collector]); + Logger('test').severe('Test message'); + expect(collector.messages.length, 1); + expect(collector.messages.first, 'Test message'); + Logger.root.severe('Test message 2'); + expect(collector.messages.length, 2); + expect(collector.messages[1], 'Test message 2'); + }); -void main() { - setUpAll(() { - HttpOverrides.global = TestHttpOverrides(); + test('collector severe exception', () async { + final collector = _TestLogCollector(); + initRemoteLogging([collector]); + final stacktrace = StackTrace.current; + Logger('test').severe('Test message', Exception('test exception'), stacktrace); + expect(collector.messages.first.contains('Test message'), isTrue); + expect(collector.messages.first.contains('test exception'), isTrue); }); - setUp(() { - Logger.root.clearListeners(); - lastBody = null; - lastHeaders = null; - lastUri = null; + test('collector severe stacktrace', () async { + final collector = _TestLogCollector(); + initRemoteLogging([collector], includeStackTrace: true); + final stacktrace = StackTrace.current; + Logger('test').severe('Test message', Exception('test exception'), stacktrace); + expect(collector.messages.first.contains(stacktrace.toString()), isTrue); }); - test('token', () async { - initLogging('token'); - Logger.root.severe('123'); - await Future.delayed(Duration.zero); - expect(lastUri, Uri.parse('https://logs-01.loggly.com/inputs/token')); + test('collector tags', () async { + final collector = _TestLogCollector(); + initRemoteLogging([collector], tagsProvider: (_) => ['tag1', 'tag2']); + Logger('logger').severe('Test message'); + expect(collector.messages.length, 1); + expect(collector.messages.first, 'Test message'); + expect(collector.tags.length, 1); + expect(collector.tags.first.contains('SEVERE'), isTrue); + expect(collector.tags.first.contains('logger'), isTrue); + expect(defaultTags.every((tag) => collector.tags.first.contains(tag)), isTrue); + expect(collector.tags.first.contains('tag1'), isTrue); + expect(collector.tags.first.contains('tag2'), isTrue); }); - test('root severe', () async { - initLogging('token'); - Logger.root.severe('severe message to root'); - await Future.delayed(Duration.zero); - expect(lastBody, 'severe message to root'); - expect(lastHeaders?.containsKey('X-LOGGLY-TAG'), true, reason: 'X-LOGGLY-TAG header not found'); - expect(lastHeaders!['X-LOGGLY-TAG']![0], 'SEVERE'); + test('output', () async { + final collector = _TestLogCollector(); + final outputs = []; + final records = []; + initRemoteLogging([collector], output: (record, message) { + outputs.add(message); + records.add(record); + }); + Logger('logger').info('Test message'); + expect(collector.messages.length, 0); + expect(outputs.length, 1); + expect(outputs.first.contains('logger'), isTrue); + expect(outputs.first.contains('Test message'), isTrue); + expect(records.length, 1); + expect(records.first.loggerName, 'logger'); + expect(records.first.message, 'Test message'); }); - group('exception', () { - test('without trace', () async { - initLogging('token'); - Logger.root.severe('severe message to root', Exception('exception'), StackTrace.current); - await Future.delayed(Duration.zero); - expect(lastBody, 'severe message to root\nException: exception'); - expect(lastHeaders?.containsKey('X-LOGGLY-TAG'), true, reason: 'X-LOGGLY-TAG header not found'); - expect(lastHeaders!['X-LOGGLY-TAG']![0], 'SEVERE'); + test('multiple collectors', () async { + final collector1 = _TestLogCollector(); + final collector2 = _TestLogCollector(); + initRemoteLogging([collector1, collector2]); + Logger.root.severe('Test message'); + expect(collector1.messages.length, 1); + expect(collector2.messages.length, 1); + }); + + group('tasks', () { + test('concurrency', () async { + initRemoteLogging([_DelayLogCollector()], verboseLoggers: ['test']); + final logger = Logger('test'); + + logger.info('100'); + logger.info('200'); + logger.info('300'); + + expect(tasks.length, 3, reason: 'Three tasks should be queued'); + await Future.delayed(const Duration(milliseconds: 100)); + expect(tasks.length, 2, reason: 'One task should be completed after 100ms'); + await Future.delayed(const Duration(milliseconds: 100)); + expect(tasks.length, 1, reason: 'Two tasks should be completed after 200ms'); + await Future.delayed(const Duration(milliseconds: 100)); + expect(tasks.length, 0, reason: 'All tasks should be completed after 300ms'); }); - test('with trace', () async { - initLogging('token', includeStackTrace: true); - final currentTrace = StackTrace.current; - Logger.root.severe('severe message to root', Exception('exception'), currentTrace); - await Future.delayed(Duration.zero); - expect(lastBody, 'severe message to root\nException: exception\n$currentTrace'); - expect(lastHeaders!['X-LOGGLY-TAG']![0], 'SEVERE'); + test('error', () async { + initRemoteLogging([_ErrorLogCollector()]); + Logger.root.severe('Test message'); + expect(tasks.length, 1, reason: 'One task should be queued'); + final task = tasks.first; + await expectLater(task, completes, reason: 'Task should complete without throwing'); }); }); +} - test('tags', () async { - initLogging('token', tagsProvider: (_) => tags); - Logger.root.severe('123'); - await Future.delayed(Duration.zero); - expect(lastHeaders!['X-LOGGLY-TAG']![0], 'SEVERE,${tags.join(',')}'); - }); +/// A log collector that delays for a number of milliseconds specified in the message +class _DelayLogCollector extends LogCollector { + @override + Future collect(String message, {List? tags}) async { + await Future.delayed(Duration(milliseconds: int.parse(message))); + } +} - test('loggers', () async { - initLogging('token', verboseLoggers: loggers); +class _ErrorLogCollector extends LogCollector { + @override + Future collect(String message, {List? tags}) async { + await Future.delayed(Duration(milliseconds: 100)); + throw Exception('Test exception'); + } +} - for(final logger in loggers) { - Logger(logger).severe('123'); - await Future.delayed(Duration.zero); - expect(lastHeaders!['X-LOGGLY-TAG']![0], 'SEVERE,$logger'); - } - }); +/// A log collector that collects messages and tags for testing +class _TestLogCollector extends LogCollector { + final tags = >[]; + final messages = []; + + @override + Future collect(String message, {List? tags}) async { + if (tags != null) this.tags.add(tags); + messages.add(message); + } } diff --git a/test/splunk_collector_test.dart b/test/splunk_collector_test.dart new file mode 100644 index 0000000..b7aa7e9 --- /dev/null +++ b/test/splunk_collector_test.dart @@ -0,0 +1,30 @@ +// Created by alex@justprodev.com on 20.12.2025. + +import 'dart:convert'; +import 'dart:io'; + +import 'package:http/http.dart'; +import 'package:http/testing.dart'; +import 'package:remote_logging/remote_logging.dart'; +import 'package:test/test.dart'; + +void main() { + test('splunk collector', () async { + Request? request; + final collector = SplunkCollector( + 'token', + host: 'test', + client: MockClient((r) async { + request = r; + return Response('', HttpStatus.ok); + }), + ); + await collector.collect('Test message', tags: ['tag1', 'tag2']); + expect(request, isNotNull); + expect(request!.url, Uri.parse('https://test/services/collector/event')); + expect(request!.headers, containsPair('Authorization', 'Splunk token')); + final json = jsonDecode(request!.body); + expect(json['event'], 'Test message'); + expect(json['fields']['tags'], ['tag1', 'tag2']); + }); +} From fbb3aea86d46d1a37270ad43056eaedcb9289f31 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=95=D0=BC=D0=B5=D0=BB=D1=8C=D1=8F=D0=BD=D0=BE=D0=B2=20?= =?UTF-8?q?=D0=90=D0=BB=D0=B5=D0=BA=D1=81=D0=B0=D0=BD=D0=B4=D1=80?= Date: Sat, 20 Dec 2025 04:52:59 +0500 Subject: [PATCH 2/7] feat: simplify remote logging initialization by accepting a single collector --- lib/src/remote_logging.dart | 27 ++++++++++++++------------- test/remote_logging_test.dart | 35 ++++++++++++++--------------------- 2 files changed, 28 insertions(+), 34 deletions(-) diff --git a/lib/src/remote_logging.dart b/lib/src/remote_logging.dart index cb3bfbd..39081cf 100644 --- a/lib/src/remote_logging.dart +++ b/lib/src/remote_logging.dart @@ -19,7 +19,7 @@ final Set tasks = {}; /// [preProcess] pre-process message before sending to collectors (e.g., hide sensitive info) /// [includeStackTrace] include stack trace in the message sent to collectors void initRemoteLogging( - List collectors, { + LogCollector collector, { List? verboseLoggers, TagsProvider? tagsProvider, Function(LogRecord, String)? output, @@ -45,17 +45,12 @@ void initRemoteLogging( // SEVERE messages will be sent to loggly anyway in if (record.level == Level.SEVERE || (verboseLoggers?.contains(record.loggerName) == true)) { - for (final collector in collectors) { - final completer = Completer.sync(); - tasks.add(completer.future); - collector.collect(message, tags: tags).catchError((e, trace) { - // ignore: avoid_print - print("Error sending message to collector $e $trace"); - }).whenComplete(() { - completer.complete(); - tasks.remove(completer.future); - }); - } + final task = collector.collect(message, tags: tags); + tasks.add(task); + task.catchError((e, trace) { + // ignore: avoid_print + print("Error sending message to collector $e $trace"); + }).whenComplete(() => tasks.remove(task)); } if (output != null) { @@ -69,6 +64,12 @@ void initRemoteLogging( Logger.root.onRecord.listen(processRecord); } +Future waitForLoggingTasks() async { + try { + await Future.wait(tasks); + } catch (_) {} +} + /// /// Watch the root [Logger] and then send messages addressed [verboseLoggers] to loggly /// [verboseLoggers] name of loggers that will be sent to loggly verbosely - i.e. INFO messages, etc @@ -84,7 +85,7 @@ void initLogging( bool includeStackTrace = false, }) { initRemoteLogging( - [LogglyCollector(logglyToken)], + LogglyCollector(logglyToken), verboseLoggers: verboseLoggers, tagsProvider: tagsProvider, output: printToConsole != null ? (_, message) => print(message) : null, diff --git a/test/remote_logging_test.dart b/test/remote_logging_test.dart index 29c1b41..c61dafa 100644 --- a/test/remote_logging_test.dart +++ b/test/remote_logging_test.dart @@ -11,7 +11,7 @@ void main() { test('collector verbose', () async { final collector = _TestLogCollector(); - initRemoteLogging([collector], verboseLoggers: ['test']); + initRemoteLogging(collector, verboseLoggers: ['test']); Logger('test').info('Test message'); expect(collector.messages.length, 1); expect(collector.messages.first, 'Test message'); @@ -19,7 +19,7 @@ void main() { test('collector severe', () async { final collector = _TestLogCollector(); - initRemoteLogging([collector]); + initRemoteLogging(collector); Logger('test').severe('Test message'); expect(collector.messages.length, 1); expect(collector.messages.first, 'Test message'); @@ -30,7 +30,7 @@ void main() { test('collector severe exception', () async { final collector = _TestLogCollector(); - initRemoteLogging([collector]); + initRemoteLogging(collector); final stacktrace = StackTrace.current; Logger('test').severe('Test message', Exception('test exception'), stacktrace); expect(collector.messages.first.contains('Test message'), isTrue); @@ -39,7 +39,7 @@ void main() { test('collector severe stacktrace', () async { final collector = _TestLogCollector(); - initRemoteLogging([collector], includeStackTrace: true); + initRemoteLogging(collector, includeStackTrace: true); final stacktrace = StackTrace.current; Logger('test').severe('Test message', Exception('test exception'), stacktrace); expect(collector.messages.first.contains(stacktrace.toString()), isTrue); @@ -47,7 +47,7 @@ void main() { test('collector tags', () async { final collector = _TestLogCollector(); - initRemoteLogging([collector], tagsProvider: (_) => ['tag1', 'tag2']); + initRemoteLogging(collector, tagsProvider: (_) => ['tag1', 'tag2']); Logger('logger').severe('Test message'); expect(collector.messages.length, 1); expect(collector.messages.first, 'Test message'); @@ -63,7 +63,7 @@ void main() { final collector = _TestLogCollector(); final outputs = []; final records = []; - initRemoteLogging([collector], output: (record, message) { + initRemoteLogging(collector, output: (record, message) { outputs.add(message); records.add(record); }); @@ -77,18 +77,9 @@ void main() { expect(records.first.message, 'Test message'); }); - test('multiple collectors', () async { - final collector1 = _TestLogCollector(); - final collector2 = _TestLogCollector(); - initRemoteLogging([collector1, collector2]); - Logger.root.severe('Test message'); - expect(collector1.messages.length, 1); - expect(collector2.messages.length, 1); - }); - group('tasks', () { test('concurrency', () async { - initRemoteLogging([_DelayLogCollector()], verboseLoggers: ['test']); + initRemoteLogging(_DelayLogCollector(), verboseLoggers: ['test']); final logger = Logger('test'); logger.info('100'); @@ -104,12 +95,14 @@ void main() { expect(tasks.length, 0, reason: 'All tasks should be completed after 300ms'); }); - test('error', () async { - initRemoteLogging([_ErrorLogCollector()]); + test('wait tasks', () async { + initRemoteLogging(_ErrorLogCollector()); + Logger.root.severe('Test message'); Logger.root.severe('Test message'); - expect(tasks.length, 1, reason: 'One task should be queued'); - final task = tasks.first; - await expectLater(task, completes, reason: 'Task should complete without throwing'); + Logger.root.severe('Test message'); + expect(tasks.length, 3, reason: 'Three tasks should be queued'); + await expectLater(waitForLoggingTasks(), completes, reason: 'Tasks should complete without throwing'); + expect(tasks.length, 0, reason: 'All tasks should be completed'); }); }); } From 1e2ced95511b4c79f9ee239eef001d0c49340815 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=95=D0=BC=D0=B5=D0=BB=D1=8C=D1=8F=D0=BD=D0=BE=D0=B2=20?= =?UTF-8?q?=D0=90=D0=BB=D0=B5=D0=BA=D1=81=D0=B0=D0=BD=D0=B4=D1=80?= Date: Sun, 21 Dec 2025 04:41:41 +0500 Subject: [PATCH 3/7] add preProcess test --- test/remote_logging_test.dart | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/test/remote_logging_test.dart b/test/remote_logging_test.dart index c61dafa..43dc86d 100644 --- a/test/remote_logging_test.dart +++ b/test/remote_logging_test.dart @@ -77,6 +77,18 @@ void main() { expect(records.first.message, 'Test message'); }); + test('preProcess', () async { + final collector = _TestLogCollector(); + initRemoteLogging( + collector, + verboseLoggers: ['test'], + preProcess: (loggerName, message) => message.replaceAll(RegExp(r'password: \d+'), 'password: [HIDDEN]'), + ); + Logger('test').info('Test message with password: 12345'); + expect(collector.messages.length, 1); + expect(collector.messages.first, 'Test message with password: [HIDDEN]'); + }); + group('tasks', () { test('concurrency', () async { initRemoteLogging(_DelayLogCollector(), verboseLoggers: ['test']); From ba48e4f80e97c3919fb8ec4008da370c0318286c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=95=D0=BC=D0=B5=D0=BB=D1=8C=D1=8F=D0=BD=D0=BE=D0=B2=20?= =?UTF-8?q?=D0=90=D0=BB=D0=B5=D0=BA=D1=81=D0=B0=D0=BD=D0=B4=D1=80?= Date: Sun, 21 Dec 2025 04:41:52 +0500 Subject: [PATCH 4/7] comment --- lib/src/remote_logging.dart | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/src/remote_logging.dart b/lib/src/remote_logging.dart index 39081cf..2a083da 100644 --- a/lib/src/remote_logging.dart +++ b/lib/src/remote_logging.dart @@ -64,6 +64,7 @@ void initRemoteLogging( Logger.root.onRecord.listen(processRecord); } +/// Wait for all logging tasks to complete Future waitForLoggingTasks() async { try { await Future.wait(tasks); From 0d39090b31755f1c2761c340f94aa01277c4843d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=95=D0=BC=D0=B5=D0=BB=D1=8C=D1=8F=D0=BD=D0=BE=D0=B2=20?= =?UTF-8?q?=D0=90=D0=BB=D0=B5=D0=BA=D1=81=D0=B0=D0=BD=D0=B4=D1=80?= Date: Sun, 21 Dec 2025 04:58:29 +0500 Subject: [PATCH 5/7] docs: update README to reflect changes in remote logging initialization and supported services --- README.md | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 0d924eb..7a491ad 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,19 @@ -# flutter_remote_logging -Sending [Logger()](https://pub.dev/packages/logging) messages to loggly.com +# Dart package `remote_logging` -```dart +Sending [Logger()](https://pub.dev/packages/logging) messages to various remote logging services, +like [Loggly](https://www.loggly.com/docs/http-endpoint/), [Splunk](https://docs.splunk.com/Documentation/Splunk/latest/Data/UsetheHTTPEventCollector), etc. +```dart import 'package:remote_logging/remote_logging.dart'; void main() async { - initLogging(logglyToken, verboseLoggers: ['logger1', 'logger2'], tagsProvider: (record) { - return [ - 'tag1', - 'tag2', - ]; - }); - + final logglyCollector = LogglyCollector(logglyToken); + initRemoteLogging( + logglyCollector, + verboseLoggers: ['logger1', 'logger2'], + tagsProvider: (_) => ['tag1','tag2'], + ); + // https://pub.dev/packages/logging Logger('logger1').info('info on logger1'); Logger('logger2').info('info on logger2'); From b3c07791b5140a1069084e5e430940759ad99ce1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=95=D0=BC=D0=B5=D0=BB=D1=8C=D1=8F=D0=BD=D0=BE=D0=B2=20?= =?UTF-8?q?=D0=90=D0=BB=D0=B5=D0=BA=D1=81=D0=B0=D0=BD=D0=B4=D1=80?= Date: Sun, 21 Dec 2025 05:00:53 +0500 Subject: [PATCH 6/7] docs: update package description to reflect support for multiple remote logging services --- pubspec.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pubspec.yaml b/pubspec.yaml index 99568dc..d54b888 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,5 +1,5 @@ name: remote_logging -description: Sending Logger() messages to loggly.com +description: Sending Logger() messages to various remote logging services version: 2.0.0 homepage: https://github.com/justprodev/flutter_remote_logging.git From 5d5e7bd8662a9ae597d59db2680f1db96ca4bcb1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=95=D0=BC=D0=B5=D0=BB=D1=8C=D1=8F=D0=BD=D0=BE=D0=B2=20?= =?UTF-8?q?=D0=90=D0=BB=D0=B5=D0=BA=D1=81=D0=B0=D0=BD=D0=B4=D1=80?= Date: Sun, 21 Dec 2025 05:08:47 +0500 Subject: [PATCH 7/7] fix: update Content-Type header to include charset in Splunk collector --- lib/src/remote/collectors/splunk_collector.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/src/remote/collectors/splunk_collector.dart b/lib/src/remote/collectors/splunk_collector.dart index 2e1a6ba..5f1243b 100644 --- a/lib/src/remote/collectors/splunk_collector.dart +++ b/lib/src/remote/collectors/splunk_collector.dart @@ -12,7 +12,7 @@ class SplunkCollector extends HttpCollector { url: Uri.parse('https://$host/services/collector/event'), headers: { 'Authorization': 'Splunk $token', - 'Content-Type': 'application/json', + 'Content-Type': 'application/json;charset=utf-8', }, );