diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b745faf84..3301e6ca3a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ ### Features - Logs: Models & Envelopes ([#2916](https://github.com/getsentry/sentry-dart/pull/2916)) +- Logs: Integrate in Sentry Client ([#2920](https://github.com/getsentry/sentry-dart/pull/2920)) +- [Structured Logs]: Buffering and Flushing of Logs ([#2930](https://github.com/getsentry/sentry-dart/pull/2930)) ## 9.0.0-beta.2 diff --git a/dart/lib/src/noop_log_batcher.dart b/dart/lib/src/noop_log_batcher.dart new file mode 100644 index 0000000000..7d5c94523d --- /dev/null +++ b/dart/lib/src/noop_log_batcher.dart @@ -0,0 +1,12 @@ +import 'dart:async'; + +import 'sentry_log_batcher.dart'; +import 'protocol/sentry_log.dart'; + +class NoopLogBatcher implements SentryLogBatcher { + @override + FutureOr addLog(SentryLog log) {} + + @override + Future flush() async {} +} diff --git a/dart/lib/src/noop_sentry_client.dart b/dart/lib/src/noop_sentry_client.dart index 39bd728dd9..10b8413854 100644 --- a/dart/lib/src/noop_sentry_client.dart +++ b/dart/lib/src/noop_sentry_client.dart @@ -66,4 +66,7 @@ class NoOpSentryClient implements SentryClient { Future captureFeedback(SentryFeedback feedback, {Scope? scope, Hint? hint}) async => SentryId.empty(); + + @override + Future captureLog(SentryLog log, {Scope? scope}) async {} } diff --git a/dart/lib/src/protocol/sentry_log.dart b/dart/lib/src/protocol/sentry_log.dart index f0f2c180b4..55c0174d90 100644 --- a/dart/lib/src/protocol/sentry_log.dart +++ b/dart/lib/src/protocol/sentry_log.dart @@ -10,14 +10,16 @@ class SentryLog { Map attributes; int? severityNumber; + /// The traceId is initially an empty default value and is populated during event processing; + /// by the time processing completes, it is guaranteed to be a valid non-empty trace id. SentryLog({ required this.timestamp, - required this.traceId, + SentryId? traceId, required this.level, required this.body, required this.attributes, this.severityNumber, - }); + }) : traceId = traceId ?? SentryId.empty(); Map toJson() { return { diff --git a/dart/lib/src/protocol/sentry_log_attribute.dart b/dart/lib/src/protocol/sentry_log_attribute.dart index 7f50227d70..63ac85eb87 100644 --- a/dart/lib/src/protocol/sentry_log_attribute.dart +++ b/dart/lib/src/protocol/sentry_log_attribute.dart @@ -8,11 +8,11 @@ class SentryLogAttribute { return SentryLogAttribute._(value, 'string'); } - factory SentryLogAttribute.boolean(bool value) { + factory SentryLogAttribute.bool(bool value) { return SentryLogAttribute._(value, 'boolean'); } - factory SentryLogAttribute.integer(int value) { + factory SentryLogAttribute.int(int value) { return SentryLogAttribute._(value, 'integer'); } @@ -20,7 +20,7 @@ class SentryLogAttribute { return SentryLogAttribute._(value, 'double'); } - // In the future the SDK will also support string[], boolean[], integer[], double[] values. + // In the future the SDK will also support List, List, List, List values. Map toJson() { return { 'value': value, diff --git a/dart/lib/src/sentry_client.dart b/dart/lib/src/sentry_client.dart index a3198e7524..03613b885e 100644 --- a/dart/lib/src/sentry_client.dart +++ b/dart/lib/src/sentry_client.dart @@ -27,6 +27,7 @@ import 'type_check_hint.dart'; import 'utils/isolate_utils.dart'; import 'utils/regex_utils.dart'; import 'utils/stacktrace_utils.dart'; +import 'sentry_log_batcher.dart'; import 'version.dart'; /// Default value for [SentryUser.ipAddress]. It gets set when an event does not have @@ -75,6 +76,9 @@ class SentryClient { if (enableFlutterSpotlight) { options.transport = SpotlightHttpTransport(options, options.transport); } + if (options.enableLogs) { + options.logBatcher = SentryLogBatcher(options); + } return SentryClient._(options); } @@ -485,6 +489,73 @@ class SentryClient { ); } + @internal + Future captureLog( + SentryLog log, { + Scope? scope, + }) async { + if (!_options.enableLogs) { + return; + } + + log.attributes['sentry.sdk.name'] = SentryLogAttribute.string( + _options.sdk.name, + ); + log.attributes['sentry.sdk.version'] = SentryLogAttribute.string( + _options.sdk.version, + ); + final environment = _options.environment; + if (environment != null) { + log.attributes['sentry.environment'] = SentryLogAttribute.string( + environment, + ); + } + final release = _options.release; + if (release != null) { + log.attributes['sentry.release'] = SentryLogAttribute.string( + release, + ); + } + + final propagationContext = scope?.propagationContext; + if (propagationContext != null) { + log.traceId = propagationContext.traceId; + } + final span = scope?.span; + if (span != null) { + log.attributes['sentry.trace.parent_span_id'] = SentryLogAttribute.string( + span.context.spanId.toString(), + ); + } + + final beforeSendLog = _options.beforeSendLog; + SentryLog? processedLog = log; + if (beforeSendLog != null) { + try { + final callbackResult = beforeSendLog(log); + + if (callbackResult is Future) { + processedLog = await callbackResult; + } else { + processedLog = callbackResult; + } + } catch (exception, stackTrace) { + _options.logger( + SentryLevel.error, + 'The beforeSendLog callback threw an exception', + exception: exception, + stackTrace: stackTrace, + ); + if (_options.automatedTestMode) { + rethrow; + } + } + } + if (processedLog != null) { + _options.logBatcher.addLog(processedLog); + } + } + void close() { _options.httpClient.close(); } diff --git a/dart/lib/src/sentry_log_batcher.dart b/dart/lib/src/sentry_log_batcher.dart new file mode 100644 index 0000000000..d5f023f979 --- /dev/null +++ b/dart/lib/src/sentry_log_batcher.dart @@ -0,0 +1,52 @@ +import 'dart:async'; +import 'sentry_envelope.dart'; +import 'sentry_options.dart'; +import 'protocol/sentry_log.dart'; +import 'package:meta/meta.dart'; + +@internal +class SentryLogBatcher { + SentryLogBatcher(this._options, {Duration? flushTimeout, int? maxBufferSize}) + : _flushTimeout = flushTimeout ?? Duration(seconds: 5), + _maxBufferSize = maxBufferSize ?? 100; + + final SentryOptions _options; + final Duration _flushTimeout; + final int _maxBufferSize; + + final _logBuffer = []; + + Timer? _flushTimer; + + void addLog(SentryLog log) { + _logBuffer.add(log); + + _flushTimer?.cancel(); + + if (_logBuffer.length >= _maxBufferSize) { + return flush(); + } else { + _flushTimer = Timer(_flushTimeout, flush); + } + } + + void flush() { + _flushTimer?.cancel(); + _flushTimer = null; + + final logs = List.from(_logBuffer); + _logBuffer.clear(); + + if (logs.isEmpty) { + return; + } + + final envelope = SentryEnvelope.fromLogs( + logs, + _options.sdk, + ); + + // TODO: Make sure the Android SDK understands the log envelope type. + _options.transport.send(envelope); + } +} diff --git a/dart/lib/src/sentry_options.dart b/dart/lib/src/sentry_options.dart index 90abb7947d..fa9c9a24a8 100644 --- a/dart/lib/src/sentry_options.dart +++ b/dart/lib/src/sentry_options.dart @@ -15,6 +15,8 @@ import 'sentry_exception_factory.dart'; import 'sentry_stack_trace_factory.dart'; import 'transport/noop_transport.dart'; import 'version.dart'; +import 'sentry_log_batcher.dart'; +import 'noop_log_batcher.dart'; // TODO: shutdownTimeout, flushTimeoutMillis // https://api.dart.dev/stable/2.10.2/dart-io/HttpClient/close.html doesn't have a timeout param, we'd need to implement manually @@ -198,6 +200,10 @@ class SentryOptions { /// Can return true to emit the metric, or false to drop it. BeforeMetricCallback? beforeMetricCallback; + /// This function is called right before a log is about to be sent. + /// Can return a modified log or null to drop the log. + BeforeSendLogCallback? beforeSendLog; + /// Sets the release. SDK will try to automatically configure a release out of the box /// See [docs for further information](https://docs.sentry.io/platforms/flutter/configuration/releases/) String? release; @@ -531,6 +537,14 @@ class SentryOptions { /// This is opt-in, as it can lead to existing exception beeing grouped as new ones. bool groupExceptions = false; + /// Enable to capture and send logs to Sentry. + /// + /// Disabled by default. + bool enableLogs = false; + + @internal + SentryLogBatcher logBatcher = NoopLogBatcher(); + SentryOptions({String? dsn, Platform? platform, RuntimeChecker? checker}) { this.dsn = dsn; if (platform != null) { @@ -660,6 +674,10 @@ typedef BeforeMetricCallback = bool Function( Map? tags, }); +/// This function is called right before a log is about to be sent. +/// Can return a modified log or null to drop the log. +typedef BeforeSendLogCallback = FutureOr Function(SentryLog log); + /// Used to provide timestamp for logging. typedef ClockProvider = DateTime Function(); diff --git a/dart/test/mocks/mock_log_batcher.dart b/dart/test/mocks/mock_log_batcher.dart new file mode 100644 index 0000000000..9de9a8ae5d --- /dev/null +++ b/dart/test/mocks/mock_log_batcher.dart @@ -0,0 +1,19 @@ +import 'dart:async'; + +import 'package:sentry/src/protocol/sentry_log.dart'; +import 'package:sentry/src/sentry_log_batcher.dart'; + +class MockLogBatcher implements SentryLogBatcher { + final addLogCalls = []; + final flushCalls = []; + + @override + FutureOr addLog(SentryLog log) { + addLogCalls.add(log); + } + + @override + Future flush() async { + flushCalls.add(null); + } +} diff --git a/dart/test/mocks/mock_sentry_client.dart b/dart/test/mocks/mock_sentry_client.dart index 6138eb5295..966b7a7590 100644 --- a/dart/test/mocks/mock_sentry_client.dart +++ b/dart/test/mocks/mock_sentry_client.dart @@ -8,8 +8,8 @@ class MockSentryClient with NoSuchMethodProvider implements SentryClient { List captureMessageCalls = []; List captureEnvelopeCalls = []; List captureTransactionCalls = []; - List captureFeedbackCalls = []; + List captureLogCalls = []; int closeCalls = 0; @override @@ -84,6 +84,11 @@ class MockSentryClient with NoSuchMethodProvider implements SentryClient { return SentryId.newId(); } + @override + Future captureLog(SentryLog log, {Scope? scope}) async { + captureLogCalls.add(CaptureLogCall(log, scope)); + } + @override void close() { closeCalls = closeCalls + 1; @@ -173,3 +178,10 @@ class CaptureTransactionCall { CaptureTransactionCall(this.transaction, this.traceContext, this.hint); } + +class CaptureLogCall { + final SentryLog log; + final Scope? scope; + + CaptureLogCall(this.log, this.scope); +} diff --git a/dart/test/mocks/mock_transport.dart b/dart/test/mocks/mock_transport.dart index 4f4c117dde..f9ba5b4829 100644 --- a/dart/test/mocks/mock_transport.dart +++ b/dart/test/mocks/mock_transport.dart @@ -7,6 +7,7 @@ class MockTransport implements Transport { List envelopes = []; List events = []; List statsdItems = []; + List> logs = []; int _calls = 0; String _exceptions = ''; @@ -31,7 +32,7 @@ class MockTransport implements Transport { try { envelopes.add(envelope); if (parseFromEnvelope) { - await _eventFromEnvelope(envelope); + await _parseEnvelope(envelope); } } catch (e, stack) { _exceptions += '$e\n$stack\n\n'; @@ -41,7 +42,7 @@ class MockTransport implements Transport { return envelope.header.eventId ?? SentryId.empty(); } - Future _eventFromEnvelope(SentryEnvelope envelope) async { + Future _parseEnvelope(SentryEnvelope envelope) async { final RegExp statSdRegex = RegExp('^(?!{).+@.+:.+\\|.+', multiLine: true); final envelopeItemData = await envelope.items.first.dataFactory(); @@ -49,6 +50,13 @@ class MockTransport implements Transport { if (statSdRegex.hasMatch(envelopeItem)) { statsdItems.add(envelopeItem); + } else if (envelopeItem.contains('items') && + envelopeItem.contains('timestamp') && + envelopeItem.contains('trace_id') && + envelopeItem.contains('level') && + envelopeItem.contains('body')) { + final envelopeItemJson = jsonDecode(envelopeItem) as Map; + logs.add(envelopeItemJson); } else { final envelopeItemJson = jsonDecode(envelopeItem) as Map; events.add(SentryEvent.fromJson(envelopeItemJson)); diff --git a/dart/test/protocol/sentry_log_attribute_test.dart b/dart/test/protocol/sentry_log_attribute_test.dart index 900507bbb6..2c0fb7ce31 100644 --- a/dart/test/protocol/sentry_log_attribute_test.dart +++ b/dart/test/protocol/sentry_log_attribute_test.dart @@ -11,8 +11,8 @@ void main() { }); }); - test('$SentryLogAttribute boolean to json', () { - final attribute = SentryLogAttribute.boolean(true); + test('$SentryLogAttribute bool to json', () { + final attribute = SentryLogAttribute.bool(true); final json = attribute.toJson(); expect(json, { 'value': true, @@ -20,8 +20,8 @@ void main() { }); }); - test('$SentryLogAttribute integer to json', () { - final attribute = SentryLogAttribute.integer(1); + test('$SentryLogAttribute int to json', () { + final attribute = SentryLogAttribute.int(1); final json = attribute.toJson(); expect(json, { diff --git a/dart/test/protocol/sentry_log_test.dart b/dart/test/protocol/sentry_log_test.dart index ab7b538495..0921df7a32 100644 --- a/dart/test/protocol/sentry_log_test.dart +++ b/dart/test/protocol/sentry_log_test.dart @@ -13,8 +13,8 @@ void main() { body: 'fixture-body', attributes: { 'test': SentryLogAttribute.string('fixture-test'), - 'test2': SentryLogAttribute.boolean(true), - 'test3': SentryLogAttribute.integer(9001), + 'test2': SentryLogAttribute.bool(true), + 'test3': SentryLogAttribute.int(9001), 'test4': SentryLogAttribute.double(9000.1), }, severityNumber: 1, diff --git a/dart/test/sentry_client_test.dart b/dart/test/sentry_client_test.dart index 0d44050d89..d8b29d486e 100644 --- a/dart/test/sentry_client_test.dart +++ b/dart/test/sentry_client_test.dart @@ -17,12 +17,15 @@ import 'package:sentry/src/transport/noop_transport.dart'; import 'package:sentry/src/transport/spotlight_http_transport.dart'; import 'package:sentry/src/utils/iterable_utils.dart'; import 'package:test/test.dart'; +import 'package:sentry/src/noop_log_batcher.dart'; import 'mocks.dart'; import 'mocks/mock_client_report_recorder.dart'; import 'mocks/mock_hub.dart'; import 'mocks/mock_transport.dart'; import 'test_utils.dart'; +import 'utils/url_details_test.dart'; +import 'mocks/mock_log_batcher.dart'; void main() { group('SentryClient captures message', () { @@ -1702,6 +1705,229 @@ void main() { }); }); + group('SentryClient captureLog', () { + late Fixture fixture; + + setUp(() { + fixture = Fixture(); + }); + + SentryLog givenLog() { + return SentryLog( + timestamp: DateTime.now(), + traceId: SentryId.newId(), + level: SentryLogLevel.info, + body: 'test', + attributes: { + 'attribute': SentryLogAttribute.string('value'), + }, + ); + } + + test('sets log batcher on options when logs are enabled', () async { + expect(fixture.options.logBatcher is NoopLogBatcher, true); + + fixture.options.enableLogs = true; + fixture.getSut(); + + expect(fixture.options.logBatcher is NoopLogBatcher, false); + }); + + test('disabled by default', () async { + final client = fixture.getSut(); + fixture.options.logBatcher = MockLogBatcher(); + + final log = givenLog(); + + await client.captureLog(log); + + final mockLogBatcher = fixture.options.logBatcher as MockLogBatcher; + expect(mockLogBatcher.addLogCalls, isEmpty); + }); + + test('should capture logs as envelope', () async { + fixture.options.enableLogs = true; + + final client = fixture.getSut(); + fixture.options.logBatcher = MockLogBatcher(); + + final log = givenLog(); + + await client.captureLog(log); + + final mockLogBatcher = fixture.options.logBatcher as MockLogBatcher; + expect(mockLogBatcher.addLogCalls.length, 1); + + final capturedLog = mockLogBatcher.addLogCalls.first; + + expect(capturedLog.traceId, log.traceId); + expect(capturedLog.level, log.level); + expect(capturedLog.body, log.body); + expect(capturedLog.attributes['attribute']?.value, + log.attributes['attribute']?.value); + }); + + test('should add additional info to attributes', () async { + fixture.options.enableLogs = true; + fixture.options.environment = 'test-environment'; + fixture.options.release = 'test-release'; + + final log = givenLog(); + + final scope = Scope(fixture.options); + final span = MockSpan(); + scope.span = span; + + final client = fixture.getSut(); + fixture.options.logBatcher = MockLogBatcher(); + + await client.captureLog(log, scope: scope); + + final mockLogBatcher = fixture.options.logBatcher as MockLogBatcher; + expect(mockLogBatcher.addLogCalls.length, 1); + final capturedLog = mockLogBatcher.addLogCalls.first; + + expect( + capturedLog.attributes['sentry.sdk.name']?.value, + fixture.options.sdk.name, + ); + expect( + capturedLog.attributes['sentry.sdk.name']?.type, + 'string', + ); + expect( + capturedLog.attributes['sentry.sdk.version']?.value, + fixture.options.sdk.version, + ); + expect( + capturedLog.attributes['sentry.sdk.version']?.type, + 'string', + ); + expect( + capturedLog.attributes['sentry.environment']?.value, + fixture.options.environment, + ); + expect( + capturedLog.attributes['sentry.environment']?.type, + 'string', + ); + expect( + capturedLog.attributes['sentry.release']?.value, + fixture.options.release, + ); + expect( + capturedLog.attributes['sentry.release']?.type, + 'string', + ); + expect( + capturedLog.attributes['sentry.trace.parent_span_id']?.value, + span.context.spanId.toString(), + ); + expect( + capturedLog.attributes['sentry.trace.parent_span_id']?.type, + 'string', + ); + }); + + test('should set trace id from propagation context', () async { + fixture.options.enableLogs = true; + + final client = fixture.getSut(); + fixture.options.logBatcher = MockLogBatcher(); + + final log = givenLog(); + final scope = Scope(fixture.options); + + await client.captureLog(log, scope: scope); + + final mockLogBatcher = fixture.options.logBatcher as MockLogBatcher; + expect(mockLogBatcher.addLogCalls.length, 1); + final capturedLog = mockLogBatcher.addLogCalls.first; + + expect(capturedLog.traceId, scope.propagationContext.traceId); + }); + + test('$BeforeSendLogCallback returning null drops the log', () async { + fixture.options.enableLogs = true; + fixture.options.beforeSendLog = (log) => null; + + final client = fixture.getSut(); + fixture.options.logBatcher = MockLogBatcher(); + + final log = givenLog(); + + await client.captureLog(log); + + final mockLogBatcher = fixture.options.logBatcher as MockLogBatcher; + expect(mockLogBatcher.addLogCalls.length, 0); + }); + + test('$BeforeSendLogCallback returning a log modifies it', () async { + fixture.options.enableLogs = true; + fixture.options.beforeSendLog = (log) { + log.body = 'modified'; + return log; + }; + + final client = fixture.getSut(); + fixture.options.logBatcher = MockLogBatcher(); + + final log = givenLog(); + + await client.captureLog(log); + + final mockLogBatcher = fixture.options.logBatcher as MockLogBatcher; + expect(mockLogBatcher.addLogCalls.length, 1); + final capturedLog = mockLogBatcher.addLogCalls.first; + + expect(capturedLog.body, 'modified'); + }); + + test('$BeforeSendLogCallback returning a log async modifies it', () async { + fixture.options.enableLogs = true; + fixture.options.beforeSendLog = (log) async { + await Future.delayed(Duration(milliseconds: 100)); + log.body = 'modified'; + return log; + }; + + final client = fixture.getSut(); + fixture.options.logBatcher = MockLogBatcher(); + + final log = givenLog(); + + await client.captureLog(log); + + final mockLogBatcher = fixture.options.logBatcher as MockLogBatcher; + expect(mockLogBatcher.addLogCalls.length, 1); + final capturedLog = mockLogBatcher.addLogCalls.first; + + expect(capturedLog.body, 'modified'); + }); + + test('$BeforeSendLogCallback throwing is caught', () async { + fixture.options.enableLogs = true; + fixture.options.automatedTestMode = false; + + fixture.options.beforeSendLog = (log) { + throw Exception('test'); + }; + + final client = fixture.getSut(); + fixture.options.logBatcher = MockLogBatcher(); + + final log = givenLog(); + + await client.captureLog(log); + + final mockLogBatcher = fixture.options.logBatcher as MockLogBatcher; + expect(mockLogBatcher.addLogCalls.length, 1); + final capturedLog = mockLogBatcher.addLogCalls.first; + + expect(capturedLog.body, 'test'); + }); + }); + group('SentryClient captures envelope', () { late Fixture fixture; final fakeEnvelope = getFakeEnvelope(); diff --git a/dart/test/sentry_envelope_item_test.dart b/dart/test/sentry_envelope_item_test.dart index 47ab01854f..741773a66a 100644 --- a/dart/test/sentry_envelope_item_test.dart +++ b/dart/test/sentry_envelope_item_test.dart @@ -113,7 +113,7 @@ void main() { level: SentryLogLevel.info, body: 'test2', attributes: { - 'test2': SentryLogAttribute.integer(9001), + 'test2': SentryLogAttribute.int(9001), }, ), ]; diff --git a/dart/test/sentry_envelope_test.dart b/dart/test/sentry_envelope_test.dart index e367c8a263..9a21339aaa 100644 --- a/dart/test/sentry_envelope_test.dart +++ b/dart/test/sentry_envelope_test.dart @@ -152,7 +152,7 @@ void main() { level: SentryLogLevel.info, body: 'test2', attributes: { - 'test2': SentryLogAttribute.integer(9001), + 'test2': SentryLogAttribute.int(9001), }, ), ]; diff --git a/dart/test/sentry_log_batcher_test.dart b/dart/test/sentry_log_batcher_test.dart new file mode 100644 index 0000000000..5e9f7cf5f3 --- /dev/null +++ b/dart/test/sentry_log_batcher_test.dart @@ -0,0 +1,148 @@ +import 'package:test/test.dart'; +import 'package:sentry/src/sentry_log_batcher.dart'; +import 'package:sentry/src/sentry_options.dart'; +import 'package:sentry/src/protocol/sentry_log.dart'; +import 'package:sentry/src/protocol/sentry_log_level.dart'; + +import 'mocks/mock_transport.dart'; + +void main() { + late Fixture fixture; + + setUp(() { + fixture = Fixture(); + }); + + test('added logs are flushed after timeout', () async { + final flushTimeout = Duration(milliseconds: 1); + + final batcher = fixture.getSut(flushTimeout: flushTimeout); + + final log = SentryLog( + timestamp: DateTime.now(), + level: SentryLogLevel.info, + body: 'test', + attributes: {}, + ); + final log2 = SentryLog( + timestamp: DateTime.now(), + level: SentryLogLevel.info, + body: 'test2', + attributes: {}, + ); + + batcher.addLog(log); + batcher.addLog(log2); + + expect(fixture.mockTransport.envelopes.length, 0); + + await Future.delayed(flushTimeout); + + expect(fixture.mockTransport.envelopes.length, 1); + + final envelopePayloadJson = (fixture.mockTransport).logs.first; + + expect(envelopePayloadJson, isNotNull); + expect(envelopePayloadJson['items'].length, 2); + expect(envelopePayloadJson['items'].first['body'], log.body); + expect(envelopePayloadJson['items'].last['body'], log2.body); + }); + + test('max logs are flushed without timeout', () async { + final batcher = fixture.getSut(maxBufferSize: 10); + + final log = SentryLog( + timestamp: DateTime.now(), + level: SentryLogLevel.info, + body: 'test', + attributes: {}, + ); + + for (var i = 0; i < 10; i++) { + batcher.addLog(log); + } + + // Just wait a little bit, as we call capture without awaiting internally. + await Future.delayed(Duration(milliseconds: 1)); + + expect(fixture.mockTransport.envelopes.length, 1); + final envelopePayloadJson = (fixture.mockTransport).logs.first; + + expect(envelopePayloadJson, isNotNull); + expect(envelopePayloadJson['items'].length, 10); + }); + + test('more than max logs are flushed eventuelly', () async { + final flushTimeout = Duration(milliseconds: 100); + final batcher = fixture.getSut( + maxBufferSize: 10, + flushTimeout: flushTimeout, + ); + + final log = SentryLog( + timestamp: DateTime.now(), + level: SentryLogLevel.info, + body: 'test', + attributes: {}, + ); + + for (var i = 0; i < 15; i++) { + batcher.addLog(log); + } + + await Future.delayed(flushTimeout); + + expect(fixture.mockTransport.envelopes.length, 2); + + final firstEnvelopePayloadJson = (fixture.mockTransport).logs.first; + + expect(firstEnvelopePayloadJson, isNotNull); + expect(firstEnvelopePayloadJson['items'].length, 10); + + final secondEnvelopePayloadJson = (fixture.mockTransport).logs.last; + + expect(secondEnvelopePayloadJson, isNotNull); + expect(secondEnvelopePayloadJson['items'].length, 5); + }); + + test('calling flush directly flushes logs', () async { + final batcher = fixture.getSut(); + + final log = SentryLog( + timestamp: DateTime.now(), + level: SentryLogLevel.info, + body: 'test', + attributes: {}, + ); + + batcher.addLog(log); + batcher.addLog(log); + batcher.flush(); + + // Just wait a little bit, as we call capture without awaiting internally. + await Future.delayed(Duration(milliseconds: 1)); + + expect(fixture.mockTransport.envelopes.length, 1); + final envelopePayloadJson = (fixture.mockTransport).logs.first; + + expect(envelopePayloadJson, isNotNull); + expect(envelopePayloadJson['items'].length, 2); + }); +} + +class Fixture { + final options = SentryOptions(); + final mockTransport = MockTransport(); + + Fixture() { + options.transport = mockTransport; + } + + SentryLogBatcher getSut({Duration? flushTimeout, int? maxBufferSize}) { + return SentryLogBatcher( + options, + flushTimeout: flushTimeout, + maxBufferSize: maxBufferSize, + ); + } +} diff --git a/dart/test/utils/url_details_test.dart b/dart/test/utils/url_details_test.dart index 828335aee2..673d4452da 100644 --- a/dart/test/utils/url_details_test.dart +++ b/dart/test/utils/url_details_test.dart @@ -86,4 +86,8 @@ void main() { }); } -class MockSpan extends Mock implements SentrySpan {} +class MockSpan extends Mock implements SentrySpan { + final SentrySpanContext _context = SentrySpanContext(operation: 'test'); + @override + SentrySpanContext get context => _context; +} diff --git a/flutter/lib/src/widgets_binding_observer.dart b/flutter/lib/src/widgets_binding_observer.dart index 63c63a560e..56a37b23e1 100644 --- a/flutter/lib/src/widgets_binding_observer.dart +++ b/flutter/lib/src/widgets_binding_observer.dart @@ -94,6 +94,7 @@ class SentryWidgetsBindingObserver with WidgetsBindingObserver { if (!_isNavigatorObserverCreated() && !_options.platform.isWeb) { if (state == AppLifecycleState.inactive) { _appInBackgroundStopwatch.start(); + _options.logBatcher.flush(); } else if (_appInBackgroundStopwatch.isRunning && state == AppLifecycleState.resumed) { _appInBackgroundStopwatch.stop(); diff --git a/flutter/test/widgets_binding_observer_test.dart b/flutter/test/widgets_binding_observer_test.dart index 122510f0a3..c470ac2ad8 100644 --- a/flutter/test/widgets_binding_observer_test.dart +++ b/flutter/test/widgets_binding_observer_test.dart @@ -9,6 +9,8 @@ import 'package:sentry/src/platform/mock_platform.dart'; import 'package:sentry_flutter/sentry_flutter.dart'; import 'package:sentry_flutter/src/widgets_binding_observer.dart'; +import 'package:sentry/src/sentry_log_batcher.dart'; + import 'mocks.dart'; import 'mocks.mocks.dart'; @@ -561,5 +563,46 @@ void main() { instance.removeObserver(observer); }); + + testWidgets( + 'calls flush on logs batcher when transitioning to inactive state', + (WidgetTester tester) async { + final hub = MockHub(); + + final mockLogBatcher = MockLogBatcher(); + + final options = defaultTestOptions(); + options.platform = MockPlatform(isWeb: false); + options.bindingUtils = TestBindingWrapper(); + + options.logBatcher = mockLogBatcher; + options.enableLogs = true; + + final observer = SentryWidgetsBindingObserver( + hub: hub, + options: options, + isNavigatorObserverCreated: () => false, + ); + final instance = options.bindingUtils.instance!; + instance.addObserver(observer); + + await sendLifecycle('inactive'); + + expect(mockLogBatcher.flushCalled, true); + + instance.removeObserver(observer); + }); }); } + +class MockLogBatcher implements SentryLogBatcher { + var flushCalled = false; + + @override + void addLog(SentryLog log) {} + + @override + Future flush() async { + flushCalled = true; + } +}