diff --git a/CHANGELOG.md b/CHANGELOG.md index d9051b9e07..71483dab8e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ ### Features +- Add [Spotlight](https://spotlightjs.com/about/) support ([#1786](https://github.com/getsentry/sentry-dart/pull/1786)) + - Set `options.spotlight = Spotlight(enabled: true)` to enable Spotlight - Add `ConnectivityIntegration` for web ([#1765](https://github.com/getsentry/sentry-dart/pull/1765)) - We only get the info if online/offline on web platform. The added breadcrumb is set to either `wifi` or `none`. - APM for isar ([#1726](https://github.com/getsentry/sentry-dart/pull/1726)) diff --git a/dart/lib/sentry.dart b/dart/lib/sentry.dart index 987e1f014a..5419aa45b8 100644 --- a/dart/lib/sentry.dart +++ b/dart/lib/sentry.dart @@ -50,3 +50,5 @@ export 'src/utils/http_header_utils.dart'; export 'src/sentry_trace_origins.dart'; // ignore: invalid_export_of_internal_element export 'src/utils.dart'; +// spotlight debugging +export 'src/spotlight.dart'; diff --git a/dart/lib/src/sentry_client.dart b/dart/lib/src/sentry_client.dart index 62d2072b85..709bda104f 100644 --- a/dart/lib/src/sentry_client.dart +++ b/dart/lib/src/sentry_client.dart @@ -16,6 +16,7 @@ import 'sentry_options.dart'; import 'sentry_stack_trace_factory.dart'; import 'transport/http_transport.dart'; import 'transport/noop_transport.dart'; +import 'transport/spotlight_http_transport.dart'; import 'utils/isolate_utils.dart'; import 'version.dart'; import 'sentry_envelope.dart'; @@ -49,6 +50,9 @@ class SentryClient { final rateLimiter = RateLimiter(options); options.transport = HttpTransport(options, rateLimiter); } + if (options.spotlight.enabled) { + options.transport = SpotlightHttpTransport(options, options.transport); + } return SentryClient._(options); } diff --git a/dart/lib/src/sentry_options.dart b/dart/lib/src/sentry_options.dart index 7d0d18622a..918a5d5758 100644 --- a/dart/lib/src/sentry_options.dart +++ b/dart/lib/src/sentry_options.dart @@ -374,6 +374,13 @@ class SentryOptions { /// Settings this to `false` will set the `level` to [SentryLevel.error]. bool markAutomaticallyCollectedErrorsAsFatal = true; + /// The Spotlight configuration. + /// Disabled by default. + /// ```dart + /// spotlight = Spotlight(enabled: true) + /// ``` + Spotlight spotlight = Spotlight(enabled: false); + SentryOptions({this.dsn, PlatformChecker? checker}) { if (checker != null) { platformChecker = checker; diff --git a/dart/lib/src/spotlight.dart b/dart/lib/src/spotlight.dart new file mode 100644 index 0000000000..b106ed3547 --- /dev/null +++ b/dart/lib/src/spotlight.dart @@ -0,0 +1,21 @@ +import 'platform_checker.dart'; + +/// Spotlight configuration class. +class Spotlight { + /// Whether to enable Spotlight for local development. + bool enabled; + + /// The Spotlight Sidecar URL. + /// Defaults to http://10.0.2.2:8969/stream due to Emulator on Android. + /// Otherwise defaults to http://localhost:8969/stream. + String url; + + Spotlight({required this.enabled, String? url}) + : url = url ?? _defaultSpotlightUrl(); +} + +String _defaultSpotlightUrl() { + return (PlatformChecker().platform.isAndroid + ? 'http://10.0.2.2:8969/stream' + : 'http://localhost:8969/stream'); +} diff --git a/dart/lib/src/transport/http_transport.dart b/dart/lib/src/transport/http_transport.dart index acf0e6cf64..90dd8949ce 100644 --- a/dart/lib/src/transport/http_transport.dart +++ b/dart/lib/src/transport/http_transport.dart @@ -2,11 +2,9 @@ import 'dart:async'; import 'dart:convert'; import 'package:http/http.dart'; +import '../utils/transport_utils.dart'; +import 'http_transport_request_handler.dart'; -import '../client_reports/client_report_recorder.dart'; -import '../client_reports/discard_reason.dart'; -import 'data_category.dart'; -import 'noop_encode.dart' if (dart.library.io) 'encode.dart'; import '../noop_client.dart'; import '../protocol.dart'; import '../sentry_options.dart'; @@ -18,15 +16,9 @@ import 'rate_limiter.dart'; class HttpTransport implements Transport { final SentryOptions _options; - final Dsn _dsn; - final RateLimiter _rateLimiter; - final ClientReportRecorder _recorder; - - late _CredentialBuilder _credentialBuilder; - - final Map _headers; + final HttpTransportRequestHandler _requestHandler; factory HttpTransport(SentryOptions options, RateLimiter rateLimiter) { if (options.httpClient is NoOpClient) { @@ -37,17 +29,8 @@ class HttpTransport implements Transport { } HttpTransport._(this._options, this._rateLimiter) - : _dsn = Dsn.parse(_options.dsn!), - _recorder = _options.recorder, - _headers = _buildHeaders( - _options.platformChecker.isWeb, - _options.sentryClientName, - ) { - _credentialBuilder = _CredentialBuilder( - _dsn, - _options.sentryClientName, - ); - } + : _requestHandler = HttpTransportRequestHandler( + _options, Dsn.parse(_options.dsn!).postUri); @override Future send(SentryEnvelope envelope) async { @@ -57,63 +40,31 @@ class HttpTransport implements Transport { } filteredEnvelope.header.sentAt = _options.clock(); - final streamedRequest = await _createStreamedRequest(filteredEnvelope); + final streamedRequest = + await _requestHandler.createRequest(filteredEnvelope); + final response = await _options.httpClient .send(streamedRequest) .then(Response.fromStream); _updateRetryAfterLimits(response); - if (response.statusCode != 200) { - // body guard to not log the error as it has performance impact to allocate - // the body String. - if (_options.debug) { - _options.logger( - SentryLevel.error, - 'API returned an error, statusCode = ${response.statusCode}, ' - 'body = ${response.body}', - ); - } - - if (response.statusCode >= 400 && response.statusCode != 429) { - _recorder.recordLostEvent( - DiscardReason.networkError, DataCategory.error); - } - - return SentryId.empty(); - } else { - _options.logger( - SentryLevel.debug, - 'Envelope ${envelope.header.eventId ?? "--"} was sent successfully.', - ); - } + TransportUtils.logResponse(_options, envelope, response, target: 'Sentry'); - final eventId = json.decode(response.body)['id']; - if (eventId == null) { - return null; + if (response.statusCode == 200) { + return _parseEventId(response); } - return SentryId.fromId(eventId); + return SentryId.empty(); } - Future _createStreamedRequest( - SentryEnvelope envelope) async { - final streamedRequest = StreamedRequest('POST', _dsn.postUri); - - if (_options.compressPayload) { - final compressionSink = compressInSink(streamedRequest.sink, _headers); - envelope - .envelopeStream(_options) - .listen(compressionSink.add) - .onDone(compressionSink.close); - } else { - envelope - .envelopeStream(_options) - .listen(streamedRequest.sink.add) - .onDone(streamedRequest.sink.close); + SentryId? _parseEventId(Response response) { + try { + final eventId = json.decode(response.body)['id']; + return eventId != null ? SentryId.fromId(eventId) : null; + } catch (e) { + _options.logger(SentryLevel.error, 'Error parsing response: $e'); + return null; } - streamedRequest.headers.addAll(_credentialBuilder.configure(_headers)); - - return streamedRequest; } void _updateRetryAfterLimits(Response response) { @@ -131,51 +82,3 @@ class HttpTransport implements Transport { sentryRateLimitHeader, retryAfterHeader, response.statusCode); } } - -class _CredentialBuilder { - final String _authHeader; - - _CredentialBuilder._(String authHeader) : _authHeader = authHeader; - - factory _CredentialBuilder(Dsn dsn, String sdkIdentifier) { - final authHeader = _buildAuthHeader( - publicKey: dsn.publicKey, - secretKey: dsn.secretKey, - sdkIdentifier: sdkIdentifier, - ); - - return _CredentialBuilder._(authHeader); - } - - static String _buildAuthHeader({ - required String publicKey, - String? secretKey, - required String sdkIdentifier, - }) { - var header = 'Sentry sentry_version=7, sentry_client=$sdkIdentifier, ' - 'sentry_key=$publicKey'; - - if (secretKey != null) { - header += ', sentry_secret=$secretKey'; - } - - return header; - } - - Map configure(Map headers) { - return headers - ..addAll( - {'X-Sentry-Auth': _authHeader}, - ); - } -} - -Map _buildHeaders(bool isWeb, String sdkIdentifier) { - final headers = {'Content-Type': 'application/x-sentry-envelope'}; - // NOTE(lejard_h) overriding user agent on VM and Flutter not sure why - // for web it use browser user agent - if (!isWeb) { - headers['User-Agent'] = sdkIdentifier; - } - return headers; -} diff --git a/dart/lib/src/transport/http_transport_request_handler.dart b/dart/lib/src/transport/http_transport_request_handler.dart new file mode 100644 index 0000000000..4aa50898c5 --- /dev/null +++ b/dart/lib/src/transport/http_transport_request_handler.dart @@ -0,0 +1,98 @@ +import 'dart:async'; + +import 'package:http/http.dart'; +import 'package:meta/meta.dart'; + +import 'noop_encode.dart' if (dart.library.io) 'encode.dart'; +import '../protocol.dart'; +import '../sentry_options.dart'; +import '../sentry_envelope.dart'; + +@internal +class HttpTransportRequestHandler { + final SentryOptions _options; + final Dsn _dsn; + final Map _headers; + final Uri _requestUri; + late _CredentialBuilder _credentialBuilder; + + HttpTransportRequestHandler(this._options, this._requestUri) + : _dsn = Dsn.parse(_options.dsn!), + _headers = _buildHeaders( + _options.platformChecker.isWeb, + _options.sentryClientName, + ) { + _credentialBuilder = _CredentialBuilder( + _dsn, + _options.sentryClientName, + ); + } + + Future createRequest(SentryEnvelope envelope) async { + final streamedRequest = StreamedRequest('POST', _requestUri); + + if (_options.compressPayload) { + final compressionSink = compressInSink(streamedRequest.sink, _headers); + envelope + .envelopeStream(_options) + .listen(compressionSink.add) + .onDone(compressionSink.close); + } else { + envelope + .envelopeStream(_options) + .listen(streamedRequest.sink.add) + .onDone(streamedRequest.sink.close); + } + + streamedRequest.headers.addAll(_credentialBuilder.configure(_headers)); + return streamedRequest; + } +} + +Map _buildHeaders(bool isWeb, String sdkIdentifier) { + final headers = {'Content-Type': 'application/x-sentry-envelope'}; + // NOTE(lejard_h) overriding user agent on VM and Flutter not sure why + // for web it use browser user agent + if (!isWeb) { + headers['User-Agent'] = sdkIdentifier; + } + return headers; +} + +class _CredentialBuilder { + final String _authHeader; + + _CredentialBuilder._(String authHeader) : _authHeader = authHeader; + + factory _CredentialBuilder(Dsn dsn, String sdkIdentifier) { + final authHeader = _buildAuthHeader( + publicKey: dsn.publicKey, + secretKey: dsn.secretKey, + sdkIdentifier: sdkIdentifier, + ); + + return _CredentialBuilder._(authHeader); + } + + static String _buildAuthHeader({ + required String publicKey, + String? secretKey, + required String sdkIdentifier, + }) { + var header = 'Sentry sentry_version=7, sentry_client=$sdkIdentifier, ' + 'sentry_key=$publicKey'; + + if (secretKey != null) { + header += ', sentry_secret=$secretKey'; + } + + return header; + } + + Map configure(Map headers) { + return headers + ..addAll( + {'X-Sentry-Auth': _authHeader}, + ); + } +} diff --git a/dart/lib/src/transport/spotlight_http_transport.dart b/dart/lib/src/transport/spotlight_http_transport.dart new file mode 100644 index 0000000000..f51e77d478 --- /dev/null +++ b/dart/lib/src/transport/spotlight_http_transport.dart @@ -0,0 +1,52 @@ +import 'package:http/http.dart'; +import '../utils/transport_utils.dart'; +import 'http_transport_request_handler.dart'; + +import '../../sentry.dart'; +import '../noop_client.dart'; + +/// Spotlight HTTP transport decorator that sends Sentry envelopes to both Sentry and Spotlight. +class SpotlightHttpTransport extends Transport { + final SentryOptions _options; + final Transport _transport; + final HttpTransportRequestHandler _requestHandler; + + factory SpotlightHttpTransport(SentryOptions options, Transport transport) { + if (options.httpClient is NoOpClient) { + options.httpClient = Client(); + } + return SpotlightHttpTransport._(options, transport); + } + + SpotlightHttpTransport._(this._options, this._transport) + : _requestHandler = HttpTransportRequestHandler( + _options, Uri.parse(_options.spotlight.url)); + + @override + Future send(SentryEnvelope envelope) async { + try { + await _sendToSpotlight(envelope); + } catch (e) { + _options.logger( + SentryLevel.warning, 'Failed to send envelope to Spotlight: $e'); + } + return _transport.send(envelope); + } + + Future _sendToSpotlight(SentryEnvelope envelope) async { + envelope.header.sentAt = _options.clock(); + + // Screenshots do not work currently https://github.com/getsentry/spotlight/issues/274 + envelope.items + .removeWhere((element) => element.header.contentType == 'image/png'); + + final spotlightRequest = await _requestHandler.createRequest(envelope); + + final response = await _options.httpClient + .send(spotlightRequest) + .then(Response.fromStream); + + TransportUtils.logResponse(_options, envelope, response, + target: 'Spotlight'); + } +} diff --git a/dart/lib/src/utils/transport_utils.dart b/dart/lib/src/utils/transport_utils.dart new file mode 100644 index 0000000000..388db8e8d5 --- /dev/null +++ b/dart/lib/src/utils/transport_utils.dart @@ -0,0 +1,30 @@ +import 'package:http/http.dart'; + +import '../../sentry_io.dart'; +import '../client_reports/discard_reason.dart'; +import '../transport/data_category.dart'; + +class TransportUtils { + static void logResponse( + SentryOptions options, SentryEnvelope envelope, Response response, + {required String target}) { + if (response.statusCode != 200) { + if (options.debug) { + options.logger( + SentryLevel.error, + 'Error, statusCode = ${response.statusCode}, body = ${response.body}', + ); + } + + if (response.statusCode >= 400 && response.statusCode != 429) { + options.recorder + .recordLostEvent(DiscardReason.networkError, DataCategory.error); + } + } else { + options.logger( + SentryLevel.debug, + 'Envelope ${envelope.header.eventId ?? "--"} was sent successfully to $target.', + ); + } + } +} diff --git a/dart/test/environment_test.dart b/dart/test/environment_test.dart index e4bf97fea7..b16225d1dd 100644 --- a/dart/test/environment_test.dart +++ b/dart/test/environment_test.dart @@ -39,7 +39,7 @@ void main() { test('SentryOptions are overriden by environment', () async { final options = SentryOptions(); options.environmentVariables = MockEnvironmentVariables( - dsn: 'foo-bar', + dsn: fakeDsn, environment: 'staging', release: 'release-9.8.7', dist: 'bar', @@ -51,7 +51,7 @@ void main() { options: options, ); - expect(options.dsn, 'foo-bar'); + expect(options.dsn, fakeDsn); expect(options.environment, 'staging'); expect(options.release, 'release-9.8.7'); expect(options.dist, 'bar'); diff --git a/dart/test/sentry_client_test.dart b/dart/test/sentry_client_test.dart index cdbbd73950..63c83a321f 100644 --- a/dart/test/sentry_client_test.dart +++ b/dart/test/sentry_client_test.dart @@ -12,6 +12,7 @@ import 'package:sentry/src/sentry_item_type.dart'; import 'package:sentry/src/sentry_stack_trace_factory.dart'; import 'package:sentry/src/sentry_tracer.dart'; import 'package:sentry/src/transport/data_category.dart'; +import 'package:sentry/src/transport/spotlight_http_transport.dart'; import 'package:test/test.dart'; import 'mocks.dart'; @@ -1706,6 +1707,14 @@ void main() { expect(capturedEnvelope.header.dsn, fixture.options.dsn); }); + + test('Spotlight enabled should set transport to SpotlightHttpTransport', + () async { + fixture.options.spotlight = Spotlight(enabled: true); + fixture.getSut(); + + expect(fixture.options.transport is SpotlightHttpTransport, true); + }); }); } diff --git a/dart/test/sentry_options_test.dart b/dart/test/sentry_options_test.dart index f6dec29e90..e921c87b32 100644 --- a/dart/test/sentry_options_test.dart +++ b/dart/test/sentry_options_test.dart @@ -127,4 +127,10 @@ void main() { expect(options.isTracingEnabled(), false); }); + + test('Spotlight is disabled by default', () { + final options = SentryOptions(dsn: fakeDsn); + + expect(options.spotlight.enabled, false); + }); } diff --git a/dart/test/transport/spotlight_http_transport_test.dart b/dart/test/transport/spotlight_http_transport_test.dart new file mode 100644 index 0000000000..b23f1fd87f --- /dev/null +++ b/dart/test/transport/spotlight_http_transport_test.dart @@ -0,0 +1,70 @@ +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:sentry/sentry.dart'; +import 'package:sentry/src/transport/http_transport.dart'; +import 'package:sentry/src/transport/rate_limiter.dart'; +import 'package:sentry/src/transport/spotlight_http_transport.dart'; +import 'package:test/expect.dart'; +import 'package:test/scaffolding.dart'; + +import '../mocks.dart'; +import '../mocks/mock_client_report_recorder.dart'; + +void main() { + group('send to Sentry', () { + late Fixture fixture; + + setUp(() { + fixture = Fixture(); + }); + + test('send event to Sentry even if Spotlight fails', () async { + List? body; + + final httpMock = MockClient((http.Request request) async { + body = request.bodyBytes; + if (request.url.toString() == fixture.options.spotlight.url) { + return http.Response('{}', 500); + } + return http.Response('{}', 200); + }); + + fixture.options.compressPayload = false; + final mockRateLimiter = MockRateLimiter(); + final sut = fixture.getSut(httpMock, mockRateLimiter); + + final sentryEvent = SentryEvent(); + final envelope = SentryEnvelope.fromEvent( + sentryEvent, + fixture.options.sdk, + dsn: fixture.options.dsn, + ); + await sut.send(envelope); + + final envelopeData = []; + await envelope + .envelopeStream(fixture.options) + .forEach(envelopeData.addAll); + + expect(body, envelopeData); + }); + }); +} + +class Fixture { + final options = SentryOptions( + dsn: 'https://public:secret@sentry.example.com/1', + ); + + late var clientReportRecorder = MockClientReportRecorder(); + + Transport getSut(http.Client client, RateLimiter rateLimiter) { + options.httpClient = client; + options.recorder = clientReportRecorder; + options.clock = () { + return DateTime.utc(2019); + }; + final httpTransport = HttpTransport(options, rateLimiter); + return SpotlightHttpTransport(options, httpTransport); + } +}