diff --git a/CHANGELOG.md b/CHANGELOG.md index 70fc7d470c..149a454b70 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ # Unreleased +* Feat: Add support for attachments (#505) + # 6.0.0-beta.1 * Feat: Browser detection (#502) @@ -7,6 +9,7 @@ * Feat: Add Culture Context (#491) * Feat: Add DeduplicationEventProcessor (#498) * Feat: Capture failed requests as event (#473) +* Feat: `beforeSend` callback accepts async code (#494) ## Breaking Changes: diff --git a/dart/lib/sentry.dart b/dart/lib/sentry.dart index d358cea613..439bf21587 100644 --- a/dart/lib/sentry.dart +++ b/dart/lib/sentry.dart @@ -22,3 +22,4 @@ export 'src/transport/transport.dart'; export 'src/integration.dart'; export 'src/event_processor.dart'; export 'src/http_client/sentry_http_client.dart'; +export 'src/sentry_attachment/sentry_attachment.dart'; diff --git a/dart/lib/sentry_io.dart b/dart/lib/sentry_io.dart new file mode 100644 index 0000000000..db73e953ba --- /dev/null +++ b/dart/lib/sentry_io.dart @@ -0,0 +1,2 @@ +export 'sentry.dart'; +export 'src/sentry_attachment/io_sentry_attachment.dart'; diff --git a/dart/lib/src/protocol/sentry_id.dart b/dart/lib/src/protocol/sentry_id.dart index d9291d42d0..500c8980a2 100644 --- a/dart/lib/src/protocol/sentry_id.dart +++ b/dart/lib/src/protocol/sentry_id.dart @@ -1,28 +1,40 @@ import 'package:meta/meta.dart'; import 'package:uuid/uuid.dart'; -/// Hexadecimal string representing a uuid4 value +/// Hexadecimal string representing a uuid4 value. +/// The length is exactly 32 +/// characters. Dashes are not allowed. Has to be lowercase. @immutable class SentryId { - static final SentryId _emptyId = - SentryId.fromId('00000000-0000-0000-0000-000000000000'); - /// The ID Sentry.io assigned to the submitted event for future reference. final String _id; static final Uuid _uuidGenerator = Uuid(); - SentryId._internal({String? id}) : _id = id ?? _uuidGenerator.v4(); + SentryId._internal({String? id}) + : _id = + id?.replaceAll('-', '') ?? _uuidGenerator.v4().replaceAll('-', ''); /// Generates a new SentryId - factory SentryId.newId() => SentryId._internal(); + SentryId.newId() : this._internal(); /// Generates a SentryId with the given UUID - factory SentryId.fromId(String id) => SentryId._internal(id: id); + SentryId.fromId(String id) : this._internal(id: id); /// SentryId with an empty UUID - factory SentryId.empty() => _emptyId; + const SentryId.empty() : _id = '00000000000000000000000000000000'; + + @override + String toString() => _id; + + @override + int get hashCode => _id.hashCode; @override - String toString() => _id.replaceAll('-', ''); + bool operator ==(o) { + if (o is SentryId) { + return o._id == _id; + } + return false; + } } diff --git a/dart/lib/src/scope.dart b/dart/lib/src/scope.dart index bd78b14921..89b003df20 100644 --- a/dart/lib/src/scope.dart +++ b/dart/lib/src/scope.dart @@ -1,5 +1,5 @@ import 'dart:collection'; - +import 'sentry_attachment/sentry_attachment.dart'; import 'event_processor.dart'; import 'protocol.dart'; import 'sentry_options.dart'; @@ -81,6 +81,10 @@ class Scope { final SentryOptions _options; + final List _attachements = []; + + List get attachements => List.unmodifiable(_attachements); + Scope(this._options); /// Adds a breadcrumb to the breadcrumbs queue @@ -116,6 +120,14 @@ class Scope { _breadcrumbs.add(breadcrumb); } + void addAttachment(SentryAttachment attachment) { + _attachements.add(attachment); + } + + void clearAttachments() { + _attachements.clear(); + } + /// Clear all the breadcrumbs void clearBreadcrumbs() { _breadcrumbs.clear(); @@ -129,6 +141,7 @@ class Scope { /// Resets the Scope to its default state void clear() { clearBreadcrumbs(); + clearAttachments(); level = null; transaction = null; user = null; @@ -285,6 +298,10 @@ class Scope { } }); + for (final attachment in _attachements) { + clone.addAttachment(attachment); + } + return clone; } } diff --git a/dart/lib/src/sentry_attachment/io_sentry_attachment.dart b/dart/lib/src/sentry_attachment/io_sentry_attachment.dart new file mode 100644 index 0000000000..a1e2585556 --- /dev/null +++ b/dart/lib/src/sentry_attachment/io_sentry_attachment.dart @@ -0,0 +1,35 @@ +import 'dart:io'; + +import 'sentry_attachment.dart'; + +class IoSentryAttachment extends SentryAttachment { + /// Creates an attachment from a given path. + /// Only available on `dart:io` platforms. + /// Not available on web. + IoSentryAttachment.fromPath( + String path, { + String? filename, + String? attachmentType, + String? contentType, + }) : this.fromFile( + File(path), + attachmentType: attachmentType, + contentType: contentType, + filename: filename, + ); + + /// Creates an attachment from a given [File]. + /// Only available on `dart:io` platforms. + /// Not available on web. + IoSentryAttachment.fromFile( + File file, { + String? filename, + String? attachmentType, + String? contentType, + }) : super.fromLoader( + loader: () => file.readAsBytes(), + filename: filename ?? file.uri.pathSegments.last, + attachmentType: attachmentType, + contentType: contentType, + ); +} diff --git a/dart/lib/src/sentry_attachment/sentry_attachment.dart b/dart/lib/src/sentry_attachment/sentry_attachment.dart new file mode 100644 index 0000000000..33d609e097 --- /dev/null +++ b/dart/lib/src/sentry_attachment/sentry_attachment.dart @@ -0,0 +1,94 @@ +import 'dart:async'; +import 'dart:typed_data'; + +// https://develop.sentry.dev/sdk/features/#attachments +// https://develop.sentry.dev/sdk/envelopes/#attachment + +typedef ContentLoader = FutureOr Function(); + +/// Arbitrary content which gets attached to an event. +class SentryAttachment { + /// Standard attachment without special meaning. + static const String typeAttachmentDefault = 'event.attachment'; + + /// Minidump file that creates an error event and is symbolicated. + /// The file should start with the `MDMP` magic bytes. + static const String typeMinidump = 'event.minidump'; + + /// Apple crash report file that creates an error event and is symbolicated. + static const String typeAppleCrashReport = 'event.applecrashreport'; + + /// XML file containing UE4 crash meta data. + /// During event ingestion, event contexts and extra fields are extracted from + /// this file. + static const String typeUnrealContext = 'unreal.context'; + + /// Plain-text log file obtained from UE4 crashes. + /// During event ingestion, the last logs are extracted into event + /// breadcrumbs. + static const String typeUnrealLogs = 'unreal.logs'; + + SentryAttachment.fromLoader({ + required ContentLoader loader, + required this.filename, + String? attachmentType, + this.contentType, + }) : _loader = loader, + attachmentType = attachmentType ?? typeAttachmentDefault; + + /// Creates an [SentryAttachment] from a [Uint8List] + SentryAttachment.fromUint8List( + Uint8List bytes, + String fileName, { + String? contentType, + String? attachmentType, + }) : this.fromLoader( + attachmentType: attachmentType, + loader: () => bytes, + filename: fileName, + contentType: contentType, + ); + + /// Creates an [SentryAttachment] from a [List] + SentryAttachment.fromIntList( + List bytes, + String fileName, { + String? contentType, + String? attachmentType, + }) : this.fromLoader( + attachmentType: attachmentType, + loader: () => Uint8List.fromList(bytes), + filename: fileName, + contentType: contentType, + ); + + /// Creates an [SentryAttachment] from [ByteData] + SentryAttachment.fromByteData( + ByteData bytes, + String fileName, { + String? contentType, + String? attachmentType, + }) : this.fromLoader( + attachmentType: attachmentType, + loader: () => bytes.buffer.asUint8List(), + filename: fileName, + contentType: contentType, + ); + + /// Attachment type. + /// Should be one of types given in [AttachmentType]. + final String attachmentType; + + /// Attachment content. + /// Is loaded while sending this attachment. + FutureOr get bytes => _loader(); + + final ContentLoader _loader; + + /// Attachment file name. + final String filename; + + /// Attachment content type. + /// Inferred by Sentry if it's not given. + final String? contentType; +} diff --git a/dart/lib/src/sentry_client.dart b/dart/lib/src/sentry_client.dart index 2ca151d0e1..a901ebc8a4 100644 --- a/dart/lib/src/sentry_client.dart +++ b/dart/lib/src/sentry_client.dart @@ -108,8 +108,14 @@ class SentryClient { return _sentryId; } } - final envelope = SentryEnvelope.fromEvent(preparedEvent, _options.sdk); - return await _options.transport.send(envelope); + final envelope = SentryEnvelope.fromEvent( + preparedEvent, + _options.sdk, + attachments: scope?.attachements, + ); + + final id = await captureEnvelope(envelope); + return id ?? SentryId.empty(); } SentryEvent _prepareEvent(SentryEvent event, {dynamic stackTrace}) { diff --git a/dart/lib/src/sentry_envelope.dart b/dart/lib/src/sentry_envelope.dart index 18a1835231..6de2f69a4b 100644 --- a/dart/lib/src/sentry_envelope.dart +++ b/dart/lib/src/sentry_envelope.dart @@ -1,5 +1,6 @@ import 'dart:convert'; +import 'sentry_attachment/sentry_attachment.dart'; import 'sentry_envelope_header.dart'; import 'sentry_envelope_item.dart'; import 'protocol/sentry_event.dart'; @@ -16,9 +17,19 @@ class SentryEnvelope { final List items; /// Create an `SentryEnvelope` with containing one `SentryEnvelopeItem` which holds the `SentyEvent` data. - factory SentryEnvelope.fromEvent(SentryEvent event, SdkVersion sdkVersion) { - return SentryEnvelope(SentryEnvelopeHeader(event.eventId, sdkVersion), - [SentryEnvelopeItem.fromEvent(event)]); + factory SentryEnvelope.fromEvent( + SentryEvent event, + SdkVersion sdkVersion, { + List? attachments, + }) { + return SentryEnvelope( + SentryEnvelopeHeader(event.eventId, sdkVersion), + [ + SentryEnvelopeItem.fromEvent(event), + if (attachments != null) + ...attachments.map((e) => SentryEnvelopeItem.fromAttachment(e)) + ], + ); } /// Stream binary data representation of `Envelope` file encoded. @@ -26,9 +37,10 @@ class SentryEnvelope { yield utf8.encode(jsonEncode(header.toJson())); final newLineData = utf8.encode('\n'); for (final item in items) { - yield newLineData; - await for (final chunk in item.envelopeItemStream()) { - yield chunk; + final itemStream = await item.envelopeItemStream(); + if (itemStream.isNotEmpty) { + yield newLineData; + yield itemStream; } } } diff --git a/dart/lib/src/sentry_envelope_item.dart b/dart/lib/src/sentry_envelope_item.dart index 5c00a4e1e6..f33ac682c1 100644 --- a/dart/lib/src/sentry_envelope_item.dart +++ b/dart/lib/src/sentry_envelope_item.dart @@ -1,5 +1,6 @@ import 'dart:convert'; +import 'sentry_attachment/sentry_attachment.dart'; import 'sentry_item_type.dart'; import 'protocol/sentry_event.dart'; import 'sentry_envelope_item_header.dart'; @@ -8,13 +9,24 @@ import 'sentry_envelope_item_header.dart'; class SentryEnvelopeItem { SentryEnvelopeItem(this.header, this.dataFactory); - /// Header with info about type and length of data in bytes. - final SentryEnvelopeItemHeader header; + factory SentryEnvelopeItem.fromAttachment(SentryAttachment attachment) { + final cachedItem = _CachedItem(() async { + return await attachment.bytes; + }); - /// Create binary data representation of item data. - final Future> Function() dataFactory; + final getLength = () async => (await cachedItem.getData()).length; + + final header = SentryEnvelopeItemHeader( + SentryItemType.attachment, + getLength, + contentType: attachment.contentType, + fileName: attachment.filename, + attachmentType: attachment.attachmentType, + ); + return SentryEnvelopeItem(header, cachedItem.getData); + } - /// Create an `SentryEnvelopeItem` which holds the `SentyEvent` data. + /// Create an [SentryEnvelopeItem] which holds the [SentryEvent] data. factory SentryEnvelopeItem.fromEvent(SentryEvent event) { final cachedItem = _CachedItem(() async { final jsonEncoded = jsonEncode(event.toJson()); @@ -26,16 +38,34 @@ class SentryEnvelopeItem { }; return SentryEnvelopeItem( - SentryEnvelopeItemHeader(SentryItemType.event, getLength, - contentType: 'application/json'), - cachedItem.getData); + SentryEnvelopeItemHeader( + SentryItemType.event, + getLength, + contentType: 'application/json', + ), + cachedItem.getData, + ); } + /// Header with info about type and length of data in bytes. + final SentryEnvelopeItemHeader header; + + /// Create binary data representation of item data. + final Future> Function() dataFactory; + /// Stream binary data of `Envelope` item. - Stream> envelopeItemStream() async* { - yield utf8.encode(jsonEncode(await header.toJson())); - yield utf8.encode('\n'); - yield await dataFactory(); + Future> envelopeItemStream() async { + // Each item needs to be encoded as one unit. + // Otherwise the header alredy got yielded if the content throws + // an exception. + try { + final itemHeader = utf8.encode(jsonEncode(await header.toJson())); + final newLine = utf8.encode('\n'); + final data = await dataFactory(); + return [...itemHeader, ...newLine, ...data]; + } catch (e) { + return []; + } } } diff --git a/dart/lib/src/sentry_envelope_item_header.dart b/dart/lib/src/sentry_envelope_item_header.dart index 013a62d35b..a69de56c81 100644 --- a/dart/lib/src/sentry_envelope_item_header.dart +++ b/dart/lib/src/sentry_envelope_item_header.dart @@ -1,7 +1,12 @@ /// Header with item info about type and length of data in bytes. class SentryEnvelopeItemHeader { - SentryEnvelopeItemHeader(this.type, this.length, - {this.contentType, this.fileName}); + SentryEnvelopeItemHeader( + this.type, + this.length, { + this.contentType, + this.fileName, + this.attachmentType, + }); /// Type of encoded data. final String type; @@ -13,17 +18,18 @@ class SentryEnvelopeItemHeader { final String? fileName; + final String? attachmentType; + /// Item header encoded as JSON Future> toJson() async { - final json = {}; - if (contentType != null) { - json['content_type'] = contentType; - } - if (fileName != null) { - json['filename'] = fileName; - } - json['type'] = type; - json['length'] = await length(); + final json = { + if (contentType != null) 'content_type': contentType, + if (fileName != null) 'filename': fileName, + if (attachmentType != null) 'attachment_type': attachmentType, + 'type': type, + 'length': await length(), + }; + return json; } } diff --git a/dart/lib/src/transport/http_transport.dart b/dart/lib/src/transport/http_transport.dart index 3a93da51f1..7137308381 100644 --- a/dart/lib/src/transport/http_transport.dart +++ b/dart/lib/src/transport/http_transport.dart @@ -45,7 +45,7 @@ class HttpTransport implements Transport { } @override - Future send(SentryEnvelope envelope) async { + Future send(SentryEnvelope envelope) async { final filteredEnvelope = _rateLimiter.filter(envelope); if (filteredEnvelope == null) { return SentryId.empty(); @@ -77,7 +77,10 @@ class HttpTransport implements Transport { } final eventId = json.decode(response.body)['id']; - return eventId != null ? SentryId.fromId(eventId) : SentryId.empty(); + if (eventId == null) { + return null; + } + return SentryId.fromId(eventId); } Future _createStreamedRequest( diff --git a/dart/lib/src/transport/noop_transport.dart b/dart/lib/src/transport/noop_transport.dart index 705dfc3a9c..117a8756cd 100644 --- a/dart/lib/src/transport/noop_transport.dart +++ b/dart/lib/src/transport/noop_transport.dart @@ -7,6 +7,5 @@ import 'transport.dart'; class NoOpTransport implements Transport { @override - Future send(SentryEnvelope envelope) => - Future.value(SentryId.empty()); + Future send(SentryEnvelope envelope) => Future.value(null); } diff --git a/dart/lib/src/transport/transport.dart b/dart/lib/src/transport/transport.dart index 08de9e9322..f0a6a2c996 100644 --- a/dart/lib/src/transport/transport.dart +++ b/dart/lib/src/transport/transport.dart @@ -6,5 +6,5 @@ import '../protocol.dart'; /// A transport is in charge of sending the event/envelope either via http /// or caching in the disk. abstract class Transport { - Future send(SentryEnvelope envelope); + Future send(SentryEnvelope envelope); } diff --git a/dart/test/scope_test.dart b/dart/test/scope_test.dart index 9a07456fb7..3176ebc715 100644 --- a/dart/test/scope_test.dart +++ b/dart/test/scope_test.dart @@ -154,6 +154,38 @@ void main() { expect(sut.breadcrumbs.length, 0); }); + test('adds $SentryAttachment', () { + final sut = fixture.getSut(); + + final attachment = SentryAttachment.fromIntList([0, 0, 0, 0], 'test.txt'); + sut.addAttachment(attachment); + + expect(sut.attachements.last, attachment); + expect(sut.attachements.length, 1); + }); + + test('clear() removes all $SentryAttachment', () { + final sut = fixture.getSut(); + + final attachment = SentryAttachment.fromIntList([0, 0, 0, 0], 'test.txt'); + sut.addAttachment(attachment); + expect(sut.attachements.length, 1); + sut.clear(); + + expect(sut.attachements.length, 0); + }); + + test('clearAttachments() removes all $SentryAttachment', () { + final sut = fixture.getSut(); + + final attachment = SentryAttachment.fromIntList([0, 0, 0, 0], 'test.txt'); + sut.addAttachment(attachment); + expect(sut.attachements.length, 1); + sut.clearAttachments(); + + expect(sut.attachements.length, 0); + }); + test('sets tag', () { final sut = fixture.getSut(); @@ -232,6 +264,13 @@ void main() { test('clones', () { final sut = fixture.getSut(); + + sut.addBreadcrumb(Breadcrumb( + message: 'test log', + timestamp: DateTime.utc(2019), + )); + sut.addAttachment(SentryAttachment.fromIntList([0, 0, 0, 0], 'test.txt')); + final clone = sut.clone(); expect(sut.user, clone.user); expect(sut.transaction, clone.transaction); @@ -239,6 +278,7 @@ void main() { expect(sut.tags, clone.tags); expect(sut.breadcrumbs, clone.breadcrumbs); expect(sut.contexts, clone.contexts); + expect(sut.attachements, clone.attachements); expect(ListEquality().equals(sut.fingerprint, clone.fingerprint), true); expect( ListEquality().equals(sut.eventProcessors, clone.eventProcessors), diff --git a/dart/test/sentry_attachment_io_test.dart b/dart/test/sentry_attachment_io_test.dart new file mode 100644 index 0000000000..2752355a2b --- /dev/null +++ b/dart/test/sentry_attachment_io_test.dart @@ -0,0 +1,31 @@ +@TestOn('vm') + +import 'dart:io'; + +import 'package:sentry/sentry_io.dart'; +import 'package:test/test.dart'; + +void main() { + group('$SentryAttachment ctor', () { + test('fromFile', () async { + final file = File('test_resources/testfile.txt'); + + final attachment = IoSentryAttachment.fromFile(file); + expect(attachment.attachmentType, SentryAttachment.typeAttachmentDefault); + expect(attachment.contentType, isNull); + expect(attachment.filename, 'testfile.txt'); + await expectLater( + await attachment.bytes, [102, 111, 111, 32, 98, 97, 114]); + }); + + test('fromPath', () async { + final attachment = + IoSentryAttachment.fromPath('test_resources/testfile.txt'); + expect(attachment.attachmentType, SentryAttachment.typeAttachmentDefault); + expect(attachment.contentType, isNull); + expect(attachment.filename, 'testfile.txt'); + await expectLater( + await attachment.bytes, [102, 111, 111, 32, 98, 97, 114]); + }); + }); +} diff --git a/dart/test/sentry_attachment_test.dart b/dart/test/sentry_attachment_test.dart new file mode 100644 index 0000000000..96bf0b53da --- /dev/null +++ b/dart/test/sentry_attachment_test.dart @@ -0,0 +1,98 @@ +import 'dart:typed_data'; + +import 'package:sentry/sentry.dart'; +import 'package:test/test.dart'; + +import 'mocks.dart'; +import 'mocks/mock_transport.dart'; + +void main() { + group('$SentryAttachment ctor', () { + test('default', () async { + final attachment = SentryAttachment.fromLoader( + loader: () => Uint8List.fromList([0, 0, 0, 0]), + filename: 'test.txt', + ); + expect(attachment.attachmentType, SentryAttachment.typeAttachmentDefault); + expect(attachment.contentType, isNull); + expect(attachment.filename, 'test.txt'); + await expectLater(await attachment.bytes, [0, 0, 0, 0]); + }); + + test('fromIntList', () async { + final attachment = SentryAttachment.fromIntList([0, 0, 0, 0], 'test.txt'); + expect(attachment.attachmentType, SentryAttachment.typeAttachmentDefault); + expect(attachment.contentType, isNull); + expect(attachment.filename, 'test.txt'); + await expectLater(await attachment.bytes, [0, 0, 0, 0]); + }); + + test('fromUint8List', () async { + final attachment = SentryAttachment.fromUint8List( + Uint8List.fromList([0, 0, 0, 0]), + 'test.txt', + ); + expect(attachment.attachmentType, SentryAttachment.typeAttachmentDefault); + expect(attachment.contentType, isNull); + expect(attachment.filename, 'test.txt'); + await expectLater(await attachment.bytes, [0, 0, 0, 0]); + }); + + test('fromByteData', () async { + final attachment = SentryAttachment.fromByteData( + ByteData.sublistView(Uint8List.fromList([0, 0, 0, 0])), + 'test.txt', + ); + expect(attachment.attachmentType, SentryAttachment.typeAttachmentDefault); + expect(attachment.contentType, isNull); + expect(attachment.filename, 'test.txt'); + await expectLater(await attachment.bytes, [0, 0, 0, 0]); + }); + }); + + group('$Scope $SentryAttachment tests', () { + late Fixture fixture; + + setUp(() { + fixture = Fixture(); + }); + + test('Sending with attachments', () async { + final sut = fixture.getSut(); + await sut.captureEvent(SentryEvent(), withScope: (scope) { + scope.addAttachment( + SentryAttachment.fromIntList([0, 0, 0, 0], 'test.txt'), + ); + }); + expect(fixture.transport.envelopes.length, 1); + expect(fixture.transport.envelopes.first.items.length, 2); + final attachmentEnvelope = fixture.transport.envelopes.first.items[1]; + expect( + attachmentEnvelope.header.attachmentType, + SentryAttachment.typeAttachmentDefault, + ); + expect( + attachmentEnvelope.header.contentType, + isNull, + ); + expect( + attachmentEnvelope.header.fileName, + 'test.txt', + ); + await expectLater( + await attachmentEnvelope.header.length(), + 4, + ); + }); + }); +} + +class Fixture { + MockTransport transport = MockTransport(); + + Hub getSut() { + final options = SentryOptions(dsn: fakeDsn); + options.transport = transport; + return Hub(options); + } +} diff --git a/dart/test/sentry_client_test.dart b/dart/test/sentry_client_test.dart index aec2f74b8e..3e42cfea35 100644 --- a/dart/test/sentry_client_test.dart +++ b/dart/test/sentry_client_test.dart @@ -725,9 +725,8 @@ void main() { Future eventFromEnvelope(SentryEnvelope envelope) async { final envelopeItemData = []; - await envelope.items.first - .envelopeItemStream() - .forEach(envelopeItemData.addAll); + envelopeItemData.addAll(await envelope.items.first.envelopeItemStream()); + final envelopeItem = utf8.decode(envelopeItemData); final envelopeItemJson = jsonDecode(envelopeItem.split('\n').last); return SentryEvent.fromJson(envelopeItemJson as Map); diff --git a/dart/test/sentry_envelope_item_test.dart b/dart/test/sentry_envelope_item_test.dart index 3e000cf953..c367e57c9c 100644 --- a/dart/test/sentry_envelope_item_test.dart +++ b/dart/test/sentry_envelope_item_test.dart @@ -24,8 +24,7 @@ void main() { final headerJsonEncoded = jsonEncode(headerJson); final expected = utf8.encode('$headerJsonEncoded\n{fixture}'); - final actualItem = []; - await sut.envelopeItemStream().forEach(actualItem.addAll); + final actualItem = await sut.envelopeItemStream(); expect(actualItem, expected); }); diff --git a/dart/test/sentry_envelope_test.dart b/dart/test/sentry_envelope_test.dart index 624e4b0992..c153509ab6 100644 --- a/dart/test/sentry_envelope_test.dart +++ b/dart/test/sentry_envelope_test.dart @@ -31,8 +31,7 @@ void main() { final expectedHeaderJson = header.toJson(); final expectedHeaderJsonSerialized = jsonEncode(expectedHeaderJson); - final expectedItem = []; - await item.envelopeItemStream().forEach(expectedItem.addAll); + final expectedItem = await item.envelopeItemStream(); final expectedItemSerialized = utf8.decode(expectedItem); final expected = utf8.encode( @@ -60,13 +59,9 @@ void main() { expect(await sut.items[0].header.length(), await expectedEnvelopeItem.header.length()); - final actualItem = []; - await sut.items[0].envelopeItemStream().forEach(actualItem.addAll); + final actualItem = await sut.items[0].envelopeItemStream(); - final expectedItem = []; - await expectedEnvelopeItem - .envelopeItemStream() - .forEach(expectedItem.addAll); + final expectedItem = await expectedEnvelopeItem.envelopeItemStream(); expect(actualItem, expectedItem); }); diff --git a/dart/test/sentry_envelope_vm_test.dart b/dart/test/sentry_envelope_vm_test.dart index ffbdd14e08..fc0396dc5a 100644 --- a/dart/test/sentry_envelope_vm_test.dart +++ b/dart/test/sentry_envelope_vm_test.dart @@ -2,6 +2,7 @@ import 'dart:io'; import 'package:sentry/sentry.dart'; +import 'package:sentry/sentry_io.dart'; import 'package:sentry/src/sentry_envelope.dart'; import 'package:sentry/src/sentry_envelope_header.dart'; import 'package:sentry/src/sentry_envelope_item_header.dart'; @@ -42,5 +43,257 @@ void main() { expect(expectedEnvelopeData, envelopeData); }); + + test('skips attachment if path is invalid', () async { + final event = SentryEvent( + eventId: SentryId.empty(), + timestamp: DateTime(1970, 1, 1), + ); + final sdkVersion = SdkVersion(name: '', version: ''); + final attachment = + IoSentryAttachment.fromPath('this_path_does_not_exist.txt'); + + final envelope = SentryEnvelope.fromEvent( + event, + sdkVersion, + attachments: [attachment], + ); + + final data = + (await envelope.envelopeStream().toList()).reduce((a, b) => a + b); + + expect(data, envelopeBinaryData); + }); }); } + +final envelopeBinaryData = [ + 123, + 34, + 101, + 118, + 101, + 110, + 116, + 95, + 105, + 100, + 34, + 58, + 34, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 34, + 44, + 34, + 115, + 100, + 107, + 34, + 58, + 123, + 34, + 110, + 97, + 109, + 101, + 34, + 58, + 34, + 34, + 44, + 34, + 118, + 101, + 114, + 115, + 105, + 111, + 110, + 34, + 58, + 34, + 34, + 125, + 125, + 10, + 123, + 34, + 99, + 111, + 110, + 116, + 101, + 110, + 116, + 95, + 116, + 121, + 112, + 101, + 34, + 58, + 34, + 97, + 112, + 112, + 108, + 105, + 99, + 97, + 116, + 105, + 111, + 110, + 47, + 106, + 115, + 111, + 110, + 34, + 44, + 34, + 116, + 121, + 112, + 101, + 34, + 58, + 34, + 101, + 118, + 101, + 110, + 116, + 34, + 44, + 34, + 108, + 101, + 110, + 103, + 116, + 104, + 34, + 58, + 56, + 54, + 125, + 10, + 123, + 34, + 101, + 118, + 101, + 110, + 116, + 95, + 105, + 100, + 34, + 58, + 34, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 48, + 34, + 44, + 34, + 116, + 105, + 109, + 101, + 115, + 116, + 97, + 109, + 112, + 34, + 58, + 34, + 49, + 57, + 55, + 48, + 45, + 48, + 49, + 45, + 48, + 49, + 84, + 48, + 48, + 58, + 48, + 48, + 58, + 48, + 48, + 46, + 48, + 48, + 48, + 90, + 34, + 125 +]; diff --git a/dart/test/sentry_id_test.dart b/dart/test/sentry_id_test.dart new file mode 100644 index 0000000000..23878f7082 --- /dev/null +++ b/dart/test/sentry_id_test.dart @@ -0,0 +1,35 @@ +import 'package:sentry/src/protocol/sentry_id.dart'; +import 'package:test/test.dart'; + +void main() { + test('empty id', () { + expect(SentryId.empty().toString(), '00000000000000000000000000000000'); + }); + + test('empty id equals from empty id', () { + expect( + SentryId.empty(), + SentryId.fromId('00000000000000000000000000000000'), + ); + }); + + test('uuid format with dashes', () { + expect( + SentryId.fromId('00000000-0000-0000-0000-000000000000'), + SentryId.empty(), + ); + }); + + test('empty id equality', () { + expect(SentryId.empty(), SentryId.empty()); + }); + + test('id roundtrip', () { + final id = SentryId.newId(); + expect(id, SentryId.fromId(id.toString())); + }); + + test('newId should not be equal to newId', () { + expect(SentryId.newId() == SentryId.newId(), false); + }); +} diff --git a/dart/test_resources/testfile.txt b/dart/test_resources/testfile.txt new file mode 100644 index 0000000000..96c906756d --- /dev/null +++ b/dart/test_resources/testfile.txt @@ -0,0 +1 @@ +foo bar \ No newline at end of file diff --git a/flutter/example/lib/main.dart b/flutter/example/lib/main.dart index 8fcbdc36f8..daf855ef0d 100644 --- a/flutter/example/lib/main.dart +++ b/flutter/example/lib/main.dart @@ -1,10 +1,12 @@ import 'dart:async'; +import 'dart:convert'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:sentry_flutter/sentry_flutter.dart'; import 'package:universal_platform/universal_platform.dart'; +import 'package:feedback/feedback.dart' as feedback; // ATTENTION: Change the DSN below with your own to see the events in Sentry. Get one at sentry.io const String _exampleDsn = @@ -33,11 +35,13 @@ class _MyAppState extends State { @override Widget build(BuildContext context) { - return MaterialApp( - navigatorObservers: [ - SentryNavigatorObserver(), - ], - home: const MainScaffold(), + return feedback.BetterFeedback( + child: MaterialApp( + navigatorObservers: [ + SentryNavigatorObserver(), + ], + home: const MainScaffold(), + ), ); } } @@ -128,6 +132,50 @@ class MainScaffold extends StatelessWidget { ); }, ), + RaisedButton( + child: const Text('Capture message with attachment'), + onPressed: () { + Sentry.captureMessage( + 'This message has an attachment', + withScope: (scope) { + final txt = 'Lorem Ipsum dolar sit amet'; + scope.addAttachment( + SentryAttachment.fromIntList( + utf8.encode(txt), + 'foobar.txt', + contentType: 'text/plain', + ), + ); + }, + ); + }, + ), + RaisedButton( + child: const Text('Capture message with image attachment'), + onPressed: () { + feedback.BetterFeedback.of(context) + .show((feedback.UserFeedback feedback) { + Sentry.captureMessage( + feedback.text, + withScope: (scope) { + final entries = feedback.extra?.entries; + if (entries != null) { + for (final extra in entries) { + scope.setExtra(extra.key, extra.value); + } + } + scope.addAttachment( + SentryAttachment.fromUint8List( + feedback.screenshot, + 'feedback.png', + contentType: 'image/png', + ), + ); + }, + ); + }); + }, + ), if (UniversalPlatform.isIOS || UniversalPlatform.isMacOS) const CocoaExample(), if (UniversalPlatform.isAndroid) const AndroidExample(), diff --git a/flutter/example/pubspec.yaml b/flutter/example/pubspec.yaml index 9f98b64f39..0f7bf84ad6 100644 --- a/flutter/example/pubspec.yaml +++ b/flutter/example/pubspec.yaml @@ -13,6 +13,7 @@ dependencies: sdk: flutter sentry_flutter: any universal_platform: ^1.0.0-nullsafety + feedback: 2.0.0 dependency_overrides: sentry: diff --git a/flutter/lib/sentry_flutter.dart b/flutter/lib/sentry_flutter.dart index 5fc6546099..9d4b8cc89f 100644 --- a/flutter/lib/sentry_flutter.dart +++ b/flutter/lib/sentry_flutter.dart @@ -5,3 +5,4 @@ export 'src/default_integrations.dart'; export 'src/navigation/sentry_navigator_observer.dart'; export 'src/sentry_flutter.dart'; export 'src/sentry_flutter_options.dart'; +export 'src/flutter_sentry_attachment.dart'; diff --git a/flutter/lib/src/file_system_transport.dart b/flutter/lib/src/file_system_transport.dart index 55d6e29de3..d155a93538 100644 --- a/flutter/lib/src/file_system_transport.dart +++ b/flutter/lib/src/file_system_transport.dart @@ -10,7 +10,7 @@ class FileSystemTransport implements Transport { final SentryOptions _options; @override - Future send(SentryEnvelope envelope) async { + Future send(SentryEnvelope envelope) async { final envelopeData = []; await envelope.envelopeStream().forEach(envelopeData.addAll); // https://flutter.dev/docs/development/platform-integration/platform-channels#codec @@ -27,6 +27,6 @@ class FileSystemTransport implements Transport { return SentryId.empty(); } - return envelope.header.eventId ?? SentryId.empty(); + return envelope.header.eventId; } } diff --git a/flutter/lib/src/flutter_sentry_attachment.dart b/flutter/lib/src/flutter_sentry_attachment.dart new file mode 100644 index 0000000000..c644a942c2 --- /dev/null +++ b/flutter/lib/src/flutter_sentry_attachment.dart @@ -0,0 +1,29 @@ +import 'package:flutter/services.dart'; +import 'package:sentry/sentry.dart'; + +class FlutterSentryAttachment extends SentryAttachment { + /// Creates an attachment from an asset out of a [AssetBundle]. + /// If no bundle is given, it's using the [rootBundle]. + /// Typically you want to use it like this: + /// ```dart + /// final attachment = Attachment.fromAsset( + /// 'assets/foo_bar.txt', + /// bundle: DefaultAssetBundle.of(context), + /// ); + /// ``` + FlutterSentryAttachment.fromAsset( + String key, { + String? filename, + AssetBundle? bundle, + String? type, + String? contentType, + }) : super.fromLoader( + loader: () async { + final data = await (bundle ?? rootBundle).load(key); + return data.buffer.asUint8List(); + }, + filename: filename ?? Uri.parse(key).pathSegments.last, + attachmentType: type, + contentType: contentType, + ); +} diff --git a/flutter/test/flutter_sentry_attachment_test.dart b/flutter/test/flutter_sentry_attachment_test.dart new file mode 100644 index 0000000000..621683e6bb --- /dev/null +++ b/flutter/test/flutter_sentry_attachment_test.dart @@ -0,0 +1,33 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:sentry_flutter/sentry_flutter.dart'; + +void main() { + test('fromAsset', () async { + final attachment = FlutterSentryAttachment.fromAsset( + 'foobar.txt', + bundle: TestAssetBundle(), + ); + + expect(attachment.attachmentType, SentryAttachment.typeAttachmentDefault); + expect(attachment.contentType, isNull); + expect(attachment.filename, 'foobar.txt'); + await expectLater(await attachment.bytes, [102, 111, 111, 32, 98, 97, 114]); + }); +} + +class TestAssetBundle extends CachingAssetBundle { + @override + Future load(String key) async { + if (key == 'foobar.txt') { + return ByteData.view( + Uint8List.fromList(utf8.encode('foo bar')).buffer, + ); + } + + return ByteData(0); + } +}