diff --git a/.github/workflows/analyze.yml b/.github/workflows/analyze.yml index 9b418a78d9..411c29e4b2 100644 --- a/.github/workflows/analyze.yml +++ b/.github/workflows/analyze.yml @@ -60,6 +60,11 @@ jobs: timeout-minutes: 20 steps: - uses: actions/checkout@v3 + - name: Apply dependency override + if: ${{ inputs.package == 'flutter' }} + working-directory: ${{ inputs.package }} + run: | + sed -i.bak 's|sentry:.*|sentry:\n path: /github/workspace/dart|g' pubspec.yaml - uses: axel-op/dart-package-analyzer@7a6c3c66bce78d82b729a1ffef2d9458fde6c8d2 # pin@v3 id: analysis with: diff --git a/.github/workflows/flutter.yml b/.github/workflows/flutter.yml index 90784f4c2e..8afd04f4b1 100644 --- a/.github/workflows/flutter.yml +++ b/.github/workflows/flutter.yml @@ -116,6 +116,7 @@ jobs: with: path: "./flutter/coverage/lcov.info" min_coverage: 90 + exclude: "lib/src/native/cocoa/binding.dart" - name: Build ${{ matrix.target }} run: | diff --git a/.github/workflows/flutter_test.yml b/.github/workflows/flutter_test.yml index 502efc1375..d0875a8604 100644 --- a/.github/workflows/flutter_test.yml +++ b/.github/workflows/flutter_test.yml @@ -104,7 +104,7 @@ jobs: avd-name: macOS-avd-x86_64-31 emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none disable-animations: true - script: flutter test integration_test --verbose + script: flutter test integration_test/all.dart --verbose cocoa: name: "${{ matrix.target }} | ${{ matrix.sdk }}" @@ -155,7 +155,7 @@ jobs: - name: run integration test # Disable flutter integration tests for iOS for now (https://github.com/getsentry/sentry-dart/issues/1605#issuecomment-1695809346) if: ${{ matrix.target != 'ios' }} - run: flutter test -d "${{ steps.device.outputs.name }}" integration_test --verbose + run: flutter test -d "${{ steps.device.outputs.name }}" integration_test/all.dart --verbose - name: run native test # We only have the native unit test package in the iOS xcodeproj at the moment. diff --git a/CHANGELOG.md b/CHANGELOG.md index d33691189d..e3507770f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,10 +2,15 @@ ## Unreleased -### Features +### Enhancements +- Log warning if both tracesSampleRate and tracesSampler are set ([#1701](https://github.com/getsentry/sentry-dart/pull/1701)) - Better Flutter framework stack traces - we now collect Flutter framework debug symbols for iOS, macOS and Android automatically on the Sentry server ([#1673](https://github.com/getsentry/sentry-dart/pull/1673)) +### Features + +- Initial (alpha) support for profiling on iOS and macOS ([#1611](https://github.com/getsentry/sentry-dart/pull/1611)) + ## 7.11.0 ### Fixes diff --git a/dart/analysis_options.yaml b/dart/analysis_options.yaml index c5924fcb20..74ed00b114 100644 --- a/dart/analysis_options.yaml +++ b/dart/analysis_options.yaml @@ -3,6 +3,7 @@ include: package:lints/recommended.yaml analyzer: exclude: - example/** # the example has its own 'analysis_options.yaml' + - test/*.mocks.dart errors: # treat missing required parameters as a warning (not a hint) missing_required_param: error diff --git a/dart/lib/src/hub.dart b/dart/lib/src/hub.dart index 2693996d2a..4c09f1722f 100644 --- a/dart/lib/src/hub.dart +++ b/dart/lib/src/hub.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:collection'; import 'package:meta/meta.dart'; +import 'profiling.dart'; import 'propagation_context.dart'; import 'transport/data_category.dart'; @@ -435,12 +436,12 @@ class Hub { } else { final item = _peek(); - final samplingContext = SentrySamplingContext( - transactionContext, customSamplingContext ?? {}); - // if transactionContext has no sampled decision, run the traces sampler - if (transactionContext.samplingDecision == null) { - final samplingDecision = _tracesSampler.sample(samplingContext); + var samplingDecision = transactionContext.samplingDecision; + if (samplingDecision == null) { + final samplingContext = SentrySamplingContext( + transactionContext, customSamplingContext ?? {}); + samplingDecision = _tracesSampler.sample(samplingContext); transactionContext = transactionContext.copyWith(samplingDecision: samplingDecision); } @@ -451,6 +452,12 @@ class Hub { ); } + SentryProfiler? profiler; + if (_profilerFactory != null && + _tracesSampler.sampleProfiling(samplingDecision)) { + profiler = _profilerFactory?.startProfiler(transactionContext); + } + final tracer = SentryTracer( transactionContext, this, @@ -459,6 +466,7 @@ class Hub { autoFinishAfter: autoFinishAfter, trimEnd: trimEnd ?? false, onFinish: onFinish, + profiler: profiler, ); if (bindToScope ?? false) { item.scope.span = tracer; @@ -554,6 +562,14 @@ class Hub { ) => _throwableToSpan.add(throwable, span, transaction); + @internal + SentryProfilerFactory? get profilerFactory => _profilerFactory; + + @internal + set profilerFactory(SentryProfilerFactory? value) => _profilerFactory = value; + + SentryProfilerFactory? _profilerFactory; + SentryEvent _assignTraceContext(SentryEvent event) { // assign trace context if (event.throwable != null && event.contexts.trace == null) { diff --git a/dart/lib/src/hub_adapter.dart b/dart/lib/src/hub_adapter.dart index 0775042e04..8a9107ae54 100644 --- a/dart/lib/src/hub_adapter.dart +++ b/dart/lib/src/hub_adapter.dart @@ -4,6 +4,7 @@ import 'package:meta/meta.dart'; import 'hint.dart'; import 'hub.dart'; +import 'profiling.dart'; import 'protocol.dart'; import 'scope.dart'; import 'sentry.dart'; @@ -168,6 +169,16 @@ class HubAdapter implements Hub { ) => Sentry.currentHub.setSpanContext(throwable, span, transaction); + @internal + @override + set profilerFactory(SentryProfilerFactory? value) => + Sentry.currentHub.profilerFactory = value; + + @internal + @override + SentryProfilerFactory? get profilerFactory => + Sentry.currentHub.profilerFactory; + @override Scope get scope => Sentry.currentHub.scope; } diff --git a/dart/lib/src/noop_hub.dart b/dart/lib/src/noop_hub.dart index 28a56de249..06d31e7da2 100644 --- a/dart/lib/src/noop_hub.dart +++ b/dart/lib/src/noop_hub.dart @@ -4,6 +4,7 @@ import 'package:meta/meta.dart'; import 'hint.dart'; import 'hub.dart'; +import 'profiling.dart'; import 'protocol.dart'; import 'scope.dart'; import 'sentry_client.dart'; @@ -120,6 +121,14 @@ class NoOpHub implements Hub { @override void setSpanContext(throwable, ISentrySpan span, String transaction) {} + @internal + @override + set profilerFactory(SentryProfilerFactory? value) {} + + @internal + @override + SentryProfilerFactory? get profilerFactory => null; + @override Scope get scope => Scope(_options); } diff --git a/dart/lib/src/profiling.dart b/dart/lib/src/profiling.dart new file mode 100644 index 0000000000..d0ed997313 --- /dev/null +++ b/dart/lib/src/profiling.dart @@ -0,0 +1,22 @@ +import 'dart:async'; + +import 'package:meta/meta.dart'; + +import '../sentry.dart'; + +@internal +abstract class SentryProfilerFactory { + SentryProfiler? startProfiler(SentryTransactionContext context); +} + +@internal +abstract class SentryProfiler { + Future finishFor(SentryTransaction transaction); + void dispose(); +} + +// See https://develop.sentry.dev/sdk/profiles/ +@internal +abstract class SentryProfileInfo { + SentryEnvelopeItem asEnvelopeItem(); +} diff --git a/dart/lib/src/protocol/sentry_event.dart b/dart/lib/src/protocol/sentry_event.dart index e51441cd7d..32a76b9885 100644 --- a/dart/lib/src/protocol/sentry_event.dart +++ b/dart/lib/src/protocol/sentry_event.dart @@ -59,7 +59,7 @@ class SentryEvent with SentryEventLike { /// The ID Sentry.io assigned to the submitted event for future reference. final SentryId eventId; - /// A timestamp representing when the breadcrumb occurred. + /// A timestamp representing when the event occurred. final DateTime? timestamp; /// A string representing the platform the SDK is submitting from. This will be used by the Sentry interface to customize various components in the interface. diff --git a/dart/lib/src/protocol/sentry_transaction.dart b/dart/lib/src/protocol/sentry_transaction.dart index 44e6c4298f..986169ce46 100644 --- a/dart/lib/src/protocol/sentry_transaction.dart +++ b/dart/lib/src/protocol/sentry_transaction.dart @@ -10,12 +10,13 @@ class SentryTransaction extends SentryEvent { late final DateTime startTimestamp; static const String _type = 'transaction'; late final List spans; - final SentryTracer _tracer; + @internal + final SentryTracer tracer; late final Map measurements; late final SentryTransactionInfo? transactionInfo; SentryTransaction( - this._tracer, { + this.tracer, { SentryId? eventId, DateTime? timestamp, String? platform, @@ -39,17 +40,17 @@ class SentryTransaction extends SentryEvent { SentryTransactionInfo? transactionInfo, }) : super( eventId: eventId, - timestamp: timestamp ?? _tracer.endTimestamp, + timestamp: timestamp ?? tracer.endTimestamp, platform: platform, serverName: serverName, release: release, dist: dist, environment: environment, - transaction: transaction ?? _tracer.name, - throwable: throwable ?? _tracer.throwable, - tags: tags ?? _tracer.tags, + transaction: transaction ?? tracer.name, + throwable: throwable ?? tracer.throwable, + tags: tags ?? tracer.tags, // ignore: deprecated_member_use_from_same_package - extra: extra ?? _tracer.data, + extra: extra ?? tracer.data, user: user, contexts: contexts, breadcrumbs: breadcrumbs, @@ -57,19 +58,19 @@ class SentryTransaction extends SentryEvent { request: request, type: _type, ) { - startTimestamp = _tracer.startTimestamp; + startTimestamp = tracer.startTimestamp; - final spanContext = _tracer.context; - spans = _tracer.children; + final spanContext = tracer.context; + spans = tracer.children; this.measurements = measurements ?? {}; this.contexts.trace = spanContext.toTraceContext( - sampled: _tracer.samplingDecision?.sampled, - status: _tracer.status, + sampled: tracer.samplingDecision?.sampled, + status: tracer.status, ); this.transactionInfo = transactionInfo ?? - SentryTransactionInfo(_tracer.transactionNameSource.name); + SentryTransactionInfo(tracer.transactionNameSource.name); } @override @@ -136,7 +137,7 @@ class SentryTransaction extends SentryEvent { SentryTransactionInfo? transactionInfo, }) => SentryTransaction( - _tracer, + tracer, eventId: eventId ?? this.eventId, timestamp: timestamp ?? this.timestamp, platform: platform ?? this.platform, diff --git a/dart/lib/src/sentry_client.dart b/dart/lib/src/sentry_client.dart index b3508db4d1..62d2072b85 100644 --- a/dart/lib/src/sentry_client.dart +++ b/dart/lib/src/sentry_client.dart @@ -345,6 +345,11 @@ class SentryClient { traceContext: traceContext, attachments: attachments, ); + + final profileInfo = preparedTransaction.tracer.profileInfo; + if (profileInfo != null) { + envelope.items.add(profileInfo.asEnvelopeItem()); + } final id = await captureEnvelope(envelope); return id ?? SentryId.empty(); diff --git a/dart/lib/src/sentry_envelope_item.dart b/dart/lib/src/sentry_envelope_item.dart index f88f808600..5e38a7123d 100644 --- a/dart/lib/src/sentry_envelope_item.dart +++ b/dart/lib/src/sentry_envelope_item.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'dart:convert'; + import 'client_reports/client_report.dart'; import 'protocol.dart'; import 'utils.dart'; @@ -97,6 +98,7 @@ class SentryEnvelopeItem { final newLine = utf8.encode('\n'); final data = await dataFactory(); + // TODO the data copy could be avoided - this would be most significant with attachments. return [...itemHeader, ...newLine, ...data]; } catch (e) { return []; diff --git a/dart/lib/src/sentry_item_type.dart b/dart/lib/src/sentry_item_type.dart index c74b6b6049..6215cbb78f 100644 --- a/dart/lib/src/sentry_item_type.dart +++ b/dart/lib/src/sentry_item_type.dart @@ -4,5 +4,6 @@ class SentryItemType { static const String attachment = 'attachment'; static const String transaction = 'transaction'; static const String clientReport = 'client_report'; + static const String profile = 'profile'; static const String unknown = '__unknown__'; } diff --git a/dart/lib/src/sentry_options.dart b/dart/lib/src/sentry_options.dart index 76d3365386..27d95fb9f3 100644 --- a/dart/lib/src/sentry_options.dart +++ b/dart/lib/src/sentry_options.dart @@ -289,6 +289,17 @@ class SentryOptions { /// to be sent to Sentry. TracesSamplerCallback? tracesSampler; + double? _profilesSampleRate; + + @internal // Only exposed by SentryFlutterOptions at the moment. + double? get profilesSampleRate => _profilesSampleRate; + + @internal // Only exposed by SentryFlutterOptions at the moment. + set profilesSampleRate(double? value) { + assert(value == null || (value >= 0 && value <= 1)); + _profilesSampleRate = value; + } + /// Send statistics to sentry when the client drops events. bool sendClientReports = true; diff --git a/dart/lib/src/sentry_tracer.dart b/dart/lib/src/sentry_tracer.dart index 46edb2bccb..6012a13bfb 100644 --- a/dart/lib/src/sentry_tracer.dart +++ b/dart/lib/src/sentry_tracer.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:meta/meta.dart'; import '../sentry.dart'; +import 'profiling.dart'; import 'sentry_tracer_finish_status.dart'; import 'utils/sample_rate_format.dart'; @@ -31,6 +32,13 @@ class SentryTracer extends ISentrySpan { SentryTraceContextHeader? _sentryTraceContextHeader; + // Profiler attached to this tracer. + late final SentryProfiler? profiler; + + // Resulting profile, after it has been collected. This is later used by + // SentryClient to attach as an envelope item when sending the transaction. + SentryProfileInfo? profileInfo; + /// If [waitForChildren] is true, this transaction will not finish until all /// its children are finished. /// @@ -52,6 +60,7 @@ class SentryTracer extends ISentrySpan { Duration? autoFinishAfter, bool trimEnd = false, OnTransactionFinish? onFinish, + this.profiler, }) { _rootSpan = SentrySpan( this, @@ -77,8 +86,13 @@ class SentryTracer extends ISentrySpan { final commonEndTimestamp = endTimestamp ?? _hub.options.clock(); _autoFinishAfterTimer?.cancel(); _finishStatus = SentryTracerFinishStatus.finishing(status); - if (!_rootSpan.finished && - (!_waitForChildren || _haveAllChildrenFinished())) { + if (_rootSpan.finished) { + return; + } + if (_waitForChildren && !_haveAllChildrenFinished()) { + return; + } + try { _rootSpan.status ??= status; // remove span where its endTimestamp is before startTimestamp @@ -131,10 +145,17 @@ class SentryTracer extends ISentrySpan { final transaction = SentryTransaction(this); transaction.measurements.addAll(_measurements); + + profileInfo = (status == null || status == SpanStatus.ok()) + ? await profiler?.finishFor(transaction) + : null; + await _hub.captureTransaction( transaction, traceContext: traceContext(), ); + } finally { + profiler?.dispose(); } } diff --git a/dart/lib/src/sentry_traces_sampler.dart b/dart/lib/src/sentry_traces_sampler.dart index 06ab2edcea..b1668084c9 100644 --- a/dart/lib/src/sentry_traces_sampler.dart +++ b/dart/lib/src/sentry_traces_sampler.dart @@ -14,7 +14,12 @@ class SentryTracesSampler { SentryTracesSampler( this._options, { Random? random, - }) : _random = random ?? Random(); + }) : _random = random ?? Random() { + if (_options.tracesSampler != null && _options.tracesSampleRate != null) { + _options.logger(SentryLevel.warning, + 'Both tracesSampler and traceSampleRate are set. tracesSampler will take precedence and fallback to traceSampleRate if it returns null.'); + } + } SentryTracesSamplingDecision sample(SentrySamplingContext samplingContext) { final samplingDecision = @@ -67,5 +72,13 @@ class SentryTracesSampler { return SentryTracesSamplingDecision(false); } + bool sampleProfiling(SentryTracesSamplingDecision tracesSamplingDecision) { + double? optionsRate = _options.profilesSampleRate; + if (optionsRate == null || !tracesSamplingDecision.sampled) { + return false; + } + return _sample(optionsRate); + } + bool _sample(double result) => !(result < _random.nextDouble()); } diff --git a/dart/pubspec.yaml b/dart/pubspec.yaml index 7520a9004d..f77b7868e5 100644 --- a/dart/pubspec.yaml +++ b/dart/pubspec.yaml @@ -18,6 +18,7 @@ dependencies: uuid: '>=3.0.0 <5.0.0' dev_dependencies: + build_runner: ^2.4.2 mockito: ^5.1.0 lints: ^2.0.0 test: ^1.21.1 diff --git a/dart/test/hub_test.dart b/dart/test/hub_test.dart index 8cc4e99502..37122d43b6 100644 --- a/dart/test/hub_test.dart +++ b/dart/test/hub_test.dart @@ -1,4 +1,5 @@ import 'package:collection/collection.dart'; +import 'package:mockito/mockito.dart'; import 'package:sentry/sentry.dart'; import 'package:sentry/src/client_reports/discard_reason.dart'; import 'package:sentry/src/sentry_tracer.dart'; @@ -6,6 +7,7 @@ import 'package:sentry/src/transport/data_category.dart'; import 'package:test/test.dart'; import 'mocks.dart'; +import 'mocks.mocks.dart'; import 'mocks/mock_client_report_recorder.dart'; import 'mocks/mock_sentry_client.dart'; @@ -375,6 +377,62 @@ void main() { fixture.client.captureTransactionCalls.first.traceContext, context); }); + test('profiler is not started by default', () async { + final hub = fixture.getSut(); + final tr = hub.startTransaction('name', 'op'); + expect(tr, isA()); + expect((tr as SentryTracer).profiler, isNull); + }); + + test('profiler is started according to the sampling rate', () async { + final hub = fixture.getSut(); + final factory = MockSentryProfilerFactory(); + when(factory.startProfiler(fixture._context)) + .thenReturn(MockSentryProfiler()); + hub.profilerFactory = factory; + + var tr = hub.startTransactionWithContext(fixture._context); + expect((tr as SentryTracer).profiler, isNull); + verifyZeroInteractions(factory); + + hub.options.profilesSampleRate = 1.0; + tr = hub.startTransactionWithContext(fixture._context); + expect((tr as SentryTracer).profiler, isNotNull); + verify(factory.startProfiler(fixture._context)).called(1); + }); + + test('profiler.finish() is called', () async { + final hub = fixture.getSut(); + final factory = MockSentryProfilerFactory(); + final profiler = MockSentryProfiler(); + final expected = MockSentryProfileInfo(); + when(factory.startProfiler(fixture._context)).thenReturn(profiler); + when(profiler.finishFor(any)).thenAnswer((_) async => expected); + + hub.profilerFactory = factory; + hub.options.profilesSampleRate = 1.0; + final tr = hub.startTransactionWithContext(fixture._context); + await tr.finish(); + verify(profiler.finishFor(any)).called(1); + verify(profiler.dispose()).called(1); + }); + + test('profiler.dispose() is called even if not captured', () async { + final hub = fixture.getSut(); + final factory = MockSentryProfilerFactory(); + final profiler = MockSentryProfiler(); + final expected = MockSentryProfileInfo(); + when(factory.startProfiler(fixture._context)).thenReturn(profiler); + when(profiler.finishFor(any)).thenAnswer((_) async => expected); + + hub.profilerFactory = factory; + hub.options.profilesSampleRate = 1.0; + final tr = hub.startTransactionWithContext(fixture._context); + await tr.finish(status: SpanStatus.aborted()); + verify(profiler.dispose()).called(1); + verifyNever(profiler.finishFor(any)); + }); + test('returns scope', () async { final hub = fixture.getSut(); @@ -649,10 +707,12 @@ class Fixture { final hub = Hub(options); + // A fully configured context - won't trigger a copy in startTransaction(). _context = SentryTransactionContext( 'name', 'op', samplingDecision: SentryTracesSamplingDecision(sampled!), + origin: SentryTraceOrigins.manual, ); tracer = SentryTracer(_context, hub); diff --git a/dart/test/mocks.dart b/dart/test/mocks.dart index 82430d96e8..b5fdd59aa9 100644 --- a/dart/test/mocks.dart +++ b/dart/test/mocks.dart @@ -1,4 +1,6 @@ +import 'package:mockito/annotations.dart'; import 'package:sentry/sentry.dart'; +import 'package:sentry/src/profiling.dart'; import 'package:sentry/src/transport/rate_limiter.dart'; final fakeDsn = 'https://abc@def.ingest.sentry.io/1234567'; @@ -149,3 +151,10 @@ class MockRateLimiter implements RateLimiter { this.errorCode = errorCode; } } + +@GenerateMocks([ + SentryProfilerFactory, + SentryProfiler, + SentryProfileInfo, +]) +void main() {} diff --git a/dart/test/mocks.mocks.dart b/dart/test/mocks.mocks.dart new file mode 100644 index 0000000000..5f2556400e --- /dev/null +++ b/dart/test/mocks.mocks.dart @@ -0,0 +1,101 @@ +// Mocks generated by Mockito 5.4.2 from annotations +// in sentry/test/mocks.dart. +// Do not manually edit this file. + +// ignore_for_file: no_leading_underscores_for_library_prefixes +import 'dart:async' as _i4; + +import 'package:mockito/mockito.dart' as _i1; +import 'package:sentry/sentry.dart' as _i2; +import 'package:sentry/src/profiling.dart' as _i3; + +// ignore_for_file: type=lint +// ignore_for_file: avoid_redundant_argument_values +// ignore_for_file: avoid_setters_without_getters +// ignore_for_file: comment_references +// ignore_for_file: implementation_imports +// ignore_for_file: invalid_use_of_visible_for_testing_member +// ignore_for_file: prefer_const_constructors +// ignore_for_file: unnecessary_parenthesis +// ignore_for_file: camel_case_types +// ignore_for_file: subtype_of_sealed_class + +class _FakeSentryEnvelopeItem_0 extends _i1.SmartFake + implements _i2.SentryEnvelopeItem { + _FakeSentryEnvelopeItem_0( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); +} + +/// A class which mocks [SentryProfilerFactory]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockSentryProfilerFactory extends _i1.Mock + implements _i3.SentryProfilerFactory { + MockSentryProfilerFactory() { + _i1.throwOnMissingStub(this); + } + + @override + _i3.SentryProfiler? startProfiler(_i2.SentryTransactionContext? context) => + (super.noSuchMethod(Invocation.method( + #startProfiler, + [context], + )) as _i3.SentryProfiler?); +} + +/// A class which mocks [SentryProfiler]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockSentryProfiler extends _i1.Mock implements _i3.SentryProfiler { + MockSentryProfiler() { + _i1.throwOnMissingStub(this); + } + + @override + _i4.Future<_i3.SentryProfileInfo?> finishFor( + _i2.SentryTransaction? transaction) => + (super.noSuchMethod( + Invocation.method( + #finishFor, + [transaction], + ), + returnValue: _i4.Future<_i3.SentryProfileInfo?>.value(), + ) as _i4.Future<_i3.SentryProfileInfo?>); + @override + void dispose() => super.noSuchMethod( + Invocation.method( + #dispose, + [], + ), + returnValueForMissingStub: null, + ); +} + +/// A class which mocks [SentryProfileInfo]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockSentryProfileInfo extends _i1.Mock implements _i3.SentryProfileInfo { + MockSentryProfileInfo() { + _i1.throwOnMissingStub(this); + } + + @override + _i2.SentryEnvelopeItem asEnvelopeItem() => (super.noSuchMethod( + Invocation.method( + #asEnvelopeItem, + [], + ), + returnValue: _FakeSentryEnvelopeItem_0( + this, + Invocation.method( + #asEnvelopeItem, + [], + ), + ), + ) as _i2.SentryEnvelopeItem); +} diff --git a/flutter/example/integration_test/all.dart b/flutter/example/integration_test/all.dart new file mode 100644 index 0000000000..69cc5a6641 --- /dev/null +++ b/flutter/example/integration_test/all.dart @@ -0,0 +1,8 @@ +// Workaround for https://github.com/flutter/flutter/issues/101031 +import 'integration_test.dart' as a; +import 'profiling_test.dart' as b; + +void main() { + a.main(); + b.main(); +} diff --git a/flutter/example/integration_test/profiling_test.dart b/flutter/example/integration_test/profiling_test.dart new file mode 100644 index 0000000000..e6c1db8bbd --- /dev/null +++ b/flutter/example/integration_test/profiling_test.dart @@ -0,0 +1,66 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:sentry_flutter/sentry_flutter.dart'; +import '../../../dart/test/mocks/mock_transport.dart'; + +void main() { + final transport = MockTransport(); + + setUp(() async { + await SentryFlutter.init((options) { + // ignore: invalid_use_of_internal_member + options.automatedTestMode = true; + options.dsn = 'https://abc@def.ingest.sentry.io/1234567'; + options.debug = true; + options.transport = transport; + options.tracesSampleRate = 1.0; + options.profilesSampleRate = 1.0; + }); + }); + + tearDown(() async { + await Sentry.close(); + transport.reset(); + }); + + test('native binding is initialized', () async { + // ignore: invalid_use_of_internal_member + expect(SentryFlutter.native, isNotNull); + }); + + test('profile is captured', () async { + final tx = Sentry.startTransaction("name", "op"); + await Future.delayed(const Duration(milliseconds: 1000)); + await tx.finish(); + expect(transport.calls, 1); + + final envelope = transport.envelopes.first; + expect(envelope.items.length, 2); + expect(envelope.items[0].header.type, "transaction"); + expect(await envelope.items[0].header.length(), greaterThan(0)); + expect(envelope.items[1].header.type, "profile"); + expect(await envelope.items[1].header.length(), greaterThan(0)); + + final txJson = utf8.decode(await envelope.items[0].dataFactory()); + final txData = json.decode(txJson) as Map; + + final profileJson = utf8.decode(await envelope.items[1].dataFactory()); + final profileData = json.decode(profileJson) as Map; + + expect(txData["event_id"], isNotNull); + expect(txData["event_id"], profileData["transaction"]["id"]); + expect(txData["contexts"]["trace"]["trace_id"], isNotNull); + expect(txData["contexts"]["trace"]["trace_id"], + profileData["transaction"]["trace_id"]); + expect(profileData["debug_meta"]["images"], isNotEmpty); + expect(profileData["profile"]["thread_metadata"], isNotEmpty); + expect(profileData["profile"]["samples"], isNotEmpty); + expect(profileData["profile"]["stacks"], isNotEmpty); + expect(profileData["profile"]["frames"], isNotEmpty); + }, + skip: (Platform.isMacOS || Platform.isIOS) + ? false + : "Profiling is not supported on this platform"); +} diff --git a/flutter/example/lib/main.dart b/flutter/example/lib/main.dart index 6d657a589e..3d044c155d 100644 --- a/flutter/example/lib/main.dart +++ b/flutter/example/lib/main.dart @@ -48,6 +48,7 @@ Future setupSentry(AppRunner appRunner, String dsn, await SentryFlutter.init((options) { options.dsn = exampleDsn; options.tracesSampleRate = 1.0; + options.profilesSampleRate = 1.0; options.reportPackages = false; options.addInAppInclude('sentry_flutter_example'); options.considerInAppFramesByDefault = false; @@ -327,7 +328,7 @@ class MainScaffold extends StatelessWidget { status: const SpanStatus.internalError()); await Future.delayed(const Duration(milliseconds: 50)); - + // findPrimeNumber(1000000); // Uncomment to see it with profiling await transaction.finish(status: const SpanStatus.ok()); }, child: const Text('Capture transaction'), @@ -855,3 +856,26 @@ class ThemeProvider extends ChangeNotifier { Future execute(String method) async { await _channel.invokeMethod(method); } + +// Don't inline this one or it shows up as an anonymous closure in profiles. +@pragma("vm:never-inline") +int findPrimeNumber(int n) { + int count = 0; + int a = 2; + while (count < n) { + int b = 2; + bool prime = true; // to check if found a prime + while (b * b <= a) { + if (a % b == 0) { + prime = false; + break; + } + b++; + } + if (prime) { + count++; + } + a++; + } + return a - 1; +} diff --git a/flutter/example/pubspec.yaml b/flutter/example/pubspec.yaml index 5331b8d0de..d2ed7bba04 100644 --- a/flutter/example/pubspec.yaml +++ b/flutter/example/pubspec.yaml @@ -36,6 +36,7 @@ dev_dependencies: sdk: flutter flutter_test: sdk: flutter + test: ^1.21.1 flutter: uses-material-design: true diff --git a/flutter/ios/Classes/SentryFlutterPluginApple.swift b/flutter/ios/Classes/SentryFlutterPluginApple.swift index 5a41c2fcc2..be5e301791 100644 --- a/flutter/ios/Classes/SentryFlutterPluginApple.swift +++ b/flutter/ios/Classes/SentryFlutterPluginApple.swift @@ -152,6 +152,14 @@ public class SentryFlutterPluginApple: NSObject, FlutterPlugin { let key = arguments?["key"] as? String removeTag(key: key, result: result) + #if !os(tvOS) && !os(watchOS) + case "discardProfiler": + discardProfiler(call, result) + + case "collectProfile": + collectProfile(call, result) + #endif + default: result(FlutterMethodNotImplemented) } @@ -352,8 +360,8 @@ public class SentryFlutterPluginApple: NSObject, FlutterPlugin { private func captureEnvelope(_ call: FlutterMethodCall, result: @escaping FlutterResult) { guard let arguments = call.arguments as? [Any], !arguments.isEmpty, - let data = (arguments.first as? FlutterStandardTypedData)?.data else { - print("Envelope is null or empty !") + let data = (arguments.first as? FlutterStandardTypedData)?.data else { + print("Envelope is null or empty!") result(FlutterError(code: "2", message: "Envelope is null or empty", details: nil)) return } @@ -385,8 +393,8 @@ public class SentryFlutterPluginApple: NSObject, FlutterPlugin { result(item) #else - print("note: appStartMeasurement not available on this platform") - result(nil) + print("note: appStartMeasurement not available on this platform") + result(nil) #endif } @@ -550,6 +558,42 @@ public class SentryFlutterPluginApple: NSObject, FlutterPlugin { result("") } } + + private func collectProfile(_ call: FlutterMethodCall, _ result: @escaping FlutterResult) { + guard let arguments = call.arguments as? [String: Any], + let traceId = arguments["traceId"] as? String else { + print("Cannot collect profile: trace ID missing") + result(FlutterError(code: "6", message: "Cannot collect profile: trace ID missing", details: nil)) + return + } + + guard let startTime = arguments["startTime"] as? UInt64 else { + print("Cannot collect profile: start time missing") + result(FlutterError(code: "7", message: "Cannot collect profile: start time missing", details: nil)) + return + } + + guard let endTime = arguments["endTime"] as? UInt64 else { + print("Cannot collect profile: end time missing") + result(FlutterError(code: "8", message: "Cannot collect profile: end time missing", details: nil)) + return + } + + let payload = PrivateSentrySDKOnly.collectProfileBetween(startTime, and: endTime, + forTrace: SentryId(uuidString: traceId)) + result(payload) + } + + private func discardProfiler(_ call: FlutterMethodCall, _ result: @escaping FlutterResult) { + guard let traceId = call.arguments as? String else { + print("Cannot discard a profiler: trace ID missing") + result(FlutterError(code: "9", message: "Cannot discard a profiler: trace ID missing", details: nil)) + return + } + + PrivateSentrySDKOnly.discardProfiler(forTrace: SentryId(uuidString: traceId)) + result(nil) + } } // swiftlint:enable function_body_length diff --git a/flutter/lib/src/native/cocoa/sentry_native_cocoa.dart b/flutter/lib/src/native/cocoa/sentry_native_cocoa.dart new file mode 100644 index 0000000000..0e6817cb48 --- /dev/null +++ b/flutter/lib/src/native/cocoa/sentry_native_cocoa.dart @@ -0,0 +1,23 @@ +import 'dart:ffi'; + +import 'package:meta/meta.dart'; + +import '../../../sentry_flutter.dart'; +import '../sentry_native_channel.dart'; +import 'binding.dart' as cocoa; + +@internal +class SentryNativeCocoa extends SentryNativeChannel { + late final _lib = cocoa.SentryCocoa(DynamicLibrary.process()); + + SentryNativeCocoa(super.channel); + + @override + int? startProfiler(SentryId traceId) { + final cSentryId = cocoa.SentryId.alloc(_lib) + ..initWithUUIDString_(cocoa.NSString(_lib, traceId.toString())); + final startTime = + cocoa.PrivateSentrySDKOnly.startProfilerForTrace_(_lib, cSentryId); + return startTime; + } +} diff --git a/flutter/lib/src/native/factory.dart b/flutter/lib/src/native/factory.dart new file mode 100644 index 0000000000..981e1d6ead --- /dev/null +++ b/flutter/lib/src/native/factory.dart @@ -0,0 +1 @@ +export 'factory_real.dart' if (dart.library.html) 'factory_web.dart'; diff --git a/flutter/lib/src/native/factory_real.dart b/flutter/lib/src/native/factory_real.dart new file mode 100644 index 0000000000..3c918b7be7 --- /dev/null +++ b/flutter/lib/src/native/factory_real.dart @@ -0,0 +1,14 @@ +import 'package:flutter/services.dart'; + +import '../../sentry_flutter.dart'; +import 'cocoa/sentry_native_cocoa.dart'; +import 'sentry_native_binding.dart'; +import 'sentry_native_channel.dart'; + +SentryNativeBinding createBinding(PlatformChecker pc, MethodChannel channel) { + if (pc.platform.isIOS || pc.platform.isMacOS) { + return SentryNativeCocoa(channel); + } else { + return SentryNativeChannel(channel); + } +} diff --git a/flutter/lib/src/native/factory_web.dart b/flutter/lib/src/native/factory_web.dart new file mode 100644 index 0000000000..8038ea9780 --- /dev/null +++ b/flutter/lib/src/native/factory_web.dart @@ -0,0 +1,9 @@ +import 'package:flutter/services.dart'; + +import '../../sentry_flutter.dart'; +import 'sentry_native_binding.dart'; + +// This isn't actually called, see SentryFlutter.init() +SentryNativeBinding createBinding(PlatformChecker pc, MethodChannel channel) { + throw UnsupportedError("Native binding is not supported on this platform."); +} diff --git a/flutter/lib/src/native/sentry_native.dart b/flutter/lib/src/native/sentry_native.dart index 6250831299..b8d2206a8d 100644 --- a/flutter/lib/src/native/sentry_native.dart +++ b/flutter/lib/src/native/sentry_native.dart @@ -3,28 +3,17 @@ import 'dart:async'; import 'package:meta/meta.dart'; import '../../sentry_flutter.dart'; -import 'sentry_native_channel.dart'; +import 'sentry_native_binding.dart'; -/// [SentryNative] holds state that it fetches from to the native SDKs. Always -/// use the shared instance with [SentryNative()]. +/// [SentryNative] holds state that it fetches from to the native SDKs. +/// It forwards to platform-specific implementations of [SentryNativeBinding]. +/// Any errors are logged and ignored. @internal class SentryNative { - SentryNative._(); + final SentryOptions _options; + final SentryNativeBinding _binding; - static final SentryNative _instance = SentryNative._(); - - SentryNativeChannel? _nativeChannel; - - factory SentryNative() { - return _instance; - } - - SentryNativeChannel? get nativeChannel => _instance._nativeChannel; - - /// Provide [nativeChannel] for native communication. - set nativeChannel(SentryNativeChannel? nativeChannel) { - _instance._nativeChannel = nativeChannel; - } + SentryNative(this._options, this._binding); // AppStart @@ -41,62 +30,99 @@ class SentryNative { /// Fetch [NativeAppStart] from native channels. Can only be called once. Future fetchNativeAppStart() async { _didFetchAppStart = true; - return await _nativeChannel?.fetchNativeAppStart(); + return _invoke("fetchNativeAppStart", _binding.fetchNativeAppStart); } // NativeFrames - Future beginNativeFramesCollection() async { - await _nativeChannel?.beginNativeFrames(); - } + Future beginNativeFramesCollection() => + _invoke("beginNativeFrames", _binding.beginNativeFrames); - Future endNativeFramesCollection(SentryId traceId) async { - return await _nativeChannel?.endNativeFrames(traceId); - } + Future endNativeFramesCollection(SentryId traceId) => + _invoke("endNativeFrames", () => _binding.endNativeFrames(traceId)); // Scope - Future setContexts(String key, dynamic value) async { - return await _nativeChannel?.setContexts(key, value); - } + Future setContexts(String key, dynamic value) => + _invoke("setContexts", () => _binding.setContexts(key, value)); - Future removeContexts(String key) async { - return await _nativeChannel?.removeContexts(key); - } + Future removeContexts(String key) => + _invoke("removeContexts", () => _binding.removeContexts(key)); - Future setUser(SentryUser? sentryUser) async { - return await _nativeChannel?.setUser(sentryUser); - } + Future setUser(SentryUser? sentryUser) => + _invoke("setUser", () => _binding.setUser(sentryUser)); - Future addBreadcrumb(Breadcrumb breadcrumb) async { - return await _nativeChannel?.addBreadcrumb(breadcrumb); - } + Future addBreadcrumb(Breadcrumb breadcrumb) => + _invoke("addBreadcrumb", () => _binding.addBreadcrumb(breadcrumb)); - Future clearBreadcrumbs() async { - return await _nativeChannel?.clearBreadcrumbs(); - } + Future clearBreadcrumbs() => + _invoke("clearBreadcrumbs", _binding.clearBreadcrumbs); - Future setExtra(String key, dynamic value) async { - return await _nativeChannel?.setExtra(key, value); - } + Future setExtra(String key, dynamic value) => + _invoke("setExtra", () => _binding.setExtra(key, value)); - Future removeExtra(String key) async { - return await _nativeChannel?.removeExtra(key); - } + Future removeExtra(String key) => + _invoke("removeExtra", () => _binding.removeExtra(key)); - Future setTag(String key, String value) async { - return await _nativeChannel?.setTag(key, value); - } + Future setTag(String key, String value) => + _invoke("setTag", () => _binding.setTag(key, value)); - Future removeTag(String key) async { - return await _nativeChannel?.removeTag(key); - } + Future removeTag(String key) => + _invoke("removeTag", () => _binding.removeTag(key)); + + int? startProfiler(SentryId traceId) => + _invokeSync("startProfiler", () => _binding.startProfiler(traceId)); + + Future discardProfiler(SentryId traceId) => + _invoke("discardProfiler", () => _binding.discardProfiler(traceId)); + + Future?> collectProfile( + SentryId traceId, int startTimeNs, int endTimeNs) => + _invoke("collectProfile", + () => _binding.collectProfile(traceId, startTimeNs, endTimeNs)); /// Reset state void reset() { appStartEnd = null; _didFetchAppStart = false; } + + // Helpers + Future _invoke( + String nativeMethodName, Future Function() fn) async { + try { + return await fn(); + } catch (error, stackTrace) { + _logError(nativeMethodName, error, stackTrace); + // ignore: invalid_use_of_internal_member + if (_options.automatedTestMode) { + rethrow; + } + return null; + } + } + + T? _invokeSync(String nativeMethodName, T? Function() fn) { + try { + return fn(); + } catch (error, stackTrace) { + _logError(nativeMethodName, error, stackTrace); + // ignore: invalid_use_of_internal_member + if (_options.automatedTestMode) { + rethrow; + } + return null; + } + } + + void _logError(String nativeMethodName, Object error, StackTrace stackTrace) { + _options.logger( + SentryLevel.error, + 'Native call `$nativeMethodName` failed', + exception: error, + stackTrace: stackTrace, + ); + } } class NativeAppStart { diff --git a/flutter/lib/src/native/sentry_native_binding.dart b/flutter/lib/src/native/sentry_native_binding.dart new file mode 100644 index 0000000000..54d335d529 --- /dev/null +++ b/flutter/lib/src/native/sentry_native_binding.dart @@ -0,0 +1,43 @@ +import 'dart:async'; + +import 'package:meta/meta.dart'; + +import '../../sentry_flutter.dart'; +import 'sentry_native.dart'; + +/// Provide typed methods to access native layer. +@internal +abstract class SentryNativeBinding { + // TODO Move other native calls here. + + Future fetchNativeAppStart(); + + Future beginNativeFrames(); + + Future endNativeFrames(SentryId id); + + Future setUser(SentryUser? user); + + Future addBreadcrumb(Breadcrumb breadcrumb); + + Future clearBreadcrumbs(); + + Future setContexts(String key, dynamic value); + + Future removeContexts(String key); + + Future setExtra(String key, dynamic value); + + Future removeExtra(String key); + + Future setTag(String key, String value); + + Future removeTag(String key); + + int? startProfiler(SentryId traceId); + + Future discardProfiler(SentryId traceId); + + Future?> collectProfile( + SentryId traceId, int startTimeNs, int endTimeNs); +} diff --git a/flutter/lib/src/native/sentry_native_channel.dart b/flutter/lib/src/native/sentry_native_channel.dart index 63fd53bd06..4bf9745cb3 100644 --- a/flutter/lib/src/native/sentry_native_channel.dart +++ b/flutter/lib/src/native/sentry_native_channel.dart @@ -6,147 +6,102 @@ import 'package:meta/meta.dart'; import '../../sentry_flutter.dart'; import 'sentry_native.dart'; import 'method_channel_helper.dart'; +import 'sentry_native_binding.dart'; /// Provide typed methods to access native layer via MethodChannel. @internal -class SentryNativeChannel { - SentryNativeChannel(this._channel, this._options); +class SentryNativeChannel implements SentryNativeBinding { + SentryNativeChannel(this._channel); final MethodChannel _channel; - final SentryFlutterOptions _options; // TODO Move other native calls here. + @override Future fetchNativeAppStart() async { - try { - final json = await _channel - .invokeMapMethod('fetchNativeAppStart'); - return (json != null) ? NativeAppStart.fromJson(json) : null; - } catch (error, stackTrace) { - _logError('fetchNativeAppStart', error, stackTrace); - return null; - } + final json = + await _channel.invokeMapMethod('fetchNativeAppStart'); + return (json != null) ? NativeAppStart.fromJson(json) : null; } - Future beginNativeFrames() async { - try { - await _channel.invokeMethod('beginNativeFrames'); - } catch (error, stackTrace) { - _logError('beginNativeFrames', error, stackTrace); - } - } + @override + Future beginNativeFrames() => + _channel.invokeMethod('beginNativeFrames'); + @override Future endNativeFrames(SentryId id) async { - try { - final json = await _channel.invokeMapMethod( - 'endNativeFrames', {'id': id.toString()}); - return (json != null) ? NativeFrames.fromJson(json) : null; - } catch (error, stackTrace) { - _logError('endNativeFrames', error, stackTrace); - return null; - } + final json = await _channel.invokeMapMethod( + 'endNativeFrames', {'id': id.toString()}); + return (json != null) ? NativeFrames.fromJson(json) : null; } + @override Future setUser(SentryUser? user) async { - try { - final normalizedUser = user?.copyWith( - data: MethodChannelHelper.normalizeMap(user.data), - ); - await _channel.invokeMethod( - 'setUser', - {'user': normalizedUser?.toJson()}, - ); - } catch (error, stackTrace) { - _logError('setUser', error, stackTrace); - } + final normalizedUser = user?.copyWith( + data: MethodChannelHelper.normalizeMap(user.data), + ); + await _channel.invokeMethod( + 'setUser', + {'user': normalizedUser?.toJson()}, + ); } + @override Future addBreadcrumb(Breadcrumb breadcrumb) async { - try { - final normalizedBreadcrumb = breadcrumb.copyWith( - data: MethodChannelHelper.normalizeMap(breadcrumb.data), - ); - await _channel.invokeMethod( - 'addBreadcrumb', - {'breadcrumb': normalizedBreadcrumb.toJson()}, - ); - } catch (error, stackTrace) { - _logError('addBreadcrumb', error, stackTrace); - } + final normalizedBreadcrumb = breadcrumb.copyWith( + data: MethodChannelHelper.normalizeMap(breadcrumb.data), + ); + await _channel.invokeMethod( + 'addBreadcrumb', + {'breadcrumb': normalizedBreadcrumb.toJson()}, + ); } - Future clearBreadcrumbs() async { - try { - await _channel.invokeMethod('clearBreadcrumbs'); - } catch (error, stackTrace) { - _logError('clearBreadcrumbs', error, stackTrace); - } - } + @override + Future clearBreadcrumbs() => _channel.invokeMethod('clearBreadcrumbs'); - Future setContexts(String key, dynamic value) async { - try { - final normalizedValue = MethodChannelHelper.normalize(value); - await _channel.invokeMethod( + @override + Future setContexts(String key, dynamic value) => _channel.invokeMethod( 'setContexts', - {'key': key, 'value': normalizedValue}, + {'key': key, 'value': MethodChannelHelper.normalize(value)}, ); - } catch (error, stackTrace) { - _logError('setContexts', error, stackTrace); - } - } - Future removeContexts(String key) async { - try { - await _channel.invokeMethod('removeContexts', {'key': key}); - } catch (error, stackTrace) { - _logError('removeContexts', error, stackTrace); - } - } + @override + Future removeContexts(String key) => + _channel.invokeMethod('removeContexts', {'key': key}); - Future setExtra(String key, dynamic value) async { - try { - final normalizedValue = MethodChannelHelper.normalize(value); - await _channel.invokeMethod( + @override + Future setExtra(String key, dynamic value) => _channel.invokeMethod( 'setExtra', - {'key': key, 'value': normalizedValue}, + {'key': key, 'value': MethodChannelHelper.normalize(value)}, ); - } catch (error, stackTrace) { - _logError('setExtra', error, stackTrace); - } - } - - Future removeExtra(String key) async { - try { - await _channel.invokeMethod('removeExtra', {'key': key}); - } catch (error, stackTrace) { - _logError('removeExtra', error, stackTrace); - } - } - - Future setTag(String key, String value) async { - try { - await _channel.invokeMethod('setTag', {'key': key, 'value': value}); - } catch (error, stackTrace) { - _logError('setTag', error, stackTrace); - } - } - Future removeTag(String key) async { - try { - await _channel.invokeMethod('removeTag', {'key': key}); - } catch (error, stackTrace) { - _logError('removeTag', error, stackTrace); - } - } - - // Helper - - void _logError(String nativeMethodName, Object error, StackTrace stackTrace) { - _options.logger( - SentryLevel.error, - 'Native call `$nativeMethodName` failed', - exception: error, - stackTrace: stackTrace, - ); - } + @override + Future removeExtra(String key) => + _channel.invokeMethod('removeExtra', {'key': key}); + + @override + Future setTag(String key, String value) => + _channel.invokeMethod('setTag', {'key': key, 'value': value}); + + @override + Future removeTag(String key) => + _channel.invokeMethod('removeTag', {'key': key}); + + @override + int? startProfiler(SentryId traceId) => + throw UnsupportedError("Not supported on this platform"); + + @override + Future discardProfiler(SentryId traceId) => + _channel.invokeMethod('discardProfiler', traceId.toString()); + + @override + Future?> collectProfile( + SentryId traceId, int startTimeNs, int endTimeNs) => + _channel.invokeMapMethod('collectProfile', { + 'traceId': traceId.toString(), + 'startTime': startTimeNs, + 'endTime': endTimeNs, + }); } diff --git a/flutter/lib/src/navigation/sentry_navigator_observer.dart b/flutter/lib/src/navigation/sentry_navigator_observer.dart index 8b88b88088..51c4b0fd07 100644 --- a/flutter/lib/src/navigation/sentry_navigator_observer.dart +++ b/flutter/lib/src/navigation/sentry_navigator_observer.dart @@ -67,7 +67,7 @@ class SentryNavigatorObserver extends RouteObserver> { _setRouteNameAsTransaction = setRouteNameAsTransaction, _routeNameExtractor = routeNameExtractor, _additionalInfoProvider = additionalInfoProvider, - _native = SentryNative() { + _native = SentryFlutter.native { if (enableAutoTransactions) { // ignore: invalid_use_of_internal_member _hub.options.sdk.addIntegration('UINavigationTracing'); @@ -80,7 +80,7 @@ class SentryNavigatorObserver extends RouteObserver> { final bool _setRouteNameAsTransaction; final RouteNameExtractor? _routeNameExtractor; final AdditionalInfoExtractor? _additionalInfoProvider; - final SentryNative _native; + final SentryNative? _native; ISentrySpan? _transaction; @@ -189,7 +189,7 @@ class SentryNavigatorObserver extends RouteObserver> { trimEnd: true, onFinish: (transaction) async { final nativeFrames = await _native - .endNativeFramesCollection(transaction.context.traceId); + ?.endNativeFramesCollection(transaction.context.traceId); if (nativeFrames != null) { final measurements = nativeFrames.toMeasurements(); for (final item in measurements.entries) { @@ -218,7 +218,7 @@ class SentryNavigatorObserver extends RouteObserver> { scope.span ??= _transaction; }); - await _native.beginNativeFramesCollection(); + await _native?.beginNativeFramesCollection(); } Future _finishTransaction() async { diff --git a/flutter/lib/src/profiling.dart b/flutter/lib/src/profiling.dart new file mode 100644 index 0000000000..a4332d77e7 --- /dev/null +++ b/flutter/lib/src/profiling.dart @@ -0,0 +1,117 @@ +import 'dart:async'; + +// ignore: implementation_imports +import 'package:sentry/src/profiling.dart'; +// ignore: implementation_imports +import 'package:sentry/src/sentry_envelope_item_header.dart'; +// ignore: implementation_imports +import 'package:sentry/src/sentry_item_type.dart'; + +import '../sentry_flutter.dart'; +import 'native/sentry_native.dart'; + +// ignore: invalid_use_of_internal_member +class SentryNativeProfilerFactory implements SentryProfilerFactory { + final SentryNative _native; + final ClockProvider _clock; + + SentryNativeProfilerFactory(this._native, this._clock); + + static void attachTo(Hub hub, SentryNative native) { + // ignore: invalid_use_of_internal_member + final options = hub.options; + + // ignore: invalid_use_of_internal_member + if ((options.profilesSampleRate ?? 0.0) <= 0.0) { + return; + } + + if (options.platformChecker.isWeb) { + return; + } + + if (options.platformChecker.platform.isMacOS || + options.platformChecker.platform.isIOS) { + // ignore: invalid_use_of_internal_member + hub.profilerFactory = SentryNativeProfilerFactory(native, options.clock); + } + } + + @override + SentryNativeProfiler? startProfiler(SentryTransactionContext context) { + if (context.traceId == SentryId.empty()) { + return null; + } + + final startTime = _native.startProfiler(context.traceId); + if (startTime == null) { + return null; + } + return SentryNativeProfiler(_native, startTime, context.traceId, _clock); + } +} + +// ignore: invalid_use_of_internal_member +class SentryNativeProfiler implements SentryProfiler { + final SentryNative _native; + final int _starTimeNs; + final SentryId _traceId; + bool _finished = false; + final ClockProvider _clock; + + SentryNativeProfiler( + this._native, this._starTimeNs, this._traceId, this._clock); + + @override + void dispose() { + if (!_finished) { + _finished = true; + _native.discardProfiler(_traceId); + } + } + + @override + Future finishFor( + SentryTransaction transaction) async { + if (_finished) { + return null; + } + _finished = true; + + // ignore: invalid_use_of_internal_member + final transactionEndTime = transaction.timestamp ?? _clock(); + final duration = transactionEndTime.difference(transaction.startTimestamp); + final endTimeNs = _starTimeNs + (duration.inMicroseconds * 1000); + + final payload = + await _native.collectProfile(_traceId, _starTimeNs, endTimeNs); + if (payload == null) { + return null; + } + + payload["transaction"]["id"] = transaction.eventId.toString(); + payload["transaction"]["trace_id"] = _traceId.toString(); + payload["transaction"]["name"] = transaction.transaction; + payload["timestamp"] = transaction.startTimestamp.toIso8601String(); + return SentryNativeProfileInfo(payload); + } +} + +// ignore: invalid_use_of_internal_member +class SentryNativeProfileInfo implements SentryProfileInfo { + final Map _payload; + // ignore: invalid_use_of_internal_member + late final List _data = utf8JsonEncoder.convert(_payload); + + SentryNativeProfileInfo(this._payload); + + @override + SentryEnvelopeItem asEnvelopeItem() { + final header = SentryEnvelopeItemHeader( + SentryItemType.profile, + () => Future.value(_data.length), + contentType: 'application/json', + ); + return SentryEnvelopeItem(header, () => Future.value(_data)); + } +} diff --git a/flutter/lib/src/sentry_flutter.dart b/flutter/lib/src/sentry_flutter.dart index 688f10aac0..d1fd8ef1c2 100644 --- a/flutter/lib/src/sentry_flutter.dart +++ b/flutter/lib/src/sentry_flutter.dart @@ -10,10 +10,11 @@ import 'event_processor/android_platform_exception_event_processor.dart'; import 'event_processor/flutter_exception_event_processor.dart'; import 'event_processor/platform_exception_event_processor.dart'; import 'integrations/screenshot_integration.dart'; +import 'native/factory.dart'; import 'native/native_scope_observer.dart'; +import 'profiling.dart'; import 'renderer/renderer.dart'; import 'native/sentry_native.dart'; -import 'native/sentry_native_channel.dart'; import 'integrations/integrations.dart'; import 'event_processor/flutter_enricher_event_processor.dart'; @@ -48,9 +49,8 @@ mixin SentryFlutter { } if (flutterOptions.platformChecker.hasNativeIntegration) { - // Set a default native channel to the singleton SentryNative instance. - SentryNative().nativeChannel = - SentryNativeChannel(channel, flutterOptions); + final binding = createBinding(flutterOptions.platformChecker, channel); + _native = SentryNative(flutterOptions, binding); } final platformDispatcher = PlatformDispatcher.instance; @@ -81,9 +81,7 @@ mixin SentryFlutter { await _initDefaultValues(flutterOptions, channel); await Sentry.init( - (options) async { - await optionsConfiguration(options as SentryFlutterOptions); - }, + (options) => optionsConfiguration(options as SentryFlutterOptions), appRunner: appRunner, // ignore: invalid_use_of_internal_member options: flutterOptions, @@ -92,6 +90,11 @@ mixin SentryFlutter { // ignore: invalid_use_of_internal_member runZonedGuardedOnError: runZonedGuardedOnError, ); + + if (_native != null) { + // ignore: invalid_use_of_internal_member + SentryNativeProfilerFactory.attachTo(Sentry.currentHub, _native!); + } } static Future _initDefaultValues( @@ -101,9 +104,9 @@ mixin SentryFlutter { options.addEventProcessor(FlutterExceptionEventProcessor()); // Not all platforms have a native integration. - if (options.platformChecker.hasNativeIntegration) { + if (_native != null) { options.transport = FileSystemTransport(channel, options); - options.addScopeObserver(NativeScopeObserver(SentryNative())); + options.addScopeObserver(NativeScopeObserver(_native!)); } var flutterEventProcessor = FlutterEnricherEventProcessor(options); @@ -179,9 +182,9 @@ mixin SentryFlutter { // in errors. integrations.add(LoadReleaseIntegration()); - if (platformChecker.hasNativeIntegration) { + if (_native != null) { integrations.add(NativeAppStartIntegration( - SentryNative(), + _native!, () { try { /// Flutter >= 2.12 throws if SchedulerBinding.instance isn't initialized. @@ -207,7 +210,7 @@ mixin SentryFlutter { /// Manually set when your app finished startup. Make sure to set /// [SentryFlutterOptions.autoAppStart] to false on init. static void setAppStartEnd(DateTime appStartEnd) { - SentryNative().appStartEnd = appStartEnd; + _native?.appStartEnd = appStartEnd; } static void _setSdk(SentryFlutterOptions options) { @@ -221,4 +224,10 @@ mixin SentryFlutter { sdk.addPackage('pub:sentry_flutter', sdkVersion); options.sdk = sdk; } + + @internal + static SentryNative? get native => _native; + @internal + static set native(SentryNative? value) => _native = value; + static SentryNative? _native; } diff --git a/flutter/lib/src/sentry_flutter_options.dart b/flutter/lib/src/sentry_flutter_options.dart index a1d7755c3c..5f85e85cb0 100644 --- a/flutter/lib/src/sentry_flutter_options.dart +++ b/flutter/lib/src/sentry_flutter_options.dart @@ -263,4 +263,24 @@ class SentryFlutterOptions extends SentryOptions { /// Setting this to a custom [BindingWrapper] allows you to use a custom [WidgetsBinding]. @experimental BindingWrapper bindingUtils = BindingWrapper(); + + /// The sample rate for profiling traces in the range of 0.0 to 1.0. + /// This is relative to tracesSampleRate - it is a ratio of profiled traces out of all sampled traces. + /// At the moment, only apps targeting iOS and macOS are supported. + @override + @experimental + double? get profilesSampleRate { + // ignore: invalid_use_of_internal_member + return super.profilesSampleRate; + } + + /// The sample rate for profiling traces in the range of 0.0 to 1.0. + /// This is relative to tracesSampleRate - it is a ratio of profiled traces out of all sampled traces. + /// At the moment, only apps targeting iOS and macOS are supported. + @override + @experimental + set profilesSampleRate(double? value) { + // ignore: invalid_use_of_internal_member + super.profilesSampleRate = value; + } } diff --git a/flutter/test/integrations/native_app_start_integration_test.dart b/flutter/test/integrations/native_app_start_integration_test.dart index b4e06183c1..d4b8deaaf5 100644 --- a/flutter/test/integrations/native_app_start_integration_test.dart +++ b/flutter/test/integrations/native_app_start_integration_test.dart @@ -23,7 +23,7 @@ void main() { test('native app start measurement added to first transaction', () async { fixture.options.autoAppStart = false; fixture.native.appStartEnd = DateTime.fromMillisecondsSinceEpoch(10); - fixture.wrapper.nativeAppStart = NativeAppStart(0, true); + fixture.binding.nativeAppStart = NativeAppStart(0, true); fixture.getNativeAppStartIntegration().call(fixture.hub, fixture.options); @@ -42,7 +42,7 @@ void main() { () async { fixture.options.autoAppStart = false; fixture.native.appStartEnd = DateTime.fromMillisecondsSinceEpoch(10); - fixture.wrapper.nativeAppStart = NativeAppStart(0, true); + fixture.binding.nativeAppStart = NativeAppStart(0, true); fixture.getNativeAppStartIntegration().call(fixture.hub, fixture.options); @@ -60,7 +60,7 @@ void main() { test('measurements appended', () async { fixture.options.autoAppStart = false; fixture.native.appStartEnd = DateTime.fromMillisecondsSinceEpoch(10); - fixture.wrapper.nativeAppStart = NativeAppStart(0, true); + fixture.binding.nativeAppStart = NativeAppStart(0, true); final measurement = SentryMeasurement.warmAppStart(Duration(seconds: 1)); fixture.getNativeAppStartIntegration().call(fixture.hub, fixture.options); @@ -81,7 +81,7 @@ void main() { test('native app start measurement not added if more than 60s', () async { fixture.options.autoAppStart = false; fixture.native.appStartEnd = DateTime.fromMillisecondsSinceEpoch(60001); - fixture.wrapper.nativeAppStart = NativeAppStart(0, true); + fixture.binding.nativeAppStart = NativeAppStart(0, true); fixture.getNativeAppStartIntegration().call(fixture.hub, fixture.options); @@ -99,11 +99,10 @@ void main() { class Fixture { final hub = MockHub(); final options = SentryFlutterOptions(dsn: fakeDsn); - final wrapper = MockNativeChannel(); - late final native = SentryNative(); + final binding = MockNativeChannel(); + late final native = SentryNative(options, binding); Fixture() { - native.nativeChannel = wrapper; native.reset(); when(hub.options).thenReturn(options); } diff --git a/flutter/test/mocks.dart b/flutter/test/mocks.dart index 39209f7015..fe032c9271 100644 --- a/flutter/test/mocks.dart +++ b/flutter/test/mocks.dart @@ -4,15 +4,14 @@ import 'package:flutter/services.dart'; import 'package:flutter/src/widgets/binding.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mockito/annotations.dart'; -import 'package:sentry/sentry.dart'; import 'package:sentry/src/platform/platform.dart'; import 'package:sentry/src/sentry_tracer.dart'; import 'package:meta/meta.dart'; -import 'package:sentry_flutter/src/binding_wrapper.dart'; +import 'package:sentry_flutter/sentry_flutter.dart'; import 'package:sentry_flutter/src/renderer/renderer.dart'; import 'package:sentry_flutter/src/native/sentry_native.dart'; -import 'package:sentry_flutter/src/native/sentry_native_channel.dart'; +import 'package:sentry_flutter/src/native/sentry_native_binding.dart'; import 'mocks.mocks.dart'; import 'no_such_method_provider.dart'; @@ -20,6 +19,12 @@ import 'no_such_method_provider.dart'; const fakeDsn = 'https://abc@def.ingest.sentry.io/1234567'; const fakeProguardUuid = '3457d982-65ef-576d-a6ad-65b5f30f49a5'; +// TODO use this everywhere in tests so that we don't get exceptions swallowed. +SentryFlutterOptions defaultTestOptions() { + // ignore: invalid_use_of_internal_member + return SentryFlutterOptions(dsn: fakeDsn)..automatedTestMode = true; +} + // https://github.com/dart-lang/mockito/blob/master/NULL_SAFETY_README.md#fallback-generators ISentrySpan startTransactionShim( String? name, @@ -40,8 +45,8 @@ ISentrySpan startTransactionShim( Transport, // ignore: invalid_use_of_internal_member SentryTracer, + SentryTransaction, MethodChannel, - SentryNative, ], customMocks: [ MockSpec(fallbackGenerators: {#startTransaction: startTransactionShim}) ]) @@ -146,7 +151,7 @@ class MockPlatformChecker with NoSuchMethodProvider implements PlatformChecker { // Does nothing or returns default values. // Useful for when a Hub needs to be passed but is not used. class NoOpHub with NoSuchMethodProvider implements Hub { - final _options = SentryOptions(dsn: 'fixture-dsn'); + final _options = defaultTestOptions(); @override @internal @@ -156,13 +161,11 @@ class NoOpHub with NoSuchMethodProvider implements Hub { bool get isEnabled => false; } +// TODO can this be replaced with https://pub.dev/packages/mockito#verifying-exact-number-of-invocations--at-least-x--never class TestMockSentryNative implements SentryNative { @override DateTime? appStartEnd; - @override - SentryNativeChannel? nativeChannel; - bool _didFetchAppStart = false; @override @@ -189,6 +192,9 @@ class TestMockSentryNative implements SentryNative { var numberOfSetTagCalls = 0; SentryUser? sentryUser; var numberOfSetUserCalls = 0; + var numberOfStartProfilerCalls = 0; + var numberOfDiscardProfilerCalls = 0; + var numberOfCollectProfileCalls = 0; @override Future addBreadcrumb(Breadcrumb breadcrumb) async { @@ -265,9 +271,29 @@ class TestMockSentryNative implements SentryNative { this.sentryUser = sentryUser; numberOfSetUserCalls++; } + + @override + Future?> collectProfile( + SentryId traceId, int startTimeNs, int endTimeNs) { + numberOfCollectProfileCalls++; + return Future.value(null); + } + + @override + int? startProfiler(SentryId traceId) { + numberOfStartProfilerCalls++; + return 42; + } + + @override + Future discardProfiler(SentryId traceId) { + numberOfDiscardProfilerCalls++; + return Future.value(null); + } } -class MockNativeChannel implements SentryNativeChannel { +// TODO can this be replaced with https://pub.dev/packages/mockito#verifying-exact-number-of-invocations--at-least-x--never +class MockNativeChannel implements SentryNativeBinding { NativeAppStart? nativeAppStart; NativeFrames? nativeFrames; SentryId? id; @@ -283,6 +309,9 @@ class MockNativeChannel implements SentryNativeChannel { int numberOfSetContextsCalls = 0; int numberOfSetExtraCalls = 0; int numberOfSetTagCalls = 0; + int numberOfStartProfilerCalls = 0; + int numberOfDiscardProfilerCalls = 0; + int numberOfCollectProfileCalls = 0; @override Future fetchNativeAppStart() async => nativeAppStart; @@ -343,6 +372,25 @@ class MockNativeChannel implements SentryNativeChannel { Future setTag(String key, value) async { numberOfSetTagCalls += 1; } + + @override + Future?> collectProfile( + SentryId traceId, int startTimeNs, int endTimeNs) { + numberOfCollectProfileCalls++; + return Future.value(null); + } + + @override + int? startProfiler(SentryId traceId) { + numberOfStartProfilerCalls++; + return null; + } + + @override + Future discardProfiler(SentryId traceId) { + numberOfDiscardProfilerCalls++; + return Future.value(null); + } } class MockRendererWrapper implements RendererWrapper { diff --git a/flutter/test/mocks.mocks.dart b/flutter/test/mocks.mocks.dart index b92e8cfba8..45a91b1b0f 100644 --- a/flutter/test/mocks.mocks.dart +++ b/flutter/test/mocks.mocks.dart @@ -3,20 +3,19 @@ // Do not manually edit this file. // ignore_for_file: no_leading_underscores_for_library_prefixes -import 'dart:async' as _i6; +import 'dart:async' as _i7; -import 'package:flutter/src/services/binary_messenger.dart' as _i5; -import 'package:flutter/src/services/message_codec.dart' as _i4; -import 'package:flutter/src/services/platform_channel.dart' as _i9; +import 'package:flutter/src/services/binary_messenger.dart' as _i6; +import 'package:flutter/src/services/message_codec.dart' as _i5; +import 'package:flutter/src/services/platform_channel.dart' as _i10; import 'package:mockito/mockito.dart' as _i1; import 'package:sentry/sentry.dart' as _i2; +import 'package:sentry/src/profiling.dart' as _i9; import 'package:sentry/src/protocol.dart' as _i3; -import 'package:sentry/src/sentry_envelope.dart' as _i7; -import 'package:sentry/src/sentry_tracer.dart' as _i8; -import 'package:sentry_flutter/src/native/sentry_native.dart' as _i10; -import 'package:sentry_flutter/src/native/sentry_native_channel.dart' as _i11; +import 'package:sentry/src/sentry_envelope.dart' as _i8; +import 'package:sentry/src/sentry_tracer.dart' as _i4; -import 'mocks.dart' as _i12; +import 'mocks.dart' as _i11; // ignore_for_file: type=lint // ignore_for_file: avoid_redundant_argument_values @@ -71,8 +70,8 @@ class _FakeSentryTraceHeader_3 extends _i1.SmartFake ); } -class _FakeMethodCodec_4 extends _i1.SmartFake implements _i4.MethodCodec { - _FakeMethodCodec_4( +class _FakeSentryTracer_4 extends _i1.SmartFake implements _i4.SentryTracer { + _FakeSentryTracer_4( Object parent, Invocation parentInvocation, ) : super( @@ -81,9 +80,9 @@ class _FakeMethodCodec_4 extends _i1.SmartFake implements _i4.MethodCodec { ); } -class _FakeBinaryMessenger_5 extends _i1.SmartFake - implements _i5.BinaryMessenger { - _FakeBinaryMessenger_5( +class _FakeSentryTransaction_5 extends _i1.SmartFake + implements _i3.SentryTransaction { + _FakeSentryTransaction_5( Object parent, Invocation parentInvocation, ) : super( @@ -92,8 +91,8 @@ class _FakeBinaryMessenger_5 extends _i1.SmartFake ); } -class _FakeSentryOptions_6 extends _i1.SmartFake implements _i2.SentryOptions { - _FakeSentryOptions_6( +class _FakeMethodCodec_6 extends _i1.SmartFake implements _i5.MethodCodec { + _FakeMethodCodec_6( Object parent, Invocation parentInvocation, ) : super( @@ -102,8 +101,9 @@ class _FakeSentryOptions_6 extends _i1.SmartFake implements _i2.SentryOptions { ); } -class _FakeSentryId_7 extends _i1.SmartFake implements _i3.SentryId { - _FakeSentryId_7( +class _FakeBinaryMessenger_7 extends _i1.SmartFake + implements _i6.BinaryMessenger { + _FakeBinaryMessenger_7( Object parent, Invocation parentInvocation, ) : super( @@ -112,8 +112,8 @@ class _FakeSentryId_7 extends _i1.SmartFake implements _i3.SentryId { ); } -class _FakeScope_8 extends _i1.SmartFake implements _i2.Scope { - _FakeScope_8( +class _FakeSentryOptions_8 extends _i1.SmartFake implements _i2.SentryOptions { + _FakeSentryOptions_8( Object parent, Invocation parentInvocation, ) : super( @@ -122,8 +122,28 @@ class _FakeScope_8 extends _i1.SmartFake implements _i2.Scope { ); } -class _FakeHub_9 extends _i1.SmartFake implements _i2.Hub { - _FakeHub_9( +class _FakeSentryId_9 extends _i1.SmartFake implements _i3.SentryId { + _FakeSentryId_9( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); +} + +class _FakeScope_10 extends _i1.SmartFake implements _i2.Scope { + _FakeScope_10( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); +} + +class _FakeHub_11 extends _i1.SmartFake implements _i2.Hub { + _FakeHub_11( Object parent, Invocation parentInvocation, ) : super( @@ -141,20 +161,20 @@ class MockTransport extends _i1.Mock implements _i2.Transport { } @override - _i6.Future<_i3.SentryId?> send(_i7.SentryEnvelope? envelope) => + _i7.Future<_i3.SentryId?> send(_i8.SentryEnvelope? envelope) => (super.noSuchMethod( Invocation.method( #send, [envelope], ), - returnValue: _i6.Future<_i3.SentryId?>.value(), - ) as _i6.Future<_i3.SentryId?>); + returnValue: _i7.Future<_i3.SentryId?>.value(), + ) as _i7.Future<_i3.SentryId?>); } /// A class which mocks [SentryTracer]. /// /// See the documentation for Mockito's code generation for more information. -class MockSentryTracer extends _i1.Mock implements _i8.SentryTracer { +class MockSentryTracer extends _i1.Mock implements _i4.SentryTracer { MockSentryTracer() { _i1.throwOnMissingStub(this); } @@ -189,6 +209,22 @@ class MockSentryTracer extends _i1.Mock implements _i8.SentryTracer { returnValueForMissingStub: null, ); @override + set profiler(_i9.SentryProfiler? _profiler) => super.noSuchMethod( + Invocation.setter( + #profiler, + _profiler, + ), + returnValueForMissingStub: null, + ); + @override + set profileInfo(_i9.SentryProfileInfo? _profileInfo) => super.noSuchMethod( + Invocation.setter( + #profileInfo, + _profileInfo, + ), + returnValueForMissingStub: null, + ); + @override _i2.SentrySpanContext get context => (super.noSuchMethod( Invocation.getter(#context), returnValue: _FakeSentrySpanContext_0( @@ -236,7 +272,7 @@ class MockSentryTracer extends _i1.Mock implements _i8.SentryTracer { returnValueForMissingStub: null, ); @override - set status(_i3.SpanStatus? status) => super.noSuchMethod( + set status(dynamic status) => super.noSuchMethod( Invocation.setter( #status, status, @@ -254,8 +290,8 @@ class MockSentryTracer extends _i1.Mock implements _i8.SentryTracer { returnValue: {}, ) as Map); @override - _i6.Future finish({ - _i3.SpanStatus? status, + _i7.Future finish({ + dynamic status, DateTime? endTimestamp, }) => (super.noSuchMethod( @@ -267,9 +303,9 @@ class MockSentryTracer extends _i1.Mock implements _i8.SentryTracer { #endTimestamp: endTimestamp, }, ), - returnValue: _i6.Future.value(), - returnValueForMissingStub: _i6.Future.value(), - ) as _i6.Future); + returnValue: _i7.Future.value(), + returnValueForMissingStub: _i7.Future.value(), + ) as _i7.Future); @override void removeData(String? key) => super.noSuchMethod( Invocation.method( @@ -345,7 +381,7 @@ class MockSentryTracer extends _i1.Mock implements _i8.SentryTracer { ) as _i2.ISentrySpan); @override _i2.ISentrySpan startChildWithParentSpanId( - _i3.SpanId? parentSpanId, + dynamic parentSpanId, String? operation, { String? description, DateTime? startTimestamp, @@ -418,10 +454,203 @@ class MockSentryTracer extends _i1.Mock implements _i8.SentryTracer { ); } +/// A class which mocks [SentryTransaction]. +/// +/// See the documentation for Mockito's code generation for more information. +// ignore: must_be_immutable +class MockSentryTransaction extends _i1.Mock implements _i3.SentryTransaction { + MockSentryTransaction() { + _i1.throwOnMissingStub(this); + } + + @override + DateTime get startTimestamp => (super.noSuchMethod( + Invocation.getter(#startTimestamp), + returnValue: _FakeDateTime_1( + this, + Invocation.getter(#startTimestamp), + ), + ) as DateTime); + @override + set startTimestamp(DateTime? _startTimestamp) => super.noSuchMethod( + Invocation.setter( + #startTimestamp, + _startTimestamp, + ), + returnValueForMissingStub: null, + ); + @override + List<_i3.SentrySpan> get spans => (super.noSuchMethod( + Invocation.getter(#spans), + returnValue: <_i3.SentrySpan>[], + ) as List<_i3.SentrySpan>); + @override + set spans(List<_i3.SentrySpan>? _spans) => super.noSuchMethod( + Invocation.setter( + #spans, + _spans, + ), + returnValueForMissingStub: null, + ); + @override + _i4.SentryTracer get tracer => (super.noSuchMethod( + Invocation.getter(#tracer), + returnValue: _FakeSentryTracer_4( + this, + Invocation.getter(#tracer), + ), + ) as _i4.SentryTracer); + @override + Map get measurements => (super.noSuchMethod( + Invocation.getter(#measurements), + returnValue: {}, + ) as Map); + @override + set measurements(Map? _measurements) => + super.noSuchMethod( + Invocation.setter( + #measurements, + _measurements, + ), + returnValueForMissingStub: null, + ); + @override + set transactionInfo(_i3.SentryTransactionInfo? _transactionInfo) => + super.noSuchMethod( + Invocation.setter( + #transactionInfo, + _transactionInfo, + ), + returnValueForMissingStub: null, + ); + @override + bool get finished => (super.noSuchMethod( + Invocation.getter(#finished), + returnValue: false, + ) as bool); + @override + bool get sampled => (super.noSuchMethod( + Invocation.getter(#sampled), + returnValue: false, + ) as bool); + @override + Map toJson() => (super.noSuchMethod( + Invocation.method( + #toJson, + [], + ), + returnValue: {}, + ) as Map); + @override + _i3.SentryTransaction copyWith({ + _i3.SentryId? eventId, + DateTime? timestamp, + String? platform, + String? logger, + String? serverName, + String? release, + String? dist, + String? environment, + Map? modules, + dynamic message, + String? transaction, + dynamic throwable, + _i3.SentryLevel? level, + String? culprit, + Map? tags, + Map? extra, + List? fingerprint, + _i3.SentryUser? user, + dynamic contexts, + List<_i3.Breadcrumb>? breadcrumbs, + _i3.SdkVersion? sdk, + dynamic request, + dynamic debugMeta, + List<_i3.SentryException>? exceptions, + List? threads, + String? type, + Map? measurements, + _i3.SentryTransactionInfo? transactionInfo, + }) => + (super.noSuchMethod( + Invocation.method( + #copyWith, + [], + { + #eventId: eventId, + #timestamp: timestamp, + #platform: platform, + #logger: logger, + #serverName: serverName, + #release: release, + #dist: dist, + #environment: environment, + #modules: modules, + #message: message, + #transaction: transaction, + #throwable: throwable, + #level: level, + #culprit: culprit, + #tags: tags, + #extra: extra, + #fingerprint: fingerprint, + #user: user, + #contexts: contexts, + #breadcrumbs: breadcrumbs, + #sdk: sdk, + #request: request, + #debugMeta: debugMeta, + #exceptions: exceptions, + #threads: threads, + #type: type, + #measurements: measurements, + #transactionInfo: transactionInfo, + }, + ), + returnValue: _FakeSentryTransaction_5( + this, + Invocation.method( + #copyWith, + [], + { + #eventId: eventId, + #timestamp: timestamp, + #platform: platform, + #logger: logger, + #serverName: serverName, + #release: release, + #dist: dist, + #environment: environment, + #modules: modules, + #message: message, + #transaction: transaction, + #throwable: throwable, + #level: level, + #culprit: culprit, + #tags: tags, + #extra: extra, + #fingerprint: fingerprint, + #user: user, + #contexts: contexts, + #breadcrumbs: breadcrumbs, + #sdk: sdk, + #request: request, + #debugMeta: debugMeta, + #exceptions: exceptions, + #threads: threads, + #type: type, + #measurements: measurements, + #transactionInfo: transactionInfo, + }, + ), + ), + ) as _i3.SentryTransaction); +} + /// A class which mocks [MethodChannel]. /// /// See the documentation for Mockito's code generation for more information. -class MockMethodChannel extends _i1.Mock implements _i9.MethodChannel { +class MockMethodChannel extends _i1.Mock implements _i10.MethodChannel { MockMethodChannel() { _i1.throwOnMissingStub(this); } @@ -432,23 +661,23 @@ class MockMethodChannel extends _i1.Mock implements _i9.MethodChannel { returnValue: '', ) as String); @override - _i4.MethodCodec get codec => (super.noSuchMethod( + _i5.MethodCodec get codec => (super.noSuchMethod( Invocation.getter(#codec), - returnValue: _FakeMethodCodec_4( + returnValue: _FakeMethodCodec_6( this, Invocation.getter(#codec), ), - ) as _i4.MethodCodec); + ) as _i5.MethodCodec); @override - _i5.BinaryMessenger get binaryMessenger => (super.noSuchMethod( + _i6.BinaryMessenger get binaryMessenger => (super.noSuchMethod( Invocation.getter(#binaryMessenger), - returnValue: _FakeBinaryMessenger_5( + returnValue: _FakeBinaryMessenger_7( this, Invocation.getter(#binaryMessenger), ), - ) as _i5.BinaryMessenger); + ) as _i6.BinaryMessenger); @override - _i6.Future invokeMethod( + _i7.Future invokeMethod( String? method, [ dynamic arguments, ]) => @@ -460,10 +689,10 @@ class MockMethodChannel extends _i1.Mock implements _i9.MethodChannel { arguments, ], ), - returnValue: _i6.Future.value(), - ) as _i6.Future); + returnValue: _i7.Future.value(), + ) as _i7.Future); @override - _i6.Future?> invokeListMethod( + _i7.Future?> invokeListMethod( String? method, [ dynamic arguments, ]) => @@ -475,10 +704,10 @@ class MockMethodChannel extends _i1.Mock implements _i9.MethodChannel { arguments, ], ), - returnValue: _i6.Future?>.value(), - ) as _i6.Future?>); + returnValue: _i7.Future?>.value(), + ) as _i7.Future?>); @override - _i6.Future?> invokeMapMethod( + _i7.Future?> invokeMapMethod( String? method, [ dynamic arguments, ]) => @@ -490,11 +719,11 @@ class MockMethodChannel extends _i1.Mock implements _i9.MethodChannel { arguments, ], ), - returnValue: _i6.Future?>.value(), - ) as _i6.Future?>); + returnValue: _i7.Future?>.value(), + ) as _i7.Future?>); @override void setMethodCallHandler( - _i6.Future Function(_i4.MethodCall)? handler) => + _i7.Future Function(_i5.MethodCall)? handler) => super.noSuchMethod( Invocation.method( #setMethodCallHandler, @@ -504,176 +733,6 @@ class MockMethodChannel extends _i1.Mock implements _i9.MethodChannel { ); } -/// A class which mocks [SentryNative]. -/// -/// See the documentation for Mockito's code generation for more information. -class MockSentryNative extends _i1.Mock implements _i10.SentryNative { - MockSentryNative() { - _i1.throwOnMissingStub(this); - } - - @override - set appStartEnd(DateTime? _appStartEnd) => super.noSuchMethod( - Invocation.setter( - #appStartEnd, - _appStartEnd, - ), - returnValueForMissingStub: null, - ); - @override - set nativeChannel(_i11.SentryNativeChannel? nativeChannel) => - super.noSuchMethod( - Invocation.setter( - #nativeChannel, - nativeChannel, - ), - returnValueForMissingStub: null, - ); - @override - bool get didFetchAppStart => (super.noSuchMethod( - Invocation.getter(#didFetchAppStart), - returnValue: false, - ) as bool); - @override - _i6.Future<_i10.NativeAppStart?> fetchNativeAppStart() => (super.noSuchMethod( - Invocation.method( - #fetchNativeAppStart, - [], - ), - returnValue: _i6.Future<_i10.NativeAppStart?>.value(), - ) as _i6.Future<_i10.NativeAppStart?>); - @override - _i6.Future beginNativeFramesCollection() => (super.noSuchMethod( - Invocation.method( - #beginNativeFramesCollection, - [], - ), - returnValue: _i6.Future.value(), - returnValueForMissingStub: _i6.Future.value(), - ) as _i6.Future); - @override - _i6.Future<_i10.NativeFrames?> endNativeFramesCollection( - _i3.SentryId? traceId) => - (super.noSuchMethod( - Invocation.method( - #endNativeFramesCollection, - [traceId], - ), - returnValue: _i6.Future<_i10.NativeFrames?>.value(), - ) as _i6.Future<_i10.NativeFrames?>); - @override - _i6.Future setContexts( - String? key, - dynamic value, - ) => - (super.noSuchMethod( - Invocation.method( - #setContexts, - [ - key, - value, - ], - ), - returnValue: _i6.Future.value(), - returnValueForMissingStub: _i6.Future.value(), - ) as _i6.Future); - @override - _i6.Future removeContexts(String? key) => (super.noSuchMethod( - Invocation.method( - #removeContexts, - [key], - ), - returnValue: _i6.Future.value(), - returnValueForMissingStub: _i6.Future.value(), - ) as _i6.Future); - @override - _i6.Future setUser(_i3.SentryUser? sentryUser) => (super.noSuchMethod( - Invocation.method( - #setUser, - [sentryUser], - ), - returnValue: _i6.Future.value(), - returnValueForMissingStub: _i6.Future.value(), - ) as _i6.Future); - @override - _i6.Future addBreadcrumb(_i3.Breadcrumb? breadcrumb) => - (super.noSuchMethod( - Invocation.method( - #addBreadcrumb, - [breadcrumb], - ), - returnValue: _i6.Future.value(), - returnValueForMissingStub: _i6.Future.value(), - ) as _i6.Future); - @override - _i6.Future clearBreadcrumbs() => (super.noSuchMethod( - Invocation.method( - #clearBreadcrumbs, - [], - ), - returnValue: _i6.Future.value(), - returnValueForMissingStub: _i6.Future.value(), - ) as _i6.Future); - @override - _i6.Future setExtra( - String? key, - dynamic value, - ) => - (super.noSuchMethod( - Invocation.method( - #setExtra, - [ - key, - value, - ], - ), - returnValue: _i6.Future.value(), - returnValueForMissingStub: _i6.Future.value(), - ) as _i6.Future); - @override - _i6.Future removeExtra(String? key) => (super.noSuchMethod( - Invocation.method( - #removeExtra, - [key], - ), - returnValue: _i6.Future.value(), - returnValueForMissingStub: _i6.Future.value(), - ) as _i6.Future); - @override - _i6.Future setTag( - String? key, - String? value, - ) => - (super.noSuchMethod( - Invocation.method( - #setTag, - [ - key, - value, - ], - ), - returnValue: _i6.Future.value(), - returnValueForMissingStub: _i6.Future.value(), - ) as _i6.Future); - @override - _i6.Future removeTag(String? key) => (super.noSuchMethod( - Invocation.method( - #removeTag, - [key], - ), - returnValue: _i6.Future.value(), - returnValueForMissingStub: _i6.Future.value(), - ) as _i6.Future); - @override - void reset() => super.noSuchMethod( - Invocation.method( - #reset, - [], - ), - returnValueForMissingStub: null, - ); -} - /// A class which mocks [Hub]. /// /// See the documentation for Mockito's code generation for more information. @@ -685,7 +744,7 @@ class MockHub extends _i1.Mock implements _i2.Hub { @override _i2.SentryOptions get options => (super.noSuchMethod( Invocation.getter(#options), - returnValue: _FakeSentryOptions_6( + returnValue: _FakeSentryOptions_8( this, Invocation.getter(#options), ), @@ -698,7 +757,7 @@ class MockHub extends _i1.Mock implements _i2.Hub { @override _i3.SentryId get lastEventId => (super.noSuchMethod( Invocation.getter(#lastEventId), - returnValue: _FakeSentryId_7( + returnValue: _FakeSentryId_9( this, Invocation.getter(#lastEventId), ), @@ -706,14 +765,22 @@ class MockHub extends _i1.Mock implements _i2.Hub { @override _i2.Scope get scope => (super.noSuchMethod( Invocation.getter(#scope), - returnValue: _FakeScope_8( + returnValue: _FakeScope_10( this, Invocation.getter(#scope), ), ) as _i2.Scope); @override - _i6.Future<_i3.SentryId> captureEvent( - _i3.SentryEvent? event, { + set profilerFactory(_i9.SentryProfilerFactory? value) => super.noSuchMethod( + Invocation.setter( + #profilerFactory, + value, + ), + returnValueForMissingStub: null, + ); + @override + _i7.Future<_i3.SentryId> captureEvent( + dynamic event, { dynamic stackTrace, _i2.Hint? hint, _i2.ScopeCallback? withScope, @@ -728,7 +795,7 @@ class MockHub extends _i1.Mock implements _i2.Hub { #withScope: withScope, }, ), - returnValue: _i6.Future<_i3.SentryId>.value(_FakeSentryId_7( + returnValue: _i7.Future<_i3.SentryId>.value(_FakeSentryId_9( this, Invocation.method( #captureEvent, @@ -740,9 +807,9 @@ class MockHub extends _i1.Mock implements _i2.Hub { }, ), )), - ) as _i6.Future<_i3.SentryId>); + ) as _i7.Future<_i3.SentryId>); @override - _i6.Future<_i3.SentryId> captureException( + _i7.Future<_i3.SentryId> captureException( dynamic throwable, { dynamic stackTrace, _i2.Hint? hint, @@ -758,7 +825,7 @@ class MockHub extends _i1.Mock implements _i2.Hub { #withScope: withScope, }, ), - returnValue: _i6.Future<_i3.SentryId>.value(_FakeSentryId_7( + returnValue: _i7.Future<_i3.SentryId>.value(_FakeSentryId_9( this, Invocation.method( #captureException, @@ -770,9 +837,9 @@ class MockHub extends _i1.Mock implements _i2.Hub { }, ), )), - ) as _i6.Future<_i3.SentryId>); + ) as _i7.Future<_i3.SentryId>); @override - _i6.Future<_i3.SentryId> captureMessage( + _i7.Future<_i3.SentryId> captureMessage( String? message, { _i3.SentryLevel? level, String? template, @@ -792,7 +859,7 @@ class MockHub extends _i1.Mock implements _i2.Hub { #withScope: withScope, }, ), - returnValue: _i6.Future<_i3.SentryId>.value(_FakeSentryId_7( + returnValue: _i7.Future<_i3.SentryId>.value(_FakeSentryId_9( this, Invocation.method( #captureMessage, @@ -806,19 +873,19 @@ class MockHub extends _i1.Mock implements _i2.Hub { }, ), )), - ) as _i6.Future<_i3.SentryId>); + ) as _i7.Future<_i3.SentryId>); @override - _i6.Future captureUserFeedback(_i2.SentryUserFeedback? userFeedback) => + _i7.Future captureUserFeedback(_i2.SentryUserFeedback? userFeedback) => (super.noSuchMethod( Invocation.method( #captureUserFeedback, [userFeedback], ), - returnValue: _i6.Future.value(), - returnValueForMissingStub: _i6.Future.value(), - ) as _i6.Future); + returnValue: _i7.Future.value(), + returnValueForMissingStub: _i7.Future.value(), + ) as _i7.Future); @override - _i6.Future addBreadcrumb( + _i7.Future addBreadcrumb( _i3.Breadcrumb? crumb, { _i2.Hint? hint, }) => @@ -828,9 +895,9 @@ class MockHub extends _i1.Mock implements _i2.Hub { [crumb], {#hint: hint}, ), - returnValue: _i6.Future.value(), - returnValueForMissingStub: _i6.Future.value(), - ) as _i6.Future); + returnValue: _i7.Future.value(), + returnValueForMissingStub: _i7.Future.value(), + ) as _i7.Future); @override void bindClient(_i2.SentryClient? client) => super.noSuchMethod( Invocation.method( @@ -845,7 +912,7 @@ class MockHub extends _i1.Mock implements _i2.Hub { #clone, [], ), - returnValue: _FakeHub_9( + returnValue: _FakeHub_11( this, Invocation.method( #clone, @@ -854,20 +921,20 @@ class MockHub extends _i1.Mock implements _i2.Hub { ), ) as _i2.Hub); @override - _i6.Future close() => (super.noSuchMethod( + _i7.Future close() => (super.noSuchMethod( Invocation.method( #close, [], ), - returnValue: _i6.Future.value(), - returnValueForMissingStub: _i6.Future.value(), - ) as _i6.Future); + returnValue: _i7.Future.value(), + returnValueForMissingStub: _i7.Future.value(), + ) as _i7.Future); @override - _i6.FutureOr configureScope(_i2.ScopeCallback? callback) => + _i7.FutureOr configureScope(_i2.ScopeCallback? callback) => (super.noSuchMethod(Invocation.method( #configureScope, [callback], - )) as _i6.FutureOr); + )) as _i7.FutureOr); @override _i2.ISentrySpan startTransaction( String? name, @@ -899,7 +966,7 @@ class MockHub extends _i1.Mock implements _i2.Hub { #customSamplingContext: customSamplingContext, }, ), - returnValue: _i12.startTransactionShim( + returnValue: _i11.startTransactionShim( name, operation, description: description, @@ -955,7 +1022,7 @@ class MockHub extends _i1.Mock implements _i2.Hub { ), ) as _i2.ISentrySpan); @override - _i6.Future<_i3.SentryId> captureTransaction( + _i7.Future<_i3.SentryId> captureTransaction( _i3.SentryTransaction? transaction, { _i2.SentryTraceContextHeader? traceContext, }) => @@ -965,7 +1032,7 @@ class MockHub extends _i1.Mock implements _i2.Hub { [transaction], {#traceContext: traceContext}, ), - returnValue: _i6.Future<_i3.SentryId>.value(_FakeSentryId_7( + returnValue: _i7.Future<_i3.SentryId>.value(_FakeSentryId_9( this, Invocation.method( #captureTransaction, @@ -973,7 +1040,7 @@ class MockHub extends _i1.Mock implements _i2.Hub { {#traceContext: traceContext}, ), )), - ) as _i6.Future<_i3.SentryId>); + ) as _i7.Future<_i3.SentryId>); @override void setSpanContext( dynamic throwable, diff --git a/flutter/test/profiling_test.dart b/flutter/test/profiling_test.dart new file mode 100644 index 0000000000..eddb391313 --- /dev/null +++ b/flutter/test/profiling_test.dart @@ -0,0 +1,95 @@ +@TestOn('vm') + +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart'; +import 'package:sentry_flutter/sentry_flutter.dart'; +import 'package:sentry_flutter/src/profiling.dart'; +import 'mocks.dart'; +import 'mocks.mocks.dart'; +import 'sentry_flutter_test.dart'; + +void main() { + group('$SentryNativeProfilerFactory', () { + Hub hubWithSampleRate(double profilesSampleRate) { + final o = SentryFlutterOptions(dsn: fakeDsn); + o.platformChecker = getPlatformChecker(platform: MockPlatform.iOs()); + o.profilesSampleRate = profilesSampleRate; + + final hub = MockHub(); + when(hub.options).thenAnswer((_) => o); + return hub; + } + + test('attachTo() respects sampling rate', () async { + var hub = hubWithSampleRate(0.0); + SentryNativeProfilerFactory.attachTo(hub, TestMockSentryNative()); + // ignore: invalid_use_of_internal_member + verifyNever(hub.profilerFactory = any); + + hub = hubWithSampleRate(0.1); + SentryNativeProfilerFactory.attachTo(hub, TestMockSentryNative()); + // ignore: invalid_use_of_internal_member + verify(hub.profilerFactory = any); + }); + + test('creates a profiler', () async { + final nativeMock = TestMockSentryNative(); + // ignore: invalid_use_of_internal_member + final sut = SentryNativeProfilerFactory(nativeMock, getUtcDateTime); + final profiler = sut.startProfiler(SentryTransactionContext( + 'name', + 'op', + )); + expect(nativeMock.numberOfStartProfilerCalls, 1); + expect(profiler, isNotNull); + }); + }); + + group('$SentryNativeProfiler', () { + late TestMockSentryNative nativeMock; + late SentryNativeProfiler sut; + + setUp(() { + nativeMock = TestMockSentryNative(); + // ignore: invalid_use_of_internal_member + final factory = SentryNativeProfilerFactory(nativeMock, getUtcDateTime); + final profiler = factory.startProfiler(SentryTransactionContext( + 'name', + 'op', + )); + expect(nativeMock.numberOfStartProfilerCalls, 1); + expect(profiler, isNotNull); + sut = profiler!; + }); + + test('dispose() calls native discard() exactly once', () async { + sut.dispose(); + sut.dispose(); // Additional calls must not have an effect. + + // Yield to let the .then() in .dispose() execute. + await null; + await null; + + expect(nativeMock.numberOfDiscardProfilerCalls, 1); + + // finishFor() mustn't work after disposing + expect(await sut.finishFor(MockSentryTransaction()), isNull); + expect(nativeMock.numberOfCollectProfileCalls, 0); + }); + + test('dispose() does not call discard() after finishing', () async { + final mockTransaction = MockSentryTransaction(); + when(mockTransaction.startTimestamp).thenReturn(DateTime.now()); + when(mockTransaction.timestamp).thenReturn(DateTime.now()); + expect(await sut.finishFor(mockTransaction), isNull); + + sut.dispose(); + + // Yield to let the .then() in .dispose() execute. + await null; + + expect(nativeMock.numberOfDiscardProfilerCalls, 0); + expect(nativeMock.numberOfCollectProfileCalls, 1); + }); + }); +} diff --git a/flutter/test/sentry_flutter_test.dart b/flutter/test/sentry_flutter_test.dart index b800d18c8d..ef8c848348 100644 --- a/flutter/test/sentry_flutter_test.dart +++ b/flutter/test/sentry_flutter_test.dart @@ -5,8 +5,8 @@ import 'package:package_info_plus/package_info_plus.dart'; import 'package:sentry_flutter/sentry_flutter.dart'; import 'package:sentry_flutter/src/integrations/integrations.dart'; import 'package:sentry_flutter/src/integrations/screenshot_integration.dart'; +import 'package:sentry_flutter/src/profiling.dart'; import 'package:sentry_flutter/src/renderer/renderer.dart'; -import 'package:sentry_flutter/src/native/sentry_native.dart'; import 'package:sentry_flutter/src/version.dart'; import 'package:sentry_flutter/src/view_hierarchy/view_hierarchy_integration.dart'; import 'mocks.dart'; @@ -50,9 +50,7 @@ void main() { setUp(() async { loadTestPackage(); await Sentry.close(); - final sentryNative = SentryNative(); - sentryNative.nativeChannel = null; - sentryNative.reset(); + SentryFlutter.native = null; }); test('Android', () async { @@ -65,6 +63,7 @@ void main() { (options) async { options.dsn = fakeDsn; options.automatedTestMode = true; + options.profilesSampleRate = 1.0; integrations = options.integrations; transport = options.transport; sentryFlutterOptions = options; @@ -99,7 +98,8 @@ void main() { beforeIntegration: WidgetsFlutterBindingIntegration, afterIntegration: OnErrorIntegration); - expect(SentryNative().nativeChannel, isNotNull); + expect(SentryFlutter.native, isNotNull); + expect(Sentry.currentHub.profilerFactory, isNull); await Sentry.close(); }, testOn: 'vm'); @@ -113,6 +113,7 @@ void main() { (options) async { options.dsn = fakeDsn; options.automatedTestMode = true; + options.profilesSampleRate = 1.0; integrations = options.integrations; transport = options.transport; sentryFlutterOptions = options; @@ -145,7 +146,9 @@ void main() { beforeIntegration: WidgetsFlutterBindingIntegration, afterIntegration: OnErrorIntegration); - expect(SentryNative().nativeChannel, isNotNull); + expect(SentryFlutter.native, isNotNull); + expect(Sentry.currentHub.profilerFactory, + isInstanceOf()); await Sentry.close(); }, testOn: 'vm'); @@ -159,6 +162,7 @@ void main() { (options) async { options.dsn = fakeDsn; options.automatedTestMode = true; + options.profilesSampleRate = 1.0; integrations = options.integrations; transport = options.transport; sentryFlutterOptions = options; @@ -191,7 +195,9 @@ void main() { beforeIntegration: WidgetsFlutterBindingIntegration, afterIntegration: OnErrorIntegration); - expect(SentryNative().nativeChannel, isNotNull); + expect(SentryFlutter.native, isNotNull); + expect(Sentry.currentHub.profilerFactory, + isInstanceOf()); await Sentry.close(); }, testOn: 'vm'); @@ -205,6 +211,7 @@ void main() { (options) async { options.dsn = fakeDsn; options.automatedTestMode = true; + options.profilesSampleRate = 1.0; integrations = options.integrations; transport = options.transport; sentryFlutterOptions = options; @@ -240,7 +247,8 @@ void main() { beforeIntegration: WidgetsFlutterBindingIntegration, afterIntegration: OnErrorIntegration); - expect(SentryNative().nativeChannel, isNull); + expect(SentryFlutter.native, isNull); + expect(Sentry.currentHub.profilerFactory, isNull); await Sentry.close(); }, testOn: 'vm'); @@ -254,6 +262,7 @@ void main() { (options) async { options.dsn = fakeDsn; options.automatedTestMode = true; + options.profilesSampleRate = 1.0; integrations = options.integrations; transport = options.transport; sentryFlutterOptions = options; @@ -289,7 +298,8 @@ void main() { beforeIntegration: WidgetsFlutterBindingIntegration, afterIntegration: OnErrorIntegration); - expect(SentryNative().nativeChannel, isNull); + expect(SentryFlutter.native, isNull); + expect(Sentry.currentHub.profilerFactory, isNull); await Sentry.close(); }, testOn: 'vm'); @@ -303,6 +313,7 @@ void main() { (options) async { options.dsn = fakeDsn; options.automatedTestMode = true; + options.profilesSampleRate = 1.0; integrations = options.integrations; transport = options.transport; sentryFlutterOptions = options; @@ -339,7 +350,8 @@ void main() { beforeIntegration: RunZonedGuardedIntegration, afterIntegration: WidgetsFlutterBindingIntegration); - expect(SentryNative().nativeChannel, isNull); + expect(SentryFlutter.native, isNull); + expect(Sentry.currentHub.profilerFactory, isNull); await Sentry.close(); }); @@ -429,6 +441,8 @@ void main() { beforeIntegration: RunZonedGuardedIntegration, afterIntegration: WidgetsFlutterBindingIntegration); + expect(Sentry.currentHub.profilerFactory, isNull); + await Sentry.close(); }); diff --git a/flutter/test/sentry_native_channel_test.dart b/flutter/test/sentry_native_channel_test.dart index cd05bf71ea..04ec723feb 100644 --- a/flutter/test/sentry_native_channel_test.dart +++ b/flutter/test/sentry_native_channel_test.dart @@ -38,6 +38,8 @@ void main() { test('beginNativeFrames', () async { final sut = fixture.getSut(); + when(fixture.methodChannel.invokeMethod('beginNativeFrames')) + .thenAnswer((realInvocation) async {}); await sut.beginNativeFrames(); verify(fixture.methodChannel.invokeMethod('beginNativeFrames')); @@ -186,14 +188,53 @@ void main() { verify(fixture.methodChannel .invokeMethod('removeTag', {'key': 'fixture-key'})); }); + + test('startProfiler', () { + final sut = fixture.getSut(); + expect(() => sut.startProfiler(SentryId.newId()), throwsUnsupportedError); + verifyZeroInteractions(fixture.methodChannel); + }); + + test('discardProfiler', () async { + final traceId = SentryId.newId(); + when(fixture.methodChannel + .invokeMethod('discardProfiler', traceId.toString())) + .thenAnswer((_) async {}); + + final sut = fixture.getSut(); + await sut.discardProfiler(traceId); + + verify(fixture.methodChannel + .invokeMethod('discardProfiler', traceId.toString())); + }); + + test('collectProfile', () async { + final traceId = SentryId.newId(); + const startTime = 42; + const endTime = 50; + when(fixture.methodChannel + .invokeMapMethod('collectProfile', { + 'traceId': traceId.toString(), + 'startTime': startTime, + 'endTime': endTime, + })).thenAnswer((_) => Future.value()); + + final sut = fixture.getSut(); + await sut.collectProfile(traceId, startTime, endTime); + + verify(fixture.methodChannel.invokeMapMethod('collectProfile', { + 'traceId': traceId.toString(), + 'startTime': startTime, + 'endTime': endTime, + })); + }); }); } class Fixture { final methodChannel = MockMethodChannel(); - final options = SentryFlutterOptions(); SentryNativeChannel getSut() { - return SentryNativeChannel(methodChannel, options); + return SentryNativeChannel(methodChannel); } } diff --git a/flutter/test/sentry_native_test.dart b/flutter/test/sentry_native_test.dart index d6b5fab583..68dc16fdfa 100644 --- a/flutter/test/sentry_native_test.dart +++ b/flutter/test/sentry_native_test.dart @@ -7,21 +7,17 @@ import 'mocks.dart'; void main() { group('$SentryNative', () { - late Fixture fixture; - - setUp(() { - fixture = Fixture(); - }); + final channel = MockNativeChannel(); + final options = SentryFlutterOptions(dsn: fakeDsn); + late final sut = SentryNative(options, channel); tearDown(() { - fixture.getSut().reset(); + sut.reset(); }); test('fetchNativeAppStart sets didFetchAppStart', () async { final nativeAppStart = NativeAppStart(0.0, true); - fixture.channel.nativeAppStart = nativeAppStart; - - final sut = fixture.getSut(); + channel.nativeAppStart = nativeAppStart; expect(sut.didFetchAppStart, false); @@ -32,110 +28,88 @@ void main() { }); test('beginNativeFramesCollection', () async { - final sut = fixture.getSut(); - await sut.beginNativeFramesCollection(); - - expect(fixture.channel.numberOfBeginNativeFramesCalls, 1); + expect(channel.numberOfBeginNativeFramesCalls, 1); }); test('endNativeFramesCollection', () async { final nativeFrames = NativeFrames(3, 2, 1); final traceId = SentryId.empty(); - fixture.channel.nativeFrames = nativeFrames; - - final sut = fixture.getSut(); + channel.nativeFrames = nativeFrames; final actual = await sut.endNativeFramesCollection(traceId); expect(actual, nativeFrames); - expect(fixture.channel.id, traceId); - expect(fixture.channel.numberOfEndNativeFramesCalls, 1); + expect(channel.id, traceId); + expect(channel.numberOfEndNativeFramesCalls, 1); }); test('setUser', () async { - final sut = fixture.getSut(); await sut.setUser(null); - - expect(fixture.channel.numberOfSetUserCalls, 1); + expect(channel.numberOfSetUserCalls, 1); }); test('addBreadcrumb', () async { - final sut = fixture.getSut(); await sut.addBreadcrumb(Breadcrumb()); - - expect(fixture.channel.numberOfAddBreadcrumbCalls, 1); + expect(channel.numberOfAddBreadcrumbCalls, 1); }); test('clearBreadcrumbs', () async { - final sut = fixture.getSut(); await sut.clearBreadcrumbs(); - - expect(fixture.channel.numberOfClearBreadcrumbCalls, 1); + expect(channel.numberOfClearBreadcrumbCalls, 1); }); test('setContexts', () async { - final sut = fixture.getSut(); await sut.setContexts('fixture-key', 'fixture-value'); - - expect(fixture.channel.numberOfSetContextsCalls, 1); + expect(channel.numberOfSetContextsCalls, 1); }); test('removeContexts', () async { - final sut = fixture.getSut(); await sut.removeContexts('fixture-key'); - - expect(fixture.channel.numberOfRemoveContextsCalls, 1); + expect(channel.numberOfRemoveContextsCalls, 1); }); test('setExtra', () async { - final sut = fixture.getSut(); await sut.setExtra('fixture-key', 'fixture-value'); - - expect(fixture.channel.numberOfSetExtraCalls, 1); + expect(channel.numberOfSetExtraCalls, 1); }); test('removeExtra', () async { - final sut = fixture.getSut(); await sut.removeExtra('fixture-key'); - - expect(fixture.channel.numberOfRemoveExtraCalls, 1); + expect(channel.numberOfRemoveExtraCalls, 1); }); test('setTag', () async { - final sut = fixture.getSut(); await sut.setTag('fixture-key', 'fixture-value'); - - expect(fixture.channel.numberOfSetTagCalls, 1); + expect(channel.numberOfSetTagCalls, 1); }); test('removeTag', () async { - final sut = fixture.getSut(); await sut.removeTag('fixture-key'); + expect(channel.numberOfRemoveTagCalls, 1); + }); - expect(fixture.channel.numberOfRemoveTagCalls, 1); + test('startProfiler', () async { + sut.startProfiler(SentryId.newId()); + expect(channel.numberOfStartProfilerCalls, 1); }); - test('reset', () async { - final sut = fixture.getSut(); + test('discardProfiler', () async { + await sut.discardProfiler(SentryId.newId()); + expect(channel.numberOfDiscardProfilerCalls, 1); + }); + + test('collectProfile', () async { + await sut.collectProfile(SentryId.newId(), 1, 2); + expect(channel.numberOfCollectProfileCalls, 1); + }); + test('reset', () async { sut.appStartEnd = DateTime.now(); await sut.fetchNativeAppStart(); - sut.reset(); - expect(sut.appStartEnd, null); expect(sut.didFetchAppStart, false); }); }); } - -class Fixture { - final channel = MockNativeChannel(); - - SentryNative getSut() { - final sut = SentryNative(); - sut.nativeChannel = channel; - return sut; - } -} diff --git a/flutter/test/sentry_navigator_observer_test.dart b/flutter/test/sentry_navigator_observer_test.dart index c9a1629362..8ef61b1398 100644 --- a/flutter/test/sentry_navigator_observer_test.dart +++ b/flutter/test/sentry_navigator_observer_test.dart @@ -33,21 +33,25 @@ void main() { } setUp(() { - SentryNative().reset(); fixture = Fixture(); }); - tearDown(() { - SentryNative().reset(); - }); - group('NativeFrames', () { + late MockNativeChannel mockNativeChannel; + + setUp(() { + mockNativeChannel = MockNativeChannel(); + SentryFlutter.native = + SentryNative(SentryFlutterOptions(dsn: fakeDsn), mockNativeChannel); + }); + + tearDown(() { + SentryFlutter.native = null; + }); + test('transaction start begins frames collection', () async { final currentRoute = route(RouteSettings(name: 'Current Route')); final mockHub = _MockHub(); - final native = SentryNative(); - final mockNativeChannel = MockNativeChannel(); - native.nativeChannel = mockNativeChannel; final tracer = getMockSentryTracer(); _whenAnyStart(mockHub, tracer); @@ -65,17 +69,13 @@ void main() { test('transaction finish adds native frames to tracer', () async { final currentRoute = route(RouteSettings(name: 'Current Route')); - final options = SentryOptions(dsn: fakeDsn); + final options = defaultTestOptions(); options.tracesSampleRate = 1; final hub = Hub(options); final nativeFrames = NativeFrames(3, 2, 1); - final mockNativeChannel = MockNativeChannel(); mockNativeChannel.nativeFrames = nativeFrames; - final mockNative = SentryNative(); - mockNative.nativeChannel = mockNativeChannel; - final sut = fixture.getSut( hub: hub, autoFinishAfter: Duration(milliseconds: 50), @@ -728,6 +728,8 @@ void main() { } class Fixture { + final mockNativeChannel = MockNativeChannel(); + SentryNavigatorObserver getSut({ required Hub hub, bool enableAutoTransactions = true, @@ -753,7 +755,7 @@ class Fixture { class _MockHub extends MockHub { @override - final options = SentryOptions(dsn: fakeDsn); + final options = defaultTestOptions(); @override late final scope = Scope(options); diff --git a/metrics/flutter.properties b/metrics/flutter.properties index e596e3de52..46cc19edb4 100644 --- a/metrics/flutter.properties +++ b/metrics/flutter.properties @@ -1,2 +1,2 @@ -version = 3.13.7 +version = 3.13.9 repo = https://github.com/flutter/flutter