From 2f614822d61765b976477f274fca57ca7f5b3f72 Mon Sep 17 00:00:00 2001 From: Jonatanm09 <44757486+Jonatanm09@users.noreply.github.com> Date: Mon, 23 Sep 2024 23:34:14 -0400 Subject: [PATCH] ICPFLUTTER-1242 (#26) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore(deps): update Cocoa SDK to v8.30.1 (#2155) * chore: update flutter/scripts/update-cocoa.sh to 8.30.1 * formatting --------- Co-authored-by: GitHub Co-authored-by: GIancarlo Buenaflor * Set dart runtime version with parsed `Platform.version` (#2156) * Parse semver * Move dart version to late and only extract it once during init * Set dartVersion to private and move _extractDartVersion out of init * Record dropped spans in client reports (#2154) * Record dropped spans * Changelog * Naming * Update CHANGELOG.md * Send dropped event as well for rate limit and network error * Update * Dart analyze * Fix test * Improve comments * improvements * Apply same logic of beforeSend to event processor * Fix test * Formatting * Comments * Rename mock * Fix compatibility with Drift 2.19.0 (#2162) * Add support for drift 2.19.0 * Update * fix test * Update CHANGELOG * Add ignores * Fix flaky app start tests (#2157) * chore(deps): update Symbol collector CLI to v1.19.0 (#2164) Co-authored-by: GitHub * Deprecate `setExtra` (#2159) * deprecate * update * Update deprecate message * Update CHANGELOG * Formatting and ignore warnings * analyze * deps: update ktlint (#2166) the new version should automatically resolve outdated comments * ci: validate publish dry runs (#2161) * draft script for checking publish * draft implementation * comment * trigger ci * Update workflow * Update workflow * Update workflow * Update workflow * revert example{ * Update workflow * Temporarily restrict drift for testing * Update pubspec.yaml * Update pubspec.yaml * Revert * Update analyze.yml * Update event_example.dart * Add flag to disable reporting of view hierarchy identifiers (#2158) * Add report view hierarchy identifier option * Update docs * Update CHANGELOG * Fix test * Formatting * Update CHANGELOG.md * release: 8.4.0 * Update CHANGELOG * build(deps): bump reactivecircus/android-emulator-runner (#2171) Bumps [reactivecircus/android-emulator-runner](https://github.com/reactivecircus/android-emulator-runner) from 2.31.0 to 2.32.0. - [Release notes](https://github.com/reactivecircus/android-emulator-runner/releases) - [Changelog](https://github.com/ReactiveCircus/android-emulator-runner/blob/main/CHANGELOG.md) - [Commits](https://github.com/reactivecircus/android-emulator-runner/compare/77986be26589807b8ebab3fde7bbf5c60dabec32...f0d1ed2dcad93c7479e8b2f2226c83af54494915) --- updated-dependencies: - dependency-name: reactivecircus/android-emulator-runner dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps): update Cocoa SDK to v8.31.1 (#2174) * chore: update flutter/scripts/update-cocoa.sh to 8.31.1 * format generated binding --------- Co-authored-by: GitHub Co-authored-by: Ivan Dlugos * chore(deps): update Android SDK to v7.12.0 (#2173) * chore: update flutter/scripts/update-android.sh to 7.12.0 * chore: update AGP and Kotlin --------- Co-authored-by: GitHub Co-authored-by: Ivan Dlugos Co-authored-by: Giancarlo Buenaflor * Fix truncated stacktraces in unhandled errors (#2152) * Fix stacktrace * Fix stacktrace * Update * Update changelog * Add test cases * formatting * formatting * Fix await * build(deps): bump ruby/setup-ruby from 1.185.0 to 1.187.0 (#2172) Bumps [ruby/setup-ruby](https://github.com/ruby/setup-ruby) from 1.185.0 to 1.187.0. - [Release notes](https://github.com/ruby/setup-ruby/releases) - [Changelog](https://github.com/ruby/setup-ruby/blob/master/release.rb) - [Commits](https://github.com/ruby/setup-ruby/compare/3a77c29278ae80936b4cb030fefc7d21c96c786f...161cd54b698f1fb3ea539faab2e036d409550e3c) --- updated-dependencies: - dependency-name: ruby/setup-ruby dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Giancarlo Buenaflor * fix analyze issues (#2177) * Update CHANGELOG.md (#2178) * Disable sff & frame delay detection on web, linux and windows (#2182) * disable * chore: update metrics/flutter.properties to 3.22.3 (#2180) Co-authored-by: GitHub * build(deps): bump gradle/gradle-build-action from 3.4.2 to 3.5.0 (#2186) Bumps [gradle/gradle-build-action](https://github.com/gradle/gradle-build-action) from 3.4.2 to 3.5.0. - [Release notes](https://github.com/gradle/gradle-build-action/releases) - [Commits](https://github.com/gradle/gradle-build-action/compare/66535aaf56f831b35e3a8481c9c99b665b84dd45...ac2d340dc04d9e1113182899e983b5400c17cda1) --- updated-dependencies: - dependency-name: gradle/gradle-build-action dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * build(deps): bump ruby/setup-ruby from 1.187.0 to 1.188.0 (#2187) Bumps [ruby/setup-ruby](https://github.com/ruby/setup-ruby) from 1.187.0 to 1.188.0. - [Release notes](https://github.com/ruby/setup-ruby/releases) - [Changelog](https://github.com/ruby/setup-ruby/blob/master/release.rb) - [Commits](https://github.com/ruby/setup-ruby/compare/161cd54b698f1fb3ea539faab2e036d409550e3c...50ba3386b050ad5b97a41fcb81240cbee1d1821f) --- updated-dependencies: - dependency-name: ruby/setup-ruby dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Support `ignoredExceptionsForType` (#2150) * Set platform in sentry frames for better raw stacktrace representation (#2193) * add platform to stacktrace * update * Update CHANGELOG * Fix tests * Fix test * release: 8.5.0 * chore(deps): update Cocoa SDK to v8.32.0 (#2195) * chore: update flutter/scripts/update-cocoa.sh to 8.32.0 * Format --------- Co-authored-by: GitHub Co-authored-by: GIancarlo Buenaflor * chore(deps): update Symbol collector CLI to v1.21.0 (#2196) Co-authored-by: GitHub * Implement error type identifier to mitigate obfuscated Flutter issue titles (#2170) * try to mitigate runtime type not being obfuscated * fix imports * Remove prints * Update * Update * Update exception_type_identifier.dart * Add caching * Update * split up dart:io and dart:html exceptions * fix analyze * Update CHANGELOG * update * Add more tests * Update docs * Update options docs * remove print * remove CustomException * import with show * try fix test * Update CHANGELOG.md * Update CHANGELOG.md * Fix analyze * try fix test * Update CHANGELOG.md * chore: update flutter/scripts/update-android.sh to 7.12.1 (#2198) Co-authored-by: GitHub * Deprecate `enableTracing` (#2199) * Deprecate * Add CHANGELOG * Fix flaky exception identifier test on web (#2201) * Fix test * build(deps): bump ruby/setup-ruby from 1.188.0 to 1.190.0 (#2202) Bumps [ruby/setup-ruby](https://github.com/ruby/setup-ruby) from 1.188.0 to 1.190.0. - [Release notes](https://github.com/ruby/setup-ruby/releases) - [Changelog](https://github.com/ruby/setup-ruby/blob/master/release.rb) - [Commits](https://github.com/ruby/setup-ruby/compare/50ba3386b050ad5b97a41fcb81240cbee1d1821f...a6e6f86333f0a2523ece813039b8b4be04560854) --- updated-dependencies: - dependency-name: ruby/setup-ruby dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * release: 8.6.0 * chore: update flutter/scripts/update-android.sh to 7.13.0 (#2206) Co-authored-by: GitHub * Add `ignored` discard reason (#2204) * deps: change updater PR strategy to update on low-risk updates (#2210) Flutter SDK in metrics and symbol-collector update jobs aren't expected to break so there's little reason to keep old PRs * feat: add span level measurements #1855 (#2214) * feat: add span level measurements * add changelog entry * add issue link to changelog * fix: correct changelog * moved the stored measurements from root span to tracer * changed issue number to pr number in changelog * fixed formatting * Update CHANGELOG.md Co-authored-by: Giancarlo Buenaflor --------- Co-authored-by: Martin Co-authored-by: Giancarlo Buenaflor * feat: add `ignoreTransactions` and ignoreErrors` #1391 (#2207) * Add ignoreTransactions and ignoreErrors #1391 * chore: add changelog entry * move methods from sentry_options to sentry_client and change to private * change discard reason to ignored Co-authored-by: Giancarlo Buenaflor * change iterable to list * add event recorder to ignoredTransactions * add tests for ignoreTransactions * set ignoreErrors list to empty list a default Co-authored-by: Giancarlo Buenaflor * change variables to final for ignoreTransaction Co-authored-by: Giancarlo Buenaflor * change var to final for ignoreErrors and adapt test * Update CHANGELOG.md Co-authored-by: Giancarlo Buenaflor * Add example for ignoreTransactions and ignoreErrors to changelog * fix: check for empty ignoreError and ignoreTransaction before handling regex * moved ignoreTransactions and ignoreErrors back to unreleased area in CHANGELOG.md * refactored implementation of ignoreErrors and ignoreTransactions and improved test cases * removed unnecessary backslash from tests --------- Co-authored-by: Martin <> Co-authored-by: Martin Co-authored-by: Giancarlo Buenaflor * Deserialize and serialize unknown fields (#2153) * Add proxy support (#2192) * chore(deps): update Cocoa SDK to v8.33.0 (#2223) * chore: update flutter/scripts/update-cocoa.sh to 8.33.0 * Formatting * fix analyze --------- Co-authored-by: GitHub Co-authored-by: GIancarlo Buenaflor * release: 8.7.0 * add ignoreRoutes parameter to SentryNavigatorObserver (#2218) * add ignoreRoutes parameter to SentryNavigatorObserver * add unitTest for ignoreRoutes * add changelog entry for ignoreRoutes in the SentryNaviagtorObserver * add for ignore routes, that not TTID and TTFD spans are created Co-authored-by: Giancarlo Buenaflor * add further tests for ignore routes * fix changelog and move ignoreRoutes to unreleased --------- Co-authored-by: Giancarlo Buenaflor * chore(deps): update Flutter SDK (metrics) to v3.24.0 (#2229) Co-authored-by: GitHub * chore(deps): update Android SDK to v7.14.0 (#2228) Co-authored-by: GitHub * test: flutter wasm (#2231) * test: flutter wasm * temporarily disable failing tests * fixup ci * cleanup * feat: Debouncing of SentryWidgetsBindingObserver.didChangeMetrics. #400 (#2232) * feat: add debouncer for SentryWidgetsBindingObserver.didChangeMetrics * adapt tests for debouncing * add changelog entry for debouncer * Update flutter/lib/src/utils/debouncer.dart Co-authored-by: Giancarlo Buenaflor * Update flutter/test/widgets_binding_observer_test.dart Co-authored-by: Giancarlo Buenaflor * add internal to debouncer and add whitespaces to comments --------- Co-authored-by: Giancarlo Buenaflor * Provide a way to cause an example native crash from Flutter (#2239) * add SentryFlutter.nativeCrash() for Android and iOS * add changelog entry * remove unused variable * improved kotlin implementation * fix kotlin analysis warnings * Update CHANGELOG.md Co-authored-by: Giancarlo Buenaflor * fix kotlin linter errors * remove whitespace * add Description for nativeCrash --------- Co-authored-by: Giancarlo Buenaflor * chore(deps): update Cocoa SDK to v8.35.1 (#2247) * chore: update flutter/scripts/update-cocoa.sh to 8.35.1 * format --------- Co-authored-by: GitHub Co-authored-by: GIancarlo Buenaflor * chore: fix cocoa bump changelog (#2248) * release: 8.8.0 * chore(deps): update Flutter SDK (metrics) to v3.24.1 (#2245) Co-authored-by: GitHub * chore(deps): update Cocoa SDK to v8.36.0 (#2252) * chore: update flutter/scripts/update-cocoa.sh to 8.36.0 * update --------- Co-authored-by: GitHub Co-authored-by: GIancarlo Buenaflor * chore: update windows code based on latest flutter template with plugin_ffi (#2243) * allow internet and network state access in the flutter example app (#2255) * allow internet and network state access in the example app * add changelog * feat: Replay support for mobile (#2208) * Flutter replay for Android (#2032) * minor gradle fixes * tmp: local sentry-java build * tmp: use relative path to sentry-java * tmp: local java build patches * replay options * replay recorder * wip: JNI native bindings * use compatible jnigen * add missing gradlew to flutter/android * replay recorder JNI binding code * replay recorder binding jni code * jni 0.6 * wip: android jni replay * replay binding * glue code for jni * chore: update to cocoa 8.24.1-alpha.0 * wip: cocoa integration * wip: ios replay * cleanup * formatting * android fixes * move native setup to the native sdk integration * cleanup & improvements * improve widget filter and implement redact options * fix image scaling * ktlint format * ci fixes * fix tests * add jnigen scripts * use android 7.9.0 alpha.1 * move native init & close to SentryNative * cleanup * add macOS integration link * rollback cocoa changes * remove jni/jnigen * wip: methodchannel based android recorder * callback * linter issues * minor fixes * more fixes * linter issues * cleanup * improve logging * move replay to experimental, same as in other SDKs * improve tree shaking * test: scheduler * support browser test * fix compat with old flutter * cleanup * rename recorder_widget_filter.dart * fixup scheduler test * improve test coverage * pr cleanup * test: widget filter * cleanup * test widget filter visibility * cleanup * always add screenshot widget * recorder test * cleanup * limit recorder test to vm * wip: integration test * cleanup * ktlint format * detekt suppression * ktlint format * improve scheduler stop behavior * wip: error replay mapping * suppress detekt TooGenericExceptionThrown * Update flutter/lib/src/replay/recorder.dart Co-authored-by: Giancarlo Buenaflor * Update flutter/lib/src/native/java/sentry_native_java.dart Co-authored-by: Giancarlo Buenaflor * improve comments * feat: associate dart errors with replays (#2070) * feat: associate dart errors with replays * ktlint * cleanup * tests * chote: remove path dependency * fix tests * feat: replay breadcrumbs (android) (#2163) * feat: replay breadcrumbs * ktlint format * fixup tests * cleanup * linter issues * detekt linter issue * move touch path build to dart to deduplicate * fix metrics app compilation * linter issue * test: native replay integration binding (#2189) * wip: test native integration * test: native replay binding * update example * chore: update pubspec * fixup tests * Update flutter/test/mocks.dart * chore: update changelog * fix publishing * release: 8.6.0-alpha.2 --------- Co-authored-by: Giancarlo Buenaflor Co-authored-by: getsentry-bot Co-authored-by: getsentry-bot * fix: update android calls after SDK update (#2211) * fix: update android calls after SDK update * ktlint * feat: iOS replay support (#2209) * minor gradle fixes * tmp: local sentry-java build * tmp: use relative path to sentry-java * tmp: local java build patches * replay options * replay recorder * wip: JNI native bindings * use compatible jnigen * add missing gradlew to flutter/android * replay recorder JNI binding code * replay recorder binding jni code * jni 0.6 * wip: android jni replay * replay binding * glue code for jni * chore: update to cocoa 8.24.1-alpha.0 * wip: cocoa integration * wip: ios replay * cleanup * formatting * android fixes * move native setup to the native sdk integration * cleanup & improvements * improve widget filter and implement redact options * fix image scaling * ktlint format * ci fixes * fix tests * add jnigen scripts * use android 7.9.0 alpha.1 * move native init & close to SentryNative * cleanup * add macOS integration link * rollback cocoa changes * remove jni/jnigen * wip: methodchannel based android recorder * callback * linter issues * minor fixes * more fixes * linter issues * cleanup * improve logging * move replay to experimental, same as in other SDKs * improve tree shaking * test: scheduler * support browser test * fix compat with old flutter * cleanup * rename recorder_widget_filter.dart * fixup scheduler test * improve test coverage * pr cleanup * test: widget filter * cleanup * test widget filter visibility * cleanup * always add screenshot widget * recorder test * cleanup * limit recorder test to vm * wip: integration test * cleanup * ktlint format * detekt suppression * ktlint format * improve scheduler stop behavior * wip: error replay mapping * suppress detekt TooGenericExceptionThrown * Update flutter/lib/src/replay/recorder.dart Co-authored-by: Giancarlo Buenaflor * Update flutter/lib/src/native/java/sentry_native_java.dart Co-authored-by: Giancarlo Buenaflor * improve comments * feat: associate dart errors with replays (#2070) * feat: associate dart errors with replays * ktlint * cleanup * tests * chote: remove path dependency * wip: ios replay * fix result callback * iOS related refactorings * logs * fix tests * call captureReplay on iOS & set * ios replay breadcrumbs * feat: replay breadcrumbs (android) (#2163) * feat: replay breadcrumbs * ktlint format * fixup tests * cleanup * linter issues * detekt linter issue * move touch path build to dart to deduplicate * fix metrics app compilation * linter issue * test: native replay integration binding (#2189) * wip: test native integration * test: native replay binding * update example * chore: update pubspec * fixup tests * Update flutter/test/mocks.dart * chore: update changelog * fix publishing * release: 8.6.0-alpha.2 * cleanup * fix macos compilation * test: iOS support * linter issues * linter issues * chore: update changelog * Update flutter/lib/src/native/cocoa/sentry_native_cocoa.dart Co-authored-by: Giancarlo Buenaflor --------- Co-authored-by: Giancarlo Buenaflor Co-authored-by: getsentry-bot Co-authored-by: getsentry-bot * fix: cocoa sdk renamed errorSampleRate to onErrorSampleRate * fixup changelog * release: 8.8.0-alpha.1 * chore: update changelog * update changelog --------- Co-authored-by: Giancarlo Buenaflor Co-authored-by: getsentry-bot Co-authored-by: getsentry-bot * chore: fixup changelog (#2260) * refactor: Remove workaround for Spotlight image handling (#2253) * fix: capture replay call on iOS (#2264) * Support allowUrls, denyUrls (#2227) * moved regex matcher into regex utils * add allowUrls, denyUrls for web * add changelog entry for allowUrls and denyUrls * add conditional import for non web platforms * fix multiplatform build * fix wording in sentry options * Update dart/lib/src/utils/regex_utils.dart Co-authored-by: Giancarlo Buenaflor * Update dart/lib/src/sentry_options.dart Co-authored-by: Giancarlo Buenaflor * Update dart/lib/src/sentry_options.dart Co-authored-by: Giancarlo Buenaflor * add tests for isMatchingRegexPattern * simplified allowUrls and denyUrls handling * moved allowUrls and denyUrls from dart to flutter * add event processor for html * rephrased documentation and split up tests for web and mobile platform. * add expected error * Update scripts/publish_validation/bin/publish_validation.dart Co-authored-by: Giancarlo Buenaflor * Update flutter/lib/src/event_processor/url_filter/html_url_filter_event_processor.dart Co-authored-by: Giancarlo Buenaflor * Update flutter/lib/src/event_processor/url_filter/web_url_filter_event_processor.dart Co-authored-by: Giancarlo Buenaflor --------- Co-authored-by: Giancarlo Buenaflor * Only access renderObject if `hasSize` is true (#2263) * feat: asset images don't need to be obscured in replay (#2269) * feat: asset images don't need to be obscured * chore: update changelog * feat: improve obscure rectangle fit/size (#2236) * chore(deps): update Flutter SDK (metrics) to v3.24.2 (#2272) Co-authored-by: GitHub * Fix: Support allowUrls, denyUrls (#2271) * moved regex matcher into regex utils * add allowUrls, denyUrls for web * add changelog entry for allowUrls and denyUrls * add conditional import for non web platforms * fix multiplatform build * fix wording in sentry options * Update dart/lib/src/utils/regex_utils.dart Co-authored-by: Giancarlo Buenaflor * Update dart/lib/src/sentry_options.dart Co-authored-by: Giancarlo Buenaflor * Update dart/lib/src/sentry_options.dart Co-authored-by: Giancarlo Buenaflor * add tests for isMatchingRegexPattern * simplified allowUrls and denyUrls handling * moved allowUrls and denyUrls from dart to flutter * add event processor for html * rephrased documentation and split up tests for web and mobile platform. * add expected error * Update scripts/publish_validation/bin/publish_validation.dart Co-authored-by: Giancarlo Buenaflor * Update flutter/lib/src/event_processor/url_filter/html_url_filter_event_processor.dart Co-authored-by: Giancarlo Buenaflor * Update flutter/lib/src/event_processor/url_filter/web_url_filter_event_processor.dart Co-authored-by: Giancarlo Buenaflor * modified code to go through stacktrace frames * change matching to window.location --------- Co-authored-by: Giancarlo Buenaflor * Symbolicate Dart stacktrace on Flutter Android and iOS without debug images from native sdks (#2256) * add symbolication * update implementation * update * update * update * update * update * update comment * update * update * update * fix * update * fix tests * fix initial value test * Update comment and test * update * Update NeedsSymbolication * revert sample * revert * update * update naming * update naming and comments of flag * set stacktrace in hint * update * add changelog * update * fix test * fix test * cache debug image * updaet * update var name * updaet * update naming * improve names * break early safeguard for parsing stacktrace and dont throw in hex format parsing * revert load native image list integration * update * fix analyze * fix analyze * feat: capture touch breadcrumbs for all buttons (#2242) * chore: cleanup user interaction widget code * renames & more cleanup * more cleanup * more refactoring & clenaup before actual functional changes * more refactoring * feat: collect touch element path * update tests * add tests for the new support of non-keyed button presses * cleanup & improve existing code * chore: update changelog * update native replay integration with touch breadcrumb path * fix tests * Update CHANGELOG.md * linter issues --------- Co-authored-by: Giancarlo Buenaflor * fix: repost replay screenshots on android while idle (#2275) * fix: repost replay screenshots on android while idle * chore: changelog * review change * chore: rename errorSampleRate to onErrorSampleRate (#2270) * chore: rename errorSampleRate to onErrorSampleRate * Update CHANGELOG.md * release: 8.9.0 * Update CHANGELOG.md * build(deps): bump natiginfo/action-detekt-all from 1.23.6 to 1.23.7 (#2278) Bumps [natiginfo/action-detekt-all](https://github.com/natiginfo/action-detekt-all) from 1.23.6 to 1.23.7. - [Release notes](https://github.com/natiginfo/action-detekt-all/releases) - [Commits](https://github.com/natiginfo/action-detekt-all/compare/b9daaf58ff7a4885ff92ba612c3ea72bf1abeadb...6bf4342ea96f638ecced05cf7d7dc48acdecc854) --- updated-dependencies: - dependency-name: natiginfo/action-detekt-all dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * build(deps): bump ruby/setup-ruby from 1.190.0 to 1.191.0 (#2279) Bumps [ruby/setup-ruby](https://github.com/ruby/setup-ruby) from 1.190.0 to 1.191.0. - [Release notes](https://github.com/ruby/setup-ruby/releases) - [Changelog](https://github.com/ruby/setup-ruby/blob/master/release.rb) - [Commits](https://github.com/ruby/setup-ruby/compare/a6e6f86333f0a2523ece813039b8b4be04560854...52753b7da854d5c07df37391a986c76ab4615999) --- updated-dependencies: - dependency-name: ruby/setup-ruby dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Giancarlo Buenaflor * Fix typo (#2283) Fixes a small typo I stumbled upon when reading through the changelogs. #skip-changelog * chore(deps): update Flutter SDK (metrics) to v3.24.3 (#2287) Co-authored-by: GitHub * fix: event processor failed to stop processing (#2289) * test: rethrow exceptions (#2290) * test: rethrow in native SDK integration * test: rethrow in automated test mode * fix event processor runner * test: use options.automatedTestMode everywhere * update failing tests * fix tests * formatting * fix tests * fix: tests * fix tests * update comments * Improve app start integration (#2266) * build(deps): bump ruby/setup-ruby from 1.191.0 to 1.192.0 (#2292) Bumps [ruby/setup-ruby](https://github.com/ruby/setup-ruby) from 1.191.0 to 1.192.0. - [Release notes](https://github.com/ruby/setup-ruby/releases) - [Changelog](https://github.com/ruby/setup-ruby/blob/master/release.rb) - [Commits](https://github.com/ruby/setup-ruby/compare/52753b7da854d5c07df37391a986c76ab4615999...a6b46b8a08edb18935835849f2a17072d5cc8c73) --- updated-dependencies: - dependency-name: ruby/setup-ruby dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Update CODEOWNERS (#2300) * block app start if appLaunchedInForeground is not set. (#2291) * block app start if appLaunchedInForeground is not set. * add changelog entry * call public method * fix deprecated toUpperCase to uppercase * Update flutter/android/src/main/kotlin/io/sentry/flutter/SentryFlutterPlugin.kt Co-authored-by: Giancarlo Buenaflor * add 60 second timeout for app starts for android native * fix missing time imports * rearrange line * add comma --------- Co-authored-by: Giancarlo Buenaflor * add missing file * rename deprecated method to avoid conflict if the method still available * add deleted file * merge with main * rename deprecated method to avoid conflict if the method still available * adde missing import --------- Signed-off-by: dependabot[bot] Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: GitHub Co-authored-by: GIancarlo Buenaflor Co-authored-by: Ivan Dlugos <6349682+vaind@users.noreply.github.com> Co-authored-by: getsentry-bot Co-authored-by: getsentry-bot Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Ivan Dlugos Co-authored-by: Denis Andrašec Co-authored-by: Martin Haintz Co-authored-by: Martin Co-authored-by: Giancarlo Buenaflor Co-authored-by: Markus Hintersteiner Co-authored-by: Karl Heinz Struggl --- .github/CODEOWNERS | 2 +- .github/workflows/analyze.yml | 5 + .github/workflows/flutter.yml | 21 +- .../workflows/flutter_integration_test.yml | 6 +- .github/workflows/flutter_test.yml | 8 +- .github/workflows/testflight.yml | 2 +- .github/workflows/update-deps.yml | 2 + CHANGELOG.md | 322 ++++++- dart/example_web/pubspec.yaml | 1 + dart/example_web/web/main.dart | 34 +- dart/example_web_legacy/.gitignore | 9 + dart/example_web_legacy/README.md | 8 + dart/example_web_legacy/analysis_options.yaml | 5 + dart/example_web_legacy/pubspec.yaml | 17 + dart/example_web_legacy/web/event.dart | 76 ++ dart/example_web_legacy/web/favicon.ico | Bin 0 -> 3559 bytes dart/example_web_legacy/web/index.html | 69 ++ dart/example_web_legacy/web/main.dart | 136 +++ dart/example_web_legacy/web/styles.css | 14 + dart/lib/sentry.dart | 4 + .../client_report_recorder.dart | 6 +- .../src/client_reports/discard_reason.dart | 1 + .../src/client_reports/discarded_event.dart | 4 + .../noop_client_report_recorder.dart | 3 +- .../src/dart_exception_type_identifier.dart | 41 + .../dart_exception_type_identifier_io.dart | 14 + .../dart_exception_type_identifier_web.dart | 6 + dart/lib/src/debug_image_extractor.dart | 195 +++++ .../environment/environment_variables.dart | 3 +- .../enricher/enricher_event_processor.dart | 3 +- .../html_enricher_event_processor.dart | 103 +++ .../enricher/io_enricher_event_processor.dart | 43 +- .../enricher/io_platform_memory.dart | 111 +++ .../web_enricher_event_processor.dart | 35 +- .../exception/exception_event_processor.dart | 3 +- .../io_exception_event_processor.dart | 3 + dart/lib/src/exception_type_identifier.dart | 54 ++ dart/lib/src/http_client/client_provider.dart | 16 + .../src/http_client/io_client_provider.dart | 67 ++ dart/lib/src/hub.dart | 32 + .../load_dart_debug_images_integration.dart | 77 ++ dart/lib/src/origin.dart | 7 +- dart/lib/src/origin_html.dart | 4 + .../src/{noop_origin.dart => origin_io.dart} | 0 dart/lib/src/origin_web.dart | 6 + dart/lib/src/performance_collector.dart | 13 + dart/lib/src/platform/_html_platform.dart | 51 ++ dart/lib/src/platform/_web_platform.dart | 14 +- dart/lib/src/platform/platform.dart | 5 +- dart/lib/src/protocol/access_aware_map.dart | 53 ++ dart/lib/src/protocol/breadcrumb.dart | 36 +- dart/lib/src/protocol/debug_image.dart | 12 +- dart/lib/src/protocol/debug_meta.dart | 15 +- dart/lib/src/protocol/mechanism.dart | 12 +- dart/lib/src/protocol/metric_summary.dart | 32 +- dart/lib/src/protocol/sdk_info.dart | 12 +- dart/lib/src/protocol/sdk_version.dart | 11 +- dart/lib/src/protocol/sentry_app.dart | 35 +- dart/lib/src/protocol/sentry_browser.dart | 29 +- dart/lib/src/protocol/sentry_culture.dart | 29 +- dart/lib/src/protocol/sentry_device.dart | 106 +-- dart/lib/src/protocol/sentry_event.dart | 14 +- dart/lib/src/protocol/sentry_exception.dart | 14 +- dart/lib/src/protocol/sentry_gpu.dart | 49 +- dart/lib/src/protocol/sentry_message.dart | 18 +- .../src/protocol/sentry_operating_system.dart | 34 +- dart/lib/src/protocol/sentry_package.dart | 15 +- dart/lib/src/protocol/sentry_proxy.dart | 62 ++ dart/lib/src/protocol/sentry_request.dart | 13 +- dart/lib/src/protocol/sentry_runtime.dart | 29 +- dart/lib/src/protocol/sentry_span.dart | 40 +- dart/lib/src/protocol/sentry_stack_frame.dart | 13 +- dart/lib/src/protocol/sentry_stack_trace.dart | 17 +- dart/lib/src/protocol/sentry_thread.dart | 11 +- .../src/protocol/sentry_trace_context.dart | 28 +- .../src/protocol/sentry_transaction_info.dart | 15 +- dart/lib/src/protocol/sentry_user.dart | 18 +- .../recursive_exception_cause_extractor.dart | 3 + dart/lib/src/scope.dart | 15 +- dart/lib/src/sentry.dart | 8 + dart/lib/src/sentry_baggage.dart | 15 + dart/lib/src/sentry_client.dart | 179 +++- dart/lib/src/sentry_client_stub.dart | 11 - dart/lib/src/sentry_envelope.dart | 17 +- dart/lib/src/sentry_envelope_item.dart | 33 +- dart/lib/src/sentry_exception_factory.dart | 19 +- dart/lib/src/sentry_options.dart | 93 +- dart/lib/src/sentry_span_interface.dart | 2 +- dart/lib/src/sentry_stack_trace_factory.dart | 4 +- dart/lib/src/sentry_trace_context_header.dart | 28 +- dart/lib/src/sentry_tracer.dart | 33 +- dart/lib/src/sentry_traces_sampler.dart | 1 + dart/lib/src/sentry_user_feedback.dart | 15 +- dart/lib/src/transport/data_category.dart | 22 +- dart/lib/src/transport/http_transport.dart | 8 +- dart/lib/src/transport/rate_limiter.dart | 36 +- .../transport/spotlight_http_transport.dart | 11 +- dart/lib/src/utils/isolate_utils.dart | 3 +- dart/lib/src/utils/regex_utils.dart | 9 + dart/lib/src/utils/transport_utils.dart | 17 +- dart/lib/src/version.dart | 2 +- dart/pubspec.yaml | 7 +- dart/test/contexts_test.dart | 2 +- dart/test/debug_image_extractor_test.dart | 118 +++ dart/test/diagnostic_logger_test.dart | 4 +- dart/test/environment_test.dart | 9 +- .../deduplication_event_processor_test.dart | 6 +- .../enricher/io_enricher_test.dart | 20 +- .../enricher/io_platform_memory_test.dart | 61 ++ .../enricher/web_enricher_test.dart | 24 +- dart/test/example_web_compile_test.dart | 27 +- dart/test/exception_identifier_test.dart | 185 ++++ .../failed_request_client_test.dart | 3 +- .../http_client/io_client_provider_test.dart | 267 ++++++ .../test/http_client/tracing_client_test.dart | 5 +- dart/test/hub_test.dart | 33 +- dart/test/initialization_test.dart | 5 +- ...ad_dart_debug_images_integration_test.dart | 110 +++ .../test/metrics/metrics_aggregator_test.dart | 5 +- dart/test/metrics/metrics_api_test.dart | 4 +- dart/test/mocks.dart | 26 + dart/test/mocks.mocks.dart | 15 +- .../mocks/mock_client_report_recorder.dart | 10 +- dart/test/mocks/mock_envelope.dart | 3 + dart/test/mocks/mock_hub.dart | 4 +- dart/test/mocks/mock_platform.dart | 8 + .../test/protocol/access_aware_map_tests.dart | 118 +++ dart/test/protocol/breadcrumb_test.dart | 36 +- dart/test/protocol/debug_image_test.dart | 4 + dart/test/protocol/debug_meta_test.dart | 4 + dart/test/protocol/mechanism_test.dart | 4 + dart/test/protocol/rate_limiter_test.dart | 35 +- dart/test/protocol/sdk_info_test.dart | 7 + dart/test/protocol/sdk_version_test.dart | 7 + dart/test/protocol/sentry_app_test.dart | 4 + .../protocol/sentry_baggage_header_test.dart | 17 +- dart/test/protocol/sentry_device_test.dart | 4 + dart/test/protocol/sentry_exception_test.dart | 4 + dart/test/protocol/sentry_gpu_test.dart | 23 +- dart/test/protocol/sentry_message_test.dart | 4 + .../sentry_operating_system_test.dart | 17 +- dart/test/protocol/sentry_package_test.dart | 4 + dart/test/protocol/sentry_proxy_test.dart | 102 +++ dart/test/protocol/sentry_request_test.dart | 4 + dart/test/protocol/sentry_runtime_test.dart | 4 + .../protocol/sentry_stack_frame_test.dart | 4 + .../protocol/sentry_stack_trace_test.dart | 8 + .../sentry_transaction_info_test.dart | 14 +- dart/test/protocol/sentry_user_test.dart | 7 + ...ursive_exception_cause_extractor_test.dart | 6 +- .../run_zoned_guarded_integration_test.dart | 4 +- dart/test/scope_test.dart | 54 +- dart/test/sentry_attachment_test.dart | 4 +- dart/test/sentry_client_test.dart | 315 ++++++- dart/test/sentry_envelope_test.dart | 11 +- dart/test/sentry_envelope_vm_test.dart | 8 +- dart/test/sentry_event_test.dart | 102 +-- dart/test/sentry_exception_factory_test.dart | 4 +- dart/test/sentry_isolate_extension_test.dart | 5 +- dart/test/sentry_isolate_test.dart | 5 +- dart/test/sentry_options_test.dart | 54 +- dart/test/sentry_span_test.dart | 15 + dart/test/sentry_test.dart | 71 +- .../sentry_trace_context_header_test.dart | 45 +- dart/test/sentry_trace_context_test.dart | 38 +- dart/test/sentry_tracer_test.dart | 25 +- dart/test/sentry_traces_sampler_test.dart | 6 +- dart/test/sentry_transaction_test.dart | 3 +- dart/test/sentry_user_feedback_test.dart | 66 +- dart/test/stack_trace_test.dart | 57 +- dart/test/test_utils.dart | 41 +- dart/test/transport/http_transport_test.dart | 65 +- .../spotlight_http_transport_test.dart | 5 +- dart/test/transport/tesk_queue_test.dart | 5 +- dart/test/utils/regex_utils_test.dart | 24 + dart/test/utils/tracing_utils_test.dart | 6 +- dio/lib/src/version.dart | 2 +- dio/pubspec.yaml | 4 +- dio/test/dio_event_processor_test.dart | 2 +- dio/test/mocks.dart | 5 + dio/test/mocks/mock_hub.dart | 3 +- dio/test/sentry_transformer_test.dart | 2 +- dio/test/tracing_client_adapter_test.dart | 2 +- drift/lib/src/sentry_query_executor.dart | 14 +- .../lib/src/sentry_transaction_executor.dart | 12 + drift/lib/src/version.dart | 2 +- drift/pubspec.yaml | 6 +- drift/test/sentry_database_test.dart | 10 +- drift/test/utils.dart | 8 + file/lib/src/sentry_file_extension.dart | 4 +- file/lib/src/version.dart | 2 +- file/pubspec.yaml | 4 +- file/test/mock_sentry_client.dart | 5 + file/test/sentry_file_extension_test.dart | 2 +- file/test/sentry_file_test.dart | 2 +- .../sentry_io_overrides_integration_test.dart | 2 +- flutter/android/build.gradle | 6 +- flutter/android/gradle.properties | 1 + .../kotlin/io/sentry/flutter/SentryFlutter.kt | 42 +- .../io/sentry/flutter/SentryFlutterPlugin.kt | 206 ++++- .../SentryFlutterReplayBreadcrumbConverter.kt | 118 +++ .../flutter/SentryFlutterReplayRecorder.kt | 72 ++ .../io/sentry/flutter/SentryFlutterTest.kt | 35 +- flutter/example/android/app/build.gradle | 2 - .../android/app/src/main/AndroidManifest.xml | 3 + flutter/example/android/build.gradle | 4 +- flutter/example/integration_test/all.dart | 2 + .../integration_test/integration_test.dart | 7 +- .../example/integration_test/replay_test.dart | 39 + flutter/example/ios/Runner/AppDelegate.swift | 2 +- .../ios/RunnerTests/SentryFlutterTests.swift | 48 +- flutter/example/lib/main.dart | 59 +- flutter/example/lib/user_feedback_dialog.dart | 9 +- flutter/example/pubspec.yaml | 2 +- flutter/example/windows/CMakeLists.txt | 31 +- .../example/windows/flutter/CMakeLists.txt | 10 +- flutter/example/windows/runner/CMakeLists.txt | 26 +- flutter/example/windows/runner/Runner.rc | 14 +- .../example/windows/runner/flutter_window.cpp | 19 +- .../example/windows/runner/flutter_window.h | 10 +- flutter/example/windows/runner/main.cpp | 13 +- flutter/example/windows/runner/run_loop.cpp | 66 -- flutter/example/windows/runner/run_loop.h | 40 - .../windows/runner/runner.exe.manifest | 2 +- flutter/example/windows/runner/utils.cpp | 13 +- .../example/windows/runner/win32_window.cpp | 55 +- flutter/example/windows/runner/win32_window.h | 20 +- flutter/ios/Classes/SentryFlutter.swift | 43 + .../Classes/SentryFlutterPluginApple.swift | 110 ++- .../SentryFlutterReplayBreadcrumbConverter.h | 15 + .../SentryFlutterReplayBreadcrumbConverter.m | 153 ++++ .../SentryFlutterReplayScreenshotProvider.h | 12 + .../SentryFlutterReplayScreenshotProvider.m | 46 + flutter/ios/sentry_flutter.podspec | 2 +- flutter/lib/sentry_flutter.dart | 3 +- flutter/lib/src/binding_wrapper.dart | 3 + ...id_platform_exception_event_processor.dart | 3 + .../flutter_enricher_event_processor.dart | 10 +- .../native_app_start_event_processor.dart | 155 ---- .../replay_event_processor.dart | 22 + .../screenshot_event_processor.dart | 4 +- .../html_url_filter_event_processor.dart | 37 + .../io_url_filter_event_processor.dart | 10 + .../url_filter_event_processor.dart | 9 + .../web_url_filter_event_processor.dart | 39 + flutter/lib/src/file_system_transport.dart | 19 +- .../flutter_exception_type_identifier.dart | 22 + flutter/lib/src/frame_callback_handler.dart | 21 + .../connectivity/connectivity_provider.dart | 3 +- .../html_connectivity_provider.dart | 32 + .../web_connectivity_provider.dart | 17 +- .../flutter_error_integration.dart | 6 +- .../load_contexts_integration.dart | 21 +- .../load_image_list_integration.dart | 79 +- .../load_release_integration.dart | 3 + .../native_app_start_handler.dart | 304 +++++++ .../native_app_start_integration.dart | 242 +----- .../integrations/native_sdk_integration.dart | 12 +- .../integrations/on_error_integration.dart | 11 + flutter/lib/src/native/cocoa/binding.dart | 231 +++-- .../src/native/cocoa/sentry_native_cocoa.dart | 73 +- flutter/lib/src/native/factory.dart | 4 +- flutter/lib/src/native/factory_real.dart | 15 +- flutter/lib/src/native/factory_web.dart | 4 +- .../src/native/java/sentry_native_java.dart | 200 ++++- flutter/lib/src/native/native_app_start.dart | 24 + flutter/lib/src/native/native_frames.dart | 18 + .../lib/src/native/native_scope_observer.dart | 24 +- flutter/lib/src/native/sentry_native.dart | 172 ---- .../lib/src/native/sentry_native_binding.dart | 24 +- .../lib/src/native/sentry_native_channel.dart | 185 ++-- .../lib/src/native/sentry_native_invoker.dart | 45 + .../native/sentry_safe_method_channel.dart | 39 + .../navigation/sentry_navigator_observer.dart | 67 +- .../navigation/time_to_display_tracker.dart | 9 - .../time_to_initial_display_tracker.dart | 14 - flutter/lib/src/profiling.dart | 8 +- flutter/lib/src/renderer/renderer.dart | 1 + flutter/lib/src/renderer/web_renderer.dart | 18 + flutter/lib/src/replay/recorder.dart | 119 +++ flutter/lib/src/replay/recorder_config.dart | 29 + .../lib/src/replay/scheduled_recorder.dart | 40 + flutter/lib/src/replay/scheduler.dart | 55 ++ flutter/lib/src/replay/widget_filter.dart | 173 ++++ .../screenshot/sentry_screenshot_widget.dart | 29 +- flutter/lib/src/sentry_asset_bundle.dart | 7 + flutter/lib/src/sentry_flutter.dart | 186 ++-- flutter/lib/src/sentry_flutter_options.dart | 85 +- flutter/lib/src/sentry_replay_options.dart | 40 + .../lib/src/span_frame_metrics_collector.dart | 256 ++++++ .../sentry_user_interaction_widget.dart | 275 +++--- .../user_interaction_info.dart | 13 + .../user_interaction_widget.dart | 15 - flutter/lib/src/utils/debouncer.dart | 20 + flutter/lib/src/utils/enum_wrapper.dart | 12 + flutter/lib/src/version.dart | 2 +- .../view_hierarchy/sentry_tree_walker.dart | 17 +- .../view_hierarchy_event_processor.dart | 2 +- flutter/lib/src/widgets_binding_observer.dart | 12 +- flutter/pubspec.yaml | 7 +- flutter/scripts/generate-cocoa-bindings.sh | 8 +- flutter/temp/native-test/dist/sentry.dll | Bin 0 -> 280064 bytes ...atform_exception_event_processor_test.dart | 3 +- ...flutter_enricher_event_processor_test.dart | 20 +- .../screenshot_event_processor_test.dart | 5 +- .../io_filter_event_processor_test.dart | 41 + .../web_url_filter_event_processor_test.dart | 121 +++ flutter/test/fake_frame_callback_handler.dart | 19 +- flutter/test/file_system_transport_test.dart | 102 ++- flutter/test/initialization_test.dart | 52 +- .../connectivity_integration_test.dart | 3 +- .../debug_print_integration_test.dart | 6 +- flutter/test/integrations/fixture.dart | 23 + .../flutter_error_integration_test.dart | 70 +- .../integrations/init_native_sdk_test.dart | 52 +- .../load_contexts_integration_test.dart | 98 +-- .../load_contexts_integrations_test.dart | 28 +- .../integrations/load_image_list_test.dart | 244 ++---- .../load_release_integration_test.dart | 2 +- .../native_app_start_handler_test.dart | 348 ++++++++ .../native_app_start_integration_test.dart | 466 ++-------- .../native_sdk_integration_test.dart | 93 +- ...ets_binding_on_error_integration_test.dart | 3 +- .../not_initialized_widgets_binding_test.dart | 4 +- .../on_error_integration_test.dart | 30 +- .../screenshot_integration_test.dart | 4 +- ...gets_flutter_binding_integration_test.dart | 4 +- flutter/test/load_image_list_test.dart | 183 ---- flutter/test/mock_frame_callback_handler.dart | 25 + flutter/test/mocks.dart | 382 ++------- flutter/test/mocks.mocks.dart | 799 +++++++++++++++--- flutter/test/native_scope_observer_test.dart | 55 +- .../sentry_display_widget_test.dart | 51 +- .../time_to_display_tracker_test.dart | 46 +- .../time_to_full_display_tracker_test.dart | 2 +- .../time_to_initial_display_tracker_test.dart | 37 +- flutter/test/profiling_test.dart | 33 +- flutter/test/replay/recorder_config_test.dart | 24 + flutter/test/replay/recorder_test.dart | 47 ++ flutter/test/replay/replay_native_test.dart | 246 ++++++ .../test/replay/scheduled_recorder_test.dart | 62 ++ flutter/test/replay/scheduler_test.dart | 82 ++ flutter/test/replay/test_widget.dart | 80 ++ flutter/test/replay/widget_filter_test.dart | 128 +++ .../sentry_screenshot_widget_test.dart | 3 +- flutter/test/sentry_asset_bundle_test.dart | 2 +- flutter/test/sentry_flutter_options_test.dart | 14 +- flutter/test/sentry_flutter_test.dart | 250 ++++-- flutter/test/sentry_native_channel_test.dart | 517 +++++++----- flutter/test/sentry_native_test.dart | 120 --- .../test/sentry_navigator_observer_test.dart | 128 ++- .../span_frame_metrics_collector_test.dart | 274 ++++++ .../sentry_user_interaction_widget_test.dart | 315 +++++-- .../sentry_tree_walker_test.dart | 10 +- .../view_hierarchy_event_processor_test.dart | 34 +- .../view_hierarchy_integration_test.dart | 4 +- .../test/widgets_binding_observer_test.dart | 81 +- flutter/windows/.gitignore | 3 - flutter/windows/CMakeLists.txt | 27 +- .../sentry_flutter/sentry_flutter_plugin.h | 23 - flutter/windows/sentry_flutter_plugin.cpp | 70 -- hive/lib/src/sentry_box_collection.dart | 1 + hive/lib/src/version.dart | 2 +- hive/pubspec.yaml | 4 +- hive/test/mocks/mocks.dart | 1 + hive/test/mocks/mocks.mocks.dart | 1 + hive/test/sentry_box_base_test.dart | 3 +- hive/test/sentry_box_collection_test.dart | 4 +- hive/test/sentry_hive_impl_test.dart | 3 +- hive/test/sentry_lazy_box_test.dart | 3 +- hive/test/utils.dart | 8 + isar/lib/src/version.dart | 2 +- isar/pubspec.yaml | 4 +- isar/test/sentry_isar_collection_test.dart | 3 +- isar/test/sentry_isar_test.dart | 3 +- isar/test/utils.dart | 8 + logging/lib/src/version.dart | 2 +- logging/pubspec.yaml | 4 +- logging/test/logging_integration_test.dart | 2 +- logging/test/mock_hub.dart | 7 +- metrics/flutter.properties | 2 +- metrics/metrics-ios.yml | 4 +- min_version_test/android/build.gradle | 4 +- min_version_test/lib/main.dart | 7 +- .../lib/transaction/file_transaction.dart | 4 +- ...ion_locator.dart => transaction_stub.dart} | 2 +- .../lib/transaction/web_transaction.dart | 5 +- min_version_test/pubspec.yaml | 1 - .../lib/src/symbol_collector_cli.dart | 2 +- scripts/publish_validation/README.md | 3 + .../publish_validation/analysis_options.yaml | 1 + .../bin/publish_validation.dart | 60 ++ scripts/publish_validation/pubspec.yaml | 13 + sqflite/lib/src/version.dart | 2 +- sqflite/pubspec.yaml | 4 +- sqflite/test/sentry_batch_test.dart | 2 +- sqflite/test/sentry_database_test.dart | 2 +- ...ry_sqflite_database_factory_dart_test.dart | 2 +- sqflite/test/sentry_sqflite_test.dart | 2 +- sqflite/test/utils.dart | 7 + 400 files changed, 12283 insertions(+), 4480 deletions(-) create mode 100644 dart/example_web_legacy/.gitignore create mode 100644 dart/example_web_legacy/README.md create mode 100644 dart/example_web_legacy/analysis_options.yaml create mode 100644 dart/example_web_legacy/pubspec.yaml create mode 100644 dart/example_web_legacy/web/event.dart create mode 100644 dart/example_web_legacy/web/favicon.ico create mode 100644 dart/example_web_legacy/web/index.html create mode 100644 dart/example_web_legacy/web/main.dart create mode 100644 dart/example_web_legacy/web/styles.css create mode 100644 dart/lib/src/dart_exception_type_identifier.dart create mode 100644 dart/lib/src/dart_exception_type_identifier_io.dart create mode 100644 dart/lib/src/dart_exception_type_identifier_web.dart create mode 100644 dart/lib/src/debug_image_extractor.dart create mode 100644 dart/lib/src/event_processor/enricher/html_enricher_event_processor.dart create mode 100644 dart/lib/src/event_processor/enricher/io_platform_memory.dart create mode 100644 dart/lib/src/exception_type_identifier.dart create mode 100644 dart/lib/src/http_client/client_provider.dart create mode 100644 dart/lib/src/http_client/io_client_provider.dart create mode 100644 dart/lib/src/load_dart_debug_images_integration.dart create mode 100644 dart/lib/src/origin_html.dart rename dart/lib/src/{noop_origin.dart => origin_io.dart} (100%) create mode 100644 dart/lib/src/origin_web.dart create mode 100644 dart/lib/src/performance_collector.dart create mode 100644 dart/lib/src/platform/_html_platform.dart create mode 100644 dart/lib/src/protocol/access_aware_map.dart create mode 100644 dart/lib/src/protocol/sentry_proxy.dart delete mode 100644 dart/lib/src/sentry_client_stub.dart create mode 100644 dart/lib/src/utils/regex_utils.dart create mode 100644 dart/test/debug_image_extractor_test.dart create mode 100644 dart/test/event_processor/enricher/io_platform_memory_test.dart create mode 100644 dart/test/exception_identifier_test.dart create mode 100644 dart/test/http_client/io_client_provider_test.dart create mode 100644 dart/test/load_dart_debug_images_integration_test.dart create mode 100644 dart/test/protocol/access_aware_map_tests.dart create mode 100644 dart/test/protocol/sentry_proxy_test.dart create mode 100644 dart/test/utils/regex_utils_test.dart create mode 100644 drift/test/utils.dart create mode 100644 flutter/android/src/main/kotlin/io/sentry/flutter/SentryFlutterReplayBreadcrumbConverter.kt create mode 100644 flutter/android/src/main/kotlin/io/sentry/flutter/SentryFlutterReplayRecorder.kt create mode 100644 flutter/example/integration_test/replay_test.dart delete mode 100644 flutter/example/windows/runner/run_loop.cpp delete mode 100644 flutter/example/windows/runner/run_loop.h create mode 100644 flutter/ios/Classes/SentryFlutterReplayBreadcrumbConverter.h create mode 100644 flutter/ios/Classes/SentryFlutterReplayBreadcrumbConverter.m create mode 100644 flutter/ios/Classes/SentryFlutterReplayScreenshotProvider.h create mode 100644 flutter/ios/Classes/SentryFlutterReplayScreenshotProvider.m delete mode 100644 flutter/lib/src/event_processor/native_app_start_event_processor.dart create mode 100644 flutter/lib/src/event_processor/replay_event_processor.dart create mode 100644 flutter/lib/src/event_processor/url_filter/html_url_filter_event_processor.dart create mode 100644 flutter/lib/src/event_processor/url_filter/io_url_filter_event_processor.dart create mode 100644 flutter/lib/src/event_processor/url_filter/url_filter_event_processor.dart create mode 100644 flutter/lib/src/event_processor/url_filter/web_url_filter_event_processor.dart create mode 100644 flutter/lib/src/flutter_exception_type_identifier.dart create mode 100644 flutter/lib/src/integrations/connectivity/html_connectivity_provider.dart create mode 100644 flutter/lib/src/integrations/native_app_start_handler.dart create mode 100644 flutter/lib/src/native/native_app_start.dart create mode 100644 flutter/lib/src/native/native_frames.dart delete mode 100644 flutter/lib/src/native/sentry_native.dart create mode 100644 flutter/lib/src/native/sentry_native_invoker.dart create mode 100644 flutter/lib/src/native/sentry_safe_method_channel.dart create mode 100644 flutter/lib/src/renderer/web_renderer.dart create mode 100644 flutter/lib/src/replay/recorder.dart create mode 100644 flutter/lib/src/replay/recorder_config.dart create mode 100644 flutter/lib/src/replay/scheduled_recorder.dart create mode 100644 flutter/lib/src/replay/scheduler.dart create mode 100644 flutter/lib/src/replay/widget_filter.dart create mode 100644 flutter/lib/src/sentry_replay_options.dart create mode 100644 flutter/lib/src/span_frame_metrics_collector.dart create mode 100644 flutter/lib/src/user_interaction/user_interaction_info.dart delete mode 100644 flutter/lib/src/user_interaction/user_interaction_widget.dart create mode 100644 flutter/lib/src/utils/debouncer.dart create mode 100644 flutter/lib/src/utils/enum_wrapper.dart create mode 100644 flutter/temp/native-test/dist/sentry.dll create mode 100644 flutter/test/event_processor/url_filter/io_filter_event_processor_test.dart create mode 100644 flutter/test/event_processor/url_filter/web_url_filter_event_processor_test.dart create mode 100644 flutter/test/integrations/fixture.dart create mode 100644 flutter/test/integrations/native_app_start_handler_test.dart delete mode 100644 flutter/test/load_image_list_test.dart create mode 100644 flutter/test/mock_frame_callback_handler.dart create mode 100644 flutter/test/replay/recorder_config_test.dart create mode 100644 flutter/test/replay/recorder_test.dart create mode 100644 flutter/test/replay/replay_native_test.dart create mode 100644 flutter/test/replay/scheduled_recorder_test.dart create mode 100644 flutter/test/replay/scheduler_test.dart create mode 100644 flutter/test/replay/test_widget.dart create mode 100644 flutter/test/replay/widget_filter_test.dart delete mode 100644 flutter/test/sentry_native_test.dart create mode 100644 flutter/test/span_frame_metrics_collector_test.dart delete mode 100644 flutter/windows/include/sentry_flutter/sentry_flutter_plugin.h delete mode 100644 flutter/windows/sentry_flutter_plugin.cpp create mode 100644 hive/test/utils.dart create mode 100644 isar/test/utils.dart rename min_version_test/lib/transaction/{transaction_locator.dart => transaction_stub.dart} (60%) create mode 100644 scripts/publish_validation/README.md create mode 100644 scripts/publish_validation/analysis_options.yaml create mode 100644 scripts/publish_validation/bin/publish_validation.dart create mode 100644 scripts/publish_validation/pubspec.yaml diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index a7f93ccc7f..1c5dfc12c1 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1 +1 @@ -* @krystofwoldrich @stefanosiano @buenaflor +* @krystofwoldrich @stefanosiano @buenaflor @martinhaintz diff --git a/.github/workflows/analyze.yml b/.github/workflows/analyze.yml index 5ae2d469a1..11a9afa61c 100644 --- a/.github/workflows/analyze.yml +++ b/.github/workflows/analyze.yml @@ -51,6 +51,11 @@ jobs: - run: dart doc --dry-run + - name: Run publish validation + run: | + dart pub get --directory ../scripts/publish_validation + dart run ../scripts/publish_validation/bin/publish_validation.dart --executable ${{ inputs.sdk }} + package-analysis: # `axel-op/dart-package-analyzer` is using `flutter pub upgrade` instead of `get`, # which ignores pubspec.yaml `dependency_overrides`. Because of that, all `release/*` branches are failing, diff --git a/.github/workflows/flutter.yml b/.github/workflows/flutter.yml index 32130d4765..9e76c84ec2 100644 --- a/.github/workflows/flutter.yml +++ b/.github/workflows/flutter.yml @@ -29,6 +29,7 @@ jobs: defaults: run: shell: bash + working-directory: flutter strategy: fail-fast: false matrix: @@ -77,21 +78,23 @@ jobs: - run: flutter upgrade - name: Pub Get - run: | - cd flutter - flutter pub get + run: flutter pub get - - name: Test chrome + - name: Test web (JS) if: matrix.target == 'web' run: | - cd flutter flutter test --platform chrome --test-randomize-ordering-seed=random --exclude-tags canvasKit flutter test --platform chrome --test-randomize-ordering-seed=random --tags canvasKit --web-renderer canvaskit + - name: Test web (WASM) + if: matrix.target == 'web' + run: | + flutter test --platform chrome --wasm --test-randomize-ordering-seed=random --exclude-tags canvasKit + flutter test --platform chrome --wasm --test-randomize-ordering-seed=random --tags canvasKit --web-renderer canvaskit + - name: Test VM with coverage if: matrix.target == 'linux' || matrix.target == 'macos' || matrix.target == 'windows' run: | - cd flutter flutter test --coverage --test-randomize-ordering-seed=random dart run remove_from_coverage -f coverage/lcov.info -r 'binding.dart' @@ -111,11 +114,11 @@ jobs: exclude: 'lib/src/native/cocoa/binding.dart' - name: Build ${{ matrix.target }} + working-directory: flutter/example run: | flutter config --enable-windows-desktop flutter config --enable-macos-desktop flutter config --enable-linux-desktop - cd flutter/example TARGET=${{ matrix.target }} flutter pub get case $TARGET in @@ -179,7 +182,7 @@ jobs: - uses: actions/checkout@v4 - name: ktlint - uses: ScaCap/action-ktlint@7bfa4928cf705b83700c91fecc0e1a3a4c0e99ad # pin@1.8.3 + uses: ScaCap/action-ktlint@26c5e9b625966139d9956cbbb6217375480d4e14 # pin@1.9.0 with: github_token: ${{ secrets.github_token }} reporter: github-pr-review @@ -192,6 +195,6 @@ jobs: steps: - uses: actions/checkout@v4 # To recreate baseline run: detekt -i flutter/android,flutter/example/android -b flutter/config/detekt-bl.xml -cb - - uses: natiginfo/action-detekt-all@b9daaf58ff7a4885ff92ba612c3ea72bf1abeadb # pin@1.23.6 + - uses: natiginfo/action-detekt-all@6bf4342ea96f638ecced05cf7d7dc48acdecc854 # pin@1.23.7 with: args: -i flutter/android,flutter/example/android --baseline flutter/config/detekt-bl.xml --jvm-target 1.8 --build-upon-default-config --all-rules diff --git a/.github/workflows/flutter_integration_test.yml b/.github/workflows/flutter_integration_test.yml index f51af5d341..9faef93ace 100644 --- a/.github/workflows/flutter_integration_test.yml +++ b/.github/workflows/flutter_integration_test.yml @@ -49,7 +49,7 @@ jobs: run: flutter pub get - name: Gradle cache - uses: gradle/gradle-build-action@66535aaf56f831b35e3a8481c9c99b665b84dd45 # pin@v3.4.2 + uses: gradle/gradle-build-action@ac2d340dc04d9e1113182899e983b5400c17cda1 # pin@v3.5.0 - name: AVD cache uses: actions/cache@v4 @@ -62,7 +62,7 @@ jobs: - name: create AVD and generate snapshot for caching if: steps.avd-cache.outputs.cache-hit != 'true' - uses: reactivecircus/android-emulator-runner@77986be26589807b8ebab3fde7bbf5c60dabec32 #pin@v2.31.0 + uses: reactivecircus/android-emulator-runner@f0d1ed2dcad93c7479e8b2f2226c83af54494915 #pin@v2.32.0 with: working-directory: ./flutter/example api-level: 21 @@ -74,7 +74,7 @@ jobs: script: echo 'Generated AVD snapshot for caching.' - name: launch android emulator & run android integration test - uses: reactivecircus/android-emulator-runner@77986be26589807b8ebab3fde7bbf5c60dabec32 #pin@v2.31.0 + uses: reactivecircus/android-emulator-runner@f0d1ed2dcad93c7479e8b2f2226c83af54494915 #pin@v2.32.0 with: working-directory: ./flutter/example api-level: 21 diff --git a/.github/workflows/flutter_test.yml b/.github/workflows/flutter_test.yml index d5b894ab36..14922be1c0 100644 --- a/.github/workflows/flutter_test.yml +++ b/.github/workflows/flutter_test.yml @@ -54,7 +54,7 @@ jobs: run: flutter pub get - name: Gradle cache - uses: gradle/gradle-build-action@66535aaf56f831b35e3a8481c9c99b665b84dd45 # pin@v3.0.0 + uses: gradle/gradle-build-action@ac2d340dc04d9e1113182899e983b5400c17cda1 # pin@v3.0.0 - name: AVD cache uses: actions/cache@v4 @@ -67,7 +67,7 @@ jobs: - name: create AVD and generate snapshot for caching if: steps.avd-cache.outputs.cache-hit != 'true' - uses: reactivecircus/android-emulator-runner@77986be26589807b8ebab3fde7bbf5c60dabec32 #pin@v2.31.0 + uses: reactivecircus/android-emulator-runner@f0d1ed2dcad93c7479e8b2f2226c83af54494915 #pin@v2.32.0 with: working-directory: ./flutter/example api-level: 31 @@ -84,7 +84,7 @@ jobs: run: flutter build apk --debug - name: launch android emulator & run android native test - uses: reactivecircus/android-emulator-runner@77986be26589807b8ebab3fde7bbf5c60dabec32 #pin@v2.31.0 + uses: reactivecircus/android-emulator-runner@f0d1ed2dcad93c7479e8b2f2226c83af54494915 #pin@v2.32.0 with: working-directory: ./flutter/example/android api-level: 31 @@ -97,7 +97,7 @@ jobs: script: ./gradlew testDebugUnitTest - name: launch android emulator & run android integration test - uses: reactivecircus/android-emulator-runner@77986be26589807b8ebab3fde7bbf5c60dabec32 #pin@v2.31.0 + uses: reactivecircus/android-emulator-runner@f0d1ed2dcad93c7479e8b2f2226c83af54494915 #pin@v2.32.0 with: working-directory: ./flutter/example api-level: 31 diff --git a/.github/workflows/testflight.yml b/.github/workflows/testflight.yml index 0cac9d620f..8e11520250 100644 --- a/.github/workflows/testflight.yml +++ b/.github/workflows/testflight.yml @@ -16,7 +16,7 @@ jobs: - uses: actions/checkout@v4 - uses: subosito/flutter-action@44ac965b96f18d999802d4b807e3256d5a3f9fa1 # pin@v2.16.0 - run: xcodes select 15.0.1 - - uses: ruby/setup-ruby@3a77c29278ae80936b4cb030fefc7d21c96c786f # pin@v1.185.0 + - uses: ruby/setup-ruby@a6b46b8a08edb18935835849f2a17072d5cc8c73 # pin@v1.192.0 with: ruby-version: '2.7.5' bundler-cache: true diff --git a/.github/workflows/update-deps.yml b/.github/workflows/update-deps.yml index d7eaf21a58..459b4f3687 100644 --- a/.github/workflows/update-deps.yml +++ b/.github/workflows/update-deps.yml @@ -33,6 +33,7 @@ jobs: path: metrics/flutter.properties name: Flutter SDK (metrics) changelog-entry: false + pr-strategy: update secrets: api-token: ${{ secrets.CI_DEPLOY_KEY }} @@ -42,5 +43,6 @@ jobs: path: scripts/update-symbol-collector.sh name: Symbol collector CLI changelog-entry: false + pr-strategy: update secrets: api-token: ${{ secrets.CI_DEPLOY_KEY }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 75b24a6314..f7944fdfa8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,319 @@ # Changelog +## Unreleased + +### Features + +- Blocking app starts if "appLaunchedInForeground" is false. (Android only) ([#2291](https://github.com/getsentry/sentry-dart/pull/2291) + +### Enhancements + +- Improve app start integration ([#2266](https://github.com/getsentry/sentry-dart/pull/2266)) + - Fixes ([#2103](https://github.com/getsentry/sentry-dart/issues/2103)) + - Fixes ([#2233](https://github.com/getsentry/sentry-dart/issues/2233)) + +## 8.9.0 + +### Features + +- Session replay Alpha for Android and iOS ([#2208](https://github.com/getsentry/sentry-dart/pull/2208), [#2269](https://github.com/getsentry/sentry-dart/pull/2269), [#2236](https://github.com/getsentry/sentry-dart/pull/2236), [#2275](https://github.com/getsentry/sentry-dart/pull/2275), [#2270](https://github.com/getsentry/sentry-dart/pull/2270)). + To try out replay, you can set following options (access is limited to early access orgs on Sentry. If you're interested, [sign up for the waitlist](https://sentry.io/lp/mobile-replay-beta/)): + + ```dart + await SentryFlutter.init( + (options) { + ... + options.experimental.replay.sessionSampleRate = 1.0; + options.experimental.replay.onErrorSampleRate = 1.0; + }, + appRunner: () => runApp(MyApp()), + ); + ``` + +- Support allowUrls and denyUrls for Flutter Web ([#2227](https://github.com/getsentry/sentry-dart/pull/2227)) + + ```dart + await SentryFlutter.init( + (options) { + ... + options.allowUrls = ["^https://sentry.com.*\$", "my-custom-domain"]; + options.denyUrls = ["^.*ends-with-this\$", "denied-url"]; + }, + appRunner: () => runApp(MyApp()), + ); + ``` + +- Collect touch breadcrumbs for all buttons, not just those with `key` specified. ([#2242](https://github.com/getsentry/sentry-dart/pull/2242)) +- Add `enableDartSymbolication` option to Sentry.init() for **Flutter iOS, macOS and Android** ([#2256](https://github.com/getsentry/sentry-dart/pull/2256)) + - This flag enables symbolication of Dart stack traces when native debug images are not available. + - Useful when using Sentry.init() instead of SentryFlutter.init() in Flutter projects for example due to size limitations. + - `true` by default but automatically set to `false` when using SentryFlutter.init() because the SentryFlutter fetches debug images from the native SDK integrations. + +### Dependencies + +- Bump Cocoa SDK from v8.35.1 to v8.36.0 ([#2252](https://github.com/getsentry/sentry-dart/pull/2252)) + - [changelog](https://github.com/getsentry/sentry-cocoa/blob/main/CHANGELOG.md#8360) + - [diff](https://github.com/getsentry/sentry-cocoa/compare/8.35.1...8.36.0) + +### Fixes + +- Only access renderObject if `hasSize` is true ([#2263](https://github.com/getsentry/sentry-dart/pull/2263)) + +## 8.8.0 + +### Features + +- Add `SentryFlutter.nativeCrash()` using MethodChannels for Android and iOS ([#2239](https://github.com/getsentry/sentry-dart/pull/2239)) + - This can be used to test if native crash reporting works + +- Add `ignoreRoutes` parameter to `SentryNavigatorObserver`. ([#2218](https://github.com/getsentry/sentry-dart/pull/2218)) + - This will ignore the Routes and prevent the Route from being pushed to the Sentry server. + - Ignored routes will also create no TTID and TTFD spans. + + ```dart + SentryNavigatorObserver(ignoreRoutes: ["/ignoreThisRoute"]), + ``` + +### Improvements + +- Debouncing of SentryWidgetsBindingObserver.didChangeMetrics with delay of 100ms. ([#2232](https://github.com/getsentry/sentry-dart/pull/2232)) + +### Dependencies + +- Bump Cocoa SDK from v8.33.0 to v8.35.1 ([#2247](https://github.com/getsentry/sentry-dart/pull/2247)) + - [changelog](https://github.com/getsentry/sentry-cocoa/blob/main/CHANGELOG.md#8351) + - [diff](https://github.com/getsentry/sentry-cocoa/compare/8.33.0...8.35.1) +- Bump Android SDK from v7.13.0 to v7.14.0 ([#2228](https://github.com/getsentry/sentry-dart/pull/2228)) + - [changelog](https://github.com/getsentry/sentry-java/blob/main/CHANGELOG.md#7140) + - [diff](https://github.com/getsentry/sentry-java/compare/7.13.0...7.14.0) + +## 8.8.0-alpha.1 + +### Features + +- iOS Session Replay Alpha ([#2209](https://github.com/getsentry/sentry-dart/pull/2209)) +- Android replay touch tracking support ([#2228](https://github.com/getsentry/sentry-dart/pull/2228)) +- Add `ignoreRoutes` parameter to `SentryNavigatorObserver`. ([#2218](https://github.com/getsentry/sentry-dart/pull/2218)) + - This will ignore the Routes and prevent the Route from being pushed to the Sentry server. + - Ignored routes will also create no TTID and TTFD spans. + +```dart +SentryNavigatorObserver(ignoreRoutes: ["/ignoreThisRoute"]), +``` + +### Dependencies + +- Bump Android SDK from v7.13.0 to v7.14.0 ([#2228](https://github.com/getsentry/sentry-dart/pull/2228)) + - [changelog](https://github.com/getsentry/sentry-java/blob/main/CHANGELOG.md#7140) + - [diff](https://github.com/getsentry/sentry-java/compare/7.13.0...7.14.0) + +## 8.7.0 + +### Features + +- Add support for span level measurements. ([#2214](https://github.com/getsentry/sentry-dart/pull/2214)) +- Add `ignoreTransactions` and `ignoreErrors` to options ([#2207](https://github.com/getsentry/sentry-dart/pull/2207)) + + ```dart + await SentryFlutter.init( + (options) { + options.dsn = 'https://examplePublicKey@o0.ingest.sentry.io/0'; + options.ignoreErrors = ["my-error", "^error-.*\$"]; + options.ignoreTransactions = ["my-transaction", "^transaction-.*\$"]; + ... + }, + appRunner: () => runApp(MyApp()), + ); + ``` + +- Add proxy support ([#2192](https://github.com/getsentry/sentry-dart/pull/2192)) + - Configure a `SentryProxy` object and set it on `SentryFlutter.init` + + ```dart + import 'package:flutter/widgets.dart'; + import 'package:sentry_flutter/sentry_flutter.dart'; + + Future main() async { + await SentryFlutter.init( + (options) { + options.dsn = 'https://example@sentry.io/add-your-dsn-here'; + options.proxy = SentryProxy( + type: SentryProxyType.http, + host: 'localhost', + port: 8080, + ); + }, + // Init your App. + appRunner: () => runApp(MyApp()), + ); + } + ``` + +### Improvements + +- Deserialize and serialize unknown fields ([#2153](https://github.com/getsentry/sentry-dart/pull/2153)) + +### Dependencies + +- Bump Cocoa SDK from v8.32.0 to v8.33.0 ([#2223](https://github.com/getsentry/sentry-dart/pull/2223)) + - [changelog](https://github.com/getsentry/sentry-cocoa/blob/main/CHANGELOG.md#8330) + - [diff](https://github.com/getsentry/sentry-cocoa/compare/8.32.0...8.33.0) + +## 8.6.0 + +### Improvements + +- Add error type identifier to improve obfuscated Flutter issue titles ([#2170](https://github.com/getsentry/sentry-dart/pull/2170)) + - Example: transforms issue titles from `GA` to `FlutterError` or `minified:nE` to `FlutterError` + - This is enabled automatically and will change grouping if you already have issues with obfuscated titles + - If you want to disable this feature, set `enableExceptionTypeIdentification` to `false` in your Sentry options + - You can add your custom exception identifier if there are exceptions that we do not identify out of the box + + ```dart + // How to add your own custom exception identifier + class MyCustomExceptionIdentifier implements ExceptionIdentifier { + @override + String? identifyType(Exception exception) { + if (exception is MyCustomException) { + return 'MyCustomException'; + } + if (exception is MyOtherCustomException) { + return 'MyOtherCustomException'; + } + return null; + } + } + + SentryFlutter.init((options) => + options..prependExceptionTypeIdentifier(MyCustomExceptionIdentifier())); + ``` + +### Deprecated + +- Deprecate `enableTracing` ([#2199](https://github.com/getsentry/sentry-dart/pull/2199)) + - The `enableTracing` option has been deprecated and will be removed in the next major version. We recommend removing it + in favor of the `tracesSampleRate` and `tracesSampler` options. If you want to enable performance monitoring, please set + the `tracesSampleRate` to a sample rate of your choice, or provide a sampling function as `tracesSampler` option + instead. If you want to disable performance monitoring, remove the `tracesSampler` and `tracesSampleRate` options. + +### Dependencies + +- Bump Android SDK from v7.12.0 to v7.13.0 ([#2198](https://github.com/getsentry/sentry-dart/pull/2198), [#2206](https://github.com/getsentry/sentry-dart/pull/2206)) + - [changelog](https://github.com/getsentry/sentry-java/blob/main/CHANGELOG.md#7130) + - [diff](https://github.com/getsentry/sentry-java/compare/7.12.0...7.13.0) + +## 8.6.0-alpha.2 + +### Features + +- Android Session Replay Alpha ([#2032](https://github.com/getsentry/sentry-dart/pull/2032)) + + To try out replay, you can set following options: + + ```dart + await SentryFlutter.init( + (options) { + ... + options.experimental.replay.sessionSampleRate = 1.0; + options.experimental.replay.onErrorSampleRate = 1.0; + }, + appRunner: () => runApp(MyApp()), + ); + ``` + + Access is limited to early access orgs on Sentry. If you're interested, [sign up for the waitlist](https://sentry.io/lp/mobile-replay-beta/) + +## 8.5.0 + +### Features + +- Add dart platform to sentry frames ([#2193](https://github.com/getsentry/sentry-dart/pull/2193)) + - This allows viewing the correct dart formatted raw stacktrace in the Sentry UI +- Support `ignoredExceptionsForType` ([#2150](https://github.com/getsentry/sentry-dart/pull/2150)) + - Filter out exception types by calling `SentryOptions.addExceptionFilterForType(Type exceptionType)` + +### Fixes + +- Disable sff & frame delay detection on web, linux and windows ([#2182](https://github.com/getsentry/sentry-dart/pull/2182)) + - Display refresh rate is locked at 60 for these platforms which can lead to inaccurate metrics + +### Improvements + +- Capture meaningful stack traces when unhandled errors have empty or missing stack traces ([#2152](https://github.com/getsentry/sentry-dart/pull/2152)) + - This will affect grouping for unhandled errors that have empty or missing stack traces. + +### Dependencies + +- Bump Android SDK from v7.11.0 to v7.12.0 ([#2173](https://github.com/getsentry/sentry-dart/pull/2173)) + - [changelog](https://github.com/getsentry/sentry-java/blob/main/CHANGELOG.md#7120) + - [diff](https://github.com/getsentry/sentry-java/compare/7.11.0...7.12.0) + - updates AGP to v7.4.2 + - updates Kotlin to v1.8.0 +- Bump Cocoa SDK from v8.30.1 to v8.32.0 ([#2174](https://github.com/getsentry/sentry-dart/pull/2174), [#2195](https://github.com/getsentry/sentry-dart/pull/2195)) + - [changelog](https://github.com/getsentry/sentry-cocoa/blob/main/CHANGELOG.md#8320) + - [diff](https://github.com/getsentry/sentry-cocoa/compare/8.30.1...8.32.0) + +## 8.4.0 + +### Features + +- Add API for pausing/resuming **iOS** and **macOS** app hang tracking ([#2134](https://github.com/getsentry/sentry-dart/pull/2134)) + - This is useful to prevent the Cocoa SDK from reporting wrongly detected app hangs when the OS shows a system dialog for asking specific permissions. + - Use `SentryFlutter.pauseAppHangTracking()` and `SentryFlutter.resumeAppHangTracking()` +- Capture total frames, frames delay, slow & frozen frames and attach to spans ([#2106](https://github.com/getsentry/sentry-dart/pull/2106)) +- Support WebAssembly compilation (dart2wasm) ([#2113](https://github.com/getsentry/sentry-dart/pull/2113)) +- Add flag to disable reporting of view hierarchy identifiers ([#2158](https://github.com/getsentry/sentry-dart/pull/2158)) + - Use `reportViewHierarchyIdentifiers` to enable or disable the option +- Record dropped spans in client reports ([#2154](https://github.com/getsentry/sentry-dart/pull/2154)) +- Add memory usage to contexts ([#2133](https://github.com/getsentry/sentry-dart/pull/2133)) + - Only for Linux/Windows applications, as iOS/Android/macOS use native SDKs + +### Fixes + +- Fix sentry_drift compatibility with Drift 2.19.0 ([#2162](https://github.com/getsentry/sentry-dart/pull/2162)) +- App starts hanging for 30s ([#2140](https://github.com/getsentry/sentry-dart/pull/2140)) + - Time out for app start info retrieval has been reduced to 10s + - If `autoAppStarts` is `false` and `setAppStartEnd` has not been called, the app start event processor will now return early instead of waiting for `getAppStartInfo` to finish + +### Improvements + +- Set dart runtime version with parsed `Platform.version` ([#2156](https://github.com/getsentry/sentry-dart/pull/2156)) + +### Dependencies + +- Bump Cocoa SDK from v8.30.0 to v8.30.1 ([#2155](https://github.com/getsentry/sentry-dart/pull/2155)) + - [changelog](https://github.com/getsentry/sentry-cocoa/blob/main/CHANGELOG.md#8301) + - [diff](https://github.com/getsentry/sentry-cocoa/compare/8.30.0...8.30.1) +- Bump Android SDK from v7.10.0 to v7.11.0 ([#2144](https://github.com/getsentry/sentry-dart/pull/2144)) + - [changelog](https://github.com/getsentry/sentry-java/blob/main/CHANGELOG.md#7110) + - [diff](https://github.com/getsentry/sentry-java/compare/7.10.0...7.11.0) + +### Deprecated + +- User segment is now deprecated and will be removed in version 9.0.0. Use a custom tag or context instead. ([#2119](https://github.com/getsentry/sentry-dart/pull/2119)) +- Deprecate `setExtra` and `removeExtra` ([#2159](https://github.com/getsentry/sentry-dart/pull/2159)) + - Use the `Contexts` structure via `setContexts` instead + +## 8.4.0-beta.1 + +### Features + +- Add API for pausing/resuming **iOS** and **macOS** app hang tracking ([#2134](https://github.com/getsentry/sentry-dart/pull/2134)) + - This is useful to prevent the Cocoa SDK from reporting wrongly detected app hangs when the OS shows a system dialog for asking specific permissions. + - Use `SentryFlutter.pauseAppHangTracking()` and `SentryFlutter.resumeAppHangTracking()` +- Capture total frames, frames delay, slow & frozen frames and attach to spans ([#2106](https://github.com/getsentry/sentry-dart/pull/2106)) +- Support WebAssembly compilation (dart2wasm) ([#2113](https://github.com/getsentry/sentry-dart/pull/2113)) + +### Deprecated + +- User segment is now deprecated and will be removed in version 9.0.0. Use a custom tag or context instead. ([#2119](https://github.com/getsentry/sentry-dart/pull/2119)) + +### Dependencies + +- Bump Cocoa SDK from v8.29.0 to v8.30.0 ([#2132](https://github.com/getsentry/sentry-dart/pull/2132)) + - [changelog](https://github.com/getsentry/sentry-cocoa/blob/main/CHANGELOG.md#8300) + - [diff](https://github.com/getsentry/sentry-cocoa/compare/8.29.0...8.30.0) + ## 8.3.0 ### Fixes @@ -114,7 +428,7 @@ This release contains breaking changes, please read the changelog carefully. ### Features -- Experimental: Add support for Sentry Developer Metrics ([#1940](https://github.com/getsentry/sentry-dart/pull/1940), [#1949](https://github.com/getsentry/sentry-dart/pull/1949), [#1954](https://github.com/getsentry/sentry-dart/pull/1954), [#1958](https://github.com/getsentry/sentry-dart/pull/1958)) +- Experimental: Add support for Sentry Developer Metrics ([#1940](https://github.com/getsentry/sentry-dart/pull/1940), [#1949](https://github.com/getsentry/sentry-dart/pull/1949), [#1954](https://github.com/getsentry/sentry-dart/pull/1954), [#1958](https://github.com/getsentry/sentry-dart/pull/1958)) Use the Metrics API to track processing time, download sizes, user signups, and conversion rates and correlate them back to tracing data in order to get deeper insights and solve issues faster. Our API supports counters, distributions, sets, gauges and timers, and it's easy to get started: ```dart Sentry.metrics() @@ -227,14 +541,14 @@ This release contains breaking changes, please read the changelog carefully. - Now the device context from Android is available in `BeforeSendCallback` - Set ip_address to {{auto}} by default, even if sendDefaultPII is disabled ([#1665](https://github.com/getsentry/sentry-dart/pull/1665)) - Instead use the "Prevent Storing of IP Addresses" option in the "Security & Privacy" project settings on sentry.io - -### Fixes + +### Fixes - Remove Flutter dependency from Drift integration ([#1867](https://github.com/getsentry/sentry-dart/pull/1867)) - Remove dead code, cold start bool is now always present ([#1861](https://github.com/getsentry/sentry-dart/pull/1861)) - Fix iOS "Arithmetic Overflow" ([#1874](https://github.com/getsentry/sentry-dart/pull/1874)) -### Dependencies +### Dependencies - Bump Cocoa SDK from v8.19.0 to v8.20.0 ([#1856](https://github.com/getsentry/sentry-dart/pull/1856)) - [changelog](https://github.com/getsentry/sentry-cocoa/blob/main/CHANGELOG.md#8200) diff --git a/dart/example_web/pubspec.yaml b/dart/example_web/pubspec.yaml index 7bcb64d06e..141e5120ac 100644 --- a/dart/example_web/pubspec.yaml +++ b/dart/example_web/pubspec.yaml @@ -9,6 +9,7 @@ environment: dependencies: sentry: path: ../../dart/ + web: ^0.5.1 dev_dependencies: build_runner: ^2.4.2 diff --git a/dart/example_web/web/main.dart b/dart/example_web/web/main.dart index 3034effe9b..3c0305b49e 100644 --- a/dart/example_web/web/main.dart +++ b/dart/example_web/web/main.dart @@ -1,5 +1,5 @@ import 'dart:async'; -import 'dart:html'; +import 'package:web/web.dart'; import 'package:sentry/sentry.dart'; import 'package:sentry/src/version.dart'; @@ -24,7 +24,7 @@ Future main() async { Future runApp() async { print('runApp'); - querySelector('#output')?.text = 'Your Dart app is running.'; + document.querySelector('#output')?.text = 'Your Dart app is running.'; await Sentry.addBreadcrumb( Breadcrumb( @@ -44,6 +44,7 @@ Future runApp() async { ..transaction = '/example/app' ..level = SentryLevel.warning; await scope.setTag('build', '579'); + // ignore: deprecated_member_use await scope.setExtra('company-name', 'Dart Inc'); await scope.setUser( @@ -57,12 +58,20 @@ Future runApp() async { ); }); - querySelector('#btEvent') + document + .querySelector('#btEvent') ?.onClick .listen((event) => captureCompleteExampleEvent()); - querySelector('#btMessage')?.onClick.listen((event) => captureMessage()); - querySelector('#btException')?.onClick.listen((event) => captureException()); - querySelector('#btUnhandledException') + document + .querySelector('#btMessage') + ?.onClick + .listen((event) => captureMessage()); + document + .querySelector('#btException') + ?.onClick + .listen((event) => captureException()); + document + .querySelector('#btUnhandledException') ?.onClick .listen((event) => captureUnhandledException()); } @@ -76,7 +85,8 @@ Future captureMessage() async { ); print('capture message result : $sentryId'); if (sentryId != SentryId.empty()) { - querySelector('#messageResult')?.style.display = 'block'; + (document.querySelector('#messageResult') as HTMLElement?)?.style.display = + 'block'; } } @@ -93,13 +103,16 @@ Future captureException() async { print('Capture exception : SentryId: $sentryId'); if (sentryId != SentryId.empty()) { - querySelector('#exceptionResult')?.style.display = 'block'; + (document.querySelector('#exceptionResult') as HTMLElement?) + ?.style + .display = 'block'; } } } Future captureUnhandledException() async { - querySelector('#unhandledResult')?.style.display = 'block'; + (document.querySelector('#unhandledResult') as HTMLElement?)?.style.display = + 'block'; await buildCard(); } @@ -111,7 +124,8 @@ Future captureCompleteExampleEvent() async { print('Response SentryId: $sentryId'); if (sentryId != SentryId.empty()) { - querySelector('#eventResult')?.style.display = 'block'; + (document.querySelector('#eventResult') as HTMLElement?)?.style.display = + 'block'; } } diff --git a/dart/example_web_legacy/.gitignore b/dart/example_web_legacy/.gitignore new file mode 100644 index 0000000000..3d64647b50 --- /dev/null +++ b/dart/example_web_legacy/.gitignore @@ -0,0 +1,9 @@ +# Files and directories created by pub +.dart_tool/ +.packages + +# Conventional directory for build outputs +build/ + +# Directory created by dartdoc +doc/api/ diff --git a/dart/example_web_legacy/README.md b/dart/example_web_legacy/README.md new file mode 100644 index 0000000000..98202566f6 --- /dev/null +++ b/dart/example_web_legacy/README.md @@ -0,0 +1,8 @@ +# Sentry Dart : web example + +```sh +dart pub get + +# run the project ( see https://dart.dev/tools/webdev#serve ) +dart run webdev serve --release +``` diff --git a/dart/example_web_legacy/analysis_options.yaml b/dart/example_web_legacy/analysis_options.yaml new file mode 100644 index 0000000000..be16ace7d1 --- /dev/null +++ b/dart/example_web_legacy/analysis_options.yaml @@ -0,0 +1,5 @@ +include: package:lints/recommended.yaml + +analyzer: + errors: + path_does_not_exist: ignore diff --git a/dart/example_web_legacy/pubspec.yaml b/dart/example_web_legacy/pubspec.yaml new file mode 100644 index 0000000000..49b7807084 --- /dev/null +++ b/dart/example_web_legacy/pubspec.yaml @@ -0,0 +1,17 @@ +name: sentry_dart_web_example +description: An absolute bare-bones web app. + +publish_to: 'none' + +environment: + sdk: '>=2.17.0 <4.0.0' + +dependencies: + sentry: + path: ../../dart/ + +dev_dependencies: + build_runner: ^2.3.0 + build_web_compilers: ^3.2.3 + lints: ^2.0.0 + webdev: ^2.7.0 diff --git a/dart/example_web_legacy/web/event.dart b/dart/example_web_legacy/web/event.dart new file mode 100644 index 0000000000..6e3e8b3e0b --- /dev/null +++ b/dart/example_web_legacy/web/event.dart @@ -0,0 +1,76 @@ +import 'package:sentry/src/protocol.dart'; + +final event = SentryEvent( + logger: 'main', + serverName: 'server.dart', + release: '1.4.0-preview.1', + environment: 'Test', + message: SentryMessage('This is an example Dart event.'), + tags: const {'project-id': '7371'}, + // ignore: deprecated_member_use, deprecated_member_use_from_same_package + extra: const {'section': '1'}, + // fingerprint: const ['example-dart'], + user: SentryUser( + id: '800', + username: 'first-user', + email: 'first@user.lan', + // ipAddress: '127.0.0.1', + data: {'first-sign-in': '2020-01-01'}, + ), + breadcrumbs: [ + Breadcrumb( + message: 'UI Lifecycle', + timestamp: DateTime.now().toUtc(), + category: 'ui.lifecycle', + type: 'navigation', + data: {'screen': 'MainActivity', 'state': 'created'}, + level: SentryLevel.info, + ) + ], + contexts: Contexts( + operatingSystem: const SentryOperatingSystem( + name: 'Android', + version: '5.0.2', + build: 'LRX22G.P900XXS0BPL2', + kernelVersion: + 'Linux version 3.4.39-5726670 (dpi@SWHC3807) (gcc version 4.8 (GCC) ) #1 SMP PREEMPT Thu Dec 1 19:42:39 KST 2016', + rooted: false, + ), + runtimes: [const SentryRuntime(name: 'ART', version: '5')], + app: SentryApp( + name: 'Example Dart App', + version: '1.42.0', + identifier: 'HGT-App-13', + build: '93785', + buildType: 'release', + deviceAppHash: '5afd3a6', + startTime: DateTime.now().toUtc(), + ), + browser: const SentryBrowser(name: 'Firefox', version: '42.0.1'), + device: SentryDevice( + name: 'SM-P900', + family: 'SM-P900', + model: 'SM-P900 (LRX22G)', + modelId: 'LRX22G', + arch: 'armeabi-v7a', + batteryLevel: 99, + orientation: SentryOrientation.landscape, + manufacturer: 'samsung', + brand: 'samsung', + screenDensity: 2.1, + screenDpi: 320, + online: true, + charging: true, + lowMemory: true, + simulator: false, + memorySize: 1500, + freeMemory: 200, + usableMemory: 4294967296, + storageSize: 4294967296, + freeStorage: 2147483648, + externalStorageSize: 8589934592, + externalFreeStorage: 2863311530, + bootTime: DateTime.now().toUtc(), + ), + ), +); diff --git a/dart/example_web_legacy/web/favicon.ico b/dart/example_web_legacy/web/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..7ba349b3e628d2423d4a2ed217422a4722f73739 GIT binary patch literal 3559 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 + + + + + + + + + + + dart_web + + + + + + + + + + +
+ +
+ +
Captured
+
+ +
+ +
Captured
+
+ +
+ +
Captured
+
+ +
+ +
Captured
+
+ + + diff --git a/dart/example_web_legacy/web/main.dart b/dart/example_web_legacy/web/main.dart new file mode 100644 index 0000000000..506bedf159 --- /dev/null +++ b/dart/example_web_legacy/web/main.dart @@ -0,0 +1,136 @@ +import 'dart:async'; +import 'dart:html'; + +import 'package:sentry/sentry.dart'; +import 'package:sentry/src/version.dart'; + +import 'event.dart'; + +// ATTENTION: Change the DSN below with your own to see the events in Sentry. Get one at sentry.io +const dsn = + 'https://e85b375ffb9f43cf8bdf9787768149e0@o447951.ingest.sentry.io/5428562'; + +Future main() async { + await Sentry.init( + (options) => options + ..dsn = dsn + ..debug = true + ..sendDefaultPii = true + ..addEventProcessor(TagEventProcessor()), + appRunner: runApp, + ); +} + +Future runApp() async { + print('runApp'); + + querySelector('#output')?.text = 'Your Dart app is running.'; + + await Sentry.addBreadcrumb( + Breadcrumb( + message: 'Authenticated user', + category: 'auth', + type: 'debug', + data: { + 'admin': true, + 'permissions': [1, 2, 3] + }, + ), + ); + + await Sentry.configureScope((scope) async { + scope + // ..fingerprint = ['example-dart'] + ..transaction = '/example/app' + ..level = SentryLevel.warning; + await scope.setTag('build', '579'); + // ignore: deprecated_member_use + await scope.setExtra('company-name', 'Dart Inc'); + + await scope.setUser( + SentryUser( + id: '800', + username: 'first-user', + email: 'first@user.lan', + // ipAddress: '127.0.0.1', + data: {'first-sign-in': '2020-01-01'}, + ), + ); + }); + + querySelector('#btEvent') + ?.onClick + .listen((event) => captureCompleteExampleEvent()); + querySelector('#btMessage')?.onClick.listen((event) => captureMessage()); + querySelector('#btException')?.onClick.listen((event) => captureException()); + querySelector('#btUnhandledException') + ?.onClick + .listen((event) => captureUnhandledException()); +} + +Future captureMessage() async { + print('Capturing Message : '); + final sentryId = await Sentry.captureMessage( + 'Message 2', + template: 'Message %s', + params: ['2'], + ); + print('capture message result : $sentryId'); + if (sentryId != SentryId.empty()) { + querySelector('#messageResult')?.style.display = 'block'; + } +} + +Future captureException() async { + try { + await buildCard(); + } catch (error, stackTrace) { + print('\nReporting the following stack trace: '); + final sentryId = await Sentry.captureException( + error, + stackTrace: stackTrace, + ); + + print('Capture exception : SentryId: $sentryId'); + + if (sentryId != SentryId.empty()) { + querySelector('#exceptionResult')?.style.display = 'block'; + } + } +} + +Future captureUnhandledException() async { + querySelector('#unhandledResult')?.style.display = 'block'; + + await buildCard(); +} + +Future captureCompleteExampleEvent() async { + print('\nReporting a complete event example: $sdkName'); + final sentryId = await Sentry.captureEvent(event); + + print('Response SentryId: $sentryId'); + + if (sentryId != SentryId.empty()) { + querySelector('#eventResult')?.style.display = 'block'; + } +} + +Future buildCard() async { + await loadData(); +} + +Future loadData() async { + await parseData(); +} + +Future parseData() async { + throw StateError('This is a test error'); +} + +class TagEventProcessor implements EventProcessor { + @override + SentryEvent? apply(SentryEvent event, Hint hint) { + return event..tags?.addAll({'page-locale': 'en-us'}); + } +} diff --git a/dart/example_web_legacy/web/styles.css b/dart/example_web_legacy/web/styles.css new file mode 100644 index 0000000000..cc035c95c9 --- /dev/null +++ b/dart/example_web_legacy/web/styles.css @@ -0,0 +1,14 @@ +@import url(https://fonts.googleapis.com/css?family=Roboto); + +html, body { + width: 100%; + height: 100%; + margin: 0; + padding: 0; + font-family: 'Roboto', sans-serif; +} + +#output { + padding: 20px; + text-align: center; +} diff --git a/dart/lib/sentry.dart b/dart/lib/sentry.dart index f416d0b797..e9cae9d666 100644 --- a/dart/lib/sentry.dart +++ b/dart/lib/sentry.dart @@ -30,6 +30,7 @@ export 'src/http_client/sentry_http_client_error.dart'; export 'src/sentry_attachment/sentry_attachment.dart'; export 'src/sentry_user_feedback.dart'; export 'src/utils/tracing_utils.dart'; +export 'src/performance_collector.dart'; // tracing export 'src/tracing.dart'; export 'src/hint.dart'; @@ -39,6 +40,7 @@ export 'src/sentry_baggage.dart'; export 'src/exception_cause_extractor.dart'; export 'src/exception_cause.dart'; export 'src/exception_stacktrace_extractor.dart'; +export 'src/exception_type_identifier.dart'; // URL // ignore: invalid_export_of_internal_element export 'src/utils/http_sanitizer.dart'; @@ -54,3 +56,5 @@ export 'src/sentry_span_operations.dart'; export 'src/utils.dart'; // spotlight debugging export 'src/spotlight.dart'; +// proxy +export 'src/protocol/sentry_proxy.dart'; diff --git a/dart/lib/src/client_reports/client_report_recorder.dart b/dart/lib/src/client_reports/client_report_recorder.dart index d064941f84..045168f0c5 100644 --- a/dart/lib/src/client_reports/client_report_recorder.dart +++ b/dart/lib/src/client_reports/client_report_recorder.dart @@ -13,11 +13,11 @@ class ClientReportRecorder { final ClockProvider _clock; final Map<_QuantityKey, int> _quantities = {}; - void recordLostEvent( - final DiscardReason reason, final DataCategory category) { + void recordLostEvent(final DiscardReason reason, final DataCategory category, + {int count = 1}) { final key = _QuantityKey(reason, category); var current = _quantities[key] ?? 0; - _quantities[key] = current + 1; + _quantities[key] = current + count; } ClientReport? flush() { diff --git a/dart/lib/src/client_reports/discard_reason.dart b/dart/lib/src/client_reports/discard_reason.dart index 81c3a45dd0..df4de4b64c 100644 --- a/dart/lib/src/client_reports/discard_reason.dart +++ b/dart/lib/src/client_reports/discard_reason.dart @@ -11,4 +11,5 @@ enum DiscardReason { queueOverflow, cacheOverflow, rateLimitBackoff, + ignored, } diff --git a/dart/lib/src/client_reports/discarded_event.dart b/dart/lib/src/client_reports/discarded_event.dart index 01caa31ca2..df0244c63f 100644 --- a/dart/lib/src/client_reports/discarded_event.dart +++ b/dart/lib/src/client_reports/discarded_event.dart @@ -37,6 +37,8 @@ extension _OutcomeExtension on DiscardReason { return 'cache_overflow'; case DiscardReason.rateLimitBackoff: return 'ratelimit_backoff'; + case DiscardReason.ignored: + return 'ignored'; } } } @@ -54,6 +56,8 @@ extension _DataCategoryExtension on DataCategory { return 'session'; case DataCategory.transaction: return 'transaction'; + case DataCategory.span: + return 'span'; case DataCategory.attachment: return 'attachment'; case DataCategory.security: diff --git a/dart/lib/src/client_reports/noop_client_report_recorder.dart b/dart/lib/src/client_reports/noop_client_report_recorder.dart index acd6472347..b03bd9c9ef 100644 --- a/dart/lib/src/client_reports/noop_client_report_recorder.dart +++ b/dart/lib/src/client_reports/noop_client_report_recorder.dart @@ -15,5 +15,6 @@ class NoOpClientReportRecorder implements ClientReportRecorder { } @override - void recordLostEvent(DiscardReason reason, DataCategory category) {} + void recordLostEvent(DiscardReason reason, DataCategory category, + {int count = 1}) {} } diff --git a/dart/lib/src/dart_exception_type_identifier.dart b/dart/lib/src/dart_exception_type_identifier.dart new file mode 100644 index 0000000000..9be98c608a --- /dev/null +++ b/dart/lib/src/dart_exception_type_identifier.dart @@ -0,0 +1,41 @@ +import 'package:http/http.dart' show ClientException; +import 'dart:async' show TimeoutException, AsyncError, DeferredLoadException; +import '../sentry.dart'; + +import 'dart_exception_type_identifier_io.dart' + if (dart.library.html) 'dart_exception_type_identifier_web.dart'; + +class DartExceptionTypeIdentifier implements ExceptionTypeIdentifier { + @override + String? identifyType(dynamic throwable) { + // dart:core + if (throwable is ArgumentError) return 'ArgumentError'; + if (throwable is AssertionError) return 'AssertionError'; + if (throwable is ConcurrentModificationError) { + return 'ConcurrentModificationError'; + } + if (throwable is FormatException) return 'FormatException'; + if (throwable is IndexError) return 'IndexError'; + if (throwable is NoSuchMethodError) return 'NoSuchMethodError'; + if (throwable is OutOfMemoryError) return 'OutOfMemoryError'; + if (throwable is RangeError) return 'RangeError'; + if (throwable is StackOverflowError) return 'StackOverflowError'; + if (throwable is StateError) return 'StateError'; + if (throwable is TypeError) return 'TypeError'; + if (throwable is UnimplementedError) return 'UnimplementedError'; + if (throwable is UnsupportedError) return 'UnsupportedError'; + // not adding Exception or Error because it's too generic + + // dart:async + if (throwable is TimeoutException) return 'TimeoutException'; + if (throwable is AsyncError) return 'FutureTimeout'; + if (throwable is DeferredLoadException) return 'DeferredLoadException'; + // not adding ParallelWaitError because it's not supported in dart 2.17.0 + + // dart http package + if (throwable is ClientException) return 'ClientException'; + + // platform specific exceptions + return identifyPlatformSpecificException(throwable); + } +} diff --git a/dart/lib/src/dart_exception_type_identifier_io.dart b/dart/lib/src/dart_exception_type_identifier_io.dart new file mode 100644 index 0000000000..1945663a01 --- /dev/null +++ b/dart/lib/src/dart_exception_type_identifier_io.dart @@ -0,0 +1,14 @@ +import 'dart:io'; + +import 'package:meta/meta.dart'; + +@internal +String? identifyPlatformSpecificException(dynamic throwable) { + if (throwable is FileSystemException) return 'FileSystemException'; + if (throwable is HttpException) return 'HttpException'; + if (throwable is SocketException) return 'SocketException'; + if (throwable is HandshakeException) return 'HandshakeException'; + if (throwable is CertificateException) return 'CertificateException'; + if (throwable is TlsException) return 'TlsException'; + return null; +} diff --git a/dart/lib/src/dart_exception_type_identifier_web.dart b/dart/lib/src/dart_exception_type_identifier_web.dart new file mode 100644 index 0000000000..088ce9556e --- /dev/null +++ b/dart/lib/src/dart_exception_type_identifier_web.dart @@ -0,0 +1,6 @@ +import 'package:meta/meta.dart'; + +@internal +String? identifyPlatformSpecificException(dynamic throwable) { + return null; +} diff --git a/dart/lib/src/debug_image_extractor.dart b/dart/lib/src/debug_image_extractor.dart new file mode 100644 index 0000000000..99776ee12b --- /dev/null +++ b/dart/lib/src/debug_image_extractor.dart @@ -0,0 +1,195 @@ +import 'dart:typed_data'; +import 'package:meta/meta.dart'; +import 'package:uuid/uuid.dart'; + +import '../sentry.dart'; + +// Regular expressions for parsing header lines +const String _headerStartLine = + '*** *** *** *** *** *** *** *** *** *** *** *** *** *** *** ***'; +final RegExp _buildIdRegex = RegExp(r"build_id(?:=|: )'([\da-f]+)'"); +final RegExp _isolateDsoBaseLineRegex = + RegExp(r'isolate_dso_base(?:=|: )([\da-f]+)'); + +/// Extracts debug information from stack trace header. +/// Needed for symbolication of Dart stack traces without native debug images. +@internal +class DebugImageExtractor { + DebugImageExtractor(this._options); + + final SentryOptions _options; + + // We don't need to always parse the debug image, so we cache it here. + DebugImage? _debugImage; + + @visibleForTesting + DebugImage? get debugImageForTesting => _debugImage; + + DebugImage? extractFrom(String stackTraceString) { + if (_debugImage != null) { + return _debugImage; + } + _debugImage = _extractDebugInfoFrom(stackTraceString).toDebugImage(); + return _debugImage; + } + + _DebugInfo _extractDebugInfoFrom(String stackTraceString) { + String? buildId; + String? isolateDsoBase; + + final lines = stackTraceString.split('\n'); + + for (final line in lines) { + if (_isHeaderStartLine(line)) { + continue; + } + // Stop parsing as soon as we get to the stack frames + // This should never happen but is a safeguard to avoid looping + // through every line of the stack trace + if (line.contains("#00 abs")) { + break; + } + + buildId ??= _extractBuildId(line); + isolateDsoBase ??= _extractIsolateDsoBase(line); + + // Early return if all needed information is found + if (buildId != null && isolateDsoBase != null) { + return _DebugInfo(buildId, isolateDsoBase, _options); + } + } + + return _DebugInfo(buildId, isolateDsoBase, _options); + } + + bool _isHeaderStartLine(String line) { + return line.contains(_headerStartLine); + } + + String? _extractBuildId(String line) { + final buildIdMatch = _buildIdRegex.firstMatch(line); + return buildIdMatch?.group(1); + } + + String? _extractIsolateDsoBase(String line) { + final isolateMatch = _isolateDsoBaseLineRegex.firstMatch(line); + return isolateMatch?.group(1); + } +} + +class _DebugInfo { + final String? buildId; + final String? isolateDsoBase; + final SentryOptions _options; + + _DebugInfo(this.buildId, this.isolateDsoBase, this._options); + + DebugImage? toDebugImage() { + if (buildId == null || isolateDsoBase == null) { + _options.logger(SentryLevel.warning, + 'Cannot create DebugImage without buildId and isolateDsoBase.'); + return null; + } + + String type; + String? imageAddr; + String? debugId; + String? codeId; + + final platform = _options.platformChecker.platform; + + // Default values for all platforms + imageAddr = '0x$isolateDsoBase'; + + if (platform.isAndroid) { + type = 'elf'; + debugId = _convertCodeIdToDebugId(buildId!); + codeId = buildId; + } else if (platform.isIOS || platform.isMacOS) { + type = 'macho'; + debugId = _formatHexToUuid(buildId!); + // `codeId` is not needed for iOS/MacOS. + } else { + _options.logger( + SentryLevel.warning, + 'Unsupported platform for creating Dart debug images.', + ); + return null; + } + + return DebugImage( + type: type, + imageAddr: imageAddr, + debugId: debugId, + codeId: codeId, + ); + } + + // Debug identifier is the little-endian UUID representation of the first 16-bytes of + // the build ID on ELF images. + String? _convertCodeIdToDebugId(String codeId) { + codeId = codeId.replaceAll(' ', ''); + if (codeId.length < 32) { + _options.logger(SentryLevel.warning, + 'Code ID must be at least 32 hexadecimal characters long'); + return null; + } + + final first16Bytes = codeId.substring(0, 32); + final byteData = _parseHexToBytes(first16Bytes); + + if (byteData == null || byteData.isEmpty) { + _options.logger( + SentryLevel.warning, 'Failed to convert code ID to debug ID'); + return null; + } + + return bigToLittleEndianUuid(UuidValue.fromByteList(byteData).uuid); + } + + Uint8List? _parseHexToBytes(String hex) { + if (hex.length % 2 != 0) { + _options.logger( + SentryLevel.warning, 'Invalid hex string during debug image parsing'); + return null; + } + if (hex.startsWith('0x')) { + hex = hex.substring(2); + } + + var bytes = Uint8List(hex.length ~/ 2); + for (var i = 0; i < hex.length; i += 2) { + bytes[i ~/ 2] = int.parse(hex.substring(i, i + 2), radix: 16); + } + return bytes; + } + + String bigToLittleEndianUuid(String bigEndianUuid) { + final byteArray = + Uuid.parse(bigEndianUuid, validationMode: ValidationMode.nonStrict); + + final reversedByteArray = Uint8List.fromList([ + ...byteArray.sublist(0, 4).reversed, + ...byteArray.sublist(4, 6).reversed, + ...byteArray.sublist(6, 8).reversed, + ...byteArray.sublist(8, 10), + ...byteArray.sublist(10), + ]); + + return Uuid.unparse(reversedByteArray); + } + + String? _formatHexToUuid(String hex) { + if (hex.length != 32) { + _options.logger(SentryLevel.warning, + 'Hex input must be a 32-character hexadecimal string'); + return null; + } + + return '${hex.substring(0, 8)}-' + '${hex.substring(8, 12)}-' + '${hex.substring(12, 16)}-' + '${hex.substring(16, 20)}-' + '${hex.substring(20)}'; + } +} diff --git a/dart/lib/src/environment/environment_variables.dart b/dart/lib/src/environment/environment_variables.dart index 916cdea47d..3dcb1674b3 100644 --- a/dart/lib/src/environment/environment_variables.dart +++ b/dart/lib/src/environment/environment_variables.dart @@ -1,6 +1,7 @@ import '../platform_checker.dart'; import '_io_environment_variables.dart' - if (dart.library.html) '_web_environment_variables.dart' as env; + if (dart.library.html) '_web_environment_variables.dart' + if (dart.library.js_interop) '_web_environment_variables.dart' as env; /// Reads environment variables from the system. /// In an Flutter environment these can be set via diff --git a/dart/lib/src/event_processor/enricher/enricher_event_processor.dart b/dart/lib/src/event_processor/enricher/enricher_event_processor.dart index 78d19738bd..779d64b700 100644 --- a/dart/lib/src/event_processor/enricher/enricher_event_processor.dart +++ b/dart/lib/src/event_processor/enricher/enricher_event_processor.dart @@ -1,7 +1,8 @@ import '../../event_processor.dart'; import '../../sentry_options.dart'; import 'io_enricher_event_processor.dart' - if (dart.library.html) 'web_enricher_event_processor.dart'; + if (dart.library.html) 'html_enricher_event_processor.dart' + if (dart.library.js_interop) 'web_enricher_event_processor.dart'; abstract class EnricherEventProcessor implements EventProcessor { factory EnricherEventProcessor(SentryOptions options) => diff --git a/dart/lib/src/event_processor/enricher/html_enricher_event_processor.dart b/dart/lib/src/event_processor/enricher/html_enricher_event_processor.dart new file mode 100644 index 0000000000..e51cff4b71 --- /dev/null +++ b/dart/lib/src/event_processor/enricher/html_enricher_event_processor.dart @@ -0,0 +1,103 @@ +import 'dart:html' as html show window, Window; + +import '../../../sentry.dart'; +import 'enricher_event_processor.dart'; + +EnricherEventProcessor enricherEventProcessor(SentryOptions options) { + return WebEnricherEventProcessor( + html.window, + options, + ); +} + +class WebEnricherEventProcessor implements EnricherEventProcessor { + WebEnricherEventProcessor( + this._window, + this._options, + ); + + final html.Window _window; + + final SentryOptions _options; + + @override + SentryEvent? apply(SentryEvent event, Hint hint) { + // Web has no native integration, so no need to check for it + + final contexts = event.contexts.copyWith( + device: _getDevice(event.contexts.device), + culture: _getSentryCulture(event.contexts.culture), + ); + + contexts['dart_context'] = _getDartContext(); + + return event.copyWith( + contexts: contexts, + request: _getRequest(event.request), + transaction: event.transaction ?? _window.location.pathname, + ); + } + + // As seen in + // https://github.com/getsentry/sentry-javascript/blob/a6f8dc26a4c7ae2146ae64995a2018c8578896a6/packages/browser/src/integrations/useragent.ts + SentryRequest _getRequest(SentryRequest? request) { + final requestHeader = request?.headers; + final header = requestHeader == null + ? {} + : Map.from(requestHeader); + + header.putIfAbsent('User-Agent', () => _window.navigator.userAgent); + + final url = request?.url ?? _window.location.toString(); + return (request ?? SentryRequest(url: url)) + .copyWith(headers: header) + .sanitized(); + } + + SentryDevice _getDevice(SentryDevice? device) { + return (device ?? SentryDevice()).copyWith( + online: device?.online ?? _window.navigator.onLine, + memorySize: device?.memorySize ?? _getMemorySize(), + orientation: device?.orientation ?? _getScreenOrientation(), + screenHeightPixels: device?.screenHeightPixels ?? + _window.screen?.available.height.toInt(), + screenWidthPixels: + device?.screenWidthPixels ?? _window.screen?.available.width.toInt(), + screenDensity: + device?.screenDensity ?? _window.devicePixelRatio.toDouble(), + ); + } + + int? _getMemorySize() { + // https://developer.mozilla.org/en-US/docs/Web/API/Navigator/deviceMemory + final size = _window.navigator.deviceMemory?.toDouble(); + final memoryByteSize = size != null ? size * 1024 * 1024 * 1024 : null; + return memoryByteSize?.toInt(); + } + + SentryOrientation? _getScreenOrientation() { + // https://developer.mozilla.org/en-US/docs/Web/API/ScreenOrientation + final screenOrientation = _window.screen?.orientation; + if (screenOrientation != null) { + if (screenOrientation.type?.startsWith('portrait') ?? false) { + return SentryOrientation.portrait; + } + if (screenOrientation.type?.startsWith('landscape') ?? false) { + return SentryOrientation.landscape; + } + } + return null; + } + + Map _getDartContext() { + return { + 'compile_mode': _options.platformChecker.compileMode, + }; + } + + SentryCulture _getSentryCulture(SentryCulture? culture) { + return (culture ?? SentryCulture()).copyWith( + timezone: culture?.timezone ?? DateTime.now().timeZoneName, + ); + } +} diff --git a/dart/lib/src/event_processor/enricher/io_enricher_event_processor.dart b/dart/lib/src/event_processor/enricher/io_enricher_event_processor.dart index 52243a8572..0bf4c218d5 100644 --- a/dart/lib/src/event_processor/enricher/io_enricher_event_processor.dart +++ b/dart/lib/src/event_processor/enricher/io_enricher_event_processor.dart @@ -2,6 +2,7 @@ import 'dart:io'; import '../../../sentry.dart'; import 'enricher_event_processor.dart'; +import 'io_platform_memory.dart'; EnricherEventProcessor enricherEventProcessor(SentryOptions options) { return IoEnricherEventProcessor(options); @@ -14,28 +15,47 @@ class IoEnricherEventProcessor implements EnricherEventProcessor { IoEnricherEventProcessor(this._options); final SentryOptions _options; + late final String _dartVersion = _extractDartVersion(Platform.version); + + /// Extracts the semantic version and channel from the full version string. + /// + /// Example: + /// Input: "3.5.0-180.3.beta (beta) (Wed Jun 5 15:06:15 2024 +0000) on "android_arm64"" + /// Output: "3.5.0-180.3.beta (beta)" + /// + /// Falls back to the full version if the matching fails. + String _extractDartVersion(String fullVersion) { + RegExp channelRegex = RegExp(r'\((stable|beta|dev)\)'); + Match? match = channelRegex.firstMatch(fullVersion); + // if match is null this will return the full version + return fullVersion.substring(0, match?.end); + } @override SentryEvent? apply(SentryEvent event, Hint hint) { + // Amend app with current memory usage, as this is not available on native. + final app = _getApp(event.contexts.app); + // If there's a native integration available, it probably has better // information available than Flutter. - final os = _options.platformChecker.hasNativeIntegration - ? null - : _getOperatingSystem(event.contexts.operatingSystem); - final device = _options.platformChecker.hasNativeIntegration ? null : _getDevice(event.contexts.device); + final os = _options.platformChecker.hasNativeIntegration + ? null + : _getOperatingSystem(event.contexts.operatingSystem); + final culture = _options.platformChecker.hasNativeIntegration ? null : _getSentryCulture(event.contexts.culture); final contexts = event.contexts.copyWith( - operatingSystem: os, device: device, + operatingSystem: os, runtimes: _getRuntimes(event.contexts.runtimes), + app: app, culture: culture, ); @@ -51,6 +71,7 @@ class IoEnricherEventProcessor implements EnricherEventProcessor { // like Flutter: https://flutter.dev/docs/testing/build-modes final dartRuntime = SentryRuntime( name: 'Dart', + version: _dartVersion, rawDescription: Platform.version, ); if (runtimes == null) { @@ -79,6 +100,9 @@ class IoEnricherEventProcessor implements EnricherEventProcessor { exception: exception, stackTrace: stackTrace, ); + if (_options.automatedTestMode) { + rethrow; + } } } @@ -97,9 +121,18 @@ class IoEnricherEventProcessor implements EnricherEventProcessor { } SentryDevice _getDevice(SentryDevice? device) { + final platformMemory = PlatformMemory(_options); return (device ?? SentryDevice()).copyWith( name: device?.name ?? Platform.localHostname, processorCount: device?.processorCount ?? Platform.numberOfProcessors, + memorySize: device?.memorySize ?? platformMemory.getTotalPhysicalMemory(), + freeMemory: device?.freeMemory ?? platformMemory.getFreePhysicalMemory(), + ); + } + + SentryApp _getApp(SentryApp? app) { + return (app ?? SentryApp()).copyWith( + appMemory: app?.appMemory ?? ProcessInfo.currentRss, ); } diff --git a/dart/lib/src/event_processor/enricher/io_platform_memory.dart b/dart/lib/src/event_processor/enricher/io_platform_memory.dart new file mode 100644 index 0000000000..1f4a32987b --- /dev/null +++ b/dart/lib/src/event_processor/enricher/io_platform_memory.dart @@ -0,0 +1,111 @@ +import 'dart:io'; + +import '../../protocol.dart'; +import '../../sentry_options.dart'; + +// Get total & free platform memory (in bytes) for linux and windows operating systems. +// Source: https://github.com/onepub-dev/system_info/blob/8a9bf6b8eb7c86a09b3c3df4bf6d7fa5a6b50732/lib/src/platform/memory.dart +class PlatformMemory { + PlatformMemory(this.options); + + final SentryOptions options; + + int? getTotalPhysicalMemory() { + if (options.platformChecker.platform.isLinux) { + return _getLinuxMemInfoValue('MemTotal'); + } else if (options.platformChecker.platform.isWindows) { + return _getWindowsWmicValue('ComputerSystem', 'TotalPhysicalMemory'); + } else { + return null; + } + } + + int? getFreePhysicalMemory() { + if (options.platformChecker.platform.isLinux) { + return _getLinuxMemInfoValue('MemFree'); + } else if (options.platformChecker.platform.isWindows) { + return _getWindowsWmicValue('OS', 'FreePhysicalMemory'); + } else { + return null; + } + } + + int? _getWindowsWmicValue(String section, String key) { + final os = _wmicGetValueAsMap(section, [key]); + final totalPhysicalMemoryValue = os?[key]; + if (totalPhysicalMemoryValue == null) { + return null; + } + final size = int.tryParse(totalPhysicalMemoryValue); + if (size == null) { + return null; + } + return size; + } + + int? _getLinuxMemInfoValue(String key) { + final meminfoList = _exec('cat', ['/proc/meminfo']) + ?.trim() + .replaceAll('\r\n', '\n') + .split('\n') ?? + []; + + final meminfoMap = _listToMap(meminfoList, ':'); + final memsizeResults = meminfoMap[key]?.split(' ') ?? []; + + if (memsizeResults.isEmpty) { + return null; + } + final memsizeResult = memsizeResults.first; + + final memsize = int.tryParse(memsizeResult); + if (memsize == null) { + return null; + } + return memsize; + } + + String? _exec(String executable, List arguments, + {bool runInShell = false}) { + try { + final result = + Process.runSync(executable, arguments, runInShell: runInShell); + if (result.exitCode == 0) { + return result.stdout.toString(); + } + } catch (e) { + options.logger(SentryLevel.warning, "Failed to run process: $e"); + if (options.automatedTestMode) { + rethrow; + } + } + return null; + } + + Map? _wmicGetValueAsMap(String section, List fields) { + final arguments = [section]; + arguments + ..add('get') + ..addAll(fields.join(', ').split(' ')) + ..add('/VALUE'); + + final list = + _exec('wmic', arguments)?.trim().replaceAll('\r\n', '\n').split('\n') ?? + []; + + return _listToMap(list, '='); + } + + Map _listToMap(List list, String separator) { + final map = {}; + for (final string in list) { + final index = string.indexOf(separator); + if (index != -1) { + final key = string.substring(0, index).trim(); + final value = string.substring(index + 1).trim(); + map[key] = value; + } + } + return map; + } +} diff --git a/dart/lib/src/event_processor/enricher/web_enricher_event_processor.dart b/dart/lib/src/event_processor/enricher/web_enricher_event_processor.dart index e51cff4b71..27bb6b99db 100644 --- a/dart/lib/src/event_processor/enricher/web_enricher_event_processor.dart +++ b/dart/lib/src/event_processor/enricher/web_enricher_event_processor.dart @@ -1,11 +1,13 @@ -import 'dart:html' as html show window, Window; +// We would lose compatibility with old dart versions by adding web to pubspec. +// ignore: depend_on_referenced_packages +import 'package:web/web.dart' as web show window, Window, Navigator; import '../../../sentry.dart'; import 'enricher_event_processor.dart'; EnricherEventProcessor enricherEventProcessor(SentryOptions options) { return WebEnricherEventProcessor( - html.window, + web.window, options, ); } @@ -16,7 +18,7 @@ class WebEnricherEventProcessor implements EnricherEventProcessor { this._options, ); - final html.Window _window; + final web.Window _window; final SentryOptions _options; @@ -59,10 +61,9 @@ class WebEnricherEventProcessor implements EnricherEventProcessor { online: device?.online ?? _window.navigator.onLine, memorySize: device?.memorySize ?? _getMemorySize(), orientation: device?.orientation ?? _getScreenOrientation(), - screenHeightPixels: device?.screenHeightPixels ?? - _window.screen?.available.height.toInt(), - screenWidthPixels: - device?.screenWidthPixels ?? _window.screen?.available.width.toInt(), + screenHeightPixels: + device?.screenHeightPixels ?? _window.screen.availHeight, + screenWidthPixels: device?.screenWidthPixels ?? _window.screen.availWidth, screenDensity: device?.screenDensity ?? _window.devicePixelRatio.toDouble(), ); @@ -70,6 +71,7 @@ class WebEnricherEventProcessor implements EnricherEventProcessor { int? _getMemorySize() { // https://developer.mozilla.org/en-US/docs/Web/API/Navigator/deviceMemory + // ignore: invalid_null_aware_operator final size = _window.navigator.deviceMemory?.toDouble(); final memoryByteSize = size != null ? size * 1024 * 1024 * 1024 : null; return memoryByteSize?.toInt(); @@ -77,14 +79,12 @@ class WebEnricherEventProcessor implements EnricherEventProcessor { SentryOrientation? _getScreenOrientation() { // https://developer.mozilla.org/en-US/docs/Web/API/ScreenOrientation - final screenOrientation = _window.screen?.orientation; - if (screenOrientation != null) { - if (screenOrientation.type?.startsWith('portrait') ?? false) { - return SentryOrientation.portrait; - } - if (screenOrientation.type?.startsWith('landscape') ?? false) { - return SentryOrientation.landscape; - } + final screenOrientation = _window.screen.orientation; + if (screenOrientation.type.startsWith('portrait')) { + return SentryOrientation.portrait; + } + if (screenOrientation.type.startsWith('landscape')) { + return SentryOrientation.landscape; } return null; } @@ -101,3 +101,8 @@ class WebEnricherEventProcessor implements EnricherEventProcessor { ); } } + +extension on web.Navigator { + // ignore: unused_element + external double? get deviceMemory; +} diff --git a/dart/lib/src/event_processor/exception/exception_event_processor.dart b/dart/lib/src/event_processor/exception/exception_event_processor.dart index e928f476f0..ab4f5e9878 100644 --- a/dart/lib/src/event_processor/exception/exception_event_processor.dart +++ b/dart/lib/src/event_processor/exception/exception_event_processor.dart @@ -1,7 +1,8 @@ import '../../event_processor.dart'; import '../../sentry_options.dart'; import 'io_exception_event_processor.dart' - if (dart.library.html) 'web_exception_event_processor.dart'; + if (dart.library.html) 'web_exception_event_processor.dart' + if (dart.library.js_interop) 'web_exception_event_processor.dart'; abstract class ExceptionEventProcessor implements EventProcessor { factory ExceptionEventProcessor(SentryOptions options) => diff --git a/dart/lib/src/event_processor/exception/io_exception_event_processor.dart b/dart/lib/src/event_processor/exception/io_exception_event_processor.dart index bb4049c00e..55677a938c 100644 --- a/dart/lib/src/event_processor/exception/io_exception_event_processor.dart +++ b/dart/lib/src/event_processor/exception/io_exception_event_processor.dart @@ -69,6 +69,9 @@ class IoExceptionEventProcessor implements ExceptionEventProcessor { exception: exception, stackTrace: stackTrace, ); + if (_options.automatedTestMode) { + rethrow; + } } return event.copyWith( diff --git a/dart/lib/src/exception_type_identifier.dart b/dart/lib/src/exception_type_identifier.dart new file mode 100644 index 0000000000..7dae9db841 --- /dev/null +++ b/dart/lib/src/exception_type_identifier.dart @@ -0,0 +1,54 @@ +import 'package:meta/meta.dart'; + +/// An abstract class for identifying the type of Dart errors and exceptions. +/// +/// It's used in scenarios where error types need to be determined in obfuscated builds +/// as [runtimeType] is not reliable in such cases. +/// +/// Implement this class to create custom error type identifiers for errors or exceptions. +/// that we do not support out of the box. +/// +/// Example: +/// ```dart +/// class MyExceptionTypeIdentifier implements ExceptionTypeIdentifier { +/// @override +/// String? identifyType(dynamic throwable) { +/// if (throwable is MyCustomError) return 'MyCustomError'; +/// return null; +/// } +/// } +/// ``` +abstract class ExceptionTypeIdentifier { + String? identifyType(dynamic throwable); +} + +extension CacheableExceptionIdentifier on ExceptionTypeIdentifier { + ExceptionTypeIdentifier withCache() => CachingExceptionTypeIdentifier(this); +} + +@visibleForTesting +class CachingExceptionTypeIdentifier implements ExceptionTypeIdentifier { + @visibleForTesting + ExceptionTypeIdentifier get identifier => _identifier; + final ExceptionTypeIdentifier _identifier; + + final Map _knownExceptionTypes = {}; + + CachingExceptionTypeIdentifier(this._identifier); + + @override + String? identifyType(dynamic throwable) { + final runtimeType = throwable.runtimeType; + if (_knownExceptionTypes.containsKey(runtimeType)) { + return _knownExceptionTypes[runtimeType]; + } + + final identifiedType = _identifier.identifyType(throwable); + + if (identifiedType != null) { + _knownExceptionTypes[runtimeType] = identifiedType; + } + + return identifiedType; + } +} diff --git a/dart/lib/src/http_client/client_provider.dart b/dart/lib/src/http_client/client_provider.dart new file mode 100644 index 0000000000..201a559601 --- /dev/null +++ b/dart/lib/src/http_client/client_provider.dart @@ -0,0 +1,16 @@ +import 'package:meta/meta.dart'; +import 'package:http/http.dart'; + +import '../sentry_options.dart'; + +@internal +ClientProvider getClientProvider() { + return ClientProvider(); +} + +@internal +class ClientProvider { + Client getClient(SentryOptions options) { + return Client(); + } +} diff --git a/dart/lib/src/http_client/io_client_provider.dart b/dart/lib/src/http_client/io_client_provider.dart new file mode 100644 index 0000000000..b8ec366d06 --- /dev/null +++ b/dart/lib/src/http_client/io_client_provider.dart @@ -0,0 +1,67 @@ +import 'dart:io'; + +import 'package:http/http.dart'; +import 'package:http/io_client.dart'; +import 'package:meta/meta.dart'; + +import '../protocol.dart'; +import '../protocol/sentry_proxy.dart'; +import '../sentry_options.dart'; +import 'client_provider.dart'; + +@internal +ClientProvider getClientProvider() { + return IoClientProvider( + () { + return HttpClient(); + }, + (user, pass) { + return HttpClientBasicCredentials(user, pass); + }, + ); +} + +@internal +class IoClientProvider implements ClientProvider { + final HttpClient Function() _httpClient; + final HttpClientCredentials Function(String, String) _httpClientCredentials; + + IoClientProvider(this._httpClient, this._httpClientCredentials); + + @override + Client getClient(SentryOptions options) { + final proxy = options.proxy; + if (proxy == null) { + return Client(); + } + final pac = proxy.toPacString(); + if (proxy.type == SentryProxyType.socks) { + options.logger( + SentryLevel.warning, + "Setting proxy '$pac' is not supported.", + ); + return Client(); + } + options.logger( + SentryLevel.info, + "Setting proxy '$pac'", + ); + final httpClient = _httpClient(); + httpClient.findProxy = (url) => pac; + + final host = proxy.host; + final port = proxy.port; + final user = proxy.user; + final pass = proxy.pass; + + if (host != null && port != null && user != null && pass != null) { + httpClient.addProxyCredentials( + host, + port, + '', + _httpClientCredentials(user, pass), + ); + } + return IOClient(httpClient); + } +} diff --git a/dart/lib/src/hub.dart b/dart/lib/src/hub.dart index a8e06e28ed..2626670a3e 100644 --- a/dart/lib/src/hub.dart +++ b/dart/lib/src/hub.dart @@ -124,6 +124,9 @@ class Hub { exception: exception, stackTrace: stackTrace, ); + if (_options.automatedTestMode) { + rethrow; + } } finally { _lastEventId = sentryId; } @@ -183,6 +186,9 @@ class Hub { exception: exception, stackTrace: stackTrace, ); + if (_options.automatedTestMode) { + rethrow; + } } finally { _lastEventId = sentryId; } @@ -238,6 +244,9 @@ class Hub { exception: exception, stackTrace: stackTrace, ); + if (_options.automatedTestMode) { + rethrow; + } } finally { _lastEventId = sentryId; } @@ -271,6 +280,9 @@ class Hub { exception: exception, stackTrace: stacktrace, ); + if (_options.automatedTestMode) { + rethrow; + } } } @@ -364,6 +376,9 @@ class Hub { exception: exception, stackTrace: stackTrace, ); + if (_options.automatedTestMode) { + rethrow; + } } _isEnabled = false; @@ -542,6 +557,11 @@ class Hub { DiscardReason.sampleRate, DataCategory.transaction, ); + _options.recorder.recordLostEvent( + DiscardReason.sampleRate, + DataCategory.span, + count: transaction.spans.length + 1, + ); _options.logger( SentryLevel.warning, 'Transaction ${transaction.eventId} was dropped due to sampling decision.', @@ -560,6 +580,9 @@ class Hub { exception: exception, stackTrace: stackTrace, ); + if (_options.automatedTestMode) { + rethrow; + } } } } @@ -597,6 +620,9 @@ class Hub { exception: exception, stackTrace: stackTrace, ); + if (_options.automatedTestMode) { + rethrow; + } } } return sentryId; @@ -677,6 +703,9 @@ class _WeakMap { exception: exception, stackTrace: stackTrace, ); + if (_options.automatedTestMode) { + rethrow; + } } } @@ -694,6 +723,9 @@ class _WeakMap { exception: exception, stackTrace: stackTrace, ); + if (_options.automatedTestMode) { + rethrow; + } } return null; } diff --git a/dart/lib/src/load_dart_debug_images_integration.dart b/dart/lib/src/load_dart_debug_images_integration.dart new file mode 100644 index 0000000000..0932cac9af --- /dev/null +++ b/dart/lib/src/load_dart_debug_images_integration.dart @@ -0,0 +1,77 @@ +import '../sentry.dart'; +import 'debug_image_extractor.dart'; + +class LoadDartDebugImagesIntegration extends Integration { + @override + void call(Hub hub, SentryOptions options) { + options.addEventProcessor(_LoadImageIntegrationEventProcessor( + DebugImageExtractor(options), options)); + options.sdk.addIntegration('loadDartImageIntegration'); + } +} + +const hintRawStackTraceKey = 'raw_stacktrace'; + +class _LoadImageIntegrationEventProcessor implements EventProcessor { + _LoadImageIntegrationEventProcessor(this._debugImageExtractor, this._options); + + final SentryOptions _options; + final DebugImageExtractor _debugImageExtractor; + + @override + Future apply(SentryEvent event, Hint hint) async { + final rawStackTrace = hint.get(hintRawStackTraceKey) as String?; + if (!_options.enableDartSymbolication || + !event.needsSymbolication() || + rawStackTrace == null) { + return event; + } + + try { + final syntheticImage = _debugImageExtractor.extractFrom(rawStackTrace); + if (syntheticImage == null) { + return event; + } + + return event.copyWith(debugMeta: DebugMeta(images: [syntheticImage])); + } catch (e, stackTrace) { + _options.logger( + SentryLevel.info, + "Couldn't add Dart debug image to event. " + 'The event will still be reported.', + exception: e, + stackTrace: stackTrace, + ); + if (_options.automatedTestMode) { + rethrow; + } + return event; + } + } +} + +extension NeedsSymbolication on SentryEvent { + bool needsSymbolication() { + if (this is SentryTransaction) { + return false; + } + final frames = _getStacktraceFrames(); + if (frames == null) { + return false; + } + return frames.any((frame) => 'native' == frame?.platform); + } + + Iterable? _getStacktraceFrames() { + if (exceptions?.isNotEmpty == true) { + return exceptions?.first.stackTrace?.frames; + } + if (threads?.isNotEmpty == true) { + var stacktraces = threads?.map((e) => e.stacktrace); + return stacktraces + ?.where((element) => element != null) + .expand((element) => element!.frames); + } + return null; + } +} diff --git a/dart/lib/src/origin.dart b/dart/lib/src/origin.dart index ed1e066c5e..1d5b3dc4bf 100644 --- a/dart/lib/src/origin.dart +++ b/dart/lib/src/origin.dart @@ -1,4 +1,3 @@ -import 'dart:html'; - -/// request origin, used for browser stacktrace -String get eventOrigin => '${window.location.origin}/'; +export 'origin_io.dart' + if (dart.library.html) 'origin_html.dart' + if (dart.library.js_interop) 'origin_web.dart'; diff --git a/dart/lib/src/origin_html.dart b/dart/lib/src/origin_html.dart new file mode 100644 index 0000000000..ed1e066c5e --- /dev/null +++ b/dart/lib/src/origin_html.dart @@ -0,0 +1,4 @@ +import 'dart:html'; + +/// request origin, used for browser stacktrace +String get eventOrigin => '${window.location.origin}/'; diff --git a/dart/lib/src/noop_origin.dart b/dart/lib/src/origin_io.dart similarity index 100% rename from dart/lib/src/noop_origin.dart rename to dart/lib/src/origin_io.dart diff --git a/dart/lib/src/origin_web.dart b/dart/lib/src/origin_web.dart new file mode 100644 index 0000000000..db99f33c56 --- /dev/null +++ b/dart/lib/src/origin_web.dart @@ -0,0 +1,6 @@ +// We would lose compatibility with old dart versions by adding web to pubspec. +// ignore: depend_on_referenced_packages +import 'package:web/web.dart'; + +/// request origin, used for browser stacktrace +String get eventOrigin => '${window.location.origin}/'; diff --git a/dart/lib/src/performance_collector.dart b/dart/lib/src/performance_collector.dart new file mode 100644 index 0000000000..736534fb03 --- /dev/null +++ b/dart/lib/src/performance_collector.dart @@ -0,0 +1,13 @@ +import '../sentry.dart'; + +abstract class PerformanceCollector {} + +/// Used for collecting continuous data about vitals (slow, frozen frames, etc.) +/// during a transaction/span. +abstract class PerformanceContinuousCollector extends PerformanceCollector { + Future onSpanStarted(ISentrySpan span); + + Future onSpanFinished(ISentrySpan span, DateTime endTimestamp); + + void clear(); +} diff --git a/dart/lib/src/platform/_html_platform.dart b/dart/lib/src/platform/_html_platform.dart new file mode 100644 index 0000000000..d3fa84eed9 --- /dev/null +++ b/dart/lib/src/platform/_html_platform.dart @@ -0,0 +1,51 @@ +import 'dart:html' as html; +import 'platform.dart'; + +const Platform instance = WebPlatform(); + +/// [Platform] implementation that delegates to `dart:html`. +class WebPlatform extends Platform { + /// Creates a new [Platform]. + const WebPlatform(); + + @override + String get operatingSystem => _browserPlatform(); + + @override + String get operatingSystemVersion => 'unknown'; + + @override + String get localHostname => html.window.location.hostname ?? 'unknown'; + + String _browserPlatform() { + final navigatorPlatform = + html.window.navigator.platform?.toLowerCase() ?? ''; + if (navigatorPlatform.startsWith('mac')) { + return 'macos'; + } + if (navigatorPlatform.startsWith('win')) { + return 'windows'; + } + if (navigatorPlatform.contains('iphone') || + navigatorPlatform.contains('ipad') || + navigatorPlatform.contains('ipod')) { + return 'ios'; + } + if (navigatorPlatform.contains('android')) { + return 'android'; + } + if (navigatorPlatform.contains('fuchsia')) { + return 'fuchsia'; + } + + // Since some phones can report a window.navigator.platform as Linux, fall + // back to use CSS to disambiguate Android vs Linux desktop. If the CSS + // indicates that a device has a "fine pointer" (mouse) as the primary + // pointing device, then we'll assume desktop linux, and otherwise we'll + // assume Android. + if (html.window.matchMedia('only screen and (pointer: fine)').matches) { + return 'linux'; + } + return 'android'; + } +} diff --git a/dart/lib/src/platform/_web_platform.dart b/dart/lib/src/platform/_web_platform.dart index d3fa84eed9..da403b254d 100644 --- a/dart/lib/src/platform/_web_platform.dart +++ b/dart/lib/src/platform/_web_platform.dart @@ -1,9 +1,12 @@ -import 'dart:html' as html; +// We would lose compatibility with old dart versions by adding web to pubspec. +// ignore: depend_on_referenced_packages +import 'package:web/web.dart' as web; + import 'platform.dart'; const Platform instance = WebPlatform(); -/// [Platform] implementation that delegates to `dart:html`. +/// [Platform] implementation that delegates to `dart:web`. class WebPlatform extends Platform { /// Creates a new [Platform]. const WebPlatform(); @@ -15,11 +18,10 @@ class WebPlatform extends Platform { String get operatingSystemVersion => 'unknown'; @override - String get localHostname => html.window.location.hostname ?? 'unknown'; + String get localHostname => web.window.location.hostname; String _browserPlatform() { - final navigatorPlatform = - html.window.navigator.platform?.toLowerCase() ?? ''; + final navigatorPlatform = web.window.navigator.platform.toLowerCase(); if (navigatorPlatform.startsWith('mac')) { return 'macos'; } @@ -43,7 +45,7 @@ class WebPlatform extends Platform { // indicates that a device has a "fine pointer" (mouse) as the primary // pointing device, then we'll assume desktop linux, and otherwise we'll // assume Android. - if (html.window.matchMedia('only screen and (pointer: fine)').matches) { + if (web.window.matchMedia('only screen and (pointer: fine)').matches) { return 'linux'; } return 'android'; diff --git a/dart/lib/src/platform/platform.dart b/dart/lib/src/platform/platform.dart index 8f6d0760e8..dffd3e81fd 100644 --- a/dart/lib/src/platform/platform.dart +++ b/dart/lib/src/platform/platform.dart @@ -1,5 +1,6 @@ -import '_io_platform.dart' if (dart.library.html) '_web_platform.dart' - as platform; +import '_io_platform.dart' + if (dart.library.html) '_html_platform.dart' + if (dart.library.js_interop) '_web_platform.dart' as platform; const Platform instance = platform.instance; diff --git a/dart/lib/src/protocol/access_aware_map.dart b/dart/lib/src/protocol/access_aware_map.dart new file mode 100644 index 0000000000..c8ea7b2395 --- /dev/null +++ b/dart/lib/src/protocol/access_aware_map.dart @@ -0,0 +1,53 @@ +import 'dart:collection'; + +import 'package:meta/meta.dart'; + +@internal +class AccessAwareMap extends MapBase { + AccessAwareMap(this._map); + + final Map _map; + final Set _accessedKeysWithValues = {}; + + Set get accessedKeysWithValues => _accessedKeysWithValues; + + @override + V? operator [](Object? key) { + if (key is String && _map.containsKey(key)) { + _accessedKeysWithValues.add(key); + } + return _map[key]; + } + + @override + void operator []=(String key, V value) { + _map[key] = value; + } + + @override + void clear() { + _map.clear(); + _accessedKeysWithValues.clear(); + } + + @override + Iterable get keys => _map.keys; + + @override + V? remove(Object? key) { + return _map.remove(key); + } + + Map? notAccessed() { + if (_accessedKeysWithValues.length == _map.length) { + return null; + } + Map unknown = _map.keys + .where((key) => !_accessedKeysWithValues.contains(key)) + .fold>({}, (map, key) { + map[key] = _map[key]; + return map; + }); + return unknown.isNotEmpty ? unknown : null; + } +} diff --git a/dart/lib/src/protocol/breadcrumb.dart b/dart/lib/src/protocol/breadcrumb.dart index f7eea55358..282a5cee8f 100644 --- a/dart/lib/src/protocol/breadcrumb.dart +++ b/dart/lib/src/protocol/breadcrumb.dart @@ -2,6 +2,7 @@ import 'package:meta/meta.dart'; import '../utils.dart'; import '../protocol.dart'; +import 'access_aware_map.dart'; /// Structured data to describe more information prior to the event captured. /// See `Sentry.captureEvent()`. @@ -30,6 +31,7 @@ class Breadcrumb { this.data, SentryLevel? level, this.type, + this.unknown, }) : timestamp = timestamp ?? getUtcDateTime(), level = level ?? SentryLevel.info; @@ -50,6 +52,10 @@ class Breadcrumb { String? httpQuery, String? httpFragment, }) { + // The timestamp is used as the request-end time, so we need to set it right + // now and not rely on the default constructor. + timestamp ??= getUtcDateTime(); + return Breadcrumb( type: 'http', category: 'http', @@ -65,6 +71,11 @@ class Breadcrumb { if (responseBodySize != null) 'response_body_size': responseBodySize, if (httpQuery != null) 'http.query': httpQuery, if (httpFragment != null) 'http.fragment': httpFragment, + if (requestDuration != null) + 'start_timestamp': + timestamp.millisecondsSinceEpoch - requestDuration.inMilliseconds, + if (requestDuration != null) + 'end_timestamp': timestamp.millisecondsSinceEpoch, }, ); } @@ -94,21 +105,17 @@ class Breadcrumb { String? viewId, String? viewClass, }) { - final newData = data ?? {}; - if (viewId != null) { - newData['view.id'] = viewId; - } - if (viewClass != null) { - newData['view.class'] = viewClass; - } - return Breadcrumb( message: message, level: level, category: 'ui.$subCategory', type: 'user', timestamp: timestamp, - data: newData, + data: { + if (viewId != null) 'view.id': viewId, + if (viewClass != null) 'view.class': viewClass, + if (data != null) ...data, + }, ); } @@ -156,8 +163,13 @@ class Breadcrumb { /// The value is submitted to Sentry with second precision. final DateTime timestamp; + @internal + final Map? unknown; + /// Deserializes a [Breadcrumb] from JSON [Map]. - factory Breadcrumb.fromJson(Map json) { + factory Breadcrumb.fromJson(Map jsonData) { + final json = AccessAwareMap(jsonData); + final levelName = json['level']; final timestamp = json['timestamp']; @@ -165,7 +177,6 @@ class Breadcrumb { if (data != null) { data = Map.from(data as Map); } - return Breadcrumb( timestamp: timestamp != null ? DateTime.tryParse(timestamp) : null, message: json['message'], @@ -173,6 +184,7 @@ class Breadcrumb { data: data, level: levelName != null ? SentryLevel.fromName(levelName) : null, type: json['type'], + unknown: json.notAccessed(), ); } @@ -180,6 +192,7 @@ class Breadcrumb { /// to the Sentry protocol. Map toJson() { return { + ...?unknown, 'timestamp': formatDateAsIso8601WithMillisPrecision(timestamp), if (message != null) 'message': message, if (category != null) 'category': category, @@ -204,5 +217,6 @@ class Breadcrumb { level: level ?? this.level, type: type ?? this.type, timestamp: timestamp ?? this.timestamp, + unknown: unknown, ); } diff --git a/dart/lib/src/protocol/debug_image.dart b/dart/lib/src/protocol/debug_image.dart index df45a12fed..6ff98cbf07 100644 --- a/dart/lib/src/protocol/debug_image.dart +++ b/dart/lib/src/protocol/debug_image.dart @@ -1,5 +1,7 @@ import 'package:meta/meta.dart'; +import 'access_aware_map.dart'; + /// The list of debug images contains all dynamic libraries loaded into /// the process and their memory addresses. /// Instruction addresses in the Stack Trace are mapped into the list of debug @@ -51,6 +53,9 @@ class DebugImage { /// MachO CPU type identifier. final int? cpuType; + @internal + final Map? unknown; + const DebugImage({ required this.type, this.name, @@ -65,10 +70,12 @@ class DebugImage { this.codeId, this.cpuType, this.cpuSubtype, + this.unknown, }); /// Deserializes a [DebugImage] from JSON [Map]. - factory DebugImage.fromJson(Map json) { + factory DebugImage.fromJson(Map data) { + final json = AccessAwareMap(data); return DebugImage( type: json['type'], name: json['name'], @@ -83,12 +90,14 @@ class DebugImage { codeId: json['code_id'], cpuType: json['cpu_type'], cpuSubtype: json['cpu_subtype'], + unknown: json.notAccessed(), ); } /// Produces a [Map] that can be serialized to JSON. Map toJson() { return { + ...?unknown, 'type': type, if (uuid != null) 'uuid': uuid, if (debugId != null) 'debug_id': debugId, @@ -134,5 +143,6 @@ class DebugImage { codeId: codeId ?? this.codeId, cpuType: cpuType ?? this.cpuType, cpuSubtype: cpuSubtype ?? this.cpuSubtype, + unknown: unknown, ); } diff --git a/dart/lib/src/protocol/debug_meta.dart b/dart/lib/src/protocol/debug_meta.dart index 205b2cc13e..dd4e93c4e4 100644 --- a/dart/lib/src/protocol/debug_meta.dart +++ b/dart/lib/src/protocol/debug_meta.dart @@ -1,6 +1,7 @@ import 'package:meta/meta.dart'; import '../protocol.dart'; +import 'access_aware_map.dart'; /// The debug meta interface carries debug information for processing errors and crash reports. @immutable @@ -16,10 +17,15 @@ class DebugMeta { /// images in order to retrieve debug files for symbolication. List get images => List.unmodifiable(_images ?? const []); - DebugMeta({this.sdk, List? images}) : _images = images; + DebugMeta({this.sdk, List? images, this.unknown}) + : _images = images; + + @internal + final Map? unknown; /// Deserializes a [DebugMeta] from JSON [Map]. - factory DebugMeta.fromJson(Map json) { + factory DebugMeta.fromJson(Map data) { + final json = AccessAwareMap(data); final sdkInfoJson = json['sdk_info']; final debugImagesJson = json['images'] as List?; return DebugMeta( @@ -28,6 +34,7 @@ class DebugMeta { ?.map((debugImageJson) => DebugImage.fromJson(debugImageJson as Map)) .toList(), + unknown: json.notAccessed(), ); } @@ -35,12 +42,13 @@ class DebugMeta { Map toJson() { final sdkInfo = sdk?.toJson(); return { + ...?unknown, if (sdkInfo?.isNotEmpty ?? false) 'sdk_info': sdkInfo, if (_images?.isNotEmpty ?? false) 'images': _images! .map((e) => e.toJson()) .where((element) => element.isNotEmpty) - .toList(growable: false) + .toList(growable: false), }; } @@ -51,5 +59,6 @@ class DebugMeta { DebugMeta( sdk: sdk ?? this.sdk, images: images ?? _images, + unknown: unknown, ); } diff --git a/dart/lib/src/protocol/mechanism.dart b/dart/lib/src/protocol/mechanism.dart index 22a6356800..fe6500b21b 100644 --- a/dart/lib/src/protocol/mechanism.dart +++ b/dart/lib/src/protocol/mechanism.dart @@ -1,5 +1,7 @@ import 'package:meta/meta.dart'; +import 'access_aware_map.dart'; + /// Sentry Exception Mechanism /// The exception mechanism is an optional field residing /// in the Exception Interface. It carries additional information about @@ -76,6 +78,9 @@ class Mechanism { /// (the last to be listed in the exception values). final int? parentId; + @internal + final Map? unknown; + Mechanism({ required this.type, this.description, @@ -88,6 +93,7 @@ class Mechanism { this.source, this.exceptionId, this.parentId, + this.unknown, }) : _meta = meta != null ? Map.from(meta) : null, _data = data != null ? Map.from(data) : null; @@ -116,10 +122,12 @@ class Mechanism { source: source ?? this.source, exceptionId: exceptionId ?? this.exceptionId, parentId: parentId ?? this.parentId, + unknown: unknown, ); /// Deserializes a [Mechanism] from JSON [Map]. - factory Mechanism.fromJson(Map json) { + factory Mechanism.fromJson(Map jsonData) { + final json = AccessAwareMap(jsonData); var data = json['data']; if (data != null) { data = Map.from(data as Map); @@ -142,12 +150,14 @@ class Mechanism { source: json['source'], exceptionId: json['exception_id'], parentId: json['parent_id'], + unknown: json.notAccessed(), ); } /// Produces a [Map] that can be serialized to JSON. Map toJson() { return { + ...?unknown, 'type': type, if (description != null) 'description': description, if (helpLink != null) 'help_link': helpLink, diff --git a/dart/lib/src/protocol/metric_summary.dart b/dart/lib/src/protocol/metric_summary.dart index b0c617fb30..f5354bfdf7 100644 --- a/dart/lib/src/protocol/metric_summary.dart +++ b/dart/lib/src/protocol/metric_summary.dart @@ -1,4 +1,7 @@ +import 'package:meta/meta.dart'; + import '../metrics/metric.dart'; +import 'access_aware_map.dart'; class MetricSummary { final num min; @@ -7,7 +10,10 @@ class MetricSummary { final int count; final Map? tags; - MetricSummary.fromGauge(GaugeMetric gauge) + @internal + final Map? unknown; + + MetricSummary.fromGauge(GaugeMetric gauge, {this.unknown}) : min = gauge.minimum, max = gauge.maximum, sum = gauge.sum, @@ -19,20 +25,26 @@ class MetricSummary { required this.max, required this.sum, required this.count, - required this.tags}); + required this.tags, + this.unknown}); /// Deserializes a [MetricSummary] from JSON [Map]. - factory MetricSummary.fromJson(Map data) => MetricSummary( - min: data['min'], - max: data['max'], - count: data['count'], - sum: data['sum'], - tags: data['tags']?.cast(), - ); + factory MetricSummary.fromJson(Map data) { + final json = AccessAwareMap(data); + return MetricSummary( + min: json['min'], + max: json['max'], + count: json['count'], + sum: json['sum'], + tags: json['tags']?.cast(), + unknown: json.notAccessed(), + ); + } /// Produces a [Map] that can be serialized to JSON. Map toJson() { - return { + return { + ...?unknown, 'min': min, 'max': max, 'count': count, diff --git a/dart/lib/src/protocol/sdk_info.dart b/dart/lib/src/protocol/sdk_info.dart index cf0b7d0f41..f5aec76efc 100644 --- a/dart/lib/src/protocol/sdk_info.dart +++ b/dart/lib/src/protocol/sdk_info.dart @@ -1,5 +1,7 @@ import 'package:meta/meta.dart'; +import 'access_aware_map.dart'; + /// An object describing the system SDK. @immutable class SdkInfo { @@ -8,26 +10,33 @@ class SdkInfo { final int? versionMinor; final int? versionPatchlevel; + @internal + final Map? unknown; + const SdkInfo({ this.sdkName, this.versionMajor, this.versionMinor, this.versionPatchlevel, + this.unknown, }); /// Deserializes a [SdkInfo] from JSON [Map]. - factory SdkInfo.fromJson(Map json) { + factory SdkInfo.fromJson(Map data) { + final json = AccessAwareMap(data); return SdkInfo( sdkName: json['sdk_name'], versionMajor: json['version_major'], versionMinor: json['version_minor'], versionPatchlevel: json['version_patchlevel'], + unknown: json.notAccessed(), ); } /// Produces a [Map] that can be serialized to JSON. Map toJson() { return { + ...?unknown, if (sdkName != null) 'sdk_name': sdkName, if (versionMajor != null) 'version_major': versionMajor, if (versionMinor != null) 'version_minor': versionMinor, @@ -46,5 +55,6 @@ class SdkInfo { versionMajor: versionMajor ?? this.versionMajor, versionMinor: versionMinor ?? this.versionMinor, versionPatchlevel: versionPatchlevel ?? this.versionPatchlevel, + unknown: unknown, ); } diff --git a/dart/lib/src/protocol/sdk_version.dart b/dart/lib/src/protocol/sdk_version.dart index e4b686a402..b915fbdde8 100644 --- a/dart/lib/src/protocol/sdk_version.dart +++ b/dart/lib/src/protocol/sdk_version.dart @@ -1,6 +1,7 @@ import 'package:meta/meta.dart'; import 'sentry_package.dart'; +import 'access_aware_map.dart'; /// Describes the SDK that is submitting events to Sentry. /// @@ -40,6 +41,7 @@ class SdkVersion { required this.version, List? integrations, List? packages, + this.unknown, }) : // List.from prevents from having immutable lists _integrations = List.from(integrations ?? []), @@ -61,8 +63,12 @@ class SdkVersion { /// An immutable list of packages that compose this SDK. List get packages => List.unmodifiable(_packages); + @internal + final Map? unknown; + /// Deserializes a [SdkVersion] from JSON [Map]. - factory SdkVersion.fromJson(Map json) { + factory SdkVersion.fromJson(Map data) { + final json = AccessAwareMap(data); final packagesJson = json['packages'] as List?; final integrationsJson = json['integrations'] as List?; return SdkVersion( @@ -72,12 +78,14 @@ class SdkVersion { ?.map((e) => SentryPackage.fromJson(e as Map)) .toList(), integrations: integrationsJson?.map((e) => e as String).toList(), + unknown: json.notAccessed(), ); } /// Produces a [Map] that can be serialized to JSON. Map toJson() { return { + ...?unknown, 'name': name, 'version': version, if (packages.isNotEmpty) @@ -117,5 +125,6 @@ class SdkVersion { version: version ?? this.version, integrations: integrations ?? _integrations, packages: packages ?? _packages, + unknown: unknown, ); } diff --git a/dart/lib/src/protocol/sentry_app.dart b/dart/lib/src/protocol/sentry_app.dart index 24501ce540..8ef217c690 100644 --- a/dart/lib/src/protocol/sentry_app.dart +++ b/dart/lib/src/protocol/sentry_app.dart @@ -1,5 +1,7 @@ import 'package:meta/meta.dart'; +import 'access_aware_map.dart'; + /// App context describes the application. /// /// As opposed to the runtime, this is the actual application that was @@ -20,6 +22,7 @@ class SentryApp { this.inForeground, this.viewNames, this.textScale, + this.unknown, }); /// Human readable application name, as it appears on the platform. @@ -56,29 +59,35 @@ class SentryApp { /// The current text scale. Only available on Flutter. final double? textScale; + @internal + final Map? unknown; + /// Deserializes a [SentryApp] from JSON [Map]. factory SentryApp.fromJson(Map data) { - final viewNamesJson = data['view_names'] as List?; + final json = AccessAwareMap(data); + final viewNamesJson = json['view_names'] as List?; return SentryApp( - name: data['app_name'], - version: data['app_version'], - identifier: data['app_identifier'], - build: data['app_build'], - buildType: data['build_type'], - startTime: data['app_start_time'] != null - ? DateTime.tryParse(data['app_start_time']) + name: json['app_name'], + version: json['app_version'], + identifier: json['app_identifier'], + build: json['app_build'], + buildType: json['build_type'], + startTime: json['app_start_time'] != null + ? DateTime.tryParse(json['app_start_time']) : null, - deviceAppHash: data['device_app_hash'], - appMemory: data['app_memory'], - inForeground: data['in_foreground'], + deviceAppHash: json['device_app_hash'], + appMemory: json['app_memory'], + inForeground: json['in_foreground'], viewNames: viewNamesJson?.map((e) => e as String).toList(), - textScale: data['text_scale'], + textScale: json['text_scale'], + unknown: json.notAccessed(), ); } /// Produces a [Map] that can be serialized to JSON. Map toJson() { return { + ...?unknown, if (name != null) 'app_name': name!, if (version != null) 'app_version': version!, if (identifier != null) 'app_identifier': identifier!, @@ -105,6 +114,7 @@ class SentryApp { inForeground: inForeground, viewNames: viewNames, textScale: textScale, + unknown: unknown, ); SentryApp copyWith({ @@ -132,5 +142,6 @@ class SentryApp { inForeground: inForeground ?? this.inForeground, viewNames: viewNames ?? this.viewNames, textScale: textScale ?? this.textScale, + unknown: unknown, ); } diff --git a/dart/lib/src/protocol/sentry_browser.dart b/dart/lib/src/protocol/sentry_browser.dart index f2807e1092..a67e1abb7d 100644 --- a/dart/lib/src/protocol/sentry_browser.dart +++ b/dart/lib/src/protocol/sentry_browser.dart @@ -1,5 +1,7 @@ import 'package:meta/meta.dart'; +import 'access_aware_map.dart'; + /// Carries information about the browser or user agent for web-related errors. /// /// This can either be the browser this event ocurred in, or the user @@ -9,7 +11,7 @@ class SentryBrowser { static const type = 'browser'; /// Creates an instance of [SentryBrowser]. - const SentryBrowser({this.name, this.version}); + const SentryBrowser({this.name, this.version, this.unknown}); /// Human readable application name, as it appears on the platform. final String? name; @@ -17,21 +19,33 @@ class SentryBrowser { /// Human readable application version, as it appears on the platform. final String? version; + @internal + final Map? unknown; + /// Deserializes a [SentryBrowser] from JSON [Map]. - factory SentryBrowser.fromJson(Map data) => SentryBrowser( - name: data['name'], - version: data['version'], - ); + factory SentryBrowser.fromJson(Map data) { + final json = AccessAwareMap(data); + return SentryBrowser( + name: json['name'], + version: json['version'], + unknown: json.notAccessed(), + ); + } /// Produces a [Map] that can be serialized to JSON. Map toJson() { - return { + return { + ...?unknown, if (name != null) 'name': name, if (version != null) 'version': version, }; } - SentryBrowser clone() => SentryBrowser(name: name, version: version); + SentryBrowser clone() => SentryBrowser( + name: name, + version: version, + unknown: unknown, + ); SentryBrowser copyWith({ String? name, @@ -40,5 +54,6 @@ class SentryBrowser { SentryBrowser( name: name ?? this.name, version: version ?? this.version, + unknown: unknown, ); } diff --git a/dart/lib/src/protocol/sentry_culture.dart b/dart/lib/src/protocol/sentry_culture.dart index e48a973131..68bcae5cd8 100644 --- a/dart/lib/src/protocol/sentry_culture.dart +++ b/dart/lib/src/protocol/sentry_culture.dart @@ -1,5 +1,7 @@ import 'package:meta/meta.dart'; +import 'access_aware_map.dart'; + /// Culture Context describes certain properties of the culture in which the /// software is used. @immutable @@ -12,15 +14,20 @@ class SentryCulture { this.locale, this.is24HourFormat, this.timezone, + this.unknown, }); - factory SentryCulture.fromJson(Map data) => SentryCulture( - calendar: data['calendar'], - displayName: data['display_name'], - locale: data['locale'], - is24HourFormat: data['is_24_hour_format'], - timezone: data['timezone'], - ); + factory SentryCulture.fromJson(Map data) { + final json = AccessAwareMap(data); + return SentryCulture( + calendar: json['calendar'], + displayName: json['display_name'], + locale: json['locale'], + is24HourFormat: json['is_24_hour_format'], + timezone: json['timezone'], + unknown: json.notAccessed(), + ); + } /// Optional: For example `GregorianCalendar`. Free form string. final String? calendar; @@ -39,9 +46,13 @@ class SentryCulture { /// Optional. The timezone of the locale. For example, `Europe/Vienna`. final String? timezone; + @internal + final Map? unknown; + /// Produces a [Map] that can be serialized to JSON. Map toJson() { - return { + return { + ...?unknown, if (calendar != null) 'calendar': calendar!, if (displayName != null) 'display_name': displayName!, if (locale != null) 'locale': locale!, @@ -56,6 +67,7 @@ class SentryCulture { locale: locale, is24HourFormat: is24HourFormat, timezone: timezone, + unknown: unknown, ); SentryCulture copyWith({ @@ -71,5 +83,6 @@ class SentryCulture { locale: locale ?? this.locale, is24HourFormat: is24HourFormat ?? this.is24HourFormat, timezone: timezone ?? this.timezone, + unknown: unknown, ); } diff --git a/dart/lib/src/protocol/sentry_device.dart b/dart/lib/src/protocol/sentry_device.dart index cad8c765f5..1bc5c89b78 100644 --- a/dart/lib/src/protocol/sentry_device.dart +++ b/dart/lib/src/protocol/sentry_device.dart @@ -1,5 +1,6 @@ import 'package:meta/meta.dart'; import '../sentry_options.dart'; +import 'access_aware_map.dart'; /// If a device is on portrait or landscape mode enum SentryOrientation { portrait, landscape } @@ -46,6 +47,7 @@ class SentryDevice { this.supportsGyroscope, this.supportsAudio, this.supportsLocationService, + this.unknown, }) : assert( batteryLevel == null || (batteryLevel >= 0 && batteryLevel <= 100), ); @@ -171,57 +173,65 @@ class SentryDevice { /// Optional. Is the device capable of reporting its location? final bool? supportsLocationService; + @internal + final Map? unknown; + /// Deserializes a [SentryDevice] from JSON [Map]. - factory SentryDevice.fromJson(Map data) => SentryDevice( - name: data['name'], - family: data['family'], - model: data['model'], - modelId: data['model_id'], - arch: data['arch'], - batteryLevel: - (data['battery_level'] is num ? data['battery_level'] as num : null) - ?.toDouble(), - orientation: data['orientation'] == 'portrait' - ? SentryOrientation.portrait - : data['orientation'] == 'landscape' - ? SentryOrientation.landscape - : null, - manufacturer: data['manufacturer'], - brand: data['brand'], - screenHeightPixels: data['screen_height_pixels']?.toInt(), - screenWidthPixels: data['screen_width_pixels']?.toInt(), - screenDensity: data['screen_density'], - screenDpi: data['screen_dpi'], - online: data['online'], - charging: data['charging'], - lowMemory: data['low_memory'], - simulator: data['simulator'], - memorySize: data['memory_size'], - freeMemory: data['free_memory'], - usableMemory: data['usable_memory'], - storageSize: data['storage_size'], - freeStorage: data['free_storage'], - externalStorageSize: data['external_storage_size'], - externalFreeStorage: data['external_free_storage'], - bootTime: data['boot_time'] != null - ? DateTime.tryParse(data['boot_time']) - : null, - processorCount: data['processor_count'], - cpuDescription: data['cpu_description'], - processorFrequency: data['processor_frequency'], - deviceType: data['device_type'], - batteryStatus: data['battery_status'], - deviceUniqueIdentifier: data['device_unique_identifier'], - supportsVibration: data['supports_vibration'], - supportsAccelerometer: data['supports_accelerometer'], - supportsGyroscope: data['supports_gyroscope'], - supportsAudio: data['supports_audio'], - supportsLocationService: data['supports_location_service'], - ); + factory SentryDevice.fromJson(Map data) { + final json = AccessAwareMap(data); + return SentryDevice( + name: json['name'], + family: json['family'], + model: json['model'], + modelId: json['model_id'], + arch: json['arch'], + batteryLevel: + (json['battery_level'] is num ? json['battery_level'] as num : null) + ?.toDouble(), + orientation: json['orientation'] == 'portrait' + ? SentryOrientation.portrait + : json['orientation'] == 'landscape' + ? SentryOrientation.landscape + : null, + manufacturer: json['manufacturer'], + brand: json['brand'], + screenHeightPixels: json['screen_height_pixels']?.toInt(), + screenWidthPixels: json['screen_width_pixels']?.toInt(), + screenDensity: json['screen_density'], + screenDpi: json['screen_dpi'], + online: json['online'], + charging: json['charging'], + lowMemory: json['low_memory'], + simulator: json['simulator'], + memorySize: json['memory_size'], + freeMemory: json['free_memory'], + usableMemory: json['usable_memory'], + storageSize: json['storage_size'], + freeStorage: json['free_storage'], + externalStorageSize: json['external_storage_size'], + externalFreeStorage: json['external_free_storage'], + bootTime: json['boot_time'] != null + ? DateTime.tryParse(json['boot_time']) + : null, + processorCount: json['processor_count'], + cpuDescription: json['cpu_description'], + processorFrequency: json['processor_frequency'], + deviceType: json['device_type'], + batteryStatus: json['battery_status'], + deviceUniqueIdentifier: json['device_unique_identifier'], + supportsVibration: json['supports_vibration'], + supportsAccelerometer: json['supports_accelerometer'], + supportsGyroscope: json['supports_gyroscope'], + supportsAudio: json['supports_audio'], + supportsLocationService: json['supports_location_service'], + unknown: json.notAccessed(), + ); + } /// Produces a [Map] that can be serialized to JSON. Map toJson() { - return { + return { + ...?unknown, if (name != null) 'name': name, if (family != null) 'family': family, if (model != null) 'model': model, @@ -304,6 +314,7 @@ class SentryDevice { supportsGyroscope: supportsGyroscope, supportsAudio: supportsAudio, supportsLocationService: supportsLocationService, + unknown: unknown, ); SentryDevice copyWith({ @@ -384,5 +395,6 @@ class SentryDevice { supportsAudio: supportsAudio ?? this.supportsAudio, supportsLocationService: supportsLocationService ?? this.supportsLocationService, + unknown: unknown, ); } diff --git a/dart/lib/src/protocol/sentry_event.dart b/dart/lib/src/protocol/sentry_event.dart index 32a76b9885..1b2765c426 100644 --- a/dart/lib/src/protocol/sentry_event.dart +++ b/dart/lib/src/protocol/sentry_event.dart @@ -3,6 +3,7 @@ import 'package:meta/meta.dart'; import '../protocol.dart'; import '../throwable_mechanism.dart'; import '../utils.dart'; +import 'access_aware_map.dart'; /// An event to be reported to Sentry.io. @immutable @@ -37,6 +38,7 @@ class SentryEvent with SentryEventLike { this.request, this.debugMeta, this.type, + this.unknown, }) : eventId = eventId ?? SentryId.newId(), timestamp = timestamp ?? getUtcDateTime(), contexts = contexts ?? Contexts(), @@ -189,6 +191,9 @@ class SentryEvent with SentryEventLike { /// defaults to 'default' final String? type; + @internal + final Map? unknown; + @override SentryEvent copyWith({ SentryId? eventId, @@ -251,10 +256,13 @@ class SentryEvent with SentryEventLike { this.exceptions, threads: (threads != null ? List.from(threads) : null) ?? this.threads, type: type ?? this.type, + unknown: unknown, ); /// Deserializes a [SentryEvent] from JSON [Map]. - factory SentryEvent.fromJson(Map json) { + factory SentryEvent.fromJson(Map data) { + final json = AccessAwareMap(data); + final breadcrumbsJson = json['breadcrumbs'] as List?; final breadcrumbs = breadcrumbsJson ?.map((e) => Breadcrumb.fromJson(e)) @@ -329,6 +337,7 @@ class SentryEvent with SentryEventLike { : null, exceptions: exceptions, type: json['type'], + unknown: json.notAccessed(), ); } @@ -368,7 +377,8 @@ class SentryEvent with SentryEventLike { .where((e) => e.isNotEmpty) .toList(growable: false); - return { + return { + ...?unknown, 'event_id': eventId.toString(), if (timestamp != null) 'timestamp': formatDateAsIso8601WithMillisPrecision(timestamp!), diff --git a/dart/lib/src/protocol/sentry_exception.dart b/dart/lib/src/protocol/sentry_exception.dart index 45de1b5c9c..9bf5f3fa13 100644 --- a/dart/lib/src/protocol/sentry_exception.dart +++ b/dart/lib/src/protocol/sentry_exception.dart @@ -1,6 +1,7 @@ import 'package:meta/meta.dart'; import '../protocol.dart'; +import 'access_aware_map.dart'; /// The Exception Interface specifies an exception or error that occurred in a program. @immutable @@ -25,6 +26,9 @@ class SentryException { final dynamic throwable; + @internal + final Map? unknown; + const SentryException({ required this.type, required this.value, @@ -33,10 +37,13 @@ class SentryException { this.mechanism, this.threadId, this.throwable, + this.unknown, }); /// Deserializes a [SentryException] from JSON [Map]. - factory SentryException.fromJson(Map json) { + factory SentryException.fromJson(Map data) { + final json = AccessAwareMap(data); + final stackTraceJson = json['stacktrace']; final mechanismJson = json['mechanism']; return SentryException( @@ -49,12 +56,14 @@ class SentryException { mechanism: mechanismJson != null ? Mechanism.fromJson(mechanismJson) : null, threadId: json['thread_id'], + unknown: json.notAccessed(), ); } /// Produces a [Map] that can be serialized to JSON. Map toJson() { - return { + return { + ...?unknown, if (type != null) 'type': type, if (value != null) 'value': value, if (module != null) 'module': module, @@ -81,5 +90,6 @@ class SentryException { mechanism: mechanism ?? this.mechanism, threadId: threadId ?? this.threadId, throwable: throwable ?? this.throwable, + unknown: unknown, ); } diff --git a/dart/lib/src/protocol/sentry_gpu.dart b/dart/lib/src/protocol/sentry_gpu.dart index f428e674bb..bb6168813e 100644 --- a/dart/lib/src/protocol/sentry_gpu.dart +++ b/dart/lib/src/protocol/sentry_gpu.dart @@ -12,6 +12,8 @@ import 'package:meta/meta.dart'; +import 'access_aware_map.dart'; + /// GPU context describes the GPU of the device. @immutable class SentryGpu { @@ -65,6 +67,9 @@ class SentryGpu { /// Whether ray tracing is available on the device. final bool? supportsRayTracing; + @internal + final Map? unknown; + const SentryGpu({ this.name, this.id, @@ -81,26 +86,31 @@ class SentryGpu { this.supportsDrawCallInstancing, this.supportsGeometryShaders, this.supportsRayTracing, + this.unknown, }); /// Deserializes a [SentryGpu] from JSON [Map]. - factory SentryGpu.fromJson(Map data) => SentryGpu( - name: data['name'], - id: data['id'], - vendorId: data['vendor_id'], - vendorName: data['vendor_name'], - memorySize: data['memory_size'], - apiType: data['api_type'], - multiThreadedRendering: data['multi_threaded_rendering'], - version: data['version'], - npotSupport: data['npot_support'], - graphicsShaderLevel: data['graphics_shader_level'], - maxTextureSize: data['max_texture_size'], - supportsComputeShaders: data['supports_compute_shaders'], - supportsDrawCallInstancing: data['supports_draw_call_instancing'], - supportsGeometryShaders: data['supports_geometry_shaders'], - supportsRayTracing: data['supports_ray_tracing'], - ); + factory SentryGpu.fromJson(Map data) { + final json = AccessAwareMap(data); + return SentryGpu( + name: json['name'], + id: json['id'], + vendorId: json['vendor_id'], + vendorName: json['vendor_name'], + memorySize: json['memory_size'], + apiType: json['api_type'], + multiThreadedRendering: json['multi_threaded_rendering'], + version: json['version'], + npotSupport: json['npot_support'], + graphicsShaderLevel: json['graphics_shader_level'], + maxTextureSize: json['max_texture_size'], + supportsComputeShaders: json['supports_compute_shaders'], + supportsDrawCallInstancing: json['supports_draw_call_instancing'], + supportsGeometryShaders: json['supports_geometry_shaders'], + supportsRayTracing: json['supports_ray_tracing'], + unknown: json.notAccessed(), + ); + } SentryGpu clone() => SentryGpu( name: name, @@ -118,11 +128,13 @@ class SentryGpu { supportsDrawCallInstancing: supportsDrawCallInstancing, supportsGeometryShaders: supportsGeometryShaders, supportsRayTracing: supportsRayTracing, + unknown: unknown, ); /// Produces a [Map] that can be serialized to JSON. Map toJson() { - return { + return { + ...?unknown, if (name != null) 'name': name, if (id != null) 'id': id, if (vendorId != null) 'vendor_id': vendorId, @@ -184,5 +196,6 @@ class SentryGpu { supportsGeometryShaders: supportsGeometryShaders ?? this.supportsGeometryShaders, supportsRayTracing: supportsRayTracing ?? this.supportsRayTracing, + unknown: unknown, ); } diff --git a/dart/lib/src/protocol/sentry_message.dart b/dart/lib/src/protocol/sentry_message.dart index 6115458ef2..bb9193f714 100644 --- a/dart/lib/src/protocol/sentry_message.dart +++ b/dart/lib/src/protocol/sentry_message.dart @@ -1,5 +1,7 @@ import 'package:meta/meta.dart'; +import 'access_aware_map.dart'; + /// The Message Interface carries a log message that describes an event or error. /// Optionally, it can carry a format string and structured parameters. This can help to group similar messages into the same issue. /// example of a serialized message: @@ -23,20 +25,31 @@ class SentryMessage { /// A list of formatting parameters, preferably strings. Non-strings will be coerced to strings. final List? params; - const SentryMessage(this.formatted, {this.template, this.params}); + @internal + final Map? unknown; + + const SentryMessage( + this.formatted, { + this.template, + this.params, + this.unknown, + }); /// Deserializes a [SentryMessage] from JSON [Map]. - factory SentryMessage.fromJson(Map json) { + factory SentryMessage.fromJson(Map data) { + final json = AccessAwareMap(data); return SentryMessage( json['formatted'], template: json['message'], params: json['params'], + unknown: json.notAccessed(), ); } /// Produces a [Map] that can be serialized to JSON. Map toJson() { return { + ...?unknown, 'formatted': formatted, if (template != null) 'message': template, if (params?.isNotEmpty ?? false) 'params': params, @@ -52,5 +65,6 @@ class SentryMessage { formatted ?? this.formatted, template: template ?? this.template, params: params ?? this.params, + unknown: unknown, ); } diff --git a/dart/lib/src/protocol/sentry_operating_system.dart b/dart/lib/src/protocol/sentry_operating_system.dart index 89839e8da1..8854a4d87f 100644 --- a/dart/lib/src/protocol/sentry_operating_system.dart +++ b/dart/lib/src/protocol/sentry_operating_system.dart @@ -1,5 +1,7 @@ import 'package:meta/meta.dart'; +import 'access_aware_map.dart'; + /// Describes the operating system on which the event was created. /// /// In web contexts, this is the operating system of the browse @@ -16,6 +18,7 @@ class SentryOperatingSystem { this.rooted, this.rawDescription, this.theme, + this.unknown, }); /// The name of the operating system. @@ -45,21 +48,28 @@ class SentryOperatingSystem { /// Describes whether the OS runs in dark mode or not. final String? theme; + @internal + final Map? unknown; + /// Deserializes a [SentryOperatingSystem] from JSON [Map]. - factory SentryOperatingSystem.fromJson(Map data) => - SentryOperatingSystem( - name: data['name'], - version: data['version'], - build: data['build'], - kernelVersion: data['kernel_version'], - rooted: data['rooted'], - rawDescription: data['raw_description'], - theme: data['theme'], - ); + factory SentryOperatingSystem.fromJson(Map data) { + final json = AccessAwareMap(data); + return SentryOperatingSystem( + name: json['name'], + version: json['version'], + build: json['build'], + kernelVersion: json['kernel_version'], + rooted: json['rooted'], + rawDescription: json['raw_description'], + theme: json['theme'], + unknown: json.notAccessed(), + ); + } /// Produces a [Map] that can be serialized to JSON. Map toJson() { - return { + return { + ...?unknown, if (name != null) 'name': name, if (version != null) 'version': version, if (build != null) 'build': build, @@ -78,6 +88,7 @@ class SentryOperatingSystem { rooted: rooted, rawDescription: rawDescription, theme: theme, + unknown: unknown, ); SentryOperatingSystem copyWith({ @@ -97,5 +108,6 @@ class SentryOperatingSystem { rooted: rooted ?? this.rooted, rawDescription: rawDescription ?? this.rawDescription, theme: theme ?? this.theme, + unknown: unknown, ); } diff --git a/dart/lib/src/protocol/sentry_package.dart b/dart/lib/src/protocol/sentry_package.dart index 8157a50aeb..643ba910ed 100644 --- a/dart/lib/src/protocol/sentry_package.dart +++ b/dart/lib/src/protocol/sentry_package.dart @@ -1,10 +1,12 @@ import 'package:meta/meta.dart'; +import 'access_aware_map.dart'; + /// A [SentryPackage] part of the SDK. @immutable class SentryPackage { /// Creates an [SentryPackage] object that is part of the SDK. - const SentryPackage(this.name, this.version); + const SentryPackage(this.name, this.version, {this.unknown}); /// The name of the SDK. final String name; @@ -12,17 +14,23 @@ class SentryPackage { /// The version of the SDK. final String version; + @internal + final Map? unknown; + /// Deserializes a [SentryPackage] from JSON [Map]. - factory SentryPackage.fromJson(Map json) { + factory SentryPackage.fromJson(Map data) { + final json = AccessAwareMap(data); return SentryPackage( json['name'], json['version'], + unknown: json.notAccessed(), ); } /// Produces a [Map] that can be serialized to JSON. Map toJson() { - return { + return { + ...?unknown, 'name': name, 'version': version, }; @@ -35,5 +43,6 @@ class SentryPackage { SentryPackage( name ?? this.name, version ?? this.version, + unknown: unknown, ); } diff --git a/dart/lib/src/protocol/sentry_proxy.dart b/dart/lib/src/protocol/sentry_proxy.dart new file mode 100644 index 0000000000..7dcaefef22 --- /dev/null +++ b/dart/lib/src/protocol/sentry_proxy.dart @@ -0,0 +1,62 @@ +class SentryProxy { + final SentryProxyType type; + final String? host; + final int? port; + final String? user; + final String? pass; + + SentryProxy({required this.type, this.host, this.port, this.user, this.pass}); + + String toPacString() { + String type = 'DIRECT'; + switch (this.type) { + case SentryProxyType.direct: + return 'DIRECT'; + case SentryProxyType.http: + type = 'PROXY'; + break; + case SentryProxyType.socks: + type = 'SOCKS'; + break; + } + if (host != null && port != null) { + return '$type $host:$port'; + } else if (host != null) { + return '$type $host'; + } else { + return 'DIRECT'; + } + } + + /// Produces a [Map] that can be serialized to JSON. + Map toJson() { + return { + if (host != null) 'host': host, + if (port != null) 'port': port, + 'type': type.toString().split('.').last.toUpperCase(), + if (user != null) 'user': user, + if (pass != null) 'pass': pass, + }; + } + + SentryProxy copyWith({ + String? host, + int? port, + SentryProxyType? type, + String? user, + String? pass, + }) => + SentryProxy( + host: host ?? this.host, + port: port ?? this.port, + type: type ?? this.type, + user: user ?? this.user, + pass: pass ?? this.pass, + ); +} + +enum SentryProxyType { + direct, + http, + socks; +} diff --git a/dart/lib/src/protocol/sentry_request.dart b/dart/lib/src/protocol/sentry_request.dart index b5ee7d003c..6ad35cd34d 100644 --- a/dart/lib/src/protocol/sentry_request.dart +++ b/dart/lib/src/protocol/sentry_request.dart @@ -1,4 +1,5 @@ import 'package:meta/meta.dart'; +import 'access_aware_map.dart'; import '../utils/iterable_utils.dart'; import '../utils/http_sanitizer.dart'; @@ -69,6 +70,9 @@ class SentryRequest { /// its target specification. final String? apiTarget; + @internal + final Map? unknown; + SentryRequest({ this.url, this.method, @@ -81,6 +85,7 @@ class SentryRequest { Map? env, @Deprecated('Will be removed in v8. Use [data] instead') Map? other, + this.unknown, }) : _data = data, _headers = headers != null ? Map.from(headers) : null, // Look for a 'Set-Cookie' header (case insensitive) if not given. @@ -119,7 +124,8 @@ class SentryRequest { } /// Deserializes a [SentryRequest] from JSON [Map]. - factory SentryRequest.fromJson(Map json) { + factory SentryRequest.fromJson(Map data) { + final json = AccessAwareMap(data); return SentryRequest( url: json['url'], method: json['method'], @@ -132,12 +138,14 @@ class SentryRequest { other: json.containsKey('other') ? Map.from(json['other']) : null, fragment: json['fragment'], apiTarget: json['api_target'], + unknown: json.notAccessed(), ); } /// Produces a [Map] that can be serialized to JSON. Map toJson() { - return { + return { + ...?unknown, if (url != null) 'url': url, if (method != null) 'method': method, if (queryString != null) 'query_string': queryString, @@ -178,5 +186,6 @@ class SentryRequest { apiTarget: apiTarget ?? this.apiTarget, // ignore: deprecated_member_use_from_same_package other: other ?? _other, + unknown: unknown, ); } diff --git a/dart/lib/src/protocol/sentry_runtime.dart b/dart/lib/src/protocol/sentry_runtime.dart index 960f48c170..02f8e632e8 100644 --- a/dart/lib/src/protocol/sentry_runtime.dart +++ b/dart/lib/src/protocol/sentry_runtime.dart @@ -1,5 +1,7 @@ import 'package:meta/meta.dart'; +import 'access_aware_map.dart'; + /// Describes a runtime in more detail. /// /// Typically this context is used multiple times if multiple runtimes @@ -17,6 +19,7 @@ class SentryRuntime { this.compiler, this.rawDescription, this.build, + this.unknown, }) : assert(key == null || key.length >= 1); /// Key used in the JSON and which will be displayed @@ -44,18 +47,26 @@ class SentryRuntime { /// Application build string, if it is separate from the version. final String? build; + @internal + final Map? unknown; + /// Deserializes a [SentryRuntime] from JSON [Map]. - factory SentryRuntime.fromJson(Map data) => SentryRuntime( - name: data['name'], - version: data['version'], - compiler: data['compiler'], - rawDescription: data['raw_description'], - build: data['build'], - ); + factory SentryRuntime.fromJson(Map data) { + final json = AccessAwareMap(data); + return SentryRuntime( + name: json['name'], + version: json['version'], + compiler: json['compiler'], + rawDescription: json['raw_description'], + build: json['build'], + unknown: json.notAccessed(), + ); + } /// Produces a [Map] that can be serialized to JSON. Map toJson() { - return { + return { + ...?unknown, if (name != null) 'name': name, if (compiler != null) 'compiler': compiler, if (version != null) 'version': version, @@ -71,6 +82,7 @@ class SentryRuntime { compiler: compiler, rawDescription: rawDescription, build: build, + unknown: unknown, ); SentryRuntime copyWith({ @@ -88,5 +100,6 @@ class SentryRuntime { compiler: compiler ?? this.compiler, rawDescription: rawDescription ?? this.rawDescription, build: build ?? this.build, + unknown: unknown, ); } diff --git a/dart/lib/src/protocol/sentry_span.dart b/dart/lib/src/protocol/sentry_span.dart index 780578d182..00f6ec8f5a 100644 --- a/dart/lib/src/protocol/sentry_span.dart +++ b/dart/lib/src/protocol/sentry_span.dart @@ -1,12 +1,11 @@ import 'dart:async'; -import '../hub.dart'; +import 'package:meta/meta.dart'; + +import '../../sentry.dart'; import '../metrics/local_metrics_aggregator.dart'; -import '../protocol.dart'; import '../sentry_tracer.dart'; -import '../tracing.dart'; -import '../utils.dart'; typedef OnFinishedCallback = Future Function({DateTime? endTimestamp}); @@ -17,7 +16,15 @@ class SentrySpan extends ISentrySpan { late final DateTime _startTimestamp; final Hub _hub; + bool _isRootSpan = false; + + bool get isRootSpan => _isRootSpan; + + @internal + SentryTracer get tracer => _tracer; + final SentryTracer _tracer; + final Map _data = {}; dynamic _throwable; @@ -36,6 +43,7 @@ class SentrySpan extends ISentrySpan { DateTime? startTimestamp, this.samplingDecision, OnFinishedCallback? finishedCallback, + isRootSpan = false, }) { _startTimestamp = startTimestamp?.toUtc() ?? _hub.options.clock(); _finishedCallback = finishedCallback; @@ -43,6 +51,7 @@ class SentrySpan extends ISentrySpan { _localMetricsAggregator = _hub.options.enableSpanLocalMetricAggregation ? LocalMetricsAggregator() : null; + _isRootSpan = isRootSpan; } @override @@ -56,17 +65,27 @@ class SentrySpan extends ISentrySpan { } if (endTimestamp == null) { - _endTimestamp = _hub.options.clock(); + endTimestamp = _hub.options.clock(); } else if (endTimestamp.isBefore(_startTimestamp)) { _hub.options.logger( SentryLevel.warning, 'End timestamp ($endTimestamp) cannot be before start timestamp ($_startTimestamp)', ); - _endTimestamp = _hub.options.clock(); + endTimestamp = _hub.options.clock(); } else { - _endTimestamp = endTimestamp.toUtc(); + endTimestamp = endTimestamp.toUtc(); + } + + for (final collector in _hub.options.performanceCollectors) { + if (collector is PerformanceContinuousCollector) { + await collector.onSpanFinished(this, endTimestamp); + } } + // The finished flag depends on the _endTimestamp + // If we set this earlier then finished is true and then we cannot use setData etc... + _endTimestamp = endTimestamp; + // associate error if (_throwable != null) { _hub.setSpanContext(_throwable, this, _tracer.name); @@ -223,7 +242,12 @@ class SentrySpan extends ISentrySpan { num value, { SentryMeasurementUnit? unit, }) { - _tracer.setMeasurement(name, value, unit: unit); + if (finished) { + _hub.options.logger(SentryLevel.debug, + "The span is already finished. Measurement $name cannot be set"); + return; + } + _tracer.setMeasurementFromChild(name, value, unit: unit); } @override diff --git a/dart/lib/src/protocol/sentry_stack_frame.dart b/dart/lib/src/protocol/sentry_stack_frame.dart index 12eb5de4f6..edba949e9f 100644 --- a/dart/lib/src/protocol/sentry_stack_frame.dart +++ b/dart/lib/src/protocol/sentry_stack_frame.dart @@ -1,4 +1,5 @@ import 'package:meta/meta.dart'; +import 'access_aware_map.dart'; /// Frames belong to a StackTrace /// It should contain at least a filename, function or instruction_addr @@ -26,6 +27,7 @@ class SentryStackFrame { List? preContext, List? postContext, Map? vars, + this.unknown, }) : _framesOmitted = framesOmitted != null ? List.from(framesOmitted) : null, _preContext = preContext != null ? List.from(preContext) : null, @@ -124,8 +126,12 @@ class SentryStackFrame { /// This is relevant for languages like Swift, C++ or Rust. final String? symbol; + @internal + final Map? unknown; + /// Deserializes a [SentryStackFrame] from JSON [Map]. - factory SentryStackFrame.fromJson(Map json) { + factory SentryStackFrame.fromJson(Map data) { + final json = AccessAwareMap(data); return SentryStackFrame( absPath: json['abs_path'], fileName: json['filename'], @@ -148,12 +154,14 @@ class SentryStackFrame { vars: json['vars'], symbol: json['symbol'], stackStart: json['stack_start'], + unknown: json.notAccessed(), ); } /// Produces a [Map] that can be serialized to JSON. Map toJson() { - return { + return { + ...?unknown, if (_preContext?.isNotEmpty ?? false) 'pre_context': _preContext, if (_postContext?.isNotEmpty ?? false) 'post_context': _postContext, if (_vars?.isNotEmpty ?? false) 'vars': _vars, @@ -223,5 +231,6 @@ class SentryStackFrame { vars: vars ?? _vars, symbol: symbol ?? symbol, stackStart: stackStart ?? stackStart, + unknown: unknown, ); } diff --git a/dart/lib/src/protocol/sentry_stack_trace.dart b/dart/lib/src/protocol/sentry_stack_trace.dart index c8a6076726..949318ec4c 100644 --- a/dart/lib/src/protocol/sentry_stack_trace.dart +++ b/dart/lib/src/protocol/sentry_stack_trace.dart @@ -1,6 +1,7 @@ import 'package:meta/meta.dart'; import 'sentry_stack_frame.dart'; +import 'access_aware_map.dart'; /// Stacktrace holds information about the frames of the stack. @immutable @@ -10,6 +11,7 @@ class SentryStackTrace { Map? registers, this.lang, this.snapshot, + this.unknown, }) : _frames = frames, _registers = Map.from(registers ?? {}); @@ -44,8 +46,12 @@ class SentryStackTrace { /// signal. final bool? snapshot; + @internal + final Map? unknown; + /// Deserializes a [SentryStackTrace] from JSON [Map]. - factory SentryStackTrace.fromJson(Map json) { + factory SentryStackTrace.fromJson(Map data) { + final json = AccessAwareMap(data); final framesJson = json['frames'] as List?; return SentryStackTrace( frames: framesJson != null @@ -56,12 +62,14 @@ class SentryStackTrace { registers: json['registers'], lang: json['lang'], snapshot: json['snapshot'], + unknown: json.notAccessed(), ); } /// Produces a [Map] that can be serialized to JSON. Map toJson() { - return { + return { + ...?unknown, if (_frames?.isNotEmpty ?? false) 'frames': _frames?.map((frame) => frame.toJson()).toList(growable: false), @@ -74,9 +82,14 @@ class SentryStackTrace { SentryStackTrace copyWith({ List? frames, Map? registers, + String? lang, + bool? snapshot, }) => SentryStackTrace( frames: frames ?? this.frames, registers: registers ?? this.registers, + lang: lang ?? this.lang, + snapshot: snapshot ?? this.snapshot, + unknown: unknown, ); } diff --git a/dart/lib/src/protocol/sentry_thread.dart b/dart/lib/src/protocol/sentry_thread.dart index c6ce13c15a..49dcd284f9 100644 --- a/dart/lib/src/protocol/sentry_thread.dart +++ b/dart/lib/src/protocol/sentry_thread.dart @@ -1,6 +1,7 @@ import 'package:meta/meta.dart'; import 'sentry_stack_trace.dart'; +import 'access_aware_map.dart'; /// The Threads Interface specifies threads that were running at the time an /// event happened. These threads can also contain stack traces. @@ -13,9 +14,11 @@ class SentryThread { this.crashed, this.current, this.stacktrace, + this.unknown, }); - factory SentryThread.fromJson(Map json) { + factory SentryThread.fromJson(Map data) { + final json = AccessAwareMap(data); return SentryThread( id: json['id'] as int?, name: json['name'] as String?, @@ -23,6 +26,7 @@ class SentryThread { current: json['current'] as bool?, stacktrace: json['stacktrace'] == null ? null : SentryStackTrace.fromJson(json), + unknown: json.notAccessed(), ); } @@ -44,9 +48,13 @@ class SentryThread { /// See https://develop.sentry.dev/sdk/event-payloads/stacktrace/ final SentryStackTrace? stacktrace; + @internal + final Map? unknown; + Map toJson() { final stacktrace = this.stacktrace; return { + ...?unknown, if (id != null) 'id': id, if (name != null) 'name': name, if (crashed != null) 'crashed': crashed, @@ -68,6 +76,7 @@ class SentryThread { crashed: crashed ?? this.crashed, current: current ?? this.current, stacktrace: stacktrace ?? this.stacktrace, + unknown: unknown, ); } } diff --git a/dart/lib/src/protocol/sentry_trace_context.dart b/dart/lib/src/protocol/sentry_trace_context.dart index 25c4ca7ad8..e44eede721 100644 --- a/dart/lib/src/protocol/sentry_trace_context.dart +++ b/dart/lib/src/protocol/sentry_trace_context.dart @@ -3,6 +3,7 @@ import 'package:meta/meta.dart'; import '../../sentry.dart'; import '../propagation_context.dart'; import '../protocol.dart'; +import 'access_aware_map.dart'; @immutable class SentryTraceContext { @@ -17,6 +18,9 @@ class SentryTraceContext { /// Id of a parent span final SpanId? parentSpanId; + /// Replay associated with this trace. + final SentryId? replayId; + /// Whether the span is sampled or not final bool? sampled; @@ -37,7 +41,11 @@ class SentryTraceContext { /// @see final String? origin; - factory SentryTraceContext.fromJson(Map json) { + @internal + final Map? unknown; + + factory SentryTraceContext.fromJson(Map data) { + final json = AccessAwareMap(data); return SentryTraceContext( operation: json['op'] as String, spanId: SpanId.fromId(json['span_id'] as String), @@ -45,22 +53,28 @@ class SentryTraceContext { ? null : SpanId.fromId(json['parent_span_id'] as String), traceId: SentryId.fromId(json['trace_id'] as String), + replayId: json['replay_id'] == null + ? null + : SentryId.fromId(json['replay_id'] as String), description: json['description'] as String?, status: json['status'] == null ? null : SpanStatus.fromString(json['status'] as String), sampled: true, origin: json['origin'] == null ? null : json['origin'] as String?, + unknown: json.notAccessed(), ); } /// Item encoded as JSON Map toJson() { return { + ...?unknown, 'span_id': spanId.toString(), 'trace_id': traceId.toString(), 'op': operation, if (parentSpanId != null) 'parent_span_id': parentSpanId!.toString(), + if (replayId != null) 'replay_id': replayId!.toString(), if (description != null) 'description': description, if (status != null) 'status': status!.toString(), if (origin != null) 'origin': origin, @@ -76,6 +90,8 @@ class SentryTraceContext { parentSpanId: parentSpanId, sampled: sampled, origin: origin, + unknown: unknown, + replayId: replayId, ); SentryTraceContext({ @@ -87,6 +103,8 @@ class SentryTraceContext { this.description, this.status, this.origin, + this.unknown, + this.replayId, }) : traceId = traceId ?? SentryId.newId(), spanId = spanId ?? SpanId.newId(); @@ -94,9 +112,9 @@ class SentryTraceContext { factory SentryTraceContext.fromPropagationContext( PropagationContext propagationContext) { return SentryTraceContext( - traceId: propagationContext.traceId, - spanId: propagationContext.spanId, - operation: 'default', - ); + traceId: propagationContext.traceId, + spanId: propagationContext.spanId, + operation: 'default', + replayId: propagationContext.baggage?.getReplayId()); } } diff --git a/dart/lib/src/protocol/sentry_transaction_info.dart b/dart/lib/src/protocol/sentry_transaction_info.dart index 96b520eb5a..773b482646 100644 --- a/dart/lib/src/protocol/sentry_transaction_info.dart +++ b/dart/lib/src/protocol/sentry_transaction_info.dart @@ -1,10 +1,18 @@ +import 'package:meta/meta.dart'; + +import 'access_aware_map.dart'; + class SentryTransactionInfo { - SentryTransactionInfo(this.source); + SentryTransactionInfo(this.source, {this.unknown}); final String source; + @internal + final Map? unknown; + Map toJson() { return { + ...?unknown, 'source': source, }; } @@ -14,12 +22,15 @@ class SentryTransactionInfo { }) { return SentryTransactionInfo( source ?? this.source, + unknown: unknown, ); } - factory SentryTransactionInfo.fromJson(Map json) { + factory SentryTransactionInfo.fromJson(Map data) { + final json = AccessAwareMap(data); return SentryTransactionInfo( json['source'], + unknown: json.notAccessed(), ); } } diff --git a/dart/lib/src/protocol/sentry_user.dart b/dart/lib/src/protocol/sentry_user.dart index 3b2f2cab1c..a62d7b9c58 100644 --- a/dart/lib/src/protocol/sentry_user.dart +++ b/dart/lib/src/protocol/sentry_user.dart @@ -1,6 +1,7 @@ import 'package:meta/meta.dart'; import '../../sentry.dart'; +import 'access_aware_map.dart'; /// Describes the current user associated with the application, such as the /// currently signed in user. @@ -44,6 +45,7 @@ class SentryUser { Map? data, @Deprecated('Will be removed in v8. Use [data] instead') Map? extras, + this.unknown, }) : assert( id != null || username != null || @@ -68,6 +70,8 @@ class SentryUser { final String? ipAddress; /// The user segment, for apps that divide users in user segments. + @Deprecated( + 'Will be removed in v9. Use a custom tag or context instead to capture this information.') final String? segment; /// Any other user context information that may be helpful. @@ -90,8 +94,13 @@ class SentryUser { /// Human readable name of the user. final String? name; + @internal + final Map? unknown; + /// Deserializes a [SentryUser] from JSON [Map]. - factory SentryUser.fromJson(Map json) { + factory SentryUser.fromJson(Map jsonData) { + final json = AccessAwareMap(jsonData); + var extras = json['extras']; if (extras != null) { extras = Map.from(extras); @@ -118,17 +127,20 @@ class SentryUser { name: json['name'], // ignore: deprecated_member_use_from_same_package extras: extras, + unknown: json.notAccessed(), ); } /// Produces a [Map] that can be serialized to JSON. Map toJson() { final geoJson = geo?.toJson(); - return { + return { + ...?unknown, if (id != null) 'id': id, if (username != null) 'username': username, if (email != null) 'email': email, if (ipAddress != null) 'ip_address': ipAddress, + // ignore: deprecated_member_use_from_same_package if (segment != null) 'segment': segment, if (data?.isNotEmpty ?? false) 'data': data, // ignore: deprecated_member_use_from_same_package @@ -155,12 +167,14 @@ class SentryUser { username: username ?? this.username, email: email ?? this.email, ipAddress: ipAddress ?? this.ipAddress, + // ignore: deprecated_member_use_from_same_package segment: segment ?? this.segment, data: data ?? this.data, // ignore: deprecated_member_use_from_same_package extras: extras ?? this.extras, geo: geo ?? this.geo, name: name ?? this.name, + unknown: unknown, ); } } diff --git a/dart/lib/src/recursive_exception_cause_extractor.dart b/dart/lib/src/recursive_exception_cause_extractor.dart index 7118d98c68..5636af84a5 100644 --- a/dart/lib/src/recursive_exception_cause_extractor.dart +++ b/dart/lib/src/recursive_exception_cause_extractor.dart @@ -41,6 +41,9 @@ class RecursiveExceptionCauseExtractor { exception: exception, stackTrace: stackTrace, ); + if (_options.automatedTestMode) { + rethrow; + } break; } } diff --git a/dart/lib/src/scope.dart b/dart/lib/src/scope.dart index 3fef9a92a2..03748445e6 100644 --- a/dart/lib/src/scope.dart +++ b/dart/lib/src/scope.dart @@ -97,6 +97,13 @@ class Scope { /// they must be JSON-serializable. Map get extra => Map.unmodifiable(_extra); + /// Active replay recording. + @internal + SentryId? get replayId => _replayId; + @internal + set replayId(SentryId? value) => _replayId = value; + SentryId? _replayId; + final Contexts _contexts = Contexts(); /// Unmodifiable map of the scope contexts key/value @@ -237,6 +244,7 @@ class Scope { _tags.clear(); _extra.clear(); _eventProcessors.clear(); + _replayId = null; _clearBreadcrumbsSync(); _setUserSync(null); @@ -268,6 +276,8 @@ class Scope { } /// Sets an extra to the Scope + @Deprecated( + 'Use Contexts instead. Additional data is deprecated in favor of structured Contexts and should be avoided when possible') Future setExtra(String key, dynamic value) async { _setExtraSync(key, value); await _callScopeObservers( @@ -275,6 +285,8 @@ class Scope { } /// Removes an extra from the Scope + @Deprecated( + 'Use Contexts instead. Additional data is deprecated in favor of structured Contexts and should be avoided when possible') Future removeExtra(String key) async { _extra.remove(key); await _callScopeObservers( @@ -425,7 +437,8 @@ class Scope { ..fingerprint = List.from(fingerprint) .._transaction = _transaction ..span = span - .._enableScopeSync = false; + .._enableScopeSync = false + .._replayId = _replayId; clone._setUserSync(user); diff --git a/dart/lib/src/sentry.dart b/dart/lib/src/sentry.dart index 1873cd6308..29217abe40 100644 --- a/dart/lib/src/sentry.dart +++ b/dart/lib/src/sentry.dart @@ -2,6 +2,8 @@ import 'dart:async'; import 'package:meta/meta.dart'; +import 'dart_exception_type_identifier.dart'; +import 'load_dart_debug_images_integration.dart'; import 'metrics/metrics_api.dart'; import 'run_zoned_guarded_integration.dart'; import 'event_processor/enricher/enricher_event_processor.dart'; @@ -82,9 +84,15 @@ class Sentry { options.addIntegrationByIndex(0, IsolateErrorIntegration()); } + if (options.enableDartSymbolication) { + options.addIntegration(LoadDartDebugImagesIntegration()); + } + options.addEventProcessor(EnricherEventProcessor(options)); options.addEventProcessor(ExceptionEventProcessor(options)); options.addEventProcessor(DeduplicationEventProcessor(options)); + + options.prependExceptionTypeIdentifier(DartExceptionTypeIdentifier()); } /// This method reads available environment variables and uses them diff --git a/dart/lib/src/sentry_baggage.dart b/dart/lib/src/sentry_baggage.dart index 25aab900f4..c47cd37624 100644 --- a/dart/lib/src/sentry_baggage.dart +++ b/dart/lib/src/sentry_baggage.dart @@ -55,6 +55,7 @@ class SentryBaggage { exception: exception, stackTrace: stackTrace, ); + // TODO rethrow in options.automatedTestMode (currently not available here to check) } } @@ -106,9 +107,14 @@ class SentryBaggage { if (scope.user?.id != null) { setUserId(scope.user!.id!); } + // ignore: deprecated_member_use_from_same_package if (scope.user?.segment != null) { + // ignore: deprecated_member_use_from_same_package setUserSegment(scope.user!.segment!); } + if (scope.replayId != null && scope.replayId != SentryId.empty()) { + setReplayId(scope.replayId.toString()); + } } static Map _extractKeyValuesFromBaggageString( @@ -176,6 +182,8 @@ class SentryBaggage { set('sentry-user_id', value); } + @Deprecated( + 'Will be removed in v9 since functionality has been removed from Sentry') void setUserSegment(String value) { set('sentry-user_segment', value); } @@ -201,5 +209,12 @@ class SentryBaggage { return double.tryParse(sampleRate); } + void setReplayId(String value) => set('sentry-replay_id', value); + + SentryId? getReplayId() { + final replayId = get('sentry-replay_id'); + return replayId == null ? null : SentryId.fromId(replayId); + } + Map get keyValues => Map.unmodifiable(_keyValues); } diff --git a/dart/lib/src/sentry_client.dart b/dart/lib/src/sentry_client.dart index 63fcbfb421..65cf799d7a 100644 --- a/dart/lib/src/sentry_client.dart +++ b/dart/lib/src/sentry_client.dart @@ -1,32 +1,35 @@ import 'dart:async'; import 'dart:math'; + import 'package:meta/meta.dart'; -import 'utils/stacktrace_utils.dart'; -import 'metrics/metric.dart'; -import 'metrics/metrics_aggregator.dart'; -import 'sentry_baggage.dart'; -import 'sentry_attachment/sentry_attachment.dart'; +import 'client_reports/client_report_recorder.dart'; +import 'client_reports/discard_reason.dart'; import 'event_processor.dart'; import 'hint.dart'; -import 'sentry_trace_context_header.dart'; -import 'sentry_user_feedback.dart'; -import 'transport/rate_limiter.dart'; +import 'load_dart_debug_images_integration.dart'; +import 'metrics/metric.dart'; +import 'metrics/metrics_aggregator.dart'; import 'protocol.dart'; import 'scope.dart'; +import 'sentry_attachment/sentry_attachment.dart'; +import 'sentry_baggage.dart'; +import 'sentry_envelope.dart'; import 'sentry_exception_factory.dart'; import 'sentry_options.dart'; import 'sentry_stack_trace_factory.dart'; +import 'sentry_trace_context_header.dart'; +import 'sentry_user_feedback.dart'; +import 'transport/data_category.dart'; import 'transport/http_transport.dart'; import 'transport/noop_transport.dart'; +import 'transport/rate_limiter.dart'; import 'transport/spotlight_http_transport.dart'; import 'transport/task_queue.dart'; import 'utils/isolate_utils.dart'; +import 'utils/regex_utils.dart'; +import 'utils/stacktrace_utils.dart'; import 'version.dart'; -import 'sentry_envelope.dart'; -import 'client_reports/client_report_recorder.dart'; -import 'client_reports/discard_reason.dart'; -import 'transport/data_category.dart'; /// Default value for [SentryUser.ipAddress]. It gets set when an event does not have /// a user and IP address. Only applies if [SentryOptions.sendDefaultPii] is set @@ -45,7 +48,7 @@ class SentryClient { late final MetricsAggregator? _metricsAggregator; - static final _sentryId = Future.value(SentryId.empty()); + static final _emptySentryId = Future.value(SentryId.empty()); SentryExceptionFactory get _exceptionFactory => _options.exceptionFactory; @@ -83,18 +86,40 @@ class SentryClient { dynamic stackTrace, Hint? hint, }) async { + if (_isIgnoredError(event)) { + _options.logger( + SentryLevel.debug, + 'Error was ignored as specified in the ignoredErrors options.', + ); + _options.recorder + .recordLostEvent(DiscardReason.ignored, _getCategory(event)); + return _emptySentryId; + } + + if (_options.containsIgnoredExceptionForType(event.throwable)) { + _options.logger( + SentryLevel.debug, + 'Event was dropped as the exception ${event.throwable.runtimeType.toString()} is ignored.', + ); + _options.recorder + .recordLostEvent(DiscardReason.eventProcessor, _getCategory(event)); + return _emptySentryId; + } + if (_sampleRate()) { - _recordLostEvent(event, DiscardReason.sampleRate); + _options.recorder + .recordLostEvent(DiscardReason.sampleRate, _getCategory(event)); _options.logger( SentryLevel.debug, 'Event ${event.eventId.toString()} was dropped due to sampling decision.', ); - return _sentryId; + return _emptySentryId; } SentryEvent? preparedEvent = _prepareEvent(event, stackTrace: stackTrace); hint ??= Hint(); + hint.set(hintRawStackTraceKey, stackTrace.toString()); if (scope != null) { preparedEvent = await scope.applyToEvent(preparedEvent, hint); @@ -105,7 +130,7 @@ class SentryClient { // dropped by scope event processors if (preparedEvent == null) { - return _sentryId; + return _emptySentryId; } preparedEvent = await _runEventProcessors( @@ -116,7 +141,7 @@ class SentryClient { // dropped by event processors if (preparedEvent == null) { - return _sentryId; + return _emptySentryId; } preparedEvent = _createUserOrSetDefaultIpAddress(preparedEvent); @@ -128,7 +153,7 @@ class SentryClient { // dropped by beforeSend if (preparedEvent == null) { - return _sentryId; + return _emptySentryId; } var attachments = List.from(scope?.attachments ?? []); @@ -145,15 +170,15 @@ class SentryClient { var traceContext = scope?.span?.traceContext(); if (traceContext == null) { - if (scope?.propagationContext.baggage == null) { - scope?.propagationContext.baggage = - SentryBaggage({}, logger: _options.logger); - scope?.propagationContext.baggage?.setValuesFromScope(scope, _options); - } if (scope != null) { + scope.propagationContext.baggage ??= + SentryBaggage({}, logger: _options.logger) + ..setValuesFromScope(scope, _options); traceContext = SentryTraceContextHeader.fromBaggage( scope.propagationContext.baggage!); } + } else { + traceContext.replayId = scope?.replayId; } final envelope = SentryEnvelope.fromEvent( @@ -168,6 +193,15 @@ class SentryClient { return id ?? SentryId.empty(); } + bool _isIgnoredError(SentryEvent event) { + if (event.message == null || _options.ignoreErrors.isEmpty) { + return false; + } + + var message = event.message!.formatted; + return isMatchingRegexPattern(message, _options.ignoreErrors); + } + SentryEvent _prepareEvent(SentryEvent event, {dynamic stackTrace}) { event = event.copyWith( serverName: event.serverName ?? _options.serverName, @@ -325,7 +359,7 @@ class SentryClient { // dropped by scope event processors if (preparedTransaction == null) { - return _sentryId; + return _emptySentryId; } preparedTransaction = await _runEventProcessors( @@ -336,7 +370,18 @@ class SentryClient { // dropped by event processors if (preparedTransaction == null) { - return _sentryId; + return _emptySentryId; + } + + if (_isIgnoredTransaction(preparedTransaction)) { + _options.logger( + SentryLevel.debug, + 'Transaction was ignored as specified in the ignoredTransactions options.', + ); + + _options.recorder.recordLostEvent( + DiscardReason.ignored, _getCategory(preparedTransaction)); + return _emptySentryId; } preparedTransaction = @@ -344,7 +389,7 @@ class SentryClient { // dropped by beforeSendTransaction if (preparedTransaction == null) { - return _sentryId; + return _emptySentryId; } final attachments = scope?.attachments @@ -367,6 +412,15 @@ class SentryClient { return id ?? SentryId.empty(); } + bool _isIgnoredTransaction(SentryTransaction transaction) { + if (_options.ignoreTransactions.isEmpty) { + return false; + } + + var name = transaction.tracer.name; + return isMatchingRegexPattern(name, _options.ignoreTransactions); + } + /// Reports the [envelope] to Sentry.io. Future captureEnvelope(SentryEnvelope envelope) { return _attachClientReportsAndSend(envelope); @@ -403,7 +457,9 @@ class SentryClient { SentryEvent event, Hint hint, ) async { - SentryEvent? eventOrTransaction = event; + SentryEvent? processedEvent = event; + final spanCountBeforeCallback = + event is SentryTransaction ? event.spans.length : 0; final beforeSend = _options.beforeSend; final beforeSendTransaction = _options.beforeSendTransaction; @@ -412,18 +468,18 @@ class SentryClient { try { if (event is SentryTransaction && beforeSendTransaction != null) { beforeSendName = 'beforeSendTransaction'; - final e = beforeSendTransaction(event); - if (e is Future) { - eventOrTransaction = await e; + final callbackResult = beforeSendTransaction(event); + if (callbackResult is Future) { + processedEvent = await callbackResult; } else { - eventOrTransaction = e; + processedEvent = callbackResult; } } else if (beforeSend != null) { - final e = beforeSend(event, hint); - if (e is Future) { - eventOrTransaction = await e; + final callbackResult = beforeSend(event, hint); + if (callbackResult is Future) { + processedEvent = await callbackResult; } else { - eventOrTransaction = e; + processedEvent = callbackResult; } } } catch (exception, stackTrace) { @@ -438,15 +494,30 @@ class SentryClient { } } - if (eventOrTransaction == null) { - _recordLostEvent(event, DiscardReason.beforeSend); + final discardReason = DiscardReason.beforeSend; + if (processedEvent == null) { + _options.recorder.recordLostEvent(discardReason, _getCategory(event)); + if (event is SentryTransaction) { + // We dropped the whole transaction, the dropped count includes all child spans + 1 root span + _options.recorder.recordLostEvent(discardReason, DataCategory.span, + count: spanCountBeforeCallback + 1); + } _options.logger( SentryLevel.debug, '${event.runtimeType} was dropped by $beforeSendName callback', ); + } else if (event is SentryTransaction && + processedEvent is SentryTransaction) { + // If beforeSend removed only some spans we still report them as dropped + final spanCountAfterCallback = processedEvent.spans.length; + final droppedSpanCount = spanCountBeforeCallback - spanCountAfterCallback; + if (droppedSpanCount > 0) { + _options.recorder.recordLostEvent(discardReason, DataCategory.span, + count: droppedSpanCount); + } } - return eventOrTransaction; + return processedEvent; } Future _runEventProcessors( @@ -455,6 +526,9 @@ class SentryClient { required List eventProcessors, }) async { SentryEvent? processedEvent = event; + int spanCountBeforeEventProcessors = + event is SentryTransaction ? event.spans.length : 0; + for (final processor in eventProcessors) { try { final e = processor.apply(processedEvent!, hint); @@ -474,12 +548,30 @@ class SentryClient { rethrow; } } + + final discardReason = DiscardReason.eventProcessor; if (processedEvent == null) { - _recordLostEvent(event, DiscardReason.eventProcessor); + _options.recorder.recordLostEvent(discardReason, _getCategory(event)); + if (event is SentryTransaction) { + // We dropped the whole transaction, the dropped count includes all child spans + 1 root span + _options.recorder.recordLostEvent(discardReason, DataCategory.span, + count: spanCountBeforeEventProcessors + 1); + } _options.logger(SentryLevel.debug, 'Event was dropped by a processor'); break; + } else if (event is SentryTransaction && + processedEvent is SentryTransaction) { + // If event processor removed only some spans we still report them as dropped + final spanCountAfterEventProcessors = processedEvent.spans.length; + final droppedSpanCount = + spanCountBeforeEventProcessors - spanCountAfterEventProcessors; + if (droppedSpanCount > 0) { + _options.recorder.recordLostEvent(discardReason, DataCategory.span, + count: droppedSpanCount); + } } } + return processedEvent; } @@ -490,14 +582,11 @@ class SentryClient { return false; } - void _recordLostEvent(SentryEvent event, DiscardReason reason) { - DataCategory category; + DataCategory _getCategory(SentryEvent event) { if (event is SentryTransaction) { - category = DataCategory.transaction; - } else { - category = DataCategory.error; + return DataCategory.transaction; } - _options.recorder.recordLostEvent(reason, category); + return DataCategory.error; } Future _attachClientReportsAndSend(SentryEnvelope envelope) { diff --git a/dart/lib/src/sentry_client_stub.dart b/dart/lib/src/sentry_client_stub.dart deleted file mode 100644 index e212b39a0d..0000000000 --- a/dart/lib/src/sentry_client_stub.dart +++ /dev/null @@ -1,11 +0,0 @@ -// Copyright 2017 The Chromium Authors. All rights reserved. -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file. - -import 'sentry_client.dart'; -import 'sentry_options.dart'; - -/// Implemented in `sentry_browser_client.dart` and `sentry_io_client.dart`. -SentryClient createSentryClient(SentryOptions options) => - throw UnsupportedError( - 'Cannot create a client without dart:html or dart:io.'); diff --git a/dart/lib/src/sentry_envelope.dart b/dart/lib/src/sentry_envelope.dart index 7835b7859e..fb7cd1543a 100644 --- a/dart/lib/src/sentry_envelope.dart +++ b/dart/lib/src/sentry_envelope.dart @@ -13,7 +13,8 @@ import 'sentry_user_feedback.dart'; /// Class representation of `Envelope` file. class SentryEnvelope { - SentryEnvelope(this.header, this.items); + SentryEnvelope(this.header, this.items, + {this.containsUnhandledException = false}); /// Header describing envelope content. final SentryEnvelopeHeader header; @@ -21,6 +22,10 @@ class SentryEnvelope { /// All items contained in the envelope. final List items; + /// Whether the envelope contains an unhandled exception. + /// This is used to determine if the native SDK should start a new session. + final bool containsUnhandledException; + /// Create a [SentryEnvelope] containing one [SentryEnvelopeItem] which holds the [SentryEvent] data. factory SentryEnvelope.fromEvent( SentryEvent event, @@ -29,6 +34,15 @@ class SentryEnvelope { SentryTraceContextHeader? traceContext, List? attachments, }) { + bool containsUnhandledException = false; + + if (event.exceptions != null && event.exceptions!.isNotEmpty) { + // Check all exceptions for any unhandled ones + containsUnhandledException = event.exceptions!.any((exception) { + return exception.mechanism?.handled == false; + }); + } + return SentryEnvelope( SentryEnvelopeHeader( event.eventId, @@ -41,6 +55,7 @@ class SentryEnvelope { if (attachments != null) ...attachments.map((e) => SentryEnvelopeItem.fromAttachment(e)) ], + containsUnhandledException: containsUnhandledException, ); } diff --git a/dart/lib/src/sentry_envelope_item.dart b/dart/lib/src/sentry_envelope_item.dart index 019e1e0a08..61463cdd8d 100644 --- a/dart/lib/src/sentry_envelope_item.dart +++ b/dart/lib/src/sentry_envelope_item.dart @@ -12,7 +12,10 @@ import 'sentry_user_feedback.dart'; /// Item holding header information and JSON encoded data. class SentryEnvelopeItem { - SentryEnvelopeItem(this.header, this.dataFactory); + /// The original, non-encoded object, used when direct access to the source data is needed. + Object? originalObject; + + SentryEnvelopeItem(this.header, this.dataFactory, {this.originalObject}); /// Creates a [SentryEnvelopeItem] which sends [SentryTransaction]. factory SentryEnvelopeItem.fromTransaction(SentryTransaction transaction) { @@ -24,7 +27,8 @@ class SentryEnvelopeItem { cachedItem.getDataLength, contentType: 'application/json', ); - return SentryEnvelopeItem(header, cachedItem.getData); + return SentryEnvelopeItem(header, cachedItem.getData, + originalObject: transaction); } factory SentryEnvelopeItem.fromAttachment(SentryAttachment attachment) { @@ -37,7 +41,8 @@ class SentryEnvelopeItem { fileName: attachment.filename, attachmentType: attachment.attachmentType, ); - return SentryEnvelopeItem(header, cachedItem.getData); + return SentryEnvelopeItem(header, cachedItem.getData, + originalObject: attachment); } /// Create a [SentryEnvelopeItem] which sends [SentryUserFeedback]. @@ -50,7 +55,8 @@ class SentryEnvelopeItem { cachedItem.getDataLength, contentType: 'application/json', ); - return SentryEnvelopeItem(header, cachedItem.getData); + return SentryEnvelopeItem(header, cachedItem.getData, + originalObject: feedback); } /// Create a [SentryEnvelopeItem] which holds the [SentryEvent] data. @@ -59,13 +65,13 @@ class SentryEnvelopeItem { _CachedItem(() async => utf8JsonEncoder.convert(event.toJson())); return SentryEnvelopeItem( - SentryEnvelopeItemHeader( - SentryItemType.event, - cachedItem.getDataLength, - contentType: 'application/json', - ), - cachedItem.getData, - ); + SentryEnvelopeItemHeader( + SentryItemType.event, + cachedItem.getDataLength, + contentType: 'application/json', + ), + cachedItem.getData, + originalObject: event); } /// Create a [SentryEnvelopeItem] which holds the [ClientReport] data. @@ -80,6 +86,7 @@ class SentryEnvelopeItem { contentType: 'application/json', ), cachedItem.getData, + originalObject: clientReport, ); } @@ -102,7 +109,8 @@ class SentryEnvelopeItem { cachedItem.getDataLength, contentType: 'application/octet-stream', ); - return SentryEnvelopeItem(header, cachedItem.getData); + return SentryEnvelopeItem(header, cachedItem.getData, + originalObject: buckets); } /// Header with info about type and length of data in bytes. @@ -124,6 +132,7 @@ class SentryEnvelopeItem { // TODO the data copy could be avoided - this would be most significant with attachments. return [...itemHeader, ...newLine, ...data]; } catch (e) { + // TODO rethrow in options.automatedTestMode (currently not available here to check) return []; } } diff --git a/dart/lib/src/sentry_exception_factory.dart b/dart/lib/src/sentry_exception_factory.dart index a8e1a80498..9ee2148c14 100644 --- a/dart/lib/src/sentry_exception_factory.dart +++ b/dart/lib/src/sentry_exception_factory.dart @@ -1,10 +1,9 @@ -import 'utils/stacktrace_utils.dart'; - -import 'recursive_exception_cause_extractor.dart'; import 'protocol.dart'; +import 'recursive_exception_cause_extractor.dart'; import 'sentry_options.dart'; import 'sentry_stack_trace_factory.dart'; import 'throwable_mechanism.dart'; +import 'utils/stacktrace_utils.dart'; /// class to convert Dart Error and exception to SentryException class SentryExceptionFactory { @@ -62,10 +61,22 @@ class SentryExceptionFactory { final stackTraceString = stackTrace.toString(); final value = throwableString.replaceAll(stackTraceString, '').trim(); + String errorTypeName = throwable.runtimeType.toString(); + + if (_options.enableExceptionTypeIdentification) { + for (final errorTypeIdentifier in _options.exceptionTypeIdentifiers) { + final identifiedErrorType = errorTypeIdentifier.identifyType(throwable); + if (identifiedErrorType != null) { + errorTypeName = identifiedErrorType; + break; + } + } + } + // if --obfuscate feature is enabled, 'type' won't be human readable. // https://flutter.dev/docs/deployment/obfuscate#caveat return SentryException( - type: (throwable.runtimeType).toString(), + type: errorTypeName, value: value.isNotEmpty ? value : null, mechanism: mechanism, stackTrace: sentryStackTrace, diff --git a/dart/lib/src/sentry_options.dart b/dart/lib/src/sentry_options.dart index 956f81c056..c9a9511c29 100644 --- a/dart/lib/src/sentry_options.dart +++ b/dart/lib/src/sentry_options.dart @@ -1,17 +1,17 @@ import 'dart:async'; import 'dart:developer'; -import 'package:meta/meta.dart'; import 'package:http/http.dart'; +import 'package:meta/meta.dart'; import '../sentry.dart'; import 'client_reports/client_report_recorder.dart'; import 'client_reports/noop_client_report_recorder.dart'; -import 'sentry_exception_factory.dart'; -import 'sentry_stack_trace_factory.dart'; import 'diagnostic_logger.dart'; import 'environment/environment_variables.dart'; import 'noop_client.dart'; +import 'sentry_exception_factory.dart'; +import 'sentry_stack_trace_factory.dart'; import 'transport/noop_transport.dart'; import 'version.dart'; @@ -184,6 +184,16 @@ class SentryOptions { /// sent. Events are picked randomly. Default is null (disabled) double? sampleRate; + /// The ignoreErrors tells the SDK which errors should be not sent to the sentry server. + /// If an null or an empty list is used, the SDK will send all transactions. + /// To use regex add the `^` and the `$` to the string. + List ignoreErrors = []; + + /// The ignoreTransactions tells the SDK which transactions should be not sent to the sentry server. + /// If null or an empty list is used, the SDK will send all transactions. + /// To use regex add the `^` and the `$` to the string. + List ignoreTransactions = []; + final List _inAppExcludes = []; /// A list of string prefixes of packages names that do not belong to the app, but rather third-party @@ -334,6 +344,32 @@ class SentryOptions { _scopeObservers.add(scopeObserver); } + final List _ignoredExceptionsForType = []; + + /// Ignored exception types. + List get ignoredExceptionsForType => _ignoredExceptionsForType; + + /// Adds exception type to the list of ignored exceptions. + void addExceptionFilterForType(Type exceptionType) { + _ignoredExceptionsForType.add(exceptionType); + } + + /// Check if [ignoredExceptionsForType] contains an exception. + bool containsIgnoredExceptionForType(dynamic exception) { + return exception != null && + _ignoredExceptionsForType.contains(exception.runtimeType); + } + + /// Enables Dart symbolication for stack traces in Flutter. + /// + /// If true, the SDK will attempt to symbolicate Dart stack traces when + /// [Sentry.init] is used instead of `SentryFlutter.init`. This is useful + /// when native debug images are not available. + /// + /// Automatically set to `false` when using `SentryFlutter.init`, as it uses + /// native SDKs for setting up symbolication on iOS, macOS, and Android. + bool enableDartSymbolication = true; + @internal late ClientReportRecorder recorder = NoOpClientReportRecorder(); @@ -382,6 +418,8 @@ class SentryOptions { /// Enables generation of transactions and propagation of trace data. If set /// to null, tracing might be enabled if [tracesSampleRate] or [tracesSampler] /// are set. + @Deprecated( + 'Use either tracesSampleRate or tracesSampler instead. This will be removed in v9') bool? enableTracing; /// Enables sending developer metrics to Sentry. @@ -436,6 +474,33 @@ class SentryOptions { /// Settings this to `false` will set the `level` to [SentryLevel.error]. bool markAutomaticallyCollectedErrorsAsFatal = true; + /// Enables identification of exception types in obfuscated builds. + /// When true, the SDK will attempt to identify common exception types + /// to improve readability of obfuscated issue titles. + /// + /// If you already have events with obfuscated issue titles this will change grouping. + /// + /// Default: `true` + bool enableExceptionTypeIdentification = true; + + final List _exceptionTypeIdentifiers = []; + + List get exceptionTypeIdentifiers => + List.unmodifiable(_exceptionTypeIdentifiers); + + void addExceptionTypeIdentifierByIndex( + int index, ExceptionTypeIdentifier exceptionTypeIdentifier) { + _exceptionTypeIdentifiers.insert( + index, exceptionTypeIdentifier.withCache()); + } + + /// Adds an exception type identifier to the beginning of the list. + /// This ensures it is processed first and takes precedence over existing identifiers. + void prependExceptionTypeIdentifier( + ExceptionTypeIdentifier exceptionTypeIdentifier) { + addExceptionTypeIdentifierByIndex(0, exceptionTypeIdentifier); + } + /// The Spotlight configuration. /// Disabled by default. /// ```dart @@ -443,6 +508,19 @@ class SentryOptions { /// ``` Spotlight spotlight = Spotlight(enabled: false); + /// Configure a proxy to use for SDK API calls. + /// + /// On io platforms without native SDKs (dart, linux, windows), this will use + /// an 'IOClient' with inner 'HTTPClient' for http communication. + /// A http proxy will be set in returned for 'HttpClient.findProxy' in the + /// form 'PROXY :'. + /// When setting 'user' and 'pass', the 'HttpClient.addProxyCredentials' + /// method will be called with empty 'realm'. + /// + /// On Android & iOS, the proxy settings are handled by the native SDK. + /// iOS only supports http proxies, while macOS also supports socks. + SentryProxy? proxy; + SentryOptions({this.dsn, PlatformChecker? checker}) { if (checker != null) { platformChecker = checker; @@ -494,6 +572,7 @@ class SentryOptions { /// Returns if tracing should be enabled. If tracing is disabled, starting transactions returns /// [NoOpSentrySpan]. bool isTracingEnabled() { + // ignore: deprecated_member_use_from_same_package final enable = enableTracing; if (enable != null) { return enable; @@ -501,6 +580,14 @@ class SentryOptions { return tracesSampleRate != null || tracesSampler != null; } + List get performanceCollectors => + _performanceCollectors; + final List _performanceCollectors = []; + + void addPerformanceCollector(PerformanceCollector collector) { + _performanceCollectors.add(collector); + } + @internal late SentryExceptionFactory exceptionFactory = SentryExceptionFactory(this); diff --git a/dart/lib/src/sentry_span_interface.dart b/dart/lib/src/sentry_span_interface.dart index 1d142c45b9..979822c2ac 100644 --- a/dart/lib/src/sentry_span_interface.dart +++ b/dart/lib/src/sentry_span_interface.dart @@ -71,7 +71,7 @@ abstract class ISentrySpan { /// Returns the trace information that could be sent as a sentry-trace header. SentryTraceHeader toSentryTrace(); - /// Set observed measurement for this transaction. + /// Set observed measurement for this span or transaction. void setMeasurement( String name, num value, { diff --git a/dart/lib/src/sentry_stack_trace_factory.dart b/dart/lib/src/sentry_stack_trace_factory.dart index 9d4a42bffc..7aab228a3e 100644 --- a/dart/lib/src/sentry_stack_trace_factory.dart +++ b/dart/lib/src/sentry_stack_trace_factory.dart @@ -1,7 +1,7 @@ import 'package:meta/meta.dart'; import 'package:stack_trace/stack_trace.dart'; -import 'noop_origin.dart' if (dart.library.html) 'origin.dart'; +import 'origin.dart'; import 'protocol.dart'; import 'sentry_options.dart'; @@ -103,6 +103,7 @@ class SentryStackTraceFactory { // least we get an indication something's wrong and are able to fix it. } + final platform = _options.platformChecker.isWeb ? 'javascript' : 'dart'; final fileName = frame.uri.pathSegments.isNotEmpty ? frame.uri.pathSegments.last : null; final abs = '$eventOrigin${_absolutePathForCrashReport(frame)}'; @@ -114,6 +115,7 @@ class SentryStackTraceFactory { inApp: _isInApp(frame), fileName: fileName, package: frame.package, + platform: platform, ); final line = frame.line; diff --git a/dart/lib/src/sentry_trace_context_header.dart b/dart/lib/src/sentry_trace_context_header.dart index bcb1d0b1bb..e17b2b91f8 100644 --- a/dart/lib/src/sentry_trace_context_header.dart +++ b/dart/lib/src/sentry_trace_context_header.dart @@ -1,4 +1,7 @@ +import 'package:meta/meta.dart'; + import 'protocol/sentry_id.dart'; +import 'protocol/access_aware_map.dart'; import 'sentry_baggage.dart'; import 'sentry_options.dart'; @@ -13,6 +16,8 @@ class SentryTraceContextHeader { this.transaction, this.sampleRate, this.sampled, + this.unknown, + this.replayId, }); final SentryId traceId; @@ -20,13 +25,22 @@ class SentryTraceContextHeader { final String? release; final String? environment; final String? userId; + @Deprecated( + 'Will be removed in v9 since functionality has been removed from Sentry') final String? userSegment; final String? transaction; final String? sampleRate; final String? sampled; + @internal + final Map? unknown; + + @internal + SentryId? replayId; + /// Deserializes a [SentryTraceContextHeader] from JSON [Map]. - factory SentryTraceContextHeader.fromJson(Map json) { + factory SentryTraceContextHeader.fromJson(Map data) { + final json = AccessAwareMap(data); return SentryTraceContextHeader( SentryId.fromId(json['trace_id']), json['public_key'], @@ -37,21 +51,27 @@ class SentryTraceContextHeader { transaction: json['transaction'], sampleRate: json['sample_rate'], sampled: json['sampled'], + replayId: + json['replay_id'] == null ? null : SentryId.fromId(json['replay_id']), + unknown: json.notAccessed(), ); } /// Produces a [Map] that can be serialized to JSON. Map toJson() { return { + ...?unknown, 'trace_id': traceId.toString(), 'public_key': publicKey, if (release != null) 'release': release, if (environment != null) 'environment': environment, if (userId != null) 'user_id': userId, + // ignore: deprecated_member_use_from_same_package if (userSegment != null) 'user_segment': userSegment, if (transaction != null) 'transaction': transaction, if (sampleRate != null) 'sample_rate': sampleRate, if (sampled != null) 'sampled': sampled, + if (replayId != null) 'replay_id': replayId.toString(), }; } @@ -71,7 +91,9 @@ class SentryTraceContextHeader { if (userId != null) { baggage.setUserId(userId!); } + // ignore: deprecated_member_use_from_same_package if (userSegment != null) { + // ignore: deprecated_member_use_from_same_package baggage.setUserSegment(userSegment!); } if (transaction != null) { @@ -83,6 +105,9 @@ class SentryTraceContextHeader { if (sampled != null) { baggage.setSampled(sampled!); } + if (replayId != null) { + baggage.setReplayId(replayId.toString()); + } return baggage; } @@ -92,6 +117,7 @@ class SentryTraceContextHeader { baggage.get('sentry-public_key').toString(), release: baggage.get('sentry-release'), environment: baggage.get('sentry-environment'), + replayId: baggage.getReplayId(), ); } } diff --git a/dart/lib/src/sentry_tracer.dart b/dart/lib/src/sentry_tracer.dart index 2326db0716..1507143d69 100644 --- a/dart/lib/src/sentry_tracer.dart +++ b/dart/lib/src/sentry_tracer.dart @@ -17,7 +17,9 @@ class SentryTracer extends ISentrySpan { late final SentrySpan _rootSpan; final List _children = []; final Map _extra = {}; + final Map _measurements = {}; + Map get measurements => _measurements; Timer? _autoFinishAfterTimer; Duration? _autoFinishAfter; @@ -69,6 +71,7 @@ class SentryTracer extends ISentrySpan { _hub, samplingDecision: transactionContext.samplingDecision, startTimestamp: startTimestamp, + isRootSpan: true, ); _waitForChildren = waitForChildren; _autoFinishAfter = autoFinishAfter; @@ -80,6 +83,12 @@ class SentryTracer extends ISentrySpan { SentryTransactionNameSource.custom; _trimEnd = trimEnd; _onFinish = onFinish; + + for (final collector in _hub.options.performanceCollectors) { + if (collector is PerformanceContinuousCollector) { + collector.onSpanStarted(_rootSpan); + } + } } @override @@ -256,6 +265,12 @@ class SentryTracer extends ISentrySpan { _children.add(child); + for (final collector in _hub.options.performanceCollectors) { + if (collector is PerformanceContinuousCollector) { + collector.onSpanStarted(child); + } + } + return child; } @@ -307,10 +322,6 @@ class SentryTracer extends ISentrySpan { @override SentryTraceHeader toSentryTrace() => _rootSpan.toSentryTrace(); - @visibleForTesting - Map get measurements => - Map.unmodifiable(_measurements); - bool _haveAllChildrenFinished() { for (final child in children) { if (!child.finished) { @@ -328,10 +339,19 @@ class SentryTracer extends ISentrySpan { @override void setMeasurement(String name, num value, {SentryMeasurementUnit? unit}) { if (finished) { + _hub.options.logger(SentryLevel.debug, + "The tracer is already finished. Measurement $name cannot be set"); return; } - final measurement = SentryMeasurement(name, value, unit: unit); - _measurements[name] = measurement; + _measurements[name] = SentryMeasurement(name, value, unit: unit); + } + + void setMeasurementFromChild(String name, num value, + {SentryMeasurementUnit? unit}) { + // We don't want to overwrite span measurement, if it comes from a child. + if (!_measurements.containsKey(name)) { + setMeasurement(name, value, unit: unit); + } } @override @@ -361,6 +381,7 @@ class SentryTracer extends ISentrySpan { release: _hub.options.release, environment: _hub.options.environment, userId: null, // because of PII not sending it for now + // ignore: deprecated_member_use_from_same_package userSegment: user?.segment, transaction: _isHighQualityTransactionName(transactionNameSource) ? name : null, diff --git a/dart/lib/src/sentry_traces_sampler.dart b/dart/lib/src/sentry_traces_sampler.dart index b1668084c9..b842514481 100644 --- a/dart/lib/src/sentry_traces_sampler.dart +++ b/dart/lib/src/sentry_traces_sampler.dart @@ -59,6 +59,7 @@ class SentryTracesSampler { double? optionsRate = _options.tracesSampleRate; double? defaultRate = + // ignore: deprecated_member_use_from_same_package _options.enableTracing == true ? _defaultSampleRate : null; double? optionsOrDefaultRate = optionsRate ?? defaultRate; diff --git a/dart/lib/src/sentry_user_feedback.dart b/dart/lib/src/sentry_user_feedback.dart index 7ba8c0d6f1..055199ed61 100644 --- a/dart/lib/src/sentry_user_feedback.dart +++ b/dart/lib/src/sentry_user_feedback.dart @@ -1,4 +1,7 @@ +import 'package:meta/meta.dart'; + import 'protocol.dart'; +import 'protocol/access_aware_map.dart'; class SentryUserFeedback { SentryUserFeedback({ @@ -6,17 +9,20 @@ class SentryUserFeedback { this.name, this.email, this.comments, + this.unknown, }) : assert(eventId != SentryId.empty() && (name?.isNotEmpty == true || email?.isNotEmpty == true || comments?.isNotEmpty == true)); - factory SentryUserFeedback.fromJson(Map json) { + factory SentryUserFeedback.fromJson(Map data) { + final json = AccessAwareMap(data); return SentryUserFeedback( eventId: SentryId.fromId(json['event_id']), name: json['name'], email: json['email'], comments: json['comments'], + unknown: json.notAccessed(), ); } @@ -32,8 +38,12 @@ class SentryUserFeedback { /// Recommended: Comments of the user about what happened. final String? comments; + @internal + final Map? unknown; + Map toJson() { - return { + return { + ...?unknown, 'event_id': eventId.toString(), if (name != null) 'name': name, if (email != null) 'email': email, @@ -52,6 +62,7 @@ class SentryUserFeedback { name: name ?? this.name, email: email ?? this.email, comments: comments ?? this.comments, + unknown: unknown, ); } } diff --git a/dart/lib/src/transport/data_category.dart b/dart/lib/src/transport/data_category.dart index ecdb1c9500..cbfb26ea58 100644 --- a/dart/lib/src/transport/data_category.dart +++ b/dart/lib/src/transport/data_category.dart @@ -5,8 +5,28 @@ enum DataCategory { error, session, transaction, + span, attachment, security, metricBucket, - unknown + unknown; + + static DataCategory fromItemType(String itemType) { + switch (itemType) { + case 'event': + return DataCategory.error; + case 'session': + return DataCategory.session; + case 'attachment': + return DataCategory.attachment; + case 'transaction': + return DataCategory.transaction; + // The envelope item type used for metrics is statsd, + // whereas the client report category is metric_bucket + case 'statsd': + return DataCategory.metricBucket; + default: + return DataCategory.unknown; + } + } } diff --git a/dart/lib/src/transport/http_transport.dart b/dart/lib/src/transport/http_transport.dart index 90dd8949ce..16cfed654c 100644 --- a/dart/lib/src/transport/http_transport.dart +++ b/dart/lib/src/transport/http_transport.dart @@ -11,6 +11,8 @@ import '../sentry_options.dart'; import '../sentry_envelope.dart'; import 'transport.dart'; import 'rate_limiter.dart'; +import '../http_client/client_provider.dart' + if (dart.library.io) '../http_client/io_client_provider.dart'; /// A transport is in charge of sending the event to the Sentry server. class HttpTransport implements Transport { @@ -22,9 +24,8 @@ class HttpTransport implements Transport { factory HttpTransport(SentryOptions options, RateLimiter rateLimiter) { if (options.httpClient is NoOpClient) { - options.httpClient = Client(); + options.httpClient = getClientProvider().getClient(options); } - return HttpTransport._(options, rateLimiter); } @@ -63,6 +64,9 @@ class HttpTransport implements Transport { return eventId != null ? SentryId.fromId(eventId) : null; } catch (e) { _options.logger(SentryLevel.error, 'Error parsing response: $e'); + if (_options.automatedTestMode) { + rethrow; + } return null; } } diff --git a/dart/lib/src/transport/rate_limiter.dart b/dart/lib/src/transport/rate_limiter.dart index ef9b168edd..fa0f7a9018 100644 --- a/dart/lib/src/transport/rate_limiter.dart +++ b/dart/lib/src/transport/rate_limiter.dart @@ -1,7 +1,5 @@ +import '../../sentry.dart'; import '../transport/rate_limit_parser.dart'; -import '../sentry_options.dart'; -import '../sentry_envelope.dart'; -import '../sentry_envelope_item.dart'; import 'rate_limit.dart'; import 'data_category.dart'; import '../client_reports/discard_reason.dart'; @@ -25,8 +23,17 @@ class RateLimiter { _options.recorder.recordLostEvent( DiscardReason.rateLimitBackoff, - _categoryFromItemType(item.header.type), + DataCategory.fromItemType(item.header.type), ); + + final originalObject = item.originalObject; + if (originalObject is SentryTransaction) { + _options.recorder.recordLostEvent( + DiscardReason.rateLimitBackoff, + DataCategory.span, + count: originalObject.spans.length + 1, + ); + } } } @@ -80,7 +87,7 @@ class RateLimiter { // Private bool _isRetryAfter(String itemType) { - final dataCategory = _categoryFromItemType(itemType); + final dataCategory = DataCategory.fromItemType(itemType); final currentDate = DateTime.fromMillisecondsSinceEpoch( _options.clock().millisecondsSinceEpoch); @@ -106,25 +113,6 @@ class RateLimiter { return false; } - DataCategory _categoryFromItemType(String itemType) { - switch (itemType) { - case 'event': - return DataCategory.error; - case 'session': - return DataCategory.session; - case 'attachment': - return DataCategory.attachment; - case 'transaction': - return DataCategory.transaction; - // The envelope item type used for metrics is statsd, - // whereas the client report category is metric_bucket - case 'statsd': - return DataCategory.metricBucket; - default: - return DataCategory.unknown; - } - } - void _applyRetryAfterOnlyIfLonger(DataCategory dataCategory, DateTime date) { final oldDate = _rateLimitedUntil[dataCategory]; diff --git a/dart/lib/src/transport/spotlight_http_transport.dart b/dart/lib/src/transport/spotlight_http_transport.dart index f51e77d478..0dce696863 100644 --- a/dart/lib/src/transport/spotlight_http_transport.dart +++ b/dart/lib/src/transport/spotlight_http_transport.dart @@ -4,6 +4,8 @@ import 'http_transport_request_handler.dart'; import '../../sentry.dart'; import '../noop_client.dart'; +import '../http_client/client_provider.dart' + if (dart.library.io) '../http_client/io_client_provider.dart'; /// Spotlight HTTP transport decorator that sends Sentry envelopes to both Sentry and Spotlight. class SpotlightHttpTransport extends Transport { @@ -13,7 +15,7 @@ class SpotlightHttpTransport extends Transport { factory SpotlightHttpTransport(SentryOptions options, Transport transport) { if (options.httpClient is NoOpClient) { - options.httpClient = Client(); + options.httpClient = getClientProvider().getClient(options); } return SpotlightHttpTransport._(options, transport); } @@ -29,6 +31,9 @@ class SpotlightHttpTransport extends Transport { } catch (e) { _options.logger( SentryLevel.warning, 'Failed to send envelope to Spotlight: $e'); + if (_options.automatedTestMode) { + rethrow; + } } return _transport.send(envelope); } @@ -36,10 +41,6 @@ class SpotlightHttpTransport extends Transport { Future _sendToSpotlight(SentryEnvelope envelope) async { envelope.header.sentAt = _options.clock(); - // Screenshots do not work currently https://github.com/getsentry/spotlight/issues/274 - envelope.items - .removeWhere((element) => element.header.contentType == 'image/png'); - final spotlightRequest = await _requestHandler.createRequest(envelope); final response = await _options.httpClient diff --git a/dart/lib/src/utils/isolate_utils.dart b/dart/lib/src/utils/isolate_utils.dart index 3e9c4b20bc..6575965ff9 100644 --- a/dart/lib/src/utils/isolate_utils.dart +++ b/dart/lib/src/utils/isolate_utils.dart @@ -1,7 +1,8 @@ import 'package:meta/meta.dart'; import '_io_get_isolate_name.dart' - if (dart.library.html) '_web_get_isolate_name.dart' as isolate_getter; + if (dart.library.html) '_web_get_isolate_name.dart' + if (dart.library.js_interop) '_web_get_isolate_name.dart' as isolate_getter; @internal String? getIsolateName() => isolate_getter.getIsolateName(); diff --git a/dart/lib/src/utils/regex_utils.dart b/dart/lib/src/utils/regex_utils.dart new file mode 100644 index 0000000000..ba64f7504e --- /dev/null +++ b/dart/lib/src/utils/regex_utils.dart @@ -0,0 +1,9 @@ +import 'package:meta/meta.dart'; + +@internal +bool isMatchingRegexPattern(String value, List regexPattern, + {bool caseSensitive = false}) { + final combinedRegexPattern = regexPattern.join('|'); + final regExp = RegExp(combinedRegexPattern, caseSensitive: caseSensitive); + return regExp.hasMatch(value); +} diff --git a/dart/lib/src/utils/transport_utils.dart b/dart/lib/src/utils/transport_utils.dart index fa2f20096a..399809b179 100644 --- a/dart/lib/src/utils/transport_utils.dart +++ b/dart/lib/src/utils/transport_utils.dart @@ -19,8 +19,21 @@ class TransportUtils { } if (response.statusCode >= 400 && response.statusCode != 429) { - options.recorder - .recordLostEvent(DiscardReason.networkError, DataCategory.error); + for (final item in envelope.items) { + options.recorder.recordLostEvent( + DiscardReason.networkError, + DataCategory.fromItemType(item.header.type), + ); + + final originalObject = item.originalObject; + if (originalObject is SentryTransaction) { + options.recorder.recordLostEvent( + DiscardReason.networkError, + DataCategory.span, + count: originalObject.spans.length + 1, + ); + } + } } } else { options.logger( diff --git a/dart/lib/src/version.dart b/dart/lib/src/version.dart index 961566fdc3..b7cc3a7055 100644 --- a/dart/lib/src/version.dart +++ b/dart/lib/src/version.dart @@ -9,7 +9,7 @@ library version; /// The SDK version reported to Sentry.io in the submitted events. -const String sdkVersion = '8.3.0'; +const String sdkVersion = '8.9.0'; String sdkName(bool isWeb) => isWeb ? _browserSdkName : _ioSdkName; diff --git a/dart/pubspec.yaml b/dart/pubspec.yaml index 41aa1b7d0c..6141a2eba6 100644 --- a/dart/pubspec.yaml +++ b/dart/pubspec.yaml @@ -1,5 +1,5 @@ name: sentry -version: 8.3.0 +version: 8.9.0 description: > A crash reporting library for Dart that sends crash reports to Sentry.io. This library supports Dart VM and Web. For Flutter consider sentry_flutter instead. @@ -26,11 +26,12 @@ dependencies: uuid: '>=3.0.0 <5.0.0' dev_dependencies: - build_runner: ^2.4.2 + build_runner: ^2.3.0 mockito: ^5.1.0 - lints: ^4.0.0 + lints: '>=2.0.0 <5.0.0' test: ^1.21.1 yaml: ^3.1.0 # needed for version match (code and pubspec) collection: ^1.16.0 coverage: ^1.3.0 intl: '>=0.17.0 <1.0.0' + version: ^3.0.2 diff --git a/dart/test/contexts_test.dart b/dart/test/contexts_test.dart index 7cce3a79ea..31cc55b7cd 100644 --- a/dart/test/contexts_test.dart +++ b/dart/test/contexts_test.dart @@ -173,6 +173,7 @@ void main() { MapEquality().equals( contexts.app!.toJson(), { + 'app_id': 'D533244D-985D-3996-9FC2-9FA353D28586', 'app_name': 'sentry_flutter_example', 'app_version': '0.1.2', 'app_identifier': 'io.sentry.flutter.example', @@ -224,7 +225,6 @@ const jsonContexts = ''' "memory_size": 17179869184, "storage_size": 1023683072000, "boot_time": "2020-11-18T13:28:11Z", - "timezone": "GMT+1", "usable_memory": 17114120192 }, "app": { diff --git a/dart/test/debug_image_extractor_test.dart b/dart/test/debug_image_extractor_test.dart new file mode 100644 index 0000000000..7a0ad7d12f --- /dev/null +++ b/dart/test/debug_image_extractor_test.dart @@ -0,0 +1,118 @@ +import 'package:test/test.dart'; +import 'package:sentry/src/debug_image_extractor.dart'; + +import 'mocks/mock_platform.dart'; +import 'mocks/mock_platform_checker.dart'; +import 'test_utils.dart'; + +void main() { + group(DebugImageExtractor, () { + late Fixture fixture; + + setUp(() { + fixture = Fixture(); + }); + + test('returns null for invalid stack trace', () { + final stackTrace = 'Invalid stack trace'; + final extractor = fixture.getSut(platform: MockPlatform.android()); + final debugImage = extractor.extractFrom(stackTrace); + + expect(debugImage, isNull); + }); + + test('extracts correct debug ID for Android with short debugId', () { + final stackTrace = ''' +*** *** *** *** *** *** *** *** *** *** *** *** *** *** *** *** +build_id: 'b680cb890f9e3c12a24b172d050dec73' +isolate_dso_base: 20000000 +'''; + final extractor = fixture.getSut(platform: MockPlatform.android()); + final debugImage = extractor.extractFrom(stackTrace); + + expect( + debugImage?.debugId, equals('89cb80b6-9e0f-123c-a24b-172d050dec73')); + }); + + test('extracts correct debug ID for Android with long debugId', () { + final stackTrace = ''' +*** *** *** *** *** *** *** *** *** *** *** *** *** *** *** *** +build_id: 'f1c3bcc0279865fe3058404b2831d9e64135386c' +isolate_dso_base: 30000000 +'''; + final extractor = fixture.getSut(platform: MockPlatform.android()); + final debugImage = extractor.extractFrom(stackTrace); + + expect( + debugImage?.debugId, equals('c0bcc3f1-9827-fe65-3058-404b2831d9e6')); + }); + + test('extracts correct debug ID for iOS', () { + final stackTrace = ''' +*** *** *** *** *** *** *** *** *** *** *** *** *** *** *** *** +build_id: 'b680cb890f9e3c12a24b172d050dec73' +isolate_dso_base: 30000000 +'''; + final extractor = fixture.getSut(platform: MockPlatform.iOS()); + final debugImage = extractor.extractFrom(stackTrace); + + expect( + debugImage?.debugId, equals('b680cb89-0f9e-3c12-a24b-172d050dec73')); + expect(debugImage?.codeId, isNull); + }); + + test('sets correct type based on platform', () { + final stackTrace = ''' +*** *** *** *** *** *** *** *** *** *** *** *** *** *** *** *** +build_id: 'b680cb890f9e3c12a24b172d050dec73' +isolate_dso_base: 40000000 +'''; + final androidExtractor = fixture.getSut(platform: MockPlatform.android()); + final iosExtractor = fixture.getSut(platform: MockPlatform.iOS()); + + final androidDebugImage = androidExtractor.extractFrom(stackTrace); + final iosDebugImage = iosExtractor.extractFrom(stackTrace); + + expect(androidDebugImage?.type, equals('elf')); + expect(iosDebugImage?.type, equals('macho')); + }); + + test('debug image is null on unsupported platforms', () { + final stackTrace = ''' +*** *** *** *** *** *** *** *** *** *** *** *** *** *** *** *** +build_id: 'b680cb890f9e3c12a24b172d050dec73' +isolate_dso_base: 40000000 +'''; + final extractor = fixture.getSut(platform: MockPlatform.linux()); + + final debugImage = extractor.extractFrom(stackTrace); + + expect(debugImage, isNull); + }); + + test('debugImage is cached after first extraction', () { + final stackTrace = ''' +*** *** *** *** *** *** *** *** *** *** *** *** *** *** *** *** +build_id: 'b680cb890f9e3c12a24b172d050dec73' +isolate_dso_base: 10000000 +'''; + final extractor = fixture.getSut(platform: MockPlatform.android()); + + // First extraction + final debugImage1 = extractor.extractFrom(stackTrace); + expect(debugImage1, isNotNull); + expect(extractor.debugImageForTesting, equals(debugImage1)); + + // Second extraction + final debugImage2 = extractor.extractFrom(stackTrace); + expect(debugImage2, equals(debugImage1)); + }); + }); +} + +class Fixture { + DebugImageExtractor getSut({required MockPlatform platform}) { + final options = defaultTestOptions(MockPlatformChecker(platform: platform)); + return DebugImageExtractor(options); + } +} diff --git a/dart/test/diagnostic_logger_test.dart b/dart/test/diagnostic_logger_test.dart index ec53421e33..f33baa1643 100644 --- a/dart/test/diagnostic_logger_test.dart +++ b/dart/test/diagnostic_logger_test.dart @@ -2,6 +2,8 @@ import 'package:sentry/sentry.dart'; import 'package:sentry/src/diagnostic_logger.dart'; import 'package:test/test.dart'; +import 'test_utils.dart'; + void main() { late Fixture fixture; @@ -44,7 +46,7 @@ void main() { } class Fixture { - var options = SentryOptions(); + var options = defaultTestOptions(); Object? loggedMessage; diff --git a/dart/test/environment_test.dart b/dart/test/environment_test.dart index b16225d1dd..d955f931e2 100644 --- a/dart/test/environment_test.dart +++ b/dart/test/environment_test.dart @@ -3,6 +3,7 @@ import 'package:test/test.dart'; import 'mocks.dart'; import 'mocks/mock_environment_variables.dart'; +import 'test_utils.dart'; void main() { // See https://docs.sentry.io/platforms/dart/configuration/options/ @@ -13,7 +14,7 @@ void main() { }); test('SentryOptions are not overriden by environment', () async { - final options = SentryOptions(dsn: fakeDsn); + final options = defaultTestOptions(); options.release = 'release-1.2.3'; options.dist = 'foo'; options.environment = 'prod'; @@ -23,28 +24,26 @@ void main() { release: 'release-9.8.7', dist: 'bar', ); - options.automatedTestMode = true; await Sentry.init( (options) => options, options: options, ); - expect(options.dsn, fakeDsn); + expect(options.dsn, testDsn); expect(options.environment, 'prod'); expect(options.release, 'release-1.2.3'); expect(options.dist, 'foo'); }); test('SentryOptions are overriden by environment', () async { - final options = SentryOptions(); + final options = defaultTestOptions()..dsn = null; options.environmentVariables = MockEnvironmentVariables( dsn: fakeDsn, environment: 'staging', release: 'release-9.8.7', dist: 'bar', ); - options.automatedTestMode = true; await Sentry.init( (options) => options, diff --git a/dart/test/event_processor/deduplication_event_processor_test.dart b/dart/test/event_processor/deduplication_event_processor_test.dart index 3e17fe4065..de576bafd4 100644 --- a/dart/test/event_processor/deduplication_event_processor_test.dart +++ b/dart/test/event_processor/deduplication_event_processor_test.dart @@ -6,6 +6,7 @@ import 'package:test/test.dart'; import '../mocks.dart'; import '../mocks/mock_hub.dart'; import '../mocks/mock_transport.dart'; +import '../test_utils.dart'; void main() { group('$DeduplicationEventProcessor', () { @@ -77,14 +78,13 @@ void main() { final transport = MockTransport(); - final options = SentryOptions(dsn: fakeDsn)..automatedTestMode = true; await Sentry.init( (options) { options.dsn = fakeDsn; options.transport = transport; options.enableDeduplication = true; }, - options: options, + options: defaultTestOptions(), ); // The test doesn't work if `outerTestMethod` is passed as @@ -114,7 +114,7 @@ class Fixture { DeduplicationEventProcessor getSut(bool enabled, [int? maxDeduplicationItems]) { - final options = SentryOptions(dsn: fakeDsn) + final options = defaultTestOptions() ..enableDeduplication = enabled ..maxDeduplicationItems = maxDeduplicationItems ?? 5; diff --git a/dart/test/event_processor/enricher/io_enricher_test.dart b/dart/test/event_processor/enricher/io_enricher_test.dart index bdc6da2e4e..a48c8dfed5 100644 --- a/dart/test/event_processor/enricher/io_enricher_test.dart +++ b/dart/test/event_processor/enricher/io_enricher_test.dart @@ -1,12 +1,15 @@ @TestOn('vm') library dart_test; +import 'dart:io'; + import 'package:sentry/sentry.dart'; import 'package:sentry/src/event_processor/enricher/io_enricher_event_processor.dart'; import 'package:test/test.dart'; import '../../mocks.dart'; import '../../mocks/mock_platform_checker.dart'; +import '../../test_utils.dart'; void main() { group('io_enricher', () { @@ -25,6 +28,8 @@ void main() { .firstWhere((element) => element.name == 'Dart'); expect(dartRuntime?.name, 'Dart'); expect(dartRuntime?.rawDescription, isNotNull); + expect(dartRuntime!.version.toString(), isNot(Platform.version)); + expect(Platform.version, contains(dartRuntime.version.toString())); }); test('does add to existing runtimes', () { @@ -160,20 +165,17 @@ void main() { }); test('$IoEnricherEventProcessor gets added on init', () async { - final options = SentryOptions(dsn: fakeDsn)..automatedTestMode = true; - late SentryOptions configuredOptions; + final options = defaultTestOptions(); await Sentry.init( (options) { options.dsn = fakeDsn; - configuredOptions = options; }, options: options, ); await Sentry.close(); - final ioEnricherCount = configuredOptions.eventProcessors - .whereType() - .length; + final ioEnricherCount = + options.eventProcessors.whereType().length; expect(ioEnricherCount, 1); }); }); @@ -184,10 +186,8 @@ class Fixture { bool hasNativeIntegration = false, bool includePii = false, }) { - final options = SentryOptions( - dsn: fakeDsn, - checker: - MockPlatformChecker(hasNativeIntegration: hasNativeIntegration)) + final options = defaultTestOptions( + MockPlatformChecker(hasNativeIntegration: hasNativeIntegration)) ..sendDefaultPii = includePii; return IoEnricherEventProcessor(options); diff --git a/dart/test/event_processor/enricher/io_platform_memory_test.dart b/dart/test/event_processor/enricher/io_platform_memory_test.dart new file mode 100644 index 0000000000..ce2c8474f4 --- /dev/null +++ b/dart/test/event_processor/enricher/io_platform_memory_test.dart @@ -0,0 +1,61 @@ +@TestOn('vm') +library dart_test; + +import 'dart:io'; + +import 'package:sentry/src/event_processor/enricher/io_platform_memory.dart'; +import 'package:test/test.dart'; + +import '../../test_utils.dart'; + +void main() { + late Fixture fixture; + + setUp(() { + fixture = Fixture(); + }); + + test('total physical memory', () { + final sut = fixture.getSut(); + final totalPhysicalMemory = sut.getTotalPhysicalMemory(); + + switch (Platform.operatingSystem) { + case 'linux': + expect(totalPhysicalMemory, isNotNull); + expect(totalPhysicalMemory! > 0, true); + break; + case 'windows': + expect(totalPhysicalMemory, isNotNull); + expect(totalPhysicalMemory! > 0, true); + break; + default: + expect(totalPhysicalMemory, isNull); + } + }); + + test('free physical memory', () { + final sut = fixture.getSut(); + final freePhysicalMemory = sut.getTotalPhysicalMemory(); + + switch (Platform.operatingSystem) { + case 'linux': + expect(freePhysicalMemory, isNotNull); + expect(freePhysicalMemory! > 0, true); + break; + case 'windows': + expect(freePhysicalMemory, isNotNull); + expect(freePhysicalMemory! > 0, true); + break; + default: + expect(freePhysicalMemory, isNull); + } + }); +} + +class Fixture { + var options = defaultTestOptions(); + + PlatformMemory getSut() { + return PlatformMemory(options); + } +} diff --git a/dart/test/event_processor/enricher/web_enricher_test.dart b/dart/test/event_processor/enricher/web_enricher_test.dart index cdc7310fab..39502fc134 100644 --- a/dart/test/event_processor/enricher/web_enricher_test.dart +++ b/dart/test/event_processor/enricher/web_enricher_test.dart @@ -1,14 +1,15 @@ @TestOn('browser') library dart_test; -import 'dart:html' as html; - import 'package:sentry/sentry.dart'; -import 'package:sentry/src/event_processor/enricher/web_enricher_event_processor.dart'; +import 'package:sentry/src/event_processor/enricher/html_enricher_event_processor.dart' + if (dart.library.html) 'package:sentry/src/event_processor/enricher/html_enricher_event_processor.dart' + if (dart.library.js_interop) 'package:sentry/src/event_processor/enricher/web_enricher_event_processor.dart'; import 'package:test/test.dart'; import '../../mocks.dart'; import '../../mocks/mock_platform_checker.dart'; +import '../../test_utils.dart'; // can be tested on command line with // `dart test -p chrome --name web_enricher` @@ -193,23 +194,16 @@ void main() { ); await Sentry.close(); - final ioEnricherCount = sentryOptions.eventProcessors - .whereType() - .length; - expect(ioEnricherCount, 1); + expect(sentryOptions.eventProcessors.map((e) => e.runtimeType.toString()), + contains('$WebEnricherEventProcessor')); }); }); } class Fixture { WebEnricherEventProcessor getSut() { - final options = SentryOptions( - dsn: fakeDsn, - checker: MockPlatformChecker(hasNativeIntegration: false)); - - return WebEnricherEventProcessor( - html.window, - options, - ); + final options = + defaultTestOptions(MockPlatformChecker(hasNativeIntegration: false)); + return enricherEventProcessor(options) as WebEnricherEventProcessor; } } diff --git a/dart/test/example_web_compile_test.dart b/dart/test/example_web_compile_test.dart index 4199a47dda..1a2a0cf13f 100644 --- a/dart/test/example_web_compile_test.dart +++ b/dart/test/example_web_compile_test.dart @@ -5,29 +5,35 @@ import 'dart:async'; import 'dart:convert'; import 'dart:io'; +import 'package:version/version.dart'; import 'package:test/test.dart'; // Tests for the following issue // https://github.com/getsentry/sentry-dart/issues/1893 void main() { - group('Compile example_web', () { + final dartVersion = Version.parse(Platform.version.split(' ')[0]); + final isLegacy = dartVersion < Version.parse('3.3.0'); + final exampleAppDir = isLegacy ? 'example_web_legacy' : 'example_web'; + final exampleAppWorkingDir = + '${Directory.current.path}${Platform.pathSeparator}$exampleAppDir'; + group('Compile $exampleAppDir', () { test( 'dart pub get and compilation should run successfully', () async { final result = await _runProcess('dart pub get', - workingDirectory: _exampleWebWorkingDir); + workingDirectory: exampleAppWorkingDir); expect(result.exitCode, 0, - reason: 'Could run `dart pub get` for example_web. ' + reason: 'Could run `dart pub get` for $exampleAppDir. ' 'Likely caused by outdated dependencies'); // running this test locally require clean working directory final cleanResult = await _runProcess('dart run build_runner clean', - workingDirectory: _exampleWebWorkingDir); + workingDirectory: exampleAppWorkingDir); expect(cleanResult.exitCode, 0); final compileResult = await _runProcess( 'dart run build_runner build -r web -o build --delete-conflicting-outputs', - workingDirectory: _exampleWebWorkingDir); + workingDirectory: exampleAppWorkingDir); expect(compileResult.exitCode, 0, - reason: 'Could not compile example_web project'); + reason: 'Could not compile $exampleAppDir project'); expect( compileResult.stdout, isNot(contains( @@ -36,8 +42,9 @@ void main() { 'Could not compile main.dart, likely because of dart:io import.'); expect( compileResult.stdout, - contains( - 'build_web_compilers:entrypoint on web/main.dart:Compiled')); + contains(isLegacy + ? 'Succeeded after ' + : 'build_web_compilers:entrypoint on web/main.dart:Compiled')); }, timeout: Timeout(const Duration(minutes: 1)), // double of detault timeout ); @@ -76,10 +83,6 @@ Future<_CommandResult> _runProcess(String command, return _CommandResult(exitCode: exitCode, stdout: processOut); } -String get _exampleWebWorkingDir { - return '${Directory.current.path}${Platform.pathSeparator}example_web'; -} - class _CommandResult { final int exitCode; final String stdout; diff --git a/dart/test/exception_identifier_test.dart b/dart/test/exception_identifier_test.dart new file mode 100644 index 0000000000..29e2a4a735 --- /dev/null +++ b/dart/test/exception_identifier_test.dart @@ -0,0 +1,185 @@ +import 'package:mockito/mockito.dart'; +import 'package:sentry/sentry.dart'; +import 'package:sentry/src/dart_exception_type_identifier.dart'; +import 'package:sentry/src/sentry_exception_factory.dart'; +import 'package:test/test.dart'; + +import 'mocks.mocks.dart'; +import 'mocks/mock_transport.dart'; +import 'sentry_client_test.dart'; +import 'test_utils.dart'; + +void main() { + late Fixture fixture; + + setUp(() { + fixture = Fixture(); + }); + + group('ExceptionTypeIdentifiers', () { + test('should be processed based on order in the list', () { + fixture.options + .prependExceptionTypeIdentifier(DartExceptionTypeIdentifier()); + fixture.options + .prependExceptionTypeIdentifier(ObfuscatedExceptionIdentifier()); + + final factory = SentryExceptionFactory(fixture.options); + final sentryException = factory.getSentryException(ObfuscatedException()); + + expect(sentryException.type, equals('ObfuscatedException')); + }); + + test('should return null if exception is not identified', () { + final identifier = DartExceptionTypeIdentifier(); + expect(identifier.identifyType(ObfuscatedException()), isNull); + }); + }); + + group('SentryExceptionFactory', () { + test('should process identifiers based on order in the list', () { + fixture.options + .prependExceptionTypeIdentifier(DartExceptionTypeIdentifier()); + fixture.options + .prependExceptionTypeIdentifier(ObfuscatedExceptionIdentifier()); + + final factory = SentryExceptionFactory(fixture.options); + final sentryException = factory.getSentryException(ObfuscatedException()); + + expect(sentryException.type, equals('ObfuscatedException')); + }); + + test('should use runtime type when identification is disabled', () { + fixture.options.enableExceptionTypeIdentification = false; + fixture.options + .prependExceptionTypeIdentifier(ObfuscatedExceptionIdentifier()); + + final factory = SentryExceptionFactory(fixture.options); + final sentryException = factory.getSentryException(ObfuscatedException()); + + expect(sentryException.type, equals('PlaceHolderException')); + }); + }); + + group('CachingExceptionTypeIdentifier', () { + late MockExceptionTypeIdentifier mockIdentifier; + late CachingExceptionTypeIdentifier cachingIdentifier; + + setUp(() { + mockIdentifier = MockExceptionTypeIdentifier(); + cachingIdentifier = CachingExceptionTypeIdentifier(mockIdentifier); + }); + + test('should return cached result for known types', () { + final exception = Exception('Test'); + when(mockIdentifier.identifyType(exception)).thenReturn('TestException'); + + expect( + cachingIdentifier.identifyType(exception), equals('TestException')); + expect( + cachingIdentifier.identifyType(exception), equals('TestException')); + expect( + cachingIdentifier.identifyType(exception), equals('TestException')); + + verify(mockIdentifier.identifyType(exception)).called(1); + }); + + test('should not cache unknown types', () { + final exception = ObfuscatedException(); + + when(mockIdentifier.identifyType(exception)).thenReturn(null); + + expect(cachingIdentifier.identifyType(exception), isNull); + expect(cachingIdentifier.identifyType(exception), isNull); + expect(cachingIdentifier.identifyType(exception), isNull); + + verify(mockIdentifier.identifyType(exception)).called(3); + }); + + test('should return null for unknown exception type', () { + final exception = Exception('Unknown'); + when(mockIdentifier.identifyType(exception)).thenReturn(null); + + expect(cachingIdentifier.identifyType(exception), isNull); + }); + + test('should handle different exception types separately', () { + final exception1 = Exception('Test1'); + final exception2 = FormatException('Test2'); + + when(mockIdentifier.identifyType(exception1)).thenReturn('Exception'); + when(mockIdentifier.identifyType(exception2)) + .thenReturn('FormatException'); + + expect(cachingIdentifier.identifyType(exception1), equals('Exception')); + expect(cachingIdentifier.identifyType(exception2), + equals('FormatException')); + + // Call again to test caching + expect(cachingIdentifier.identifyType(exception1), equals('Exception')); + expect(cachingIdentifier.identifyType(exception2), + equals('FormatException')); + + verify(mockIdentifier.identifyType(exception1)).called(1); + verify(mockIdentifier.identifyType(exception2)).called(1); + }); + }); + + group('Integration test', () { + setUp(() { + fixture.options.transport = MockTransport(); + }); + + test( + 'should capture CustomException as exception type with custom identifier', + () async { + fixture.options + .prependExceptionTypeIdentifier(ObfuscatedExceptionIdentifier()); + + final client = SentryClient(fixture.options); + + await client.captureException(ObfuscatedException()); + + final transport = fixture.options.transport as MockTransport; + final capturedEnvelope = transport.envelopes.first; + final capturedEvent = await eventFromEnvelope(capturedEnvelope); + + expect( + capturedEvent.exceptions!.first.type, equals('ObfuscatedException')); + }); + + test( + 'should capture PlaceHolderException as exception type without custom identifier', + () async { + final client = SentryClient(fixture.options); + + await client.captureException(ObfuscatedException()); + + final transport = fixture.options.transport as MockTransport; + final capturedEnvelope = transport.envelopes.first; + final capturedEvent = await eventFromEnvelope(capturedEnvelope); + + expect( + capturedEvent.exceptions!.first.type, equals('PlaceHolderException')); + }); + }); +} + +class Fixture { + SentryOptions options = defaultTestOptions(); +} + +// We use this PlaceHolder exception to mimic an obfuscated runtimeType +class PlaceHolderException implements Exception {} + +class ObfuscatedException implements Exception { + @override + Type get runtimeType => PlaceHolderException; +} + +class ObfuscatedExceptionIdentifier implements ExceptionTypeIdentifier { + @override + String? identifyType(dynamic throwable) { + if (throwable is ObfuscatedException) return 'ObfuscatedException'; + return null; + } +} diff --git a/dart/test/http_client/failed_request_client_test.dart b/dart/test/http_client/failed_request_client_test.dart index 2ac9a74c8e..b35dd2957b 100644 --- a/dart/test/http_client/failed_request_client_test.dart +++ b/dart/test/http_client/failed_request_client_test.dart @@ -8,6 +8,7 @@ import 'package:test/test.dart'; import '../mocks.dart'; import '../mocks/mock_hub.dart'; import '../mocks/mock_transport.dart'; +import '../test_utils.dart'; final requestUri = Uri.parse('https://example.com?foo=bar#myFragment'); @@ -359,7 +360,7 @@ MockClient createThrowingClient() { class CloseableMockClient extends Mock implements BaseClient {} class Fixture { - final options = SentryOptions(dsn: fakeDsn); + final options = defaultTestOptions(); late Hub _hub; final transport = MockTransport(); Fixture() { diff --git a/dart/test/http_client/io_client_provider_test.dart b/dart/test/http_client/io_client_provider_test.dart new file mode 100644 index 0000000000..81d05c1540 --- /dev/null +++ b/dart/test/http_client/io_client_provider_test.dart @@ -0,0 +1,267 @@ +@TestOn('vm') +library dart_test; + +import 'dart:io'; + +import 'package:sentry/sentry.dart'; +import 'package:sentry/src/http_client/io_client_provider.dart'; +import 'package:test/test.dart'; + +import '../test_utils.dart'; + +void main() { + group('getClient', () { + late Fixture fixture; + + setUp(() { + fixture = Fixture(); + }); + + test('http proxy should call findProxyResult', () async { + fixture.options.proxy = SentryProxy( + type: SentryProxyType.http, + host: 'localhost', + port: 8080, + ); + + final sut = fixture.getSut(); + sut.getClient(fixture.options); + + expect(fixture.mockHttpClient.findProxyResult, + equals(fixture.options.proxy?.toPacString())); + }); + + test('direct proxy should call findProxyResult', () async { + fixture.options.proxy = SentryProxy(type: SentryProxyType.direct); + + final sut = fixture.getSut(); + sut.getClient(fixture.options); + + expect(fixture.mockHttpClient.findProxyResult, + equals(fixture.options.proxy?.toPacString())); + }); + + test('socks proxy should not call findProxyResult', () async { + fixture.options.proxy = SentryProxy( + type: SentryProxyType.socks, host: 'localhost', port: 8080); + + final sut = fixture.getSut(); + sut.getClient(fixture.options); + + expect(fixture.mockHttpClient.findProxyResult, isNull); + }); + + test('authenticated proxy http should call addProxyCredentials', () async { + fixture.options.proxy = SentryProxy( + type: SentryProxyType.http, + host: 'localhost', + port: 8080, + user: 'admin', + pass: '0000', + ); + + final sut = fixture.getSut(); + + sut.getClient(fixture.options); + + expect(fixture.mockHttpClient.addProxyCredentialsHost, + fixture.options.proxy?.host); + expect(fixture.mockHttpClient.addProxyCredentialsPort, + fixture.options.proxy?.port); + expect(fixture.mockHttpClient.addProxyCredentialsRealm, ''); + expect(fixture.mockUser, fixture.options.proxy?.user); + expect(fixture.mockPass, fixture.options.proxy?.pass); + expect(fixture.mockHttpClient.addProxyCredentialsBasic, isNotNull); + }); + }); +} + +class Fixture { + final options = defaultTestOptions(); + final mockHttpClient = MockHttpClient(); + + String? mockUser; + String? mockPass; + + IoClientProvider getSut() { + return IoClientProvider( + () { + return mockHttpClient; + }, + (user, pass) { + mockUser = user; + mockPass = pass; + return HttpClientBasicCredentials(user, pass); + }, + ); + } +} + +class MockHttpClient implements HttpClient { + @override + bool autoUncompress = false; + + @override + Duration? connectionTimeout; + + @override + Duration idleTimeout = Duration(seconds: 1); + + @override + int? maxConnectionsPerHost; + + @override + String? userAgent; + + @override + void addCredentials( + Uri url, String realm, HttpClientCredentials credentials) { + // TODO: implement addCredentials + } + + String? addProxyCredentialsHost; + int? addProxyCredentialsPort; + String? addProxyCredentialsRealm; + HttpClientBasicCredentials? addProxyCredentialsBasic; + + @override + void addProxyCredentials( + String host, int port, String realm, HttpClientCredentials credentials) { + addProxyCredentialsHost = host; + addProxyCredentialsPort = port; + addProxyCredentialsRealm = realm; + if (credentials is HttpClientBasicCredentials) { + addProxyCredentialsBasic = credentials; + } + } + + @override + set authenticate( + Future Function(Uri url, String scheme, String? realm)? f) { + // TODO: implement authenticate + } + + @override + set authenticateProxy( + Future Function( + String host, int port, String scheme, String? realm)? + f) { + // TODO: implement authenticateProxy + } + + @override + set badCertificateCallback( + bool Function(X509Certificate cert, String host, int port)? callback) { + // TODO: implement badCertificateCallback + } + + @override + void close({bool force = false}) { + // TODO: implement close + } + + @override + set connectionFactory( + Future> Function( + Uri url, String? proxyHost, int? proxyPort)? + f) { + // TODO: implement connectionFactory + } + + @override + Future delete(String host, int port, String path) { + // TODO: implement delete + throw UnimplementedError(); + } + + @override + Future deleteUrl(Uri url) { + // TODO: implement deleteUrl + throw UnimplementedError(); + } + + String? findProxyResult; + + @override + set findProxy(String Function(Uri url)? f) { + findProxyResult = f!(Uri(scheme: "http", host: "localhost", port: 8080)); + } + + @override + Future get(String host, int port, String path) { + // TODO: implement get + throw UnimplementedError(); + } + + @override + Future getUrl(Uri url) { + // TODO: implement getUrl + throw UnimplementedError(); + } + + @override + Future head(String host, int port, String path) { + // TODO: implement head + throw UnimplementedError(); + } + + @override + Future headUrl(Uri url) { + // TODO: implement headUrl + throw UnimplementedError(); + } + + @override + set keyLog(Function(String line)? callback) { + // TODO: implement keyLog + } + + @override + Future open( + String method, String host, int port, String path) { + // TODO: implement open + throw UnimplementedError(); + } + + @override + Future openUrl(String method, Uri url) { + // TODO: implement openUrl + throw UnimplementedError(); + } + + @override + Future patch(String host, int port, String path) { + // TODO: implement patch + throw UnimplementedError(); + } + + @override + Future patchUrl(Uri url) { + // TODO: implement patchUrl + throw UnimplementedError(); + } + + @override + Future post(String host, int port, String path) { + // TODO: implement post + throw UnimplementedError(); + } + + @override + Future postUrl(Uri url) { + // TODO: implement postUrl + throw UnimplementedError(); + } + + @override + Future put(String host, int port, String path) { + // TODO: implement put + throw UnimplementedError(); + } + + @override + Future putUrl(Uri url) { + // TODO: implement putUrl + throw UnimplementedError(); + } +} diff --git a/dart/test/http_client/tracing_client_test.dart b/dart/test/http_client/tracing_client_test.dart index cc48d5cb37..4e052dc518 100644 --- a/dart/test/http_client/tracing_client_test.dart +++ b/dart/test/http_client/tracing_client_test.dart @@ -5,8 +5,8 @@ import 'package:sentry/src/http_client/tracing_client.dart'; import 'package:sentry/src/sentry_tracer.dart'; import 'package:test/test.dart'; -import '../mocks.dart'; import '../mocks/mock_transport.dart'; +import '../test_utils.dart'; final requestUri = Uri.parse('https://example.com?foo=bar#baz'); @@ -177,6 +177,7 @@ void main() { test('set headers from propagationContext when tracing is disabled', () async { + // ignore: deprecated_member_use_from_same_package fixture._options.enableTracing = false; final sut = fixture.getSut( client: fixture.getClient(statusCode: 200, reason: 'OK'), @@ -219,7 +220,7 @@ MockClient createThrowingClient() { } class Fixture { - final _options = SentryOptions(dsn: fakeDsn); + final _options = defaultTestOptions(); late Hub _hub; final transport = MockTransport(); Fixture() { diff --git a/dart/test/hub_test.dart b/dart/test/hub_test.dart index 8dcc622654..564c59ff7d 100644 --- a/dart/test/hub_test.dart +++ b/dart/test/hub_test.dart @@ -10,6 +10,7 @@ import 'mocks.dart'; import 'mocks.mocks.dart'; import 'mocks/mock_client_report_recorder.dart'; import 'mocks/mock_sentry_client.dart'; +import 'test_utils.dart'; void main() { bool scopeEquals(Scope? a, Scope b) { @@ -25,7 +26,7 @@ void main() { group('Hub instantiation', () { test('should instantiate with a dsn', () { - final hub = Hub(SentryOptions(dsn: fakeDsn)); + final hub = Hub(defaultTestOptions()); expect(hub.isEnabled, true); }); }); @@ -458,11 +459,11 @@ void main() { }); group('Hub scope', () { - var hub = Hub(SentryOptions(dsn: fakeDsn)); + var hub = Hub(defaultTestOptions()); var client = MockSentryClient(); setUp(() { - hub = Hub(SentryOptions(dsn: fakeDsn)); + hub = Hub(defaultTestOptions()); client = MockSentryClient(); hub.bindClient(client); }); @@ -487,7 +488,7 @@ void main() { expect(client.captureEventCalls.first.scope, isNotNull); final scope = client.captureEventCalls.first.scope; - final otherScope = Scope(SentryOptions(dsn: fakeDsn)) + final otherScope = Scope(defaultTestOptions()) ..level = SentryLevel.debug ..fingerprint = ['1', '2']; @@ -511,7 +512,7 @@ void main() { await hub.captureEvent(fakeEvent); final scope = client.captureEventCalls.first.scope; - final otherScope = Scope(SentryOptions(dsn: fakeDsn)); + final otherScope = Scope(defaultTestOptions()); await otherScope.setUser(fakeUser); expect( @@ -542,6 +543,7 @@ void main() { }); test('captureEvent should handle thrown error in scope callback', () async { + fixture.options.automatedTestMode = false; final hub = fixture.getSut(debug: true); final scopeCallbackException = Exception('error in scope callback'); @@ -557,6 +559,7 @@ void main() { test('captureException should handle thrown error in scope callback', () async { + fixture.options.automatedTestMode = false; final hub = fixture.getSut(debug: true); final scopeCallbackException = Exception('error in scope callback'); @@ -573,6 +576,7 @@ void main() { test('captureMessage should handle thrown error in scope callback', () async { + fixture.options.automatedTestMode = false; final hub = fixture.getSut(debug: true); final scopeCallbackException = Exception('error in scope callback'); @@ -593,7 +597,7 @@ void main() { SentryOptions options; setUp(() { - options = SentryOptions(dsn: fakeDsn); + options = defaultTestOptions(); hub = Hub(options); client = MockSentryClient(); hub.bindClient(client); @@ -694,11 +698,22 @@ void main() { test('record sample rate dropping transaction', () async { final hub = fixture.getSut(sampled: false); var transaction = SentryTransaction(fixture.tracer); + fixture.tracer.startChild('child1'); + fixture.tracer.startChild('child2'); + fixture.tracer.startChild('child3'); await hub.captureTransaction(transaction); - expect(fixture.recorder.reason, DiscardReason.sampleRate); - expect(fixture.recorder.category, DataCategory.transaction); + expect(fixture.recorder.discardedEvents.length, 2); + + // we dropped the whole tracer and it has 3 span children so the span count should be 4 + // 3 children + 1 root span + final spanCount = fixture.recorder.discardedEvents + .firstWhere((element) => + element.category == DataCategory.span && + element.reason == DiscardReason.sampleRate) + .quantity; + expect(spanCount, 4); }); }); @@ -745,7 +760,7 @@ class Fixture { final client = MockSentryClient(); final recorder = MockClientReportRecorder(); - final options = SentryOptions(dsn: fakeDsn); + final options = defaultTestOptions(); late SentryTransactionContext _context; late SentryTracer tracer; diff --git a/dart/test/initialization_test.dart b/dart/test/initialization_test.dart index e6f9ddea23..42766ac93d 100644 --- a/dart/test/initialization_test.dart +++ b/dart/test/initialization_test.dart @@ -5,6 +5,7 @@ import 'package:sentry/sentry.dart'; import 'package:test/test.dart'; import 'mocks.dart'; +import 'test_utils.dart'; // Tests for the following issue // https://github.com/getsentry/sentry-dart/issues/508 @@ -15,7 +16,7 @@ void main() { }); test('async re-initilization', () async { - final options = SentryOptions(dsn: fakeDsn)..automatedTestMode = true; + final options = defaultTestOptions(); await Sentry.init( (options) { options.dsn = fakeDsn; @@ -36,7 +37,7 @@ void main() { // This is the failure from // https://github.com/getsentry/sentry-dart/issues/508 test('re-initilization', () async { - final options = SentryOptions(dsn: fakeDsn)..automatedTestMode = true; + final options = defaultTestOptions(); await Sentry.init( (options) { options.dsn = fakeDsn; diff --git a/dart/test/load_dart_debug_images_integration_test.dart b/dart/test/load_dart_debug_images_integration_test.dart new file mode 100644 index 0000000000..e7f06525ce --- /dev/null +++ b/dart/test/load_dart_debug_images_integration_test.dart @@ -0,0 +1,110 @@ +@TestOn('vm') +library dart_test; + +import 'package:sentry/sentry.dart'; +import 'package:sentry/src/load_dart_debug_images_integration.dart'; +import 'package:test/test.dart'; + +import 'mocks/mock_platform.dart'; +import 'mocks/mock_platform_checker.dart'; +import 'test_utils.dart'; + +void main() { + group(LoadDartDebugImagesIntegration, () { + late Fixture fixture; + + final platforms = [ + MockPlatform.iOS(), + MockPlatform.macOS(), + MockPlatform.android(), + ]; + + for (final platform in platforms) { + setUp(() { + fixture = Fixture(); + fixture.options.platformChecker = + MockPlatformChecker(platform: platform); + }); + + test('adds itself to sdk.integrations', () { + expect( + fixture.options.sdk.integrations.contains('loadDartImageIntegration'), + true, + ); + }); + + test('Event processor is added to options', () { + expect(fixture.options.eventProcessors.length, 1); + expect( + fixture.options.eventProcessors.first.runtimeType.toString(), + '_LoadImageIntegrationEventProcessor', + ); + }); + + test( + 'Event processor does not add debug image if symbolication is not needed', + () async { + final event = _getEvent(needsSymbolication: false); + final processor = fixture.options.eventProcessors.first; + final resultEvent = await processor.apply(event, Hint()); + + expect(resultEvent, equals(event)); + }); + + test('Event processor does not add debug image if stackTrace is null', + () async { + final event = _getEvent(); + final processor = fixture.options.eventProcessors.first; + final resultEvent = await processor.apply(event, Hint()); + + expect(resultEvent, equals(event)); + }); + + test( + 'Event processor does not add debug image if enableDartSymbolication is false', + () async { + fixture.options.enableDartSymbolication = false; + final event = _getEvent(); + final processor = fixture.options.eventProcessors.first; + final resultEvent = await processor.apply(event, Hint()); + + expect(resultEvent, equals(event)); + }); + + test('Event processor adds debug image when symbolication is needed', + () async { + final stackTrace = ''' +*** *** *** *** *** *** *** *** *** *** *** *** *** *** *** *** +build_id: 'b680cb890f9e3c12a24b172d050dec73' +isolate_dso_base: 10000000 +'''; + SentryEvent event = _getEvent(); + final processor = fixture.options.eventProcessors.first; + final resultEvent = await processor.apply( + event, Hint()..set(hintRawStackTraceKey, stackTrace)); + + expect(resultEvent?.debugMeta?.images.length, 1); + final debugImage = resultEvent?.debugMeta?.images.first; + expect(debugImage?.debugId, isNotEmpty); + expect(debugImage?.imageAddr, equals('0x10000000')); + }); + } + }); +} + +class Fixture { + final options = defaultTestOptions(); + + Fixture() { + final integration = LoadDartDebugImagesIntegration(); + integration.call(Hub(options), options); + } +} + +SentryEvent _getEvent({bool needsSymbolication = true}) { + final frame = + SentryStackFrame(platform: needsSymbolication ? 'native' : 'dart'); + final st = SentryStackTrace(frames: [frame]); + return SentryEvent( + threads: [SentryThread(stacktrace: st)], debugMeta: DebugMeta()); +} diff --git a/dart/test/metrics/metrics_aggregator_test.dart b/dart/test/metrics/metrics_aggregator_test.dart index 5636e7ebf6..19c6a9f184 100644 --- a/dart/test/metrics/metrics_aggregator_test.dart +++ b/dart/test/metrics/metrics_aggregator_test.dart @@ -3,8 +3,8 @@ import 'package:sentry/src/metrics/metric.dart'; import 'package:sentry/src/metrics/metrics_aggregator.dart'; import 'package:test/test.dart'; -import '../mocks.dart'; import '../mocks/mock_hub.dart'; +import '../test_utils.dart'; void main() { group('emit', () { @@ -402,6 +402,7 @@ void main() { }); test('emits if it throws', () async { + fixture.options.automatedTestMode = false; final MetricsAggregator sut = fixture.getSut(maxWeight: 4); fixture.options.beforeMetricCallback = (key, {tags}) => throw Exception(); sut.testEmit(key: 'key1'); @@ -455,7 +456,7 @@ const Map mockTags2 = {'tag1': 'val1'}; final DateTime mockTimestamp = DateTime.fromMillisecondsSinceEpoch(1); class Fixture { - final options = SentryOptions(dsn: fakeDsn); + final options = defaultTestOptions(); final mockHub = MockHub(); late final hub = Hub(options); diff --git a/dart/test/metrics/metrics_api_test.dart b/dart/test/metrics/metrics_api_test.dart index 8c53e66772..8e18d21996 100644 --- a/dart/test/metrics/metrics_api_test.dart +++ b/dart/test/metrics/metrics_api_test.dart @@ -6,8 +6,8 @@ import 'package:sentry/src/metrics/metrics_api.dart'; import 'package:sentry/src/sentry_tracer.dart'; import 'package:test/test.dart'; -import '../mocks.dart'; import '../mocks/mock_hub.dart'; +import '../test_utils.dart'; void main() { group('api', () { @@ -144,7 +144,7 @@ void main() { } class Fixture { - final _options = SentryOptions(dsn: fakeDsn); + final _options = defaultTestOptions(); final mockHub = MockHub(); late final hub = Hub(_options); diff --git a/dart/test/mocks.dart b/dart/test/mocks.dart index 7c960c8a07..679b9f73cd 100644 --- a/dart/test/mocks.dart +++ b/dart/test/mocks.dart @@ -137,6 +137,25 @@ class DropAllEventProcessor implements EventProcessor { } } +class DropSpansEventProcessor implements EventProcessor { + DropSpansEventProcessor(this.numberOfSpansToDrop); + + final int numberOfSpansToDrop; + + @override + SentryEvent? apply(SentryEvent event, Hint hint) { + if (event is SentryTransaction) { + if (numberOfSpansToDrop > event.spans.length) { + throw ArgumentError( + 'numberOfSpansToDrop must be less than the number of spans in the transaction'); + } + final droppedSpans = event.spans.take(numberOfSpansToDrop).toList(); + event.spans.removeWhere((element) => droppedSpans.contains(element)); + } + return event; + } +} + class FunctionEventProcessor implements EventProcessor { FunctionEventProcessor(this.applyFunction); @@ -184,9 +203,16 @@ class MockRateLimiter implements RateLimiter { } } +final Map testUnknown = { + 'unknown-string': 'foo', + 'unknown-bool': true, + 'unknown-num': 9001, +}; + @GenerateMocks([ SentryProfilerFactory, SentryProfiler, SentryProfileInfo, + ExceptionTypeIdentifier, ]) void main() {} diff --git a/dart/test/mocks.mocks.dart b/dart/test/mocks.mocks.dart index 5f2556400e..58133b794b 100644 --- a/dart/test/mocks.mocks.dart +++ b/dart/test/mocks.mocks.dart @@ -1,4 +1,4 @@ -// Mocks generated by Mockito 5.4.2 from annotations +// Mocks generated by Mockito 5.4.4 from annotations // in sentry/test/mocks.dart. // Do not manually edit this file. @@ -13,6 +13,8 @@ import 'package:sentry/src/profiling.dart' as _i3; // ignore_for_file: avoid_redundant_argument_values // ignore_for_file: avoid_setters_without_getters // ignore_for_file: comment_references +// ignore_for_file: deprecated_member_use +// ignore_for_file: deprecated_member_use_from_same_package // ignore_for_file: implementation_imports // ignore_for_file: invalid_use_of_visible_for_testing_member // ignore_for_file: prefer_const_constructors @@ -66,6 +68,7 @@ class MockSentryProfiler extends _i1.Mock implements _i3.SentryProfiler { ), returnValue: _i4.Future<_i3.SentryProfileInfo?>.value(), ) as _i4.Future<_i3.SentryProfileInfo?>); + @override void dispose() => super.noSuchMethod( Invocation.method( @@ -99,3 +102,13 @@ class MockSentryProfileInfo extends _i1.Mock implements _i3.SentryProfileInfo { ), ) as _i2.SentryEnvelopeItem); } + +/// A class which mocks [ExceptionTypeIdentifier]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockExceptionTypeIdentifier extends _i1.Mock + implements _i2.ExceptionTypeIdentifier { + MockExceptionTypeIdentifier() { + _i1.throwOnMissingStub(this); + } +} diff --git a/dart/test/mocks/mock_client_report_recorder.dart b/dart/test/mocks/mock_client_report_recorder.dart index fa1af54ed8..4d8eaa5b1d 100644 --- a/dart/test/mocks/mock_client_report_recorder.dart +++ b/dart/test/mocks/mock_client_report_recorder.dart @@ -1,11 +1,11 @@ import 'package:sentry/src/client_reports/client_report_recorder.dart'; import 'package:sentry/src/client_reports/discard_reason.dart'; import 'package:sentry/src/client_reports/client_report.dart'; +import 'package:sentry/src/client_reports/discarded_event.dart'; import 'package:sentry/src/transport/data_category.dart'; class MockClientReportRecorder implements ClientReportRecorder { - DiscardReason? reason; - DataCategory? category; + List discardedEvents = []; ClientReport? clientReport; @@ -18,8 +18,8 @@ class MockClientReportRecorder implements ClientReportRecorder { } @override - void recordLostEvent(DiscardReason reason, DataCategory category) { - this.reason = reason; - this.category = category; + void recordLostEvent(DiscardReason reason, DataCategory category, + {int count = 1}) { + discardedEvents.add(DiscardedEvent(reason, category, count)); } } diff --git a/dart/test/mocks/mock_envelope.dart b/dart/test/mocks/mock_envelope.dart index 9b43a41b8a..1009f2e396 100644 --- a/dart/test/mocks/mock_envelope.dart +++ b/dart/test/mocks/mock_envelope.dart @@ -23,4 +23,7 @@ class MockEnvelope implements SentryEnvelope { @override List items = []; + + @override + bool get containsUnhandledException => false; } diff --git a/dart/test/mocks/mock_hub.dart b/dart/test/mocks/mock_hub.dart index c076251736..8fa7d31388 100644 --- a/dart/test/mocks/mock_hub.dart +++ b/dart/test/mocks/mock_hub.dart @@ -3,7 +3,7 @@ import 'package:sentry/sentry.dart'; import 'package:sentry/src/metrics/metric.dart'; import 'package:sentry/src/metrics/metrics_aggregator.dart'; -import '../mocks.dart'; +import '../test_utils.dart'; import 'mock_sentry_client.dart'; import 'no_such_method_provider.dart'; @@ -21,7 +21,7 @@ class MockHub with NoSuchMethodProvider implements Hub { int spanContextCals = 0; int getSpanCalls = 0; - final _options = SentryOptions(dsn: fakeDsn); + final _options = defaultTestOptions(); late final MetricsAggregator _metricsAggregator = MetricsAggregator(options: _options, hub: this); diff --git a/dart/test/mocks/mock_platform.dart b/dart/test/mocks/mock_platform.dart index a045f794af..9f8f391c88 100644 --- a/dart/test/mocks/mock_platform.dart +++ b/dart/test/mocks/mock_platform.dart @@ -13,6 +13,14 @@ class MockPlatform extends Platform with NoSuchMethodProvider { return MockPlatform(os: 'ios'); } + factory MockPlatform.macOS() { + return MockPlatform(os: 'macos'); + } + + factory MockPlatform.linux() { + return MockPlatform(os: 'linux'); + } + @override String operatingSystem; } diff --git a/dart/test/protocol/access_aware_map_tests.dart b/dart/test/protocol/access_aware_map_tests.dart new file mode 100644 index 0000000000..b9c08f2b9a --- /dev/null +++ b/dart/test/protocol/access_aware_map_tests.dart @@ -0,0 +1,118 @@ +import 'package:collection/collection.dart'; +import 'package:sentry/src/protocol/access_aware_map.dart'; +import 'package:test/test.dart'; + +void main() { + group('MapBase', () { + test('set/get value for key', () { + final sut = AccessAwareMap({ + 'foo': 'foo', + }); + + sut['foo'] = 'bar'; + sut['bar'] = 'foo'; + + expect(sut['foo'], 'bar'); + expect(sut['bar'], 'foo'); + }); + + test('clear', () { + final sut = AccessAwareMap({ + 'foo': 'foo', + }); + + sut.clear(); + + expect(sut.isEmpty, true); + }); + + test('keys', () { + final sut = AccessAwareMap({ + 'foo': 'foo', + 'bar': 'bar', + }); + expect( + sut.keys.sortedBy((it) => it), ['bar', 'foo'].sortedBy((it) => it)); + }); + + test('remove', () { + final sut = AccessAwareMap({ + 'foo': 'foo', + }); + + sut.remove('foo'); + + expect(sut.isEmpty, true); + }); + }); + + group('access aware', () { + test('collects accessedKeys', () { + final sut = AccessAwareMap({ + 'foo': 'foo', + 'bar': 'bar', + }); + + sut['foo']; + sut['bar']; + sut['baz']; + + expect(sut.accessedKeysWithValues, {'foo', 'bar', 'baz'}); + }); + + test('returns notAccessed data', () { + final sut = AccessAwareMap({ + 'foo': 'foo', + 'bar': 'bar', + }); + + sut['foo']; + + final notAccessed = sut.notAccessed(); + expect(notAccessed, isNotNull); + expect(notAccessed?.containsKey('foo'), false); + expect(notAccessed?.containsKey('bar'), true); + }); + }); + + group('map base functionality', () { + test('set value with []= operator', () { + final sut = AccessAwareMap({ + 'foo': 'foo', + }); + + sut['foo'] = 'bar'; + sut['bar'] = 'foo'; + + expect(sut['foo'], 'bar'); + expect(sut['bar'], 'foo'); + }); + + test('clear', () { + final sut = AccessAwareMap({ + 'foo': 'foo', + }); + + sut.clear(); + + expect(sut.accessedKeysWithValues.isEmpty, true); + expect(sut.isEmpty, true); + }); + + test('keys', () { + final sut = AccessAwareMap({ + 'foo': 'foo', + 'bar': 'bar', + }); + expect(sut.keys.toSet(), {'foo', 'bar'}); + }); + + test('remove', () { + final sut = AccessAwareMap({'foo': 'foo'}); + + sut.remove('foo'); + + expect(sut['foo'], isNull); + }); + }); +} diff --git a/dart/test/protocol/breadcrumb_test.dart b/dart/test/protocol/breadcrumb_test.dart index ddd4008d98..24f0a1b408 100644 --- a/dart/test/protocol/breadcrumb_test.dart +++ b/dart/test/protocol/breadcrumb_test.dart @@ -2,6 +2,8 @@ import 'package:collection/collection.dart'; import 'package:sentry/sentry.dart'; import 'package:test/test.dart'; +import '../mocks.dart'; + void main() { final timestamp = DateTime.now(); @@ -12,6 +14,7 @@ void main() { level: SentryLevel.warning, category: 'category', type: 'type', + unknown: testUnknown, ); final breadcrumbJson = { @@ -22,6 +25,7 @@ void main() { 'level': 'warning', 'type': 'type', }; + breadcrumbJson.addAll(testUnknown); group('json', () { test('toJson', () { @@ -86,7 +90,7 @@ void main() { level: SentryLevel.fatal, reason: 'OK', statusCode: 200, - requestDuration: Duration.zero, + requestDuration: Duration(milliseconds: 55), timestamp: DateTime.now(), requestBodySize: 2, responseBodySize: 3, @@ -103,17 +107,43 @@ void main() { 'method': 'GET', 'status_code': 200, 'reason': 'OK', - 'duration': '0:00:00.000000', + 'duration': '0:00:00.055000', 'request_body_size': 2, 'response_body_size': 3, 'http.query': 'foo=bar', - 'http.fragment': 'baz' + 'http.fragment': 'baz', + 'start_timestamp': breadcrumb.timestamp.millisecondsSinceEpoch - 55, + 'end_timestamp': breadcrumb.timestamp.millisecondsSinceEpoch }, 'level': 'fatal', 'type': 'http', }); }); + test('Breadcrumb http', () { + final breadcrumb = Breadcrumb.http( + url: Uri.parse('https://example.org'), + method: 'GET', + requestDuration: Duration(milliseconds: 10), + ); + final json = breadcrumb.toJson(); + + expect(json, { + 'timestamp': + formatDateAsIso8601WithMillisPrecision(breadcrumb.timestamp), + 'category': 'http', + 'data': { + 'url': 'https://example.org', + 'method': 'GET', + 'duration': '0:00:00.010000', + 'start_timestamp': breadcrumb.timestamp.millisecondsSinceEpoch - 10, + 'end_timestamp': breadcrumb.timestamp.millisecondsSinceEpoch + }, + 'level': 'info', + 'type': 'http', + }); + }); + test('Minimal Breadcrumb http', () { final breadcrumb = Breadcrumb.http( url: Uri.parse('https://example.org'), diff --git a/dart/test/protocol/debug_image_test.dart b/dart/test/protocol/debug_image_test.dart index d06dad4b5e..5f3de134e1 100644 --- a/dart/test/protocol/debug_image_test.dart +++ b/dart/test/protocol/debug_image_test.dart @@ -2,6 +2,8 @@ import 'package:collection/collection.dart'; import 'package:sentry/sentry.dart'; import 'package:test/test.dart'; +import '../mocks.dart'; + void main() { final debugImage = DebugImage( type: 'type', @@ -13,6 +15,7 @@ void main() { codeFile: 'codeFile', arch: 'arch', codeId: 'codeId', + unknown: testUnknown, ); final debugImageJson = { @@ -26,6 +29,7 @@ void main() { 'arch': 'arch', 'code_id': 'codeId', }; + debugImageJson.addAll(testUnknown); group('json', () { test('toJson', () { diff --git a/dart/test/protocol/debug_meta_test.dart b/dart/test/protocol/debug_meta_test.dart index 21caf02747..4b5624f4a9 100644 --- a/dart/test/protocol/debug_meta_test.dart +++ b/dart/test/protocol/debug_meta_test.dart @@ -2,12 +2,15 @@ import 'package:collection/collection.dart'; import 'package:sentry/sentry.dart'; import 'package:test/test.dart'; +import '../mocks.dart'; + void main() { final debugMeta = DebugMeta( sdk: SdkInfo( sdkName: 'sdkName', ), images: [DebugImage(type: 'macho', uuid: 'uuid')], + unknown: testUnknown, ); final debugMetaJson = { @@ -16,6 +19,7 @@ void main() { {'uuid': 'uuid', 'type': 'macho'} ] }; + debugMetaJson.addAll(testUnknown); group('json', () { test('toJson', () { diff --git a/dart/test/protocol/mechanism_test.dart b/dart/test/protocol/mechanism_test.dart index 857a0529a9..d9bdc3a7cf 100644 --- a/dart/test/protocol/mechanism_test.dart +++ b/dart/test/protocol/mechanism_test.dart @@ -2,6 +2,8 @@ import 'package:collection/collection.dart'; import 'package:sentry/sentry.dart'; import 'package:test/test.dart'; +import '../mocks.dart'; + void main() { final mechanism = Mechanism( type: 'type', @@ -15,6 +17,7 @@ void main() { exceptionId: 0, parentId: 0, source: 'source', + unknown: testUnknown, ); final mechanismJson = { @@ -30,6 +33,7 @@ void main() { 'exception_id': 0, 'parent_id': 0, }; + mechanismJson.addAll(testUnknown); group('json', () { test('toJson', () { diff --git a/dart/test/protocol/rate_limiter_test.dart b/dart/test/protocol/rate_limiter_test.dart index 1f52a60003..96d91ea685 100644 --- a/dart/test/protocol/rate_limiter_test.dart +++ b/dart/test/protocol/rate_limiter_test.dart @@ -1,3 +1,4 @@ +import 'package:collection/collection.dart'; import 'package:sentry/sentry.dart'; import 'package:sentry/src/client_reports/discard_reason.dart'; import 'package:sentry/src/transport/data_category.dart'; @@ -9,6 +10,7 @@ import 'package:sentry/src/sentry_envelope_header.dart'; import '../mocks/mock_client_report_recorder.dart'; import '../mocks/mock_hub.dart'; +import '../test_utils.dart'; void main() { var fixture = Fixture(); @@ -205,14 +207,18 @@ void main() { final result = rateLimiter.filter(eventEnvelope); expect(result, isNull); - expect(fixture.mockRecorder.category, DataCategory.error); - expect(fixture.mockRecorder.reason, DiscardReason.rateLimitBackoff); + expect(fixture.mockRecorder.discardedEvents.first.category, + DataCategory.error); + expect(fixture.mockRecorder.discardedEvents.first.reason, + DiscardReason.rateLimitBackoff); }); test('dropping of transaction recorded', () { final rateLimiter = fixture.getSut(); final transaction = fixture.getTransaction(); + transaction.tracer.startChild('child1'); + transaction.tracer.startChild('child2'); final eventItem = SentryEnvelopeItem.fromTransaction(transaction); final eventEnvelope = SentryEnvelope( SentryEnvelopeHeader.newEventId(), @@ -225,8 +231,21 @@ void main() { final result = rateLimiter.filter(eventEnvelope); expect(result, isNull); - expect(fixture.mockRecorder.category, DataCategory.transaction); - expect(fixture.mockRecorder.reason, DiscardReason.rateLimitBackoff); + expect(fixture.mockRecorder.discardedEvents.length, 2); + + final transactionDiscardedEvent = fixture.mockRecorder.discardedEvents + .firstWhereOrNull((element) => + element.category == DataCategory.transaction && + element.reason == DiscardReason.rateLimitBackoff); + + final spanDiscardedEvent = fixture.mockRecorder.discardedEvents + .firstWhereOrNull((element) => + element.category == DataCategory.span && + element.reason == DiscardReason.rateLimitBackoff); + + expect(transactionDiscardedEvent, isNotNull); + expect(spanDiscardedEvent, isNotNull); + expect(spanDiscardedEvent!.quantity, 3); }); test('dropping of metrics recorded', () { @@ -244,8 +263,10 @@ void main() { final result = rateLimiter.filter(eventEnvelope); expect(result, isNull); - expect(fixture.mockRecorder.category, DataCategory.metricBucket); - expect(fixture.mockRecorder.reason, DiscardReason.rateLimitBackoff); + expect(fixture.mockRecorder.discardedEvents.first.category, + DataCategory.metricBucket); + expect(fixture.mockRecorder.discardedEvents.first.reason, + DiscardReason.rateLimitBackoff); }); group('apply rateLimit', () { @@ -348,7 +369,7 @@ class Fixture { late var mockRecorder = MockClientReportRecorder(); RateLimiter getSut() { - final options = SentryOptions(); + final options = defaultTestOptions(); options.clock = _currentDateTime; options.recorder = mockRecorder; diff --git a/dart/test/protocol/sdk_info_test.dart b/dart/test/protocol/sdk_info_test.dart index 50e3c3fdcd..522638f238 100644 --- a/dart/test/protocol/sdk_info_test.dart +++ b/dart/test/protocol/sdk_info_test.dart @@ -2,12 +2,15 @@ import 'package:collection/collection.dart'; import 'package:sentry/sentry.dart'; import 'package:test/test.dart'; +import '../mocks.dart'; + void main() { final sdkInfo = SdkInfo( sdkName: 'sdkName', versionMajor: 1, versionMinor: 2, versionPatchlevel: 3, + unknown: testUnknown, ); final sdkInfoJson = { @@ -16,6 +19,7 @@ void main() { 'version_minor': 2, 'version_patchlevel': 3, }; + sdkInfoJson.addAll(testUnknown); group('json', () { test('toJson', () { @@ -30,6 +34,9 @@ void main() { final sdkInfo = SdkInfo.fromJson(sdkInfoJson); final json = sdkInfo.toJson(); + print(sdkInfo); + print(json); + expect( MapEquality().equals(sdkInfoJson, json), true, diff --git a/dart/test/protocol/sdk_version_test.dart b/dart/test/protocol/sdk_version_test.dart index 92f799712c..b2e85a73c4 100644 --- a/dart/test/protocol/sdk_version_test.dart +++ b/dart/test/protocol/sdk_version_test.dart @@ -2,6 +2,8 @@ import 'package:collection/collection.dart'; import 'package:sentry/sentry.dart'; import 'package:test/test.dart'; +import '../mocks.dart'; + void main() { group('json', () { final fixture = Fixture(); @@ -95,10 +97,15 @@ class Fixture { ], }; + Fixture() { + sdkVersionJson.addAll(testUnknown); + } + SdkVersion getSut() => SdkVersion( name: 'name', version: 'version', integrations: ['test'], packages: [SentryPackage('name', 'version')], + unknown: testUnknown, ); } diff --git a/dart/test/protocol/sentry_app_test.dart b/dart/test/protocol/sentry_app_test.dart index 0fdef8a780..fbc468d3d9 100644 --- a/dart/test/protocol/sentry_app_test.dart +++ b/dart/test/protocol/sentry_app_test.dart @@ -2,6 +2,8 @@ import 'package:collection/collection.dart'; import 'package:sentry/sentry.dart'; import 'package:test/test.dart'; +import '../mocks.dart'; + void main() { final testStartTime = DateTime.fromMicrosecondsSinceEpoch(0); @@ -16,6 +18,7 @@ void main() { inForeground: true, viewNames: ['fixture-viewName', 'fixture-viewName2'], textScale: 2.0, + unknown: testUnknown, ); final sentryAppJson = { @@ -30,6 +33,7 @@ void main() { 'view_names': ['fixture-viewName', 'fixture-viewName2'], 'text_scale': 2.0, }; + sentryAppJson.addAll(testUnknown); group('json', () { test('toJson', () { diff --git a/dart/test/protocol/sentry_baggage_header_test.dart b/dart/test/protocol/sentry_baggage_header_test.dart index 38428be41a..910929776e 100644 --- a/dart/test/protocol/sentry_baggage_header_test.dart +++ b/dart/test/protocol/sentry_baggage_header_test.dart @@ -17,15 +17,28 @@ void main() { baggage.setRelease('release'); baggage.setEnvironment('environment'); baggage.setUserId('userId'); + // ignore: deprecated_member_use_from_same_package baggage.setUserSegment('userSegment'); baggage.setTransaction('transaction'); baggage.setSampleRate('1.0'); baggage.setSampled('false'); + final replayId = SentryId.newId().toString(); + baggage.setReplayId(replayId); final baggageHeader = SentryBaggageHeader.fromBaggage(baggage); - expect(baggageHeader.value, - 'sentry-trace_id=$id,sentry-public_key=publicKey,sentry-release=release,sentry-environment=environment,sentry-user_id=userId,sentry-user_segment=userSegment,sentry-transaction=transaction,sentry-sample_rate=1.0,sentry-sampled=false'); + expect( + baggageHeader.value, + 'sentry-trace_id=$id,' + 'sentry-public_key=publicKey,' + 'sentry-release=release,' + 'sentry-environment=environment,' + 'sentry-user_id=userId,' + 'sentry-user_segment=userSegment,' + 'sentry-transaction=transaction,' + 'sentry-sample_rate=1.0,' + 'sentry-sampled=false,' + 'sentry-replay_id=$replayId'); }); }); } diff --git a/dart/test/protocol/sentry_device_test.dart b/dart/test/protocol/sentry_device_test.dart index d7ed3e518f..7f405e09bf 100644 --- a/dart/test/protocol/sentry_device_test.dart +++ b/dart/test/protocol/sentry_device_test.dart @@ -2,6 +2,8 @@ import 'package:collection/collection.dart'; import 'package:sentry/sentry.dart'; import 'package:test/test.dart'; +import '../mocks.dart'; + void main() { final testBootTime = DateTime.fromMicrosecondsSinceEpoch(0); @@ -42,6 +44,7 @@ void main() { supportsVibration: true, screenHeightPixels: 100, screenWidthPixels: 100, + unknown: testUnknown, ); final sentryDeviceJson = { @@ -82,6 +85,7 @@ void main() { 'screen_height_pixels': 100, 'screen_width_pixels': 100, }; + sentryDeviceJson.addAll(testUnknown); group('json', () { test('toJson', () { diff --git a/dart/test/protocol/sentry_exception_test.dart b/dart/test/protocol/sentry_exception_test.dart index 15c75b0191..4541e67332 100644 --- a/dart/test/protocol/sentry_exception_test.dart +++ b/dart/test/protocol/sentry_exception_test.dart @@ -2,6 +2,8 @@ import 'package:collection/collection.dart'; import 'package:sentry/sentry.dart'; import 'package:test/test.dart'; +import '../mocks.dart'; + void main() { final sentryException = SentryException( type: 'type', @@ -10,6 +12,7 @@ void main() { stackTrace: SentryStackTrace(frames: [SentryStackFrame(absPath: 'abs')]), mechanism: Mechanism(type: 'type'), threadId: 1, + unknown: testUnknown, ); final sentryExceptionJson = { @@ -24,6 +27,7 @@ void main() { 'mechanism': {'type': 'type'}, 'thread_id': 1, }; + sentryExceptionJson.addAll(testUnknown); group('json', () { test('fromJson', () { diff --git a/dart/test/protocol/sentry_gpu_test.dart b/dart/test/protocol/sentry_gpu_test.dart index 443b50ea91..af2907a648 100644 --- a/dart/test/protocol/sentry_gpu_test.dart +++ b/dart/test/protocol/sentry_gpu_test.dart @@ -2,17 +2,21 @@ import 'package:collection/collection.dart'; import 'package:sentry/sentry.dart'; import 'package:test/test.dart'; +import '../mocks.dart'; + void main() { final sentryGpu = SentryGpu( - name: 'fixture-name', - id: 1, - vendorId: '2', - vendorName: 'fixture-vendorName', - memorySize: 3, - apiType: 'fixture-apiType', - multiThreadedRendering: true, - version: '4', - npotSupport: 'fixture-npotSupport'); + name: 'fixture-name', + id: 1, + vendorId: '2', + vendorName: 'fixture-vendorName', + memorySize: 3, + apiType: 'fixture-apiType', + multiThreadedRendering: true, + version: '4', + npotSupport: 'fixture-npotSupport', + unknown: testUnknown, + ); final sentryGpuJson = { 'name': 'fixture-name', @@ -25,6 +29,7 @@ void main() { 'version': '4', 'npot_support': 'fixture-npotSupport' }; + sentryGpuJson.addAll(testUnknown); group('json', () { test('toJson', () { diff --git a/dart/test/protocol/sentry_message_test.dart b/dart/test/protocol/sentry_message_test.dart index 7112642ed2..b89c0ef08f 100644 --- a/dart/test/protocol/sentry_message_test.dart +++ b/dart/test/protocol/sentry_message_test.dart @@ -2,11 +2,14 @@ import 'package:collection/collection.dart'; import 'package:sentry/sentry.dart'; import 'package:test/test.dart'; +import '../mocks.dart'; + void main() { final sentryMessage = SentryMessage( 'message 1', template: 'message %d', params: ['1'], + unknown: testUnknown, ); final sentryMessageJson = { @@ -14,6 +17,7 @@ void main() { 'message': 'message %d', 'params': ['1'], }; + sentryMessageJson.addAll(testUnknown); group('json', () { test('toJson', () { diff --git a/dart/test/protocol/sentry_operating_system_test.dart b/dart/test/protocol/sentry_operating_system_test.dart index dee235712d..9ca41e8590 100644 --- a/dart/test/protocol/sentry_operating_system_test.dart +++ b/dart/test/protocol/sentry_operating_system_test.dart @@ -2,14 +2,18 @@ import 'package:collection/collection.dart'; import 'package:sentry/sentry.dart'; import 'package:test/test.dart'; +import '../mocks.dart'; + void main() { final sentryOperatingSystem = SentryOperatingSystem( - name: 'fixture-name', - version: 'fixture-version', - build: 'fixture-build', - kernelVersion: 'fixture-kernelVersion', - rooted: true, - rawDescription: 'fixture-rawDescription'); + name: 'fixture-name', + version: 'fixture-version', + build: 'fixture-build', + kernelVersion: 'fixture-kernelVersion', + rooted: true, + rawDescription: 'fixture-rawDescription', + unknown: testUnknown, + ); final sentryOperatingSystemJson = { 'name': 'fixture-name', @@ -19,6 +23,7 @@ void main() { 'rooted': true, 'raw_description': 'fixture-rawDescription' }; + sentryOperatingSystemJson.addAll(testUnknown); group('json', () { test('toJson', () { diff --git a/dart/test/protocol/sentry_package_test.dart b/dart/test/protocol/sentry_package_test.dart index b8c1d40e71..7f9e82bfc4 100644 --- a/dart/test/protocol/sentry_package_test.dart +++ b/dart/test/protocol/sentry_package_test.dart @@ -2,16 +2,20 @@ import 'package:collection/collection.dart'; import 'package:sentry/sentry.dart'; import 'package:test/test.dart'; +import '../mocks.dart'; + void main() { final sentryPackage = SentryPackage( 'name', 'version', + unknown: testUnknown, ); final sentryPackageJson = { 'name': 'name', 'version': 'version', }; + sentryPackageJson.addAll(testUnknown); group('json', () { test('toJson', () { diff --git a/dart/test/protocol/sentry_proxy_test.dart b/dart/test/protocol/sentry_proxy_test.dart new file mode 100644 index 0000000000..795dc1793d --- /dev/null +++ b/dart/test/protocol/sentry_proxy_test.dart @@ -0,0 +1,102 @@ +import 'package:collection/collection.dart'; +import 'package:sentry/sentry.dart'; +import 'package:test/test.dart'; + +void main() { + final proxy = SentryProxy( + host: 'localhost', + port: 8080, + type: SentryProxyType.http, + user: 'admin', + pass: '0000', + ); + + final proxyJson = { + 'host': 'localhost', + 'port': 8080, + 'type': 'HTTP', + 'user': 'admin', + 'pass': '0000', + }; + + group('toPacString', () { + test('returns "DIRECT" for ProxyType.direct', () { + SentryProxy proxy = SentryProxy(type: SentryProxyType.direct); + expect(proxy.toPacString(), equals('DIRECT')); + }); + + test('returns "PROXY host:port" for ProxyType.http with host and port', () { + SentryProxy proxy = SentryProxy( + type: SentryProxyType.http, host: 'localhost', port: 8080); + expect(proxy.toPacString(), equals('PROXY localhost:8080')); + }); + + test('returns "PROXY host" for ProxyType.http with host only', () { + SentryProxy proxy = + SentryProxy(type: SentryProxyType.http, host: 'localhost'); + expect(proxy.toPacString(), equals('PROXY localhost')); + }); + + test('returns "SOCKS host:port" for ProxyType.socks with host and port', + () { + SentryProxy proxy = SentryProxy( + type: SentryProxyType.socks, host: 'localhost', port: 8080); + expect(proxy.toPacString(), equals('SOCKS localhost:8080')); + }); + + test('returns "SOCKS host" for ProxyType.socks with host only', () { + SentryProxy proxy = + SentryProxy(type: SentryProxyType.socks, host: 'localhost'); + expect(proxy.toPacString(), equals('SOCKS localhost')); + }); + + test('falls back to "DIRECT" if http is missing host', () { + SentryProxy proxy = SentryProxy(type: SentryProxyType.http); + expect(proxy.toPacString(), equals('DIRECT')); + }); + + test('falls back to "DIRECT" if socks is missing host', () { + SentryProxy proxy = SentryProxy(type: SentryProxyType.socks); + expect(proxy.toPacString(), equals('DIRECT')); + }); + }); + + group('json', () { + test('toJson', () { + final json = proxy.toJson(); + + expect( + DeepCollectionEquality().equals(proxyJson, json), + true, + ); + }); + }); + + group('copyWith', () { + test('copyWith keeps unchanged', () { + final data = proxy; + + final copy = data.copyWith(); + + expect(data.toJson(), copy.toJson()); + }); + + test('copyWith takes new values', () { + final data = proxy; + + final copy = data.copyWith( + host: 'localhost-2', + port: 9001, + type: SentryProxyType.socks, + user: 'user', + pass: '1234', + ); + + expect('localhost-2', copy.host); + expect(9001, copy.port); + expect(SentryProxyType.socks, copy.type); + expect('user', copy.user); + expect('1234', copy.pass); + }); + }); +} diff --git a/dart/test/protocol/sentry_request_test.dart b/dart/test/protocol/sentry_request_test.dart index a1d186632b..0aeb24dc1d 100644 --- a/dart/test/protocol/sentry_request_test.dart +++ b/dart/test/protocol/sentry_request_test.dart @@ -2,6 +2,8 @@ import 'package:collection/collection.dart'; import 'package:sentry/sentry.dart'; import 'package:test/test.dart'; +import '../mocks.dart'; + void main() { final sentryRequest = SentryRequest( url: 'url', @@ -14,6 +16,7 @@ void main() { apiTarget: 'GraphQL', // ignore: deprecated_member_use_from_same_package other: {'other_key': 'other_value'}, + unknown: testUnknown, ); final sentryRequestJson = { @@ -27,6 +30,7 @@ void main() { 'api_target': 'GraphQL', 'other': {'other_key': 'other_value'}, }; + sentryRequestJson.addAll(testUnknown); group('json', () { test('toJson', () { diff --git a/dart/test/protocol/sentry_runtime_test.dart b/dart/test/protocol/sentry_runtime_test.dart index 578eb8c9a3..2537950d8e 100644 --- a/dart/test/protocol/sentry_runtime_test.dart +++ b/dart/test/protocol/sentry_runtime_test.dart @@ -2,12 +2,15 @@ import 'package:collection/collection.dart'; import 'package:sentry/sentry.dart'; import 'package:test/test.dart'; +import '../mocks.dart'; + void main() { final sentryRuntime = SentryRuntime( key: 'key', name: 'name', version: 'version', rawDescription: 'rawDescription', + unknown: testUnknown, ); final sentryRuntimeJson = { @@ -15,6 +18,7 @@ void main() { 'version': 'version', 'raw_description': 'rawDescription', }; + sentryRuntimeJson.addAll(testUnknown); group('json', () { test('toJson', () { diff --git a/dart/test/protocol/sentry_stack_frame_test.dart b/dart/test/protocol/sentry_stack_frame_test.dart index 69b5ad30fa..eb4619dfbc 100644 --- a/dart/test/protocol/sentry_stack_frame_test.dart +++ b/dart/test/protocol/sentry_stack_frame_test.dart @@ -2,6 +2,8 @@ import 'package:collection/collection.dart'; import 'package:sentry/sentry.dart'; import 'package:test/test.dart'; +import '../mocks.dart'; + void main() { final sentryStackFrame = SentryStackFrame( absPath: 'absPath', @@ -23,6 +25,7 @@ void main() { preContext: ['a'], postContext: ['b'], vars: {'key': 'value'}, + unknown: testUnknown, ); final sentryStackFrameJson = { @@ -46,6 +49,7 @@ void main() { 'instruction_addr': 'instructionAddr', 'raw_function': 'rawFunction', }; + sentryStackFrameJson.addAll(testUnknown); group('json', () { test('toJson', () { diff --git a/dart/test/protocol/sentry_stack_trace_test.dart b/dart/test/protocol/sentry_stack_trace_test.dart index ce3f4817d6..4c40032381 100644 --- a/dart/test/protocol/sentry_stack_trace_test.dart +++ b/dart/test/protocol/sentry_stack_trace_test.dart @@ -2,10 +2,15 @@ import 'package:collection/collection.dart'; import 'package:sentry/sentry.dart'; import 'package:test/test.dart'; +import '../mocks.dart'; + void main() { final sentryStackTrace = SentryStackTrace( frames: [SentryStackFrame(absPath: 'abs')], registers: {'key': 'value'}, + lang: 'de', + snapshot: true, + unknown: testUnknown, ); final sentryStackTraceJson = { @@ -13,7 +18,10 @@ void main() { {'abs_path': 'abs'} ], 'registers': {'key': 'value'}, + 'lang': 'de', + 'snapshot': true, }; + sentryStackTraceJson.addAll(testUnknown); group('json', () { test('toJson', () { diff --git a/dart/test/protocol/sentry_transaction_info_test.dart b/dart/test/protocol/sentry_transaction_info_test.dart index 951fb08453..31438d820c 100644 --- a/dart/test/protocol/sentry_transaction_info_test.dart +++ b/dart/test/protocol/sentry_transaction_info_test.dart @@ -1,16 +1,24 @@ import 'package:sentry/sentry.dart'; import 'package:test/test.dart'; +import '../mocks.dart'; + void main() { group('$SentryTransactionInfo', () { + final info = SentryTransactionInfo( + 'component', + unknown: testUnknown, + ); + + final json = {'source': 'component'}; + json.addAll(testUnknown); + test('returns source', () { - final info = SentryTransactionInfo('component'); expect(info.source, 'component'); }); test('toJson has source', () { - final info = SentryTransactionInfo('component'); - expect(info.toJson(), {'source': 'component'}); + expect(info.toJson(), json); }); test('fromJson has source', () { diff --git a/dart/test/protocol/sentry_user_test.dart b/dart/test/protocol/sentry_user_test.dart index 2a958624e2..dbc6e6cd87 100644 --- a/dart/test/protocol/sentry_user_test.dart +++ b/dart/test/protocol/sentry_user_test.dart @@ -2,6 +2,8 @@ import 'package:collection/collection.dart'; import 'package:sentry/sentry.dart'; import 'package:test/test.dart'; +import '../mocks.dart'; + void main() { final sentryUser = SentryUser( id: 'id', @@ -10,6 +12,7 @@ void main() { ipAddress: 'ipAddress', data: {'key': 'value'}, segment: 'seg', + unknown: testUnknown, ); final sentryUserJson = { @@ -20,11 +23,14 @@ void main() { 'data': {'key': 'value'}, 'segment': 'seg', }; + sentryUserJson.addAll(testUnknown); group('json', () { test('toJson', () { final json = sentryUser.toJson(); + print("$json"); + expect( DeepCollectionEquality().equals(sentryUserJson, json), true, @@ -91,6 +97,7 @@ void main() { expect('email1', copy.email); expect('ipAddress1', copy.ipAddress); expect({'key1': 'value1'}, copy.data); + // ignore: deprecated_member_use_from_same_package expect('seg1', copy.segment); }); }); diff --git a/dart/test/recursive_exception_cause_extractor_test.dart b/dart/test/recursive_exception_cause_extractor_test.dart index e32400b36b..b2da696998 100644 --- a/dart/test/recursive_exception_cause_extractor_test.dart +++ b/dart/test/recursive_exception_cause_extractor_test.dart @@ -2,10 +2,9 @@ import 'package:sentry/src/exception_cause.dart'; import 'package:sentry/src/exception_cause_extractor.dart'; import 'package:sentry/src/recursive_exception_cause_extractor.dart'; import 'package:sentry/src/protocol/mechanism.dart'; -import 'package:sentry/src/sentry_options.dart'; import 'package:sentry/src/throwable_mechanism.dart'; import 'package:test/test.dart'; -import 'mocks.dart'; +import 'test_utils.dart'; void main() { late Fixture fixture; @@ -91,6 +90,7 @@ void main() { ExceptionBCauseExtractor(), ); + fixture.options.automatedTestMode = false; final sut = fixture.getSut(); final flattened = sut.flatten(errorA, null); @@ -101,7 +101,7 @@ void main() { } class Fixture { - final options = SentryOptions(dsn: fakeDsn); + final options = defaultTestOptions(); RecursiveExceptionCauseExtractor getSut() { return RecursiveExceptionCauseExtractor(options); diff --git a/dart/test/run_zoned_guarded_integration_test.dart b/dart/test/run_zoned_guarded_integration_test.dart index dd3c2aa0f3..71f87e9d12 100644 --- a/dart/test/run_zoned_guarded_integration_test.dart +++ b/dart/test/run_zoned_guarded_integration_test.dart @@ -4,9 +4,9 @@ library dart_test; import 'package:sentry/sentry.dart'; import 'package:test/test.dart'; -import 'mocks.dart'; import 'mocks/mock_hub.dart'; import 'mocks/mock_sentry_client.dart'; +import 'test_utils.dart'; void main() { group(RunZonedGuardedIntegration, () { @@ -77,7 +77,7 @@ void main() { class Fixture { final hub = MockHub(); - final options = SentryOptions(dsn: fakeDsn)..tracesSampleRate = 1.0; + final options = defaultTestOptions()..tracesSampleRate = 1.0; RunZonedGuardedIntegration getSut( {required RunZonedGuardedRunner runner, diff --git a/dart/test/scope_test.dart b/dart/test/scope_test.dart index 66cc543b6b..59b42e4227 100644 --- a/dart/test/scope_test.dart +++ b/dart/test/scope_test.dart @@ -1,3 +1,5 @@ +// ignore_for_file: deprecated_member_use_from_same_package + import 'package:collection/collection.dart'; import 'package:sentry/sentry.dart'; import 'package:sentry/src/sentry_tracer.dart'; @@ -6,6 +8,7 @@ import 'package:test/test.dart'; import 'mocks.dart'; import 'mocks/mock_hub.dart'; import 'mocks/mock_scope_observer.dart'; +import 'test_utils.dart'; void main() { late Fixture fixture; @@ -86,6 +89,14 @@ void main() { expect(sut.fingerprint, fingerprints); }); + test('sets replay ID', () { + final sut = fixture.getSut(); + + sut.replayId = SentryId.fromId('1'); + + expect(sut.replayId, SentryId.fromId('1')); + }); + test('adds $Breadcrumb', () { final sut = fixture.getSut(); @@ -305,6 +316,7 @@ void main() { sut.level = SentryLevel.debug; sut.transaction = 'test'; sut.span = null; + sut.replayId = SentryId.newId(); final user = SentryUser(id: 'test'); sut.setUser(user); @@ -320,21 +332,15 @@ void main() { sut.clear(); expect(sut.breadcrumbs.length, 0); - expect(sut.level, null); - expect(sut.transaction, null); expect(sut.span, null); - expect(sut.user, null); - expect(sut.fingerprint.length, 0); - expect(sut.tags.length, 0); - expect(sut.extra.length, 0); - expect(sut.eventProcessors.length, 0); + expect(sut.replayId, isNull); }); test('clones', () async { @@ -347,6 +353,7 @@ void main() { sut.addAttachment(SentryAttachment.fromIntList([0, 0, 0, 0], 'test.txt')); sut.span = NoOpSentrySpan(); sut.level = SentryLevel.warning; + sut.replayId = SentryId.newId(); await sut.setUser(SentryUser(id: 'id')); await sut.setTag('key', 'vakye'); await sut.setExtra('key', 'vakye'); @@ -367,6 +374,7 @@ void main() { true, ); expect(sut.span, clone.span); + expect(sut.replayId, clone.replayId); }); test('clone does not additionally call observers', () async { @@ -417,10 +425,9 @@ void main() { test('apply context to event', () async { final event = SentryEvent( tags: const {'etag': '987'}, - // ignore: deprecated_member_use_from_same_package extra: const {'e-infos': 'abc'}, ); - final scope = Scope(SentryOptions(dsn: fakeDsn)) + final scope = Scope(defaultTestOptions()) ..fingerprint = ['example-dart'] ..transaction = '/example/app' ..level = SentryLevel.warning @@ -442,16 +449,13 @@ void main() { expect(updatedEvent?.tags, {'etag': '987', 'build': '579', 'page-locale': 'en-us'}); expect( - // ignore: deprecated_member_use_from_same_package - updatedEvent?.extra, - {'e-infos': 'abc', 'company-name': 'Dart Inc'}); + updatedEvent?.extra, {'e-infos': 'abc', 'company-name': 'Dart Inc'}); expect(updatedEvent?.contexts['theme'], {'value': 'material'}); }); test('apply trace context to event', () async { final event = SentryEvent(); - final scope = Scope(SentryOptions(dsn: fakeDsn)) - ..span = fixture.sentryTracer; + final scope = Scope(defaultTestOptions())..span = fixture.sentryTracer; final updatedEvent = await scope.applyToEvent(event, Hint()); @@ -469,7 +473,7 @@ void main() { fingerprint: ['event-fingerprint'], breadcrumbs: [eventBreadcrumb], ); - final scope = Scope(SentryOptions(dsn: fakeDsn)) + final scope = Scope(defaultTestOptions()) ..fingerprint = ['example-dart'] ..transaction = '/example/app'; @@ -498,7 +502,7 @@ void main() { operatingSystem: SentryOperatingSystem(name: 'event-os'), ), ); - final scope = Scope(SentryOptions(dsn: fakeDsn)); + final scope = Scope(defaultTestOptions()); await scope.setContexts( SentryDevice.type, SentryDevice(name: 'context-device'), @@ -538,7 +542,7 @@ void main() { test('should apply the scope.contexts values', () async { final event = SentryEvent(); - final scope = Scope(SentryOptions(dsn: fakeDsn)); + final scope = Scope(defaultTestOptions()); await scope.setContexts( SentryDevice.type, SentryDevice(name: 'context-device')); await scope.setContexts(SentryApp.type, SentryApp(name: 'context-app')); @@ -576,8 +580,7 @@ void main() { test('should apply the scope level', () async { final event = SentryEvent(level: SentryLevel.warning); - final scope = Scope(SentryOptions(dsn: fakeDsn)) - ..level = SentryLevel.error; + final scope = Scope(defaultTestOptions())..level = SentryLevel.error; final updatedEvent = await scope.applyToEvent(event, Hint()); @@ -586,8 +589,7 @@ void main() { test('should apply the scope transaction from the span', () async { final event = SentryEvent(); - final scope = Scope(SentryOptions(dsn: fakeDsn)) - ..span = fixture.sentryTracer; + final scope = Scope(defaultTestOptions())..span = fixture.sentryTracer; final updatedEvent = await scope.applyToEvent(event, Hint()); @@ -608,7 +610,7 @@ void main() { test('should not apply fingerprint if transaction', () async { var tr = SentryTransaction(fixture.sentryTracer); - final scope = Scope(SentryOptions(dsn: fakeDsn))..fingerprint = ['test']; + final scope = Scope(defaultTestOptions())..fingerprint = ['test']; final updatedTr = await scope.applyToEvent(tr, Hint()); @@ -617,7 +619,7 @@ void main() { test('should not apply level if transaction', () async { var tr = SentryTransaction(fixture.sentryTracer); - final scope = Scope(SentryOptions(dsn: fakeDsn))..level = SentryLevel.error; + final scope = Scope(defaultTestOptions())..level = SentryLevel.error; final updatedTr = await scope.applyToEvent(tr, Hint()); @@ -626,7 +628,7 @@ void main() { test('apply sampled to trace', () async { var tr = SentryTransaction(fixture.sentryTracer); - final scope = Scope(SentryOptions(dsn: fakeDsn))..level = SentryLevel.error; + final scope = Scope(defaultTestOptions())..level = SentryLevel.error; final updatedTr = await scope.applyToEvent(tr, Hint()); @@ -716,6 +718,7 @@ void main() { test("addBreadcrumb with beforeBreadcrumb error handled ", () async { final exception = Exception("before breadcrumb exception"); + fixture.options.automatedTestMode = false; final sut = fixture.getSut( beforeBreadcrumbCallback: ( Breadcrumb? breadcrumb, @@ -740,6 +743,7 @@ void main() { var numberOfBeforeBreadcrumbCalls = 0; final exception = Exception("before breadcrumb exception"); + fixture.options.automatedTestMode = false; final sut = fixture.getSut( beforeBreadcrumbCallback: ( Breadcrumb? breadcrumb, @@ -772,7 +776,7 @@ void main() { class Fixture { final mockScopeObserver = MockScopeObserver(); - final options = SentryOptions(dsn: fakeDsn); + final options = defaultTestOptions(); final sentryTracer = SentryTracer( SentryTransactionContext( diff --git a/dart/test/sentry_attachment_test.dart b/dart/test/sentry_attachment_test.dart index 578cb5ad9b..267ca62601 100644 --- a/dart/test/sentry_attachment_test.dart +++ b/dart/test/sentry_attachment_test.dart @@ -3,8 +3,8 @@ import 'dart:typed_data'; import 'package:sentry/sentry.dart'; import 'package:test/test.dart'; -import 'mocks.dart'; import 'mocks/mock_transport.dart'; +import 'test_utils.dart'; void main() { group('$SentryAttachment ctor', () { @@ -185,7 +185,7 @@ class Fixture { MockTransport transport = MockTransport(); Hub getSut() { - final options = SentryOptions(dsn: fakeDsn); + final options = defaultTestOptions(); options.transport = transport; return Hub(options); } diff --git a/dart/test/sentry_client_test.dart b/dart/test/sentry_client_test.dart index b8c6bee716..cf3df91d13 100644 --- a/dart/test/sentry_client_test.dart +++ b/dart/test/sentry_client_test.dart @@ -24,6 +24,7 @@ import 'mocks/mock_hub.dart'; import 'mocks/mock_platform.dart'; import 'mocks/mock_platform_checker.dart'; import 'mocks/mock_transport.dart'; +import 'test_utils.dart'; void main() { group('SentryClient captures message', () { @@ -139,7 +140,10 @@ void main() { capturedEvent.threads?.first.id, ); }, - onPlatform: {'js': Skip("Isolates don't exist on the web")}, + onPlatform: { + 'js': Skip("Isolates don't exist on the web"), + 'wasm': Skip("Isolates don't exist on the web") + }, ); test( @@ -809,7 +813,9 @@ void main() { ..fingerprint = fingerprint ..addBreadcrumb(crumb) ..setTag(scopeTagKey, scopeTagValue) - ..setExtra(scopeExtraKey, scopeExtraValue); + // ignore: deprecated_member_use_from_same_package + ..setExtra(scopeExtraKey, scopeExtraValue) + ..replayId = SentryId.fromId('1'); scope.setUser(user); }); @@ -835,6 +841,8 @@ void main() { scopeExtraKey: scopeExtraValue, eventExtraKey: eventExtraValue, }); + expect( + capturedEnvelope.header.traceContext?.replayId, SentryId.fromId('1')); }); }); @@ -1029,6 +1037,166 @@ void main() { }); }); + group('SentryClient ignored errors', () { + late Fixture fixture; + + setUp(() { + fixture = Fixture(); + fixture.options.ignoreErrors = ["my-error", "^error-.*\$"]; + }); + + test('drop event if error message fully matches ignoreErrors value', + () async { + final event = SentryEvent(message: SentryMessage("my-error")); + + final client = fixture.getSut(); + await client.captureEvent(event); + + expect((fixture.transport).called(0), true); + }); + + test('drop event if error message partially matches ignoreErrors value', + () async { + final event = SentryEvent(message: SentryMessage("this is my-error-foo")); + + final client = fixture.getSut(); + await client.captureEvent(event); + + expect((fixture.transport).called(0), true); + }); + + test( + 'drop event if error message partially matches ignoreErrors regex value', + () async { + final event = SentryEvent(message: SentryMessage("error-test message")); + + final client = fixture.getSut(); + await client.captureEvent(event); + + expect((fixture.transport).called(0), true); + }); + + test('send event if error message does not match ignoreErrors value', + () async { + final event = SentryEvent(message: SentryMessage("warning")); + + final client = fixture.getSut(); + await client.captureEvent(event); + + expect((fixture.transport).called(1), true); + }); + + test('send event if no values are set for ignoreErrors', () async { + fixture.options.ignoreErrors = []; + final event = SentryEvent(message: SentryMessage("this is a test event")); + + final client = fixture.getSut(); + await client.captureEvent(event); + + expect((fixture.transport).called(1), true); + }); + }); + + group('SentryClient ignored transactions', () { + late Fixture fixture; + + setUp(() { + fixture = Fixture(); + fixture.options.ignoreTransactions = [ + "my-transaction", + "^transaction-.*\$" + ]; + }); + + test('drop transaction if name fully matches ignoreTransaction value', + () async { + final client = fixture.getSut(); + final fakeTransaction = fixture.fakeTransaction(); + fakeTransaction.tracer.name = "my-transaction"; + await client.captureTransaction(fakeTransaction); + + expect((fixture.transport).called(0), true); + }); + + test('drop transaction if name partially matches ignoreTransaction value', + () async { + final client = fixture.getSut(); + final fakeTransaction = fixture.fakeTransaction(); + fakeTransaction.tracer.name = "this is a transaction-test"; + await client.captureTransaction(fakeTransaction); + + expect((fixture.transport).called(0), true); + }); + + test( + 'drop transaction if name partially matches ignoreTransaction regex value', + () async { + final client = fixture.getSut(); + final fakeTransaction = fixture.fakeTransaction(); + fakeTransaction.tracer.name = "transaction-test message"; + await client.captureTransaction(fakeTransaction); + + expect((fixture.transport).called(0), true); + }); + + test('send transaction if name does not match ignoreTransaction value', + () async { + final client = fixture.getSut(); + final fakeTransaction = fixture.fakeTransaction(); + fakeTransaction.tracer.name = "capture"; + await client.captureTransaction(fakeTransaction); + + expect((fixture.transport).called(1), true); + }); + + test('send transaction if no values are set for ignoreTransaction', + () async { + fixture.options.ignoreTransactions = []; + final client = fixture.getSut(); + final fakeTransaction = fixture.fakeTransaction(); + fakeTransaction.tracer.name = "this is a test transaction"; + await client.captureTransaction(fakeTransaction); + + expect((fixture.transport).called(1), true); + }); + }); + + group('SentryClient ignored exceptions', () { + late Fixture fixture; + + setUp(() { + fixture = Fixture(); + }); + + test('addExceptionFilterForType drops matching error event throwable', + () async { + fixture.options.addExceptionFilterForType(ExceptionWithCause); + + final throwable = ExceptionWithCause(Error(), StackTrace.current); + final event = SentryEvent(throwable: throwable); + + final client = fixture.getSut(); + await client.captureEvent(event); + + expect((fixture.transport).called(0), true); + }); + + test('record ignored exceptions dropping event', () async { + fixture.options.addExceptionFilterForType(ExceptionWithCause); + + final throwable = ExceptionWithCause(Error(), StackTrace.current); + final event = SentryEvent(throwable: throwable); + + final client = fixture.getSut(); + await client.captureEvent(event); + + expect(fixture.recorder.discardedEvents.first.reason, + DiscardReason.eventProcessor); + expect( + fixture.recorder.discardedEvents.first.category, DataCategory.error); + }); + }); + group('SentryClient before send transaction', () { late Fixture fixture; @@ -1082,11 +1250,13 @@ void main() { }); test('thrown error is handled', () async { + fixture.options.automatedTestMode = false; final exception = Exception("before send exception"); final beforeSendTransactionCallback = (SentryTransaction event) { throw exception; }; + fixture.options.automatedTestMode = false; final client = fixture.getSut( beforeSendTransaction: beforeSendTransactionCallback, debug: true); final fakeTransaction = fixture.fakeTransaction(); @@ -1144,11 +1314,13 @@ void main() { }); test('thrown error is handled', () async { + fixture.options.automatedTestMode = false; final exception = Exception("before send exception"); final beforeSendCallback = (SentryEvent event, Hint hint) { throw exception; }; + fixture.options.automatedTestMode = false; final client = fixture.getSut(beforeSend: beforeSendCallback, debug: true); @@ -1321,6 +1493,7 @@ void main() { final client = fixture.getSut(); final scope = Scope(fixture.options); + scope.replayId = SentryId.newId(); scope.span = SentrySpan(fixture.tracer, fixture.tracer.context, MockHub()); @@ -1328,6 +1501,7 @@ void main() { final envelope = fixture.transport.envelopes.first; expect(envelope.header.traceContext, isNotNull); + expect(envelope.header.traceContext?.replayId, scope.replayId); }); test('captureEvent adds attachments from hint', () async { @@ -1384,12 +1558,14 @@ void main() { final context = SentryTraceContextHeader.fromJson({ 'trace_id': '${tr.eventId}', 'public_key': '123', + 'replay_id': '456', }); await client.captureTransaction(tr, traceContext: context); final envelope = fixture.transport.envelopes.first; expect(envelope.header.traceContext, isNotNull); + expect(envelope.header.traceContext?.replayId, SentryId.fromId('456')); }); test('captureUserFeedback calls flush', () async { @@ -1433,12 +1609,119 @@ void main() { }); test('record event processor dropping event', () async { - final client = fixture.getSut(eventProcessor: DropAllEventProcessor()); + bool secondProcessorCalled = false; + fixture.options.addEventProcessor(DropAllEventProcessor()); + fixture.options.addEventProcessor(FunctionEventProcessor((event, hint) { + secondProcessorCalled = true; + return event; + })); + final client = fixture.getSut(); await client.captureEvent(fakeEvent); - expect(fixture.recorder.reason, DiscardReason.eventProcessor); - expect(fixture.recorder.category, DataCategory.error); + expect(fixture.recorder.discardedEvents.first.reason, + DiscardReason.eventProcessor); + expect( + fixture.recorder.discardedEvents.first.category, DataCategory.error); + expect(secondProcessorCalled, isFalse); + }); + + test('record event processor dropping transaction', () async { + final sut = fixture.getSut(eventProcessor: DropAllEventProcessor()); + final transaction = SentryTransaction(fixture.tracer); + fixture.tracer.startChild('child1'); + fixture.tracer.startChild('child2'); + fixture.tracer.startChild('child3'); + + await sut.captureTransaction(transaction); + + expect(fixture.recorder.discardedEvents.length, 2); + + final spanCount = fixture.recorder.discardedEvents + .firstWhere((element) => + element.category == DataCategory.span && + element.reason == DiscardReason.eventProcessor) + .quantity; + expect(spanCount, 4); + }); + + test('record event processor dropping partially spans', () async { + final numberOfSpansDropped = 2; + final sut = fixture.getSut( + eventProcessor: DropSpansEventProcessor(numberOfSpansDropped)); + final transaction = SentryTransaction(fixture.tracer); + fixture.tracer.startChild('child1'); + fixture.tracer.startChild('child2'); + fixture.tracer.startChild('child3'); + + await sut.captureTransaction(transaction); + + expect(fixture.recorder.discardedEvents.length, 1); + + final spanCount = fixture.recorder.discardedEvents + .firstWhere((element) => + element.category == DataCategory.span && + element.reason == DiscardReason.eventProcessor) + .quantity; + expect(spanCount, numberOfSpansDropped); + }); + + test('beforeSendTransaction correctly records partially dropped spans', + () async { + final sut = fixture.getSut(); + final transaction = SentryTransaction(fixture.tracer); + fixture.tracer.startChild('child1'); + fixture.tracer.startChild('child2'); + fixture.tracer.startChild('child3'); + + fixture.options.beforeSendTransaction = (transaction) { + if (transaction.tracer == fixture.tracer) { + return null; + } + return transaction; + }; + + await sut.captureTransaction(transaction); + + expect(fixture.recorder.discardedEvents.length, 2); + + final spanCount = fixture.recorder.discardedEvents + .firstWhere((element) => + element.category == DataCategory.span && + element.reason == DiscardReason.beforeSend) + .quantity; + expect(spanCount, 4); + }); + + test('beforeSendTransaction correctly records partially dropped spans', + () async { + final sut = fixture.getSut(); + final transaction = SentryTransaction(fixture.tracer); + fixture.tracer.startChild('child1'); + fixture.tracer.startChild('child2'); + fixture.tracer.startChild('child3'); + + fixture.options.beforeSendTransaction = (transaction) { + if (transaction.tracer == fixture.tracer) { + transaction.spans + .removeWhere((element) => element.context.operation == 'child2'); + return transaction; + } + return transaction; + }; + + await sut.captureTransaction(transaction); + + // we didn't drop the whole transaction, we only have 1 event for the dropped spans + expect(fixture.recorder.discardedEvents.length, 1); + + // tracer has 3 span children and we dropped 1 of them + final spanCount = fixture.recorder.discardedEvents + .firstWhere((element) => + element.category == DataCategory.span && + element.reason == DiscardReason.beforeSend) + .quantity; + expect(spanCount, 1); }); test('record event processor dropping transaction', () async { @@ -1450,8 +1733,10 @@ void main() { await client.captureTransaction(transaction); - expect(fixture.recorder.reason, DiscardReason.eventProcessor); - expect(fixture.recorder.category, DataCategory.transaction); + expect(fixture.recorder.discardedEvents.first.reason, + DiscardReason.eventProcessor); + expect(fixture.recorder.discardedEvents.first.category, + DataCategory.transaction); }); test('record beforeSend dropping event', () async { @@ -1461,8 +1746,10 @@ void main() { await client.captureEvent(fakeEvent); - expect(fixture.recorder.reason, DiscardReason.beforeSend); - expect(fixture.recorder.category, DataCategory.error); + expect(fixture.recorder.discardedEvents.first.reason, + DiscardReason.beforeSend); + expect( + fixture.recorder.discardedEvents.first.category, DataCategory.error); }); test('record sample rate dropping event', () async { @@ -1472,8 +1759,10 @@ void main() { await client.captureEvent(fakeEvent); - expect(fixture.recorder.reason, DiscardReason.sampleRate); - expect(fixture.recorder.category, DataCategory.error); + expect(fixture.recorder.discardedEvents.first.reason, + DiscardReason.sampleRate); + expect( + fixture.recorder.discardedEvents.first.category, DataCategory.error); }); test('user feedback envelope contains dsn', () async { @@ -1607,8 +1896,8 @@ class Fixture { final recorder = MockClientReportRecorder(); final transport = MockTransport(); - final options = SentryOptions(dsn: fakeDsn) - ..platformChecker = MockPlatformChecker(platform: MockPlatform.iOS()); + final options = + defaultTestOptions(MockPlatformChecker(platform: MockPlatform.iOS())); late SentryTransactionContext _context; late SentryTracer tracer; diff --git a/dart/test/sentry_envelope_test.dart b/dart/test/sentry_envelope_test.dart index a24cab20c7..7fe59d099d 100644 --- a/dart/test/sentry_envelope_test.dart +++ b/dart/test/sentry_envelope_test.dart @@ -10,6 +10,7 @@ import 'package:test/test.dart'; import 'mocks.dart'; import 'mocks/mock_hub.dart'; +import 'test_utils.dart'; void main() { group('SentryEnvelope', () { @@ -51,7 +52,9 @@ void main() { '$expectedHeaderJsonSerialized\n$expectedItemSerialized\n$expectedItemSerialized'); final envelopeData = []; - await sut.envelopeStream(SentryOptions()).forEach(envelopeData.addAll); + await sut + .envelopeStream(defaultTestOptions()) + .forEach(envelopeData.addAll); expect(envelopeData, expected); }); @@ -213,12 +216,12 @@ void main() { final sutEnvelopeData = []; await sut - .envelopeStream(SentryOptions()..maxAttachmentSize = 1) + .envelopeStream(defaultTestOptions()..maxAttachmentSize = 1) .forEach(sutEnvelopeData.addAll); final envelopeData = []; await expectedEnvelopeItem - .envelopeStream(SentryOptions()) + .envelopeStream(defaultTestOptions()) .forEach(envelopeData.addAll); expect(sutEnvelopeData, envelopeData); @@ -238,7 +241,7 @@ void main() { dsn: fakeDsn, ); - final _ = sut.envelopeStream(SentryOptions()).map((e) => e); + final _ = sut.envelopeStream(defaultTestOptions()).map((e) => e); }); }); } diff --git a/dart/test/sentry_envelope_vm_test.dart b/dart/test/sentry_envelope_vm_test.dart index 7854ffccb0..e5f0276704 100644 --- a/dart/test/sentry_envelope_vm_test.dart +++ b/dart/test/sentry_envelope_vm_test.dart @@ -10,6 +10,7 @@ import 'package:sentry/src/sentry_envelope_item_header.dart'; import 'package:test/test.dart'; import 'mocks.dart'; +import 'test_utils.dart'; void main() { group('SentryEnvelopeItem', () { @@ -37,7 +38,7 @@ void main() { final envelopeData = []; await envelope - .envelopeStream(SentryOptions()) + .envelopeStream(defaultTestOptions()) .forEach(envelopeData.addAll); final expectedEnvelopeFile = @@ -62,8 +63,9 @@ void main() { attachments: [attachment], ); - final data = (await envelope.envelopeStream(SentryOptions()).toList()) - .reduce((a, b) => a + b); + final data = + (await envelope.envelopeStream(defaultTestOptions()).toList()) + .reduce((a, b) => a + b); final file = File('test_resources/envelope-no-attachment.envelope'); final jsonStr = await file.readAsString(); diff --git a/dart/test/sentry_event_test.dart b/dart/test/sentry_event_test.dart index 4e661638a8..fe9d3b4d48 100644 --- a/dart/test/sentry_event_test.dart +++ b/dart/test/sentry_event_test.dart @@ -7,6 +7,8 @@ import 'package:sentry/sentry.dart'; import 'package:sentry/src/version.dart'; import 'package:test/test.dart'; +import 'mocks.dart'; + void main() { group('deserialize', () { final sentryId = SentryId.empty(); @@ -60,6 +62,7 @@ void main() { }, 'type': 'type', }; + sentryEventJson.addAll(testUnknown); final emptyFieldsSentryEventJson = { 'event_id': sentryId.toString(), @@ -105,6 +108,7 @@ void main() { expect(sentryEvent.request, isNull); expect(sentryEvent.debugMeta, isNull); expect(sentryEvent.type, isNull); + expect(sentryEvent.unknown, isNull); }); }); @@ -184,52 +188,56 @@ void main() { expect( SentryEvent( - eventId: SentryId.empty(), - timestamp: timestamp, - platform: sdkPlatform(platformChecker.isWeb), - message: SentryMessage( - 'test-message 1 2', - template: 'test-message %d %d', - params: ['1', '2'], - ), - transaction: '/test/1', - level: SentryLevel.debug, - culprit: 'Professor Moriarty', - tags: const { - 'a': 'b', - 'c': 'd', - }, - // ignore: deprecated_member_use_from_same_package - extra: const { - 'e': 'f', - 'g': 2, - }, - fingerprint: const [SentryEvent.defaultFingerprint, 'foo'], - user: user, - breadcrumbs: breadcrumbs, - request: request, - debugMeta: DebugMeta( - sdk: SdkInfo( - sdkName: 'sentry.dart', - versionMajor: 4, - versionMinor: 1, - versionPatchlevel: 2, - ), - images: const [ - DebugImage( - type: 'macho', - debugId: '84a04d24-0e60-3810-a8c0-90a65e2df61a', - debugFile: 'libDiagnosticMessagesClient.dylib', - codeFile: '/usr/lib/libDiagnosticMessagesClient.dylib', - imageAddr: '0x7fffe668e000', - imageSize: 8192, - arch: 'x86_64', - codeId: '123', - ) - ], - ), - type: 'type', - ).toJson(), + eventId: SentryId.empty(), + timestamp: timestamp, + platform: sdkPlatform(platformChecker.isWeb), + message: SentryMessage( + 'test-message 1 2', + template: 'test-message %d %d', + params: ['1', '2'], + ), + transaction: '/test/1', + level: SentryLevel.debug, + culprit: 'Professor Moriarty', + tags: const { + 'a': 'b', + 'c': 'd', + }, + // ignore: deprecated_member_use_from_same_package + extra: const { + 'e': 'f', + 'g': 2, + }, + fingerprint: const [ + SentryEvent.defaultFingerprint, + 'foo' + ], + user: user, + breadcrumbs: breadcrumbs, + request: request, + debugMeta: DebugMeta( + sdk: SdkInfo( + sdkName: 'sentry.dart', + versionMajor: 4, + versionMinor: 1, + versionPatchlevel: 2, + ), + images: const [ + DebugImage( + type: 'macho', + debugId: '84a04d24-0e60-3810-a8c0-90a65e2df61a', + debugFile: 'libDiagnosticMessagesClient.dylib', + codeFile: '/usr/lib/libDiagnosticMessagesClient.dylib', + imageAddr: '0x7fffe668e000', + imageSize: 8192, + arch: 'x86_64', + codeId: '123', + ) + ], + ), + type: 'type', + unknown: testUnknown) + .toJson(), { 'platform': platformChecker.isWeb ? 'javascript' : 'other', 'event_id': '00000000000000000000000000000000', @@ -286,7 +294,7 @@ void main() { ] }, 'type': 'type', - }, + }..addAll(testUnknown), ); }); diff --git a/dart/test/sentry_exception_factory_test.dart b/dart/test/sentry_exception_factory_test.dart index cca350f2b4..a3129fb8f3 100644 --- a/dart/test/sentry_exception_factory_test.dart +++ b/dart/test/sentry_exception_factory_test.dart @@ -2,7 +2,7 @@ import 'package:sentry/sentry.dart'; import 'package:sentry/src/sentry_exception_factory.dart'; import 'package:test/test.dart'; -import 'mocks.dart'; +import 'test_utils.dart'; void main() { final fixture = Fixture(); @@ -284,7 +284,7 @@ isolate_instructions: 7526344980, vm_instructions: 752633f000 } class Fixture { - final options = SentryOptions(dsn: fakeDsn); + final options = defaultTestOptions(); SentryExceptionFactory getSut({bool attachStacktrace = true}) { options.attachStacktrace = true; diff --git a/dart/test/sentry_isolate_extension_test.dart b/dart/test/sentry_isolate_extension_test.dart index 626d7e525c..89c6a0fe4b 100644 --- a/dart/test/sentry_isolate_extension_test.dart +++ b/dart/test/sentry_isolate_extension_test.dart @@ -4,11 +4,10 @@ library dart_test; import 'dart:isolate'; import 'package:sentry/src/sentry_isolate_extension.dart'; -import 'package:sentry/src/sentry_options.dart'; import 'package:test/test.dart'; -import 'mocks.dart'; import 'mocks/mock_hub.dart'; +import 'test_utils.dart'; void main() { group("SentryIsolate", () { @@ -53,7 +52,7 @@ void main() { class Fixture { final hub = MockHub(); - final options = SentryOptions(dsn: fakeDsn)..tracesSampleRate = 1.0; + final options = defaultTestOptions()..tracesSampleRate = 1.0; Isolate getSut() { return Isolate.current; diff --git a/dart/test/sentry_isolate_test.dart b/dart/test/sentry_isolate_test.dart index 6d41636303..07347d5585 100644 --- a/dart/test/sentry_isolate_test.dart +++ b/dart/test/sentry_isolate_test.dart @@ -5,12 +5,11 @@ import 'package:sentry/src/hub.dart'; import 'package:sentry/src/protocol/sentry_level.dart'; import 'package:sentry/src/protocol/span_status.dart'; import 'package:sentry/src/sentry_isolate.dart'; -import 'package:sentry/src/sentry_options.dart'; import 'package:test/test.dart'; -import 'mocks.dart'; import 'mocks/mock_hub.dart'; import 'mocks/mock_sentry_client.dart'; +import 'test_utils.dart'; void main() { group("SentryIsolate", () { @@ -72,5 +71,5 @@ void main() { class Fixture { final hub = MockHub(); - final options = SentryOptions(dsn: fakeDsn)..tracesSampleRate = 1.0; + final options = defaultTestOptions()..tracesSampleRate = 1.0; } diff --git a/dart/test/sentry_options_test.dart b/dart/test/sentry_options_test.dart index 273366e442..be652ca91c 100644 --- a/dart/test/sentry_options_test.dart +++ b/dart/test/sentry_options_test.dart @@ -4,16 +4,16 @@ import 'package:sentry/src/noop_client.dart'; import 'package:sentry/src/version.dart'; import 'package:test/test.dart'; -import 'mocks.dart'; +import 'test_utils.dart'; void main() { test('$Client is NoOp', () { - final options = SentryOptions(dsn: fakeDsn); + final options = defaultTestOptions(); expect(NoOpClient(), options.httpClient); }); test('$Client sets a custom client', () { - final options = SentryOptions(dsn: fakeDsn); + final options = defaultTestOptions(); final client = Client(); options.httpClient = client; @@ -21,20 +21,20 @@ void main() { }); test('maxBreadcrumbs is 100 by default', () { - final options = SentryOptions(dsn: fakeDsn); + final options = defaultTestOptions(); expect(100, options.maxBreadcrumbs); }); test('maxBreadcrumbs sets custom maxBreadcrumbs', () { - final options = SentryOptions(dsn: fakeDsn); + final options = defaultTestOptions(); options.maxBreadcrumbs = 200; expect(200, options.maxBreadcrumbs); }); test('SentryLogger sets a diagnostic logger', () { - final options = SentryOptions(dsn: fakeDsn); + final options = defaultTestOptions(); // ignore: deprecated_member_use_from_same_package expect(options.logger, noOpLogger); // ignore: deprecated_member_use_from_same_package @@ -45,32 +45,32 @@ void main() { }); test('tracesSampler is null by default', () { - final options = SentryOptions(dsn: fakeDsn); + final options = defaultTestOptions(); expect(options.tracesSampler, isNull); }); test('tracesSampleRate is null by default', () { - final options = SentryOptions(dsn: fakeDsn); + final options = defaultTestOptions(); expect(options.tracesSampleRate, isNull); }); test('isTracingEnabled is disabled', () { - final options = SentryOptions(dsn: fakeDsn); + final options = defaultTestOptions(); expect(options.isTracingEnabled(), false); }); test('isTracingEnabled is enabled by theres rate', () { - final options = SentryOptions(dsn: fakeDsn); + final options = defaultTestOptions(); options.tracesSampleRate = 1.0; expect(options.isTracingEnabled(), true); }); test('isTracingEnabled is enabled by theres sampler', () { - final options = SentryOptions(dsn: fakeDsn); + final options = defaultTestOptions(); double? sampler(SentrySamplingContext samplingContext) => 0.0; @@ -98,7 +98,7 @@ void main() { }); test('SentryOptions has sentryClientName set', () { - final options = SentryOptions(dsn: fakeDsn); + final options = defaultTestOptions(); expect(options.sentryClientName, '${sdkName(options.platformChecker.isWeb)}/$sdkVersion'); @@ -112,6 +112,7 @@ void main() { test('when enableTracing is set to true tracing is considered enabled', () { final options = SentryOptions.empty(); + // ignore: deprecated_member_use_from_same_package options.enableTracing = true; expect(options.isTracingEnabled(), true); @@ -119,6 +120,7 @@ void main() { test('when enableTracing is set to false tracing is considered disabled', () { final options = SentryOptions.empty(); + // ignore: deprecated_member_use_from_same_package options.enableTracing = false; options.tracesSampleRate = 1.0; options.tracesSampler = (_) { @@ -129,33 +131,39 @@ void main() { }); test('Spotlight is disabled by default', () { - final options = SentryOptions(dsn: fakeDsn); + final options = defaultTestOptions(); expect(options.spotlight.enabled, false); }); test('metrics are disabled by default', () { - final options = SentryOptions(dsn: fakeDsn); + final options = defaultTestOptions(); expect(options.enableMetrics, false); }); + test('enableExceptionTypeIdentification is enabled by default', () { + final options = defaultTestOptions(); + + expect(options.enableExceptionTypeIdentification, true); + }); + test('default tags for metrics are enabled by default', () { - final options = SentryOptions(dsn: fakeDsn); + final options = defaultTestOptions(); options.enableMetrics = true; expect(options.enableDefaultTagsForMetrics, true); }); test('default tags for metrics are disabled if metrics are disabled', () { - final options = SentryOptions(dsn: fakeDsn); + final options = defaultTestOptions(); options.enableMetrics = false; expect(options.enableDefaultTagsForMetrics, false); }); test('default tags for metrics are enabled if metrics are enabled, too', () { - final options = SentryOptions(dsn: fakeDsn); + final options = defaultTestOptions(); options.enableMetrics = true; options.enableDefaultTagsForMetrics = true; @@ -163,14 +171,14 @@ void main() { }); test('span local metric aggregation is enabled by default', () { - final options = SentryOptions(dsn: fakeDsn); + final options = defaultTestOptions(); options.enableMetrics = true; expect(options.enableSpanLocalMetricAggregation, true); }); test('span local metric aggregation is disabled if metrics are disabled', () { - final options = SentryOptions(dsn: fakeDsn); + final options = defaultTestOptions(); options.enableMetrics = false; expect(options.enableSpanLocalMetricAggregation, false); @@ -178,10 +186,16 @@ void main() { test('span local metric aggregation is enabled if metrics are enabled, too', () { - final options = SentryOptions(dsn: fakeDsn); + final options = defaultTestOptions(); options.enableMetrics = true; options.enableSpanLocalMetricAggregation = true; expect(options.enableSpanLocalMetricAggregation, true); }); + + test('enablePureDartSymbolication is enabled by default', () { + final options = defaultTestOptions(); + + expect(options.enableDartSymbolication, true); + }); } diff --git a/dart/test/sentry_span_test.dart b/dart/test/sentry_span_test.dart index e161ceee2f..d063cf787e 100644 --- a/dart/test/sentry_span_test.dart +++ b/dart/test/sentry_span_test.dart @@ -310,6 +310,21 @@ void main() { expect(fixture.hub.options.enableSpanLocalMetricAggregation, false); expect(sut.localMetricsAggregator, null); }); + + test('setMeasurement sets a measurement', () async { + final sut = fixture.getSut(); + sut.setMeasurement("test", 1); + expect(sut.tracer.measurements.containsKey("test"), true); + expect(sut.tracer.measurements["test"]!.value, 1); + }); + + test('setMeasurement does not set a measurement if a span is finished', + () async { + final sut = fixture.getSut(); + await sut.finish(); + sut.setMeasurement("test", 1); + expect(sut.tracer.measurements.isEmpty, true); + }); } class Fixture { diff --git a/dart/test/sentry_test.dart b/dart/test/sentry_test.dart index 0a8dd5db27..201062ed5d 100644 --- a/dart/test/sentry_test.dart +++ b/dart/test/sentry_test.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:sentry/sentry.dart'; +import 'package:sentry/src/dart_exception_type_identifier.dart'; import 'package:sentry/src/event_processor/deduplication_event_processor.dart'; import 'package:test/test.dart'; @@ -8,6 +9,7 @@ import 'fake_platform_checker.dart'; import 'mocks.dart'; import 'mocks/mock_integration.dart'; import 'mocks/mock_sentry_client.dart'; +import 'test_utils.dart'; AppRunner appRunner = () {}; @@ -18,7 +20,7 @@ void main() { var anException = Exception(); setUp(() async { - final options = SentryOptions(dsn: fakeDsn)..automatedTestMode = true; + final options = defaultTestOptions(); await Sentry.init( options: options, (options) { @@ -141,7 +143,7 @@ void main() { }); test('null DSN', () async { - final options = SentryOptions(dsn: fakeDsn)..automatedTestMode = true; + final options = defaultTestOptions(); expect( () async => await Sentry.init( options: options, @@ -154,7 +156,7 @@ void main() { test('appRunner should be optional', () async { expect(Sentry.isEnabled, false); - final options = SentryOptions(dsn: fakeDsn)..automatedTestMode = true; + final options = defaultTestOptions(); await Sentry.init( options: options, (options) => options.dsn = fakeDsn, @@ -163,7 +165,7 @@ void main() { }); test('empty DSN', () async { - final options = SentryOptions(dsn: fakeDsn)..automatedTestMode = true; + final options = defaultTestOptions(); await Sentry.init( options: options, (options) => options.dsn = '', @@ -174,7 +176,7 @@ void main() { test('empty DSN disables the SDK but runs the integrations', () async { final integration = MockIntegration(); - final options = SentryOptions(dsn: fakeDsn)..automatedTestMode = true; + final options = defaultTestOptions(); await Sentry.init( options: options, (options) { @@ -187,7 +189,7 @@ void main() { }); test('close disables the SDK', () async { - final options = SentryOptions(dsn: fakeDsn)..automatedTestMode = true; + final options = defaultTestOptions(); await Sentry.init( options: options, (options) => options.dsn = fakeDsn, @@ -211,7 +213,7 @@ void main() { test('should install integrations', () async { final integration = MockIntegration(); - final options = SentryOptions(dsn: fakeDsn)..automatedTestMode = true; + final options = defaultTestOptions(); await Sentry.init( options: options, (options) { @@ -225,7 +227,7 @@ void main() { test('should add default integrations', () async { late SentryOptions optionsReference; - final options = SentryOptions(dsn: fakeDsn)..automatedTestMode = true; + final options = defaultTestOptions(); await Sentry.init( options: options, (options) { @@ -249,7 +251,7 @@ void main() { }, onPlatform: {'browser': Skip()}); test('should add only web compatible default integrations', () async { - final options = SentryOptions(dsn: fakeDsn)..automatedTestMode = true; + final options = defaultTestOptions(); await Sentry.init( options: options, (options) { @@ -265,7 +267,7 @@ void main() { test('should close integrations', () async { final integration = MockIntegration(); - final options = SentryOptions(dsn: fakeDsn)..automatedTestMode = true; + final options = defaultTestOptions(); await Sentry.init( options: options, (options) { @@ -281,7 +283,7 @@ void main() { }); test('$DeduplicationEventProcessor is added on init', () async { - final options = SentryOptions(dsn: fakeDsn)..automatedTestMode = true; + final options = defaultTestOptions(); await Sentry.init( options: options, (options) { @@ -298,7 +300,7 @@ void main() { final completer = Completer(); var completed = false; - final options = SentryOptions(dsn: fakeDsn)..automatedTestMode = true; + final options = defaultTestOptions(); final init = Sentry.init( options: options, (options) { @@ -318,6 +320,28 @@ void main() { expect(completed, true); }); + + test('should add DartExceptionTypeIdentifier by default', () async { + final options = defaultTestOptions(); + await Sentry.init( + options: options, + (options) { + options.dsn = fakeDsn; + }, + ); + + expect(options.exceptionTypeIdentifiers.length, 1); + final cachingIdentifier = options.exceptionTypeIdentifiers.first + as CachingExceptionTypeIdentifier; + expect( + cachingIdentifier, + isA().having( + (c) => c.identifier, + 'wrapped identifier', + isA(), + ), + ); + }); }); test('should complete when appRunner is not called in runZonedGuarded', @@ -325,7 +349,7 @@ void main() { final completer = Completer(); var completed = false; - final options = SentryOptions(dsn: fakeDsn)..automatedTestMode = true; + final options = defaultTestOptions(); final init = Sentry.init( options: options, (options) { @@ -348,12 +372,7 @@ void main() { }); test('options.environment debug', () async { - final sentryOptions = SentryOptions(dsn: fakeDsn) - ..automatedTestMode = true - ..platformChecker = FakePlatformChecker.debugMode(); - - final options = SentryOptions(); - options.automatedTestMode = true; + final sentryOptions = defaultTestOptions(FakePlatformChecker.debugMode()); await Sentry.init( (options) { options.dsn = fakeDsn; @@ -365,9 +384,7 @@ void main() { }); test('options.environment profile', () async { - final sentryOptions = - SentryOptions(dsn: fakeDsn, checker: FakePlatformChecker.profileMode()) - ..automatedTestMode = true; + final sentryOptions = defaultTestOptions(FakePlatformChecker.profileMode()); await Sentry.init( (options) { @@ -380,9 +397,7 @@ void main() { }); test('options.environment production (defaultEnvironment)', () async { - final sentryOptions = - SentryOptions(dsn: fakeDsn, checker: FakePlatformChecker.releaseMode()) - ..automatedTestMode = true; + final sentryOptions = defaultTestOptions(FakePlatformChecker.releaseMode()); await Sentry.init( (options) { options.dsn = fakeDsn; @@ -394,9 +409,7 @@ void main() { }); test('options.logger is set by setting the debug flag', () async { - final sentryOptions = - SentryOptions(dsn: fakeDsn, checker: FakePlatformChecker.debugMode()) - ..automatedTestMode = true; + final sentryOptions = defaultTestOptions(FakePlatformChecker.debugMode()); await Sentry.init( (options) { @@ -420,7 +433,7 @@ void main() { final fixture = Fixture(); test('throw is handled and logged', () async { - final sentryOptions = SentryOptions(dsn: fakeDsn) + final sentryOptions = defaultTestOptions() ..automatedTestMode = false ..debug = true ..logger = fixture.mockLogger; diff --git a/dart/test/sentry_trace_context_header_test.dart b/dart/test/sentry_trace_context_header_test.dart index 6ba6d93bc2..04b2526c34 100644 --- a/dart/test/sentry_trace_context_header_test.dart +++ b/dart/test/sentry_trace_context_header_test.dart @@ -2,11 +2,28 @@ import 'package:collection/collection.dart'; import 'package:sentry/sentry.dart'; import 'package:test/test.dart'; +import 'mocks.dart'; + void main() { group('$SentryTraceContextHeader', () { - final id = SentryId.newId(); + final traceId = SentryId.newId(); + + final context = SentryTraceContextHeader( + traceId, + '123', + release: 'release', + environment: 'environment', + userId: 'user_id', + userSegment: 'user_segment', + transaction: 'transaction', + sampleRate: '1.0', + sampled: 'false', + replayId: SentryId.fromId('456'), + unknown: testUnknown, + ); + final mapJson = { - 'trace_id': '$id', + 'trace_id': '$traceId', 'public_key': '123', 'release': 'release', 'environment': 'environment', @@ -14,20 +31,23 @@ void main() { 'user_segment': 'user_segment', 'transaction': 'transaction', 'sample_rate': '1.0', - 'sampled': 'false' + 'sampled': 'false', + 'replay_id': '456', }; - final context = SentryTraceContextHeader.fromJson(mapJson); + mapJson.addAll(testUnknown); test('fromJson', () { - expect(context.traceId.toString(), id.toString()); + expect(context.traceId.toString(), traceId.toString()); expect(context.publicKey, '123'); expect(context.release, 'release'); expect(context.environment, 'environment'); expect(context.userId, 'user_id'); + // ignore: deprecated_member_use_from_same_package expect(context.userSegment, 'user_segment'); expect(context.transaction, 'transaction'); expect(context.sampleRate, '1.0'); expect(context.sampled, 'false'); + expect(context.replayId, SentryId.fromId('456')); }); test('toJson', () { @@ -39,8 +59,19 @@ void main() { test('to baggage', () { final baggage = context.toBaggage(); - expect(baggage.toHeaderString(), - 'sentry-trace_id=${id.toString()},sentry-public_key=123,sentry-release=release,sentry-environment=environment,sentry-user_id=user_id,sentry-user_segment=user_segment,sentry-transaction=transaction,sentry-sample_rate=1.0,sentry-sampled=false'); + expect( + baggage.toHeaderString(), + 'sentry-trace_id=${traceId.toString()},' + 'sentry-public_key=123,' + 'sentry-release=release,' + 'sentry-environment=environment,' + 'sentry-user_id=user_id,' + 'sentry-user_segment=user_segment,' + 'sentry-transaction=transaction,' + 'sentry-sample_rate=1.0,' + 'sentry-sampled=false,' + 'sentry-replay_id=456', + ); }); }); } diff --git a/dart/test/sentry_trace_context_test.dart b/dart/test/sentry_trace_context_test.dart index dde599bef1..ab33512a2c 100644 --- a/dart/test/sentry_trace_context_test.dart +++ b/dart/test/sentry_trace_context_test.dart @@ -1,6 +1,8 @@ import 'package:sentry/sentry.dart'; import 'package:test/test.dart'; +import 'mocks.dart'; + void main() { final fixture = Fixture(); @@ -16,38 +18,46 @@ void main() { expect(map['description'], 'desc'); expect(map['status'], 'aborted'); expect(map['origin'], 'auto.ui'); + expect(map['replay_id'], isNotNull); }); test('fromJson deserializes', () { - final map = { + final map = { 'op': 'op', - 'span_id': '0000000000000000', - 'trace_id': '00000000000000000000000000000000', - 'parent_span_id': '0000000000000000', + 'span_id': '0000000000000001', + 'trace_id': '00000000000000000000000000000002', + 'parent_span_id': '0000000000000003', 'description': 'desc', 'status': 'aborted', - 'origin': 'auto.ui' + 'origin': 'auto.ui', + 'replay_id': '00000000000000000000000000000004' }; + map.addAll(testUnknown); final traceContext = SentryTraceContext.fromJson(map); expect(traceContext.description, 'desc'); expect(traceContext.operation, 'op'); - expect(traceContext.spanId.toString(), '0000000000000000'); - expect(traceContext.traceId.toString(), '00000000000000000000000000000000'); - expect(traceContext.parentSpanId.toString(), '0000000000000000'); + expect(traceContext.spanId.toString(), '0000000000000001'); + expect(traceContext.traceId.toString(), '00000000000000000000000000000002'); + expect(traceContext.parentSpanId.toString(), '0000000000000003'); expect(traceContext.status.toString(), 'aborted'); expect(traceContext.sampled, true); + expect( + traceContext.replayId.toString(), '00000000000000000000000000000004'); }); } class Fixture { SentryTraceContext getSut() { return SentryTraceContext( - operation: 'op', - parentSpanId: SpanId.newId(), - description: 'desc', - sampled: true, - status: SpanStatus.aborted(), - origin: 'auto.ui'); + operation: 'op', + parentSpanId: SpanId.newId(), + description: 'desc', + sampled: true, + status: SpanStatus.aborted(), + origin: 'auto.ui', + replayId: SentryId.newId(), + unknown: testUnknown, + ); } } diff --git a/dart/test/sentry_tracer_test.dart b/dart/test/sentry_tracer_test.dart index 7ba5e80fac..9bc6410725 100644 --- a/dart/test/sentry_tracer_test.dart +++ b/dart/test/sentry_tracer_test.dart @@ -2,9 +2,9 @@ import 'package:sentry/sentry.dart'; import 'package:sentry/src/sentry_tracer.dart'; import 'package:test/test.dart'; -import 'mocks.dart'; import 'mocks/mock_hub.dart'; import 'mocks/mock_sentry_client.dart'; +import 'test_utils.dart'; void main() { group('$SentryTracer', () { @@ -467,6 +467,22 @@ void main() { expect(fixture.hub.options.enableSpanLocalMetricAggregation, false); expect(sut.localMetricsAggregator, null); }); + + test('setMeasurement sets a measurement', () async { + final sut = fixture.getSut(); + sut.setMeasurement("test", 1); + expect(sut.measurements.containsKey("test"), true); + expect(sut.measurements["test"]!.value, 1); + }); + + test('setMeasurementFromChild does not override existing measurements', + () async { + final sut = fixture.getSut(); + sut.setMeasurement("test", 1); + sut.setMeasurementFromChild("test", 5); + expect(sut.measurements.containsKey("test"), true); + expect(sut.measurements["test"]!.value, 1); + }); }); group('$SentryBaggageHeader', () { @@ -505,7 +521,7 @@ void main() { final newBaggage = SentryBaggage.fromHeader(baggage.value); expect(newBaggage.get('sentry-trace_id'), sut.context.traceId.toString()); - expect(newBaggage.get('sentry-public_key'), 'abc'); + expect(newBaggage.get('sentry-public_key'), 'public'); expect(newBaggage.get('sentry-release'), 'release'); expect(newBaggage.get('sentry-environment'), 'environment'); expect(newBaggage.get('sentry-user_segment'), 'segment'); @@ -579,9 +595,10 @@ void main() { final context = sut.traceContext(); expect(context!.traceId, sut.context.traceId); - expect(context.publicKey, 'abc'); + expect(context.publicKey, 'public'); expect(context.release, 'release'); expect(context.environment, 'environment'); + // ignore: deprecated_member_use_from_same_package expect(context.userSegment, 'segment'); expect(context.transaction, 'name'); expect(context.sampleRate, '1'); @@ -591,7 +608,7 @@ void main() { } class Fixture { - final options = SentryOptions(dsn: fakeDsn) + final options = defaultTestOptions() ..release = 'release' ..environment = 'environment'; diff --git a/dart/test/sentry_traces_sampler_test.dart b/dart/test/sentry_traces_sampler_test.dart index d4938e1b28..3fbcb85772 100644 --- a/dart/test/sentry_traces_sampler_test.dart +++ b/dart/test/sentry_traces_sampler_test.dart @@ -2,7 +2,7 @@ import 'package:sentry/sentry.dart'; import 'package:sentry/src/sentry_traces_sampler.dart'; import 'package:test/test.dart'; -import 'mocks.dart'; +import 'test_utils.dart'; void main() { late Fixture fixture; @@ -81,6 +81,7 @@ void main() { }); test('tracesSampler exception is handled', () { + fixture.options.automatedTestMode = false; final sut = fixture.getSut(debug: true); final exception = Exception("tracesSampler exception"); @@ -151,7 +152,7 @@ void main() { } class Fixture { - final options = SentryOptions(dsn: fakeDsn); + final options = defaultTestOptions(); SentryLevel? loggedLevel; Object? loggedException; @@ -166,6 +167,7 @@ class Fixture { options.tracesSampler = tracesSampler; options.debug = debug; options.logger = mockLogger; + // ignore: deprecated_member_use_from_same_package options.enableTracing = enableTracing; return SentryTracesSampler(options); } diff --git a/dart/test/sentry_transaction_test.dart b/dart/test/sentry_transaction_test.dart index 7de0549a13..8457e6eb4d 100644 --- a/dart/test/sentry_transaction_test.dart +++ b/dart/test/sentry_transaction_test.dart @@ -4,6 +4,7 @@ import 'package:test/test.dart'; import 'mocks.dart'; import 'mocks/mock_hub.dart'; +import 'test_utils.dart'; void main() { final fixture = Fixture(); @@ -108,7 +109,7 @@ void main() { } class Fixture { - final SentryOptions options = SentryOptions(dsn: fakeDsn); + final SentryOptions options = defaultTestOptions(); late final Hub hub = Hub(options); SentryTransaction getSut(SentryTracer tracer) { diff --git a/dart/test/sentry_user_feedback_test.dart b/dart/test/sentry_user_feedback_test.dart index 6051798b65..fec01ca5bd 100644 --- a/dart/test/sentry_user_feedback_test.dart +++ b/dart/test/sentry_user_feedback_test.dart @@ -1,41 +1,47 @@ +import 'package:collection/collection.dart'; import 'package:sentry/sentry.dart'; import 'package:sentry/src/sentry_item_type.dart'; import 'package:test/test.dart'; import 'mocks.dart'; import 'mocks/mock_transport.dart'; +import 'test_utils.dart'; void main() { group('$SentryUserFeedback', () { + final id = SentryId.newId(); + + final feedback = SentryUserFeedback( + eventId: id, + comments: 'this is awesome', + email: 'sentry@example.com', + name: 'Rockstar Developer', + unknown: testUnknown, + ); + final feedbackJson = { + 'event_id': id.toString(), + 'comments': 'this is awesome', + 'email': 'sentry@example.com', + 'name': 'Rockstar Developer', + }; + feedbackJson.addAll(testUnknown); + test('toJson', () { - final id = SentryId.newId(); - final feedback = SentryUserFeedback( - eventId: id, - comments: 'this is awesome', - email: 'sentry@example.com', - name: 'Rockstar Developer', + final json = feedback.toJson(); + expect( + MapEquality().equals(feedbackJson, json), + true, ); - expect(feedback.toJson(), { - 'event_id': id.toString(), - 'comments': 'this is awesome', - 'email': 'sentry@example.com', - 'name': 'Rockstar Developer', - }); }); test('fromJson', () { - final id = SentryId.newId(); - final feedback = SentryUserFeedback.fromJson({ - 'event_id': id.toString(), - 'comments': 'this is awesome', - 'email': 'sentry@example.com', - 'name': 'Rockstar Developer', - }); - - expect(feedback.eventId.toString(), id.toString()); - expect(feedback.comments, 'this is awesome'); - expect(feedback.email, 'sentry@example.com'); - expect(feedback.name, 'Rockstar Developer'); + final feedback = SentryRuntime.fromJson(feedbackJson); + final json = feedback.toJson(); + + expect( + MapEquality().equals(feedbackJson, json), + true, + ); }); test('copyWith', () { @@ -136,7 +142,7 @@ void main() { }); test('captureUserFeedback does not throw', () async { - final options = SentryOptions(dsn: fakeDsn); + final options = defaultTestOptions()..automatedTestMode = false; final transport = ThrowingTransport(); options.transport = transport; final sut = Hub(options); @@ -153,7 +159,7 @@ class Fixture { late MockTransport transport; Hub getSut() { - final options = SentryOptions(dsn: fakeDsn); + final options = defaultTestOptions(); transport = MockTransport(); options.transport = transport; return Hub(options); @@ -169,6 +175,7 @@ class SentryUserFeedbackWithoutAssert implements SentryUserFeedback { this.name, this.email, this.comments, + this.unknown, }); @override @@ -183,9 +190,13 @@ class SentryUserFeedbackWithoutAssert implements SentryUserFeedback { @override final String? comments; + @override + Map? unknown; + @override Map toJson() { - return { + return { + ...?unknown, 'event_id': eventId.toString(), if (name != null) 'name': name, if (email != null) 'email': email, @@ -205,6 +216,7 @@ class SentryUserFeedbackWithoutAssert implements SentryUserFeedback { name: name ?? this.name, email: email ?? this.email, comments: comments ?? this.comments, + unknown: unknown, ); } } diff --git a/dart/test/stack_trace_test.dart b/dart/test/stack_trace_test.dart index 6524581794..f63bc0f227 100644 --- a/dart/test/stack_trace_test.dart +++ b/dart/test/stack_trace_test.dart @@ -2,14 +2,13 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -import 'package:sentry/sentry.dart'; -import 'package:sentry/src/noop_origin.dart' - if (dart.library.html) 'package:sentry/src/origin.dart'; +import 'package:sentry/src/origin.dart'; import 'package:sentry/src/sentry_stack_trace_factory.dart'; import 'package:stack_trace/stack_trace.dart'; import 'package:test/test.dart'; -import 'mocks.dart'; +import 'mocks/mock_platform_checker.dart'; +import 'test_utils.dart'; void main() { group('encodeStackTraceFrame', () { @@ -24,7 +23,8 @@ void main() { 'lineno': 1, 'colno': 2, 'in_app': false, - 'filename': 'core' + 'filename': 'core', + 'platform': 'dart', }, ); }); @@ -124,7 +124,8 @@ void main() { 'lineno': 46, 'colno': 9, 'in_app': true, - 'filename': 'test.dart' + 'filename': 'test.dart', + 'platform': 'dart', }, { 'abs_path': '${eventOrigin}test.dart', @@ -132,7 +133,8 @@ void main() { 'lineno': 50, 'colno': 3, 'in_app': true, - 'filename': 'test.dart' + 'filename': 'test.dart', + 'platform': 'dart', }, ]); }); @@ -153,7 +155,8 @@ void main() { 'lineno': 46, 'colno': 9, 'in_app': true, - 'filename': 'test.dart' + 'filename': 'test.dart', + 'platform': 'dart', }, { 'abs_path': '', @@ -164,7 +167,8 @@ void main() { 'lineno': 50, 'colno': 3, 'in_app': true, - 'filename': 'test.dart' + 'filename': 'test.dart', + 'platform': 'dart', }, ]); }); @@ -230,7 +234,8 @@ isolate_instructions: 10fa27070, vm_instructions: 10fa21e20 'function': 'PlatformDispatcher._dispatchPointerDataPacket', 'lineno': 341, 'abs_path': '${eventOrigin}dart:ui/platform_dispatcher.dart', - 'in_app': false + 'in_app': false, + 'platform': 'dart', }, { 'filename': 'main.dart', @@ -238,14 +243,16 @@ isolate_instructions: 10fa27070, vm_instructions: 10fa21e20 'function': 'MainScaffold.build.', 'lineno': 131, 'abs_path': '${eventOrigin}package:example/main.dart', - 'in_app': true + 'in_app': true, + 'platform': 'dart', }, { 'filename': 'main.dart', 'function': 'asyncThrows', 'lineno': 404, 'abs_path': '${eventOrigin}main.dart', - 'in_app': true + 'in_app': true, + 'platform': 'dart', } ]); }); @@ -259,6 +266,29 @@ isolate_instructions: 10fa27070, vm_instructions: 10fa21e20 .map((frame) => frame.toJson()); expect(frames.isEmpty, true); }); + + test('sets platform to javascript for web and dart for non-web', () { + final frame = Frame(Uri.parse('file://foo/bar/baz.dart'), 1, 2, 'buzz'); + final fixture = Fixture(); + + // Test for web platform + final webSut = fixture.getSut(isWeb: true); + var webFrame = webSut.encodeStackTraceFrame(frame)!; + expect(webFrame.platform, 'javascript'); + + // Test for non-web platform + final nativeFrameBeforeSut = fixture.getSut(isWeb: false); + var nativeFrameBefore = + nativeFrameBeforeSut.encodeStackTraceFrame(frame)!; + expect(nativeFrameBefore.platform, 'dart'); + + // Test when platform is already set + final frameWithPlatform = fixture + .getSut() + .encodeStackTraceFrame(frame)! + .copyWith(platform: 'native'); + expect(frameWithPlatform.platform, 'native'); + }); }); } @@ -267,8 +297,9 @@ class Fixture { List inAppIncludes = const [], List inAppExcludes = const [], bool considerInAppFramesByDefault = true, + bool isWeb = false, }) { - final options = SentryOptions(dsn: fakeDsn); + final options = defaultTestOptions(MockPlatformChecker(isWebValue: isWeb)); inAppIncludes.forEach(options.addInAppInclude); inAppExcludes.forEach(options.addInAppExclude); options.considerInAppFramesByDefault = considerInAppFramesByDefault; diff --git a/dart/test/test_utils.dart b/dart/test/test_utils.dart index 627d19903d..ec25b32f85 100644 --- a/dart/test/test_utils.dart +++ b/dart/test/test_utils.dart @@ -18,6 +18,11 @@ const String _testDsnWithPath = const String _testDsnWithPort = 'https://public:secret@sentry.example.com:8888/1'; +SentryOptions defaultTestOptions([PlatformChecker? checker]) { + return SentryOptions(dsn: testDsn, checker: checker) + ..automatedTestMode = true; +} + void testHeaders( Map? headers, ClockProvider fakeClockProvider, { @@ -69,7 +74,7 @@ Future testCaptureException( fail('Unexpected request on ${request.method} ${request.url} in HttpMock'); }); - final options = SentryOptions(dsn: testDsn) + final options = defaultTestOptions() ..compressPayload = compressPayload ..clock = fakeClockProvider ..httpClient = httpMock @@ -117,9 +122,22 @@ Future testCaptureException( final topFrame = (stacktrace['frames'] as Iterable).last as Map; + if (topFrame['function'].contains('browser_test.dart.wasm')) { + // TODO stacktrace parsing for wasm is not implemented yet + // {filename: unparsed, function: at testCaptureException (http://localhost:59959/9R3KYfjvkWCySr4h2hI0pVO7PqmPFeE6/test/sentry_browser_test.dart.browser_test.dart.wasm:wasm-function[1007]:0x4bc18), abs_path: http://localhost:59959/unparsed, in_app: true} + return; + } expect( topFrame.keys, - ['filename', 'function', 'lineno', 'colno', 'abs_path', 'in_app'], + [ + 'filename', + 'function', + 'lineno', + 'colno', + 'abs_path', + 'in_app', + 'platform' + ], ); if (isWeb) { @@ -183,7 +201,7 @@ Future testCaptureException( void runTest({Codec, List?>? gzip, bool isWeb = false}) { test('can parse DSN', () async { - final options = SentryOptions(dsn: testDsn); + final options = defaultTestOptions(); final client = SentryClient(options); final dsn = Dsn.parse(options.dsn!); @@ -200,7 +218,7 @@ void runTest({Codec, List?>? gzip, bool isWeb = false}) { }); test('can parse DSN without secret', () async { - final options = SentryOptions(dsn: _testDsnWithoutSecret); + final options = defaultTestOptions()..dsn = _testDsnWithoutSecret; final client = SentryClient(options); final dsn = Dsn.parse(options.dsn!); @@ -217,7 +235,7 @@ void runTest({Codec, List?>? gzip, bool isWeb = false}) { }); test('can parse DSN with path', () async { - final options = SentryOptions(dsn: _testDsnWithPath); + final options = defaultTestOptions()..dsn = _testDsnWithPath; final client = SentryClient(options); final dsn = Dsn.parse(options.dsn!); @@ -233,7 +251,7 @@ void runTest({Codec, List?>? gzip, bool isWeb = false}) { client.close(); }); test('can parse DSN with port', () async { - final options = SentryOptions(dsn: _testDsnWithPort); + final options = defaultTestOptions()..dsn = _testDsnWithPort; final client = SentryClient(options); final dsn = Dsn.parse(options.dsn!); @@ -264,7 +282,8 @@ void runTest({Codec, List?>? gzip, bool isWeb = false}) { }); final client = SentryClient( - SentryOptions(dsn: _testDsnWithoutSecret) + defaultTestOptions() + ..dsn = _testDsnWithoutSecret ..httpClient = httpMock ..clock = fakeClockProvider ..compressPayload = false @@ -318,9 +337,7 @@ void runTest({Codec, List?>? gzip, bool isWeb = false}) { }); final client = SentryClient( - SentryOptions( - dsn: testDsn, - ) + defaultTestOptions() ..httpClient = httpMock ..clock = fakeClockProvider ..compressPayload = false @@ -379,9 +396,7 @@ void runTest({Codec, List?>? gzip, bool isWeb = false}) { data: {'foo': 'bar'}, ); - final options = SentryOptions( - dsn: testDsn, - ) + final options = defaultTestOptions() ..httpClient = httpMock ..clock = fakeClockProvider ..compressPayload = false diff --git a/dart/test/transport/http_transport_test.dart b/dart/test/transport/http_transport_test.dart index 8319e21b7d..6546cd73eb 100644 --- a/dart/test/transport/http_transport_test.dart +++ b/dart/test/transport/http_transport_test.dart @@ -1,5 +1,6 @@ import 'dart:convert'; +import 'package:collection/collection.dart'; import 'package:http/http.dart' as http; import 'package:http/testing.dart'; import 'package:sentry/sentry.dart'; @@ -7,6 +8,7 @@ import 'package:sentry/src/client_reports/discard_reason.dart'; import 'package:sentry/src/sentry_envelope_header.dart'; import 'package:sentry/src/sentry_envelope_item_header.dart'; import 'package:sentry/src/sentry_item_type.dart'; +import 'package:sentry/src/sentry_tracer.dart'; import 'package:sentry/src/transport/data_category.dart'; import 'package:sentry/src/transport/http_transport.dart'; import 'package:sentry/src/transport/rate_limiter.dart'; @@ -14,6 +16,8 @@ import 'package:test/test.dart'; import '../mocks.dart'; import '../mocks/mock_client_report_recorder.dart'; +import '../mocks/mock_hub.dart'; +import '../test_utils.dart'; void main() { SentryEnvelope givenEnvelope() { @@ -205,8 +209,42 @@ void main() { ); await sut.send(envelope); - expect(fixture.clientReportRecorder.reason, DiscardReason.networkError); - expect(fixture.clientReportRecorder.category, DataCategory.error); + expect(fixture.clientReportRecorder.discardedEvents.first.reason, + DiscardReason.networkError); + expect(fixture.clientReportRecorder.discardedEvents.first.category, + DataCategory.error); + }); + + test('does records lost transaction and span for error >= 400', () async { + final httpMock = MockClient((http.Request request) async { + return http.Response('{}', 400); + }); + final sut = fixture.getSut(httpMock, MockRateLimiter()); + + final transaction = fixture.getTransaction(); + transaction.tracer.startChild('child1'); + transaction.tracer.startChild('child2'); + final envelope = SentryEnvelope.fromTransaction( + transaction, + fixture.options.sdk, + dsn: fixture.options.dsn, + ); + await sut.send(envelope); + + final transactionDiscardedEvent = fixture + .clientReportRecorder.discardedEvents + .firstWhereOrNull((element) => + element.category == DataCategory.transaction && + element.reason == DiscardReason.networkError); + + final spanDiscardedEvent = fixture.clientReportRecorder.discardedEvents + .firstWhereOrNull((element) => + element.category == DataCategory.span && + element.reason == DiscardReason.networkError); + + expect(transactionDiscardedEvent, isNotNull); + expect(spanDiscardedEvent, isNotNull); + expect(spanDiscardedEvent!.quantity, 3); }); test('does not record lost event for error 429', () async { @@ -223,8 +261,7 @@ void main() { ); await sut.send(envelope); - expect(fixture.clientReportRecorder.reason, null); - expect(fixture.clientReportRecorder.category, null); + expect(fixture.clientReportRecorder.discardedEvents.isEmpty, isTrue); }); test('does record lost event for error >= 500', () async { @@ -241,16 +278,16 @@ void main() { ); await sut.send(envelope); - expect(fixture.clientReportRecorder.reason, DiscardReason.networkError); - expect(fixture.clientReportRecorder.category, DataCategory.error); + expect(fixture.clientReportRecorder.discardedEvents.first.reason, + DiscardReason.networkError); + expect(fixture.clientReportRecorder.discardedEvents.first.category, + DataCategory.error); }); }); } class Fixture { - final options = SentryOptions( - dsn: 'https://public:secret@sentry.example.com/1', - ); + final options = defaultTestOptions(); late var clientReportRecorder = MockClientReportRecorder(); @@ -262,4 +299,14 @@ class Fixture { }; return HttpTransport(options, rateLimiter); } + + SentryTransaction getTransaction() { + final context = SentryTransactionContext( + 'name', + 'op', + samplingDecision: SentryTracesSamplingDecision(true), + ); + final tracer = SentryTracer(context, MockHub()); + return SentryTransaction(tracer); + } } diff --git a/dart/test/transport/spotlight_http_transport_test.dart b/dart/test/transport/spotlight_http_transport_test.dart index b23f1fd87f..3e9d742bb1 100644 --- a/dart/test/transport/spotlight_http_transport_test.dart +++ b/dart/test/transport/spotlight_http_transport_test.dart @@ -9,6 +9,7 @@ import 'package:test/scaffolding.dart'; import '../mocks.dart'; import '../mocks/mock_client_report_recorder.dart'; +import '../test_utils.dart'; void main() { group('send to Sentry', () { @@ -52,9 +53,7 @@ void main() { } class Fixture { - final options = SentryOptions( - dsn: 'https://public:secret@sentry.example.com/1', - ); + final options = defaultTestOptions(); late var clientReportRecorder = MockClientReportRecorder(); diff --git a/dart/test/transport/tesk_queue_test.dart b/dart/test/transport/tesk_queue_test.dart index 80dc97161c..af22672d97 100644 --- a/dart/test/transport/tesk_queue_test.dart +++ b/dart/test/transport/tesk_queue_test.dart @@ -1,10 +1,9 @@ import 'dart:async'; -import 'package:sentry/sentry.dart'; import 'package:sentry/src/transport/task_queue.dart'; import 'package:test/test.dart'; -import '../mocks.dart'; +import '../test_utils.dart'; void main() { group("called sync", () { @@ -110,7 +109,7 @@ void main() { } class Fixture { - final options = SentryOptions(dsn: fakeDsn); + final options = defaultTestOptions(); TaskQueue getSut({required int maxQueueSize}) { return TaskQueue(maxQueueSize, options.logger); diff --git a/dart/test/utils/regex_utils_test.dart b/dart/test/utils/regex_utils_test.dart new file mode 100644 index 0000000000..ff098ab964 --- /dev/null +++ b/dart/test/utils/regex_utils_test.dart @@ -0,0 +1,24 @@ +import 'package:sentry/src/utils/regex_utils.dart'; +import 'package:test/test.dart'; + +void main() { + group('regex_utils', () { + final testString = "this is a test"; + + test('testString contains string pattern', () { + expect(isMatchingRegexPattern(testString, ["is"]), isTrue); + }); + + test('testString does not contain string pattern', () { + expect(isMatchingRegexPattern(testString, ["not"]), isFalse); + }); + + test('testString contains regex pattern', () { + expect(isMatchingRegexPattern(testString, ["^this.*\$"]), isTrue); + }); + + test('testString does not contain regex pattern', () { + expect(isMatchingRegexPattern(testString, ["^is.*\$"]), isFalse); + }); + }); +} diff --git a/dart/test/utils/tracing_utils_test.dart b/dart/test/utils/tracing_utils_test.dart index 96b26b55b1..134997afae 100644 --- a/dart/test/utils/tracing_utils_test.dart +++ b/dart/test/utils/tracing_utils_test.dart @@ -2,8 +2,8 @@ import 'package:sentry/sentry.dart'; import 'package:sentry/src/sentry_tracer.dart'; import 'package:test/test.dart'; -import '../mocks.dart'; import '../mocks/mock_sentry_client.dart'; +import '../test_utils.dart'; void main() { group('$containsTargetOrMatchesRegExp', () { @@ -125,7 +125,7 @@ void main() { addBaggageHeaderFromSpan(sut, headers); expect(headers[baggage!.name], - 'other-vendor-value=foo,sentry-trace_id=${sut.context.traceId},sentry-public_key=abc,sentry-release=release,sentry-environment=environment,sentry-user_segment=segment,sentry-transaction=name,sentry-sample_rate=1,sentry-sampled=true'); + 'other-vendor-value=foo,sentry-trace_id=${sut.context.traceId},sentry-public_key=public,sentry-release=release,sentry-environment=environment,sentry-user_segment=segment,sentry-transaction=name,sentry-sample_rate=1,sentry-sampled=true'); }); }); @@ -167,7 +167,7 @@ class Fixture { ), ); - final _options = SentryOptions(dsn: fakeDsn) + final _options = defaultTestOptions() ..release = 'release' ..environment = 'environment'; diff --git a/dio/lib/src/version.dart b/dio/lib/src/version.dart index 4b219aa09a..33b619bd24 100644 --- a/dio/lib/src/version.dart +++ b/dio/lib/src/version.dart @@ -1,5 +1,5 @@ /// The SDK version reported to Sentry.io in the submitted events. -const String sdkVersion = '8.3.0'; +const String sdkVersion = '8.9.0'; /// The package name reported to Sentry.io in the submitted events. const String packageName = 'pub:sentry_dio'; diff --git a/dio/pubspec.yaml b/dio/pubspec.yaml index b4865df495..937ffd5324 100644 --- a/dio/pubspec.yaml +++ b/dio/pubspec.yaml @@ -1,6 +1,6 @@ name: sentry_dio description: An integration which adds support for performance tracing for the Dio package. -version: 8.3.0 +version: 8.9.0 homepage: https://docs.sentry.io/platforms/dart/ repository: https://github.com/getsentry/sentry-dart issue_tracker: https://github.com/getsentry/sentry-dart/issues @@ -19,7 +19,7 @@ platforms: dependencies: dio: ^5.0.0 - sentry: 8.3.0 + sentry: 8.9.0 dev_dependencies: meta: ^1.3.0 diff --git a/dio/test/dio_event_processor_test.dart b/dio/test/dio_event_processor_test.dart index af4a94ab8d..9786828841 100644 --- a/dio/test/dio_event_processor_test.dart +++ b/dio/test/dio_event_processor_test.dart @@ -436,7 +436,7 @@ final requestOptions = RequestOptions( ); class Fixture { - final SentryOptions options = SentryOptions(dsn: fakeDsn); + final SentryOptions options = defaultTestOptions(); // ignore: invalid_use_of_internal_member SentryExceptionFactory get exceptionFactory => options.exceptionFactory; diff --git a/dio/test/mocks.dart b/dio/test/mocks.dart index 30e53c5772..052ab82576 100644 --- a/dio/test/mocks.dart +++ b/dio/test/mocks.dart @@ -3,6 +3,11 @@ import 'package:sentry/src/transport/rate_limiter.dart'; final fakeDsn = 'https://abc@def.ingest.sentry.io/1234567'; +SentryOptions defaultTestOptions() { + // ignore: invalid_use_of_internal_member + return SentryOptions(dsn: fakeDsn)..automatedTestMode = true; +} + final fakeException = Exception('Error'); final fakeMessage = SentryMessage( diff --git a/dio/test/mocks/mock_hub.dart b/dio/test/mocks/mock_hub.dart index 377e1efb79..5e896d58d7 100644 --- a/dio/test/mocks/mock_hub.dart +++ b/dio/test/mocks/mock_hub.dart @@ -2,6 +2,7 @@ import 'package:meta/meta.dart'; import 'package:sentry/sentry.dart'; +import '../mocks.dart'; import 'no_such_method_provider.dart'; class MockHub with NoSuchMethodProvider implements Hub { @@ -17,7 +18,7 @@ class MockHub with NoSuchMethodProvider implements Hub { int spanContextCals = 0; int getSpanCalls = 0; - final _options = SentryOptions(dsn: 'fixture-dsn'); + final _options = defaultTestOptions(); @override @internal diff --git a/dio/test/sentry_transformer_test.dart b/dio/test/sentry_transformer_test.dart index ffa793c791..93fd36f138 100644 --- a/dio/test/sentry_transformer_test.dart +++ b/dio/test/sentry_transformer_test.dart @@ -137,7 +137,7 @@ void main() { } class Fixture { - final _options = SentryOptions(dsn: fakeDsn); + final _options = defaultTestOptions(); late Hub _hub; final transport = MockTransport(); Fixture() { diff --git a/dio/test/tracing_client_adapter_test.dart b/dio/test/tracing_client_adapter_test.dart index f67ec4c14d..680825b34f 100644 --- a/dio/test/tracing_client_adapter_test.dart +++ b/dio/test/tracing_client_adapter_test.dart @@ -183,7 +183,7 @@ MockHttpClientAdapter createThrowingClient() { } class Fixture { - final _options = SentryOptions(dsn: fakeDsn); + final _options = defaultTestOptions(); late Hub _hub; final transport = MockTransport(); Fixture() { diff --git a/drift/lib/src/sentry_query_executor.dart b/drift/lib/src/sentry_query_executor.dart index 6f09906970..95784670c8 100644 --- a/drift/lib/src/sentry_query_executor.dart +++ b/drift/lib/src/sentry_query_executor.dart @@ -3,9 +3,10 @@ import 'dart:async'; import 'package:drift/drift.dart'; import 'package:meta/meta.dart'; import 'package:sentry/sentry.dart'; -import 'version.dart'; + import 'sentry_span_helper.dart'; import 'sentry_transaction_executor.dart'; +import 'version.dart'; /// Signature of a function that opens a database connection when instructed to. typedef DatabaseOpener = FutureOr Function(); @@ -167,6 +168,17 @@ class SentryQueryExecutor extends QueryExecutor { ); } + @override + // ignore: override_on_non_overriding_member, public_member_api_docs + QueryExecutor beginExclusive() { + final dynamic uncheckedExecutor = _executor; + try { + return uncheckedExecutor.beginExclusive() as QueryExecutor; + } on NoSuchMethodError catch (_) { + throw Exception('This method is not supported in Drift versions <2.19.0'); + } + } + @override Future close() { return _spanHelper.asyncWrapInSpan( diff --git a/drift/lib/src/sentry_transaction_executor.dart b/drift/lib/src/sentry_transaction_executor.dart index afd7625524..dba05cd8cc 100644 --- a/drift/lib/src/sentry_transaction_executor.dart +++ b/drift/lib/src/sentry_transaction_executor.dart @@ -1,6 +1,7 @@ import 'package:drift/backends.dart'; import 'package:meta/meta.dart'; import 'package:sentry/sentry.dart'; + import 'sentry_span_helper.dart'; /// @nodoc @@ -134,6 +135,17 @@ class SentryTransactionExecutor extends TransactionExecutor { ); } + @override + // ignore: override_on_non_overriding_member, public_member_api_docs + QueryExecutor beginExclusive() { + final dynamic uncheckedExecutor = _executor; + try { + return uncheckedExecutor.beginExclusive() as QueryExecutor; + } on NoSuchMethodError catch (_) { + throw Exception('This method is not supported in Drift versions <2.19.0'); + } + } + @override Future runUpdate(String statement, List args) { return _spanHelper.asyncWrapInSpan( diff --git a/drift/lib/src/version.dart b/drift/lib/src/version.dart index f56b0af46f..66971d7c5a 100644 --- a/drift/lib/src/version.dart +++ b/drift/lib/src/version.dart @@ -1,5 +1,5 @@ /// The SDK version reported to Sentry.io in the submitted events. -const String sdkVersion = '8.3.0'; +const String sdkVersion = '8.9.0'; /// The package name reported to Sentry.io in the submitted events. const String packageName = 'pub:sentry_drift'; diff --git a/drift/pubspec.yaml b/drift/pubspec.yaml index b3a529de9c..de8b841c76 100644 --- a/drift/pubspec.yaml +++ b/drift/pubspec.yaml @@ -1,6 +1,6 @@ name: sentry_drift description: An integration which adds support for performance tracing for the drift package. -version: 8.3.0 +version: 8.9.0 homepage: https://docs.sentry.io/platforms/flutter/ repository: https://github.com/getsentry/sentry-dart issue_tracker: https://github.com/getsentry/sentry-dart/issues @@ -17,7 +17,7 @@ platforms: web: dependencies: - sentry: 8.3.0 + sentry: 8.9.0 meta: ^1.3.0 drift: ^2.13.0 @@ -32,4 +32,4 @@ dev_dependencies: yaml: ^3.1.0 # needed for version match (code and pubspec) sqlite3_flutter_libs: ^0.5.0 sqlite3: ^2.1.0 - archive: ^3.1.2 \ No newline at end of file + archive: ^3.1.2 diff --git a/drift/test/sentry_database_test.dart b/drift/test/sentry_database_test.dart index 1a2f594007..9028889630 100644 --- a/drift/test/sentry_database_test.dart +++ b/drift/test/sentry_database_test.dart @@ -15,6 +15,7 @@ import 'package:sqlite3/open.dart'; import 'mocks/mocks.mocks.dart'; import 'test_database.dart'; +import 'utils.dart'; import 'utils/windows_helper.dart'; void main() { @@ -324,9 +325,14 @@ void main() { }); } catch (_) {} + final spans = fixture.tracer.children + .where((child) => child.status == SpanStatus.aborted()); + expect(spans.length, 1); + final abortedSpan = spans.first; + verifySpan( expectedTransactionStatement, - fixture.getCreatedSpan(), + abortedSpan, origin: SentryTraceOrigins.autoDbDriftTransactionExecutor, status: SpanStatus.aborted(), ); @@ -638,7 +644,7 @@ void main() { } class Fixture { - final options = SentryOptions(); + final options = defaultTestOptions(); final hub = MockHub(); static final dbName = 'people-drift-impl'; final exception = Exception('fixture-exception'); diff --git a/drift/test/utils.dart b/drift/test/utils.dart new file mode 100644 index 0000000000..7fb87861f0 --- /dev/null +++ b/drift/test/utils.dart @@ -0,0 +1,8 @@ +import 'package:sentry/sentry.dart'; + +final fakeDsn = 'https://abc@def.ingest.sentry.io/1234567'; + +SentryOptions defaultTestOptions() { + // ignore: invalid_use_of_internal_member + return SentryOptions(dsn: fakeDsn)..automatedTestMode = true; +} diff --git a/file/lib/src/sentry_file_extension.dart b/file/lib/src/sentry_file_extension.dart index f6f0c70de2..3cc764c36e 100644 --- a/file/lib/src/sentry_file_extension.dart +++ b/file/lib/src/sentry_file_extension.dart @@ -1,6 +1,8 @@ // ignore_for_file: invalid_use_of_internal_member -import 'dart:io' if (dart.library.html) 'dart:html'; +import 'dart:io' + if (dart.library.html) 'dart:html' + if (dart.library.js_interop) 'dart:js_interop'; import 'package:meta/meta.dart'; import 'package:sentry/sentry.dart'; diff --git a/file/lib/src/version.dart b/file/lib/src/version.dart index 6e9dc3050c..91e75ccc74 100644 --- a/file/lib/src/version.dart +++ b/file/lib/src/version.dart @@ -1,5 +1,5 @@ /// The SDK version reported to Sentry.io in the submitted events. -const String sdkVersion = '8.3.0'; +const String sdkVersion = '8.9.0'; /// The package name reported to Sentry.io in the submitted events. const String packageName = 'pub:sentry_file'; diff --git a/file/pubspec.yaml b/file/pubspec.yaml index 9a2027e8c7..4a31a3ceaa 100644 --- a/file/pubspec.yaml +++ b/file/pubspec.yaml @@ -1,6 +1,6 @@ name: sentry_file description: An integration which adds support for performance tracing for dart.io.File. -version: 8.3.0 +version: 8.9.0 homepage: https://docs.sentry.io/platforms/dart/ repository: https://github.com/getsentry/sentry-dart issue_tracker: https://github.com/getsentry/sentry-dart/issues @@ -17,7 +17,7 @@ platforms: windows: dependencies: - sentry: 8.3.0 + sentry: 8.9.0 meta: ^1.3.0 dev_dependencies: diff --git a/file/test/mock_sentry_client.dart b/file/test/mock_sentry_client.dart index 4a4a28142d..8fb4091cd7 100644 --- a/file/test/mock_sentry_client.dart +++ b/file/test/mock_sentry_client.dart @@ -4,6 +4,11 @@ import 'no_such_method_provider.dart'; final fakeDsn = 'https://abc@def.ingest.sentry.io/1234567'; +SentryOptions defaultTestOptions() { + // ignore: invalid_use_of_internal_member + return SentryOptions(dsn: fakeDsn)..automatedTestMode = true; +} + class MockSentryClient with NoSuchMethodProvider implements SentryClient { List captureTransactionCalls = []; diff --git a/file/test/sentry_file_extension_test.dart b/file/test/sentry_file_extension_test.dart index d6a0bff5bc..843de96003 100644 --- a/file/test/sentry_file_extension_test.dart +++ b/file/test/sentry_file_extension_test.dart @@ -46,7 +46,7 @@ void main() { } class Fixture { - final options = SentryOptions(dsn: fakeDsn); + final options = defaultTestOptions(); late Hub hub; File getSut({ diff --git a/file/test/sentry_file_test.dart b/file/test/sentry_file_test.dart index 48cf9b5175..8a1731a96c 100644 --- a/file/test/sentry_file_test.dart +++ b/file/test/sentry_file_test.dart @@ -662,7 +662,7 @@ void main() { class Fixture { final client = MockSentryClient(); - final options = SentryOptions(dsn: fakeDsn); + final options = defaultTestOptions(); late Hub hub; SentryFile getSut( diff --git a/file/test/sentry_io_overrides_integration_test.dart b/file/test/sentry_io_overrides_integration_test.dart index 86d5f25d74..5a0dcc23a5 100644 --- a/file/test/sentry_io_overrides_integration_test.dart +++ b/file/test/sentry_io_overrides_integration_test.dart @@ -68,7 +68,7 @@ void main() { } class Fixture { - final options = SentryOptions(dsn: fakeDsn); + final options = defaultTestOptions(); late final hub = Hub(options); SentryIOOverridesIntegration getSut() { diff --git a/flutter/android/build.gradle b/flutter/android/build.gradle index 89146e73ff..7c305409ba 100644 --- a/flutter/android/build.gradle +++ b/flutter/android/build.gradle @@ -1,12 +1,12 @@ buildscript { - ext.kotlin_version = '1.6.21' + ext.kotlin_version = '1.8.0' repositories { google() mavenCentral() } dependencies { - classpath 'com.android.tools.build:gradle:7.2.2' + classpath 'com.android.tools.build:gradle:7.4.2' classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" } } @@ -60,7 +60,7 @@ android { } dependencies { - api 'io.sentry:sentry-android:7.10.0' + api 'io.sentry:sentry-android:7.14.0' implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version" // Required -- JUnit 4 framework diff --git a/flutter/android/gradle.properties b/flutter/android/gradle.properties index 8bd86f6805..d9cf55df7c 100644 --- a/flutter/android/gradle.properties +++ b/flutter/android/gradle.properties @@ -1 +1,2 @@ org.gradle.jvmargs=-Xmx1536M +android.useAndroidX=true diff --git a/flutter/android/src/main/kotlin/io/sentry/flutter/SentryFlutter.kt b/flutter/android/src/main/kotlin/io/sentry/flutter/SentryFlutter.kt index c06a8b0dc2..4e11cf5cf5 100644 --- a/flutter/android/src/main/kotlin/io/sentry/flutter/SentryFlutter.kt +++ b/flutter/android/src/main/kotlin/io/sentry/flutter/SentryFlutter.kt @@ -1,9 +1,13 @@ package io.sentry.flutter +import android.util.Log import io.sentry.SentryLevel +import io.sentry.SentryOptions.Proxy +import io.sentry.SentryReplayOptions import io.sentry.android.core.BuildConfig import io.sentry.android.core.SentryAndroidOptions import io.sentry.protocol.SdkVersion +import java.net.Proxy.Type import java.util.Locale class SentryFlutter( @@ -61,7 +65,7 @@ class SentryFlutter( } data.getIfNotNull("diagnosticLevel") { if (options.isDebug) { - val sentryLevel = SentryLevel.valueOf(it.toUpperCase(Locale.ROOT)) + val sentryLevel = SentryLevel.valueOf(it.uppercase(Locale.ROOT)) options.setDiagnosticLevel(sentryLevel) } } @@ -119,6 +123,42 @@ class SentryFlutter( data.getIfNotNull("readTimeoutMillis") { options.readTimeoutMillis = it } + data.getIfNotNull>("proxy") { proxyJson -> + options.proxy = + Proxy() + .apply { + host = proxyJson["host"] as? String + port = + (proxyJson["port"] as? Int) + ?.let { + "$it" + } + (proxyJson["type"] as? String) + ?.let { + type = + try { + Type.valueOf(it.toUpperCase(Locale.ROOT)) + } catch (_: IllegalArgumentException) { + Log.w("Sentry", "Could not parse `type` from proxy json: $proxyJson") + null + } + } + user = proxyJson["user"] as? String + pass = proxyJson["pass"] as? String + } + } + + data.getIfNotNull>("replay") { + updateReplayOptions(options.experimental.sessionReplay, it) + } + } + + fun updateReplayOptions( + options: SentryReplayOptions, + data: Map, + ) { + options.sessionSampleRate = data["sessionSampleRate"] as? Double + options.errorSampleRate = data["onErrorSampleRate"] as? Double } } diff --git a/flutter/android/src/main/kotlin/io/sentry/flutter/SentryFlutterPlugin.kt b/flutter/android/src/main/kotlin/io/sentry/flutter/SentryFlutterPlugin.kt index 424e51c6b7..a6d4516152 100644 --- a/flutter/android/src/main/kotlin/io/sentry/flutter/SentryFlutterPlugin.kt +++ b/flutter/android/src/main/kotlin/io/sentry/flutter/SentryFlutterPlugin.kt @@ -2,6 +2,8 @@ package io.sentry.flutter import android.app.Activity import android.content.Context +import android.os.Build +import android.os.Looper import android.util.Log import io.flutter.embedding.engine.plugins.FlutterPlugin import io.flutter.embedding.engine.plugins.activity.ActivityAware @@ -24,16 +26,22 @@ import io.sentry.android.core.SentryAndroid import io.sentry.android.core.SentryAndroidOptions import io.sentry.android.core.performance.AppStartMetrics import io.sentry.android.core.performance.TimeSpan +import io.sentry.android.replay.ReplayIntegration import io.sentry.protocol.DebugImage import io.sentry.protocol.SdkVersion import io.sentry.protocol.SentryId import io.sentry.protocol.User +import io.sentry.transport.CurrentDateProvider +import java.io.File import java.lang.ref.WeakReference +import kotlin.time.DurationUnit +import kotlin.time.toDuration class SentryFlutterPlugin : FlutterPlugin, MethodCallHandler, ActivityAware { private lateinit var channel: MethodChannel private lateinit var context: Context private lateinit var sentryFlutter: SentryFlutter + private lateinit var replay: ReplayIntegration private var activity: WeakReference? = null private var framesTracker: ActivityFramesTracker? = null @@ -48,12 +56,16 @@ class SentryFlutterPlugin : FlutterPlugin, MethodCallHandler, ActivityAware { sentryFlutter = SentryFlutter( - androidSdk = androidSdk, - nativeSdk = nativeSdk, + androidSdk = ANDROID_SDK, + nativeSdk = NATIVE_SDK, ) } - override fun onMethodCall(call: MethodCall, result: Result) { + @Suppress("CyclomaticComplexMethod") + override fun onMethodCall( + call: MethodCall, + result: Result, + ) { when (call.method) { "initNativeSdk" -> initNativeSdk(call, result) "captureEnvelope" -> captureEnvelope(call, result) @@ -72,6 +84,10 @@ class SentryFlutterPlugin : FlutterPlugin, MethodCallHandler, ActivityAware { "setTag" -> setTag(call.argument("key"), call.argument("value"), result) "removeTag" -> removeTag(call.argument("key"), result) "loadContexts" -> loadContexts(result) + "displayRefreshRate" -> displayRefreshRate(result) + "nativeCrash" -> crash() + "addReplayScreenshot" -> addReplayScreenshot(call.argument("path"), call.argument("timestamp"), result) + "captureReplay" -> captureReplay(call.argument("isCrash"), result) else -> result.notImplemented() } } @@ -101,7 +117,10 @@ class SentryFlutterPlugin : FlutterPlugin, MethodCallHandler, ActivityAware { // Stub } - private fun initNativeSdk(call: MethodCall, result: Result) { + private fun initNativeSdk( + call: MethodCall, + result: Result, + ) { if (!this::context.isInitialized) { result.error("1", "Context is null", null) return @@ -121,6 +140,27 @@ class SentryFlutterPlugin : FlutterPlugin, MethodCallHandler, ActivityAware { } options.beforeSend = BeforeSendCallbackImpl(options.sdkVersion) + + // Replace the default ReplayIntegration with a Flutter-specific recorder. + options.integrations.removeAll { it is ReplayIntegration } + val cacheDirPath = options.cacheDirPath + val replayOptions = options.experimental.sessionReplay + val isReplayEnabled = replayOptions.isSessionReplayEnabled || replayOptions.isSessionReplayForErrorsEnabled + if (cacheDirPath != null && isReplayEnabled) { + replay = + ReplayIntegration( + context, + dateProvider = CurrentDateProvider.getInstance(), + recorderProvider = { SentryFlutterReplayRecorder(channel, replay) }, + recorderConfigProvider = null, + replayCacheProvider = null, + ) + replay.breadcrumbConverter = SentryFlutterReplayBreadcrumbConverter() + options.addIntegration(replay) + options.setReplayController(replay) + } else { + options.setReplayController(null) + } } result.success("") } @@ -133,6 +173,16 @@ class SentryFlutterPlugin : FlutterPlugin, MethodCallHandler, ActivityAware { val appStartMetrics = AppStartMetrics.getInstance() + if (!appStartMetrics.isAppLaunchedInForeground || + appStartMetrics.appStartTimeSpan.durationMs > 1.toDuration(DurationUnit.MINUTES).inWholeMilliseconds + ) { + Log.w( + "Sentry", + "Invalid app start data: app not launched in foreground or app start took too long (>60s)", + ) + result.success(null) + } + val appStartTimeSpan = appStartMetrics.appStartTimeSpan val appStartTime = appStartTimeSpan.startTimestamp val isColdStart = appStartMetrics.appStartType == AppStartMetrics.AppStartType.COLD @@ -143,6 +193,7 @@ class SentryFlutterPlugin : FlutterPlugin, MethodCallHandler, ActivityAware { } else { val appStartTimeMillis = DateUtils.nanosToMillis(appStartTime.nanoTimestamp().toDouble()) val item = + mutableMapOf( "pluginRegistrationTime" to pluginRegistrationTime, "appStartTime" to appStartTimeMillis, @@ -179,6 +230,29 @@ class SentryFlutterPlugin : FlutterPlugin, MethodCallHandler, ActivityAware { } } + private fun displayRefreshRate(result: Result) { + var refreshRate: Int? = null + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + val display = activity?.get()?.display + if (display != null) { + refreshRate = display.refreshRate.toInt() + } + } else { + val display = + activity + ?.get() + ?.window + ?.windowManager + ?.defaultDisplay + if (display != null) { + refreshRate = display.refreshRate.toInt() + } + } + + result.success(refreshRate) + } + private fun TimeSpan.addToMap(map: MutableMap) { if (startTimestamp == null) return @@ -203,7 +277,10 @@ class SentryFlutterPlugin : FlutterPlugin, MethodCallHandler, ActivityAware { result.success(null) } - private fun endNativeFrames(id: String?, result: Result) { + private fun endNativeFrames( + id: String?, + result: Result, + ) { val activity = activity?.get() if (!sentryFlutter.autoPerformanceTracingEnabled || activity == null || id == null) { if (id == null) { @@ -223,16 +300,21 @@ class SentryFlutterPlugin : FlutterPlugin, MethodCallHandler, ActivityAware { if (total == 0 && slow == 0 && frozen == 0) { result.success(null) } else { - val frames = mapOf( - "totalFrames" to total, - "slowFrames" to slow, - "frozenFrames" to frozen, - ) + val frames = + mapOf( + "totalFrames" to total, + "slowFrames" to slow, + "frozenFrames" to frozen, + ) result.success(frames) } } - private fun setContexts(key: String?, value: Any?, result: Result) { + private fun setContexts( + key: String?, + value: Any?, + result: Result, + ) { if (key == null || value == null) { result.success("") return @@ -244,7 +326,10 @@ class SentryFlutterPlugin : FlutterPlugin, MethodCallHandler, ActivityAware { } } - private fun removeContexts(key: String?, result: Result) { + private fun removeContexts( + key: String?, + result: Result, + ) { if (key == null) { result.success("") return @@ -256,7 +341,10 @@ class SentryFlutterPlugin : FlutterPlugin, MethodCallHandler, ActivityAware { } } - private fun setUser(user: Map?, result: Result) { + private fun setUser( + user: Map?, + result: Result, + ) { if (user != null) { val options = HubAdapter.getInstance().options val userInstance = User.fromMap(user, options) @@ -267,7 +355,10 @@ class SentryFlutterPlugin : FlutterPlugin, MethodCallHandler, ActivityAware { result.success("") } - private fun addBreadcrumb(breadcrumb: Map?, result: Result) { + private fun addBreadcrumb( + breadcrumb: Map?, + result: Result, + ) { if (breadcrumb != null) { val options = HubAdapter.getInstance().options val breadcrumbInstance = Breadcrumb.fromMap(breadcrumb, options) @@ -282,7 +373,11 @@ class SentryFlutterPlugin : FlutterPlugin, MethodCallHandler, ActivityAware { result.success("") } - private fun setExtra(key: String?, value: String?, result: Result) { + private fun setExtra( + key: String?, + value: String?, + result: Result, + ) { if (key == null || value == null) { result.success("") return @@ -292,7 +387,10 @@ class SentryFlutterPlugin : FlutterPlugin, MethodCallHandler, ActivityAware { result.success("") } - private fun removeExtra(key: String?, result: Result) { + private fun removeExtra( + key: String?, + result: Result, + ) { if (key == null) { result.success("") return @@ -302,7 +400,11 @@ class SentryFlutterPlugin : FlutterPlugin, MethodCallHandler, ActivityAware { result.success("") } - private fun setTag(key: String?, value: String?, result: Result) { + private fun setTag( + key: String?, + value: String?, + result: Result, + ) { if (key == null || value == null) { result.success("") return @@ -312,7 +414,10 @@ class SentryFlutterPlugin : FlutterPlugin, MethodCallHandler, ActivityAware { result.success("") } - private fun removeTag(key: String?, result: Result) { + private fun removeTag( + key: String?, + result: Result, + ) { if (key == null) { result.success("") return @@ -322,7 +427,10 @@ class SentryFlutterPlugin : FlutterPlugin, MethodCallHandler, ActivityAware { result.success("") } - private fun captureEnvelope(call: MethodCall, result: Result) { + private fun captureEnvelope( + call: MethodCall, + result: Result, + ) { if (!Sentry.isEnabled()) { result.error("1", "The Sentry Android SDK is disabled", null) return @@ -330,8 +438,9 @@ class SentryFlutterPlugin : FlutterPlugin, MethodCallHandler, ActivityAware { val args = call.arguments() as List? ?: listOf() if (args.isNotEmpty()) { val event = args.first() as ByteArray? + val containsUnhandledException = args[1] as Boolean if (event != null && event.isNotEmpty()) { - val id = InternalSentrySdk.captureEnvelope(event) + val id = InternalSentrySdk.captureEnvelope(event, containsUnhandledException) if (id != null) { result.success("") } else { @@ -379,7 +488,10 @@ class SentryFlutterPlugin : FlutterPlugin, MethodCallHandler, ActivityAware { private class BeforeSendCallbackImpl( private val sdkVersion: SdkVersion?, ) : SentryOptions.BeforeSendCallback { - override fun execute(event: SentryEvent, hint: Hint): SentryEvent { + override fun execute( + event: SentryEvent, + hint: Hint, + ): SentryEvent { setEventOriginTag(event) addPackages(event, sdkVersion) return event @@ -387,16 +499,17 @@ class SentryFlutterPlugin : FlutterPlugin, MethodCallHandler, ActivityAware { } companion object { + private const val FLUTTER_SDK = "sentry.dart.flutter" + private const val ANDROID_SDK = "sentry.java.android.flutter" + private const val NATIVE_SDK = "sentry.native.android.flutter" + private const val NATIVE_CRASH_WAIT_TIME = 500L - private const val flutterSdk = "sentry.dart.flutter" - private const val androidSdk = "sentry.java.android.flutter" - private const val nativeSdk = "sentry.native.android.flutter" private fun setEventOriginTag(event: SentryEvent) { event.sdk?.let { when (it.name) { - flutterSdk -> setEventEnvironmentTag(event, "flutter", "dart") - androidSdk -> setEventEnvironmentTag(event, environment = "java") - nativeSdk -> setEventEnvironmentTag(event, environment = "native") + FLUTTER_SDK -> setEventEnvironmentTag(event, "flutter", "dart") + ANDROID_SDK -> setEventEnvironmentTag(event, environment = "java") + NATIVE_SDK -> setEventEnvironmentTag(event, environment = "native") else -> return } } @@ -411,9 +524,12 @@ class SentryFlutterPlugin : FlutterPlugin, MethodCallHandler, ActivityAware { event.setTag("event.environment", environment) } - private fun addPackages(event: SentryEvent, sdk: SdkVersion?) { + private fun addPackages( + event: SentryEvent, + sdk: SdkVersion?, + ) { event.sdk?.let { - if (it.name == flutterSdk) { + if (it.name == FLUTTER_SDK) { sdk?.packageSet?.forEach { sentryPackage -> it.addPackage(sentryPackage.name, sentryPackage.version) } @@ -423,6 +539,13 @@ class SentryFlutterPlugin : FlutterPlugin, MethodCallHandler, ActivityAware { } } } + + private fun crash() { + val exception = RuntimeException("FlutterSentry Native Integration: Sample RuntimeException") + val mainThread = Looper.getMainLooper().thread + mainThread.uncaughtExceptionHandler.uncaughtException(mainThread, exception) + mainThread.join(NATIVE_CRASH_WAIT_TIME) + } } private fun loadContexts(result: Result) { @@ -440,4 +563,29 @@ class SentryFlutterPlugin : FlutterPlugin, MethodCallHandler, ActivityAware { ) result.success(serializedScope) } + + private fun addReplayScreenshot( + path: String?, + timestamp: Long?, + result: Result, + ) { + if (path == null || timestamp == null) { + result.error("5", "Arguments are null", null) + return + } + replay.onScreenshotRecorded(File(path), timestamp) + result.success("") + } + + private fun captureReplay( + isCrash: Boolean?, + result: Result, + ) { + if (isCrash == null) { + result.error("5", "Arguments are null", null) + return + } + replay.captureReplay(isCrash) + result.success(replay.getReplayId().toString()) + } } diff --git a/flutter/android/src/main/kotlin/io/sentry/flutter/SentryFlutterReplayBreadcrumbConverter.kt b/flutter/android/src/main/kotlin/io/sentry/flutter/SentryFlutterReplayBreadcrumbConverter.kt new file mode 100644 index 0000000000..a711a36439 --- /dev/null +++ b/flutter/android/src/main/kotlin/io/sentry/flutter/SentryFlutterReplayBreadcrumbConverter.kt @@ -0,0 +1,118 @@ +package io.sentry.flutter + +import io.sentry.Breadcrumb +import io.sentry.android.replay.DefaultReplayBreadcrumbConverter +import io.sentry.rrweb.RRWebBreadcrumbEvent +import io.sentry.rrweb.RRWebEvent +import io.sentry.rrweb.RRWebSpanEvent +import java.util.Date + +private const val MILLIS_PER_SECOND = 1000.0 +private const val MAX_PATH_ITEMS = 4 +private const val MAX_PATH_IDENTIFIER_LENGTH = 20 + +class SentryFlutterReplayBreadcrumbConverter : DefaultReplayBreadcrumbConverter() { + internal companion object { + private val supportedNetworkData = + mapOf( + "status_code" to "statusCode", + "method" to "method", + "response_body_size" to "responseBodySize", + "request_body_size" to "requestBodySize", + ) + } + + override fun convert(breadcrumb: Breadcrumb): RRWebEvent? { + return when (breadcrumb.category) { + null -> null + "sentry.event" -> null + "sentry.transaction" -> null + "http" -> convertNetworkBreadcrumb(breadcrumb) + "navigation" -> newRRWebBreadcrumb(breadcrumb) + "ui.click" -> + newRRWebBreadcrumb(breadcrumb).apply { + category = "ui.tap" + message = getTouchPathMessage(breadcrumb.data["path"]) + } + + else -> { + val nativeBreadcrumb = super.convert(breadcrumb) + + // ignore native navigation breadcrumbs + if (nativeBreadcrumb is RRWebBreadcrumbEvent) { + if (nativeBreadcrumb.category == "navigation") { + return null + } + } + + nativeBreadcrumb + } + } + } + + private fun newRRWebBreadcrumb(breadcrumb: Breadcrumb): RRWebBreadcrumbEvent = + RRWebBreadcrumbEvent().apply { + category = breadcrumb.category + level = breadcrumb.level + data = breadcrumb.data + timestamp = breadcrumb.timestamp.time + breadcrumbTimestamp = doubleTimestamp(breadcrumb.timestamp) + breadcrumbType = "default" + } + + private fun doubleTimestamp(date: Date) = doubleTimestamp(date.time) + + private fun doubleTimestamp(timestamp: Long) = timestamp / MILLIS_PER_SECOND + + private fun convertNetworkBreadcrumb(breadcrumb: Breadcrumb): RRWebEvent? { + var rrWebEvent = super.convert(breadcrumb) + if (rrWebEvent == null && + breadcrumb.data.containsKey("start_timestamp") && + breadcrumb.data.containsKey("end_timestamp") + ) { + rrWebEvent = + RRWebSpanEvent().apply { + op = "resource.http" + timestamp = breadcrumb.timestamp.time + description = breadcrumb.data["url"] as String + startTimestamp = doubleTimestamp(breadcrumb.data["start_timestamp"] as Long) + endTimestamp = doubleTimestamp(breadcrumb.data["end_timestamp"] as Long) + data = + breadcrumb.data + .filterKeys { key -> supportedNetworkData.containsKey(key) } + .mapKeys { (key, _) -> supportedNetworkData[key] } + } + } + return rrWebEvent + } + + private fun getTouchPathMessage(maybePath: Any?): String? { + if (maybePath !is List<*> || maybePath.isEmpty()) { + return null + } + + val message = StringBuilder() + for (i in Math.min(MAX_PATH_ITEMS, maybePath.size) - 1 downTo 0) { + val item = maybePath[i] + if (item !is Map<*, *>) { + continue + } + + message.append(item["element"] ?: "?") + + var identifier = item["label"] ?: item["name"] + if (identifier is String && identifier.isNotEmpty()) { + if (identifier.length > MAX_PATH_IDENTIFIER_LENGTH) { + identifier = identifier.substring(0, MAX_PATH_IDENTIFIER_LENGTH - "...".length) + "..." + } + message.append("(").append(identifier).append(")") + } + + if (i > 0) { + message.append(" > ") + } + } + + return message.toString() + } +} diff --git a/flutter/android/src/main/kotlin/io/sentry/flutter/SentryFlutterReplayRecorder.kt b/flutter/android/src/main/kotlin/io/sentry/flutter/SentryFlutterReplayRecorder.kt new file mode 100644 index 0000000000..ba285a12a0 --- /dev/null +++ b/flutter/android/src/main/kotlin/io/sentry/flutter/SentryFlutterReplayRecorder.kt @@ -0,0 +1,72 @@ +package io.sentry.flutter + +import android.os.Handler +import android.os.Looper +import android.util.Log +import io.flutter.plugin.common.MethodChannel +import io.sentry.android.replay.Recorder +import io.sentry.android.replay.ReplayIntegration +import io.sentry.android.replay.ScreenshotRecorderConfig + +internal class SentryFlutterReplayRecorder( + private val channel: MethodChannel, + private val integration: ReplayIntegration, +) : Recorder { + override fun start(recorderConfig: ScreenshotRecorderConfig) { + val cacheDirPath = integration.replayCacheDir?.absolutePath + if (cacheDirPath == null) { + Log.w("Sentry", "Replay cache directory is null, can't start replay recorder.") + return + } + Handler(Looper.getMainLooper()).post { + try { + channel.invokeMethod( + "ReplayRecorder.start", + mapOf( + "directory" to cacheDirPath, + "width" to recorderConfig.recordingWidth, + "height" to recorderConfig.recordingHeight, + "frameRate" to recorderConfig.frameRate, + "replayId" to integration.getReplayId().toString(), + ), + ) + } catch (ignored: Exception) { + Log.w("Sentry", "Failed to start replay recorder", ignored) + } + } + } + + override fun resume() { + Handler(Looper.getMainLooper()).post { + try { + channel.invokeMethod("ReplayRecorder.resume", null) + } catch (ignored: Exception) { + Log.w("Sentry", "Failed to resume replay recorder", ignored) + } + } + } + + override fun pause() { + Handler(Looper.getMainLooper()).post { + try { + channel.invokeMethod("ReplayRecorder.pause", null) + } catch (ignored: Exception) { + Log.w("Sentry", "Failed to pause replay recorder", ignored) + } + } + } + + override fun stop() { + Handler(Looper.getMainLooper()).post { + try { + channel.invokeMethod("ReplayRecorder.stop", null) + } catch (ignored: Exception) { + Log.w("Sentry", "Failed to stop replay recorder", ignored) + } + } + } + + override fun close() { + stop() + } +} diff --git a/flutter/android/src/test/kotlin/io/sentry/flutter/SentryFlutterTest.kt b/flutter/android/src/test/kotlin/io/sentry/flutter/SentryFlutterTest.kt index 724559bb76..efab672972 100644 --- a/flutter/android/src/test/kotlin/io/sentry/flutter/SentryFlutterTest.kt +++ b/flutter/android/src/test/kotlin/io/sentry/flutter/SentryFlutterTest.kt @@ -6,6 +6,7 @@ import io.sentry.android.core.SentryAndroidOptions import org.junit.Assert.assertEquals import org.junit.Before import org.junit.Test +import java.net.Proxy class SentryFlutterTest { private lateinit var fixture: Fixture @@ -60,6 +61,22 @@ class SentryFlutterTest { assertEquals(9006, fixture.options.connectionTimeoutMillis) assertEquals(9007, fixture.options.readTimeoutMillis) + + assertEquals("localhost", fixture.options.proxy?.host) + assertEquals("8080", fixture.options.proxy?.port) + assertEquals(Proxy.Type.HTTP, fixture.options.proxy?.type) + assertEquals("admin", fixture.options.proxy?.user) + assertEquals("0000", fixture.options.proxy?.pass) + + assertEquals(0.5, fixture.options.experimental.sessionReplay.sessionSampleRate) + assertEquals(0.6, fixture.options.experimental.sessionReplay.errorSampleRate) + + // Note: these are currently read-only in SentryReplayOptions so we're only asserting the default values here to + // know when there's a change in the native SDK, as it may require a manual change in the Flutter implementation. + assertEquals(1, fixture.options.experimental.sessionReplay.frameRate) + assertEquals(30_000L, fixture.options.experimental.sessionReplay.errorReplayDuration) + assertEquals(5000L, fixture.options.experimental.sessionReplay.sessionSegmentDuration) + assertEquals(60 * 60 * 1000L, fixture.options.experimental.sessionReplay.sessionDuration) } @Test @@ -127,12 +144,24 @@ class Fixture { "enableAutoPerformanceTracing" to true, "connectionTimeoutMillis" to 9006, "readTimeoutMillis" to 9007, + "proxy" to + mapOf( + "host" to "localhost", + "port" to 8080, + "type" to "http", // lowercase to check enum mapping + "user" to "admin", + "pass" to "0000", + ), + "replay" to + mapOf( + "sessionSampleRate" to 0.5, + "onErrorSampleRate" to 0.6, + ), ) - fun getSut(): SentryFlutter { - return SentryFlutter( + fun getSut(): SentryFlutter = + SentryFlutter( androidSdk = "sentry.java.android.flutter", nativeSdk = "fixture-nativeSdk", ) - } } diff --git a/flutter/example/android/app/build.gradle b/flutter/example/android/app/build.gradle index e9ac4161a5..ed3e1a1b6b 100644 --- a/flutter/example/android/app/build.gradle +++ b/flutter/example/android/app/build.gradle @@ -65,8 +65,6 @@ android { } } - // TODO: we need to fix CI as the version 21.1 (default) is not installed by default on - // GH Actions. ndkVersion "25.1.8937393" externalNativeBuild { diff --git a/flutter/example/android/app/src/main/AndroidManifest.xml b/flutter/example/android/app/src/main/AndroidManifest.xml index c2029920e9..1b2b2012cf 100644 --- a/flutter/example/android/app/src/main/AndroidManifest.xml +++ b/flutter/example/android/app/src/main/AndroidManifest.xml @@ -1,6 +1,9 @@ + + + setupSentryAndApp(WidgetTester tester, {String? dsn, BeforeSendCallback? beforeSendCallback}) async { - NativeAppStartIntegration.isIntegrationTest = true; - await setupSentry( () async { await tester.pumpWidget(SentryScreenshotWidget( @@ -121,7 +118,9 @@ void main() { await scope.addBreadcrumb(breadcrumb); await scope.clearBreadcrumbs(); + // ignore: deprecated_member_use await scope.setExtra('extra-key', 'extra-value'); + // ignore: deprecated_member_use await scope.removeExtra('extra-key'); await scope.setTag('tag-key', 'tag-value'); diff --git a/flutter/example/integration_test/replay_test.dart b/flutter/example/integration_test/replay_test.dart new file mode 100644 index 0000000000..00d85ca14f --- /dev/null +++ b/flutter/example/integration_test/replay_test.dart @@ -0,0 +1,39 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:sentry_flutter/sentry_flutter.dart'; + +void main() { + group('Replay recording', () { + 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.experimental.replay.sessionSampleRate = 1.0; + }); + }); + + tearDown(() async { + await Sentry.close(); + }); + + test('native binding is initialized', () async { + // ignore: invalid_use_of_internal_member + expect(SentryFlutter.native, isNotNull); + }); + + test('session replay is captured', () async { + // TODO add when the beforeSend callback is implemented for replays. + }); + + test('replay is captured on errors', () async { + // TODO we may need an HTTP server for this because Android sends replays + // in a separate envelope. + }); + }, + skip: Platform.isAndroid + ? false + : "Replay recording is not supported on this platform"); +} diff --git a/flutter/example/ios/Runner/AppDelegate.swift b/flutter/example/ios/Runner/AppDelegate.swift index a231cc9c60..c24cacbbb2 100644 --- a/flutter/example/ios/Runner/AppDelegate.swift +++ b/flutter/example/ios/Runner/AppDelegate.swift @@ -2,7 +2,7 @@ import UIKit import Flutter import Sentry -@UIApplicationMain +@main @objc class AppDelegate: FlutterAppDelegate { private let _channel = "example.flutter.sentry.io" diff --git a/flutter/example/ios/RunnerTests/SentryFlutterTests.swift b/flutter/example/ios/RunnerTests/SentryFlutterTests.swift index 057b2363b5..4873388f2f 100644 --- a/flutter/example/ios/RunnerTests/SentryFlutterTests.swift +++ b/flutter/example/ios/RunnerTests/SentryFlutterTests.swift @@ -9,6 +9,8 @@ import XCTest import sentry_flutter import Sentry +// swiftlint:disable function_body_length line_length + final class SentryFlutterTests: XCTestCase { private var fixture: Fixture! @@ -43,7 +45,14 @@ final class SentryFlutterTests: XCTestCase { "maxAttachmentSize": NSNumber(value: 9004), "captureFailedRequests": false, "enableAppHangTracking": false, - "appHangTimeoutIntervalMillis": NSNumber(value: 10000) + "appHangTimeoutIntervalMillis": NSNumber(value: 10000), + "proxy": [ + "host": "localhost", + "port": NSNumber(value: 8080), + "type": "hTtP", // mixed case to check enum mapping + "user": "admin", + "pass": "0000" + ] ] ) @@ -68,6 +77,41 @@ final class SentryFlutterTests: XCTestCase { XCTAssertEqual(false, fixture.options.enableCaptureFailedRequests) XCTAssertEqual(false, fixture.options.enableAppHangTracking) XCTAssertEqual(10, fixture.options.appHangTimeoutInterval) + + XCTAssertNotNil(fixture.options.urlSession) + XCTAssertEqual(true, fixture.options.urlSession?.configuration.connectionProxyDictionary?[kCFNetworkProxiesHTTPEnable as String] as? Bool) + XCTAssertEqual("localhost", fixture.options.urlSession?.configuration.connectionProxyDictionary?[kCFNetworkProxiesHTTPProxy as String] as? String) + XCTAssertEqual(8080, fixture.options.urlSession?.configuration.connectionProxyDictionary?[kCFNetworkProxiesHTTPPort as String] as? Int) + XCTAssertEqual("admin", fixture.options.urlSession?.configuration.connectionProxyDictionary?[kCFProxyUsernameKey as String] as? String) + XCTAssertEqual("0000", fixture.options.urlSession?.configuration.connectionProxyDictionary?[kCFProxyPasswordKey as String] as? String) + } + + func testUpdateSocksProxy() { + let sut = fixture.getSut() + + sut.update( + options: fixture.options, + with: [ + "proxy": [ + "host": "localhost", + "port": 8080, + "type": "sOcKs", // mixed case to check enum mapping + "user": "admin", + "pass": "0000" + ] + ] + ) + + #if os(macOS) + XCTAssertNotNil(fixture.options.urlSession) + XCTAssertEqual(true, fixture.options.urlSession?.configuration.connectionProxyDictionary?[kCFNetworkProxiesSOCKSEnable as String] as? Bool) + XCTAssertEqual("localhost", fixture.options.urlSession?.configuration.connectionProxyDictionary?[kCFNetworkProxiesSOCKSProxy as String] as? String) + XCTAssertEqual(8080, fixture.options.urlSession?.configuration.connectionProxyDictionary?[kCFNetworkProxiesSOCKSPort as String] as? Int) + XCTAssertEqual("admin", fixture.options.urlSession?.configuration.connectionProxyDictionary?[kCFProxyUsernameKey as String] as? String) + XCTAssertEqual("0000", fixture.options.urlSession?.configuration.connectionProxyDictionary?[kCFProxyPasswordKey as String] as? String) + #else + XCTAssertNil(fixture.options.urlSession) + #endif } } @@ -81,3 +125,5 @@ extension SentryFlutterTests { } } } + +// swiftlint:enable function_body_length line_length diff --git a/flutter/example/lib/main.dart b/flutter/example/lib/main.dart index 86da143e31..dd870eb587 100644 --- a/flutter/example/lib/main.dart +++ b/flutter/example/lib/main.dart @@ -4,31 +4,31 @@ import 'dart:async'; import 'dart:convert'; import 'dart:math'; +import 'package:dio/dio.dart'; +import 'package:feedback/feedback.dart' as feedback; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:logging/logging.dart'; import 'package:path_provider/path_provider.dart'; +import 'package:provider/provider.dart'; +import 'package:sentry_dio/sentry_dio.dart'; import 'package:sentry_drift/sentry_drift.dart'; import 'package:sentry_flutter/sentry_flutter.dart'; +import 'package:sentry_hive/sentry_hive.dart'; import 'package:sentry_isar/sentry_isar.dart'; +import 'package:sentry_logging/sentry_logging.dart'; import 'package:sentry_sqflite/sentry_sqflite.dart'; import 'package:sqflite/sqflite.dart'; - // import 'package:sqflite_common_ffi/sqflite_ffi.dart'; // import 'package:sqflite_common_ffi_web/sqflite_ffi_web.dart'; import 'package:universal_platform/universal_platform.dart'; -import 'package:feedback/feedback.dart' as feedback; -import 'package:provider/provider.dart'; + import 'auto_close_screen.dart'; -import 'drift/database.dart'; import 'drift/connection/connection.dart'; +import 'drift/database.dart'; import 'isar/user.dart'; import 'user_feedback_dialog.dart'; -import 'package:dio/dio.dart'; -import 'package:sentry_dio/sentry_dio.dart'; -import 'package:sentry_logging/sentry_logging.dart'; -import 'package:sentry_hive/sentry_hive.dart'; // ATTENTION: Change the DSN below with your own to see the events in Sentry. Get one at sentry.io const String exampleDsn = @@ -90,6 +90,9 @@ Future setupSentry( options.maxResponseBodySize = MaxResponseBodySize.always; options.navigatorKey = navigatorKey; + options.experimental.replay.sessionSampleRate = 1.0; + options.experimental.replay.onErrorSampleRate = 1.0; + _isIntegrationTest = isIntegrationTest; if (_isIntegrationTest) { options.dist = '1'; @@ -103,7 +106,7 @@ Future setupSentry( } class MyApp extends StatefulWidget { - const MyApp({Key? key}) : super(key: key); + const MyApp({super.key}); @override _MyAppState createState() => _MyAppState(); @@ -139,8 +142,8 @@ class TooltipButton extends StatelessWidget { required this.onPressed, required this.buttonTitle, required this.text, - Key? key, - }) : super(key: key); + super.key, + }); @override Widget build(BuildContext context) { @@ -157,8 +160,8 @@ class TooltipButton extends StatelessWidget { class MainScaffold extends StatelessWidget { const MainScaffold({ - Key? key, - }) : super(key: key); + super.key, + }); @override Widget build(BuildContext context) { @@ -474,6 +477,7 @@ class MainScaffold extends StatelessWidget { final entries = feedback.extra?.entries; if (entries != null) { for (final extra in entries) { + // ignore: deprecated_member_use scope.setExtra(extra.key, extra.value); } } @@ -715,7 +719,7 @@ extension BuildContextExtension on BuildContext { } class AndroidExample extends StatelessWidget { - const AndroidExample({Key? key}) : super(key: key); + const AndroidExample({super.key}); @override Widget build(BuildContext context) { @@ -757,6 +761,12 @@ class AndroidExample extends StatelessWidget { }, child: const Text('Platform exception'), ), + ElevatedButton( + onPressed: () async { + SentryFlutter.nativeCrash(); + }, + child: const Text('Sentry.nativeCrash'), + ), ]); } } @@ -833,7 +843,7 @@ class _IntegrationTestWidgetState extends State { } class CocoaExample extends StatelessWidget { - const CocoaExample({Key? key}) : super(key: key); + const CocoaExample({super.key}); @override Widget build(BuildContext context) { @@ -869,6 +879,12 @@ class CocoaExample extends StatelessWidget { }, child: const Text('Objective-C SEGFAULT'), ), + ElevatedButton( + onPressed: () async { + SentryFlutter.nativeCrash(); + }, + child: const Text('Sentry.nativeCrash'), + ), ], ); } @@ -886,7 +902,7 @@ int loop(int val) { } class SecondaryScaffold extends StatelessWidget { - const SecondaryScaffold({Key? key}) : super(key: key); + const SecondaryScaffold({super.key}); static Future openSecondaryScaffold(BuildContext context) { return Navigator.push( @@ -1027,7 +1043,10 @@ Future showDialogWithTextAndImage(BuildContext context) async { await DefaultAssetBundle.of(context).loadString('assets/lorem-ipsum.txt'); if (!context.mounted) return; + final imageBytes = + await DefaultAssetBundle.of(context).load('assets/sentry-wordmark.png'); await showDialog( + // ignore: use_build_context_synchronously context: context, // gets tracked if using SentryNavigatorObserver routeSettings: const RouteSettings( @@ -1040,7 +1059,15 @@ Future showDialogWithTextAndImage(BuildContext context) async { child: Column( mainAxisSize: MainAxisSize.min, children: [ + // Use various ways an image is included in the app. + // Local asset images are not obscured in replay recording. Image.asset('assets/sentry-wordmark.png'), + Image.asset('assets/sentry-wordmark.png', bundle: rootBundle), + Image.asset('assets/sentry-wordmark.png', + bundle: DefaultAssetBundle.of(context)), + Image.network( + 'https://www.gstatic.com/recaptcha/api2/logo_48.png'), + Image.memory(imageBytes.buffer.asUint8List()), Text(text), ], ), diff --git a/flutter/example/lib/user_feedback_dialog.dart b/flutter/example/lib/user_feedback_dialog.dart index 934270b15e..159f3c69d9 100644 --- a/flutter/example/lib/user_feedback_dialog.dart +++ b/flutter/example/lib/user_feedback_dialog.dart @@ -6,11 +6,10 @@ import 'package:sentry_flutter/sentry_flutter.dart'; class UserFeedbackDialog extends StatefulWidget { const UserFeedbackDialog({ - Key? key, + super.key, required this.eventId, this.hub, - }) : assert(eventId != const SentryId.empty()), - super(key: key); + }) : assert(eventId != const SentryId.empty()); final SentryId eventId; final Hub? hub; @@ -115,7 +114,7 @@ class _UserFeedbackDialogState extends State { } class _PoweredBySentryMessage extends StatelessWidget { - const _PoweredBySentryMessage({Key? key}) : super(key: key); + const _PoweredBySentryMessage(); @override Widget build(BuildContext context) { @@ -144,7 +143,7 @@ class _SentryLogo extends StatelessWidget { var color = Colors.white; final brightenss = Theme.of(context).brightness; if (brightenss == Brightness.light) { - color = const Color(0xff362d59).withOpacity(1.0); + color = const Color(0xff362d59); } return FittedBox( diff --git a/flutter/example/pubspec.yaml b/flutter/example/pubspec.yaml index 1f53f5d1c3..398b34568b 100644 --- a/flutter/example/pubspec.yaml +++ b/flutter/example/pubspec.yaml @@ -1,6 +1,6 @@ name: sentry_flutter_example description: Demonstrates how to use the sentry_flutter plugin. -version: 8.3.0 +version: 8.9.0 publish_to: 'none' # Remove this line if you wish to publish to pub.dev diff --git a/flutter/example/windows/CMakeLists.txt b/flutter/example/windows/CMakeLists.txt index 845ddf6fef..5a554e25d0 100644 --- a/flutter/example/windows/CMakeLists.txt +++ b/flutter/example/windows/CMakeLists.txt @@ -1,13 +1,16 @@ -cmake_minimum_required(VERSION 3.15) +# Project-level configuration. +cmake_minimum_required(VERSION 3.14) project(sentry_flutter_example LANGUAGES CXX) +# The name of the executable created for the application. Change this to change +# the on-disk name of your application. set(BINARY_NAME "sentry_flutter_example") -cmake_policy(SET CMP0063 NEW) +# Explicitly opt in to modern CMake behaviors to avoid warnings with recent +# versions of CMake. +cmake_policy(VERSION 3.14...3.25) -set(CMAKE_INSTALL_RPATH "$ORIGIN/lib") - -# Configure build options. +# Define build configuration option. get_property(IS_MULTICONFIG GLOBAL PROPERTY GENERATOR_IS_MULTI_CONFIG) if(IS_MULTICONFIG) set(CMAKE_CONFIGURATION_TYPES "Debug;Profile;Release" @@ -20,7 +23,7 @@ else() "Debug" "Profile" "Release") endif() endif() - +# Define settings for the Profile build mode. set(CMAKE_EXE_LINKER_FLAGS_PROFILE "${CMAKE_EXE_LINKER_FLAGS_RELEASE}") set(CMAKE_SHARED_LINKER_FLAGS_PROFILE "${CMAKE_SHARED_LINKER_FLAGS_RELEASE}") set(CMAKE_C_FLAGS_PROFILE "${CMAKE_C_FLAGS_RELEASE}") @@ -30,6 +33,10 @@ set(CMAKE_CXX_FLAGS_PROFILE "${CMAKE_CXX_FLAGS_RELEASE}") add_definitions(-DUNICODE -D_UNICODE) # Compilation settings that should be applied to most targets. +# +# Be cautious about adding new options here, as plugins use this function by +# default. In most cases, you should add new options to specific targets instead +# of modifying this function. function(APPLY_STANDARD_SETTINGS TARGET) target_compile_features(${TARGET} PUBLIC cxx_std_17) target_compile_options(${TARGET} PRIVATE /W4 /WX /wd"4100") @@ -38,14 +45,14 @@ function(APPLY_STANDARD_SETTINGS TARGET) target_compile_definitions(${TARGET} PRIVATE "$<$:_DEBUG>") endfunction() -set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") - # Flutter library and tool build rules. +set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") add_subdirectory(${FLUTTER_MANAGED_DIR}) -# Application build +# Application build; see runner/CMakeLists.txt. add_subdirectory("runner") + # Generated plugin build rules, which manage building the plugins and adding # them to the application. include(flutter/generated_plugins.cmake) @@ -80,6 +87,12 @@ if(PLUGIN_BUNDLED_LIBRARIES) COMPONENT Runtime) endif() +# Copy the native assets provided by the build.dart from all packages. +set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/windows/") +install(DIRECTORY "${NATIVE_ASSETS_DIR}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + # Fully re-copy the assets directory on each build to avoid having stale files # from a previous install. set(FLUTTER_ASSET_DIR_NAME "flutter_assets") diff --git a/flutter/example/windows/flutter/CMakeLists.txt b/flutter/example/windows/flutter/CMakeLists.txt index c10f4f62cb..efb62ebe7d 100644 --- a/flutter/example/windows/flutter/CMakeLists.txt +++ b/flutter/example/windows/flutter/CMakeLists.txt @@ -1,4 +1,5 @@ -cmake_minimum_required(VERSION 3.15) +# This file controls Flutter-level build steps. It should not be edited. +cmake_minimum_required(VERSION 3.14) set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") @@ -9,6 +10,11 @@ include(${EPHEMERAL_DIR}/generated_config.cmake) # https://github.com/flutter/flutter/issues/57146. set(WRAPPER_ROOT "${EPHEMERAL_DIR}/cpp_client_wrapper") +# Set fallback configurations for older versions of the flutter tool. +if (NOT DEFINED FLUTTER_TARGET_PLATFORM) + set(FLUTTER_TARGET_PLATFORM "windows-x64") +endif() + # === Flutter Library === set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/flutter_windows.dll") @@ -91,7 +97,7 @@ add_custom_command( COMMAND ${CMAKE_COMMAND} -E env ${FLUTTER_TOOL_ENVIRONMENT} "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.bat" - windows-x64 $ + ${FLUTTER_TARGET_PLATFORM} $ VERBATIM ) add_custom_target(flutter_assemble DEPENDS diff --git a/flutter/example/windows/runner/CMakeLists.txt b/flutter/example/windows/runner/CMakeLists.txt index e993217632..2041a04410 100644 --- a/flutter/example/windows/runner/CMakeLists.txt +++ b/flutter/example/windows/runner/CMakeLists.txt @@ -1,18 +1,40 @@ -cmake_minimum_required(VERSION 3.15) +cmake_minimum_required(VERSION 3.14) project(runner LANGUAGES CXX) +# Define the application target. To change its name, change BINARY_NAME in the +# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer +# work. +# +# Any new source files that you add to the application should be added here. add_executable(${BINARY_NAME} WIN32 "flutter_window.cpp" "main.cpp" - "run_loop.cpp" "utils.cpp" "win32_window.cpp" "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" "Runner.rc" "runner.exe.manifest" ) + +# Apply the standard set of build settings. This can be removed for applications +# that need different build settings. apply_standard_settings(${BINARY_NAME}) + +# Add preprocessor definitions for the build version. +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION=\"${FLUTTER_VERSION}\"") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MAJOR=${FLUTTER_VERSION_MAJOR}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MINOR=${FLUTTER_VERSION_MINOR}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_PATCH=${FLUTTER_VERSION_PATCH}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_BUILD=${FLUTTER_VERSION_BUILD}") + +# Disable Windows macros that collide with C++ standard library functions. target_compile_definitions(${BINARY_NAME} PRIVATE "NOMINMAX") + +# Add dependency libraries and include directories. Add any application-specific +# dependencies here. target_link_libraries(${BINARY_NAME} PRIVATE flutter flutter_wrapper_app) +target_link_libraries(${BINARY_NAME} PRIVATE "dwmapi.lib") target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") + +# Run the Flutter tool portions of the build. This must not be removed. add_dependencies(${BINARY_NAME} flutter_assemble) diff --git a/flutter/example/windows/runner/Runner.rc b/flutter/example/windows/runner/Runner.rc index a059a08d0d..90e64b7b0f 100644 --- a/flutter/example/windows/runner/Runner.rc +++ b/flutter/example/windows/runner/Runner.rc @@ -60,14 +60,14 @@ IDI_APP_ICON ICON "resources\\app_icon.ico" // Version // -#ifdef FLUTTER_BUILD_NUMBER -#define VERSION_AS_NUMBER FLUTTER_BUILD_NUMBER +#if defined(FLUTTER_VERSION_MAJOR) && defined(FLUTTER_VERSION_MINOR) && defined(FLUTTER_VERSION_PATCH) && defined(FLUTTER_VERSION_BUILD) +#define VERSION_AS_NUMBER FLUTTER_VERSION_MAJOR,FLUTTER_VERSION_MINOR,FLUTTER_VERSION_PATCH,FLUTTER_VERSION_BUILD #else -#define VERSION_AS_NUMBER 1,0,0 +#define VERSION_AS_NUMBER 1,0,0,0 #endif -#ifdef FLUTTER_BUILD_NAME -#define VERSION_AS_STRING #FLUTTER_BUILD_NAME +#if defined(FLUTTER_VERSION) +#define VERSION_AS_STRING FLUTTER_VERSION #else #define VERSION_AS_STRING "1.0.0" #endif @@ -90,10 +90,10 @@ BEGIN BLOCK "040904e4" BEGIN VALUE "CompanyName", "com.example" "\0" - VALUE "FileDescription", "Demonstrates how to use the sentry_flutter plugin." "\0" + VALUE "FileDescription", "sentry_flutter_example" "\0" VALUE "FileVersion", VERSION_AS_STRING "\0" VALUE "InternalName", "sentry_flutter_example" "\0" - VALUE "LegalCopyright", "Copyright (C) 2021 com.example. All rights reserved." "\0" + VALUE "LegalCopyright", "Copyright (C) 2024 com.example. All rights reserved." "\0" VALUE "OriginalFilename", "sentry_flutter_example.exe" "\0" VALUE "ProductName", "sentry_flutter_example" "\0" VALUE "ProductVersion", VERSION_AS_STRING "\0" diff --git a/flutter/example/windows/runner/flutter_window.cpp b/flutter/example/windows/runner/flutter_window.cpp index ac04f7790a..c819cb083f 100644 --- a/flutter/example/windows/runner/flutter_window.cpp +++ b/flutter/example/windows/runner/flutter_window.cpp @@ -4,9 +4,8 @@ #include "flutter/generated_plugin_registrant.h" -FlutterWindow::FlutterWindow(RunLoop* run_loop, - const flutter::DartProject& project) - : run_loop_(run_loop), project_(project) {} +FlutterWindow::FlutterWindow(const flutter::DartProject& project) + : project_(project) {} FlutterWindow::~FlutterWindow() {} @@ -26,14 +25,22 @@ bool FlutterWindow::OnCreate() { return false; } RegisterPlugins(flutter_controller_->engine()); - run_loop_->RegisterFlutterInstance(flutter_controller_->engine()); SetChildContent(flutter_controller_->view()->GetNativeWindow()); + + flutter_controller_->engine()->SetNextFrameCallback([&]() { + this->Show(); + }); + + // Flutter can complete the first frame before the "show window" callback is + // registered. The following call ensures a frame is pending to ensure the + // window is shown. It is a no-op if the first frame hasn't completed yet. + flutter_controller_->ForceRedraw(); + return true; } void FlutterWindow::OnDestroy() { if (flutter_controller_) { - run_loop_->UnregisterFlutterInstance(flutter_controller_->engine()); flutter_controller_ = nullptr; } @@ -44,7 +51,7 @@ LRESULT FlutterWindow::MessageHandler(HWND hwnd, UINT const message, WPARAM const wparam, LPARAM const lparam) noexcept { - // Give Flutter, including plugins, an opporutunity to handle window messages. + // Give Flutter, including plugins, an opportunity to handle window messages. if (flutter_controller_) { std::optional result = flutter_controller_->HandleTopLevelWindowProc(hwnd, message, wparam, diff --git a/flutter/example/windows/runner/flutter_window.h b/flutter/example/windows/runner/flutter_window.h index ba86031c6c..28c23839b9 100644 --- a/flutter/example/windows/runner/flutter_window.h +++ b/flutter/example/windows/runner/flutter_window.h @@ -6,16 +6,13 @@ #include -#include "run_loop.h" #include "win32_window.h" // A window that does nothing but host a Flutter view. class FlutterWindow : public Win32Window { public: - // Creates a new FlutterWindow driven by the |run_loop|, hosting a - // Flutter view running |project|. - explicit FlutterWindow(RunLoop* run_loop, - const flutter::DartProject& project); + // Creates a new FlutterWindow hosting a Flutter view running |project|. + explicit FlutterWindow(const flutter::DartProject& project); virtual ~FlutterWindow(); protected: @@ -26,9 +23,6 @@ class FlutterWindow : public Win32Window { LPARAM const lparam) noexcept override; private: - // The run loop driving events for this window. - RunLoop* run_loop_; - // The project to run. flutter::DartProject project_; diff --git a/flutter/example/windows/runner/main.cpp b/flutter/example/windows/runner/main.cpp index 0685ffa6aa..11ea9c69a7 100644 --- a/flutter/example/windows/runner/main.cpp +++ b/flutter/example/windows/runner/main.cpp @@ -3,7 +3,6 @@ #include #include "flutter_window.h" -#include "run_loop.h" #include "utils.h" int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev, @@ -18,8 +17,6 @@ int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev, // plugins. ::CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); - RunLoop run_loop; - flutter::DartProject project(L"data"); std::vector command_line_arguments = @@ -27,15 +24,19 @@ int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev, project.set_dart_entrypoint_arguments(std::move(command_line_arguments)); - FlutterWindow window(&run_loop, project); + FlutterWindow window(project); Win32Window::Point origin(10, 10); Win32Window::Size size(1280, 720); - if (!window.CreateAndShow(L"sentry_flutter_example", origin, size)) { + if (!window.Create(L"sentry_flutter_example", origin, size)) { return EXIT_FAILURE; } window.SetQuitOnClose(true); - run_loop.Run(); + ::MSG msg; + while (::GetMessage(&msg, nullptr, 0, 0)) { + ::TranslateMessage(&msg); + ::DispatchMessage(&msg); + } ::CoUninitialize(); return EXIT_SUCCESS; diff --git a/flutter/example/windows/runner/run_loop.cpp b/flutter/example/windows/runner/run_loop.cpp deleted file mode 100644 index 0d912118c2..0000000000 --- a/flutter/example/windows/runner/run_loop.cpp +++ /dev/null @@ -1,66 +0,0 @@ -#include "run_loop.h" - -#include - -#include - -RunLoop::RunLoop() {} - -RunLoop::~RunLoop() {} - -void RunLoop::Run() { - bool keep_running = true; - TimePoint next_flutter_event_time = TimePoint::clock::now(); - while (keep_running) { - std::chrono::nanoseconds wait_duration = - std::max(std::chrono::nanoseconds(0), - next_flutter_event_time - TimePoint::clock::now()); - ::MsgWaitForMultipleObjects( - 0, nullptr, FALSE, static_cast(wait_duration.count() / 1000), - QS_ALLINPUT); - bool processed_events = false; - MSG message; - // All pending Windows messages must be processed; MsgWaitForMultipleObjects - // won't return again for items left in the queue after PeekMessage. - while (::PeekMessage(&message, nullptr, 0, 0, PM_REMOVE)) { - processed_events = true; - if (message.message == WM_QUIT) { - keep_running = false; - break; - } - ::TranslateMessage(&message); - ::DispatchMessage(&message); - // Allow Flutter to process messages each time a Windows message is - // processed, to prevent starvation. - next_flutter_event_time = - std::min(next_flutter_event_time, ProcessFlutterMessages()); - } - // If the PeekMessage loop didn't run, process Flutter messages. - if (!processed_events) { - next_flutter_event_time = - std::min(next_flutter_event_time, ProcessFlutterMessages()); - } - } -} - -void RunLoop::RegisterFlutterInstance( - flutter::FlutterEngine* flutter_instance) { - flutter_instances_.insert(flutter_instance); -} - -void RunLoop::UnregisterFlutterInstance( - flutter::FlutterEngine* flutter_instance) { - flutter_instances_.erase(flutter_instance); -} - -RunLoop::TimePoint RunLoop::ProcessFlutterMessages() { - TimePoint next_event_time = TimePoint::max(); - for (auto instance : flutter_instances_) { - std::chrono::nanoseconds wait_duration = instance->ProcessMessages(); - if (wait_duration != std::chrono::nanoseconds::max()) { - next_event_time = - std::min(next_event_time, TimePoint::clock::now() + wait_duration); - } - } - return next_event_time; -} diff --git a/flutter/example/windows/runner/run_loop.h b/flutter/example/windows/runner/run_loop.h deleted file mode 100644 index 54927f9773..0000000000 --- a/flutter/example/windows/runner/run_loop.h +++ /dev/null @@ -1,40 +0,0 @@ -#ifndef RUNNER_RUN_LOOP_H_ -#define RUNNER_RUN_LOOP_H_ - -#include - -#include -#include - -// A runloop that will service events for Flutter instances as well -// as native messages. -class RunLoop { - public: - RunLoop(); - ~RunLoop(); - - // Prevent copying - RunLoop(RunLoop const&) = delete; - RunLoop& operator=(RunLoop const&) = delete; - - // Runs the run loop until the application quits. - void Run(); - - // Registers the given Flutter instance for event servicing. - void RegisterFlutterInstance( - flutter::FlutterEngine* flutter_instance); - - // Unregisters the given Flutter instance from event servicing. - void UnregisterFlutterInstance( - flutter::FlutterEngine* flutter_instance); - - private: - using TimePoint = std::chrono::steady_clock::time_point; - - // Processes all currently pending messages for registered Flutter instances. - TimePoint ProcessFlutterMessages(); - - std::set flutter_instances_; -}; - -#endif // RUNNER_RUN_LOOP_H_ diff --git a/flutter/example/windows/runner/runner.exe.manifest b/flutter/example/windows/runner/runner.exe.manifest index 2c680b8be2..157e871fe8 100644 --- a/flutter/example/windows/runner/runner.exe.manifest +++ b/flutter/example/windows/runner/runner.exe.manifest @@ -7,7 +7,7 @@ - + diff --git a/flutter/example/windows/runner/utils.cpp b/flutter/example/windows/runner/utils.cpp index 05b53c01b4..fc55c573b5 100644 --- a/flutter/example/windows/runner/utils.cpp +++ b/flutter/example/windows/runner/utils.cpp @@ -47,16 +47,17 @@ std::string Utf8FromUtf16(const wchar_t* utf16_string) { } int target_length = ::WideCharToMultiByte( CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, - -1, nullptr, 0, nullptr, nullptr); - if (target_length == 0) { - return std::string(); - } + -1, nullptr, 0, nullptr, nullptr) + -1; // remove the trailing null character + int input_length = (int)wcslen(utf16_string); std::string utf8_string; + if (target_length <= 0 || target_length > utf8_string.max_size()) { + return utf8_string; + } utf8_string.resize(target_length); int converted_length = ::WideCharToMultiByte( CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, - -1, utf8_string.data(), - target_length, nullptr, nullptr); + input_length, utf8_string.data(), target_length, nullptr, nullptr); if (converted_length == 0) { return std::string(); } diff --git a/flutter/example/windows/runner/win32_window.cpp b/flutter/example/windows/runner/win32_window.cpp index 97f4439cd1..b5ba2a099f 100644 --- a/flutter/example/windows/runner/win32_window.cpp +++ b/flutter/example/windows/runner/win32_window.cpp @@ -1,13 +1,31 @@ #include "win32_window.h" +#include #include #include "resource.h" namespace { +/// Window attribute that enables dark mode window decorations. +/// +/// Redefined in case the developer's machine has a Windows SDK older than +/// version 10.0.22000.0. +/// See: https://docs.microsoft.com/windows/win32/api/dwmapi/ne-dwmapi-dwmwindowattribute +#ifndef DWMWA_USE_IMMERSIVE_DARK_MODE +#define DWMWA_USE_IMMERSIVE_DARK_MODE 20 +#endif + constexpr const wchar_t kWindowClassName[] = L"FLUTTER_RUNNER_WIN32_WINDOW"; +/// Registry key for app theme preference. +/// +/// A value of 0 indicates apps should use dark mode. A non-zero or missing +/// value indicates apps should use light mode. +constexpr const wchar_t kGetPreferredBrightnessRegKey[] = + L"Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize"; +constexpr const wchar_t kGetPreferredBrightnessRegValue[] = L"AppsUseLightTheme"; + // The number of Win32Window objects that currently exist. static int g_active_window_count = 0; @@ -31,8 +49,8 @@ void EnableFullDpiSupportIfAvailable(HWND hwnd) { GetProcAddress(user32_module, "EnableNonClientDpiScaling")); if (enable_non_client_dpi_scaling != nullptr) { enable_non_client_dpi_scaling(hwnd); - FreeLibrary(user32_module); } + FreeLibrary(user32_module); } } // namespace @@ -42,7 +60,7 @@ class WindowClassRegistrar { public: ~WindowClassRegistrar() = default; - // Returns the singleton registar instance. + // Returns the singleton registrar instance. static WindowClassRegistrar* GetInstance() { if (!instance_) { instance_ = new WindowClassRegistrar(); @@ -102,9 +120,9 @@ Win32Window::~Win32Window() { Destroy(); } -bool Win32Window::CreateAndShow(const std::wstring& title, - const Point& origin, - const Size& size) { +bool Win32Window::Create(const std::wstring& title, + const Point& origin, + const Size& size) { Destroy(); const wchar_t* window_class = @@ -117,7 +135,7 @@ bool Win32Window::CreateAndShow(const std::wstring& title, double scale_factor = dpi / 96.0; HWND window = CreateWindow( - window_class, title.c_str(), WS_OVERLAPPEDWINDOW | WS_VISIBLE, + window_class, title.c_str(), WS_OVERLAPPEDWINDOW, Scale(origin.x, scale_factor), Scale(origin.y, scale_factor), Scale(size.width, scale_factor), Scale(size.height, scale_factor), nullptr, nullptr, GetModuleHandle(nullptr), this); @@ -126,9 +144,15 @@ bool Win32Window::CreateAndShow(const std::wstring& title, return false; } + UpdateTheme(window); + return OnCreate(); } +bool Win32Window::Show() { + return ShowWindow(window_handle_, SW_SHOWNORMAL); +} + // static LRESULT CALLBACK Win32Window::WndProc(HWND const window, UINT const message, @@ -188,6 +212,10 @@ Win32Window::MessageHandler(HWND hwnd, SetFocus(child_content_); } return 0; + + case WM_DWMCOLORIZATIONCOLORCHANGED: + UpdateTheme(hwnd); + return 0; } return DefWindowProc(window_handle_, message, wparam, lparam); @@ -243,3 +271,18 @@ bool Win32Window::OnCreate() { void Win32Window::OnDestroy() { // No-op; provided for subclasses. } + +void Win32Window::UpdateTheme(HWND const window) { + DWORD light_mode; + DWORD light_mode_size = sizeof(light_mode); + LSTATUS result = RegGetValue(HKEY_CURRENT_USER, kGetPreferredBrightnessRegKey, + kGetPreferredBrightnessRegValue, + RRF_RT_REG_DWORD, nullptr, &light_mode, + &light_mode_size); + + if (result == ERROR_SUCCESS) { + BOOL enable_dark_mode = light_mode == 0; + DwmSetWindowAttribute(window, DWMWA_USE_IMMERSIVE_DARK_MODE, + &enable_dark_mode, sizeof(enable_dark_mode)); + } +} diff --git a/flutter/example/windows/runner/win32_window.h b/flutter/example/windows/runner/win32_window.h index d9bcac1b60..49b847f075 100644 --- a/flutter/example/windows/runner/win32_window.h +++ b/flutter/example/windows/runner/win32_window.h @@ -28,15 +28,16 @@ class Win32Window { Win32Window(); virtual ~Win32Window(); - // Creates and shows a win32 window with |title| and position and size using + // Creates a win32 window with |title| that is positioned and sized using // |origin| and |size|. New windows are created on the default monitor. Window // sizes are specified to the OS in physical pixels, hence to ensure a - // consistent size to will treat the width height passed in to this function - // as logical pixels and scale to appropriate for the default monitor. Returns - // true if the window was created successfully. - bool CreateAndShow(const std::wstring& title, - const Point& origin, - const Size& size); + // consistent size this function will scale the inputted width and height as + // as appropriate for the default monitor. The window is invisible until + // |Show| is called. Returns true if the window was created successfully. + bool Create(const std::wstring& title, const Point& origin, const Size& size); + + // Show the current window. Returns true if the window was successfully shown. + bool Show(); // Release OS resources associated with window. void Destroy(); @@ -76,7 +77,7 @@ class Win32Window { // OS callback called by message pump. Handles the WM_NCCREATE message which // is passed when the non-client area is being created and enables automatic // non-client DPI scaling so that the non-client area automatically - // responsponds to changes in DPI. All other messages are handled by + // responds to changes in DPI. All other messages are handled by // MessageHandler. static LRESULT CALLBACK WndProc(HWND const window, UINT const message, @@ -86,6 +87,9 @@ class Win32Window { // Retrieves a class instance pointer for |window| static Win32Window* GetThisFromHandle(HWND const window) noexcept; + // Update the window frame's theme to match the system theme. + static void UpdateTheme(HWND const window); + bool quit_on_close_ = false; // window handle for top level window. diff --git a/flutter/ios/Classes/SentryFlutter.swift b/flutter/ios/Classes/SentryFlutter.swift index b26bcfc30d..987528987c 100644 --- a/flutter/ios/Classes/SentryFlutter.swift +++ b/flutter/ios/Classes/SentryFlutter.swift @@ -70,6 +70,49 @@ public final class SentryFlutter { if let appHangTimeoutIntervalMillis = data["appHangTimeoutIntervalMillis"] as? NSNumber { options.appHangTimeoutInterval = appHangTimeoutIntervalMillis.doubleValue / 1000 } + if let proxy = data["proxy"] as? [String: Any] { + guard let host = proxy["host"] as? String, + let port = proxy["port"] as? Int, + let type = proxy["type"] as? String else { + print("Could not read proxy data") + return + } + + var connectionProxyDictionary: [String: Any] = [:] + if type.lowercased() == "http" { + connectionProxyDictionary[kCFNetworkProxiesHTTPEnable as String] = true + connectionProxyDictionary[kCFNetworkProxiesHTTPProxy as String] = host + connectionProxyDictionary[kCFNetworkProxiesHTTPPort as String] = port + } else if type.lowercased() == "socks" { + #if os(macOS) + connectionProxyDictionary[kCFNetworkProxiesSOCKSEnable as String] = true + connectionProxyDictionary[kCFNetworkProxiesSOCKSProxy as String] = host + connectionProxyDictionary[kCFNetworkProxiesSOCKSPort as String] = port + #else + return + #endif + } else { + return + } + + if let user = proxy["user"] as? String, let pass = proxy["pass"] { + connectionProxyDictionary[kCFProxyUsernameKey as String] = user + connectionProxyDictionary[kCFProxyPasswordKey as String] = pass + } + + let configuration = URLSessionConfiguration.default + configuration.connectionProxyDictionary = connectionProxyDictionary + + options.urlSession = URLSession(configuration: configuration) + } +#if canImport(UIKit) && !SENTRY_NO_UIKIT && (os(iOS) || os(tvOS)) + if let replayOptions = data["replay"] as? [String: Any] { + options.experimental.sessionReplay.sessionSampleRate = + (replayOptions["sessionSampleRate"] as? NSNumber)?.floatValue ?? 0 + options.experimental.sessionReplay.onErrorSampleRate = + (replayOptions["onErrorSampleRate"] as? NSNumber)?.floatValue ?? 0 + } +#endif } private func logLevelFrom(diagnosticLevel: String) -> SentryLevel { diff --git a/flutter/ios/Classes/SentryFlutterPluginApple.swift b/flutter/ios/Classes/SentryFlutterPluginApple.swift index fc8fb42f9c..24e50cc166 100644 --- a/flutter/ios/Classes/SentryFlutterPluginApple.swift +++ b/flutter/ios/Classes/SentryFlutterPluginApple.swift @@ -5,12 +5,14 @@ import UIKit #elseif os(macOS) import FlutterMacOS import AppKit +import CoreVideo #endif // swiftlint:disable file_length function_body_length // swiftlint:disable:next type_body_length public class SentryFlutterPluginApple: NSObject, FlutterPlugin { + private let channel: FlutterMethodChannel private static let nativeClientName = "sentry.cocoa.flutter" @@ -37,12 +39,16 @@ public class SentryFlutterPluginApple: NSObject, FlutterPlugin { let channel = FlutterMethodChannel(name: "sentry_flutter", binaryMessenger: registrar.messenger) #endif - let instance = SentryFlutterPluginApple() + let instance = SentryFlutterPluginApple(channel: channel) instance.registerObserver() - registrar.addMethodCallDelegate(instance, channel: channel) } + private init(channel: FlutterMethodChannel) { + self.channel = channel + super.init() + } + private lazy var sentryFlutter = SentryFlutter() private func registerObserver() { @@ -164,6 +170,26 @@ public class SentryFlutterPluginApple: NSObject, FlutterPlugin { collectProfile(call, result) #endif + case "displayRefreshRate": + displayRefreshRate(result) + + case "pauseAppHangTracking": + pauseAppHangTracking(result) + + case "resumeAppHangTracking": + resumeAppHangTracking(result) + + case "nativeCrash": + crash() + + case "captureReplay": +#if canImport(UIKit) && !SENTRY_NO_UIKIT && (os(iOS) || os(tvOS)) + PrivateSentrySDKOnly.captureReplay() + result(PrivateSentrySDKOnly.getReplayId()) +#else + result(nil) +#endif + default: result(FlutterMethodNotImplemented) } @@ -313,6 +339,14 @@ public class SentryFlutterPluginApple: NSObject, FlutterPlugin { didReceiveDidBecomeActiveNotification = false } +#if canImport(UIKit) && !SENTRY_NO_UIKIT +#if os(iOS) || os(tvOS) + let breadcrumbConverter = SentryFlutterReplayBreadcrumbConverter() + let screenshotProvider = SentryFlutterReplayScreenshotProvider(channel: self.channel) + PrivateSentrySDKOnly.configureSessionReplay(with: breadcrumbConverter, screenshotProvider: screenshotProvider) +#endif +#endif + result("") } @@ -651,6 +685,78 @@ public class SentryFlutterPluginApple: NSObject, FlutterPlugin { PrivateSentrySDKOnly.discardProfiler(forTrace: SentryId(uuidString: traceId)) result(nil) } + + #if os(iOS) + // Taken from the Flutter engine: + // https://github.com/flutter/engine/blob/main/shell/platform/darwin/ios/framework/Source/vsync_waiter_ios.mm#L150 + private func displayRefreshRate(_ result: @escaping FlutterResult) { + let displayLink = CADisplayLink(target: self, selector: #selector(onDisplayLink(_:))) + displayLink.add(to: .main, forMode: .common) + displayLink.isPaused = true + + let preferredFPS = displayLink.preferredFramesPerSecond + displayLink.invalidate() + + if preferredFPS != 0 { + result(preferredFPS) + return + } + + if #available(iOS 13.0, *) { + guard let windowScene = UIApplication.shared.windows.first?.windowScene else { + result(nil) + return + } + result(windowScene.screen.maximumFramesPerSecond) + } else { + result(UIScreen.main.maximumFramesPerSecond) + } + } + + @objc private func onDisplayLink(_ displayLink: CADisplayLink) { + // No-op + } + #elseif os(macOS) + private func displayRefreshRate(_ result: @escaping FlutterResult) { + // We don't use CADisplayLink for macOS because it's only available starting with macOS 14 + guard let window = NSApplication.shared.keyWindow else { + result(nil) + return + } + + guard let screen = window.screen else { + result(nil) + return + } + + guard let displayID = + screen.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? CGDirectDisplayID else { + result(nil) + return + } + + guard let mode = CGDisplayCopyDisplayMode(displayID) else { + result(nil) + return + } + + result(Int(mode.refreshRate)) + } + #endif + + private func pauseAppHangTracking(_ result: @escaping FlutterResult) { + SentrySDK.pauseAppHangTracking() + result("") + } + + private func resumeAppHangTracking(_ result: @escaping FlutterResult) { + SentrySDK.resumeAppHangTracking() + result("") + } + + private func crash() { + SentrySDK.crash() + } } // swiftlint:enable function_body_length diff --git a/flutter/ios/Classes/SentryFlutterReplayBreadcrumbConverter.h b/flutter/ios/Classes/SentryFlutterReplayBreadcrumbConverter.h new file mode 100644 index 0000000000..1260268ced --- /dev/null +++ b/flutter/ios/Classes/SentryFlutterReplayBreadcrumbConverter.h @@ -0,0 +1,15 @@ +@import Sentry; + +#if SENTRY_TARGET_REPLAY_SUPPORTED +@class SentryRRWebEvent; + +@interface SentryFlutterReplayBreadcrumbConverter + : NSObject + +- (instancetype _Nonnull)init; + +- (id _Nullable)convertFrom: + (SentryBreadcrumb *_Nonnull)breadcrumb; + +@end +#endif diff --git a/flutter/ios/Classes/SentryFlutterReplayBreadcrumbConverter.m b/flutter/ios/Classes/SentryFlutterReplayBreadcrumbConverter.m new file mode 100644 index 0000000000..bde889b6bf --- /dev/null +++ b/flutter/ios/Classes/SentryFlutterReplayBreadcrumbConverter.m @@ -0,0 +1,153 @@ +#import "SentryFlutterReplayBreadcrumbConverter.h" + +@import Sentry; + +#if SENTRY_TARGET_REPLAY_SUPPORTED + +@implementation SentryFlutterReplayBreadcrumbConverter { + SentrySRDefaultBreadcrumbConverter *defaultConverter; +} + +- (instancetype _Nonnull)init { + if (self = [super init]) { + self->defaultConverter = + [SentrySessionReplayIntegration createDefaultBreadcrumbConverter]; + } + return self; +} + +- (id _Nullable)convertFrom: + (SentryBreadcrumb *_Nonnull)breadcrumb { + assert(breadcrumb.timestamp != nil); + + if (breadcrumb.category == nil + // Do not add Sentry Event breadcrumbs to replay + || [breadcrumb.category isEqualToString:@"sentry.event"] || + [breadcrumb.category isEqualToString:@"sentry.transaction"]) { + return nil; + } + + if ([breadcrumb.category isEqualToString:@"http"]) { + return [self convertNetwork:breadcrumb]; + } + + if ([breadcrumb.category isEqualToString:@"navigation"]) { + return [self convertFrom:breadcrumb withCategory:nil andMessage:nil]; + } + + if ([breadcrumb.category isEqualToString:@"ui.click"]) { + return [self convertFrom:breadcrumb + withCategory:@"ui.tap" + andMessage:[self getTouchPathMessage:breadcrumb.data[@"path"]]]; + } + + SentryRRWebEvent *nativeBreadcrumb = + [self->defaultConverter convertFrom:breadcrumb]; + + // ignore native navigation breadcrumbs + if (nativeBreadcrumb && nativeBreadcrumb.data && + nativeBreadcrumb.data[@"payload"] && + nativeBreadcrumb.data[@"payload"][@"category"] && + [nativeBreadcrumb.data[@"payload"][@"category"] + isEqualToString:@"navigation"]) { + return nil; + } + + return nativeBreadcrumb; +} + +- (id _Nullable)convertFrom: + (SentryBreadcrumb *_Nonnull)breadcrumb + withCategory:(NSString *)category + andMessage:(NSString *)message { + return [SentrySessionReplayIntegration + createBreadcrumbwithTimestamp:breadcrumb.timestamp + category:category ?: breadcrumb.category + message:message ?: breadcrumb.message + level:breadcrumb.level + data:breadcrumb.data]; +} + +- (id _Nullable)convertNetwork: + (SentryBreadcrumb *_Nonnull)breadcrumb { + NSNumber *startTimestamp = + [breadcrumb.data[@"start_timestamp"] isKindOfClass:[NSNumber class]] + ? breadcrumb.data[@"start_timestamp"] + : nil; + NSNumber *endTimestamp = + [breadcrumb.data[@"end_timestamp"] isKindOfClass:[NSNumber class]] + ? breadcrumb.data[@"end_timestamp"] + : nil; + NSString *url = [breadcrumb.data[@"url"] isKindOfClass:[NSString class]] + ? breadcrumb.data[@"url"] + : nil; + + if (startTimestamp == nil || endTimestamp == nil || url == nil) { + return nil; + } + + NSMutableDictionary *data = [[NSMutableDictionary alloc] init]; + if ([breadcrumb.data[@"method"] isKindOfClass:[NSString class]]) { + data[@"method"] = breadcrumb.data[@"method"]; + } + if ([breadcrumb.data[@"status_code"] isKindOfClass:[NSNumber class]]) { + data[@"statusCode"] = breadcrumb.data[@"status_code"]; + } + if ([breadcrumb.data[@"request_body_size"] isKindOfClass:[NSNumber class]]) { + data[@"requestBodySize"] = breadcrumb.data[@"request_body_size"]; + } + if ([breadcrumb.data[@"response_body_size"] isKindOfClass:[NSNumber class]]) { + data[@"responseBodySize"] = breadcrumb.data[@"response_body_size"]; + } + + return [SentrySessionReplayIntegration + createNetworkBreadcrumbWithTimestamp:[self dateFrom:startTimestamp] + endTimestamp:[self dateFrom:endTimestamp] + operation:@"resource.http" + description:url + data:data]; +} + +- (NSDate *_Nonnull)dateFrom:(NSNumber *_Nonnull)timestamp { + return [NSDate dateWithTimeIntervalSince1970:(timestamp.doubleValue / 1000)]; +} + +- (NSString * _Nullable)getTouchPathMessage:(id _Nullable)maybePath { + if (![maybePath isKindOfClass:[NSArray class]]) { + return nil; + } + + NSArray *path = (NSArray *)maybePath; + if (path.count == 0) { + return nil; + } + + NSMutableString *message = [NSMutableString string]; + for (NSInteger i = MIN(3, path.count - 1); i >= 0; i--) { + id item = path[i]; + if (![item isKindOfClass:[NSDictionary class]]) { + continue; + } + + NSDictionary *itemDict = (NSDictionary *)item; + [message appendString:itemDict[@"element"] ?: @"?"]; + + id identifier = itemDict[@"label"] ?: itemDict[@"name"]; + if ([identifier isKindOfClass:[NSString class]] && [(NSString *)identifier length] > 0) { + NSString *identifierStr = (NSString *)identifier; + if (identifierStr.length > 20) { + identifierStr = [[identifierStr substringToIndex:17] stringByAppendingString:@"..."]; + } + [message appendFormat:@"(%@)", identifierStr]; + } + + if (i > 0) { + [message appendString:@" > "]; + } + } + + return message.length > 0 ? message : nil; +} +@end + +#endif diff --git a/flutter/ios/Classes/SentryFlutterReplayScreenshotProvider.h b/flutter/ios/Classes/SentryFlutterReplayScreenshotProvider.h new file mode 100644 index 0000000000..d59e5f4612 --- /dev/null +++ b/flutter/ios/Classes/SentryFlutterReplayScreenshotProvider.h @@ -0,0 +1,12 @@ +@import Sentry; + +#if SENTRY_TARGET_REPLAY_SUPPORTED +@class SentryRRWebEvent; + +@interface SentryFlutterReplayScreenshotProvider + : NSObject + +- (instancetype)initWithChannel:(id)FlutterMethodChannel; + +@end +#endif diff --git a/flutter/ios/Classes/SentryFlutterReplayScreenshotProvider.m b/flutter/ios/Classes/SentryFlutterReplayScreenshotProvider.m new file mode 100644 index 0000000000..fc03fd5365 --- /dev/null +++ b/flutter/ios/Classes/SentryFlutterReplayScreenshotProvider.m @@ -0,0 +1,46 @@ +@import Sentry; + +#if SENTRY_TARGET_REPLAY_SUPPORTED +#import "SentryFlutterReplayScreenshotProvider.h" +#import + +@implementation SentryFlutterReplayScreenshotProvider { + FlutterMethodChannel *channel; +} + +- (instancetype _Nonnull)initWithChannel: + (FlutterMethodChannel *_Nonnull)channel { + if (self = [super init]) { + self->channel = channel; + } + return self; +} + +- (void)imageWithView:(UIView *_Nonnull)view + options:(id _Nonnull)options + onComplete:(void (^_Nonnull)(UIImage *_Nonnull))onComplete { + [self->channel + invokeMethod:@"captureReplayScreenshot" + arguments:@{@"replayId" : [PrivateSentrySDKOnly getReplayId]} + result:^(id value) { + if (value == nil) { + NSLog(@"SentryFlutterReplayScreenshotProvider received null " + @"result. " + @"Cannot capture a replay screenshot."); + } else if ([value + isKindOfClass:[FlutterStandardTypedData class]]) { + FlutterStandardTypedData *typedData = + (FlutterStandardTypedData *)value; + UIImage *image = [UIImage imageWithData:typedData.data]; + onComplete(image); + } else { + NSLog(@"SentryFlutterReplayScreenshotProvider received an " + @"unexpected result. " + @"Cannot capture a replay screenshot."); + } + }]; +} + +@end + +#endif diff --git a/flutter/ios/sentry_flutter.podspec b/flutter/ios/sentry_flutter.podspec index bd76cc2f2f..86a06e8179 100644 --- a/flutter/ios/sentry_flutter.podspec +++ b/flutter/ios/sentry_flutter.podspec @@ -16,7 +16,7 @@ Sentry SDK for Flutter with support to native through sentry-cocoa. :tag => s.version.to_s } s.source_files = 'Classes/**/*' s.public_header_files = 'Classes/**/*.h' - s.dependency 'Sentry/HybridSDK', '8.29.0' + s.dependency 'Sentry/HybridSDK', '8.36.0' s.ios.dependency 'Flutter' s.osx.dependency 'FlutterMacOS' s.ios.deployment_target = '12.0' diff --git a/flutter/lib/sentry_flutter.dart b/flutter/lib/sentry_flutter.dart index d15c8b7a70..c74013e81e 100644 --- a/flutter/lib/sentry_flutter.dart +++ b/flutter/lib/sentry_flutter.dart @@ -8,8 +8,9 @@ export 'src/integrations/load_release_integration.dart'; export 'src/navigation/sentry_navigator_observer.dart'; export 'src/sentry_flutter.dart'; export 'src/sentry_flutter_options.dart'; +export 'src/sentry_replay_options.dart'; export 'src/flutter_sentry_attachment.dart'; -export 'src/sentry_asset_bundle.dart'; +export 'src/sentry_asset_bundle.dart' show SentryAssetBundle; export 'src/integrations/on_error_integration.dart'; export 'src/screenshot/sentry_screenshot_widget.dart'; export 'src/screenshot/sentry_screenshot_quality.dart'; diff --git a/flutter/lib/src/binding_wrapper.dart b/flutter/lib/src/binding_wrapper.dart index 4d6856861a..862f55f8de 100644 --- a/flutter/lib/src/binding_wrapper.dart +++ b/flutter/lib/src/binding_wrapper.dart @@ -26,6 +26,9 @@ class BindingWrapper { stackTrace: s, logger: 'BindingWrapper', ); + if (_hub.options.automatedTestMode) { + rethrow; + } return null; } } diff --git a/flutter/lib/src/event_processor/android_platform_exception_event_processor.dart b/flutter/lib/src/event_processor/android_platform_exception_event_processor.dart index d939f4b90e..edb5773bbc 100644 --- a/flutter/lib/src/event_processor/android_platform_exception_event_processor.dart +++ b/flutter/lib/src/event_processor/android_platform_exception_event_processor.dart @@ -58,6 +58,9 @@ class AndroidPlatformExceptionEventProcessor implements EventProcessor { exception: e, stackTrace: stackTrace, ); + if (_options.automatedTestMode) { + rethrow; + } return event; } } diff --git a/flutter/lib/src/event_processor/flutter_enricher_event_processor.dart b/flutter/lib/src/event_processor/flutter_enricher_event_processor.dart index 0ea1b731d6..3cf5ab9970 100644 --- a/flutter/lib/src/event_processor/flutter_enricher_event_processor.dart +++ b/flutter/lib/src/event_processor/flutter_enricher_event_processor.dart @@ -7,6 +7,7 @@ import 'package:sentry/sentry.dart'; import '../navigation/sentry_navigator_observer.dart'; import '../sentry_flutter_options.dart'; +import '../utils/enum_wrapper.dart'; typedef WidgetBindingGetter = WidgetsBinding? Function(); @@ -139,15 +140,16 @@ class FlutterEnricherEventProcessor implements EventProcessor { return { 'has_render_view': hasRenderView.toString(), if (tempDebugBrightnessOverride != null) - 'debug_brightness_override': tempDebugBrightnessOverride.name, + 'debug_brightness_override': pDescribeEnum(tempDebugBrightnessOverride), if (debugPlatformOverride != null) - 'debug_default_target_platform_override': debugPlatformOverride.name, + 'debug_default_target_platform_override': + pDescribeEnum(debugPlatformOverride), if (initialLifecycleState != null && initialLifecycleState.isNotEmpty) 'initial_lifecycle_state': initialLifecycleState, if (defaultRouteName != null && defaultRouteName.isNotEmpty) 'default_route_name': defaultRouteName, if (currentLifecycle != null) - 'current_lifecycle_state': currentLifecycle.name, + 'current_lifecycle_state': pDescribeEnum(currentLifecycle), // Seems to always return false. // Also always fails in tests. // See https://github.com/flutter/flutter/issues/83919 @@ -195,7 +197,7 @@ class FlutterEnricherEventProcessor implements EventProcessor { SentryOperatingSystem _getOperatingSystem(SentryOperatingSystem? os) { return (os ?? SentryOperatingSystem()).copyWith( // ignore: deprecated_member_use - theme: os?.theme ?? describeEnum(window.platformBrightness), + theme: os?.theme ?? pDescribeEnum(window.platformBrightness), ); } diff --git a/flutter/lib/src/event_processor/native_app_start_event_processor.dart b/flutter/lib/src/event_processor/native_app_start_event_processor.dart deleted file mode 100644 index f8ea23b265..0000000000 --- a/flutter/lib/src/event_processor/native_app_start_event_processor.dart +++ /dev/null @@ -1,155 +0,0 @@ -// ignore_for_file: invalid_use_of_internal_member - -import 'dart:async'; - -import '../../sentry_flutter.dart'; -import '../integrations/integrations.dart'; -import '../native/sentry_native.dart'; - -// ignore: implementation_imports -import 'package:sentry/src/sentry_tracer.dart'; - -/// EventProcessor that enriches [SentryTransaction] objects with app start -/// measurement. -class NativeAppStartEventProcessor implements EventProcessor { - final SentryNative _native; - final Hub _hub; - - NativeAppStartEventProcessor( - this._native, { - Hub? hub, - }) : _hub = hub ?? HubAdapter(); - - @override - Future apply(SentryEvent event, Hint hint) async { - final options = _hub.options; - if (_native.didAddAppStartMeasurement || - event is! SentryTransaction || - options is! SentryFlutterOptions) { - return event; - } - - final appStartInfo = await NativeAppStartIntegration.getAppStartInfo(); - - final appStartEnd = _native.appStartEnd; - if (!options.autoAppStart) { - if (appStartEnd != null) { - appStartInfo?.end = appStartEnd; - } else { - // If autoAppStart is disabled and appStartEnd is not set, we can't add app starts - return event; - } - } - - final measurement = appStartInfo?.toMeasurement(); - if (measurement != null) { - event.measurements[measurement.name] = measurement; - _native.didAddAppStartMeasurement = true; - } - - if (appStartInfo != null) { - await _attachAppStartSpans(appStartInfo, event.tracer); - } - - return event; - } - - Future _attachAppStartSpans( - AppStartInfo appStartInfo, SentryTracer transaction) async { - final transactionTraceId = transaction.context.traceId; - final appStartEnd = appStartInfo.end; - if (appStartEnd == null) { - return; - } - - final appStartSpan = await _createAndFinishSpan( - tracer: transaction, - operation: appStartInfo.appStartTypeOperation, - description: appStartInfo.appStartTypeDescription, - parentSpanId: transaction.context.spanId, - traceId: transactionTraceId, - startTimestamp: appStartInfo.start, - endTimestamp: appStartEnd); - - await _attachNativeSpans(appStartInfo, transaction, appStartSpan); - - final pluginRegistrationSpan = await _createAndFinishSpan( - tracer: transaction, - operation: appStartInfo.appStartTypeOperation, - description: appStartInfo.pluginRegistrationDescription, - parentSpanId: appStartSpan.context.spanId, - traceId: transactionTraceId, - startTimestamp: appStartInfo.start, - endTimestamp: appStartInfo.pluginRegistration); - - final sentrySetupSpan = await _createAndFinishSpan( - tracer: transaction, - operation: appStartInfo.appStartTypeOperation, - description: appStartInfo.sentrySetupDescription, - parentSpanId: appStartSpan.context.spanId, - traceId: transactionTraceId, - startTimestamp: appStartInfo.pluginRegistration, - endTimestamp: appStartInfo.sentrySetupStart); - - final firstFrameRenderSpan = await _createAndFinishSpan( - tracer: transaction, - operation: appStartInfo.appStartTypeOperation, - description: appStartInfo.firstFrameRenderDescription, - parentSpanId: appStartSpan.context.spanId, - traceId: transactionTraceId, - startTimestamp: appStartInfo.sentrySetupStart, - endTimestamp: appStartEnd); - - transaction.children.addAll([ - appStartSpan, - pluginRegistrationSpan, - sentrySetupSpan, - firstFrameRenderSpan - ]); - } - - Future _attachNativeSpans(AppStartInfo appStartInfo, - SentryTracer transaction, SentrySpan parent) async { - await Future.forEach(appStartInfo.nativeSpanTimes, - (timeSpan) async { - try { - final span = await _createAndFinishSpan( - tracer: transaction, - operation: appStartInfo.appStartTypeOperation, - description: timeSpan.description, - parentSpanId: parent.context.spanId, - traceId: transaction.context.traceId, - startTimestamp: timeSpan.start, - endTimestamp: timeSpan.end); - span.data.putIfAbsent('native', () => true); - transaction.children.add(span); - } catch (e) { - _hub.options.logger(SentryLevel.warning, - 'Failed to attach native span to app start transaction: $e'); - } - }); - } - - Future _createAndFinishSpan({ - required SentryTracer tracer, - required String operation, - required String description, - required SpanId parentSpanId, - required SentryId traceId, - required DateTime startTimestamp, - required DateTime endTimestamp, - }) async { - final span = SentrySpan( - tracer, - SentrySpanContext( - operation: operation, - description: description, - parentSpanId: parentSpanId, - traceId: traceId, - ), - _hub, - startTimestamp: startTimestamp); - await span.finish(endTimestamp: endTimestamp); - return span; - } -} diff --git a/flutter/lib/src/event_processor/replay_event_processor.dart b/flutter/lib/src/event_processor/replay_event_processor.dart new file mode 100644 index 0000000000..1d534f94b0 --- /dev/null +++ b/flutter/lib/src/event_processor/replay_event_processor.dart @@ -0,0 +1,22 @@ +import 'dart:async'; + +import 'package:sentry/sentry.dart'; + +import '../native/sentry_native_binding.dart'; + +class ReplayEventProcessor implements EventProcessor { + final SentryNativeBinding _binding; + + ReplayEventProcessor(this._binding); + + @override + Future apply(SentryEvent event, Hint hint) async { + if (event.eventId != SentryId.empty() && + event.exceptions?.isNotEmpty == true) { + final isCrash = + event.exceptions!.any((e) => e.mechanism?.handled == false); + await _binding.captureReplay(isCrash); + } + return event; + } +} diff --git a/flutter/lib/src/event_processor/screenshot_event_processor.dart b/flutter/lib/src/event_processor/screenshot_event_processor.dart index 8981afe7b1..2b9c80dc05 100644 --- a/flutter/lib/src/event_processor/screenshot_event_processor.dart +++ b/flutter/lib/src/event_processor/screenshot_event_processor.dart @@ -50,7 +50,6 @@ class ScreenshotEventProcessor implements EventProcessor { exception: exception, stackTrace: stackTrace, ); - // ignore: invalid_use_of_internal_member if (_options.automatedTestMode) { rethrow; } @@ -138,6 +137,9 @@ class ScreenshotEventProcessor implements EventProcessor { exception: exception, stackTrace: stackTrace, ); + if (_options.automatedTestMode) { + rethrow; + } } return null; } diff --git a/flutter/lib/src/event_processor/url_filter/html_url_filter_event_processor.dart b/flutter/lib/src/event_processor/url_filter/html_url_filter_event_processor.dart new file mode 100644 index 0000000000..39b73cb0cb --- /dev/null +++ b/flutter/lib/src/event_processor/url_filter/html_url_filter_event_processor.dart @@ -0,0 +1,37 @@ +import 'dart:html' as html show window, Window; + +import '../../../sentry_flutter.dart'; +import 'url_filter_event_processor.dart'; +// ignore: implementation_imports +import 'package:sentry/src/utils/regex_utils.dart'; + +// ignore_for_file: invalid_use_of_internal_member + +UrlFilterEventProcessor urlFilterEventProcessor(SentryFlutterOptions options) => + WebUrlFilterEventProcessor(options); + +class WebUrlFilterEventProcessor implements UrlFilterEventProcessor { + WebUrlFilterEventProcessor( + this._options, + ); + + final html.Window _window = html.window; + final SentryFlutterOptions _options; + + @override + SentryEvent? apply(SentryEvent event, Hint hint) { + final url = _window.location.toString(); + + if (_options.allowUrls.isNotEmpty && + !isMatchingRegexPattern(url, _options.allowUrls)) { + return null; + } + + if (_options.denyUrls.isNotEmpty && + isMatchingRegexPattern(url, _options.denyUrls)) { + return null; + } + + return event; + } +} diff --git a/flutter/lib/src/event_processor/url_filter/io_url_filter_event_processor.dart b/flutter/lib/src/event_processor/url_filter/io_url_filter_event_processor.dart new file mode 100644 index 0000000000..b49573bbc5 --- /dev/null +++ b/flutter/lib/src/event_processor/url_filter/io_url_filter_event_processor.dart @@ -0,0 +1,10 @@ +import '../../../sentry_flutter.dart'; +import 'url_filter_event_processor.dart'; + +UrlFilterEventProcessor urlFilterEventProcessor(SentryFlutterOptions _) => + IoUrlFilterEventProcessor(); + +class IoUrlFilterEventProcessor implements UrlFilterEventProcessor { + @override + SentryEvent apply(SentryEvent event, Hint hint) => event; +} diff --git a/flutter/lib/src/event_processor/url_filter/url_filter_event_processor.dart b/flutter/lib/src/event_processor/url_filter/url_filter_event_processor.dart new file mode 100644 index 0000000000..5a1e5ed537 --- /dev/null +++ b/flutter/lib/src/event_processor/url_filter/url_filter_event_processor.dart @@ -0,0 +1,9 @@ +import '../../../sentry_flutter.dart'; +import 'io_url_filter_event_processor.dart' + if (dart.library.html) 'html_url_filter_event_processor.dart' + if (dart.library.js_interop) 'web_url_filter_event_processor.dart'; + +abstract class UrlFilterEventProcessor implements EventProcessor { + factory UrlFilterEventProcessor(SentryFlutterOptions options) => + urlFilterEventProcessor(options); +} diff --git a/flutter/lib/src/event_processor/url_filter/web_url_filter_event_processor.dart b/flutter/lib/src/event_processor/url_filter/web_url_filter_event_processor.dart new file mode 100644 index 0000000000..3b94afeed8 --- /dev/null +++ b/flutter/lib/src/event_processor/url_filter/web_url_filter_event_processor.dart @@ -0,0 +1,39 @@ +// We would lose compatibility with old dart versions by adding web to pubspec. +// ignore: depend_on_referenced_packages +import 'package:web/web.dart' as web show window, Window; + +import '../../../sentry_flutter.dart'; +import 'url_filter_event_processor.dart'; +// ignore: implementation_imports +import 'package:sentry/src/utils/regex_utils.dart'; + +// ignore_for_file: invalid_use_of_internal_member + +UrlFilterEventProcessor urlFilterEventProcessor(SentryFlutterOptions options) => + WebUrlFilterEventProcessor(options); + +class WebUrlFilterEventProcessor implements UrlFilterEventProcessor { + WebUrlFilterEventProcessor( + this._options, + ); + + final web.Window _window = web.window; + final SentryFlutterOptions _options; + + @override + SentryEvent? apply(SentryEvent event, Hint hint) { + final url = _window.location.toString(); + + if (_options.allowUrls.isNotEmpty && + !isMatchingRegexPattern(url, _options.allowUrls)) { + return null; + } + + if (_options.denyUrls.isNotEmpty && + isMatchingRegexPattern(url, _options.denyUrls)) { + return null; + } + + return event; + } +} diff --git a/flutter/lib/src/file_system_transport.dart b/flutter/lib/src/file_system_transport.dart index cf073a38ea..e28f81ae78 100644 --- a/flutter/lib/src/file_system_transport.dart +++ b/flutter/lib/src/file_system_transport.dart @@ -3,22 +3,24 @@ import 'dart:typed_data'; import 'package:flutter/services.dart'; -import 'package:sentry/sentry.dart'; + +import '../sentry_flutter.dart'; +import 'native/sentry_native_binding.dart'; class FileSystemTransport implements Transport { - FileSystemTransport(this._channel, this._options); + FileSystemTransport(this._native, this._options); - final MethodChannel _channel; - final SentryOptions _options; + final SentryNativeBinding _native; + final SentryFlutterOptions _options; @override Future send(SentryEnvelope envelope) async { final envelopeData = []; await envelope.envelopeStream(_options).forEach(envelopeData.addAll); - // https://flutter.dev/docs/development/platform-integration/platform-channels#codec - final args = [Uint8List.fromList(envelopeData)]; try { - await _channel.invokeMethod('captureEnvelope', args); + // TODO avoid copy + await _native.captureEnvelope(Uint8List.fromList(envelopeData), + envelope.containsUnhandledException); } catch (exception, stackTrace) { _options.logger( SentryLevel.error, @@ -26,6 +28,9 @@ class FileSystemTransport implements Transport { exception: exception, stackTrace: stackTrace, ); + if (_options.automatedTestMode) { + rethrow; + } return SentryId.empty(); } diff --git a/flutter/lib/src/flutter_exception_type_identifier.dart b/flutter/lib/src/flutter_exception_type_identifier.dart new file mode 100644 index 0000000000..755f09e544 --- /dev/null +++ b/flutter/lib/src/flutter_exception_type_identifier.dart @@ -0,0 +1,22 @@ +import 'package:flutter/cupertino.dart' + show FlutterError, NetworkImageLoadException, TickerCanceled; +import 'package:flutter/services.dart' + show PlatformException, MissingPluginException; + +import '../sentry_flutter.dart'; + +class FlutterExceptionTypeIdentifier implements ExceptionTypeIdentifier { + @override + String? identifyType(dynamic throwable) { + // FlutterError check should run before AssertionError check because + // it's a subclass of AssertionError + if (throwable is FlutterError) return 'FlutterError'; + if (throwable is PlatformException) return 'PlatformException'; + if (throwable is MissingPluginException) return 'MissingPluginException'; + if (throwable is NetworkImageLoadException) { + return 'NetworkImageLoadException'; + } + if (throwable is TickerCanceled) return 'TickerCanceled'; + return null; + } +} diff --git a/flutter/lib/src/frame_callback_handler.dart b/flutter/lib/src/frame_callback_handler.dart index 71a8f928b1..139d3cbde9 100644 --- a/flutter/lib/src/frame_callback_handler.dart +++ b/flutter/lib/src/frame_callback_handler.dart @@ -1,7 +1,11 @@ +import 'package:flutter/cupertino.dart'; import 'package:flutter/scheduler.dart'; abstract class FrameCallbackHandler { void addPostFrameCallback(FrameCallback callback); + void addPersistentFrameCallback(FrameCallback callback); + Future get endOfFrame; + bool get hasScheduledFrame; } class DefaultFrameCallbackHandler implements FrameCallbackHandler { @@ -12,4 +16,21 @@ class DefaultFrameCallbackHandler implements FrameCallbackHandler { SchedulerBinding.instance.addPostFrameCallback(callback); } catch (_) {} } + + @override + void addPersistentFrameCallback(FrameCallback callback) { + try { + WidgetsBinding.instance.addPersistentFrameCallback(callback); + } catch (_) {} + } + + @override + Future get endOfFrame async { + try { + await WidgetsBinding.instance.endOfFrame; + } catch (_) {} + } + + @override + bool get hasScheduledFrame => WidgetsBinding.instance.hasScheduledFrame; } diff --git a/flutter/lib/src/integrations/connectivity/connectivity_provider.dart b/flutter/lib/src/integrations/connectivity/connectivity_provider.dart index 30095dda0d..ea27f80f72 100644 --- a/flutter/lib/src/integrations/connectivity/connectivity_provider.dart +++ b/flutter/lib/src/integrations/connectivity/connectivity_provider.dart @@ -1,5 +1,6 @@ import 'noop_connectivity_provider.dart' - if (dart.library.html) 'web_connectivity_provider.dart'; + if (dart.library.html) 'html_connectivity_provider.dart' + if (dart.library.js_interop) 'web_connectivity_provider.dart'; abstract class ConnectivityProvider { factory ConnectivityProvider() => connectivityProvider(); diff --git a/flutter/lib/src/integrations/connectivity/html_connectivity_provider.dart b/flutter/lib/src/integrations/connectivity/html_connectivity_provider.dart new file mode 100644 index 0000000000..34d0e0ab42 --- /dev/null +++ b/flutter/lib/src/integrations/connectivity/html_connectivity_provider.dart @@ -0,0 +1,32 @@ +import 'dart:async'; +import 'dart:html' as html; + +import 'connectivity_provider.dart'; + +ConnectivityProvider connectivityProvider() { + return WebConnectivityProvider(); +} + +class WebConnectivityProvider implements ConnectivityProvider { + StreamSubscription? _onOnlineSub; + StreamSubscription? _onOfflineSub; + + @override + void listen(void Function(String connectivity) onChange) { + _onOnlineSub = html.window.onOnline.listen((_) { + onChange('wifi'); + }); + _onOfflineSub = html.window.onOffline.listen((_) { + onChange('none'); + }); + } + + @override + void cancel() { + _onOnlineSub?.cancel(); + _onOnlineSub = null; + + _onOfflineSub?.cancel(); + _onOfflineSub = null; + } +} diff --git a/flutter/lib/src/integrations/connectivity/web_connectivity_provider.dart b/flutter/lib/src/integrations/connectivity/web_connectivity_provider.dart index 34d0e0ab42..d1c18af777 100644 --- a/flutter/lib/src/integrations/connectivity/web_connectivity_provider.dart +++ b/flutter/lib/src/integrations/connectivity/web_connectivity_provider.dart @@ -1,5 +1,8 @@ import 'dart:async'; -import 'dart:html' as html; + +// We would lose compatibility with old dart versions by adding web to pubspec. +// ignore: depend_on_referenced_packages +import 'package:web/web.dart' as web; import 'connectivity_provider.dart'; @@ -8,15 +11,19 @@ ConnectivityProvider connectivityProvider() { } class WebConnectivityProvider implements ConnectivityProvider { - StreamSubscription? _onOnlineSub; - StreamSubscription? _onOfflineSub; + StreamSubscription? _onOnlineSub; + StreamSubscription? _onOfflineSub; @override void listen(void Function(String connectivity) onChange) { - _onOnlineSub = html.window.onOnline.listen((_) { + _onOnlineSub = web.EventStreamProviders.onlineEvent + .forElement(web.document.body!) + .listen((_) { onChange('wifi'); }); - _onOfflineSub = html.window.onOffline.listen((_) { + _onOfflineSub = web.EventStreamProviders.offlineEvent + .forElement(web.document.body!) + .listen((_) { onChange('none'); }); } diff --git a/flutter/lib/src/integrations/flutter_error_integration.dart b/flutter/lib/src/integrations/flutter_error_integration.dart index c1a6d57a1d..7a3945906e 100644 --- a/flutter/lib/src/integrations/flutter_error_integration.dart +++ b/flutter/lib/src/integrations/flutter_error_integration.dart @@ -2,6 +2,9 @@ import 'package:flutter/foundation.dart'; import 'package:sentry/sentry.dart'; import '../sentry_flutter_options.dart'; +// ignore: implementation_imports +import 'package:sentry/src/utils/stacktrace_utils.dart'; + /// Integration that capture errors on the [FlutterError.onError] handler. /// /// Remarks: @@ -77,7 +80,8 @@ class FlutterErrorIntegration implements Integration { ); await hub.captureEvent(event, - stackTrace: errorDetails.stack, + // ignore: invalid_use_of_internal_member + stackTrace: errorDetails.stack ?? getCurrentStackTrace(), hint: Hint.withMap({TypeCheckHint.syntheticException: errorDetails})); // we don't call Zone.current.handleUncaughtError because we'd like diff --git a/flutter/lib/src/integrations/load_contexts_integration.dart b/flutter/lib/src/integrations/load_contexts_integration.dart index df8b52b97a..679c7b0e36 100644 --- a/flutter/lib/src/integrations/load_contexts_integration.dart +++ b/flutter/lib/src/integrations/load_contexts_integration.dart @@ -1,7 +1,7 @@ import 'dart:async'; -import 'package:flutter/services.dart'; import 'package:sentry/sentry.dart'; +import '../native/sentry_native_binding.dart'; import '../sentry_flutter_options.dart'; /// Load Device's Contexts from the iOS & Android SDKs. @@ -15,32 +15,30 @@ import '../sentry_flutter_options.dart'; /// /// This integration is only executed on iOS, macOS & Android Apps. class LoadContextsIntegration extends Integration { - final MethodChannel _channel; + final SentryNativeBinding _native; - LoadContextsIntegration(this._channel); + LoadContextsIntegration(this._native); @override void call(Hub hub, SentryFlutterOptions options) { options.addEventProcessor( - _LoadContextsIntegrationEventProcessor(_channel, options), + _LoadContextsIntegrationEventProcessor(_native, options), ); options.sdk.addIntegration('loadContextsIntegration'); } } class _LoadContextsIntegrationEventProcessor implements EventProcessor { - _LoadContextsIntegrationEventProcessor(this._channel, this._options); + _LoadContextsIntegrationEventProcessor(this._native, this._options); - final MethodChannel _channel; + final SentryNativeBinding _native; final SentryFlutterOptions _options; @override Future apply(SentryEvent event, Hint hint) async { + // TODO don't copy everything (i.e. avoid unnecessary Map.from()) try { - final loadContexts = await _channel.invokeMethod('loadContexts'); - - final infos = - Map.from(loadContexts is Map ? loadContexts : {}); + final infos = await _native.loadContexts() ?? {}; final contextsMap = infos['contexts'] as Map?; if (contextsMap != null && contextsMap.isNotEmpty) { final contexts = Contexts.fromJson( @@ -214,6 +212,9 @@ class _LoadContextsIntegrationEventProcessor implements EventProcessor { exception: exception, stackTrace: stackTrace, ); + if (_options.automatedTestMode) { + rethrow; + } } return event; } diff --git a/flutter/lib/src/integrations/load_image_list_integration.dart b/flutter/lib/src/integrations/load_image_list_integration.dart index ae08d93946..776c86640d 100644 --- a/flutter/lib/src/integrations/load_image_list_integration.dart +++ b/flutter/lib/src/integrations/load_image_list_integration.dart @@ -1,95 +1,44 @@ import 'dart:async'; -import 'package:flutter/services.dart'; import 'package:sentry/sentry.dart'; +import '../native/sentry_native_binding.dart'; import '../sentry_flutter_options.dart'; +// ignore: implementation_imports +import 'package:sentry/src/load_dart_debug_images_integration.dart' + show NeedsSymbolication; + /// Loads the native debug image list for stack trace symbolication. class LoadImageListIntegration extends Integration { - final MethodChannel _channel; + /// TODO: rename to LoadNativeDebugImagesIntegration in the next major version + final SentryNativeBinding _native; - LoadImageListIntegration(this._channel); + LoadImageListIntegration(this._native); @override void call(Hub hub, SentryFlutterOptions options) { options.addEventProcessor( - _LoadImageListIntegrationEventProcessor(_channel, options), + _LoadImageListIntegrationEventProcessor(_native), ); options.sdk.addIntegration('loadImageListIntegration'); } } -extension _NeedsSymbolication on SentryEvent { - bool needsSymbolication() { - if (this is SentryTransaction) { - return false; - } - final frames = _getStacktraceFrames(); - if (frames == null) { - return false; - } - return frames.any((frame) => 'native' == frame?.platform); - } - - List? _getStacktraceFrames() { - if (exceptions?.isNotEmpty == true) { - return exceptions?.first.stackTrace?.frames; - } - if (threads?.isNotEmpty == true) { - var stacktraces = threads?.map((e) => e.stacktrace); - return stacktraces - ?.where((element) => element != null) - .expand((element) => element!.frames) - .toList(); - } - return null; - } -} - class _LoadImageListIntegrationEventProcessor implements EventProcessor { - _LoadImageListIntegrationEventProcessor(this._channel, this._options); + _LoadImageListIntegrationEventProcessor(this._native); - final MethodChannel _channel; - final SentryFlutterOptions _options; + final SentryNativeBinding _native; @override Future apply(SentryEvent event, Hint hint) async { if (event.needsSymbolication()) { - try { - // we call on every event because the loaded image list is cached - // and it could be changed on the Native side. - final loadImageList = await _channel.invokeMethod('loadImageList'); - final imageList = List>.from( - loadImageList is List ? loadImageList : [], - ); - return copyWithDebugImages(event, imageList); - } catch (exception, stackTrace) { - _options.logger( - SentryLevel.error, - 'loadImageList failed', - exception: exception, - stackTrace: stackTrace, - ); + final images = await _native.loadDebugImages(); + if (images != null) { + return event.copyWith(debugMeta: DebugMeta(images: images)); } } return event; } - - static SentryEvent copyWithDebugImages( - SentryEvent event, List imageList) { - if (imageList.isEmpty) { - return event; - } - - final newDebugImages = []; - for (final obj in imageList) { - final jsonMap = Map.from(obj as Map); - final image = DebugImage.fromJson(jsonMap); - newDebugImages.add(image); - } - - return event.copyWith(debugMeta: DebugMeta(images: newDebugImages)); - } } diff --git a/flutter/lib/src/integrations/load_release_integration.dart b/flutter/lib/src/integrations/load_release_integration.dart index de44876e06..ca8a29d11e 100644 --- a/flutter/lib/src/integrations/load_release_integration.dart +++ b/flutter/lib/src/integrations/load_release_integration.dart @@ -46,6 +46,9 @@ class LoadReleaseIntegration extends Integration { exception: exception, stackTrace: stackTrace, ); + if (options.automatedTestMode) { + rethrow; + } } options.sdk.addIntegration('loadReleaseIntegration'); diff --git a/flutter/lib/src/integrations/native_app_start_handler.dart b/flutter/lib/src/integrations/native_app_start_handler.dart new file mode 100644 index 0000000000..a503d351d4 --- /dev/null +++ b/flutter/lib/src/integrations/native_app_start_handler.dart @@ -0,0 +1,304 @@ +// ignore_for_file: invalid_use_of_internal_member + +import '../../sentry_flutter.dart'; +import '../native/native_app_start.dart'; +import '../native/sentry_native_binding.dart'; + +// ignore: implementation_imports +import 'package:sentry/src/sentry_tracer.dart'; + +/// Handles communication with native frameworks in order to enrich +/// root [SentryTransaction] with app start data for mobile vitals. +class NativeAppStartHandler { + NativeAppStartHandler(this._native); + + final SentryNativeBinding _native; + + late final Hub _hub; + late final SentryFlutterOptions _options; + + /// We filter out App starts more than 60s + static const _maxAppStartMillis = 60000; + + Future call(Hub hub, SentryFlutterOptions options, + {required DateTime? appStartEnd}) async { + _hub = hub; + _options = options; + + final nativeAppStart = await _native.fetchNativeAppStart(); + if (nativeAppStart == null) { + return; + } + final appStartInfo = _infoNativeAppStart(nativeAppStart, appStartEnd); + if (appStartInfo == null) { + return; + } + + // Create Transaction & Span + + const screenName = 'root /'; + final transaction = _hub.startTransaction( + screenName, + SentrySpanOperations.uiLoad, + startTimestamp: appStartInfo.start, + ); + final ttidSpan = transaction.startChild( + SentrySpanOperations.uiTimeToInitialDisplay, + description: '$screenName initial display', + startTimestamp: appStartInfo.start, + ); + + // Enrich Transaction + + SentryTracer sentryTracer; + if (transaction is SentryTracer) { + sentryTracer = transaction; + } else { + return; + } + + SentryMeasurement? measurement; + if (options.autoAppStart) { + measurement = appStartInfo.toMeasurement(); + } else if (appStartEnd != null) { + appStartInfo.end = appStartEnd; + measurement = appStartInfo.toMeasurement(); + } + + if (measurement != null) { + sentryTracer.measurements[measurement.name] = measurement; + await _attachAppStartSpans(appStartInfo, sentryTracer); + } + + // Finish Transaction & Span + + await ttidSpan.finish(endTimestamp: appStartInfo.end); + await transaction.finish(endTimestamp: appStartInfo.end); + } + + _AppStartInfo? _infoNativeAppStart( + NativeAppStart nativeAppStart, + DateTime? appStartEnd, + ) { + final sentrySetupStartDateTime = SentryFlutter.sentrySetupStartTime; + if (sentrySetupStartDateTime == null) { + return null; + } + + final appStartDateTime = DateTime.fromMillisecondsSinceEpoch( + nativeAppStart.appStartTime.toInt()); + final pluginRegistrationDateTime = DateTime.fromMillisecondsSinceEpoch( + nativeAppStart.pluginRegistrationTime); + + if (_options.autoAppStart) { + // We only assign the current time if it's not already set - this is useful in tests + appStartEnd ??= _options.clock(); + + final duration = appStartEnd.difference(appStartDateTime); + + // We filter out app start more than 60s. + // This could be due to many different reasons. + // If you do the manual init and init the SDK too late and it does not + // compute the app start end in the very first Screen. + // If the process starts but the App isn't in the foreground. + // If the system forked the process earlier to accelerate the app start. + // And some unknown reasons that could not be reproduced. + // We've seen app starts with hours, days and even months. + if (duration.inMilliseconds > _maxAppStartMillis) { + return null; + } + } + + List<_TimeSpan> nativeSpanTimes = []; + for (final entry in nativeAppStart.nativeSpanTimes.entries) { + try { + final startTimestampMs = + entry.value['startTimestampMsSinceEpoch'] as int; + final endTimestampMs = entry.value['stopTimestampMsSinceEpoch'] as int; + nativeSpanTimes.add(_TimeSpan( + start: DateTime.fromMillisecondsSinceEpoch(startTimestampMs), + end: DateTime.fromMillisecondsSinceEpoch(endTimestampMs), + description: entry.key as String, + )); + } catch (e) { + _options.logger( + SentryLevel.warning, 'Failed to parse native span times: $e'); + continue; + } + } + + // We want to sort because the native spans are not guaranteed to be in order. + // Performance wise this won't affect us since the native span amount is very low. + nativeSpanTimes.sort((a, b) => a.start.compareTo(b.start)); + + return _AppStartInfo( + nativeAppStart.isColdStart ? _AppStartType.cold : _AppStartType.warm, + start: appStartDateTime, + end: appStartEnd, + pluginRegistration: pluginRegistrationDateTime, + sentrySetupStart: sentrySetupStartDateTime, + nativeSpanTimes: nativeSpanTimes, + ); + } + + Future _attachAppStartSpans( + _AppStartInfo appStartInfo, SentryTracer transaction) async { + final transactionTraceId = transaction.context.traceId; + final appStartEnd = appStartInfo.end; + if (appStartEnd == null) { + return; + } + + final appStartSpan = await _createAndFinishSpan( + tracer: transaction, + operation: appStartInfo.appStartTypeOperation, + description: appStartInfo.appStartTypeDescription, + parentSpanId: transaction.context.spanId, + traceId: transactionTraceId, + startTimestamp: appStartInfo.start, + endTimestamp: appStartEnd, + ); + + await _attachNativeSpans(appStartInfo, transaction, appStartSpan); + + final pluginRegistrationSpan = await _createAndFinishSpan( + tracer: transaction, + operation: appStartInfo.appStartTypeOperation, + description: appStartInfo.pluginRegistrationDescription, + parentSpanId: appStartSpan.context.spanId, + traceId: transactionTraceId, + startTimestamp: appStartInfo.start, + endTimestamp: appStartInfo.pluginRegistration, + ); + + final sentrySetupSpan = await _createAndFinishSpan( + tracer: transaction, + operation: appStartInfo.appStartTypeOperation, + description: appStartInfo.sentrySetupDescription, + parentSpanId: appStartSpan.context.spanId, + traceId: transactionTraceId, + startTimestamp: appStartInfo.pluginRegistration, + endTimestamp: appStartInfo.sentrySetupStart, + ); + + final firstFrameRenderSpan = await _createAndFinishSpan( + tracer: transaction, + operation: appStartInfo.appStartTypeOperation, + description: appStartInfo.firstFrameRenderDescription, + parentSpanId: appStartSpan.context.spanId, + traceId: transactionTraceId, + startTimestamp: appStartInfo.sentrySetupStart, + endTimestamp: appStartEnd, + ); + + transaction.children.addAll([ + appStartSpan, + pluginRegistrationSpan, + sentrySetupSpan, + firstFrameRenderSpan + ]); + } + + Future _attachNativeSpans( + _AppStartInfo appStartInfo, + SentryTracer transaction, + SentrySpan parent, + ) async { + await Future.forEach<_TimeSpan>(appStartInfo.nativeSpanTimes, + (timeSpan) async { + try { + final span = await _createAndFinishSpan( + tracer: transaction, + operation: appStartInfo.appStartTypeOperation, + description: timeSpan.description, + parentSpanId: parent.context.spanId, + traceId: transaction.context.traceId, + startTimestamp: timeSpan.start, + endTimestamp: timeSpan.end, + ); + span.data.putIfAbsent('native', () => true); + transaction.children.add(span); + } catch (e) { + _options.logger(SentryLevel.warning, + 'Failed to attach native span to app start transaction: $e'); + } + }); + } + + Future _createAndFinishSpan({ + required SentryTracer tracer, + required String operation, + required String description, + required SpanId parentSpanId, + required SentryId traceId, + required DateTime startTimestamp, + required DateTime endTimestamp, + }) async { + final span = SentrySpan( + tracer, + SentrySpanContext( + operation: operation, + description: description, + parentSpanId: parentSpanId, + traceId: traceId, + ), + _hub, + startTimestamp: startTimestamp, + ); + await span.finish(endTimestamp: endTimestamp); + return span; + } +} + +enum _AppStartType { cold, warm } + +class _AppStartInfo { + _AppStartInfo( + this.type, { + required this.start, + required this.pluginRegistration, + required this.sentrySetupStart, + required this.nativeSpanTimes, + this.end, + }); + + final _AppStartType type; + final DateTime start; + final List<_TimeSpan> nativeSpanTimes; + + // We allow the end to be null, since it might be set at a later time + // with setAppStartEnd when autoAppStart is disabled + DateTime? end; + + final DateTime pluginRegistration; + final DateTime sentrySetupStart; + + Duration? get duration => end?.difference(start); + + SentryMeasurement? toMeasurement() { + final duration = this.duration; + if (duration == null) { + return null; + } + return type == _AppStartType.cold + ? SentryMeasurement.coldAppStart(duration) + : SentryMeasurement.warmAppStart(duration); + } + + String get appStartTypeOperation => 'app.start.${type.name}'; + + String get appStartTypeDescription => + type == _AppStartType.cold ? 'Cold Start' : 'Warm Start'; + final pluginRegistrationDescription = 'App start to plugin registration'; + final sentrySetupDescription = 'Before Sentry Init Setup'; + final firstFrameRenderDescription = 'First frame render'; +} + +class _TimeSpan { + _TimeSpan( + {required this.start, required this.end, required this.description}); + + final DateTime start; + final DateTime end; + final String description; +} diff --git a/flutter/lib/src/integrations/native_app_start_integration.dart b/flutter/lib/src/integrations/native_app_start_integration.dart index d3be7b4a0f..31dc2b6ad8 100644 --- a/flutter/lib/src/integrations/native_app_start_integration.dart +++ b/flutter/lib/src/integrations/native_app_start_integration.dart @@ -1,233 +1,59 @@ -// ignore_for_file: invalid_use_of_internal_member - import 'dart:async'; import 'package:meta/meta.dart'; import '../../sentry_flutter.dart'; import '../frame_callback_handler.dart'; -import '../native/sentry_native.dart'; -import '../event_processor/native_app_start_event_processor.dart'; +import 'native_app_start_handler.dart'; -/// Integration which handles communication with native frameworks in order to -/// enrich [SentryTransaction] objects with app start data for mobile vitals. +/// Integration which calls [NativeAppStartHandler] after +/// [SchedulerBinding.instance.addPostFrameCallback] is called. class NativeAppStartIntegration extends Integration { - NativeAppStartIntegration(this._native, this._frameCallbackHandler, - {Hub? hub}) - : _hub = hub ?? HubAdapter(); + NativeAppStartIntegration( + this._frameCallbackHandler, this._nativeAppStartHandler); - final SentryNative _native; final FrameCallbackHandler _frameCallbackHandler; - final Hub _hub; - - /// Timeout duration to wait for the app start info to be fetched. - static const _timeoutDuration = Duration(seconds: 30); - - /// We filter out App starts more than 60s - static const _maxAppStartMillis = 60000; - - static Completer _appStartCompleter = - Completer(); - static AppStartInfo? _appStartInfo; - - @internal - static bool isIntegrationTest = false; + final NativeAppStartHandler _nativeAppStartHandler; + DateTime? _appStartEnd; + /// This timestamp marks the end of app startup. Either set by calling + /// [SentryFlutter.setAppStartEnd]. The [SentryFlutterOptions.autoAppStart] + /// option needs to be false. @internal - static void setAppStartInfo(AppStartInfo? appStartInfo) { - _appStartInfo = appStartInfo; - if (_appStartCompleter.isCompleted) { - _appStartCompleter = Completer(); + set appStartEnd(DateTime appStartEnd) { + _appStartEnd = appStartEnd; + if (!_appStartEndCompleter.isCompleted) { + _appStartEndCompleter.complete(); } - _appStartCompleter.complete(appStartInfo); } - @internal - static Future getAppStartInfo() { - if (_appStartInfo != null) { - return Future.value(_appStartInfo); - } - return _appStartCompleter.future - .timeout(_timeoutDuration, onTimeout: () => null); - } - - @visibleForTesting - static void clearAppStartInfo() { - _appStartInfo = null; - _appStartCompleter = Completer(); - } + final Completer _appStartEndCompleter = Completer(); @override - void call(Hub hub, SentryFlutterOptions options) { - if (isIntegrationTest) { - final appStartInfo = AppStartInfo( - AppStartType.cold, - start: DateTime.now(), - end: DateTime.now().add(const Duration(milliseconds: 100)), - pluginRegistration: - DateTime.now().add(const Duration(milliseconds: 50)), - sentrySetupStart: DateTime.now().add(const Duration(milliseconds: 60)), - nativeSpanTimes: [], - ); - setAppStartInfo(appStartInfo); - return; - } - - if (_native.didFetchAppStart) { - return; - } - + void call(Hub hub, SentryFlutterOptions options) async { _frameCallbackHandler.addPostFrameCallback((timeStamp) async { - final nativeAppStart = await _native.fetchNativeAppStart(); - if (nativeAppStart == null) { - setAppStartInfo(null); - return; - } - - final sentrySetupStartDateTime = SentryFlutter.sentrySetupStartTime; - if (sentrySetupStartDateTime == null) { - setAppStartInfo(null); - return; - } - - final appStartDateTime = DateTime.fromMillisecondsSinceEpoch( - nativeAppStart.appStartTime.toInt()); - final pluginRegistrationDateTime = DateTime.fromMillisecondsSinceEpoch( - nativeAppStart.pluginRegistrationTime); - DateTime? appStartEndDateTime; - - if (options.autoAppStart) { - // We only assign the current time if it's not already set - this is useful in tests - _native.appStartEnd ??= options.clock(); - appStartEndDateTime = _native.appStartEnd; - - final duration = appStartEndDateTime?.difference(appStartDateTime); - - // We filter out app start more than 60s. - // This could be due to many different reasons. - // If you do the manual init and init the SDK too late and it does not - // compute the app start end in the very first Screen. - // If the process starts but the App isn't in the foreground. - // If the system forked the process earlier to accelerate the app start. - // And some unknown reasons that could not be reproduced. - // We've seen app starts with hours, days and even months. - if (duration != null && duration.inMilliseconds > _maxAppStartMillis) { - setAppStartInfo(null); - return; + try { + if (!options.autoAppStart && _appStartEnd == null) { + await _appStartEndCompleter.future + .timeout(const Duration(seconds: 10)); } - } - - List nativeSpanTimes = []; - for (final entry in nativeAppStart.nativeSpanTimes.entries) { - try { - final startTimestampMs = - entry.value['startTimestampMsSinceEpoch'] as int; - final endTimestampMs = - entry.value['stopTimestampMsSinceEpoch'] as int; - nativeSpanTimes.add(TimeSpan( - start: DateTime.fromMillisecondsSinceEpoch(startTimestampMs), - end: DateTime.fromMillisecondsSinceEpoch(endTimestampMs), - description: entry.key as String, - )); - } catch (e) { - _hub.options.logger( - SentryLevel.warning, 'Failed to parse native span times: $e'); - continue; + await _nativeAppStartHandler.call( + hub, + options, + appStartEnd: _appStartEnd, + ); + } catch (exception, stackTrace) { + options.logger( + SentryLevel.error, + 'Error while capturing native app start', + exception: exception, + stackTrace: stackTrace, + ); + if (options.automatedTestMode) { + rethrow; } } - - // We want to sort because the native spans are not guaranteed to be in order. - // Performance wise this won't affect us since the native span amount is very low. - nativeSpanTimes.sort((a, b) => a.start.compareTo(b.start)); - - final appStartInfo = AppStartInfo( - nativeAppStart.isColdStart ? AppStartType.cold : AppStartType.warm, - start: appStartDateTime, - end: appStartEndDateTime, - pluginRegistration: pluginRegistrationDateTime, - sentrySetupStart: sentrySetupStartDateTime, - nativeSpanTimes: nativeSpanTimes); - - setAppStartInfo(appStartInfo); - - // When we don't have a SentryNavigatorObserver, a TTID transaction - // is not created therefore we need to create a transaction ourselves. - // We detect this by checking if the currentRouteName is null. - // This is a workaround since there is no api that tells us if - // the navigator observer exists and has been attached. - // The navigator observer also triggers much earlier so if it was attached - // it would have already set the routeName and the isCreated flag. - // The currentRouteName is always set during a didPush triggered - // by the navigator observer. - if (!SentryNavigatorObserver.isCreated && - SentryNavigatorObserver.currentRouteName == null) { - const screenName = SentryNavigatorObserver.rootScreenName; - final transaction = hub.startTransaction( - screenName, SentrySpanOperations.uiLoad, - startTimestamp: appStartInfo.start); - final ttidSpan = transaction.startChild( - SentrySpanOperations.uiTimeToInitialDisplay, - description: '$screenName initial display', - startTimestamp: appStartInfo.start); - await ttidSpan.finish(endTimestamp: appStartInfo.end); - await transaction.finish(endTimestamp: appStartInfo.end); - } }); - - options.addEventProcessor(NativeAppStartEventProcessor(_native, hub: hub)); - options.sdk.addIntegration('nativeAppStartIntegration'); } } - -enum AppStartType { cold, warm } - -class AppStartInfo { - AppStartInfo( - this.type, { - required this.start, - required this.pluginRegistration, - required this.sentrySetupStart, - required this.nativeSpanTimes, - this.end, - }); - - final AppStartType type; - final DateTime start; - final List nativeSpanTimes; - - // We allow the end to be null, since it might be set at a later time - // with setAppStartEnd when autoAppStart is disabled - DateTime? end; - - final DateTime pluginRegistration; - final DateTime sentrySetupStart; - - Duration? get duration => end?.difference(start); - - SentryMeasurement? toMeasurement() { - final duration = this.duration; - if (duration == null) { - return null; - } - return type == AppStartType.cold - ? SentryMeasurement.coldAppStart(duration) - : SentryMeasurement.warmAppStart(duration); - } - - String get appStartTypeOperation => 'app.start.${type.name}'; - - String get appStartTypeDescription => - type == AppStartType.cold ? 'Cold Start' : 'Warm Start'; - final pluginRegistrationDescription = 'App start to plugin registration'; - final sentrySetupDescription = 'Before Sentry Init Setup'; - final firstFrameRenderDescription = 'First frame render'; -} - -class TimeSpan { - TimeSpan({required this.start, required this.end, required this.description}); - - final DateTime start; - final DateTime end; - final String description; -} diff --git a/flutter/lib/src/integrations/native_sdk_integration.dart b/flutter/lib/src/integrations/native_sdk_integration.dart index 35181afc1e..4c7c9a92a4 100644 --- a/flutter/lib/src/integrations/native_sdk_integration.dart +++ b/flutter/lib/src/integrations/native_sdk_integration.dart @@ -1,7 +1,7 @@ import 'dart:async'; import 'package:sentry/sentry.dart'; -import '../native/sentry_native.dart'; +import '../native/sentry_native_binding.dart'; import '../sentry_flutter_options.dart'; /// Enables Sentry's native SDKs (Android and iOS) with options. @@ -9,7 +9,7 @@ class NativeSdkIntegration implements Integration { NativeSdkIntegration(this._native); SentryFlutterOptions? _options; - final SentryNative _native; + final SentryNativeBinding _native; @override Future call(Hub hub, SentryFlutterOptions options) async { @@ -20,7 +20,7 @@ class NativeSdkIntegration implements Integration { } try { - await _native.init(options); + await _native.init(hub); options.sdk.addIntegration('nativeSdkIntegration'); } catch (exception, stackTrace) { options.logger( @@ -29,6 +29,9 @@ class NativeSdkIntegration implements Integration { exception: exception, stackTrace: stackTrace, ); + if (_options?.automatedTestMode ?? false) { + rethrow; + } } } @@ -44,6 +47,9 @@ class NativeSdkIntegration implements Integration { exception: exception, stackTrace: stackTrace, ); + if (_options?.automatedTestMode ?? false) { + rethrow; + } } } } diff --git a/flutter/lib/src/integrations/on_error_integration.dart b/flutter/lib/src/integrations/on_error_integration.dart index 69aee9030f..365a3067ab 100644 --- a/flutter/lib/src/integrations/on_error_integration.dart +++ b/flutter/lib/src/integrations/on_error_integration.dart @@ -4,6 +4,9 @@ import 'package:flutter/widgets.dart'; import 'package:sentry/sentry.dart'; import '../sentry_flutter_options.dart'; +// ignore: implementation_imports +import 'package:sentry/src/utils/stacktrace_utils.dart'; + typedef ErrorCallback = bool Function(Object exception, StackTrace stackTrace); /// Integration which captures `PlatformDispatcher.onError` @@ -74,6 +77,11 @@ class OnErrorIntegration implements Integration { (scope) => scope.span?.status ??= const SpanStatus.internalError(), ); + if (stackTrace == StackTrace.empty) { + // ignore: invalid_use_of_internal_member + stackTrace = getCurrentStackTrace(); + } + // unawaited future hub.captureEvent(event, stackTrace: stackTrace); @@ -135,6 +143,9 @@ class PlatformDispatcherWrapper { exception: exception, stackTrace: stacktrace, ); + if (options.automatedTestMode) { + rethrow; + } return false; } return true; diff --git a/flutter/lib/src/native/cocoa/binding.dart b/flutter/lib/src/native/cocoa/binding.dart index 4af31cfc57..e958709d4e 100644 --- a/flutter/lib/src/native/cocoa/binding.dart +++ b/flutter/lib/src/native/cocoa/binding.dart @@ -6038,41 +6038,19 @@ class SentryCocoa { _registerName1("fileSystemRepresentation"); late final _sel_isFileURL1 = _registerName1("isFileURL"); late final _sel_standardizedURL1 = _registerName1("standardizedURL"); - late final _sel_checkResourceIsReachableAndReturnError_1 = - _registerName1("checkResourceIsReachableAndReturnError:"); - bool _objc_msgSend_225( - ffi.Pointer obj, - ffi.Pointer sel, - ffi.Pointer> error, - ) { - return __objc_msgSend_225( - obj, - sel, - error, - ); - } - - late final __objc_msgSend_225Ptr = _lookup< - ffi.NativeFunction< - ffi.Bool Function(ffi.Pointer, ffi.Pointer, - ffi.Pointer>)>>('objc_msgSend'); - late final __objc_msgSend_225 = __objc_msgSend_225Ptr.asFunction< - bool Function(ffi.Pointer, ffi.Pointer, - ffi.Pointer>)>(); - late final _sel_isFileReferenceURL1 = _registerName1("isFileReferenceURL"); late final _sel_fileReferenceURL1 = _registerName1("fileReferenceURL"); late final _sel_filePathURL1 = _registerName1("filePathURL"); late final _sel_getResourceValue_forKey_error_1 = _registerName1("getResourceValue:forKey:error:"); - bool _objc_msgSend_226( + bool _objc_msgSend_225( ffi.Pointer obj, ffi.Pointer sel, ffi.Pointer> value, ffi.Pointer key, ffi.Pointer> error, ) { - return __objc_msgSend_226( + return __objc_msgSend_225( obj, sel, value, @@ -6081,7 +6059,7 @@ class SentryCocoa { ); } - late final __objc_msgSend_226Ptr = _lookup< + late final __objc_msgSend_225Ptr = _lookup< ffi.NativeFunction< ffi.Bool Function( ffi.Pointer, @@ -6089,7 +6067,7 @@ class SentryCocoa { ffi.Pointer>, ffi.Pointer, ffi.Pointer>)>>('objc_msgSend'); - late final __objc_msgSend_226 = __objc_msgSend_226Ptr.asFunction< + late final __objc_msgSend_225 = __objc_msgSend_225Ptr.asFunction< bool Function( ffi.Pointer, ffi.Pointer, @@ -6099,13 +6077,13 @@ class SentryCocoa { late final _sel_resourceValuesForKeys_error_1 = _registerName1("resourceValuesForKeys:error:"); - ffi.Pointer _objc_msgSend_227( + ffi.Pointer _objc_msgSend_226( ffi.Pointer obj, ffi.Pointer sel, ffi.Pointer keys, ffi.Pointer> error, ) { - return __objc_msgSend_227( + return __objc_msgSend_226( obj, sel, keys, @@ -6113,14 +6091,14 @@ class SentryCocoa { ); } - late final __objc_msgSend_227Ptr = _lookup< + late final __objc_msgSend_226Ptr = _lookup< ffi.NativeFunction< ffi.Pointer Function( ffi.Pointer, ffi.Pointer, ffi.Pointer, ffi.Pointer>)>>('objc_msgSend'); - late final __objc_msgSend_227 = __objc_msgSend_227Ptr.asFunction< + late final __objc_msgSend_226 = __objc_msgSend_226Ptr.asFunction< ffi.Pointer Function( ffi.Pointer, ffi.Pointer, @@ -6129,14 +6107,14 @@ class SentryCocoa { late final _sel_setResourceValue_forKey_error_1 = _registerName1("setResourceValue:forKey:error:"); - bool _objc_msgSend_228( + bool _objc_msgSend_227( ffi.Pointer obj, ffi.Pointer sel, ffi.Pointer value, ffi.Pointer key, ffi.Pointer> error, ) { - return __objc_msgSend_228( + return __objc_msgSend_227( obj, sel, value, @@ -6145,7 +6123,7 @@ class SentryCocoa { ); } - late final __objc_msgSend_228Ptr = _lookup< + late final __objc_msgSend_227Ptr = _lookup< ffi.NativeFunction< ffi.Bool Function( ffi.Pointer, @@ -6153,7 +6131,7 @@ class SentryCocoa { ffi.Pointer, ffi.Pointer, ffi.Pointer>)>>('objc_msgSend'); - late final __objc_msgSend_228 = __objc_msgSend_228Ptr.asFunction< + late final __objc_msgSend_227 = __objc_msgSend_227Ptr.asFunction< bool Function( ffi.Pointer, ffi.Pointer, @@ -6163,13 +6141,13 @@ class SentryCocoa { late final _sel_setResourceValues_error_1 = _registerName1("setResourceValues:error:"); - bool _objc_msgSend_229( + bool _objc_msgSend_228( ffi.Pointer obj, ffi.Pointer sel, ffi.Pointer keyedValues, ffi.Pointer> error, ) { - return __objc_msgSend_229( + return __objc_msgSend_228( obj, sel, keyedValues, @@ -6177,14 +6155,14 @@ class SentryCocoa { ); } - late final __objc_msgSend_229Ptr = _lookup< + late final __objc_msgSend_228Ptr = _lookup< ffi.NativeFunction< ffi.Bool Function( ffi.Pointer, ffi.Pointer, ffi.Pointer, ffi.Pointer>)>>('objc_msgSend'); - late final __objc_msgSend_229 = __objc_msgSend_229Ptr.asFunction< + late final __objc_msgSend_228 = __objc_msgSend_228Ptr.asFunction< bool Function(ffi.Pointer, ffi.Pointer, ffi.Pointer, ffi.Pointer>)>(); @@ -6197,7 +6175,7 @@ class SentryCocoa { late final _sel_bookmarkDataWithOptions_includingResourceValuesForKeys_relativeToURL_error_1 = _registerName1( "bookmarkDataWithOptions:includingResourceValuesForKeys:relativeToURL:error:"); - ffi.Pointer _objc_msgSend_230( + ffi.Pointer _objc_msgSend_229( ffi.Pointer obj, ffi.Pointer sel, int options, @@ -6205,7 +6183,7 @@ class SentryCocoa { ffi.Pointer relativeURL, ffi.Pointer> error, ) { - return __objc_msgSend_230( + return __objc_msgSend_229( obj, sel, options, @@ -6215,7 +6193,7 @@ class SentryCocoa { ); } - late final __objc_msgSend_230Ptr = _lookup< + late final __objc_msgSend_229Ptr = _lookup< ffi.NativeFunction< ffi.Pointer Function( ffi.Pointer, @@ -6224,7 +6202,7 @@ class SentryCocoa { ffi.Pointer, ffi.Pointer, ffi.Pointer>)>>('objc_msgSend'); - late final __objc_msgSend_230 = __objc_msgSend_230Ptr.asFunction< + late final __objc_msgSend_229 = __objc_msgSend_229Ptr.asFunction< ffi.Pointer Function( ffi.Pointer, ffi.Pointer, @@ -6236,7 +6214,7 @@ class SentryCocoa { late final _sel_initByResolvingBookmarkData_options_relativeToURL_bookmarkDataIsStale_error_1 = _registerName1( "initByResolvingBookmarkData:options:relativeToURL:bookmarkDataIsStale:error:"); - instancetype _objc_msgSend_231( + instancetype _objc_msgSend_230( ffi.Pointer obj, ffi.Pointer sel, ffi.Pointer bookmarkData, @@ -6245,7 +6223,7 @@ class SentryCocoa { ffi.Pointer isStale, ffi.Pointer> error, ) { - return __objc_msgSend_231( + return __objc_msgSend_230( obj, sel, bookmarkData, @@ -6256,7 +6234,7 @@ class SentryCocoa { ); } - late final __objc_msgSend_231Ptr = _lookup< + late final __objc_msgSend_230Ptr = _lookup< ffi.NativeFunction< instancetype Function( ffi.Pointer, @@ -6266,7 +6244,7 @@ class SentryCocoa { ffi.Pointer, ffi.Pointer, ffi.Pointer>)>>('objc_msgSend'); - late final __objc_msgSend_231 = __objc_msgSend_231Ptr.asFunction< + late final __objc_msgSend_230 = __objc_msgSend_230Ptr.asFunction< instancetype Function( ffi.Pointer, ffi.Pointer, @@ -6281,13 +6259,13 @@ class SentryCocoa { "URLByResolvingBookmarkData:options:relativeToURL:bookmarkDataIsStale:error:"); late final _sel_resourceValuesForKeys_fromBookmarkData_1 = _registerName1("resourceValuesForKeys:fromBookmarkData:"); - ffi.Pointer _objc_msgSend_232( + ffi.Pointer _objc_msgSend_231( ffi.Pointer obj, ffi.Pointer sel, ffi.Pointer keys, ffi.Pointer bookmarkData, ) { - return __objc_msgSend_232( + return __objc_msgSend_231( obj, sel, keys, @@ -6295,14 +6273,14 @@ class SentryCocoa { ); } - late final __objc_msgSend_232Ptr = _lookup< + late final __objc_msgSend_231Ptr = _lookup< ffi.NativeFunction< ffi.Pointer Function( ffi.Pointer, ffi.Pointer, ffi.Pointer, ffi.Pointer)>>('objc_msgSend'); - late final __objc_msgSend_232 = __objc_msgSend_232Ptr.asFunction< + late final __objc_msgSend_231 = __objc_msgSend_231Ptr.asFunction< ffi.Pointer Function( ffi.Pointer, ffi.Pointer, @@ -6311,7 +6289,7 @@ class SentryCocoa { late final _sel_writeBookmarkData_toURL_options_error_1 = _registerName1("writeBookmarkData:toURL:options:error:"); - bool _objc_msgSend_233( + bool _objc_msgSend_232( ffi.Pointer obj, ffi.Pointer sel, ffi.Pointer bookmarkData, @@ -6319,7 +6297,7 @@ class SentryCocoa { int options, ffi.Pointer> error, ) { - return __objc_msgSend_233( + return __objc_msgSend_232( obj, sel, bookmarkData, @@ -6329,7 +6307,7 @@ class SentryCocoa { ); } - late final __objc_msgSend_233Ptr = _lookup< + late final __objc_msgSend_232Ptr = _lookup< ffi.NativeFunction< ffi.Bool Function( ffi.Pointer, @@ -6338,7 +6316,7 @@ class SentryCocoa { ffi.Pointer, ffi.UnsignedLong, ffi.Pointer>)>>('objc_msgSend'); - late final __objc_msgSend_233 = __objc_msgSend_233Ptr.asFunction< + late final __objc_msgSend_232 = __objc_msgSend_232Ptr.asFunction< bool Function( ffi.Pointer, ffi.Pointer, @@ -6349,13 +6327,13 @@ class SentryCocoa { late final _sel_bookmarkDataWithContentsOfURL_error_1 = _registerName1("bookmarkDataWithContentsOfURL:error:"); - ffi.Pointer _objc_msgSend_234( + ffi.Pointer _objc_msgSend_233( ffi.Pointer obj, ffi.Pointer sel, ffi.Pointer bookmarkFileURL, ffi.Pointer> error, ) { - return __objc_msgSend_234( + return __objc_msgSend_233( obj, sel, bookmarkFileURL, @@ -6363,14 +6341,14 @@ class SentryCocoa { ); } - late final __objc_msgSend_234Ptr = _lookup< + late final __objc_msgSend_233Ptr = _lookup< ffi.NativeFunction< ffi.Pointer Function( ffi.Pointer, ffi.Pointer, ffi.Pointer, ffi.Pointer>)>>('objc_msgSend'); - late final __objc_msgSend_234 = __objc_msgSend_234Ptr.asFunction< + late final __objc_msgSend_233 = __objc_msgSend_233Ptr.asFunction< ffi.Pointer Function( ffi.Pointer, ffi.Pointer, @@ -6379,14 +6357,14 @@ class SentryCocoa { late final _sel_URLByResolvingAliasFileAtURL_options_error_1 = _registerName1("URLByResolvingAliasFileAtURL:options:error:"); - instancetype _objc_msgSend_235( + instancetype _objc_msgSend_234( ffi.Pointer obj, ffi.Pointer sel, ffi.Pointer url, int options, ffi.Pointer> error, ) { - return __objc_msgSend_235( + return __objc_msgSend_234( obj, sel, url, @@ -6395,7 +6373,7 @@ class SentryCocoa { ); } - late final __objc_msgSend_235Ptr = _lookup< + late final __objc_msgSend_234Ptr = _lookup< ffi.NativeFunction< instancetype Function( ffi.Pointer, @@ -6403,7 +6381,7 @@ class SentryCocoa { ffi.Pointer, ffi.Int32, ffi.Pointer>)>>('objc_msgSend'); - late final __objc_msgSend_235 = __objc_msgSend_235Ptr.asFunction< + late final __objc_msgSend_234 = __objc_msgSend_234Ptr.asFunction< instancetype Function( ffi.Pointer, ffi.Pointer, @@ -6419,13 +6397,13 @@ class SentryCocoa { _registerName1("getPromisedItemResourceValue:forKey:error:"); late final _sel_promisedItemResourceValuesForKeys_error_1 = _registerName1("promisedItemResourceValuesForKeys:error:"); - ffi.Pointer _objc_msgSend_236( + ffi.Pointer _objc_msgSend_235( ffi.Pointer obj, ffi.Pointer sel, ffi.Pointer keys, ffi.Pointer> error, ) { - return __objc_msgSend_236( + return __objc_msgSend_235( obj, sel, keys, @@ -6433,14 +6411,14 @@ class SentryCocoa { ); } - late final __objc_msgSend_236Ptr = _lookup< + late final __objc_msgSend_235Ptr = _lookup< ffi.NativeFunction< ffi.Pointer Function( ffi.Pointer, ffi.Pointer, ffi.Pointer, ffi.Pointer>)>>('objc_msgSend'); - late final __objc_msgSend_236 = __objc_msgSend_236Ptr.asFunction< + late final __objc_msgSend_235 = __objc_msgSend_235Ptr.asFunction< ffi.Pointer Function( ffi.Pointer, ffi.Pointer, @@ -6449,6 +6427,26 @@ class SentryCocoa { late final _sel_checkPromisedItemIsReachableAndReturnError_1 = _registerName1("checkPromisedItemIsReachableAndReturnError:"); + bool _objc_msgSend_236( + ffi.Pointer obj, + ffi.Pointer sel, + ffi.Pointer> error, + ) { + return __objc_msgSend_236( + obj, + sel, + error, + ); + } + + late final __objc_msgSend_236Ptr = _lookup< + ffi.NativeFunction< + ffi.Bool Function(ffi.Pointer, ffi.Pointer, + ffi.Pointer>)>>('objc_msgSend'); + late final __objc_msgSend_236 = __objc_msgSend_236Ptr.asFunction< + bool Function(ffi.Pointer, ffi.Pointer, + ffi.Pointer>)>(); + late final _sel_fileURLWithPathComponents_1 = _registerName1("fileURLWithPathComponents:"); ffi.Pointer _objc_msgSend_237( @@ -6484,6 +6482,8 @@ class SentryCocoa { _registerName1("URLByAppendingPathExtension:"); late final _sel_URLByDeletingPathExtension1 = _registerName1("URLByDeletingPathExtension"); + late final _sel_checkResourceIsReachableAndReturnError_1 = + _registerName1("checkResourceIsReachableAndReturnError:"); late final _sel_URLByStandardizingPath1 = _registerName1("URLByStandardizingPath"); late final _sel_URLByResolvingSymlinksInPath1 = @@ -28721,6 +28721,10 @@ class SentryCocoa { late final _sel_currentDirectoryURL1 = _registerName1("currentDirectoryURL"); late final _sel_setCurrentDirectoryURL_1 = _registerName1("setCurrentDirectoryURL:"); + late final _sel_launchRequirementData1 = + _registerName1("launchRequirementData"); + late final _sel_setLaunchRequirementData_1 = + _registerName1("setLaunchRequirementData:"); late final _sel_standardInput1 = _registerName1("standardInput"); late final _sel_setStandardInput_1 = _registerName1("setStandardInput:"); late final _sel_standardOutput1 = _registerName1("standardOutput"); @@ -30112,12 +30116,6 @@ class SentryCocoa { ffi.Pointer Function(ffi.Pointer, ffi.Pointer, ffi.Pointer)>(); - late final _sel_captureReplay1 = _registerName1("captureReplay"); - late final _sel_getReplayId1 = _registerName1("getReplayId"); - late final _sel_addReplayIgnoreClasses_1 = - _registerName1("addReplayIgnoreClasses:"); - late final _sel_addReplayRedactClasses_1 = - _registerName1("addReplayRedactClasses:"); late final _class_SentryCurrentDateProvider1 = _getClass1("SentryCurrentDateProvider"); late final _sel_timezoneOffset1 = _registerName1("timezoneOffset"); @@ -30660,7 +30658,7 @@ class NSObject extends _ObjCWrapper { bool validateValue_forKey_error_(ffi.Pointer> ioValue, NSString? inKey, ffi.Pointer> outError) { - return _lib._objc_msgSend_226(_id, _lib._sel_validateValue_forKey_error_1, + return _lib._objc_msgSend_225(_id, _lib._sel_validateValue_forKey_error_1, ioValue, inKey?._id ?? ffi.nullptr, outError); } @@ -30697,7 +30695,7 @@ class NSObject extends _ObjCWrapper { ffi.Pointer> ioValue, NSString? inKeyPath, ffi.Pointer> outError) { - return _lib._objc_msgSend_226( + return _lib._objc_msgSend_225( _id, _lib._sel_validateValue_forKeyPath_error_1, ioValue, @@ -34809,12 +34807,6 @@ class NSURL extends NSObject { : NSURL._(_ret, _lib, retain: true, release: true); } - bool checkResourceIsReachableAndReturnError_( - ffi.Pointer> error) { - return _lib._objc_msgSend_225( - _id, _lib._sel_checkResourceIsReachableAndReturnError_1, error); - } - bool isFileReferenceURL() { return _lib._objc_msgSend_12(_id, _lib._sel_isFileReferenceURL1); } @@ -34835,13 +34827,13 @@ class NSURL extends NSObject { ffi.Pointer> value, NSString key, ffi.Pointer> error) { - return _lib._objc_msgSend_226( + return _lib._objc_msgSend_225( _id, _lib._sel_getResourceValue_forKey_error_1, value, key._id, error); } NSObject resourceValuesForKeys_error_( NSArray? keys, ffi.Pointer> error) { - final _ret = _lib._objc_msgSend_227( + final _ret = _lib._objc_msgSend_226( _id, _lib._sel_resourceValuesForKeys_error_1, keys?._id ?? ffi.nullptr, @@ -34851,13 +34843,13 @@ class NSURL extends NSObject { bool setResourceValue_forKey_error_(NSObject value, NSString key, ffi.Pointer> error) { - return _lib._objc_msgSend_228(_id, + return _lib._objc_msgSend_227(_id, _lib._sel_setResourceValue_forKey_error_1, value._id, key._id, error); } bool setResourceValues_error_( NSObject? keyedValues, ffi.Pointer> error) { - return _lib._objc_msgSend_229(_id, _lib._sel_setResourceValues_error_1, + return _lib._objc_msgSend_228(_id, _lib._sel_setResourceValues_error_1, keyedValues?._id ?? ffi.nullptr, error); } @@ -34881,7 +34873,7 @@ class NSURL extends NSObject { NSArray? keys, NSURL? relativeURL, ffi.Pointer> error) { - final _ret = _lib._objc_msgSend_230( + final _ret = _lib._objc_msgSend_229( _id, _lib._sel_bookmarkDataWithOptions_includingResourceValuesForKeys_relativeToURL_error_1, options, @@ -34898,7 +34890,7 @@ class NSURL extends NSObject { NSURL? relativeURL, ffi.Pointer isStale, ffi.Pointer> error) { - final _ret = _lib._objc_msgSend_231( + final _ret = _lib._objc_msgSend_230( _id, _lib._sel_initByResolvingBookmarkData_options_relativeToURL_bookmarkDataIsStale_error_1, bookmarkData?._id ?? ffi.nullptr, @@ -34917,7 +34909,7 @@ class NSURL extends NSObject { NSURL? relativeURL, ffi.Pointer isStale, ffi.Pointer> error) { - final _ret = _lib._objc_msgSend_231( + final _ret = _lib._objc_msgSend_230( _lib._class_NSURL1, _lib._sel_URLByResolvingBookmarkData_options_relativeToURL_bookmarkDataIsStale_error_1, bookmarkData?._id ?? ffi.nullptr, @@ -34930,7 +34922,7 @@ class NSURL extends NSObject { static NSObject resourceValuesForKeys_fromBookmarkData_( SentryCocoa _lib, NSArray? keys, NSData? bookmarkData) { - final _ret = _lib._objc_msgSend_232( + final _ret = _lib._objc_msgSend_231( _lib._class_NSURL1, _lib._sel_resourceValuesForKeys_fromBookmarkData_1, keys?._id ?? ffi.nullptr, @@ -34944,7 +34936,7 @@ class NSURL extends NSObject { NSURL? bookmarkFileURL, int options, ffi.Pointer> error) { - return _lib._objc_msgSend_233( + return _lib._objc_msgSend_232( _lib._class_NSURL1, _lib._sel_writeBookmarkData_toURL_options_error_1, bookmarkData?._id ?? ffi.nullptr, @@ -34955,7 +34947,7 @@ class NSURL extends NSObject { static NSData bookmarkDataWithContentsOfURL_error_(SentryCocoa _lib, NSURL? bookmarkFileURL, ffi.Pointer> error) { - final _ret = _lib._objc_msgSend_234( + final _ret = _lib._objc_msgSend_233( _lib._class_NSURL1, _lib._sel_bookmarkDataWithContentsOfURL_error_1, bookmarkFileURL?._id ?? ffi.nullptr, @@ -34965,7 +34957,7 @@ class NSURL extends NSObject { static NSURL URLByResolvingAliasFileAtURL_options_error_(SentryCocoa _lib, NSURL? url, int options, ffi.Pointer> error) { - final _ret = _lib._objc_msgSend_235( + final _ret = _lib._objc_msgSend_234( _lib._class_NSURL1, _lib._sel_URLByResolvingAliasFileAtURL_options_error_1, url?._id ?? ffi.nullptr, @@ -34987,7 +34979,7 @@ class NSURL extends NSObject { ffi.Pointer> value, NSString key, ffi.Pointer> error) { - return _lib._objc_msgSend_226( + return _lib._objc_msgSend_225( _id, _lib._sel_getPromisedItemResourceValue_forKey_error_1, value, @@ -34997,7 +34989,7 @@ class NSURL extends NSObject { NSDictionary promisedItemResourceValuesForKeys_error_( NSArray? keys, ffi.Pointer> error) { - final _ret = _lib._objc_msgSend_236( + final _ret = _lib._objc_msgSend_235( _id, _lib._sel_promisedItemResourceValuesForKeys_error_1, keys?._id ?? ffi.nullptr, @@ -35007,7 +34999,7 @@ class NSURL extends NSObject { bool checkPromisedItemIsReachableAndReturnError_( ffi.Pointer> error) { - return _lib._objc_msgSend_225( + return _lib._objc_msgSend_236( _id, _lib._sel_checkPromisedItemIsReachableAndReturnError_1, error); } @@ -35081,6 +35073,12 @@ class NSURL extends NSObject { : NSURL._(_ret, _lib, retain: true, release: true); } + bool checkResourceIsReachableAndReturnError_( + ffi.Pointer> error) { + return _lib._objc_msgSend_236( + _id, _lib._sel_checkResourceIsReachableAndReturnError_1, error); + } + NSURL? get URLByStandardizingPath { final _ret = _lib._objc_msgSend_40(_id, _lib._sel_URLByStandardizingPath1); return _ret.address == 0 @@ -52177,12 +52175,12 @@ class NSBundle extends NSObject { } bool preflightAndReturnError_(ffi.Pointer> error) { - return _lib._objc_msgSend_225( + return _lib._objc_msgSend_236( _id, _lib._sel_preflightAndReturnError_1, error); } bool loadAndReturnError_(ffi.Pointer> error) { - return _lib._objc_msgSend_225(_id, _lib._sel_loadAndReturnError_1, error); + return _lib._objc_msgSend_236(_id, _lib._sel_loadAndReturnError_1, error); } NSURL? get bundleURL { @@ -57371,12 +57369,12 @@ class NSFileHandle extends NSObject { } bool synchronizeAndReturnError_(ffi.Pointer> error) { - return _lib._objc_msgSend_225( + return _lib._objc_msgSend_236( _id, _lib._sel_synchronizeAndReturnError_1, error); } bool closeAndReturnError_(ffi.Pointer> error) { - return _lib._objc_msgSend_225(_id, _lib._sel_closeAndReturnError_1, error); + return _lib._objc_msgSend_236(_id, _lib._sel_closeAndReturnError_1, error); } static NSFileHandle? getFileHandleWithStandardInput(SentryCocoa _lib) { @@ -68287,6 +68285,18 @@ class NSTask extends NSObject { _id, _lib._sel_setCurrentDirectoryURL_1, value?._id ?? ffi.nullptr); } + NSData? get launchRequirementData { + final _ret = _lib._objc_msgSend_39(_id, _lib._sel_launchRequirementData1); + return _ret.address == 0 + ? null + : NSData._(_ret, _lib, retain: true, release: true); + } + + set launchRequirementData(NSData? value) { + return _lib._objc_msgSend_939( + _id, _lib._sel_setLaunchRequirementData_1, value?._id ?? ffi.nullptr); + } + NSObject get standardInput { final _ret = _lib._objc_msgSend_2(_id, _lib._sel_standardInput1); return NSObject._(_ret, _lib, retain: true, release: true); @@ -68316,7 +68326,7 @@ class NSTask extends NSObject { } bool launchAndReturnError_(ffi.Pointer> error) { - return _lib._objc_msgSend_225(_id, _lib._sel_launchAndReturnError_1, error); + return _lib._objc_msgSend_236(_id, _lib._sel_launchAndReturnError_1, error); } void interrupt() { @@ -69733,7 +69743,7 @@ class NSXMLDocument extends NSXMLNode { } bool validateAndReturnError_(ffi.Pointer> error) { - return _lib._objc_msgSend_225( + return _lib._objc_msgSend_236( _id, _lib._sel_validateAndReturnError_1, error); } @@ -70897,27 +70907,6 @@ class PrivateSentrySDKOnly extends NSObject { return SentryBreadcrumb._(_ret, _lib, retain: true, release: true); } - static void captureReplay(SentryCocoa _lib) { - _lib._objc_msgSend_1( - _lib._class_PrivateSentrySDKOnly1, _lib._sel_captureReplay1); - } - - static NSString getReplayId(SentryCocoa _lib) { - final _ret = _lib._objc_msgSend_20( - _lib._class_PrivateSentrySDKOnly1, _lib._sel_getReplayId1); - return NSString._(_ret, _lib, retain: true, release: true); - } - - static void addReplayIgnoreClasses_(SentryCocoa _lib, NSArray? classes) { - _lib._objc_msgSend_441(_lib._class_PrivateSentrySDKOnly1, - _lib._sel_addReplayIgnoreClasses_1, classes?._id ?? ffi.nullptr); - } - - static void addReplayRedactClasses_(SentryCocoa _lib, NSArray? classes) { - _lib._objc_msgSend_441(_lib._class_PrivateSentrySDKOnly1, - _lib._sel_addReplayRedactClasses_1, classes?._id ?? ffi.nullptr); - } - @override PrivateSentrySDKOnly init() { final _ret = _lib._objc_msgSend_2(_id, _lib._sel_init1); diff --git a/flutter/lib/src/native/cocoa/sentry_native_cocoa.dart b/flutter/lib/src/native/cocoa/sentry_native_cocoa.dart index d6912c107c..3c956205ae 100644 --- a/flutter/lib/src/native/cocoa/sentry_native_cocoa.dart +++ b/flutter/lib/src/native/cocoa/sentry_native_cocoa.dart @@ -1,23 +1,82 @@ import 'dart:ffi'; +import 'dart:typed_data'; +import 'dart:ui'; import 'package:meta/meta.dart'; import '../../../sentry_flutter.dart'; +import '../../event_processor/replay_event_processor.dart'; +import '../../replay/recorder.dart'; +import '../../replay/recorder_config.dart'; import '../sentry_native_channel.dart'; import 'binding.dart' as cocoa; @internal class SentryNativeCocoa extends SentryNativeChannel { late final _lib = cocoa.SentryCocoa(DynamicLibrary.process()); + ScreenshotRecorder? _replayRecorder; + SentryId? _replayId; - SentryNativeCocoa(super.channel); + SentryNativeCocoa(super.options); @override - int? startProfiler(SentryId traceId) { - final cSentryId = cocoa.SentryId1.alloc(_lib) - ..initWithUUIDString_(cocoa.NSString(_lib, traceId.toString())); - final startTime = - cocoa.PrivateSentrySDKOnly.startProfilerForTrace_(_lib, cSentryId); - return startTime; + Future init(Hub hub) async { + // We only need these when replay is enabled (session or error capture) + // so let's set it up conditionally. This allows Dart to trim the code. + if (options.experimental.replay.isEnabled && + options.platformChecker.platform.isIOS) { + // We only need the integration when error-replay capture is enabled. + if ((options.experimental.replay.onErrorSampleRate ?? 0) > 0) { + options.addEventProcessor(ReplayEventProcessor(this)); + } + + channel.setMethodCallHandler((call) async { + switch (call.method) { + case 'captureReplayScreenshot': + _replayRecorder ??= + ScreenshotRecorder(ScreenshotRecorderConfig(), options); + final replayId = + SentryId.fromId(call.arguments['replayId'] as String); + if (_replayId != replayId) { + _replayId = replayId; + hub.configureScope((s) { + // ignore: invalid_use_of_internal_member + s.replayId = replayId; + }); + } + + Uint8List? imageBytes; + await _replayRecorder?.capture((image) async { + final imageData = + await image.toByteData(format: ImageByteFormat.png); + if (imageData != null) { + options.logger( + SentryLevel.debug, + 'Replay: captured screenshot (' + '${image.width}x${image.height} pixels, ' + '${imageData.lengthInBytes} bytes)'); + imageBytes = imageData.buffer.asUint8List(); + } else { + options.logger(SentryLevel.warning, + 'Replay: failed to convert screenshot to PNG'); + } + }); + return imageBytes; + default: + throw UnimplementedError('Method ${call.method} not implemented'); + } + }); + } + + return super.init(hub); } + + @override + int? startProfiler(SentryId traceId) => tryCatchSync('startProfiler', () { + final cSentryId = cocoa.SentryId1.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 index 981e1d6ead..c81c526594 100644 --- a/flutter/lib/src/native/factory.dart +++ b/flutter/lib/src/native/factory.dart @@ -1 +1,3 @@ -export 'factory_real.dart' if (dart.library.html) 'factory_web.dart'; +export 'factory_real.dart' + if (dart.library.html) 'factory_web.dart' + if (dart.library.js_interop) 'factory_web.dart'; diff --git a/flutter/lib/src/native/factory_real.dart b/flutter/lib/src/native/factory_real.dart index d5ee4f5cca..67af20e2e8 100644 --- a/flutter/lib/src/native/factory_real.dart +++ b/flutter/lib/src/native/factory_real.dart @@ -1,17 +1,16 @@ -import 'package:flutter/services.dart'; - import '../../sentry_flutter.dart'; import 'cocoa/sentry_native_cocoa.dart'; import 'java/sentry_native_java.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 if (pc.platform.isAndroid) { - return SentryNativeJava(channel); +SentryNativeBinding createBinding(SentryFlutterOptions options) { + final platform = options.platformChecker.platform; + if (platform.isIOS || platform.isMacOS) { + return SentryNativeCocoa(options); + } else if (platform.isAndroid) { + return SentryNativeJava(options); } else { - return SentryNativeChannel(channel); + return SentryNativeChannel(options); } } diff --git a/flutter/lib/src/native/factory_web.dart b/flutter/lib/src/native/factory_web.dart index c2cd57ada7..17c3f5afe0 100644 --- a/flutter/lib/src/native/factory_web.dart +++ b/flutter/lib/src/native/factory_web.dart @@ -1,9 +1,7 @@ -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 _, MethodChannel __) { +SentryNativeBinding createBinding(SentryFlutterOptions options) { throw UnsupportedError("Native binding is not supported on this platform."); } diff --git a/flutter/lib/src/native/java/sentry_native_java.dart b/flutter/lib/src/native/java/sentry_native_java.dart index 20774e11fa..94c29fca16 100644 --- a/flutter/lib/src/native/java/sentry_native_java.dart +++ b/flutter/lib/src/native/java/sentry_native_java.dart @@ -1,10 +1,208 @@ +import 'dart:ui'; + +import 'package:flutter/services.dart'; import 'package:meta/meta.dart'; +import '../../../sentry_flutter.dart'; +import '../../event_processor/replay_event_processor.dart'; +import '../../replay/scheduled_recorder.dart'; +import '../../replay/recorder_config.dart'; import '../sentry_native_channel.dart'; // Note: currently this doesn't do anything. Later, it shall be used with // generated JNI bindings. See https://github.com/getsentry/sentry-dart/issues/1444 @internal class SentryNativeJava extends SentryNativeChannel { - SentryNativeJava(super.channel); + ScheduledScreenshotRecorder? _replayRecorder; + String? _replayCacheDir; + _IdleFrameFiller? _idleFrameFiller; + SentryNativeJava(super.options); + + @override + Future init(Hub hub) async { + // We only need these when replay is enabled (session or error capture) + // so let's set it up conditionally. This allows Dart to trim the code. + if (options.experimental.replay.isEnabled) { + // We only need the integration when error-replay capture is enabled. + if ((options.experimental.replay.onErrorSampleRate ?? 0) > 0) { + options.addEventProcessor(ReplayEventProcessor(this)); + } + + channel.setMethodCallHandler((call) async { + switch (call.method) { + case 'ReplayRecorder.start': + final replayId = + SentryId.fromId(call.arguments['replayId'] as String); + + _startRecorder( + call.arguments['directory'] as String, + ScheduledScreenshotRecorderConfig( + width: call.arguments['width'] as int, + height: call.arguments['height'] as int, + frameRate: call.arguments['frameRate'] as int, + ), + ); + + hub.configureScope((s) { + // ignore: invalid_use_of_internal_member + s.replayId = replayId; + }); + + break; + case 'ReplayRecorder.stop': + await _stopRecorder(); + + hub.configureScope((s) { + // ignore: invalid_use_of_internal_member + s.replayId = null; + }); + + break; + case 'ReplayRecorder.pause': + await _replayRecorder?.stop(); + await _idleFrameFiller?.pause(); + break; + case 'ReplayRecorder.resume': + _replayRecorder?.start(); + await _idleFrameFiller?.resume(); + break; + default: + throw UnimplementedError('Method ${call.method} not implemented'); + } + }); + } + + return super.init(hub); + } + + @override + Future close() async { + await _stopRecorder(); + return super.close(); + } + + Future _stopRecorder() async { + await _replayRecorder?.stop(); + await _idleFrameFiller?.stop(); + _replayRecorder = null; + _idleFrameFiller = null; + } + + void _startRecorder( + String cacheDir, ScheduledScreenshotRecorderConfig config) { + _idleFrameFiller = _IdleFrameFiller( + Duration(milliseconds: 1000 ~/ config.frameRate), _addReplayScreenshot); + + // Note: time measurements using a Stopwatch in a debug build: + // save as rawRgba (1230876 bytes): 0.257 ms -- discarded + // save as PNG (25401 bytes): 43.110 ms -- used for the final image + // image size: 25401 bytes + // save to file: 3.677 ms + // onScreenshotRecorded1: 1.237 ms + // released and exiting callback: 0.021 ms + ScreenshotRecorderCallback callback = (image) async { + var imageData = await image.toByteData(format: ImageByteFormat.png); + if (imageData != null) { + final screenshot = _Screenshot(image.width, image.height, imageData); + await _addReplayScreenshot(screenshot); + _idleFrameFiller?.actualFrameReceived(screenshot); + } + }; + + _replayCacheDir = cacheDir; + _replayRecorder = ScheduledScreenshotRecorder(config, callback, options) + ..start(); + } + + Future _addReplayScreenshot(_Screenshot screenshot) async { + final timestamp = DateTime.now().millisecondsSinceEpoch; + final filePath = "$_replayCacheDir/$timestamp.png"; + + options.logger( + SentryLevel.debug, + 'Replay: Saving screenshot to $filePath (' + '${screenshot.width}x${screenshot.height} pixels, ' + '${screenshot.data.lengthInBytes} bytes)'); + try { + await options.fileSystem + .file(filePath) + .writeAsBytes(screenshot.data.buffer.asUint8List(), flush: true); + + await channel.invokeMethod( + 'addReplayScreenshot', + {'path': filePath, 'timestamp': timestamp}, + ); + } catch (error, stackTrace) { + options.logger( + SentryLevel.error, + 'Native call `addReplayScreenshot` failed', + exception: error, + stackTrace: stackTrace, + ); + if (options.automatedTestMode) { + rethrow; + } + } + } +} + +class _Screenshot { + final int width; + final int height; + final ByteData data; + + _Screenshot(this.width, this.height, this.data); +} + +// Workaround for https://github.com/getsentry/sentry-java/issues/3677 +// In short: when there are no postFrameCallbacks issued by Flutter (because +// there are no animations or user interactions), the replay recorder will +// need to get screenshots at a fixed frame rate. This class is responsible for +// filling the gaps between actual frames with the most recent frame. +class _IdleFrameFiller { + final Duration _interval; + final Future Function(_Screenshot screenshot) _callback; + bool _running = true; + Future? _scheduled; + _Screenshot? _mostRecent; + + _IdleFrameFiller(this._interval, this._callback); + + void actualFrameReceived(_Screenshot screenshot) { + // We store the most recent frame but only repost it when the most recent + // one is the same instance (unchanged). + _mostRecent = screenshot; + // Also, the initial reposted frame will be delayed to allow actual frames + // to cancel the reposting. + repostLater(_interval * 1.5, screenshot); + } + + Future stop() async { + // Clearing [_mostRecent] stops the delayed callback from posting the image. + _mostRecent = null; + _running = false; + await _scheduled; + _scheduled = null; + } + + Future pause() async { + _running = false; + } + + Future resume() async { + _running = true; + } + + void repostLater(Duration delay, _Screenshot screenshot) { + _scheduled = Future.delayed(delay, () async { + // Only repost if the screenshot haven't changed. + if (screenshot == _mostRecent) { + if (_running) { + await _callback(screenshot); + } + // On subsequent frames, we stick to the actual frame rate. + repostLater(_interval, screenshot); + } + }); + } } diff --git a/flutter/lib/src/native/native_app_start.dart b/flutter/lib/src/native/native_app_start.dart new file mode 100644 index 0000000000..7b6d53422f --- /dev/null +++ b/flutter/lib/src/native/native_app_start.dart @@ -0,0 +1,24 @@ +import 'package:meta/meta.dart'; + +@internal +class NativeAppStart { + NativeAppStart( + {required this.appStartTime, + required this.pluginRegistrationTime, + required this.isColdStart, + required this.nativeSpanTimes}); + + double appStartTime; + int pluginRegistrationTime; + bool isColdStart; + Map nativeSpanTimes; + + factory NativeAppStart.fromJson(Map json) { + return NativeAppStart( + appStartTime: json['appStartTime'] as double, + pluginRegistrationTime: json['pluginRegistrationTime'] as int, + isColdStart: json['isColdStart'] as bool, + nativeSpanTimes: json['nativeSpanTimes'] as Map, + ); + } +} diff --git a/flutter/lib/src/native/native_frames.dart b/flutter/lib/src/native/native_frames.dart new file mode 100644 index 0000000000..309abccbb7 --- /dev/null +++ b/flutter/lib/src/native/native_frames.dart @@ -0,0 +1,18 @@ +import 'package:meta/meta.dart'; + +@internal +class NativeFrames { + NativeFrames(this.totalFrames, this.slowFrames, this.frozenFrames); + + int totalFrames; + int slowFrames; + int frozenFrames; + + factory NativeFrames.fromJson(Map json) { + return NativeFrames( + json['totalFrames'] as int, + json['slowFrames'] as int, + json['frozenFrames'] as int, + ); + } +} diff --git a/flutter/lib/src/native/native_scope_observer.dart b/flutter/lib/src/native/native_scope_observer.dart index fe26a2b094..84dc31f646 100644 --- a/flutter/lib/src/native/native_scope_observer.dart +++ b/flutter/lib/src/native/native_scope_observer.dart @@ -2,55 +2,55 @@ import 'dart:async'; import 'package:sentry/sentry.dart'; -import 'sentry_native.dart'; +import 'sentry_native_binding.dart'; class NativeScopeObserver implements ScopeObserver { - NativeScopeObserver(this._sentryNative); + NativeScopeObserver(this._native); - final SentryNative _sentryNative; + final SentryNativeBinding _native; @override Future setContexts(String key, value) async { - await _sentryNative.setContexts(key, value); + await _native.setContexts(key, value); } @override Future removeContexts(String key) async { - await _sentryNative.removeContexts(key); + await _native.removeContexts(key); } @override Future setUser(SentryUser? user) async { - await _sentryNative.setUser(user); + await _native.setUser(user); } @override Future addBreadcrumb(Breadcrumb breadcrumb) async { - await _sentryNative.addBreadcrumb(breadcrumb); + await _native.addBreadcrumb(breadcrumb); } @override Future clearBreadcrumbs() async { - await _sentryNative.clearBreadcrumbs(); + await _native.clearBreadcrumbs(); } @override Future setExtra(String key, dynamic value) async { - await _sentryNative.setExtra(key, value); + await _native.setExtra(key, value); } @override Future removeExtra(String key) async { - await _sentryNative.removeExtra(key); + await _native.removeExtra(key); } @override Future setTag(String key, String value) async { - await _sentryNative.setTag(key, value); + await _native.setTag(key, value); } @override Future removeTag(String key) async { - await _sentryNative.removeTag(key); + await _native.removeTag(key); } } diff --git a/flutter/lib/src/native/sentry_native.dart b/flutter/lib/src/native/sentry_native.dart deleted file mode 100644 index d8d9c5a964..0000000000 --- a/flutter/lib/src/native/sentry_native.dart +++ /dev/null @@ -1,172 +0,0 @@ -import 'dart:async'; - -import 'package:meta/meta.dart'; - -import '../../sentry_flutter.dart'; -import 'sentry_native_binding.dart'; - -/// [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 { - final SentryOptions _options; - final SentryNativeBinding _binding; - - SentryNative(this._options, this._binding); - - // AppStart - - /// This timestamp marks the end of app startup. Either set automatically when - /// [SentryFlutterOptions.autoAppStart] is true, or by calling - /// [SentryFlutter.setAppStartEnd] - DateTime? appStartEnd; - - bool _didFetchAppStart = false; - - /// Flag indicating if app start was already fetched. - bool get didFetchAppStart => _didFetchAppStart; - - /// Flag indicating if app start measurement was added to the first transaction. - bool didAddAppStartMeasurement = false; - - Future init(SentryFlutterOptions options) async => - _invoke("init", () => _binding.init(options)); - - Future close() async => _invoke("close", _binding.close); - - /// Fetch [NativeAppStart] from native channels. Can only be called once. - Future fetchNativeAppStart() async { - _didFetchAppStart = true; - return _invoke("fetchNativeAppStart", _binding.fetchNativeAppStart); - } - - // NativeFrames - - Future beginNativeFramesCollection() => - _invoke("beginNativeFrames", _binding.beginNativeFrames); - - Future endNativeFramesCollection(SentryId traceId) => - _invoke("endNativeFrames", () => _binding.endNativeFrames(traceId)); - - // Scope - - Future setContexts(String key, dynamic value) => - _invoke("setContexts", () => _binding.setContexts(key, value)); - - Future removeContexts(String key) => - _invoke("removeContexts", () => _binding.removeContexts(key)); - - Future setUser(SentryUser? sentryUser) => - _invoke("setUser", () => _binding.setUser(sentryUser)); - - Future addBreadcrumb(Breadcrumb breadcrumb) => - _invoke("addBreadcrumb", () => _binding.addBreadcrumb(breadcrumb)); - - Future clearBreadcrumbs() => - _invoke("clearBreadcrumbs", _binding.clearBreadcrumbs); - - Future setExtra(String key, dynamic value) => - _invoke("setExtra", () => _binding.setExtra(key, value)); - - Future removeExtra(String key) => - _invoke("removeExtra", () => _binding.removeExtra(key)); - - Future setTag(String key, String value) => - _invoke("setTag", () => _binding.setTag(key, value)); - - 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 { - NativeAppStart( - {required this.appStartTime, - required this.pluginRegistrationTime, - required this.isColdStart, - required this.nativeSpanTimes}); - - double appStartTime; - int pluginRegistrationTime; - bool isColdStart; - Map nativeSpanTimes; - - factory NativeAppStart.fromJson(Map json) { - return NativeAppStart( - appStartTime: json['appStartTime'] as double, - pluginRegistrationTime: json['pluginRegistrationTime'] as int, - isColdStart: json['isColdStart'] as bool, - nativeSpanTimes: json['nativeSpanTimes'] as Map, - ); - } -} - -class NativeFrames { - NativeFrames(this.totalFrames, this.slowFrames, this.frozenFrames); - - int totalFrames; - int slowFrames; - int frozenFrames; - - factory NativeFrames.fromJson(Map json) { - return NativeFrames( - json['totalFrames'] as int, - json['slowFrames'] as int, - json['frozenFrames'] as int, - ); - } -} diff --git a/flutter/lib/src/native/sentry_native_binding.dart b/flutter/lib/src/native/sentry_native_binding.dart index 950e7f9994..44ee6432b5 100644 --- a/flutter/lib/src/native/sentry_native_binding.dart +++ b/flutter/lib/src/native/sentry_native_binding.dart @@ -1,20 +1,24 @@ import 'dart:async'; +import 'dart:typed_data'; import 'package:meta/meta.dart'; import '../../sentry_flutter.dart'; -import 'sentry_native.dart'; +import 'native_app_start.dart'; +import 'native_frames.dart'; /// Provide typed methods to access native layer. @internal abstract class SentryNativeBinding { - // TODO Move other native calls here. - Future init(SentryFlutterOptions options); + Future init(Hub hub); Future close(); Future fetchNativeAppStart(); + Future captureEnvelope( + Uint8List envelopeData, bool containsUnhandledException); + Future beginNativeFrames(); Future endNativeFrames(SentryId id); @@ -25,6 +29,8 @@ abstract class SentryNativeBinding { Future clearBreadcrumbs(); + Future?> loadContexts(); + Future setContexts(String key, dynamic value); Future removeContexts(String key); @@ -41,6 +47,18 @@ abstract class SentryNativeBinding { Future discardProfiler(SentryId traceId); + Future displayRefreshRate(); + Future?> collectProfile( SentryId traceId, int startTimeNs, int endTimeNs); + + Future?> loadDebugImages(); + + Future pauseAppHangTracking(); + + Future resumeAppHangTracking(); + + Future nativeCrash(); + + Future captureReplay(bool isCrash); } diff --git a/flutter/lib/src/native/sentry_native_channel.dart b/flutter/lib/src/native/sentry_native_channel.dart index 61b361de30..1e4faf4494 100644 --- a/flutter/lib/src/native/sentry_native_channel.dart +++ b/flutter/lib/src/native/sentry_native_channel.dart @@ -1,79 +1,106 @@ import 'dart:async'; +// backcompatibility for Flutter < 3.3 +// ignore: unnecessary_import +import 'dart:typed_data'; import 'package:flutter/services.dart'; import 'package:meta/meta.dart'; import '../../sentry_flutter.dart'; -import 'sentry_native.dart'; +import 'native_app_start.dart'; +import 'native_frames.dart'; import 'method_channel_helper.dart'; import 'sentry_native_binding.dart'; +import 'sentry_native_invoker.dart'; +import 'sentry_safe_method_channel.dart'; /// Provide typed methods to access native layer via MethodChannel. @internal -class SentryNativeChannel implements SentryNativeBinding { - SentryNativeChannel(this._channel); - - final MethodChannel _channel; - - // TODO Move other native calls here. - - @override - Future init(SentryFlutterOptions options) async => - _channel.invokeMethod('initNativeSdk', { - 'dsn': options.dsn, - 'debug': options.debug, - 'environment': options.environment, - 'release': options.release, - 'enableAutoSessionTracking': options.enableAutoSessionTracking, - 'enableNativeCrashHandling': options.enableNativeCrashHandling, - 'attachStacktrace': options.attachStacktrace, - 'attachThreads': options.attachThreads, - 'autoSessionTrackingIntervalMillis': - options.autoSessionTrackingInterval.inMilliseconds, - 'dist': options.dist, - 'integrations': options.sdk.integrations, - 'packages': - options.sdk.packages.map((e) => e.toJson()).toList(growable: false), - 'diagnosticLevel': options.diagnosticLevel.name, - 'maxBreadcrumbs': options.maxBreadcrumbs, - 'anrEnabled': options.anrEnabled, - 'anrTimeoutIntervalMillis': options.anrTimeoutInterval.inMilliseconds, - 'enableAutoNativeBreadcrumbs': options.enableAutoNativeBreadcrumbs, - 'maxCacheItems': options.maxCacheItems, - 'sendDefaultPii': options.sendDefaultPii, - 'enableWatchdogTerminationTracking': - options.enableWatchdogTerminationTracking, - 'enableNdkScopeSync': options.enableNdkScopeSync, - 'enableAutoPerformanceTracing': options.enableAutoPerformanceTracing, - 'sendClientReports': options.sendClientReports, - 'proguardUuid': options.proguardUuid, - 'maxAttachmentSize': options.maxAttachmentSize, - 'recordHttpBreadcrumbs': options.recordHttpBreadcrumbs, - 'captureFailedRequests': options.captureFailedRequests, - 'enableAppHangTracking': options.enableAppHangTracking, - 'connectionTimeoutMillis': options.connectionTimeout.inMilliseconds, - 'readTimeoutMillis': options.readTimeout.inMilliseconds, - 'appHangTimeoutIntervalMillis': - options.appHangTimeoutInterval.inMilliseconds, - }); +class SentryNativeChannel + with SentryNativeSafeInvoker + implements SentryNativeBinding { + @override + final SentryFlutterOptions options; + + @protected + final SentrySafeMethodChannel channel; + + SentryNativeChannel(this.options) + : channel = SentrySafeMethodChannel(options); + + @override + Future init(Hub hub) async { + return channel.invokeMethod('initNativeSdk', { + 'dsn': options.dsn, + 'debug': options.debug, + 'environment': options.environment, + 'release': options.release, + 'enableAutoSessionTracking': options.enableAutoSessionTracking, + 'enableNativeCrashHandling': options.enableNativeCrashHandling, + 'attachStacktrace': options.attachStacktrace, + 'attachThreads': options.attachThreads, + 'autoSessionTrackingIntervalMillis': + options.autoSessionTrackingInterval.inMilliseconds, + 'dist': options.dist, + 'integrations': options.sdk.integrations, + 'packages': + options.sdk.packages.map((e) => e.toJson()).toList(growable: false), + 'diagnosticLevel': options.diagnosticLevel.name, + 'maxBreadcrumbs': options.maxBreadcrumbs, + 'anrEnabled': options.anrEnabled, + 'anrTimeoutIntervalMillis': options.anrTimeoutInterval.inMilliseconds, + 'enableAutoNativeBreadcrumbs': options.enableAutoNativeBreadcrumbs, + 'maxCacheItems': options.maxCacheItems, + 'sendDefaultPii': options.sendDefaultPii, + 'enableWatchdogTerminationTracking': + options.enableWatchdogTerminationTracking, + 'enableNdkScopeSync': options.enableNdkScopeSync, + 'enableAutoPerformanceTracing': options.enableAutoPerformanceTracing, + 'sendClientReports': options.sendClientReports, + 'proguardUuid': options.proguardUuid, + 'maxAttachmentSize': options.maxAttachmentSize, + 'recordHttpBreadcrumbs': options.recordHttpBreadcrumbs, + 'captureFailedRequests': options.captureFailedRequests, + 'enableAppHangTracking': options.enableAppHangTracking, + 'connectionTimeoutMillis': options.connectionTimeout.inMilliseconds, + 'readTimeoutMillis': options.readTimeout.inMilliseconds, + 'appHangTimeoutIntervalMillis': + options.appHangTimeoutInterval.inMilliseconds, + if (options.proxy != null) 'proxy': options.proxy?.toJson(), + 'replay': { + 'sessionSampleRate': options.experimental.replay.sessionSampleRate, + 'onErrorSampleRate': options.experimental.replay.onErrorSampleRate, + }, + }); + } @override - Future close() async => _channel.invokeMethod('closeNativeSdk'); + Future close() async => channel.invokeMethod('closeNativeSdk'); @override Future fetchNativeAppStart() async { final json = - await _channel.invokeMapMethod('fetchNativeAppStart'); + await channel.invokeMapMethod('fetchNativeAppStart'); return (json != null) ? NativeAppStart.fromJson(json) : null; } @override - Future beginNativeFrames() => - _channel.invokeMethod('beginNativeFrames'); + Future captureEnvelope( + Uint8List envelopeData, bool containsUnhandledException) { + return channel.invokeMethod( + 'captureEnvelope', [envelopeData, containsUnhandledException]); + } + + @override + Future?> loadContexts() => + channel.invokeMapMethod('loadContexts'); + + @override + Future beginNativeFrames() => channel.invokeMethod('beginNativeFrames'); @override Future endNativeFrames(SentryId id) async { - final json = await _channel.invokeMapMethod( + final json = await channel.invokeMapMethod( 'endNativeFrames', {'id': id.toString()}); return (json != null) ? NativeFrames.fromJson(json) : null; } @@ -83,7 +110,7 @@ class SentryNativeChannel implements SentryNativeBinding { final normalizedUser = user?.copyWith( data: MethodChannelHelper.normalizeMap(user.data), ); - await _channel.invokeMethod( + await channel.invokeMethod( 'setUser', {'user': normalizedUser?.toJson()}, ); @@ -94,42 +121,42 @@ class SentryNativeChannel implements SentryNativeBinding { final normalizedBreadcrumb = breadcrumb.copyWith( data: MethodChannelHelper.normalizeMap(breadcrumb.data), ); - await _channel.invokeMethod( + await channel.invokeMethod( 'addBreadcrumb', {'breadcrumb': normalizedBreadcrumb.toJson()}, ); } @override - Future clearBreadcrumbs() => _channel.invokeMethod('clearBreadcrumbs'); + Future clearBreadcrumbs() => channel.invokeMethod('clearBreadcrumbs'); @override - Future setContexts(String key, dynamic value) => _channel.invokeMethod( + Future setContexts(String key, dynamic value) => channel.invokeMethod( 'setContexts', {'key': key, 'value': MethodChannelHelper.normalize(value)}, ); @override Future removeContexts(String key) => - _channel.invokeMethod('removeContexts', {'key': key}); + channel.invokeMethod('removeContexts', {'key': key}); @override - Future setExtra(String key, dynamic value) => _channel.invokeMethod( + Future setExtra(String key, dynamic value) => channel.invokeMethod( 'setExtra', {'key': key, 'value': MethodChannelHelper.normalize(value)}, ); @override Future removeExtra(String key) => - _channel.invokeMethod('removeExtra', {'key': key}); + channel.invokeMethod('removeExtra', {'key': key}); @override Future setTag(String key, String value) => - _channel.invokeMethod('setTag', {'key': key, 'value': value}); + channel.invokeMethod('setTag', {'key': key, 'value': value}); @override Future removeTag(String key) => - _channel.invokeMethod('removeTag', {'key': key}); + channel.invokeMethod('removeTag', {'key': key}); @override int? startProfiler(SentryId traceId) => @@ -137,14 +164,46 @@ class SentryNativeChannel implements SentryNativeBinding { @override Future discardProfiler(SentryId traceId) => - _channel.invokeMethod('discardProfiler', traceId.toString()); + channel.invokeMethod('discardProfiler', traceId.toString()); @override Future?> collectProfile( SentryId traceId, int startTimeNs, int endTimeNs) => - _channel.invokeMapMethod('collectProfile', { + channel.invokeMapMethod('collectProfile', { 'traceId': traceId.toString(), 'startTime': startTimeNs, 'endTime': endTimeNs, }); + + @override + Future?> loadDebugImages() => + tryCatchAsync('loadDebugImages', () async { + final images = await channel + .invokeListMethod>('loadImageList'); + return images + ?.map((e) => e.cast()) + .map(DebugImage.fromJson) + .toList(); + }); + + @override + Future displayRefreshRate() => + channel.invokeMethod('displayRefreshRate'); + + @override + Future pauseAppHangTracking() => + channel.invokeMethod('pauseAppHangTracking'); + + @override + Future resumeAppHangTracking() => + channel.invokeMethod('resumeAppHangTracking'); + + @override + Future nativeCrash() => channel.invokeMethod('nativeCrash'); + + @override + Future captureReplay(bool isCrash) => + channel.invokeMethod('captureReplay', { + 'isCrash': isCrash, + }).then((value) => SentryId.fromId(value as String)); } diff --git a/flutter/lib/src/native/sentry_native_invoker.dart b/flutter/lib/src/native/sentry_native_invoker.dart new file mode 100644 index 0000000000..0c0a637601 --- /dev/null +++ b/flutter/lib/src/native/sentry_native_invoker.dart @@ -0,0 +1,45 @@ +import 'dart:async'; + +import 'package:meta/meta.dart'; + +import '../../sentry_flutter.dart'; + +/// Helper to safely invoke native methods. Any errors are logged and ignored. +@internal +mixin SentryNativeSafeInvoker { + SentryFlutterOptions get options; + + Future tryCatchAsync( + String nativeMethodName, Future Function() fn) async { + try { + return await fn(); + } catch (error, stackTrace) { + _logError(nativeMethodName, error, stackTrace); + if (options.automatedTestMode) { + rethrow; + } + return null; + } + } + + T? tryCatchSync(String nativeMethodName, T? Function() fn) { + try { + return fn(); + } catch (error, stackTrace) { + _logError(nativeMethodName, error, stackTrace); + 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, + ); + } +} diff --git a/flutter/lib/src/native/sentry_safe_method_channel.dart b/flutter/lib/src/native/sentry_safe_method_channel.dart new file mode 100644 index 0000000000..184843279d --- /dev/null +++ b/flutter/lib/src/native/sentry_safe_method_channel.dart @@ -0,0 +1,39 @@ +import 'dart:async'; + +import 'package:flutter/services.dart'; +import 'package:meta/meta.dart'; + +import '../../sentry_flutter.dart'; +import 'sentry_native_invoker.dart'; + +class SentrySafeMethodChannel with SentryNativeSafeInvoker { + @override + final SentryFlutterOptions options; + + final MethodChannel _channel; + + SentrySafeMethodChannel(this.options) : _channel = options.methodChannel; + + void setMethodCallHandler( + Future Function(MethodCall call)? handler) => + _channel.setMethodCallHandler(handler); + + @optionalTypeArgs + Future invokeMethod(String method, [dynamic args]) => + tryCatchAsync(method, () => _channel.invokeMethod(method, args)); + + Future?> invokeListMethod(String method, [dynamic args]) => + tryCatchAsync(method, () async { + // Note, we're not using channel.invokeListMethod because it would fail in tests due to the generated mock not doing a cast. + final result = await _channel.invokeMethod>(method, args); + return result?.cast(); + }); + + Future?> invokeMapMethod(String method, [dynamic args]) => + tryCatchAsync(method, () async { + // Note, we're not using channel.invokeMapMethod because it would fail in tests due to the generated mock not doing a cast. + final result = + await _channel.invokeMethod>(method, args); + return result?.cast(); + }); +} diff --git a/flutter/lib/src/navigation/sentry_navigator_observer.dart b/flutter/lib/src/navigation/sentry_navigator_observer.dart index fbfc12b6ee..0554c0ad2a 100644 --- a/flutter/lib/src/navigation/sentry_navigator_observer.dart +++ b/flutter/lib/src/navigation/sentry_navigator_observer.dart @@ -5,12 +5,12 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter/widgets.dart'; import 'package:meta/meta.dart'; -import '../integrations/integrations.dart'; +import '../native/native_frames.dart'; +import '../native/sentry_native_binding.dart'; import 'time_to_display_tracker.dart'; import '../../sentry_flutter.dart'; import '../event_processor/flutter_enricher_event_processor.dart'; -import '../native/sentry_native.dart'; // ignore: implementation_imports import 'package:sentry/src/sentry_tracer.dart'; @@ -79,12 +79,14 @@ class SentryNavigatorObserver extends RouteObserver> { RouteNameExtractor? routeNameExtractor, AdditionalInfoExtractor? additionalInfoProvider, @visibleForTesting TimeToDisplayTracker? timeToDisplayTracker, + List? ignoreRoutes, }) : _hub = hub ?? HubAdapter(), _enableAutoTransactions = enableAutoTransactions, _autoFinishAfter = autoFinishAfter, _setRouteNameAsTransaction = setRouteNameAsTransaction, _routeNameExtractor = routeNameExtractor, _additionalInfoProvider = additionalInfoProvider, + _ignoreRoutes = ignoreRoutes ?? [], _native = SentryFlutter.native { _isCreated = true; if (enableAutoTransactions) { @@ -111,7 +113,8 @@ class SentryNavigatorObserver extends RouteObserver> { final bool _setRouteNameAsTransaction; final RouteNameExtractor? _routeNameExtractor; final AdditionalInfoExtractor? _additionalInfoProvider; - final SentryNative? _native; + final SentryNativeBinding? _native; + final List _ignoreRoutes; static TimeToDisplayTracker? _timeToDisplayTracker; @internal @@ -140,6 +143,11 @@ class SentryNavigatorObserver extends RouteObserver> { void didPush(Route route, Route? previousRoute) { super.didPush(route, previousRoute); + if (_isRouteIgnored(route) || + previousRoute != null && _isRouteIgnored(previousRoute)) { + return; + } + _setCurrentRouteName(route); _setCurrentRouteNameAsTransaction(route); @@ -159,6 +167,11 @@ class SentryNavigatorObserver extends RouteObserver> { void didReplace({Route? newRoute, Route? oldRoute}) { super.didReplace(newRoute: newRoute, oldRoute: oldRoute); + if (newRoute != null && _isRouteIgnored(newRoute) || + oldRoute != null && _isRouteIgnored(oldRoute)) { + return; + } + _setCurrentRouteName(newRoute); _setCurrentRouteNameAsTransaction(newRoute); @@ -173,6 +186,11 @@ class SentryNavigatorObserver extends RouteObserver> { void didPop(Route route, Route? previousRoute) { super.didPop(route, previousRoute); + if (_isRouteIgnored(route) || + previousRoute != null && _isRouteIgnored(previousRoute)) { + return; + } + _setCurrentRouteName(previousRoute); _setCurrentRouteNameAsTransaction(previousRoute); @@ -225,13 +243,10 @@ class SentryNavigatorObserver extends RouteObserver> { String? name = _getRouteName(route); final arguments = route?.settings.arguments; - if (name == null) { + if (name == null || (name == '/')) { return; } - if (name == '/') { - name = rootScreenName; - } final transactionContext = SentryTransactionContext( name, SentrySpanOperations.uiLoad, @@ -247,8 +262,8 @@ class SentryNavigatorObserver extends RouteObserver> { trimEnd: true, onFinish: (transaction) async { _transaction = null; - final nativeFrames = await _native - ?.endNativeFramesCollection(transaction.context.traceId); + final nativeFrames = + await _native?.endNativeFrames(transaction.context.traceId); if (nativeFrames != null) { final measurements = nativeFrames.toMeasurements(); for (final item in measurements.entries) { @@ -277,7 +292,7 @@ class SentryNavigatorObserver extends RouteObserver> { scope.span ??= _transaction; }); - await _native?.beginNativeFramesCollection(); + await _native?.beginNativeFrames(); } Future _finishTimeToDisplayTracking({bool clearAfter = false}) async { @@ -312,6 +327,9 @@ class SentryNavigatorObserver extends RouteObserver> { exception: exception, stackTrace: stacktrace, ); + if (_hub.options.automatedTestMode) { + rethrow; + } } finally { await transaction?.finish(); if (clearAfter) { @@ -329,15 +347,6 @@ class SentryNavigatorObserver extends RouteObserver> { bool isAppStart = routeName == '/'; DateTime startTimestamp = _hub.options.clock(); - DateTime? endTimestamp; - - if (isAppStart) { - final appStartInfo = await NativeAppStartIntegration.getAppStartInfo(); - if (appStartInfo == null) return; - - startTimestamp = appStartInfo.start; - endTimestamp = appStartInfo.end; - } await _startTransaction(route, startTimestamp); @@ -346,12 +355,11 @@ class SentryNavigatorObserver extends RouteObserver> { return; } - if (isAppStart && endTimestamp != null) { - await _timeToDisplayTracker?.trackAppStartTTD(transaction, - startTimestamp: startTimestamp, endTimestamp: endTimestamp); - } else { - await _timeToDisplayTracker?.trackRegularRouteTTD(transaction, - startTimestamp: startTimestamp); + if (!isAppStart) { + await _timeToDisplayTracker?.trackRegularRouteTTD( + transaction, + startTimestamp: startTimestamp, + ); } } catch (exception, stacktrace) { _hub.options.logger( @@ -360,6 +368,9 @@ class SentryNavigatorObserver extends RouteObserver> { exception: exception, stackTrace: stacktrace, ); + if (_hub.options.automatedTestMode) { + rethrow; + } } finally { _clear(); } @@ -373,8 +384,10 @@ class SentryNavigatorObserver extends RouteObserver> { _timeToDisplayTracker?.clear(); } - @internal - static const String rootScreenName = 'root /'; + bool _isRouteIgnored(Route route) { + return _ignoreRoutes.isNotEmpty && + _ignoreRoutes.contains(_getRouteName(route)); + } } /// This class makes it easier to record breadcrumbs for events of Flutters diff --git a/flutter/lib/src/navigation/time_to_display_tracker.dart b/flutter/lib/src/navigation/time_to_display_tracker.dart index 342e305c75..a2c1813317 100644 --- a/flutter/lib/src/navigation/time_to_display_tracker.dart +++ b/flutter/lib/src/navigation/time_to_display_tracker.dart @@ -21,15 +21,6 @@ class TimeToDisplayTracker { ? ttfdTracker ?? TimeToFullDisplayTracker() : null; - Future trackAppStartTTD(ISentrySpan transaction, - {required DateTime startTimestamp, - required DateTime endTimestamp}) async { - // We start and immediately finish the spans since we cannot mutate the history of spans. - await _ttidTracker.trackAppStart(transaction, - startTimestamp: startTimestamp, endTimestamp: endTimestamp); - await _trackTTFDIfEnabled(transaction, startTimestamp); - } - Future trackRegularRouteTTD(ISentrySpan transaction, {required DateTime startTimestamp}) async { await _ttidTracker.trackRegularRoute(transaction, startTimestamp); diff --git a/flutter/lib/src/navigation/time_to_initial_display_tracker.dart b/flutter/lib/src/navigation/time_to_initial_display_tracker.dart index ce2f7b9e9c..051d0602b2 100644 --- a/flutter/lib/src/navigation/time_to_initial_display_tracker.dart +++ b/flutter/lib/src/navigation/time_to_initial_display_tracker.dart @@ -45,20 +45,6 @@ class TimeToInitialDisplayTracker { ); } - Future trackAppStart(ISentrySpan transaction, - {required DateTime startTimestamp, - required DateTime endTimestamp}) async { - await _trackTimeToInitialDisplay( - transaction: transaction, - startTimestamp: startTimestamp, - endTimestamp: endTimestamp, - origin: SentryTraceOrigins.autoUiTimeToDisplay, - ); - - // Store the end timestamp for potential use by TTFD tracking - _endTimestamp = endTimestamp; - } - Future _trackTimeToInitialDisplay({ required ISentrySpan transaction, required DateTime startTimestamp, diff --git a/flutter/lib/src/profiling.dart b/flutter/lib/src/profiling.dart index a4332d77e7..3fb22a9b75 100644 --- a/flutter/lib/src/profiling.dart +++ b/flutter/lib/src/profiling.dart @@ -8,16 +8,16 @@ import 'package:sentry/src/sentry_envelope_item_header.dart'; import 'package:sentry/src/sentry_item_type.dart'; import '../sentry_flutter.dart'; -import 'native/sentry_native.dart'; +import 'native/sentry_native_binding.dart'; // ignore: invalid_use_of_internal_member class SentryNativeProfilerFactory implements SentryProfilerFactory { - final SentryNative _native; + final SentryNativeBinding _native; final ClockProvider _clock; SentryNativeProfilerFactory(this._native, this._clock); - static void attachTo(Hub hub, SentryNative native) { + static void attachTo(Hub hub, SentryNativeBinding native) { // ignore: invalid_use_of_internal_member final options = hub.options; @@ -53,7 +53,7 @@ class SentryNativeProfilerFactory implements SentryProfilerFactory { // ignore: invalid_use_of_internal_member class SentryNativeProfiler implements SentryProfiler { - final SentryNative _native; + final SentryNativeBinding _native; final int _starTimeNs; final SentryId _traceId; bool _finished = false; diff --git a/flutter/lib/src/renderer/renderer.dart b/flutter/lib/src/renderer/renderer.dart index 3e41eced70..dc9d81276b 100644 --- a/flutter/lib/src/renderer/renderer.dart +++ b/flutter/lib/src/renderer/renderer.dart @@ -2,6 +2,7 @@ import 'package:meta/meta.dart'; import 'unknown_renderer.dart' if (dart.library.html) 'html_renderer.dart' + if (dart.library.js_interop) 'web_renderer.dart' if (dart.library.io) 'io_renderer.dart' as implementation; @internal diff --git a/flutter/lib/src/renderer/web_renderer.dart b/flutter/lib/src/renderer/web_renderer.dart new file mode 100644 index 0000000000..6baa3ca8b4 --- /dev/null +++ b/flutter/lib/src/renderer/web_renderer.dart @@ -0,0 +1,18 @@ +import 'dart:js_interop'; + +import 'renderer.dart'; + +FlutterRenderer? getRenderer() { + return isCanvasKitRenderer ? FlutterRenderer.canvasKit : FlutterRenderer.html; +} + +bool get isCanvasKitRenderer { + return _windowFlutterCanvasKit != null; +} + +// These values are set by the engine. They are used to determine if the +// application is using canvaskit or skwasm. +// +// See https://github.com/flutter/flutter/blob/414d9238720a3cde85475f49ce0ba313f95046f7/packages/flutter/lib/src/foundation/_capabilities_web.dart#L10 +@JS('window.flutterCanvasKit') +external JSAny? get _windowFlutterCanvasKit; diff --git a/flutter/lib/src/replay/recorder.dart b/flutter/lib/src/replay/recorder.dart new file mode 100644 index 0000000000..847c3a75f6 --- /dev/null +++ b/flutter/lib/src/replay/recorder.dart @@ -0,0 +1,119 @@ +import 'dart:async'; +import 'dart:ui'; + +import 'package:flutter/rendering.dart'; +import 'package:meta/meta.dart'; + +import '../../sentry_flutter.dart'; +import 'recorder_config.dart'; +import 'widget_filter.dart'; + +@internal +typedef ScreenshotRecorderCallback = Future Function(Image); + +@internal +class ScreenshotRecorder { + @protected + final ScreenshotRecorderConfig config; + @protected + final SentryFlutterOptions options; + WidgetFilter? _widgetFilter; + bool warningLogged = false; + + ScreenshotRecorder(this.config, this.options) { + final replayOptions = options.experimental.replay; + if (replayOptions.redactAllText || replayOptions.redactAllImages) { + _widgetFilter = WidgetFilter( + redactText: replayOptions.redactAllText, + redactImages: replayOptions.redactAllImages, + logger: options.logger); + } + } + + Future capture(ScreenshotRecorderCallback callback) async { + final context = sentryScreenshotWidgetGlobalKey.currentContext; + final renderObject = context?.findRenderObject() as RenderRepaintBoundary?; + if (context == null || renderObject == null) { + if (!warningLogged) { + options.logger( + SentryLevel.warning, + "Replay: SentryScreenshotWidget is not attached. " + "Skipping replay capture."); + warningLogged = true; + } + return; + } + + try { + final watch = Stopwatch()..start(); + + // On Android, the desired resolution (coming from the configuration) + // is rounded to next multitude of 16 . Therefore, we scale the image. + // On iOS, the screenshot resolution is not adjusted. + final srcWidth = renderObject.size.width; + final srcHeight = renderObject.size.height; + final pixelRatio = config.getPixelRatio(srcWidth, srcHeight); + + // First, we synchronously capture the image and enumerate widgets on the main UI loop. + final futureImage = renderObject.toImage(pixelRatio: pixelRatio); + + final filter = _widgetFilter; + if (filter != null) { + filter.obscure( + context, + pixelRatio, + Rect.fromLTWH(0, 0, srcWidth * pixelRatio, srcHeight * pixelRatio), + ); + } + + final blockingTime = watch.elapsedMilliseconds; + + // Then we draw the image and obscure collected coordinates asynchronously. + final recorder = PictureRecorder(); + final canvas = Canvas(recorder); + final image = await futureImage; + try { + canvas.drawImage(image, Offset.zero, Paint()); + } finally { + image.dispose(); + } + + if (filter != null) { + _obscureWidgets(canvas, filter.items); + } + + final picture = recorder.endRecording(); + + try { + final finalImage = await picture.toImage( + (srcWidth * pixelRatio).round(), (srcHeight * pixelRatio).round()); + try { + await callback(finalImage); + } finally { + finalImage.dispose(); + } + } finally { + picture.dispose(); + } + + options.logger( + SentryLevel.debug, + "Replay: captured a screenshot in ${watch.elapsedMilliseconds}" + " ms ($blockingTime ms blocking)."); + } catch (e, stackTrace) { + options.logger(SentryLevel.error, "Replay: failed to capture screenshot.", + exception: e, stackTrace: stackTrace); + if (options.automatedTestMode) { + rethrow; + } + } + } + + void _obscureWidgets(Canvas canvas, List items) { + final paint = Paint()..style = PaintingStyle.fill; + for (var item in items) { + paint.color = item.color; + canvas.drawRect(item.bounds, paint); + } + } +} diff --git a/flutter/lib/src/replay/recorder_config.dart b/flutter/lib/src/replay/recorder_config.dart new file mode 100644 index 0000000000..9649a33823 --- /dev/null +++ b/flutter/lib/src/replay/recorder_config.dart @@ -0,0 +1,29 @@ +import 'dart:math'; + +import 'package:meta/meta.dart'; + +@internal +class ScreenshotRecorderConfig { + final int? width; + final int? height; + + const ScreenshotRecorderConfig({this.width, this.height}); + + double getPixelRatio(double srcWidth, double srcHeight) { + assert((width == null) == (height == null)); + if (width == null || height == null) { + return 1.0; + } + return min(width! / srcWidth, height! / srcHeight); + } +} + +class ScheduledScreenshotRecorderConfig extends ScreenshotRecorderConfig { + final int frameRate; + + const ScheduledScreenshotRecorderConfig({ + super.width, + super.height, + required this.frameRate, + }); +} diff --git a/flutter/lib/src/replay/scheduled_recorder.dart b/flutter/lib/src/replay/scheduled_recorder.dart new file mode 100644 index 0000000000..c575278a74 --- /dev/null +++ b/flutter/lib/src/replay/scheduled_recorder.dart @@ -0,0 +1,40 @@ +import 'dart:async'; +import 'dart:ui'; + +import 'package:meta/meta.dart'; + +import '../../sentry_flutter.dart'; +import 'recorder.dart'; +import 'recorder_config.dart'; +import 'scheduler.dart'; + +@internal +typedef ScreenshotRecorderCallback = Future Function(Image); + +@internal +class ScheduledScreenshotRecorder extends ScreenshotRecorder { + late final Scheduler _scheduler; + final ScreenshotRecorderCallback _callback; + + ScheduledScreenshotRecorder(ScheduledScreenshotRecorderConfig config, + this._callback, SentryFlutterOptions options) + : super(config, options) { + assert(config.frameRate > 0); + final frameDuration = Duration(milliseconds: 1000 ~/ config.frameRate); + _scheduler = Scheduler(frameDuration, _capture, + options.bindingUtils.instance!.addPostFrameCallback); + } + + void start() { + options.logger(SentryLevel.debug, "Replay: starting replay capture."); + _scheduler.start(); + } + + Future stop() async { + await _scheduler.stop(); + options.logger(SentryLevel.debug, "Replay: replay capture stopped."); + } + + Future _capture(Duration sinceSchedulerEpoch) async => + capture(_callback); +} diff --git a/flutter/lib/src/replay/scheduler.dart b/flutter/lib/src/replay/scheduler.dart new file mode 100644 index 0000000000..4d246360e3 --- /dev/null +++ b/flutter/lib/src/replay/scheduler.dart @@ -0,0 +1,55 @@ +import 'package:flutter/scheduler.dart'; +import 'package:meta/meta.dart'; + +@internal +typedef SchedulerCallback = Future Function(Duration); + +/// This is a low-priority scheduler. +/// We're not using Timer.periodic() because it may schedule a callback +/// even if the previous call hasn't finished (or started) yet. +/// Instead, we manually schedule a callback with a given delay after the +/// previous callback finished. Therefore, if the capture takes too long, we +/// won't overload the system. We sacrifice the frame rate for performance. +@internal +class Scheduler { + final SchedulerCallback _callback; + final Duration _interval; + bool _running = false; + Future? _scheduled; + + final void Function(FrameCallback callback) _addPostFrameCallback; + + Scheduler(this._interval, this._callback, this._addPostFrameCallback); + + void start() { + _running = true; + if (_scheduled == null) { + _runAfterNextFrame(); + } + } + + Future stop() async { + _running = false; + final scheduled = _scheduled; + _scheduled = null; + if (scheduled != null) { + await scheduled; + } + } + + @pragma('vm:prefer-inline') + void _scheduleNext() { + _scheduled ??= Future.delayed(_interval, _runAfterNextFrame); + } + + @pragma('vm:prefer-inline') + void _runAfterNextFrame() { + _scheduled = null; + _addPostFrameCallback(_run); + } + + void _run(Duration sinceSchedulerEpoch) { + if (!_running) return; + _callback(sinceSchedulerEpoch).then((_) => _scheduleNext()); + } +} diff --git a/flutter/lib/src/replay/widget_filter.dart b/flutter/lib/src/replay/widget_filter.dart new file mode 100644 index 0000000000..1f66a42f1a --- /dev/null +++ b/flutter/lib/src/replay/widget_filter.dart @@ -0,0 +1,173 @@ +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:meta/meta.dart'; + +import '../../sentry_flutter.dart'; +import '../sentry_asset_bundle.dart'; + +@internal +class WidgetFilter { + final items = []; + final SentryLogger logger; + final bool redactText; + final bool redactImages; + static const _defaultColor = Color.fromARGB(255, 0, 0, 0); + late double _pixelRatio; + late Rect _bounds; + final _warnedWidgets = {}; + final AssetBundle _rootAssetBundle; + + WidgetFilter( + {required this.redactText, + required this.redactImages, + required this.logger, + @visibleForTesting AssetBundle? rootAssetBundle}) + : _rootAssetBundle = rootAssetBundle ?? rootBundle; + + void obscure(BuildContext context, double pixelRatio, Rect bounds) { + _pixelRatio = pixelRatio; + _bounds = bounds; + items.clear(); + if (context is Element) { + _obscure(context); + } else { + context.visitChildElements(_obscure); + } + } + + void _obscure(Element element) { + final widget = element.widget; + + if (!_isVisible(widget)) { + assert(() { + logger(SentryLevel.debug, "WidgetFilter skipping invisible: $widget"); + return true; + }()); + return; + } + + final obscured = _obscureIfNeeded(element, widget); + if (!obscured) { + element.visitChildElements(_obscure); + } + } + + @pragma('vm:prefer-inline') + bool _obscureIfNeeded(Element element, Widget widget) { + Color? color; + + if (redactText && widget is Text) { + color = widget.style?.color; + } else if (redactText && widget is EditableText) { + color = widget.style.color; + } else if (redactImages && widget is Image) { + if (widget.image is AssetBundleImageProvider) { + final image = widget.image as AssetBundleImageProvider; + if (isBuiltInAssetImage(image)) { + logger(SentryLevel.debug, + "WidgetFilter skipping asset: $widget ($image)."); + return false; + } + } + color = widget.color; + } else { + // No other type is currently obscured. + return false; + } + + final renderObject = element.renderObject; + if (renderObject is! RenderBox) { + _cantObscure(widget, "its renderObject is not a RenderBox"); + return false; + } + + var rect = _boundingBox(renderObject); + + // If it's a clipped render object, use parent's offset and size. + // This helps with text fields which often have oversized render objects. + if (renderObject.parent is RenderStack) { + final renderStack = (renderObject.parent as RenderStack); + final clipBehavior = renderStack.clipBehavior; + if (clipBehavior == Clip.hardEdge || + clipBehavior == Clip.antiAlias || + clipBehavior == Clip.antiAliasWithSaveLayer) { + final clipRect = _boundingBox(renderStack); + rect = rect.intersect(clipRect); + } + } + + if (!rect.overlaps(_bounds)) { + assert(() { + logger(SentryLevel.debug, "WidgetFilter skipping offscreen: $widget"); + return true; + }()); + return false; + } + + items.add(WidgetFilterItem(color ?? _defaultColor, rect)); + assert(() { + logger(SentryLevel.debug, "WidgetFilter obscuring: $widget"); + return true; + }()); + + return true; + } + + // We cut off some widgets early because they're not visible at all. + bool _isVisible(Widget widget) { + if (widget is Visibility) { + return widget.visible; + } + if (widget is Opacity) { + return widget.opacity > 0; + } + if (widget is Offstage) { + return !widget.offstage; + } + return true; + } + + @visibleForTesting + @pragma('vm:prefer-inline') + bool isBuiltInAssetImage(AssetBundleImageProvider image) { + late final AssetBundle? bundle; + if (image is AssetImage) { + bundle = image.bundle; + } else if (image is ExactAssetImage) { + bundle = image.bundle; + } else { + return false; + } + return (bundle == null || + bundle == _rootAssetBundle || + (bundle is SentryAssetBundle && bundle.bundle == _rootAssetBundle)); + } + + @pragma('vm:prefer-inline') + void _cantObscure(Widget widget, String message) { + if (!_warnedWidgets.contains(widget.hashCode)) { + _warnedWidgets.add(widget.hashCode); + logger(SentryLevel.warning, + "WidgetFilter cannot obscure widget $widget: $message"); + } + } + + @pragma('vm:prefer-inline') + Rect _boundingBox(RenderBox box) { + final offset = box.localToGlobal(Offset.zero); + return Rect.fromLTWH( + offset.dx * _pixelRatio, + offset.dy * _pixelRatio, + box.size.width * _pixelRatio, + box.size.height * _pixelRatio, + ); + } +} + +class WidgetFilterItem { + final Color color; + final Rect bounds; + + const WidgetFilterItem(this.color, this.bounds); +} diff --git a/flutter/lib/src/screenshot/sentry_screenshot_widget.dart b/flutter/lib/src/screenshot/sentry_screenshot_widget.dart index e83d46d0c5..6eafb935a5 100644 --- a/flutter/lib/src/screenshot/sentry_screenshot_widget.dart +++ b/flutter/lib/src/screenshot/sentry_screenshot_widget.dart @@ -1,8 +1,6 @@ import 'package:flutter/material.dart'; import 'package:meta/meta.dart'; -import '../../sentry_flutter.dart'; - /// Key which is used to identify the [RepaintBoundary] @internal final sentryScreenshotWidgetGlobalKey = @@ -25,36 +23,19 @@ final sentryScreenshotWidgetGlobalKey = /// times. class SentryScreenshotWidget extends StatefulWidget { final Widget child; - late final Hub _hub; - - SentryFlutterOptions? get _options => - // ignore: invalid_use_of_internal_member - _hub.options is SentryFlutterOptions - // ignore: invalid_use_of_internal_member - ? _hub.options as SentryFlutterOptions - : null; - SentryScreenshotWidget({ - super.key, - required this.child, - @internal Hub? hub, - }) : _hub = hub ?? HubAdapter(); + const SentryScreenshotWidget({super.key, required this.child}); @override _SentryScreenshotWidgetState createState() => _SentryScreenshotWidgetState(); } class _SentryScreenshotWidgetState extends State { - SentryFlutterOptions? get _options => widget._options; - @override Widget build(BuildContext context) { - if (_options?.attachScreenshot ?? false) { - return RepaintBoundary( - key: sentryScreenshotWidgetGlobalKey, - child: widget.child, - ); - } - return widget.child; + return RepaintBoundary( + key: sentryScreenshotWidgetGlobalKey, + child: widget.child, + ); } } diff --git a/flutter/lib/src/sentry_asset_bundle.dart b/flutter/lib/src/sentry_asset_bundle.dart index 52d1a2da0c..a41288209f 100644 --- a/flutter/lib/src/sentry_asset_bundle.dart +++ b/flutter/lib/src/sentry_asset_bundle.dart @@ -7,6 +7,7 @@ import 'dart:ui'; import 'package:flutter/services.dart'; import 'package:flutter/material.dart'; +import 'package:meta/meta.dart'; import 'package:sentry/sentry.dart'; typedef _StringParser = Future Function(String value); @@ -375,3 +376,9 @@ class SentryAssetBundle implements AssetBundle { as Future; } } + +@internal +extension SentryAssetBundleInternal on SentryAssetBundle { + /// Returns the wrapped [AssetBundle]. + AssetBundle get bundle => _bundle; +} diff --git a/flutter/lib/src/sentry_flutter.dart b/flutter/lib/src/sentry_flutter.dart index e0d4415de5..e190115149 100644 --- a/flutter/lib/src/sentry_flutter.dart +++ b/flutter/lib/src/sentry_flutter.dart @@ -1,28 +1,29 @@ import 'dart:async'; import 'dart:ui'; -import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; import 'package:meta/meta.dart'; + import '../sentry_flutter.dart'; import 'event_processor/android_platform_exception_event_processor.dart'; +import 'event_processor/flutter_enricher_event_processor.dart'; import 'event_processor/flutter_exception_event_processor.dart'; import 'event_processor/platform_exception_event_processor.dart'; +import 'event_processor/url_filter/url_filter_event_processor.dart'; import 'event_processor/widget_event_processor.dart'; +import 'file_system_transport.dart'; +import 'flutter_exception_type_identifier.dart'; import 'frame_callback_handler.dart'; import 'integrations/connectivity/connectivity_integration.dart'; +import 'integrations/integrations.dart'; +import 'integrations/native_app_start_handler.dart'; import 'integrations/screenshot_integration.dart'; import 'native/factory.dart'; import 'native/native_scope_observer.dart'; +import 'native/sentry_native_binding.dart'; import 'profiling.dart'; import 'renderer/renderer.dart'; -import 'native/sentry_native.dart'; - -import 'integrations/integrations.dart'; -import 'event_processor/flutter_enricher_event_processor.dart'; - -import 'file_system_transport.dart'; - +import 'span_frame_metrics_collector.dart'; import 'version.dart'; import 'view_hierarchy/view_hierarchy_integration.dart'; @@ -32,35 +33,34 @@ typedef FlutterOptionsConfiguration = FutureOr Function( /// Sentry Flutter SDK main entry point mixin SentryFlutter { - static const _channel = MethodChannel('sentry_flutter'); - /// Represents the time when the Sentry init set up has started. @internal // ignore: invalid_use_of_internal_member static DateTime? sentrySetupStartTime; + /// Initializes the Sentry Flutter SDK. + /// + /// Unlike [Sentry.init], this method creates the Flutter default integrations. + /// + /// [optionsConfiguration] is a callback that allows you to configure the Sentry + /// options. The [SentryFlutterOptions] should not be adjusted anywhere else than + /// during [init], so that's why they're not directly exposed outside of this method. + /// + /// You can use the static members of [Sentry] from within other packages without the + /// need of initializing it in the package; as long as they have been already properly + /// initialized in the application package. static Future init( FlutterOptionsConfiguration optionsConfiguration, { AppRunner? appRunner, - @internal MethodChannel channel = _channel, - @internal PlatformChecker? platformChecker, - @internal RendererWrapper? rendererWrapper, + @internal SentryFlutterOptions? options, }) async { - final flutterOptions = SentryFlutterOptions(); + options ??= SentryFlutterOptions(); // ignore: invalid_use_of_internal_member - sentrySetupStartTime ??= flutterOptions.clock(); + sentrySetupStartTime ??= options.clock(); - if (platformChecker != null) { - flutterOptions.platformChecker = platformChecker; - } - if (rendererWrapper != null) { - flutterOptions.rendererWrapper = rendererWrapper; - } - - if (flutterOptions.platformChecker.hasNativeIntegration) { - final binding = createBinding(flutterOptions.platformChecker, channel); - _native = SentryNative(flutterOptions, binding); + if (options.platformChecker.hasNativeIntegration) { + _native = createBinding(options); } final platformDispatcher = PlatformDispatcher.instance; @@ -69,32 +69,31 @@ mixin SentryFlutter { // Flutter Web don't capture [Future] errors if using [PlatformDispatcher.onError] and not // the [runZonedGuarded]. // likely due to https://github.com/flutter/flutter/issues/100277 - final isOnErrorSupported = flutterOptions.platformChecker.isWeb + final isOnErrorSupported = options.platformChecker.isWeb ? false - : wrapper.isOnErrorSupported(flutterOptions); + : wrapper.isOnErrorSupported(options); - final runZonedGuardedOnError = flutterOptions.platformChecker.isWeb - ? _createRunZonedGuardedOnError() - : null; + final runZonedGuardedOnError = + options.platformChecker.isWeb ? _createRunZonedGuardedOnError() : null; // first step is to install the native integration and set default values, // so we are able to capture future errors. - final defaultIntegrations = _createDefaultIntegrations( - channel, - flutterOptions, - isOnErrorSupported, - ); + final defaultIntegrations = + _createDefaultIntegrations(options, isOnErrorSupported); for (final defaultIntegration in defaultIntegrations) { - flutterOptions.addIntegration(defaultIntegration); + options.addIntegration(defaultIntegration); } - await _initDefaultValues(flutterOptions, channel); + await _initDefaultValues(options); await Sentry.init( - (options) => optionsConfiguration(options as SentryFlutterOptions), + (o) { + assert(options == o); + return optionsConfiguration(o as SentryFlutterOptions); + }, appRunner: appRunner, // ignore: invalid_use_of_internal_member - options: flutterOptions, + options: options, // ignore: invalid_use_of_internal_member callAppRunnerInRunZonedGuarded: !isOnErrorSupported, // ignore: invalid_use_of_internal_member @@ -105,22 +104,24 @@ mixin SentryFlutter { // ignore: invalid_use_of_internal_member SentryNativeProfilerFactory.attachTo(Sentry.currentHub, _native!); } + + // Insert it at the start of the list, before the Dart Exceptions that are set in Sentry.init + // so we can identify Flutter exceptions first. + options.prependExceptionTypeIdentifier(FlutterExceptionTypeIdentifier()); } - static Future _initDefaultValues( - SentryFlutterOptions options, - MethodChannel channel, - ) async { + static Future _initDefaultValues(SentryFlutterOptions options) async { options.addEventProcessor(FlutterExceptionEventProcessor()); // Not all platforms have a native integration. if (_native != null) { - options.transport = FileSystemTransport(channel, options); + options.transport = FileSystemTransport(_native!, options); options.addScopeObserver(NativeScopeObserver(_native!)); } options.addEventProcessor(FlutterEnricherEventProcessor(options)); options.addEventProcessor(WidgetEventProcessor()); + options.addEventProcessor(UrlFilterEventProcessor(options)); if (options.platformChecker.platform.isAndroid) { options.addEventProcessor( @@ -130,19 +131,24 @@ mixin SentryFlutter { options.addEventProcessor(PlatformExceptionEventProcessor()); + // Disabled for web, linux and windows until we can reliably get the display refresh rate + if (options.platformChecker.platform.isAndroid || + options.platformChecker.platform.isIOS || + options.platformChecker.platform.isMacOS) { + options.addPerformanceCollector(SpanFrameMetricsCollector(options)); + } + _setSdk(options); } /// Install default integrations /// https://medium.com/flutter-community/error-handling-in-flutter-98fce88a34f0 static List _createDefaultIntegrations( - MethodChannel channel, SentryFlutterOptions options, bool isOnErrorSupported, ) { final integrations = []; final platformChecker = options.platformChecker; - final platform = platformChecker.platform; // Will call WidgetsFlutterBinding.ensureInitialized() before all other integrations. integrations.add(WidgetsFlutterBindingIntegration()); @@ -161,22 +167,14 @@ mixin SentryFlutter { // The ordering here matters, as we'd like to first start the native integration. // That allow us to send events to the network and then the Flutter integrations. // Flutter Web doesn't need that, only Android and iOS. - if (_native != null) { - integrations.add(NativeSdkIntegration(_native!)); + final native = _native; + if (native != null) { + integrations.add(NativeSdkIntegration(native)); + integrations.add(LoadContextsIntegration(native)); + integrations.add(LoadImageListIntegration(native)); + options.enableDartSymbolication = false; } - // Will enrich events with device context, native packages and integrations - if (platformChecker.hasNativeIntegration && - !platformChecker.isWeb && - (platform.isIOS || platform.isMacOS || platform.isAndroid)) { - integrations.add(LoadContextsIntegration(channel)); - } - - if (platformChecker.hasNativeIntegration && - !platformChecker.isWeb && - (platform.isAndroid || platform.isIOS || platform.isMacOS)) { - integrations.add(LoadImageListIntegration(channel)); - } final renderer = options.rendererWrapper.getRenderer(); if (!platformChecker.isWeb || renderer == FlutterRenderer.canvasKit) { integrations.add(ScreenshotIntegration()); @@ -196,11 +194,13 @@ mixin SentryFlutter { // in errors. integrations.add(LoadReleaseIntegration()); - if (_native != null) { - integrations.add(NativeAppStartIntegration( - _native!, - DefaultFrameCallbackHandler(), - )); + if (native != null) { + integrations.add( + NativeAppStartIntegration( + DefaultFrameCallbackHandler(), + NativeAppStartHandler(native), + ), + ); } return integrations; } @@ -216,9 +216,15 @@ mixin SentryFlutter { } /// Manually set when your app finished startup. Make sure to set - /// [SentryFlutterOptions.autoAppStart] to false on init. + /// [SentryFlutterOptions.autoAppStart] to false on init. The timeout duration + /// for this to work is 10 seconds. static void setAppStartEnd(DateTime appStartEnd) { - _native?.appStartEnd = appStartEnd; + // ignore: invalid_use_of_internal_member + final integrations = Sentry.currentHub.options.integrations + .whereType(); + for (final integration in integrations) { + integration.appStartEnd = appStartEnd; + } } static void _setSdk(SentryFlutterOptions options) { @@ -239,10 +245,50 @@ mixin SentryFlutter { return SentryNavigatorObserver.timeToDisplayTracker?.reportFullyDisplayed(); } + /// Pauses the app hang tracking. + /// Only for iOS and macOS. + static Future pauseAppHangTracking() { + if (_native == null) { + _logNativeIntegrationNotAvailable("pauseAppHangTracking"); + return Future.value(); + } + return _native!.pauseAppHangTracking(); + } + + /// Resumes the app hang tracking. + /// Only for iOS and macOS + static Future resumeAppHangTracking() { + if (_native == null) { + _logNativeIntegrationNotAvailable("resumeAppHangTracking"); + return Future.value(); + } + return _native!.resumeAppHangTracking(); + } + @internal - static SentryNative? get native => _native; + static SentryNativeBinding? get native => _native; @internal - static set native(SentryNative? value) => _native = value; - static SentryNative? _native; + static set native(SentryNativeBinding? value) => _native = value; + + static SentryNativeBinding? _native; + + /// Use `nativeCrash()` to crash the native implementation and test/debug the crash reporting for native code. + /// This should not be used in production code. + /// Only for Android, iOS and macOS + static Future nativeCrash() { + if (_native == null) { + _logNativeIntegrationNotAvailable("nativeCrash"); + return Future.value(); + } + return _native!.nativeCrash(); + } + + static void _logNativeIntegrationNotAvailable(String methodName) { + // ignore: invalid_use_of_internal_member + Sentry.currentHub.options.logger( + SentryLevel.debug, + 'Native integration is not available. Make sure SentryFlutter is initialized before accessing the $methodName API.', + ); + } } diff --git a/flutter/lib/src/sentry_flutter_options.dart b/flutter/lib/src/sentry_flutter_options.dart index ae83de611e..8e533e4384 100644 --- a/flutter/lib/src/sentry_flutter_options.dart +++ b/flutter/lib/src/sentry_flutter_options.dart @@ -1,6 +1,9 @@ import 'dart:async'; -import 'package:meta/meta.dart'; +import 'package:file/file.dart'; +import 'package:file/local.dart'; +import 'package:flutter/services.dart'; +import 'package:meta/meta.dart' as meta; import 'package:sentry/sentry.dart'; import 'package:flutter/widgets.dart'; @@ -10,6 +13,7 @@ import 'screenshot/sentry_screenshot_quality.dart'; import 'event_processor/screenshot_event_processor.dart'; import 'screenshot/sentry_screenshot_widget.dart'; import 'sentry_flutter.dart'; +import 'sentry_replay_options.dart'; import 'user_interaction/sentry_user_interaction_widget.dart'; /// This class adds options which are only available in a Flutter environment. @@ -143,6 +147,21 @@ class SentryFlutterOptions extends SentryOptions { /// See https://api.flutter.dev/flutter/foundation/FlutterErrorDetails/silent.html bool reportSilentFlutterErrors = false; + /// (Web only) Events only occurring on these Urls will be handled and sent to sentry. + /// If an empty list is used, the SDK will send all errors. + /// `allowUrls` uses regex for the matching. + /// + /// If used on a platform other than Web, this setting will be ignored. + List allowUrls = []; + + /// (Web only) Events occurring on these Urls will be ignored and are not sent to sentry. + /// If an empty list is used, the SDK will send all errors. + /// `denyUrls` uses regex for the matching. + /// In combination with `allowUrls` you can block subdomains of the domains listed in `allowUrls`. + /// + /// If used on a platform other than Web, this setting will be ignored. + List denyUrls = []; + /// Enables Out of Memory Tracking for iOS and macCatalyst. /// See the following link for more information and possible restrictions: /// https://docs.sentry.io/platforms/apple/guides/ios/configuration/out-of-memory/ @@ -184,14 +203,14 @@ class SentryFlutterOptions extends SentryOptions { /// /// Requires adding the [SentryUserInteractionWidget] to the widget tree. /// Example: - /// runApp(SentryUserInteractionWidget(child: App())); + /// runApp(SentryWidget(child: App())); bool enableUserInteractionBreadcrumbs = true; /// Enables the Auto instrumentation for user interaction tracing. /// /// Requires adding the [SentryUserInteractionWidget] to the widget tree. /// Example: - /// runApp(SentryUserInteractionWidget(child: App())); + /// runApp(SentryWidget(child: App())); bool enableUserInteractionTracing = true; /// Enable or disable the tracing of time to full display (TTFD). @@ -203,16 +222,27 @@ class SentryFlutterOptions extends SentryOptions { /// Sets the Proguard uuid for Android platform. String? proguardUuid; - @internal + @meta.internal late RendererWrapper rendererWrapper = RendererWrapper(); + @meta.internal + late MethodChannel methodChannel = const MethodChannel('sentry_flutter'); + /// Enables the View Hierarchy feature. /// /// Renders an ASCII represention of the entire view hierarchy of the /// application when an error happens and includes it as an attachment. - @experimental + @meta.experimental bool attachViewHierarchy = false; + /// Enables collection of view hierarchy element identifiers. + /// + /// Identifiers are extracted from widget keys. + /// Disable this flag if your widget keys contain sensitive data. + /// + /// Default: `true` + bool reportViewHierarchyIdentifiers = true; + /// When enabled, the SDK tracks when the application stops responding for a /// specific amount of time, See [appHangTimeoutInterval]. /// Only available on iOS and macOS. @@ -232,6 +262,20 @@ class SentryFlutterOptions extends SentryOptions { /// Read timeout. This will only be synced to the Android native SDK. Duration readTimeout = Duration(seconds: 5); + /// Enable or disable Frames Tracking, which is used to report frame information + /// for every [ISentrySpan]. + /// + /// When enabled, the following metrics are reported for each span: + /// - Slow frames: The number of frames that exceeded a specified threshold for frame duration. + /// - Frozen frames: The number of frames that took an unusually long time to render, indicating a potential freeze or hang. + /// - Total frames count: The total number of frames rendered during the span. + /// - Frames delay: The delayed frame render duration of all frames. + + /// Read more about frames tracking here: https://develop.sentry.dev/sdk/performance/frames-delay/ + /// + /// Defaults to `true` + bool enableFramesTracking = true; + /// By using this, you are disabling native [Breadcrumb] tracking and instead /// you are just tracking [Breadcrumb]s which result from events available /// in the current Flutter environment. @@ -280,14 +324,14 @@ class SentryFlutterOptions extends SentryOptions { } /// Setting this to a custom [BindingWrapper] allows you to use a custom [WidgetsBinding]. - @experimental + @meta.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 + @meta.experimental double? get profilesSampleRate { // ignore: invalid_use_of_internal_member return super.profilesSampleRate; @@ -297,7 +341,7 @@ class SentryFlutterOptions extends SentryOptions { /// 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 + @meta.experimental set profilesSampleRate(double? value) { // ignore: invalid_use_of_internal_member super.profilesSampleRate = value; @@ -305,6 +349,31 @@ class SentryFlutterOptions extends SentryOptions { /// The [navigatorKey] is used to add information of the currently used locale to the contexts. GlobalKey? navigatorKey; + + // Override so we don't have to add `ignore` on each use. + @meta.internal + @override + // ignore: invalid_use_of_internal_member + bool get automatedTestMode => super.automatedTestMode; + + @meta.internal + @override + // ignore: invalid_use_of_internal_member + set automatedTestMode(bool value) => super.automatedTestMode = value; + + @meta.internal + FileSystem fileSystem = LocalFileSystem(); + + /// Configuration of experimental features that may change or be removed + /// without prior notice. Additionally, these features may not be ready for + /// production use yet. + @meta.experimental + final experimental = _SentryFlutterExperimentalOptions(); +} + +class _SentryFlutterExperimentalOptions { + /// Replay recording configuration. + final replay = SentryReplayOptions(); } /// Callback being executed in [ScreenshotEventProcessor], deciding if a diff --git a/flutter/lib/src/sentry_replay_options.dart b/flutter/lib/src/sentry_replay_options.dart new file mode 100644 index 0000000000..e52fbb2877 --- /dev/null +++ b/flutter/lib/src/sentry_replay_options.dart @@ -0,0 +1,40 @@ +import 'package:meta/meta.dart'; + +/// Configuration of the experimental replay feature. +class SentryReplayOptions { + double? _sessionSampleRate; + + /// A percentage of sessions in which a replay will be created. + /// The value needs to be >= 0.0 and <= 1.0. + /// Specifying 0 means none, 1.0 means 100 %. Defaults to null (disabled). + double? get sessionSampleRate => _sessionSampleRate; + set sessionSampleRate(double? value) { + assert(value == null || (value >= 0 && value <= 1)); + _sessionSampleRate = value; + } + + double? _onErrorSampleRate; + + /// A percentage of errors that will be accompanied by a 30 seconds replay. + /// The value needs to be >= 0.0 and <= 1.0. + /// Specifying 0 means none, 1.0 means 100 %. Defaults to null (disabled). + double? get onErrorSampleRate => _onErrorSampleRate; + set onErrorSampleRate(double? value) { + assert(value == null || (value >= 0 && value <= 1)); + _onErrorSampleRate = value; + } + + /// Redact all text content. Draws a rectangle of text bounds with text color + /// on top. Currently, only [Text] and [EditableText] Widgets are redacted. + /// Default is enabled. + var redactAllText = true; + + /// Redact all image content. Draws a rectangle of image bounds with image's + /// dominant color on top. Currently, only [Image] widgets are redacted. + /// Default is enabled. + var redactAllImages = true; + + @internal + bool get isEnabled => + ((sessionSampleRate ?? 0) > 0) || ((onErrorSampleRate ?? 0) > 0); +} diff --git a/flutter/lib/src/span_frame_metrics_collector.dart b/flutter/lib/src/span_frame_metrics_collector.dart new file mode 100644 index 0000000000..ecf0ca961c --- /dev/null +++ b/flutter/lib/src/span_frame_metrics_collector.dart @@ -0,0 +1,256 @@ +import 'dart:collection'; +import 'dart:math'; + +import 'package:flutter/cupertino.dart'; +import 'package:meta/meta.dart'; + +import '../sentry_flutter.dart'; + +import 'frame_callback_handler.dart'; +import 'native/sentry_native_binding.dart'; + +@internal +class SpanFrameMetricsCollector implements PerformanceContinuousCollector { + static const _frozenFrameThresholdMs = 700; + static const totalFramesKey = 'frames.total'; + static const framesDelayKey = 'frames.delay'; + static const slowFramesKey = 'frames.slow'; + static const frozenFramesKey = 'frames.frozen'; + + final SentryFlutterOptions options; + final FrameCallbackHandler _frameCallbackHandler; + final SentryNativeBinding? _native; + + final bool _isTestMode; + + /// Stores frame timestamps and their durations in milliseconds. + /// Keys are frame timestamps, values are frame durations. + /// The timestamps mark the end of the frame. + final frames = SplayTreeMap(); + + /// Stores the spans that are actively being tracked. + /// After the frames are calculated and stored in the span the span is removed from this list. + final activeSpans = SplayTreeSet( + (a, b) => a.startTimestamp.compareTo(b.startTimestamp)); + + bool get isTrackingPaused => _isTrackingPaused; + bool _isTrackingPaused = true; + + bool get isTrackingRegistered => _isTrackingRegistered; + bool _isTrackingRegistered = false; + + int displayRefreshRate = 60; + + final _stopwatch = Stopwatch(); + + SpanFrameMetricsCollector(this.options, + {FrameCallbackHandler? frameCallbackHandler, + SentryNativeBinding? native, + @internal bool isTestMode = false}) + : _frameCallbackHandler = + frameCallbackHandler ?? DefaultFrameCallbackHandler(), + _native = native ?? SentryFlutter.native, + _isTestMode = isTestMode; + + @override + Future onSpanStarted(ISentrySpan span) async { + if (span is NoOpSentrySpan || !options.enableFramesTracking) { + return; + } + + final fetchedDisplayRefreshRate = await _native?.displayRefreshRate(); + if (fetchedDisplayRefreshRate != null) { + options.logger(SentryLevel.debug, + 'Retrieved display refresh rate at $fetchedDisplayRefreshRate'); + displayRefreshRate = fetchedDisplayRefreshRate; + } else { + options.logger(SentryLevel.debug, + 'Could not fetch display refresh rate, keeping at 60hz by default'); + } + + activeSpans.add(span); + startFrameTracking(); + } + + @override + Future onSpanFinished(ISentrySpan span, DateTime endTimestamp) async { + if (span is NoOpSentrySpan || !activeSpans.contains(span)) return; + + final frameMetrics = + calculateFrameMetrics(span, endTimestamp, displayRefreshRate); + _applyFrameMetricsToSpan(span, frameMetrics); + + activeSpans.remove(span); + if (activeSpans.isEmpty) { + clear(); + } else { + frames.removeWhere((frameTimestamp, _) => + frameTimestamp.isBefore(activeSpans.first.startTimestamp)); + } + } + + /// Calls [WidgetsBinding.instance.addPersistentFrameCallback] which cannot be unregistered + /// and exists for the duration of the application's lifetime. + /// + /// Stopping the frame tracking means setting [isTrackingPaused] is `true` + /// to prevent actions being done when the frame callback is triggered. + void startFrameTracking() { + _isTrackingPaused = false; + + if (!_isTrackingRegistered) { + _frameCallbackHandler.addPersistentFrameCallback(measureFrameDuration); + _isTrackingRegistered = true; + } + } + + /// Records the duration of a single frame and stores it in [frames]. + /// + /// This method is called for each frame when frame tracking is active. + Future measureFrameDuration(Duration duration) async { + // Using the stopwatch to measure the frame duration is flaky in ci + if (_isTestMode) { + // ignore: invalid_use_of_internal_member + frames[options.clock().add(duration)] = duration.inMilliseconds; + return; + } + + if (_isTrackingPaused) return; + + if (!_stopwatch.isRunning) { + _stopwatch.start(); + } + + await _frameCallbackHandler.endOfFrame; + + final frameDuration = _stopwatch.elapsedMilliseconds; + // ignore: invalid_use_of_internal_member + frames[options.clock()] = frameDuration; + + _stopwatch.reset(); + + if (_frameCallbackHandler.hasScheduledFrame == true) { + _stopwatch.start(); + } + } + + void _applyFrameMetricsToSpan( + ISentrySpan span, Map frameMetrics) { + frameMetrics.forEach((key, value) { + span.setData(key, value); + }); + + // This will call the methods on the tracer, not on the span directly + if (span is SentrySpan && span.isRootSpan) { + frameMetrics.forEach((key, value) { + // ignore: invalid_use_of_internal_member + span.tracer.setData(key, value); + + // In measurements we change e.g frames.total to frames_total + // We don't do span.tracer.setMeasurement because setMeasurement in SentrySpan + // uses the tracer internally + span.setMeasurement(key.replaceAll('.', '_'), value); + }); + } + } + + @visibleForTesting + Map calculateFrameMetrics( + ISentrySpan span, DateTime spanEndTimestamp, int displayRefreshRate) { + if (frames.isEmpty) { + options.logger( + SentryLevel.info, 'No frame durations available in frame tracker.'); + return {}; + } + + final expectedFrameDuration = ((1 / displayRefreshRate) * 1000).toInt(); + + int slowFramesCount = 0; + int frozenFramesCount = 0; + int slowFramesDuration = 0; + int frozenFramesDuration = 0; + int framesDelay = 0; + + for (final entry in frames.entries) { + final frameDuration = entry.value; + final frameEndTimestamp = entry.key; + final frameStartMs = + frameEndTimestamp.millisecondsSinceEpoch - frameDuration; + final frameEndMs = frameEndTimestamp.millisecondsSinceEpoch; + final spanStartMs = span.startTimestamp.millisecondsSinceEpoch; + final spanEndMs = spanEndTimestamp.millisecondsSinceEpoch; + + final frameFullyContainedInSpan = + frameEndMs <= spanEndMs && frameStartMs >= spanStartMs; + final frameStartsBeforeSpan = + frameStartMs < spanStartMs && frameEndMs > spanStartMs; + final frameEndsAfterSpan = + frameStartMs < spanEndMs && frameEndMs > spanEndMs; + final framePartiallyContainedInSpan = + frameStartsBeforeSpan || frameEndsAfterSpan; + + int effectiveDuration = 0; + int effectiveDelay = 0; + + if (frameFullyContainedInSpan) { + effectiveDuration = frameDuration; + effectiveDelay = max(0, frameDuration - expectedFrameDuration); + } else if (framePartiallyContainedInSpan) { + final intersectionStart = max(frameStartMs, spanStartMs); + final intersectionEnd = min(frameEndMs, spanEndMs); + effectiveDuration = intersectionEnd - intersectionStart; + + final fullFrameDelay = max(0, frameDuration - expectedFrameDuration); + final intersectionRatio = effectiveDuration / frameDuration; + effectiveDelay = (fullFrameDelay * intersectionRatio).round(); + } else if (frameStartMs > spanEndMs) { + // Other frames will be newer than this span, as frames are ordered + break; + } else { + // Frame is completely outside the span, skip it + continue; + } + + if (effectiveDuration > _frozenFrameThresholdMs) { + frozenFramesCount++; + frozenFramesDuration += effectiveDuration; + } else if (effectiveDuration > expectedFrameDuration) { + slowFramesCount++; + slowFramesDuration += effectiveDuration; + } + + framesDelay += effectiveDelay; + } + + final spanDuration = + spanEndTimestamp.difference(span.startTimestamp).inMilliseconds; + final totalFramesCount = + ((spanDuration - (slowFramesDuration + frozenFramesDuration)) / + expectedFrameDuration) + + slowFramesCount + + frozenFramesCount; + + if (totalFramesCount < 0 || + framesDelay < 0 || + slowFramesCount < 0 || + frozenFramesCount < 0) { + options.logger(SentryLevel.warning, + 'Negative frame metrics calculated. Dropping frame metrics.'); + return {}; + } + + return { + SpanFrameMetricsCollector.totalFramesKey: totalFramesCount.toInt(), + SpanFrameMetricsCollector.framesDelayKey: framesDelay, + SpanFrameMetricsCollector.slowFramesKey: slowFramesCount, + SpanFrameMetricsCollector.frozenFramesKey: frozenFramesCount, + }; + } + + @override + void clear() { + _isTrackingPaused = true; + frames.clear(); + activeSpans.clear(); + displayRefreshRate = 60; + } +} diff --git a/flutter/lib/src/user_interaction/sentry_user_interaction_widget.dart b/flutter/lib/src/user_interaction/sentry_user_interaction_widget.dart index 018f750a2e..45c3a0921c 100644 --- a/flutter/lib/src/user_interaction/sentry_user_interaction_widget.dart +++ b/flutter/lib/src/user_interaction/sentry_user_interaction_widget.dart @@ -208,7 +208,7 @@ import 'package:meta/meta.dart'; import '../../sentry_flutter.dart'; import '../widget_utils.dart'; -import 'user_interaction_widget.dart'; +import 'user_interaction_info.dart'; const _tapDeltaArea = 20 * 20; Element? _clickTrackerElement; @@ -223,7 +223,7 @@ Element? _clickTrackerElement; /// Mostly for onPressed, onTap, and onLongPress events /// /// Example on how to set up: -/// runApp(SentryUserInteractionWidget(child: App())); +/// runApp(SentryWidget(child: App())); /// /// For transactions, enable it in the [SentryFlutterOptions.enableUserInteractionTracing]. /// The idle timeout can be configured in the [SentryOptions.idleTimeout]. @@ -272,7 +272,7 @@ class _SentryUserInteractionWidgetState extends State { int? _lastPointerId; Offset? _lastPointerDownLocation; - UserInteractionWidget? _lastTappedWidget; + UserInteractionInfo? _lastTappedWidget; ISentrySpan? _activeTransaction; Hub get _hub => widget._hub; @@ -294,68 +294,129 @@ class _SentryUserInteractionWidgetState } void _onPointerDown(PointerDownEvent event) { - _lastPointerId = event.pointer; - _lastPointerDownLocation = event.localPosition; + try { + _lastPointerId = event.pointer; + _lastPointerDownLocation = event.localPosition; + } catch (exception, stacktrace) { + _options?.logger( + SentryLevel.error, + 'Error while handling pointer-down event $event in $SentryUserInteractionWidget', + exception: exception, + stackTrace: stacktrace, + ); + // ignore: invalid_use_of_internal_member + if (_options?.automatedTestMode ?? false) { + rethrow; + } + } } void _onPointerUp(PointerUpEvent event) { - // Figure out if something was tapped - final location = _lastPointerDownLocation; - if (location == null || event.pointer != _lastPointerId) { - return; - } - final delta = Offset( - location.dx - event.localPosition.dx, - location.dy - event.localPosition.dy, - ); + try { + // Figure out if something was tapped + final location = _lastPointerDownLocation; + if (location == null || event.pointer != _lastPointerId) { + return; + } + final delta = Offset( + location.dx - event.localPosition.dx, + location.dy - event.localPosition.dy, + ); - if (delta.distanceSquared < _tapDeltaArea) { - // Widget was tapped - _onTappedAt(event.localPosition); + if (delta.distanceSquared < _tapDeltaArea) { + // Widget was tapped + _onTappedAt(event.localPosition); + } + } catch (exception, stacktrace) { + _options?.logger( + SentryLevel.error, + 'Error while handling pointer-up event $event in $SentryUserInteractionWidget', + exception: exception, + stackTrace: stacktrace, + ); + // ignore: invalid_use_of_internal_member + if (_options?.automatedTestMode ?? false) { + rethrow; + } } } void _onTappedAt(Offset position) { - final tappedWidget = _getElementAt(position); - final keyValue = - WidgetUtils.toStringValue(tappedWidget?.element.widget.key); - if (tappedWidget == null || keyValue == null) { + final tapInfo = _getElementAt(position); + if (tapInfo == null) { return; } - final element = tappedWidget.element; - - Map? data; - // ignore: invalid_use_of_internal_member - if ((_options?.sendDefaultPii ?? false) && - tappedWidget.description.isNotEmpty) { - data = {}; - data['label'] = tappedWidget.description; + + final widgetKey = WidgetUtils.toStringValue(tapInfo.element.widget.key); + _createBreadcrumbOnTap(tapInfo, widgetKey); + _startTransactionOnTap(tapInfo, widgetKey); + } + + void _createBreadcrumbOnTap(UserInteractionInfo info, String? widgetKey) { + if (!(_options?.enableUserInteractionBreadcrumbs ?? false)) { + return; } - const category = 'click'; - // ignore: invalid_use_of_internal_member - if (_options?.enableUserInteractionBreadcrumbs ?? false) { - final crumb = Breadcrumb.userInteraction( - subCategory: category, - viewId: keyValue, - viewClass: tappedWidget.type, // to avoid minification - data: data, - ); - final hint = Hint.withMap({TypeCheckHint.widget: element.widget}); - _hub.addBreadcrumb(crumb, hint: hint); + final label = _getLabelRecursively(info.element); + final data = { + 'path': _getTouchPath(info.element), + if (label != null) 'label': label + }; + + final crumb = Breadcrumb.userInteraction( + subCategory: 'click', + viewId: widgetKey, + viewClass: info.type, // to avoid minification + data: data, + ); + final hint = Hint.withMap({TypeCheckHint.widget: info.element.widget}); + _hub.addBreadcrumb(crumb, hint: hint); + } + + List> _getTouchPath(Element element) { + final path = >[]; + + bool addToPath(Element element) { + // Break at the boundary (i.e. this [SentryUserInteractionWidget]). + if (element.widget == widget) { + return false; + } + + final widgetName = element.widget.runtimeType.toString(); + if (!widgetName.startsWith('_')) { + final info = { + 'name': WidgetUtils.toStringValue(element.widget.key), + 'element': _getElementType(element) ?? widgetName, + 'label': _getLabel(element, true), + }..removeWhere((key, value) => value == null); + if (info.isNotEmpty) { + path.add(info); + } + } + + return path.length < 10; + } + + if (addToPath(element)) { + element.visitAncestorElements(addToPath); } - // ignore: invalid_use_of_internal_member - if (!(_options?.isTracingEnabled() ?? false) || + return path; + } + + void _startTransactionOnTap(UserInteractionInfo info, String? widgetKey) { + if (widgetKey == null || + !(_options?.isTracingEnabled() ?? false) || !(_options?.enableUserInteractionTracing ?? false)) { return; } + final element = info.element; // getting the name of the screen using ModalRoute.of(context).settings.name // is expensive, so we expect that the keys are unique across the app final transactionContext = SentryTransactionContext( - keyValue, - 'ui.action.$category', + widgetKey, + 'ui.action.click', transactionNameSource: SentryTransactionNameSource.component, ); @@ -365,7 +426,6 @@ class _SentryUserInteractionWidgetState if (_isElementMounted(lastElement) && _isElementMounted(element) && lastElement?.widget == element.widget && - _lastTappedWidget?.eventType == tappedWidget.eventType && !activeTransaction.finished) { // ignore: invalid_use_of_internal_member activeTransaction.scheduleFinish(); @@ -382,7 +442,7 @@ class _SentryUserInteractionWidgetState } } - _lastTappedWidget = tappedWidget; + _lastTappedWidget = info; bool hasRunningTransaction = false; _hub.configureScope((scope) { @@ -399,9 +459,7 @@ class _SentryUserInteractionWidgetState _activeTransaction = _hub.startTransactionWithContext( transactionContext, waitForChildren: true, - autoFinishAfter: - // ignore: invalid_use_of_internal_member - _options?.idleTimeout, + autoFinishAfter: _options?.idleTimeout, trimEnd: true, ); @@ -415,43 +473,53 @@ class _SentryUserInteractionWidgetState }); } - String _findDescriptionOf(Element element, bool allowText) { - var description = ''; - - // traverse tree to find a suiting element - void descriptionFinder(Element element) { - bool foundDescription = false; + String? _getLabel(Element element, bool allowText) { + String? label; + if (_options?.sendDefaultPii ?? false) { final widget = element.widget; if (allowText && widget is Text) { - final data = widget.data; - if (data != null && data.isNotEmpty) { - description = data; - foundDescription = true; - } + label = widget.data; } else if (widget is Semantics) { - if (widget.properties.label?.isNotEmpty ?? false) { - description = widget.properties.label!; - foundDescription = true; - } + label = widget.properties.label; } else if (widget is Icon) { - if (widget.semanticLabel?.isNotEmpty ?? false) { - description = widget.semanticLabel!; - foundDescription = true; - } + label = widget.semanticLabel; + } else if (widget is Tooltip) { + label = widget.message; } - if (!foundDescription) { - element.visitChildren(descriptionFinder); + if (label?.isEmpty ?? true) { + label = null; } } - element.visitChildren(descriptionFinder); + return label; + } + + String? _getLabelRecursively(Element element) { + String? label; + + if (_options?.sendDefaultPii ?? false) { + final widget = element.widget; + final allowText = widget is ButtonStyleButton || + widget is MaterialButton || + widget is CupertinoButton; + + // traverse tree to find a suiting element + void descriptionFinder(Element element) { + label ??= _getLabel(element, allowText); + if (label == null) { + element.visitChildren(descriptionFinder); + } + } + + descriptionFinder(element); + } - return description; + return label; } - UserInteractionWidget? _getElementAt(Offset position) { + UserInteractionInfo? _getElementAt(Offset position) { // WidgetsBinding.instance.renderViewElement does not work, so using // the element from createElement final rootElement = _clickTrackerElement; @@ -459,7 +527,7 @@ class _SentryUserInteractionWidgetState return null; } - UserInteractionWidget? tappedWidget; + UserInteractionInfo? tappedWidget; void elementFinder(Element element) { if (tappedWidget != null) { @@ -487,7 +555,13 @@ class _SentryUserInteractionWidgetState return; } - tappedWidget = _getDescriptionFrom(element); + final type = _getElementType(element); + if (type != null) { + tappedWidget = UserInteractionInfo( + element: element, + type: type, + ); + } if (tappedWidget == null || !hitFound) { element.visitChildElements(elementFinder); @@ -499,71 +573,36 @@ class _SentryUserInteractionWidgetState return tappedWidget; } - UserInteractionWidget? _getDescriptionFrom(Element element) { + String? _getElementType(Element element) { final widget = element.widget; // Used by ElevatedButton, TextButton, OutlinedButton. if (widget is ButtonStyleButton) { if (widget.enabled) { - return UserInteractionWidget( - element: element, - description: _findDescriptionOf(element, true), - type: 'ButtonStyleButton', - eventType: 'onClick', - ); + return 'ButtonStyleButton'; } } else if (widget is MaterialButton) { if (widget.enabled) { - return UserInteractionWidget( - element: element, - description: _findDescriptionOf(element, true), - type: 'MaterialButton', - eventType: 'onClick', - ); + return 'MaterialButton'; } } else if (widget is CupertinoButton) { if (widget.enabled) { - return UserInteractionWidget( - element: element, - description: _findDescriptionOf(element, true), - type: 'CupertinoButton', - eventType: 'onPressed', - ); + return 'CupertinoButton'; } } else if (widget is InkWell) { if (widget.onTap != null) { - return UserInteractionWidget( - element: element, - description: _findDescriptionOf(element, false), - type: 'InkWell', - eventType: 'onTap', - ); + return 'InkWell'; } } else if (widget is IconButton) { if (widget.onPressed != null) { - return UserInteractionWidget( - element: element, - description: _findDescriptionOf(element, false), - type: 'IconButton', - eventType: 'onPressed', - ); + return 'IconButton'; } } else if (widget is PopupMenuButton) { if (widget.enabled) { - return UserInteractionWidget( - element: element, - description: _findDescriptionOf(element, false), - type: 'PopupMenuButton', - eventType: 'onTap', - ); + return 'PopupMenuButton'; } } else if (widget is PopupMenuItem) { if (widget.enabled) { - return UserInteractionWidget( - element: element, - description: _findDescriptionOf(element, false), - type: 'PopupMenuItem', - eventType: 'onTap', - ); + return 'PopupMenuItem'; } } diff --git a/flutter/lib/src/user_interaction/user_interaction_info.dart b/flutter/lib/src/user_interaction/user_interaction_info.dart new file mode 100644 index 0000000000..38ceb8f6a4 --- /dev/null +++ b/flutter/lib/src/user_interaction/user_interaction_info.dart @@ -0,0 +1,13 @@ +import 'package:flutter/widgets.dart'; +import 'package:meta/meta.dart'; + +@internal +class UserInteractionInfo { + final Element element; + final String type; + + const UserInteractionInfo({ + required this.element, + required this.type, + }); +} diff --git a/flutter/lib/src/user_interaction/user_interaction_widget.dart b/flutter/lib/src/user_interaction/user_interaction_widget.dart deleted file mode 100644 index 58d7d18e79..0000000000 --- a/flutter/lib/src/user_interaction/user_interaction_widget.dart +++ /dev/null @@ -1,15 +0,0 @@ -import 'package:flutter/widgets.dart'; - -class UserInteractionWidget { - final Element element; - final String description; - final String type; - final String eventType; - - const UserInteractionWidget({ - required this.element, - required this.description, - required this.type, - required this.eventType, - }); -} diff --git a/flutter/lib/src/utils/debouncer.dart b/flutter/lib/src/utils/debouncer.dart new file mode 100644 index 0000000000..b714b41b4e --- /dev/null +++ b/flutter/lib/src/utils/debouncer.dart @@ -0,0 +1,20 @@ +import 'dart:async'; +import 'package:flutter/foundation.dart'; +import 'package:meta/meta.dart'; + +@internal +class Debouncer { + final int milliseconds; + Timer? _timer; + + Debouncer({required this.milliseconds}); + + void run(VoidCallback action) { + _timer?.cancel(); + _timer = Timer(Duration(milliseconds: milliseconds), action); + } + + void dispose() { + _timer?.cancel(); + } +} diff --git a/flutter/lib/src/utils/enum_wrapper.dart b/flutter/lib/src/utils/enum_wrapper.dart new file mode 100644 index 0000000000..6f7772af55 --- /dev/null +++ b/flutter/lib/src/utils/enum_wrapper.dart @@ -0,0 +1,12 @@ +String pDescribeEnum(Object enumEntry) { + if (enumEntry is Enum) { + return enumEntry.name; + } + final String description = enumEntry.toString(); + final int indexOfDot = description.indexOf('.'); + assert( + indexOfDot != -1 && indexOfDot < description.length - 1, + 'The provided object "$enumEntry" is not an enum.', + ); + return description.substring(indexOfDot + 1); +} diff --git a/flutter/lib/src/version.dart b/flutter/lib/src/version.dart index 0a431dd2de..fa00328942 100644 --- a/flutter/lib/src/version.dart +++ b/flutter/lib/src/version.dart @@ -1,5 +1,5 @@ /// The SDK version reported to Sentry.io in the submitted events. -const String sdkVersion = '8.3.0'; +const String sdkVersion = '8.9.0'; /// The default SDK name reported to Sentry.io in the submitted events. const String sdkName = 'sentry.dart.flutter'; diff --git a/flutter/lib/src/view_hierarchy/sentry_tree_walker.dart b/flutter/lib/src/view_hierarchy/sentry_tree_walker.dart index 41b03f4808..578792d18a 100644 --- a/flutter/lib/src/view_hierarchy/sentry_tree_walker.dart +++ b/flutter/lib/src/view_hierarchy/sentry_tree_walker.dart @@ -209,9 +209,10 @@ import '../widget_utils.dart'; class _TreeWalker { static const _privateDelimiter = '_'; - _TreeWalker(this.rootElement); + _TreeWalker(this.rootElement, this.options); final Element rootElement; + final SentryFlutterOptions options; ValueChanged _visitor( SentryViewHierarchyElement parentSentryElement) { @@ -254,7 +255,7 @@ class _TreeWalker { double? alpha; final renderObject = element.renderObject; - if (renderObject is RenderBox) { + if (renderObject is RenderBox && renderObject.hasSize) { final offset = renderObject.localToGlobal(Offset.zero); if (offset.dx > 0) { x = offset.dx; @@ -278,10 +279,15 @@ class _TreeWalker { alpha = widget.opacity; } + String? identifier; + if (options.reportViewHierarchyIdentifiers) { + identifier = WidgetUtils.toStringValue(widget.key); + } + return SentryViewHierarchyElement( element.widget.runtimeType.toString(), depth: element.depth, - identifier: WidgetUtils.toStringValue(element.widget.key), + identifier: identifier, width: width, height: height, x: x, @@ -292,7 +298,8 @@ class _TreeWalker { } } -SentryViewHierarchy? walkWidgetTree(WidgetsBinding instance) { +SentryViewHierarchy? walkWidgetTree( + WidgetsBinding instance, SentryFlutterOptions options) { // to keep compatibility with older versions // ignore: deprecated_member_use final rootElement = instance.renderViewElement; @@ -300,7 +307,7 @@ SentryViewHierarchy? walkWidgetTree(WidgetsBinding instance) { return null; } - final walker = _TreeWalker(rootElement); + final walker = _TreeWalker(rootElement, options); return walker.toSentryViewHierarchy(); } diff --git a/flutter/lib/src/view_hierarchy/view_hierarchy_event_processor.dart b/flutter/lib/src/view_hierarchy/view_hierarchy_event_processor.dart index c3fefe52f5..cf9ae008ec 100644 --- a/flutter/lib/src/view_hierarchy/view_hierarchy_event_processor.dart +++ b/flutter/lib/src/view_hierarchy/view_hierarchy_event_processor.dart @@ -23,7 +23,7 @@ class SentryViewHierarchyEventProcessor implements EventProcessor { if (instance == null) { return event; } - final sentryViewHierarchy = walkWidgetTree(instance); + final sentryViewHierarchy = walkWidgetTree(instance, _options); if (sentryViewHierarchy == null) { return event; diff --git a/flutter/lib/src/widgets_binding_observer.dart b/flutter/lib/src/widgets_binding_observer.dart index b199b7f8a5..7c0db1842f 100644 --- a/flutter/lib/src/widgets_binding_observer.dart +++ b/flutter/lib/src/widgets_binding_observer.dart @@ -4,6 +4,7 @@ import 'dart:ui'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import '../sentry_flutter.dart'; +import 'utils/debouncer.dart'; /// This is a `WidgetsBindingObserver` which can observe some events of a /// Flutter application. @@ -50,6 +51,8 @@ class SentryWidgetsBindingObserver with WidgetsBindingObserver { // ignore: deprecated_member_use final StreamController _screenSizeStreamController; + final _didChangeMetricsDebouncer = Debouncer(milliseconds: 100); + /// This method records lifecycle events. /// It tries to mimic the behavior of ActivityBreadcrumbsIntegration of Sentry /// Android for lifecycle events. @@ -88,9 +91,12 @@ class SentryWidgetsBindingObserver with WidgetsBindingObserver { if (!_options.enableWindowMetricBreadcrumbs) { return; } - // ignore: deprecated_member_use - final window = _options.bindingUtils.instance?.window; - _screenSizeStreamController.add(window); + + _didChangeMetricsDebouncer.run(() { + // ignore: deprecated_member_use + final window = _options.bindingUtils.instance?.window; + _screenSizeStreamController.add(window); + }); } void _onScreenSizeChanged(Map data) { diff --git a/flutter/pubspec.yaml b/flutter/pubspec.yaml index d2a244f171..201da4df1f 100644 --- a/flutter/pubspec.yaml +++ b/flutter/pubspec.yaml @@ -1,5 +1,5 @@ name: sentry_flutter -version: 8.3.0 +version: 8.9.0 description: Sentry SDK for Flutter. This package aims to support different Flutter targets by relying on the many platforms supported by Sentry with native SDKs. homepage: https://docs.sentry.io/platforms/flutter/ repository: https://github.com/getsentry/sentry-dart @@ -23,10 +23,11 @@ dependencies: sdk: flutter flutter_web_plugins: sdk: flutter - sentry: 8.3.0 + sentry: 8.9.0 package_info_plus: '>=1.0.0' meta: ^1.3.0 ffi: ^2.0.0 + file: '>=6.1.4' dev_dependencies: build_runner: ^2.4.2 @@ -56,4 +57,4 @@ flutter: linux: pluginClass: SentryFlutterPlugin windows: - pluginClass: SentryFlutterPlugin + ffiPlugin: true diff --git a/flutter/scripts/generate-cocoa-bindings.sh b/flutter/scripts/generate-cocoa-bindings.sh index 722a52fb32..9af4cb2084 100755 --- a/flutter/scripts/generate-cocoa-bindings.sh +++ b/flutter/scripts/generate-cocoa-bindings.sh @@ -33,7 +33,9 @@ subdir="Sentry.xcframework/macos-arm64_x86_64/Sentry.framework" unzip -q $temp/Sentry.xcframework.zip "$subdir/*" -d $temp mv "$temp/$subdir" $temp/Sentry.framework +binding="lib/src/native/cocoa/binding.dart" dart run ffigen --config ffi-cocoa.yaml -sed -i.bak 's|final class|class|g' lib/src/native/cocoa/binding.dart -sed -i.bak 's|static int startProfilerForTrace_(SentryCocoa _lib, SentryId? traceId)|static int startProfilerForTrace_(SentryCocoa _lib, SentryId1? traceId)|g' lib/src/native/cocoa/binding.dart -rm lib/src/native/cocoa/binding.dart.bak +sed -i.bak 's|final class|class|g' $binding +sed -i.bak 's|static int startProfilerForTrace_(SentryCocoa _lib, SentryId? traceId)|static int startProfilerForTrace_(SentryCocoa _lib, SentryId1? traceId)|g' $binding +rm $binding.bak +dart format $binding diff --git a/flutter/temp/native-test/dist/sentry.dll b/flutter/temp/native-test/dist/sentry.dll new file mode 100644 index 0000000000000000000000000000000000000000..21615e9b7d335ff796f8ef03339c05780c9e36f6 GIT binary patch literal 280064 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�-0)E$fH{&iSTzR59+v^ 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%0A8d$Wr2cmF@b#M0vCDY7zCl!lXFw{tv-~4gCcV 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.delayed(Duration(milliseconds: 1)); @@ -181,7 +182,7 @@ void main() { class Fixture { late Hub hub; - SentryFlutterOptions options = SentryFlutterOptions(dsn: fakeDsn); + SentryFlutterOptions options = defaultTestOptions(); Fixture() { options.attachScreenshot = true; diff --git a/flutter/test/event_processor/url_filter/io_filter_event_processor_test.dart b/flutter/test/event_processor/url_filter/io_filter_event_processor_test.dart new file mode 100644 index 0000000000..186858888d --- /dev/null +++ b/flutter/test/event_processor/url_filter/io_filter_event_processor_test.dart @@ -0,0 +1,41 @@ +@TestOn('vm') +library flutter_test; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:sentry_flutter/sentry_flutter.dart'; +import 'package:sentry_flutter/src/event_processor/url_filter/url_filter_event_processor.dart'; + +import '../../mocks.dart'; + +void main() { + group("ignore allowUrls and denyUrls for non Web", () { + late Fixture fixture; + + setUp(() async { + fixture = Fixture(); + }); + + test('returns the event and ignore allowUrls and denyUrls for non Web', + () async { + SentryEvent? event = SentryEvent( + request: SentryRequest( + url: 'another.url/for/a/special/test/testing/this-feature', + ), + ); + fixture.options.allowUrls = ["^this.is/.*\$"]; + fixture.options.denyUrls = ["special"]; + + var eventProcessor = fixture.getSut(); + event = await eventProcessor.apply(event, Hint()); + + expect(event, isNotNull); + }); + }); +} + +class Fixture { + SentryFlutterOptions options = defaultTestOptions(); + UrlFilterEventProcessor getSut() { + return UrlFilterEventProcessor(options); + } +} diff --git a/flutter/test/event_processor/url_filter/web_url_filter_event_processor_test.dart b/flutter/test/event_processor/url_filter/web_url_filter_event_processor_test.dart new file mode 100644 index 0000000000..bc80d45e6b --- /dev/null +++ b/flutter/test/event_processor/url_filter/web_url_filter_event_processor_test.dart @@ -0,0 +1,121 @@ +@TestOn('browser') +library flutter_test; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:sentry_flutter/sentry_flutter.dart'; +import 'package:sentry_flutter/src/event_processor/url_filter/url_filter_event_processor.dart'; + +import '../../mocks.dart'; + +// can be tested on command line with +// `flutter test --platform=chrome test/event_processor/url_filter/web_url_filter_event_processor_test.dart` +// The URL looks something like this: http://localhost:58551/event_processor/url_filter/web_url_filter_event_processor_test.html + +void main() { + group(UrlFilterEventProcessor, () { + late Fixture fixture; + + setUp(() { + fixture = Fixture(); + }); + + test('returns event if no allowUrl and no denyUrl is set', () async { + final event = SentryEvent(); + final eventProcessor = fixture.getSut(); + + final processedEvent = await eventProcessor.apply(event, Hint()); + + expect(processedEvent, isNotNull); + }); + + test('returns null if allowUrl is set and does not match with url', + () async { + final event = SentryEvent(); + fixture.options.allowUrls = ["another.url"]; + final eventProcessor = fixture.getSut(); + + final processedEvent = await eventProcessor.apply(event, Hint()); + + expect(processedEvent, isNull); + }); + + test('returns event if allowUrl is set and does partially match with url', + () async { + final event = SentryEvent(); + fixture.options.allowUrls = ["event_processor_test"]; + final eventProcessor = fixture.getSut(); + + final processedEvent = await eventProcessor.apply(event, Hint()); + + expect(processedEvent, isNotNull); + }); + + test('returns event if denyUrl is set and does not match with url', + () async { + final event = SentryEvent(); + fixture.options.denyUrls = ["another.url"]; + final eventProcessor = fixture.getSut(); + + final processedEvent = await eventProcessor.apply(event, Hint()); + + expect(processedEvent, isNotNull); + }); + + test('returns null if denyUrl is set and partially matches with url', + () async { + final event = SentryEvent(); + fixture.options.denyUrls = ["event_processor_test"]; + final eventProcessor = fixture.getSut(); + + final processedEvent = await eventProcessor.apply(event, Hint()); + + expect(processedEvent, isNull); + }); + + test( + 'returns null if it is part of the allowed domain, but blocked for subdomain', + () async { + final event = SentryEvent(); + fixture.options.allowUrls = [".*localhost.*\$"]; + fixture.options.denyUrls = ["event"]; + final eventProcessor = fixture.getSut(); + + final processedEvent = await eventProcessor.apply(event, Hint()); + + expect(processedEvent, isNull); + }); + + test( + 'returns event if it is part of the allowed domain, and not of the blocked for subdomain', + () async { + final event = SentryEvent(); + fixture.options.allowUrls = [".*localhost.*\$"]; + fixture.options.denyUrls = ["special"]; + final eventProcessor = fixture.getSut(); + + final processedEvent = await eventProcessor.apply(event, Hint()); + + expect(processedEvent, isNotNull); + }); + + test( + 'returns null if it is not part of the allowed domain, and not of the blocked for subdomain', + () async { + final event = SentryEvent(); + fixture.options.allowUrls = ["^this.is/.*\$"]; + fixture.options.denyUrls = ["special"]; + final eventProcessor = fixture.getSut(); + + final processedEvent = await eventProcessor.apply(event, Hint()); + + expect(processedEvent, isNull); + }); + }); +} + +class Fixture { + SentryFlutterOptions options = defaultTestOptions(); + UrlFilterEventProcessor getSut() { + return UrlFilterEventProcessor(options); + } +} diff --git a/flutter/test/fake_frame_callback_handler.dart b/flutter/test/fake_frame_callback_handler.dart index 0dc968f22c..aa450a7911 100644 --- a/flutter/test/fake_frame_callback_handler.dart +++ b/flutter/test/fake_frame_callback_handler.dart @@ -1,9 +1,9 @@ import 'package:flutter/scheduler.dart'; import 'package:sentry_flutter/src/frame_callback_handler.dart'; -class FakeFrameCallbackHandler implements FrameCallbackHandler { - FrameCallback? storedCallback; +import 'mocks.dart'; +class FakeFrameCallbackHandler implements FrameCallbackHandler { final Duration finishAfterDuration; FakeFrameCallbackHandler( @@ -15,4 +15,19 @@ class FakeFrameCallbackHandler implements FrameCallbackHandler { await Future.delayed(finishAfterDuration); callback(Duration.zero); } + + @override + Future addPersistentFrameCallback(FrameCallback callback) async { + for (final duration in fakeFrameDurations) { + // Let's wait a bit so the timestamp intervals are large enough + await Future.delayed(Duration(milliseconds: 20)); + callback(duration); + } + } + + @override + bool hasScheduledFrame = true; + + @override + Future get endOfFrame => Future.value(); } diff --git a/flutter/test/file_system_transport_test.dart b/flutter/test/file_system_transport_test.dart index aa948e56fb..84bef4babe 100644 --- a/flutter/test/file_system_transport_test.dart +++ b/flutter/test/file_system_transport_test.dart @@ -2,38 +2,29 @@ library flutter_test; import 'dart:convert'; + // backcompatibility for Flutter < 3.3 // ignore: unnecessary_import import 'dart:typed_data'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart'; import 'package:sentry/sentry.dart'; import 'package:sentry_flutter/src/file_system_transport.dart'; import 'mocks.dart'; +import 'mocks.mocks.dart'; void main() { - const _channel = MethodChannel('sentry_flutter'); - - TestWidgetsFlutterBinding.ensureInitialized(); - late Fixture fixture; setUp(() { fixture = Fixture(); }); - tearDown(() { - // ignore: deprecated_member_use - _channel.setMockMethodCallHandler(null); - }); - - test('FileSystemTransport wont throw', () async { - // ignore: deprecated_member_use - _channel.setMockMethodCallHandler((MethodCall methodCall) async {}); - - final transport = fixture.getSut(_channel); + test("$FileSystemTransport won't throw", () async { + final transport = fixture.getSut(); final event = SentryEvent(); final sdkVersion = SdkVersion(name: 'fixture-sdkName', version: 'fixture-sdkVersion'); @@ -48,13 +39,11 @@ void main() { expect(sentryId, sentryId); }); - test('FileSystemTransport returns emptyId if channel throws', () async { - // ignore: deprecated_member_use - _channel.setMockMethodCallHandler((MethodCall methodCall) async { - throw Exception(); - }); + test('$FileSystemTransport returns emptyId if channel throws', () async { + fixture.options.automatedTestMode = false; + when(fixture.binding.captureEnvelope(any, false)).thenThrow(Exception()); - final transport = fixture.getSut(_channel); + final transport = fixture.getSut(); final event = SentryEvent(); final sdkVersion = SdkVersion(name: 'fixture-sdkName', version: 'fixture-sdkVersion'); @@ -69,14 +58,60 @@ void main() { expect(SentryId.empty(), sentryId); }); - test('FileSystemTransport asserts the event', () async { - dynamic arguments; - // ignore: deprecated_member_use - _channel.setMockMethodCallHandler((MethodCall methodCall) async { - arguments = methodCall.arguments; - }); + test( + 'sets unhandled exception flag in captureEnvelope to true for unhandled exception', + () async { + final transport = fixture.getSut(); + + final unhandledException = SentryException( + mechanism: Mechanism(type: 'UnhandledException', handled: false), + threadId: 99, + type: 'Exception', + value: 'Unhandled exception', + ); + final event = SentryEvent(exceptions: [unhandledException]); + final sdkVersion = + SdkVersion(name: 'fixture-sdkName', version: 'fixture-sdkVersion'); + final envelope = SentryEnvelope.fromEvent( + event, + sdkVersion, + dsn: fixture.options.dsn, + ); + + await transport.send(envelope); + + verify(fixture.binding.captureEnvelope(captureAny, true)).captured.single + as Uint8List; + }); + + test( + 'sets unhandled exception flag in captureEnvelope to false for handled exception', + () async { + final transport = fixture.getSut(); + + final unhandledException = SentryException( + mechanism: Mechanism(type: 'UnhandledException', handled: true), + threadId: 99, + type: 'Exception', + value: 'Unhandled exception', + ); + final event = SentryEvent(exceptions: [unhandledException]); + final sdkVersion = + SdkVersion(name: 'fixture-sdkName', version: 'fixture-sdkVersion'); + final envelope = SentryEnvelope.fromEvent( + event, + sdkVersion, + dsn: fixture.options.dsn, + ); + + await transport.send(envelope); + + verify(fixture.binding.captureEnvelope(captureAny, false)).captured.single + as Uint8List; + }); - final transport = fixture.getSut(_channel); + test('$FileSystemTransport asserts the event', () async { + final transport = fixture.getSut(); final event = SentryEvent(message: SentryMessage('hi I am a special char ◤')); @@ -89,8 +124,10 @@ void main() { ); await transport.send(envelope); - final envelopeList = arguments as List; - final envelopeData = envelopeList.first as Uint8List; + final envelopeData = + verify(fixture.binding.captureEnvelope(captureAny, false)) + .captured + .single as Uint8List; final envelopeString = utf8.decode(envelopeData); final lines = envelopeString.split('\n'); final envelopeHeader = lines.first; @@ -119,9 +156,10 @@ void main() { } class Fixture { - final options = SentryOptions(dsn: fakeDsn); + final options = defaultTestOptions(); + final binding = MockSentryNativeBinding(); - FileSystemTransport getSut(MethodChannel channel) { - return FileSystemTransport(channel, options); + FileSystemTransport getSut() { + return FileSystemTransport(binding, options); } } diff --git a/flutter/test/initialization_test.dart b/flutter/test/initialization_test.dart index 7b59c173bf..d36e549339 100644 --- a/flutter/test/initialization_test.dart +++ b/flutter/test/initialization_test.dart @@ -10,52 +10,22 @@ import 'mocks.dart'; // https://github.com/getsentry/sentry-dart/issues/508 // There are no asserts, test are succesfull if no exceptions are thrown. void main() { - setUp(() async { - await Sentry.close(); - }); - - test('async re-initilization', () async { - await SentryFlutter.init( - (options) { - options.dsn = fakeDsn; - // ignore: invalid_use_of_internal_member - options.automatedTestMode = true; - }, - ); - - await Sentry.close(); + final native = NativeChannelFixture(); - await SentryFlutter.init( - (options) { - options.dsn = fakeDsn; - // ignore: invalid_use_of_internal_member - options.automatedTestMode = true; - }, - ); + void optionsInitializer(SentryFlutterOptions options) { + // LoadReleaseIntegration throws because package_info channel is not available + options.removeIntegration( + options.integrations.firstWhere((i) => i is LoadReleaseIntegration)); + } - await Sentry.close(); - }); - - // This is the failure from - // https://github.com/getsentry/sentry-dart/issues/508 - test('re-initilization', () async { - await SentryFlutter.init( - (options) { - options.dsn = fakeDsn; - // ignore: invalid_use_of_internal_member - options.automatedTestMode = true; - }, - ); + test('async re-initilization', () async { + await SentryFlutter.init(optionsInitializer, + options: defaultTestOptions()..methodChannel = native.channel); await Sentry.close(); - await SentryFlutter.init( - (options) { - options.dsn = fakeDsn; - // ignore: invalid_use_of_internal_member - options.automatedTestMode = true; - }, - ); + await SentryFlutter.init(optionsInitializer, + options: defaultTestOptions()..methodChannel = native.channel); await Sentry.close(); }); diff --git a/flutter/test/integrations/connectivity_integration_test.dart b/flutter/test/integrations/connectivity_integration_test.dart index 2f0781e9ec..ed7ca80345 100644 --- a/flutter/test/integrations/connectivity_integration_test.dart +++ b/flutter/test/integrations/connectivity_integration_test.dart @@ -3,7 +3,6 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:mockito/mockito.dart'; import 'package:sentry/sentry.dart'; import 'package:sentry_flutter/src/integrations/connectivity/connectivity_integration.dart'; -import 'package:sentry_flutter/src/sentry_flutter_options.dart'; import '../mocks.dart'; import '../mocks.mocks.dart'; @@ -48,7 +47,7 @@ void main() { class Fixture { final hub = MockHub(); - final options = SentryFlutterOptions(dsn: fakeDsn); + final options = defaultTestOptions(); ConnectivityIntegration getSut() { return ConnectivityIntegration(); diff --git a/flutter/test/integrations/debug_print_integration_test.dart b/flutter/test/integrations/debug_print_integration_test.dart index b7804abeeb..67359ade71 100644 --- a/flutter/test/integrations/debug_print_integration_test.dart +++ b/flutter/test/integrations/debug_print_integration_test.dart @@ -94,10 +94,8 @@ class Fixture { bool debug = false, bool enablePrintBreadcrumbs = true, }) { - return SentryFlutterOptions( - dsn: fakeDsn, - checker: MockPlatformChecker(isDebug: debug), - )..enablePrintBreadcrumbs = enablePrintBreadcrumbs; + return defaultTestOptions(MockPlatformChecker(isDebug: debug)) + ..enablePrintBreadcrumbs = enablePrintBreadcrumbs; } DebugPrintIntegration getSut() { diff --git a/flutter/test/integrations/fixture.dart b/flutter/test/integrations/fixture.dart new file mode 100644 index 0000000000..471c6e42b8 --- /dev/null +++ b/flutter/test/integrations/fixture.dart @@ -0,0 +1,23 @@ +import 'dart:async'; + +import 'package:sentry_flutter/sentry_flutter.dart'; +import 'package:sentry_flutter/src/native/sentry_native_binding.dart'; + +import '../mocks.dart'; +import '../mocks.mocks.dart'; + +class IntegrationTestFixture { + late T sut; + late Hub hub; + final options = defaultTestOptions(); + final binding = MockSentryNativeBinding(); + + IntegrationTestFixture(T Function(SentryNativeBinding) factory) { + hub = Hub(options); + sut = factory(binding); + } + + Future registerIntegration() async { + await sut.call(hub, options); + } +} diff --git a/flutter/test/integrations/flutter_error_integration_test.dart b/flutter/test/integrations/flutter_error_integration_test.dart index 401b606bc4..4ec10025cb 100644 --- a/flutter/test/integrations/flutter_error_integration_test.dart +++ b/flutter/test/integrations/flutter_error_integration_test.dart @@ -18,7 +18,8 @@ void main() { void _mockValues() { when(fixture.hub.configureScope(captureAny)).thenAnswer((_) {}); - when(fixture.hub.captureEvent(captureAny, hint: anyNamed('hint'))) + when(fixture.hub.captureEvent(captureAny, + hint: anyNamed('hint'), stackTrace: anyNamed('stackTrace'))) .thenAnswer((_) => Future.value(SentryId.empty())); when(fixture.hub.options).thenReturn(fixture.options); @@ -63,7 +64,11 @@ void main() { _reportError(exception: exception); final event = verify( - await fixture.hub.captureEvent(captureAny, hint: anyNamed('hint')), + await fixture.hub.captureEvent( + captureAny, + hint: anyNamed('hint'), + stackTrace: anyNamed('stackTrace'), + ), ).captured.first as SentryEvent; expect(event.level, SentryLevel.fatal); @@ -77,6 +82,10 @@ void main() { expect(event.contexts['flutter_error_details']['context'], 'thrown while handling a gesture'); expect(event.contexts['flutter_error_details']['information'], 'foo bar'); + }, onPlatform: { + // TODO stacktrace parsing for wasm is not implemented yet + // https://github.com/getsentry/sentry-dart/issues/1480 + 'wasm': Skip('WASM stack trace parsing not implemented yet'), }); test('captures error with long FlutterErrorDetails.information', () async { @@ -95,7 +104,8 @@ void main() { _reportError(exception: StateError('error'), optionalDetails: details); final event = verify( - await fixture.hub.captureEvent(captureAny, hint: anyNamed('hint')), + await fixture.hub.captureEvent(captureAny, + hint: anyNamed('hint'), stackTrace: anyNamed('stackTrace')), ).captured.first as SentryEvent; expect(event.level, SentryLevel.fatal); @@ -109,6 +119,10 @@ void main() { 'thrown while handling a gesture'); expect(event.contexts['flutter_error_details']['information'], 'foo bar\nHello World!'); + }, onPlatform: { + // TODO stacktrace parsing for wasm is not implemented yet + // https://github.com/getsentry/sentry-dart/issues/1480 + 'wasm': Skip('WASM stack trace parsing not implemented yet'), }); test('captures error with no FlutterErrorDetails', () async { @@ -119,7 +133,8 @@ void main() { _reportError(exception: StateError('error'), optionalDetails: details); final event = verify( - await fixture.hub.captureEvent(captureAny, hint: anyNamed('hint')), + await fixture.hub.captureEvent(captureAny, + hint: anyNamed('hint'), stackTrace: anyNamed('stackTrace')), ).captured.first as SentryEvent; expect(event.level, SentryLevel.fatal); @@ -141,7 +156,9 @@ void main() { _reportError(handler: defaultError); verify( - await fixture.hub.captureEvent(captureAny, hint: anyNamed('hint'))); + await fixture.hub.captureEvent(captureAny, + hint: anyNamed('hint'), stackTrace: anyNamed('stackTrace')), + ); expect(called, true); }); @@ -166,8 +183,10 @@ void main() { FlutterError.reportError(details); - verify(await fixture.hub.captureEvent(captureAny, hint: anyNamed('hint'))) - .called(1); + verify( + await fixture.hub.captureEvent(captureAny, + hint: anyNamed('hint'), stackTrace: anyNamed('stackTrace')), + ).called(1); expect(numberOfDefaultCalls, 1); }); @@ -200,6 +219,26 @@ void main() { expect(FlutterError.onError, afterIntegrationOnError); }); + test('captureEvent never uses an empty or null stack trace', () async { + final exception = StateError('error'); + final details = FlutterErrorDetails( + exception: exception, + stack: null, // Explicitly set stack to null + ); + + _reportError(optionalDetails: details); + + final captured = verify( + await fixture.hub.captureEvent(captureAny, + hint: anyNamed('hint'), stackTrace: captureAnyNamed('stackTrace')), + ).captured; + + final stackTrace = captured[1] as StackTrace?; + + expect(stackTrace, isNotNull); + expect(stackTrace.toString(), isNotEmpty); + }); + test('do not capture if silent error', () async { _reportError(silent: true); @@ -211,7 +250,9 @@ void main() { _reportError(silent: true); verify( - await fixture.hub.captureEvent(captureAny, hint: anyNamed('hint'))); + await fixture.hub.captureEvent(captureAny, + hint: anyNamed('hint'), stackTrace: anyNamed('stackTrace')), + ); }); test('adds integration', () { @@ -228,6 +269,14 @@ void main() { final hub = Hub(fixture.options); final client = MockSentryClient(); + when(client.captureEvent(any, + scope: anyNamed('scope'), + stackTrace: anyNamed('stackTrace'), + hint: anyNamed('hint'))) + .thenAnswer((_) => Future.value(SentryId.newId())); + when(client.captureTransaction(any, + scope: anyNamed('scope'), traceContext: anyNamed('traceContext'))) + .thenAnswer((_) => Future.value(SentryId.newId())); hub.bindClient(client); final sut = fixture.getSut(); @@ -255,7 +304,8 @@ void main() { _reportError(exception: exception); final event = verify( - await fixture.hub.captureEvent(captureAny, hint: anyNamed('hint')), + await fixture.hub.captureEvent(captureAny, + hint: anyNamed('hint'), stackTrace: anyNamed('stackTrace')), ).captured.first as SentryEvent; expect(event.level, SentryLevel.error); @@ -265,7 +315,7 @@ void main() { class Fixture { final hub = MockHub(); - final options = SentryFlutterOptions(dsn: fakeDsn)..tracesSampleRate = 1.0; + final options = defaultTestOptions()..tracesSampleRate = 1.0; FlutterErrorIntegration getSut() { return FlutterErrorIntegration(); diff --git a/flutter/test/integrations/init_native_sdk_test.dart b/flutter/test/integrations/init_native_sdk_test.dart index 6d3bd57e22..6f84e946f5 100644 --- a/flutter/test/integrations/init_native_sdk_test.dart +++ b/flutter/test/integrations/init_native_sdk_test.dart @@ -8,6 +8,7 @@ import 'package:sentry_flutter/src/native/sentry_native_channel.dart'; import 'package:sentry_flutter/src/version.dart'; import '../mocks.dart'; +import '../mocks.mocks.dart'; void main() { late Fixture fixture; @@ -25,7 +26,7 @@ void main() { }); var sut = fixture.getSut(channel); - await sut.init(createOptions()); + await sut.init(MockHub()); channel.setMethodCallHandler(null); @@ -64,6 +65,10 @@ void main() { 'connectionTimeoutMillis': 5000, 'readTimeoutMillis': 5000, 'appHangTimeoutIntervalMillis': 2000, + 'replay': { + 'sessionSampleRate': null, + 'onErrorSampleRate': null, + }, }); }); @@ -76,7 +81,7 @@ void main() { }); var sut = fixture.getSut(channel); - final options = createOptions() + fixture.options ..debug = false ..environment = 'foo' ..release = 'foo@bar+1' @@ -104,12 +109,21 @@ void main() { ..enableAppHangTracking = false ..connectionTimeout = Duration(milliseconds: 9001) ..readTimeout = Duration(milliseconds: 9002) - ..appHangTimeoutInterval = Duration(milliseconds: 9003); - - options.sdk.addIntegration('foo'); - options.sdk.addPackage('bar', '1'); - - await sut.init(options); + ..appHangTimeoutInterval = Duration(milliseconds: 9003) + ..proxy = SentryProxy( + host: "localhost", + port: 8080, + type: SentryProxyType.http, + user: 'admin', + pass: '0000', + ) + ..experimental.replay.sessionSampleRate = 0.1 + ..experimental.replay.onErrorSampleRate = 0.2; + + fixture.options.sdk.addIntegration('foo'); + fixture.options.sdk.addPackage('bar', '1'); + + await sut.init(MockHub()); channel.setMethodCallHandler(null); @@ -149,6 +163,17 @@ void main() { 'connectionTimeoutMillis': 9001, 'readTimeoutMillis': 9002, 'appHangTimeoutIntervalMillis': 9003, + 'proxy': { + 'host': 'localhost', + 'port': 8080, + 'type': 'HTTP', + 'user': 'admin', + 'pass': '0000', + }, + 'replay': { + 'sessionSampleRate': 0.1, + 'onErrorSampleRate': 0.2, + }, }); }); } @@ -164,10 +189,7 @@ MethodChannel createChannelWithCallback( SentryFlutterOptions createOptions() { final mockPlatformChecker = MockPlatformChecker(hasNativeIntegration: true); - final options = SentryFlutterOptions( - dsn: fakeDsn, - checker: mockPlatformChecker, - ); + final options = defaultTestOptions(mockPlatformChecker); options.sdk = SdkVersion( name: sdkName, version: sdkVersion, @@ -177,7 +199,9 @@ SentryFlutterOptions createOptions() { } class Fixture { - SentryNativeChannel getSut(MethodChannel native) { - return SentryNativeChannel(native); + late SentryFlutterOptions options; + SentryNativeChannel getSut(MethodChannel channel) { + options = createOptions()..methodChannel = channel; + return SentryNativeChannel(options); } } diff --git a/flutter/test/integrations/load_contexts_integration_test.dart b/flutter/test/integrations/load_contexts_integration_test.dart index a3bbbcbab7..5acce8d264 100644 --- a/flutter/test/integrations/load_contexts_integration_test.dart +++ b/flutter/test/integrations/load_contexts_integration_test.dart @@ -1,40 +1,23 @@ @TestOn('vm') library flutter_test; -import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mockito/mockito.dart'; import 'package:sentry_flutter/sentry_flutter.dart'; import 'package:sentry_flutter/src/integrations/load_contexts_integration.dart'; -import '../mocks.dart'; -import '../mocks.mocks.dart'; +import 'fixture.dart'; void main() { group(LoadContextsIntegration, () { - const _channel = MethodChannel('sentry_flutter'); + late IntegrationTestFixture fixture; - TestWidgetsFlutterBinding.ensureInitialized(); - - late Fixture fixture; - - setUp(() { - fixture = Fixture(); - }); - - tearDown(() { - // ignore: deprecated_member_use - _channel.setMockMethodCallHandler(null); + setUp(() async { + fixture = IntegrationTestFixture(LoadContextsIntegration.new); + await fixture.registerIntegration(); }); test('loadContextsIntegration adds integration', () { - // ignore: deprecated_member_use - _channel.setMockMethodCallHandler((MethodCall methodCall) async {}); - - final integration = LoadContextsIntegration(_channel); - - integration(fixture.hub, fixture.options); - expect( fixture.options.sdk.integrations.contains('loadContextsIntegration'), true); @@ -46,19 +29,10 @@ void main() { final eventBreadcrumb = Breadcrumb(message: 'event'); var event = SentryEvent(breadcrumbs: [eventBreadcrumb]); - final nativeBreadcrumb = Breadcrumb(message: 'native'); - Map loadContexts = { - 'breadcrumbs': [nativeBreadcrumb.toJson()] - }; - - final future = Future.value(loadContexts); - when(fixture.methodChannel.invokeMethod('loadContexts')) - .thenAnswer((_) => future); - // ignore: deprecated_member_use - _channel.setMockMethodCallHandler((MethodCall methodCall) async {}); + when(fixture.binding.loadContexts()).thenAnswer((_) async => { + 'breadcrumbs': [Breadcrumb(message: 'native').toJson()] + }); - final integration = LoadContextsIntegration(fixture.methodChannel); - integration.call(fixture.hub, fixture.options); event = (await fixture.options.eventProcessors.first.apply(event, Hint()))!; @@ -72,19 +46,10 @@ void main() { final eventBreadcrumb = Breadcrumb(message: 'event'); var event = SentryEvent(breadcrumbs: [eventBreadcrumb]); - final nativeBreadcrumb = Breadcrumb(message: 'native'); - Map loadContexts = { - 'breadcrumbs': [nativeBreadcrumb.toJson()] - }; - - final future = Future.value(loadContexts); - when(fixture.methodChannel.invokeMethod('loadContexts')) - .thenAnswer((_) => future); - // ignore: deprecated_member_use - _channel.setMockMethodCallHandler((MethodCall methodCall) async {}); + when(fixture.binding.loadContexts()).thenAnswer((_) async => { + 'breadcrumbs': [Breadcrumb(message: 'native').toJson()] + }); - final integration = LoadContextsIntegration(fixture.methodChannel); - integration.call(fixture.hub, fixture.options); event = (await fixture.options.eventProcessors.first.apply(event, Hint()))!; @@ -105,23 +70,13 @@ void main() { final eventBreadcrumb = Breadcrumb(message: 'event'); var event = SentryEvent(breadcrumbs: [eventBreadcrumb]); - final nativeMutatedBreadcrumb = Breadcrumb(message: 'native-mutated'); - final nativeDeletedBreadcrumb = Breadcrumb(message: 'native-deleted'); - Map loadContexts = { - 'breadcrumbs': [ - nativeMutatedBreadcrumb.toJson(), - nativeDeletedBreadcrumb.toJson(), - ] - }; - - final future = Future.value(loadContexts); - when(fixture.methodChannel.invokeMethod('loadContexts')) - .thenAnswer((_) => future); - // ignore: deprecated_member_use - _channel.setMockMethodCallHandler((MethodCall methodCall) async {}); + when(fixture.binding.loadContexts()).thenAnswer((_) async => { + 'breadcrumbs': [ + Breadcrumb(message: 'native-mutated').toJson(), + Breadcrumb(message: 'native-deleted').toJson(), + ] + }); - final integration = LoadContextsIntegration(fixture.methodChannel); - integration.call(fixture.hub, fixture.options); event = (await fixture.options.eventProcessors.first.apply(event, Hint()))!; @@ -148,16 +103,8 @@ void main() { final options = fixture.options; final user = SentryUser(id: expectedId); - Map loadContexts = {'user': user.toJson()}; - final future = Future.value(loadContexts); - when(fixture.methodChannel.invokeMethod('loadContexts')) - .thenAnswer((_) => future); - // ignore: deprecated_member_use - _channel.setMockMethodCallHandler((MethodCall methodCall) async {}); - - final integration = LoadContextsIntegration(fixture.methodChannel); - options.addIntegration(integration); - options.integrations.first.call(fixture.hub, options); + when(fixture.binding.loadContexts()) + .thenAnswer((_) async => {'user': user.toJson()}); final client = SentryClient(options); final event = SentryEvent(); @@ -169,10 +116,3 @@ void main() { }); }); } - -class Fixture { - final hub = MockHub(); - final options = SentryFlutterOptions(dsn: fakeDsn); - - final methodChannel = MockMethodChannel(); -} diff --git a/flutter/test/integrations/load_contexts_integrations_test.dart b/flutter/test/integrations/load_contexts_integrations_test.dart index 8c7ae456e0..7add3f7929 100644 --- a/flutter/test/integrations/load_contexts_integrations_test.dart +++ b/flutter/test/integrations/load_contexts_integrations_test.dart @@ -1,11 +1,12 @@ @TestOn('vm') library flutter_test; -import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart'; import 'package:sentry_flutter/sentry_flutter.dart'; import 'package:sentry_flutter/src/integrations/load_contexts_integration.dart'; +import '../mocks.dart'; import '../mocks.mocks.dart'; void main() { @@ -84,7 +85,7 @@ void main() { final event = await fixture.options.eventProcessors.first.apply(e, Hint()); - expect(fixture.called, true); + verify(fixture.binding.loadContexts()).called(1); expect(event?.contexts.device?.name, 'Device1'); expect(event?.contexts.app?.name, 'test-app'); expect(event?.contexts.app?.inForeground, true); @@ -124,7 +125,7 @@ void main() { final event = await fixture.options.eventProcessors.first.apply(e, Hint()); - expect(fixture.called, true); + verify(fixture.binding.loadContexts()).called(1); expect(event?.contexts.device?.name, 'eDevice'); expect(event?.contexts.app?.name, 'eApp'); expect(event?.contexts.app?.inForeground, true); @@ -236,10 +237,7 @@ void main() { ); test('should not throw on loadContextsIntegration exception', () async { - // ignore: deprecated_member_use - fixture.channel.setMockMethodCallHandler((MethodCall methodCall) async { - throw Exception(); - }); + when(fixture.binding.loadContexts()).thenThrow(Exception()); final integration = fixture.getSut(); integration(fixture.hub, fixture.options); @@ -426,12 +424,9 @@ void main() { } class Fixture { - final channel = MethodChannel('sentry_flutter'); - final hub = MockHub(); - final options = SentryFlutterOptions(); - - var called = false; + final options = defaultTestOptions(); + final binding = MockSentryNativeBinding(); LoadContextsIntegration getSut( {Map contexts = const { @@ -466,12 +461,7 @@ class Fixture { } ] }}) { - // ignore: deprecated_member_use - channel.setMockMethodCallHandler((MethodCall methodCall) async { - called = true; - return contexts; - }); - - return LoadContextsIntegration(channel); + when(binding.loadContexts()).thenAnswer((_) async => contexts); + return LoadContextsIntegration(binding); } } diff --git a/flutter/test/integrations/load_image_list_test.dart b/flutter/test/integrations/load_image_list_test.dart index f7c72fc95e..35e59b7599 100644 --- a/flutter/test/integrations/load_image_list_test.dart +++ b/flutter/test/integrations/load_image_list_test.dart @@ -1,121 +1,62 @@ @TestOn('vm') library flutter_test; -import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart'; import 'package:sentry_flutter/sentry_flutter.dart'; import 'package:sentry_flutter/src/integrations/load_image_list_integration.dart'; -import '../mocks.dart'; -import '../sentry_flutter_test.dart'; +import 'fixture.dart'; void main() { group(LoadImageListIntegration, () { - TestWidgetsFlutterBinding.ensureInitialized(); - late Fixture fixture; + final imageList = [ + DebugImage.fromJson({ + 'code_file': '/apex/com.android.art/javalib/arm64/boot.oat', + 'code_id': '13577ce71153c228ecf0eb73fc39f45010d487f8', + 'image_addr': '0x6f80b000', + 'image_size': 3092480, + 'type': 'elf', + 'debug_id': 'e77c5713-5311-28c2-ecf0-eb73fc39f450', + 'debug_file': 'test' + }) + ]; + + late IntegrationTestFixture fixture; + + setUp(() async { + fixture = IntegrationTestFixture(LoadImageListIntegration.new); + when(fixture.binding.loadDebugImages()) + .thenAnswer((_) async => imageList); + await fixture.registerIntegration(); + }); + + test('$LoadImageListIntegration adds itself to sdk.integrations', () async { + expect( + fixture.options.sdk.integrations.contains('loadImageListIntegration'), + true, + ); + }); + + test('Native layer is not called as the event is symbolicated', () async { + expect(fixture.options.eventProcessors.length, 1); + + await fixture.hub.captureException(StateError('error'), + stackTrace: StackTrace.current); + + verifyNever(fixture.binding.loadDebugImages()); + }); + + test('Native layer is not called if the event has no stack traces', + () async { + await fixture.hub.captureException(StateError('error')); - tearDown(() { - // ignore: deprecated_member_use - fixture.channel.setMockMethodCallHandler(null); + verifyNever(fixture.binding.loadDebugImages()); }); - for (var platform in [ - MockPlatform.android(), - MockPlatform.iOs(), - MockPlatform.macOs() - ]) { - group(platform.operatingSystem, () { - final imageList = [ - { - 'code_file': '/apex/com.android.art/javalib/arm64/boot.oat', - 'code_id': '13577ce71153c228ecf0eb73fc39f45010d487f8', - 'image_addr': '0x6f80b000', - 'image_size': 3092480, - 'type': 'elf', - 'debug_id': 'e77c5713-5311-28c2-ecf0-eb73fc39f450', - 'debug_file': 'test' - } - ]; - - setUp(() { - fixture = Fixture(platform); - fixture.channel - // ignore: deprecated_member_use - .setMockMethodCallHandler((MethodCall methodCall) async { - return imageList; - }); - }); - - test('$LoadImageListIntegration adds itself to sdk.integrations', - () async { - final sut = fixture.getSut(); - - sut.call(fixture.hub, fixture.options); - - expect( - fixture.options.sdk.integrations - .contains('loadImageListIntegration'), - true, - ); - }); - - test('Native layer is not called as the event is symbolicated', - () async { - var called = false; - - final sut = fixture.getSut(); - fixture.channel - // ignore: deprecated_member_use - .setMockMethodCallHandler((MethodCall methodCall) async { - called = true; - return imageList; - }); - - sut.call(fixture.hub, fixture.options); - - expect(fixture.options.eventProcessors.length, 1); - - await fixture.hub.captureException(StateError('error'), - stackTrace: StackTrace.current); - - expect(called, false); - }); - - test('Native layer is not called if the event has no stack traces', - () async { - var called = false; - - final sut = fixture.getSut(); - fixture.channel - // ignore: deprecated_member_use - .setMockMethodCallHandler((MethodCall methodCall) async { - called = true; - return imageList; - }); - - sut.call(fixture.hub, fixture.options); - - await fixture.hub.captureException(StateError('error')); - - expect(called, false); - }); - - test('Native layer is called because stack traces are not symbolicated', - () async { - var called = false; - - final sut = fixture.getSut(); - fixture.channel - // ignore: deprecated_member_use - .setMockMethodCallHandler((MethodCall methodCall) async { - called = true; - return imageList; - }); - - sut.call(fixture.hub, fixture.options); - - await fixture.hub - .captureException(StateError('error'), stackTrace: ''' + test('Native layer is called because stack traces are not symbolicated', + () async { + await fixture.hub.captureException(StateError('error'), stackTrace: ''' warning: This VM has been configured to produce stack traces that violate the Dart standard. *** *** *** *** *** *** *** *** *** *** *** *** *** *** *** *** pid: 30930, tid: 30990, name 1.ui @@ -126,62 +67,41 @@ void main() { #01 abs 000000723d637527 virt 00000000001f0527 _kDartIsolateSnapshotInstructions+0x1e5527 '''); - expect(called, true); - }); - - test('Event processor adds image list to the event', () async { - final sut = fixture.getSut(); - - sut.call(fixture.hub, fixture.options); - - final ep = fixture.options.eventProcessors.first; - SentryEvent? event = _getEvent(); - event = await ep.apply(event, Hint()); - - expect(1, event!.debugMeta!.images.length); - }); - - test('Event processor asserts image list', () async { - final sut = fixture.getSut(); - - sut.call(fixture.hub, fixture.options); - final ep = fixture.options.eventProcessors.first; - SentryEvent? event = _getEvent(); - event = await ep.apply(event, Hint()); - - final image = event!.debugMeta!.images.first; + verify(fixture.binding.loadDebugImages()).called(1); + }); - expect( - '/apex/com.android.art/javalib/arm64/boot.oat', image.codeFile); - expect('13577ce71153c228ecf0eb73fc39f45010d487f8', image.codeId); - expect('0x6f80b000', image.imageAddr); - expect(3092480, image.imageSize); - expect('elf', image.type); - expect('e77c5713-5311-28c2-ecf0-eb73fc39f450', image.debugId); - expect('test', image.debugFile); - }); + test('Event processor adds image list to the event', () async { + final ep = fixture.options.eventProcessors.first; + expect( + ep.runtimeType.toString(), "_LoadImageListIntegrationEventProcessor"); + SentryEvent? event = _getEvent(); + event = await ep.apply(event, Hint()); - test('Native layer is not called as there is no exceptions', () async { - var called = false; + expect(1, event!.debugMeta!.images.length); + }); - final sut = fixture.getSut(); - fixture.channel - // ignore: deprecated_member_use - .setMockMethodCallHandler((MethodCall methodCall) async { - called = true; - return imageList; - }); + test('Event processor asserts image list', () async { + final ep = fixture.options.eventProcessors.first; + SentryEvent? event = _getEvent(); + event = await ep.apply(event, Hint()); - sut.call(fixture.hub, fixture.options); + final image = event!.debugMeta!.images.first; - expect(fixture.options.eventProcessors.length, 1); + expect('/apex/com.android.art/javalib/arm64/boot.oat', image.codeFile); + expect('13577ce71153c228ecf0eb73fc39f45010d487f8', image.codeId); + expect('0x6f80b000', image.imageAddr); + expect(3092480, image.imageSize); + expect('elf', image.type); + expect('e77c5713-5311-28c2-ecf0-eb73fc39f450', image.debugId); + expect('test', image.debugFile); + }); - await fixture.hub.captureMessage('error'); + test('Native layer is not called as there is no exceptions', () async { + expect(fixture.options.eventProcessors.length, 1); - expect(called, false); - }); - }); - } + await fixture.hub.captureMessage('error'); + verifyNever(fixture.binding.loadDebugImages()); + }); }); } @@ -190,19 +110,3 @@ SentryEvent _getEvent() { final st = SentryStackTrace(frames: [frame]); return SentryEvent(threads: [SentryThread(stacktrace: st)]); } - -class Fixture { - late final Hub hub; - late final SentryFlutterOptions options; - final channel = MethodChannel('sentry_flutter'); - - Fixture(MockPlatform platform) { - options = SentryFlutterOptions( - dsn: fakeDsn, checker: getPlatformChecker(platform: platform)); - hub = Hub(options); - } - - LoadImageListIntegration getSut() { - return LoadImageListIntegration(channel); - } -} diff --git a/flutter/test/integrations/load_release_integration_test.dart b/flutter/test/integrations/load_release_integration_test.dart index 0af572bd8b..3d089b03d9 100644 --- a/flutter/test/integrations/load_release_integration_test.dart +++ b/flutter/test/integrations/load_release_integration_test.dart @@ -143,7 +143,7 @@ void main() { } class Fixture { - final options = SentryFlutterOptions(dsn: fakeDsn); + final options = defaultTestOptions(); LoadReleaseIntegration getIntegration({Function? loader}) { if (loader != null) { diff --git a/flutter/test/integrations/native_app_start_handler_test.dart b/flutter/test/integrations/native_app_start_handler_test.dart new file mode 100644 index 0000000000..925879ea6a --- /dev/null +++ b/flutter/test/integrations/native_app_start_handler_test.dart @@ -0,0 +1,348 @@ +@TestOn('vm') +library flutter_test; + +import 'package:collection/collection.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart'; +import 'package:sentry_flutter/sentry_flutter.dart'; +import 'package:sentry_flutter/src/integrations/integrations.dart'; +import 'package:sentry_flutter/src/integrations/native_app_start_handler.dart'; +import 'package:sentry_flutter/src/integrations/native_app_start_integration.dart'; +import 'package:sentry/src/sentry_tracer.dart'; +import 'package:sentry_flutter/src/native/native_app_start.dart'; + +import '../mocks.dart'; +import '../mocks.mocks.dart'; + +void main() { + void setupMocks(Fixture fixture) { + when(fixture.hub.startTransaction( + 'root /', + 'ui.load', + description: null, + startTimestamp: anyNamed('startTimestamp'), + )).thenReturn(fixture.tracer); + + when(fixture.hub.configureScope(captureAny)).thenAnswer((_) {}); + when(fixture.hub.captureTransaction( + any, + traceContext: anyNamed('traceContext'), + )).thenAnswer((_) async => SentryId.empty()); + + when(fixture.nativeBinding.fetchNativeAppStart()).thenAnswer( + (_) async => fixture.nativeAppStart, + ); + } + + group('$NativeAppStartIntegration', () { + late Fixture fixture; + + setUp(() { + fixture = Fixture(); + setupMocks(fixture); + }); + + test('native app start measurement added to first transaction', () async { + await fixture.call( + appStartEnd: DateTime.fromMillisecondsSinceEpoch(10), + ); + final transaction = fixture.capturedTransaction(); + + final measurement = transaction.measurements['app_start_cold']!; + expect(measurement.value, 10); + expect(measurement.unit, DurationSentryMeasurementUnit.milliSecond); + }); + + test('measurements appended', () async { + await fixture.call( + appStartEnd: DateTime.fromMillisecondsSinceEpoch(10), + ); + + final measurement = SentryMeasurement.warmAppStart(Duration(seconds: 1)); + + final transaction = fixture.capturedTransaction().copyWith(); + transaction.measurements[measurement.name] = measurement; + + expect(transaction.measurements.length, 2); + expect(transaction.measurements.containsKey(measurement.name), true); + }); + + test('native app start measurement not added if more than 60s', () async { + await fixture.call( + appStartEnd: DateTime.fromMillisecondsSinceEpoch(60001), + ); + + verifyNever(fixture.hub.captureTransaction( + captureAny, + traceContext: captureAnyNamed('traceContext'), + )); + }); + + test( + 'autoAppStart is false and appStartEnd is not set does not add app start measurement', + () async { + fixture.options.autoAppStart = false; + await fixture.call( + appStartEnd: null, + ); + + final transaction = fixture.capturedTransaction(); + + expect(transaction.measurements.isEmpty, true); + expect(transaction.spans.length, + 1); // Only containing ui.load.initial_display + expect(transaction.spans[0].context.operation, 'ui.load.initial_display'); + }); + + test( + 'autoAppStart is false and appStartEnd is set adds app start measurement', + () async { + fixture.options.autoAppStart = false; + + await fixture.call( + appStartEnd: DateTime.fromMillisecondsSinceEpoch(10), + ); + + final transaction = fixture.capturedTransaction(); + + final measurement = transaction.measurements['app_start_cold']!; + expect(measurement.value, 10); + expect(measurement.unit, DurationSentryMeasurementUnit.milliSecond); + + final spans = transaction.spans; + + final appStartSpan = spans.firstWhereOrNull( + (element) => element.context.description == 'Cold Start'); + + final pluginRegistrationSpan = spans.firstWhereOrNull((element) => + element.context.description == 'App start to plugin registration'); + + final sentrySetupSpan = spans.firstWhereOrNull((element) => + element.context.description == 'Before Sentry Init Setup'); + + final firstFrameRenderSpan = spans.firstWhereOrNull( + (element) => element.context.description == 'First frame render'); + + expect(appStartSpan, isNotNull); + expect(pluginRegistrationSpan, isNotNull); + expect(sentrySetupSpan, isNotNull); + expect(firstFrameRenderSpan, isNotNull); + }); + }); + + group('App start spans', () { + late SentrySpan? coldStartSpan, + pluginRegistrationSpan, + sentrySetupSpan, + firstFrameRenderSpan; + // ignore: invalid_use_of_internal_member + late SentryTracer tracer; + late Fixture fixture; + late SentryTransaction enriched; + + final validNativeSpanTimes = { + 'correct span description': { + 'startTimestampMsSinceEpoch': 1, + 'stopTimestampMsSinceEpoch': 2, + }, + 'correct span description 2': { + 'startTimestampMsSinceEpoch': 4, + 'stopTimestampMsSinceEpoch': 6, + }, + 'correct span description 3': { + 'startTimestampMsSinceEpoch': 3, + 'stopTimestampMsSinceEpoch': 4, + }, + }; + + final invalidNativeSpanTimes = { + 'failing span with null timestamp': { + 'startTimestampMsSinceEpoch': null, + 'stopTimestampMsSinceEpoch': 3, + }, + 'failing span with string timestamp': { + 'startTimestampMsSinceEpoch': '1', + 'stopTimestampMsSinceEpoch': 3, + }, + }; + + final appStartInfoSrc = NativeAppStart( + appStartTime: 0, + pluginRegistrationTime: 10, + isColdStart: true, + nativeSpanTimes: { + ...validNativeSpanTimes, + ...invalidNativeSpanTimes, + }); + + setUp(() async { + fixture = Fixture(); + tracer = fixture.tracer; + + // dartLoadingEnd needs to be set after engine end (see MockNativeChannel) + SentryFlutter.sentrySetupStartTime = + DateTime.fromMillisecondsSinceEpoch(15); + + setupMocks(fixture); + + when(fixture.nativeBinding.fetchNativeAppStart()) + .thenAnswer((_) async => appStartInfoSrc); + + await fixture.call(appStartEnd: DateTime.fromMillisecondsSinceEpoch(50)); + enriched = fixture.capturedTransaction(); + + final spans = enriched.spans; + + coldStartSpan = spans.firstWhereOrNull( + (element) => element.context.description == 'Cold Start'); + + pluginRegistrationSpan = spans.firstWhereOrNull((element) => + element.context.description == 'App start to plugin registration'); + + sentrySetupSpan = spans.firstWhereOrNull((element) => + element.context.description == 'Before Sentry Init Setup'); + + firstFrameRenderSpan = spans.firstWhereOrNull( + (element) => element.context.description == 'First frame render'); + }); + + test('includes only valid native spans', () async { + final spans = + enriched.spans.where((element) => element.data['native'] == true); + + expect(spans.length, validNativeSpanTimes.length); + + for (final span in spans) { + final validSpan = validNativeSpanTimes[span.context.description]; + expect(validSpan, isNotNull); + expect( + span.startTimestamp, + DateTime.fromMillisecondsSinceEpoch( + validSpan!['startTimestampMsSinceEpoch']!) + .toUtc()); + expect( + span.endTimestamp, + DateTime.fromMillisecondsSinceEpoch( + validSpan['stopTimestampMsSinceEpoch']!) + .toUtc()); + } + }); + + test('are correctly ordered', () async { + final spans = + enriched.spans.where((element) => element.data['native'] == true); + + final orderedSpans = spans.toList() + ..sort((a, b) => a.startTimestamp.compareTo(b.startTimestamp)); + + expect(spans, orderedEquals(orderedSpans)); + }); + + test('ignores invalid spans', () async { + final spans = + enriched.spans.where((element) => element.data['native'] == true); + + expect(spans, isNot(contains('failing span'))); + }); + + test('are added by event processor', () async { + expect(coldStartSpan, isNotNull); + expect(pluginRegistrationSpan, isNotNull); + expect(sentrySetupSpan, isNotNull); + expect(firstFrameRenderSpan, isNotNull); + }); + + test('have correct op', () async { + const op = 'app.start.cold'; + expect(coldStartSpan?.context.operation, op); + expect(pluginRegistrationSpan?.context.operation, op); + expect(sentrySetupSpan?.context.operation, op); + expect(firstFrameRenderSpan?.context.operation, op); + }); + + test('have correct parents', () async { + expect(coldStartSpan?.context.parentSpanId, tracer.context.spanId); + expect(pluginRegistrationSpan?.context.parentSpanId, + coldStartSpan?.context.spanId); + expect( + sentrySetupSpan?.context.parentSpanId, coldStartSpan?.context.spanId); + expect(firstFrameRenderSpan?.context.parentSpanId, + coldStartSpan?.context.spanId); + }); + + test('have correct traceId', () async { + final traceId = tracer.context.traceId; + expect(coldStartSpan?.context.traceId, traceId); + expect(pluginRegistrationSpan?.context.traceId, traceId); + expect(sentrySetupSpan?.context.traceId, traceId); + expect(firstFrameRenderSpan?.context.traceId, traceId); + }); + + test('have correct startTimestamp', () async { + final appStartTime = DateTime.fromMillisecondsSinceEpoch( + appStartInfoSrc.appStartTime.toInt()) + .toUtc(); + expect(coldStartSpan?.startTimestamp, appStartTime); + expect(pluginRegistrationSpan?.startTimestamp, appStartTime); + expect(sentrySetupSpan?.startTimestamp, + pluginRegistrationSpan?.endTimestamp); + expect( + firstFrameRenderSpan?.startTimestamp, sentrySetupSpan?.endTimestamp); + }); + + test('have correct endTimestamp', () async { + final appStartEnd = DateTime.fromMillisecondsSinceEpoch(50); + + final engineReadyEndtime = DateTime.fromMillisecondsSinceEpoch( + appStartInfoSrc.pluginRegistrationTime.toInt()) + .toUtc(); + expect(coldStartSpan?.endTimestamp, appStartEnd.toUtc()); + expect(pluginRegistrationSpan?.endTimestamp, engineReadyEndtime); + expect(sentrySetupSpan?.endTimestamp, + SentryFlutter.sentrySetupStartTime?.toUtc()); + expect(firstFrameRenderSpan?.endTimestamp, coldStartSpan?.endTimestamp); + }); + }); +} + +class Fixture { + final options = SentryFlutterOptions(dsn: fakeDsn); + final nativeBinding = MockSentryNativeBinding(); + final hub = MockHub(); + + late final tracer = SentryTracer( + SentryTransactionContext( + 'name', + 'op', + samplingDecision: SentryTracesSamplingDecision(true), + ), + hub, + startTimestamp: DateTime.fromMillisecondsSinceEpoch(0), + ); + + final nativeAppStart = NativeAppStart( + appStartTime: 0, + pluginRegistrationTime: 10, + isColdStart: true, + nativeSpanTimes: {}, + ); + + late final sut = NativeAppStartHandler(nativeBinding); + + Fixture() { + when(hub.options).thenReturn(options); + SentryFlutter.sentrySetupStartTime = DateTime.now().toUtc(); + } + + Future call({DateTime? appStartEnd}) async { + await sut.call(hub, options, appStartEnd: appStartEnd); + } + + SentryTransaction capturedTransaction() { + final args = verify(hub.captureTransaction( + captureAny, + traceContext: captureAnyNamed('traceContext'), + )).captured; + return args[0] as SentryTransaction; + } +} diff --git a/flutter/test/integrations/native_app_start_integration_test.dart b/flutter/test/integrations/native_app_start_integration_test.dart index eb13b41a8d..02dbee7116 100644 --- a/flutter/test/integrations/native_app_start_integration_test.dart +++ b/flutter/test/integrations/native_app_start_integration_test.dart @@ -1,436 +1,124 @@ @TestOn('vm') library flutter_test; -import 'package:collection/collection.dart'; +import 'dart:core'; + import 'package:flutter_test/flutter_test.dart'; import 'package:mockito/mockito.dart'; import 'package:sentry_flutter/sentry_flutter.dart'; +import 'package:sentry_flutter/src/integrations/integrations.dart'; +import 'package:sentry_flutter/src/integrations/native_app_start_handler.dart'; import 'package:sentry_flutter/src/integrations/native_app_start_integration.dart'; -import 'package:sentry_flutter/src/native/sentry_native.dart'; -import 'package:sentry/src/sentry_tracer.dart'; -import '../fake_frame_callback_handler.dart'; +import '../mock_frame_callback_handler.dart'; import '../mocks.dart'; import '../mocks.mocks.dart'; void main() { - void setupMocks(Fixture fixture) { - when(fixture.hub.startTransaction('root /', 'ui.load', - description: null, startTimestamp: anyNamed('startTimestamp'))) - .thenReturn(fixture.createTracer()); - when(fixture.hub.configureScope(captureAny)).thenAnswer((_) {}); - when(fixture.hub - .captureTransaction(any, traceContext: anyNamed('traceContext'))) - .thenAnswer((_) async => SentryId.empty()); - } - - group('$NativeAppStartIntegration', () { - late Fixture fixture; - - setUp(() { - TestWidgetsFlutterBinding.ensureInitialized(); - - fixture = Fixture(); - setupMocks(fixture); - - NativeAppStartIntegration.clearAppStartInfo(); - }); - - test('native app start measurement added to first transaction', () async { - fixture.native.appStartEnd = DateTime.fromMillisecondsSinceEpoch(10); - fixture.binding.nativeAppStart = NativeAppStart( - appStartTime: 0, - pluginRegistrationTime: 10, - isColdStart: true, - nativeSpanTimes: {}); - - fixture.getNativeAppStartIntegration().call(fixture.hub, fixture.options); - - final tracer = fixture.createTracer(); - final transaction = SentryTransaction(tracer); - - final processor = fixture.options.eventProcessors.first; - final enriched = - await processor.apply(transaction, Hint()) as SentryTransaction; - - final measurement = enriched.measurements['app_start_cold']!; - expect(measurement.value, 10); - expect(measurement.unit, DurationSentryMeasurementUnit.milliSecond); - }); - - test('native app start measurement not added to following transactions', - () async { - fixture.native.appStartEnd = DateTime.fromMillisecondsSinceEpoch(10); - fixture.binding.nativeAppStart = NativeAppStart( - appStartTime: 0, - pluginRegistrationTime: 10, - isColdStart: true, - nativeSpanTimes: {}); - - fixture.getNativeAppStartIntegration().call(fixture.hub, fixture.options); - - final tracer = fixture.createTracer(); - final transaction = SentryTransaction(tracer); - - final processor = fixture.options.eventProcessors.first; - - var enriched = - await processor.apply(transaction, Hint()) as SentryTransaction; - var secondEnriched = - await processor.apply(enriched, Hint()) as SentryTransaction; - - expect(secondEnriched.measurements.length, 1); - }); - - test('measurements appended', () async { - fixture.native.appStartEnd = DateTime.fromMillisecondsSinceEpoch(10); - fixture.binding.nativeAppStart = NativeAppStart( - appStartTime: 0, - pluginRegistrationTime: 10, - isColdStart: true, - nativeSpanTimes: {}); - final measurement = SentryMeasurement.warmAppStart(Duration(seconds: 1)); - - fixture.getNativeAppStartIntegration().call(fixture.hub, fixture.options); - - final tracer = fixture.createTracer(); - final transaction = SentryTransaction(tracer).copyWith(); - transaction.measurements[measurement.name] = measurement; - - final processor = fixture.options.eventProcessors.first; - - var enriched = - await processor.apply(transaction, Hint()) as SentryTransaction; - var secondEnriched = - await processor.apply(enriched, Hint()) as SentryTransaction; - - expect(secondEnriched.measurements.length, 2); - expect(secondEnriched.measurements.containsKey(measurement.name), true); - }); - - test('native app start measurement not added if more than 60s', () async { - fixture.native.appStartEnd = DateTime.fromMillisecondsSinceEpoch(60001); - fixture.binding.nativeAppStart = NativeAppStart( - appStartTime: 0, - pluginRegistrationTime: 10, - isColdStart: true, - nativeSpanTimes: {}); - - fixture.getNativeAppStartIntegration().call(fixture.hub, fixture.options); - - final tracer = fixture.createTracer(); - final transaction = SentryTransaction(tracer); - - final processor = fixture.options.eventProcessors.first; - final enriched = - await processor.apply(transaction, Hint()) as SentryTransaction; - - expect(enriched.measurements.isEmpty, true); - }); - - test('native app start integration is called and sets app start info', - () async { - fixture.native.appStartEnd = DateTime.fromMillisecondsSinceEpoch(10); - fixture.binding.nativeAppStart = NativeAppStart( - appStartTime: 0, - pluginRegistrationTime: 10, - isColdStart: true, - nativeSpanTimes: {}); - - fixture.getNativeAppStartIntegration().call(fixture.hub, fixture.options); - - final appStartInfo = await NativeAppStartIntegration.getAppStartInfo(); - expect(appStartInfo?.start, DateTime.fromMillisecondsSinceEpoch(0)); - expect(appStartInfo?.end, DateTime.fromMillisecondsSinceEpoch(10)); - }); - - test( - 'autoAppStart is false and appStartEnd is not set does not add app start measurement', - () async { - fixture.options.autoAppStart = false; - fixture.binding.nativeAppStart = NativeAppStart( - appStartTime: 0, - pluginRegistrationTime: 10, - isColdStart: true, - nativeSpanTimes: {}); + late Fixture fixture; - fixture.getNativeAppStartIntegration().call(fixture.hub, fixture.options); - - final tracer = fixture.createTracer(); - final transaction = SentryTransaction(tracer); - - final processor = fixture.options.eventProcessors.first; - final enriched = - await processor.apply(transaction, Hint()) as SentryTransaction; - - expect(enriched.measurements.isEmpty, true); - expect(enriched.spans.isEmpty, true); - }); - - test( - 'autoAppStart is false and appStartEnd is set adds app start measurement', - () async { - fixture.options.autoAppStart = false; - fixture.binding.nativeAppStart = NativeAppStart( - appStartTime: 0, - pluginRegistrationTime: 10, - isColdStart: true, - nativeSpanTimes: {}); - SentryFlutter.native = fixture.native; - - fixture.getNativeAppStartIntegration().call(fixture.hub, fixture.options); - - SentryFlutter.setAppStartEnd(DateTime.fromMillisecondsSinceEpoch(10)); - - final tracer = fixture.createTracer(); - final transaction = SentryTransaction(tracer); - - final processor = fixture.options.eventProcessors.first; - final enriched = - await processor.apply(transaction, Hint()) as SentryTransaction; - - final measurement = enriched.measurements['app_start_cold']!; - expect(measurement.value, 10); - expect(measurement.unit, DurationSentryMeasurementUnit.milliSecond); - - final appStartInfo = await NativeAppStartIntegration.getAppStartInfo(); - - final appStartSpan = enriched.spans.firstWhereOrNull((element) => - element.context.description == appStartInfo!.appStartTypeDescription); - final pluginRegistrationSpan = enriched.spans.firstWhereOrNull( - (element) => - element.context.description == - appStartInfo!.pluginRegistrationDescription); - final sentrySetupSpan = enriched.spans.firstWhereOrNull((element) => - element.context.description == appStartInfo!.sentrySetupDescription); - final firstFrameRenderSpan = enriched.spans.firstWhereOrNull((element) => - element.context.description == - appStartInfo!.firstFrameRenderDescription); - - expect(appStartSpan, isNotNull); - expect(pluginRegistrationSpan, isNotNull); - expect(sentrySetupSpan, isNotNull); - expect(firstFrameRenderSpan, isNotNull); - }); + setUp(() { + fixture = Fixture(); }); - group('App start spans', () { - late SentrySpan? coldStartSpan, - pluginRegistrationSpan, - sentrySetupSpan, - firstFrameRenderSpan; - // ignore: invalid_use_of_internal_member - late SentryTracer tracer; - late Fixture fixture; - late SentryTransaction enriched; - - final validNativeSpanTimes = { - 'correct span description': { - 'startTimestampMsSinceEpoch': 1, - 'stopTimestampMsSinceEpoch': 2, - }, - 'correct span description 2': { - 'startTimestampMsSinceEpoch': 4, - 'stopTimestampMsSinceEpoch': 6, - }, - 'correct span description 3': { - 'startTimestampMsSinceEpoch': 3, - 'stopTimestampMsSinceEpoch': 4, - }, - }; - - final invalidNativeSpanTimes = { - 'failing span with null timestamp': { - 'startTimestampMsSinceEpoch': null, - 'stopTimestampMsSinceEpoch': 3, - }, - 'failing span with string timestamp': { - 'startTimestampMsSinceEpoch': '1', - 'stopTimestampMsSinceEpoch': 3, - }, - }; - - final allNativeSpanTimes = { - ...validNativeSpanTimes, - ...invalidNativeSpanTimes, - }; - - setUp(() async { - TestWidgetsFlutterBinding.ensureInitialized(); + test('$NativeAppStartIntegration adds integration', () async { + fixture.callIntegration(); - fixture = Fixture(); - NativeAppStartIntegration.clearAppStartInfo(); - - fixture.native.appStartEnd = DateTime.fromMillisecondsSinceEpoch(50); - fixture.binding.nativeAppStart = NativeAppStart( - appStartTime: 0, - pluginRegistrationTime: 10, - isColdStart: true, - nativeSpanTimes: allNativeSpanTimes); - // dartLoadingEnd needs to be set after engine end (see MockNativeChannel) - SentryFlutter.sentrySetupStartTime = - DateTime.fromMillisecondsSinceEpoch(15); - - setupMocks(fixture); - fixture.getNativeAppStartIntegration().call(fixture.hub, fixture.options); - - final processor = fixture.options.eventProcessors.first; - tracer = fixture.createTracer(); - final transaction = SentryTransaction(tracer); - enriched = - await processor.apply(transaction, Hint()) as SentryTransaction; - - final appStartInfo = await NativeAppStartIntegration.getAppStartInfo(); - - coldStartSpan = enriched.spans.firstWhereOrNull((element) => - element.context.description == appStartInfo?.appStartTypeDescription); - pluginRegistrationSpan = enriched.spans.firstWhereOrNull((element) => - element.context.description == - appStartInfo?.pluginRegistrationDescription); - sentrySetupSpan = enriched.spans.firstWhereOrNull((element) => - element.context.description == appStartInfo?.sentrySetupDescription); - firstFrameRenderSpan = enriched.spans.firstWhereOrNull((element) => - element.context.description == - appStartInfo?.firstFrameRenderDescription); - }); - - test('native app start spans not added to following transactions', - () async { - final processor = fixture.options.eventProcessors.first; + expect( + fixture.options.sdk.integrations.contains('nativeAppStartIntegration'), + true); + }); - final transaction = SentryTransaction(fixture.createTracer()); + test('$NativeAppStartIntegration adds postFrameCallback', () async { + fixture.callIntegration(); - final secondEnriched = - await processor.apply(transaction, Hint()) as SentryTransaction; + expect(fixture.frameCallbackHandler.postFrameCallback, isNotNull); + }); - expect(secondEnriched.spans.length, 0); - }); + test( + '$NativeAppStartIntegration postFrameCallback calls nativeAppStartHandler', + () async { + fixture.callIntegration(); - test('includes only valid native spans', () async { - final spans = - enriched.spans.where((element) => element.data['native'] == true); + final appStartEnd = DateTime.fromMicrosecondsSinceEpoch(50); + fixture.sut.appStartEnd = appStartEnd; - expect(spans.length, validNativeSpanTimes.length); + final postFrameCallback = fixture.frameCallbackHandler.postFrameCallback!; + postFrameCallback(Duration(seconds: 0)); - for (final span in spans) { - final validSpan = validNativeSpanTimes[span.context.description]; - expect(validSpan, isNotNull); - expect( - span.startTimestamp, - DateTime.fromMillisecondsSinceEpoch( - validSpan!['startTimestampMsSinceEpoch']!) - .toUtc()); - expect( - span.endTimestamp, - DateTime.fromMillisecondsSinceEpoch( - validSpan['stopTimestampMsSinceEpoch']!) - .toUtc()); - } - }); + expect(fixture.nativeAppStartHandler.calls, 1); + expect(fixture.nativeAppStartHandler.appStartEnd, appStartEnd); + }); - test('are correctly ordered', () async { - final spans = - enriched.spans.where((element) => element.data['native'] == true); + test( + '$NativeAppStartIntegration with disabled auto app start waits until appStartEnd is set', + () async { + fixture.options.autoAppStart = false; - final orderedSpans = spans.toList() - ..sort((a, b) => a.startTimestamp.compareTo(b.startTimestamp)); + fixture.callIntegration(); + final postFrameCallback = fixture.frameCallbackHandler.postFrameCallback!; + postFrameCallback(Duration(seconds: 0)); - expect(spans, orderedEquals(orderedSpans)); - }); + expect(fixture.nativeAppStartHandler.calls, 0); - test('ignores invalid spans', () async { - final spans = - enriched.spans.where((element) => element.data['native'] == true); + final appStartEnd = DateTime.fromMicrosecondsSinceEpoch(50); + fixture.sut.appStartEnd = appStartEnd; - expect(spans, isNot(contains('failing span'))); - }); + await Future.delayed(Duration(milliseconds: 10)); - test('are added by event processor', () async { - expect(coldStartSpan, isNotNull); - expect(pluginRegistrationSpan, isNotNull); - expect(sentrySetupSpan, isNotNull); - expect(firstFrameRenderSpan, isNotNull); - }); + expect(fixture.frameCallbackHandler.postFrameCallback, isNotNull); + expect(fixture.nativeAppStartHandler.calls, 1); + expect(fixture.nativeAppStartHandler.appStartEnd, appStartEnd); + }); - test('have correct op', () async { - const op = 'app.start.cold'; - expect(coldStartSpan?.context.operation, op); - expect(pluginRegistrationSpan?.context.operation, op); - expect(sentrySetupSpan?.context.operation, op); - expect(firstFrameRenderSpan?.context.operation, op); - }); + test( + '$NativeAppStartIntegration with disabled auto app start waits until timeout', + () async { + fixture.options.autoAppStart = false; - test('have correct parents', () async { - expect(coldStartSpan?.context.parentSpanId, tracer.context.spanId); - expect(pluginRegistrationSpan?.context.parentSpanId, - coldStartSpan?.context.spanId); - expect( - sentrySetupSpan?.context.parentSpanId, coldStartSpan?.context.spanId); - expect(firstFrameRenderSpan?.context.parentSpanId, - coldStartSpan?.context.spanId); - }); + fixture.callIntegration(); + final postFrameCallback = fixture.frameCallbackHandler.postFrameCallback!; + postFrameCallback(Duration(seconds: 0)); - test('have correct traceId', () async { - final traceId = tracer.context.traceId; - expect(coldStartSpan?.context.traceId, traceId); - expect(pluginRegistrationSpan?.context.traceId, traceId); - expect(sentrySetupSpan?.context.traceId, traceId); - expect(firstFrameRenderSpan?.context.traceId, traceId); - }); + expect(fixture.nativeAppStartHandler.calls, 0); - test('have correct startTimestamp', () async { - final appStartTime = DateTime.fromMillisecondsSinceEpoch( - fixture.binding.nativeAppStart!.appStartTime.toInt()) - .toUtc(); - expect(coldStartSpan?.startTimestamp, appStartTime); - expect(pluginRegistrationSpan?.startTimestamp, appStartTime); - expect(sentrySetupSpan?.startTimestamp, - pluginRegistrationSpan?.endTimestamp); - expect( - firstFrameRenderSpan?.startTimestamp, sentrySetupSpan?.endTimestamp); - }); + await Future.delayed(Duration(seconds: 11)); - test('have correct endTimestamp', () async { - final engineReadyEndtime = DateTime.fromMillisecondsSinceEpoch( - fixture.binding.nativeAppStart!.pluginRegistrationTime.toInt()) - .toUtc(); - expect(coldStartSpan?.endTimestamp, fixture.native.appStartEnd?.toUtc()); - expect(pluginRegistrationSpan?.endTimestamp, engineReadyEndtime); - expect(sentrySetupSpan?.endTimestamp, - SentryFlutter.sentrySetupStartTime?.toUtc()); - expect(firstFrameRenderSpan?.endTimestamp, coldStartSpan?.endTimestamp); - }); + expect(fixture.frameCallbackHandler.postFrameCallback, isNotNull); + expect(fixture.nativeAppStartHandler.calls, 0); + expect(fixture.nativeAppStartHandler.appStartEnd, null); }); } class Fixture { - final hub = MockHub(); final options = SentryFlutterOptions(dsn: fakeDsn); - final binding = MockNativeChannel(); - late final native = SentryNative(options, binding); + final hub = MockHub(); + + final frameCallbackHandler = MockFrameCallbackHandler(); + final nativeAppStartHandler = MockNativeAppStartHandler(); + + late NativeAppStartIntegration sut = NativeAppStartIntegration( + frameCallbackHandler, + nativeAppStartHandler, + ); Fixture() { - native.reset(); when(hub.options).thenReturn(options); - SentryFlutter.sentrySetupStartTime = DateTime.now().toUtc(); } - NativeAppStartIntegration getNativeAppStartIntegration() { - return NativeAppStartIntegration( - native, - FakeFrameCallbackHandler(), - ); + void callIntegration() { + sut.call(hub, options); } +} + +class MockNativeAppStartHandler implements NativeAppStartHandler { + DateTime? appStartEnd; + var calls = 0; - // ignore: invalid_use_of_internal_member - SentryTracer createTracer({ - bool? sampled = true, - }) { - final context = SentryTransactionContext( - 'name', - 'op', - samplingDecision: SentryTracesSamplingDecision(sampled!), - ); - return SentryTracer(context, hub); + @override + Future call(Hub hub, SentryFlutterOptions options, + {required DateTime? appStartEnd}) async { + this.appStartEnd = appStartEnd; + calls += 1; } } diff --git a/flutter/test/integrations/native_sdk_integration_test.dart b/flutter/test/integrations/native_sdk_integration_test.dart index 1826bd38f2..7c25cec7b7 100644 --- a/flutter/test/integrations/native_sdk_integration_test.dart +++ b/flutter/test/integrations/native_sdk_integration_test.dart @@ -1,110 +1,75 @@ @TestOn('vm') library flutter_test; -import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart'; import 'package:sentry_flutter/sentry_flutter.dart'; import 'package:sentry_flutter/src/integrations/native_sdk_integration.dart'; -import '../mocks.dart'; import '../mocks.mocks.dart'; +import 'fixture.dart'; void main() { group(NativeSdkIntegration, () { - const _channel = MethodChannel('sentry_flutter'); - - TestWidgetsFlutterBinding.ensureInitialized(); - - late Fixture fixture; + late IntegrationTestFixture fixture; setUp(() { - fixture = Fixture(); - }); - - tearDown(() { - // ignore: deprecated_member_use - _channel.setMockMethodCallHandler(null); + fixture = IntegrationTestFixture(NativeSdkIntegration.new); }); test('adds integration', () async { - // ignore: deprecated_member_use - _channel.setMockMethodCallHandler((MethodCall methodCall) async {}); - final mock = TestMockSentryNative(); - final integration = NativeSdkIntegration(mock); - - await integration(fixture.hub, fixture.options); - + await fixture.registerIntegration(); expect( fixture.options.sdk.integrations, contains('nativeSdkIntegration')); - expect(mock.numberOfInitCalls, 1); + verify(fixture.binding.init(any)).called(1); }); - test('do not throw', () async { - final integration = NativeSdkIntegration(_ThrowingMockSentryNative()); + test('does not throw', () async { + fixture.options.automatedTestMode = false; - await integration(fixture.hub, fixture.options); + fixture.sut = NativeSdkIntegration(_ThrowingMockSentryNative()); + await fixture.registerIntegration(); + expect(fixture.options.sdk.integrations.contains('nativeSdkIntegration'), + false); + }); + test('rethrows in tests', () async { + fixture.sut = NativeSdkIntegration(_ThrowingMockSentryNative()); + expect(fixture.registerIntegration, throwsException); expect(fixture.options.sdk.integrations.contains('nativeSdkIntegration'), false); }); test('closes native SDK', () async { - final mock = TestMockSentryNative(); - final integration = NativeSdkIntegration(mock); - - await integration.call(fixture.hub, fixture.options); - await integration.close(); - - expect(mock.numberOfCloseCalls, 1); + await fixture.registerIntegration(); + await fixture.sut.close(); + verify(fixture.binding.close()).called(1); }); test('does not call native sdk when auto init disabled', () async { - final mock = TestMockSentryNative(); - final integration = NativeSdkIntegration(mock); fixture.options.autoInitializeNativeSdk = false; - - await integration.call(fixture.hub, fixture.options); - - expect(mock.numberOfInitCalls, 0); + await fixture.registerIntegration(); + verifyNever(fixture.binding.init(any)); }); test('does not close native when auto init disabled', () async { - final mock = TestMockSentryNative(); - final integration = NativeSdkIntegration(mock); fixture.options.autoInitializeNativeSdk = false; - - await integration(fixture.hub, fixture.options); - await integration.close(); - - expect(mock.numberOfCloseCalls, 0); + await fixture.registerIntegration(); + await fixture.sut.close(); + verifyNever(fixture.binding.close()); }); - test('adds integration', () async { - final mock = TestMockSentryNative(); - final integration = NativeSdkIntegration(mock); - - await integration.call(fixture.hub, fixture.options); - - expect(fixture.options.sdk.integrations, ['nativeSdkIntegration']); - }); - - test(' is not added in case of an exception', () async { - final integration = NativeSdkIntegration(_ThrowingMockSentryNative()); - - await integration.call(fixture.hub, fixture.options); + test('is not added in case of an exception', () async { + fixture.sut = NativeSdkIntegration(_ThrowingMockSentryNative()); + expect(fixture.registerIntegration, throwsException); expect(fixture.options.sdk.integrations, []); }); }); } -class Fixture { - final hub = MockHub(); - final options = SentryFlutterOptions(dsn: fakeDsn); -} - -class _ThrowingMockSentryNative extends TestMockSentryNative { +class _ThrowingMockSentryNative extends MockSentryNativeBinding { @override - Future init(SentryFlutterOptions options) async { + Future init(Hub? hub) async { throw Exception(); } } diff --git a/flutter/test/integrations/not_initialized_widgets_binding_on_error_integration_test.dart b/flutter/test/integrations/not_initialized_widgets_binding_on_error_integration_test.dart index 41de1e557e..6df7df0d0f 100644 --- a/flutter/test/integrations/not_initialized_widgets_binding_on_error_integration_test.dart +++ b/flutter/test/integrations/not_initialized_widgets_binding_on_error_integration_test.dart @@ -2,7 +2,6 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:mockito/mockito.dart'; import 'package:sentry/sentry.dart'; import 'package:sentry_flutter/src/integrations/on_error_integration.dart'; -import 'package:sentry_flutter/src/sentry_flutter_options.dart'; import '../mocks.dart'; import '../mocks.mocks.dart'; @@ -51,7 +50,7 @@ void main() { class Fixture { final hub = MockHub(); - final options = SentryFlutterOptions(dsn: fakeDsn); + final options = defaultTestOptions(); late final platformDispatcherWrapper = PlatformDispatcherWrapper(MockPlatformDispatcher()); diff --git a/flutter/test/integrations/not_initialized_widgets_binding_test.dart b/flutter/test/integrations/not_initialized_widgets_binding_test.dart index 50c8b22b49..702071d106 100644 --- a/flutter/test/integrations/not_initialized_widgets_binding_test.dart +++ b/flutter/test/integrations/not_initialized_widgets_binding_test.dart @@ -1,7 +1,7 @@ import 'package:flutter_test/flutter_test.dart'; -import 'package:sentry_flutter/sentry_flutter.dart'; import 'package:sentry_flutter/src/integrations/widgets_binding_integration.dart'; +import '../mocks.dart'; import '../mocks.mocks.dart'; /// Tests that require `WidgetsFlutterBinding.ensureInitialized();` not @@ -25,5 +25,5 @@ void main() { class Fixture { final hub = MockHub(); - final options = SentryFlutterOptions(); + final options = defaultTestOptions(); } diff --git a/flutter/test/integrations/on_error_integration_test.dart b/flutter/test/integrations/on_error_integration_test.dart index a56b4cf8cd..f367916612 100644 --- a/flutter/test/integrations/on_error_integration_test.dart +++ b/flutter/test/integrations/on_error_integration_test.dart @@ -2,7 +2,6 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:mockito/mockito.dart'; import 'package:sentry/sentry.dart'; import 'package:sentry_flutter/src/integrations/on_error_integration.dart'; -import 'package:sentry_flutter/src/sentry_flutter_options.dart'; import '../mocks.dart'; import '../mocks.mocks.dart'; @@ -95,6 +94,25 @@ void main() { expect(throwableMechanism.mechanism.handled, false); }); + test('captureEvent never uses an empty or null stack trace', () async { + final exception = StateError('error'); + _reportError( + exception: exception, + stackTrace: StackTrace.current, + onErrorReturnValue: false, + ); + + final captured = verify( + await fixture.hub.captureEvent(captureAny, + hint: anyNamed('hint'), stackTrace: captureAnyNamed('stackTrace')), + ).captured; + + final stackTrace = captured[1] as StackTrace?; + + expect(stackTrace, isNotNull); + expect(stackTrace.toString(), isNotEmpty); + }); + test('calls default error', () async { var called = false; final defaultError = (_, __) { @@ -146,6 +164,14 @@ void main() { final hub = Hub(fixture.options); final client = MockSentryClient(); + when(client.captureEvent(any, + scope: anyNamed('scope'), + stackTrace: anyNamed('stackTrace'), + hint: anyNamed('hint'))) + .thenAnswer((_) => Future.value(SentryId.newId())); + when(client.captureTransaction(any, + scope: anyNamed('scope'), traceContext: anyNamed('traceContext'))) + .thenAnswer((_) => Future.value(SentryId.newId())); hub.bindClient(client); final sut = fixture.getSut(); @@ -168,7 +194,7 @@ void main() { class Fixture { final hub = MockHub(); - final options = SentryFlutterOptions(dsn: fakeDsn)..tracesSampleRate = 1.0; + final options = defaultTestOptions()..tracesSampleRate = 1.0; final platformDispatcherWrapper = PlatformDispatcherWrapper(MockPlatformDispatcher()); diff --git a/flutter/test/integrations/screenshot_integration_test.dart b/flutter/test/integrations/screenshot_integration_test.dart index 4bf2252685..cedf8bca43 100644 --- a/flutter/test/integrations/screenshot_integration_test.dart +++ b/flutter/test/integrations/screenshot_integration_test.dart @@ -1,8 +1,8 @@ import 'package:flutter_test/flutter_test.dart'; -import 'package:sentry_flutter/sentry_flutter.dart'; import 'package:sentry_flutter/src/event_processor/screenshot_event_processor.dart'; import 'package:sentry_flutter/src/integrations/screenshot_integration.dart'; +import '../mocks.dart'; import '../mocks.mocks.dart'; void main() { @@ -69,7 +69,7 @@ void main() { class Fixture { final hub = MockHub(); - final options = SentryFlutterOptions(); + final options = defaultTestOptions(); ScreenshotIntegration getSut({bool attachScreenshot = true}) { options.attachScreenshot = attachScreenshot; diff --git a/flutter/test/integrations/widgets_flutter_binding_integration_test.dart b/flutter/test/integrations/widgets_flutter_binding_integration_test.dart index 72fb406ee9..3ab2a8f89c 100644 --- a/flutter/test/integrations/widgets_flutter_binding_integration_test.dart +++ b/flutter/test/integrations/widgets_flutter_binding_integration_test.dart @@ -1,6 +1,5 @@ import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:sentry_flutter/sentry_flutter.dart'; import 'package:sentry_flutter/src/integrations/widgets_flutter_binding_integration.dart'; import '../mocks.dart'; @@ -43,8 +42,7 @@ void main() { class Fixture { final hub = MockHub(); - final options = SentryFlutterOptions(dsn: fakeDsn) - ..bindingUtils = TestBindingWrapper(); + final options = defaultTestOptions()..bindingUtils = TestBindingWrapper(); TestBindingWrapper get testBindingUtils => options.bindingUtils as TestBindingWrapper; diff --git a/flutter/test/load_image_list_test.dart b/flutter/test/load_image_list_test.dart deleted file mode 100644 index 472aac0947..0000000000 --- a/flutter/test/load_image_list_test.dart +++ /dev/null @@ -1,183 +0,0 @@ -@TestOn('vm') -library flutter_test; - -import 'package:flutter/services.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:sentry_flutter/sentry_flutter.dart'; -import 'package:sentry_flutter/src/integrations/load_image_list_integration.dart'; - -import 'mocks.dart'; -import 'sentry_flutter_test.dart'; - -void main() { - TestWidgetsFlutterBinding.ensureInitialized(); - late Fixture fixture; - - tearDown(() { - // ignore: deprecated_member_use - fixture.channel.setMockMethodCallHandler(null); - }); - - for (var platform in [ - MockPlatform.android(), - MockPlatform.iOs(), - MockPlatform.macOs() - ]) { - group(platform.operatingSystem, () { - final imageList = [ - { - 'code_file': '/apex/com.android.art/javalib/arm64/boot.oat', - 'code_id': '13577ce71153c228ecf0eb73fc39f45010d487f8', - 'image_addr': '0x6f80b000', - 'image_size': 3092480, - 'type': 'elf', - 'debug_id': 'e77c5713-5311-28c2-ecf0-eb73fc39f450', - 'debug_file': 'test' - } - ]; - - setUp(() { - fixture = Fixture(platform); - // ignore: deprecated_member_use - fixture.channel.setMockMethodCallHandler((MethodCall methodCall) async { - return imageList; - }); - }); - - test('$LoadImageListIntegration adds itself to sdk.integrations', - () async { - final sut = fixture.getSut(); - - sut.call(fixture.hub, fixture.options); - - expect( - fixture.options.sdk.integrations.contains('loadImageListIntegration'), - true, - ); - }); - - test('Native layer is not called as the event is symbolicated', () async { - var called = false; - - final sut = fixture.getSut(); - // ignore: deprecated_member_use - fixture.channel.setMockMethodCallHandler((MethodCall methodCall) async { - called = true; - return imageList; - }); - - sut.call(fixture.hub, fixture.options); - - expect(fixture.options.eventProcessors.length, 1); - - await fixture.hub.captureException(StateError('error'), - stackTrace: StackTrace.current); - - expect(called, false); - }); - - test('Native layer is not called if the event has no stack traces', - () async { - var called = false; - - final sut = fixture.getSut(); - // ignore: deprecated_member_use - fixture.channel.setMockMethodCallHandler((MethodCall methodCall) async { - called = true; - return imageList; - }); - - sut.call(fixture.hub, fixture.options); - - await fixture.hub.captureException(StateError('error')); - - expect(called, false); - }); - - test('Native layer is called because stack traces are not symbolicated', - () async { - var called = false; - - final sut = fixture.getSut(); - // ignore: deprecated_member_use - fixture.channel.setMockMethodCallHandler((MethodCall methodCall) async { - called = true; - return imageList; - }); - - sut.call(fixture.hub, fixture.options); - - await fixture.hub.captureException(StateError('error'), stackTrace: ''' - warning: This VM has been configured to produce stack traces that violate the Dart standard. - *** *** *** *** *** *** *** *** *** *** *** *** *** *** *** *** - pid: 30930, tid: 30990, name 1.ui - build_id: '5346e01103ffeed44e97094ff7bfcc19' - isolate_dso_base: 723d447000, vm_dso_base: 723d447000 - isolate_instructions: 723d452000, vm_instructions: 723d449000 - #00 abs 000000723d6346d7 virt 00000000001ed6d7 _kDartIsolateSnapshotInstructions+0x1e26d7 - #01 abs 000000723d637527 virt 00000000001f0527 _kDartIsolateSnapshotInstructions+0x1e5527 - '''); - - expect(called, true); - }); - - test('Event processor adds image list to the event', () async { - final sut = fixture.getSut(); - - sut.call(fixture.hub, fixture.options); - - final ep = fixture.options.eventProcessors.first; - SentryEvent? event = _getEvent(); - event = await ep.apply(event, Hint()); - - expect(1, event!.debugMeta!.images.length); - }); - - test('Event processor asserts image list', () async { - final sut = fixture.getSut(); - - sut.call(fixture.hub, fixture.options); - final ep = fixture.options.eventProcessors.first; - SentryEvent? event = _getEvent(); - event = await ep.apply(event, Hint()); - - final image = event!.debugMeta!.images.first; - - expect('/apex/com.android.art/javalib/arm64/boot.oat', image.codeFile); - expect('13577ce71153c228ecf0eb73fc39f45010d487f8', image.codeId); - expect('0x6f80b000', image.imageAddr); - expect(3092480, image.imageSize); - expect('elf', image.type); - expect('e77c5713-5311-28c2-ecf0-eb73fc39f450', image.debugId); - expect('test', image.debugFile); - }); - }); - } -} - -SentryEvent _getEvent() { - final frame = SentryStackFrame(platform: 'native'); - final st = SentryStackTrace(frames: [frame]); - final ex = SentryException( - type: 'type', - value: 'value', - stackTrace: st, - ); - return SentryEvent(exceptions: [ex]); -} - -class Fixture { - late final Hub hub; - late final SentryFlutterOptions options; - final channel = MethodChannel('sentry_flutter'); - - Fixture(MockPlatform platform) { - options = SentryFlutterOptions( - dsn: fakeDsn, checker: getPlatformChecker(platform: platform)); - hub = Hub(options); - } - - LoadImageListIntegration getSut() { - return LoadImageListIntegration(channel); - } -} diff --git a/flutter/test/mock_frame_callback_handler.dart b/flutter/test/mock_frame_callback_handler.dart new file mode 100644 index 0000000000..e57a7c681c --- /dev/null +++ b/flutter/test/mock_frame_callback_handler.dart @@ -0,0 +1,25 @@ +import 'dart:async'; + +import 'package:flutter/scheduler.dart'; +import 'package:sentry_flutter/src/frame_callback_handler.dart'; + +class MockFrameCallbackHandler implements FrameCallbackHandler { + FrameCallback? postFrameCallback; + FrameCallback? persistentFrameCallback; + + @override + void addPostFrameCallback(FrameCallback callback) { + postFrameCallback = callback; + } + + @override + void addPersistentFrameCallback(FrameCallback callback) { + persistentFrameCallback = callback; + } + + @override + bool hasScheduledFrame = true; + + @override + Future get endOfFrame => Future.value(); +} diff --git a/flutter/test/mocks.dart b/flutter/test/mocks.dart index a373ee7511..b920578f0f 100644 --- a/flutter/test/mocks.dart +++ b/flutter/test/mocks.dart @@ -4,13 +4,13 @@ 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:mockito/mockito.dart'; import 'package:sentry/src/platform/platform.dart'; import 'package:sentry/src/sentry_tracer.dart'; import 'package:meta/meta.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_binding.dart'; import 'mocks.mocks.dart'; @@ -19,10 +19,9 @@ 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; +SentryFlutterOptions defaultTestOptions([PlatformChecker? checker]) { + return SentryFlutterOptions(dsn: fakeDsn, checker: checker) + ..automatedTestMode = true; } // https://github.com/dart-lang/mockito/blob/master/NULL_SAFETY_README.md#fallback-generators @@ -47,53 +46,33 @@ ISentrySpan startTransactionShim( SentryTracer, SentryTransaction, SentrySpan, + SentryClient, MethodChannel, + SentryNativeBinding ], customMocks: [ MockSpec(fallbackGenerators: {#startTransaction: startTransactionShim}) ]) void main() {} class MockPlatform with NoSuchMethodProvider implements Platform { - MockPlatform({ - String? os, - String? osVersion, - String? hostname, - }) : operatingSystem = os ?? '', - operatingSystemVersion = osVersion ?? '', - localHostname = hostname ?? ''; - - factory MockPlatform.android() { - return MockPlatform(os: 'android'); - } + const MockPlatform(this.operatingSystem, + {this.operatingSystemVersion = '', this.localHostname = ''}); - factory MockPlatform.iOs() { - return MockPlatform(os: 'ios'); - } - - factory MockPlatform.macOs() { - return MockPlatform(os: 'macos'); - } - - factory MockPlatform.windows() { - return MockPlatform(os: 'windows'); - } - - factory MockPlatform.linux() { - return MockPlatform(os: 'linux'); - } - - factory MockPlatform.fuchsia() { - return MockPlatform(os: 'fuchsia'); - } + const MockPlatform.android() : this('android'); + const MockPlatform.iOs() : this('ios'); + const MockPlatform.macOs() : this('macos'); + const MockPlatform.windows() : this('windows'); + const MockPlatform.linux() : this('linux'); + const MockPlatform.fuchsia() : this('fuchsia'); @override - String operatingSystem; + final String operatingSystem; @override - String operatingSystemVersion; + final String operatingSystemVersion; @override - String localHostname; + final String localHostname; @override bool get isLinux => (operatingSystem == 'linux'); @@ -122,7 +101,7 @@ class MockPlatformChecker with NoSuchMethodProvider implements PlatformChecker { this.isWebValue = false, this.hasNativeIntegration = false, Platform? mockPlatform, - }) : _mockPlatform = mockPlatform ?? MockPlatform(); + }) : _mockPlatform = mockPlatform ?? MockPlatform(''); final bool isDebug; final bool isProfile; @@ -162,271 +141,6 @@ 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; - - bool _didFetchAppStart = false; - - @override - bool get didFetchAppStart => _didFetchAppStart; - - @override - bool didAddAppStartMeasurement = false; - - Breadcrumb? breadcrumb; - var numberOfAddBreadcrumbCalls = 0; - var numberOfBeginNativeFramesCollectionCalls = 0; - var numberOfClearBreadcrumbsCalls = 0; - var numberOfEndNativeFramesCollectionCalls = 0; - var numberOfFetchNativeAppStartCalls = 0; - var removeContextsKey = ''; - var numberOfRemoveContextsCalls = 0; - var removeExtraKey = ''; - var numberOfRemoveExtraCalls = 0; - var removeTagKey = ''; - var numberOfRemoveTagCalls = 0; - var numberOfResetCalls = 0; - Map setContextData = {}; - var numberOfSetContextsCalls = 0; - Map setExtraData = {}; - var numberOfSetExtraCalls = 0; - Map setTagsData = {}; - var numberOfSetTagCalls = 0; - SentryUser? sentryUser; - var numberOfSetUserCalls = 0; - var numberOfStartProfilerCalls = 0; - var numberOfDiscardProfilerCalls = 0; - var numberOfCollectProfileCalls = 0; - var numberOfInitCalls = 0; - SentryFlutterOptions? initOptions; - var numberOfCloseCalls = 0; - - @override - Future addBreadcrumb(Breadcrumb breadcrumb) async { - this.breadcrumb = breadcrumb; - numberOfAddBreadcrumbCalls++; - } - - @override - Future beginNativeFramesCollection() async { - numberOfBeginNativeFramesCollectionCalls++; - } - - @override - Future clearBreadcrumbs() async { - numberOfClearBreadcrumbsCalls++; - } - - @override - Future endNativeFramesCollection(SentryId traceId) async { - numberOfEndNativeFramesCollectionCalls++; - return null; - } - - @override - Future fetchNativeAppStart() async { - _didFetchAppStart = true; - numberOfFetchNativeAppStartCalls++; - return null; - } - - @override - Future removeContexts(String key) async { - removeContextsKey = key; - numberOfRemoveContextsCalls++; - } - - @override - Future removeExtra(String key) async { - removeExtraKey = key; - numberOfRemoveExtraCalls++; - } - - @override - Future removeTag(String key) async { - removeTagKey = key; - numberOfRemoveTagCalls++; - } - - @override - void reset() { - numberOfResetCalls++; - } - - @override - Future setContexts(String key, value) async { - setContextData[key] = value; - numberOfSetContextsCalls++; - } - - @override - Future setExtra(String key, value) async { - setExtraData[key] = value; - numberOfSetExtraCalls++; - } - - @override - Future setTag(String key, String value) async { - setTagsData[key] = value; - numberOfSetTagCalls++; - } - - @override - Future setUser(SentryUser? sentryUser) async { - 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); - } - - @override - Future init(SentryFlutterOptions options) { - numberOfInitCalls++; - initOptions = options; - return Future.value(null); - } - - @override - Future close() { - numberOfCloseCalls++; - return Future.value(null); - } -} - -// 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; - - int numberOfBeginNativeFramesCalls = 0; - int numberOfEndNativeFramesCalls = 0; - int numberOfSetUserCalls = 0; - int numberOfAddBreadcrumbCalls = 0; - int numberOfClearBreadcrumbCalls = 0; - int numberOfRemoveContextsCalls = 0; - int numberOfRemoveExtraCalls = 0; - int numberOfRemoveTagCalls = 0; - int numberOfSetContextsCalls = 0; - int numberOfSetExtraCalls = 0; - int numberOfSetTagCalls = 0; - int numberOfStartProfilerCalls = 0; - int numberOfDiscardProfilerCalls = 0; - int numberOfCollectProfileCalls = 0; - int numberOfInitCalls = 0; - int numberOfCloseCalls = 0; - - @override - Future fetchNativeAppStart() async => nativeAppStart; - - @override - Future beginNativeFrames() async { - numberOfBeginNativeFramesCalls += 1; - } - - @override - Future endNativeFrames(SentryId id) async { - this.id = id; - numberOfEndNativeFramesCalls += 1; - return nativeFrames; - } - - @override - Future setUser(SentryUser? user) async { - numberOfSetUserCalls += 1; - } - - @override - Future addBreadcrumb(Breadcrumb breadcrumb) async { - numberOfAddBreadcrumbCalls += 1; - } - - @override - Future clearBreadcrumbs() async { - numberOfClearBreadcrumbCalls += 1; - } - - @override - Future removeContexts(String key) async { - numberOfRemoveContextsCalls += 1; - } - - @override - Future removeExtra(String key) async { - numberOfRemoveExtraCalls += 1; - } - - @override - Future removeTag(String key) async { - numberOfRemoveTagCalls += 1; - } - - @override - Future setContexts(String key, value) async { - numberOfSetContextsCalls += 1; - } - - @override - Future setExtra(String key, value) async { - numberOfSetExtraCalls += 1; - } - - @override - 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); - } - - @override - Future init(SentryFlutterOptions options) { - numberOfInitCalls++; - return Future.value(null); - } - - @override - Future close() { - numberOfCloseCalls++; - return Future.value(null); - } -} - class MockRendererWrapper implements RendererWrapper { MockRendererWrapper(this._renderer); @@ -455,4 +169,62 @@ class TestBindingWrapper implements BindingWrapper { } } -class MockSentryClient with NoSuchMethodProvider implements SentryClient {} +// All these values are based on the fakeFrameDurations list. +// The expected total frames is also based on the span duration of 1000ms and the slow and frozen frames. +const expectedTotalFrames = 17; +const expectedFramesDelay = 722; +const expectedSlowFrames = 2; +const expectedFrozenFrames = 1; + +final fakeFrameDurations = [ + Duration(milliseconds: 0), + Duration(milliseconds: 10), + Duration(milliseconds: 20), + Duration(milliseconds: 40), + Duration(milliseconds: 710), +]; + +@GenerateMocks([Callbacks]) +abstract class Callbacks { + Future? methodCallHandler(String method, [dynamic arguments]); +} + +class NativeChannelFixture { + late final MethodChannel channel; + late final Future? Function(String method, [dynamic arguments]) + handler; + static TestDefaultBinaryMessenger get _messenger => + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + + NativeChannelFixture() { + TestWidgetsFlutterBinding.ensureInitialized(); + channel = MethodChannel('test.channel', StandardMethodCodec(), _messenger); + handler = MockCallbacks().methodCallHandler; + when(handler('initNativeSdk', any)).thenAnswer((_) => Future.value()); + when(handler('closeNativeSdk', any)).thenAnswer((_) => Future.value()); + _messenger.setMockMethodCallHandler( + channel, (call) => handler(call.method, call.arguments)); + } + + // Mock this call as if it was invoked by the native side. + Future invokeFromNative(String method, [dynamic arguments]) async { + final call = + StandardMethodCodec().encodeMethodCall(MethodCall(method, arguments)); + return _messenger.handlePlatformMessage( + channel.name, call, (ByteData? data) {}); + } +} + +typedef EventProcessorFunction = SentryEvent? Function( + SentryEvent event, Hint hint); + +class FunctionEventProcessor implements EventProcessor { + FunctionEventProcessor(this.applyFunction); + + final EventProcessorFunction applyFunction; + + @override + SentryEvent? apply(SentryEvent event, Hint hint) { + return applyFunction(event, hint); + } +} diff --git a/flutter/test/mocks.mocks.dart b/flutter/test/mocks.mocks.dart index ee81d44430..3f94ca0274 100644 --- a/flutter/test/mocks.mocks.dart +++ b/flutter/test/mocks.mocks.dart @@ -4,19 +4,21 @@ // ignore_for_file: no_leading_underscores_for_library_prefixes import 'dart:async' as _i7; +import 'dart:typed_data' as _i13; -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 _i11; +import 'package:flutter/services.dart' as _i4; import 'package:mockito/mockito.dart' as _i1; -import 'package:mockito/src/dummies.dart' as _i9; -import 'package:sentry/sentry.dart' as _i2; -import 'package:sentry/src/profiling.dart' as _i10; -import 'package:sentry/src/protocol.dart' as _i3; -import 'package:sentry/src/sentry_envelope.dart' as _i8; -import 'package:sentry/src/sentry_tracer.dart' as _i4; - -import 'mocks.dart' as _i12; +import 'package:mockito/src/dummies.dart' as _i8; +import 'package:sentry/src/metrics/metric.dart' as _i10; +import 'package:sentry/src/metrics/metrics_api.dart' as _i5; +import 'package:sentry/src/profiling.dart' as _i9; +import 'package:sentry/src/sentry_tracer.dart' as _i3; +import 'package:sentry_flutter/sentry_flutter.dart' as _i2; +import 'package:sentry_flutter/src/native/native_app_start.dart' as _i12; +import 'package:sentry_flutter/src/native/native_frames.dart' as _i14; +import 'package:sentry_flutter/src/native/sentry_native_binding.dart' as _i11; + +import 'mocks.dart' as _i6; // ignore_for_file: type=lint // ignore_for_file: avoid_redundant_argument_values @@ -63,7 +65,7 @@ class _FakeISentrySpan_2 extends _i1.SmartFake implements _i2.ISentrySpan { } class _FakeSentryTraceHeader_3 extends _i1.SmartFake - implements _i3.SentryTraceHeader { + implements _i2.SentryTraceHeader { _FakeSentryTraceHeader_3( Object parent, Invocation parentInvocation, @@ -73,7 +75,7 @@ class _FakeSentryTraceHeader_3 extends _i1.SmartFake ); } -class _FakeSentryTracer_4 extends _i1.SmartFake implements _i4.SentryTracer { +class _FakeSentryTracer_4 extends _i1.SmartFake implements _i3.SentryTracer { _FakeSentryTracer_4( Object parent, Invocation parentInvocation, @@ -83,7 +85,7 @@ class _FakeSentryTracer_4 extends _i1.SmartFake implements _i4.SentryTracer { ); } -class _FakeSentryId_5 extends _i1.SmartFake implements _i3.SentryId { +class _FakeSentryId_5 extends _i1.SmartFake implements _i2.SentryId { _FakeSentryId_5( Object parent, Invocation parentInvocation, @@ -93,7 +95,7 @@ class _FakeSentryId_5 extends _i1.SmartFake implements _i3.SentryId { ); } -class _FakeContexts_6 extends _i1.SmartFake implements _i3.Contexts { +class _FakeContexts_6 extends _i1.SmartFake implements _i2.Contexts { _FakeContexts_6( Object parent, Invocation parentInvocation, @@ -104,7 +106,7 @@ class _FakeContexts_6 extends _i1.SmartFake implements _i3.Contexts { } class _FakeSentryTransaction_7 extends _i1.SmartFake - implements _i3.SentryTransaction { + implements _i2.SentryTransaction { _FakeSentryTransaction_7( Object parent, Invocation parentInvocation, @@ -114,7 +116,7 @@ class _FakeSentryTransaction_7 extends _i1.SmartFake ); } -class _FakeMethodCodec_8 extends _i1.SmartFake implements _i5.MethodCodec { +class _FakeMethodCodec_8 extends _i1.SmartFake implements _i4.MethodCodec { _FakeMethodCodec_8( Object parent, Invocation parentInvocation, @@ -125,7 +127,7 @@ class _FakeMethodCodec_8 extends _i1.SmartFake implements _i5.MethodCodec { } class _FakeBinaryMessenger_9 extends _i1.SmartFake - implements _i6.BinaryMessenger { + implements _i4.BinaryMessenger { _FakeBinaryMessenger_9( Object parent, Invocation parentInvocation, @@ -145,8 +147,18 @@ class _FakeSentryOptions_10 extends _i1.SmartFake implements _i2.SentryOptions { ); } -class _FakeScope_11 extends _i1.SmartFake implements _i2.Scope { - _FakeScope_11( +class _FakeMetricsApi_11 extends _i1.SmartFake implements _i5.MetricsApi { + _FakeMetricsApi_11( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); +} + +class _FakeScope_12 extends _i1.SmartFake implements _i2.Scope { + _FakeScope_12( Object parent, Invocation parentInvocation, ) : super( @@ -155,8 +167,8 @@ class _FakeScope_11 extends _i1.SmartFake implements _i2.Scope { ); } -class _FakeHub_12 extends _i1.SmartFake implements _i2.Hub { - _FakeHub_12( +class _FakeHub_13 extends _i1.SmartFake implements _i2.Hub { + _FakeHub_13( Object parent, Invocation parentInvocation, ) : super( @@ -165,6 +177,28 @@ class _FakeHub_12 extends _i1.SmartFake implements _i2.Hub { ); } +/// A class which mocks [Callbacks]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockCallbacks extends _i1.Mock implements _i6.Callbacks { + MockCallbacks() { + _i1.throwOnMissingStub(this); + } + + @override + _i7.Future? methodCallHandler( + String? method, [ + dynamic arguments, + ]) => + (super.noSuchMethod(Invocation.method( + #methodCallHandler, + [ + method, + arguments, + ], + )) as _i7.Future?); +} + /// A class which mocks [Transport]. /// /// See the documentation for Mockito's code generation for more information. @@ -174,20 +208,20 @@ class MockTransport extends _i1.Mock implements _i2.Transport { } @override - _i7.Future<_i3.SentryId?> send(_i8.SentryEnvelope? envelope) => + _i7.Future<_i2.SentryId?> send(_i2.SentryEnvelope? envelope) => (super.noSuchMethod( Invocation.method( #send, [envelope], ), - returnValue: _i7.Future<_i3.SentryId?>.value(), - ) as _i7.Future<_i3.SentryId?>); + returnValue: _i7.Future<_i2.SentryId?>.value(), + ) as _i7.Future<_i2.SentryId?>); } /// A class which mocks [SentryTracer]. /// /// See the documentation for Mockito's code generation for more information. -class MockSentryTracer extends _i1.Mock implements _i4.SentryTracer { +class MockSentryTracer extends _i1.Mock implements _i3.SentryTracer { MockSentryTracer() { _i1.throwOnMissingStub(this); } @@ -195,7 +229,7 @@ class MockSentryTracer extends _i1.Mock implements _i4.SentryTracer { @override String get name => (super.noSuchMethod( Invocation.getter(#name), - returnValue: _i9.dummyValue( + returnValue: _i8.dummyValue( this, Invocation.getter(#name), ), @@ -211,15 +245,15 @@ class MockSentryTracer extends _i1.Mock implements _i4.SentryTracer { ); @override - _i3.SentryTransactionNameSource get transactionNameSource => + _i2.SentryTransactionNameSource get transactionNameSource => (super.noSuchMethod( Invocation.getter(#transactionNameSource), - returnValue: _i3.SentryTransactionNameSource.custom, - ) as _i3.SentryTransactionNameSource); + returnValue: _i2.SentryTransactionNameSource.custom, + ) as _i2.SentryTransactionNameSource); @override set transactionNameSource( - _i3.SentryTransactionNameSource? _transactionNameSource) => + _i2.SentryTransactionNameSource? _transactionNameSource) => super.noSuchMethod( Invocation.setter( #transactionNameSource, @@ -229,7 +263,7 @@ class MockSentryTracer extends _i1.Mock implements _i4.SentryTracer { ); @override - set profiler(_i10.SentryProfiler? _profiler) => super.noSuchMethod( + set profiler(_i9.SentryProfiler? _profiler) => super.noSuchMethod( Invocation.setter( #profiler, _profiler, @@ -238,7 +272,7 @@ class MockSentryTracer extends _i1.Mock implements _i4.SentryTracer { ); @override - set profileInfo(_i10.SentryProfileInfo? _profileInfo) => super.noSuchMethod( + set profileInfo(_i9.SentryProfileInfo? _profileInfo) => super.noSuchMethod( Invocation.setter( #profileInfo, _profileInfo, @@ -246,6 +280,12 @@ class MockSentryTracer extends _i1.Mock implements _i4.SentryTracer { returnValueForMissingStub: null, ); + @override + Map get measurements => (super.noSuchMethod( + Invocation.getter(#measurements), + returnValue: {}, + ) as Map); + @override _i2.SentrySpanContext get context => (super.noSuchMethod( Invocation.getter(#context), @@ -286,10 +326,10 @@ class MockSentryTracer extends _i1.Mock implements _i4.SentryTracer { ) as bool); @override - List<_i3.SentrySpan> get children => (super.noSuchMethod( + List<_i2.SentrySpan> get children => (super.noSuchMethod( Invocation.getter(#children), - returnValue: <_i3.SentrySpan>[], - ) as List<_i3.SentrySpan>); + returnValue: <_i2.SentrySpan>[], + ) as List<_i2.SentrySpan>); @override set throwable(dynamic throwable) => super.noSuchMethod( @@ -301,7 +341,7 @@ class MockSentryTracer extends _i1.Mock implements _i4.SentryTracer { ); @override - set status(_i3.SpanStatus? status) => super.noSuchMethod( + set status(_i2.SpanStatus? status) => super.noSuchMethod( Invocation.setter( #status, status, @@ -315,15 +355,9 @@ class MockSentryTracer extends _i1.Mock implements _i4.SentryTracer { returnValue: {}, ) as Map); - @override - Map get measurements => (super.noSuchMethod( - Invocation.getter(#measurements), - returnValue: {}, - ) as Map); - @override _i7.Future finish({ - _i3.SpanStatus? status, + _i2.SpanStatus? status, DateTime? endTimestamp, }) => (super.noSuchMethod( @@ -419,7 +453,7 @@ class MockSentryTracer extends _i1.Mock implements _i4.SentryTracer { @override _i2.ISentrySpan startChildWithParentSpanId( - _i3.SpanId? parentSpanId, + _i2.SpanId? parentSpanId, String? operation, { String? description, DateTime? startTimestamp, @@ -453,7 +487,7 @@ class MockSentryTracer extends _i1.Mock implements _i4.SentryTracer { ) as _i2.ISentrySpan); @override - _i3.SentryTraceHeader toSentryTrace() => (super.noSuchMethod( + _i2.SentryTraceHeader toSentryTrace() => (super.noSuchMethod( Invocation.method( #toSentryTrace, [], @@ -465,7 +499,7 @@ class MockSentryTracer extends _i1.Mock implements _i4.SentryTracer { [], ), ), - ) as _i3.SentryTraceHeader); + ) as _i2.SentryTraceHeader); @override void setMeasurement( @@ -485,6 +519,24 @@ class MockSentryTracer extends _i1.Mock implements _i4.SentryTracer { returnValueForMissingStub: null, ); + @override + void setMeasurementFromChild( + String? name, + num? value, { + _i2.SentryMeasurementUnit? unit, + }) => + super.noSuchMethod( + Invocation.method( + #setMeasurementFromChild, + [ + name, + value, + ], + {#unit: unit}, + ), + returnValueForMissingStub: null, + ); + @override void scheduleFinish() => super.noSuchMethod( Invocation.method( @@ -499,7 +551,7 @@ class MockSentryTracer extends _i1.Mock implements _i4.SentryTracer { /// /// See the documentation for Mockito's code generation for more information. // ignore: must_be_immutable -class MockSentryTransaction extends _i1.Mock implements _i3.SentryTransaction { +class MockSentryTransaction extends _i1.Mock implements _i2.SentryTransaction { MockSentryTransaction() { _i1.throwOnMissingStub(this); } @@ -523,13 +575,13 @@ class MockSentryTransaction extends _i1.Mock implements _i3.SentryTransaction { ); @override - List<_i3.SentrySpan> get spans => (super.noSuchMethod( + List<_i2.SentrySpan> get spans => (super.noSuchMethod( Invocation.getter(#spans), - returnValue: <_i3.SentrySpan>[], - ) as List<_i3.SentrySpan>); + returnValue: <_i2.SentrySpan>[], + ) as List<_i2.SentrySpan>); @override - set spans(List<_i3.SentrySpan>? _spans) => super.noSuchMethod( + set spans(List<_i2.SentrySpan>? _spans) => super.noSuchMethod( Invocation.setter( #spans, _spans, @@ -538,13 +590,13 @@ class MockSentryTransaction extends _i1.Mock implements _i3.SentryTransaction { ); @override - _i4.SentryTracer get tracer => (super.noSuchMethod( + _i3.SentryTracer get tracer => (super.noSuchMethod( Invocation.getter(#tracer), returnValue: _FakeSentryTracer_4( this, Invocation.getter(#tracer), ), - ) as _i4.SentryTracer); + ) as _i3.SentryTracer); @override Map get measurements => (super.noSuchMethod( @@ -563,7 +615,17 @@ class MockSentryTransaction extends _i1.Mock implements _i3.SentryTransaction { ); @override - set transactionInfo(_i3.SentryTransactionInfo? _transactionInfo) => + set metricSummaries(Map>? _metricSummaries) => + super.noSuchMethod( + Invocation.setter( + #metricSummaries, + _metricSummaries, + ), + returnValueForMissingStub: null, + ); + + @override + set transactionInfo(_i2.SentryTransactionInfo? _transactionInfo) => super.noSuchMethod( Invocation.setter( #transactionInfo, @@ -585,22 +647,22 @@ class MockSentryTransaction extends _i1.Mock implements _i3.SentryTransaction { ) as bool); @override - _i3.SentryId get eventId => (super.noSuchMethod( + _i2.SentryId get eventId => (super.noSuchMethod( Invocation.getter(#eventId), returnValue: _FakeSentryId_5( this, Invocation.getter(#eventId), ), - ) as _i3.SentryId); + ) as _i2.SentryId); @override - _i3.Contexts get contexts => (super.noSuchMethod( + _i2.Contexts get contexts => (super.noSuchMethod( Invocation.getter(#contexts), returnValue: _FakeContexts_6( this, Invocation.getter(#contexts), ), - ) as _i3.Contexts); + ) as _i2.Contexts); @override Map toJson() => (super.noSuchMethod( @@ -612,8 +674,8 @@ class MockSentryTransaction extends _i1.Mock implements _i3.SentryTransaction { ) as Map); @override - _i3.SentryTransaction copyWith({ - _i3.SentryId? eventId, + _i2.SentryTransaction copyWith({ + _i2.SentryId? eventId, DateTime? timestamp, String? platform, String? logger, @@ -622,26 +684,26 @@ class MockSentryTransaction extends _i1.Mock implements _i3.SentryTransaction { String? dist, String? environment, Map? modules, - _i3.SentryMessage? message, + _i2.SentryMessage? message, String? transaction, dynamic throwable, - _i3.SentryLevel? level, + _i2.SentryLevel? level, String? culprit, Map? tags, Map? extra, List? fingerprint, - _i3.SentryUser? user, - _i3.Contexts? contexts, - List<_i3.Breadcrumb>? breadcrumbs, - _i3.SdkVersion? sdk, - _i3.SentryRequest? request, - _i3.DebugMeta? debugMeta, - List<_i3.SentryException>? exceptions, - List<_i3.SentryThread>? threads, + _i2.SentryUser? user, + _i2.Contexts? contexts, + List<_i2.Breadcrumb>? breadcrumbs, + _i2.SdkVersion? sdk, + _i2.SentryRequest? request, + _i2.DebugMeta? debugMeta, + List<_i2.SentryException>? exceptions, + List<_i2.SentryThread>? threads, String? type, Map? measurements, - Map>? metricSummaries, - _i3.SentryTransactionInfo? transactionInfo, + Map>? metricSummaries, + _i2.SentryTransactionInfo? transactionInfo, }) => (super.noSuchMethod( Invocation.method( @@ -712,23 +774,39 @@ class MockSentryTransaction extends _i1.Mock implements _i3.SentryTransaction { #threads: threads, #type: type, #measurements: measurements, + #metricSummaries: metricSummaries, #transactionInfo: transactionInfo, }, ), ), - ) as _i3.SentryTransaction); + ) as _i2.SentryTransaction); } /// A class which mocks [SentrySpan]. /// /// See the documentation for Mockito's code generation for more information. -class MockSentrySpan extends _i1.Mock implements _i3.SentrySpan { +class MockSentrySpan extends _i1.Mock implements _i2.SentrySpan { MockSentrySpan() { _i1.throwOnMissingStub(this); } @override - set status(_i3.SpanStatus? status) => super.noSuchMethod( + bool get isRootSpan => (super.noSuchMethod( + Invocation.getter(#isRootSpan), + returnValue: false, + ) as bool); + + @override + _i3.SentryTracer get tracer => (super.noSuchMethod( + Invocation.getter(#tracer), + returnValue: _FakeSentryTracer_4( + this, + Invocation.getter(#tracer), + ), + ) as _i3.SentryTracer); + + @override + set status(_i2.SpanStatus? status) => super.noSuchMethod( Invocation.setter( #status, status, @@ -792,7 +870,7 @@ class MockSentrySpan extends _i1.Mock implements _i3.SentrySpan { @override _i7.Future finish({ - _i3.SpanStatus? status, + _i2.SpanStatus? status, DateTime? endTimestamp, }) => (super.noSuchMethod( @@ -896,7 +974,7 @@ class MockSentrySpan extends _i1.Mock implements _i3.SentrySpan { ) as Map); @override - _i3.SentryTraceHeader toSentryTrace() => (super.noSuchMethod( + _i2.SentryTraceHeader toSentryTrace() => (super.noSuchMethod( Invocation.method( #toSentryTrace, [], @@ -908,7 +986,7 @@ class MockSentrySpan extends _i1.Mock implements _i3.SentrySpan { [], ), ), - ) as _i3.SentryTraceHeader); + ) as _i2.SentryTraceHeader); @override void setMeasurement( @@ -938,10 +1016,193 @@ class MockSentrySpan extends _i1.Mock implements _i3.SentrySpan { ); } +/// A class which mocks [SentryClient]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockSentryClient extends _i1.Mock implements _i2.SentryClient { + MockSentryClient() { + _i1.throwOnMissingStub(this); + } + + @override + _i7.Future<_i2.SentryId> captureEvent( + _i2.SentryEvent? event, { + _i2.Scope? scope, + dynamic stackTrace, + _i2.Hint? hint, + }) => + (super.noSuchMethod( + Invocation.method( + #captureEvent, + [event], + { + #scope: scope, + #stackTrace: stackTrace, + #hint: hint, + }, + ), + returnValue: _i7.Future<_i2.SentryId>.value(_FakeSentryId_5( + this, + Invocation.method( + #captureEvent, + [event], + { + #scope: scope, + #stackTrace: stackTrace, + #hint: hint, + }, + ), + )), + ) as _i7.Future<_i2.SentryId>); + + @override + _i7.Future<_i2.SentryId> captureException( + dynamic throwable, { + dynamic stackTrace, + _i2.Scope? scope, + _i2.Hint? hint, + }) => + (super.noSuchMethod( + Invocation.method( + #captureException, + [throwable], + { + #stackTrace: stackTrace, + #scope: scope, + #hint: hint, + }, + ), + returnValue: _i7.Future<_i2.SentryId>.value(_FakeSentryId_5( + this, + Invocation.method( + #captureException, + [throwable], + { + #stackTrace: stackTrace, + #scope: scope, + #hint: hint, + }, + ), + )), + ) as _i7.Future<_i2.SentryId>); + + @override + _i7.Future<_i2.SentryId> captureMessage( + String? formatted, { + _i2.SentryLevel? level, + String? template, + List? params, + _i2.Scope? scope, + _i2.Hint? hint, + }) => + (super.noSuchMethod( + Invocation.method( + #captureMessage, + [formatted], + { + #level: level, + #template: template, + #params: params, + #scope: scope, + #hint: hint, + }, + ), + returnValue: _i7.Future<_i2.SentryId>.value(_FakeSentryId_5( + this, + Invocation.method( + #captureMessage, + [formatted], + { + #level: level, + #template: template, + #params: params, + #scope: scope, + #hint: hint, + }, + ), + )), + ) as _i7.Future<_i2.SentryId>); + + @override + _i7.Future<_i2.SentryId> captureTransaction( + _i2.SentryTransaction? transaction, { + _i2.Scope? scope, + _i2.SentryTraceContextHeader? traceContext, + }) => + (super.noSuchMethod( + Invocation.method( + #captureTransaction, + [transaction], + { + #scope: scope, + #traceContext: traceContext, + }, + ), + returnValue: _i7.Future<_i2.SentryId>.value(_FakeSentryId_5( + this, + Invocation.method( + #captureTransaction, + [transaction], + { + #scope: scope, + #traceContext: traceContext, + }, + ), + )), + ) as _i7.Future<_i2.SentryId>); + + @override + _i7.Future<_i2.SentryId?> captureEnvelope(_i2.SentryEnvelope? envelope) => + (super.noSuchMethod( + Invocation.method( + #captureEnvelope, + [envelope], + ), + returnValue: _i7.Future<_i2.SentryId?>.value(), + ) as _i7.Future<_i2.SentryId?>); + + @override + _i7.Future captureUserFeedback(_i2.SentryUserFeedback? userFeedback) => + (super.noSuchMethod( + Invocation.method( + #captureUserFeedback, + [userFeedback], + ), + returnValue: _i7.Future.value(), + returnValueForMissingStub: _i7.Future.value(), + ) as _i7.Future); + + @override + _i7.Future<_i2.SentryId> captureMetrics( + Map>? metricsBuckets) => + (super.noSuchMethod( + Invocation.method( + #captureMetrics, + [metricsBuckets], + ), + returnValue: _i7.Future<_i2.SentryId>.value(_FakeSentryId_5( + this, + Invocation.method( + #captureMetrics, + [metricsBuckets], + ), + )), + ) as _i7.Future<_i2.SentryId>); + + @override + void close() => super.noSuchMethod( + Invocation.method( + #close, + [], + ), + returnValueForMissingStub: null, + ); +} + /// A class which mocks [MethodChannel]. /// /// See the documentation for Mockito's code generation for more information. -class MockMethodChannel extends _i1.Mock implements _i11.MethodChannel { +class MockMethodChannel extends _i1.Mock implements _i4.MethodChannel { MockMethodChannel() { _i1.throwOnMissingStub(this); } @@ -949,29 +1210,29 @@ class MockMethodChannel extends _i1.Mock implements _i11.MethodChannel { @override String get name => (super.noSuchMethod( Invocation.getter(#name), - returnValue: _i9.dummyValue( + returnValue: _i8.dummyValue( this, Invocation.getter(#name), ), ) as String); @override - _i5.MethodCodec get codec => (super.noSuchMethod( + _i4.MethodCodec get codec => (super.noSuchMethod( Invocation.getter(#codec), returnValue: _FakeMethodCodec_8( this, Invocation.getter(#codec), ), - ) as _i5.MethodCodec); + ) as _i4.MethodCodec); @override - _i6.BinaryMessenger get binaryMessenger => (super.noSuchMethod( + _i4.BinaryMessenger get binaryMessenger => (super.noSuchMethod( Invocation.getter(#binaryMessenger), returnValue: _FakeBinaryMessenger_9( this, Invocation.getter(#binaryMessenger), ), - ) as _i6.BinaryMessenger); + ) as _i4.BinaryMessenger); @override _i7.Future invokeMethod( @@ -1023,7 +1284,7 @@ class MockMethodChannel extends _i1.Mock implements _i11.MethodChannel { @override void setMethodCallHandler( - _i7.Future Function(_i5.MethodCall)? handler) => + _i7.Future Function(_i4.MethodCall)? handler) => super.noSuchMethod( Invocation.method( #setMethodCallHandler, @@ -1033,6 +1294,302 @@ class MockMethodChannel extends _i1.Mock implements _i11.MethodChannel { ); } +/// A class which mocks [SentryNativeBinding]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockSentryNativeBinding extends _i1.Mock + implements _i11.SentryNativeBinding { + MockSentryNativeBinding() { + _i1.throwOnMissingStub(this); + } + + @override + _i7.Future init(_i2.Hub? hub) => (super.noSuchMethod( + Invocation.method( + #init, + [hub], + ), + returnValue: _i7.Future.value(), + returnValueForMissingStub: _i7.Future.value(), + ) as _i7.Future); + + @override + _i7.Future close() => (super.noSuchMethod( + Invocation.method( + #close, + [], + ), + returnValue: _i7.Future.value(), + returnValueForMissingStub: _i7.Future.value(), + ) as _i7.Future); + + @override + _i7.Future<_i12.NativeAppStart?> fetchNativeAppStart() => (super.noSuchMethod( + Invocation.method( + #fetchNativeAppStart, + [], + ), + returnValue: _i7.Future<_i12.NativeAppStart?>.value(), + ) as _i7.Future<_i12.NativeAppStart?>); + + @override + _i7.Future captureEnvelope( + _i13.Uint8List? envelopeData, + bool? containsUnhandledException, + ) => + (super.noSuchMethod( + Invocation.method( + #captureEnvelope, + [ + envelopeData, + containsUnhandledException, + ], + ), + returnValue: _i7.Future.value(), + returnValueForMissingStub: _i7.Future.value(), + ) as _i7.Future); + + @override + _i7.Future beginNativeFrames() => (super.noSuchMethod( + Invocation.method( + #beginNativeFrames, + [], + ), + returnValue: _i7.Future.value(), + returnValueForMissingStub: _i7.Future.value(), + ) as _i7.Future); + + @override + _i7.Future<_i14.NativeFrames?> endNativeFrames(_i2.SentryId? id) => + (super.noSuchMethod( + Invocation.method( + #endNativeFrames, + [id], + ), + returnValue: _i7.Future<_i14.NativeFrames?>.value(), + ) as _i7.Future<_i14.NativeFrames?>); + + @override + _i7.Future setUser(_i2.SentryUser? user) => (super.noSuchMethod( + Invocation.method( + #setUser, + [user], + ), + returnValue: _i7.Future.value(), + returnValueForMissingStub: _i7.Future.value(), + ) as _i7.Future); + + @override + _i7.Future addBreadcrumb(_i2.Breadcrumb? breadcrumb) => + (super.noSuchMethod( + Invocation.method( + #addBreadcrumb, + [breadcrumb], + ), + returnValue: _i7.Future.value(), + returnValueForMissingStub: _i7.Future.value(), + ) as _i7.Future); + + @override + _i7.Future clearBreadcrumbs() => (super.noSuchMethod( + Invocation.method( + #clearBreadcrumbs, + [], + ), + returnValue: _i7.Future.value(), + returnValueForMissingStub: _i7.Future.value(), + ) as _i7.Future); + + @override + _i7.Future?> loadContexts() => (super.noSuchMethod( + Invocation.method( + #loadContexts, + [], + ), + returnValue: _i7.Future?>.value(), + ) as _i7.Future?>); + + @override + _i7.Future setContexts( + String? key, + dynamic value, + ) => + (super.noSuchMethod( + Invocation.method( + #setContexts, + [ + key, + value, + ], + ), + returnValue: _i7.Future.value(), + returnValueForMissingStub: _i7.Future.value(), + ) as _i7.Future); + + @override + _i7.Future removeContexts(String? key) => (super.noSuchMethod( + Invocation.method( + #removeContexts, + [key], + ), + returnValue: _i7.Future.value(), + returnValueForMissingStub: _i7.Future.value(), + ) as _i7.Future); + + @override + _i7.Future setExtra( + String? key, + dynamic value, + ) => + (super.noSuchMethod( + Invocation.method( + #setExtra, + [ + key, + value, + ], + ), + returnValue: _i7.Future.value(), + returnValueForMissingStub: _i7.Future.value(), + ) as _i7.Future); + + @override + _i7.Future removeExtra(String? key) => (super.noSuchMethod( + Invocation.method( + #removeExtra, + [key], + ), + returnValue: _i7.Future.value(), + returnValueForMissingStub: _i7.Future.value(), + ) as _i7.Future); + + @override + _i7.Future setTag( + String? key, + String? value, + ) => + (super.noSuchMethod( + Invocation.method( + #setTag, + [ + key, + value, + ], + ), + returnValue: _i7.Future.value(), + returnValueForMissingStub: _i7.Future.value(), + ) as _i7.Future); + + @override + _i7.Future removeTag(String? key) => (super.noSuchMethod( + Invocation.method( + #removeTag, + [key], + ), + returnValue: _i7.Future.value(), + returnValueForMissingStub: _i7.Future.value(), + ) as _i7.Future); + + @override + int? startProfiler(_i2.SentryId? traceId) => + (super.noSuchMethod(Invocation.method( + #startProfiler, + [traceId], + )) as int?); + + @override + _i7.Future discardProfiler(_i2.SentryId? traceId) => + (super.noSuchMethod( + Invocation.method( + #discardProfiler, + [traceId], + ), + returnValue: _i7.Future.value(), + returnValueForMissingStub: _i7.Future.value(), + ) as _i7.Future); + + @override + _i7.Future displayRefreshRate() => (super.noSuchMethod( + Invocation.method( + #displayRefreshRate, + [], + ), + returnValue: _i7.Future.value(), + ) as _i7.Future); + + @override + _i7.Future?> collectProfile( + _i2.SentryId? traceId, + int? startTimeNs, + int? endTimeNs, + ) => + (super.noSuchMethod( + Invocation.method( + #collectProfile, + [ + traceId, + startTimeNs, + endTimeNs, + ], + ), + returnValue: _i7.Future?>.value(), + ) as _i7.Future?>); + + @override + _i7.Future?> loadDebugImages() => (super.noSuchMethod( + Invocation.method( + #loadDebugImages, + [], + ), + returnValue: _i7.Future?>.value(), + ) as _i7.Future?>); + + @override + _i7.Future pauseAppHangTracking() => (super.noSuchMethod( + Invocation.method( + #pauseAppHangTracking, + [], + ), + returnValue: _i7.Future.value(), + returnValueForMissingStub: _i7.Future.value(), + ) as _i7.Future); + + @override + _i7.Future resumeAppHangTracking() => (super.noSuchMethod( + Invocation.method( + #resumeAppHangTracking, + [], + ), + returnValue: _i7.Future.value(), + returnValueForMissingStub: _i7.Future.value(), + ) as _i7.Future); + + @override + _i7.Future nativeCrash() => (super.noSuchMethod( + Invocation.method( + #nativeCrash, + [], + ), + returnValue: _i7.Future.value(), + returnValueForMissingStub: _i7.Future.value(), + ) as _i7.Future); + + @override + _i7.Future<_i2.SentryId> captureReplay(bool? isCrash) => (super.noSuchMethod( + Invocation.method( + #captureReplay, + [isCrash], + ), + returnValue: _i7.Future<_i2.SentryId>.value(_FakeSentryId_5( + this, + Invocation.method( + #captureReplay, + [isCrash], + ), + )), + ) as _i7.Future<_i2.SentryId>); +} + /// A class which mocks [Hub]. /// /// See the documentation for Mockito's code generation for more information. @@ -1050,6 +1607,15 @@ class MockHub extends _i1.Mock implements _i2.Hub { ), ) as _i2.SentryOptions); + @override + _i5.MetricsApi get metricsApi => (super.noSuchMethod( + Invocation.getter(#metricsApi), + returnValue: _FakeMetricsApi_11( + this, + Invocation.getter(#metricsApi), + ), + ) as _i5.MetricsApi); + @override bool get isEnabled => (super.noSuchMethod( Invocation.getter(#isEnabled), @@ -1057,25 +1623,25 @@ class MockHub extends _i1.Mock implements _i2.Hub { ) as bool); @override - _i3.SentryId get lastEventId => (super.noSuchMethod( + _i2.SentryId get lastEventId => (super.noSuchMethod( Invocation.getter(#lastEventId), returnValue: _FakeSentryId_5( this, Invocation.getter(#lastEventId), ), - ) as _i3.SentryId); + ) as _i2.SentryId); @override _i2.Scope get scope => (super.noSuchMethod( Invocation.getter(#scope), - returnValue: _FakeScope_11( + returnValue: _FakeScope_12( this, Invocation.getter(#scope), ), ) as _i2.Scope); @override - set profilerFactory(_i10.SentryProfilerFactory? value) => super.noSuchMethod( + set profilerFactory(_i9.SentryProfilerFactory? value) => super.noSuchMethod( Invocation.setter( #profilerFactory, value, @@ -1084,8 +1650,8 @@ class MockHub extends _i1.Mock implements _i2.Hub { ); @override - _i7.Future<_i3.SentryId> captureEvent( - _i3.SentryEvent? event, { + _i7.Future<_i2.SentryId> captureEvent( + _i2.SentryEvent? event, { dynamic stackTrace, _i2.Hint? hint, _i2.ScopeCallback? withScope, @@ -1100,7 +1666,7 @@ class MockHub extends _i1.Mock implements _i2.Hub { #withScope: withScope, }, ), - returnValue: _i7.Future<_i3.SentryId>.value(_FakeSentryId_5( + returnValue: _i7.Future<_i2.SentryId>.value(_FakeSentryId_5( this, Invocation.method( #captureEvent, @@ -1112,10 +1678,10 @@ class MockHub extends _i1.Mock implements _i2.Hub { }, ), )), - ) as _i7.Future<_i3.SentryId>); + ) as _i7.Future<_i2.SentryId>); @override - _i7.Future<_i3.SentryId> captureException( + _i7.Future<_i2.SentryId> captureException( dynamic throwable, { dynamic stackTrace, _i2.Hint? hint, @@ -1131,7 +1697,7 @@ class MockHub extends _i1.Mock implements _i2.Hub { #withScope: withScope, }, ), - returnValue: _i7.Future<_i3.SentryId>.value(_FakeSentryId_5( + returnValue: _i7.Future<_i2.SentryId>.value(_FakeSentryId_5( this, Invocation.method( #captureException, @@ -1143,12 +1709,12 @@ class MockHub extends _i1.Mock implements _i2.Hub { }, ), )), - ) as _i7.Future<_i3.SentryId>); + ) as _i7.Future<_i2.SentryId>); @override - _i7.Future<_i3.SentryId> captureMessage( + _i7.Future<_i2.SentryId> captureMessage( String? message, { - _i3.SentryLevel? level, + _i2.SentryLevel? level, String? template, List? params, _i2.Hint? hint, @@ -1166,7 +1732,7 @@ class MockHub extends _i1.Mock implements _i2.Hub { #withScope: withScope, }, ), - returnValue: _i7.Future<_i3.SentryId>.value(_FakeSentryId_5( + returnValue: _i7.Future<_i2.SentryId>.value(_FakeSentryId_5( this, Invocation.method( #captureMessage, @@ -1180,7 +1746,7 @@ class MockHub extends _i1.Mock implements _i2.Hub { }, ), )), - ) as _i7.Future<_i3.SentryId>); + ) as _i7.Future<_i2.SentryId>); @override _i7.Future captureUserFeedback(_i2.SentryUserFeedback? userFeedback) => @@ -1195,7 +1761,7 @@ class MockHub extends _i1.Mock implements _i2.Hub { @override _i7.Future addBreadcrumb( - _i3.Breadcrumb? crumb, { + _i2.Breadcrumb? crumb, { _i2.Hint? hint, }) => (super.noSuchMethod( @@ -1223,7 +1789,7 @@ class MockHub extends _i1.Mock implements _i2.Hub { #clone, [], ), - returnValue: _FakeHub_12( + returnValue: _FakeHub_13( this, Invocation.method( #clone, @@ -1280,7 +1846,7 @@ class MockHub extends _i1.Mock implements _i2.Hub { #customSamplingContext: customSamplingContext, }, ), - returnValue: _i12.startTransactionShim( + returnValue: _i6.startTransactionShim( name, operation, description: description, @@ -1338,8 +1904,8 @@ class MockHub extends _i1.Mock implements _i2.Hub { ) as _i2.ISentrySpan); @override - _i7.Future<_i3.SentryId> captureTransaction( - _i3.SentryTransaction? transaction, { + _i7.Future<_i2.SentryId> captureTransaction( + _i2.SentryTransaction? transaction, { _i2.SentryTraceContextHeader? traceContext, }) => (super.noSuchMethod( @@ -1348,7 +1914,7 @@ class MockHub extends _i1.Mock implements _i2.Hub { [transaction], {#traceContext: traceContext}, ), - returnValue: _i7.Future<_i3.SentryId>.value(_FakeSentryId_5( + returnValue: _i7.Future<_i2.SentryId>.value(_FakeSentryId_5( this, Invocation.method( #captureTransaction, @@ -1356,7 +1922,24 @@ class MockHub extends _i1.Mock implements _i2.Hub { {#traceContext: traceContext}, ), )), - ) as _i7.Future<_i3.SentryId>); + ) as _i7.Future<_i2.SentryId>); + + @override + _i7.Future<_i2.SentryId> captureMetrics( + Map>? metricsBuckets) => + (super.noSuchMethod( + Invocation.method( + #captureMetrics, + [metricsBuckets], + ), + returnValue: _i7.Future<_i2.SentryId>.value(_FakeSentryId_5( + this, + Invocation.method( + #captureMetrics, + [metricsBuckets], + ), + )), + ) as _i7.Future<_i2.SentryId>); @override void setSpanContext( diff --git a/flutter/test/native_scope_observer_test.dart b/flutter/test/native_scope_observer_test.dart index 980449b11f..9d7a8fd2a9 100644 --- a/flutter/test/native_scope_observer_test.dart +++ b/flutter/test/native_scope_observer_test.dart @@ -2,98 +2,75 @@ library flutter_test; import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart'; import 'package:sentry/sentry.dart'; import 'package:sentry_flutter/src/native/native_scope_observer.dart'; -import 'mocks.dart'; +import 'mocks.mocks.dart'; void main() { - late Fixture fixture; + late MockSentryNativeBinding mock; + late NativeScopeObserver sut; setUp(() { - fixture = Fixture(); + mock = MockSentryNativeBinding(); + sut = NativeScopeObserver(mock); }); test('addBreadcrumbCalls', () async { - final sut = fixture.getSut(); final breadcrumb = Breadcrumb(); await sut.addBreadcrumb(breadcrumb); - expect(fixture.mock.breadcrumb, breadcrumb); - expect(fixture.mock.numberOfAddBreadcrumbCalls, 1); + expect(verify(mock.addBreadcrumb(captureAny)).captured.single, breadcrumb); }); test('clearBreadcrumbsCalls', () async { - final sut = fixture.getSut(); await sut.clearBreadcrumbs(); - expect(fixture.mock.numberOfClearBreadcrumbsCalls, 1); + verify(mock.clearBreadcrumbs()).called(1); }); test('removeContextsCalls', () async { - final sut = fixture.getSut(); await sut.removeContexts('fixture-key'); - expect(fixture.mock.removeContextsKey, 'fixture-key'); - expect(fixture.mock.numberOfRemoveContextsCalls, 1); + expect( + verify(mock.removeContexts(captureAny)).captured.single, 'fixture-key'); }); test('removeExtraCalls', () async { - final sut = fixture.getSut(); await sut.removeExtra('fixture-key'); - expect(fixture.mock.removeExtraKey, 'fixture-key'); - expect(fixture.mock.numberOfRemoveExtraCalls, 1); + expect(verify(mock.removeExtra(captureAny)).captured.single, 'fixture-key'); }); test('removeTagCalls', () async { - final sut = fixture.getSut(); await sut.removeTag('fixture-key'); - expect(fixture.mock.removeTagKey, 'fixture-key'); - expect(fixture.mock.numberOfRemoveTagCalls, 1); + expect(verify(mock.removeTag(captureAny)).captured.single, 'fixture-key'); }); test('setContextsCalls', () async { - final sut = fixture.getSut(); await sut.setContexts('fixture-key', 'fixture-value'); - expect(fixture.mock.setContextData['fixture-key'], 'fixture-value'); - expect(fixture.mock.numberOfSetContextsCalls, 1); + verify(mock.setContexts('fixture-key', 'fixture-value')).called(1); }); test('setExtraCalls', () async { - final sut = fixture.getSut(); await sut.setExtra('fixture-key', 'fixture-value'); - expect(fixture.mock.setExtraData['fixture-key'], 'fixture-value'); - expect(fixture.mock.numberOfSetExtraCalls, 1); + verify(mock.setExtra('fixture-key', 'fixture-value')).called(1); }); test('setTagCalls', () async { - final sut = fixture.getSut(); await sut.setTag('fixture-key', 'fixture-value'); - expect(fixture.mock.setTagsData['fixture-key'], 'fixture-value'); - expect(fixture.mock.numberOfSetTagCalls, 1); + verify(mock.setTag('fixture-key', 'fixture-value')).called(1); }); test('setUserCalls', () async { - final sut = fixture.getSut(); - final user = SentryUser(id: 'foo bar'); await sut.setUser(user); - expect(fixture.mock.sentryUser, user); - expect(fixture.mock.numberOfSetUserCalls, 1); + expect(verify(mock.setUser(captureAny)).captured.single, user); }); } - -class Fixture { - var mock = TestMockSentryNative(); - - NativeScopeObserver getSut() { - final sut = NativeScopeObserver(mock); - return sut; - } -} diff --git a/flutter/test/navigation/sentry_display_widget_test.dart b/flutter/test/navigation/sentry_display_widget_test.dart index 2cafb706d5..37f5e26988 100644 --- a/flutter/test/navigation/sentry_display_widget_test.dart +++ b/flutter/test/navigation/sentry_display_widget_test.dart @@ -4,7 +4,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:sentry_flutter/sentry_flutter.dart'; import 'package:sentry/src/sentry_tracer.dart'; -import 'package:sentry_flutter/src/integrations/integrations.dart'; import '../fake_frame_callback_handler.dart'; import '../mocks.dart'; @@ -53,62 +52,14 @@ void main() { expect(measurement?.unit, DurationSentryMeasurementUnit.milliSecond); expect(measurement?.value, ttidSpanDuration.inMilliseconds); }); - - testWidgets('SentryDisplayWidget is ignored for app starts', - (WidgetTester tester) async { - final currentRoute = route(RouteSettings(name: '/')); - final appStartInfo = AppStartInfo( - AppStartType.cold, - start: getUtcDateTime().add(Duration(seconds: 1)), - end: getUtcDateTime().add(Duration(seconds: 2)), - pluginRegistration: getUtcDateTime().add(Duration(seconds: 3)), - sentrySetupStart: getUtcDateTime().add(Duration(seconds: 4)), - nativeSpanTimes: [], - ); - NativeAppStartIntegration.setAppStartInfo(appStartInfo); - - await tester.runAsync(() async { - fixture.navigatorObserver.didPush(currentRoute, null); - await tester.pumpWidget(fixture.getSut()); - await fixture.navigatorObserver.completedDisplayTracking?.future; - }); - - final tracer = fixture.hub.getSpan() as SentryTracer; - final spans = tracer.children.where((element) => - element.context.operation == - SentrySpanOperations.uiTimeToInitialDisplay); - - expect(spans, hasLength(1)); - - final ttidSpan = spans.first; - expect(ttidSpan.context.operation, - SentrySpanOperations.uiTimeToInitialDisplay); - expect(ttidSpan.finished, isTrue); - expect(ttidSpan.context.description, 'root / initial display'); - expect(ttidSpan.origin, SentryTraceOrigins.autoUiTimeToDisplay); - - expect(ttidSpan.startTimestamp, appStartInfo.start); - expect(ttidSpan.endTimestamp, appStartInfo.end); - final ttidSpanDuration = - ttidSpan.endTimestamp!.difference(ttidSpan.startTimestamp); - - expect(tracer.measurements, hasLength(1)); - final measurement = tracer.measurements['time_to_initial_display']; - expect(measurement, isNotNull); - expect(measurement?.value, appStartInfo.duration?.inMilliseconds); - expect(measurement?.value, ttidSpanDuration.inMilliseconds); - expect(measurement?.unit, DurationSentryMeasurementUnit.milliSecond); - }); } class Fixture { - final Hub hub = - Hub(SentryFlutterOptions(dsn: fakeDsn)..tracesSampleRate = 1.0); + final Hub hub = Hub(defaultTestOptions()..tracesSampleRate = 1.0); late final SentryNavigatorObserver navigatorObserver; final fakeFrameCallbackHandler = FakeFrameCallbackHandler(); Fixture() { - SentryFlutter.native = TestMockSentryNative(); navigatorObserver = SentryNavigatorObserver(hub: hub); } diff --git a/flutter/test/navigation/time_to_display_tracker_test.dart b/flutter/test/navigation/time_to_display_tracker_test.dart index ca9d0425a0..79f2d377d9 100644 --- a/flutter/test/navigation/time_to_display_tracker_test.dart +++ b/flutter/test/navigation/time_to_display_tracker_test.dart @@ -50,23 +50,6 @@ void main() { expect(transaction.context.operation, SentrySpanOperations.uiLoad); expect(transaction.startTimestamp, ttidSpan?.startTimestamp); }); - - test('finishes ttid span', () async { - SentryFlutter.native = TestMockSentryNative(); - final sut = fixture.getSut(); - final endTimestamp = - fixture.startTimestamp.add(const Duration(milliseconds: 10)); - - final transaction = fixture.getTransaction(name: '/') as SentryTracer; - await sut.trackAppStartTTD(transaction, - startTimestamp: fixture.startTimestamp, endTimestamp: endTimestamp); - - final ttidSpan = _getTTIDSpan(transaction); - expect(ttidSpan?.context.operation, - SentrySpanOperations.uiTimeToInitialDisplay); - expect(ttidSpan?.finished, isTrue); - expect(ttidSpan?.origin, SentryTraceOrigins.autoUiTimeToDisplay); - }); }); group('in regular routes', () { @@ -167,32 +150,6 @@ void main() { }); }); - group('in root screen app start route', () { - test( - 'finishes span after timeout with deadline exceeded and ttid matching end time', - () async { - final sut = fixture.getSut(); - final transaction = - fixture.getTransaction(name: 'root ("/")') as SentryTracer; - final endTimestamp = - fixture.startTimestamp.add(const Duration(milliseconds: 10)); - - await sut.trackAppStartTTD(transaction, - startTimestamp: fixture.startTimestamp, endTimestamp: endTimestamp); - - final ttidSpan = _getTTIDSpan(transaction); - expect(ttidSpan, isNotNull); - - final ttfdSpan = _getTTFDSpan(transaction); - expect(ttfdSpan, isNotNull); - - expect(ttfdSpan?.finished, isTrue); - expect(ttfdSpan?.status, SpanStatus.deadlineExceeded()); - expect(ttfdSpan?.endTimestamp, ttidSpan?.endTimestamp); - expect(ttfdSpan?.startTimestamp, ttidSpan?.startTimestamp); - }); - }); - test('multiple ttfd timeouts have correct ttid matching end time', () async { final sut = fixture.getSut(); @@ -228,7 +185,6 @@ void main() { test('does not create ttfd span when not enabled', () async { fixture.options.enableTimeToFullDisplayTracing = false; - SentryFlutter.native = TestMockSentryNative(); final sut = fixture.getSut(); final transaction = fixture.getTransaction() as SentryTracer; @@ -257,7 +213,7 @@ void main() { class Fixture { final startTimestamp = getUtcDateTime(); - final options = SentryFlutterOptions() + final options = defaultTestOptions() ..dsn = fakeDsn ..tracesSampleRate = 1.0; late final endTimeProvider = ttidEndTimestampProvider(); diff --git a/flutter/test/navigation/time_to_full_display_tracker_test.dart b/flutter/test/navigation/time_to_full_display_tracker_test.dart index 50609f2692..95d5a3ee25 100644 --- a/flutter/test/navigation/time_to_full_display_tracker_test.dart +++ b/flutter/test/navigation/time_to_full_display_tracker_test.dart @@ -65,7 +65,7 @@ void main() { class Fixture { final startTimestamp = getUtcDateTime(); - final hub = Hub(SentryFlutterOptions(dsn: fakeDsn)..tracesSampleRate = 1.0); + final hub = Hub(defaultTestOptions()..tracesSampleRate = 1.0); final autoFinishAfter = const Duration(seconds: 2); late final endTimestampProvider = fakeTTIDEndTimestampProvider(); diff --git a/flutter/test/navigation/time_to_initial_display_tracker_test.dart b/flutter/test/navigation/time_to_initial_display_tracker_test.dart index 6e55029572..526343a8bd 100644 --- a/flutter/test/navigation/time_to_initial_display_tracker_test.dart +++ b/flutter/test/navigation/time_to_initial_display_tracker_test.dart @@ -22,41 +22,6 @@ void main() { sut.clear(); }); - group('app start', () { - test('tracking creates and finishes ttid span with correct measurements', - () async { - final endTimestamp = - fixture.startTimestamp.add(const Duration(milliseconds: 10)); - - final transaction = - fixture.getTransaction(name: 'root ("/")') as SentryTracer; - await sut.trackAppStart(transaction, - startTimestamp: fixture.startTimestamp, endTimestamp: endTimestamp); - - final children = transaction.children; - expect(children, hasLength(1)); - - final ttidSpan = children.first; - expect(ttidSpan.context.operation, - SentrySpanOperations.uiTimeToInitialDisplay); - expect(ttidSpan.finished, isTrue); - expect(ttidSpan.context.description, 'root ("/") initial display'); - expect(ttidSpan.origin, SentryTraceOrigins.autoUiTimeToDisplay); - expect(ttidSpan.startTimestamp, fixture.startTimestamp); - expect(ttidSpan.endTimestamp, endTimestamp); - - final ttidMeasurement = - transaction.measurements['time_to_initial_display']; - expect(ttidMeasurement, isNotNull); - expect(ttidMeasurement?.unit, DurationSentryMeasurementUnit.milliSecond); - expect( - ttidMeasurement?.value, - ttidSpan.endTimestamp! - .difference(ttidSpan.startTimestamp) - .inMilliseconds); - }); - }); - group('regular route', () { test( 'approximation tracking creates and finishes ttid span with correct measurements', @@ -179,7 +144,7 @@ void main() { class Fixture { final startTimestamp = getUtcDateTime(); - final hub = Hub(SentryFlutterOptions(dsn: fakeDsn)..tracesSampleRate = 1.0); + final hub = Hub(defaultTestOptions()..tracesSampleRate = 1.0); late final fakeFrameCallbackHandler = FakeFrameCallbackHandler(); ISentrySpan getTransaction({String? name = "Regular route"}) { diff --git a/flutter/test/profiling_test.dart b/flutter/test/profiling_test.dart index adf88e0f2e..5ab944e53f 100644 --- a/flutter/test/profiling_test.dart +++ b/flutter/test/profiling_test.dart @@ -10,9 +10,16 @@ import 'mocks.mocks.dart'; import 'sentry_flutter_test.dart'; void main() { + late MockSentryNativeBinding mock; + + setUp(() { + mock = MockSentryNativeBinding(); + when(mock.startProfiler(any)).thenReturn(1); + }); + group('$SentryNativeProfilerFactory', () { Hub hubWithSampleRate(double profilesSampleRate) { - final o = SentryFlutterOptions(dsn: fakeDsn); + final o = defaultTestOptions(); o.platformChecker = getPlatformChecker(platform: MockPlatform.iOs()); o.profilesSampleRate = profilesSampleRate; @@ -23,42 +30,39 @@ void main() { test('attachTo() respects sampling rate', () async { var hub = hubWithSampleRate(0.0); - SentryNativeProfilerFactory.attachTo(hub, TestMockSentryNative()); + SentryNativeProfilerFactory.attachTo(hub, mock); // ignore: invalid_use_of_internal_member verifyNever(hub.profilerFactory = any); hub = hubWithSampleRate(0.1); - SentryNativeProfilerFactory.attachTo(hub, TestMockSentryNative()); + SentryNativeProfilerFactory.attachTo(hub, mock); // 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 sut = SentryNativeProfilerFactory(mock, getUtcDateTime); final profiler = sut.startProfiler(SentryTransactionContext( 'name', 'op', )); - expect(nativeMock.numberOfStartProfilerCalls, 1); + verify(mock.startProfiler(any)).called(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 factory = SentryNativeProfilerFactory(mock, getUtcDateTime); final profiler = factory.startProfiler(SentryTransactionContext( 'name', 'op', )); - expect(nativeMock.numberOfStartProfilerCalls, 1); + verify(mock.startProfiler(any)).called(1); expect(profiler, isNotNull); sut = profiler!; }); @@ -71,14 +75,15 @@ void main() { await null; await null; - expect(nativeMock.numberOfDiscardProfilerCalls, 1); + verify(mock.discardProfiler(any)).called(1); // finishFor() mustn't work after disposing expect(await sut.finishFor(MockSentryTransaction()), isNull); - expect(nativeMock.numberOfCollectProfileCalls, 0); + verifyNever(mock.collectProfile(any, any, any)); }); test('dispose() does not call discard() after finishing', () async { + when(mock.collectProfile(any, any, any)).thenAnswer((_) async => null); final mockTransaction = MockSentryTransaction(); when(mockTransaction.startTimestamp).thenReturn(DateTime.now()); when(mockTransaction.timestamp).thenReturn(DateTime.now()); @@ -89,8 +94,8 @@ void main() { // Yield to let the .then() in .dispose() execute. await null; - expect(nativeMock.numberOfDiscardProfilerCalls, 0); - expect(nativeMock.numberOfCollectProfileCalls, 1); + verifyNever(mock.discardProfiler(any)); + verify(mock.collectProfile(any, any, any)).called(1); }); }); } diff --git a/flutter/test/replay/recorder_config_test.dart b/flutter/test/replay/recorder_config_test.dart new file mode 100644 index 0000000000..d884073e91 --- /dev/null +++ b/flutter/test/replay/recorder_config_test.dart @@ -0,0 +1,24 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:sentry_flutter/src/replay/recorder_config.dart'; + +void main() async { + group('$ScreenshotRecorderConfig', () { + test('defaults', () { + var sut = ScreenshotRecorderConfig(); + expect(sut.height, isNull); + expect(sut.width, isNull); + }); + + test('pixel ratio calculation', () { + expect(ScreenshotRecorderConfig().getPixelRatio(100, 100), 1.0); + expect( + ScreenshotRecorderConfig(width: 5, height: 10) + .getPixelRatio(100, 100), + 0.05); + expect( + ScreenshotRecorderConfig(width: 20, height: 10) + .getPixelRatio(100, 100), + 0.1); + }); + }); +} diff --git a/flutter/test/replay/recorder_test.dart b/flutter/test/replay/recorder_test.dart new file mode 100644 index 0000000000..2df4334c5b --- /dev/null +++ b/flutter/test/replay/recorder_test.dart @@ -0,0 +1,47 @@ +// For some reason, this test is not working in the browser but that's OK, we +// don't support video recording anyway. +@TestOn('vm') +library dart_test; + +import 'dart:ui'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:sentry_flutter/src/replay/recorder.dart'; +import 'package:sentry_flutter/src/replay/recorder_config.dart'; + +import '../mocks.dart'; +import 'test_widget.dart'; + +void main() async { + TestWidgetsFlutterBinding.ensureInitialized(); + + testWidgets('captures images', (tester) async { + final fixture = await _Fixture.create(tester); + expect(fixture.capture(), completion('800x600')); + }); +} + +class _Fixture { + late final ScreenshotRecorder sut; + + _Fixture._() { + sut = ScreenshotRecorder( + ScreenshotRecorderConfig(), + defaultTestOptions()..bindingUtils = TestBindingWrapper(), + ); + } + + static Future<_Fixture> create(WidgetTester tester) async { + final fixture = _Fixture._(); + await pumpTestElement(tester); + return fixture; + } + + Future capture() async { + String? captured; + await sut.capture((Image image) async { + captured = "${image.width}x${image.height}"; + }); + return captured; + } +} diff --git a/flutter/test/replay/replay_native_test.dart b/flutter/test/replay/replay_native_test.dart new file mode 100644 index 0000000000..c758cd3005 --- /dev/null +++ b/flutter/test/replay/replay_native_test.dart @@ -0,0 +1,246 @@ +// ignore_for_file: invalid_use_of_internal_member + +@TestOn('vm') +library flutter_test; + +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:file/file.dart'; +import 'package:file/memory.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart'; +import 'package:sentry_flutter/sentry_flutter.dart'; +import 'package:sentry_flutter/src/event_processor/replay_event_processor.dart'; +import 'package:sentry_flutter/src/native/factory.dart'; +import 'package:sentry_flutter/src/native/sentry_native_binding.dart'; + +import '../mocks.dart'; +import '../mocks.mocks.dart'; +import 'test_widget.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + for (final mockPlatform in [ + MockPlatform.android(), + MockPlatform.iOs(), + ]) { + group('$SentryNativeBinding ($mockPlatform)', () { + late SentryNativeBinding sut; + late NativeChannelFixture native; + late SentryFlutterOptions options; + late MockHub hub; + late FileSystem fs; + late Directory replayDir; + late final Map replayConfig; + + if (mockPlatform.isIOS) { + replayConfig = { + 'replayId': '123', + 'directory': 'dir', + }; + } else if (mockPlatform.isAndroid) { + replayConfig = { + 'replayId': '123', + 'directory': 'dir', + 'width': 800, + 'height': 600, + 'frameRate': 10, + }; + } + + setUp(() { + hub = MockHub(); + + fs = MemoryFileSystem.test(); + replayDir = fs.directory(replayConfig['directory']) + ..createSync(recursive: true); + + native = NativeChannelFixture(); + + options = + defaultTestOptions(MockPlatformChecker(mockPlatform: mockPlatform)) + ..fileSystem = fs + ..methodChannel = native.channel; + + sut = createBinding(options); + }); + + tearDown(() async { + await sut.close(); + }); + + test('init sets $ReplayEventProcessor when error replay is enabled', + () async { + options.experimental.replay.onErrorSampleRate = 0.1; + await sut.init(hub); + + expect(options.eventProcessors.map((e) => e.runtimeType.toString()), + contains('$ReplayEventProcessor')); + }); + + test( + 'init does not set $ReplayEventProcessor when error replay is disabled', + () async { + await sut.init(hub); + + expect(options.eventProcessors.map((e) => e.runtimeType.toString()), + isNot(contains('$ReplayEventProcessor'))); + }); + + group('replay recorder', () { + setUp(() async { + options.experimental.replay.sessionSampleRate = 0.1; + options.experimental.replay.onErrorSampleRate = 0.1; + await sut.init(hub); + }); + + test('sets replay ID to context', () async { + // verify there was no scope configured before + verifyNever(hub.configureScope(any)); + + // emulate the native platform invoking the method + await native.invokeFromNative( + mockPlatform.isAndroid + ? 'ReplayRecorder.start' + : 'captureReplayScreenshot', + replayConfig); + + // verify the replay ID was set + final closure = + verify(hub.configureScope(captureAny)).captured.single; + final scope = Scope(options); + expect(scope.replayId, isNull); + await closure(scope); + expect(scope.replayId.toString(), replayConfig['replayId']); + }); + + test('clears replay ID from context', () async { + // verify there was no scope configured before + verifyNever(hub.configureScope(any)); + + // emulate the native platform invoking the method + await native.invokeFromNative('ReplayRecorder.stop'); + + // verify the replay ID was cleared + final closure = + verify(hub.configureScope(captureAny)).captured.single; + final scope = Scope(options); + scope.replayId = SentryId.newId(); + expect(scope.replayId, isNotNull); + await closure(scope); + expect(scope.replayId, isNull); + }, skip: mockPlatform.isIOS ? 'iOS does not clear replay ID' : false); + + testWidgets('captures images', (tester) async { + await tester.runAsync(() async { + if (mockPlatform.isAndroid) { + var callbackFinished = Completer(); + + nextFrame({bool wait = true}) async { + final future = callbackFinished.future; + tester.binding.scheduleFrame(); + await tester.pumpAndSettle(const Duration(seconds: 1)); + await future.timeout(Duration(milliseconds: wait ? 1000 : 100), + onTimeout: () { + if (wait) { + fail('native callback not called'); + } + }); + } + + imageInfo(File file) => file.readAsBytesSync().length; + + fileToImageMap(Iterable files) => + {for (var file in files) file.path: imageInfo(file)}; + + final capturedImages = {}; + when(native.handler('addReplayScreenshot', any)) + .thenAnswer((invocation) async { + final path = + invocation.positionalArguments[1]["path"] as String; + capturedImages[path] = imageInfo(fs.file(path)); + callbackFinished.complete(); + callbackFinished = Completer(); + return null; + }); + + fsImages() { + final files = replayDir.listSync().map((f) => f as File); + return fileToImageMap(files); + } + + await pumpTestElement(tester); + + await nextFrame(wait: false); + expect(fsImages(), isEmpty); + verifyNever(native.handler('addReplayScreenshot', any)); + + await native.invokeFromNative( + 'ReplayRecorder.start', replayConfig); + + await nextFrame(); + expect(fsImages().values, isNotEmpty); + final size = fsImages().values.first; + expect(size, greaterThan(3000)); + expect(fsImages().values, [size]); + expect(capturedImages, equals(fsImages())); + + await nextFrame(); + fsImages().values.forEach((s) => expect(s, size)); + expect(capturedImages, equals(fsImages())); + + await native.invokeFromNative('ReplayRecorder.pause'); + var count = capturedImages.length; + + await nextFrame(wait: false); + await Future.delayed(const Duration(milliseconds: 100)); + fsImages().values.forEach((s) => expect(s, size)); + expect(capturedImages, equals(fsImages())); + expect(capturedImages.length, count); + + await nextFrame(wait: false); + fsImages().values.forEach((s) => expect(s, size)); + expect(capturedImages, equals(fsImages())); + expect(capturedImages.length, count); + + await native.invokeFromNative('ReplayRecorder.resume'); + + await nextFrame(); + fsImages().values.forEach((s) => expect(s, size)); + expect(capturedImages, equals(fsImages())); + expect(capturedImages.length, greaterThan(count)); + + await native.invokeFromNative('ReplayRecorder.stop'); + count = capturedImages.length; + await Future.delayed(const Duration(milliseconds: 100)); + await nextFrame(wait: false); + fsImages().values.forEach((s) => expect(s, size)); + expect(capturedImages, equals(fsImages())); + expect(capturedImages.length, count); + } else if (mockPlatform.isIOS) { + // configureScope() is called on iOS + when(hub.configureScope(captureAny)).thenReturn(null); + + nextFrame() async { + tester.binding.scheduleFrame(); + await Future.delayed(const Duration(milliseconds: 100)); + await tester.pumpAndSettle(const Duration(seconds: 1)); + } + + await pumpTestElement(tester); + await nextFrame(); + + final imagaData = await native.invokeFromNative( + 'captureReplayScreenshot', replayConfig) as ByteData; + expect(imagaData.lengthInBytes, greaterThan(3000)); + } else { + fail('unsupported platform'); + } + }); + }, timeout: Timeout(Duration(seconds: 10))); + }); + }); + } +} diff --git a/flutter/test/replay/scheduled_recorder_test.dart b/flutter/test/replay/scheduled_recorder_test.dart new file mode 100644 index 0000000000..7ace54c18e --- /dev/null +++ b/flutter/test/replay/scheduled_recorder_test.dart @@ -0,0 +1,62 @@ +// For some reason, this test is not working in the browser but that's OK, we +// don't support video recording anyway. +@TestOn('vm') +library dart_test; + +import 'dart:ui'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:sentry_flutter/src/replay/scheduled_recorder.dart'; +import 'package:sentry_flutter/src/replay/recorder_config.dart'; + +import '../mocks.dart'; +import 'test_widget.dart'; + +void main() async { + TestWidgetsFlutterBinding.ensureInitialized(); + + testWidgets('captures images', (tester) async { + final fixture = await _Fixture.create(tester); + expect(fixture.capturedImages, isEmpty); + await fixture.nextFrame(); + expect(fixture.capturedImages, ['1000x750']); + await fixture.nextFrame(); + expect(fixture.capturedImages, ['1000x750', '1000x750']); + final stopFuture = fixture.sut.stop(); + await fixture.nextFrame(); + await stopFuture; + expect(fixture.capturedImages, ['1000x750', '1000x750']); + }); +} + +class _Fixture { + final WidgetTester _tester; + late final ScheduledScreenshotRecorder sut; + final capturedImages = []; + + _Fixture._(this._tester) { + sut = ScheduledScreenshotRecorder( + ScheduledScreenshotRecorderConfig( + width: 1000, + height: 1000, + frameRate: 1000, + ), + (Image image) async { + capturedImages.add("${image.width}x${image.height}"); + }, + defaultTestOptions()..bindingUtils = TestBindingWrapper(), + ); + } + + static Future<_Fixture> create(WidgetTester tester) async { + final fixture = _Fixture._(tester); + await pumpTestElement(tester); + fixture.sut.start(); + return fixture; + } + + Future nextFrame() async { + _tester.binding.scheduleFrame(); + await _tester.pumpAndSettle(const Duration(seconds: 1)); + } +} diff --git a/flutter/test/replay/scheduler_test.dart b/flutter/test/replay/scheduler_test.dart new file mode 100644 index 0000000000..c41260c854 --- /dev/null +++ b/flutter/test/replay/scheduler_test.dart @@ -0,0 +1,82 @@ +import 'package:flutter/scheduler.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:sentry_flutter/src/replay/scheduler.dart'; + +void main() { + test('does not trigger callback between frames', () async { + var fixture = _Fixture.started(); + + expect(fixture.calls, 0); + await Future.delayed(const Duration(milliseconds: 100), () {}); + expect(fixture.calls, 0); + }); + + test('triggers callback after a frame', () async { + var fixture = _Fixture(); + fixture.sut.start(); + + expect(fixture.calls, 0); + await fixture.drawFrame(); + expect(fixture.calls, 1); + await fixture.drawFrame(); + await fixture.drawFrame(); + await fixture.drawFrame(); + expect(fixture.calls, 4); + }); + + test('does not trigger when stopped', () async { + var fixture = _Fixture(); + fixture.sut.start(); + + expect(fixture.calls, 0); + await fixture.drawFrame(); + expect(fixture.calls, 1); + await fixture.drawFrame(); + await fixture.sut.stop(); + await fixture.drawFrame(); + expect(fixture.calls, 2); + }); + + test('triggers after a restart', () async { + var fixture = _Fixture(); + fixture.sut.start(); + + expect(fixture.calls, 0); + await fixture.drawFrame(); + expect(fixture.calls, 1); + await fixture.sut.stop(); + await fixture.drawFrame(); + expect(fixture.calls, 1); + fixture.sut.start(); + await fixture.drawFrame(); + expect(fixture.calls, 2); + }); +} + +class _Fixture { + var calls = 0; + late final Scheduler sut; + FrameCallback? registeredCallback; + var _frames = 0; + + _Fixture() { + sut = Scheduler( + const Duration(milliseconds: 1), + (_) async => calls++, + (FrameCallback callback, {String debugLabel = 'callback'}) { + registeredCallback = callback; + }, + ); + } + + factory _Fixture.started() { + return _Fixture()..sut.start(); + } + + Future drawFrame() async { + await Future.delayed(const Duration(milliseconds: 8), () {}); + _frames++; + registeredCallback!(Duration(milliseconds: _frames)); + registeredCallback = null; + } +} diff --git a/flutter/test/replay/test_widget.dart b/flutter/test/replay/test_widget.dart new file mode 100644 index 0000000000..d800a3ef12 --- /dev/null +++ b/flutter/test/replay/test_widget.dart @@ -0,0 +1,80 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:sentry_flutter/sentry_flutter.dart'; + +Future pumpTestElement(WidgetTester tester, + {List? children}) async { + await tester.pumpWidget( + MaterialApp( + home: SentryWidget( + child: SingleChildScrollView( + child: Visibility( + visible: true, + child: Opacity( + opacity: 0.5, + child: Column( + children: children ?? + [ + newImage(), + const Padding( + padding: EdgeInsets.all(15), + child: Center(child: Text('Centered text')), + ), + ElevatedButton( + onPressed: () {}, + child: Text('Button title'), + ), + newImage(), + // Invisible widgets won't be obscured. + Visibility(visible: false, child: Text('Invisible text')), + Visibility(visible: false, child: newImage()), + Opacity(opacity: 0, child: Text('Invisible text')), + Opacity(opacity: 0, child: newImage()), + Offstage(offstage: true, child: Text('Offstage text')), + Offstage(offstage: true, child: newImage()), + Text(dummyText), + SizedBox( + width: 100, + height: 20, + child: Stack(children: [ + Positioned( + top: 0, + left: 0, + width: 50, + child: Text(dummyText)), + Positioned( + top: 0, + left: 0, + width: 50, + child: newImage(width: 500, height: 500)), + ])) + ], + ), + ), + ), + ), + ), + ), + ); + return TestWidgetsFlutterBinding.instance.rootElement!; +} + +final testImageData = Uint8List.fromList([ + 66, 77, 142, 0, 0, 0, 0, 0, 0, 0, 138, 0, 0, 0, 124, 0, 0, 0, 1, 0, + 0, 0, 255, 255, 255, 255, 1, 0, 32, 0, 3, 0, 0, 0, 4, 0, 0, 0, 19, + 11, 0, 0, 19, 11, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 255, 0, 0, + 255, 0, 0, 255, 0, 0, 0, 0, 0, 0, 255, 66, 71, 82, 115, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 135, 135, 135, 255, + // This comment prevents dartfmt reformatting this to single-item lines. +]); + +Image newImage({double width = 1, double height = 1}) => Image.memory( + testImageData, + width: width, + height: height, + ); + +const dummyText = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.'; diff --git a/flutter/test/replay/widget_filter_test.dart b/flutter/test/replay/widget_filter_test.dart new file mode 100644 index 0000000000..e5787431bd --- /dev/null +++ b/flutter/test/replay/widget_filter_test.dart @@ -0,0 +1,128 @@ +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:sentry_flutter/sentry_flutter.dart'; +import 'package:sentry_flutter/src/replay/widget_filter.dart'; + +import 'test_widget.dart'; + +void main() async { + TestWidgetsFlutterBinding.ensureInitialized(); + const defaultBounds = Rect.fromLTRB(0, 0, 1000, 1000); + final rootBundle = TestAssetBundle(); + final otherBundle = TestAssetBundle(); + + final createSut = + ({bool redactImages = false, bool redactText = false}) => WidgetFilter( + logger: (level, message, {exception, logger, stackTrace}) {}, + redactImages: redactImages, + redactText: redactText, + rootAssetBundle: rootBundle, + ); + + boundsRect(WidgetFilterItem item) => + '${item.bounds.width.floor()}x${item.bounds.height.floor()}'; + + group('redact text', () { + testWidgets('redacts the correct number of elements', (tester) async { + final sut = createSut(redactText: true); + final element = await pumpTestElement(tester); + sut.obscure(element, 1.0, defaultBounds); + expect(sut.items.length, 4); + }); + + testWidgets('does not redact text when disabled', (tester) async { + final sut = createSut(redactText: false); + final element = await pumpTestElement(tester); + sut.obscure(element, 1.0, defaultBounds); + expect(sut.items.length, 0); + }); + + testWidgets('does not redact elements that are outside the screen', + (tester) async { + final sut = createSut(redactText: true); + final element = await pumpTestElement(tester); + sut.obscure(element, 1.0, Rect.fromLTRB(0, 0, 100, 100)); + expect(sut.items.length, 1); + }); + + testWidgets('correctly determines sizes', (tester) async { + final sut = createSut(redactText: true); + final element = await pumpTestElement(tester); + sut.obscure(element, 1.0, defaultBounds); + expect(sut.items.length, 4); + expect(boundsRect(sut.items[0]), '624x48'); + expect(boundsRect(sut.items[1]), '169x20'); + expect(boundsRect(sut.items[2]), '800x192'); + expect(boundsRect(sut.items[3]), '50x20'); + }); + }); + + group('redact images', () { + testWidgets('redacts the correct number of elements', (tester) async { + final sut = createSut(redactImages: true); + final element = await pumpTestElement(tester); + sut.obscure(element, 1.0, defaultBounds); + expect(sut.items.length, 3); + }); + + // Note: we cannot currently test actual asset images without either: + // - introducing assets to the package because those wouldn't get tree-shaken in final user apps (https://github.com/flutter/flutter/issues/64106) + // - using a mock asset bundle implementation, because the image widget loads AssetManifest.bin first and we don't have a way to mock that (https://github.com/flutter/flutter/issues/126860) + // Therefore we only check the function that actually decides whether the image is a built-in asset image. + for (var newAssetImage in [AssetImage.new, ExactAssetImage.new]) { + testWidgets( + 'recognizes ${newAssetImage('').runtimeType} from the root bundle', + (tester) async { + final sut = createSut(redactImages: true); + + expect(sut.isBuiltInAssetImage(newAssetImage('')), isTrue); + expect(sut.isBuiltInAssetImage(newAssetImage('', bundle: rootBundle)), + isTrue); + expect(sut.isBuiltInAssetImage(newAssetImage('', bundle: otherBundle)), + isFalse); + expect( + sut.isBuiltInAssetImage(newAssetImage('', + bundle: SentryAssetBundle(bundle: rootBundle))), + isTrue); + expect( + sut.isBuiltInAssetImage(newAssetImage('', + bundle: SentryAssetBundle(bundle: otherBundle))), + isFalse); + }); + } + + testWidgets('does not redact text when disabled', (tester) async { + final sut = createSut(redactImages: false); + final element = await pumpTestElement(tester); + sut.obscure(element, 1.0, defaultBounds); + expect(sut.items.length, 0); + }); + + testWidgets('does not redact elements that are outside the screen', + (tester) async { + final sut = createSut(redactImages: true); + final element = await pumpTestElement(tester); + sut.obscure(element, 1.0, Rect.fromLTRB(0, 0, 500, 100)); + expect(sut.items.length, 1); + }); + + testWidgets('correctly determines sizes', (tester) async { + final sut = createSut(redactImages: true); + final element = await pumpTestElement(tester); + sut.obscure(element, 1.0, defaultBounds); + expect(sut.items.length, 3); + expect(boundsRect(sut.items[0]), '1x1'); + expect(boundsRect(sut.items[1]), '1x1'); + expect(boundsRect(sut.items[2]), '50x20'); + }); + }); +} + +class TestAssetBundle extends CachingAssetBundle { + @override + Future load(String key) async { + return ByteData(0); + } +} diff --git a/flutter/test/screenshot/sentry_screenshot_widget_test.dart b/flutter/test/screenshot/sentry_screenshot_widget_test.dart index 57379387d0..bedbfe8f1f 100644 --- a/flutter/test/screenshot/sentry_screenshot_widget_test.dart +++ b/flutter/test/screenshot/sentry_screenshot_widget_test.dart @@ -53,7 +53,7 @@ void main() { } class Fixture { - final _options = SentryFlutterOptions(dsn: fakeDsn); + final _options = defaultTestOptions(); late Hub hub; SentryScreenshotWidget getSut({ @@ -64,7 +64,6 @@ class Fixture { hub = Hub(_options); return SentryScreenshotWidget( - hub: hub, child: MaterialApp(home: MyApp()), ); } diff --git a/flutter/test/sentry_asset_bundle_test.dart b/flutter/test/sentry_asset_bundle_test.dart index c5236f674a..cd17f3e279 100644 --- a/flutter/test/sentry_asset_bundle_test.dart +++ b/flutter/test/sentry_asset_bundle_test.dart @@ -516,7 +516,7 @@ void main() { } class Fixture { - final _options = SentryOptions(dsn: fakeDsn); + final _options = defaultTestOptions(); late Hub _hub; final transport = MockTransport(); final assetBundle = TestAssetBundle(); diff --git a/flutter/test/sentry_flutter_options_test.dart b/flutter/test/sentry_flutter_options_test.dart index ed4a3ea7f7..7c4d2e79b1 100644 --- a/flutter/test/sentry_flutter_options_test.dart +++ b/flutter/test/sentry_flutter_options_test.dart @@ -1,6 +1,4 @@ import 'package:flutter_test/flutter_test.dart'; -import 'package:sentry_flutter/sentry_flutter.dart'; -import 'package:sentry_flutter/src/sentry_flutter_options.dart'; import 'mocks.dart'; @@ -8,8 +6,8 @@ void main() { group('SentryFlutterOptions', () { testWidgets('auto breadcrumb tracking: has native integration', (WidgetTester tester) async { - final options = SentryFlutterOptions( - checker: MockPlatformChecker(hasNativeIntegration: true)); + final options = + defaultTestOptions(MockPlatformChecker(hasNativeIntegration: true)); expect(options.enableAppLifecycleBreadcrumbs, isFalse); expect(options.enableWindowMetricBreadcrumbs, isFalse); @@ -21,8 +19,8 @@ void main() { testWidgets('auto breadcrumb tracking: without native integration', (WidgetTester tester) async { - final options = SentryFlutterOptions( - checker: MockPlatformChecker(hasNativeIntegration: false)); + final options = + defaultTestOptions(MockPlatformChecker(hasNativeIntegration: false)); expect(options.enableAppLifecycleBreadcrumbs, isTrue); expect(options.enableWindowMetricBreadcrumbs, isTrue); @@ -33,7 +31,7 @@ void main() { }); testWidgets('useNativeBreadcrumbTracking', (WidgetTester tester) async { - final options = SentryFlutterOptions(); + final options = defaultTestOptions(); options.useNativeBreadcrumbTracking(); expect(options.enableAppLifecycleBreadcrumbs, isFalse); @@ -45,7 +43,7 @@ void main() { }); testWidgets('useFlutterBreadcrumbTracking', (WidgetTester tester) async { - final options = SentryFlutterOptions(); + final options = defaultTestOptions(); options.useFlutterBreadcrumbTracking(); expect(options.enableAppLifecycleBreadcrumbs, isTrue); diff --git a/flutter/test/sentry_flutter_test.dart b/flutter/test/sentry_flutter_test.dart index e06938da90..7cd3794285 100644 --- a/flutter/test/sentry_flutter_test.dart +++ b/flutter/test/sentry_flutter_test.dart @@ -1,8 +1,12 @@ // ignore_for_file: invalid_use_of_internal_member import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart'; import 'package:package_info_plus/package_info_plus.dart'; +import 'package:sentry/src/platform/platform.dart'; +import 'package:sentry/src/dart_exception_type_identifier.dart'; import 'package:sentry_flutter/sentry_flutter.dart'; +import 'package:sentry_flutter/src/flutter_exception_type_identifier.dart'; import 'package:sentry_flutter/src/integrations/connectivity/connectivity_integration.dart'; import 'package:sentry_flutter/src/integrations/integrations.dart'; import 'package:sentry_flutter/src/integrations/screenshot_integration.dart'; @@ -51,6 +55,11 @@ final nativeIntegrations = [ void main() { TestWidgetsFlutterBinding.ensureInitialized(); + late NativeChannelFixture native; + + setUp(() async { + native = NativeChannelFixture(); + }); group('Test platform integrations', () { setUp(() async { @@ -63,19 +72,19 @@ void main() { List integrations = []; Transport transport = MockTransport(); - SentryFlutterOptions? sentryFlutterOptions; + final sentryFlutterOptions = defaultTestOptions( + getPlatformChecker(platform: MockPlatform.android())) + ..methodChannel = native.channel; await SentryFlutter.init( (options) async { options.dsn = fakeDsn; - options.automatedTestMode = true; options.profilesSampleRate = 1.0; integrations = options.integrations; transport = options.transport; - sentryFlutterOptions = options; }, appRunner: appRunner, - platformChecker: getPlatformChecker(platform: MockPlatform.android()), + options: sentryFlutterOptions, ); testTransport( @@ -84,7 +93,7 @@ void main() { ); testScopeObserver( - options: sentryFlutterOptions!, expectedHasNativeScopeObserver: true); + options: sentryFlutterOptions, expectedHasNativeScopeObserver: true); testConfiguration( integrations: integrations, @@ -117,19 +126,20 @@ void main() { test('iOS', () async { List integrations = []; Transport transport = MockTransport(); - SentryFlutterOptions? sentryFlutterOptions; + + final sentryFlutterOptions = + defaultTestOptions(getPlatformChecker(platform: MockPlatform.iOs())) + ..methodChannel = native.channel; await SentryFlutter.init( (options) async { options.dsn = fakeDsn; - options.automatedTestMode = true; options.profilesSampleRate = 1.0; integrations = options.integrations; transport = options.transport; - sentryFlutterOptions = options; }, appRunner: appRunner, - platformChecker: getPlatformChecker(platform: MockPlatform.iOs()), + options: sentryFlutterOptions, ); testTransport( @@ -138,7 +148,7 @@ void main() { ); testScopeObserver( - options: sentryFlutterOptions!, expectedHasNativeScopeObserver: true); + options: sentryFlutterOptions, expectedHasNativeScopeObserver: true); testConfiguration( integrations: integrations, @@ -169,19 +179,19 @@ void main() { test('macOS', () async { List integrations = []; Transport transport = MockTransport(); - SentryFlutterOptions? sentryFlutterOptions; + final sentryFlutterOptions = + defaultTestOptions(getPlatformChecker(platform: MockPlatform.macOs())) + ..methodChannel = native.channel; await SentryFlutter.init( (options) async { options.dsn = fakeDsn; - options.automatedTestMode = true; options.profilesSampleRate = 1.0; integrations = options.integrations; transport = options.transport; - sentryFlutterOptions = options; }, appRunner: appRunner, - platformChecker: getPlatformChecker(platform: MockPlatform.macOs()), + options: sentryFlutterOptions, ); testTransport( @@ -190,7 +200,7 @@ void main() { ); testScopeObserver( - options: sentryFlutterOptions!, expectedHasNativeScopeObserver: true); + options: sentryFlutterOptions, expectedHasNativeScopeObserver: true); testConfiguration(integrations: integrations, shouldHaveIntegrations: [ ...iOsAndMacOsIntegrations, @@ -217,19 +227,19 @@ void main() { test('Windows', () async { List integrations = []; Transport transport = MockTransport(); - SentryFlutterOptions? sentryFlutterOptions; + final sentryFlutterOptions = defaultTestOptions( + getPlatformChecker(platform: MockPlatform.windows())) + ..methodChannel = native.channel; await SentryFlutter.init( (options) async { options.dsn = fakeDsn; - options.automatedTestMode = true; options.profilesSampleRate = 1.0; integrations = options.integrations; transport = options.transport; - sentryFlutterOptions = options; }, appRunner: appRunner, - platformChecker: getPlatformChecker(platform: MockPlatform.windows()), + options: sentryFlutterOptions, ); testTransport( @@ -238,8 +248,7 @@ void main() { ); testScopeObserver( - options: sentryFlutterOptions!, - expectedHasNativeScopeObserver: false); + options: sentryFlutterOptions, expectedHasNativeScopeObserver: false); testConfiguration( integrations: integrations, @@ -269,19 +278,19 @@ void main() { test('Linux', () async { List integrations = []; Transport transport = MockTransport(); - SentryFlutterOptions? sentryFlutterOptions; + final sentryFlutterOptions = + defaultTestOptions(getPlatformChecker(platform: MockPlatform.linux())) + ..methodChannel = native.channel; await SentryFlutter.init( (options) async { options.dsn = fakeDsn; - options.automatedTestMode = true; options.profilesSampleRate = 1.0; integrations = options.integrations; transport = options.transport; - sentryFlutterOptions = options; }, appRunner: appRunner, - platformChecker: getPlatformChecker(platform: MockPlatform.linux()), + options: sentryFlutterOptions, ); testTransport( @@ -290,8 +299,7 @@ void main() { ); testScopeObserver( - options: sentryFlutterOptions!, - expectedHasNativeScopeObserver: false); + options: sentryFlutterOptions, expectedHasNativeScopeObserver: false); testConfiguration( integrations: integrations, @@ -321,22 +329,18 @@ void main() { test('Web', () async { List integrations = []; Transport transport = MockTransport(); - SentryFlutterOptions? sentryFlutterOptions; + final sentryFlutterOptions = defaultTestOptions( + getPlatformChecker(isWeb: true, platform: MockPlatform.linux())) + ..methodChannel = native.channel; await SentryFlutter.init( (options) async { - options.dsn = fakeDsn; - options.automatedTestMode = true; options.profilesSampleRate = 1.0; integrations = options.integrations; transport = options.transport; - sentryFlutterOptions = options; }, appRunner: appRunner, - platformChecker: getPlatformChecker( - isWeb: true, - platform: MockPlatform.linux(), - ), + options: sentryFlutterOptions, ); testTransport( @@ -345,8 +349,7 @@ void main() { ); testScopeObserver( - options: sentryFlutterOptions!, - expectedHasNativeScopeObserver: false); + options: sentryFlutterOptions, expectedHasNativeScopeObserver: false); testConfiguration( integrations: integrations, @@ -376,21 +379,19 @@ void main() { test('Web && (iOS || macOS)', () async { List integrations = []; Transport transport = MockTransport(); + final sentryFlutterOptions = defaultTestOptions( + getPlatformChecker(isWeb: true, platform: MockPlatform.iOs())) + ..methodChannel = native.channel; // Tests that iOS || macOS integrations aren't added on a browser which // runs on iOS or macOS await SentryFlutter.init( (options) async { - options.dsn = fakeDsn; - options.automatedTestMode = true; integrations = options.integrations; transport = options.transport; }, appRunner: appRunner, - platformChecker: getPlatformChecker( - isWeb: true, - platform: MockPlatform.iOs(), - ), + options: sentryFlutterOptions, ); testTransport( @@ -423,21 +424,19 @@ void main() { test('Web && (macOS)', () async { List integrations = []; Transport transport = MockTransport(); + final sentryFlutterOptions = defaultTestOptions( + getPlatformChecker(isWeb: true, platform: MockPlatform.macOs())) + ..methodChannel = native.channel; // Tests that iOS || macOS integrations aren't added on a browser which // runs on iOS or macOS await SentryFlutter.init( (options) async { - options.dsn = fakeDsn; - options.automatedTestMode = true; integrations = options.integrations; transport = options.transport; }, appRunner: appRunner, - platformChecker: getPlatformChecker( - isWeb: true, - platform: MockPlatform.macOs(), - ), + options: sentryFlutterOptions, ); testTransport( @@ -472,20 +471,18 @@ void main() { test('Web && Android', () async { List integrations = []; Transport transport = MockTransport(); + final sentryFlutterOptions = defaultTestOptions( + getPlatformChecker(isWeb: true, platform: MockPlatform.android())) + ..methodChannel = native.channel; // Tests that Android integrations aren't added on an Android browser await SentryFlutter.init( (options) async { - options.dsn = fakeDsn; - options.automatedTestMode = true; integrations = options.integrations; transport = options.transport; }, appRunner: appRunner, - platformChecker: getPlatformChecker( - isWeb: true, - platform: MockPlatform.android(), - ), + options: sentryFlutterOptions, ); testTransport( @@ -524,16 +521,19 @@ void main() { test('installed on io platforms', () async { List integrations = []; + final sentryFlutterOptions = defaultTestOptions( + getPlatformChecker(platform: MockPlatform.iOs(), isWeb: false)) + ..methodChannel = native.channel + ..rendererWrapper = MockRendererWrapper(FlutterRenderer.skia) + ..release = '' + ..dist = ''; + await SentryFlutter.init( (options) async { - options.dsn = fakeDsn; - options.automatedTestMode = true; integrations = options.integrations; }, appRunner: appRunner, - platformChecker: - getPlatformChecker(platform: MockPlatform.iOs(), isWeb: false), - rendererWrapper: MockRendererWrapper(FlutterRenderer.skia), + options: sentryFlutterOptions, ); expect( @@ -548,16 +548,18 @@ void main() { test('installed with canvasKit renderer', () async { List integrations = []; + final sentryFlutterOptions = defaultTestOptions( + getPlatformChecker(platform: MockPlatform.iOs(), isWeb: true)) + ..rendererWrapper = MockRendererWrapper(FlutterRenderer.canvasKit) + ..release = '' + ..dist = ''; + await SentryFlutter.init( (options) async { - options.dsn = fakeDsn; - options.automatedTestMode = true; integrations = options.integrations; }, appRunner: appRunner, - platformChecker: - getPlatformChecker(platform: MockPlatform.iOs(), isWeb: true), - rendererWrapper: MockRendererWrapper(FlutterRenderer.canvasKit), + options: sentryFlutterOptions, ); expect( @@ -572,16 +574,18 @@ void main() { test('not installed with html renderer', () async { List integrations = []; + final sentryFlutterOptions = defaultTestOptions( + getPlatformChecker(platform: MockPlatform.iOs(), isWeb: true)) + ..rendererWrapper = MockRendererWrapper(FlutterRenderer.html) + ..release = '' + ..dist = ''; + await SentryFlutter.init( (options) async { - options.dsn = fakeDsn; - options.automatedTestMode = true; integrations = options.integrations; }, appRunner: appRunner, - platformChecker: - getPlatformChecker(platform: MockPlatform.iOs(), isWeb: true), - rendererWrapper: MockRendererWrapper(FlutterRenderer.html), + options: sentryFlutterOptions, ); expect( @@ -601,11 +605,11 @@ void main() { }); test('test that initial values are set correctly', () async { + final sentryFlutterOptions = defaultTestOptions( + getPlatformChecker(platform: MockPlatform.android(), isWeb: true)); + await SentryFlutter.init( (options) { - options.dsn = fakeDsn; - options.automatedTestMode = true; - expect(false, options.debug); expect('debug', options.environment); expect(sdkName, options.sdk.name); @@ -614,9 +618,101 @@ void main() { expect(sdkVersion, options.sdk.packages.last.version); }, appRunner: appRunner, - platformChecker: getPlatformChecker( - platform: MockPlatform.android(), - isWeb: true, + options: sentryFlutterOptions, + ); + + await Sentry.close(); + }); + + test( + 'enablePureDartSymbolication is set to false during SentryFlutter init', + () async { + final sentryFlutterOptions = defaultTestOptions( + getPlatformChecker(platform: MockPlatform.android(), isWeb: true)); + SentryFlutter.native = MockSentryNativeBinding(); + await SentryFlutter.init( + (options) { + expect(options.enableDartSymbolication, false); + }, + appRunner: appRunner, + options: sentryFlutterOptions, + ); + + await Sentry.close(); + }); + }); + + test('resumeAppHangTracking calls native method when available', () async { + SentryFlutter.native = MockSentryNativeBinding(); + when(SentryFlutter.native?.resumeAppHangTracking()) + .thenAnswer((_) => Future.value()); + + await SentryFlutter.resumeAppHangTracking(); + + verify(SentryFlutter.native?.resumeAppHangTracking()).called(1); + + SentryFlutter.native = null; + }); + + test('resumeAppHangTracking does nothing when native is null', () async { + SentryFlutter.native = null; + + // This should complete without throwing an error + await expectLater(SentryFlutter.resumeAppHangTracking(), completes); + }); + + test('pauseAppHangTracking calls native method when available', () async { + SentryFlutter.native = MockSentryNativeBinding(); + when(SentryFlutter.native?.pauseAppHangTracking()) + .thenAnswer((_) => Future.value()); + + await SentryFlutter.pauseAppHangTracking(); + + verify(SentryFlutter.native?.pauseAppHangTracking()).called(1); + + SentryFlutter.native = null; + }); + + test('pauseAppHangTracking does nothing when native is null', () async { + SentryFlutter.native = null; + + // This should complete without throwing an error + await expectLater(SentryFlutter.pauseAppHangTracking(), completes); + }); + + group('exception identifiers', () { + setUp(() async { + loadTestPackage(); + await Sentry.close(); + }); + + test( + 'should add DartExceptionTypeIdentifier and FlutterExceptionTypeIdentifier by default', + () async { + final actualOptions = defaultTestOptions( + getPlatformChecker(platform: MockPlatform.android(), isWeb: true)); + await SentryFlutter.init( + (options) {}, + appRunner: appRunner, + options: actualOptions, + ); + + expect(actualOptions.exceptionTypeIdentifiers.length, 2); + // Flutter identifier should be first as it's more specific + expect( + actualOptions.exceptionTypeIdentifiers.first, + isA().having( + (c) => c.identifier, + 'wrapped identifier', + isA(), + ), + ); + expect( + actualOptions.exceptionTypeIdentifiers[1], + isA().having( + (c) => c.identifier, + 'wrapped identifier', + isA(), ), ); @@ -639,7 +735,7 @@ void loadTestPackage() { } PlatformChecker getPlatformChecker({ - required MockPlatform platform, + required Platform platform, bool isWeb = false, }) { final platformChecker = PlatformChecker( diff --git a/flutter/test/sentry_native_channel_test.dart b/flutter/test/sentry_native_channel_test.dart index 050693cc8a..0428349d49 100644 --- a/flutter/test/sentry_native_channel_test.dart +++ b/flutter/test/sentry_native_channel_test.dart @@ -3,242 +3,313 @@ @TestOn('vm') library flutter_test; +import 'dart:typed_data'; + import 'package:flutter_test/flutter_test.dart'; import 'package:mockito/mockito.dart'; import 'package:sentry_flutter/sentry_flutter.dart'; +import 'package:sentry_flutter/src/native/factory.dart'; import 'package:sentry_flutter/src/native/method_channel_helper.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 'package:sentry/src/platform/platform.dart' as platform; +import 'mocks.dart'; import 'mocks.mocks.dart'; +import 'sentry_flutter_test.dart'; void main() { - group('$SentryNative', () { - late Fixture fixture; + for (var mockPlatform in [ + MockPlatform.android(), + MockPlatform.iOs(), + MockPlatform.macOs() + ]) { + group('$SentryNativeBinding', () { + late SentryNativeBinding sut; + late MockMethodChannel channel; + + setUp(() { + channel = MockMethodChannel(); + final options = + defaultTestOptions(getPlatformChecker(platform: mockPlatform)) + ..methodChannel = channel; + sut = createBinding(options); + }); + + // TODO move other methods here, e.g. init_native_sdk_test.dart + + test('fetchNativeAppStart', () async { + when(channel.invokeMethod('fetchNativeAppStart')) + .thenAnswer((_) async => { + 'pluginRegistrationTime': 1, + 'appStartTime': 0.1, + 'isColdStart': true, + // ignore: inference_failure_on_collection_literal + 'nativeSpanTimes': {}, + }); + + final actual = await sut.fetchNativeAppStart(); + + expect(actual?.appStartTime, 0.1); + expect(actual?.isColdStart, true); + }); + + test('beginNativeFrames', () async { + when(channel.invokeMethod('beginNativeFrames')) + .thenAnswer((realInvocation) async {}); + await sut.beginNativeFrames(); + + verify(channel.invokeMethod('beginNativeFrames')); + }); + + test('endNativeFrames', () async { + final sentryId = SentryId.empty(); + + when(channel + .invokeMethod('endNativeFrames', {'id': sentryId.toString()})) + .thenAnswer((_) async => { + 'totalFrames': 3, + 'slowFrames': 2, + 'frozenFrames': 1, + }); + + final actual = await sut.endNativeFrames(sentryId); + + expect(actual?.totalFrames, 3); + expect(actual?.slowFrames, 2); + expect(actual?.frozenFrames, 1); + }); + + test('setUser', () async { + final user = SentryUser( + id: "fixture-id", + data: {'object': Object()}, + ); + final normalizedUser = user.copyWith( + data: MethodChannelHelper.normalizeMap(user.data), + ); + when(channel.invokeMethod('setUser', {'user': normalizedUser.toJson()})) + .thenAnswer((_) => Future.value()); + + await sut.setUser(user); + + verify( + channel.invokeMethod('setUser', {'user': normalizedUser.toJson()})); + }); + + test('addBreadcrumb', () async { + final breadcrumb = Breadcrumb( + data: {'object': Object()}, + ); + final normalizedBreadcrumb = breadcrumb.copyWith( + data: MethodChannelHelper.normalizeMap(breadcrumb.data)); + + when(channel.invokeMethod( + 'addBreadcrumb', {'breadcrumb': normalizedBreadcrumb.toJson()})) + .thenAnswer((_) => Future.value()); + + await sut.addBreadcrumb(breadcrumb); + + verify(channel.invokeMethod( + 'addBreadcrumb', {'breadcrumb': normalizedBreadcrumb.toJson()})); + }); + + test('clearBreadcrumbs', () async { + when(channel.invokeMethod('clearBreadcrumbs')) + .thenAnswer((_) => Future.value()); + + await sut.clearBreadcrumbs(); + + verify(channel.invokeMethod('clearBreadcrumbs')); + }); + + test('setContexts', () async { + final value = {'object': Object()}; + final normalizedValue = MethodChannelHelper.normalize(value); + when(channel.invokeMethod('setContexts', { + 'key': 'fixture-key', + 'value': normalizedValue + })).thenAnswer((_) => Future.value()); + + await sut.setContexts('fixture-key', value); + + verify(channel.invokeMethod( + 'setContexts', {'key': 'fixture-key', 'value': normalizedValue})); + }); + + test('removeContexts', () async { + when(channel.invokeMethod('removeContexts', {'key': 'fixture-key'})) + .thenAnswer((_) => Future.value()); + + await sut.removeContexts('fixture-key'); + + verify(channel.invokeMethod('removeContexts', {'key': 'fixture-key'})); + }); + + test('setExtra', () async { + final value = {'object': Object()}; + final normalizedValue = MethodChannelHelper.normalize(value); + when(channel.invokeMethod( + 'setExtra', {'key': 'fixture-key', 'value': normalizedValue})) + .thenAnswer((_) => Future.value()); + + await sut.setExtra('fixture-key', value); + + verify(channel.invokeMethod( + 'setExtra', {'key': 'fixture-key', 'value': normalizedValue})); + }); + + test('removeExtra', () async { + when(channel.invokeMethod('removeExtra', {'key': 'fixture-key'})) + .thenAnswer((_) => Future.value()); + + await sut.removeExtra('fixture-key'); + + verify(channel.invokeMethod('removeExtra', {'key': 'fixture-key'})); + }); + + test('setTag', () async { + when(channel.invokeMethod( + 'setTag', {'key': 'fixture-key', 'value': 'fixture-value'})) + .thenAnswer((_) => Future.value()); + + await sut.setTag('fixture-key', 'fixture-value'); + + verify(channel.invokeMethod( + 'setTag', {'key': 'fixture-key', 'value': 'fixture-value'})); + }); + + test('removeTag', () async { + when(channel.invokeMethod('removeTag', {'key': 'fixture-key'})) + .thenAnswer((_) => Future.value()); + + await sut.removeTag('fixture-key'); + + verify(channel.invokeMethod('removeTag', {'key': 'fixture-key'})); + }); + + test('startProfiler', () { + late Matcher matcher; + if (mockPlatform.isAndroid) { + matcher = throwsUnsupportedError; + } else if (mockPlatform.isIOS || mockPlatform.isMacOS) { + if (platform.instance.isMacOS) { + matcher = throwsA(predicate((e) => + e is Exception && + e.toString().contains('Failed to load Objective-C class'))); + } else { + matcher = throwsA(predicate((e) => + e is ArgumentError && + e.toString().contains('Failed to lookup symbol'))); + } + } + expect(() => sut.startProfiler(SentryId.newId()), matcher); + + verifyZeroInteractions(channel); + }); - setUp(() { - fixture = Fixture(); - }); + test('discardProfiler', () async { + final traceId = SentryId.newId(); + when(channel.invokeMethod('discardProfiler', traceId.toString())) + .thenAnswer((_) async {}); + + await sut.discardProfiler(traceId); + + verify(channel.invokeMethod('discardProfiler', traceId.toString())); + }); - test('fetchNativeAppStart', () async { - final map = { - 'pluginRegistrationTime': 1, - 'appStartTime': 0.1, - 'isColdStart': true, - // ignore: inference_failure_on_collection_literal - 'nativeSpanTimes': {}, - }; - final future = Future.value(map); - - when(fixture.methodChannel - .invokeMapMethod('fetchNativeAppStart')) - .thenAnswer((_) => future); - - final sut = fixture.getSut(); - final actual = await sut.fetchNativeAppStart(); - - expect(actual?.appStartTime, 0.1); - expect(actual?.isColdStart, true); - }); + test('collectProfile', () async { + final traceId = SentryId.newId(); + const startTime = 42; + const endTime = 50; + when(channel.invokeMethod('collectProfile', { + 'traceId': traceId.toString(), + 'startTime': startTime, + 'endTime': endTime, + })).thenAnswer((_) async => {}); + + await sut.collectProfile(traceId, startTime, endTime); - test('beginNativeFrames', () async { - final sut = fixture.getSut(); - when(fixture.methodChannel.invokeMethod('beginNativeFrames')) - .thenAnswer((realInvocation) async {}); - await sut.beginNativeFrames(); + verify(channel.invokeMethod('collectProfile', { + 'traceId': traceId.toString(), + 'startTime': startTime, + 'endTime': endTime, + })); + }); + + test('captureEnvelope', () async { + final data = Uint8List.fromList([1, 2, 3]); + + late Uint8List captured; + when(channel.invokeMethod('captureEnvelope', any)).thenAnswer( + (invocation) async => + {captured = invocation.positionalArguments[1][0] as Uint8List}); + + await sut.captureEnvelope(data, false); + + expect(captured, data); + }); + + test('loadContexts', () async { + when(channel.invokeMethod('loadContexts')) + .thenAnswer((invocation) async => { + 'foo': [1, 2, 3], + 'bar': {'a': 'b'}, + }); + + final data = await sut.loadContexts(); + + expect(data, { + 'foo': [1, 2, 3], + 'bar': {'a': 'b'}, + }); + }); + + test('loadDebugImages', () async { + final json = [ + { + 'code_file': '/apex/com.android.art/javalib/arm64/boot.oat', + 'code_id': '13577ce71153c228ecf0eb73fc39f45010d487f8', + 'image_addr': '0x6f80b000', + 'image_size': 3092480, + 'type': 'elf', + 'debug_id': 'e77c5713-5311-28c2-ecf0-eb73fc39f450', + 'debug_file': 'test' + } + ]; + + when(channel.invokeMethod('loadImageList')) + .thenAnswer((invocation) async => json); + + final data = await sut.loadDebugImages(); + + expect(data?.map((v) => v.toJson()), json); + }); + + test('pauseAppHangTracking', () async { + when(channel.invokeMethod('pauseAppHangTracking')) + .thenAnswer((_) => Future.value()); + + await sut.pauseAppHangTracking(); + + verify(channel.invokeMethod('pauseAppHangTracking')); + }); + + test('resumeAppHangTracking', () async { + when(channel.invokeMethod('resumeAppHangTracking')) + .thenAnswer((_) => Future.value()); + + await sut.resumeAppHangTracking(); + + verify(channel.invokeMethod('resumeAppHangTracking')); + }); - verify(fixture.methodChannel.invokeMethod('beginNativeFrames')); - }); + test('nativeCrash', () async { + when(channel.invokeMethod('nativeCrash')) + .thenAnswer((_) => Future.value()); - test('endNativeFrames', () async { - final sentryId = SentryId.empty(); - final map = { - 'totalFrames': 3, - 'slowFrames': 2, - 'frozenFrames': 1 - }; - final future = Future.value(map); - - when(fixture.methodChannel.invokeMapMethod( - 'endNativeFrames', {'id': sentryId.toString()})) - .thenAnswer((_) => future); - - final sut = fixture.getSut(); - final actual = await sut.endNativeFrames(sentryId); - - expect(actual?.totalFrames, 3); - expect(actual?.slowFrames, 2); - expect(actual?.frozenFrames, 1); - }); + await sut.nativeCrash(); - test('setUser', () async { - final user = SentryUser( - id: "fixture-id", - data: {'object': Object()}, - ); - final normalizedUser = user.copyWith( - data: MethodChannelHelper.normalizeMap(user.data), - ); - when(fixture.methodChannel - .invokeMethod('setUser', {'user': normalizedUser.toJson()})) - .thenAnswer((_) => Future.value()); - - final sut = fixture.getSut(); - await sut.setUser(user); - - verify(fixture.methodChannel - .invokeMethod('setUser', {'user': normalizedUser.toJson()})); + verify(channel.invokeMethod('nativeCrash')); + }); }); - - test('addBreadcrumb', () async { - final breadcrumb = Breadcrumb( - data: {'object': Object()}, - ); - final normalizedBreadcrumb = breadcrumb.copyWith( - data: MethodChannelHelper.normalizeMap(breadcrumb.data)); - - when(fixture.methodChannel.invokeMethod( - 'addBreadcrumb', {'breadcrumb': normalizedBreadcrumb.toJson()})) - .thenAnswer((_) => Future.value()); - - final sut = fixture.getSut(); - await sut.addBreadcrumb(breadcrumb); - - verify(fixture.methodChannel.invokeMethod( - 'addBreadcrumb', {'breadcrumb': normalizedBreadcrumb.toJson()})); - }); - - test('clearBreadcrumbs', () async { - when(fixture.methodChannel.invokeMethod('clearBreadcrumbs')) - .thenAnswer((_) => Future.value()); - - final sut = fixture.getSut(); - await sut.clearBreadcrumbs(); - - verify(fixture.methodChannel.invokeMethod('clearBreadcrumbs')); - }); - - test('setContexts', () async { - final value = {'object': Object()}; - final normalizedValue = MethodChannelHelper.normalize(value); - when(fixture.methodChannel.invokeMethod( - 'setContexts', {'key': 'fixture-key', 'value': normalizedValue})) - .thenAnswer((_) => Future.value()); - - final sut = fixture.getSut(); - await sut.setContexts('fixture-key', value); - - verify(fixture.methodChannel.invokeMethod( - 'setContexts', {'key': 'fixture-key', 'value': normalizedValue})); - }); - - test('removeContexts', () async { - when(fixture.methodChannel - .invokeMethod('removeContexts', {'key': 'fixture-key'})) - .thenAnswer((_) => Future.value()); - - final sut = fixture.getSut(); - await sut.removeContexts('fixture-key'); - - verify(fixture.methodChannel - .invokeMethod('removeContexts', {'key': 'fixture-key'})); - }); - - test('setExtra', () async { - final value = {'object': Object()}; - final normalizedValue = MethodChannelHelper.normalize(value); - when(fixture.methodChannel.invokeMethod( - 'setExtra', {'key': 'fixture-key', 'value': normalizedValue})) - .thenAnswer((_) => Future.value()); - - final sut = fixture.getSut(); - await sut.setExtra('fixture-key', value); - - verify(fixture.methodChannel.invokeMethod( - 'setExtra', {'key': 'fixture-key', 'value': normalizedValue})); - }); - - test('removeExtra', () async { - when(fixture.methodChannel - .invokeMethod('removeExtra', {'key': 'fixture-key'})) - .thenAnswer((_) => Future.value()); - - final sut = fixture.getSut(); - await sut.removeExtra('fixture-key'); - - verify(fixture.methodChannel - .invokeMethod('removeExtra', {'key': 'fixture-key'})); - }); - - test('setTag', () async { - when(fixture.methodChannel.invokeMethod( - 'setTag', {'key': 'fixture-key', 'value': 'fixture-value'})) - .thenAnswer((_) => Future.value()); - - final sut = fixture.getSut(); - await sut.setTag('fixture-key', 'fixture-value'); - - verify(fixture.methodChannel.invokeMethod( - 'setTag', {'key': 'fixture-key', 'value': 'fixture-value'})); - }); - - test('removeTag', () async { - when(fixture.methodChannel - .invokeMethod('removeTag', {'key': 'fixture-key'})) - .thenAnswer((_) => Future.value()); - - final sut = fixture.getSut(); - await sut.removeTag('fixture-key'); - - 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(); - - SentryNativeChannel getSut() { - return SentryNativeChannel(methodChannel); } } diff --git a/flutter/test/sentry_native_test.dart b/flutter/test/sentry_native_test.dart deleted file mode 100644 index 8d30312f6f..0000000000 --- a/flutter/test/sentry_native_test.dart +++ /dev/null @@ -1,120 +0,0 @@ -@TestOn('vm') -library flutter_test; - -import 'package:flutter_test/flutter_test.dart'; -import 'package:sentry_flutter/sentry_flutter.dart'; -import 'package:sentry_flutter/src/native/sentry_native.dart'; -import 'mocks.dart'; - -void main() { - group('$SentryNative', () { - final channel = MockNativeChannel(); - final options = SentryFlutterOptions(dsn: fakeDsn); - late final sut = SentryNative(options, channel); - - tearDown(() { - sut.reset(); - }); - - test('fetchNativeAppStart sets didFetchAppStart', () async { - final nativeAppStart = NativeAppStart( - appStartTime: 0.0, - pluginRegistrationTime: 10, - isColdStart: true, - nativeSpanTimes: {}); - channel.nativeAppStart = nativeAppStart; - - expect(sut.didFetchAppStart, false); - - final firstCall = await sut.fetchNativeAppStart(); - expect(firstCall, nativeAppStart); - - expect(sut.didFetchAppStart, true); - }); - - test('beginNativeFramesCollection', () async { - await sut.beginNativeFramesCollection(); - expect(channel.numberOfBeginNativeFramesCalls, 1); - }); - - test('endNativeFramesCollection', () async { - final nativeFrames = NativeFrames(3, 2, 1); - final traceId = SentryId.empty(); - channel.nativeFrames = nativeFrames; - - final actual = await sut.endNativeFramesCollection(traceId); - - expect(actual, nativeFrames); - expect(channel.id, traceId); - expect(channel.numberOfEndNativeFramesCalls, 1); - }); - - test('setUser', () async { - await sut.setUser(null); - expect(channel.numberOfSetUserCalls, 1); - }); - - test('addBreadcrumb', () async { - await sut.addBreadcrumb(Breadcrumb()); - expect(channel.numberOfAddBreadcrumbCalls, 1); - }); - - test('clearBreadcrumbs', () async { - await sut.clearBreadcrumbs(); - expect(channel.numberOfClearBreadcrumbCalls, 1); - }); - - test('setContexts', () async { - await sut.setContexts('fixture-key', 'fixture-value'); - expect(channel.numberOfSetContextsCalls, 1); - }); - - test('removeContexts', () async { - await sut.removeContexts('fixture-key'); - expect(channel.numberOfRemoveContextsCalls, 1); - }); - - test('setExtra', () async { - await sut.setExtra('fixture-key', 'fixture-value'); - expect(channel.numberOfSetExtraCalls, 1); - }); - - test('removeExtra', () async { - await sut.removeExtra('fixture-key'); - expect(channel.numberOfRemoveExtraCalls, 1); - }); - - test('setTag', () async { - await sut.setTag('fixture-key', 'fixture-value'); - expect(channel.numberOfSetTagCalls, 1); - }); - - test('removeTag', () async { - await sut.removeTag('fixture-key'); - expect(channel.numberOfRemoveTagCalls, 1); - }); - - test('startProfiler', () async { - sut.startProfiler(SentryId.newId()); - expect(channel.numberOfStartProfilerCalls, 1); - }); - - 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); - }); - }); -} diff --git a/flutter/test/sentry_navigator_observer_test.dart b/flutter/test/sentry_navigator_observer_test.dart index e2b1488cca..6d487a3b4f 100644 --- a/flutter/test/sentry_navigator_observer_test.dart +++ b/flutter/test/sentry_navigator_observer_test.dart @@ -7,9 +7,8 @@ import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mockito/mockito.dart'; import 'package:sentry_flutter/sentry_flutter.dart'; -import 'package:sentry_flutter/src/integrations/integrations.dart'; -import 'package:sentry_flutter/src/native/sentry_native.dart'; import 'package:sentry/src/sentry_tracer.dart'; +import 'package:sentry_flutter/src/native/native_frames.dart'; import 'package:sentry_flutter/src/navigation/time_to_display_tracker.dart'; import 'package:sentry_flutter/src/navigation/time_to_initial_display_tracker.dart'; @@ -41,16 +40,14 @@ void main() { setUp(() { fixture = Fixture(); - WidgetsFlutterBinding.ensureInitialized(); }); group('NativeFrames', () { - late MockNativeChannel mockNativeChannel; + late MockSentryNativeBinding mockBinding; setUp(() { - mockNativeChannel = MockNativeChannel(); - SentryFlutter.native = - SentryNative(SentryFlutterOptions(dsn: fakeDsn), mockNativeChannel); + mockBinding = MockSentryNativeBinding(); + SentryFlutter.native = mockBinding; }); tearDown(() { @@ -76,9 +73,8 @@ void main() { await sut.completedDisplayTracking?.future; // Handle internal async method calls. - await Future.delayed(const Duration(milliseconds: 10), () { - expect(mockNativeChannel.numberOfBeginNativeFramesCalls, 1); - }); + await Future.delayed(const Duration(milliseconds: 10), () {}); + verify(mockBinding.beginNativeFrames()).called(1); }); test('transaction finish adds native frames to tracer', () async { @@ -86,14 +82,12 @@ void main() { final options = defaultTestOptions(); options.tracesSampleRate = 1; + // Drop events, otherwise sentry tries to send them to the test DSN. + options.addEventProcessor(FunctionEventProcessor((_, __) => null)); final hub = Hub(options); - mockNativeChannel = MockNativeChannel(); - SentryFlutter.native = - SentryNative(SentryFlutterOptions(dsn: fakeDsn), mockNativeChannel); - - final nativeFrames = NativeFrames(3, 2, 1); - mockNativeChannel.nativeFrames = nativeFrames; + when(mockBinding.endNativeFrames(any)) + .thenAnswer((_) async => NativeFrames(3, 2, 1)); final sut = fixture.getSut(hub: hub); @@ -109,7 +103,7 @@ void main() { // Wait for the transaction to finish the async native frame fetching await Future.delayed(Duration(milliseconds: 1500)); - expect(mockNativeChannel.numberOfEndNativeFramesCalls, 1); + verify(mockBinding.beginNativeFrames()).called(1); final measurements = actualTransaction?.measurements ?? {}; @@ -494,49 +488,32 @@ void main() { verify(span.setData('route_settings_arguments', arguments)); }); - test('flutter root name is replaced', () async { + test('root route does not start transaction', () async { final rootRoute = route(RouteSettings(name: '/')); - NativeAppStartIntegration.setAppStartInfo( - AppStartInfo( - AppStartType.cold, - start: DateTime.now().add(const Duration(seconds: 1)), - end: DateTime.now().add(const Duration(seconds: 2)), - pluginRegistration: DateTime.now().add(const Duration(seconds: 3)), - sentrySetupStart: DateTime.now().add(const Duration(seconds: 4)), - nativeSpanTimes: [], - ), - ); final hub = _MockHub(); - final span = getMockSentryTracer(name: '/'); + final span = getMockSentryTracer(); when(span.context).thenReturn(SentrySpanContext(operation: 'op')); when(span.finished).thenReturn(false); when(span.status).thenReturn(SpanStatus.ok()); - when(span.startChild('ui.load.initial_display', - description: anyNamed('description'), - startTimestamp: anyNamed('startTimestamp'))) - .thenReturn(NoOpSentrySpan()); _whenAnyStart(hub, span); final sut = fixture.getSut(hub: hub); sut.didPush(rootRoute, null); - await Future.delayed(const Duration(milliseconds: 100)); - final context = verify(hub.startTransactionWithContext( - captureAny, - waitForChildren: true, + verifyNever(hub.startTransactionWithContext( + any, startTimestamp: anyNamed('startTimestamp'), + waitForChildren: true, autoFinishAfter: anyNamed('autoFinishAfter'), trimEnd: true, onFinish: anyNamed('onFinish'), - )).captured.single as SentryTransactionContext; - - expect(context.name, 'root /'); + )); hub.configureScope((scope) { - expect(scope.span, span); + expect(scope.span, null); }); }); @@ -605,6 +582,7 @@ void main() { const op = 'navigation'; final hub = _MockHub(); final span = getMockSentryTracer(name: oldRouteName); + when(span.children).thenReturn([]); when(span.context).thenReturn(SentrySpanContext(operation: op)); when(span.status).thenReturn(null); when(span.finished).thenReturn(false); @@ -982,12 +960,72 @@ void main() { observer.didReplace(newRoute: route(to), oldRoute: route(previous)); expect(hub.scope.transaction, 'to_test'); }); + + test('ignores Route and prevents recognition of this route for didPush', + () async { + final firstRoute = route(RouteSettings(name: 'default')); + final secondRoute = route(RouteSettings(name: 'testRoute')); + + final hub = _MockHub(); + _whenAnyStart(hub, NoOpSentrySpan()); + + final sut = fixture.getSut(hub: hub, ignoreRoutes: ["testRoute"]); + + sut.didPush(firstRoute, null); + expect( + SentryNavigatorObserver.currentRouteName, firstRoute.settings.name); + sut.didPush(secondRoute, firstRoute); + expect( + SentryNavigatorObserver.currentRouteName, firstRoute.settings.name); + sut.didPush(firstRoute, secondRoute); + expect( + SentryNavigatorObserver.currentRouteName, firstRoute.settings.name); + }); + + test('ignores Route and prevents recognition of this route for didPop', + () async { + final firstRoute = route(RouteSettings(name: 'default')); + final secondRoute = route(RouteSettings(name: 'testRoute')); + + final hub = _MockHub(); + _whenAnyStart(hub, NoOpSentrySpan()); + + final sut = fixture.getSut(hub: hub, ignoreRoutes: ["testRoute"]); + + sut.didPush(firstRoute, null); + expect( + SentryNavigatorObserver.currentRouteName, firstRoute.settings.name); + sut.didPush(secondRoute, firstRoute); + expect( + SentryNavigatorObserver.currentRouteName, firstRoute.settings.name); + sut.didPop(firstRoute, secondRoute); + expect( + SentryNavigatorObserver.currentRouteName, firstRoute.settings.name); + }); + + test('ignores Route and prevents recognition of this route for didReplace', + () async { + final firstRoute = route(RouteSettings(name: 'default')); + final secondRoute = route(RouteSettings(name: 'testRoute')); + + final hub = _MockHub(); + + final sut = fixture.getSut(hub: hub, ignoreRoutes: ["testRoute"]); + + sut.didReplace(newRoute: firstRoute); + expect( + SentryNavigatorObserver.currentRouteName, firstRoute.settings.name); + sut.didReplace(newRoute: secondRoute, oldRoute: firstRoute); + expect( + SentryNavigatorObserver.currentRouteName, firstRoute.settings.name); + sut.didReplace(newRoute: firstRoute, oldRoute: secondRoute); + expect( + SentryNavigatorObserver.currentRouteName, firstRoute.settings.name); + }); }); } class Fixture { - final mockNativeChannel = MockNativeChannel(); - SentryNavigatorObserver getSut({ required Hub hub, bool enableAutoTransactions = true, @@ -996,6 +1034,7 @@ class Fixture { RouteNameExtractor? routeNameExtractor, AdditionalInfoExtractor? additionalInfoProvider, bool enableTimeToFullDisplayTracing = false, + List? ignoreRoutes, }) { final frameCallbackHandler = FakeFrameCallbackHandler(); final timeToInitialDisplayTracker = @@ -1012,6 +1051,7 @@ class Fixture { routeNameExtractor: routeNameExtractor, additionalInfoProvider: additionalInfoProvider, timeToDisplayTracker: timeToDisplayTracker, + ignoreRoutes: ignoreRoutes, ); } @@ -1033,7 +1073,7 @@ class _MockHub extends MockHub { } } -ISentrySpan getMockSentryTracer({String? name, bool? finished}) { +MockSentryTracer getMockSentryTracer({String? name, bool? finished}) { final tracer = MockSentryTracer(); when(tracer.name).thenReturn(name ?? 'name'); when(tracer.finished).thenReturn(finished ?? true); diff --git a/flutter/test/span_frame_metrics_collector_test.dart b/flutter/test/span_frame_metrics_collector_test.dart new file mode 100644 index 0000000000..41cb56b6d5 --- /dev/null +++ b/flutter/test/span_frame_metrics_collector_test.dart @@ -0,0 +1,274 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart'; +import 'package:sentry_flutter/sentry_flutter.dart'; +import 'package:sentry_flutter/src/span_frame_metrics_collector.dart'; + +import 'fake_frame_callback_handler.dart'; +import 'mocks.dart'; + +// ignore: implementation_imports +import 'package:sentry/src/sentry_tracer.dart'; + +import 'mocks.mocks.dart'; + +void main() { + late Fixture fixture; + + setUp(() { + fixture = Fixture(); + WidgetsFlutterBinding.ensureInitialized(); + + when(fixture.mockSentryNative.displayRefreshRate()) + .thenAnswer((_) async => 60); + }); + + test('clear() clears frames, running spans and pauses frame tracking', () { + final sut = fixture.sut; + sut.frames[DateTime.now()] = 1; + final mockSpan = MockSentrySpan(); + when(mockSpan.startTimestamp).thenReturn(DateTime.now()); + + sut.onSpanStarted(mockSpan); + sut.clear(); + + expect(sut.frames, isEmpty); + expect(sut.activeSpans, isEmpty); + expect(sut.isTrackingPaused, isTrue); + }); + + test('does not start frame tracking if frames tracking is disabled', () { + final sut = fixture.sut; + fixture.options.enableFramesTracking = false; + + final span = MockSentrySpan(); + sut.onSpanStarted(span); + + expect(sut.isTrackingRegistered, isFalse); + }); + + test( + 'captures metrics with display refresh rate of 60 if native refresh rate is null', + () async { + final sut = fixture.sut; + fixture.options.tracesSampleRate = 1.0; + fixture.options.addPerformanceCollector(sut); + final startTimestamp = DateTime.now(); + final endTimestamp = + startTimestamp.add(Duration(milliseconds: 1000)).toUtc(); + + when(fixture.mockSentryNative.displayRefreshRate()) + .thenAnswer((_) async => null); + + final tracer = SentryTracer( + SentryTransactionContext('name', 'op', description: 'tracerDesc'), + fixture.hub, + startTimestamp: startTimestamp); + + await Future.delayed(Duration(milliseconds: 500)); + await tracer.finish(endTimestamp: endTimestamp); + + expect(tracer.data['frames.slow'], expectedSlowFrames); + expect(tracer.data['frames.frozen'], expectedFrozenFrames); + expect(tracer.data['frames.delay'], expectedFramesDelay); + expect(tracer.data['frames.total'], expectedTotalFrames); + }); + + test('onSpanFinished removes frames older than span start timestamp', + () async { + // Using multiple spans to test frame removal. When the last span is finished, + // the tracker clears all data, so we need at least two spans to observe partial removal. + final sut = fixture.sut; + final span1 = MockSentrySpan(); + final span2 = MockSentrySpan(); + final spanStartTimestamp = DateTime.now(); + final spanEndTimestamp = spanStartTimestamp.add(Duration(seconds: 1)); + + when(span1.isRootSpan).thenReturn(false); + when(span1.startTimestamp).thenReturn(spanStartTimestamp); + when(span1.context).thenReturn(SentrySpanContext(operation: 'op')); + + when(span2.isRootSpan).thenReturn(false); + when(span2.startTimestamp) + .thenReturn(spanStartTimestamp.add(Duration(seconds: 2))); + when(span2.context).thenReturn(SentrySpanContext(operation: 'op')); + + sut.activeSpans.add(span1); + sut.activeSpans.add(span2); + + sut.frames[spanStartTimestamp.subtract(Duration(seconds: 5))] = 1; + sut.frames[spanStartTimestamp.subtract(Duration(seconds: 3))] = 1; + sut.frames[spanStartTimestamp.add(Duration(seconds: 4))] = 1; + + await sut.onSpanFinished(span1, spanEndTimestamp); + + expect(sut.frames, hasLength(1)); + expect(sut.frames.keys.first, spanStartTimestamp.add(Duration(seconds: 4))); + }); + + test( + 'starting and finishing a span calculates and attaches frame metrics to span', + () async { + final sut = fixture.sut; + fixture.options.tracesSampleRate = 1.0; + fixture.options.addPerformanceCollector(sut); + final startTimestamp = DateTime.now(); + final endTimestamp = startTimestamp.add(Duration(milliseconds: 1000)); + + final tracer = SentryTracer( + SentryTransactionContext('name1', 'op1'), fixture.hub, + startTimestamp: startTimestamp); + + await Future.delayed(Duration(milliseconds: 500)); + await tracer.finish(endTimestamp: endTimestamp); + + expect(tracer.data['frames.slow'], expectedSlowFrames); + expect(tracer.data['frames.frozen'], expectedFrozenFrames); + expect(tracer.data['frames.delay'], expectedFramesDelay); + expect(tracer.data['frames.total'], expectedTotalFrames); + + expect(tracer.measurements['frames_delay']!.value, expectedFramesDelay); + expect(tracer.measurements['frames_total']!.value, expectedTotalFrames); + expect(tracer.measurements['frames_slow']!.value, expectedSlowFrames); + expect(tracer.measurements['frames_frozen']!.value, expectedFrozenFrames); + }); + + test('frame fully contained in span should contribute to frame metrics', () { + final sut = fixture.sut; + final span = MockSentrySpan(); + + final now = DateTime.now(); + when(span.startTimestamp).thenReturn(now); + when(span.endTimestamp).thenReturn(now.add(Duration(milliseconds: 500))); + sut.frames[now.add(Duration(milliseconds: 200))] = 100; + + final metrics = sut.calculateFrameMetrics(span, span.endTimestamp!, 60); + + expect(metrics['frames.total'], 26); + expect(metrics['frames.slow'], 1); + expect(metrics['frames.delay'], 84); + expect(metrics['frames.frozen'], 0); + }); + + test('frame fully outside of span should not contribute to frame metrics', + () { + final sut = fixture.sut; + final span = MockSentrySpan(); + + final now = DateTime.now(); + when(span.startTimestamp).thenReturn(now); + when(span.endTimestamp).thenReturn(now.add(Duration(milliseconds: 500))); + sut.frames[now.subtract(Duration(milliseconds: 200))] = 100; + + final metrics = sut.calculateFrameMetrics(span, span.endTimestamp!, 60); + + expect(metrics['frames.total'], 31); + expect(metrics['frames.slow'], 0); + expect(metrics['frames.delay'], 0); + expect(metrics['frames.frozen'], 0); + }); + + test( + 'frame partially contained in span (starts before span and ends within span) should contribute to frame metrics', + () { + final sut = fixture.sut; + final span = MockSentrySpan(); + + final now = DateTime.now(); + when(span.startTimestamp).thenReturn(now); + when(span.endTimestamp).thenReturn(now.add(Duration(milliseconds: 500))); + // 50ms before span starts and ends 50ms after span starts + sut.frames[now.add(Duration(milliseconds: 50))] = 100; + + final metrics = sut.calculateFrameMetrics(span, span.endTimestamp!, 60); + + expect(metrics['frames.total'], 29); + expect(metrics['frames.slow'], 1); + expect(metrics['frames.delay'], 42); + expect(metrics['frames.frozen'], 0); + }); + + test( + 'frame partially contained in span (starts withing span and ends after span end) should contribute to frame metrics', + () { + final sut = fixture.sut; + final span = MockSentrySpan(); + + final now = DateTime.now(); + when(span.startTimestamp).thenReturn(now); + when(span.endTimestamp).thenReturn(now.add(Duration(milliseconds: 500))); + sut.frames[now.add(Duration(milliseconds: 550))] = 100; + + final metrics = sut.calculateFrameMetrics(span, span.endTimestamp!, 60); + + expect(metrics['frames.total'], 29); + expect(metrics['frames.slow'], 1); + expect(metrics['frames.delay'], 42); + expect(metrics['frames.frozen'], 0); + }); + + test('calculates frame metrics correctly for multiple simultaneous spans', + () async { + final sut = fixture.sut; + fixture.options.tracesSampleRate = 1.0; + fixture.options.addPerformanceCollector(sut); + final startTimestamp = DateTime.now(); + final endTimestamp = startTimestamp.add(Duration(milliseconds: 1000)); + + final tracer = SentryTracer( + SentryTransactionContext('name1', 'op1'), fixture.hub, + startTimestamp: startTimestamp); + + final child = tracer.startChild('child', + startTimestamp: startTimestamp.add(Duration(milliseconds: 1))) + as SentrySpan; + + await Future.delayed(Duration(milliseconds: 500)); + await child.finish(endTimestamp: endTimestamp); + + await Future.delayed(Duration(milliseconds: 500)); + await tracer.finish(endTimestamp: endTimestamp); + + expect(child.data['frames.slow'], expectedSlowFrames); + expect(child.data['frames.frozen'], expectedFrozenFrames); + expect(child.data['frames.delay'], expectedFramesDelay); + expect(child.data['frames.total'], expectedTotalFrames); + + // total frames is hardcoded here since it depends on span duration as well + // and we are deviating from the default 800ms to 1600ms for the whole transaction + expect(tracer.data['frames.slow'], expectedSlowFrames); + expect(tracer.data['frames.frozen'], expectedFrozenFrames); + expect(tracer.data['frames.delay'], expectedFramesDelay); + // expect(tracer.data['frames.total'], 54); + expect(tracer.measurements['frames_delay']!.value, expectedFramesDelay); + // expect(tracer.measurements['frames_total']!.value, 54); + expect(tracer.measurements['frames_slow']!.value, expectedSlowFrames); + expect(tracer.measurements['frames_frozen']!.value, expectedFrozenFrames); + }); + + test('frame tracker is paused after finishing a span', () async { + final sut = fixture.sut; + fixture.options.tracesSampleRate = 1.0; + fixture.options.addPerformanceCollector(sut); + + final tracer = + SentryTracer(SentryTransactionContext('name', 'op'), fixture.hub); + + await Future.delayed(Duration(milliseconds: 100)); + await tracer.finish(); + + expect(sut.isTrackingPaused, isTrue); + }); +} + +class Fixture { + final options = defaultTestOptions(); + late final hub = Hub(options); + final fakeFrameCallbackHandler = FakeFrameCallbackHandler(); + final mockSentryNative = MockSentryNativeBinding(); + + SpanFrameMetricsCollector get sut => SpanFrameMetricsCollector(options, + frameCallbackHandler: fakeFrameCallbackHandler, + native: mockSentryNative, + isTestMode: true); +} diff --git a/flutter/test/user_interaction/sentry_user_interaction_widget_test.dart b/flutter/test/user_interaction/sentry_user_interaction_widget_test.dart index 8f326579f4..cdb27f9275 100644 --- a/flutter/test/user_interaction/sentry_user_interaction_widget_test.dart +++ b/flutter/test/user_interaction/sentry_user_interaction_widget_test.dart @@ -109,13 +109,24 @@ void main() { await tapMe(tester, sut, 'btn_1'); - Breadcrumb? crumb; - fixture.hub.configureScope((scope) { - crumb = scope.breadcrumbs.last; - }); - expect(crumb?.category, 'ui.click'); - expect(crumb?.data?['view.id'], 'btn_1'); - expect(crumb?.data?['view.class'], 'MaterialButton'); + expect( + fixture.getBreadcrumb().data?.replaceHashCodes(), + equals({ + 'path': [ + {'name': 'btn_1', 'element': 'MaterialButton'}, + {'element': 'Column'}, + {'element': 'Center'}, + {'name': '[GlobalKey#00000]', 'element': 'KeyedSubtree'}, + {'element': 'MediaQuery'}, + {'name': '_ScaffoldSlot.body', 'element': 'LayoutId'}, + {'element': 'CustomMultiChildLayout'}, + {'element': 'Actions'}, + {'element': 'AnimatedBuilder'}, + {'element': 'DefaultTextStyle'} + ], + 'view.id': 'btn_1', + 'view.class': 'MaterialButton', + })); }); }); @@ -125,11 +136,25 @@ void main() { await tapMe(tester, sut, 'btn_1'); - Breadcrumb? crumb; - fixture.hub.configureScope((scope) { - crumb = scope.breadcrumbs.last; - }); - expect(crumb?.data?['label'], 'Button 1'); + expect( + fixture.getBreadcrumb().data?.replaceHashCodes(), + equals({ + 'path': [ + {'name': 'btn_1', 'element': 'MaterialButton'}, + {'element': 'Column'}, + {'element': 'Center'}, + {'name': '[GlobalKey#00000]', 'element': 'KeyedSubtree'}, + {'element': 'MediaQuery'}, + {'name': '_ScaffoldSlot.body', 'element': 'LayoutId'}, + {'element': 'CustomMultiChildLayout'}, + {'element': 'Actions'}, + {'element': 'AnimatedBuilder'}, + {'element': 'DefaultTextStyle'} + ], + 'label': 'Button 1', + 'view.id': 'btn_1', + 'view.class': 'MaterialButton' + })); }); }); @@ -139,11 +164,25 @@ void main() { await tapMe(tester, sut, 'btn_3'); - Breadcrumb? crumb; - fixture.hub.configureScope((scope) { - crumb = scope.breadcrumbs.last; - }); - expect(crumb?.data?['label'], 'My Icon'); + expect( + fixture.getBreadcrumb().data?.replaceHashCodes(), + equals({ + 'path': [ + {'name': 'btn_3', 'element': 'IconButton'}, + {'element': 'Column'}, + {'element': 'Center'}, + {'name': '[GlobalKey#00000]', 'element': 'KeyedSubtree'}, + {'element': 'MediaQuery'}, + {'name': '_ScaffoldSlot.body', 'element': 'LayoutId'}, + {'element': 'CustomMultiChildLayout'}, + {'element': 'Actions'}, + {'element': 'AnimatedBuilder'}, + {'element': 'DefaultTextStyle'} + ], + 'label': 'My Icon', + 'view.id': 'btn_3', + 'view.class': 'IconButton' + })); }); }); @@ -153,11 +192,25 @@ void main() { await tapMe(tester, sut, 'btn_2'); - Breadcrumb? crumb; - fixture.hub.configureScope((scope) { - crumb = scope.breadcrumbs.last; - }); - expect(crumb?.data?['label'], 'Button 2'); + expect( + fixture.getBreadcrumb().data?.replaceHashCodes(), + equals({ + 'path': [ + {'name': 'btn_2', 'element': 'CupertinoButton'}, + {'element': 'Column'}, + {'element': 'Center'}, + {'name': '[GlobalKey#00000]', 'element': 'KeyedSubtree'}, + {'element': 'MediaQuery'}, + {'name': '_ScaffoldSlot.body', 'element': 'LayoutId'}, + {'element': 'CustomMultiChildLayout'}, + {'element': 'Actions'}, + {'element': 'AnimatedBuilder'}, + {'element': 'DefaultTextStyle'} + ], + 'label': 'Button 2', + 'view.id': 'btn_2', + 'view.class': 'CupertinoButton' + })); }); }); @@ -183,11 +236,25 @@ void main() { await tapMe(tester, sut, 'btn_5'); - Breadcrumb? crumb; - fixture.hub.configureScope((scope) { - crumb = scope.breadcrumbs.last; - }); - expect(crumb?.data?['label'], 'Button 5'); + expect( + fixture.getBreadcrumb().data?.replaceHashCodes(), + equals({ + 'path': [ + {'name': 'btn_5', 'element': 'ButtonStyleButton'}, + {'element': 'Stack'}, + {'element': 'Listener'}, + {'element': 'RawGestureDetector'}, + {'name': 'btn_4', 'element': 'GestureDetector'}, + {'element': 'Semantics'}, + {'element': 'DefaultTextStyle'}, + {'element': 'AnimatedDefaultTextStyle'}, + {'element': 'NotificationListener'}, + {'element': 'CustomPaint'} + ], + 'label': 'Button 5', + 'view.id': 'btn_5', + 'view.class': 'ButtonStyleButton' + })); }); }); @@ -197,13 +264,24 @@ void main() { await tapMe(tester, sut, 'popup_menu_button'); - Breadcrumb? crumb; - fixture.hub.configureScope((scope) { - crumb = scope.breadcrumbs.last; - }); - expect(crumb?.category, 'ui.click'); - expect(crumb?.data?['view.id'], 'popup_menu_button'); - expect(crumb?.data?['view.class'], 'PopupMenuButton'); + expect( + fixture.getBreadcrumb().data?.replaceHashCodes(), + equals({ + 'path': [ + {'name': 'popup_menu_button', 'element': 'PopupMenuButton'}, + {'element': 'Column'}, + {'element': 'Center'}, + {'name': '[GlobalKey#00000]', 'element': 'KeyedSubtree'}, + {'element': 'MediaQuery'}, + {'name': '_ScaffoldSlot.body', 'element': 'LayoutId'}, + {'element': 'CustomMultiChildLayout'}, + {'element': 'Actions'}, + {'element': 'AnimatedBuilder'}, + {'element': 'DefaultTextStyle'} + ], + 'view.id': 'popup_menu_button', + 'view.class': 'PopupMenuButton' + })); }); }); @@ -217,13 +295,90 @@ void main() { await tapMe(tester, sut, 'popup_menu_item_1'); - Breadcrumb? crumb; - fixture.hub.configureScope((scope) { - crumb = scope.breadcrumbs.last; - }); - expect(crumb?.category, 'ui.click'); - expect(crumb?.data?['view.id'], 'popup_menu_item_1'); - expect(crumb?.data?['view.class'], 'PopupMenuItem'); + expect( + fixture.getBreadcrumb().data?.replaceHashCodes(), + equals({ + 'path': [ + {'name': 'popup_menu_item_1', 'element': 'PopupMenuItem'}, + {'name': '[GlobalKey#00000]', 'element': 'FadeTransition'}, + {'element': 'ListBody'}, + {'element': 'Padding'}, + {'name': '[GlobalKey#00000]', 'element': 'IgnorePointer'}, + {'element': 'Semantics'}, + {'element': 'Listener'}, + { + 'name': '[LabeledGlobalKey#00000]', + 'element': 'RawGestureDetector' + }, + {'element': 'Listener'}, + {'element': 'NotificationListener'} + ], + 'view.id': 'popup_menu_item_1', + 'view.class': 'PopupMenuItem' + })); + }); + }); + + testWidgets('Add crumb for button with tooltip', (tester) async { + await tester.runAsync(() async { + final sut = fixture.getSut(sendDefaultPii: true); + + // open the popup menu and wait for the animation to complete + await tapMe(tester, sut, 'tooltip_button'); + + expect( + fixture.getBreadcrumb().data?.replaceHashCodes(), + equals({ + 'path': [ + {'name': 'tooltip_button', 'element': 'ButtonStyleButton'}, + {'element': 'Semantics'}, + {'element': 'Listener'}, + {'element': 'OverlayPortal'}, + {'element': 'Tooltip', 'label': 'Tooltip message.'}, + {'element': 'Column'}, + {'element': 'Center'}, + {'name': '[GlobalKey#00000]', 'element': 'KeyedSubtree'}, + {'element': 'MediaQuery'}, + {'name': '_ScaffoldSlot.body', 'element': 'LayoutId'} + ], + 'label': 'Button text', + 'view.id': 'tooltip_button', + 'view.class': 'ButtonStyleButton' + })); + }); + }); + + testWidgets('Add crumb for button without key', (tester) async { + await tester.runAsync(() async { + final sut = fixture.getSut(sendDefaultPii: true); + + await tester.pumpWidget(sut); + await tester.tap(find.byElementPredicate((element) { + final widget = element.widget; + if (widget is MaterialButton) { + return (widget.child as Text).data == 'Button 5'; + } + return false; + })); + + expect( + fixture.getBreadcrumb().data?.replaceHashCodes(), + equals({ + 'path': [ + {'element': 'MaterialButton'}, + {'element': 'Column'}, + {'element': 'Center'}, + {'name': '[GlobalKey#00000]', 'element': 'KeyedSubtree'}, + {'element': 'MediaQuery'}, + {'name': '_ScaffoldSlot.body', 'element': 'LayoutId'}, + {'element': 'CustomMultiChildLayout'}, + {'element': 'Actions'}, + {'element': 'AnimatedBuilder'}, + {'element': 'DefaultTextStyle'} + ], + 'label': 'Button 5', + 'view.class': 'MaterialButton' + })); }); }); }); @@ -365,12 +520,11 @@ Future tapMe( if (pumpWidget) { await tester.pumpWidget(widget); } - await tester.tap(find.byKey(Key(key))); } class Fixture { - final _options = SentryFlutterOptions(dsn: fakeDsn); + final _options = defaultTestOptions(); final _transport = MockTransport(); late Hub hub; @@ -394,6 +548,14 @@ class Fixture { child: MyApp(), ); } + + Breadcrumb getBreadcrumb() { + late final Breadcrumb crumb; + hub.configureScope((scope) { + crumb = scope.breadcrumbs.last; + }); + return crumb; + } } class MyApp extends StatelessWidget { @@ -420,23 +582,17 @@ class Page1 extends StatelessWidget { children: [ MaterialButton( key: Key('btn_1'), - onPressed: () { - // print('button pressed'); - }, + onPressed: () {}, child: const Text('Button 1'), ), CupertinoButton( key: Key('btn_2'), - onPressed: () { - // print('button pressed 2'); - }, + onPressed: () {}, child: const Text('Button 2'), ), IconButton( key: Key('btn_3'), - onPressed: () { - // print('button pressed 3'); - }, + onPressed: () {}, icon: Icon( Icons.dark_mode, semanticLabel: 'My Icon', @@ -445,17 +601,13 @@ class Page1 extends StatelessWidget { Card( child: GestureDetector( key: Key('btn_4'), - onTap: () => { - // print('button pressed 4'), - }, + onTap: () => {}, child: Stack( children: [ //fancy card layout ElevatedButton( key: Key('btn_5'), - onPressed: () => { - // print('button pressed 5'), - }, + onPressed: () => {}, child: const Text('Button 5'), ), ], @@ -478,6 +630,18 @@ class Page1 extends StatelessWidget { ), ], ), + Tooltip( + message: 'Tooltip message.', + child: ElevatedButton( + key: ValueKey('tooltip_button'), + onPressed: () {}, + child: Text('Button text'), + ), + ), + MaterialButton( + onPressed: () {}, + child: const Text('Button 5'), + ), ], ), ), @@ -496,9 +660,7 @@ class Page2 extends StatelessWidget { children: [ MaterialButton( key: Key('btn_page_2'), - onPressed: () { - // print('button page 2 pressed'); - }, + onPressed: () {}, child: const Text('Button Page 2'), ), ], @@ -507,3 +669,34 @@ class Page2 extends StatelessWidget { ); } } + +extension on String { + String replaceHashCodes() => replaceAll(RegExp(r'#[\da-fA-F]{5}'), '#00000'); +} + +extension on Map { + Map replaceHashCodes() => map((key, value) { + if (value is String) { + value = value.replaceHashCodes(); + } else if (value is Map) { + value = value.replaceHashCodes(); + } else if (value is List) { + value = value.replaceHashCodes(); + } + return MapEntry(key, value); + }); +} + +extension on List { + Iterable replaceHashCodes() => map((value) { + if (value is String) { + return value.replaceHashCodes(); + } else if (value is Map) { + return value.replaceHashCodes(); + } else if (value is List) { + return value.replaceHashCodes(); + } else { + return value; + } + }); +} diff --git a/flutter/test/view_hierarchy/sentry_tree_walker_test.dart b/flutter/test/view_hierarchy/sentry_tree_walker_test.dart index 52d775529b..26a7c41a0a 100644 --- a/flutter/test/view_hierarchy/sentry_tree_walker_test.dart +++ b/flutter/test/view_hierarchy/sentry_tree_walker_test.dart @@ -1,8 +1,10 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:sentry/sentry.dart'; +import 'package:sentry_flutter/sentry_flutter.dart'; import 'package:sentry_flutter/src/view_hierarchy/sentry_tree_walker.dart'; +import '../mocks.dart'; + void main() { group('TreeWalker', () { late WidgetsBinding instance; @@ -16,7 +18,8 @@ void main() { await tester.runAsync(() async { await tester.pumpWidget(MyApp()); - final sentryViewHierarchy = walkWidgetTree(instance); + final sentryViewHierarchy = + walkWidgetTree(instance, defaultTestOptions()); expect(sentryViewHierarchy!.renderingSystem, 'flutter'); }); @@ -147,7 +150,8 @@ void main() { SentryViewHierarchyElement _getFirstSentryViewHierarchy( WidgetsBinding instance) { - final sentryViewHierarchy = walkWidgetTree(instance); + final options = defaultTestOptions(); + final sentryViewHierarchy = walkWidgetTree(instance, options); return sentryViewHierarchy!.windows.first; } diff --git a/flutter/test/view_hierarchy/view_hierarchy_event_processor_test.dart b/flutter/test/view_hierarchy/view_hierarchy_event_processor_test.dart index 7743e8ebc6..9ba8a694ff 100644 --- a/flutter/test/view_hierarchy/view_hierarchy_event_processor_test.dart +++ b/flutter/test/view_hierarchy/view_hierarchy_event_processor_test.dart @@ -1,10 +1,13 @@ +import 'dart:convert'; + import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:sentry/sentry.dart'; import 'package:sentry_flutter/src/binding_wrapper.dart'; -import 'package:sentry_flutter/src/sentry_flutter_options.dart'; import 'package:sentry_flutter/src/view_hierarchy/view_hierarchy_event_processor.dart'; +import '../mocks.dart'; + void main() { group(SentryViewHierarchyEventProcessor, () { late Fixture fixture; @@ -79,6 +82,27 @@ void main() { expect(hint.viewHierarchy, isNull); }); }); + + testWidgets('does not add view hierarchy identifiers if opt out in options', + (tester) async { + await tester.runAsync(() async { + final sut = + fixture.getSut(instance, reportViewHierarchyIdentifiers: false); + + await tester.pumpWidget(MyApp()); + + final event = SentryEvent( + exceptions: [SentryException(type: 'type', value: 'value')]); + final hint = Hint(); + + sut.apply(event, hint); + + expect(hint.viewHierarchy, isNotNull); + final bytes = await hint.viewHierarchy!.bytes; + final jsonString = utf8.decode(bytes); + expect(jsonString, isNot(contains('identifier'))); + }); + }); }); } @@ -99,9 +123,11 @@ class TestBindingWrapper implements BindingWrapper { } class Fixture { - SentryViewHierarchyEventProcessor getSut(WidgetsBinding instance) { - final options = SentryFlutterOptions() - ..bindingUtils = TestBindingWrapper(instance); + SentryViewHierarchyEventProcessor getSut(WidgetsBinding instance, + {bool reportViewHierarchyIdentifiers = true}) { + final options = defaultTestOptions() + ..bindingUtils = TestBindingWrapper(instance) + ..reportViewHierarchyIdentifiers = reportViewHierarchyIdentifiers; return SentryViewHierarchyEventProcessor(options); } } diff --git a/flutter/test/view_hierarchy/view_hierarchy_integration_test.dart b/flutter/test/view_hierarchy/view_hierarchy_integration_test.dart index bef4050c6e..c57fff8b5e 100644 --- a/flutter/test/view_hierarchy/view_hierarchy_integration_test.dart +++ b/flutter/test/view_hierarchy/view_hierarchy_integration_test.dart @@ -2,10 +2,10 @@ library flutter_test; import 'package:flutter_test/flutter_test.dart'; -import 'package:sentry_flutter/sentry_flutter.dart'; import 'package:sentry_flutter/src/view_hierarchy/view_hierarchy_event_processor.dart'; import 'package:sentry_flutter/src/view_hierarchy/view_hierarchy_integration.dart'; +import '../mocks.dart'; import '../mocks.mocks.dart'; void main() { @@ -74,7 +74,7 @@ void main() { class Fixture { final hub = MockHub(); - final options = SentryFlutterOptions(); + final options = defaultTestOptions(); SentryViewHierarchyIntegration getSut({bool attachViewHierarchy = true}) { options.attachViewHierarchy = attachViewHierarchy; diff --git a/flutter/test/widgets_binding_observer_test.dart b/flutter/test/widgets_binding_observer_test.dart index 8e3bff9c25..ce44577db9 100644 --- a/flutter/test/widgets_binding_observer_test.dart +++ b/flutter/test/widgets_binding_observer_test.dart @@ -17,11 +17,11 @@ void main() { setUp(() { TestWidgetsFlutterBinding.ensureInitialized(); - flutterTrackingEnabledOptions = SentryFlutterOptions() + flutterTrackingEnabledOptions = defaultTestOptions() ..bindingUtils = TestBindingWrapper(); flutterTrackingEnabledOptions.useFlutterBreadcrumbTracking(); - flutterTrackingDisabledOptions = SentryFlutterOptions() + flutterTrackingDisabledOptions = defaultTestOptions() ..bindingUtils = TestBindingWrapper(); flutterTrackingDisabledOptions.useNativeBreadcrumbTracking(); }); @@ -193,6 +193,9 @@ void main() { // ignore: deprecated_member_use window.physicalSizeTestValue = Size(newWidth, newHeight); + // waiting for debouncing with 100ms added https://github.com/getsentry/sentry-dart/issues/400 + await tester.pump(Duration(milliseconds: 150)); + final breadcrumb = verify(hub.addBreadcrumb(captureAny)).captured.single as Breadcrumb; @@ -230,6 +233,9 @@ void main() { // ignore: deprecated_member_use window.devicePixelRatioTestValue = newPixelRatio; + // waiting for debouncing with 100ms added https://github.com/getsentry/sentry-dart/issues/400 + await tester.pump(Duration(milliseconds: 150)); + final breadcrumb = verify(hub.addBreadcrumb(captureAny)).captured.single as Breadcrumb; @@ -265,6 +271,9 @@ void main() { // ignore: deprecated_member_use window.viewInsetsTestValue = WindowPadding.zero; + // waiting for debouncing with 100ms added https://github.com/getsentry/sentry-dart/issues/400 + await tester.pump(Duration(milliseconds: 150)); + verifyNever(hub.addBreadcrumb(captureAny)); instance.removeObserver(observer); @@ -286,6 +295,9 @@ void main() { window.onMetricsChanged!(); + // waiting for debouncing with 100ms added https://github.com/getsentry/sentry-dart/issues/400 + await tester.pump(Duration(milliseconds: 150)); + verifyNever(hub.addBreadcrumb(captureAny)); instance.removeObserver(observer); @@ -400,5 +412,70 @@ void main() { instance.removeObserver(observer); }); + + testWidgets('debouncing didChangeMetrics with 100ms delay', + (WidgetTester tester) async { + final hub = MockHub(); + + final observer = SentryWidgetsBindingObserver( + hub: hub, + options: flutterTrackingEnabledOptions, + ); + final instance = tester.binding; + instance.addObserver(observer); + + // ignore: deprecated_member_use + final window = instance.window; + + // ignore: deprecated_member_use + window.physicalSizeTestValue = window.physicalSize; + + const newPixelRatio = 1.7; + // ignore: deprecated_member_use + window.devicePixelRatioTestValue = newPixelRatio; + + verifyNever(hub.addBreadcrumb(captureAny)); + + // waiting for debouncing with 100ms added https://github.com/getsentry/sentry-dart/issues/400 + await tester.pump(Duration(milliseconds: 150)); + + verify(hub.addBreadcrumb(captureAny)); + + instance.removeObserver(observer); + }); + + testWidgets('debouncing: didChangeMetrics is called only once in 100ms', + (WidgetTester tester) async { + final hub = MockHub(); + + final observer = SentryWidgetsBindingObserver( + hub: hub, + options: flutterTrackingEnabledOptions, + ); + final instance = tester.binding; + instance.addObserver(observer); + + // ignore: deprecated_member_use + final window = instance.window; + + // ignore: deprecated_member_use + window.physicalSizeTestValue = window.physicalSize; + + // ignore: deprecated_member_use + window.devicePixelRatioTestValue = 2.1; + // ignore: deprecated_member_use + window.devicePixelRatioTestValue = 2.2; + // ignore: deprecated_member_use + window.devicePixelRatioTestValue = 2.3; + + verifyNever(hub.addBreadcrumb(captureAny)); + + // waiting for debouncing with 100ms added https://github.com/getsentry/sentry-dart/issues/400 + await tester.pump(Duration(milliseconds: 150)); + + verify(hub.addBreadcrumb(captureAny)).called(1); + + instance.removeObserver(observer); + }); }); } diff --git a/flutter/windows/.gitignore b/flutter/windows/.gitignore index 2c36fa939d..808064a0fa 100644 --- a/flutter/windows/.gitignore +++ b/flutter/windows/.gitignore @@ -15,6 +15,3 @@ x86/ *.[Cc]ache # but keep track of directories ending in .cache !*.[Cc]ache/ - -flutter/example/windows/flutter/generated_plugins.cmake -flutter/example/windows/flutter/generated_plugin_registrant.* \ No newline at end of file diff --git a/flutter/windows/CMakeLists.txt b/flutter/windows/CMakeLists.txt index 1f0a4aff9d..b7d3459e35 100644 --- a/flutter/windows/CMakeLists.txt +++ b/flutter/windows/CMakeLists.txt @@ -1,23 +1,16 @@ -cmake_minimum_required(VERSION 3.15) +# The Flutter tooling requires that developers have a version of Visual Studio +# installed that includes CMake 3.14 or later. You should not increase this +# version, as doing so will cause the plugin to fail to compile for some +# customers of the plugin. +cmake_minimum_required(VERSION 3.14) + +# Project-level configuration. set(PROJECT_NAME "sentry_flutter") project(${PROJECT_NAME} LANGUAGES CXX) -# This value is used when generating builds using this plugin, so it must -# not be changed -set(PLUGIN_NAME "sentry_flutter_plugin") - -add_library(${PLUGIN_NAME} SHARED - "sentry_flutter_plugin.cpp" -) -apply_standard_settings(${PLUGIN_NAME}) -set_target_properties(${PLUGIN_NAME} PROPERTIES - CXX_VISIBILITY_PRESET hidden) -target_compile_definitions(${PLUGIN_NAME} PRIVATE FLUTTER_PLUGIN_IMPL) -target_include_directories(${PLUGIN_NAME} INTERFACE - "${CMAKE_CURRENT_SOURCE_DIR}/include") -target_link_libraries(${PLUGIN_NAME} PRIVATE flutter flutter_wrapper_plugin) - -# List of absolute paths to libraries that should be bundled with the plugin +# List of absolute paths to libraries that should be bundled with the plugin. +# This list could contain prebuilt libraries, or libraries created by an +# external build triggered from this build file. set(sentry_flutter_bundled_libraries "" PARENT_SCOPE diff --git a/flutter/windows/include/sentry_flutter/sentry_flutter_plugin.h b/flutter/windows/include/sentry_flutter/sentry_flutter_plugin.h deleted file mode 100644 index d8482b94cd..0000000000 --- a/flutter/windows/include/sentry_flutter/sentry_flutter_plugin.h +++ /dev/null @@ -1,23 +0,0 @@ -#ifndef FLUTTER_PLUGIN_SENTRY_FLUTTER_PLUGIN_H_ -#define FLUTTER_PLUGIN_SENTRY_FLUTTER_PLUGIN_H_ - -#include - -#ifdef FLUTTER_PLUGIN_IMPL -#define FLUTTER_PLUGIN_EXPORT __declspec(dllexport) -#else -#define FLUTTER_PLUGIN_EXPORT __declspec(dllimport) -#endif - -#if defined(__cplusplus) -extern "C" { -#endif - -FLUTTER_PLUGIN_EXPORT void SentryFlutterPluginRegisterWithRegistrar( - FlutterDesktopPluginRegistrarRef registrar); - -#if defined(__cplusplus) -} // extern "C" -#endif - -#endif // FLUTTER_PLUGIN_SENTRY_FLUTTER_PLUGIN_H_ diff --git a/flutter/windows/sentry_flutter_plugin.cpp b/flutter/windows/sentry_flutter_plugin.cpp deleted file mode 100644 index 638f4ff16e..0000000000 --- a/flutter/windows/sentry_flutter_plugin.cpp +++ /dev/null @@ -1,70 +0,0 @@ -#include "include/sentry_flutter/sentry_flutter_plugin.h" - -// This must be included before many other Windows headers. -#include - -// For getPlatformVersion; remove unless needed for your plugin implementation. -#include - -#include -#include -#include - -#include -#include -#include - -namespace { - -class SentryFlutterPlugin : public flutter::Plugin { - public: - static void RegisterWithRegistrar(flutter::PluginRegistrarWindows *registrar); - - SentryFlutterPlugin(); - - virtual ~SentryFlutterPlugin(); - - private: - // Called when a method is called on this plugin's channel from Dart. - void HandleMethodCall( - const flutter::MethodCall &method_call, - std::unique_ptr> result); -}; - -// static -void SentryFlutterPlugin::RegisterWithRegistrar( - flutter::PluginRegistrarWindows *registrar) { - auto channel = - std::make_unique>( - registrar->messenger(), "sentry_flutter", - &flutter::StandardMethodCodec::GetInstance()); - - auto plugin = std::make_unique(); - - channel->SetMethodCallHandler( - [plugin_pointer = plugin.get()](const auto &call, auto result) { - plugin_pointer->HandleMethodCall(call, std::move(result)); - }); - - registrar->AddPlugin(std::move(plugin)); -} - -SentryFlutterPlugin::SentryFlutterPlugin() {} - -SentryFlutterPlugin::~SentryFlutterPlugin() {} - -void SentryFlutterPlugin::HandleMethodCall( - const flutter::MethodCall &method_call, - std::unique_ptr> result) { - // Native features will be added in a next release - result->NotImplemented(); -} - -} // namespace - -void SentryFlutterPluginRegisterWithRegistrar( - FlutterDesktopPluginRegistrarRef registrar) { - SentryFlutterPlugin::RegisterWithRegistrar( - flutter::PluginRegistrarManager::GetInstance() - ->GetRegistrar(registrar)); -} diff --git a/hive/lib/src/sentry_box_collection.dart b/hive/lib/src/sentry_box_collection.dart index d4c605efe2..0dee9e9831 100644 --- a/hive/lib/src/sentry_box_collection.dart +++ b/hive/lib/src/sentry_box_collection.dart @@ -10,6 +10,7 @@ import 'package:hive/src/box_collection/box_collection_stub.dart' as stub; // ignore: implementation_imports import 'package:hive/src/box_collection/box_collection_stub.dart' if (dart.library.html) 'package:hive/src/box_collection/box_collection_indexed_db.dart' + if (dart.library.js_interop) 'package:hive/src/box_collection/box_collection_indexed_db.dart' if (dart.library.io) 'package:hive/src/box_collection/box_collection.dart' as impl; diff --git a/hive/lib/src/version.dart b/hive/lib/src/version.dart index 18932a8818..0157ef30a8 100644 --- a/hive/lib/src/version.dart +++ b/hive/lib/src/version.dart @@ -1,5 +1,5 @@ /// The SDK version reported to Sentry.io in the submitted events. -const String sdkVersion = '8.3.0'; +const String sdkVersion = '8.9.0'; /// The package name reported to Sentry.io in the submitted events. const String packageName = 'pub:sentry_hive'; diff --git a/hive/pubspec.yaml b/hive/pubspec.yaml index a065329ce4..8087900cbb 100644 --- a/hive/pubspec.yaml +++ b/hive/pubspec.yaml @@ -1,6 +1,6 @@ name: sentry_hive description: An integration which adds support for performance tracing for the hive package. -version: 8.3.0 +version: 8.9.0 homepage: https://docs.sentry.io/platforms/flutter/ repository: https://github.com/getsentry/sentry-dart issue_tracker: https://github.com/getsentry/sentry-dart/issues @@ -17,7 +17,7 @@ platforms: web: dependencies: - sentry: 8.3.0 + sentry: 8.9.0 hive: ^2.2.3 meta: ^1.3.0 diff --git a/hive/test/mocks/mocks.dart b/hive/test/mocks/mocks.dart index 9b2db0e30d..8b6d7f4db9 100644 --- a/hive/test/mocks/mocks.dart +++ b/hive/test/mocks/mocks.dart @@ -4,6 +4,7 @@ import 'package:sentry/sentry.dart'; import 'package:hive/src/box_collection/box_collection_stub.dart' if (dart.library.html) 'package:hive/src/box_collection/box_collection_indexed_db.dart' + if (dart.library.js_interop) 'package:hive/src/box_collection/box_collection_indexed_db.dart' if (dart.library.io) 'package:hive/src/box_collection/box_collection.dart' as impl; diff --git a/hive/test/mocks/mocks.mocks.dart b/hive/test/mocks/mocks.mocks.dart index 6d1bb907d7..5cd8fc7e30 100644 --- a/hive/test/mocks/mocks.mocks.dart +++ b/hive/test/mocks/mocks.mocks.dart @@ -18,6 +18,7 @@ import 'package:hive/src/box_collection/box_collection_stub.dart' as stub; // ignore: implementation_imports import 'package:hive/src/box_collection/box_collection_stub.dart' if (dart.library.html) 'package:hive/src/box_collection/box_collection_indexed_db.dart' + if (dart.library.js_interop) 'package:hive/src/box_collection/box_collection_indexed_db.dart' if (dart.library.io) 'package:hive/src/box_collection/box_collection.dart' as impl; diff --git a/hive/test/sentry_box_base_test.dart b/hive/test/sentry_box_base_test.dart index 3821b8b341..e46564a3f3 100644 --- a/hive/test/sentry_box_base_test.dart +++ b/hive/test/sentry_box_base_test.dart @@ -12,6 +12,7 @@ import 'package:sentry/src/sentry_tracer.dart'; import 'mocks/mocks.mocks.dart'; import 'person.dart'; +import 'utils.dart'; void main() { void verifySpan(String description, SentrySpan? span) { @@ -525,7 +526,7 @@ void main() { class Fixture { late final Box box; late final mockBox = MockBox(); - final options = SentryOptions(); + final options = defaultTestOptions(); final hub = MockHub(); final exception = Exception('fixture-exception'); diff --git a/hive/test/sentry_box_collection_test.dart b/hive/test/sentry_box_collection_test.dart index f13b95e1c8..3f730be3c1 100644 --- a/hive/test/sentry_box_collection_test.dart +++ b/hive/test/sentry_box_collection_test.dart @@ -15,6 +15,8 @@ import 'person.dart'; import 'package:hive/src/box_collection/box_collection_stub.dart' as stub; +import 'utils.dart'; + void main() { void verifySpan(String description, SentrySpan? span) { expect(span?.context.operation, SentryHiveImpl.dbOp); @@ -355,7 +357,7 @@ void main() { } class Fixture { - final options = SentryOptions(); + final options = defaultTestOptions(); final hub = MockHub(); final exception = Exception('fixture-exception'); diff --git a/hive/test/sentry_hive_impl_test.dart b/hive/test/sentry_hive_impl_test.dart index 521415d1ed..a478c9f24a 100644 --- a/hive/test/sentry_hive_impl_test.dart +++ b/hive/test/sentry_hive_impl_test.dart @@ -14,6 +14,7 @@ import 'package:test/test.dart'; import 'mocks/mocks.mocks.dart'; import 'person.dart'; +import 'utils.dart'; void main() { void verifySpan( @@ -583,7 +584,7 @@ void main() { } class Fixture { - final options = SentryOptions(); + final options = defaultTestOptions(); late final mockHive = MockHiveInterface(); final hub = MockHub(); static final dbName = 'people-hive-impl'; diff --git a/hive/test/sentry_lazy_box_test.dart b/hive/test/sentry_lazy_box_test.dart index 291dc3fc20..72282ada42 100644 --- a/hive/test/sentry_lazy_box_test.dart +++ b/hive/test/sentry_lazy_box_test.dart @@ -12,6 +12,7 @@ import 'package:test/test.dart'; import 'mocks/mocks.mocks.dart'; import 'person.dart'; +import 'utils.dart'; void main() { void verifySpan(String description, SentrySpan? span) { @@ -247,7 +248,7 @@ void main() { class Fixture { late final LazyBox box; late final mockBox = MockLazyBox(); - final options = SentryOptions(); + final options = defaultTestOptions(); final hub = MockHub(); final exception = Exception('fixture-exception'); diff --git a/hive/test/utils.dart b/hive/test/utils.dart new file mode 100644 index 0000000000..7fb87861f0 --- /dev/null +++ b/hive/test/utils.dart @@ -0,0 +1,8 @@ +import 'package:sentry/sentry.dart'; + +final fakeDsn = 'https://abc@def.ingest.sentry.io/1234567'; + +SentryOptions defaultTestOptions() { + // ignore: invalid_use_of_internal_member + return SentryOptions(dsn: fakeDsn)..automatedTestMode = true; +} diff --git a/isar/lib/src/version.dart b/isar/lib/src/version.dart index c215620372..5309339503 100644 --- a/isar/lib/src/version.dart +++ b/isar/lib/src/version.dart @@ -1,5 +1,5 @@ /// The SDK version reported to Sentry.io in the submitted events. -const String sdkVersion = '8.3.0'; +const String sdkVersion = '8.9.0'; /// The package name reported to Sentry.io in the submitted events. const String packageName = 'pub:sentry_isar'; diff --git a/isar/pubspec.yaml b/isar/pubspec.yaml index cbafa2af80..c3f4ad9466 100644 --- a/isar/pubspec.yaml +++ b/isar/pubspec.yaml @@ -1,6 +1,6 @@ name: sentry_isar description: An integration which adds support for performance tracing for the isar package. -version: 8.3.0 +version: 8.9.0 homepage: https://docs.sentry.io/platforms/flutter/ repository: https://github.com/getsentry/sentry-dart issue_tracker: https://github.com/getsentry/sentry-dart/issues @@ -20,7 +20,7 @@ platforms: dependencies: isar: ^3.1.0 isar_flutter_libs: ^3.1.0 # contains Isar Core - sentry: 8.3.0 + sentry: 8.9.0 meta: ^1.3.0 path: ^1.8.3 diff --git a/isar/test/sentry_isar_collection_test.dart b/isar/test/sentry_isar_collection_test.dart index de55ac5989..5cf06586ea 100644 --- a/isar/test/sentry_isar_collection_test.dart +++ b/isar/test/sentry_isar_collection_test.dart @@ -12,6 +12,7 @@ import 'package:sentry/src/sentry_tracer.dart'; import 'mocks/mocks.mocks.dart'; import 'person.dart'; +import 'utils.dart'; void main() { void verifySpan( @@ -876,7 +877,7 @@ void main() { } class Fixture { - final options = SentryOptions(); + final options = defaultTestOptions(); final hub = MockHub(); final isarCollection = MockIsarCollection(); diff --git a/isar/test/sentry_isar_test.dart b/isar/test/sentry_isar_test.dart index 222226fa53..00bafb2164 100644 --- a/isar/test/sentry_isar_test.dart +++ b/isar/test/sentry_isar_test.dart @@ -13,6 +13,7 @@ import 'package:sentry_isar/src/version.dart'; import 'mocks/mocks.mocks.dart'; import 'person.dart'; +import 'utils.dart'; void main() { void verifySpan(String description, SentrySpan? span) { @@ -416,7 +417,7 @@ void main() { } class Fixture { - final options = SentryOptions(); + final options = defaultTestOptions(); final hub = MockHub(); final isar = MockIsar(); diff --git a/isar/test/utils.dart b/isar/test/utils.dart new file mode 100644 index 0000000000..7fb87861f0 --- /dev/null +++ b/isar/test/utils.dart @@ -0,0 +1,8 @@ +import 'package:sentry/sentry.dart'; + +final fakeDsn = 'https://abc@def.ingest.sentry.io/1234567'; + +SentryOptions defaultTestOptions() { + // ignore: invalid_use_of_internal_member + return SentryOptions(dsn: fakeDsn)..automatedTestMode = true; +} diff --git a/logging/lib/src/version.dart b/logging/lib/src/version.dart index 58dbea03c7..9245308f97 100644 --- a/logging/lib/src/version.dart +++ b/logging/lib/src/version.dart @@ -1,5 +1,5 @@ /// The SDK version reported to Sentry.io in the submitted events. -const String sdkVersion = '8.3.0'; +const String sdkVersion = '8.9.0'; /// The package name reported to Sentry.io in the submitted events. const String packageName = 'pub:sentry_logging'; diff --git a/logging/pubspec.yaml b/logging/pubspec.yaml index 80b484da48..a62fa30113 100644 --- a/logging/pubspec.yaml +++ b/logging/pubspec.yaml @@ -1,6 +1,6 @@ name: sentry_logging description: An integration which adds support for recording log from the logging package. -version: 8.3.0 +version: 8.9.0 homepage: https://docs.sentry.io/platforms/dart/ repository: https://github.com/getsentry/sentry-dart issue_tracker: https://github.com/getsentry/sentry-dart/issues @@ -19,7 +19,7 @@ platforms: dependencies: logging: ^1.0.0 - sentry: 8.3.0 + sentry: 8.9.0 dev_dependencies: lints: ^4.0.0 diff --git a/logging/test/logging_integration_test.dart b/logging/test/logging_integration_test.dart index 5ddb50cf3e..c5e78657a5 100644 --- a/logging/test/logging_integration_test.dart +++ b/logging/test/logging_integration_test.dart @@ -203,7 +203,7 @@ void main() { } class Fixture { - SentryOptions options = SentryOptions(dsn: fakeDsn); + SentryOptions options = defaultTestOptions(); MockHub hub = MockHub(); LoggingIntegration createSut({ diff --git a/logging/test/mock_hub.dart b/logging/test/mock_hub.dart index 3dcb1d4fc8..ec3d8b2af9 100644 --- a/logging/test/mock_hub.dart +++ b/logging/test/mock_hub.dart @@ -6,10 +6,15 @@ import 'no_such_method_provider.dart'; final fakeDsn = 'https://abc@def.ingest.sentry.io/1234567'; +SentryOptions defaultTestOptions() { + // ignore: invalid_use_of_internal_member + return SentryOptions(dsn: fakeDsn)..automatedTestMode = true; +} + class MockHub with NoSuchMethodProvider implements Hub { final List breadcrumbs = []; final List events = []; - final _options = SentryOptions(dsn: 'fixture-dsn'); + final _options = defaultTestOptions(); @override @internal diff --git a/metrics/flutter.properties b/metrics/flutter.properties index a218c6f206..9a86225f5e 100644 --- a/metrics/flutter.properties +++ b/metrics/flutter.properties @@ -1,2 +1,2 @@ -version = 3.22.2 +version = 3.24.3 repo = https://github.com/flutter/flutter diff --git a/metrics/metrics-ios.yml b/metrics/metrics-ios.yml index 57177c0899..6c7e175b04 100644 --- a/metrics/metrics-ios.yml +++ b/metrics/metrics-ios.yml @@ -10,5 +10,5 @@ startupTimeTest: diffMax: 150 binarySizeTest: - diffMin: 900 KiB - diffMax: 1300 KiB + diffMin: 1200 KiB + diffMax: 1500 KiB diff --git a/min_version_test/android/build.gradle b/min_version_test/android/build.gradle index 70ef661422..e269d91443 100644 --- a/min_version_test/android/build.gradle +++ b/min_version_test/android/build.gradle @@ -1,12 +1,12 @@ buildscript { - ext.kotlin_version = '1.6.21' + ext.kotlin_version = '1.8.0' repositories { google() mavenCentral() } dependencies { - classpath 'com.android.tools.build:gradle:7.2.2' + classpath 'com.android.tools.build:gradle:7.4.2' classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" } } diff --git a/min_version_test/lib/main.dart b/min_version_test/lib/main.dart index 72a8aea404..504c706a43 100644 --- a/min_version_test/lib/main.dart +++ b/min_version_test/lib/main.dart @@ -1,8 +1,9 @@ import 'package:flutter/material.dart'; -import 'package:min_version_test/transaction/transaction_locator.dart' - if (dart.library.html) 'package:min_version_test/transaction/file_transaction.dart' - if (dart.library.io) 'package:min_version_test/transaction/web_transaction.dart'; +import 'package:min_version_test/transaction/transaction_stub.dart' + if (dart.library.html) 'package:min_version_test/transaction/web_transaction.dart' + if (dart.library.js_interop) 'package:min_version_test/transaction/web_transaction.dart' + if (dart.library.io) 'package:min_version_test/transaction/file_transaction.dart'; import 'package:sentry_flutter/sentry_flutter.dart'; import 'package:sentry_logging/sentry_logging.dart'; diff --git a/min_version_test/lib/transaction/file_transaction.dart b/min_version_test/lib/transaction/file_transaction.dart index 6f85ddc337..5afa62f6ce 100644 --- a/min_version_test/lib/transaction/file_transaction.dart +++ b/min_version_test/lib/transaction/file_transaction.dart @@ -1,12 +1,12 @@ -import 'package:min_version_test/transaction/transaction.dart'; import 'dart:io'; import 'package:logging/logging.dart'; import 'package:dio/dio.dart'; - import 'package:sentry_flutter/sentry_flutter.dart'; import 'package:sentry_dio/sentry_dio.dart'; +import 'transaction.dart'; + class FileTransaction implements Transaction { @override Future start() async { diff --git a/min_version_test/lib/transaction/transaction_locator.dart b/min_version_test/lib/transaction/transaction_stub.dart similarity index 60% rename from min_version_test/lib/transaction/transaction_locator.dart rename to min_version_test/lib/transaction/transaction_stub.dart index 7e3a3f75d5..1869f2ee17 100644 --- a/min_version_test/lib/transaction/transaction_locator.dart +++ b/min_version_test/lib/transaction/transaction_stub.dart @@ -1,4 +1,4 @@ -import 'package:min_version_test/transaction/transaction.dart'; +import 'transaction.dart'; Transaction getTransaction() => throw UnsupportedError('Cannot create sample transaction.'); diff --git a/min_version_test/lib/transaction/web_transaction.dart b/min_version_test/lib/transaction/web_transaction.dart index 99ce8f3508..03c8cdd368 100644 --- a/min_version_test/lib/transaction/web_transaction.dart +++ b/min_version_test/lib/transaction/web_transaction.dart @@ -1,11 +1,10 @@ -import 'package:min_version_test/transaction/transaction.dart'; - import 'package:logging/logging.dart'; import 'package:dio/dio.dart'; - import 'package:sentry_flutter/sentry_flutter.dart'; import 'package:sentry_dio/sentry_dio.dart'; +import 'transaction.dart'; + class WebTransaction implements Transaction { @override Future start() async { diff --git a/min_version_test/pubspec.yaml b/min_version_test/pubspec.yaml index 8fdfe47329..e8e76dc9df 100644 --- a/min_version_test/pubspec.yaml +++ b/min_version_test/pubspec.yaml @@ -91,4 +91,3 @@ flutter: # # For details regarding fonts from package dependencies, # see https://flutter.dev/custom-fonts/#from-packages - \ No newline at end of file diff --git a/scripts/flutter_symbol_collector/lib/src/symbol_collector_cli.dart b/scripts/flutter_symbol_collector/lib/src/symbol_collector_cli.dart index c67b0208bc..2bb3fcae22 100644 --- a/scripts/flutter_symbol_collector/lib/src/symbol_collector_cli.dart +++ b/scripts/flutter_symbol_collector/lib/src/symbol_collector_cli.dart @@ -19,7 +19,7 @@ class SymbolCollectorCli { // https://github.com/getsentry/symbol-collector/releases @internal - static const version = '1.18.0'; + static const version = '1.21.0'; @internal late final String cli; diff --git a/scripts/publish_validation/README.md b/scripts/publish_validation/README.md new file mode 100644 index 0000000000..e87d94c86d --- /dev/null +++ b/scripts/publish_validation/README.md @@ -0,0 +1,3 @@ +An internal command-line application to validate publish. +We temporarily need to use the `--skip-validation` flag in order to publish with backwards compatible WASM support. +Since we now don't have validations in place, this validation tool will catch unexpected errors that might occur during dry runs. \ No newline at end of file diff --git a/scripts/publish_validation/analysis_options.yaml b/scripts/publish_validation/analysis_options.yaml new file mode 100644 index 0000000000..572dd239d0 --- /dev/null +++ b/scripts/publish_validation/analysis_options.yaml @@ -0,0 +1 @@ +include: package:lints/recommended.yaml diff --git a/scripts/publish_validation/bin/publish_validation.dart b/scripts/publish_validation/bin/publish_validation.dart new file mode 100644 index 0000000000..0585d7dd00 --- /dev/null +++ b/scripts/publish_validation/bin/publish_validation.dart @@ -0,0 +1,60 @@ +import 'dart:io'; + +import 'package:args/args.dart'; + +void main(List arguments) async { + final parser = ArgParser() + ..addOption( + 'executable', + allowed: ['dart', 'flutter'], + defaultsTo: 'dart', + help: 'Specify the executable to use (dart or flutter)', + ); + + ArgResults args; + try { + args = parser.parse(arguments); + } on FormatException catch (e) { + print('Error: ${e.message}'); + print('Usage: dart script.dart [--executable ]'); + exit(1); + } + + final executable = args['executable'] as String; + + final result = await Process.run(executable, ['pub', 'publish', '--dry-run']); + final publishOutput = result.stderr as String; + + if (publishOutput.contains('Found no `pubspec.yaml` file')) { + print(publishOutput); + exit(1); + } + + const expectedErrors = [ + 'lib/src/integrations/connectivity/web_connectivity_provider.dart: This package does not have web in the `dependencies` section of `pubspec.yaml`', + 'lib/src/event_processor/enricher/web_enricher_event_processor.dart: This package does not have web in the `dependencies` section of `pubspec.yaml`', + 'lib/src/origin_web.dart: This package does not have web in the `dependencies` section of `pubspec.yaml`', + 'lib/src/platform/_web_platform.dart: This package does not have web in the `dependencies` section of `pubspec.yaml`', + 'lib/src/event_processor/url_filter/web_url_filter_event_processor.dart: This package does not have web in the `dependencies` section of `pubspec.yaml`', + ]; + + // So far the expected errors all start with `* line` + final errorLines = publishOutput + .split('\n') + .where((line) => line.startsWith('* line')) + .toList(); + + final unexpectedErrors = errorLines.where((errorLine) { + return !expectedErrors + .any((expectedError) => errorLine.contains(expectedError)); + }).toList(); + + if (unexpectedErrors.isEmpty) { + print('Only expected errors found. Validation passed.'); + exit(0); + } else { + print('Unexpected errors found:'); + unexpectedErrors.forEach(print); + exit(1); + } +} diff --git a/scripts/publish_validation/pubspec.yaml b/scripts/publish_validation/pubspec.yaml new file mode 100644 index 0000000000..a8dd96a670 --- /dev/null +++ b/scripts/publish_validation/pubspec.yaml @@ -0,0 +1,13 @@ +name: publish_validation +description: Command-line application for validating publish dry runs. +publish_to: none + +environment: + sdk: ^3.4.3 + +dependencies: + args: ^2.5.0 + +dev_dependencies: + lints: ^3.0.0 + test: ^1.24.0 diff --git a/sqflite/lib/src/version.dart b/sqflite/lib/src/version.dart index a74f25cdbd..84ea80aaf5 100644 --- a/sqflite/lib/src/version.dart +++ b/sqflite/lib/src/version.dart @@ -1,5 +1,5 @@ /// The SDK version reported to Sentry.io in the submitted events. -const String sdkVersion = '8.3.0'; +const String sdkVersion = '8.9.0'; /// The package name reported to Sentry.io in the submitted events. const String packageName = 'pub:sentry_sqflite'; diff --git a/sqflite/pubspec.yaml b/sqflite/pubspec.yaml index b8449f13f7..6ac65b9a1f 100644 --- a/sqflite/pubspec.yaml +++ b/sqflite/pubspec.yaml @@ -1,6 +1,6 @@ name: sentry_sqflite description: An integration which adds support for performance tracing for the sqflite package. -version: 8.3.0 +version: 8.9.0 homepage: https://docs.sentry.io/platforms/flutter/ repository: https://github.com/getsentry/sentry-dart issue_tracker: https://github.com/getsentry/sentry-dart/issues @@ -15,7 +15,7 @@ platforms: macos: dependencies: - sentry: 8.3.0 + sentry: 8.9.0 sqflite: ^2.2.8 sqflite_common: ^2.0.0 meta: ^1.3.0 diff --git a/sqflite/test/sentry_batch_test.dart b/sqflite/test/sentry_batch_test.dart index 4a5062f2a2..50dc469ab3 100644 --- a/sqflite/test/sentry_batch_test.dart +++ b/sqflite/test/sentry_batch_test.dart @@ -668,7 +668,7 @@ SELECT * FROM Product'''; class Fixture { final hub = MockHub(); - final options = SentryOptions(dsn: fakeDsn); + final options = defaultTestOptions(); final _context = SentryTransactionContext('name', 'operation'); late final tracer = SentryTracer(_context, hub); final batch = MockBatch(); diff --git a/sqflite/test/sentry_database_test.dart b/sqflite/test/sentry_database_test.dart index 93b258b20f..b97f97784e 100644 --- a/sqflite/test/sentry_database_test.dart +++ b/sqflite/test/sentry_database_test.dart @@ -1234,7 +1234,7 @@ void main() { class Fixture { final hub = MockHub(); - final options = SentryOptions(dsn: fakeDsn); + final options = defaultTestOptions(); final _context = SentryTransactionContext('name', 'operation'); late final tracer = SentryTracer(_context, hub); final database = MockDatabase(); diff --git a/sqflite/test/sentry_sqflite_database_factory_dart_test.dart b/sqflite/test/sentry_sqflite_database_factory_dart_test.dart index 061660884f..38bc23d404 100644 --- a/sqflite/test/sentry_sqflite_database_factory_dart_test.dart +++ b/sqflite/test/sentry_sqflite_database_factory_dart_test.dart @@ -95,7 +95,7 @@ void main() { class Fixture { final hub = MockHub(); - final options = SentryOptions(dsn: fakeDsn)..tracesSampleRate = 1.0; + final options = defaultTestOptions()..tracesSampleRate = 1.0; final _context = SentryTransactionContext('name', 'operation'); late final tracer = SentryTracer(_context, hub); late final scope = Scope(options); diff --git a/sqflite/test/sentry_sqflite_test.dart b/sqflite/test/sentry_sqflite_test.dart index 41aa3277b9..f54cfda980 100644 --- a/sqflite/test/sentry_sqflite_test.dart +++ b/sqflite/test/sentry_sqflite_test.dart @@ -121,7 +121,7 @@ void main() { class Fixture { final hub = MockHub(); - final options = SentryOptions(dsn: fakeDsn)..tracesSampleRate = 1.0; + final options = defaultTestOptions()..tracesSampleRate = 1.0; final _context = SentryTransactionContext('name', 'operation'); late final tracer = SentryTracer(_context, hub); late final scope = Scope(options); diff --git a/sqflite/test/utils.dart b/sqflite/test/utils.dart index 5dbeee585a..7fb87861f0 100644 --- a/sqflite/test/utils.dart +++ b/sqflite/test/utils.dart @@ -1 +1,8 @@ +import 'package:sentry/sentry.dart'; + final fakeDsn = 'https://abc@def.ingest.sentry.io/1234567'; + +SentryOptions defaultTestOptions() { + // ignore: invalid_use_of_internal_member + return SentryOptions(dsn: fakeDsn)..automatedTestMode = true; +}