From 2f4f7853b66b7d04ecfc92751dca34716dfa3eab Mon Sep 17 00:00:00 2001 From: Denis Andrasec Date: Wed, 7 May 2025 11:04:37 +0200 Subject: [PATCH 01/28] add log models --- dart/lib/src/protocol.dart | 3 ++ .../src/protocol/sentry_log_attribute.dart | 29 +++++++++++ dart/lib/src/protocol/sentry_log_item.dart | 30 ++++++++++++ dart/lib/src/protocol/sentry_log_level.dart | 11 +++++ .../protocol/sentry_log_attribute_test.dart | 42 ++++++++++++++++ dart/test/protocol/sentry_log_item_test.dart | 49 +++++++++++++++++++ 6 files changed, 164 insertions(+) create mode 100644 dart/lib/src/protocol/sentry_log_attribute.dart create mode 100644 dart/lib/src/protocol/sentry_log_item.dart create mode 100644 dart/lib/src/protocol/sentry_log_level.dart create mode 100644 dart/test/protocol/sentry_log_attribute_test.dart create mode 100644 dart/test/protocol/sentry_log_item_test.dart diff --git a/dart/lib/src/protocol.dart b/dart/lib/src/protocol.dart index 4127259d77..7d05fbdfee 100644 --- a/dart/lib/src/protocol.dart +++ b/dart/lib/src/protocol.dart @@ -41,3 +41,6 @@ export 'protocol/span_status.dart'; export 'sentry_event_like.dart'; export 'protocol/sentry_feature_flag.dart'; export 'protocol/sentry_feature_flags.dart'; +export 'protocol/sentry_log_attribute.dart'; +export 'protocol/sentry_log_item.dart'; +export 'protocol/sentry_log_level.dart'; diff --git a/dart/lib/src/protocol/sentry_log_attribute.dart b/dart/lib/src/protocol/sentry_log_attribute.dart new file mode 100644 index 0000000000..e0afc7ddf3 --- /dev/null +++ b/dart/lib/src/protocol/sentry_log_attribute.dart @@ -0,0 +1,29 @@ +class SentryLogAttribute { + final dynamic value; + final String type; + + const SentryLogAttribute(this.value, this.type); + + factory SentryLogAttribute.string(String value) { + return SentryLogAttribute(value, 'string'); + } + + factory SentryLogAttribute.boolean(bool value) { + return SentryLogAttribute(value, 'boolean'); + } + + factory SentryLogAttribute.integer(int value) { + return SentryLogAttribute(value, 'integer'); + } + + factory SentryLogAttribute.double(double value) { + return SentryLogAttribute(value, 'double'); + } + + Map toJson() { + return { + 'value': value, + 'type': type, + }; + } +} diff --git a/dart/lib/src/protocol/sentry_log_item.dart b/dart/lib/src/protocol/sentry_log_item.dart new file mode 100644 index 0000000000..52dd26905d --- /dev/null +++ b/dart/lib/src/protocol/sentry_log_item.dart @@ -0,0 +1,30 @@ +import 'sentry_id.dart'; +import 'sentry_log_level.dart'; +import 'sentry_log_attribute.dart'; + +class SentryLogItem { + DateTime timestamp; + SentryId traceId; + SentryLogLevel level; + String body; + Map attributes; + + SentryLogItem({ + required this.timestamp, + required this.traceId, + required this.level, + required this.body, + required this.attributes, + }); + + Map toJson() { + return { + 'timestamp': timestamp.toIso8601String(), + 'trace_id': traceId.toString(), + 'level': level.value, + 'body': body, + 'attributes': + attributes.map((key, value) => MapEntry(key, value.toJson())), + }; + } +} diff --git a/dart/lib/src/protocol/sentry_log_level.dart b/dart/lib/src/protocol/sentry_log_level.dart new file mode 100644 index 0000000000..190b13d4f9 --- /dev/null +++ b/dart/lib/src/protocol/sentry_log_level.dart @@ -0,0 +1,11 @@ +enum SentryLogLevel { + trace('trace'), + debug('debug'), + info('info'), + warn('warn'), + error('error'), + fatal('fatal'); + + final String value; + const SentryLogLevel(this.value); +} diff --git a/dart/test/protocol/sentry_log_attribute_test.dart b/dart/test/protocol/sentry_log_attribute_test.dart new file mode 100644 index 0000000000..900507bbb6 --- /dev/null +++ b/dart/test/protocol/sentry_log_attribute_test.dart @@ -0,0 +1,42 @@ +import 'package:test/test.dart'; +import 'package:sentry/sentry.dart'; + +void main() { + test('$SentryLogAttribute string to json', () { + final attribute = SentryLogAttribute.string('test'); + final json = attribute.toJson(); + expect(json, { + 'value': 'test', + 'type': 'string', + }); + }); + + test('$SentryLogAttribute boolean to json', () { + final attribute = SentryLogAttribute.boolean(true); + final json = attribute.toJson(); + expect(json, { + 'value': true, + 'type': 'boolean', + }); + }); + + test('$SentryLogAttribute integer to json', () { + final attribute = SentryLogAttribute.integer(1); + final json = attribute.toJson(); + + expect(json, { + 'value': 1, + 'type': 'integer', + }); + }); + + test('$SentryLogAttribute double to json', () { + final attribute = SentryLogAttribute.double(1.0); + final json = attribute.toJson(); + + expect(json, { + 'value': 1.0, + 'type': 'double', + }); + }); +} diff --git a/dart/test/protocol/sentry_log_item_test.dart b/dart/test/protocol/sentry_log_item_test.dart new file mode 100644 index 0000000000..52febcf2ab --- /dev/null +++ b/dart/test/protocol/sentry_log_item_test.dart @@ -0,0 +1,49 @@ +import 'package:test/test.dart'; +import 'package:sentry/sentry.dart'; + +void main() { + test('$SentryLogItem to json', () { + final timestamp = DateTime.now(); + final traceId = SentryId.newId(); + + final logItem = SentryLogItem( + timestamp: timestamp, + traceId: traceId, + level: SentryLogLevel.info, + body: 'fixture-body', + attributes: { + 'test': SentryLogAttribute.string('fixture-test'), + 'test2': SentryLogAttribute.boolean(true), + 'test3': SentryLogAttribute.integer(9001), + 'test4': SentryLogAttribute.double(9000.1), + }, + ); + + final json = logItem.toJson(); + + expect(json, { + 'timestamp': timestamp.toIso8601String(), + 'trace_id': traceId.toString(), + 'level': 'info', + 'body': 'fixture-body', + 'attributes': { + 'test': { + 'value': 'fixture-test', + 'type': 'string', + }, + 'test2': { + 'value': true, + 'type': 'boolean', + }, + 'test3': { + 'value': 9001, + 'type': 'integer', + }, + 'test4': { + 'value': 9000.1, + 'type': 'double', + }, + }, + }); + }); +} From 20506d4e973a7283c8b9a50c5638f4a0ec29e9a6 Mon Sep 17 00:00:00 2001 From: Denis Andrasec Date: Wed, 7 May 2025 12:57:44 +0200 Subject: [PATCH 02/28] Log in envelope --- dart/lib/src/protocol.dart | 3 +- dart/lib/src/protocol/sentry_log.dart | 13 +++++++ dart/lib/src/sentry_envelope.dart | 16 ++++++++ dart/lib/src/sentry_envelope_item.dart | 13 +++++++ dart/lib/src/sentry_envelope_item_header.dart | 4 ++ dart/lib/src/sentry_item_type.dart | 1 + .../sentry_envelope_item_header_test.dart | 3 +- dart/test/sentry_envelope_item_test.dart | 35 +++++++++++++++++ dart/test/sentry_envelope_test.dart | 38 +++++++++++++++++++ 9 files changed, 124 insertions(+), 2 deletions(-) create mode 100644 dart/lib/src/protocol/sentry_log.dart diff --git a/dart/lib/src/protocol.dart b/dart/lib/src/protocol.dart index 7d05fbdfee..9da55b3975 100644 --- a/dart/lib/src/protocol.dart +++ b/dart/lib/src/protocol.dart @@ -41,6 +41,7 @@ export 'protocol/span_status.dart'; export 'sentry_event_like.dart'; export 'protocol/sentry_feature_flag.dart'; export 'protocol/sentry_feature_flags.dart'; -export 'protocol/sentry_log_attribute.dart'; +export 'protocol/sentry_log.dart'; export 'protocol/sentry_log_item.dart'; export 'protocol/sentry_log_level.dart'; +export 'protocol/sentry_log_attribute.dart'; diff --git a/dart/lib/src/protocol/sentry_log.dart b/dart/lib/src/protocol/sentry_log.dart new file mode 100644 index 0000000000..54f158df79 --- /dev/null +++ b/dart/lib/src/protocol/sentry_log.dart @@ -0,0 +1,13 @@ +import 'sentry_log_item.dart'; + +class SentryLog { + final List items; + + SentryLog({required this.items}); + + toJson() { + return { + 'items': items.map((e) => e.toJson()).toList(), + }; + } +} diff --git a/dart/lib/src/sentry_envelope.dart b/dart/lib/src/sentry_envelope.dart index 10d4b3a085..256224ad64 100644 --- a/dart/lib/src/sentry_envelope.dart +++ b/dart/lib/src/sentry_envelope.dart @@ -9,6 +9,7 @@ import 'sentry_item_type.dart'; import 'sentry_options.dart'; import 'sentry_trace_context_header.dart'; import 'utils.dart'; +import 'protocol/sentry_log.dart'; /// Class representation of `Envelope` file. class SentryEnvelope { @@ -81,6 +82,21 @@ class SentryEnvelope { ); } + factory SentryEnvelope.fromLog( + SentryLog log, + SdkVersion sdkVersion, + ) { + return SentryEnvelope( + SentryEnvelopeHeader( + null, + sdkVersion, + ), + [ + SentryEnvelopeItem.fromLog(log), + ], + ); + } + /// Stream binary data representation of `Envelope` file encoded. Stream> envelopeStream(SentryOptions options) async* { yield utf8JsonEncoder.convert(header.toJson()); diff --git a/dart/lib/src/sentry_envelope_item.dart b/dart/lib/src/sentry_envelope_item.dart index bfe4d818eb..f87b21f83f 100644 --- a/dart/lib/src/sentry_envelope_item.dart +++ b/dart/lib/src/sentry_envelope_item.dart @@ -6,6 +6,7 @@ import 'sentry_attachment/sentry_attachment.dart'; import 'sentry_envelope_item_header.dart'; import 'sentry_item_type.dart'; import 'utils.dart'; +import 'protocol/sentry_log.dart'; /// Item holding header information and JSON encoded data. class SentryEnvelopeItem { @@ -63,6 +64,18 @@ class SentryEnvelopeItem { ); } + factory SentryEnvelopeItem.fromLog(SentryLog log) { + return SentryEnvelopeItem( + SentryEnvelopeItemHeader( + SentryItemType.log, + itemCount: log.items.length, + contentType: 'application/vnd.sentry.items.log+json', + ), + () => utf8JsonEncoder.convert(log.toJson()), + originalObject: log, + ); + } + /// Header with info about type and length of data in bytes. final SentryEnvelopeItemHeader header; diff --git a/dart/lib/src/sentry_envelope_item_header.dart b/dart/lib/src/sentry_envelope_item_header.dart index c1e742cfd1..31ff3d8602 100644 --- a/dart/lib/src/sentry_envelope_item_header.dart +++ b/dart/lib/src/sentry_envelope_item_header.dart @@ -2,6 +2,7 @@ class SentryEnvelopeItemHeader { SentryEnvelopeItemHeader( this.type, { + this.itemCount, this.contentType, this.fileName, this.attachmentType, @@ -10,6 +11,8 @@ class SentryEnvelopeItemHeader { /// Type of encoded data. final String type; + final int? itemCount; + final String? contentType; final String? fileName; @@ -19,6 +22,7 @@ class SentryEnvelopeItemHeader { /// Item header encoded as JSON Future> toJson(int length) async { return { + if (itemCount != null) 'item_count': itemCount, if (contentType != null) 'content_type': contentType, if (fileName != null) 'filename': fileName, if (attachmentType != null) 'attachment_type': attachmentType, diff --git a/dart/lib/src/sentry_item_type.dart b/dart/lib/src/sentry_item_type.dart index d6bf2a31de..c712ad8793 100644 --- a/dart/lib/src/sentry_item_type.dart +++ b/dart/lib/src/sentry_item_type.dart @@ -5,5 +5,6 @@ class SentryItemType { static const String clientReport = 'client_report'; static const String profile = 'profile'; static const String statsd = 'statsd'; + static const String log = 'log'; static const String unknown = '__unknown__'; } diff --git a/dart/test/sentry_envelope_item_header_test.dart b/dart/test/sentry_envelope_item_header_test.dart index 3ffccaf0b4..b948568ed9 100644 --- a/dart/test/sentry_envelope_item_header_test.dart +++ b/dart/test/sentry_envelope_item_header_test.dart @@ -6,8 +6,9 @@ void main() { group('SentryEnvelopeItemHeader', () { test('serialize', () async { final sut = SentryEnvelopeItemHeader(SentryItemType.event, - contentType: 'application/json'); + itemCount: 3, contentType: 'application/json'); final expected = { + 'item_count': 3, 'content_type': 'application/json', 'type': 'event', 'length': 3 diff --git a/dart/test/sentry_envelope_item_test.dart b/dart/test/sentry_envelope_item_test.dart index 9e63104a31..9c83788e26 100644 --- a/dart/test/sentry_envelope_item_test.dart +++ b/dart/test/sentry_envelope_item_test.dart @@ -95,5 +95,40 @@ void main() { expect(sut.header.type, SentryItemType.clientReport); expect(actualData, expectedData); }); + + test('fromLog', () async { + final log = SentryLog(items: [ + SentryLogItem( + timestamp: DateTime.now(), + traceId: SentryId.newId(), + level: SentryLogLevel.info, + body: 'test', + attributes: { + 'test': SentryLogAttribute.string('test'), + }, + ), + SentryLogItem( + timestamp: DateTime.now(), + traceId: SentryId.newId(), + level: SentryLogLevel.info, + body: 'test2', + attributes: { + 'test2': SentryLogAttribute.integer(9001), + }, + ), + ]); + + final sut = SentryEnvelopeItem.fromLog(log); + + final expectedData = utf8.encode(jsonEncode( + log.toJson(), + toEncodable: jsonSerializationFallback, + )); + final actualData = await sut.dataFactory(); + + expect(sut.header.contentType, 'application/vnd.sentry.items.log+json'); + expect(sut.header.type, SentryItemType.log); + expect(actualData, expectedData); + }); }); } diff --git a/dart/test/sentry_envelope_test.dart b/dart/test/sentry_envelope_test.dart index 9f60ab4bc7..3a89bcc4ac 100644 --- a/dart/test/sentry_envelope_test.dart +++ b/dart/test/sentry_envelope_test.dart @@ -135,6 +135,44 @@ void main() { expect(actualItem, expectedItem); }); + test('fromLog', () async { + final log = SentryLog(items: [ + SentryLogItem( + timestamp: DateTime.now(), + traceId: SentryId.newId(), + level: SentryLogLevel.info, + body: 'test', + attributes: { + 'test': SentryLogAttribute.string('test'), + }, + ), + SentryLogItem( + timestamp: DateTime.now(), + traceId: SentryId.newId(), + level: SentryLogLevel.info, + body: 'test2', + attributes: { + 'test2': SentryLogAttribute.integer(9001), + }, + ), + ]); + + final sdkVersion = SdkVersion( + name: 'fixture-name', + version: 'fixture-version', + ); + + final sut = SentryEnvelope.fromLog(log, sdkVersion); + + expect(sut.header.sdkVersion, sdkVersion); + + final expectedItem = SentryEnvelopeItem.fromLog(log); + final expectedItemData = await expectedItem.dataFactory(); + final actualItemData = await sut.items[0].dataFactory(); + + expect(actualItemData, expectedItemData); + }); + test('max attachment size', () async { final attachment = SentryAttachment.fromLoader( loader: () => Uint8List.fromList([1, 2, 3, 4]), From c5f5d6ef5790e7f417b55b5b38fd53f648bc9359 Mon Sep 17 00:00:00 2001 From: Denis Andrasec Date: Wed, 7 May 2025 13:25:38 +0200 Subject: [PATCH 03/28] add type annotation --- dart/lib/src/protocol/sentry_log.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dart/lib/src/protocol/sentry_log.dart b/dart/lib/src/protocol/sentry_log.dart index 54f158df79..4d74606865 100644 --- a/dart/lib/src/protocol/sentry_log.dart +++ b/dart/lib/src/protocol/sentry_log.dart @@ -5,7 +5,7 @@ class SentryLog { SentryLog({required this.items}); - toJson() { + Map toJson() { return { 'items': items.map((e) => e.toJson()).toList(), }; From 1a2c03895e99084d811c6979c64e9c219a073d9e Mon Sep 17 00:00:00 2001 From: Denis Andrasec Date: Wed, 7 May 2025 13:26:37 +0200 Subject: [PATCH 04/28] add cl entry --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1c2ce9e1af..0b745faf84 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +### Features + +- Logs: Models & Envelopes ([#2916](https://github.com/getsentry/sentry-dart/pull/2916)) + ## 9.0.0-beta.2 ### Fixes From 16c4b42ca43443d85fd7af0ecdf1bb0914d74e67 Mon Sep 17 00:00:00 2001 From: Denis Andrasec Date: Wed, 7 May 2025 13:29:58 +0200 Subject: [PATCH 05/28] remove unused imports --- dart/lib/src/sentry_envelope.dart | 1 - dart/lib/src/sentry_envelope_item.dart | 1 - 2 files changed, 2 deletions(-) diff --git a/dart/lib/src/sentry_envelope.dart b/dart/lib/src/sentry_envelope.dart index 256224ad64..c12522735f 100644 --- a/dart/lib/src/sentry_envelope.dart +++ b/dart/lib/src/sentry_envelope.dart @@ -9,7 +9,6 @@ import 'sentry_item_type.dart'; import 'sentry_options.dart'; import 'sentry_trace_context_header.dart'; import 'utils.dart'; -import 'protocol/sentry_log.dart'; /// Class representation of `Envelope` file. class SentryEnvelope { diff --git a/dart/lib/src/sentry_envelope_item.dart b/dart/lib/src/sentry_envelope_item.dart index f87b21f83f..40815326dc 100644 --- a/dart/lib/src/sentry_envelope_item.dart +++ b/dart/lib/src/sentry_envelope_item.dart @@ -6,7 +6,6 @@ import 'sentry_attachment/sentry_attachment.dart'; import 'sentry_envelope_item_header.dart'; import 'sentry_item_type.dart'; import 'utils.dart'; -import 'protocol/sentry_log.dart'; /// Item holding header information and JSON encoded data. class SentryEnvelopeItem { From f4ec20b482b0ca283f094feeb05df598be4879cb Mon Sep 17 00:00:00 2001 From: Denis Andrasec Date: Wed, 7 May 2025 14:12:06 +0200 Subject: [PATCH 06/28] capture log in client --- dart/lib/src/noop_sentry_client.dart | 4 +++ dart/lib/src/sentry_client.dart | 8 ++++++ dart/test/mocks/mock_sentry_client.dart | 14 +++++++++- dart/test/mocks/mock_transport.dart | 12 +++++++-- dart/test/sentry_client_test.dart | 36 +++++++++++++++++++++++++ 5 files changed, 71 insertions(+), 3 deletions(-) diff --git a/dart/lib/src/noop_sentry_client.dart b/dart/lib/src/noop_sentry_client.dart index 39bd728dd9..cd7535944c 100644 --- a/dart/lib/src/noop_sentry_client.dart +++ b/dart/lib/src/noop_sentry_client.dart @@ -66,4 +66,8 @@ class NoOpSentryClient implements SentryClient { Future captureFeedback(SentryFeedback feedback, {Scope? scope, Hint? hint}) async => SentryId.empty(); + + @override + Future captureLogs(List logs) async => + SentryId.empty(); } diff --git a/dart/lib/src/sentry_client.dart b/dart/lib/src/sentry_client.dart index a3198e7524..1fc2a686c5 100644 --- a/dart/lib/src/sentry_client.dart +++ b/dart/lib/src/sentry_client.dart @@ -485,6 +485,14 @@ class SentryClient { ); } + @internal + Future captureLogs(List logs) async { + final log = SentryLog(items: logs); + final envelope = SentryEnvelope.fromLog(log, _options.sdk); + final id = await captureEnvelope(envelope); + return id ?? SentryId.empty(); + } + void close() { _options.httpClient.close(); } diff --git a/dart/test/mocks/mock_sentry_client.dart b/dart/test/mocks/mock_sentry_client.dart index 6138eb5295..ce657b6bb9 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 captureLogsCalls = []; int closeCalls = 0; @override @@ -84,6 +84,12 @@ class MockSentryClient with NoSuchMethodProvider implements SentryClient { return SentryId.newId(); } + @override + Future captureLogs(List logs) async { + captureLogsCalls.add(CaptureLogsCall(logs)); + return SentryId.newId(); + } + @override void close() { closeCalls = closeCalls + 1; @@ -173,3 +179,9 @@ class CaptureTransactionCall { CaptureTransactionCall(this.transaction, this.traceContext, this.hint); } + +class CaptureLogsCall { + final List logs; + + CaptureLogsCall(this.logs); +} 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/sentry_client_test.dart b/dart/test/sentry_client_test.dart index 0d44050d89..95e04290b1 100644 --- a/dart/test/sentry_client_test.dart +++ b/dart/test/sentry_client_test.dart @@ -1702,6 +1702,42 @@ void main() { }); }); + group('SentryClient captures logs', () { + late Fixture fixture; + + setUp(() { + fixture = Fixture(); + }); + + test('should capture logs', () async { + final client = fixture.getSut(); + final logs = [ + SentryLogItem( + timestamp: DateTime.now(), + traceId: SentryId.newId(), + level: SentryLogLevel.info, + body: 'test', + attributes: {}, + ), + ]; + final logItemJson = logs.first.toJson(); + + await client.captureLogs(logs); + + final capturedLogJson = (fixture.transport).logs.first; + + expect(capturedLogJson, isNotNull); + expect(capturedLogJson['items'].first['timestamp'], + logItemJson['timestamp']); + expect( + capturedLogJson['items'].first['trace_id'], logItemJson['trace_id']); + expect(capturedLogJson['items'].first['level'], logItemJson['level']); + expect(capturedLogJson['items'].first['body'], logItemJson['body']); + expect(capturedLogJson['items'].first['attributes'], + logItemJson['attributes']); + }); + }); + group('SentryClient captures envelope', () { late Fixture fixture; final fakeEnvelope = getFakeEnvelope(); From 9bf5514e1e7614ef170adf802c5532b7c5d6eed1 Mon Sep 17 00:00:00 2001 From: Denis Andrasec Date: Wed, 7 May 2025 14:49:04 +0200 Subject: [PATCH 07/28] add severity number --- dart/lib/src/protocol/sentry_log_item.dart | 3 +++ dart/test/protocol/sentry_log_item_test.dart | 2 ++ 2 files changed, 5 insertions(+) diff --git a/dart/lib/src/protocol/sentry_log_item.dart b/dart/lib/src/protocol/sentry_log_item.dart index 52dd26905d..a47198ceea 100644 --- a/dart/lib/src/protocol/sentry_log_item.dart +++ b/dart/lib/src/protocol/sentry_log_item.dart @@ -8,6 +8,7 @@ class SentryLogItem { SentryLogLevel level; String body; Map attributes; + int? severityNumber; SentryLogItem({ required this.timestamp, @@ -15,6 +16,7 @@ class SentryLogItem { required this.level, required this.body, required this.attributes, + this.severityNumber, }); Map toJson() { @@ -25,6 +27,7 @@ class SentryLogItem { 'body': body, 'attributes': attributes.map((key, value) => MapEntry(key, value.toJson())), + if (severityNumber != null) 'severity_number': severityNumber, }; } } diff --git a/dart/test/protocol/sentry_log_item_test.dart b/dart/test/protocol/sentry_log_item_test.dart index 52febcf2ab..08a407646a 100644 --- a/dart/test/protocol/sentry_log_item_test.dart +++ b/dart/test/protocol/sentry_log_item_test.dart @@ -17,6 +17,7 @@ void main() { 'test3': SentryLogAttribute.integer(9001), 'test4': SentryLogAttribute.double(9000.1), }, + severityNumber: 1, ); final json = logItem.toJson(); @@ -44,6 +45,7 @@ void main() { 'type': 'double', }, }, + 'severity_number': 1, }); }); } From 9cb7821af2bd606f497b5d623cde2b7588dadec1 Mon Sep 17 00:00:00 2001 From: Denis Andrasec Date: Wed, 7 May 2025 15:01:34 +0200 Subject: [PATCH 08/28] remove parent SnetryLog, Rename SentryLogItem to SentryLog --- dart/lib/src/protocol.dart | 1 - dart/lib/src/protocol/sentry_log.dart | 28 ++++++++++++++--- dart/lib/src/protocol/sentry_log_item.dart | 33 -------------------- dart/lib/src/sentry_envelope.dart | 6 ++-- dart/lib/src/sentry_envelope_item.dart | 11 ++++--- dart/test/protocol/sentry_log_item_test.dart | 4 +-- dart/test/sentry_envelope_item_test.dart | 14 +++++---- dart/test/sentry_envelope_test.dart | 14 ++++----- 8 files changed, 51 insertions(+), 60 deletions(-) delete mode 100644 dart/lib/src/protocol/sentry_log_item.dart diff --git a/dart/lib/src/protocol.dart b/dart/lib/src/protocol.dart index 9da55b3975..07b7e0b30b 100644 --- a/dart/lib/src/protocol.dart +++ b/dart/lib/src/protocol.dart @@ -42,6 +42,5 @@ export 'sentry_event_like.dart'; export 'protocol/sentry_feature_flag.dart'; export 'protocol/sentry_feature_flags.dart'; export 'protocol/sentry_log.dart'; -export 'protocol/sentry_log_item.dart'; export 'protocol/sentry_log_level.dart'; export 'protocol/sentry_log_attribute.dart'; diff --git a/dart/lib/src/protocol/sentry_log.dart b/dart/lib/src/protocol/sentry_log.dart index 4d74606865..c4742facb0 100644 --- a/dart/lib/src/protocol/sentry_log.dart +++ b/dart/lib/src/protocol/sentry_log.dart @@ -1,13 +1,33 @@ -import 'sentry_log_item.dart'; +import 'sentry_id.dart'; +import 'sentry_log_level.dart'; +import 'sentry_log_attribute.dart'; class SentryLog { - final List items; + DateTime timestamp; + SentryId traceId; + SentryLogLevel level; + String body; + Map attributes; + int? severityNumber; - SentryLog({required this.items}); + SentryLog({ + required this.timestamp, + required this.traceId, + required this.level, + required this.body, + required this.attributes, + this.severityNumber, + }); Map toJson() { return { - 'items': items.map((e) => e.toJson()).toList(), + 'timestamp': timestamp.toIso8601String(), + 'trace_id': traceId.toString(), + 'level': level.value, + 'body': body, + 'attributes': + attributes.map((key, value) => MapEntry(key, value.toJson())), + if (severityNumber != null) 'severity_number': severityNumber, }; } } diff --git a/dart/lib/src/protocol/sentry_log_item.dart b/dart/lib/src/protocol/sentry_log_item.dart deleted file mode 100644 index a47198ceea..0000000000 --- a/dart/lib/src/protocol/sentry_log_item.dart +++ /dev/null @@ -1,33 +0,0 @@ -import 'sentry_id.dart'; -import 'sentry_log_level.dart'; -import 'sentry_log_attribute.dart'; - -class SentryLogItem { - DateTime timestamp; - SentryId traceId; - SentryLogLevel level; - String body; - Map attributes; - int? severityNumber; - - SentryLogItem({ - required this.timestamp, - required this.traceId, - required this.level, - required this.body, - required this.attributes, - this.severityNumber, - }); - - Map toJson() { - return { - 'timestamp': timestamp.toIso8601String(), - 'trace_id': traceId.toString(), - 'level': level.value, - 'body': body, - 'attributes': - attributes.map((key, value) => MapEntry(key, value.toJson())), - if (severityNumber != null) 'severity_number': severityNumber, - }; - } -} diff --git a/dart/lib/src/sentry_envelope.dart b/dart/lib/src/sentry_envelope.dart index c12522735f..83cccb14b7 100644 --- a/dart/lib/src/sentry_envelope.dart +++ b/dart/lib/src/sentry_envelope.dart @@ -81,8 +81,8 @@ class SentryEnvelope { ); } - factory SentryEnvelope.fromLog( - SentryLog log, + factory SentryEnvelope.fromLogs( + List items, SdkVersion sdkVersion, ) { return SentryEnvelope( @@ -91,7 +91,7 @@ class SentryEnvelope { sdkVersion, ), [ - SentryEnvelopeItem.fromLog(log), + SentryEnvelopeItem.fromLogs(items), ], ); } diff --git a/dart/lib/src/sentry_envelope_item.dart b/dart/lib/src/sentry_envelope_item.dart index 40815326dc..06261380db 100644 --- a/dart/lib/src/sentry_envelope_item.dart +++ b/dart/lib/src/sentry_envelope_item.dart @@ -63,15 +63,18 @@ class SentryEnvelopeItem { ); } - factory SentryEnvelopeItem.fromLog(SentryLog log) { + factory SentryEnvelopeItem.fromLogs(List items) { + final payload = { + 'items': items.map((e) => e.toJson()).toList(), + }; return SentryEnvelopeItem( SentryEnvelopeItemHeader( SentryItemType.log, - itemCount: log.items.length, + itemCount: items.length, contentType: 'application/vnd.sentry.items.log+json', ), - () => utf8JsonEncoder.convert(log.toJson()), - originalObject: log, + () => utf8JsonEncoder.convert(payload), + originalObject: payload, ); } diff --git a/dart/test/protocol/sentry_log_item_test.dart b/dart/test/protocol/sentry_log_item_test.dart index 08a407646a..1f82575e70 100644 --- a/dart/test/protocol/sentry_log_item_test.dart +++ b/dart/test/protocol/sentry_log_item_test.dart @@ -2,11 +2,11 @@ import 'package:test/test.dart'; import 'package:sentry/sentry.dart'; void main() { - test('$SentryLogItem to json', () { + test('$SentryLog to json', () { final timestamp = DateTime.now(); final traceId = SentryId.newId(); - final logItem = SentryLogItem( + final logItem = SentryLog( timestamp: timestamp, traceId: traceId, level: SentryLogLevel.info, diff --git a/dart/test/sentry_envelope_item_test.dart b/dart/test/sentry_envelope_item_test.dart index 9c83788e26..47ab01854f 100644 --- a/dart/test/sentry_envelope_item_test.dart +++ b/dart/test/sentry_envelope_item_test.dart @@ -97,8 +97,8 @@ void main() { }); test('fromLog', () async { - final log = SentryLog(items: [ - SentryLogItem( + final logs = [ + SentryLog( timestamp: DateTime.now(), traceId: SentryId.newId(), level: SentryLogLevel.info, @@ -107,7 +107,7 @@ void main() { 'test': SentryLogAttribute.string('test'), }, ), - SentryLogItem( + SentryLog( timestamp: DateTime.now(), traceId: SentryId.newId(), level: SentryLogLevel.info, @@ -116,12 +116,14 @@ void main() { 'test2': SentryLogAttribute.integer(9001), }, ), - ]); + ]; - final sut = SentryEnvelopeItem.fromLog(log); + final sut = SentryEnvelopeItem.fromLogs(logs); final expectedData = utf8.encode(jsonEncode( - log.toJson(), + { + 'items': logs.map((e) => e.toJson()).toList(), + }, toEncodable: jsonSerializationFallback, )); final actualData = await sut.dataFactory(); diff --git a/dart/test/sentry_envelope_test.dart b/dart/test/sentry_envelope_test.dart index 3a89bcc4ac..e367c8a263 100644 --- a/dart/test/sentry_envelope_test.dart +++ b/dart/test/sentry_envelope_test.dart @@ -135,9 +135,9 @@ void main() { expect(actualItem, expectedItem); }); - test('fromLog', () async { - final log = SentryLog(items: [ - SentryLogItem( + test('fromLogs', () async { + final logs = [ + SentryLog( timestamp: DateTime.now(), traceId: SentryId.newId(), level: SentryLogLevel.info, @@ -146,7 +146,7 @@ void main() { 'test': SentryLogAttribute.string('test'), }, ), - SentryLogItem( + SentryLog( timestamp: DateTime.now(), traceId: SentryId.newId(), level: SentryLogLevel.info, @@ -155,18 +155,18 @@ void main() { 'test2': SentryLogAttribute.integer(9001), }, ), - ]); + ]; final sdkVersion = SdkVersion( name: 'fixture-name', version: 'fixture-version', ); - final sut = SentryEnvelope.fromLog(log, sdkVersion); + final sut = SentryEnvelope.fromLogs(logs, sdkVersion); expect(sut.header.sdkVersion, sdkVersion); - final expectedItem = SentryEnvelopeItem.fromLog(log); + final expectedItem = SentryEnvelopeItem.fromLogs(logs); final expectedItemData = await expectedItem.dataFactory(); final actualItemData = await sut.items[0].dataFactory(); From 3dee73ce626a27c842637fc0f40de6a37acd2cc5 Mon Sep 17 00:00:00 2001 From: Denis Andrasec Date: Wed, 7 May 2025 15:12:09 +0200 Subject: [PATCH 09/28] update from model feedback --- dart/lib/src/noop_sentry_client.dart | 3 +-- dart/lib/src/sentry_client.dart | 5 ++--- dart/test/mocks/mock_sentry_client.dart | 4 ++-- dart/test/sentry_client_test.dart | 2 +- 4 files changed, 6 insertions(+), 8 deletions(-) diff --git a/dart/lib/src/noop_sentry_client.dart b/dart/lib/src/noop_sentry_client.dart index cd7535944c..60c0e7c433 100644 --- a/dart/lib/src/noop_sentry_client.dart +++ b/dart/lib/src/noop_sentry_client.dart @@ -68,6 +68,5 @@ class NoOpSentryClient implements SentryClient { SentryId.empty(); @override - Future captureLogs(List logs) async => - SentryId.empty(); + Future captureLogs(List logs) async => SentryId.empty(); } diff --git a/dart/lib/src/sentry_client.dart b/dart/lib/src/sentry_client.dart index 1fc2a686c5..38f8730e35 100644 --- a/dart/lib/src/sentry_client.dart +++ b/dart/lib/src/sentry_client.dart @@ -486,9 +486,8 @@ class SentryClient { } @internal - Future captureLogs(List logs) async { - final log = SentryLog(items: logs); - final envelope = SentryEnvelope.fromLog(log, _options.sdk); + Future captureLogs(List logs) async { + final envelope = SentryEnvelope.fromLogs(logs, _options.sdk); final id = await captureEnvelope(envelope); return id ?? SentryId.empty(); } diff --git a/dart/test/mocks/mock_sentry_client.dart b/dart/test/mocks/mock_sentry_client.dart index ce657b6bb9..ecd2913a29 100644 --- a/dart/test/mocks/mock_sentry_client.dart +++ b/dart/test/mocks/mock_sentry_client.dart @@ -85,7 +85,7 @@ class MockSentryClient with NoSuchMethodProvider implements SentryClient { } @override - Future captureLogs(List logs) async { + Future captureLogs(List logs) async { captureLogsCalls.add(CaptureLogsCall(logs)); return SentryId.newId(); } @@ -181,7 +181,7 @@ class CaptureTransactionCall { } class CaptureLogsCall { - final List logs; + final List logs; CaptureLogsCall(this.logs); } diff --git a/dart/test/sentry_client_test.dart b/dart/test/sentry_client_test.dart index 95e04290b1..5b24b5e9e3 100644 --- a/dart/test/sentry_client_test.dart +++ b/dart/test/sentry_client_test.dart @@ -1712,7 +1712,7 @@ void main() { test('should capture logs', () async { final client = fixture.getSut(); final logs = [ - SentryLogItem( + SentryLog( timestamp: DateTime.now(), traceId: SentryId.newId(), level: SentryLogLevel.info, From 6c74691074b595c4ef75c20e77843a13fa726731 Mon Sep 17 00:00:00 2001 From: Denis Andrasec Date: Wed, 7 May 2025 15:13:53 +0200 Subject: [PATCH 10/28] add comment to attrib --- dart/lib/src/protocol/sentry_log_attribute.dart | 1 + 1 file changed, 1 insertion(+) diff --git a/dart/lib/src/protocol/sentry_log_attribute.dart b/dart/lib/src/protocol/sentry_log_attribute.dart index e0afc7ddf3..13a1f9bcb3 100644 --- a/dart/lib/src/protocol/sentry_log_attribute.dart +++ b/dart/lib/src/protocol/sentry_log_attribute.dart @@ -20,6 +20,7 @@ class SentryLogAttribute { return SentryLogAttribute(value, 'double'); } + // In the future the SDK will also support string[], boolean[], integer[], double[] values. Map toJson() { return { 'value': value, From ea79978dd974fd0441b087e325ce83121f763bde Mon Sep 17 00:00:00 2001 From: Denis Andrasec Date: Wed, 7 May 2025 16:15:18 +0200 Subject: [PATCH 11/28] infer severity number from level --- dart/lib/src/protocol/sentry_log.dart | 2 +- dart/lib/src/protocol/sentry_log_level.dart | 17 +++++++++ ...og_item_test.dart => sentry_log_test.dart} | 35 +++++++++++++++++++ 3 files changed, 53 insertions(+), 1 deletion(-) rename dart/test/protocol/{sentry_log_item_test.dart => sentry_log_test.dart} (55%) diff --git a/dart/lib/src/protocol/sentry_log.dart b/dart/lib/src/protocol/sentry_log.dart index c4742facb0..f0f2c180b4 100644 --- a/dart/lib/src/protocol/sentry_log.dart +++ b/dart/lib/src/protocol/sentry_log.dart @@ -27,7 +27,7 @@ class SentryLog { 'body': body, 'attributes': attributes.map((key, value) => MapEntry(key, value.toJson())), - if (severityNumber != null) 'severity_number': severityNumber, + 'severity_number': severityNumber ?? level.toSeverityNumber(), }; } } diff --git a/dart/lib/src/protocol/sentry_log_level.dart b/dart/lib/src/protocol/sentry_log_level.dart index 190b13d4f9..aac0b386bc 100644 --- a/dart/lib/src/protocol/sentry_log_level.dart +++ b/dart/lib/src/protocol/sentry_log_level.dart @@ -8,4 +8,21 @@ enum SentryLogLevel { final String value; const SentryLogLevel(this.value); + + int toSeverityNumber() { + switch (this) { + case SentryLogLevel.trace: + return 1; + case SentryLogLevel.debug: + return 5; + case SentryLogLevel.info: + return 9; + case SentryLogLevel.warn: + return 13; + case SentryLogLevel.error: + return 17; + case SentryLogLevel.fatal: + return 21; + } + } } diff --git a/dart/test/protocol/sentry_log_item_test.dart b/dart/test/protocol/sentry_log_test.dart similarity index 55% rename from dart/test/protocol/sentry_log_item_test.dart rename to dart/test/protocol/sentry_log_test.dart index 1f82575e70..ab7b538495 100644 --- a/dart/test/protocol/sentry_log_item_test.dart +++ b/dart/test/protocol/sentry_log_test.dart @@ -48,4 +48,39 @@ void main() { 'severity_number': 1, }); }); + + test('$SentryLevel without severity number infers from level in toJson', () { + final logItem = SentryLog( + timestamp: DateTime.now(), + traceId: SentryId.newId(), + level: SentryLogLevel.trace, + body: 'fixture-body', + attributes: { + 'test': SentryLogAttribute.string('fixture-test'), + }, + ); + + var json = logItem.toJson(); + expect(json['severity_number'], 1); + + logItem.level = SentryLogLevel.debug; + json = logItem.toJson(); + expect(json['severity_number'], 5); + + logItem.level = SentryLogLevel.info; + json = logItem.toJson(); + expect(json['severity_number'], 9); + + logItem.level = SentryLogLevel.warn; + json = logItem.toJson(); + expect(json['severity_number'], 13); + + logItem.level = SentryLogLevel.error; + json = logItem.toJson(); + expect(json['severity_number'], 17); + + logItem.level = SentryLogLevel.fatal; + json = logItem.toJson(); + expect(json['severity_number'], 21); + }); } From 39f413075f9596fde77de5e4cac3d4bf470120ac Mon Sep 17 00:00:00 2001 From: Denis Andrasec Date: Wed, 7 May 2025 16:15:32 +0200 Subject: [PATCH 12/28] make default constructor provate for attribute --- dart/lib/src/protocol/sentry_log_attribute.dart | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/dart/lib/src/protocol/sentry_log_attribute.dart b/dart/lib/src/protocol/sentry_log_attribute.dart index 13a1f9bcb3..7f50227d70 100644 --- a/dart/lib/src/protocol/sentry_log_attribute.dart +++ b/dart/lib/src/protocol/sentry_log_attribute.dart @@ -2,22 +2,22 @@ class SentryLogAttribute { final dynamic value; final String type; - const SentryLogAttribute(this.value, this.type); + const SentryLogAttribute._(this.value, this.type); factory SentryLogAttribute.string(String value) { - return SentryLogAttribute(value, 'string'); + return SentryLogAttribute._(value, 'string'); } factory SentryLogAttribute.boolean(bool value) { - return SentryLogAttribute(value, 'boolean'); + return SentryLogAttribute._(value, 'boolean'); } factory SentryLogAttribute.integer(int value) { - return SentryLogAttribute(value, 'integer'); + return SentryLogAttribute._(value, 'integer'); } factory SentryLogAttribute.double(double value) { - return SentryLogAttribute(value, 'double'); + return SentryLogAttribute._(value, 'double'); } // In the future the SDK will also support string[], boolean[], integer[], double[] values. From 9163b432e4ca0a812b6eb069932566657933d776 Mon Sep 17 00:00:00 2001 From: Denis Andrasec Date: Wed, 7 May 2025 16:27:12 +0200 Subject: [PATCH 13/28] add sdk name & version to log attributes --- dart/lib/src/sentry_client.dart | 10 ++++++++ dart/test/sentry_client_test.dart | 38 +++++++++++++++++++++++++++++-- 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/dart/lib/src/sentry_client.dart b/dart/lib/src/sentry_client.dart index 38f8730e35..c3fef4b58c 100644 --- a/dart/lib/src/sentry_client.dart +++ b/dart/lib/src/sentry_client.dart @@ -487,6 +487,16 @@ class SentryClient { @internal Future captureLogs(List logs) async { + + for (final log in logs) { + log.attributes['sentry.sdk.name'] = SentryLogAttribute.string( + _options.sdk.name, + ); + log.attributes['sentry.sdk.version'] = SentryLogAttribute.string( + _options.sdk.version, + ); + } + final envelope = SentryEnvelope.fromLogs(logs, _options.sdk); final id = await captureEnvelope(envelope); return id ?? SentryId.empty(); diff --git a/dart/test/sentry_client_test.dart b/dart/test/sentry_client_test.dart index 5b24b5e9e3..0874f73973 100644 --- a/dart/test/sentry_client_test.dart +++ b/dart/test/sentry_client_test.dart @@ -1702,14 +1702,14 @@ void main() { }); }); - group('SentryClient captures logs', () { + group('SentryClient captureLogs', () { late Fixture fixture; setUp(() { fixture = Fixture(); }); - test('should capture logs', () async { + test('should capture logs as envelope', () async { final client = fixture.getSut(); final logs = [ SentryLog( @@ -1736,6 +1736,40 @@ void main() { expect(capturedLogJson['items'].first['attributes'], logItemJson['attributes']); }); + + test('should apply sdk name and version as attrubute', () async { + final client = fixture.getSut(); + final logs = [ + SentryLog( + timestamp: DateTime.now(), + traceId: SentryId.newId(), + level: SentryLogLevel.info, + body: 'test', + attributes: {}, + ), + ]; + + await client.captureLogs(logs); + final capturedLogJson = (fixture.transport).logs.first; + final attributesJson = capturedLogJson['items'].first['attributes']; + + expect( + attributesJson['sentry.sdk.name']['value'], + fixture.options.sdk.name, + ); + expect( + attributesJson['sentry.sdk.name']['type'], + 'string', + ); + expect( + attributesJson['sentry.sdk.version']['value'], + fixture.options.sdk.version, + ); + expect( + attributesJson['sentry.sdk.version']['type'], + 'string', + ); + }); }); group('SentryClient captures envelope', () { From 58bbb6ac9dee09dbd04a178469297858665fa9a0 Mon Sep 17 00:00:00 2001 From: Denis Andrasec Date: Wed, 7 May 2025 17:03:46 +0200 Subject: [PATCH 14/28] set sentry attributes to log --- dart/lib/src/noop_sentry_client.dart | 3 +- dart/lib/src/sentry_client.dart | 25 +++++++- dart/test/mocks/mock_sentry_client.dart | 7 ++- dart/test/sentry_client_test.dart | 76 +++++++++++++++++-------- dart/test/utils/url_details_test.dart | 6 +- 5 files changed, 87 insertions(+), 30 deletions(-) diff --git a/dart/lib/src/noop_sentry_client.dart b/dart/lib/src/noop_sentry_client.dart index 60c0e7c433..23f3a2f62f 100644 --- a/dart/lib/src/noop_sentry_client.dart +++ b/dart/lib/src/noop_sentry_client.dart @@ -68,5 +68,6 @@ class NoOpSentryClient implements SentryClient { SentryId.empty(); @override - Future captureLogs(List logs) async => SentryId.empty(); + Future captureLogs(List logs, {Scope? scope}) async => + SentryId.empty(); } diff --git a/dart/lib/src/sentry_client.dart b/dart/lib/src/sentry_client.dart index c3fef4b58c..f801937b87 100644 --- a/dart/lib/src/sentry_client.dart +++ b/dart/lib/src/sentry_client.dart @@ -486,8 +486,10 @@ class SentryClient { } @internal - Future captureLogs(List logs) async { - + Future captureLogs( + List logs, { + Scope? scope, + }) async { for (final log in logs) { log.attributes['sentry.sdk.name'] = SentryLogAttribute.string( _options.sdk.name, @@ -495,6 +497,25 @@ class SentryClient { 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 span = scope?.span; + if (span != null) { + log.attributes['sentry.trace.parent_span_id'] = + SentryLogAttribute.string( + span.context.spanId.toString(), + ); + } } final envelope = SentryEnvelope.fromLogs(logs, _options.sdk); diff --git a/dart/test/mocks/mock_sentry_client.dart b/dart/test/mocks/mock_sentry_client.dart index ecd2913a29..de408d5ac0 100644 --- a/dart/test/mocks/mock_sentry_client.dart +++ b/dart/test/mocks/mock_sentry_client.dart @@ -85,8 +85,8 @@ class MockSentryClient with NoSuchMethodProvider implements SentryClient { } @override - Future captureLogs(List logs) async { - captureLogsCalls.add(CaptureLogsCall(logs)); + Future captureLogs(List logs, {Scope? scope}) async { + captureLogsCalls.add(CaptureLogsCall(logs, scope)); return SentryId.newId(); } @@ -182,6 +182,7 @@ class CaptureTransactionCall { class CaptureLogsCall { final List logs; + final Scope? scope; - CaptureLogsCall(this.logs); + CaptureLogsCall(this.logs, this.scope); } diff --git a/dart/test/sentry_client_test.dart b/dart/test/sentry_client_test.dart index 0874f73973..c61b1ecb09 100644 --- a/dart/test/sentry_client_test.dart +++ b/dart/test/sentry_client_test.dart @@ -23,6 +23,8 @@ 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 'package:mockito/mockito.dart'; void main() { group('SentryClient captures message', () { @@ -1709,17 +1711,21 @@ void main() { fixture = Fixture(); }); + SentryLog givenLog() { + return SentryLog( + timestamp: DateTime.now(), + traceId: SentryId.newId(), + level: SentryLogLevel.info, + body: 'test', + attributes: { + 'attribute': SentryLogAttribute.string('value'), + }, + ); + } + test('should capture logs as envelope', () async { final client = fixture.getSut(); - final logs = [ - SentryLog( - timestamp: DateTime.now(), - traceId: SentryId.newId(), - level: SentryLogLevel.info, - body: 'test', - attributes: {}, - ), - ]; + final logs = [givenLog()]; final logItemJson = logs.first.toJson(); await client.captureLogs(logs); @@ -1733,26 +1739,26 @@ void main() { capturedLogJson['items'].first['trace_id'], logItemJson['trace_id']); expect(capturedLogJson['items'].first['level'], logItemJson['level']); expect(capturedLogJson['items'].first['body'], logItemJson['body']); - expect(capturedLogJson['items'].first['attributes'], - logItemJson['attributes']); + expect(capturedLogJson['items'].first['attributes']['attribute']['value'], + 'value'); }); - test('should apply sdk name and version as attrubute', () async { + test('should add additional info to attributes', () async { + fixture.options.environment = 'test-environment'; + fixture.options.release = 'test-release'; + + final logs = [givenLog()]; + + final scope = Scope(fixture.options); + final span = MockSpan(); + scope.span = span; + final client = fixture.getSut(); - final logs = [ - SentryLog( - timestamp: DateTime.now(), - traceId: SentryId.newId(), - level: SentryLogLevel.info, - body: 'test', - attributes: {}, - ), - ]; + await client.captureLogs(logs, scope: scope); - await client.captureLogs(logs); final capturedLogJson = (fixture.transport).logs.first; final attributesJson = capturedLogJson['items'].first['attributes']; - + expect( attributesJson['sentry.sdk.name']['value'], fixture.options.sdk.name, @@ -1769,6 +1775,30 @@ void main() { attributesJson['sentry.sdk.version']['type'], 'string', ); + expect( + attributesJson['sentry.environment']['value'], + fixture.options.environment, + ); + expect( + attributesJson['sentry.environment']['type'], + 'string', + ); + expect( + attributesJson['sentry.release']['value'], + fixture.options.release, + ); + expect( + attributesJson['sentry.release']['type'], + 'string', + ); + expect( + attributesJson['sentry.trace.parent_span_id']['value'], + span.context.spanId.toString(), + ); + expect( + attributesJson['sentry.trace.parent_span_id']['type'], + 'string', + ); }); }); 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; +} From 5050c90d05aba936dd24504512047a2701a3736e Mon Sep 17 00:00:00 2001 From: Denis Andrasec Date: Wed, 7 May 2025 17:32:44 +0200 Subject: [PATCH 15/28] change client api to take single log --- dart/lib/src/noop_sentry_client.dart | 2 +- .../src/protocol/sentry_log_attribute.dart | 6 +- dart/lib/src/sentry_client.dart | 55 +++++++++---------- dart/test/mocks/mock_sentry_client.dart | 13 ++--- .../protocol/sentry_log_attribute_test.dart | 4 +- dart/test/protocol/sentry_log_test.dart | 4 +- dart/test/sentry_client_test.dart | 22 ++++---- dart/test/sentry_envelope_item_test.dart | 2 +- dart/test/sentry_envelope_test.dart | 2 +- 9 files changed, 52 insertions(+), 58 deletions(-) diff --git a/dart/lib/src/noop_sentry_client.dart b/dart/lib/src/noop_sentry_client.dart index 23f3a2f62f..21129074df 100644 --- a/dart/lib/src/noop_sentry_client.dart +++ b/dart/lib/src/noop_sentry_client.dart @@ -68,6 +68,6 @@ class NoOpSentryClient implements SentryClient { SentryId.empty(); @override - Future captureLogs(List logs, {Scope? scope}) async => + Future captureLog(SentryLog log, {Scope? scope}) async => SentryId.empty(); } diff --git a/dart/lib/src/protocol/sentry_log_attribute.dart b/dart/lib/src/protocol/sentry_log_attribute.dart index 7f50227d70..09be543b3f 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 string[], bool[], int[], double[] values. Map toJson() { return { 'value': value, diff --git a/dart/lib/src/sentry_client.dart b/dart/lib/src/sentry_client.dart index f801937b87..c19d97621b 100644 --- a/dart/lib/src/sentry_client.dart +++ b/dart/lib/src/sentry_client.dart @@ -486,41 +486,38 @@ class SentryClient { } @internal - Future captureLogs( - List logs, { + Future captureLog( + SentryLog log, { Scope? scope, }) async { - for (final log in logs) { - log.attributes['sentry.sdk.name'] = SentryLogAttribute.string( - _options.sdk.name, + 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, ); - log.attributes['sentry.sdk.version'] = SentryLogAttribute.string( - _options.sdk.version, + } + final release = _options.release; + if (release != null) { + log.attributes['sentry.release'] = SentryLogAttribute.string( + release, + ); + } + final span = scope?.span; + if (span != null) { + log.attributes['sentry.trace.parent_span_id'] = SentryLogAttribute.string( + span.context.spanId.toString(), ); - 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 span = scope?.span; - if (span != null) { - log.attributes['sentry.trace.parent_span_id'] = - SentryLogAttribute.string( - span.context.spanId.toString(), - ); - } } - final envelope = SentryEnvelope.fromLogs(logs, _options.sdk); - final id = await captureEnvelope(envelope); - return id ?? SentryId.empty(); + // TODO: Batch in separate PR, so we can send multiple logs at once. + final envelope = SentryEnvelope.fromLogs([log], _options.sdk); + await captureEnvelope(envelope); } void close() { diff --git a/dart/test/mocks/mock_sentry_client.dart b/dart/test/mocks/mock_sentry_client.dart index de408d5ac0..966b7a7590 100644 --- a/dart/test/mocks/mock_sentry_client.dart +++ b/dart/test/mocks/mock_sentry_client.dart @@ -9,7 +9,7 @@ class MockSentryClient with NoSuchMethodProvider implements SentryClient { List captureEnvelopeCalls = []; List captureTransactionCalls = []; List captureFeedbackCalls = []; - List captureLogsCalls = []; + List captureLogCalls = []; int closeCalls = 0; @override @@ -85,9 +85,8 @@ class MockSentryClient with NoSuchMethodProvider implements SentryClient { } @override - Future captureLogs(List logs, {Scope? scope}) async { - captureLogsCalls.add(CaptureLogsCall(logs, scope)); - return SentryId.newId(); + Future captureLog(SentryLog log, {Scope? scope}) async { + captureLogCalls.add(CaptureLogCall(log, scope)); } @override @@ -180,9 +179,9 @@ class CaptureTransactionCall { CaptureTransactionCall(this.transaction, this.traceContext, this.hint); } -class CaptureLogsCall { - final List logs; +class CaptureLogCall { + final SentryLog log; final Scope? scope; - CaptureLogsCall(this.logs, this.scope); + CaptureLogCall(this.log, this.scope); } diff --git a/dart/test/protocol/sentry_log_attribute_test.dart b/dart/test/protocol/sentry_log_attribute_test.dart index 900507bbb6..301c292d84 100644 --- a/dart/test/protocol/sentry_log_attribute_test.dart +++ b/dart/test/protocol/sentry_log_attribute_test.dart @@ -12,7 +12,7 @@ void main() { }); test('$SentryLogAttribute boolean to json', () { - final attribute = SentryLogAttribute.boolean(true); + final attribute = SentryLogAttribute.bool(true); final json = attribute.toJson(); expect(json, { 'value': true, @@ -21,7 +21,7 @@ void main() { }); test('$SentryLogAttribute integer to json', () { - final attribute = SentryLogAttribute.integer(1); + 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 c61b1ecb09..dade62a5a5 100644 --- a/dart/test/sentry_client_test.dart +++ b/dart/test/sentry_client_test.dart @@ -1704,7 +1704,7 @@ void main() { }); }); - group('SentryClient captureLogs', () { + group('SentryClient captureLog', () { late Fixture fixture; setUp(() { @@ -1725,20 +1725,18 @@ void main() { test('should capture logs as envelope', () async { final client = fixture.getSut(); - final logs = [givenLog()]; - final logItemJson = logs.first.toJson(); + final log = givenLog(); + final logJson = log.toJson(); - await client.captureLogs(logs); + await client.captureLog(log); final capturedLogJson = (fixture.transport).logs.first; expect(capturedLogJson, isNotNull); - expect(capturedLogJson['items'].first['timestamp'], - logItemJson['timestamp']); - expect( - capturedLogJson['items'].first['trace_id'], logItemJson['trace_id']); - expect(capturedLogJson['items'].first['level'], logItemJson['level']); - expect(capturedLogJson['items'].first['body'], logItemJson['body']); + expect(capturedLogJson['items'].first['timestamp'], logJson['timestamp']); + expect(capturedLogJson['items'].first['trace_id'], logJson['trace_id']); + expect(capturedLogJson['items'].first['level'], logJson['level']); + expect(capturedLogJson['items'].first['body'], logJson['body']); expect(capturedLogJson['items'].first['attributes']['attribute']['value'], 'value'); }); @@ -1747,14 +1745,14 @@ void main() { fixture.options.environment = 'test-environment'; fixture.options.release = 'test-release'; - final logs = [givenLog()]; + final log = givenLog(); final scope = Scope(fixture.options); final span = MockSpan(); scope.span = span; final client = fixture.getSut(); - await client.captureLogs(logs, scope: scope); + await client.captureLog(log, scope: scope); final capturedLogJson = (fixture.transport).logs.first; final attributesJson = capturedLogJson['items'].first['attributes']; 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), }, ), ]; From a58b17dd5ce2b9e48cd464bc49e418eae2e4ec20 Mon Sep 17 00:00:00 2001 From: Denis Andrasec Date: Wed, 7 May 2025 17:34:31 +0200 Subject: [PATCH 16/28] update comment with correct dart types --- dart/lib/src/protocol/sentry_log_attribute.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dart/lib/src/protocol/sentry_log_attribute.dart b/dart/lib/src/protocol/sentry_log_attribute.dart index 09be543b3f..63ac85eb87 100644 --- a/dart/lib/src/protocol/sentry_log_attribute.dart +++ b/dart/lib/src/protocol/sentry_log_attribute.dart @@ -20,7 +20,7 @@ class SentryLogAttribute { return SentryLogAttribute._(value, 'double'); } - // In the future the SDK will also support string[], bool[], int[], double[] values. + // In the future the SDK will also support List, List, List, List values. Map toJson() { return { 'value': value, From 874a4ce7651ff1df3c0f7c0f181959185b6f95f8 Mon Sep 17 00:00:00 2001 From: Denis Andrasec Date: Wed, 7 May 2025 17:35:58 +0200 Subject: [PATCH 17/28] add todo for android --- dart/lib/src/sentry_client.dart | 2 ++ 1 file changed, 2 insertions(+) diff --git a/dart/lib/src/sentry_client.dart b/dart/lib/src/sentry_client.dart index c19d97621b..04bed59621 100644 --- a/dart/lib/src/sentry_client.dart +++ b/dart/lib/src/sentry_client.dart @@ -517,6 +517,8 @@ class SentryClient { // TODO: Batch in separate PR, so we can send multiple logs at once. final envelope = SentryEnvelope.fromLogs([log], _options.sdk); + + // TODO: Make sure the Android SDK understands the log envelope type. await captureEnvelope(envelope); } From 8ed3a83f64ed3562a1830f7a6634d672439b3635 Mon Sep 17 00:00:00 2001 From: Denis Andrasec Date: Thu, 8 May 2025 10:11:22 +0200 Subject: [PATCH 18/28] set trace id --- dart/lib/src/noop_sentry_client.dart | 3 +-- dart/lib/src/protocol/sentry_log.dart | 4 ++-- dart/lib/src/sentry_client.dart | 4 ++++ dart/test/sentry_client_test.dart | 30 +++++++++++++++++++++++++++ 4 files changed, 37 insertions(+), 4 deletions(-) diff --git a/dart/lib/src/noop_sentry_client.dart b/dart/lib/src/noop_sentry_client.dart index 21129074df..10b8413854 100644 --- a/dart/lib/src/noop_sentry_client.dart +++ b/dart/lib/src/noop_sentry_client.dart @@ -68,6 +68,5 @@ class NoOpSentryClient implements SentryClient { SentryId.empty(); @override - Future captureLog(SentryLog log, {Scope? scope}) async => - SentryId.empty(); + 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..ac93e44a0f 100644 --- a/dart/lib/src/protocol/sentry_log.dart +++ b/dart/lib/src/protocol/sentry_log.dart @@ -12,12 +12,12 @@ class SentryLog { 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/sentry_client.dart b/dart/lib/src/sentry_client.dart index 04bed59621..1bd166c361 100644 --- a/dart/lib/src/sentry_client.dart +++ b/dart/lib/src/sentry_client.dart @@ -509,10 +509,14 @@ class SentryClient { ); } final span = scope?.span; + final propagationContext = scope?.propagationContext; if (span != null) { log.attributes['sentry.trace.parent_span_id'] = SentryLogAttribute.string( span.context.spanId.toString(), ); + log.traceId = span.context.traceId; + } else if (propagationContext != null) { + log.traceId = propagationContext.traceId; } // TODO: Batch in separate PR, so we can send multiple logs at once. diff --git a/dart/test/sentry_client_test.dart b/dart/test/sentry_client_test.dart index dade62a5a5..45f293371f 100644 --- a/dart/test/sentry_client_test.dart +++ b/dart/test/sentry_client_test.dart @@ -1798,6 +1798,36 @@ void main() { 'string', ); }); + + test('should set trace id if there is a scope span', () async { + final client = fixture.getSut(); + final log = givenLog(); + final scope = Scope(fixture.options); + final span = MockSpan(); + scope.span = span; + + await client.captureLog(log, scope: scope); + + final envelopePayloadJson = (fixture.transport).logs.first; + final logJson = envelopePayloadJson['items'].first; + + expect(logJson['trace_id'], span.context.traceId.toString()); + }); + + test( + 'should set trace id from propagation context if there is no scope span', + () async { + final client = fixture.getSut(); + final log = givenLog(); + final scope = Scope(fixture.options); + + await client.captureLog(log, scope: scope); + + final envelopePayloadJson = (fixture.transport).logs.first; + final logJson = envelopePayloadJson['items'].first; + + expect(logJson['trace_id'], scope.propagationContext.traceId.toString()); + }); }); group('SentryClient captures envelope', () { From de3a856fcbf4c023c6c80760190abb4a63a80bad Mon Sep 17 00:00:00 2001 From: Denis Andrasec Date: Thu, 8 May 2025 10:15:41 +0200 Subject: [PATCH 19/28] disable logs per default --- dart/lib/src/sentry_client.dart | 6 +++++- dart/lib/src/sentry_options.dart | 4 ++++ dart/test/sentry_client_test.dart | 16 ++++++++++++++++ 3 files changed, 25 insertions(+), 1 deletion(-) diff --git a/dart/lib/src/sentry_client.dart b/dart/lib/src/sentry_client.dart index 1bd166c361..e725c58ddd 100644 --- a/dart/lib/src/sentry_client.dart +++ b/dart/lib/src/sentry_client.dart @@ -490,6 +490,10 @@ class SentryClient { SentryLog log, { Scope? scope, }) async { + if (!_options.enableLogs) { + return; + } + log.attributes['sentry.sdk.name'] = SentryLogAttribute.string( _options.sdk.name, ); @@ -518,7 +522,7 @@ class SentryClient { } else if (propagationContext != null) { log.traceId = propagationContext.traceId; } - + // TODO: Batch in separate PR, so we can send multiple logs at once. final envelope = SentryEnvelope.fromLogs([log], _options.sdk); diff --git a/dart/lib/src/sentry_options.dart b/dart/lib/src/sentry_options.dart index 90abb7947d..2621580423 100644 --- a/dart/lib/src/sentry_options.dart +++ b/dart/lib/src/sentry_options.dart @@ -15,6 +15,7 @@ import 'sentry_exception_factory.dart'; import 'sentry_stack_trace_factory.dart'; import 'transport/noop_transport.dart'; import 'version.dart'; +import 'package:meta/meta.dart' as meta; // 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 @@ -531,6 +532,9 @@ class SentryOptions { /// This is opt-in, as it can lead to existing exception beeing grouped as new ones. bool groupExceptions = false; + @meta.experimental + bool enableLogs = false; + SentryOptions({String? dsn, Platform? platform, RuntimeChecker? checker}) { this.dsn = dsn; if (platform != null) { diff --git a/dart/test/sentry_client_test.dart b/dart/test/sentry_client_test.dart index 45f293371f..e2eb0cd926 100644 --- a/dart/test/sentry_client_test.dart +++ b/dart/test/sentry_client_test.dart @@ -1723,7 +1723,18 @@ void main() { ); } + test('disabled by default', () async { + final client = fixture.getSut(); + final log = givenLog(); + + await client.captureLog(log); + + expect((fixture.transport).logs, isEmpty); + }); + test('should capture logs as envelope', () async { + fixture.options.enableLogs = true; + final client = fixture.getSut(); final log = givenLog(); final logJson = log.toJson(); @@ -1742,6 +1753,7 @@ void main() { }); test('should add additional info to attributes', () async { + fixture.options.enableLogs = true; fixture.options.environment = 'test-environment'; fixture.options.release = 'test-release'; @@ -1800,6 +1812,8 @@ void main() { }); test('should set trace id if there is a scope span', () async { + fixture.options.enableLogs = true; + final client = fixture.getSut(); final log = givenLog(); final scope = Scope(fixture.options); @@ -1817,6 +1831,8 @@ void main() { test( 'should set trace id from propagation context if there is no scope span', () async { + fixture.options.enableLogs = true; + final client = fixture.getSut(); final log = givenLog(); final scope = Scope(fixture.options); From 3ad3ff064f0d9046f4669819e8b2e035b838dff5 Mon Sep 17 00:00:00 2001 From: Denis Andrasec Date: Thu, 8 May 2025 10:26:06 +0200 Subject: [PATCH 20/28] add BeforeSendLogCallback --- dart/lib/src/sentry_client.dart | 28 +++++++++++++++-- dart/lib/src/sentry_options.dart | 6 ++++ dart/test/sentry_client_test.dart | 50 ++++++++++++++++++++++++++++++- 3 files changed, 81 insertions(+), 3 deletions(-) diff --git a/dart/lib/src/sentry_client.dart b/dart/lib/src/sentry_client.dart index e725c58ddd..149d16659a 100644 --- a/dart/lib/src/sentry_client.dart +++ b/dart/lib/src/sentry_client.dart @@ -522,9 +522,33 @@ class SentryClient { } else if (propagationContext != null) { log.traceId = propagationContext.traceId; } - + + final beforeSendLog = _options.beforeSendLog; + SentryLog? processedLog = log; + if (beforeSendLog != null) { + try { + processedLog = beforeSendLog(log); + if (processedLog == null) { + return; + } + } catch (exception, stackTrace) { + _options.logger( + SentryLevel.error, + 'The beforeSendLog callback threw an exception', + exception: exception, + stackTrace: stackTrace, + ); + if (_options.automatedTestMode) { + rethrow; + } + } + } + // TODO: Batch in separate PR, so we can send multiple logs at once. - final envelope = SentryEnvelope.fromLogs([log], _options.sdk); + final envelope = SentryEnvelope.fromLogs( + [processedLog ?? log], + _options.sdk, + ); // TODO: Make sure the Android SDK understands the log envelope type. await captureEnvelope(envelope); diff --git a/dart/lib/src/sentry_options.dart b/dart/lib/src/sentry_options.dart index 2621580423..a421b758ff 100644 --- a/dart/lib/src/sentry_options.dart +++ b/dart/lib/src/sentry_options.dart @@ -199,6 +199,9 @@ class SentryOptions { /// Can return true to emit the metric, or false to drop it. BeforeMetricCallback? beforeMetricCallback; + @meta.experimental + 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; @@ -664,6 +667,9 @@ typedef BeforeMetricCallback = bool Function( Map? tags, }); +@meta.experimental +typedef BeforeSendLogCallback = SentryLog? Function(SentryLog log); + /// Used to provide timestamp for logging. typedef ClockProvider = DateTime Function(); diff --git a/dart/test/sentry_client_test.dart b/dart/test/sentry_client_test.dart index e2eb0cd926..3f86e12bdf 100644 --- a/dart/test/sentry_client_test.dart +++ b/dart/test/sentry_client_test.dart @@ -24,7 +24,6 @@ import 'mocks/mock_hub.dart'; import 'mocks/mock_transport.dart'; import 'test_utils.dart'; import 'utils/url_details_test.dart'; -import 'package:mockito/mockito.dart'; void main() { group('SentryClient captures message', () { @@ -1844,6 +1843,55 @@ void main() { expect(logJson['trace_id'], scope.propagationContext.traceId.toString()); }); + + test('$BeforeSendLogCallback returning null drops the log', () async { + fixture.options.enableLogs = true; + fixture.options.beforeSendLog = (log) => null; + + final client = fixture.getSut(); + final log = givenLog(); + + await client.captureLog(log); + + expect((fixture.transport).logs, isEmpty); + }); + + 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(); + final log = givenLog(); + + await client.captureLog(log); + + final capturedLogJson = (fixture.transport).logs.first; + final logJson = capturedLogJson['items'].first; + + expect(logJson['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(); + final log = givenLog(); + + await client.captureLog(log); + + final capturedLogJson = (fixture.transport).logs.first; + final logJson = capturedLogJson['items'].first; + + expect(logJson['body'], 'test'); + }); }); group('SentryClient captures envelope', () { From 345d4f66c2f2d347b8c6af9d80caa122410036aa Mon Sep 17 00:00:00 2001 From: Denis Andrasec Date: Thu, 8 May 2025 10:29:39 +0200 Subject: [PATCH 21/28] add cl entry --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b745faf84..5a1dd792da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### 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)) ## 9.0.0-beta.2 From 74e69d91f22783e9b344c8f3213988131631c63a Mon Sep 17 00:00:00 2001 From: Denis Andrasec Date: Thu, 8 May 2025 10:34:35 +0200 Subject: [PATCH 22/28] update test text to match api --- dart/test/protocol/sentry_log_attribute_test.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/dart/test/protocol/sentry_log_attribute_test.dart b/dart/test/protocol/sentry_log_attribute_test.dart index 301c292d84..2c0fb7ce31 100644 --- a/dart/test/protocol/sentry_log_attribute_test.dart +++ b/dart/test/protocol/sentry_log_attribute_test.dart @@ -11,7 +11,7 @@ void main() { }); }); - test('$SentryLogAttribute boolean to json', () { + test('$SentryLogAttribute bool to json', () { final attribute = SentryLogAttribute.bool(true); final json = attribute.toJson(); expect(json, { @@ -20,7 +20,7 @@ void main() { }); }); - test('$SentryLogAttribute integer to json', () { + test('$SentryLogAttribute int to json', () { final attribute = SentryLogAttribute.int(1); final json = attribute.toJson(); From 87d52e63bdb1ecaa34c55109bbc3564fe07b10f4 Mon Sep 17 00:00:00 2001 From: Denis Andrasec Date: Thu, 8 May 2025 10:36:46 +0200 Subject: [PATCH 23/28] rename var --- dart/test/sentry_client_test.dart | 32 +++++++++++++++++-------------- 1 file changed, 18 insertions(+), 14 deletions(-) diff --git a/dart/test/sentry_client_test.dart b/dart/test/sentry_client_test.dart index 3f86e12bdf..11af60b0c1 100644 --- a/dart/test/sentry_client_test.dart +++ b/dart/test/sentry_client_test.dart @@ -1740,14 +1740,18 @@ void main() { await client.captureLog(log); - final capturedLogJson = (fixture.transport).logs.first; - - expect(capturedLogJson, isNotNull); - expect(capturedLogJson['items'].first['timestamp'], logJson['timestamp']); - expect(capturedLogJson['items'].first['trace_id'], logJson['trace_id']); - expect(capturedLogJson['items'].first['level'], logJson['level']); - expect(capturedLogJson['items'].first['body'], logJson['body']); - expect(capturedLogJson['items'].first['attributes']['attribute']['value'], + final envelopePayloadJson = (fixture.transport).logs.first; + + expect(envelopePayloadJson, isNotNull); + expect(envelopePayloadJson['items'].first['timestamp'], + logJson['timestamp']); + expect( + envelopePayloadJson['items'].first['trace_id'], logJson['trace_id']); + expect(envelopePayloadJson['items'].first['level'], logJson['level']); + expect(envelopePayloadJson['items'].first['body'], logJson['body']); + expect( + envelopePayloadJson['items'].first['attributes']['attribute'] + ['value'], 'value'); }); @@ -1765,8 +1769,8 @@ void main() { final client = fixture.getSut(); await client.captureLog(log, scope: scope); - final capturedLogJson = (fixture.transport).logs.first; - final attributesJson = capturedLogJson['items'].first['attributes']; + final envelopePayloadJson = (fixture.transport).logs.first; + final attributesJson = envelopePayloadJson['items'].first['attributes']; expect( attributesJson['sentry.sdk.name']['value'], @@ -1868,8 +1872,8 @@ void main() { await client.captureLog(log); - final capturedLogJson = (fixture.transport).logs.first; - final logJson = capturedLogJson['items'].first; + final envelopePayloadJson = (fixture.transport).logs.first; + final logJson = envelopePayloadJson['items'].first; expect(logJson['body'], 'modified'); }); @@ -1887,8 +1891,8 @@ void main() { await client.captureLog(log); - final capturedLogJson = (fixture.transport).logs.first; - final logJson = capturedLogJson['items'].first; + final envelopePayloadJson = (fixture.transport).logs.first; + final logJson = envelopePayloadJson['items'].first; expect(logJson['body'], 'test'); }); From b58cd376ce601223543f2e7d536bfb6dae8c830a Mon Sep 17 00:00:00 2001 From: Denis Andrasec Date: Mon, 12 May 2025 11:51:00 +0200 Subject: [PATCH 24/28] always use propagation context for trace id --- dart/lib/src/sentry_client.dart | 9 +++++---- dart/test/sentry_client_test.dart | 21 +-------------------- 2 files changed, 6 insertions(+), 24 deletions(-) diff --git a/dart/lib/src/sentry_client.dart b/dart/lib/src/sentry_client.dart index 149d16659a..cbaa61415e 100644 --- a/dart/lib/src/sentry_client.dart +++ b/dart/lib/src/sentry_client.dart @@ -512,15 +512,16 @@ class SentryClient { release, ); } - final span = scope?.span; + 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(), ); - log.traceId = span.context.traceId; - } else if (propagationContext != null) { - log.traceId = propagationContext.traceId; } final beforeSendLog = _options.beforeSendLog; diff --git a/dart/test/sentry_client_test.dart b/dart/test/sentry_client_test.dart index 11af60b0c1..e2865ea517 100644 --- a/dart/test/sentry_client_test.dart +++ b/dart/test/sentry_client_test.dart @@ -1814,26 +1814,7 @@ void main() { ); }); - test('should set trace id if there is a scope span', () async { - fixture.options.enableLogs = true; - - final client = fixture.getSut(); - final log = givenLog(); - final scope = Scope(fixture.options); - final span = MockSpan(); - scope.span = span; - - await client.captureLog(log, scope: scope); - - final envelopePayloadJson = (fixture.transport).logs.first; - final logJson = envelopePayloadJson['items'].first; - - expect(logJson['trace_id'], span.context.traceId.toString()); - }); - - test( - 'should set trace id from propagation context if there is no scope span', - () async { + test('should set trace id from propagation context', () async { fixture.options.enableLogs = true; final client = fixture.getSut(); From 969dd3c5ad0b447cbc748159224e9a97e0df6e06 Mon Sep 17 00:00:00 2001 From: Denis Andrasec Date: Mon, 12 May 2025 14:20:49 +0200 Subject: [PATCH 25/28] add comment for empty trace id --- dart/lib/src/protocol/sentry_log.dart | 1 + 1 file changed, 1 insertion(+) diff --git a/dart/lib/src/protocol/sentry_log.dart b/dart/lib/src/protocol/sentry_log.dart index ac93e44a0f..c012eb1212 100644 --- a/dart/lib/src/protocol/sentry_log.dart +++ b/dart/lib/src/protocol/sentry_log.dart @@ -10,6 +10,7 @@ class SentryLog { Map attributes; int? severityNumber; + /// The traceId optional with empty default value. The correct value will be set before transport. SentryLog({ required this.timestamp, SentryId? traceId, From 1a206f431b532e672776c7b4f0c3696ec57594e4 Mon Sep 17 00:00:00 2001 From: Denis Andrasec Date: Tue, 13 May 2025 10:52:38 +0200 Subject: [PATCH 26/28] update comment --- dart/lib/src/protocol/sentry_log.dart | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/dart/lib/src/protocol/sentry_log.dart b/dart/lib/src/protocol/sentry_log.dart index c012eb1212..55c0174d90 100644 --- a/dart/lib/src/protocol/sentry_log.dart +++ b/dart/lib/src/protocol/sentry_log.dart @@ -10,7 +10,8 @@ class SentryLog { Map attributes; int? severityNumber; - /// The traceId optional with empty default value. The correct value will be set before transport. + /// 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, SentryId? traceId, From 58535aea9af421bd882de35933c6d780eca109a0 Mon Sep 17 00:00:00 2001 From: Denis Andrasec Date: Tue, 13 May 2025 11:57:10 +0200 Subject: [PATCH 27/28] remove experimental, add comments --- dart/lib/src/sentry_options.dart | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/dart/lib/src/sentry_options.dart b/dart/lib/src/sentry_options.dart index a421b758ff..571f9a3745 100644 --- a/dart/lib/src/sentry_options.dart +++ b/dart/lib/src/sentry_options.dart @@ -15,7 +15,6 @@ import 'sentry_exception_factory.dart'; import 'sentry_stack_trace_factory.dart'; import 'transport/noop_transport.dart'; import 'version.dart'; -import 'package:meta/meta.dart' as meta; // 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 @@ -199,7 +198,8 @@ class SentryOptions { /// Can return true to emit the metric, or false to drop it. BeforeMetricCallback? beforeMetricCallback; - @meta.experimental + /// 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 @@ -535,7 +535,9 @@ class SentryOptions { /// This is opt-in, as it can lead to existing exception beeing grouped as new ones. bool groupExceptions = false; - @meta.experimental + /// Enable to capture and send logs to Sentry. + /// + /// Disabled by default. bool enableLogs = false; SentryOptions({String? dsn, Platform? platform, RuntimeChecker? checker}) { @@ -667,7 +669,8 @@ typedef BeforeMetricCallback = bool Function( Map? tags, }); -@meta.experimental +/// 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 = SentryLog? Function(SentryLog log); /// Used to provide timestamp for logging. From b7f74b3487686ceebf5e10792217cffe2cc03a7d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Denis=20Andra=C5=A1ec?= Date: Tue, 13 May 2025 13:39:11 +0200 Subject: [PATCH 28/28] [Structured Logs]: Buffering and Flushing of Logs (#2930) --- CHANGELOG.md | 1 + dart/lib/src/noop_log_batcher.dart | 12 ++ dart/lib/src/sentry_client.dart | 25 +-- dart/lib/src/sentry_log_batcher.dart | 52 ++++++ dart/lib/src/sentry_options.dart | 7 +- dart/test/mocks/mock_log_batcher.dart | 19 +++ dart/test/sentry_client_test.dart | 121 +++++++++----- dart/test/sentry_log_batcher_test.dart | 148 ++++++++++++++++++ flutter/lib/src/widgets_binding_observer.dart | 1 + .../test/widgets_binding_observer_test.dart | 43 +++++ 10 files changed, 380 insertions(+), 49 deletions(-) create mode 100644 dart/lib/src/noop_log_batcher.dart create mode 100644 dart/lib/src/sentry_log_batcher.dart create mode 100644 dart/test/mocks/mock_log_batcher.dart create mode 100644 dart/test/sentry_log_batcher_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a1dd792da..3301e6ca3a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - 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/sentry_client.dart b/dart/lib/src/sentry_client.dart index cbaa61415e..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); } @@ -528,9 +532,12 @@ class SentryClient { SentryLog? processedLog = log; if (beforeSendLog != null) { try { - processedLog = beforeSendLog(log); - if (processedLog == null) { - return; + final callbackResult = beforeSendLog(log); + + if (callbackResult is Future) { + processedLog = await callbackResult; + } else { + processedLog = callbackResult; } } catch (exception, stackTrace) { _options.logger( @@ -544,15 +551,9 @@ class SentryClient { } } } - - // TODO: Batch in separate PR, so we can send multiple logs at once. - final envelope = SentryEnvelope.fromLogs( - [processedLog ?? log], - _options.sdk, - ); - - // TODO: Make sure the Android SDK understands the log envelope type. - await captureEnvelope(envelope); + if (processedLog != null) { + _options.logBatcher.addLog(processedLog); + } } void 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 571f9a3745..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 @@ -540,6 +542,9 @@ class SentryOptions { /// Disabled by default. bool enableLogs = false; + @internal + SentryLogBatcher logBatcher = NoopLogBatcher(); + SentryOptions({String? dsn, Platform? platform, RuntimeChecker? checker}) { this.dsn = dsn; if (platform != null) { @@ -671,7 +676,7 @@ typedef BeforeMetricCallback = bool Function( /// 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 = SentryLog? Function(SentryLog 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/sentry_client_test.dart b/dart/test/sentry_client_test.dart index e2865ea517..d8b29d486e 100644 --- a/dart/test/sentry_client_test.dart +++ b/dart/test/sentry_client_test.dart @@ -17,6 +17,7 @@ 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'; @@ -24,6 +25,7 @@ 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', () { @@ -1722,37 +1724,47 @@ void main() { ); } + 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); - expect((fixture.transport).logs, isEmpty); + 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(); - final logJson = log.toJson(); await client.captureLog(log); - final envelopePayloadJson = (fixture.transport).logs.first; + final mockLogBatcher = fixture.options.logBatcher as MockLogBatcher; + expect(mockLogBatcher.addLogCalls.length, 1); - expect(envelopePayloadJson, isNotNull); - expect(envelopePayloadJson['items'].first['timestamp'], - logJson['timestamp']); - expect( - envelopePayloadJson['items'].first['trace_id'], logJson['trace_id']); - expect(envelopePayloadJson['items'].first['level'], logJson['level']); - expect(envelopePayloadJson['items'].first['body'], logJson['body']); - expect( - envelopePayloadJson['items'].first['attributes']['attribute'] - ['value'], - 'value'); + 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 { @@ -1767,49 +1779,52 @@ void main() { scope.span = span; final client = fixture.getSut(); + fixture.options.logBatcher = MockLogBatcher(); + await client.captureLog(log, scope: scope); - final envelopePayloadJson = (fixture.transport).logs.first; - final attributesJson = envelopePayloadJson['items'].first['attributes']; + final mockLogBatcher = fixture.options.logBatcher as MockLogBatcher; + expect(mockLogBatcher.addLogCalls.length, 1); + final capturedLog = mockLogBatcher.addLogCalls.first; expect( - attributesJson['sentry.sdk.name']['value'], + capturedLog.attributes['sentry.sdk.name']?.value, fixture.options.sdk.name, ); expect( - attributesJson['sentry.sdk.name']['type'], + capturedLog.attributes['sentry.sdk.name']?.type, 'string', ); expect( - attributesJson['sentry.sdk.version']['value'], + capturedLog.attributes['sentry.sdk.version']?.value, fixture.options.sdk.version, ); expect( - attributesJson['sentry.sdk.version']['type'], + capturedLog.attributes['sentry.sdk.version']?.type, 'string', ); expect( - attributesJson['sentry.environment']['value'], + capturedLog.attributes['sentry.environment']?.value, fixture.options.environment, ); expect( - attributesJson['sentry.environment']['type'], + capturedLog.attributes['sentry.environment']?.type, 'string', ); expect( - attributesJson['sentry.release']['value'], + capturedLog.attributes['sentry.release']?.value, fixture.options.release, ); expect( - attributesJson['sentry.release']['type'], + capturedLog.attributes['sentry.release']?.type, 'string', ); expect( - attributesJson['sentry.trace.parent_span_id']['value'], + capturedLog.attributes['sentry.trace.parent_span_id']?.value, span.context.spanId.toString(), ); expect( - attributesJson['sentry.trace.parent_span_id']['type'], + capturedLog.attributes['sentry.trace.parent_span_id']?.type, 'string', ); }); @@ -1818,15 +1833,18 @@ void main() { 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 envelopePayloadJson = (fixture.transport).logs.first; - final logJson = envelopePayloadJson['items'].first; + final mockLogBatcher = fixture.options.logBatcher as MockLogBatcher; + expect(mockLogBatcher.addLogCalls.length, 1); + final capturedLog = mockLogBatcher.addLogCalls.first; - expect(logJson['trace_id'], scope.propagationContext.traceId.toString()); + expect(capturedLog.traceId, scope.propagationContext.traceId); }); test('$BeforeSendLogCallback returning null drops the log', () async { @@ -1834,11 +1852,14 @@ void main() { fixture.options.beforeSendLog = (log) => null; final client = fixture.getSut(); + fixture.options.logBatcher = MockLogBatcher(); + final log = givenLog(); await client.captureLog(log); - expect((fixture.transport).logs, isEmpty); + final mockLogBatcher = fixture.options.logBatcher as MockLogBatcher; + expect(mockLogBatcher.addLogCalls.length, 0); }); test('$BeforeSendLogCallback returning a log modifies it', () async { @@ -1849,14 +1870,39 @@ void main() { }; 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 envelopePayloadJson = (fixture.transport).logs.first; - final logJson = envelopePayloadJson['items'].first; + final mockLogBatcher = fixture.options.logBatcher as MockLogBatcher; + expect(mockLogBatcher.addLogCalls.length, 1); + final capturedLog = mockLogBatcher.addLogCalls.first; - expect(logJson['body'], 'modified'); + expect(capturedLog.body, 'modified'); }); test('$BeforeSendLogCallback throwing is caught', () async { @@ -1868,14 +1914,17 @@ void main() { }; final client = fixture.getSut(); + fixture.options.logBatcher = MockLogBatcher(); + final log = givenLog(); await client.captureLog(log); - final envelopePayloadJson = (fixture.transport).logs.first; - final logJson = envelopePayloadJson['items'].first; + final mockLogBatcher = fixture.options.logBatcher as MockLogBatcher; + expect(mockLogBatcher.addLogCalls.length, 1); + final capturedLog = mockLogBatcher.addLogCalls.first; - expect(logJson['body'], 'test'); + expect(capturedLog.body, 'test'); }); }); 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/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; + } +}