From 3fbaf80f8e0969dbe9eb9d6a3a07fff1cf3a6165 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 2 Jul 2024 08:00:45 +0000 Subject: [PATCH 01/92] Bump reactivecircus/android-emulator-runner from 2.30.1 to 2.31.0 (#3530) Bumps [reactivecircus/android-emulator-runner](https://github.com/reactivecircus/android-emulator-runner) from 2.30.1 to 2.31.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/6b0df4b0efb23bb0ec63d881db79aefbc976e4b2...77986be26589807b8ebab3fde7bbf5c60dabec32) --- 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> --- .github/workflows/agp-matrix.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/agp-matrix.yml b/.github/workflows/agp-matrix.yml index a9c4293292..80c85e71b8 100644 --- a/.github/workflows/agp-matrix.yml +++ b/.github/workflows/agp-matrix.yml @@ -59,7 +59,7 @@ jobs: # We tried to use the cache action to cache gradle stuff, but it made tests slower and timeout - name: Run instrumentation tests - uses: reactivecircus/android-emulator-runner@6b0df4b0efb23bb0ec63d881db79aefbc976e4b2 # pin@v2 + uses: reactivecircus/android-emulator-runner@77986be26589807b8ebab3fde7bbf5c60dabec32 # pin@v2 with: api-level: 30 force-avd-creation: false From 25f1ca4e1636a801c17c1662f0145f888550bce8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 2 Jul 2024 09:15:41 +0000 Subject: [PATCH 02/92] Bump codecov/codecov-action from 4.3.1 to 4.5.0 (#3533) Bumps [codecov/codecov-action](https://github.com/codecov/codecov-action) from 4.3.1 to 4.5.0. - [Release notes](https://github.com/codecov/codecov-action/releases) - [Changelog](https://github.com/codecov/codecov-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/codecov/codecov-action/compare/5ecb98a3c6b747ed38dc09f787459979aebb39be...e28ff129e5465c2c0dcc6f003fc735cb6ae0c673) --- updated-dependencies: - dependency-name: codecov/codecov-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> Co-authored-by: Stefano --- .github/workflows/build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 969ad6135e..8816d5fde6 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -35,7 +35,7 @@ jobs: run: make preMerge - name: Upload coverage to Codecov - uses: codecov/codecov-action@5ecb98a3c6b747ed38dc09f787459979aebb39be # pin@v4 + uses: codecov/codecov-action@e28ff129e5465c2c0dcc6f003fc735cb6ae0c673 # pin@v4 with: name: sentry-java fail_ci_if_error: false From e34c467f7cb9b1709c7394de525c041c83416cdf Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Mon, 15 Jul 2024 19:11:45 +0200 Subject: [PATCH 03/92] [SR] Session Replay (#3339) * Add new sentry-android-replay module * Add screenshot recorder * Add sentry replay envelope and event * Add TODOs and license headers * Api dump * Formatting * Lint * Format code * More comments * Disable detekt plugin for now * WIP * Add replay envelopes * Remove jsonValue * Remove * Fix json * Finalize replay envelopes * Introduce MapObjectReader * Add missing test * Add test for MapObjectReader * Add MapObjectWriter change * Add finals * Fix test * Fix test * Address review * Add finals and annotations * Specify SHA for license headers * Address review from Dhiogo * Address review from Markus * Remove public captureReplay method * Fix test * api dump * api dump * Address review from Markus * Api dump * Add replay integration * Uncomment redacting * Update proguard rules * Add missing rule for AndroidTest * Add ReplayCache tests * Add tests * Add SessionReplayOptions * Call listeners when installing RootViewsSpy * Call listeners when installing RootViewsSpy * SessionReplayOptions -> SentryReplayOptions * Fix test * Add AndroidManifest options for replays * Add buffer mode and link replays with events/transactions * Pass hint to captureReplay * Better error handling * recycler lastScreenshot before re-assigning * Expose ReplayCache as public api * Fix redacting out of sync * _experimental -> experimental * Merge conflicts * Fix tests * Add more tests * Improve ReplayCache logic * frameUsec -> frameDurationUsec * bottom/right -> height/width * add todos * duration -> durationMs * replaId non-nullable * More conflicts * More conflicts * Fix tests * Address PR review * Add kdoc * Add kdoc * Fix tests * Add comment for experimental options * Do not run recorder if full session was not sampled * Add more tests * Add session deadline of 1h * Clean up older replays when starting a new one * Remove unnecessary extension fun * Safe executors * Fix crashing MediaCodec and use density to determine recording resolution * Add redact options and align naming * Fix tests * Fix tests * WIP * Try-catch release of encoder * Support orientation change for session mode * WIP * Spotless * TODO * Update sentry/src/main/java/io/sentry/SentryReplayOptions.java Co-authored-by: Markus Hintersteiner * More gates * Revert addAll * Fix conflicts * fix test * release: 7.8.0-alpha.0 * Introduce CaptureStrategy for buffer and session modes * Formatting * WIP * Expose public API for flutter * Spotless * Spotless * Remove breadcrumb import * Send temporary breadcrumbs and add test * Formatting * Sort rrweb events * Formatting * Expose replayCacheDir * Capture network requests * Change op name to resource.http * feat(replay): Add `sendReplay` method for Hybrid SDKs * fix apiDump * Address PR review * Capture motion events as incremental rrweb events * Spotless * Revert * Changelog * release: 7.9.0-alpha.1 * Fix test * WIP * Adhere to rrweb move event expectations * formatting * Align breadcrumbs with frontend and iOS * Add tests and fix deserialization * Rotate buffered motion events in buffer mode * Add Nullables * Address PR feedback * Formatting * Rotate current events until segment end exclusively * Allow rrweb breadcrumb customization from hybrid SDKs * Fix proguard rules * WIP * Add tests * Detect obscured views * revert some thigns * Remove commented code * Suppress lint * Support multi-touch gestures * Address PR feedback * Changelog * release: 7.11.0-alpha.2 * Make multi-touch work * Fix tests * WIP * Capture screen names as urls for replay * Fix * Ignore warning * Address PR feedback * Tests * Add quality settings * Fix redacting out of sync * Remove time measuring * Mark isEnableScreenTracking as experimental * Format code * Address PR feedback * Clean up * Spotless * Format code * Changelog * release: 7.12.0-alpha.3 * [SR] Add `redactClasses` option (#3546) Co-authored-by: Roman Zavarnitsyn * misc(changelog): Prepare for next alpha * fix(changelog): Bump alpha version number * release: 7.12.0-alpha.4 * Redaction fixes for RN * Add stopgap for offline session recording * Recycle unused bitmap * Add tests for sentry * Add ReplayIntegrationTest * Replay SmokeTest * Fix test * Fix test * Fix events linking with buffered replays * Changelog --------- Co-authored-by: Sentry Github Bot Co-authored-by: Markus Hintersteiner Co-authored-by: getsentry-bot Co-authored-by: getsentry-bot Co-authored-by: Krystof Woldrich Co-authored-by: Krystof Woldrich <31292499+krystofwoldrich@users.noreply.github.com> --- CHANGELOG.md | 29 + build.gradle.kts | 5 +- buildSrc/src/main/java/Config.kt | 2 + gradle.properties | 2 +- .../api/sentry-android-core.api | 2 + sentry-android-core/build.gradle.kts | 2 + sentry-android-core/proguard-rules.pro | 6 + .../core/ActivityLifecycleIntegration.java | 2 +- .../core/AndroidOptionsInitializer.java | 13 +- .../core/DefaultAndroidEventProcessor.java | 14 + .../sentry/android/core/DeviceInfoUtil.java | 11 +- .../sentry/android/core/LifecycleWatcher.java | 63 +- .../android/core/ManifestMetadataReader.java | 43 + .../io/sentry/android/core/SentryAndroid.java | 41 +- .../SystemEventsBreadcrumbsIntegration.java | 73 +- .../core/AndroidOptionsInitializerTest.kt | 34 +- .../android/core/AndroidProfilerTest.kt | 1 + .../core/AndroidTransactionProfilerTest.kt | 1 + .../android/core/LifecycleWatcherTest.kt | 67 +- .../core/ManifestMetadataReaderTest.kt | 69 ++ .../sentry/android/core/SentryAndroidTest.kt | 25 +- .../android/core/SentryInitProviderTest.kt | 1 + .../core/SessionTrackingIntegrationTest.kt | 9 + .../SystemEventsBreadcrumbsIntegrationTest.kt | 60 ++ .../SentryFragmentLifecycleCallbacks.kt | 3 + .../sentry-uitest-android/proguard-rules.pro | 2 +- .../navigation/SentryNavigationListener.kt | 45 +- .../SentryNavigationListenerTest.kt | 22 +- sentry-android-replay/.gitignore | 1 + .../api/sentry-android-replay.api | 195 ++++ sentry-android-replay/build.gradle.kts | 84 ++ sentry-android-replay/proguard-rules.pro | 3 + .../DefaultReplayBreadcrumbConverter.kt | 157 +++ .../java/io/sentry/android/replay/Recorder.kt | 18 + .../io/sentry/android/replay/ReplayCache.kt | 252 +++++ .../android/replay/ReplayIntegration.kt | 260 +++++ .../android/replay/ScreenshotRecorder.kt | 361 +++++++ .../sentry/android/replay/WindowRecorder.kt | 167 ++++ .../java/io/sentry/android/replay/Windows.kt | 226 +++++ .../replay/capture/BaseCaptureStrategy.kt | 419 ++++++++ .../replay/capture/BufferCaptureStrategy.kt | 230 +++++ .../android/replay/capture/CaptureStrategy.kt | 39 + .../replay/capture/SessionCaptureStrategy.kt | 152 +++ .../sentry/android/replay/util/Executors.kt | 67 ++ .../replay/util/FixedWindowCallback.java | 254 +++++ .../android/replay/util/MainLooperHandler.kt | 12 + .../io/sentry/android/replay/util/Sampling.kt | 10 + .../io/sentry/android/replay/util/Views.kt | 86 ++ .../android/replay/video/SimpleFrameMuxer.kt | 47 + .../replay/video/SimpleMp4FrameMuxer.kt | 83 ++ .../replay/video/SimpleVideoEncoder.kt | 245 +++++ .../replay/viewhierarchy/ViewHierarchyNode.kt | 296 ++++++ sentry-android-replay/src/main/res/public.xml | 4 + .../DefaultReplayBreadcrumbConverterTest.kt | 288 ++++++ .../sentry/android/replay/ReplayCacheTest.kt | 267 +++++ .../android/replay/ReplayIntegrationTest.kt | 381 ++++++++ .../ReplayIntegrationWithRecorderTest.kt | 231 +++++ .../sentry/android/replay/ReplaySmokeTest.kt | 286 ++++++ .../src/test/resources/Tongariro.jpg | Bin 0 -> 239154 bytes sentry-android/build.gradle.kts | 1 + .../io/sentry/okhttp/SentryOkHttpEvent.kt | 5 + .../sentry/okhttp/SentryOkHttpInterceptor.kt | 14 +- .../src/main/AndroidManifest.xml | 3 + sentry/api/sentry.api | 909 +++++++++++++++--- sentry/build.gradle.kts | 1 + sentry/src/main/java/io/sentry/Baggage.java | 27 +- .../src/main/java/io/sentry/Breadcrumb.java | 7 +- sentry/src/main/java/io/sentry/CheckIn.java | 2 +- .../src/main/java/io/sentry/DataCategory.java | 1 + .../main/java/io/sentry/EventProcessor.java | 12 + .../java/io/sentry/ExperimentalOptions.java | 22 + sentry/src/main/java/io/sentry/Hint.java | 11 +- sentry/src/main/java/io/sentry/Hub.java | 21 + .../src/main/java/io/sentry/HubAdapter.java | 6 + sentry/src/main/java/io/sentry/IHub.java | 3 + sentry/src/main/java/io/sentry/IScope.java | 18 + .../main/java/io/sentry/ISentryClient.java | 4 + .../main/java/io/sentry/JsonDeserializer.java | 2 +- .../main/java/io/sentry/JsonObjectReader.java | 197 ++-- .../main/java/io/sentry/JsonObjectWriter.java | 11 + .../main/java/io/sentry/JsonSerializer.java | 17 + .../java/io/sentry/MainEventProcessor.java | 14 + .../main/java/io/sentry/MonitorConfig.java | 4 +- .../main/java/io/sentry/MonitorContexts.java | 2 +- .../main/java/io/sentry/MonitorSchedule.java | 2 +- sentry/src/main/java/io/sentry/NoOpHub.java | 5 + .../sentry/NoOpReplayBreadcrumbConverter.java | 21 + .../java/io/sentry/NoOpReplayController.java | 53 + sentry/src/main/java/io/sentry/NoOpScope.java | 9 + .../main/java/io/sentry/NoOpSentryClient.java | 6 + .../src/main/java/io/sentry/ObjectReader.java | 105 ++ .../src/main/java/io/sentry/ObjectWriter.java | 4 + .../java/io/sentry/ProfilingTraceData.java | 2 +- .../io/sentry/ProfilingTransactionData.java | 2 +- .../io/sentry/ReplayBreadcrumbConverter.java | 12 + .../main/java/io/sentry/ReplayController.java | 31 + .../main/java/io/sentry/ReplayRecording.java | 237 +++++ sentry/src/main/java/io/sentry/Scope.java | 17 + .../SentryAppStartProfilingOptions.java | 2 +- .../main/java/io/sentry/SentryBaseEvent.java | 2 +- .../src/main/java/io/sentry/SentryClient.java | 192 +++- .../java/io/sentry/SentryEnvelopeHeader.java | 2 +- .../java/io/sentry/SentryEnvelopeItem.java | 99 +- .../io/sentry/SentryEnvelopeItemHeader.java | 2 +- .../src/main/java/io/sentry/SentryEvent.java | 4 +- .../main/java/io/sentry/SentryItemType.java | 3 +- .../src/main/java/io/sentry/SentryLevel.java | 6 +- .../main/java/io/sentry/SentryLockReason.java | 2 +- .../main/java/io/sentry/SentryOptions.java | 34 + .../java/io/sentry/SentryReplayEvent.java | 319 ++++++ .../java/io/sentry/SentryReplayOptions.java | 196 ++++ .../src/main/java/io/sentry/SentryTracer.java | 8 +- sentry/src/main/java/io/sentry/Session.java | 2 +- .../src/main/java/io/sentry/SpanContext.java | 4 +- .../java/io/sentry/SpanDataConvention.java | 2 + sentry/src/main/java/io/sentry/SpanId.java | 2 +- .../src/main/java/io/sentry/SpanStatus.java | 4 +- .../src/main/java/io/sentry/TraceContext.java | 43 +- .../src/main/java/io/sentry/UserFeedback.java | 4 +- .../io/sentry/clientreport/ClientReport.java | 6 +- .../sentry/clientreport/DiscardedEvent.java | 4 +- .../ProfileMeasurement.java | 4 +- .../ProfileMeasurementValue.java | 4 +- .../src/main/java/io/sentry/protocol/App.java | 4 +- .../main/java/io/sentry/protocol/Browser.java | 4 +- .../java/io/sentry/protocol/Contexts.java | 4 +- .../java/io/sentry/protocol/DebugImage.java | 6 +- .../java/io/sentry/protocol/DebugMeta.java | 4 +- .../main/java/io/sentry/protocol/Device.java | 6 +- .../src/main/java/io/sentry/protocol/Geo.java | 5 +- .../src/main/java/io/sentry/protocol/Gpu.java | 4 +- .../io/sentry/protocol/MeasurementValue.java | 4 +- .../java/io/sentry/protocol/Mechanism.java | 4 +- .../main/java/io/sentry/protocol/Message.java | 4 +- .../io/sentry/protocol/MetricSummary.java | 4 +- .../io/sentry/protocol/OperatingSystem.java | 4 +- .../main/java/io/sentry/protocol/Request.java | 4 +- .../java/io/sentry/protocol/Response.java | 4 +- .../main/java/io/sentry/protocol/SdkInfo.java | 4 +- .../java/io/sentry/protocol/SdkVersion.java | 6 +- .../io/sentry/protocol/SentryException.java | 4 +- .../java/io/sentry/protocol/SentryId.java | 4 +- .../io/sentry/protocol/SentryPackage.java | 6 +- .../io/sentry/protocol/SentryRuntime.java | 6 +- .../java/io/sentry/protocol/SentrySpan.java | 6 +- .../io/sentry/protocol/SentryStackFrame.java | 4 +- .../io/sentry/protocol/SentryStackTrace.java | 4 +- .../java/io/sentry/protocol/SentryThread.java | 6 +- .../io/sentry/protocol/SentryTransaction.java | 4 +- .../io/sentry/protocol/TransactionInfo.java | 4 +- .../main/java/io/sentry/protocol/User.java | 4 +- .../io/sentry/protocol/ViewHierarchy.java | 6 +- .../io/sentry/protocol/ViewHierarchyNode.java | 4 +- .../io/sentry/rrweb/RRWebBreadcrumbEvent.java | 317 ++++++ .../main/java/io/sentry/rrweb/RRWebEvent.java | 94 ++ .../java/io/sentry/rrweb/RRWebEventType.java | 33 + .../rrweb/RRWebIncrementalSnapshotEvent.java | 95 ++ .../sentry/rrweb/RRWebInteractionEvent.java | 268 ++++++ .../rrweb/RRWebInteractionMoveEvent.java | 303 ++++++ .../java/io/sentry/rrweb/RRWebMetaEvent.java | 191 ++++ .../java/io/sentry/rrweb/RRWebSpanEvent.java | 289 ++++++ .../java/io/sentry/rrweb/RRWebVideoEvent.java | 433 +++++++++ .../java/io/sentry/util/MapObjectReader.java | 413 ++++++++ .../java/io/sentry/util/MapObjectWriter.java | 11 + sentry/src/test/java/io/sentry/BaggageTest.kt | 8 +- sentry/src/test/java/io/sentry/HubTest.kt | 21 + .../java/io/sentry/JsonObjectReaderTest.kt | 2 +- .../test/java/io/sentry/JsonSerializerTest.kt | 24 +- .../java/io/sentry/MainEventProcessorTest.kt | 16 + .../test/java/io/sentry/SentryClientTest.kt | 176 +++- .../java/io/sentry/SentryEnvelopeItemTest.kt | 235 ++++- .../java/io/sentry/SentryReplayOptionsTest.kt | 32 + .../test/java/io/sentry/SentryTracerTest.kt | 7 + .../sentry/TraceContextSerializationTest.kt | 4 +- .../ReplayRecordingSerializationTest.kt | 53 + .../SentryBaseEventSerializationTest.kt | 4 +- .../SentryReplayEventSerializationTest.kt | 62 ++ .../RRWebBreadcrumbEventSerializationTest.kt | 45 + .../rrweb/RRWebEventSerializationTest.kt | 78 ++ .../RRWebInteractionEventSerializationTest.kt | 41 + ...ebInteractionMoveEventSerializationTest.kt | 45 + .../rrweb/RRWebMetaEventSerializationTest.kt | 42 + .../rrweb/RRWebSpanEventSerializationTest.kt | 43 + .../rrweb/RRWebVideoEventSerializationTest.kt | 47 + .../io/sentry/util/MapObjectReaderTest.kt | 151 +++ .../test/resources/json/replay_recording.json | 2 + .../json/rrweb_breadcrumb_event.json | 18 + .../src/test/resources/json/rrweb_event.json | 4 + .../json/rrweb_interaction_event.json | 13 + .../json/rrweb_interaction_move_event.json | 16 + .../test/resources/json/rrweb_meta_event.json | 9 + .../test/resources/json/rrweb_span_event.json | 17 + .../resources/json/rrweb_video_event.json | 21 + .../json/sentry_envelope_header.json | 3 +- .../resources/json/sentry_replay_event.json | 240 +++++ .../src/test/resources/json/trace_state.json | 3 +- settings.gradle.kts | 1 + 197 files changed, 12153 insertions(+), 452 deletions(-) create mode 100644 sentry-android-replay/.gitignore create mode 100644 sentry-android-replay/api/sentry-android-replay.api create mode 100644 sentry-android-replay/build.gradle.kts create mode 100644 sentry-android-replay/proguard-rules.pro create mode 100644 sentry-android-replay/src/main/java/io/sentry/android/replay/DefaultReplayBreadcrumbConverter.kt create mode 100644 sentry-android-replay/src/main/java/io/sentry/android/replay/Recorder.kt create mode 100644 sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt create mode 100644 sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt create mode 100644 sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt create mode 100644 sentry-android-replay/src/main/java/io/sentry/android/replay/WindowRecorder.kt create mode 100644 sentry-android-replay/src/main/java/io/sentry/android/replay/Windows.kt create mode 100644 sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt create mode 100644 sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BufferCaptureStrategy.kt create mode 100644 sentry-android-replay/src/main/java/io/sentry/android/replay/capture/CaptureStrategy.kt create mode 100644 sentry-android-replay/src/main/java/io/sentry/android/replay/capture/SessionCaptureStrategy.kt create mode 100644 sentry-android-replay/src/main/java/io/sentry/android/replay/util/Executors.kt create mode 100644 sentry-android-replay/src/main/java/io/sentry/android/replay/util/FixedWindowCallback.java create mode 100644 sentry-android-replay/src/main/java/io/sentry/android/replay/util/MainLooperHandler.kt create mode 100644 sentry-android-replay/src/main/java/io/sentry/android/replay/util/Sampling.kt create mode 100644 sentry-android-replay/src/main/java/io/sentry/android/replay/util/Views.kt create mode 100644 sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleFrameMuxer.kt create mode 100644 sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleMp4FrameMuxer.kt create mode 100644 sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleVideoEncoder.kt create mode 100644 sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ViewHierarchyNode.kt create mode 100644 sentry-android-replay/src/main/res/public.xml create mode 100644 sentry-android-replay/src/test/java/io/sentry/android/replay/DefaultReplayBreadcrumbConverterTest.kt create mode 100644 sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayCacheTest.kt create mode 100644 sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationTest.kt create mode 100644 sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationWithRecorderTest.kt create mode 100644 sentry-android-replay/src/test/java/io/sentry/android/replay/ReplaySmokeTest.kt create mode 100644 sentry-android-replay/src/test/resources/Tongariro.jpg create mode 100644 sentry/src/main/java/io/sentry/ExperimentalOptions.java create mode 100644 sentry/src/main/java/io/sentry/NoOpReplayBreadcrumbConverter.java create mode 100644 sentry/src/main/java/io/sentry/NoOpReplayController.java create mode 100644 sentry/src/main/java/io/sentry/ObjectReader.java create mode 100644 sentry/src/main/java/io/sentry/ReplayBreadcrumbConverter.java create mode 100644 sentry/src/main/java/io/sentry/ReplayController.java create mode 100644 sentry/src/main/java/io/sentry/ReplayRecording.java create mode 100644 sentry/src/main/java/io/sentry/SentryReplayEvent.java create mode 100644 sentry/src/main/java/io/sentry/SentryReplayOptions.java create mode 100644 sentry/src/main/java/io/sentry/rrweb/RRWebBreadcrumbEvent.java create mode 100644 sentry/src/main/java/io/sentry/rrweb/RRWebEvent.java create mode 100644 sentry/src/main/java/io/sentry/rrweb/RRWebEventType.java create mode 100644 sentry/src/main/java/io/sentry/rrweb/RRWebIncrementalSnapshotEvent.java create mode 100644 sentry/src/main/java/io/sentry/rrweb/RRWebInteractionEvent.java create mode 100644 sentry/src/main/java/io/sentry/rrweb/RRWebInteractionMoveEvent.java create mode 100644 sentry/src/main/java/io/sentry/rrweb/RRWebMetaEvent.java create mode 100644 sentry/src/main/java/io/sentry/rrweb/RRWebSpanEvent.java create mode 100644 sentry/src/main/java/io/sentry/rrweb/RRWebVideoEvent.java create mode 100644 sentry/src/main/java/io/sentry/util/MapObjectReader.java create mode 100644 sentry/src/test/java/io/sentry/SentryReplayOptionsTest.kt create mode 100644 sentry/src/test/java/io/sentry/protocol/ReplayRecordingSerializationTest.kt create mode 100644 sentry/src/test/java/io/sentry/protocol/SentryReplayEventSerializationTest.kt create mode 100644 sentry/src/test/java/io/sentry/rrweb/RRWebBreadcrumbEventSerializationTest.kt create mode 100644 sentry/src/test/java/io/sentry/rrweb/RRWebEventSerializationTest.kt create mode 100644 sentry/src/test/java/io/sentry/rrweb/RRWebInteractionEventSerializationTest.kt create mode 100644 sentry/src/test/java/io/sentry/rrweb/RRWebInteractionMoveEventSerializationTest.kt create mode 100644 sentry/src/test/java/io/sentry/rrweb/RRWebMetaEventSerializationTest.kt create mode 100644 sentry/src/test/java/io/sentry/rrweb/RRWebSpanEventSerializationTest.kt create mode 100644 sentry/src/test/java/io/sentry/rrweb/RRWebVideoEventSerializationTest.kt create mode 100644 sentry/src/test/java/io/sentry/util/MapObjectReaderTest.kt create mode 100644 sentry/src/test/resources/json/replay_recording.json create mode 100644 sentry/src/test/resources/json/rrweb_breadcrumb_event.json create mode 100644 sentry/src/test/resources/json/rrweb_event.json create mode 100644 sentry/src/test/resources/json/rrweb_interaction_event.json create mode 100644 sentry/src/test/resources/json/rrweb_interaction_move_event.json create mode 100644 sentry/src/test/resources/json/rrweb_meta_event.json create mode 100644 sentry/src/test/resources/json/rrweb_span_event.json create mode 100644 sentry/src/test/resources/json/rrweb_video_event.json create mode 100644 sentry/src/test/resources/json/sentry_replay_event.json diff --git a/CHANGELOG.md b/CHANGELOG.md index f67618933f..45f45011bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,34 @@ # Changelog +## Unreleased + +### Features + +- Session Replay Public Beta ([#3339](https://github.com/getsentry/sentry-java/pull/3339)) + + To enable Replay use the `sessionReplay.sessionSampleRate` or `sessionReplay.errorSampleRate` experimental options. + + ```kotlin + import io.sentry.SentryReplayOptions + import io.sentry.android.core.SentryAndroid + + SentryAndroid.init(context) { options -> + + // Currently under experimental options: + options.experimental.sessionReplay.sessionSampleRate = 1.0 + options.experimental.sessionReplay.errorSampleRate = 1.0 + + // To change default redaction behavior (defaults to true) + options.experimental.sessionReplay.redactAllImages = true + options.experimental.sessionReplay.redactAllText = true + + // To change quality of the recording (defaults to MEDIUM) + options.experimental.sessionReplay.quality = SentryReplayOptions.SentryReplayQuality.MEDIUM // (LOW|MEDIUM|HIGH) + } + ``` + + To learn more visit [Sentry's Mobile Session Replay](https://docs.sentry.io/product/explore/session-replay/mobile/) documentation page. + ## 7.11.0 ### Features diff --git a/build.gradle.kts b/build.gradle.kts index 998c547efb..f44f541015 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -112,6 +112,7 @@ subprojects { "sentry-android-ndk", "sentry-android-okhttp", "sentry-android-sqlite", + "sentry-android-replay", "sentry-android-timber" ) if (jacocoAndroidModules.contains(name)) { @@ -296,7 +297,9 @@ private val androidLibs = setOf( "sentry-android-navigation", "sentry-android-okhttp", "sentry-android-timber", - "sentry-compose-android" + "sentry-compose-android", + "sentry-android-sqlite", + "sentry-android-replay" ) private val androidXLibs = listOf( diff --git a/buildSrc/src/main/java/Config.kt b/buildSrc/src/main/java/Config.kt index 7a0081d5f4..2da41627ab 100644 --- a/buildSrc/src/main/java/Config.kt +++ b/buildSrc/src/main/java/Config.kt @@ -34,6 +34,7 @@ object Config { val minSdkVersion = 19 val minSdkVersionOkHttp = 21 + val minSdkVersionReplay = 19 val minSdkVersionNdk = 19 val minSdkVersionCompose = 21 val targetSdkVersion = sdkVersion @@ -194,6 +195,7 @@ object Config { val jsonUnit = "net.javacrumbs.json-unit:json-unit:2.32.0" val hsqldb = "org.hsqldb:hsqldb:2.6.1" val javaFaker = "com.github.javafaker:javafaker:1.0.2" + val msgpack = "org.msgpack:msgpack-core:0.9.8" } object QualityPlugins { diff --git a/gradle.properties b/gradle.properties index 35ce98ed2d..827ee0034e 100644 --- a/gradle.properties +++ b/gradle.properties @@ -10,7 +10,7 @@ android.useAndroidX=true android.defaults.buildfeatures.buildconfig=true # Release information -versionName=7.11.0 +versionName=7.12.0-alpha.4 # Override the SDK name on native crashes on Android sentryAndroidSdkName=sentry.native.android diff --git a/sentry-android-core/api/sentry-android-core.api b/sentry-android-core/api/sentry-android-core.api index adcc6ea87d..0e493f54a7 100644 --- a/sentry-android-core/api/sentry-android-core.api +++ b/sentry-android-core/api/sentry-android-core.api @@ -184,9 +184,11 @@ public final class io/sentry/android/core/CurrentActivityIntegration : android/a public final class io/sentry/android/core/DeviceInfoUtil { public fun (Landroid/content/Context;Lio/sentry/android/core/SentryAndroidOptions;)V public fun collectDeviceInformation (ZZ)Lio/sentry/protocol/Device; + public static fun getBatteryLevel (Landroid/content/Intent;Lio/sentry/SentryOptions;)Ljava/lang/Float; public static fun getInstance (Landroid/content/Context;Lio/sentry/android/core/SentryAndroidOptions;)Lio/sentry/android/core/DeviceInfoUtil; public fun getOperatingSystem ()Lio/sentry/protocol/OperatingSystem; public fun getSideLoadedInfo ()Lio/sentry/android/core/ContextUtils$SideLoadedInfo; + public static fun isCharging (Landroid/content/Intent;Lio/sentry/SentryOptions;)Ljava/lang/Boolean; public static fun resetInstance ()V } diff --git a/sentry-android-core/build.gradle.kts b/sentry-android-core/build.gradle.kts index 2ec856cf5f..12e6e6ad4f 100644 --- a/sentry-android-core/build.gradle.kts +++ b/sentry-android-core/build.gradle.kts @@ -76,6 +76,7 @@ dependencies { api(projects.sentry) compileOnly(projects.sentryAndroidFragment) compileOnly(projects.sentryAndroidTimber) + compileOnly(projects.sentryAndroidReplay) compileOnly(projects.sentryCompose) compileOnly(projects.sentryComposeHelper) @@ -104,6 +105,7 @@ dependencies { testImplementation(projects.sentryTestSupport) testImplementation(projects.sentryAndroidFragment) testImplementation(projects.sentryAndroidTimber) + testImplementation(projects.sentryAndroidReplay) testImplementation(projects.sentryComposeHelper) testImplementation(projects.sentryAndroidNdk) testRuntimeOnly(Config.Libs.composeUi) diff --git a/sentry-android-core/proguard-rules.pro b/sentry-android-core/proguard-rules.pro index 67d7e7691d..0c6d47e5ec 100644 --- a/sentry-android-core/proguard-rules.pro +++ b/sentry-android-core/proguard-rules.pro @@ -72,3 +72,9 @@ -keepnames class io.sentry.exception.SentryHttpClientException ##---------------End: proguard configuration for sentry-okhttp ---------- + +##---------------Begin: proguard configuration for sentry-android-replay ---------- +-dontwarn io.sentry.android.replay.ReplayIntegration +-dontwarn io.sentry.android.replay.DefaultReplayBreadcrumbConverter +-keepnames class io.sentry.android.replay.ReplayIntegration +##---------------End: proguard configuration for sentry-android-replay ---------- diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ActivityLifecycleIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/ActivityLifecycleIntegration.java index 1121a6bfe7..205360b8f1 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/ActivityLifecycleIntegration.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ActivityLifecycleIntegration.java @@ -371,7 +371,7 @@ private void finishTransaction( public synchronized void onActivityCreated( final @NotNull Activity activity, final @Nullable Bundle savedInstanceState) { setColdStart(savedInstanceState); - if (hub != null) { + if (hub != null && options != null && options.isEnableScreenTracking()) { final @Nullable String activityClassName = ClassUtil.getClassName(activity); hub.configureScope(scope -> scope.setScreen(activityClassName)); } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java index 372448b8e7..2d559fd781 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java @@ -22,6 +22,8 @@ import io.sentry.android.core.internal.util.SentryFrameMetricsCollector; import io.sentry.android.core.performance.AppStartMetrics; import io.sentry.android.fragment.FragmentLifecycleIntegration; +import io.sentry.android.replay.DefaultReplayBreadcrumbConverter; +import io.sentry.android.replay.ReplayIntegration; import io.sentry.android.timber.SentryTimberIntegration; import io.sentry.cache.PersistingOptionsObserver; import io.sentry.cache.PersistingScopeObserver; @@ -29,6 +31,7 @@ import io.sentry.compose.viewhierarchy.ComposeViewHierarchyExporter; import io.sentry.internal.gestures.GestureTargetLocator; import io.sentry.internal.viewhierarchy.ViewHierarchyExporter; +import io.sentry.transport.CurrentDateProvider; import io.sentry.transport.NoOpEnvelopeCache; import io.sentry.util.LazyEvaluator; import io.sentry.util.Objects; @@ -237,7 +240,8 @@ static void installDefaultIntegrations( final @NotNull LoadClass loadClass, final @NotNull ActivityFramesTracker activityFramesTracker, final boolean isFragmentAvailable, - final boolean isTimberAvailable) { + final boolean isTimberAvailable, + final boolean isReplayAvailable) { // Integration MUST NOT cache option values in ctor, as they will be configured later by the // user @@ -302,6 +306,13 @@ static void installDefaultIntegrations( new NetworkBreadcrumbsIntegration(context, buildInfoProvider, options.getLogger())); options.addIntegration(new TempSensorBreadcrumbsIntegration(context)); options.addIntegration(new PhoneStateBreadcrumbsIntegration(context)); + if (isReplayAvailable) { + final ReplayIntegration replay = + new ReplayIntegration(context, CurrentDateProvider.getInstance()); + replay.setBreadcrumbConverter(new DefaultReplayBreadcrumbConverter()); + options.addIntegration(replay); + options.setReplayController(replay); + } } /** diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/DefaultAndroidEventProcessor.java b/sentry-android-core/src/main/java/io/sentry/android/core/DefaultAndroidEventProcessor.java index 45e4b78787..5ef35cbfe1 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/DefaultAndroidEventProcessor.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/DefaultAndroidEventProcessor.java @@ -10,6 +10,7 @@ import io.sentry.SentryBaseEvent; import io.sentry.SentryEvent; import io.sentry.SentryLevel; +import io.sentry.SentryReplayEvent; import io.sentry.android.core.internal.util.AndroidMainThreadChecker; import io.sentry.android.core.performance.AppStartMetrics; import io.sentry.android.core.performance.TimeSpan; @@ -303,4 +304,17 @@ private void setSideLoadedInfo(final @NotNull SentryBaseEvent event) { return transaction; } + + @Override + public @NotNull SentryReplayEvent process( + final @NotNull SentryReplayEvent event, final @NotNull Hint hint) { + final boolean applyScopeData = shouldApplyScopeData(event, hint); + if (applyScopeData) { + processNonCachedEvent(event, hint); + } + + setCommons(event, false, applyScopeData); + + return event; + } } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/DeviceInfoUtil.java b/sentry-android-core/src/main/java/io/sentry/android/core/DeviceInfoUtil.java index 8c5d661524..f1debc5d23 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/DeviceInfoUtil.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/DeviceInfoUtil.java @@ -16,6 +16,7 @@ import android.util.DisplayMetrics; import io.sentry.DateUtils; import io.sentry.SentryLevel; +import io.sentry.SentryOptions; import io.sentry.android.core.internal.util.CpuInfoUtils; import io.sentry.android.core.internal.util.DeviceOrientations; import io.sentry.android.core.internal.util.RootChecker; @@ -184,8 +185,8 @@ public ContextUtils.SideLoadedInfo getSideLoadedInfo() { private void setDeviceIO(final @NotNull Device device, final boolean includeDynamicData) { final Intent batteryIntent = getBatteryIntent(); if (batteryIntent != null) { - device.setBatteryLevel(getBatteryLevel(batteryIntent)); - device.setCharging(isCharging(batteryIntent)); + device.setBatteryLevel(getBatteryLevel(batteryIntent, options)); + device.setCharging(isCharging(batteryIntent, options)); device.setBatteryTemperature(getBatteryTemperature(batteryIntent)); } @@ -270,7 +271,8 @@ private Intent getBatteryIntent() { * @return the device's current battery level (as a percentage of total), or null if unknown */ @Nullable - private Float getBatteryLevel(final @NotNull Intent batteryIntent) { + public static Float getBatteryLevel( + final @NotNull Intent batteryIntent, final @NotNull SentryOptions options) { try { int level = batteryIntent.getIntExtra(BatteryManager.EXTRA_LEVEL, -1); int scale = batteryIntent.getIntExtra(BatteryManager.EXTRA_SCALE, -1); @@ -294,7 +296,8 @@ private Float getBatteryLevel(final @NotNull Intent batteryIntent) { * @return whether or not the device is currently plugged in and charging, or null if unknown */ @Nullable - private Boolean isCharging(final @NotNull Intent batteryIntent) { + public static Boolean isCharging( + final @NotNull Intent batteryIntent, final @NotNull SentryOptions options) { try { int plugged = batteryIntent.getIntExtra(BatteryManager.EXTRA_PLUGGED, -1); return plugged == BatteryManager.BATTERY_PLUGGED_AC diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/LifecycleWatcher.java b/sentry-android-core/src/main/java/io/sentry/android/core/LifecycleWatcher.java index 7b38bcd9c2..81e77a75fb 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/LifecycleWatcher.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/LifecycleWatcher.java @@ -11,6 +11,7 @@ import io.sentry.transport.ICurrentDateProvider; import java.util.Timer; import java.util.TimerTask; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicLong; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -19,11 +20,12 @@ final class LifecycleWatcher implements DefaultLifecycleObserver { private final AtomicLong lastUpdatedSession = new AtomicLong(0L); + private final AtomicBoolean isFreshSession = new AtomicBoolean(false); private final long sessionIntervalMillis; private @Nullable TimerTask timerTask; - private final @Nullable Timer timer; + private final @NotNull Timer timer = new Timer(true); private final @NotNull Object timerLock = new Object(); private final @NotNull IHub hub; private final boolean enableSessionTracking; @@ -55,11 +57,6 @@ final class LifecycleWatcher implements DefaultLifecycleObserver { this.enableAppLifecycleBreadcrumbs = enableAppLifecycleBreadcrumbs; this.hub = hub; this.currentDateProvider = currentDateProvider; - if (enableSessionTracking) { - timer = new Timer(true); - } else { - timer = null; - } } // App goes to foreground @@ -74,41 +71,46 @@ public void onStart(final @NotNull LifecycleOwner owner) { } private void startSession() { - if (enableSessionTracking) { - cancelTask(); + cancelTask(); - final long currentTimeMillis = currentDateProvider.getCurrentTimeMillis(); + final long currentTimeMillis = currentDateProvider.getCurrentTimeMillis(); - hub.configureScope( - scope -> { - if (lastUpdatedSession.get() == 0L) { - final @Nullable Session currentSession = scope.getSession(); - if (currentSession != null && currentSession.getStarted() != null) { - lastUpdatedSession.set(currentSession.getStarted().getTime()); - } + hub.configureScope( + scope -> { + if (lastUpdatedSession.get() == 0L) { + final @Nullable Session currentSession = scope.getSession(); + if (currentSession != null && currentSession.getStarted() != null) { + lastUpdatedSession.set(currentSession.getStarted().getTime()); + isFreshSession.set(true); } - }); + } + }); - final long lastUpdatedSession = this.lastUpdatedSession.get(); - if (lastUpdatedSession == 0L - || (lastUpdatedSession + sessionIntervalMillis) <= currentTimeMillis) { + final long lastUpdatedSession = this.lastUpdatedSession.get(); + if (lastUpdatedSession == 0L + || (lastUpdatedSession + sessionIntervalMillis) <= currentTimeMillis) { + if (enableSessionTracking) { addSessionBreadcrumb("start"); hub.startSession(); } - this.lastUpdatedSession.set(currentTimeMillis); + hub.getOptions().getReplayController().start(); + } else if (!isFreshSession.get()) { + // only resume if it's not a fresh session, which has been started in SentryAndroid.init + hub.getOptions().getReplayController().resume(); } + isFreshSession.set(false); + this.lastUpdatedSession.set(currentTimeMillis); } // App went to background and triggered this callback after 700ms // as no new screen was shown @Override public void onStop(final @NotNull LifecycleOwner owner) { - if (enableSessionTracking) { - final long currentTimeMillis = currentDateProvider.getCurrentTimeMillis(); - this.lastUpdatedSession.set(currentTimeMillis); + final long currentTimeMillis = currentDateProvider.getCurrentTimeMillis(); + this.lastUpdatedSession.set(currentTimeMillis); - scheduleEndSession(); - } + hub.getOptions().getReplayController().pause(); + scheduleEndSession(); AppState.getInstance().setInBackground(true); addAppBreadcrumb("background"); @@ -122,8 +124,11 @@ private void scheduleEndSession() { new TimerTask() { @Override public void run() { - addSessionBreadcrumb("end"); - hub.endSession(); + if (enableSessionTracking) { + addSessionBreadcrumb("end"); + hub.endSession(); + } + hub.getOptions().getReplayController().stop(); } }; @@ -164,7 +169,7 @@ TimerTask getTimerTask() { } @TestOnly - @Nullable + @NotNull Timer getTimer() { return timer; } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java b/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java index 31e026dd00..e1f227e90c 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java @@ -104,6 +104,14 @@ final class ManifestMetadataReader { static final String ENABLE_METRICS = "io.sentry.enable-metrics"; + static final String REPLAYS_SESSION_SAMPLE_RATE = "io.sentry.session-replay.session-sample-rate"; + + static final String REPLAYS_ERROR_SAMPLE_RATE = "io.sentry.session-replay.error-sample-rate"; + + static final String REPLAYS_REDACT_ALL_TEXT = "io.sentry.session-replay.redact-all-text"; + + static final String REPLAYS_REDACT_ALL_IMAGES = "io.sentry.session-replay.redact-all-images"; + /** ManifestMetadataReader ctor */ private ManifestMetadataReader() {} @@ -382,6 +390,41 @@ static void applyMetadata( options.setEnableMetrics( readBool(metadata, logger, ENABLE_METRICS, options.isEnableMetrics())); + + if (options.getExperimental().getSessionReplay().getSessionSampleRate() == null) { + final Double sessionSampleRate = + readDouble(metadata, logger, REPLAYS_SESSION_SAMPLE_RATE); + if (sessionSampleRate != -1) { + options.getExperimental().getSessionReplay().setSessionSampleRate(sessionSampleRate); + } + } + + if (options.getExperimental().getSessionReplay().getErrorSampleRate() == null) { + final Double errorSampleRate = readDouble(metadata, logger, REPLAYS_ERROR_SAMPLE_RATE); + if (errorSampleRate != -1) { + options.getExperimental().getSessionReplay().setErrorSampleRate(errorSampleRate); + } + } + + options + .getExperimental() + .getSessionReplay() + .setRedactAllText( + readBool( + metadata, + logger, + REPLAYS_REDACT_ALL_TEXT, + options.getExperimental().getSessionReplay().getRedactAllText())); + + options + .getExperimental() + .getSessionReplay() + .setRedactAllImages( + readBool( + metadata, + logger, + REPLAYS_REDACT_ALL_IMAGES, + options.getExperimental().getSessionReplay().getRedactAllImages())); } options diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroid.java b/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroid.java index 46590826ef..d444d08cb0 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroid.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroid.java @@ -36,6 +36,9 @@ public final class SentryAndroid { static final String SENTRY_TIMBER_INTEGRATION_CLASS_NAME = "io.sentry.android.timber.SentryTimberIntegration"; + static final String SENTRY_REPLAY_INTEGRATION_CLASS_NAME = + "io.sentry.android.replay.ReplayIntegration"; + private static final String TIMBER_CLASS_NAME = "timber.log.Timber"; private static final String FRAGMENT_CLASS_NAME = "androidx.fragment.app.FragmentManager$FragmentLifecycleCallbacks"; @@ -102,6 +105,8 @@ public static synchronized void init( final boolean isTimberAvailable = (isTimberUpstreamAvailable && classLoader.isClassAvailable(SENTRY_TIMBER_INTEGRATION_CLASS_NAME, options)); + final boolean isReplayAvailable = + classLoader.isClassAvailable(SENTRY_REPLAY_INTEGRATION_CLASS_NAME, options); final BuildInfoProvider buildInfoProvider = new BuildInfoProvider(logger); final LoadClass loadClass = new LoadClass(); @@ -121,7 +126,8 @@ public static synchronized void init( loadClass, activityFramesTracker, isFragmentAvailable, - isTimberAvailable); + isTimberAvailable, + isReplayAvailable); configuration.configure(options); @@ -148,22 +154,25 @@ public static synchronized void init( true); final @NotNull IHub hub = Sentry.getCurrentHub(); - if (hub.getOptions().isEnableAutoSessionTracking() && ContextUtils.isForegroundImportance()) { - // The LifecycleWatcher of AppLifecycleIntegration may already started a session - // so only start a session if it's not already started - // This e.g. happens on React Native, or e.g. on deferred SDK init - final AtomicBoolean sessionStarted = new AtomicBoolean(false); - hub.configureScope( - scope -> { - final @Nullable Session currentSession = scope.getSession(); - if (currentSession != null && currentSession.getStarted() != null) { - sessionStarted.set(true); - } - }); - if (!sessionStarted.get()) { - hub.addBreadcrumb(BreadcrumbFactory.forSession("session.start")); - hub.startSession(); + if (ContextUtils.isForegroundImportance()) { + if (hub.getOptions().isEnableAutoSessionTracking()) { + // The LifecycleWatcher of AppLifecycleIntegration may already started a session + // so only start a session if it's not already started + // This e.g. happens on React Native, or e.g. on deferred SDK init + final AtomicBoolean sessionStarted = new AtomicBoolean(false); + hub.configureScope( + scope -> { + final @Nullable Session currentSession = scope.getSession(); + if (currentSession != null && currentSession.getStarted() != null) { + sessionStarted.set(true); + } + }); + if (!sessionStarted.get()) { + hub.addBreadcrumb(BreadcrumbFactory.forSession("session.start")); + hub.startSession(); + } } + hub.getOptions().getReplayController().start(); } } catch (IllegalAccessException e) { logger.log(SentryLevel.FATAL, "Fatal error during SentryAndroid.init(...)", e); diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/SystemEventsBreadcrumbsIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/SystemEventsBreadcrumbsIntegration.java index 1c22a7dcc8..dcd92e8bf8 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/SystemEventsBreadcrumbsIntegration.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/SystemEventsBreadcrumbsIntegration.java @@ -6,6 +6,7 @@ import static android.appwidget.AppWidgetManager.ACTION_APPWIDGET_UPDATE; import static android.content.Intent.ACTION_AIRPLANE_MODE_CHANGED; import static android.content.Intent.ACTION_APP_ERROR; +import static android.content.Intent.ACTION_BATTERY_CHANGED; import static android.content.Intent.ACTION_BATTERY_LOW; import static android.content.Intent.ACTION_BATTERY_OKAY; import static android.content.Intent.ACTION_BOOT_COMPLETED; @@ -41,10 +42,11 @@ import io.sentry.Breadcrumb; import io.sentry.Hint; import io.sentry.IHub; -import io.sentry.ILogger; import io.sentry.Integration; import io.sentry.SentryLevel; import io.sentry.SentryOptions; +import io.sentry.android.core.internal.util.AndroidCurrentDateProvider; +import io.sentry.android.core.internal.util.Debouncer; import io.sentry.util.Objects; import io.sentry.util.StringUtils; import java.io.Closeable; @@ -120,7 +122,7 @@ public void register(final @NotNull IHub hub, final @NotNull SentryOptions optio private void startSystemEventsReceiver( final @NotNull IHub hub, final @NotNull SentryAndroidOptions options) { - receiver = new SystemEventsBroadcastReceiver(hub, options.getLogger()); + receiver = new SystemEventsBroadcastReceiver(hub, options); final IntentFilter filter = new IntentFilter(); for (String item : actions) { filter.addAction(item); @@ -154,6 +156,7 @@ private void startSystemEventsReceiver( actions.add(ACTION_AIRPLANE_MODE_CHANGED); actions.add(ACTION_BATTERY_LOW); actions.add(ACTION_BATTERY_OKAY); + actions.add(ACTION_BATTERY_CHANGED); actions.add(ACTION_BOOT_COMPLETED); actions.add(ACTION_CAMERA_BUTTON); actions.add(ACTION_CONFIGURATION_CHANGED); @@ -204,45 +207,69 @@ public void close() throws IOException { static final class SystemEventsBroadcastReceiver extends BroadcastReceiver { + private static final long DEBOUNCE_WAIT_TIME_MS = 60 * 1000; private final @NotNull IHub hub; - private final @NotNull ILogger logger; + private final @NotNull SentryAndroidOptions options; + private final @NotNull Debouncer debouncer = + new Debouncer(AndroidCurrentDateProvider.getInstance(), DEBOUNCE_WAIT_TIME_MS, 0); - SystemEventsBroadcastReceiver(final @NotNull IHub hub, final @NotNull ILogger logger) { + SystemEventsBroadcastReceiver( + final @NotNull IHub hub, final @NotNull SentryAndroidOptions options) { this.hub = hub; - this.logger = logger; + this.options = options; } @Override public void onReceive(Context context, Intent intent) { + final boolean shouldDebounce = debouncer.checkForDebounce(); + final String action = intent.getAction(); + final boolean isBatteryChanged = ACTION_BATTERY_CHANGED.equals(action); + if (isBatteryChanged && shouldDebounce) { + // aligning with iOS which only captures battery status changes every minute at maximum + return; + } + final Breadcrumb breadcrumb = new Breadcrumb(); breadcrumb.setType("system"); breadcrumb.setCategory("device.event"); - final String action = intent.getAction(); String shortAction = StringUtils.getStringAfterDot(action); if (shortAction != null) { breadcrumb.setData("action", shortAction); } - final Bundle extras = intent.getExtras(); - final Map newExtras = new HashMap<>(); - if (extras != null && !extras.isEmpty()) { - for (String item : extras.keySet()) { - try { - @SuppressWarnings("deprecation") - Object value = extras.get(item); - if (value != null) { - newExtras.put(item, value.toString()); + if (isBatteryChanged) { + final Float batteryLevel = DeviceInfoUtil.getBatteryLevel(intent, options); + if (batteryLevel != null) { + breadcrumb.setData("level", batteryLevel); + } + final Boolean isCharging = DeviceInfoUtil.isCharging(intent, options); + if (isCharging != null) { + breadcrumb.setData("charging", isCharging); + } + } else { + final Bundle extras = intent.getExtras(); + final Map newExtras = new HashMap<>(); + if (extras != null && !extras.isEmpty()) { + for (String item : extras.keySet()) { + try { + @SuppressWarnings("deprecation") + Object value = extras.get(item); + if (value != null) { + newExtras.put(item, value.toString()); + } + } catch (Throwable exception) { + options + .getLogger() + .log( + SentryLevel.ERROR, + exception, + "%s key of the %s action threw an error.", + item, + action); } - } catch (Throwable exception) { - logger.log( - SentryLevel.ERROR, - exception, - "%s key of the %s action threw an error.", - item, - action); } + breadcrumb.setData("extras", newExtras); } - breadcrumb.setData("extras", newExtras); } breadcrumb.setLevel(SentryLevel.INFO); diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/AndroidOptionsInitializerTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/AndroidOptionsInitializerTest.kt index 7800063b35..ed2fa3338a 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/AndroidOptionsInitializerTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/AndroidOptionsInitializerTest.kt @@ -15,6 +15,7 @@ import io.sentry.android.core.internal.gestures.AndroidViewGestureTargetLocator import io.sentry.android.core.internal.modules.AssetsModulesLoader import io.sentry.android.core.internal.util.AndroidMainThreadChecker import io.sentry.android.fragment.FragmentLifecycleIntegration +import io.sentry.android.replay.ReplayIntegration import io.sentry.android.timber.SentryTimberIntegration import io.sentry.cache.PersistingOptionsObserver import io.sentry.cache.PersistingScopeObserver @@ -83,6 +84,7 @@ class AndroidOptionsInitializerTest { loadClass, activityFramesTracker, false, + false, false ) @@ -99,7 +101,8 @@ class AndroidOptionsInitializerTest { minApi: Int = Build.VERSION_CODES.KITKAT, classesToLoad: List = emptyList(), isFragmentAvailable: Boolean = false, - isTimberAvailable: Boolean = false + isTimberAvailable: Boolean = false, + isReplayAvailable: Boolean = false ) { mockContext = ContextUtilsTestHelper.mockMetaData( mockContext = ContextUtilsTestHelper.createMockContext(hasAppContext = true), @@ -126,7 +129,8 @@ class AndroidOptionsInitializerTest { loadClass, activityFramesTracker, isFragmentAvailable, - isTimberAvailable + isTimberAvailable, + isReplayAvailable ) AndroidOptionsInitializer.initializeIntegrationsAndProcessors( @@ -478,6 +482,31 @@ class AndroidOptionsInitializerTest { assertNull(actual) } + @Test + fun `ReplayIntegration added to the integration list if available on classpath`() { + fixture.initSutWithClassLoader(isReplayAvailable = true) + + val actual = + fixture.sentryOptions.integrations.firstOrNull { it is ReplayIntegration } + assertNotNull(actual) + } + + @Test + fun `ReplayIntegration set as ReplayController if available on classpath`() { + fixture.initSutWithClassLoader(isReplayAvailable = true) + + assertTrue(fixture.sentryOptions.replayController is ReplayIntegration) + } + + @Test + fun `ReplayIntegration won't be enabled, it throws class not found`() { + fixture.initSutWithClassLoader(isReplayAvailable = false) + + val actual = + fixture.sentryOptions.integrations.firstOrNull { it is ReplayIntegration } + assertNull(actual) + } + @Test fun `AndroidEnvelopeCache is set to options`() { fixture.initSut() @@ -634,6 +663,7 @@ class AndroidOptionsInitializerTest { mock(), mock(), false, + false, false ) verify(mockOptions, never()).outboxPath diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/AndroidProfilerTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/AndroidProfilerTest.kt index 8219a273d0..c5bb334bb3 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/AndroidProfilerTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/AndroidProfilerTest.kt @@ -118,6 +118,7 @@ class AndroidProfilerTest { loadClass, activityFramesTracker, false, + false, false ) diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/AndroidTransactionProfilerTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/AndroidTransactionProfilerTest.kt index fd03d34631..02cda7d23b 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/AndroidTransactionProfilerTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/AndroidTransactionProfilerTest.kt @@ -125,6 +125,7 @@ class AndroidTransactionProfilerTest { loadClass, activityFramesTracker, false, + false, false ) diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/LifecycleWatcherTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/LifecycleWatcherTest.kt index be30993142..388bfbe274 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/LifecycleWatcherTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/LifecycleWatcherTest.kt @@ -5,8 +5,10 @@ import io.sentry.Breadcrumb import io.sentry.DateUtils import io.sentry.IHub import io.sentry.IScope +import io.sentry.ReplayController import io.sentry.ScopeCallback import io.sentry.SentryLevel +import io.sentry.SentryOptions import io.sentry.Session import io.sentry.Session.State import io.sentry.transport.ICurrentDateProvider @@ -34,6 +36,8 @@ class LifecycleWatcherTest { val ownerMock = mock() val hub = mock() val dateProvider = mock() + val options = SentryOptions() + val replayController = mock() fun getSUT( sessionIntervalMillis: Long = 0L, @@ -47,6 +51,8 @@ class LifecycleWatcherTest { whenever(hub.configureScope(argumentCaptor.capture())).thenAnswer { argumentCaptor.value.run(scope) } + options.setReplayController(replayController) + whenever(hub.options).thenReturn(options) return LifecycleWatcher( hub, @@ -70,6 +76,7 @@ class LifecycleWatcherTest { val watcher = fixture.getSUT(enableAppLifecycleBreadcrumbs = false) watcher.onStart(fixture.ownerMock) verify(fixture.hub).startSession() + verify(fixture.replayController).start() } @Test @@ -79,6 +86,7 @@ class LifecycleWatcherTest { watcher.onStart(fixture.ownerMock) watcher.onStart(fixture.ownerMock) verify(fixture.hub, times(2)).startSession() + verify(fixture.replayController, times(2)).start() } @Test @@ -88,6 +96,7 @@ class LifecycleWatcherTest { watcher.onStart(fixture.ownerMock) watcher.onStart(fixture.ownerMock) verify(fixture.hub).startSession() + verify(fixture.replayController).start() } @Test @@ -96,6 +105,7 @@ class LifecycleWatcherTest { watcher.onStart(fixture.ownerMock) watcher.onStop(fixture.ownerMock) verify(fixture.hub, timeout(10000)).endSession() + verify(fixture.replayController, timeout(10000)).stop() } @Test @@ -110,6 +120,7 @@ class LifecycleWatcherTest { assertNull(watcher.timerTask) verify(fixture.hub, never()).endSession() + verify(fixture.replayController, never()).stop() } @Test @@ -123,7 +134,6 @@ class LifecycleWatcherTest { fun `When session tracking is disabled, do not end session`() { val watcher = fixture.getSUT(enableAutoSessionTracking = false, enableAppLifecycleBreadcrumbs = false) watcher.onStop(fixture.ownerMock) - assertNull(watcher.timerTask) verify(fixture.hub, never()).endSession() } @@ -167,7 +177,6 @@ class LifecycleWatcherTest { fun `When session tracking is disabled, do not add breadcrumb on stop`() { val watcher = fixture.getSUT(enableAutoSessionTracking = false, enableAppLifecycleBreadcrumbs = false) watcher.onStop(fixture.ownerMock) - assertNull(watcher.timerTask) verify(fixture.hub, never()).addBreadcrumb(any()) } @@ -219,12 +228,6 @@ class LifecycleWatcherTest { assertNotNull(watcher.timer) } - @Test - fun `timer is not created if session tracking is disabled`() { - val watcher = fixture.getSUT(enableAutoSessionTracking = false, enableAppLifecycleBreadcrumbs = false) - assertNull(watcher.timer) - } - @Test fun `if the hub has already a fresh session running, don't start new one`() { val watcher = fixture.getSUT( @@ -249,6 +252,7 @@ class LifecycleWatcherTest { watcher.onStart(fixture.ownerMock) verify(fixture.hub, never()).startSession() + verify(fixture.replayController, never()).start() } @Test @@ -275,6 +279,7 @@ class LifecycleWatcherTest { watcher.onStart(fixture.ownerMock) verify(fixture.hub).startSession() + verify(fixture.replayController).start() } @Test @@ -290,4 +295,50 @@ class LifecycleWatcherTest { watcher.onStop(fixture.ownerMock) assertTrue(AppState.getInstance().isInBackground!!) } + + @Test + fun `if the hub has already a fresh session running, doesn't resume replay`() { + val watcher = fixture.getSUT( + enableAppLifecycleBreadcrumbs = false, + session = Session( + State.Ok, + DateUtils.getCurrentDateTime(), + DateUtils.getCurrentDateTime(), + 0, + "abc", + UUID.fromString("3c1ffc32-f68f-4af2-a1ee-dd72f4d62d17"), + true, + 0, + 10.0, + null, + null, + null, + "release", + null + ) + ) + + watcher.onStart(fixture.ownerMock) + verify(fixture.replayController, never()).resume() + } + + @Test + fun `background-foreground replay`() { + whenever(fixture.dateProvider.currentTimeMillis).thenReturn(1L) + val watcher = fixture.getSUT( + sessionIntervalMillis = 2L, + enableAppLifecycleBreadcrumbs = false + ) + watcher.onStart(fixture.ownerMock) + verify(fixture.replayController).start() + + watcher.onStop(fixture.ownerMock) + verify(fixture.replayController).pause() + + watcher.onStart(fixture.ownerMock) + verify(fixture.replayController).resume() + + watcher.onStop(fixture.ownerMock) + verify(fixture.replayController, timeout(10000)).stop() + } } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt index 4a8e57303e..162b1fde71 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt @@ -1420,4 +1420,73 @@ class ManifestMetadataReaderTest { // Assert assertFalse(fixture.options.isEnableMetrics) } + + @Test + fun `applyMetadata reads replays errorSampleRate from metadata`() { + // Arrange + val expectedSampleRate = 0.99f + + val bundle = bundleOf(ManifestMetadataReader.REPLAYS_ERROR_SAMPLE_RATE to expectedSampleRate) + val context = fixture.getContext(metaData = bundle) + + // Act + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + // Assert + assertEquals(expectedSampleRate.toDouble(), fixture.options.experimental.sessionReplay.errorSampleRate) + } + + @Test + fun `applyMetadata does not override replays errorSampleRate from options`() { + // Arrange + val expectedSampleRate = 0.99f + fixture.options.experimental.sessionReplay.errorSampleRate = expectedSampleRate.toDouble() + val bundle = bundleOf(ManifestMetadataReader.REPLAYS_ERROR_SAMPLE_RATE to 0.1f) + val context = fixture.getContext(metaData = bundle) + + // Act + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + // Assert + assertEquals(expectedSampleRate.toDouble(), fixture.options.experimental.sessionReplay.errorSampleRate) + } + + @Test + fun `applyMetadata without specifying replays errorSampleRate, stays null`() { + // Arrange + val context = fixture.getContext() + + // Act + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + // Assert + assertNull(fixture.options.experimental.sessionReplay.errorSampleRate) + } + + @Test + fun `applyMetadata reads session replay redact flags to options`() { + // Arrange + val bundle = bundleOf(ManifestMetadataReader.REPLAYS_REDACT_ALL_TEXT to false, ManifestMetadataReader.REPLAYS_REDACT_ALL_IMAGES to false) + val context = fixture.getContext(metaData = bundle) + + // Act + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + // Assert + assertFalse(fixture.options.experimental.sessionReplay.redactAllImages) + assertFalse(fixture.options.experimental.sessionReplay.redactAllText) + } + + @Test + fun `applyMetadata reads session replay redact flags to options and keeps default if not found`() { + // Arrange + val context = fixture.getContext() + + // Act + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + // Assert + assertTrue(fixture.options.experimental.sessionReplay.redactAllImages) + assertTrue(fixture.options.experimental.sessionReplay.redactAllText) + } } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidTest.kt index 990c3f4b13..cf00173513 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidTest.kt @@ -28,6 +28,7 @@ import io.sentry.UncaughtExceptionHandlerIntegration import io.sentry.android.core.cache.AndroidEnvelopeCache import io.sentry.android.core.performance.AppStartMetrics import io.sentry.android.fragment.FragmentLifecycleIntegration +import io.sentry.android.replay.ReplayIntegration import io.sentry.android.timber.SentryTimberIntegration import io.sentry.cache.IEnvelopeCache import io.sentry.cache.PersistingOptionsObserver @@ -331,13 +332,27 @@ class SentryAndroidTest { verify(client, times(1)).captureSession(any(), any()) } + @Test + @Config(sdk = [26]) + fun `init starts session replay if app is in foreground`() { + initSentryWithForegroundImportance(true) { _ -> + assertTrue(Sentry.getCurrentHub().options.replayController.isRecording()) + } + } + + @Test + @Config(sdk = [26]) + fun `init does not start session replay if the app is in background`() { + initSentryWithForegroundImportance(false) { _ -> + assertFalse(Sentry.getCurrentHub().options.replayController.isRecording()) + } + } + private fun initSentryWithForegroundImportance( inForeground: Boolean, optionsConfig: (SentryAndroidOptions) -> Unit = {}, callback: (session: Session?) -> Unit ) { - val context = ContextUtilsTestHelper.createMockContext() - Mockito.mockStatic(ContextUtils::class.java).use { mockedContextUtils -> mockedContextUtils.`when` { ContextUtils.isForegroundImportance() } .thenReturn(inForeground) @@ -345,6 +360,7 @@ class SentryAndroidTest { options.release = "prod" options.dsn = "https://key@sentry.io/123" options.isEnableAutoSessionTracking = true + options.experimental.sessionReplay.errorSampleRate = 1.0 optionsConfig(options) } @@ -432,7 +448,7 @@ class SentryAndroidTest { fixture.initSut(context = mock()) { options -> optionsRef = options options.dsn = "https://key@sentry.io/123" - assertEquals(20, options.integrations.size) + assertEquals(21, options.integrations.size) options.integrations.removeAll { it is UncaughtExceptionHandlerIntegration || it is ShutdownHookIntegration || @@ -452,7 +468,8 @@ class SentryAndroidTest { it is NetworkBreadcrumbsIntegration || it is TempSensorBreadcrumbsIntegration || it is PhoneStateBreadcrumbsIntegration || - it is SpotlightIntegration + it is SpotlightIntegration || + it is ReplayIntegration } } assertEquals(0, optionsRef.integrations.size) diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/SentryInitProviderTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/SentryInitProviderTest.kt index a83076efb0..5b546523d0 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/SentryInitProviderTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/SentryInitProviderTest.kt @@ -143,6 +143,7 @@ class SentryInitProviderTest { loadClass, activityFramesTracker, false, + false, false ) diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/SessionTrackingIntegrationTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/SessionTrackingIntegrationTest.kt index 1a441cd832..e6d3dfadd7 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/SessionTrackingIntegrationTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/SessionTrackingIntegrationTest.kt @@ -16,6 +16,7 @@ import io.sentry.Sentry import io.sentry.SentryEnvelope import io.sentry.SentryEvent import io.sentry.SentryOptions +import io.sentry.SentryReplayEvent import io.sentry.Session import io.sentry.TraceContext import io.sentry.UserFeedback @@ -146,6 +147,14 @@ class SessionTrackingIntegrationTest { TODO("Not yet implemented") } + override fun captureReplayEvent( + event: SentryReplayEvent, + scope: IScope?, + hint: Hint? + ): SentryId { + TODO("Not yet implemented") + } + override fun captureUserFeedback(userFeedback: UserFeedback) { TODO("Not yet implemented") } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/SystemEventsBreadcrumbsIntegrationTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/SystemEventsBreadcrumbsIntegrationTest.kt index f8293f9b87..3dfca15fdb 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/SystemEventsBreadcrumbsIntegrationTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/SystemEventsBreadcrumbsIntegrationTest.kt @@ -2,18 +2,22 @@ package io.sentry.android.core import android.content.Context import android.content.Intent +import android.os.BatteryManager +import androidx.test.ext.junit.runners.AndroidJUnit4 import io.sentry.Breadcrumb import io.sentry.IHub import io.sentry.ISentryExecutorService import io.sentry.SentryLevel import io.sentry.test.DeferredExecutorService import io.sentry.test.ImmediateExecutorService +import org.junit.runner.RunWith import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.check import org.mockito.kotlin.mock import org.mockito.kotlin.never import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyNoMoreInteractions import org.mockito.kotlin.whenever import kotlin.test.Test import kotlin.test.assertEquals @@ -21,6 +25,7 @@ import kotlin.test.assertFalse import kotlin.test.assertNotNull import kotlin.test.assertNull +@RunWith(AndroidJUnit4::class) class SystemEventsBreadcrumbsIntegrationTest { private class Fixture { @@ -111,6 +116,61 @@ class SystemEventsBreadcrumbsIntegrationTest { ) } + @Test + fun `handles battery changes`() { + val sut = fixture.getSut() + + sut.register(fixture.hub, fixture.options) + val intent = Intent().apply { + action = Intent.ACTION_BATTERY_CHANGED + putExtra(BatteryManager.EXTRA_LEVEL, 75) + putExtra(BatteryManager.EXTRA_SCALE, 100) + putExtra(BatteryManager.EXTRA_PLUGGED, BatteryManager.BATTERY_PLUGGED_USB) + } + sut.receiver!!.onReceive(fixture.context, intent) + + verify(fixture.hub).addBreadcrumb( + check { + assertEquals("device.event", it.category) + assertEquals("system", it.type) + assertEquals(SentryLevel.INFO, it.level) + assertEquals(it.data["level"], 75f) + assertEquals(it.data["charging"], true) + }, + anyOrNull() + ) + } + + @Test + fun `battery changes are debounced`() { + val sut = fixture.getSut() + + sut.register(fixture.hub, fixture.options) + val intent1 = Intent().apply { + action = Intent.ACTION_BATTERY_CHANGED + putExtra(BatteryManager.EXTRA_LEVEL, 80) + putExtra(BatteryManager.EXTRA_SCALE, 100) + } + val intent2 = Intent().apply { + action = Intent.ACTION_BATTERY_CHANGED + putExtra(BatteryManager.EXTRA_LEVEL, 75) + putExtra(BatteryManager.EXTRA_SCALE, 100) + putExtra(BatteryManager.EXTRA_PLUGGED, BatteryManager.BATTERY_PLUGGED_USB) + } + sut.receiver!!.onReceive(fixture.context, intent1) + sut.receiver!!.onReceive(fixture.context, intent2) + + // should only add the first crumb + verify(fixture.hub).addBreadcrumb( + check { + assertEquals(it.data["level"], 80f) + assertEquals(it.data["charging"], false) + }, + anyOrNull() + ) + verifyNoMoreInteractions(fixture.hub) + } + @Test fun `Do not crash if registerReceiver throws exception`() { val sut = fixture.getSut() diff --git a/sentry-android-fragment/src/main/java/io/sentry/android/fragment/SentryFragmentLifecycleCallbacks.kt b/sentry-android-fragment/src/main/java/io/sentry/android/fragment/SentryFragmentLifecycleCallbacks.kt index 5e03c99e0d..18468b99c1 100644 --- a/sentry-android-fragment/src/main/java/io/sentry/android/fragment/SentryFragmentLifecycleCallbacks.kt +++ b/sentry-android-fragment/src/main/java/io/sentry/android/fragment/SentryFragmentLifecycleCallbacks.kt @@ -81,6 +81,9 @@ class SentryFragmentLifecycleCallbacks( // we only start the tracing for the fragment if the fragment has been added to its activity // and not only to the backstack if (fragment.isAdded) { + if (hub.options.isEnableScreenTracking) { + hub.configureScope { it.screen = getFragmentName(fragment) } + } startTracing(fragment) } } diff --git a/sentry-android-integration-tests/sentry-uitest-android/proguard-rules.pro b/sentry-android-integration-tests/sentry-uitest-android/proguard-rules.pro index 49f7f0749d..02f5e80ba3 100644 --- a/sentry-android-integration-tests/sentry-uitest-android/proguard-rules.pro +++ b/sentry-android-integration-tests/sentry-uitest-android/proguard-rules.pro @@ -39,4 +39,4 @@ -dontwarn org.opentest4j.AssertionFailedError -dontwarn org.mockito.internal.** -dontwarn org.jetbrains.annotations.** - +-dontwarn io.sentry.android.replay.ReplayIntegration diff --git a/sentry-android-navigation/src/main/java/io/sentry/android/navigation/SentryNavigationListener.kt b/sentry-android-navigation/src/main/java/io/sentry/android/navigation/SentryNavigationListener.kt index 8fdf8b0df8..dac8e54e80 100644 --- a/sentry-android-navigation/src/main/java/io/sentry/android/navigation/SentryNavigationListener.kt +++ b/sentry-android-navigation/src/main/java/io/sentry/android/navigation/SentryNavigationListener.kt @@ -1,5 +1,6 @@ package io.sentry.android.navigation +import android.content.Context import android.content.res.Resources.NotFoundException import android.os.Bundle import androidx.navigation.NavController @@ -59,9 +60,15 @@ class SentryNavigationListener @JvmOverloads constructor( arguments: Bundle? ) { val toArguments = arguments.refined() - addBreadcrumb(destination, toArguments) - startTracing(controller, destination, toArguments) + + val routeName = destination.extractName(controller.context) + if (routeName != null) { + if (hub.options.isEnableScreenTracking) { + hub.configureScope { it.screen = routeName } + } + startTracing(routeName, destination, toArguments) + } previousDestinationRef = WeakReference(destination) previousArgs = arguments } @@ -95,7 +102,7 @@ class SentryNavigationListener @JvmOverloads constructor( } private fun startTracing( - controller: NavController, + routeName: String, destination: NavDestination, arguments: Map ) { @@ -118,20 +125,6 @@ class SentryNavigationListener @JvmOverloads constructor( return } - @Suppress("SwallowedException") // we swallow it on purpose - var name = destination.route ?: try { - controller.context.resources.getResourceEntryName(destination.id) - } catch (e: NotFoundException) { - hub.options.logger.log( - DEBUG, - "Destination id cannot be retrieved from Resources, no transaction captured." - ) - return - } - - // we add '/' to the name to match dart and web pattern - name = "/" + name.substringBefore('/') // strip out arguments from the tx name - val transactionOptions = TransactionOptions().also { it.isWaitForChildren = true it.idleTimeout = hub.options.idleTimeout @@ -140,7 +133,7 @@ class SentryNavigationListener @JvmOverloads constructor( } val transaction = hub.startTransaction( - TransactionContext(name, TransactionNameSource.ROUTE, NAVIGATION_OP), + TransactionContext(routeName, TransactionNameSource.ROUTE, NAVIGATION_OP), transactionOptions ) @@ -184,6 +177,22 @@ class SentryNavigationListener @JvmOverloads constructor( }.associateWith { args[it] } } ?: emptyMap() + @Suppress("SwallowedException") // we swallow it on purpose + private fun NavDestination.extractName(context: Context): String? { + val name = route ?: try { + context.resources.getResourceEntryName(id) + } catch (e: NotFoundException) { + hub.options.logger.log( + DEBUG, + "Destination id cannot be retrieved from Resources, no transaction captured." + ) + null + } ?: return null + + // we add '/' to the name to match dart and web pattern + return "/" + name.substringBefore('/') // strip out arguments from the tx name + } + companion object { const val NAVIGATION_OP = "navigation" } diff --git a/sentry-android-navigation/src/test/java/io/sentry/android/navigation/SentryNavigationListenerTest.kt b/sentry-android-navigation/src/test/java/io/sentry/android/navigation/SentryNavigationListenerTest.kt index 76c57159c3..342673dafb 100644 --- a/sentry-android-navigation/src/test/java/io/sentry/android/navigation/SentryNavigationListenerTest.kt +++ b/sentry-android-navigation/src/test/java/io/sentry/android/navigation/SentryNavigationListenerTest.kt @@ -56,6 +56,7 @@ class SentryNavigationListenerTest { toId: String? = "destination-id-1", enableBreadcrumbs: Boolean = true, enableTracing: Boolean = true, + enableScreenTracking: Boolean = true, tracesSampleRate: Double? = 1.0, hasViewIdInRes: Boolean = true, transaction: SentryTracer? = null, @@ -66,6 +67,7 @@ class SentryNavigationListenerTest { setTracesSampleRate( tracesSampleRate ) + isEnableScreenTracking = enableScreenTracking } whenever(hub.options).thenReturn(options) @@ -371,7 +373,7 @@ class SentryNavigationListenerTest { sut.onDestinationChanged(fixture.navController, fixture.destination, null) - verify(fixture.hub).configureScope(any()) + verify(fixture.hub, times(2)).configureScope(any()) assertNotSame(propagationContextAtStart, scope.propagationContext) } @@ -406,4 +408,22 @@ class SentryNavigationListenerTest { } ) } + + @Test + fun `onDestinationChanged sets scope screen`() { + val sut = fixture.getSut() + + sut.onDestinationChanged(fixture.navController, fixture.destination, null) + + verify(fixture.scope).screen = "/route" + } + + @Test + fun `onDestinationChanged does not set scope screen when screen tracking is disabled`() { + val sut = fixture.getSut(enableScreenTracking = false) + + sut.onDestinationChanged(fixture.navController, fixture.destination, null) + + verify(fixture.scope, never()).screen = "/route" + } } diff --git a/sentry-android-replay/.gitignore b/sentry-android-replay/.gitignore new file mode 100644 index 0000000000..42afabfd2a --- /dev/null +++ b/sentry-android-replay/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/sentry-android-replay/api/sentry-android-replay.api b/sentry-android-replay/api/sentry-android-replay.api new file mode 100644 index 0000000000..e8b85a0ae9 --- /dev/null +++ b/sentry-android-replay/api/sentry-android-replay.api @@ -0,0 +1,195 @@ +public final class io/sentry/android/replay/BuildConfig { + public static final field BUILD_TYPE Ljava/lang/String; + public static final field DEBUG Z + public static final field LIBRARY_PACKAGE_NAME Ljava/lang/String; + public static final field VERSION_NAME Ljava/lang/String; + public fun ()V +} + +public class io/sentry/android/replay/DefaultReplayBreadcrumbConverter : io/sentry/ReplayBreadcrumbConverter { + public fun ()V + public fun convert (Lio/sentry/Breadcrumb;)Lio/sentry/rrweb/RRWebEvent; +} + +public final class io/sentry/android/replay/GeneratedVideo { + public fun (Ljava/io/File;IJ)V + public final fun component1 ()Ljava/io/File; + public final fun component2 ()I + public final fun component3 ()J + public final fun copy (Ljava/io/File;IJ)Lio/sentry/android/replay/GeneratedVideo; + public static synthetic fun copy$default (Lio/sentry/android/replay/GeneratedVideo;Ljava/io/File;IJILjava/lang/Object;)Lio/sentry/android/replay/GeneratedVideo; + public fun equals (Ljava/lang/Object;)Z + public final fun getDuration ()J + public final fun getFrameCount ()I + public final fun getVideo ()Ljava/io/File; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public abstract interface class io/sentry/android/replay/Recorder : java/io/Closeable { + public abstract fun pause ()V + public abstract fun resume ()V + public abstract fun start (Lio/sentry/android/replay/ScreenshotRecorderConfig;)V + public abstract fun stop ()V +} + +public final class io/sentry/android/replay/ReplayCache : java/io/Closeable { + public fun (Lio/sentry/SentryOptions;Lio/sentry/protocol/SentryId;Lio/sentry/android/replay/ScreenshotRecorderConfig;)V + public final fun addFrame (Ljava/io/File;J)V + public fun close ()V + public final fun createVideoOf (JJIIILjava/io/File;)Lio/sentry/android/replay/GeneratedVideo; + public static synthetic fun createVideoOf$default (Lio/sentry/android/replay/ReplayCache;JJIIILjava/io/File;ILjava/lang/Object;)Lio/sentry/android/replay/GeneratedVideo; + public final fun rotate (J)V +} + +public final class io/sentry/android/replay/ReplayIntegration : android/content/ComponentCallbacks, io/sentry/Integration, io/sentry/ReplayController, io/sentry/android/replay/ScreenshotRecorderCallback, io/sentry/android/replay/TouchRecorderCallback, java/io/Closeable { + public fun (Landroid/content/Context;Lio/sentry/transport/ICurrentDateProvider;)V + public fun (Landroid/content/Context;Lio/sentry/transport/ICurrentDateProvider;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;)V + public synthetic fun (Landroid/content/Context;Lio/sentry/transport/ICurrentDateProvider;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun close ()V + public fun getBreadcrumbConverter ()Lio/sentry/ReplayBreadcrumbConverter; + public final fun getReplayCacheDir ()Ljava/io/File; + public fun getReplayId ()Lio/sentry/protocol/SentryId; + public fun isRecording ()Z + public fun onConfigurationChanged (Landroid/content/res/Configuration;)V + public fun onLowMemory ()V + public fun onScreenshotRecorded (Landroid/graphics/Bitmap;)V + public fun onScreenshotRecorded (Ljava/io/File;J)V + public fun onTouchEvent (Landroid/view/MotionEvent;)V + public fun pause ()V + public fun register (Lio/sentry/IHub;Lio/sentry/SentryOptions;)V + public fun resume ()V + public fun sendReplay (Ljava/lang/Boolean;Ljava/lang/String;Lio/sentry/Hint;)V + public fun sendReplayForEvent (Lio/sentry/SentryEvent;Lio/sentry/Hint;)V + public fun setBreadcrumbConverter (Lio/sentry/ReplayBreadcrumbConverter;)V + public fun start ()V + public fun stop ()V +} + +public abstract interface class io/sentry/android/replay/ScreenshotRecorderCallback { + public abstract fun onScreenshotRecorded (Landroid/graphics/Bitmap;)V + public abstract fun onScreenshotRecorded (Ljava/io/File;J)V +} + +public final class io/sentry/android/replay/ScreenshotRecorderConfig { + public static final field Companion Lio/sentry/android/replay/ScreenshotRecorderConfig$Companion; + public fun (IIFFII)V + public final fun component1 ()I + public final fun component2 ()I + public final fun component3 ()F + public final fun component4 ()F + public final fun component5 ()I + public final fun component6 ()I + public final fun copy (IIFFII)Lio/sentry/android/replay/ScreenshotRecorderConfig; + public static synthetic fun copy$default (Lio/sentry/android/replay/ScreenshotRecorderConfig;IIFFIIILjava/lang/Object;)Lio/sentry/android/replay/ScreenshotRecorderConfig; + public fun equals (Ljava/lang/Object;)Z + public final fun getBitRate ()I + public final fun getFrameRate ()I + public final fun getRecordingHeight ()I + public final fun getRecordingWidth ()I + public final fun getScaleFactorX ()F + public final fun getScaleFactorY ()F + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class io/sentry/android/replay/ScreenshotRecorderConfig$Companion { + public final fun from (Landroid/content/Context;Lio/sentry/SentryReplayOptions;)Lio/sentry/android/replay/ScreenshotRecorderConfig; +} + +public abstract interface class io/sentry/android/replay/TouchRecorderCallback { + public abstract fun onTouchEvent (Landroid/view/MotionEvent;)V +} + +public class io/sentry/android/replay/util/FixedWindowCallback : android/view/Window$Callback { + public final field delegate Landroid/view/Window$Callback; + public fun (Landroid/view/Window$Callback;)V + public fun dispatchGenericMotionEvent (Landroid/view/MotionEvent;)Z + public fun dispatchKeyEvent (Landroid/view/KeyEvent;)Z + public fun dispatchKeyShortcutEvent (Landroid/view/KeyEvent;)Z + public fun dispatchPopulateAccessibilityEvent (Landroid/view/accessibility/AccessibilityEvent;)Z + public fun dispatchTouchEvent (Landroid/view/MotionEvent;)Z + public fun dispatchTrackballEvent (Landroid/view/MotionEvent;)Z + public fun onActionModeFinished (Landroid/view/ActionMode;)V + public fun onActionModeStarted (Landroid/view/ActionMode;)V + public fun onAttachedToWindow ()V + public fun onContentChanged ()V + public fun onCreatePanelMenu (ILandroid/view/Menu;)Z + public fun onCreatePanelView (I)Landroid/view/View; + public fun onDetachedFromWindow ()V + public fun onMenuItemSelected (ILandroid/view/MenuItem;)Z + public fun onMenuOpened (ILandroid/view/Menu;)Z + public fun onPanelClosed (ILandroid/view/Menu;)V + public fun onPointerCaptureChanged (Z)V + public fun onPreparePanel (ILandroid/view/View;Landroid/view/Menu;)Z + public fun onProvideKeyboardShortcuts (Ljava/util/List;Landroid/view/Menu;I)V + public fun onSearchRequested ()Z + public fun onSearchRequested (Landroid/view/SearchEvent;)Z + public fun onWindowAttributesChanged (Landroid/view/WindowManager$LayoutParams;)V + public fun onWindowFocusChanged (Z)V + public fun onWindowStartingActionMode (Landroid/view/ActionMode$Callback;)Landroid/view/ActionMode; + public fun onWindowStartingActionMode (Landroid/view/ActionMode$Callback;I)Landroid/view/ActionMode; +} + +public abstract interface class io/sentry/android/replay/video/SimpleFrameMuxer { + public abstract fun getVideoTime ()J + public abstract fun isStarted ()Z + public abstract fun muxVideoFrame (Ljava/nio/ByteBuffer;Landroid/media/MediaCodec$BufferInfo;)V + public abstract fun release ()V + public abstract fun start (Landroid/media/MediaFormat;)V +} + +public final class io/sentry/android/replay/video/SimpleMp4FrameMuxer : io/sentry/android/replay/video/SimpleFrameMuxer { + public fun (Ljava/lang/String;F)V + public fun getVideoTime ()J + public fun isStarted ()Z + public fun muxVideoFrame (Ljava/nio/ByteBuffer;Landroid/media/MediaCodec$BufferInfo;)V + public fun release ()V + public fun start (Landroid/media/MediaFormat;)V +} + +public abstract class io/sentry/android/replay/viewhierarchy/ViewHierarchyNode { + public static final field Companion Lio/sentry/android/replay/viewhierarchy/ViewHierarchyNode$Companion; + public synthetic fun (FFIIFILio/sentry/android/replay/viewhierarchy/ViewHierarchyNode;ZZZLandroid/graphics/Rect;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public synthetic fun (FFIIFILio/sentry/android/replay/viewhierarchy/ViewHierarchyNode;ZZZLandroid/graphics/Rect;Lkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun getChildren ()Ljava/util/List; + public final fun getDistance ()I + public final fun getElevation ()F + public final fun getHeight ()I + public final fun getParent ()Lio/sentry/android/replay/viewhierarchy/ViewHierarchyNode; + public final fun getShouldRedact ()Z + public final fun getVisibleRect ()Landroid/graphics/Rect; + public final fun getWidth ()I + public final fun getX ()F + public final fun getY ()F + public final fun isImportantForContentCapture ()Z + public final fun isObscured (Lio/sentry/android/replay/viewhierarchy/ViewHierarchyNode;)Z + public final fun isVisible ()Z + public final fun setChildren (Ljava/util/List;)V + public final fun setImportantForContentCapture (Z)V + public final fun traverse (Lkotlin/jvm/functions/Function1;)V +} + +public final class io/sentry/android/replay/viewhierarchy/ViewHierarchyNode$Companion { + public final fun fromView (Landroid/view/View;Lio/sentry/android/replay/viewhierarchy/ViewHierarchyNode;ILio/sentry/SentryOptions;)Lio/sentry/android/replay/viewhierarchy/ViewHierarchyNode; +} + +public final class io/sentry/android/replay/viewhierarchy/ViewHierarchyNode$GenericViewHierarchyNode : io/sentry/android/replay/viewhierarchy/ViewHierarchyNode { + public fun (FFIIFILio/sentry/android/replay/viewhierarchy/ViewHierarchyNode;ZZZLandroid/graphics/Rect;)V + public synthetic fun (FFIIFILio/sentry/android/replay/viewhierarchy/ViewHierarchyNode;ZZZLandroid/graphics/Rect;ILkotlin/jvm/internal/DefaultConstructorMarker;)V +} + +public final class io/sentry/android/replay/viewhierarchy/ViewHierarchyNode$ImageViewHierarchyNode : io/sentry/android/replay/viewhierarchy/ViewHierarchyNode { + public fun (FFIIFILio/sentry/android/replay/viewhierarchy/ViewHierarchyNode;ZZZLandroid/graphics/Rect;)V + public synthetic fun (FFIIFILio/sentry/android/replay/viewhierarchy/ViewHierarchyNode;ZZZLandroid/graphics/Rect;ILkotlin/jvm/internal/DefaultConstructorMarker;)V +} + +public final class io/sentry/android/replay/viewhierarchy/ViewHierarchyNode$TextViewHierarchyNode : io/sentry/android/replay/viewhierarchy/ViewHierarchyNode { + public fun (Landroid/text/Layout;Ljava/lang/Integer;IIFFIIFILio/sentry/android/replay/viewhierarchy/ViewHierarchyNode;ZZZLandroid/graphics/Rect;)V + public synthetic fun (Landroid/text/Layout;Ljava/lang/Integer;IIFFIIFILio/sentry/android/replay/viewhierarchy/ViewHierarchyNode;ZZZLandroid/graphics/Rect;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun getDominantColor ()Ljava/lang/Integer; + public final fun getLayout ()Landroid/text/Layout; + public final fun getPaddingLeft ()I + public final fun getPaddingTop ()I +} + diff --git a/sentry-android-replay/build.gradle.kts b/sentry-android-replay/build.gradle.kts new file mode 100644 index 0000000000..bd9b5d961b --- /dev/null +++ b/sentry-android-replay/build.gradle.kts @@ -0,0 +1,84 @@ +import io.gitlab.arturbosch.detekt.Detekt +import org.jetbrains.kotlin.config.KotlinCompilerVersion + +plugins { + id("com.android.library") + kotlin("android") + jacoco + id(Config.QualityPlugins.jacocoAndroid) + id(Config.QualityPlugins.gradleVersions) + // TODO: enable it later +// id(Config.QualityPlugins.detektPlugin) +} + +android { + compileSdk = Config.Android.compileSdkVersion + namespace = "io.sentry.android.replay" + + defaultConfig { + targetSdk = Config.Android.targetSdkVersion + minSdk = Config.Android.minSdkVersionReplay + + testInstrumentationRunner = Config.TestLibs.androidJUnitRunner + + // for AGP 4.1 + buildConfigField("String", "VERSION_NAME", "\"${project.version}\"") + } + + buildTypes { + getByName("debug") + getByName("release") + } + + kotlinOptions { + jvmTarget = JavaVersion.VERSION_1_8.toString() + kotlinOptions.languageVersion = Config.kotlinCompatibleLanguageVersion + } + + testOptions { + animationsDisabled = true + unitTests.apply { + isReturnDefaultValues = true + isIncludeAndroidResources = true + } + } + + lint { + warningsAsErrors = true + checkDependencies = true + + // We run a full lint analysis as build part in CI, so skip vital checks for assemble tasks. + checkReleaseBuilds = false + } + + variantFilter { + if (Config.Android.shouldSkipDebugVariant(buildType.name)) { + ignore = true + } + } +} + +kotlin { + explicitApi() +} + +dependencies { + api(projects.sentry) + + implementation(kotlin(Config.kotlinStdLib, KotlinCompilerVersion.VERSION)) + + // tests + testImplementation(projects.sentryTestSupport) + testImplementation(Config.TestLibs.robolectric) + testImplementation(Config.TestLibs.kotlinTestJunit) + testImplementation(Config.TestLibs.androidxRunner) + testImplementation(Config.TestLibs.androidxJunit) + testImplementation(Config.TestLibs.mockitoKotlin) + testImplementation(Config.TestLibs.mockitoInline) + testImplementation(Config.TestLibs.awaitility) +} + +tasks.withType { + // Target version of the generated JVM bytecode. It is used for type resolution. + jvmTarget = JavaVersion.VERSION_1_8.toString() +} diff --git a/sentry-android-replay/proguard-rules.pro b/sentry-android-replay/proguard-rules.pro new file mode 100644 index 0000000000..738204b4c8 --- /dev/null +++ b/sentry-android-replay/proguard-rules.pro @@ -0,0 +1,3 @@ +# Uncomment this to preserve the line number information for +# debugging stack traces. +-keepattributes SourceFile,LineNumberTable diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/DefaultReplayBreadcrumbConverter.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/DefaultReplayBreadcrumbConverter.kt new file mode 100644 index 0000000000..504c4adf21 --- /dev/null +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/DefaultReplayBreadcrumbConverter.kt @@ -0,0 +1,157 @@ +package io.sentry.android.replay + +import io.sentry.Breadcrumb +import io.sentry.ReplayBreadcrumbConverter +import io.sentry.SentryLevel +import io.sentry.SpanDataConvention +import io.sentry.rrweb.RRWebBreadcrumbEvent +import io.sentry.rrweb.RRWebEvent +import io.sentry.rrweb.RRWebSpanEvent +import kotlin.LazyThreadSafetyMode.NONE + +public open class DefaultReplayBreadcrumbConverter : ReplayBreadcrumbConverter { + internal companion object { + private val snakecasePattern by lazy(NONE) { "_[a-z]".toRegex() } + private val supportedNetworkData = setOf( + "status_code", + "method", + "response_content_length", + "request_content_length", + "http.response_content_length", + "http.request_content_length" + ) + } + + private var lastConnectivityState: String? = null + + override fun convert(breadcrumb: Breadcrumb): RRWebEvent? { + var breadcrumbMessage: String? = null + var breadcrumbCategory: String? = null + var breadcrumbLevel: SentryLevel? = null + val breadcrumbData = mutableMapOf() + when { + breadcrumb.category == "http" -> { + return if (breadcrumb.isValidForRRWebSpan()) breadcrumb.toRRWebSpanEvent() else null + } + + breadcrumb.type == "navigation" && + breadcrumb.category == "app.lifecycle" -> { + breadcrumbCategory = "app.${breadcrumb.data["state"]}" + } + + breadcrumb.type == "navigation" && + breadcrumb.category == "device.orientation" -> { + breadcrumbCategory = breadcrumb.category!! + val position = breadcrumb.data["position"] + if (position == "landscape" || position == "portrait") { + breadcrumbData["position"] = position + } else { + return null + } + } + + breadcrumb.type == "navigation" -> { + breadcrumbCategory = "navigation" + breadcrumbData["to"] = when { + breadcrumb.data["state"] == "resumed" -> (breadcrumb.data["screen"] as? String)?.substringAfterLast('.') + "to" in breadcrumb.data -> breadcrumb.data["to"] as? String + else -> null + } ?: return null + } + + breadcrumb.category == "ui.click" -> { + breadcrumbCategory = "ui.tap" + breadcrumbMessage = ( + breadcrumb.data["view.id"] + ?: breadcrumb.data["view.tag"] + ?: breadcrumb.data["view.class"] + ) as? String ?: return null + breadcrumbData.putAll(breadcrumb.data) + } + + breadcrumb.type == "system" && breadcrumb.category == "network.event" -> { + breadcrumbCategory = "device.connectivity" + breadcrumbData["state"] = when { + breadcrumb.data["action"] == "NETWORK_LOST" -> "offline" + "network_type" in breadcrumb.data -> if (!(breadcrumb.data["network_type"] as? String).isNullOrEmpty()) { + breadcrumb.data["network_type"] + } else { + return null + } + + else -> return null + } + + if (lastConnectivityState == breadcrumbData["state"]) { + // debounce same state + return null + } + + lastConnectivityState = breadcrumbData["state"] as? String + } + + breadcrumb.data["action"] == "BATTERY_CHANGED" -> { + breadcrumbCategory = "device.battery" + breadcrumbData.putAll( + breadcrumb.data.filterKeys { it == "level" || it == "charging" } + ) + } + + else -> { + breadcrumbCategory = breadcrumb.category + breadcrumbMessage = breadcrumb.message + breadcrumbLevel = breadcrumb.level + breadcrumbData.putAll(breadcrumb.data) + } + } + return if (!breadcrumbCategory.isNullOrEmpty()) { + RRWebBreadcrumbEvent().apply { + timestamp = breadcrumb.timestamp.time + breadcrumbTimestamp = breadcrumb.timestamp.time / 1000.0 + breadcrumbType = "default" + category = breadcrumbCategory + message = breadcrumbMessage + level = breadcrumbLevel + data = breadcrumbData + } + } else { + null + } + } + + private fun Breadcrumb.isValidForRRWebSpan(): Boolean { + return !(data["url"] as? String).isNullOrEmpty() && + SpanDataConvention.HTTP_START_TIMESTAMP in data && + SpanDataConvention.HTTP_END_TIMESTAMP in data + } + + private fun String.snakeToCamelCase(): String { + return replace(snakecasePattern) { it.value.last().uppercase() } + } + + private fun Breadcrumb.toRRWebSpanEvent(): RRWebSpanEvent { + val breadcrumb = this + return RRWebSpanEvent().apply { + timestamp = breadcrumb.timestamp.time + op = "resource.http" + description = breadcrumb.data["url"] as String + startTimestamp = + (breadcrumb.data[SpanDataConvention.HTTP_START_TIMESTAMP] as Long) / 1000.0 + endTimestamp = + (breadcrumb.data[SpanDataConvention.HTTP_END_TIMESTAMP] as Long) / 1000.0 + + val breadcrumbData = mutableMapOf() + for ((key, value) in breadcrumb.data) { + if (key in supportedNetworkData) { + breadcrumbData[ + key + .replace("content_length", "body_size") + .substringAfter(".") + .snakeToCamelCase() + ] = value + } + } + data = breadcrumbData + } + } +} diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/Recorder.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/Recorder.kt new file mode 100644 index 0000000000..6cf86b6a7e --- /dev/null +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/Recorder.kt @@ -0,0 +1,18 @@ +package io.sentry.android.replay + +import java.io.Closeable + +interface Recorder : Closeable { + /** + * @param recorderConfig a [ScreenshotRecorderConfig] that can be used to determine frame rate + * at which the screenshots should be taken, and the screenshots size/resolution, which can + * change e.g. in the case of orientation change or window size change + */ + fun start(recorderConfig: ScreenshotRecorderConfig) + + fun resume() + + fun pause() + + fun stop() +} diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt new file mode 100644 index 0000000000..f49abfaa84 --- /dev/null +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt @@ -0,0 +1,252 @@ +package io.sentry.android.replay + +import android.graphics.Bitmap +import android.graphics.Bitmap.CompressFormat.JPEG +import android.graphics.BitmapFactory +import io.sentry.SentryLevel.DEBUG +import io.sentry.SentryLevel.ERROR +import io.sentry.SentryLevel.WARNING +import io.sentry.SentryOptions +import io.sentry.android.replay.video.MuxerConfig +import io.sentry.android.replay.video.SimpleVideoEncoder +import io.sentry.protocol.SentryId +import java.io.Closeable +import java.io.File + +/** + * A basic in-memory and disk cache for Session Replay frames. Frames are stored in order under the + * [SentryOptions.cacheDirPath] + [replayId] folder. The class is also capable of creating an mp4 + * video segment out of the stored frames, provided start time and duration using the available + * on-device [android.media.MediaCodec]. + * + * This class is not thread-safe, meaning, [addFrame] cannot be called concurrently with + * [createVideoOf], and they should be invoked from the same thread. + * + * @param options SentryOptions instance, used for logging and cacheDir + * @param replayId the current replay id, used for giving a unique name to the replay folder + * @param recorderConfig ScreenshotRecorderConfig, used for video resolution and frame-rate + */ +public class ReplayCache internal constructor( + private val options: SentryOptions, + private val replayId: SentryId, + private val recorderConfig: ScreenshotRecorderConfig, + private val encoderProvider: (videoFile: File, height: Int, width: Int) -> SimpleVideoEncoder +) : Closeable { + + public constructor( + options: SentryOptions, + replayId: SentryId, + recorderConfig: ScreenshotRecorderConfig + ) : this(options, replayId, recorderConfig, encoderProvider = { videoFile, height, width -> + SimpleVideoEncoder( + options, + MuxerConfig( + file = videoFile, + recordingHeight = height, + recordingWidth = width, + frameRate = recorderConfig.frameRate, + bitRate = recorderConfig.bitRate + ) + ).also { it.start() } + }) + + private val encoderLock = Any() + private var encoder: SimpleVideoEncoder? = null + + internal val replayCacheDir: File? by lazy { + if (options.cacheDirPath.isNullOrEmpty()) { + options.logger.log( + WARNING, + "SentryOptions.cacheDirPath is not set, session replay is no-op" + ) + null + } else { + File(options.cacheDirPath!!, "replay_$replayId").also { it.mkdirs() } + } + } + + // TODO: maybe account for multi-threaded access + internal val frames = mutableListOf() + + /** + * Stores the current frame screenshot to in-memory cache as well as disk with [frameTimestamp] + * as filename. Uses [Bitmap.CompressFormat.JPEG] format with quality 80. The frames are stored + * under [replayCacheDir]. + * + * This method is not thread-safe. + * + * @param bitmap the frame screenshot + * @param frameTimestamp the timestamp when the frame screenshot was taken + */ + internal fun addFrame(bitmap: Bitmap, frameTimestamp: Long) { + if (replayCacheDir == null) { + return + } + + val screenshot = File(replayCacheDir, "$frameTimestamp.jpg").also { + it.createNewFile() + } + screenshot.outputStream().use { + bitmap.compress(JPEG, 80, it) + it.flush() + } + + addFrame(screenshot, frameTimestamp) + } + + /** + * Same as [addFrame], but accepts frame screenshot as [File], the file should contain + * a bitmap/image by the time [createVideoOf] is invoked. + * + * This method is not thread-safe. + * + * @param screenshot file containing the frame screenshot + * @param frameTimestamp the timestamp when the frame screenshot was taken + */ + public fun addFrame(screenshot: File, frameTimestamp: Long) { + val frame = ReplayFrame(screenshot, frameTimestamp) + frames += frame + } + + /** + * Creates a video out of currently stored [frames] given the start time and duration using the + * on-device codecs [android.media.MediaCodec]. The generated video will be stored in + * [videoFile] location, which defaults to "[replayCacheDir]/[segmentId].mp4". + * + * This method is not thread-safe. + * + * @param duration desired video duration in milliseconds + * @param from desired start of the video represented as unix timestamp in milliseconds + * @param segmentId current segment id, used for inferring the filename to store the + * result video under [replayCacheDir], e.g. "replay_/0.mp4", where segmentId=0 + * @param height desired height of the video in pixels (e.g. it can change from the initial one + * in case of window resize or orientation change) + * @param width desired width of the video in pixels (e.g. it can change from the initial one + * in case of window resize or orientation change) + * @param videoFile optional, location of the file to store the result video. If this is + * provided, [segmentId] from above is disregarded and not used. + * @return a generated video of type [GeneratedVideo], which contains the resulting video file + * location, frame count and duration in milliseconds. + */ + public fun createVideoOf( + duration: Long, + from: Long, + segmentId: Int, + height: Int, + width: Int, + videoFile: File = File(replayCacheDir, "$segmentId.mp4") + ): GeneratedVideo? { + if (frames.isEmpty()) { + options.logger.log( + DEBUG, + "No captured frames, skipping generating a video segment" + ) + return null + } + + // TODO: reuse instance of encoder and just change file path to create a different muxer + encoder = synchronized(encoderLock) { encoderProvider(videoFile, height, width) } + + val step = 1000 / recorderConfig.frameRate.toLong() + var frameCount = 0 + var lastFrame: ReplayFrame = frames.first() + for (timestamp in from until (from + (duration)) step step) { + val iter = frames.iterator() + while (iter.hasNext()) { + val frame = iter.next() + if (frame.timestamp in (timestamp..timestamp + step)) { + lastFrame = frame + break // we only support 1 frame per given interval + } + + // assuming frames are in order, if out of bounds exit early + if (frame.timestamp > timestamp + step) { + break + } + } + + // we either encode a new frame within the step bounds or replicate the last known frame + // to respect the video duration + if (encode(lastFrame)) { + frameCount++ + } + } + + if (frameCount == 0) { + options.logger.log( + DEBUG, + "Generated a video with no frames, not capturing a replay segment" + ) + deleteFile(videoFile) + return null + } + + var videoDuration: Long + synchronized(encoderLock) { + encoder?.release() + videoDuration = encoder?.duration ?: 0 + encoder = null + } + + rotate(until = (from + duration)) + + return GeneratedVideo(videoFile, frameCount, videoDuration) + } + + private fun encode(frame: ReplayFrame): Boolean { + return try { + val bitmap = BitmapFactory.decodeFile(frame.screenshot.absolutePath) + synchronized(encoderLock) { + encoder?.encode(bitmap) + } + bitmap.recycle() + true + } catch (e: Throwable) { + options.logger.log(WARNING, "Unable to decode bitmap and encode it into a video, skipping frame", e) + false + } + } + + private fun deleteFile(file: File) { + try { + if (!file.delete()) { + options.logger.log(ERROR, "Failed to delete replay frame: %s", file.absolutePath) + } + } catch (e: Throwable) { + options.logger.log(ERROR, e, "Failed to delete replay frame: %s", file.absolutePath) + } + } + + /** + * Removes frames from the in-memory and disk cache from start to [until]. + * + * @param until value until whose the frames should be removed, represented as unix timestamp + */ + fun rotate(until: Long) { + frames.removeAll { + if (it.timestamp < until) { + deleteFile(it.screenshot) + return@removeAll true + } + return@removeAll false + } + } + + override fun close() { + synchronized(encoderLock) { + encoder?.release() + encoder = null + } + } +} + +internal data class ReplayFrame( + val screenshot: File, + val timestamp: Long +) + +public data class GeneratedVideo( + val video: File, + val frameCount: Int, + val duration: Long +) diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt new file mode 100644 index 0000000000..d207cf0331 --- /dev/null +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt @@ -0,0 +1,260 @@ +package io.sentry.android.replay + +import android.content.ComponentCallbacks +import android.content.Context +import android.content.res.Configuration +import android.graphics.Bitmap +import android.os.Build +import android.view.MotionEvent +import io.sentry.Hint +import io.sentry.IHub +import io.sentry.Integration +import io.sentry.NoOpReplayBreadcrumbConverter +import io.sentry.ReplayBreadcrumbConverter +import io.sentry.ReplayController +import io.sentry.ScopeObserverAdapter +import io.sentry.SentryEvent +import io.sentry.SentryIntegrationPackageStorage +import io.sentry.SentryLevel.DEBUG +import io.sentry.SentryLevel.INFO +import io.sentry.SentryOptions +import io.sentry.android.replay.capture.BufferCaptureStrategy +import io.sentry.android.replay.capture.CaptureStrategy +import io.sentry.android.replay.capture.SessionCaptureStrategy +import io.sentry.android.replay.util.MainLooperHandler +import io.sentry.android.replay.util.sample +import io.sentry.protocol.Contexts +import io.sentry.protocol.SentryId +import io.sentry.transport.ICurrentDateProvider +import io.sentry.util.IntegrationUtils.addIntegrationToSdkVersion +import java.io.Closeable +import java.io.File +import java.security.SecureRandom +import java.util.concurrent.atomic.AtomicBoolean + +public class ReplayIntegration( + private val context: Context, + private val dateProvider: ICurrentDateProvider, + private val recorderProvider: (() -> Recorder)? = null, + private val recorderConfigProvider: ((configChanged: Boolean) -> ScreenshotRecorderConfig)? = null, + private val replayCacheProvider: ((replayId: SentryId, recorderConfig: ScreenshotRecorderConfig) -> ReplayCache)? = null +) : Integration, Closeable, ScreenshotRecorderCallback, TouchRecorderCallback, ReplayController, ComponentCallbacks { + + // needed for the Java's call site + constructor(context: Context, dateProvider: ICurrentDateProvider) : this( + context, + dateProvider, + null, + null, + null + ) + + internal constructor( + context: Context, + dateProvider: ICurrentDateProvider, + recorderProvider: (() -> Recorder)?, + recorderConfigProvider: ((configChanged: Boolean) -> ScreenshotRecorderConfig)?, + replayCacheProvider: ((replayId: SentryId, recorderConfig: ScreenshotRecorderConfig) -> ReplayCache)?, + replayCaptureStrategyProvider: ((isFullSession: Boolean) -> CaptureStrategy)? = null, + mainLooperHandler: MainLooperHandler? = null + ) : this(context, dateProvider, recorderProvider, recorderConfigProvider, replayCacheProvider) { + this.replayCaptureStrategyProvider = replayCaptureStrategyProvider + this.mainLooperHandler = mainLooperHandler ?: MainLooperHandler() + } + + private lateinit var options: SentryOptions + private var hub: IHub? = null + private var recorder: Recorder? = null + private val random by lazy { SecureRandom() } + + // TODO: probably not everything has to be thread-safe here + internal val isEnabled = AtomicBoolean(false) + private val isRecording = AtomicBoolean(false) + private var captureStrategy: CaptureStrategy? = null + public val replayCacheDir: File? get() = captureStrategy?.replayCacheDir + private var replayBreadcrumbConverter: ReplayBreadcrumbConverter = NoOpReplayBreadcrumbConverter.getInstance() + private var replayCaptureStrategyProvider: ((isFullSession: Boolean) -> CaptureStrategy)? = null + private var mainLooperHandler: MainLooperHandler = MainLooperHandler() + + private lateinit var recorderConfig: ScreenshotRecorderConfig + + override fun register(hub: IHub, options: SentryOptions) { + this.options = options + + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + options.logger.log(INFO, "Session replay is only supported on API 26 and above") + return + } + + if (!options.experimental.sessionReplay.isSessionReplayEnabled && + !options.experimental.sessionReplay.isSessionReplayForErrorsEnabled + ) { + options.logger.log(INFO, "Session replay is disabled, no sample rate specified") + return + } + + this.hub = hub + this.options.addScopeObserver(object : ScopeObserverAdapter() { + override fun setContexts(contexts: Contexts) { + // scope screen has fully-qualified name + captureStrategy?.onScreenChanged(contexts.app?.viewNames?.lastOrNull()?.substringAfterLast('.')) + } + }) + recorder = recorderProvider?.invoke() ?: WindowRecorder(options, this, this, mainLooperHandler) + isEnabled.set(true) + + try { + context.registerComponentCallbacks(this) + } catch (e: Throwable) { + options.logger.log(INFO, "ComponentCallbacks is not available, orientation changes won't be handled by Session replay", e) + } + + addIntegrationToSdkVersion(javaClass) + SentryIntegrationPackageStorage.getInstance() + .addPackage("maven:io.sentry:sentry-android-replay", BuildConfig.VERSION_NAME) + } + + override fun isRecording() = isRecording.get() + + override fun start() { + // TODO: add lifecycle state instead and manage it in start/pause/resume/stop + if (!isEnabled.get()) { + return + } + + if (isRecording.getAndSet(true)) { + options.logger.log( + DEBUG, + "Session replay is already being recorded, not starting a new one" + ) + return + } + + val isFullSession = random.sample(options.experimental.sessionReplay.sessionSampleRate) + if (!isFullSession && !options.experimental.sessionReplay.isSessionReplayForErrorsEnabled) { + options.logger.log(INFO, "Session replay is not started, full session was not sampled and errorSampleRate is not specified") + return + } + + recorderConfig = recorderConfigProvider?.invoke(false) ?: ScreenshotRecorderConfig.from(context, options.experimental.sessionReplay) + captureStrategy = replayCaptureStrategyProvider?.invoke(isFullSession) ?: if (isFullSession) { + SessionCaptureStrategy(options, hub, dateProvider, recorderConfig, replayCacheProvider = replayCacheProvider) + } else { + BufferCaptureStrategy(options, hub, dateProvider, recorderConfig, random, replayCacheProvider) + } + + captureStrategy?.start() + recorder?.start(recorderConfig) + } + + override fun resume() { + if (!isEnabled.get() || !isRecording.get()) { + return + } + + captureStrategy?.resume() + recorder?.resume() + } + + override fun sendReplayForEvent(event: SentryEvent, hint: Hint) { + if (!isEnabled.get() || !isRecording.get()) { + return + } + + if (!(event.isErrored || event.isCrashed)) { + options.logger.log(DEBUG, "Event is not error or crash, not capturing for event %s", event.eventId) + return + } + + sendReplay(event.isCrashed, event.eventId.toString(), hint) + } + + override fun sendReplay(isCrashed: Boolean?, eventId: String?, hint: Hint?) { + if (!isEnabled.get() || !isRecording.get()) { + return + } + + if (SentryId.EMPTY_ID.equals(captureStrategy?.currentReplayId?.get())) { + options.logger.log(DEBUG, "Replay id is not set, not capturing for event %s", eventId) + return + } + + captureStrategy?.sendReplayForEvent(isCrashed == true, eventId, hint, onSegmentSent = { captureStrategy?.currentSegment?.getAndIncrement() }) + captureStrategy = captureStrategy?.convert() + } + + override fun getReplayId(): SentryId = captureStrategy?.currentReplayId?.get() ?: SentryId.EMPTY_ID + + override fun setBreadcrumbConverter(converter: ReplayBreadcrumbConverter) { + replayBreadcrumbConverter = converter + } + + override fun getBreadcrumbConverter(): ReplayBreadcrumbConverter = replayBreadcrumbConverter + + override fun pause() { + if (!isEnabled.get() || !isRecording.get()) { + return + } + + recorder?.pause() + captureStrategy?.pause() + } + + override fun stop() { + if (!isEnabled.get() || !isRecording.get()) { + return + } + + recorder?.stop() + captureStrategy?.stop() + isRecording.set(false) + captureStrategy?.close() + captureStrategy = null + } + + override fun onScreenshotRecorded(bitmap: Bitmap) { + captureStrategy?.onScreenshotRecorded(bitmap) { frameTimeStamp -> + addFrame(bitmap, frameTimeStamp) + } + } + + override fun onScreenshotRecorded(screenshot: File, frameTimestamp: Long) { + captureStrategy?.onScreenshotRecorded { _ -> + addFrame(screenshot, frameTimestamp) + } + } + + override fun close() { + if (!isEnabled.get()) { + return + } + + try { + context.unregisterComponentCallbacks(this) + } catch (ignored: Throwable) { + } + stop() + recorder?.close() + recorder = null + } + + override fun onConfigurationChanged(newConfig: Configuration) { + if (!isEnabled.get() || !isRecording.get()) { + return + } + + recorder?.stop() + + // refresh config based on new device configuration + recorderConfig = recorderConfigProvider?.invoke(true) ?: ScreenshotRecorderConfig.from(context, options.experimental.sessionReplay) + captureStrategy?.onConfigurationChanged(recorderConfig) + + recorder?.start(recorderConfig) + } + + override fun onLowMemory() = Unit + + override fun onTouchEvent(event: MotionEvent) { + captureStrategy?.onTouchEvent(event) + } +} diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt new file mode 100644 index 0000000000..40fb6ef931 --- /dev/null +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt @@ -0,0 +1,361 @@ +package io.sentry.android.replay + +import android.annotation.TargetApi +import android.content.Context +import android.graphics.Bitmap +import android.graphics.Bitmap.Config.ARGB_8888 +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Matrix +import android.graphics.Paint +import android.graphics.Point +import android.graphics.Rect +import android.graphics.RectF +import android.os.Build.VERSION +import android.os.Build.VERSION_CODES +import android.view.PixelCopy +import android.view.View +import android.view.ViewGroup +import android.view.ViewTreeObserver +import android.view.WindowManager +import io.sentry.SentryLevel.DEBUG +import io.sentry.SentryLevel.INFO +import io.sentry.SentryLevel.WARNING +import io.sentry.SentryOptions +import io.sentry.SentryReplayOptions +import io.sentry.android.replay.util.MainLooperHandler +import io.sentry.android.replay.util.getVisibleRects +import io.sentry.android.replay.util.gracefullyShutdown +import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode +import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode.ImageViewHierarchyNode +import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode.TextViewHierarchyNode +import java.io.File +import java.lang.ref.WeakReference +import java.util.concurrent.Executors +import java.util.concurrent.ThreadFactory +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicReference +import kotlin.math.roundToInt + +@TargetApi(26) +internal class ScreenshotRecorder( + val config: ScreenshotRecorderConfig, + val options: SentryOptions, + val mainLooperHandler: MainLooperHandler, + private val screenshotRecorderCallback: ScreenshotRecorderCallback? +) : ViewTreeObserver.OnDrawListener { + + private val recorder by lazy { + Executors.newSingleThreadScheduledExecutor(RecorderExecutorServiceThreadFactory()) + } + private var rootView: WeakReference? = null + private val pendingViewHierarchy = AtomicReference() + private val maskingPaint = Paint() + private val singlePixelBitmap: Bitmap = Bitmap.createBitmap( + 1, + 1, + Bitmap.Config.ARGB_8888 + ) + private val singlePixelBitmapCanvas: Canvas = Canvas(singlePixelBitmap) + private val prescaledMatrix = Matrix().apply { + preScale(config.scaleFactorX, config.scaleFactorY) + } + private val contentChanged = AtomicBoolean(false) + private val isCapturing = AtomicBoolean(true) + private var lastScreenshot: Bitmap? = null + + fun capture() { + if (!isCapturing.get()) { + options.logger.log(DEBUG, "ScreenshotRecorder is paused, not capturing screenshot") + return + } + + if (!contentChanged.get() && lastScreenshot != null && !lastScreenshot!!.isRecycled) { + options.logger.log(DEBUG, "Content hasn't changed, repeating last known frame") + + lastScreenshot?.let { + screenshotRecorderCallback?.onScreenshotRecorded( + it.copy(ARGB_8888, false) + ) + } + return + } + + val root = rootView?.get() + if (root == null || root.width <= 0 || root.height <= 0 || !root.isShown) { + options.logger.log(DEBUG, "Root view is invalid, not capturing screenshot") + return + } + + val window = root.phoneWindow + if (window == null) { + options.logger.log(DEBUG, "Window is invalid, not capturing screenshot") + return + } + + val bitmap = Bitmap.createBitmap( + config.recordingWidth, + config.recordingHeight, + Bitmap.Config.ARGB_8888 + ) + + // postAtFrontOfQueue to ensure the view hierarchy and bitmap are ase close in-sync as possible + mainLooperHandler.post { + try { + contentChanged.set(false) + PixelCopy.request( + window, + bitmap, + { copyResult: Int -> + if (copyResult != PixelCopy.SUCCESS) { + options.logger.log(INFO, "Failed to capture replay recording: %d", copyResult) + bitmap.recycle() + return@request + } + + if (contentChanged.get()) { + options.logger.log(INFO, "Failed to determine view hierarchy, not capturing") + bitmap.recycle() + return@request + } + + val viewHierarchy = ViewHierarchyNode.fromView(root, null, 0, options) + root.traverse(viewHierarchy) + + recorder.submit { + val canvas = Canvas(bitmap) + canvas.setMatrix(prescaledMatrix) + viewHierarchy.traverse { node -> + if (node.shouldRedact && (node.width > 0 && node.height > 0)) { + node.visibleRect ?: return@traverse false + + // TODO: investigate why it returns true on RN when it shouldn't +// if (viewHierarchy.isObscured(node)) { +// return@traverse true +// } + + val (visibleRects, color) = when (node) { + is ImageViewHierarchyNode -> { + listOf(node.visibleRect) to + bitmap.dominantColorForRect(node.visibleRect) + } + + is TextViewHierarchyNode -> { + // TODO: find a way to get the correct text color for RN + // TODO: now it always returns black + node.layout.getVisibleRects( + node.visibleRect, + node.paddingLeft, + node.paddingTop + ) to (node.dominantColor ?: Color.BLACK) + } + + else -> { + listOf(node.visibleRect) to Color.BLACK + } + } + + maskingPaint.setColor(color) + visibleRects.forEach { rect -> + canvas.drawRoundRect(RectF(rect), 10f, 10f, maskingPaint) + } + } + return@traverse true + } + + val screenshot = bitmap.copy(ARGB_8888, false) + screenshotRecorderCallback?.onScreenshotRecorded(screenshot) + lastScreenshot?.recycle() + lastScreenshot = screenshot + contentChanged.set(false) + + bitmap.recycle() + } + }, + mainLooperHandler.handler + ) + } catch (e: Throwable) { + options.logger.log(WARNING, "Failed to capture replay recording", e) + bitmap.recycle() + } + } + } + + override fun onDraw() { + val root = rootView?.get() + if (root == null || root.width <= 0 || root.height <= 0 || !root.isShown) { + options.logger.log(DEBUG, "Root view is invalid, not capturing screenshot") + return + } + + contentChanged.set(true) + } + + fun bind(root: View) { + // first unbind the current root + unbind(rootView?.get()) + rootView?.clear() + + // next bind the new root + rootView = WeakReference(root) + root.viewTreeObserver?.addOnDrawListener(this) + } + + fun unbind(root: View?) { + root?.viewTreeObserver?.removeOnDrawListener(this) + } + + fun pause() { + isCapturing.set(false) + unbind(rootView?.get()) + } + + fun resume() { + // can't use bind() as it will invalidate the weakref + rootView?.get()?.viewTreeObserver?.addOnDrawListener(this) + isCapturing.set(true) + } + + fun close() { + unbind(rootView?.get()) + rootView?.clear() + lastScreenshot?.recycle() + pendingViewHierarchy.set(null) + isCapturing.set(false) + recorder.gracefullyShutdown(options) + } + + private fun Bitmap.dominantColorForRect(rect: Rect): Int { + // TODO: maybe this ceremony can be just simplified to + // TODO: multiplying the visibleRect by the prescaledMatrix + val visibleRect = Rect(rect) + val visibleRectF = RectF(visibleRect) + + // since we take screenshot with lower scale, we also + // have to apply the same scale to the visibleRect to get the + // correct screenshot part to determine the dominant color + prescaledMatrix.mapRect(visibleRectF) + // round it back to integer values, because drawBitmap below accepts Rect only + visibleRectF.round(visibleRect) + // draw part of the screenshot (visibleRect) to a single pixel bitmap + singlePixelBitmapCanvas.drawBitmap( + this, + visibleRect, + Rect(0, 0, 1, 1), + null + ) + // get the pixel color (= dominant color) + return singlePixelBitmap.getPixel(0, 0) + } + + private fun View.traverse(parentNode: ViewHierarchyNode) { + if (this !is ViewGroup) { + return + } + + if (this.childCount == 0) { + return + } + + val childNodes = ArrayList(this.childCount) + for (i in 0 until childCount) { + val child = getChildAt(i) + if (child != null) { + val childNode = + ViewHierarchyNode.fromView(child, parentNode, indexOfChild(child), options) + childNodes.add(childNode) + child.traverse(childNode) + } + } + parentNode.children = childNodes + } + + private class RecorderExecutorServiceThreadFactory : ThreadFactory { + private var cnt = 0 + override fun newThread(r: Runnable): Thread { + val ret = Thread(r, "SentryReplayRecorder-" + cnt++) + ret.setDaemon(true) + return ret + } + } +} + +public data class ScreenshotRecorderConfig( + val recordingWidth: Int, + val recordingHeight: Int, + val scaleFactorX: Float, + val scaleFactorY: Float, + val frameRate: Int, + val bitRate: Int +) { + companion object { + /** + * Since codec block size is 16, so we have to adjust the width and height to it, otherwise + * the codec might fail to configure on some devices, see https://cs.android.com/android/platform/superproject/+/master:frameworks/base/media/java/android/media/MediaCodecInfo.java;l=1999-2001 + */ + private fun Int.adjustToBlockSize(): Int { + val remainder = this % 16 + return if (remainder <= 8) { + this - remainder + } else { + this + (16 - remainder) + } + } + + fun from( + context: Context, + sessionReplay: SentryReplayOptions + ): ScreenshotRecorderConfig { + // PixelCopy takes screenshots including system bars, so we have to get the real size here + val wm = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager + val screenBounds = if (VERSION.SDK_INT >= VERSION_CODES.R) { + wm.currentWindowMetrics.bounds + } else { + val screenBounds = Point() + @Suppress("DEPRECATION") + wm.defaultDisplay.getRealSize(screenBounds) + Rect(0, 0, screenBounds.x, screenBounds.y) + } + + // use the baseline density of 1x (mdpi) + val (height, width) = + ((screenBounds.height() / context.resources.displayMetrics.density) * sessionReplay.quality.sizeScale) + .roundToInt() + .adjustToBlockSize() to + ((screenBounds.width() / context.resources.displayMetrics.density) * sessionReplay.quality.sizeScale) + .roundToInt() + .adjustToBlockSize() + + return ScreenshotRecorderConfig( + recordingWidth = width, + recordingHeight = height, + scaleFactorX = width.toFloat() / screenBounds.width(), + scaleFactorY = height.toFloat() / screenBounds.height(), + frameRate = sessionReplay.frameRate, + bitRate = sessionReplay.quality.bitRate + ) + } + } +} + +/** + * A callback to be invoked when a new screenshot available. Normally, only one of the + * [onScreenshotRecorded] method overloads should be called by a single recorder, however, it will + * still work of both are used at the same time. + */ +public interface ScreenshotRecorderCallback { + /** + * Called whenever a new frame screenshot is available. + * + * @param bitmap a screenshot taken in the form of [android.graphics.Bitmap] + */ + fun onScreenshotRecorded(bitmap: Bitmap) + + /** + * Called whenever a new frame screenshot is available. + * + * @param screenshot file containing the frame screenshot + * @param frameTimestamp the timestamp when the frame screenshot was taken + */ + fun onScreenshotRecorded(screenshot: File, frameTimestamp: Long) +} diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/WindowRecorder.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/WindowRecorder.kt new file mode 100644 index 0000000000..01147e3a7f --- /dev/null +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/WindowRecorder.kt @@ -0,0 +1,167 @@ +package io.sentry.android.replay + +import android.annotation.TargetApi +import android.view.MotionEvent +import android.view.View +import android.view.Window +import io.sentry.SentryLevel.DEBUG +import io.sentry.SentryLevel.ERROR +import io.sentry.SentryOptions +import io.sentry.android.replay.util.FixedWindowCallback +import io.sentry.android.replay.util.MainLooperHandler +import io.sentry.android.replay.util.gracefullyShutdown +import io.sentry.android.replay.util.scheduleAtFixedRateSafely +import java.lang.ref.WeakReference +import java.util.concurrent.Executors +import java.util.concurrent.ScheduledFuture +import java.util.concurrent.ThreadFactory +import java.util.concurrent.TimeUnit.MILLISECONDS +import java.util.concurrent.atomic.AtomicBoolean +import kotlin.LazyThreadSafetyMode.NONE + +@TargetApi(26) +internal class WindowRecorder( + private val options: SentryOptions, + private val screenshotRecorderCallback: ScreenshotRecorderCallback? = null, + private val touchRecorderCallback: TouchRecorderCallback? = null, + private val mainLooperHandler: MainLooperHandler +) : Recorder { + + internal companion object { + private const val TAG = "WindowRecorder" + } + + private val rootViewsSpy by lazy(NONE) { + RootViewsSpy.install() + } + + private val isRecording = AtomicBoolean(false) + private val rootViews = ArrayList>() + private var recorder: ScreenshotRecorder? = null + private var capturingTask: ScheduledFuture<*>? = null + private val capturer by lazy { + Executors.newSingleThreadScheduledExecutor(RecorderExecutorServiceThreadFactory()) + } + + private val onRootViewsChangedListener = OnRootViewsChangedListener { root, added -> + if (added) { + rootViews.add(WeakReference(root)) + recorder?.bind(root) + + root.startGestureTracking() + } else { + root.stopGestureTracking() + + recorder?.unbind(root) + rootViews.removeAll { it.get() == root } + + val newRoot = rootViews.lastOrNull()?.get() + if (newRoot != null && root != newRoot) { + recorder?.bind(newRoot) + } + } + } + + override fun start(recorderConfig: ScreenshotRecorderConfig) { + if (isRecording.getAndSet(true)) { + return + } + + recorder = ScreenshotRecorder(recorderConfig, options, mainLooperHandler, screenshotRecorderCallback) + rootViewsSpy.listeners += onRootViewsChangedListener + capturingTask = capturer.scheduleAtFixedRateSafely( + options, + "$TAG.capture", + 0L, + 1000L / recorderConfig.frameRate, + MILLISECONDS + ) { + recorder?.capture() + } + } + + override fun resume() { + recorder?.resume() + } + override fun pause() { + recorder?.pause() + } + + override fun stop() { + rootViewsSpy.listeners -= onRootViewsChangedListener + rootViews.forEach { recorder?.unbind(it.get()) } + recorder?.close() + rootViews.clear() + recorder = null + capturingTask?.cancel(false) + capturingTask = null + isRecording.set(false) + } + + override fun close() { + stop() + capturer.gracefullyShutdown(options) + } + + private fun View.startGestureTracking() { + val window = phoneWindow + if (window == null) { + options.logger.log(DEBUG, "Window is invalid, not tracking gestures") + return + } + + if (touchRecorderCallback == null) { + options.logger.log(DEBUG, "TouchRecorderCallback is null, not tracking gestures") + return + } + + val delegate = window.callback + window.callback = SentryReplayGestureRecorder(options, touchRecorderCallback, delegate) + } + + private fun View.stopGestureTracking() { + val window = phoneWindow + if (window == null) { + options.logger.log(DEBUG, "Window was null in stopGestureTracking") + return + } + + if (window.callback is SentryReplayGestureRecorder) { + val delegate = (window.callback as SentryReplayGestureRecorder).delegate + window.callback = delegate + } + } + + private class SentryReplayGestureRecorder( + private val options: SentryOptions, + private val touchRecorderCallback: TouchRecorderCallback?, + delegate: Window.Callback? + ) : FixedWindowCallback(delegate) { + override fun dispatchTouchEvent(event: MotionEvent?): Boolean { + if (event != null) { + val copy: MotionEvent = MotionEvent.obtainNoHistory(event) + try { + touchRecorderCallback?.onTouchEvent(copy) + } catch (e: Throwable) { + options.logger.log(ERROR, "Error dispatching touch event", e) + } finally { + copy.recycle() + } + } + return super.dispatchTouchEvent(event) + } + } + + private class RecorderExecutorServiceThreadFactory : ThreadFactory { + private var cnt = 0 + override fun newThread(r: Runnable): Thread { + val ret = Thread(r, "SentryWindowRecorder-" + cnt++) + ret.setDaemon(true) + return ret + } + } +} + +public interface TouchRecorderCallback { + fun onTouchEvent(event: MotionEvent) +} diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/Windows.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/Windows.kt new file mode 100644 index 0000000000..8ef595f193 --- /dev/null +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/Windows.kt @@ -0,0 +1,226 @@ +/** + * Adapted from https://github.com/square/curtains/tree/v1.2.5 + * + * Copyright 2021 Square Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.sentry.android.replay + +import android.annotation.SuppressLint +import android.os.Build.VERSION.SDK_INT +import android.os.Handler +import android.os.Looper +import android.util.Log +import android.view.View +import android.view.Window +import java.util.concurrent.CopyOnWriteArrayList +import kotlin.LazyThreadSafetyMode.NONE + +/** + * If this view is part of the view hierarchy from a [android.app.Activity], [android.app.Dialog] or + * [android.service.dreams.DreamService], then this returns the [android.view.Window] instance + * associated to it. Otherwise, this returns null. + * + * Note: this property is called [phoneWindow] because the only implementation of [Window] is + * the internal class android.view.PhoneWindow. + */ +internal val View.phoneWindow: Window? + get() { + return WindowSpy.pullWindow(rootView) + } + +internal object WindowSpy { + + /** + * Originally, DecorView was an inner class of PhoneWindow. In the initial import in 2009, + * PhoneWindow is in com.android.internal.policy.impl.PhoneWindow and that didn't change until + * API 23. + * In API 22: https://android.googlesource.com/platform/frameworks/base/+/android-5.1.1_r38/policy/src/com/android/internal/policy/impl/PhoneWindow.java + * PhoneWindow was then moved to android.view and then again to com.android.internal.policy + * https://android.googlesource.com/platform/frameworks/base/+/b10e33ff804a831c71be9303146cea892b9aeb5d + * https://android.googlesource.com/platform/frameworks/base/+/6711f3b34c2ad9c622f56a08b81e313795fe7647 + * In API 23: https://android.googlesource.com/platform/frameworks/base/+/android-6.0.0_r1/core/java/com/android/internal/policy/PhoneWindow.java + * Then DecorView moved out of PhoneWindow into its own class: + * https://android.googlesource.com/platform/frameworks/base/+/8804af2b63b0584034f7ec7d4dc701d06e6a8754 + * In API 24: https://android.googlesource.com/platform/frameworks/base/+/android-7.0.0_r1/core/java/com/android/internal/policy/DecorView.java + */ + private val decorViewClass by lazy(NONE) { + val sdkInt = SDK_INT + // TODO: we can only consider API 26 + val decorViewClassName = when { + sdkInt >= 24 -> "com.android.internal.policy.DecorView" + sdkInt == 23 -> "com.android.internal.policy.PhoneWindow\$DecorView" + else -> "com.android.internal.policy.impl.PhoneWindow\$DecorView" + } + try { + Class.forName(decorViewClassName) + } catch (ignored: Throwable) { + Log.d( + "WindowSpy", + "Unexpected exception loading $decorViewClassName on API $sdkInt", + ignored + ) + null + } + } + + /** + * See [decorViewClass] for the AOSP history of the DecorView class. + * Between the latest API 23 release and the first API 24 release, DecorView first became a + * static class: + * https://android.googlesource.com/platform/frameworks/base/+/0daf2102a20d224edeb4ee45dd4ee91889ef3e0c + * Then it was extracted into a separate class. + * + * Hence the change of window field name from "this$0" to "mWindow" on API 24+. + */ + private val windowField by lazy(NONE) { + decorViewClass?.let { decorViewClass -> + val sdkInt = SDK_INT + val fieldName = if (sdkInt >= 24) "mWindow" else "this$0" + try { + decorViewClass.getDeclaredField(fieldName).apply { isAccessible = true } + } catch (ignored: NoSuchFieldException) { + Log.d( + "WindowSpy", + "Unexpected exception retrieving $decorViewClass#$fieldName on API $sdkInt", + ignored + ) + null + } + } + } + + fun pullWindow(maybeDecorView: View): Window? { + return decorViewClass?.let { decorViewClass -> + if (decorViewClass.isInstance(maybeDecorView)) { + windowField?.let { windowField -> + windowField[maybeDecorView] as Window + } + } else { + null + } + } + } +} + +/** + * Listener added to [Curtains.onRootViewsChangedListeners]. + * If you only care about either attached or detached, consider implementing [OnRootViewAddedListener] + * or [OnRootViewRemovedListener] instead. + */ +internal fun interface OnRootViewsChangedListener { + /** + * Called when [android.view.WindowManager.addView] and [android.view.WindowManager.removeView] + * are called. + */ + fun onRootViewsChanged( + view: View, + added: Boolean + ) +} + +/** + * A utility that holds the list of root views that WindowManager updates. + */ +internal class RootViewsSpy private constructor() { + + val listeners: CopyOnWriteArrayList = object : CopyOnWriteArrayList() { + override fun add(element: OnRootViewsChangedListener?): Boolean { + // notify listener about existing root views immediately + delegatingViewList.forEach { + element?.onRootViewsChanged(it, true) + } + return super.add(element) + } + } + + private val delegatingViewList: ArrayList = object : ArrayList() { + override fun addAll(elements: Collection): Boolean { + listeners.forEach { listener -> + elements.forEach { element -> + listener.onRootViewsChanged(element, true) + } + } + return super.addAll(elements) + } + + override fun add(element: View): Boolean { + listeners.forEach { it.onRootViewsChanged(element, true) } + return super.add(element) + } + + override fun removeAt(index: Int): View { + val removedView = super.removeAt(index) + listeners.forEach { it.onRootViewsChanged(removedView, false) } + return removedView + } + } + + companion object { + fun install(): RootViewsSpy { + return RootViewsSpy().apply { + // had to do this as a first message of the main thread queue, otherwise if this is + // called from ContentProvider, it might be too early and the listener won't be installed + Handler(Looper.getMainLooper()).postAtFrontOfQueue { + WindowManagerSpy.swapWindowManagerGlobalMViews { mViews -> + delegatingViewList.apply { addAll(mViews) } + } + } + } + } + } +} + +internal object WindowManagerSpy { + + private val windowManagerClass by lazy(NONE) { + val className = "android.view.WindowManagerGlobal" + try { + Class.forName(className) + } catch (ignored: Throwable) { + Log.w("WindowManagerSpy", ignored) + null + } + } + + private val windowManagerInstance by lazy(NONE) { + windowManagerClass?.getMethod("getInstance")?.invoke(null) + } + + private val mViewsField by lazy(NONE) { + windowManagerClass?.let { windowManagerClass -> + windowManagerClass.getDeclaredField("mViews").apply { isAccessible = true } + } + } + + // You can discourage me all you want I'll still do it. + @SuppressLint("PrivateApi", "ObsoleteSdkInt", "DiscouragedPrivateApi") + fun swapWindowManagerGlobalMViews(swap: (ArrayList) -> ArrayList) { + if (SDK_INT < 19) { + return + } + try { + windowManagerInstance?.let { windowManagerInstance -> + mViewsField?.let { mViewsField -> + @Suppress("UNCHECKED_CAST") + val mViews = mViewsField[windowManagerInstance] as ArrayList + mViewsField[windowManagerInstance] = swap(mViews) + } + } + } catch (ignored: Throwable) { + Log.w("WindowManagerSpy", ignored) + } + } +} diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt new file mode 100644 index 0000000000..79a75f816c --- /dev/null +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt @@ -0,0 +1,419 @@ +package io.sentry.android.replay.capture + +import android.view.MotionEvent +import io.sentry.DateUtils +import io.sentry.Hint +import io.sentry.IHub +import io.sentry.ReplayRecording +import io.sentry.SentryOptions +import io.sentry.SentryReplayEvent +import io.sentry.SentryReplayEvent.ReplayType +import io.sentry.SentryReplayEvent.ReplayType.SESSION +import io.sentry.android.replay.ReplayCache +import io.sentry.android.replay.ScreenshotRecorderConfig +import io.sentry.android.replay.util.gracefullyShutdown +import io.sentry.android.replay.util.submitSafely +import io.sentry.protocol.SentryId +import io.sentry.rrweb.RRWebBreadcrumbEvent +import io.sentry.rrweb.RRWebEvent +import io.sentry.rrweb.RRWebIncrementalSnapshotEvent +import io.sentry.rrweb.RRWebInteractionEvent +import io.sentry.rrweb.RRWebInteractionEvent.InteractionType +import io.sentry.rrweb.RRWebInteractionMoveEvent +import io.sentry.rrweb.RRWebInteractionMoveEvent.Position +import io.sentry.rrweb.RRWebMetaEvent +import io.sentry.rrweb.RRWebVideoEvent +import io.sentry.transport.ICurrentDateProvider +import io.sentry.util.FileUtils +import java.io.File +import java.util.Date +import java.util.LinkedList +import java.util.concurrent.Executors +import java.util.concurrent.ScheduledExecutorService +import java.util.concurrent.ThreadFactory +import java.util.concurrent.atomic.AtomicInteger +import java.util.concurrent.atomic.AtomicLong +import java.util.concurrent.atomic.AtomicReference + +internal abstract class BaseCaptureStrategy( + private val options: SentryOptions, + private val hub: IHub?, + private val dateProvider: ICurrentDateProvider, + protected var recorderConfig: ScreenshotRecorderConfig, + executor: ScheduledExecutorService? = null, + private val replayCacheProvider: ((replayId: SentryId, recorderConfig: ScreenshotRecorderConfig) -> ReplayCache)? = null +) : CaptureStrategy { + + internal companion object { + private const val TAG = "CaptureStrategy" + + // rrweb values + private const val TOUCH_MOVE_DEBOUNCE_THRESHOLD = 50 + private const val CAPTURE_MOVE_EVENT_THRESHOLD = 500 + } + + protected var cache: ReplayCache? = null + protected val segmentTimestamp = AtomicReference() + protected val replayStartTimestamp = AtomicLong() + protected val screenAtStart = AtomicReference() + override val currentReplayId = AtomicReference(SentryId.EMPTY_ID) + override val currentSegment = AtomicInteger(0) + override val replayCacheDir: File? get() = cache?.replayCacheDir + + protected val currentEvents = LinkedList() + private val currentEventsLock = Any() + private val currentPositions = LinkedHashMap>(10) + private var touchMoveBaseline = 0L + private var lastCapturedMoveEvent = 0L + + protected val replayExecutor: ScheduledExecutorService by lazy { + executor ?: Executors.newSingleThreadScheduledExecutor(ReplayExecutorServiceThreadFactory()) + } + + override fun start(segmentId: Int, replayId: SentryId, cleanupOldReplays: Boolean) { + currentSegment.set(segmentId) + currentReplayId.set(replayId) + + if (cleanupOldReplays) { + replayExecutor.submitSafely(options, "$TAG.replays_cleanup") { + // clean up old replays + options.cacheDirPath?.let { cacheDir -> + File(cacheDir).listFiles { dir, name -> + // TODO: also exclude persisted replay_id from scope when implementing ANRs + if (name.startsWith("replay_") && !name.contains( + currentReplayId.get().toString() + ) + ) { + FileUtils.deleteRecursively(File(dir, name)) + } + false + } + } + } + } + + cache = + replayCacheProvider?.invoke(replayId, recorderConfig) ?: ReplayCache(options, replayId, recorderConfig) + + // TODO: replace it with dateProvider.currentTimeMillis to also test it + segmentTimestamp.set(DateUtils.getCurrentDateTime()) + replayStartTimestamp.set(dateProvider.currentTimeMillis) + // TODO: finalize old recording if there's some left on disk and send it using the replayId from persisted scope (e.g. for ANRs) + } + + override fun resume() { + // TODO: replace it with dateProvider.currentTimeMillis to also test it + segmentTimestamp.set(DateUtils.getCurrentDateTime()) + } + + override fun pause() = Unit + + override fun stop() { + cache?.close() + currentSegment.set(0) + replayStartTimestamp.set(0) + segmentTimestamp.set(null) + currentReplayId.set(SentryId.EMPTY_ID) + } + + protected fun createSegment( + duration: Long, + currentSegmentTimestamp: Date, + replayId: SentryId, + segmentId: Int, + height: Int, + width: Int, + replayType: ReplayType = SESSION + ): ReplaySegment { + val generatedVideo = cache?.createVideoOf( + duration, + currentSegmentTimestamp.time, + segmentId, + height, + width + ) ?: return ReplaySegment.Failed + + val (video, frameCount, videoDuration) = generatedVideo + return buildReplay( + video, + replayId, + currentSegmentTimestamp, + segmentId, + height, + width, + frameCount, + videoDuration, + replayType + ) + } + + private fun buildReplay( + video: File, + currentReplayId: SentryId, + segmentTimestamp: Date, + segmentId: Int, + height: Int, + width: Int, + frameCount: Int, + duration: Long, + replayType: ReplayType + ): ReplaySegment { + val endTimestamp = DateUtils.getDateTime(segmentTimestamp.time + duration) + val replay = SentryReplayEvent().apply { + eventId = currentReplayId + replayId = currentReplayId + this.segmentId = segmentId + this.timestamp = endTimestamp + replayStartTimestamp = segmentTimestamp + this.replayType = replayType + videoFile = video + } + + val recordingPayload = mutableListOf() + recordingPayload += RRWebMetaEvent().apply { + this.timestamp = segmentTimestamp.time + this.height = height + this.width = width + } + recordingPayload += RRWebVideoEvent().apply { + this.timestamp = segmentTimestamp.time + this.segmentId = segmentId + this.durationMs = duration + this.frameCount = frameCount + size = video.length() + frameRate = recorderConfig.frameRate + this.height = height + this.width = width + // TODO: support non-fullscreen windows later + left = 0 + top = 0 + } + + val urls = LinkedList() + hub?.configureScope { scope -> + scope.breadcrumbs.forEach { breadcrumb -> + if (breadcrumb.timestamp.time >= segmentTimestamp.time && + breadcrumb.timestamp.time < endTimestamp.time + ) { + val rrwebEvent = options + .replayController + .breadcrumbConverter + .convert(breadcrumb) + + if (rrwebEvent != null) { + recordingPayload += rrwebEvent + + // fill in the urls array from navigation breadcrumbs + if ((rrwebEvent as? RRWebBreadcrumbEvent)?.category == "navigation") { + urls.add(rrwebEvent.data!!["to"] as String) + } + } + } + } + } + + if (screenAtStart.get() != null && urls.firstOrNull() != screenAtStart.get()) { + urls.addFirst(screenAtStart.get()) + } + + rotateCurrentEvents(endTimestamp.time) { event -> + if (event.timestamp >= segmentTimestamp.time) { + recordingPayload += event + } + } + + val recording = ReplayRecording().apply { + this.segmentId = segmentId + payload = recordingPayload.sortedBy { it.timestamp } + } + + replay.urls = urls + return ReplaySegment.Created( + videoDuration = duration, + replay = replay, + recording = recording + ) + } + + override fun onConfigurationChanged(recorderConfig: ScreenshotRecorderConfig) { + this.recorderConfig = recorderConfig + } + + override fun onTouchEvent(event: MotionEvent) { + val rrwebEvents = event.toRRWebIncrementalSnapshotEvent() + if (rrwebEvents != null) { + synchronized(currentEventsLock) { + currentEvents += rrwebEvents + } + } + } + + override fun close() { + replayExecutor.gracefullyShutdown(options) + } + + protected fun rotateCurrentEvents( + until: Long, + callback: ((RRWebEvent) -> Unit)? = null + ) { + synchronized(currentEventsLock) { + var event = currentEvents.peek() + while (event != null && event.timestamp < until) { + callback?.invoke(event) + currentEvents.remove() + event = currentEvents.peek() + } + } + } + + private class ReplayExecutorServiceThreadFactory : ThreadFactory { + private var cnt = 0 + override fun newThread(r: Runnable): Thread { + val ret = Thread(r, "SentryReplayIntegration-" + cnt++) + ret.setDaemon(true) + return ret + } + } + + protected sealed class ReplaySegment { + object Failed : ReplaySegment() + data class Created( + val videoDuration: Long, + val replay: SentryReplayEvent, + val recording: ReplayRecording + ) : ReplaySegment() { + fun capture(hub: IHub?, hint: Hint = Hint()) { + hub?.captureReplay(replay, hint.apply { replayRecording = recording }) + } + + fun setSegmentId(segmentId: Int) { + replay.segmentId = segmentId + recording.payload?.forEach { + when (it) { + is RRWebVideoEvent -> it.segmentId = segmentId + } + } + } + } + } + + private fun MotionEvent.toRRWebIncrementalSnapshotEvent(): List? { + val event = this + return when (event.actionMasked) { + MotionEvent.ACTION_MOVE -> { + // we only throttle move events as those can be overwhelming + val now = dateProvider.currentTimeMillis + if (lastCapturedMoveEvent != 0L && lastCapturedMoveEvent + TOUCH_MOVE_DEBOUNCE_THRESHOLD > now) { + return null + } + lastCapturedMoveEvent = now + + currentPositions.keys.forEach { pId -> + val pIndex = event.findPointerIndex(pId) + + if (pIndex == -1) { + // no data for this pointer + return@forEach + } + + // idk why but rrweb does it like dis + if (touchMoveBaseline == 0L) { + touchMoveBaseline = now + } + + currentPositions[pId]!! += Position().apply { + x = event.getX(pIndex) * recorderConfig.scaleFactorX + y = event.getY(pIndex) * recorderConfig.scaleFactorY + id = 0 // html node id, but we don't have it, so hardcode to 0 to align with FE + timeOffset = now - touchMoveBaseline + } + } + + val totalOffset = now - touchMoveBaseline + return if (totalOffset > CAPTURE_MOVE_EVENT_THRESHOLD) { + val moveEvents = mutableListOf() + for ((pointerId, positions) in currentPositions) { + if (positions.isNotEmpty()) { + moveEvents += RRWebInteractionMoveEvent().apply { + this.timestamp = now + this.positions = positions.map { pos -> + pos.timeOffset -= totalOffset + pos + } + this.pointerId = pointerId + } + currentPositions[pointerId]!!.clear() + } + } + touchMoveBaseline = 0L + moveEvents + } else { + null + } + } + + MotionEvent.ACTION_DOWN, + MotionEvent.ACTION_POINTER_DOWN -> { + val pId = event.getPointerId(event.actionIndex) + val pIndex = event.findPointerIndex(pId) + + if (pIndex == -1) { + // no data for this pointer + return null + } + + // new finger down - add a new pointer for tracking movement + currentPositions[pId] = ArrayList() + listOf( + RRWebInteractionEvent().apply { + timestamp = dateProvider.currentTimeMillis + x = event.getX(pIndex) * recorderConfig.scaleFactorX + y = event.getY(pIndex) * recorderConfig.scaleFactorY + id = 0 // html node id, but we don't have it, so hardcode to 0 to align with FE + pointerId = pId + interactionType = InteractionType.TouchStart + } + ) + } + MotionEvent.ACTION_UP, + MotionEvent.ACTION_POINTER_UP -> { + val pId = event.getPointerId(event.actionIndex) + val pIndex = event.findPointerIndex(pId) + + if (pIndex == -1) { + // no data for this pointer + return null + } + + // finger lift up - remove the pointer from tracking + currentPositions.remove(pId) + listOf( + RRWebInteractionEvent().apply { + timestamp = dateProvider.currentTimeMillis + x = event.getX(pIndex) * recorderConfig.scaleFactorX + y = event.getY(pIndex) * recorderConfig.scaleFactorY + id = 0 // html node id, but we don't have it, so hardcode to 0 to align with FE + pointerId = pId + interactionType = InteractionType.TouchEnd + } + ) + } + MotionEvent.ACTION_CANCEL -> { + // gesture cancelled - remove all pointers from tracking + currentPositions.clear() + listOf( + RRWebInteractionEvent().apply { + timestamp = dateProvider.currentTimeMillis + x = event.x * recorderConfig.scaleFactorX + y = event.y * recorderConfig.scaleFactorY + id = 0 // html node id, but we don't have it, so hardcode to 0 to align with FE + pointerId = 0 // the pointerId is not used for TouchCancel, so just set it to 0 + interactionType = InteractionType.TouchCancel + } + ) + } + + else -> null + } + } +} diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BufferCaptureStrategy.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BufferCaptureStrategy.kt new file mode 100644 index 0000000000..96d54735d5 --- /dev/null +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BufferCaptureStrategy.kt @@ -0,0 +1,230 @@ +package io.sentry.android.replay.capture + +import android.graphics.Bitmap +import android.view.MotionEvent +import io.sentry.DateUtils +import io.sentry.Hint +import io.sentry.IHub +import io.sentry.SentryLevel.ERROR +import io.sentry.SentryLevel.INFO +import io.sentry.SentryOptions +import io.sentry.SentryReplayEvent.ReplayType.BUFFER +import io.sentry.android.replay.ReplayCache +import io.sentry.android.replay.ScreenshotRecorderConfig +import io.sentry.android.replay.util.sample +import io.sentry.android.replay.util.submitSafely +import io.sentry.protocol.SentryId +import io.sentry.transport.ICurrentDateProvider +import io.sentry.util.FileUtils +import java.io.File +import java.security.SecureRandom + +internal class BufferCaptureStrategy( + private val options: SentryOptions, + private val hub: IHub?, + private val dateProvider: ICurrentDateProvider, + recorderConfig: ScreenshotRecorderConfig, + private val random: SecureRandom, + replayCacheProvider: ((replayId: SentryId, recorderConfig: ScreenshotRecorderConfig) -> ReplayCache)? = null +) : BaseCaptureStrategy(options, hub, dateProvider, recorderConfig, replayCacheProvider = replayCacheProvider) { + + private val bufferedSegments = mutableListOf() + private val bufferedScreensLock = Any() + private val bufferedScreens = mutableListOf>() + + internal companion object { + private const val TAG = "BufferCaptureStrategy" + } + + override fun start(segmentId: Int, replayId: SentryId, cleanupOldReplays: Boolean) { + super.start(segmentId, replayId, cleanupOldReplays) + + hub?.configureScope { + val screen = it.screen + if (screen != null) { + synchronized(bufferedScreensLock) { + bufferedScreens.add(screen to dateProvider.currentTimeMillis) + } + } + } + } + + override fun onScreenChanged(screen: String?) { + synchronized(bufferedScreensLock) { + val lastKnownScreen = bufferedScreens.lastOrNull()?.first + if (screen != null && lastKnownScreen != screen) { + bufferedScreens.add(screen to dateProvider.currentTimeMillis) + } + } + } + + override fun stop() { + val replayCacheDir = cache?.replayCacheDir + replayExecutor.submitSafely(options, "$TAG.stop") { + FileUtils.deleteRecursively(replayCacheDir) + } + super.stop() + } + + override fun sendReplayForEvent( + isCrashed: Boolean, + eventId: String?, + hint: Hint?, + onSegmentSent: () -> Unit + ) { + val sampled = random.sample(options.experimental.sessionReplay.errorSampleRate) + + if (!sampled) { + options.logger.log(INFO, "Replay wasn't sampled by errorSampleRate, not capturing for event %s", eventId) + return + } + + // write replayId to scope right away, so it gets picked up by the event that caused buffer + // to flush + hub?.configureScope { + it.replayId = currentReplayId.get() + } + + val errorReplayDuration = options.experimental.sessionReplay.errorReplayDuration + val now = dateProvider.currentTimeMillis + val currentSegmentTimestamp = if (cache?.frames?.isNotEmpty() == true) { + // in buffer mode we have to set the timestamp of the first frame as the actual start + DateUtils.getDateTime(cache!!.frames.first().timestamp) + } else { + DateUtils.getDateTime(now - errorReplayDuration) + } + val segmentId = currentSegment.get() + val replayId = currentReplayId.get() + val height = recorderConfig.recordingHeight + val width = recorderConfig.recordingWidth + + findAndSetStartScreen(currentSegmentTimestamp.time) + + replayExecutor.submitSafely(options, "$TAG.send_replay_for_event") { + var bufferedSegment = bufferedSegments.removeFirstOrNull() + while (bufferedSegment != null) { + // capture without hint, so the buffered segments don't trigger flush notification + bufferedSegment.capture(hub) + bufferedSegment = bufferedSegments.removeFirstOrNull() + Thread.sleep(100L) + } + val segment = + createSegment( + now - currentSegmentTimestamp.time, + currentSegmentTimestamp, + replayId, + segmentId, + height, + width, + BUFFER + ) + if (segment is ReplaySegment.Created) { + segment.capture(hub, hint ?: Hint()) + + // we only want to increment segment_id in the case of success, but currentSegment + // might be irrelevant since we changed strategies, so in the callback we increment + // it on the new strategy already + onSegmentSent() + } + } + } + + override fun onScreenshotRecorded(bitmap: Bitmap?, store: ReplayCache.(frameTimestamp: Long) -> Unit) { + // have to do it before submitting, otherwise if the queue is busy, the timestamp won't be + // reflecting the exact time of when it was captured + val frameTimestamp = dateProvider.currentTimeMillis + replayExecutor.submitSafely(options, "$TAG.add_frame") { + cache?.store(frameTimestamp) + + val now = dateProvider.currentTimeMillis + val bufferLimit = now - options.experimental.sessionReplay.errorReplayDuration + cache?.rotate(bufferLimit) + + var removed = false + bufferedSegments.removeAll { + // it can be that the buffered segment is half-way older than the buffer limit, but + // we only drop it if its end timestamp is older + if (it.replay.timestamp.time < bufferLimit) { + currentSegment.decrementAndGet() + deleteFile(it.replay.videoFile) + removed = true + return@removeAll true + } + return@removeAll false + } + if (removed) { + // shift segmentIds after rotating buffered segments + bufferedSegments.forEachIndexed { index, segment -> + segment.setSegmentId(index) + } + } + } + } + + private fun deleteFile(file: File?) { + if (file == null) { + return + } + try { + if (!file.delete()) { + options.logger.log(ERROR, "Failed to delete replay segment: %s", file.absolutePath) + } + } catch (e: Throwable) { + options.logger.log(ERROR, e, "Failed to delete replay segment: %s", file.absolutePath) + } + } + + override fun onConfigurationChanged(recorderConfig: ScreenshotRecorderConfig) { + val errorReplayDuration = options.experimental.sessionReplay.errorReplayDuration + val now = dateProvider.currentTimeMillis + val currentSegmentTimestamp = if (cache?.frames?.isNotEmpty() == true) { + // in buffer mode we have to set the timestamp of the first frame as the actual start + DateUtils.getDateTime(cache!!.frames.first().timestamp) + } else { + DateUtils.getDateTime(now - errorReplayDuration) + } + val segmentId = currentSegment.get() + val duration = now - currentSegmentTimestamp.time + val replayId = currentReplayId.get() + val height = this.recorderConfig.recordingHeight + val width = this.recorderConfig.recordingWidth + replayExecutor.submitSafely(options, "$TAG.onConfigurationChanged") { + val segment = + createSegment(duration, currentSegmentTimestamp, replayId, segmentId, height, width, BUFFER) + if (segment is ReplaySegment.Created) { + bufferedSegments += segment + + currentSegment.getAndIncrement() + } + } + super.onConfigurationChanged(recorderConfig) + } + + override fun convert(): CaptureStrategy { + // we hand over replayExecutor to the new strategy to preserve order of execution + val captureStrategy = SessionCaptureStrategy(options, hub, dateProvider, recorderConfig, replayExecutor) + captureStrategy.start(segmentId = currentSegment.get(), replayId = currentReplayId.get(), cleanupOldReplays = false) + return captureStrategy + } + + override fun onTouchEvent(event: MotionEvent) { + super.onTouchEvent(event) + val bufferLimit = dateProvider.currentTimeMillis - options.experimental.sessionReplay.errorReplayDuration + rotateCurrentEvents(bufferLimit) + } + + private fun findAndSetStartScreen(segmentStart: Long) { + synchronized(bufferedScreensLock) { + val startScreen = bufferedScreens.lastOrNull { (_, timestamp) -> + timestamp <= segmentStart + }?.first + // if no screen is found before the segment start, this likely means the buffer is from the + // app start, and the start screen will be taken from the navigation crumbs + if (startScreen != null) { + screenAtStart.set(startScreen) + } + // can clear as we switch to session mode and don't care anymore about buffering + bufferedSegments.clear() + } + } +} diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/CaptureStrategy.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/CaptureStrategy.kt new file mode 100644 index 0000000000..3233556615 --- /dev/null +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/CaptureStrategy.kt @@ -0,0 +1,39 @@ +package io.sentry.android.replay.capture + +import android.graphics.Bitmap +import android.view.MotionEvent +import io.sentry.Hint +import io.sentry.android.replay.ReplayCache +import io.sentry.android.replay.ScreenshotRecorderConfig +import io.sentry.protocol.SentryId +import java.io.File +import java.util.concurrent.atomic.AtomicInteger +import java.util.concurrent.atomic.AtomicReference + +internal interface CaptureStrategy { + val currentSegment: AtomicInteger + val currentReplayId: AtomicReference + val replayCacheDir: File? + + fun start(segmentId: Int = 0, replayId: SentryId = SentryId(), cleanupOldReplays: Boolean = true) + + fun stop() + + fun pause() + + fun resume() + + fun sendReplayForEvent(isCrashed: Boolean, eventId: String?, hint: Hint?, onSegmentSent: () -> Unit) + + fun onScreenshotRecorded(bitmap: Bitmap? = null, store: ReplayCache.(frameTimestamp: Long) -> Unit) + + fun onConfigurationChanged(recorderConfig: ScreenshotRecorderConfig) + + fun onTouchEvent(event: MotionEvent) + + fun onScreenChanged(screen: String?) = Unit + + fun convert(): CaptureStrategy + + fun close() +} diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/SessionCaptureStrategy.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/SessionCaptureStrategy.kt new file mode 100644 index 0000000000..02687201b8 --- /dev/null +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/SessionCaptureStrategy.kt @@ -0,0 +1,152 @@ +package io.sentry.android.replay.capture + +import android.graphics.Bitmap +import io.sentry.DateUtils +import io.sentry.Hint +import io.sentry.IConnectionStatusProvider.ConnectionStatus.DISCONNECTED +import io.sentry.IHub +import io.sentry.SentryLevel.DEBUG +import io.sentry.SentryLevel.INFO +import io.sentry.SentryOptions +import io.sentry.android.replay.ReplayCache +import io.sentry.android.replay.ScreenshotRecorderConfig +import io.sentry.android.replay.util.submitSafely +import io.sentry.protocol.SentryId +import io.sentry.transport.ICurrentDateProvider +import io.sentry.util.FileUtils +import java.util.concurrent.ScheduledExecutorService + +internal class SessionCaptureStrategy( + private val options: SentryOptions, + private val hub: IHub?, + private val dateProvider: ICurrentDateProvider, + recorderConfig: ScreenshotRecorderConfig, + executor: ScheduledExecutorService? = null, + replayCacheProvider: ((replayId: SentryId, recorderConfig: ScreenshotRecorderConfig) -> ReplayCache)? = null +) : BaseCaptureStrategy(options, hub, dateProvider, recorderConfig, executor, replayCacheProvider) { + + internal companion object { + private const val TAG = "SessionCaptureStrategy" + } + + override fun start(segmentId: Int, replayId: SentryId, cleanupOldReplays: Boolean) { + super.start(segmentId, replayId, cleanupOldReplays) + // only set replayId on the scope if it's a full session, otherwise all events will be + // tagged with the replay that might never be sent when we're recording in buffer mode + hub?.configureScope { + it.replayId = currentReplayId.get() + screenAtStart.set(it.screen) + } + } + + override fun pause() { + createCurrentSegment("pause") { segment -> + if (segment is ReplaySegment.Created) { + segment.capture(hub) + + currentSegment.getAndIncrement() + } + } + super.pause() + } + + override fun stop() { + val replayCacheDir = cache?.replayCacheDir + createCurrentSegment("stop") { segment -> + if (segment is ReplaySegment.Created) { + segment.capture(hub) + } + FileUtils.deleteRecursively(replayCacheDir) + } + hub?.configureScope { it.replayId = SentryId.EMPTY_ID } + super.stop() + } + + override fun sendReplayForEvent(isCrashed: Boolean, eventId: String?, hint: Hint?, onSegmentSent: () -> Unit) { + if (!isCrashed) { + options.logger.log(DEBUG, "Replay is already running in 'session' mode, not capturing for event %s", eventId) + } else { + options.logger.log(DEBUG, "Replay is already running in 'session' mode, capturing last segment for crashed event %s", eventId) + createCurrentSegment("send_replay_for_event") { segment -> + if (segment is ReplaySegment.Created) { + segment.capture(hub, hint ?: Hint()) + } + } + } + } + + override fun onScreenshotRecorded(bitmap: Bitmap?, store: ReplayCache.(frameTimestamp: Long) -> Unit) { + if (options.connectionStatusProvider.connectionStatus == DISCONNECTED) { + options.logger.log(DEBUG, "Skipping screenshot recording, no internet connection") + bitmap?.recycle() + return + } + // have to do it before submitting, otherwise if the queue is busy, the timestamp won't be + // reflecting the exact time of when it was captured + val frameTimestamp = dateProvider.currentTimeMillis + val height = recorderConfig.recordingHeight + val width = recorderConfig.recordingWidth + replayExecutor.submitSafely(options, "$TAG.add_frame") { + cache?.store(frameTimestamp) + + val now = dateProvider.currentTimeMillis + if ((now - segmentTimestamp.get().time >= options.experimental.sessionReplay.sessionSegmentDuration)) { + val currentSegmentTimestamp = segmentTimestamp.get() + val segmentId = currentSegment.get() + val replayId = currentReplayId.get() + + val segment = + createSegment( + options.experimental.sessionReplay.sessionSegmentDuration, + currentSegmentTimestamp, + replayId, + segmentId, + height, + width + ) + if (segment is ReplaySegment.Created) { + segment.capture(hub) + currentSegment.getAndIncrement() + // set next segment timestamp as close to the previous one as possible to avoid gaps + segmentTimestamp.set(DateUtils.getDateTime(currentSegmentTimestamp.time + segment.videoDuration)) + } + } else if ((now - replayStartTimestamp.get() >= options.experimental.sessionReplay.sessionDuration)) { + stop() + options.logger.log(INFO, "Session replay deadline exceeded (1h), stopping recording") + } + } + } + + override fun onConfigurationChanged(recorderConfig: ScreenshotRecorderConfig) { + val currentSegmentTimestamp = segmentTimestamp.get() + createCurrentSegment("onConfigurationChanged") { segment -> + if (segment is ReplaySegment.Created) { + segment.capture(hub) + + currentSegment.getAndIncrement() + // set next segment timestamp as close to the previous one as possible to avoid gaps + segmentTimestamp.set(DateUtils.getDateTime(currentSegmentTimestamp.time + segment.videoDuration)) + } + } + + // refresh recorder config after submitting the last segment with current config + super.onConfigurationChanged(recorderConfig) + } + + override fun convert(): CaptureStrategy = this + + private fun createCurrentSegment(taskName: String, onSegmentCreated: (ReplaySegment) -> Unit) { + val now = dateProvider.currentTimeMillis + val currentSegmentTimestamp = segmentTimestamp.get() + val segmentId = currentSegment.get() + val duration = now - (currentSegmentTimestamp?.time ?: 0) + val replayId = currentReplayId.get() + val height = recorderConfig.recordingHeight + val width = recorderConfig.recordingWidth + replayExecutor.submitSafely(options, "$TAG.$taskName") { + val segment = + createSegment(duration, currentSegmentTimestamp, replayId, segmentId, height, width) + onSegmentCreated(segment) + } + } +} diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Executors.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Executors.kt new file mode 100644 index 0000000000..093416f9bb --- /dev/null +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Executors.kt @@ -0,0 +1,67 @@ +package io.sentry.android.replay.util + +import io.sentry.SentryLevel.ERROR +import io.sentry.SentryOptions +import java.util.concurrent.ExecutorService +import java.util.concurrent.Future +import java.util.concurrent.ScheduledExecutorService +import java.util.concurrent.ScheduledFuture +import java.util.concurrent.TimeUnit +import java.util.concurrent.TimeUnit.MILLISECONDS + +internal fun ExecutorService.gracefullyShutdown(options: SentryOptions) { + synchronized(this) { + if (!isShutdown) { + shutdown() + } + try { + if (!awaitTermination(options.shutdownTimeoutMillis, MILLISECONDS)) { + shutdownNow() + } + } catch (e: InterruptedException) { + shutdownNow() + Thread.currentThread().interrupt() + } + } +} + +internal fun ExecutorService.submitSafely( + options: SentryOptions, + taskName: String, + task: Runnable +): Future<*>? { + return try { + submit { + try { + task.run() + } catch (e: Throwable) { + options.logger.log(ERROR, "Failed to execute task $taskName", e) + } + } + } catch (e: Throwable) { + options.logger.log(ERROR, "Failed to submit task $taskName to executor", e) + null + } +} + +internal fun ScheduledExecutorService.scheduleAtFixedRateSafely( + options: SentryOptions, + taskName: String, + initialDelay: Long, + period: Long, + unit: TimeUnit, + task: Runnable +): ScheduledFuture<*>? { + return try { + scheduleAtFixedRate({ + try { + task.run() + } catch (e: Throwable) { + options.logger.log(ERROR, "Failed to execute task $taskName", e) + } + }, initialDelay, period, unit) + } catch (e: Throwable) { + options.logger.log(ERROR, "Failed to submit task $taskName to executor", e) + null + } +} diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/util/FixedWindowCallback.java b/sentry-android-replay/src/main/java/io/sentry/android/replay/util/FixedWindowCallback.java new file mode 100644 index 0000000000..7245eefabe --- /dev/null +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/util/FixedWindowCallback.java @@ -0,0 +1,254 @@ +/** + * Adapted from https://github.com/square/curtains/tree/v1.2.5 + * + *

Copyright 2021 Square Inc. + * + *

Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file + * except in compliance with the License. You may obtain a copy of the License at + * + *

http://www.apache.org/licenses/LICENSE-2.0 + * + *

Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.sentry.android.replay.util; + +import android.annotation.SuppressLint; +import android.view.ActionMode; +import android.view.KeyEvent; +import android.view.KeyboardShortcutGroup; +import android.view.Menu; +import android.view.MenuItem; +import android.view.MotionEvent; +import android.view.SearchEvent; +import android.view.View; +import android.view.Window; +import android.view.WindowManager; +import android.view.accessibility.AccessibilityEvent; +import java.util.List; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * Implementation of Window.Callback that updates the signature of {@link #onMenuOpened(int, Menu)} + * to change the menu param from non null to nullable to avoid runtime null check crashes. Issue: + * https://issuetracker.google.com/issues/188568911 + */ +public class FixedWindowCallback implements Window.Callback { + + public final @Nullable Window.Callback delegate; + + public FixedWindowCallback(@Nullable Window.Callback delegate) { + this.delegate = delegate; + } + + @Override + public boolean dispatchKeyEvent(KeyEvent event) { + if (delegate == null) { + return false; + } + return delegate.dispatchKeyEvent(event); + } + + @Override + public boolean dispatchKeyShortcutEvent(KeyEvent event) { + if (delegate == null) { + return false; + } + return delegate.dispatchKeyShortcutEvent(event); + } + + @Override + public boolean dispatchTouchEvent(MotionEvent event) { + if (delegate == null) { + return false; + } + return delegate.dispatchTouchEvent(event); + } + + @Override + public boolean dispatchTrackballEvent(MotionEvent event) { + if (delegate == null) { + return false; + } + return delegate.dispatchTrackballEvent(event); + } + + @Override + public boolean dispatchGenericMotionEvent(MotionEvent event) { + if (delegate == null) { + return false; + } + return delegate.dispatchGenericMotionEvent(event); + } + + @Override + public boolean dispatchPopulateAccessibilityEvent(AccessibilityEvent event) { + if (delegate == null) { + return false; + } + return delegate.dispatchPopulateAccessibilityEvent(event); + } + + @Nullable + @Override + public View onCreatePanelView(int featureId) { + if (delegate == null) { + return null; + } + return delegate.onCreatePanelView(featureId); + } + + @Override + public boolean onCreatePanelMenu(int featureId, @NotNull Menu menu) { + if (delegate == null) { + return false; + } + return delegate.onCreatePanelMenu(featureId, menu); + } + + @Override + public boolean onPreparePanel(int featureId, @Nullable View view, @NotNull Menu menu) { + if (delegate == null) { + return false; + } + return delegate.onPreparePanel(featureId, view, menu); + } + + @Override + public boolean onMenuOpened(int featureId, @Nullable Menu menu) { + if (delegate == null) { + return false; + } + return delegate.onMenuOpened(featureId, menu); + } + + @Override + public boolean onMenuItemSelected(int featureId, @NotNull MenuItem item) { + if (delegate == null) { + return false; + } + return delegate.onMenuItemSelected(featureId, item); + } + + @Override + public void onWindowAttributesChanged(WindowManager.LayoutParams attrs) { + if (delegate == null) { + return; + } + delegate.onWindowAttributesChanged(attrs); + } + + @Override + public void onContentChanged() { + if (delegate == null) { + return; + } + delegate.onContentChanged(); + } + + @Override + public void onWindowFocusChanged(boolean hasFocus) { + if (delegate == null) { + return; + } + delegate.onWindowFocusChanged(hasFocus); + } + + @Override + public void onAttachedToWindow() { + if (delegate == null) { + return; + } + delegate.onAttachedToWindow(); + } + + @Override + public void onDetachedFromWindow() { + if (delegate == null) { + return; + } + delegate.onDetachedFromWindow(); + } + + @Override + public void onPanelClosed(int featureId, @NotNull Menu menu) { + if (delegate == null) { + return; + } + delegate.onPanelClosed(featureId, menu); + } + + @Override + public boolean onSearchRequested() { + if (delegate == null) { + return false; + } + return delegate.onSearchRequested(); + } + + @SuppressLint("NewApi") + @Override + public boolean onSearchRequested(SearchEvent searchEvent) { + if (delegate == null) { + return false; + } + return delegate.onSearchRequested(searchEvent); + } + + @Nullable + @Override + public ActionMode onWindowStartingActionMode(ActionMode.Callback callback) { + if (delegate == null) { + return null; + } + return delegate.onWindowStartingActionMode(callback); + } + + @SuppressLint("NewApi") + @Nullable + @Override + public ActionMode onWindowStartingActionMode(ActionMode.Callback callback, int type) { + if (delegate == null) { + return null; + } + return delegate.onWindowStartingActionMode(callback, type); + } + + @Override + public void onActionModeStarted(ActionMode mode) { + if (delegate == null) { + return; + } + delegate.onActionModeStarted(mode); + } + + @Override + public void onActionModeFinished(ActionMode mode) { + if (delegate == null) { + return; + } + delegate.onActionModeFinished(mode); + } + + @SuppressLint("NewApi") + @Override + public void onProvideKeyboardShortcuts( + List data, @Nullable Menu menu, int deviceId) { + if (delegate == null) { + return; + } + delegate.onProvideKeyboardShortcuts(data, menu, deviceId); + } + + @SuppressLint("NewApi") + @Override + public void onPointerCaptureChanged(boolean hasCapture) { + if (delegate == null) { + return; + } + delegate.onPointerCaptureChanged(hasCapture); + } +} diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/util/MainLooperHandler.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/util/MainLooperHandler.kt new file mode 100644 index 0000000000..ab48fd56b4 --- /dev/null +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/util/MainLooperHandler.kt @@ -0,0 +1,12 @@ +package io.sentry.android.replay.util + +import android.os.Handler +import android.os.Looper + +internal class MainLooperHandler(looper: Looper = Looper.getMainLooper()) { + val handler = Handler(looper) + + fun post(runnable: Runnable) { + handler.post(runnable) + } +} diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Sampling.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Sampling.kt new file mode 100644 index 0000000000..8acb6b00a6 --- /dev/null +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Sampling.kt @@ -0,0 +1,10 @@ +package io.sentry.android.replay.util + +import java.security.SecureRandom + +internal fun SecureRandom.sample(rate: Double?): Boolean { + if (rate != null) { + return !(rate < this.nextDouble()) // bad luck + } + return false +} diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Views.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Views.kt new file mode 100644 index 0000000000..58accf0b77 --- /dev/null +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Views.kt @@ -0,0 +1,86 @@ +package io.sentry.android.replay.util + +import android.annotation.SuppressLint +import android.annotation.TargetApi +import android.graphics.Point +import android.graphics.Rect +import android.graphics.drawable.BitmapDrawable +import android.graphics.drawable.ColorDrawable +import android.graphics.drawable.Drawable +import android.graphics.drawable.GradientDrawable +import android.graphics.drawable.InsetDrawable +import android.graphics.drawable.VectorDrawable +import android.os.Build.VERSION +import android.os.Build.VERSION_CODES +import android.text.Layout +import android.view.View + +/** + * Adapted copy of AccessibilityNodeInfo from https://cs.android.com/android/platform/superproject/+/master:frameworks/base/core/java/android/view/View.java;l=10718 + */ +internal fun View.isVisibleToUser(): Pair { + if (isAttachedToWindow) { + // Attached to invisible window means this view is not visible. + if (windowVisibility != View.VISIBLE) { + return false to null + } + // An invisible predecessor or one with alpha zero means + // that this view is not visible to the user. + var current: Any = this + while (current is View) { + val view = current + val transitionAlpha = if (VERSION.SDK_INT >= VERSION_CODES.Q) view.transitionAlpha else 1f + // We have attach info so this view is attached and there is no + // need to check whether we reach to ViewRootImpl on the way up. + if (view.alpha <= 0 || transitionAlpha <= 0 || view.visibility != View.VISIBLE) { + return false to null + } + current = view.parent + } + // Check if the view is entirely covered by its predecessors. + val rect = Rect() + val offset = Point() + val isVisible = getGlobalVisibleRect(rect, offset) + return isVisible to rect + } + return false to null +} + +@SuppressLint("ObsoleteSdkInt") +@TargetApi(21) +internal fun Drawable?.isRedactable(): Boolean { + // TODO: maybe find a way how to check if the drawable is coming from the apk or loaded from network + // TODO: otherwise maybe check for the bitmap size and don't redact those that take a lot of height (e.g. a background of a whatsapp chat) + return when (this) { + is InsetDrawable, is ColorDrawable, is VectorDrawable, is GradientDrawable -> false + is BitmapDrawable -> !bitmap.isRecycled && bitmap.height > 10 && bitmap.width > 10 + else -> true + } +} + +internal fun Layout?.getVisibleRects(globalRect: Rect, paddingLeft: Int, paddingTop: Int): List { + if (this == null) { + return listOf(globalRect) + } + + val rects = mutableListOf() + for (i in 0 until lineCount) { + val lineStart = getPrimaryHorizontal(getLineStart(i)).toInt() + val ellipsisCount = getEllipsisCount(i) + var lineEnd = getPrimaryHorizontal(getLineVisibleEnd(i) - ellipsisCount + if (ellipsisCount > 0) 1 else 0).toInt() + if (lineEnd == 0) { + // looks like the case for when emojis are present in text + lineEnd = getPrimaryHorizontal(getLineVisibleEnd(i) - 1).toInt() + 1 + } + val lineTop = getLineTop(i) + val lineBottom = getLineBottom(i) + val rect = Rect() + rect.left = globalRect.left + paddingLeft + lineStart + rect.right = rect.left + (lineEnd - lineStart) + rect.top = globalRect.top + paddingTop + lineTop + rect.bottom = rect.top + (lineBottom - lineTop) + + rects += rect + } + return rects +} diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleFrameMuxer.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleFrameMuxer.kt new file mode 100644 index 0000000000..17f454967b --- /dev/null +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleFrameMuxer.kt @@ -0,0 +1,47 @@ +/** + * Adapted from https://github.com/fzyzcjy/flutter_screen_recorder/blob/dce41cec25c66baf42c6bac4198e95874ce3eb9d/packages/fast_screen_recorder/android/src/main/kotlin/com/cjy/fast_screen_recorder/SimpleFrameMuxer.kt + * + * Copyright (c) 2021 fzyzcjy + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + * + * In addition to the standard MIT license, this library requires the following: + * The recorder itself only saves data on user's phone locally, thus it does not have any privacy problem. + * However, if you are going to get the records out of the local storage (e.g. upload the records to your server), + * please explicitly ask the user for permission, and promise to only use the records to debug your app. + * This is a part of the license of this library. + */ + +package io.sentry.android.replay.video + +import android.media.MediaCodec +import android.media.MediaFormat +import java.nio.ByteBuffer + +interface SimpleFrameMuxer { + fun isStarted(): Boolean + + fun start(videoFormat: MediaFormat) + + fun muxVideoFrame(encodedData: ByteBuffer, bufferInfo: MediaCodec.BufferInfo) + + fun release() + + fun getVideoTime(): Long +} diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleMp4FrameMuxer.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleMp4FrameMuxer.kt new file mode 100644 index 0000000000..cf30f9e49f --- /dev/null +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleMp4FrameMuxer.kt @@ -0,0 +1,83 @@ +/** + * Adapted from https://github.com/fzyzcjy/flutter_screen_recorder/blob/dce41cec25c66baf42c6bac4198e95874ce3eb9d/packages/fast_screen_recorder/android/src/main/kotlin/com/cjy/fast_screen_recorder/SimpleMp4FrameMuxer.kt + * + * Copyright (c) 2021 fzyzcjy + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + * + * In addition to the standard MIT license, this library requires the following: + * The recorder itself only saves data on user's phone locally, thus it does not have any privacy problem. + * However, if you are going to get the records out of the local storage (e.g. upload the records to your server), + * please explicitly ask the user for permission, and promise to only use the records to debug your app. + * This is a part of the license of this library. + */ +package io.sentry.android.replay.video + +import android.media.MediaCodec +import android.media.MediaFormat +import android.media.MediaMuxer +import java.nio.ByteBuffer +import java.util.concurrent.TimeUnit +import java.util.concurrent.TimeUnit.MICROSECONDS +import java.util.concurrent.TimeUnit.MILLISECONDS + +class SimpleMp4FrameMuxer(path: String, fps: Float) : SimpleFrameMuxer { + private val frameDurationUsec: Long = (TimeUnit.SECONDS.toMicros(1L) / fps).toLong() + + private val muxer: MediaMuxer = MediaMuxer(path, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4) + + private var started = false + private var videoTrackIndex = 0 + private var videoFrames = 0 + private var finalVideoTime: Long = 0 + + override fun isStarted(): Boolean = started + + override fun start(videoFormat: MediaFormat) { + videoTrackIndex = muxer.addTrack(videoFormat) + muxer.start() + started = true + } + + override fun muxVideoFrame(encodedData: ByteBuffer, bufferInfo: MediaCodec.BufferInfo) { + // This code will break if the encoder supports B frames. + // Ideally we would use set the value in the encoder, + // don't know how to do that without using OpenGL + finalVideoTime = frameDurationUsec * videoFrames++ + bufferInfo.presentationTimeUs = finalVideoTime + +// encodedData.position(bufferInfo.offset) +// encodedData.limit(bufferInfo.offset + bufferInfo.size) + + muxer.writeSampleData(videoTrackIndex, encodedData, bufferInfo) + } + + override fun release() { + muxer.stop() + muxer.release() + } + + override fun getVideoTime(): Long { + if (videoFrames == 0) { + return 0 + } + // have to add one sec as we calculate it 0-based above + return MILLISECONDS.convert(finalVideoTime + frameDurationUsec, MICROSECONDS) + } +} diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleVideoEncoder.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleVideoEncoder.kt new file mode 100644 index 0000000000..54a3bc1f89 --- /dev/null +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleVideoEncoder.kt @@ -0,0 +1,245 @@ +/** + * Adapted from https://github.com/fzyzcjy/flutter_screen_recorder/blob/dce41cec25c66baf42c6bac4198e95874ce3eb9d/packages/fast_screen_recorder/android/src/main/kotlin/com/cjy/fast_screen_recorder/SimpleFrameMuxer.kt + * + * Copyright (c) 2021 fzyzcjy + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + * + * In addition to the standard MIT license, this library requires the following: + * The recorder itself only saves data on user's phone locally, thus it does not have any privacy problem. + * However, if you are going to get the records out of the local storage (e.g. upload the records to your server), + * please explicitly ask the user for permission, and promise to only use the records to debug your app. + * This is a part of the license of this library. + */ +package io.sentry.android.replay.video + +import android.annotation.TargetApi +import android.graphics.Bitmap +import android.media.MediaCodec +import android.media.MediaCodecInfo +import android.media.MediaFormat +import android.view.Surface +import io.sentry.SentryLevel.DEBUG +import io.sentry.SentryOptions +import java.io.File +import java.nio.ByteBuffer +import kotlin.LazyThreadSafetyMode.NONE + +private const val TIMEOUT_USEC = 100_000L + +@TargetApi(26) +internal class SimpleVideoEncoder( + val options: SentryOptions, + val muxerConfig: MuxerConfig, + val onClose: (() -> Unit)? = null +) { + + internal val mediaCodec: MediaCodec = run { + val codec = MediaCodec.createEncoderByType(muxerConfig.mimeType) + + codec + } + + private val mediaFormat: MediaFormat by lazy(NONE) { + var bitRate = muxerConfig.bitRate + + try { + val videoCapabilities = mediaCodec.codecInfo + .getCapabilitiesForType(muxerConfig.mimeType) + .videoCapabilities + + if (!videoCapabilities.bitrateRange.contains(bitRate)) { + options.logger.log( + DEBUG, + "Encoder doesn't support the provided bitRate: $bitRate, the value will be clamped to the closest one" + ) + bitRate = videoCapabilities.bitrateRange.clamp(bitRate) + } + } catch (e: Throwable) { + options.logger.log(DEBUG, "Could not retrieve MediaCodec info", e) + } + + // TODO: if this ever becomes a problem, move this to ScreenshotRecorderConfig.from() + // TODO: because the screenshot config has to match the video config + +// var frameRate = muxerConfig.recorderConfig.frameRate +// if (!videoCapabilities.supportedFrameRates.contains(frameRate)) { +// options.logger.log(DEBUG, "Encoder doesn't support the provided frameRate: $frameRate, the value will be clamped to the closest one") +// frameRate = videoCapabilities.supportedFrameRates.clamp(frameRate) +// } + +// var height = muxerConfig.recorderConfig.recordingHeight +// var width = muxerConfig.recorderConfig.recordingWidth +// val aspectRatio = height.toFloat() / width.toFloat() +// while (!videoCapabilities.supportedHeights.contains(height) || !videoCapabilities.supportedWidths.contains(width)) { +// options.logger.log(DEBUG, "Encoder doesn't support the provided height x width: ${height}x${width}, the values will be clamped to the closest ones") +// if (!videoCapabilities.supportedHeights.contains(height)) { +// height = videoCapabilities.supportedHeights.clamp(height) +// width = (height / aspectRatio).roundToInt() +// } else if (!videoCapabilities.supportedWidths.contains(width)) { +// width = videoCapabilities.supportedWidths.clamp(width) +// height = (width * aspectRatio).roundToInt() +// } +// } + + val format = MediaFormat.createVideoFormat( + muxerConfig.mimeType, + muxerConfig.recordingWidth, + muxerConfig.recordingHeight + ) + + // this allows reducing bitrate on newer devices, where they enforce higher quality in VBR + // mode, see https://developer.android.com/reference/android/media/MediaCodec#qualityFloor + // TODO: maybe enable this back later, for now variable bitrate seems to provide much better + // TODO: quality with almost no overhead in terms of video size, let's monitor that +// format.setInteger( +// MediaFormat.KEY_BITRATE_MODE, +// MediaCodecInfo.EncoderCapabilities.BITRATE_MODE_CBR +// ) + // Set some properties. Failing to specify some of these can cause the MediaCodec + // configure() call to throw an unhelpful exception. + format.setInteger( + MediaFormat.KEY_COLOR_FORMAT, + MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface + ) + format.setInteger(MediaFormat.KEY_BIT_RATE, bitRate) + format.setFloat(MediaFormat.KEY_FRAME_RATE, muxerConfig.frameRate.toFloat()) + format.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, 10) + + format + } + + private val bufferInfo: MediaCodec.BufferInfo = MediaCodec.BufferInfo() + private val frameMuxer = SimpleMp4FrameMuxer(muxerConfig.file.absolutePath, muxerConfig.frameRate.toFloat()) + val duration get() = frameMuxer.getVideoTime() + + private var surface: Surface? = null + + fun start() { + mediaCodec.configure(mediaFormat, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE) + surface = mediaCodec.createInputSurface() + mediaCodec.start() + drainCodec(false) + } + + fun encode(image: Bitmap) { + // NOTE do not use `lockCanvas` like what is done in bitmap2video + // This is because https://developer.android.com/reference/android/media/MediaCodec#createInputSurface() + // says that, "Surface.lockCanvas(android.graphics.Rect) may fail or produce unexpected results." + val canvas = surface?.lockHardwareCanvas() + canvas?.drawBitmap(image, 0f, 0f, null) + surface?.unlockCanvasAndPost(canvas) + drainCodec(false) + } + + /** + * Extracts all pending data from the encoder. + * + * + * If endOfStream is not set, this returns when there is no more data to drain. If it + * is set, we send EOS to the encoder, and then iterate until we see EOS on the output. + * Calling this with endOfStream set should be done once, right before stopping the muxer. + * + * Borrows heavily from https://bigflake.com/mediacodec/EncodeAndMuxTest.java.txt + */ + private fun drainCodec(endOfStream: Boolean) { + options.logger.log(DEBUG, "[Encoder]: drainCodec($endOfStream)") + if (endOfStream) { + options.logger.log(DEBUG, "[Encoder]: sending EOS to encoder") + mediaCodec.signalEndOfInputStream() + } + var encoderOutputBuffers: Array? = mediaCodec.outputBuffers + while (true) { + val encoderStatus: Int = mediaCodec.dequeueOutputBuffer(bufferInfo, TIMEOUT_USEC) + if (encoderStatus == MediaCodec.INFO_TRY_AGAIN_LATER) { + // no output available yet + if (!endOfStream) { + break // out of while + } else { + options.logger.log(DEBUG, "[Encoder]: no output available, spinning to await EOS") + } + } else if (encoderStatus == MediaCodec.INFO_OUTPUT_BUFFERS_CHANGED) { + // not expected for an encoder + encoderOutputBuffers = mediaCodec.outputBuffers + } else if (encoderStatus == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) { + // should happen before receiving buffers, and should only happen once + if (frameMuxer.isStarted()) { + throw RuntimeException("format changed twice") + } + val newFormat: MediaFormat = mediaCodec.outputFormat + options.logger.log(DEBUG, "[Encoder]: encoder output format changed: $newFormat") + + // now that we have the Magic Goodies, start the muxer + frameMuxer.start(newFormat) + } else if (encoderStatus < 0) { + options.logger.log(DEBUG, "[Encoder]: unexpected result from encoder.dequeueOutputBuffer: $encoderStatus") + // let's ignore it + } else { + val encodedData = encoderOutputBuffers?.get(encoderStatus) + ?: throw RuntimeException("encoderOutputBuffer $encoderStatus was null") + if (bufferInfo.flags and MediaCodec.BUFFER_FLAG_CODEC_CONFIG != 0) { + // The codec config data was pulled out and fed to the muxer when we got + // the INFO_OUTPUT_FORMAT_CHANGED status. Ignore it. + options.logger.log(DEBUG, "[Encoder]: ignoring BUFFER_FLAG_CODEC_CONFIG") + bufferInfo.size = 0 + } + if (bufferInfo.size != 0) { + if (!frameMuxer.isStarted()) { + throw RuntimeException("muxer hasn't started") + } + frameMuxer.muxVideoFrame(encodedData, bufferInfo) + options.logger.log(DEBUG, "[Encoder]: sent ${bufferInfo.size} bytes to muxer") + } + mediaCodec.releaseOutputBuffer(encoderStatus, false) + if (bufferInfo.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0) { + if (!endOfStream) { + options.logger.log(DEBUG, "[Encoder]: reached end of stream unexpectedly") + } else { + options.logger.log(DEBUG, "[Encoder]: end of stream reached") + } + break // out of while + } + } + } + } + + fun release() { + try { + onClose?.invoke() + drainCodec(true) + mediaCodec.stop() + mediaCodec.release() + surface?.release() + + frameMuxer.release() + } catch (e: Throwable) { + options.logger.log(DEBUG, "Failed to properly release video encoder", e) + } + } +} + +@TargetApi(24) +internal data class MuxerConfig( + val file: File, + var recordingWidth: Int, + var recordingHeight: Int, + val frameRate: Int, + val bitRate: Int, + val mimeType: String = MediaFormat.MIMETYPE_VIDEO_AVC +) diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ViewHierarchyNode.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ViewHierarchyNode.kt new file mode 100644 index 0000000000..1a94b295f7 --- /dev/null +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ViewHierarchyNode.kt @@ -0,0 +1,296 @@ +package io.sentry.android.replay.viewhierarchy + +import android.annotation.TargetApi +import android.graphics.Rect +import android.text.Layout +import android.view.View +import android.widget.ImageView +import android.widget.TextView +import io.sentry.SentryOptions +import io.sentry.android.replay.util.isRedactable +import io.sentry.android.replay.util.isVisibleToUser + +@TargetApi(26) +sealed class ViewHierarchyNode( + val x: Float, + val y: Float, + val width: Int, + val height: Int, + /* Elevation (in px) */ + val elevation: Float, + /* Distance to the parent (index) */ + val distance: Int, + val parent: ViewHierarchyNode? = null, + val shouldRedact: Boolean = false, + /* Whether the node is important for content capture (=non-empty container) */ + var isImportantForContentCapture: Boolean = false, + val isVisible: Boolean = false, + val visibleRect: Rect? = null +) { + var children: List? = null + + class GenericViewHierarchyNode( + x: Float, + y: Float, + width: Int, + height: Int, + elevation: Float, + distance: Int, + parent: ViewHierarchyNode? = null, + shouldRedact: Boolean = false, + isImportantForContentCapture: Boolean = false, + isVisible: Boolean = false, + visibleRect: Rect? = null + ) : ViewHierarchyNode(x, y, width, height, elevation, distance, parent, shouldRedact, isImportantForContentCapture, isVisible, visibleRect) + + class TextViewHierarchyNode( + val layout: Layout? = null, + val dominantColor: Int? = null, + val paddingLeft: Int = 0, + val paddingTop: Int = 0, + x: Float, + y: Float, + width: Int, + height: Int, + elevation: Float, + distance: Int, + parent: ViewHierarchyNode? = null, + shouldRedact: Boolean = false, + isImportantForContentCapture: Boolean = false, + isVisible: Boolean = false, + visibleRect: Rect? = null + ) : ViewHierarchyNode(x, y, width, height, elevation, distance, parent, shouldRedact, isImportantForContentCapture, isVisible, visibleRect) + + class ImageViewHierarchyNode( + x: Float, + y: Float, + width: Int, + height: Int, + elevation: Float, + distance: Int, + parent: ViewHierarchyNode? = null, + shouldRedact: Boolean = false, + isImportantForContentCapture: Boolean = false, + isVisible: Boolean = false, + visibleRect: Rect? = null + ) : ViewHierarchyNode(x, y, width, height, elevation, distance, parent, shouldRedact, isImportantForContentCapture, isVisible, visibleRect) + + /** + * Traverses the view hierarchy starting from this node. The traversal is done in a depth-first + * manner. + * + * @param callback a callback that will be called for each node in the hierarchy. If the callback + * returns false, the traversal will stop for the current node and its children. + */ + fun traverse(callback: (ViewHierarchyNode) -> Boolean) { + val traverseChildren = callback(this) + if (traverseChildren) { + if (this.children != null) { + this.children!!.forEach { + it.traverse(callback) + } + } + } + } + + /** + * Checks if the given node is obscured by other nodes in the view hierarchy. A node is considered + * obscured if it's not visible, or if it's not fully visible because it's behind another node + * with a higher elevation or distance from the common parent. + * + * This method should be called on the root node of the view hierarchy. + * + * @param node the node to check if it's obscured by other nodes in the view hierarchy + */ + fun isObscured(node: ViewHierarchyNode): Boolean { + require(this.parent == null) { + "This method should be called on the root node of the view hierarchy." + } + node.visibleRect ?: return false + + var isObscured = false + + traverse { otherNode -> + // if the other node doesn't have a visible rect or the current node is already obscured + // we can skip the traversal + if (otherNode.visibleRect == null || isObscured) { + return@traverse false + } + + // if the other node is not visible, or not important for content capture (empty container) + // or doesn't contain the node's visible rect, we can skip it + if (!otherNode.isVisible || + !otherNode.isImportantForContentCapture || + !otherNode.visibleRect.contains(node.visibleRect) + ) { + return@traverse false + } + + // if otherNode's elevation is higher, we know it's obscuring the node + if (otherNode.elevation > node.elevation) { + isObscured = true + return@traverse false + } else if (otherNode.elevation == node.elevation) { + // if otherNode's elevation is the same, we need to find the lowest common ancestor + // and compare the distances from the common parent + val (lca, nodeAncestor, otherNodeAncestor) = findLCA(node, otherNode) + // if otherNode is the LCA, this means it's a parent of the node, so it's not obscuring it + // otherwise compare the distances from the common parent + if (lca != otherNode && otherNodeAncestor != null && nodeAncestor != null) { + isObscured = otherNodeAncestor.distance > nodeAncestor.distance + return@traverse !isObscured + } + } + return@traverse true + } + return isObscured + } + + /** + * Find the lowest common ancestor of two nodes in the view hierarchy. Given the following view + * hierarchy: + * + * CoordinatorLayout + * -FrameLayout + * --TextView + * -BottomNavigationView + * --NavigationItemView + * --NavigationItemView + * + * We want to know if the TextView is obscured by anything. For that we're searching for the + * lowest common ancestor (common parent) of the TextView and the other node. In this case it'd + * be CoordinatorLayout. + * + * After that we also need to know which subtrees contain both the TextView + * and the obscuring node. In this case it'd be FrameLayout and BottomNavigationView. Once we + * have the subtrees, we can compare their distances (indexes) from the common parent. In this + * case BottomNavigationView will have a higher index than FrameLayout, so we can conclude that + * it obscures the TextView. + * + * This method should be called on the root node of the view hierarchy. + */ + private fun findLCA(node: ViewHierarchyNode, otherNode: ViewHierarchyNode): LCAResult { + var nodeSubtree: ViewHierarchyNode? = null + var otherNodeSubtree: ViewHierarchyNode? = null + var lca: ViewHierarchyNode? = null + + // Check if the current node is node or otherNode + if (this == node) { + nodeSubtree = this + } + if (this == otherNode) { + otherNodeSubtree = this + } + + // Search for nodes node and otherNode in the children subtrees + if (children != null) { + for (child in children!!) { + val result = child.findLCA(node, otherNode) + + if (result.lca != null) { + return result // If LCA is found, propagate it up + } + if (result.nodeSubtree != null) { + nodeSubtree = child + } + if (result.otherNodeSubtree != null) { + otherNodeSubtree = child + } + } + } + + // If both node and otherNode are found, and LCA is not already determined, the current node + // is the LCA + if (nodeSubtree != null && otherNodeSubtree != null) { + lca = this + } + + return LCAResult(lca, nodeSubtree, otherNodeSubtree) + } + + private data class LCAResult( + val lca: ViewHierarchyNode?, + var nodeSubtree: ViewHierarchyNode?, + var otherNodeSubtree: ViewHierarchyNode? + ) + + companion object { + + private fun Int.toOpaque() = this or 0xFF000000.toInt() + + /** + * Basically replicating this: https://developer.android.com/reference/android/view/View#isImportantForContentCapture() + * but for lower APIs and with less overhead. If we take a look at how it's set in Android: + * https://cs.android.com/search?q=IMPORTANT_FOR_CONTENT_CAPTURE_YES&ss=android%2Fplatform%2Fsuperproject%2Fmain + * we see that they just set it as important for views containing TextViews, ImageViews and WebViews. + */ + private fun ViewHierarchyNode?.setImportantForCaptureToAncestors(isImportant: Boolean) { + var parent = this?.parent + while (parent != null) { + parent.isImportantForContentCapture = isImportant + parent = parent.parent + } + } + + private fun shouldRedact(view: View, options: SentryOptions): Boolean { + return options.experimental.sessionReplay.redactClasses.contains(view.javaClass.canonicalName) + } + + fun fromView(view: View, parent: ViewHierarchyNode?, distance: Int, options: SentryOptions): ViewHierarchyNode { + val (isVisible, visibleRect) = view.isVisibleToUser() + when { + view is TextView && options.experimental.sessionReplay.redactAllText -> { + parent.setImportantForCaptureToAncestors(true) + return TextViewHierarchyNode( + layout = view.layout, + dominantColor = view.currentTextColor.toOpaque(), + paddingLeft = view.totalPaddingLeft, + paddingTop = view.totalPaddingTop, + x = view.x, + y = view.y, + width = view.width, + height = view.height, + elevation = (parent?.elevation ?: 0f) + view.elevation, + shouldRedact = isVisible, + distance = distance, + parent = parent, + isImportantForContentCapture = true, + isVisible = isVisible, + visibleRect = visibleRect + ) + } + + view is ImageView && options.experimental.sessionReplay.redactAllImages -> { + parent.setImportantForCaptureToAncestors(true) + return ImageViewHierarchyNode( + x = view.x, + y = view.y, + width = view.width, + height = view.height, + elevation = (parent?.elevation ?: 0f) + view.elevation, + distance = distance, + parent = parent, + isVisible = isVisible, + isImportantForContentCapture = true, + shouldRedact = isVisible && view.drawable?.isRedactable() == true, + visibleRect = visibleRect + ) + } + } + + return GenericViewHierarchyNode( + view.x, + view.y, + view.width, + view.height, + (parent?.elevation ?: 0f) + view.elevation, + distance = distance, + parent = parent, + shouldRedact = isVisible && shouldRedact(view, options), + isImportantForContentCapture = false, /* will be set by children */ + isVisible = isVisible, + visibleRect = visibleRect + ) + } + } +} diff --git a/sentry-android-replay/src/main/res/public.xml b/sentry-android-replay/src/main/res/public.xml new file mode 100644 index 0000000000..379be515be --- /dev/null +++ b/sentry-android-replay/src/main/res/public.xml @@ -0,0 +1,4 @@ + + + + diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/DefaultReplayBreadcrumbConverterTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/DefaultReplayBreadcrumbConverterTest.kt new file mode 100644 index 0000000000..0dfb3d39c8 --- /dev/null +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/DefaultReplayBreadcrumbConverterTest.kt @@ -0,0 +1,288 @@ +package io.sentry.android.replay + +import io.sentry.Breadcrumb +import io.sentry.SentryLevel +import io.sentry.SpanDataConvention +import io.sentry.rrweb.RRWebBreadcrumbEvent +import io.sentry.rrweb.RRWebSpanEvent +import junit.framework.TestCase.assertEquals +import java.util.Date +import kotlin.test.Test +import kotlin.test.assertNull + +class DefaultReplayBreadcrumbConverterTest { + class Fixture { + fun getSut(): DefaultReplayBreadcrumbConverter { + return DefaultReplayBreadcrumbConverter() + } + } + + private val fixture = Fixture() + + @Test + fun `returns null when no category`() { + val converter = fixture.getSut() + + val breadcrumb = Breadcrumb(Date(123L)).apply { + message = "message" + } + + val rrwebEvent = converter.convert(breadcrumb) + + assertNull(rrwebEvent) + } + + @Test + fun `convert RRWebSpanEvent`() { + val converter = fixture.getSut() + + val breadcrumb = Breadcrumb(Date(123L)).apply { + category = "http" + data["url"] = "http://example.com" + data["status_code"] = 404 + data["method"] = "GET" + data[SpanDataConvention.HTTP_START_TIMESTAMP] = 1234L + data[SpanDataConvention.HTTP_END_TIMESTAMP] = 2234L + data["http.response_content_length"] = 300 + data["http.request_content_length"] = 400 + } + + val rrwebEvent = converter.convert(breadcrumb) + + check(rrwebEvent is RRWebSpanEvent) + assertEquals("resource.http", rrwebEvent.op) + assertEquals("http://example.com", rrwebEvent.description) + assertEquals(123L, rrwebEvent.timestamp) + assertEquals(1.234, rrwebEvent.startTimestamp) + assertEquals(2.234, rrwebEvent.endTimestamp) + assertEquals(404, rrwebEvent.data!!["statusCode"]) + assertEquals("GET", rrwebEvent.data!!["method"]) + assertEquals(300, rrwebEvent.data!!["responseBodySize"]) + assertEquals(400, rrwebEvent.data!!["requestBodySize"]) + } + + @Test + fun `returns null if not eligible for RRWebSpanEvent`() { + val converter = fixture.getSut() + + val breadcrumb = Breadcrumb(Date(123L)).apply { + category = "http" + data["status_code"] = 404 + data["method"] = "GET" + data[SpanDataConvention.HTTP_START_TIMESTAMP] = 1234L + data[SpanDataConvention.HTTP_END_TIMESTAMP] = 2234L + data["http.response_content_length"] = 300 + data["http.request_content_length"] = 400 + } + + val rrwebEvent = converter.convert(breadcrumb) + + assertNull(rrwebEvent) + } + + @Test + fun `converts app lifecycle breadcrumbs`() { + val converter = fixture.getSut() + + val breadcrumb = Breadcrumb(Date(123L)).apply { + category = "app.lifecycle" + type = "navigation" + data["state"] = "background" + } + + val rrwebEvent = converter.convert(breadcrumb) + + check(rrwebEvent is RRWebBreadcrumbEvent) + assertEquals("app.background", rrwebEvent.category) + assertEquals(123L, rrwebEvent.timestamp) + assertEquals(0.123, rrwebEvent.breadcrumbTimestamp) + assertEquals("default", rrwebEvent.breadcrumbType) + } + + @Test + fun `converts device orientation breadcrumbs`() { + val converter = fixture.getSut() + + val breadcrumb = Breadcrumb(Date(123L)).apply { + category = "device.orientation" + type = "navigation" + data["position"] = "landscape" + } + + val rrwebEvent = converter.convert(breadcrumb) + + check(rrwebEvent is RRWebBreadcrumbEvent) + assertEquals("device.orientation", rrwebEvent.category) + assertEquals("landscape", rrwebEvent.data!!["position"]) + } + + @Test + fun `returns null if no position for orientation breadcrumbs`() { + val converter = fixture.getSut() + + val breadcrumb = Breadcrumb(Date(123L)).apply { + category = "device.orientation" + type = "navigation" + } + + val rrwebEvent = converter.convert(breadcrumb) + + assertNull(rrwebEvent) + } + + @Test + fun `converts navigation breadcrumbs`() { + val converter = fixture.getSut() + + val breadcrumb = Breadcrumb(Date(123L)).apply { + category = "navigation" + type = "navigation" + data["state"] = "resumed" + data["screen"] = "io.sentry.MainActivity" + } + + val rrwebEvent = converter.convert(breadcrumb) + + check(rrwebEvent is RRWebBreadcrumbEvent) + assertEquals("navigation", rrwebEvent.category) + assertEquals("MainActivity", rrwebEvent.data!!["to"]) + } + + @Test + fun `converts navigation breadcrumbs with destination`() { + val converter = fixture.getSut() + + val breadcrumb = Breadcrumb(Date(123L)).apply { + category = "navigation" + type = "navigation" + data["to"] = "/github" + } + + val rrwebEvent = converter.convert(breadcrumb) + + check(rrwebEvent is RRWebBreadcrumbEvent) + assertEquals("navigation", rrwebEvent.category) + assertEquals("/github", rrwebEvent.data!!["to"]) + } + + @Test + fun `returns null when lifecycle state is not 'resumed'`() { + val converter = fixture.getSut() + + val breadcrumb = Breadcrumb(Date(123L)).apply { + category = "navigation" + type = "navigation" + data["state"] = "started" + data["screen"] = "io.sentry.MainActivity" + } + + val rrwebEvent = converter.convert(breadcrumb) + + assertNull(rrwebEvent) + } + + @Test + fun `converts ui click breadcrumbs`() { + val converter = fixture.getSut() + + val breadcrumb = Breadcrumb(Date(123L)).apply { + category = "ui.click" + type = "user" + data["view.id"] = "button_login" + } + + val rrwebEvent = converter.convert(breadcrumb) + + check(rrwebEvent is RRWebBreadcrumbEvent) + assertEquals("ui.tap", rrwebEvent.category) + assertEquals("button_login", rrwebEvent.message) + } + + @Test + fun `returns null if no view identifier in data`() { + val converter = fixture.getSut() + + val breadcrumb = Breadcrumb(Date(123L)).apply { + category = "ui.click" + type = "user" + } + + val rrwebEvent = converter.convert(breadcrumb) + + assertNull(rrwebEvent) + } + + @Test + fun `converts network connectivity breadcrumbs`() { + val converter = fixture.getSut() + + val breadcrumb = Breadcrumb(Date(123L)).apply { + category = "network.event" + type = "system" + data["network_type"] = "cellular" + } + + val rrwebEvent = converter.convert(breadcrumb) + + check(rrwebEvent is RRWebBreadcrumbEvent) + assertEquals("device.connectivity", rrwebEvent.category) + assertEquals("cellular", rrwebEvent.data!!["state"]) + } + + @Test + fun `returns null if no network connectivity state`() { + val converter = fixture.getSut() + + val breadcrumb = Breadcrumb(Date(123L)).apply { + category = "network.event" + type = "system" + } + + val rrwebEvent = converter.convert(breadcrumb) + + assertNull(rrwebEvent) + } + + @Test + fun `converts battery status breadcrumbs`() { + val converter = fixture.getSut() + + val breadcrumb = Breadcrumb(Date(123L)).apply { + category = "device.event" + type = "system" + data["action"] = "BATTERY_CHANGED" + data["level"] = 85.0f + data["charging"] = true + data["stuff"] = "shiet" + } + + val rrwebEvent = converter.convert(breadcrumb) + + check(rrwebEvent is RRWebBreadcrumbEvent) + assertEquals("device.battery", rrwebEvent.category) + assertEquals(85.0f, rrwebEvent.data!!["level"]) + assertEquals(true, rrwebEvent.data!!["charging"]) + assertNull(rrwebEvent.data!!["stuff"]) + } + + @Test + fun `converts generic breadcrumbs`() { + val converter = fixture.getSut() + + val breadcrumb = Breadcrumb(Date(123L)).apply { + category = "device.event" + type = "system" + message = "message" + level = SentryLevel.ERROR + data["stuff"] = "shiet" + } + + val rrwebEvent = converter.convert(breadcrumb) + + check(rrwebEvent is RRWebBreadcrumbEvent) + assertEquals("device.event", rrwebEvent.category) + assertEquals("message", rrwebEvent.message) + assertEquals(SentryLevel.ERROR, rrwebEvent.level) + assertEquals("shiet", rrwebEvent.data!!["stuff"]) + } +} diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayCacheTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayCacheTest.kt new file mode 100644 index 0000000000..fe0b50c9c8 --- /dev/null +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayCacheTest.kt @@ -0,0 +1,267 @@ +package io.sentry.android.replay + +import android.graphics.Bitmap +import android.graphics.Bitmap.CompressFormat.JPEG +import android.graphics.Bitmap.Config.ARGB_8888 +import android.media.MediaCodec +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.sentry.SentryOptions +import io.sentry.android.replay.video.MuxerConfig +import io.sentry.android.replay.video.SimpleVideoEncoder +import io.sentry.protocol.SentryId +import org.junit.Rule +import org.junit.rules.TemporaryFolder +import org.junit.runner.RunWith +import org.robolectric.annotation.Config +import java.io.File +import java.util.concurrent.TimeUnit.MICROSECONDS +import java.util.concurrent.TimeUnit.MILLISECONDS +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +@RunWith(AndroidJUnit4::class) +@Config(sdk = [26]) +class ReplayCacheTest { + + @get:Rule + val tmpDir = TemporaryFolder() + + internal class Fixture { + val options = SentryOptions() + var encoder: SimpleVideoEncoder? = null + fun getSut( + dir: TemporaryFolder?, + replayId: SentryId = SentryId(), + frameRate: Int, + framesToEncode: Int = 0 + ): ReplayCache { + val recorderConfig = ScreenshotRecorderConfig(100, 200, 1f, 1f, frameRate = frameRate, bitRate = 20_000) + options.run { + cacheDirPath = dir?.newFolder()?.absolutePath + } + return ReplayCache(options, replayId, recorderConfig, encoderProvider = { videoFile, height, width -> + encoder = SimpleVideoEncoder( + options, + MuxerConfig( + file = videoFile, + recordingHeight = height, + recordingWidth = width, + frameRate = recorderConfig.frameRate, + bitRate = recorderConfig.bitRate + ), + onClose = { + encodeFrame(framesToEncode, frameRate, size = 0, flags = MediaCodec.BUFFER_FLAG_END_OF_STREAM) + } + ).also { it.start() } + repeat(framesToEncode) { encodeFrame(it, frameRate) } + + encoder!! + }) + } + + fun encodeFrame(index: Int, frameRate: Int, size: Int = 10, flags: Int = 0) { + val presentationTime = MICROSECONDS.convert(index * (1000L / frameRate), MILLISECONDS) + encoder!!.mediaCodec.dequeueInputBuffer(0) + encoder!!.mediaCodec.queueInputBuffer(index, index * size, size, presentationTime, flags) + } + } + + private val fixture = Fixture() + + @Test + fun `when no cacheDirPath specified, does not store screenshots`() { + val replayId = SentryId() + val replayCache = fixture.getSut( + null, + replayId, + frameRate = 1 + ) + + val bitmap = Bitmap.createBitmap(1, 1, ARGB_8888) + replayCache.addFrame(bitmap, 1) + + assertTrue(replayCache.frames.isEmpty()) + } + + @Test + fun `stores screenshots with timestamp as name`() { + val replayId = SentryId() + val replayCache = fixture.getSut( + tmpDir, + replayId, + frameRate = 1 + ) + + val bitmap = Bitmap.createBitmap(1, 1, ARGB_8888) + replayCache.addFrame(bitmap, 1) + + val expectedScreenshotFile = File(replayCache.replayCacheDir, "1.jpg") + assertTrue(expectedScreenshotFile.exists()) + assertEquals(replayCache.frames.first().timestamp, 1) + assertEquals(replayCache.frames.first().screenshot, expectedScreenshotFile) + } + + @Test + fun `when no frames are provided, returns nothing`() { + val replayCache = fixture.getSut( + tmpDir, + frameRate = 1 + ) + + val video = replayCache.createVideoOf(5000L, 0, 0, 100, 200) + + assertNull(video) + } + + @Test + fun `deletes frames after creating a video`() { + val replayCache = fixture.getSut( + tmpDir, + frameRate = 1, + framesToEncode = 3 + ) + + val bitmap = Bitmap.createBitmap(1, 1, ARGB_8888) + replayCache.addFrame(bitmap, 1) + replayCache.addFrame(bitmap, 1001) + replayCache.addFrame(bitmap, 2001) + + val segment0 = replayCache.createVideoOf(3000L, 0, 0, 100, 200) + assertEquals(3, segment0!!.frameCount) + assertEquals(3000, segment0.duration) + assertTrue { segment0.video.exists() && segment0.video.length() > 0 } + assertEquals(File(replayCache.replayCacheDir, "0.mp4"), segment0.video) + + assertTrue(replayCache.frames.isEmpty()) + assertTrue(replayCache.replayCacheDir!!.listFiles()!!.none { it.extension == "jpg" }) + } + + @Test + fun `repeats last known frame for the segment duration`() { + val replayCache = fixture.getSut( + tmpDir, + frameRate = 1, + framesToEncode = 5 + ) + + val bitmap = Bitmap.createBitmap(1, 1, ARGB_8888) + replayCache.addFrame(bitmap, 1) + + val segment0 = replayCache.createVideoOf(5000L, 0, 0, 100, 200) + assertEquals(5, segment0!!.frameCount) + assertEquals(5000, segment0.duration) + assertTrue { segment0.video.exists() && segment0.video.length() > 0 } + assertEquals(File(replayCache.replayCacheDir, "0.mp4"), segment0.video) + } + + @Test + fun `repeats last known frame for the segment duration for each timespan`() { + val replayCache = fixture.getSut( + tmpDir, + frameRate = 1, + framesToEncode = 5 + ) + + val bitmap = Bitmap.createBitmap(1, 1, ARGB_8888) + replayCache.addFrame(bitmap, 1) + replayCache.addFrame(bitmap, 3001) + + val segment0 = replayCache.createVideoOf(5000L, 0, 0, 100, 200) + assertEquals(5, segment0!!.frameCount) + assertEquals(5000, segment0.duration) + assertTrue { segment0.video.exists() && segment0.video.length() > 0 } + assertEquals(File(replayCache.replayCacheDir, "0.mp4"), segment0.video) + } + + @Test + fun `repeats last known frame for each segment`() { + val replayCache = fixture.getSut( + tmpDir, + frameRate = 1, + framesToEncode = 5 + ) + + val bitmap = Bitmap.createBitmap(1, 1, ARGB_8888) + replayCache.addFrame(bitmap, 1) + replayCache.addFrame(bitmap, 5001) + + val segment0 = replayCache.createVideoOf(5000L, 0, 0, 100, 200) + assertEquals(5, segment0!!.frameCount) + assertEquals(5000, segment0.duration) + assertEquals(File(replayCache.replayCacheDir, "0.mp4"), segment0.video) + + val segment1 = replayCache.createVideoOf(5000L, 5000L, 1, 100, 200) + assertEquals(5, segment1!!.frameCount) + assertEquals(5000, segment1.duration) + assertTrue { segment0.video.exists() && segment0.video.length() > 0 } + assertEquals(File(replayCache.replayCacheDir, "1.mp4"), segment1.video) + } + + @Test + fun `respects frameRate`() { + val replayCache = fixture.getSut( + tmpDir, + frameRate = 2, + framesToEncode = 6 + ) + + val bitmap = Bitmap.createBitmap(1, 1, ARGB_8888) + replayCache.addFrame(bitmap, 1) + replayCache.addFrame(bitmap, 1001) + replayCache.addFrame(bitmap, 1501) + + val segment0 = replayCache.createVideoOf(3000L, 0, 0, 100, 200) + assertEquals(6, segment0!!.frameCount) + assertEquals(3000, segment0.duration) + assertTrue { segment0.video.exists() && segment0.video.length() > 0 } + assertEquals(File(replayCache.replayCacheDir, "0.mp4"), segment0.video) + } + + @Test + fun `addFrame with File path works`() { + val replayCache = fixture.getSut( + tmpDir, + frameRate = 1, + framesToEncode = 5 + ) + + val flutterCacheDir = + File(fixture.options.cacheDirPath!!, "flutter_replay").also { it.mkdirs() } + val screenshot = File(flutterCacheDir, "1.jpg").also { it.createNewFile() } + val video = File(flutterCacheDir, "flutter_0.mp4") + + screenshot.outputStream().use { + Bitmap.createBitmap(1, 1, ARGB_8888).compress(JPEG, 80, it) + it.flush() + } + replayCache.addFrame(screenshot, frameTimestamp = 1) + + val segment0 = replayCache.createVideoOf(5000L, 0, 0, 100, 200, videoFile = video) + assertEquals(5, segment0!!.frameCount) + assertEquals(5000, segment0.duration) + + assertTrue { segment0.video.exists() && segment0.video.length() > 0 } + assertEquals(File(flutterCacheDir, "flutter_0.mp4"), segment0.video) + } + + @Test + fun `rotates frames`() { + val replayCache = fixture.getSut( + tmpDir, + frameRate = 1, + framesToEncode = 5 + ) + + val bitmap = Bitmap.createBitmap(1, 1, ARGB_8888) + replayCache.addFrame(bitmap, 1) + replayCache.addFrame(bitmap, 1001) + replayCache.addFrame(bitmap, 2001) + + replayCache.rotate(2000) + + assertEquals(1, replayCache.frames.size) + assertTrue(replayCache.replayCacheDir!!.listFiles()!!.none { it.name == "1.jpg" || it.name == "1001.jpg" }) + } +} diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationTest.kt new file mode 100644 index 0000000000..cb236b6318 --- /dev/null +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationTest.kt @@ -0,0 +1,381 @@ +package io.sentry.android.replay + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.sentry.Hint +import io.sentry.IHub +import io.sentry.SentryEvent +import io.sentry.SentryIntegrationPackageStorage +import io.sentry.SentryOptions +import io.sentry.android.replay.ReplayCacheTest.Fixture +import io.sentry.android.replay.capture.CaptureStrategy +import io.sentry.protocol.SentryException +import io.sentry.protocol.SentryId +import io.sentry.transport.CurrentDateProvider +import io.sentry.transport.ICurrentDateProvider +import org.junit.Rule +import org.junit.rules.TemporaryFolder +import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.argThat +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.robolectric.annotation.Config +import java.util.concurrent.atomic.AtomicReference +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +@RunWith(AndroidJUnit4::class) +@Config(sdk = [26]) +class ReplayIntegrationTest { + // write tests for ReplayIntegration with mocked context and other android things + @get:Rule + val tmpDir = TemporaryFolder() + + internal class Fixture { + val options = SentryOptions() + val hub = mock() + + fun getSut( + context: Context, + sessionSampleRate: Double = 1.0, + errorSampleRate: Double = 1.0, + recorderProvider: (() -> Recorder)? = null, + replayCaptureStrategyProvider: ((isFullSession: Boolean) -> CaptureStrategy)? = null, + recorderConfigProvider: ((configChanged: Boolean) -> ScreenshotRecorderConfig)? = null, + dateProvider: ICurrentDateProvider = CurrentDateProvider.getInstance() + ): ReplayIntegration { + options.run { + experimental.sessionReplay.errorSampleRate = errorSampleRate + experimental.sessionReplay.sessionSampleRate = sessionSampleRate + } + return ReplayIntegration( + context, + dateProvider, + recorderProvider, + recorderConfigProvider = recorderConfigProvider, + replayCacheProvider = null, + replayCaptureStrategyProvider = replayCaptureStrategyProvider + ) + } + } + + private val fixture = Fixture() + private lateinit var context: Context + + @BeforeTest + fun `set up`() { + context = ApplicationProvider.getApplicationContext() + SentryIntegrationPackageStorage.getInstance().clearStorage() + } + + @Test + @Config(sdk = [24]) + fun `when API is below 26, does not register`() { + val replay = fixture.getSut(context) + + replay.register(fixture.hub, fixture.options) + + assertFalse(replay.isEnabled.get()) + } + + @Test + fun `when no sample rate is set, does not register`() { + val replay = fixture.getSut(context, 0.0, 0.0) + + replay.register(fixture.hub, fixture.options) + + assertFalse(replay.isEnabled.get()) + } + + @Test + fun `registers the integration`() { + var recorderCreated = false + val replay = fixture.getSut(context, recorderProvider = { + recorderCreated = true + mock() + }) + + replay.register(fixture.hub, fixture.options) + + assertTrue(replay.isEnabled.get()) + assertEquals(1, fixture.options.scopeObservers.size) + assertTrue(recorderCreated) + assertTrue(SentryIntegrationPackageStorage.getInstance().integrations.contains("Replay")) + } + + @Test + fun `when disabled start does nothing`() { + val captureStrategy = mock() + val replay = fixture.getSut(context, replayCaptureStrategyProvider = { captureStrategy }) + + replay.start() + + verify(captureStrategy, never()).start() + } + + @Test + fun `start sets isRecording to true`() { + val captureStrategy = mock() + val replay = fixture.getSut(context, replayCaptureStrategyProvider = { captureStrategy }) + + replay.register(fixture.hub, fixture.options) + replay.start() + + assertTrue(replay.isRecording) + } + + @Test + fun `starting two times does nothing`() { + val captureStrategy = mock() + val replay = fixture.getSut(context, replayCaptureStrategyProvider = { captureStrategy }) + + replay.register(fixture.hub, fixture.options) + replay.start() + replay.start() + + verify(captureStrategy, times(1)).start(eq(0), argThat { this != SentryId.EMPTY_ID }, eq(true)) + } + + @Test + fun `does not start replay when session is not sampled`() { + val captureStrategy = mock() + val replay = fixture.getSut(context, errorSampleRate = 0.0, sessionSampleRate = 0.0, replayCaptureStrategyProvider = { captureStrategy }) + + replay.register(fixture.hub, fixture.options) + replay.start() + + verify(captureStrategy, never()).start(eq(0), argThat { this != SentryId.EMPTY_ID }, eq(true)) + } + + @Test + fun `still starts replay when errorsSampleRate is set`() { + val captureStrategy = mock() + val replay = fixture.getSut(context, sessionSampleRate = 0.0, replayCaptureStrategyProvider = { captureStrategy }) + + replay.register(fixture.hub, fixture.options) + replay.start() + + verify(captureStrategy, times(1)).start(eq(0), argThat { this != SentryId.EMPTY_ID }, eq(true)) + } + + @Test + fun `calls recorder start`() { + val recorder = mock() + val replay = fixture.getSut(context, recorderProvider = { recorder }) + + replay.register(fixture.hub, fixture.options) + replay.start() + + verify(recorder).start(any()) + } + + @Test + fun `resume does not resume when not recording`() { + val captureStrategy = mock() + val replay = fixture.getSut(context, replayCaptureStrategyProvider = { captureStrategy }) + + replay.register(fixture.hub, fixture.options) + replay.resume() + + verify(captureStrategy, never()).resume() + } + + @Test + fun `resume resumes capture strategy and recorder`() { + val captureStrategy = mock() + val recorder = mock() + val replay = fixture.getSut(context, recorderProvider = { recorder }, replayCaptureStrategyProvider = { captureStrategy }) + + replay.register(fixture.hub, fixture.options) + replay.start() + replay.resume() + + verify(captureStrategy).resume() + verify(recorder).resume() + } + + @Test + fun `sendReplayForEvent does nothing when not recording`() { + val captureStrategy = mock() + val replay = fixture.getSut(context, replayCaptureStrategyProvider = { captureStrategy }) + + replay.register(fixture.hub, fixture.options) + + val event = SentryEvent().apply { + exceptions = listOf(SentryException()) + } + replay.sendReplayForEvent(event, Hint()) + + verify(captureStrategy, never()).sendReplayForEvent(any(), anyOrNull(), anyOrNull(), any()) + } + + @Test + fun `sendReplayForEvent does nothing for non errored events`() { + val captureStrategy = mock() + val replay = fixture.getSut(context, replayCaptureStrategyProvider = { captureStrategy }) + + replay.register(fixture.hub, fixture.options) + replay.start() + + val event = SentryEvent() + replay.sendReplayForEvent(event, Hint()) + + verify(captureStrategy, never()).sendReplayForEvent(any(), anyOrNull(), anyOrNull(), any()) + } + + @Test + fun `sendReplayForEvent does nothing when currentReplayId is not set`() { + val captureStrategy = mock { + whenever(mock.currentReplayId).thenReturn(AtomicReference(SentryId.EMPTY_ID)) + } + val replay = fixture.getSut(context, replayCaptureStrategyProvider = { captureStrategy }) + + replay.register(fixture.hub, fixture.options) + replay.start() + + val event = SentryEvent().apply { + exceptions = listOf(SentryException()) + } + replay.sendReplayForEvent(event, Hint()) + + verify(captureStrategy, never()).sendReplayForEvent(any(), anyOrNull(), anyOrNull(), any()) + } + + @Test + fun `sendReplayForEvent calls and converts strategy`() { + val captureStrategy = mock { + whenever(mock.currentReplayId).thenReturn(AtomicReference(SentryId())) + } + val replay = fixture.getSut(context, replayCaptureStrategyProvider = { captureStrategy }) + + replay.register(fixture.hub, fixture.options) + replay.start() + + val id = SentryId() + val event = SentryEvent().apply { + exceptions = listOf(SentryException()) + } + event.eventId = id + val hint = Hint() + replay.sendReplayForEvent(event, hint) + + verify(captureStrategy).sendReplayForEvent(eq(false), eq(id.toString()), eq(hint), any()) + verify(captureStrategy).convert() + } + + @Test + fun `pause does nothing when not recording`() { + val captureStrategy = mock() + val replay = fixture.getSut(context, replayCaptureStrategyProvider = { captureStrategy }) + + replay.register(fixture.hub, fixture.options) + replay.pause() + + verify(captureStrategy, never()).pause() + } + + @Test + fun `pause calls strategy and recorder`() { + val captureStrategy = mock() + val recorder = mock() + val replay = fixture.getSut(context, recorderProvider = { recorder }, replayCaptureStrategyProvider = { captureStrategy }) + + replay.register(fixture.hub, fixture.options) + replay.start() + replay.pause() + + verify(captureStrategy).pause() + verify(recorder).pause() + } + + @Test + fun `stop does nothing when not recording`() { + val captureStrategy = mock() + val recorder = mock() + val replay = fixture.getSut(context, recorderProvider = { recorder }, replayCaptureStrategyProvider = { captureStrategy }) + + replay.register(fixture.hub, fixture.options) + replay.stop() + + verify(captureStrategy, never()).stop() + verify(recorder, never()).stop() + } + + @Test + fun `stop calls stop for recorder and strategy and sets recording to false`() { + val captureStrategy = mock() + val recorder = mock() + val replay = fixture.getSut(context, recorderProvider = { recorder }, replayCaptureStrategyProvider = { captureStrategy }) + + replay.register(fixture.hub, fixture.options) + replay.start() + replay.stop() + + verify(captureStrategy).stop() + verify(recorder).stop() + assertFalse(replay.isRecording) + } + + @Test + fun `close cleans up resources`() { + val recorder = mock() + val captureStrategy = mock() + val replay = fixture.getSut(context, recorderProvider = { recorder }, replayCaptureStrategyProvider = { captureStrategy }) + replay.register(fixture.hub, fixture.options) + replay.start() + + replay.close() + + verify(recorder).stop() + verify(recorder).close() + verify(captureStrategy).stop() + verify(captureStrategy).close() + assertFalse(replay.isRecording()) + } + + @Test + fun `onConfigurationChanged does nothing when not recording`() { + val captureStrategy = mock() + val recorder = mock() + val replay = fixture.getSut(context, recorderProvider = { recorder }, replayCaptureStrategyProvider = { captureStrategy }) + + replay.register(fixture.hub, fixture.options) + replay.onConfigurationChanged(mock()) + + verify(captureStrategy, never()).onConfigurationChanged(any()) + verify(recorder, never()).stop() + } + + @Test + fun `onConfigurationChanged stops and restarts recorder with a new recorder config`() { + var configChanged = false + val recorderConfig = mock() + val captureStrategy = mock() + val recorder = mock() + val replay = fixture.getSut( + context, + recorderProvider = { recorder }, + replayCaptureStrategyProvider = { captureStrategy }, + recorderConfigProvider = { configChanged = it; recorderConfig } + ) + + replay.register(fixture.hub, fixture.options) + replay.start() + replay.onConfigurationChanged(mock()) + + verify(recorder).stop() + verify(captureStrategy).onConfigurationChanged(eq(recorderConfig)) + verify(recorder, times(2)).start(eq(recorderConfig)) + assertTrue(configChanged) + } +} diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationWithRecorderTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationWithRecorderTest.kt new file mode 100644 index 0000000000..f7e4da2304 --- /dev/null +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationWithRecorderTest.kt @@ -0,0 +1,231 @@ +package io.sentry.android.replay + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.Bitmap.CompressFormat.JPEG +import android.graphics.Bitmap.Config.ARGB_8888 +import android.media.MediaCodec +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.sentry.IHub +import io.sentry.SentryOptions +import io.sentry.SentryReplayEvent.ReplayType +import io.sentry.android.replay.ReplayIntegrationWithRecorderTest.LifecycleState.CLOSED +import io.sentry.android.replay.ReplayIntegrationWithRecorderTest.LifecycleState.INITALIZED +import io.sentry.android.replay.ReplayIntegrationWithRecorderTest.LifecycleState.PAUSED +import io.sentry.android.replay.ReplayIntegrationWithRecorderTest.LifecycleState.RESUMED +import io.sentry.android.replay.ReplayIntegrationWithRecorderTest.LifecycleState.STARTED +import io.sentry.android.replay.ReplayIntegrationWithRecorderTest.LifecycleState.STOPPED +import io.sentry.android.replay.video.MuxerConfig +import io.sentry.android.replay.video.SimpleVideoEncoder +import io.sentry.rrweb.RRWebMetaEvent +import io.sentry.rrweb.RRWebVideoEvent +import io.sentry.transport.CurrentDateProvider +import io.sentry.transport.ICurrentDateProvider +import org.awaitility.kotlin.await +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.check +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.robolectric.annotation.Config +import java.io.File +import java.util.concurrent.TimeUnit.MICROSECONDS +import java.util.concurrent.TimeUnit.MILLISECONDS +import java.util.concurrent.atomic.AtomicBoolean +import kotlin.test.BeforeTest +import kotlin.test.assertEquals + +@RunWith(AndroidJUnit4::class) +@Config(sdk = [26]) +class ReplayIntegrationWithRecorderTest { + + @get:Rule + val tmpDir = TemporaryFolder() + + internal class Fixture { + val options = SentryOptions() + val hub = mock() + var encoder: SimpleVideoEncoder? = null + + fun getSut( + context: Context, + recorder: Recorder, + recorderConfig: ScreenshotRecorderConfig, + dateProvider: ICurrentDateProvider = CurrentDateProvider.getInstance(), + framesToEncode: Int = 0 + ): ReplayIntegration { + return ReplayIntegration( + context, + dateProvider, + recorderProvider = { recorder }, + recorderConfigProvider = { recorderConfig }, + // this is just needed for testing to encode a fake video + replayCacheProvider = { replayId, config -> + ReplayCache( + options, + replayId, + config, + encoderProvider = { videoFile, height, width -> + encoder = SimpleVideoEncoder( + options, + MuxerConfig( + file = videoFile, + recordingHeight = height, + recordingWidth = width, + frameRate = recorderConfig.frameRate, + bitRate = recorderConfig.bitRate + ), + onClose = { + encodeFrame( + framesToEncode, + recorderConfig.frameRate, + size = 0, + flags = MediaCodec.BUFFER_FLAG_END_OF_STREAM + ) + } + ).also { it.start() } + repeat(framesToEncode) { encodeFrame(it, recorderConfig.frameRate) } + + encoder!! + } + ) + } + ) + } + + private fun encodeFrame(index: Int, frameRate: Int, size: Int = 10, flags: Int = 0) { + val presentationTime = MICROSECONDS.convert(index * (1000L / frameRate), MILLISECONDS) + encoder!!.mediaCodec.dequeueInputBuffer(0) + encoder!!.mediaCodec.queueInputBuffer( + index, + index * size, + size, + presentationTime, + flags + ) + } + } + + private val fixture = Fixture() + private lateinit var context: Context + + @BeforeTest + fun `set up`() { + context = ApplicationProvider.getApplicationContext() + } + + @Test + fun `works with different recorder`() { + val captured = AtomicBoolean(false) + whenever(fixture.hub.captureReplay(any(), anyOrNull())).then { + captured.set(true) + } + // fake current time to trigger segment creation, CurrentDateProvider.getInstance() should + // be used in prod + val dateProvider = ICurrentDateProvider { + System.currentTimeMillis() + fixture.options.experimental.sessionReplay.sessionSegmentDuration + } + + fixture.options.experimental.sessionReplay.sessionSampleRate = 1.0 + fixture.options.cacheDirPath = tmpDir.newFolder().absolutePath + + val replay: ReplayIntegration + val recorderConfig = ScreenshotRecorderConfig(100, 200, 1f, 1f, 1, 20_000) + val recorder = object : Recorder { + var state: LifecycleState = INITALIZED + + override fun start(recorderConfig: ScreenshotRecorderConfig) { + state = STARTED + } + + override fun resume() { + state = RESUMED + } + + override fun pause() { + state = PAUSED + } + + override fun stop() { + state = STOPPED + } + + override fun close() { + state = CLOSED + } + } + + replay = fixture.getSut(context, recorder, recorderConfig, dateProvider, framesToEncode = 5) + replay.register(fixture.hub, fixture.options) + + assertEquals(INITALIZED, recorder.state) + + replay.start() + assertEquals(STARTED, recorder.state) + + replay.resume() + assertEquals(RESUMED, recorder.state) + + replay.pause() + assertEquals(PAUSED, recorder.state) + + replay.stop() + assertEquals(STOPPED, recorder.state) + + replay.close() + assertEquals(CLOSED, recorder.state) + + // start again and capture some frames + replay.start() + + // have to access 'replayCacheDir' after calling replay.start(), BUT can already be accessed + // inside recorder.start() + val screenshot = File(replay.replayCacheDir, "1.jpg").also { it.createNewFile() } + + screenshot.outputStream().use { + Bitmap.createBitmap(1, 1, ARGB_8888).compress(JPEG, 80, it) + it.flush() + } + replay.onScreenshotRecorded(screenshot, frameTimestamp = 1) + + // verify + await.untilTrue(captured) + + verify(fixture.hub).captureReplay( + check { + assertEquals(replay.replayId, it.replayId) + assertEquals(ReplayType.SESSION, it.replayType) + assertEquals("0.mp4", it.videoFile?.name) + assertEquals("replay_${replay.replayId}", it.videoFile?.parentFile?.name) + }, + check { + val metaEvents = it.replayRecording?.payload?.filterIsInstance() + assertEquals(200, metaEvents?.first()?.height) + assertEquals(100, metaEvents?.first()?.width) + + val videoEvents = it.replayRecording?.payload?.filterIsInstance() + assertEquals(200, videoEvents?.first()?.height) + assertEquals(100, videoEvents?.first()?.width) + assertEquals(5000, videoEvents?.first()?.durationMs) + assertEquals(5, videoEvents?.first()?.frameCount) + assertEquals(1, videoEvents?.first()?.frameRate) + assertEquals(0, videoEvents?.first()?.segmentId) + } + ) + } + + enum class LifecycleState { + INITALIZED, + STARTED, + RESUMED, + PAUSED, + STOPPED, + CLOSED + } +} diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplaySmokeTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplaySmokeTest.kt new file mode 100644 index 0000000000..22f35b157b --- /dev/null +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplaySmokeTest.kt @@ -0,0 +1,286 @@ +package io.sentry.android.replay + +import android.app.Activity +import android.content.Context +import android.graphics.drawable.Drawable +import android.media.MediaCodec +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.LinearLayout.LayoutParams +import android.widget.TextView +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.sentry.Hint +import io.sentry.IHub +import io.sentry.Scope +import io.sentry.ScopeCallback +import io.sentry.SentryEvent +import io.sentry.SentryOptions +import io.sentry.SentryReplayEvent.ReplayType +import io.sentry.android.replay.video.MuxerConfig +import io.sentry.android.replay.video.SimpleVideoEncoder +import io.sentry.protocol.Mechanism +import io.sentry.protocol.SentryException +import io.sentry.rrweb.RRWebMetaEvent +import io.sentry.rrweb.RRWebVideoEvent +import io.sentry.transport.CurrentDateProvider +import io.sentry.transport.ICurrentDateProvider +import org.awaitility.core.ConditionTimeoutException +import org.awaitility.kotlin.await +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.check +import org.mockito.kotlin.doAnswer +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.robolectric.Robolectric.buildActivity +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config +import org.robolectric.shadows.ShadowPixelCopy +import java.time.Duration +import java.util.concurrent.TimeUnit +import java.util.concurrent.TimeUnit.MICROSECONDS +import java.util.concurrent.TimeUnit.MILLISECONDS +import java.util.concurrent.atomic.AtomicBoolean +import kotlin.test.BeforeTest +import kotlin.test.assertEquals + +@RunWith(AndroidJUnit4::class) +@Config( + shadows = [ShadowPixelCopy::class], + sdk = [28], + qualifiers = "w360dp-h640dp-xxhdpi" +) +class ReplaySmokeTest { + + @get:Rule + val tmpDir = TemporaryFolder() + + internal class Fixture { + val options = SentryOptions() + val scope = Scope(options) + val hub = mock { + doAnswer { + (it.arguments[0] as ScopeCallback).run(scope) + }.whenever(it).configureScope(any()) + } + var encoder: SimpleVideoEncoder? = null + var count: Int = 0 + + private class ImmediateHandler : Handler(Callback { it.callback?.run(); true }) + + fun getSut( + context: Context, + dateProvider: ICurrentDateProvider = CurrentDateProvider.getInstance(), + framesToEncode: Int = 0 + ): ReplayIntegration { + return ReplayIntegration( + context, + dateProvider, + recorderProvider = null, + recorderConfigProvider = null, + // this is just needed for testing to encode a fake video + replayCacheProvider = { replayId, recorderConfig -> + ReplayCache( + options, + replayId, + recorderConfig, + encoderProvider = { videoFile, height, width -> + encoder = SimpleVideoEncoder( + options, + MuxerConfig( + file = videoFile, + recordingHeight = height, + recordingWidth = width, + frameRate = recorderConfig.frameRate, + bitRate = recorderConfig.bitRate + ), + onClose = { + encodeFrame( + framesToEncode, + recorderConfig.frameRate, + size = 0, + flags = MediaCodec.BUFFER_FLAG_END_OF_STREAM + ) + } + ).also { it.start() } + repeat(framesToEncode) { encodeFrame(it, recorderConfig.frameRate) } + + encoder!! + } + ) + }, + replayCaptureStrategyProvider = null, + mainLooperHandler = mock { + whenever(mock.handler).thenReturn(ImmediateHandler()) + whenever(mock.post(any())).then { + (it.arguments[0] as Runnable).run() + count++ + } + } + ) + } + + private fun encodeFrame(index: Int, frameRate: Int, size: Int = 10, flags: Int = 0) { + val presentationTime = MICROSECONDS.convert(index * (1000L / frameRate), MILLISECONDS) + encoder!!.mediaCodec.dequeueInputBuffer(0) + encoder!!.mediaCodec.queueInputBuffer( + index, + index * size, + size, + presentationTime, + flags + ) + } + } + + private val fixture = Fixture() + private lateinit var context: Context + + @BeforeTest + fun `set up`() { + context = ApplicationProvider.getApplicationContext() + } + + @Test + fun `works in session mode`() { + val captured = AtomicBoolean(false) + whenever(fixture.hub.captureReplay(any(), anyOrNull())).then { + captured.set(true) + } + + fixture.options.experimental.sessionReplay.sessionSampleRate = 1.0 + fixture.options.cacheDirPath = tmpDir.newFolder().absolutePath + + val replay: ReplayIntegration = fixture.getSut(context, framesToEncode = 5) + replay.register(fixture.hub, fixture.options) + + val controller = buildActivity(ExampleActivity::class.java, null).setup() + controller.create().start().resume() + + replay.start() + // wait for windows to be registered in our listeners + shadowOf(Looper.getMainLooper()).idle() + + await.timeout(Duration.ofSeconds(15)).untilTrue(captured) + + verify(fixture.hub).captureReplay( + check { + assertEquals(replay.replayId, it.replayId) + assertEquals(ReplayType.SESSION, it.replayType) + assertEquals("0.mp4", it.videoFile?.name) + assertEquals("replay_${replay.replayId}", it.videoFile?.parentFile?.name) + }, + check { + val metaEvents = it.replayRecording?.payload?.filterIsInstance() + assertEquals(640, metaEvents?.first()?.height) + assertEquals(352, metaEvents?.first()?.width) // clamped to power of 16 + + val videoEvents = it.replayRecording?.payload?.filterIsInstance() + assertEquals(640, videoEvents?.first()?.height) + assertEquals(352, videoEvents?.first()?.width) // clamped to power of 16 + assertEquals(5000, videoEvents?.first()?.durationMs) + assertEquals(5, videoEvents?.first()?.frameCount) + assertEquals(1, videoEvents?.first()?.frameRate) + assertEquals(0, videoEvents?.first()?.segmentId) + } + ) + } + + @Test + fun `works in buffer mode`() { + val captured = AtomicBoolean(false) + whenever(fixture.hub.captureReplay(any(), anyOrNull())).then { + captured.set(true) + } + + fixture.options.experimental.sessionReplay.errorSampleRate = 1.0 + fixture.options.cacheDirPath = tmpDir.newFolder().absolutePath + + val replay: ReplayIntegration = fixture.getSut(context, framesToEncode = 10) + replay.register(fixture.hub, fixture.options) + + val controller = buildActivity(ExampleActivity::class.java, null).setup() + controller.create().start().resume() + + replay.start() + // wait for windows to be registered in our listeners + shadowOf(Looper.getMainLooper()).idle() + + try { + // Use Awaitility to wait for 10 seconds so buffer is filled + await.atMost(10, TimeUnit.SECONDS).untilTrue(captured) + } catch (e: ConditionTimeoutException) { + } + + val crash = SentryEvent().apply { + exceptions = listOf( + SentryException().apply { + mechanism = Mechanism().apply { isHandled = false } + } + ) + } + replay.sendReplayForEvent(crash, Hint()) + + await.timeout(Duration.ofSeconds(5)).untilTrue(captured) + + verify(fixture.hub).captureReplay( + check { + assertEquals(replay.replayId, it.replayId) + assertEquals(ReplayType.BUFFER, it.replayType) + assertEquals("0.mp4", it.videoFile?.name) + assertEquals("replay_${replay.replayId}", it.videoFile?.parentFile?.name) + }, + check { + val metaEvents = it.replayRecording?.payload?.filterIsInstance() + assertEquals(640, metaEvents?.first()?.height) + assertEquals(352, metaEvents?.first()?.width) // clamped to power of 16 + + val videoEvents = it.replayRecording?.payload?.filterIsInstance() + assertEquals(640, videoEvents?.first()?.height) + assertEquals(352, videoEvents?.first()?.width) // clamped to power of 16 + assertEquals(10000, videoEvents?.first()?.durationMs) + // TODO: figure out why there's more than 10 +// assertEquals(10, videoEvents?.first()?.frameCount) + assertEquals(1, videoEvents?.first()?.frameRate) + assertEquals(0, videoEvents?.first()?.segmentId) + } + ) + } +} + +private class ExampleActivity : Activity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val linearLayout = LinearLayout(this).apply { + setBackgroundColor(android.R.color.white) + orientation = LinearLayout.VERTICAL + layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT) + } + + val textView = TextView(this).apply { + text = "Hello, World!" + layoutParams = LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT) + } + linearLayout.addView(textView) + + val image = this::class.java.classLoader.getResource("Tongariro.jpg")!! + val imageView = ImageView(this).apply { + setImageDrawable(Drawable.createFromPath(image.path)) + layoutParams = LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT).apply { + setMargins(0, 16, 0, 0) + } + } + linearLayout.addView(imageView) + + setContentView(linearLayout) + } +} diff --git a/sentry-android-replay/src/test/resources/Tongariro.jpg b/sentry-android-replay/src/test/resources/Tongariro.jpg new file mode 100644 index 0000000000000000000000000000000000000000..96e2f074f0d56243385f5d0a56972d54ffc9e333 GIT binary patch literal 239154 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

eaQ^@tg(F2POdPCfPj+~&-js6ld6MEb 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 z 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%0Dx`y;G6#d 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 literal 0 HcmV?d00001 diff --git a/sentry-android/build.gradle.kts b/sentry-android/build.gradle.kts index 47b873ac49..81619b736f 100644 --- a/sentry-android/build.gradle.kts +++ b/sentry-android/build.gradle.kts @@ -35,4 +35,5 @@ android { dependencies { api(projects.sentryAndroidCore) api(projects.sentryAndroidNdk) + api(projects.sentryAndroidReplay) } diff --git a/sentry-okhttp/src/main/java/io/sentry/okhttp/SentryOkHttpEvent.kt b/sentry-okhttp/src/main/java/io/sentry/okhttp/SentryOkHttpEvent.kt index 21a3329a14..6bceb81a19 100644 --- a/sentry-okhttp/src/main/java/io/sentry/okhttp/SentryOkHttpEvent.kt +++ b/sentry-okhttp/src/main/java/io/sentry/okhttp/SentryOkHttpEvent.kt @@ -15,6 +15,7 @@ import io.sentry.okhttp.SentryOkHttpEventListener.Companion.REQUEST_HEADERS_EVEN import io.sentry.okhttp.SentryOkHttpEventListener.Companion.RESPONSE_BODY_EVENT import io.sentry.okhttp.SentryOkHttpEventListener.Companion.RESPONSE_HEADERS_EVENT import io.sentry.okhttp.SentryOkHttpEventListener.Companion.SECURE_CONNECT_EVENT +import io.sentry.transport.CurrentDateProvider import io.sentry.util.Platform import io.sentry.util.UrlUtils import okhttp3.Request @@ -58,6 +59,8 @@ internal class SentryOkHttpEvent(private val hub: IHub, private val request: Req breadcrumb = Breadcrumb.http(url, method) breadcrumb.setData("host", host) breadcrumb.setData("path", encodedPath) + // needs this as unix timestamp for rrweb + breadcrumb.setData(SpanDataConvention.HTTP_START_TIMESTAMP, CurrentDateProvider.getInstance().currentTimeMillis) // We add the same data to the root call span callRootSpan?.setData("url", url) @@ -150,6 +153,8 @@ internal class SentryOkHttpEvent(private val hub: IHub, private val request: Req hint.set(TypeCheckHint.OKHTTP_REQUEST, request) response?.let { hint.set(TypeCheckHint.OKHTTP_RESPONSE, it) } + // needs this as unix timestamp for rrweb + breadcrumb.setData(SpanDataConvention.HTTP_END_TIMESTAMP, CurrentDateProvider.getInstance().currentTimeMillis) // We send the breadcrumb even without spans. hub.addBreadcrumb(breadcrumb, hint) diff --git a/sentry-okhttp/src/main/java/io/sentry/okhttp/SentryOkHttpInterceptor.kt b/sentry-okhttp/src/main/java/io/sentry/okhttp/SentryOkHttpInterceptor.kt index efa472963d..5bf93be060 100644 --- a/sentry-okhttp/src/main/java/io/sentry/okhttp/SentryOkHttpInterceptor.kt +++ b/sentry-okhttp/src/main/java/io/sentry/okhttp/SentryOkHttpInterceptor.kt @@ -14,6 +14,7 @@ import io.sentry.SpanStatus import io.sentry.TypeCheckHint.OKHTTP_REQUEST import io.sentry.TypeCheckHint.OKHTTP_RESPONSE import io.sentry.okhttp.SentryOkHttpInterceptor.BeforeSpanCallback +import io.sentry.transport.CurrentDateProvider import io.sentry.util.IntegrationUtils.addIntegrationToSdkVersion import io.sentry.util.Platform import io.sentry.util.PropagationTargetsUtils @@ -79,6 +80,7 @@ public open class SentryOkHttpInterceptor( val parentSpan = if (Platform.isAndroid()) hub.transaction else hub.span span = parentSpan?.startChild("http.client", "$method $url") } + val startTimestamp = CurrentDateProvider.getInstance().currentTimeMillis span?.spanContext?.origin = TRACE_ORIGIN @@ -137,12 +139,17 @@ public open class SentryOkHttpInterceptor( // The SentryOkHttpEventListener will send the breadcrumb itself if used for this call if (!isFromEventListener) { - sendBreadcrumb(request, code, response) + sendBreadcrumb(request, code, response, startTimestamp) } } } - private fun sendBreadcrumb(request: Request, code: Int?, response: Response?) { + private fun sendBreadcrumb( + request: Request, + code: Int?, + response: Response?, + startTimestamp: Long + ) { val breadcrumb = Breadcrumb.http(request.url.toString(), request.method, code) request.body?.contentLength().ifHasValidLength { breadcrumb.setData("http.request_content_length", it) @@ -156,6 +163,9 @@ public open class SentryOkHttpInterceptor( hint[OKHTTP_RESPONSE] = it } + // needs this as unix timestamp for rrweb + breadcrumb.setData(SpanDataConvention.HTTP_START_TIMESTAMP, startTimestamp) + breadcrumb.setData(SpanDataConvention.HTTP_END_TIMESTAMP, CurrentDateProvider.getInstance().currentTimeMillis) hub.addBreadcrumb(breadcrumb, hint) } diff --git a/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml b/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml index 6d4b96bdca..8876efd66d 100644 --- a/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml +++ b/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml @@ -165,5 +165,8 @@ + + + diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 8c25105d82..af9ffe9fc4 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -45,6 +45,7 @@ public final class io/sentry/Baggage { public fun getEnvironment ()Ljava/lang/String; public fun getPublicKey ()Ljava/lang/String; public fun getRelease ()Ljava/lang/String; + public fun getReplayId ()Ljava/lang/String; public fun getSampleRate ()Ljava/lang/String; public fun getSampleRateDouble ()Ljava/lang/Double; public fun getSampled ()Ljava/lang/String; @@ -59,6 +60,7 @@ public final class io/sentry/Baggage { public fun setEnvironment (Ljava/lang/String;)V public fun setPublicKey (Ljava/lang/String;)V public fun setRelease (Ljava/lang/String;)V + public fun setReplayId (Ljava/lang/String;)V public fun setSampleRate (Ljava/lang/String;)V public fun setSampled (Ljava/lang/String;)V public fun setTraceId (Ljava/lang/String;)V @@ -66,7 +68,7 @@ public final class io/sentry/Baggage { public fun setUserId (Ljava/lang/String;)V public fun setUserSegment (Ljava/lang/String;)V public fun setValuesFromScope (Lio/sentry/IScope;Lio/sentry/SentryOptions;)V - public fun setValuesFromTransaction (Lio/sentry/ITransaction;Lio/sentry/protocol/User;Lio/sentry/SentryOptions;Lio/sentry/TracesSamplingDecision;)V + public fun setValuesFromTransaction (Lio/sentry/ITransaction;Lio/sentry/protocol/User;Lio/sentry/protocol/SentryId;Lio/sentry/SentryOptions;Lio/sentry/TracesSamplingDecision;)V public fun toHeaderString (Ljava/lang/String;)Ljava/lang/String; public fun toTraceContext ()Lio/sentry/TraceContext; } @@ -76,6 +78,7 @@ public final class io/sentry/Baggage$DSCKeys { public static final field ENVIRONMENT Ljava/lang/String; public static final field PUBLIC_KEY Ljava/lang/String; public static final field RELEASE Ljava/lang/String; + public static final field REPLAY_ID Ljava/lang/String; public static final field SAMPLED Ljava/lang/String; public static final field SAMPLE_RATE Ljava/lang/String; public static final field TRACE_ID Ljava/lang/String; @@ -136,8 +139,8 @@ public final class io/sentry/Breadcrumb : io/sentry/JsonSerializable, io/sentry/ public final class io/sentry/Breadcrumb$Deserializer : io/sentry/JsonDeserializer { public fun ()V - public fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Lio/sentry/Breadcrumb; - public synthetic fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/Breadcrumb; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; } public final class io/sentry/Breadcrumb$JsonKeys { @@ -181,8 +184,8 @@ public final class io/sentry/CheckIn : io/sentry/JsonSerializable, io/sentry/Jso public final class io/sentry/CheckIn$Deserializer : io/sentry/JsonDeserializer { public fun ()V - public fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Lio/sentry/CheckIn; - public synthetic fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/CheckIn; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; } public final class io/sentry/CheckIn$JsonKeys { @@ -227,6 +230,7 @@ public final class io/sentry/DataCategory : java/lang/Enum { public static final field MetricBucket Lio/sentry/DataCategory; public static final field Monitor Lio/sentry/DataCategory; public static final field Profile Lio/sentry/DataCategory; + public static final field Replay Lio/sentry/DataCategory; public static final field Security Lio/sentry/DataCategory; public static final field Session Lio/sentry/DataCategory; public static final field Span Lio/sentry/DataCategory; @@ -302,9 +306,16 @@ public final class io/sentry/EnvelopeSender : io/sentry/IEnvelopeSender { public abstract interface class io/sentry/EventProcessor { public fun process (Lio/sentry/SentryEvent;Lio/sentry/Hint;)Lio/sentry/SentryEvent; + public fun process (Lio/sentry/SentryReplayEvent;Lio/sentry/Hint;)Lio/sentry/SentryReplayEvent; public fun process (Lio/sentry/protocol/SentryTransaction;Lio/sentry/Hint;)Lio/sentry/protocol/SentryTransaction; } +public final class io/sentry/ExperimentalOptions { + public fun ()V + public fun getSessionReplay ()Lio/sentry/SentryReplayOptions; + public fun setSessionReplay (Lio/sentry/SentryReplayOptions;)V +} + public final class io/sentry/ExternalOptions { public fun ()V public fun addBundleId (Ljava/lang/String;)V @@ -391,12 +402,14 @@ public final class io/sentry/Hint { public fun get (Ljava/lang/String;)Ljava/lang/Object; public fun getAs (Ljava/lang/String;Ljava/lang/Class;)Ljava/lang/Object; public fun getAttachments ()Ljava/util/List; + public fun getReplayRecording ()Lio/sentry/ReplayRecording; public fun getScreenshot ()Lio/sentry/Attachment; public fun getThreadDump ()Lio/sentry/Attachment; public fun getViewHierarchy ()Lio/sentry/Attachment; public fun remove (Ljava/lang/String;)V public fun replaceAttachments (Ljava/util/List;)V public fun set (Ljava/lang/String;Ljava/lang/Object;)V + public fun setReplayRecording (Lio/sentry/ReplayRecording;)V public fun setScreenshot (Lio/sentry/Attachment;)V public fun setThreadDump (Lio/sentry/Attachment;)V public fun setViewHierarchy (Lio/sentry/Attachment;)V @@ -425,6 +438,7 @@ public final class io/sentry/Hub : io/sentry/IHub, io/sentry/metrics/MetricsApi$ public fun captureException (Ljava/lang/Throwable;Lio/sentry/Hint;Lio/sentry/ScopeCallback;)Lio/sentry/protocol/SentryId; public fun captureMessage (Ljava/lang/String;Lio/sentry/SentryLevel;)Lio/sentry/protocol/SentryId; public fun captureMessage (Ljava/lang/String;Lio/sentry/SentryLevel;Lio/sentry/ScopeCallback;)Lio/sentry/protocol/SentryId; + public fun captureReplay (Lio/sentry/SentryReplayEvent;Lio/sentry/Hint;)Lio/sentry/protocol/SentryId; public fun captureTransaction (Lio/sentry/protocol/SentryTransaction;Lio/sentry/TraceContext;Lio/sentry/Hint;Lio/sentry/ProfilingTraceData;)Lio/sentry/protocol/SentryId; public fun captureUserFeedback (Lio/sentry/UserFeedback;)V public fun clearBreadcrumbs ()V @@ -481,6 +495,7 @@ public final class io/sentry/HubAdapter : io/sentry/IHub { public fun captureException (Ljava/lang/Throwable;Lio/sentry/Hint;Lio/sentry/ScopeCallback;)Lio/sentry/protocol/SentryId; public fun captureMessage (Ljava/lang/String;Lio/sentry/SentryLevel;)Lio/sentry/protocol/SentryId; public fun captureMessage (Ljava/lang/String;Lio/sentry/SentryLevel;Lio/sentry/ScopeCallback;)Lio/sentry/protocol/SentryId; + public fun captureReplay (Lio/sentry/SentryReplayEvent;Lio/sentry/Hint;)Lio/sentry/protocol/SentryId; public fun captureTransaction (Lio/sentry/protocol/SentryTransaction;Lio/sentry/TraceContext;Lio/sentry/Hint;Lio/sentry/ProfilingTraceData;)Lio/sentry/protocol/SentryId; public fun captureUserFeedback (Lio/sentry/UserFeedback;)V public fun clearBreadcrumbs ()V @@ -571,6 +586,7 @@ public abstract interface class io/sentry/IHub { public fun captureMessage (Ljava/lang/String;Lio/sentry/ScopeCallback;)Lio/sentry/protocol/SentryId; public abstract fun captureMessage (Ljava/lang/String;Lio/sentry/SentryLevel;)Lio/sentry/protocol/SentryId; public abstract fun captureMessage (Ljava/lang/String;Lio/sentry/SentryLevel;Lio/sentry/ScopeCallback;)Lio/sentry/protocol/SentryId; + public abstract fun captureReplay (Lio/sentry/SentryReplayEvent;Lio/sentry/Hint;)Lio/sentry/protocol/SentryId; public fun captureTransaction (Lio/sentry/protocol/SentryTransaction;Lio/sentry/Hint;)Lio/sentry/protocol/SentryId; public fun captureTransaction (Lio/sentry/protocol/SentryTransaction;Lio/sentry/TraceContext;)Lio/sentry/protocol/SentryId; public fun captureTransaction (Lio/sentry/protocol/SentryTransaction;Lio/sentry/TraceContext;Lio/sentry/Hint;)Lio/sentry/protocol/SentryId; @@ -681,6 +697,7 @@ public abstract interface class io/sentry/IScope { public abstract fun getLevel ()Lio/sentry/SentryLevel; public abstract fun getOptions ()Lio/sentry/SentryOptions; public abstract fun getPropagationContext ()Lio/sentry/PropagationContext; + public abstract fun getReplayId ()Lio/sentry/protocol/SentryId; public abstract fun getRequest ()Lio/sentry/protocol/Request; public abstract fun getScreen ()Ljava/lang/String; public abstract fun getSession ()Lio/sentry/Session; @@ -703,6 +720,7 @@ public abstract interface class io/sentry/IScope { public abstract fun setFingerprint (Ljava/util/List;)V public abstract fun setLevel (Lio/sentry/SentryLevel;)V public abstract fun setPropagationContext (Lio/sentry/PropagationContext;)V + public abstract fun setReplayId (Lio/sentry/protocol/SentryId;)V public abstract fun setRequest (Lio/sentry/protocol/Request;)V public abstract fun setScreen (Ljava/lang/String;)V public abstract fun setTag (Ljava/lang/String;Ljava/lang/String;)V @@ -747,6 +765,7 @@ public abstract interface class io/sentry/ISentryClient { public fun captureException (Ljava/lang/Throwable;Lio/sentry/IScope;Lio/sentry/Hint;)Lio/sentry/protocol/SentryId; public fun captureMessage (Ljava/lang/String;Lio/sentry/SentryLevel;)Lio/sentry/protocol/SentryId; public fun captureMessage (Ljava/lang/String;Lio/sentry/SentryLevel;Lio/sentry/IScope;)Lio/sentry/protocol/SentryId; + public abstract fun captureReplayEvent (Lio/sentry/SentryReplayEvent;Lio/sentry/IScope;Lio/sentry/Hint;)Lio/sentry/protocol/SentryId; public fun captureSession (Lio/sentry/Session;)V public abstract fun captureSession (Lio/sentry/Session;Lio/sentry/Hint;)V public fun captureTransaction (Lio/sentry/protocol/SentryTransaction;)Lio/sentry/protocol/SentryId; @@ -870,7 +889,7 @@ public final class io/sentry/JavaMemoryCollector : io/sentry/IPerformanceSnapsho } public abstract interface class io/sentry/JsonDeserializer { - public abstract fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; + public abstract fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; } public final class io/sentry/JsonObjectDeserializer { @@ -878,24 +897,39 @@ public final class io/sentry/JsonObjectDeserializer { public fun deserialize (Lio/sentry/JsonObjectReader;)Ljava/lang/Object; } -public final class io/sentry/JsonObjectReader : io/sentry/vendor/gson/stream/JsonReader { +public final class io/sentry/JsonObjectReader : io/sentry/ObjectReader { public fun (Ljava/io/Reader;)V - public static fun dateOrNull (Ljava/lang/String;Lio/sentry/ILogger;)Ljava/util/Date; + public fun beginArray ()V + public fun beginObject ()V + public fun close ()V + public fun endArray ()V + public fun endObject ()V + public fun hasNext ()Z + public fun nextBoolean ()Z public fun nextBooleanOrNull ()Ljava/lang/Boolean; public fun nextDateOrNull (Lio/sentry/ILogger;)Ljava/util/Date; + public fun nextDouble ()D public fun nextDoubleOrNull ()Ljava/lang/Double; - public fun nextFloat ()Ljava/lang/Float; + public fun nextFloat ()F public fun nextFloatOrNull ()Ljava/lang/Float; + public fun nextInt ()I public fun nextIntegerOrNull ()Ljava/lang/Integer; public fun nextListOrNull (Lio/sentry/ILogger;Lio/sentry/JsonDeserializer;)Ljava/util/List; + public fun nextLong ()J public fun nextLongOrNull ()Ljava/lang/Long; public fun nextMapOfListOrNull (Lio/sentry/ILogger;Lio/sentry/JsonDeserializer;)Ljava/util/Map; public fun nextMapOrNull (Lio/sentry/ILogger;Lio/sentry/JsonDeserializer;)Ljava/util/Map; + public fun nextName ()Ljava/lang/String; + public fun nextNull ()V public fun nextObjectOrNull ()Ljava/lang/Object; public fun nextOrNull (Lio/sentry/ILogger;Lio/sentry/JsonDeserializer;)Ljava/lang/Object; + public fun nextString ()Ljava/lang/String; public fun nextStringOrNull ()Ljava/lang/String; public fun nextTimeZoneOrNull (Lio/sentry/ILogger;)Ljava/util/TimeZone; public fun nextUnknown (Lio/sentry/ILogger;Ljava/util/Map;Ljava/lang/String;)V + public fun peek ()Lio/sentry/vendor/gson/stream/JsonToken; + public fun setLenient (Z)V + public fun skipValue ()V } public final class io/sentry/JsonObjectSerializer { @@ -915,11 +949,13 @@ public final class io/sentry/JsonObjectWriter : io/sentry/ObjectWriter { public synthetic fun endArray ()Lio/sentry/ObjectWriter; public fun endObject ()Lio/sentry/JsonObjectWriter; public synthetic fun endObject ()Lio/sentry/ObjectWriter; + public fun jsonValue (Ljava/lang/String;)Lio/sentry/ObjectWriter; public fun name (Ljava/lang/String;)Lio/sentry/JsonObjectWriter; public synthetic fun name (Ljava/lang/String;)Lio/sentry/ObjectWriter; public fun nullValue ()Lio/sentry/JsonObjectWriter; public synthetic fun nullValue ()Lio/sentry/ObjectWriter; public fun setIndent (Ljava/lang/String;)V + public fun setLenient (Z)V public fun value (D)Lio/sentry/JsonObjectWriter; public synthetic fun value (D)Lio/sentry/ObjectWriter; public fun value (J)Lio/sentry/JsonObjectWriter; @@ -964,6 +1000,7 @@ public final class io/sentry/MainEventProcessor : io/sentry/EventProcessor, java public fun (Lio/sentry/SentryOptions;)V public fun close ()V public fun process (Lio/sentry/SentryEvent;Lio/sentry/Hint;)Lio/sentry/SentryEvent; + public fun process (Lio/sentry/SentryReplayEvent;Lio/sentry/Hint;)Lio/sentry/SentryReplayEvent; public fun process (Lio/sentry/protocol/SentryTransaction;Lio/sentry/Hint;)Lio/sentry/protocol/SentryTransaction; } @@ -1063,8 +1100,8 @@ public final class io/sentry/MonitorConfig : io/sentry/JsonSerializable, io/sent public final class io/sentry/MonitorConfig$Deserializer : io/sentry/JsonDeserializer { public fun ()V - public fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Lio/sentry/MonitorConfig; - public synthetic fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/MonitorConfig; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; } public final class io/sentry/MonitorConfig$JsonKeys { @@ -1087,8 +1124,8 @@ public final class io/sentry/MonitorContexts : java/util/concurrent/ConcurrentHa public final class io/sentry/MonitorContexts$Deserializer : io/sentry/JsonDeserializer { public fun ()V - public fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Lio/sentry/MonitorContexts; - public synthetic fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/MonitorContexts; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; } public final class io/sentry/MonitorSchedule : io/sentry/JsonSerializable, io/sentry/JsonUnknown { @@ -1110,8 +1147,8 @@ public final class io/sentry/MonitorSchedule : io/sentry/JsonSerializable, io/se public final class io/sentry/MonitorSchedule$Deserializer : io/sentry/JsonDeserializer { public fun ()V - public fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Lio/sentry/MonitorSchedule; - public synthetic fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/MonitorSchedule; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; } public final class io/sentry/MonitorSchedule$JsonKeys { @@ -1166,6 +1203,7 @@ public final class io/sentry/NoOpHub : io/sentry/IHub { public fun captureException (Ljava/lang/Throwable;Lio/sentry/Hint;Lio/sentry/ScopeCallback;)Lio/sentry/protocol/SentryId; public fun captureMessage (Ljava/lang/String;Lio/sentry/SentryLevel;)Lio/sentry/protocol/SentryId; public fun captureMessage (Ljava/lang/String;Lio/sentry/SentryLevel;Lio/sentry/ScopeCallback;)Lio/sentry/protocol/SentryId; + public fun captureReplay (Lio/sentry/SentryReplayEvent;Lio/sentry/Hint;)Lio/sentry/protocol/SentryId; public fun captureTransaction (Lio/sentry/protocol/SentryTransaction;Lio/sentry/TraceContext;Lio/sentry/Hint;Lio/sentry/ProfilingTraceData;)Lio/sentry/protocol/SentryId; public fun captureUserFeedback (Lio/sentry/UserFeedback;)V public fun clearBreadcrumbs ()V @@ -1215,6 +1253,25 @@ public final class io/sentry/NoOpLogger : io/sentry/ILogger { public fun log (Lio/sentry/SentryLevel;Ljava/lang/Throwable;Ljava/lang/String;[Ljava/lang/Object;)V } +public final class io/sentry/NoOpReplayBreadcrumbConverter : io/sentry/ReplayBreadcrumbConverter { + public fun convert (Lio/sentry/Breadcrumb;)Lio/sentry/rrweb/RRWebEvent; + public static fun getInstance ()Lio/sentry/NoOpReplayBreadcrumbConverter; +} + +public final class io/sentry/NoOpReplayController : io/sentry/ReplayController { + public fun getBreadcrumbConverter ()Lio/sentry/ReplayBreadcrumbConverter; + public static fun getInstance ()Lio/sentry/NoOpReplayController; + public fun getReplayId ()Lio/sentry/protocol/SentryId; + public fun isRecording ()Z + public fun pause ()V + public fun resume ()V + public fun sendReplay (Ljava/lang/Boolean;Ljava/lang/String;Lio/sentry/Hint;)V + public fun sendReplayForEvent (Lio/sentry/SentryEvent;Lio/sentry/Hint;)V + public fun setBreadcrumbConverter (Lio/sentry/ReplayBreadcrumbConverter;)V + public fun start ()V + public fun stop ()V +} + public final class io/sentry/NoOpScope : io/sentry/IScope { public fun addAttachment (Lio/sentry/Attachment;)V public fun addBreadcrumb (Lio/sentry/Breadcrumb;)V @@ -1238,6 +1295,7 @@ public final class io/sentry/NoOpScope : io/sentry/IScope { public fun getLevel ()Lio/sentry/SentryLevel; public fun getOptions ()Lio/sentry/SentryOptions; public fun getPropagationContext ()Lio/sentry/PropagationContext; + public fun getReplayId ()Lio/sentry/protocol/SentryId; public fun getRequest ()Lio/sentry/protocol/Request; public fun getScreen ()Ljava/lang/String; public fun getSession ()Lio/sentry/Session; @@ -1260,6 +1318,7 @@ public final class io/sentry/NoOpScope : io/sentry/IScope { public fun setFingerprint (Ljava/util/List;)V public fun setLevel (Lio/sentry/SentryLevel;)V public fun setPropagationContext (Lio/sentry/PropagationContext;)V + public fun setReplayId (Lio/sentry/protocol/SentryId;)V public fun setRequest (Lio/sentry/protocol/Request;)V public fun setScreen (Ljava/lang/String;)V public fun setTag (Ljava/lang/String;Ljava/lang/String;)V @@ -1383,13 +1442,49 @@ public final class io/sentry/NoOpTransportFactory : io/sentry/ITransportFactory public static fun getInstance ()Lio/sentry/NoOpTransportFactory; } +public abstract interface class io/sentry/ObjectReader : java/io/Closeable { + public abstract fun beginArray ()V + public abstract fun beginObject ()V + public static fun dateOrNull (Ljava/lang/String;Lio/sentry/ILogger;)Ljava/util/Date; + public abstract fun endArray ()V + public abstract fun endObject ()V + public abstract fun hasNext ()Z + public abstract fun nextBoolean ()Z + public abstract fun nextBooleanOrNull ()Ljava/lang/Boolean; + public abstract fun nextDateOrNull (Lio/sentry/ILogger;)Ljava/util/Date; + public abstract fun nextDouble ()D + public abstract fun nextDoubleOrNull ()Ljava/lang/Double; + public abstract fun nextFloat ()F + public abstract fun nextFloatOrNull ()Ljava/lang/Float; + public abstract fun nextInt ()I + public abstract fun nextIntegerOrNull ()Ljava/lang/Integer; + public abstract fun nextListOrNull (Lio/sentry/ILogger;Lio/sentry/JsonDeserializer;)Ljava/util/List; + public abstract fun nextLong ()J + public abstract fun nextLongOrNull ()Ljava/lang/Long; + public abstract fun nextMapOfListOrNull (Lio/sentry/ILogger;Lio/sentry/JsonDeserializer;)Ljava/util/Map; + public abstract fun nextMapOrNull (Lio/sentry/ILogger;Lio/sentry/JsonDeserializer;)Ljava/util/Map; + public abstract fun nextName ()Ljava/lang/String; + public abstract fun nextNull ()V + public abstract fun nextObjectOrNull ()Ljava/lang/Object; + public abstract fun nextOrNull (Lio/sentry/ILogger;Lio/sentry/JsonDeserializer;)Ljava/lang/Object; + public abstract fun nextString ()Ljava/lang/String; + public abstract fun nextStringOrNull ()Ljava/lang/String; + public abstract fun nextTimeZoneOrNull (Lio/sentry/ILogger;)Ljava/util/TimeZone; + public abstract fun nextUnknown (Lio/sentry/ILogger;Ljava/util/Map;Ljava/lang/String;)V + public abstract fun peek ()Lio/sentry/vendor/gson/stream/JsonToken; + public abstract fun setLenient (Z)V + public abstract fun skipValue ()V +} + public abstract interface class io/sentry/ObjectWriter { public abstract fun beginArray ()Lio/sentry/ObjectWriter; public abstract fun beginObject ()Lio/sentry/ObjectWriter; public abstract fun endArray ()Lio/sentry/ObjectWriter; public abstract fun endObject ()Lio/sentry/ObjectWriter; + public abstract fun jsonValue (Ljava/lang/String;)Lio/sentry/ObjectWriter; public abstract fun name (Ljava/lang/String;)Lio/sentry/ObjectWriter; public abstract fun nullValue ()Lio/sentry/ObjectWriter; + public abstract fun setLenient (Z)V public abstract fun value (D)Lio/sentry/ObjectWriter; public abstract fun value (J)Lio/sentry/ObjectWriter; public abstract fun value (Lio/sentry/ILogger;Ljava/lang/Object;)Lio/sentry/ObjectWriter; @@ -1480,8 +1575,8 @@ public final class io/sentry/ProfilingTraceData : io/sentry/JsonSerializable, io public final class io/sentry/ProfilingTraceData$Deserializer : io/sentry/JsonDeserializer { public fun ()V - public fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Lio/sentry/ProfilingTraceData; - public synthetic fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/ProfilingTraceData; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; } public final class io/sentry/ProfilingTraceData$JsonKeys { @@ -1539,8 +1634,8 @@ public final class io/sentry/ProfilingTransactionData : io/sentry/JsonSerializab public final class io/sentry/ProfilingTransactionData$Deserializer : io/sentry/JsonDeserializer { public fun ()V - public fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Lio/sentry/ProfilingTransactionData; - public synthetic fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/ProfilingTransactionData; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; } public final class io/sentry/ProfilingTransactionData$JsonKeys { @@ -1574,6 +1669,47 @@ public final class io/sentry/PropagationContext { public fun traceContext ()Lio/sentry/TraceContext; } +public abstract interface class io/sentry/ReplayBreadcrumbConverter { + public abstract fun convert (Lio/sentry/Breadcrumb;)Lio/sentry/rrweb/RRWebEvent; +} + +public abstract interface class io/sentry/ReplayController { + public abstract fun getBreadcrumbConverter ()Lio/sentry/ReplayBreadcrumbConverter; + public abstract fun getReplayId ()Lio/sentry/protocol/SentryId; + public abstract fun isRecording ()Z + public abstract fun pause ()V + public abstract fun resume ()V + public abstract fun sendReplay (Ljava/lang/Boolean;Ljava/lang/String;Lio/sentry/Hint;)V + public abstract fun sendReplayForEvent (Lio/sentry/SentryEvent;Lio/sentry/Hint;)V + public abstract fun setBreadcrumbConverter (Lio/sentry/ReplayBreadcrumbConverter;)V + public abstract fun start ()V + public abstract fun stop ()V +} + +public final class io/sentry/ReplayRecording : io/sentry/JsonSerializable, io/sentry/JsonUnknown { + public fun ()V + public fun equals (Ljava/lang/Object;)Z + public fun getPayload ()Ljava/util/List; + public fun getSegmentId ()Ljava/lang/Integer; + public fun getUnknown ()Ljava/util/Map; + public fun hashCode ()I + public fun serialize (Lio/sentry/ObjectWriter;Lio/sentry/ILogger;)V + public fun setPayload (Ljava/util/List;)V + public fun setSegmentId (Ljava/lang/Integer;)V + public fun setUnknown (Ljava/util/Map;)V +} + +public final class io/sentry/ReplayRecording$Deserializer : io/sentry/JsonDeserializer { + public fun ()V + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/ReplayRecording; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; +} + +public final class io/sentry/ReplayRecording$JsonKeys { + public static final field SEGMENT_ID Ljava/lang/String; + public fun ()V +} + public final class io/sentry/RequestDetails { public fun (Ljava/lang/String;Ljava/util/Map;)V public fun getHeaders ()Ljava/util/Map; @@ -1609,6 +1745,7 @@ public final class io/sentry/Scope : io/sentry/IScope { public fun getLevel ()Lio/sentry/SentryLevel; public fun getOptions ()Lio/sentry/SentryOptions; public fun getPropagationContext ()Lio/sentry/PropagationContext; + public fun getReplayId ()Lio/sentry/protocol/SentryId; public fun getRequest ()Lio/sentry/protocol/Request; public fun getScreen ()Ljava/lang/String; public fun getSession ()Lio/sentry/Session; @@ -1631,6 +1768,7 @@ public final class io/sentry/Scope : io/sentry/IScope { public fun setFingerprint (Ljava/util/List;)V public fun setLevel (Lio/sentry/SentryLevel;)V public fun setPropagationContext (Lio/sentry/PropagationContext;)V + public fun setReplayId (Lio/sentry/protocol/SentryId;)V public fun setRequest (Lio/sentry/protocol/Request;)V public fun setScreen (Ljava/lang/String;)V public fun setTag (Ljava/lang/String;Ljava/lang/String;)V @@ -1799,8 +1937,8 @@ public final class io/sentry/SentryAppStartProfilingOptions : io/sentry/JsonSeri public final class io/sentry/SentryAppStartProfilingOptions$Deserializer : io/sentry/JsonDeserializer { public fun ()V - public fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Lio/sentry/SentryAppStartProfilingOptions; - public synthetic fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/SentryAppStartProfilingOptions; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; } public final class io/sentry/SentryAppStartProfilingOptions$JsonKeys { @@ -1866,7 +2004,7 @@ public abstract class io/sentry/SentryBaseEvent { public final class io/sentry/SentryBaseEvent$Deserializer { public fun ()V - public fun deserializeValue (Lio/sentry/SentryBaseEvent;Ljava/lang/String;Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Z + public fun deserializeValue (Lio/sentry/SentryBaseEvent;Ljava/lang/String;Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Z } public final class io/sentry/SentryBaseEvent$JsonKeys { @@ -1897,6 +2035,7 @@ public final class io/sentry/SentryClient : io/sentry/ISentryClient, io/sentry/m public fun captureEnvelope (Lio/sentry/SentryEnvelope;Lio/sentry/Hint;)Lio/sentry/protocol/SentryId; public fun captureEvent (Lio/sentry/SentryEvent;Lio/sentry/IScope;Lio/sentry/Hint;)Lio/sentry/protocol/SentryId; public fun captureMetrics (Lio/sentry/metrics/EncodedMetrics;)Lio/sentry/protocol/SentryId; + public fun captureReplayEvent (Lio/sentry/SentryReplayEvent;Lio/sentry/IScope;Lio/sentry/Hint;)Lio/sentry/protocol/SentryId; public fun captureSession (Lio/sentry/Session;Lio/sentry/Hint;)V public fun captureTransaction (Lio/sentry/protocol/SentryTransaction;Lio/sentry/TraceContext;Lio/sentry/IScope;Lio/sentry/Hint;Lio/sentry/ProfilingTraceData;)Lio/sentry/protocol/SentryId; public fun captureUserFeedback (Lio/sentry/UserFeedback;)V @@ -1959,8 +2098,8 @@ public final class io/sentry/SentryEnvelopeHeader : io/sentry/JsonSerializable, public final class io/sentry/SentryEnvelopeHeader$Deserializer : io/sentry/JsonDeserializer { public fun ()V - public fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Lio/sentry/SentryEnvelopeHeader; - public synthetic fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/SentryEnvelopeHeader; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; } public final class io/sentry/SentryEnvelopeHeader$JsonKeys { @@ -1978,6 +2117,7 @@ public final class io/sentry/SentryEnvelopeItem { public static fun fromEvent (Lio/sentry/ISerializer;Lio/sentry/SentryBaseEvent;)Lio/sentry/SentryEnvelopeItem; public static fun fromMetrics (Lio/sentry/metrics/EncodedMetrics;)Lio/sentry/SentryEnvelopeItem; public static fun fromProfilingTrace (Lio/sentry/ProfilingTraceData;JLio/sentry/ISerializer;)Lio/sentry/SentryEnvelopeItem; + public static fun fromReplay (Lio/sentry/ISerializer;Lio/sentry/ILogger;Lio/sentry/SentryReplayEvent;Lio/sentry/ReplayRecording;)Lio/sentry/SentryEnvelopeItem; public static fun fromSession (Lio/sentry/ISerializer;Lio/sentry/Session;)Lio/sentry/SentryEnvelopeItem; public static fun fromUserFeedback (Lio/sentry/ISerializer;Lio/sentry/UserFeedback;)Lio/sentry/SentryEnvelopeItem; public fun getClientReport (Lio/sentry/ISerializer;)Lio/sentry/clientreport/ClientReport; @@ -2001,8 +2141,8 @@ public final class io/sentry/SentryEnvelopeItemHeader : io/sentry/JsonSerializab public final class io/sentry/SentryEnvelopeItemHeader$Deserializer : io/sentry/JsonDeserializer { public fun ()V - public fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Lio/sentry/SentryEnvelopeItemHeader; - public synthetic fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/SentryEnvelopeItemHeader; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; } public final class io/sentry/SentryEnvelopeItemHeader$JsonKeys { @@ -2048,8 +2188,8 @@ public final class io/sentry/SentryEvent : io/sentry/SentryBaseEvent, io/sentry/ public final class io/sentry/SentryEvent$Deserializer : io/sentry/JsonDeserializer { public fun ()V - public fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Lio/sentry/SentryEvent; - public synthetic fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/SentryEvent; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; } public final class io/sentry/SentryEvent$JsonKeys { @@ -2108,6 +2248,7 @@ public final class io/sentry/SentryItemType : java/lang/Enum, io/sentry/JsonSeri public static final field Profile Lio/sentry/SentryItemType; public static final field ReplayEvent Lio/sentry/SentryItemType; public static final field ReplayRecording Lio/sentry/SentryItemType; + public static final field ReplayVideo Lio/sentry/SentryItemType; public static final field Session Lio/sentry/SentryItemType; public static final field Statsd Lio/sentry/SentryItemType; public static final field Transaction Lio/sentry/SentryItemType; @@ -2132,6 +2273,12 @@ public final class io/sentry/SentryLevel : java/lang/Enum, io/sentry/JsonSeriali public static fun values ()[Lio/sentry/SentryLevel; } +public final class io/sentry/SentryLevel$Deserializer : io/sentry/JsonDeserializer { + public fun ()V + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/SentryLevel; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; +} + public final class io/sentry/SentryLockReason : io/sentry/JsonSerializable, io/sentry/JsonUnknown { public static final field ANY I public static final field BLOCKED I @@ -2159,8 +2306,8 @@ public final class io/sentry/SentryLockReason : io/sentry/JsonSerializable, io/s public final class io/sentry/SentryLockReason$Deserializer : io/sentry/JsonDeserializer { public fun ()V - public fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Lio/sentry/SentryLockReason; - public synthetic fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/SentryLockReason; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; } public final class io/sentry/SentryLockReason$JsonKeys { @@ -2232,6 +2379,7 @@ public class io/sentry/SentryOptions { public fun getEnvironment ()Ljava/lang/String; public fun getEventProcessors ()Ljava/util/List; public fun getExecutorService ()Lio/sentry/ISentryExecutorService; + public fun getExperimental ()Lio/sentry/ExperimentalOptions; public fun getFlushTimeoutMillis ()J public fun getFullyDisplayedReporter ()Lio/sentry/FullyDisplayedReporter; public fun getGestureTargetLocators ()Ljava/util/List; @@ -2264,6 +2412,7 @@ public class io/sentry/SentryOptions { public fun getProxy ()Lio/sentry/SentryOptions$Proxy; public fun getReadTimeoutMillis ()I public fun getRelease ()Ljava/lang/String; + public fun getReplayController ()Lio/sentry/ReplayController; public fun getSampleRate ()Ljava/lang/Double; public fun getScopeObservers ()Ljava/util/List; public fun getSdkVersion ()Lio/sentry/protocol/SdkVersion; @@ -2299,6 +2448,7 @@ public class io/sentry/SentryOptions { public fun isEnableMetrics ()Z public fun isEnablePrettySerializationOutput ()Z public fun isEnableScopePersistence ()Z + public fun isEnableScreenTracking ()Z public fun isEnableShutdownHook ()Z public fun isEnableSpanLocalMetricAggregation ()Z public fun isEnableSpotlight ()Z @@ -2345,6 +2495,7 @@ public class io/sentry/SentryOptions { public fun setEnableMetrics (Z)V public fun setEnablePrettySerializationOutput (Z)V public fun setEnableScopePersistence (Z)V + public fun setEnableScreenTracking (Z)V public fun setEnableShutdownHook (Z)V public fun setEnableSpanLocalMetricAggregation (Z)V public fun setEnableSpotlight (Z)V @@ -2383,6 +2534,7 @@ public class io/sentry/SentryOptions { public fun setProxy (Lio/sentry/SentryOptions$Proxy;)V public fun setReadTimeoutMillis (I)V public fun setRelease (Ljava/lang/String;)V + public fun setReplayController (Lio/sentry/ReplayController;)V public fun setSampleRate (Ljava/lang/Double;)V public fun setSdkVersion (Lio/sentry/protocol/SdkVersion;)V public fun setSendClientReports (Z)V @@ -2480,6 +2632,103 @@ public abstract interface class io/sentry/SentryOptions$TracesSamplerCallback { public abstract fun sample (Lio/sentry/SamplingContext;)Ljava/lang/Double; } +public final class io/sentry/SentryReplayEvent : io/sentry/SentryBaseEvent, io/sentry/JsonSerializable, io/sentry/JsonUnknown { + public static final field REPLAY_EVENT_TYPE Ljava/lang/String; + public static final field REPLAY_VIDEO_MAX_SIZE J + public fun ()V + public fun equals (Ljava/lang/Object;)Z + public fun getErrorIds ()Ljava/util/List; + public fun getReplayId ()Lio/sentry/protocol/SentryId; + public fun getReplayStartTimestamp ()Ljava/util/Date; + public fun getReplayType ()Lio/sentry/SentryReplayEvent$ReplayType; + public fun getSegmentId ()I + public fun getTimestamp ()Ljava/util/Date; + public fun getTraceIds ()Ljava/util/List; + public fun getType ()Ljava/lang/String; + public fun getUnknown ()Ljava/util/Map; + public fun getUrls ()Ljava/util/List; + public fun getVideoFile ()Ljava/io/File; + public fun hashCode ()I + public fun serialize (Lio/sentry/ObjectWriter;Lio/sentry/ILogger;)V + public fun setErrorIds (Ljava/util/List;)V + public fun setReplayId (Lio/sentry/protocol/SentryId;)V + public fun setReplayStartTimestamp (Ljava/util/Date;)V + public fun setReplayType (Lio/sentry/SentryReplayEvent$ReplayType;)V + public fun setSegmentId (I)V + public fun setTimestamp (Ljava/util/Date;)V + public fun setTraceIds (Ljava/util/List;)V + public fun setType (Ljava/lang/String;)V + public fun setUnknown (Ljava/util/Map;)V + public fun setUrls (Ljava/util/List;)V + public fun setVideoFile (Ljava/io/File;)V +} + +public final class io/sentry/SentryReplayEvent$Deserializer : io/sentry/JsonDeserializer { + public fun ()V + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/SentryReplayEvent; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; +} + +public final class io/sentry/SentryReplayEvent$JsonKeys { + public static final field ERROR_IDS Ljava/lang/String; + public static final field REPLAY_ID Ljava/lang/String; + public static final field REPLAY_START_TIMESTAMP Ljava/lang/String; + public static final field REPLAY_TYPE Ljava/lang/String; + public static final field SEGMENT_ID Ljava/lang/String; + public static final field TIMESTAMP Ljava/lang/String; + public static final field TRACE_IDS Ljava/lang/String; + public static final field TYPE Ljava/lang/String; + public static final field URLS Ljava/lang/String; + public fun ()V +} + +public final class io/sentry/SentryReplayEvent$ReplayType : java/lang/Enum, io/sentry/JsonSerializable { + public static final field BUFFER Lio/sentry/SentryReplayEvent$ReplayType; + public static final field SESSION Lio/sentry/SentryReplayEvent$ReplayType; + public fun serialize (Lio/sentry/ObjectWriter;Lio/sentry/ILogger;)V + public static fun valueOf (Ljava/lang/String;)Lio/sentry/SentryReplayEvent$ReplayType; + public static fun values ()[Lio/sentry/SentryReplayEvent$ReplayType; +} + +public final class io/sentry/SentryReplayEvent$ReplayType$Deserializer : io/sentry/JsonDeserializer { + public fun ()V + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/SentryReplayEvent$ReplayType; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; +} + +public final class io/sentry/SentryReplayOptions { + public fun ()V + public fun (Ljava/lang/Double;Ljava/lang/Double;)V + public fun addClassToRedact (Ljava/lang/String;)V + public fun getErrorReplayDuration ()J + public fun getErrorSampleRate ()Ljava/lang/Double; + public fun getFrameRate ()I + public fun getQuality ()Lio/sentry/SentryReplayOptions$SentryReplayQuality; + public fun getRedactAllImages ()Z + public fun getRedactAllText ()Z + public fun getRedactClasses ()Ljava/util/Set; + public fun getSessionDuration ()J + public fun getSessionSampleRate ()Ljava/lang/Double; + public fun getSessionSegmentDuration ()J + public fun isSessionReplayEnabled ()Z + public fun isSessionReplayForErrorsEnabled ()Z + public fun setErrorSampleRate (Ljava/lang/Double;)V + public fun setQuality (Lio/sentry/SentryReplayOptions$SentryReplayQuality;)V + public fun setRedactAllImages (Z)V + public fun setRedactAllText (Z)V + public fun setSessionSampleRate (Ljava/lang/Double;)V +} + +public final class io/sentry/SentryReplayOptions$SentryReplayQuality : java/lang/Enum { + public static final field HIGH Lio/sentry/SentryReplayOptions$SentryReplayQuality; + public static final field LOW Lio/sentry/SentryReplayOptions$SentryReplayQuality; + public static final field MEDIUM Lio/sentry/SentryReplayOptions$SentryReplayQuality; + public final field bitRate I + public final field sizeScale F + public static fun valueOf (Ljava/lang/String;)Lio/sentry/SentryReplayOptions$SentryReplayQuality; + public static fun values ()[Lio/sentry/SentryReplayOptions$SentryReplayQuality; +} + public final class io/sentry/SentrySpanStorage { public fun get (Ljava/lang/String;)Lio/sentry/ISpan; public static fun getInstance ()Lio/sentry/SentrySpanStorage; @@ -2604,8 +2853,8 @@ public final class io/sentry/Session : io/sentry/JsonSerializable, io/sentry/Jso public final class io/sentry/Session$Deserializer : io/sentry/JsonDeserializer { public fun ()V - public fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Lio/sentry/Session; - public synthetic fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/Session; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; } public final class io/sentry/Session$JsonKeys { @@ -2728,8 +2977,8 @@ public class io/sentry/SpanContext : io/sentry/JsonSerializable, io/sentry/JsonU public final class io/sentry/SpanContext$Deserializer : io/sentry/JsonDeserializer { public fun ()V - public fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Lio/sentry/SpanContext; - public synthetic fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/SpanContext; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; } public final class io/sentry/SpanContext$JsonKeys { @@ -2755,10 +3004,12 @@ public abstract interface class io/sentry/SpanDataConvention { public static final field FRAMES_FROZEN Ljava/lang/String; public static final field FRAMES_SLOW Ljava/lang/String; public static final field FRAMES_TOTAL Ljava/lang/String; + public static final field HTTP_END_TIMESTAMP Ljava/lang/String; public static final field HTTP_FRAGMENT_KEY Ljava/lang/String; public static final field HTTP_METHOD_KEY Ljava/lang/String; public static final field HTTP_QUERY_KEY Ljava/lang/String; public static final field HTTP_RESPONSE_CONTENT_LENGTH_KEY Ljava/lang/String; + public static final field HTTP_START_TIMESTAMP Ljava/lang/String; public static final field HTTP_STATUS_CODE_KEY Ljava/lang/String; public static final field THREAD_ID Ljava/lang/String; public static final field THREAD_NAME Ljava/lang/String; @@ -2776,8 +3027,8 @@ public final class io/sentry/SpanId : io/sentry/JsonSerializable { public final class io/sentry/SpanId$Deserializer : io/sentry/JsonDeserializer { public fun ()V - public fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Lio/sentry/SpanId; - public synthetic fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/SpanId; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; } public class io/sentry/SpanOptions { @@ -2818,8 +3069,8 @@ public final class io/sentry/SpanStatus : java/lang/Enum, io/sentry/JsonSerializ public final class io/sentry/SpanStatus$Deserializer : io/sentry/JsonDeserializer { public fun ()V - public fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Lio/sentry/SpanStatus; - public synthetic fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/SpanStatus; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; } public final class io/sentry/SpotlightIntegration : io/sentry/Integration, io/sentry/SentryOptions$BeforeEnvelopeCallback, java/io/Closeable { @@ -2842,6 +3093,7 @@ public final class io/sentry/TraceContext : io/sentry/JsonSerializable, io/sentr public fun getEnvironment ()Ljava/lang/String; public fun getPublicKey ()Ljava/lang/String; public fun getRelease ()Ljava/lang/String; + public fun getReplayId ()Lio/sentry/protocol/SentryId; public fun getSampleRate ()Ljava/lang/String; public fun getSampled ()Ljava/lang/String; public fun getTraceId ()Lio/sentry/protocol/SentryId; @@ -2855,14 +3107,15 @@ public final class io/sentry/TraceContext : io/sentry/JsonSerializable, io/sentr public final class io/sentry/TraceContext$Deserializer : io/sentry/JsonDeserializer { public fun ()V - public fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Lio/sentry/TraceContext; - public synthetic fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/TraceContext; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; } public final class io/sentry/TraceContext$JsonKeys { public static final field ENVIRONMENT Ljava/lang/String; public static final field PUBLIC_KEY Ljava/lang/String; public static final field RELEASE Ljava/lang/String; + public static final field REPLAY_ID Ljava/lang/String; public static final field SAMPLED Ljava/lang/String; public static final field SAMPLE_RATE Ljava/lang/String; public static final field TRACE_ID Ljava/lang/String; @@ -3015,8 +3268,8 @@ public final class io/sentry/UserFeedback : io/sentry/JsonSerializable, io/sentr public final class io/sentry/UserFeedback$Deserializer : io/sentry/JsonDeserializer { public fun ()V - public fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Lio/sentry/UserFeedback; - public synthetic fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/UserFeedback; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; } public final class io/sentry/UserFeedback$JsonKeys { @@ -3127,8 +3380,8 @@ public final class io/sentry/clientreport/ClientReport : io/sentry/JsonSerializa public final class io/sentry/clientreport/ClientReport$Deserializer : io/sentry/JsonDeserializer { public fun ()V - public fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Lio/sentry/clientreport/ClientReport; - public synthetic fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/clientreport/ClientReport; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; } public final class io/sentry/clientreport/ClientReport$JsonKeys { @@ -3173,8 +3426,8 @@ public final class io/sentry/clientreport/DiscardedEvent : io/sentry/JsonSeriali public final class io/sentry/clientreport/DiscardedEvent$Deserializer : io/sentry/JsonDeserializer { public fun ()V - public fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Lio/sentry/clientreport/DiscardedEvent; - public synthetic fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/clientreport/DiscardedEvent; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; } public final class io/sentry/clientreport/DiscardedEvent$JsonKeys { @@ -3607,8 +3860,8 @@ public final class io/sentry/profilemeasurements/ProfileMeasurement : io/sentry/ public final class io/sentry/profilemeasurements/ProfileMeasurement$Deserializer : io/sentry/JsonDeserializer { public fun ()V - public fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Lio/sentry/profilemeasurements/ProfileMeasurement; - public synthetic fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/profilemeasurements/ProfileMeasurement; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; } public final class io/sentry/profilemeasurements/ProfileMeasurement$JsonKeys { @@ -3631,8 +3884,8 @@ public final class io/sentry/profilemeasurements/ProfileMeasurementValue : io/se public final class io/sentry/profilemeasurements/ProfileMeasurementValue$Deserializer : io/sentry/JsonDeserializer { public fun ()V - public fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Lio/sentry/profilemeasurements/ProfileMeasurementValue; - public synthetic fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/profilemeasurements/ProfileMeasurementValue; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; } public final class io/sentry/profilemeasurements/ProfileMeasurementValue$JsonKeys { @@ -3675,8 +3928,8 @@ public final class io/sentry/protocol/App : io/sentry/JsonSerializable, io/sentr public final class io/sentry/protocol/App$Deserializer : io/sentry/JsonDeserializer { public fun ()V - public fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/App; - public synthetic fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/App; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; } public final class io/sentry/protocol/App$JsonKeys { @@ -3710,8 +3963,8 @@ public final class io/sentry/protocol/Browser : io/sentry/JsonSerializable, io/s public final class io/sentry/protocol/Browser$Deserializer : io/sentry/JsonDeserializer { public fun ()V - public fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/Browser; - public synthetic fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/Browser; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; } public final class io/sentry/protocol/Browser$JsonKeys { @@ -3745,8 +3998,8 @@ public final class io/sentry/protocol/Contexts : java/util/concurrent/Concurrent public final class io/sentry/protocol/Contexts$Deserializer : io/sentry/JsonDeserializer { public fun ()V - public fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/Contexts; - public synthetic fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/Contexts; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; } public final class io/sentry/protocol/DebugImage : io/sentry/JsonSerializable, io/sentry/JsonUnknown { @@ -3779,8 +4032,8 @@ public final class io/sentry/protocol/DebugImage : io/sentry/JsonSerializable, i public final class io/sentry/protocol/DebugImage$Deserializer : io/sentry/JsonDeserializer { public fun ()V - public fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/DebugImage; - public synthetic fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/DebugImage; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; } public final class io/sentry/protocol/DebugImage$JsonKeys { @@ -3809,8 +4062,8 @@ public final class io/sentry/protocol/DebugMeta : io/sentry/JsonSerializable, io public final class io/sentry/protocol/DebugMeta$Deserializer : io/sentry/JsonDeserializer { public fun ()V - public fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/DebugMeta; - public synthetic fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/DebugMeta; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; } public final class io/sentry/protocol/DebugMeta$JsonKeys { @@ -3899,8 +4152,8 @@ public final class io/sentry/protocol/Device : io/sentry/JsonSerializable, io/se public final class io/sentry/protocol/Device$Deserializer : io/sentry/JsonDeserializer { public fun ()V - public fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/Device; - public synthetic fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/Device; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; } public final class io/sentry/protocol/Device$DeviceOrientation : java/lang/Enum, io/sentry/JsonSerializable { @@ -3913,8 +4166,8 @@ public final class io/sentry/protocol/Device$DeviceOrientation : java/lang/Enum, public final class io/sentry/protocol/Device$DeviceOrientation$Deserializer : io/sentry/JsonDeserializer { public fun ()V - public fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/Device$DeviceOrientation; - public synthetic fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/Device$DeviceOrientation; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; } public final class io/sentry/protocol/Device$JsonKeys { @@ -3972,8 +4225,8 @@ public final class io/sentry/protocol/Geo : io/sentry/JsonSerializable, io/sentr public final class io/sentry/protocol/Geo$Deserializer : io/sentry/JsonDeserializer { public fun ()V - public fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/Geo; - public synthetic fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/Geo; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; } public final class io/sentry/protocol/Geo$JsonKeys { @@ -4013,8 +4266,8 @@ public final class io/sentry/protocol/Gpu : io/sentry/JsonSerializable, io/sentr public final class io/sentry/protocol/Gpu$Deserializer : io/sentry/JsonDeserializer { public fun ()V - public fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/Gpu; - public synthetic fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/Gpu; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; } public final class io/sentry/protocol/Gpu$JsonKeys { @@ -4050,8 +4303,8 @@ public final class io/sentry/protocol/MeasurementValue : io/sentry/JsonSerializa public final class io/sentry/protocol/MeasurementValue$Deserializer : io/sentry/JsonDeserializer { public fun ()V - public fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/MeasurementValue; - public synthetic fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/MeasurementValue; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; } public final class io/sentry/protocol/MeasurementValue$JsonKeys { @@ -4084,8 +4337,8 @@ public final class io/sentry/protocol/Mechanism : io/sentry/JsonSerializable, io public final class io/sentry/protocol/Mechanism$Deserializer : io/sentry/JsonDeserializer { public fun ()V - public fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/Mechanism; - public synthetic fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/Mechanism; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; } public final class io/sentry/protocol/Mechanism$JsonKeys { @@ -4114,8 +4367,8 @@ public final class io/sentry/protocol/Message : io/sentry/JsonSerializable, io/s public final class io/sentry/protocol/Message$Deserializer : io/sentry/JsonDeserializer { public fun ()V - public fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/Message; - public synthetic fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/Message; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; } public final class io/sentry/protocol/Message$JsonKeys { @@ -4145,8 +4398,8 @@ public final class io/sentry/protocol/MetricSummary : io/sentry/JsonSerializable public final class io/sentry/protocol/MetricSummary$Deserializer : io/sentry/JsonDeserializer { public fun ()V - public fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/MetricSummary; - public synthetic fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/MetricSummary; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; } public final class io/sentry/protocol/MetricSummary$JsonKeys { @@ -4182,8 +4435,8 @@ public final class io/sentry/protocol/OperatingSystem : io/sentry/JsonSerializab public final class io/sentry/protocol/OperatingSystem$Deserializer : io/sentry/JsonDeserializer { public fun ()V - public fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/OperatingSystem; - public synthetic fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/OperatingSystem; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; } public final class io/sentry/protocol/OperatingSystem$JsonKeys { @@ -4230,8 +4483,8 @@ public final class io/sentry/protocol/Request : io/sentry/JsonSerializable, io/s public final class io/sentry/protocol/Request$Deserializer : io/sentry/JsonDeserializer { public fun ()V - public fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/Request; - public synthetic fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/Request; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; } public final class io/sentry/protocol/Request$JsonKeys { @@ -4270,8 +4523,8 @@ public final class io/sentry/protocol/Response : io/sentry/JsonSerializable, io/ public final class io/sentry/protocol/Response$Deserializer : io/sentry/JsonDeserializer { public fun ()V - public fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/Response; - public synthetic fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/Response; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; } public final class io/sentry/protocol/Response$JsonKeys { @@ -4300,8 +4553,8 @@ public final class io/sentry/protocol/SdkInfo : io/sentry/JsonSerializable, io/s public final class io/sentry/protocol/SdkInfo$Deserializer : io/sentry/JsonDeserializer { public fun ()V - public fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/SdkInfo; - public synthetic fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/SdkInfo; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; } public final class io/sentry/protocol/SdkInfo$JsonKeys { @@ -4334,8 +4587,8 @@ public final class io/sentry/protocol/SdkVersion : io/sentry/JsonSerializable, i public final class io/sentry/protocol/SdkVersion$Deserializer : io/sentry/JsonDeserializer { public fun ()V - public fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/SdkVersion; - public synthetic fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/SdkVersion; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; } public final class io/sentry/protocol/SdkVersion$JsonKeys { @@ -4367,8 +4620,8 @@ public final class io/sentry/protocol/SentryException : io/sentry/JsonSerializab public final class io/sentry/protocol/SentryException$Deserializer : io/sentry/JsonDeserializer { public fun ()V - public fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/SentryException; - public synthetic fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/SentryException; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; } public final class io/sentry/protocol/SentryException$JsonKeys { @@ -4394,8 +4647,8 @@ public final class io/sentry/protocol/SentryId : io/sentry/JsonSerializable { public final class io/sentry/protocol/SentryId$Deserializer : io/sentry/JsonDeserializer { public fun ()V - public fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/SentryId; - public synthetic fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/SentryId; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; } public final class io/sentry/protocol/SentryPackage : io/sentry/JsonSerializable, io/sentry/JsonUnknown { @@ -4413,8 +4666,8 @@ public final class io/sentry/protocol/SentryPackage : io/sentry/JsonSerializable public final class io/sentry/protocol/SentryPackage$Deserializer : io/sentry/JsonDeserializer { public fun ()V - public fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/SentryPackage; - public synthetic fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/SentryPackage; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; } public final class io/sentry/protocol/SentryPackage$JsonKeys { @@ -4439,8 +4692,8 @@ public final class io/sentry/protocol/SentryRuntime : io/sentry/JsonSerializable public final class io/sentry/protocol/SentryRuntime$Deserializer : io/sentry/JsonDeserializer { public fun ()V - public fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/SentryRuntime; - public synthetic fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/SentryRuntime; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; } public final class io/sentry/protocol/SentryRuntime$JsonKeys { @@ -4476,8 +4729,8 @@ public final class io/sentry/protocol/SentrySpan : io/sentry/JsonSerializable, i public final class io/sentry/protocol/SentrySpan$Deserializer : io/sentry/JsonDeserializer { public fun ()V - public fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/SentrySpan; - public synthetic fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/SentrySpan; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; } public final class io/sentry/protocol/SentrySpan$JsonKeys { @@ -4548,8 +4801,8 @@ public final class io/sentry/protocol/SentryStackFrame : io/sentry/JsonSerializa public final class io/sentry/protocol/SentryStackFrame$Deserializer : io/sentry/JsonDeserializer { public fun ()V - public fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/SentryStackFrame; - public synthetic fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/SentryStackFrame; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; } public final class io/sentry/protocol/SentryStackFrame$JsonKeys { @@ -4589,8 +4842,8 @@ public final class io/sentry/protocol/SentryStackTrace : io/sentry/JsonSerializa public final class io/sentry/protocol/SentryStackTrace$Deserializer : io/sentry/JsonDeserializer { public fun ()V - public fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/SentryStackTrace; - public synthetic fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/SentryStackTrace; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; } public final class io/sentry/protocol/SentryStackTrace$JsonKeys { @@ -4629,8 +4882,8 @@ public final class io/sentry/protocol/SentryThread : io/sentry/JsonSerializable, public final class io/sentry/protocol/SentryThread$Deserializer : io/sentry/JsonDeserializer { public fun ()V - public fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/SentryThread; - public synthetic fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/SentryThread; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; } public final class io/sentry/protocol/SentryThread$JsonKeys { @@ -4669,8 +4922,8 @@ public final class io/sentry/protocol/SentryTransaction : io/sentry/SentryBaseEv public final class io/sentry/protocol/SentryTransaction$Deserializer : io/sentry/JsonDeserializer { public fun ()V - public fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/SentryTransaction; - public synthetic fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/SentryTransaction; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; } public final class io/sentry/protocol/SentryTransaction$JsonKeys { @@ -4694,8 +4947,8 @@ public final class io/sentry/protocol/TransactionInfo : io/sentry/JsonSerializab public final class io/sentry/protocol/TransactionInfo$Deserializer : io/sentry/JsonDeserializer { public fun ()V - public fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/TransactionInfo; - public synthetic fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/TransactionInfo; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; } public final class io/sentry/protocol/TransactionInfo$JsonKeys { @@ -4746,8 +4999,8 @@ public final class io/sentry/protocol/User : io/sentry/JsonSerializable, io/sent public final class io/sentry/protocol/User$Deserializer : io/sentry/JsonDeserializer { public fun ()V - public fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/User; - public synthetic fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/User; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; } public final class io/sentry/protocol/User$JsonKeys { @@ -4774,8 +5027,8 @@ public final class io/sentry/protocol/ViewHierarchy : io/sentry/JsonSerializable public final class io/sentry/protocol/ViewHierarchy$Deserializer : io/sentry/JsonDeserializer { public fun ()V - public fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/ViewHierarchy; - public synthetic fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/ViewHierarchy; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; } public final class io/sentry/protocol/ViewHierarchy$JsonKeys { @@ -4815,8 +5068,8 @@ public final class io/sentry/protocol/ViewHierarchyNode : io/sentry/JsonSerializ public final class io/sentry/protocol/ViewHierarchyNode$Deserializer : io/sentry/JsonDeserializer { public fun ()V - public fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/ViewHierarchyNode; - public synthetic fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/ViewHierarchyNode; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; } public final class io/sentry/protocol/ViewHierarchyNode$JsonKeys { @@ -4834,6 +5087,401 @@ public final class io/sentry/protocol/ViewHierarchyNode$JsonKeys { public fun ()V } +public final class io/sentry/rrweb/RRWebBreadcrumbEvent : io/sentry/rrweb/RRWebEvent, io/sentry/JsonSerializable, io/sentry/JsonUnknown { + public static final field EVENT_TAG Ljava/lang/String; + public fun ()V + public fun getBreadcrumbTimestamp ()D + public fun getBreadcrumbType ()Ljava/lang/String; + public fun getCategory ()Ljava/lang/String; + public fun getData ()Ljava/util/Map; + public fun getDataUnknown ()Ljava/util/Map; + public fun getLevel ()Lio/sentry/SentryLevel; + public fun getMessage ()Ljava/lang/String; + public fun getPayloadUnknown ()Ljava/util/Map; + public fun getTag ()Ljava/lang/String; + public fun getUnknown ()Ljava/util/Map; + public fun serialize (Lio/sentry/ObjectWriter;Lio/sentry/ILogger;)V + public fun setBreadcrumbTimestamp (D)V + public fun setBreadcrumbType (Ljava/lang/String;)V + public fun setCategory (Ljava/lang/String;)V + public fun setData (Ljava/util/Map;)V + public fun setDataUnknown (Ljava/util/Map;)V + public fun setLevel (Lio/sentry/SentryLevel;)V + public fun setMessage (Ljava/lang/String;)V + public fun setPayloadUnknown (Ljava/util/Map;)V + public fun setTag (Ljava/lang/String;)V + public fun setUnknown (Ljava/util/Map;)V +} + +public final class io/sentry/rrweb/RRWebBreadcrumbEvent$Deserializer : io/sentry/JsonDeserializer { + public fun ()V + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/rrweb/RRWebBreadcrumbEvent; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; +} + +public final class io/sentry/rrweb/RRWebBreadcrumbEvent$JsonKeys { + public static final field CATEGORY Ljava/lang/String; + public static final field DATA Ljava/lang/String; + public static final field LEVEL Ljava/lang/String; + public static final field MESSAGE Ljava/lang/String; + public static final field PAYLOAD Ljava/lang/String; + public static final field TIMESTAMP Ljava/lang/String; + public static final field TYPE Ljava/lang/String; + public fun ()V +} + +public abstract class io/sentry/rrweb/RRWebEvent { + protected fun ()V + protected fun (Lio/sentry/rrweb/RRWebEventType;)V + public fun equals (Ljava/lang/Object;)Z + public fun getTimestamp ()J + public fun getType ()Lio/sentry/rrweb/RRWebEventType; + public fun hashCode ()I + public fun setTimestamp (J)V + public fun setType (Lio/sentry/rrweb/RRWebEventType;)V +} + +public final class io/sentry/rrweb/RRWebEvent$Deserializer { + public fun ()V + public fun deserializeValue (Lio/sentry/rrweb/RRWebEvent;Ljava/lang/String;Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Z +} + +public final class io/sentry/rrweb/RRWebEvent$JsonKeys { + public static final field TAG Ljava/lang/String; + public static final field TIMESTAMP Ljava/lang/String; + public static final field TYPE Ljava/lang/String; + public fun ()V +} + +public final class io/sentry/rrweb/RRWebEvent$Serializer { + public fun ()V + public fun serialize (Lio/sentry/rrweb/RRWebEvent;Lio/sentry/ObjectWriter;Lio/sentry/ILogger;)V +} + +public final class io/sentry/rrweb/RRWebEventType : java/lang/Enum, io/sentry/JsonSerializable { + public static final field Custom Lio/sentry/rrweb/RRWebEventType; + public static final field DomContentLoaded Lio/sentry/rrweb/RRWebEventType; + public static final field FullSnapshot Lio/sentry/rrweb/RRWebEventType; + public static final field IncrementalSnapshot Lio/sentry/rrweb/RRWebEventType; + public static final field Load Lio/sentry/rrweb/RRWebEventType; + public static final field Meta Lio/sentry/rrweb/RRWebEventType; + public static final field Plugin Lio/sentry/rrweb/RRWebEventType; + public fun serialize (Lio/sentry/ObjectWriter;Lio/sentry/ILogger;)V + public static fun valueOf (Ljava/lang/String;)Lio/sentry/rrweb/RRWebEventType; + public static fun values ()[Lio/sentry/rrweb/RRWebEventType; +} + +public final class io/sentry/rrweb/RRWebEventType$Deserializer : io/sentry/JsonDeserializer { + public fun ()V + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/rrweb/RRWebEventType; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; +} + +public abstract class io/sentry/rrweb/RRWebIncrementalSnapshotEvent : io/sentry/rrweb/RRWebEvent { + public fun (Lio/sentry/rrweb/RRWebIncrementalSnapshotEvent$IncrementalSource;)V + public fun getSource ()Lio/sentry/rrweb/RRWebIncrementalSnapshotEvent$IncrementalSource; + public fun setSource (Lio/sentry/rrweb/RRWebIncrementalSnapshotEvent$IncrementalSource;)V +} + +public final class io/sentry/rrweb/RRWebIncrementalSnapshotEvent$Deserializer { + public fun ()V + public fun deserializeValue (Lio/sentry/rrweb/RRWebIncrementalSnapshotEvent;Ljava/lang/String;Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Z +} + +public final class io/sentry/rrweb/RRWebIncrementalSnapshotEvent$IncrementalSource : java/lang/Enum, io/sentry/JsonSerializable { + public static final field AdoptedStyleSheet Lio/sentry/rrweb/RRWebIncrementalSnapshotEvent$IncrementalSource; + public static final field CanvasMutation Lio/sentry/rrweb/RRWebIncrementalSnapshotEvent$IncrementalSource; + public static final field CustomElement Lio/sentry/rrweb/RRWebIncrementalSnapshotEvent$IncrementalSource; + public static final field Drag Lio/sentry/rrweb/RRWebIncrementalSnapshotEvent$IncrementalSource; + public static final field Font Lio/sentry/rrweb/RRWebIncrementalSnapshotEvent$IncrementalSource; + public static final field Input Lio/sentry/rrweb/RRWebIncrementalSnapshotEvent$IncrementalSource; + public static final field Log Lio/sentry/rrweb/RRWebIncrementalSnapshotEvent$IncrementalSource; + public static final field MediaInteraction Lio/sentry/rrweb/RRWebIncrementalSnapshotEvent$IncrementalSource; + public static final field MouseInteraction Lio/sentry/rrweb/RRWebIncrementalSnapshotEvent$IncrementalSource; + public static final field MouseMove Lio/sentry/rrweb/RRWebIncrementalSnapshotEvent$IncrementalSource; + public static final field Mutation Lio/sentry/rrweb/RRWebIncrementalSnapshotEvent$IncrementalSource; + public static final field Scroll Lio/sentry/rrweb/RRWebIncrementalSnapshotEvent$IncrementalSource; + public static final field Selection Lio/sentry/rrweb/RRWebIncrementalSnapshotEvent$IncrementalSource; + public static final field StyleDeclaration Lio/sentry/rrweb/RRWebIncrementalSnapshotEvent$IncrementalSource; + public static final field StyleSheetRule Lio/sentry/rrweb/RRWebIncrementalSnapshotEvent$IncrementalSource; + public static final field TouchMove Lio/sentry/rrweb/RRWebIncrementalSnapshotEvent$IncrementalSource; + public static final field ViewportResize Lio/sentry/rrweb/RRWebIncrementalSnapshotEvent$IncrementalSource; + public fun serialize (Lio/sentry/ObjectWriter;Lio/sentry/ILogger;)V + public static fun valueOf (Ljava/lang/String;)Lio/sentry/rrweb/RRWebIncrementalSnapshotEvent$IncrementalSource; + public static fun values ()[Lio/sentry/rrweb/RRWebIncrementalSnapshotEvent$IncrementalSource; +} + +public final class io/sentry/rrweb/RRWebIncrementalSnapshotEvent$IncrementalSource$Deserializer : io/sentry/JsonDeserializer { + public fun ()V + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/rrweb/RRWebIncrementalSnapshotEvent$IncrementalSource; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; +} + +public final class io/sentry/rrweb/RRWebIncrementalSnapshotEvent$JsonKeys { + public static final field SOURCE Ljava/lang/String; + public fun ()V +} + +public final class io/sentry/rrweb/RRWebIncrementalSnapshotEvent$Serializer { + public fun ()V + public fun serialize (Lio/sentry/rrweb/RRWebIncrementalSnapshotEvent;Lio/sentry/ObjectWriter;Lio/sentry/ILogger;)V +} + +public final class io/sentry/rrweb/RRWebInteractionEvent : io/sentry/rrweb/RRWebIncrementalSnapshotEvent, io/sentry/JsonSerializable, io/sentry/JsonUnknown { + public fun ()V + public fun getDataUnknown ()Ljava/util/Map; + public fun getId ()I + public fun getInteractionType ()Lio/sentry/rrweb/RRWebInteractionEvent$InteractionType; + public fun getPointerId ()I + public fun getPointerType ()I + public fun getUnknown ()Ljava/util/Map; + public fun getX ()F + public fun getY ()F + public fun serialize (Lio/sentry/ObjectWriter;Lio/sentry/ILogger;)V + public fun setDataUnknown (Ljava/util/Map;)V + public fun setId (I)V + public fun setInteractionType (Lio/sentry/rrweb/RRWebInteractionEvent$InteractionType;)V + public fun setPointerId (I)V + public fun setPointerType (I)V + public fun setUnknown (Ljava/util/Map;)V + public fun setX (F)V + public fun setY (F)V +} + +public final class io/sentry/rrweb/RRWebInteractionEvent$Deserializer : io/sentry/JsonDeserializer { + public fun ()V + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/rrweb/RRWebInteractionEvent; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; +} + +public final class io/sentry/rrweb/RRWebInteractionEvent$InteractionType : java/lang/Enum, io/sentry/JsonSerializable { + public static final field Blur Lio/sentry/rrweb/RRWebInteractionEvent$InteractionType; + public static final field Click Lio/sentry/rrweb/RRWebInteractionEvent$InteractionType; + public static final field ContextMenu Lio/sentry/rrweb/RRWebInteractionEvent$InteractionType; + public static final field DblClick Lio/sentry/rrweb/RRWebInteractionEvent$InteractionType; + public static final field Focus Lio/sentry/rrweb/RRWebInteractionEvent$InteractionType; + public static final field MouseDown Lio/sentry/rrweb/RRWebInteractionEvent$InteractionType; + public static final field MouseUp Lio/sentry/rrweb/RRWebInteractionEvent$InteractionType; + public static final field TouchCancel Lio/sentry/rrweb/RRWebInteractionEvent$InteractionType; + public static final field TouchEnd Lio/sentry/rrweb/RRWebInteractionEvent$InteractionType; + public static final field TouchMove_Departed Lio/sentry/rrweb/RRWebInteractionEvent$InteractionType; + public static final field TouchStart Lio/sentry/rrweb/RRWebInteractionEvent$InteractionType; + public fun serialize (Lio/sentry/ObjectWriter;Lio/sentry/ILogger;)V + public static fun valueOf (Ljava/lang/String;)Lio/sentry/rrweb/RRWebInteractionEvent$InteractionType; + public static fun values ()[Lio/sentry/rrweb/RRWebInteractionEvent$InteractionType; +} + +public final class io/sentry/rrweb/RRWebInteractionEvent$InteractionType$Deserializer : io/sentry/JsonDeserializer { + public fun ()V + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/rrweb/RRWebInteractionEvent$InteractionType; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; +} + +public final class io/sentry/rrweb/RRWebInteractionEvent$JsonKeys { + public static final field DATA Ljava/lang/String; + public static final field ID Ljava/lang/String; + public static final field POINTER_ID Ljava/lang/String; + public static final field POINTER_TYPE Ljava/lang/String; + public static final field TYPE Ljava/lang/String; + public static final field X Ljava/lang/String; + public static final field Y Ljava/lang/String; + public fun ()V +} + +public final class io/sentry/rrweb/RRWebInteractionMoveEvent : io/sentry/rrweb/RRWebIncrementalSnapshotEvent, io/sentry/JsonSerializable, io/sentry/JsonUnknown { + public fun ()V + public fun getDataUnknown ()Ljava/util/Map; + public fun getPointerId ()I + public fun getPositions ()Ljava/util/List; + public fun getUnknown ()Ljava/util/Map; + public fun serialize (Lio/sentry/ObjectWriter;Lio/sentry/ILogger;)V + public fun setDataUnknown (Ljava/util/Map;)V + public fun setPointerId (I)V + public fun setPositions (Ljava/util/List;)V + public fun setUnknown (Ljava/util/Map;)V +} + +public final class io/sentry/rrweb/RRWebInteractionMoveEvent$Deserializer : io/sentry/JsonDeserializer { + public fun ()V + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/rrweb/RRWebInteractionMoveEvent; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; +} + +public final class io/sentry/rrweb/RRWebInteractionMoveEvent$JsonKeys { + public static final field DATA Ljava/lang/String; + public static final field POINTER_ID Ljava/lang/String; + public static final field POSITIONS Ljava/lang/String; + public fun ()V +} + +public final class io/sentry/rrweb/RRWebInteractionMoveEvent$Position : io/sentry/JsonSerializable, io/sentry/JsonUnknown { + public fun ()V + public fun getId ()I + public fun getTimeOffset ()J + public fun getUnknown ()Ljava/util/Map; + public fun getX ()F + public fun getY ()F + public fun serialize (Lio/sentry/ObjectWriter;Lio/sentry/ILogger;)V + public fun setId (I)V + public fun setTimeOffset (J)V + public fun setUnknown (Ljava/util/Map;)V + public fun setX (F)V + public fun setY (F)V +} + +public final class io/sentry/rrweb/RRWebInteractionMoveEvent$Position$Deserializer : io/sentry/JsonDeserializer { + public fun ()V + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/rrweb/RRWebInteractionMoveEvent$Position; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; +} + +public final class io/sentry/rrweb/RRWebInteractionMoveEvent$Position$JsonKeys { + public static final field ID Ljava/lang/String; + public static final field TIME_OFFSET Ljava/lang/String; + public static final field X Ljava/lang/String; + public static final field Y Ljava/lang/String; + public fun ()V +} + +public final class io/sentry/rrweb/RRWebMetaEvent : io/sentry/rrweb/RRWebEvent, io/sentry/JsonSerializable, io/sentry/JsonUnknown { + public fun ()V + public fun equals (Ljava/lang/Object;)Z + public fun getDataUnknown ()Ljava/util/Map; + public fun getHeight ()I + public fun getHref ()Ljava/lang/String; + public fun getUnknown ()Ljava/util/Map; + public fun getWidth ()I + public fun hashCode ()I + public fun serialize (Lio/sentry/ObjectWriter;Lio/sentry/ILogger;)V + public fun setDataUnknown (Ljava/util/Map;)V + public fun setHeight (I)V + public fun setHref (Ljava/lang/String;)V + public fun setUnknown (Ljava/util/Map;)V + public fun setWidth (I)V +} + +public final class io/sentry/rrweb/RRWebMetaEvent$Deserializer : io/sentry/JsonDeserializer { + public fun ()V + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/rrweb/RRWebMetaEvent; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; +} + +public final class io/sentry/rrweb/RRWebMetaEvent$JsonKeys { + public static final field DATA Ljava/lang/String; + public static final field HEIGHT Ljava/lang/String; + public static final field HREF Ljava/lang/String; + public static final field WIDTH Ljava/lang/String; + public fun ()V +} + +public final class io/sentry/rrweb/RRWebSpanEvent : io/sentry/rrweb/RRWebEvent, io/sentry/JsonSerializable, io/sentry/JsonUnknown { + public static final field EVENT_TAG Ljava/lang/String; + public fun ()V + public fun getData ()Ljava/util/Map; + public fun getDataUnknown ()Ljava/util/Map; + public fun getDescription ()Ljava/lang/String; + public fun getEndTimestamp ()D + public fun getOp ()Ljava/lang/String; + public fun getPayloadUnknown ()Ljava/util/Map; + public fun getStartTimestamp ()D + public fun getTag ()Ljava/lang/String; + public fun getUnknown ()Ljava/util/Map; + public fun serialize (Lio/sentry/ObjectWriter;Lio/sentry/ILogger;)V + public fun setData (Ljava/util/Map;)V + public fun setDataUnknown (Ljava/util/Map;)V + public fun setDescription (Ljava/lang/String;)V + public fun setEndTimestamp (D)V + public fun setOp (Ljava/lang/String;)V + public fun setPayloadUnknown (Ljava/util/Map;)V + public fun setStartTimestamp (D)V + public fun setTag (Ljava/lang/String;)V + public fun setUnknown (Ljava/util/Map;)V +} + +public final class io/sentry/rrweb/RRWebSpanEvent$Deserializer : io/sentry/JsonDeserializer { + public fun ()V + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/rrweb/RRWebSpanEvent; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; +} + +public final class io/sentry/rrweb/RRWebSpanEvent$JsonKeys { + public static final field DATA Ljava/lang/String; + public static final field DESCRIPTION Ljava/lang/String; + public static final field END_TIMESTAMP Ljava/lang/String; + public static final field OP Ljava/lang/String; + public static final field PAYLOAD Ljava/lang/String; + public static final field START_TIMESTAMP Ljava/lang/String; + public fun ()V +} + +public final class io/sentry/rrweb/RRWebVideoEvent : io/sentry/rrweb/RRWebEvent, io/sentry/JsonSerializable, io/sentry/JsonUnknown { + public static final field EVENT_TAG Ljava/lang/String; + public static final field REPLAY_CONTAINER Ljava/lang/String; + public static final field REPLAY_ENCODING Ljava/lang/String; + public static final field REPLAY_FRAME_RATE_TYPE_CONSTANT Ljava/lang/String; + public static final field REPLAY_FRAME_RATE_TYPE_VARIABLE Ljava/lang/String; + public fun ()V + public fun equals (Ljava/lang/Object;)Z + public fun getContainer ()Ljava/lang/String; + public fun getDataUnknown ()Ljava/util/Map; + public fun getDurationMs ()J + public fun getEncoding ()Ljava/lang/String; + public fun getFrameCount ()I + public fun getFrameRate ()I + public fun getFrameRateType ()Ljava/lang/String; + public fun getHeight ()I + public fun getLeft ()I + public fun getPayloadUnknown ()Ljava/util/Map; + public fun getSegmentId ()I + public fun getSize ()J + public fun getTag ()Ljava/lang/String; + public fun getTop ()I + public fun getUnknown ()Ljava/util/Map; + public fun getWidth ()I + public fun hashCode ()I + public fun serialize (Lio/sentry/ObjectWriter;Lio/sentry/ILogger;)V + public fun setContainer (Ljava/lang/String;)V + public fun setDataUnknown (Ljava/util/Map;)V + public fun setDurationMs (J)V + public fun setEncoding (Ljava/lang/String;)V + public fun setFrameCount (I)V + public fun setFrameRate (I)V + public fun setFrameRateType (Ljava/lang/String;)V + public fun setHeight (I)V + public fun setLeft (I)V + public fun setPayloadUnknown (Ljava/util/Map;)V + public fun setSegmentId (I)V + public fun setSize (J)V + public fun setTag (Ljava/lang/String;)V + public fun setTop (I)V + public fun setUnknown (Ljava/util/Map;)V + public fun setWidth (I)V +} + +public final class io/sentry/rrweb/RRWebVideoEvent$Deserializer : io/sentry/JsonDeserializer { + public fun ()V + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/rrweb/RRWebVideoEvent; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; +} + +public final class io/sentry/rrweb/RRWebVideoEvent$JsonKeys { + public static final field CONTAINER Ljava/lang/String; + public static final field DATA Ljava/lang/String; + public static final field DURATION Ljava/lang/String; + public static final field ENCODING Ljava/lang/String; + public static final field FRAME_COUNT Ljava/lang/String; + public static final field FRAME_RATE Ljava/lang/String; + public static final field FRAME_RATE_TYPE Ljava/lang/String; + public static final field HEIGHT Ljava/lang/String; + public static final field LEFT Ljava/lang/String; + public static final field PAYLOAD Ljava/lang/String; + public static final field SEGMENT_ID Ljava/lang/String; + public static final field SIZE Ljava/lang/String; + public static final field TOP Ljava/lang/String; + public static final field WIDTH Ljava/lang/String; + public fun ()V +} + public final class io/sentry/transport/AsyncHttpTransport : io/sentry/transport/ITransport { public fun (Lio/sentry/SentryOptions;Lio/sentry/transport/RateLimiter;Lio/sentry/transport/ITransportGate;Lio/sentry/RequestDetails;)V public fun (Lio/sentry/transport/QueuedThreadPoolExecutor;Lio/sentry/SentryOptions;Lio/sentry/transport/RateLimiter;Lio/sentry/transport/ITransportGate;Lio/sentry/transport/HttpConnection;)V @@ -5040,6 +5688,41 @@ public final class io/sentry/util/LogUtils { public static fun logNotInstanceOf (Ljava/lang/Class;Ljava/lang/Object;Lio/sentry/ILogger;)V } +public final class io/sentry/util/MapObjectReader : io/sentry/ObjectReader { + public fun (Ljava/util/Map;)V + public fun beginArray ()V + public fun beginObject ()V + public fun close ()V + public fun endArray ()V + public fun endObject ()V + public fun hasNext ()Z + public fun nextBoolean ()Z + public fun nextBooleanOrNull ()Ljava/lang/Boolean; + public fun nextDateOrNull (Lio/sentry/ILogger;)Ljava/util/Date; + public fun nextDouble ()D + public fun nextDoubleOrNull ()Ljava/lang/Double; + public fun nextFloat ()F + public fun nextFloatOrNull ()Ljava/lang/Float; + public fun nextInt ()I + public fun nextIntegerOrNull ()Ljava/lang/Integer; + public fun nextListOrNull (Lio/sentry/ILogger;Lio/sentry/JsonDeserializer;)Ljava/util/List; + public fun nextLong ()J + public fun nextLongOrNull ()Ljava/lang/Long; + public fun nextMapOfListOrNull (Lio/sentry/ILogger;Lio/sentry/JsonDeserializer;)Ljava/util/Map; + public fun nextMapOrNull (Lio/sentry/ILogger;Lio/sentry/JsonDeserializer;)Ljava/util/Map; + public fun nextName ()Ljava/lang/String; + public fun nextNull ()V + public fun nextObjectOrNull ()Ljava/lang/Object; + public fun nextOrNull (Lio/sentry/ILogger;Lio/sentry/JsonDeserializer;)Ljava/lang/Object; + public fun nextString ()Ljava/lang/String; + public fun nextStringOrNull ()Ljava/lang/String; + public fun nextTimeZoneOrNull (Lio/sentry/ILogger;)Ljava/util/TimeZone; + public fun nextUnknown (Lio/sentry/ILogger;Ljava/util/Map;Ljava/lang/String;)V + public fun peek ()Lio/sentry/vendor/gson/stream/JsonToken; + public fun setLenient (Z)V + public fun skipValue ()V +} + public final class io/sentry/util/MapObjectWriter : io/sentry/ObjectWriter { public fun (Ljava/util/Map;)V public synthetic fun beginArray ()Lio/sentry/ObjectWriter; @@ -5050,10 +5733,12 @@ public final class io/sentry/util/MapObjectWriter : io/sentry/ObjectWriter { public fun endArray ()Lio/sentry/util/MapObjectWriter; public synthetic fun endObject ()Lio/sentry/ObjectWriter; public fun endObject ()Lio/sentry/util/MapObjectWriter; + public fun jsonValue (Ljava/lang/String;)Lio/sentry/ObjectWriter; public synthetic fun name (Ljava/lang/String;)Lio/sentry/ObjectWriter; public fun name (Ljava/lang/String;)Lio/sentry/util/MapObjectWriter; public synthetic fun nullValue ()Lio/sentry/ObjectWriter; public fun nullValue ()Lio/sentry/util/MapObjectWriter; + public fun setLenient (Z)V public synthetic fun value (D)Lio/sentry/ObjectWriter; public fun value (D)Lio/sentry/util/MapObjectWriter; public synthetic fun value (J)Lio/sentry/ObjectWriter; diff --git a/sentry/build.gradle.kts b/sentry/build.gradle.kts index 2f35cbd4f7..08efc550d5 100644 --- a/sentry/build.gradle.kts +++ b/sentry/build.gradle.kts @@ -32,6 +32,7 @@ dependencies { testImplementation(Config.TestLibs.mockitoInline) testImplementation(Config.TestLibs.awaitility) testImplementation(Config.TestLibs.javaFaker) + testImplementation(Config.TestLibs.msgpack) testImplementation(projects.sentryTestSupport) } diff --git a/sentry/src/main/java/io/sentry/Baggage.java b/sentry/src/main/java/io/sentry/Baggage.java index 360e1dc7d2..de7cf95a20 100644 --- a/sentry/src/main/java/io/sentry/Baggage.java +++ b/sentry/src/main/java/io/sentry/Baggage.java @@ -141,6 +141,7 @@ public static Baggage fromEvent( // we don't persist sample rate baggage.setSampleRate(null); baggage.setSampled(null); + // TODO: add replay_id later baggage.freeze(); return baggage; } @@ -355,6 +356,16 @@ public void setSampled(final @Nullable String sampled) { set(DSCKeys.SAMPLED, sampled); } + @ApiStatus.Internal + public @Nullable String getReplayId() { + return get(DSCKeys.REPLAY_ID); + } + + @ApiStatus.Internal + public void setReplayId(final @Nullable String replayId) { + set(DSCKeys.REPLAY_ID, replayId); + } + @ApiStatus.Internal public void set(final @NotNull String key, final @Nullable String value) { if (mutable) { @@ -383,6 +394,7 @@ public void set(final @NotNull String key, final @Nullable String value) { public void setValuesFromTransaction( final @NotNull ITransaction transaction, final @Nullable User user, + final @Nullable SentryId replayId, final @NotNull SentryOptions sentryOptions, final @Nullable TracesSamplingDecision samplingDecision) { setTraceId(transaction.getSpanContext().getTraceId().toString()); @@ -394,6 +406,9 @@ public void setValuesFromTransaction( isHighQualityTransactionName(transaction.getTransactionNameSource()) ? transaction.getName() : null); + if (replayId != null && !SentryId.EMPTY_ID.equals(replayId)) { + setReplayId(replayId.toString()); + } setSampleRate(sampleRateToString(sampleRate(samplingDecision))); setSampled(StringUtils.toString(sampled(samplingDecision))); } @@ -403,10 +418,14 @@ public void setValuesFromScope( final @NotNull IScope scope, final @NotNull SentryOptions options) { final @NotNull PropagationContext propagationContext = scope.getPropagationContext(); final @Nullable User user = scope.getUser(); + final @NotNull SentryId replayId = scope.getReplayId(); setTraceId(propagationContext.getTraceId().toString()); setPublicKey(new Dsn(options.getDsn()).getPublicKey()); setRelease(options.getRelease()); setEnvironment(options.getEnvironment()); + if (!SentryId.EMPTY_ID.equals(replayId)) { + setReplayId(replayId.toString()); + } setUserSegment(user != null ? getSegment(user) : null); setTransaction(null); setSampleRate(null); @@ -482,6 +501,7 @@ private static boolean isHighQualityTransactionName( @Nullable public TraceContext toTraceContext() { final String traceIdString = getTraceId(); + final String replayIdString = getReplayId(); final String publicKey = getPublicKey(); if (traceIdString != null && publicKey != null) { @@ -496,7 +516,8 @@ public TraceContext toTraceContext() { getUserSegment(), getTransaction(), getSampleRate(), - getSampled()); + getSampled(), + replayIdString == null ? null : new SentryId(replayIdString)); traceContext.setUnknown(getUnknown()); return traceContext; } else { @@ -515,6 +536,7 @@ public static final class DSCKeys { public static final String TRANSACTION = "sentry-transaction"; public static final String SAMPLE_RATE = "sentry-sample_rate"; public static final String SAMPLED = "sentry-sampled"; + public static final String REPLAY_ID = "sentry-replay_id"; public static final List ALL = Arrays.asList( @@ -526,6 +548,7 @@ public static final class DSCKeys { USER_SEGMENT, TRANSACTION, SAMPLE_RATE, - SAMPLED); + SAMPLED, + REPLAY_ID); } } diff --git a/sentry/src/main/java/io/sentry/Breadcrumb.java b/sentry/src/main/java/io/sentry/Breadcrumb.java index fe2055c336..da1453bc68 100644 --- a/sentry/src/main/java/io/sentry/Breadcrumb.java +++ b/sentry/src/main/java/io/sentry/Breadcrumb.java @@ -86,8 +86,7 @@ public static Breadcrumb fromMap( switch (entry.getKey()) { case JsonKeys.TIMESTAMP: if (value instanceof String) { - Date deserializedDate = - JsonObjectReader.dateOrNull((String) value, options.getLogger()); + Date deserializedDate = ObjectReader.dateOrNull((String) value, options.getLogger()); if (deserializedDate != null) { timestamp = deserializedDate; } @@ -700,8 +699,8 @@ public void serialize(final @NotNull ObjectWriter writer, final @NotNull ILogger public static final class Deserializer implements JsonDeserializer { @SuppressWarnings("unchecked") @Override - public @NotNull Breadcrumb deserialize( - @NotNull JsonObjectReader reader, @NotNull ILogger logger) throws Exception { + public @NotNull Breadcrumb deserialize(@NotNull ObjectReader reader, @NotNull ILogger logger) + throws Exception { reader.beginObject(); @NotNull Date timestamp = DateUtils.getCurrentDateTime(); String message = null; diff --git a/sentry/src/main/java/io/sentry/CheckIn.java b/sentry/src/main/java/io/sentry/CheckIn.java index 4c83771324..e7c6abef3e 100644 --- a/sentry/src/main/java/io/sentry/CheckIn.java +++ b/sentry/src/main/java/io/sentry/CheckIn.java @@ -170,7 +170,7 @@ public void serialize(final @NotNull ObjectWriter writer, final @NotNull ILogger public static final class Deserializer implements JsonDeserializer { @Override - public @NotNull CheckIn deserialize(@NotNull JsonObjectReader reader, @NotNull ILogger logger) + public @NotNull CheckIn deserialize(@NotNull ObjectReader reader, @NotNull ILogger logger) throws Exception { SentryId sentryId = null; MonitorConfig monitorConfig = null; diff --git a/sentry/src/main/java/io/sentry/DataCategory.java b/sentry/src/main/java/io/sentry/DataCategory.java index a4eafc2bb5..d9acdb60cf 100644 --- a/sentry/src/main/java/io/sentry/DataCategory.java +++ b/sentry/src/main/java/io/sentry/DataCategory.java @@ -14,6 +14,7 @@ public enum DataCategory { Profile("profile"), MetricBucket("metric_bucket"), Transaction("transaction"), + Replay("replay"), Span("span"), Security("security"), UserReport("user_report"), diff --git a/sentry/src/main/java/io/sentry/EventProcessor.java b/sentry/src/main/java/io/sentry/EventProcessor.java index ba67508614..9e52408edb 100644 --- a/sentry/src/main/java/io/sentry/EventProcessor.java +++ b/sentry/src/main/java/io/sentry/EventProcessor.java @@ -32,4 +32,16 @@ default SentryEvent process(@NotNull SentryEvent event, @NotNull Hint hint) { default SentryTransaction process(@NotNull SentryTransaction transaction, @NotNull Hint hint) { return transaction; } + + /** + * May mutate or drop a SentryEvent + * + * @param event the SentryEvent + * @param hint the Hint + * @return the event itself, a mutated SentryEvent or null + */ + @Nullable + default SentryReplayEvent process(@NotNull SentryReplayEvent event, @NotNull Hint hint) { + return event; + } } diff --git a/sentry/src/main/java/io/sentry/ExperimentalOptions.java b/sentry/src/main/java/io/sentry/ExperimentalOptions.java new file mode 100644 index 0000000000..f587996bd8 --- /dev/null +++ b/sentry/src/main/java/io/sentry/ExperimentalOptions.java @@ -0,0 +1,22 @@ +package io.sentry; + +import org.jetbrains.annotations.NotNull; + +/** + * Experimental options for new features, these options are going to be promoted to SentryOptions + * before GA. + * + *

Beware that experimental options can change at any time. + */ +public final class ExperimentalOptions { + private @NotNull SentryReplayOptions sessionReplay = new SentryReplayOptions(); + + @NotNull + public SentryReplayOptions getSessionReplay() { + return sessionReplay; + } + + public void setSessionReplay(final @NotNull SentryReplayOptions sessionReplayOptions) { + this.sessionReplay = sessionReplayOptions; + } +} diff --git a/sentry/src/main/java/io/sentry/Hint.java b/sentry/src/main/java/io/sentry/Hint.java index 07dde3cb80..750017d00d 100644 --- a/sentry/src/main/java/io/sentry/Hint.java +++ b/sentry/src/main/java/io/sentry/Hint.java @@ -29,8 +29,8 @@ public final class Hint { private final @NotNull List attachments = new ArrayList<>(); private @Nullable Attachment screenshot = null; private @Nullable Attachment viewHierarchy = null; - private @Nullable Attachment threadDump = null; + private @Nullable ReplayRecording replayRecording = null; public static @NotNull Hint withAttachment(@Nullable Attachment attachment) { @NotNull final Hint hint = new Hint(); @@ -136,6 +136,15 @@ public void setThreadDump(final @Nullable Attachment threadDump) { return threadDump; } + @Nullable + public ReplayRecording getReplayRecording() { + return replayRecording; + } + + public void setReplayRecording(final @Nullable ReplayRecording replayRecording) { + this.replayRecording = replayRecording; + } + private boolean isCastablePrimitive(@Nullable Object hintValue, @NotNull Class clazz) { Class nonPrimitiveClass = PRIMITIVE_MAPPINGS.get(clazz.getCanonicalName()); return hintValue != null diff --git a/sentry/src/main/java/io/sentry/Hub.java b/sentry/src/main/java/io/sentry/Hub.java index b993b84ebc..240c6b54f2 100644 --- a/sentry/src/main/java/io/sentry/Hub.java +++ b/sentry/src/main/java/io/sentry/Hub.java @@ -949,6 +949,27 @@ private IScope buildLocalScope( return sentryId; } + @Override + public @NotNull SentryId captureReplay( + final @NotNull SentryReplayEvent replay, final @Nullable Hint hint) { + SentryId sentryId = SentryId.EMPTY_ID; + if (!isEnabled()) { + options + .getLogger() + .log( + SentryLevel.WARNING, + "Instance is disabled and this 'captureReplay' call is a no-op."); + } else { + try { + final @NotNull StackItem item = stack.peek(); + sentryId = item.getClient().captureReplayEvent(replay, item.getScope(), hint); + } catch (Throwable e) { + options.getLogger().log(SentryLevel.ERROR, "Error while capturing replay", e); + } + } + return sentryId; + } + @ApiStatus.Internal @Override public @Nullable RateLimiter getRateLimiter() { diff --git a/sentry/src/main/java/io/sentry/HubAdapter.java b/sentry/src/main/java/io/sentry/HubAdapter.java index b31d853192..d5adc4da80 100644 --- a/sentry/src/main/java/io/sentry/HubAdapter.java +++ b/sentry/src/main/java/io/sentry/HubAdapter.java @@ -268,6 +268,12 @@ public void reportFullyDisplayed() { return Sentry.captureCheckIn(checkIn); } + @Override + public @NotNull SentryId captureReplay( + final @NotNull SentryReplayEvent replay, final @Nullable Hint hint) { + return Sentry.getCurrentHub().captureReplay(replay, hint); + } + @ApiStatus.Internal @Override public @Nullable RateLimiter getRateLimiter() { diff --git a/sentry/src/main/java/io/sentry/IHub.java b/sentry/src/main/java/io/sentry/IHub.java index 684d8ec528..6ae5a00925 100644 --- a/sentry/src/main/java/io/sentry/IHub.java +++ b/sentry/src/main/java/io/sentry/IHub.java @@ -580,6 +580,9 @@ TransactionContext continueTrace( @NotNull SentryId captureCheckIn(final @NotNull CheckIn checkIn); + @NotNull + SentryId captureReplay(@NotNull SentryReplayEvent replay, @Nullable Hint hint); + @ApiStatus.Internal @Nullable RateLimiter getRateLimiter(); diff --git a/sentry/src/main/java/io/sentry/IScope.java b/sentry/src/main/java/io/sentry/IScope.java index 3064df8f79..a8acb4277f 100644 --- a/sentry/src/main/java/io/sentry/IScope.java +++ b/sentry/src/main/java/io/sentry/IScope.java @@ -2,6 +2,7 @@ import io.sentry.protocol.Contexts; import io.sentry.protocol.Request; +import io.sentry.protocol.SentryId; import io.sentry.protocol.User; import java.util.Collection; import java.util.List; @@ -84,6 +85,23 @@ public interface IScope { @ApiStatus.Internal void setScreen(final @Nullable String screen); + /** + * Returns the Scope's current replay_id, previously set by {@link IScope#setReplayId(SentryId)} + * + * @return the id of the current session replay + */ + @ApiStatus.Internal + @NotNull + SentryId getReplayId(); + + /** + * Sets the Scope's current replay_id + * + * @param replayId the id of the current session replay + */ + @ApiStatus.Internal + void setReplayId(final @NotNull SentryId replayId); + /** * Returns the Scope's request * diff --git a/sentry/src/main/java/io/sentry/ISentryClient.java b/sentry/src/main/java/io/sentry/ISentryClient.java index 8685e1db2e..8d1815b4c8 100644 --- a/sentry/src/main/java/io/sentry/ISentryClient.java +++ b/sentry/src/main/java/io/sentry/ISentryClient.java @@ -154,6 +154,10 @@ public interface ISentryClient { return captureException(throwable, scope, null); } + @NotNull + SentryId captureReplayEvent( + @NotNull SentryReplayEvent event, @Nullable IScope scope, @Nullable Hint hint); + /** * Captures a manually created user feedback and sends it to Sentry. * diff --git a/sentry/src/main/java/io/sentry/JsonDeserializer.java b/sentry/src/main/java/io/sentry/JsonDeserializer.java index 7e62814fe6..390328231b 100644 --- a/sentry/src/main/java/io/sentry/JsonDeserializer.java +++ b/sentry/src/main/java/io/sentry/JsonDeserializer.java @@ -6,5 +6,5 @@ @ApiStatus.Internal public interface JsonDeserializer { @NotNull - T deserialize(@NotNull JsonObjectReader reader, @NotNull ILogger logger) throws Exception; + T deserialize(@NotNull ObjectReader reader, @NotNull ILogger logger) throws Exception; } diff --git a/sentry/src/main/java/io/sentry/JsonObjectReader.java b/sentry/src/main/java/io/sentry/JsonObjectReader.java index 533d8cffb6..f9fe184184 100644 --- a/sentry/src/main/java/io/sentry/JsonObjectReader.java +++ b/sentry/src/main/java/io/sentry/JsonObjectReader.java @@ -15,64 +15,74 @@ import org.jetbrains.annotations.Nullable; @ApiStatus.Internal -public final class JsonObjectReader extends JsonReader { +public final class JsonObjectReader implements ObjectReader { + + private final @NotNull JsonReader jsonReader; public JsonObjectReader(Reader in) { - super(in); + this.jsonReader = new JsonReader(in); } + @Override public @Nullable String nextStringOrNull() throws IOException { - if (peek() == JsonToken.NULL) { - nextNull(); + if (jsonReader.peek() == JsonToken.NULL) { + jsonReader.nextNull(); return null; } - return nextString(); + return jsonReader.nextString(); } + @Override public @Nullable Double nextDoubleOrNull() throws IOException { - if (peek() == JsonToken.NULL) { - nextNull(); + if (jsonReader.peek() == JsonToken.NULL) { + jsonReader.nextNull(); return null; } - return nextDouble(); + return jsonReader.nextDouble(); } + @Override public @Nullable Float nextFloatOrNull() throws IOException { - if (peek() == JsonToken.NULL) { - nextNull(); + if (jsonReader.peek() == JsonToken.NULL) { + jsonReader.nextNull(); return null; } return nextFloat(); } - public @NotNull Float nextFloat() throws IOException { - return (float) nextDouble(); + @Override + public float nextFloat() throws IOException { + return (float) jsonReader.nextDouble(); } + @Override public @Nullable Long nextLongOrNull() throws IOException { - if (peek() == JsonToken.NULL) { - nextNull(); + if (jsonReader.peek() == JsonToken.NULL) { + jsonReader.nextNull(); return null; } - return nextLong(); + return jsonReader.nextLong(); } + @Override public @Nullable Integer nextIntegerOrNull() throws IOException { - if (peek() == JsonToken.NULL) { - nextNull(); + if (jsonReader.peek() == JsonToken.NULL) { + jsonReader.nextNull(); return null; } - return nextInt(); + return jsonReader.nextInt(); } + @Override public @Nullable Boolean nextBooleanOrNull() throws IOException { - if (peek() == JsonToken.NULL) { - nextNull(); + if (jsonReader.peek() == JsonToken.NULL) { + jsonReader.nextNull(); return null; } - return nextBoolean(); + return jsonReader.nextBoolean(); } + @Override public void nextUnknown(ILogger logger, Map unknown, String name) { try { unknown.put(name, nextObjectOrNull()); @@ -81,50 +91,53 @@ public void nextUnknown(ILogger logger, Map unknown, String name } } + @Override public @Nullable List nextListOrNull( @NotNull ILogger logger, @NotNull JsonDeserializer deserializer) throws IOException { - if (peek() == JsonToken.NULL) { - nextNull(); + if (jsonReader.peek() == JsonToken.NULL) { + jsonReader.nextNull(); return null; } - beginArray(); + jsonReader.beginArray(); List list = new ArrayList<>(); - if (hasNext()) { + if (jsonReader.hasNext()) { do { try { list.add(deserializer.deserialize(this, logger)); } catch (Exception e) { logger.log(SentryLevel.WARNING, "Failed to deserialize object in list.", e); } - } while (peek() == JsonToken.BEGIN_OBJECT); + } while (jsonReader.peek() == JsonToken.BEGIN_OBJECT); } - endArray(); + jsonReader.endArray(); return list; } + @Override public @Nullable Map nextMapOrNull( @NotNull ILogger logger, @NotNull JsonDeserializer deserializer) throws IOException { - if (peek() == JsonToken.NULL) { - nextNull(); + if (jsonReader.peek() == JsonToken.NULL) { + jsonReader.nextNull(); return null; } - beginObject(); + jsonReader.beginObject(); Map map = new HashMap<>(); - if (hasNext()) { + if (jsonReader.hasNext()) { do { try { - String key = nextName(); + String key = jsonReader.nextName(); map.put(key, deserializer.deserialize(this, logger)); } catch (Exception e) { logger.log(SentryLevel.WARNING, "Failed to deserialize object in map.", e); } - } while (peek() == JsonToken.BEGIN_OBJECT || peek() == JsonToken.NAME); + } while (jsonReader.peek() == JsonToken.BEGIN_OBJECT || jsonReader.peek() == JsonToken.NAME); } - endObject(); + jsonReader.endObject(); return map; } + @Override public @Nullable Map> nextMapOfListOrNull( @NotNull ILogger logger, @NotNull JsonDeserializer deserializer) throws IOException { @@ -149,46 +162,33 @@ public void nextUnknown(ILogger logger, Map unknown, String name return result; } + @Override public @Nullable T nextOrNull( @NotNull ILogger logger, @NotNull JsonDeserializer deserializer) throws Exception { - if (peek() == JsonToken.NULL) { - nextNull(); + if (jsonReader.peek() == JsonToken.NULL) { + jsonReader.nextNull(); return null; } return deserializer.deserialize(this, logger); } + @Override public @Nullable Date nextDateOrNull(ILogger logger) throws IOException { - if (peek() == JsonToken.NULL) { - nextNull(); + if (jsonReader.peek() == JsonToken.NULL) { + jsonReader.nextNull(); return null; } - return JsonObjectReader.dateOrNull(nextString(), logger); - } - - public static @Nullable Date dateOrNull(@Nullable String dateString, ILogger logger) { - if (dateString == null) { - return null; - } - try { - return DateUtils.getDateTime(dateString); - } catch (Exception ignored) { - try { - return DateUtils.getDateTimeWithMillisPrecision(dateString); - } catch (Exception e) { - logger.log(SentryLevel.ERROR, "Error when deserializing millis timestamp format.", e); - } - } - return null; + return ObjectReader.dateOrNull(jsonReader.nextString(), logger); } + @Override public @Nullable TimeZone nextTimeZoneOrNull(ILogger logger) throws IOException { - if (peek() == JsonToken.NULL) { - nextNull(); + if (jsonReader.peek() == JsonToken.NULL) { + jsonReader.nextNull(); return null; } try { - return TimeZone.getTimeZone(nextString()); + return TimeZone.getTimeZone(jsonReader.nextString()); } catch (Exception e) { logger.log(SentryLevel.ERROR, "Error when deserializing TimeZone", e); } @@ -201,7 +201,88 @@ public void nextUnknown(ILogger logger, Map unknown, String name * * @return The deserialized object from json. */ + @Override public @Nullable Object nextObjectOrNull() throws IOException { return new JsonObjectDeserializer().deserialize(this); } + + @Override + public @NotNull JsonToken peek() throws IOException { + return jsonReader.peek(); + } + + @Override + public @NotNull String nextName() throws IOException { + return jsonReader.nextName(); + } + + @Override + public void beginObject() throws IOException { + jsonReader.beginObject(); + } + + @Override + public void endObject() throws IOException { + jsonReader.endObject(); + } + + @Override + public void beginArray() throws IOException { + jsonReader.beginArray(); + } + + @Override + public void endArray() throws IOException { + jsonReader.endArray(); + } + + @Override + public boolean hasNext() throws IOException { + return jsonReader.hasNext(); + } + + @Override + public int nextInt() throws IOException { + return jsonReader.nextInt(); + } + + @Override + public long nextLong() throws IOException { + return jsonReader.nextLong(); + } + + @Override + public String nextString() throws IOException { + return jsonReader.nextString(); + } + + @Override + public boolean nextBoolean() throws IOException { + return jsonReader.nextBoolean(); + } + + @Override + public double nextDouble() throws IOException { + return jsonReader.nextDouble(); + } + + @Override + public void nextNull() throws IOException { + jsonReader.nextNull(); + } + + @Override + public void setLenient(boolean lenient) { + jsonReader.setLenient(lenient); + } + + @Override + public void skipValue() throws IOException { + jsonReader.skipValue(); + } + + @Override + public void close() throws IOException { + jsonReader.close(); + } } diff --git a/sentry/src/main/java/io/sentry/JsonObjectWriter.java b/sentry/src/main/java/io/sentry/JsonObjectWriter.java index b174ddb484..f1e84e6d5a 100644 --- a/sentry/src/main/java/io/sentry/JsonObjectWriter.java +++ b/sentry/src/main/java/io/sentry/JsonObjectWriter.java @@ -52,6 +52,12 @@ public JsonObjectWriter value(final @Nullable String value) throws IOException { return this; } + @Override + public ObjectWriter jsonValue(@Nullable String value) throws IOException { + jsonWriter.jsonValue(value); + return this; + } + @Override public JsonObjectWriter nullValue() throws IOException { jsonWriter.nullValue(); @@ -103,6 +109,11 @@ public JsonObjectWriter value(final @NotNull ILogger logger, final @Nullable Obj return this; } + @Override + public void setLenient(final boolean lenient) { + jsonWriter.setLenient(lenient); + } + public void setIndent(final @NotNull String indent) { jsonWriter.setIndent(indent); } diff --git a/sentry/src/main/java/io/sentry/JsonSerializer.java b/sentry/src/main/java/io/sentry/JsonSerializer.java index 022a3d2044..6c46306cc7 100644 --- a/sentry/src/main/java/io/sentry/JsonSerializer.java +++ b/sentry/src/main/java/io/sentry/JsonSerializer.java @@ -30,6 +30,13 @@ import io.sentry.protocol.User; import io.sentry.protocol.ViewHierarchy; import io.sentry.protocol.ViewHierarchyNode; +import io.sentry.rrweb.RRWebBreadcrumbEvent; +import io.sentry.rrweb.RRWebEventType; +import io.sentry.rrweb.RRWebInteractionEvent; +import io.sentry.rrweb.RRWebInteractionMoveEvent; +import io.sentry.rrweb.RRWebMetaEvent; +import io.sentry.rrweb.RRWebSpanEvent; +import io.sentry.rrweb.RRWebVideoEvent; import io.sentry.util.Objects; import java.io.BufferedOutputStream; import java.io.BufferedWriter; @@ -91,6 +98,15 @@ public JsonSerializer(@NotNull SentryOptions options) { deserializersByClass.put( ProfileMeasurementValue.class, new ProfileMeasurementValue.Deserializer()); deserializersByClass.put(Request.class, new Request.Deserializer()); + deserializersByClass.put(ReplayRecording.class, new ReplayRecording.Deserializer()); + deserializersByClass.put(RRWebBreadcrumbEvent.class, new RRWebBreadcrumbEvent.Deserializer()); + deserializersByClass.put(RRWebEventType.class, new RRWebEventType.Deserializer()); + deserializersByClass.put(RRWebInteractionEvent.class, new RRWebInteractionEvent.Deserializer()); + deserializersByClass.put( + RRWebInteractionMoveEvent.class, new RRWebInteractionMoveEvent.Deserializer()); + deserializersByClass.put(RRWebMetaEvent.class, new RRWebMetaEvent.Deserializer()); + deserializersByClass.put(RRWebSpanEvent.class, new RRWebSpanEvent.Deserializer()); + deserializersByClass.put(RRWebVideoEvent.class, new RRWebVideoEvent.Deserializer()); deserializersByClass.put(SdkInfo.class, new SdkInfo.Deserializer()); deserializersByClass.put(SdkVersion.class, new SdkVersion.Deserializer()); deserializersByClass.put(SentryEnvelopeHeader.class, new SentryEnvelopeHeader.Deserializer()); @@ -103,6 +119,7 @@ public JsonSerializer(@NotNull SentryOptions options) { deserializersByClass.put(SentryLockReason.class, new SentryLockReason.Deserializer()); deserializersByClass.put(SentryPackage.class, new SentryPackage.Deserializer()); deserializersByClass.put(SentryRuntime.class, new SentryRuntime.Deserializer()); + deserializersByClass.put(SentryReplayEvent.class, new SentryReplayEvent.Deserializer()); deserializersByClass.put(SentrySpan.class, new SentrySpan.Deserializer()); deserializersByClass.put(SentryStackFrame.class, new SentryStackFrame.Deserializer()); deserializersByClass.put(SentryStackTrace.class, new SentryStackTrace.Deserializer()); diff --git a/sentry/src/main/java/io/sentry/MainEventProcessor.java b/sentry/src/main/java/io/sentry/MainEventProcessor.java index abbf21c84e..d6445e3a56 100644 --- a/sentry/src/main/java/io/sentry/MainEventProcessor.java +++ b/sentry/src/main/java/io/sentry/MainEventProcessor.java @@ -149,6 +149,20 @@ private void processNonCachedEvent(final @NotNull SentryBaseEvent event) { return transaction; } + @Override + public @NotNull SentryReplayEvent process( + final @NotNull SentryReplayEvent event, final @NotNull Hint hint) { + setCommons(event); + // TODO: maybe later it's needed to deobfuscate something (e.g. view hierarchy), for now the + // TODO: protocol does not support it + // setDebugMeta(event); + + if (shouldApplyScopeData(event, hint)) { + processNonCachedEvent(event); + } + return event; + } + private void setCommons(final @NotNull SentryBaseEvent event) { setPlatform(event); } diff --git a/sentry/src/main/java/io/sentry/MonitorConfig.java b/sentry/src/main/java/io/sentry/MonitorConfig.java index d954a50466..07e76d856d 100644 --- a/sentry/src/main/java/io/sentry/MonitorConfig.java +++ b/sentry/src/main/java/io/sentry/MonitorConfig.java @@ -138,8 +138,8 @@ public void serialize(final @NotNull ObjectWriter writer, final @NotNull ILogger public static final class Deserializer implements JsonDeserializer { @Override - public @NotNull MonitorConfig deserialize( - @NotNull JsonObjectReader reader, @NotNull ILogger logger) throws Exception { + public @NotNull MonitorConfig deserialize(@NotNull ObjectReader reader, @NotNull ILogger logger) + throws Exception { MonitorSchedule schedule = null; Long checkinMargin = null; Long maxRuntime = null; diff --git a/sentry/src/main/java/io/sentry/MonitorContexts.java b/sentry/src/main/java/io/sentry/MonitorContexts.java index 3a15aa4113..00ccb680fc 100644 --- a/sentry/src/main/java/io/sentry/MonitorContexts.java +++ b/sentry/src/main/java/io/sentry/MonitorContexts.java @@ -66,7 +66,7 @@ public static final class Deserializer implements JsonDeserializer { @Override public @NotNull MonitorSchedule deserialize( - @NotNull JsonObjectReader reader, @NotNull ILogger logger) throws Exception { + @NotNull ObjectReader reader, @NotNull ILogger logger) throws Exception { String type = null; String value = null; String unit = null; diff --git a/sentry/src/main/java/io/sentry/NoOpHub.java b/sentry/src/main/java/io/sentry/NoOpHub.java index e51cea8d2d..88488fbda0 100644 --- a/sentry/src/main/java/io/sentry/NoOpHub.java +++ b/sentry/src/main/java/io/sentry/NoOpHub.java @@ -225,6 +225,11 @@ public void reportFullyDisplayed() {} return SentryId.EMPTY_ID; } + @Override + public @NotNull SentryId captureReplay(@NotNull SentryReplayEvent replay, @Nullable Hint hint) { + return SentryId.EMPTY_ID; + } + @Override public @Nullable RateLimiter getRateLimiter() { return null; diff --git a/sentry/src/main/java/io/sentry/NoOpReplayBreadcrumbConverter.java b/sentry/src/main/java/io/sentry/NoOpReplayBreadcrumbConverter.java new file mode 100644 index 0000000000..d71a57e440 --- /dev/null +++ b/sentry/src/main/java/io/sentry/NoOpReplayBreadcrumbConverter.java @@ -0,0 +1,21 @@ +package io.sentry; + +import io.sentry.rrweb.RRWebEvent; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +public final class NoOpReplayBreadcrumbConverter implements ReplayBreadcrumbConverter { + + private static final NoOpReplayBreadcrumbConverter instance = new NoOpReplayBreadcrumbConverter(); + + public static NoOpReplayBreadcrumbConverter getInstance() { + return instance; + } + + private NoOpReplayBreadcrumbConverter() {} + + @Override + public @Nullable RRWebEvent convert(final @NotNull Breadcrumb breadcrumb) { + return null; + } +} diff --git a/sentry/src/main/java/io/sentry/NoOpReplayController.java b/sentry/src/main/java/io/sentry/NoOpReplayController.java new file mode 100644 index 0000000000..d365f650ea --- /dev/null +++ b/sentry/src/main/java/io/sentry/NoOpReplayController.java @@ -0,0 +1,53 @@ +package io.sentry; + +import io.sentry.protocol.SentryId; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +public final class NoOpReplayController implements ReplayController { + + private static final NoOpReplayController instance = new NoOpReplayController(); + + public static NoOpReplayController getInstance() { + return instance; + } + + private NoOpReplayController() {} + + @Override + public void start() {} + + @Override + public void stop() {} + + @Override + public void pause() {} + + @Override + public void resume() {} + + @Override + public boolean isRecording() { + return false; + } + + @Override + public void sendReplayForEvent(@NotNull SentryEvent event, @NotNull Hint hint) {} + + @Override + public void sendReplay( + @Nullable Boolean isCrashed, @Nullable String eventId, @Nullable Hint hint) {} + + @Override + public @NotNull SentryId getReplayId() { + return SentryId.EMPTY_ID; + } + + @Override + public void setBreadcrumbConverter(@NotNull ReplayBreadcrumbConverter converter) {} + + @Override + public @NotNull ReplayBreadcrumbConverter getBreadcrumbConverter() { + return NoOpReplayBreadcrumbConverter.getInstance(); + } +} diff --git a/sentry/src/main/java/io/sentry/NoOpScope.java b/sentry/src/main/java/io/sentry/NoOpScope.java index ed787b0029..3a554476ae 100644 --- a/sentry/src/main/java/io/sentry/NoOpScope.java +++ b/sentry/src/main/java/io/sentry/NoOpScope.java @@ -2,6 +2,7 @@ import io.sentry.protocol.Contexts; import io.sentry.protocol.Request; +import io.sentry.protocol.SentryId; import io.sentry.protocol.User; import java.util.ArrayDeque; import java.util.ArrayList; @@ -68,6 +69,14 @@ public void setUser(@Nullable User user) {} @Override public void setScreen(@Nullable String screen) {} + @Override + public @NotNull SentryId getReplayId() { + return SentryId.EMPTY_ID; + } + + @Override + public void setReplayId(@Nullable SentryId replayId) {} + @Override public @Nullable Request getRequest() { return null; diff --git a/sentry/src/main/java/io/sentry/NoOpSentryClient.java b/sentry/src/main/java/io/sentry/NoOpSentryClient.java index 3ae70b4bf5..f00f309544 100644 --- a/sentry/src/main/java/io/sentry/NoOpSentryClient.java +++ b/sentry/src/main/java/io/sentry/NoOpSentryClient.java @@ -66,6 +66,12 @@ public SentryId captureEnvelope(@NotNull SentryEnvelope envelope, @Nullable Hint return SentryId.EMPTY_ID; } + @Override + public @NotNull SentryId captureReplayEvent( + @NotNull SentryReplayEvent event, @Nullable IScope scope, @Nullable Hint hint) { + return SentryId.EMPTY_ID; + } + @Override public @Nullable RateLimiter getRateLimiter() { return null; diff --git a/sentry/src/main/java/io/sentry/ObjectReader.java b/sentry/src/main/java/io/sentry/ObjectReader.java new file mode 100644 index 0000000000..6ea43926b0 --- /dev/null +++ b/sentry/src/main/java/io/sentry/ObjectReader.java @@ -0,0 +1,105 @@ +package io.sentry; + +import io.sentry.vendor.gson.stream.JsonToken; +import java.io.Closeable; +import java.io.IOException; +import java.util.Date; +import java.util.List; +import java.util.Map; +import java.util.TimeZone; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +public interface ObjectReader extends Closeable { + static @Nullable Date dateOrNull( + final @Nullable String dateString, final @NotNull ILogger logger) { + if (dateString == null) { + return null; + } + try { + return DateUtils.getDateTime(dateString); + } catch (Exception ignored) { + try { + return DateUtils.getDateTimeWithMillisPrecision(dateString); + } catch (Exception e) { + logger.log(SentryLevel.ERROR, "Error when deserializing millis timestamp format.", e); + } + } + return null; + } + + void nextUnknown(ILogger logger, Map unknown, String name); + + @Nullable List nextListOrNull( + @NotNull ILogger logger, @NotNull JsonDeserializer deserializer) throws IOException; + + @Nullable Map nextMapOrNull( + @NotNull ILogger logger, @NotNull JsonDeserializer deserializer) throws IOException; + + @Nullable Map> nextMapOfListOrNull( + @NotNull ILogger logger, @NotNull JsonDeserializer deserializer) throws IOException; + + @Nullable T nextOrNull(@NotNull ILogger logger, @NotNull JsonDeserializer deserializer) + throws Exception; + + @Nullable + Date nextDateOrNull(ILogger logger) throws IOException; + + @Nullable + TimeZone nextTimeZoneOrNull(ILogger logger) throws IOException; + + @Nullable + Object nextObjectOrNull() throws IOException; + + @NotNull + JsonToken peek() throws IOException; + + @NotNull + String nextName() throws IOException; + + void beginObject() throws IOException; + + void endObject() throws IOException; + + void beginArray() throws IOException; + + void endArray() throws IOException; + + boolean hasNext() throws IOException; + + int nextInt() throws IOException; + + @Nullable + Integer nextIntegerOrNull() throws IOException; + + long nextLong() throws IOException; + + @Nullable + Long nextLongOrNull() throws IOException; + + String nextString() throws IOException; + + @Nullable + String nextStringOrNull() throws IOException; + + boolean nextBoolean() throws IOException; + + @Nullable + Boolean nextBooleanOrNull() throws IOException; + + double nextDouble() throws IOException; + + @Nullable + Double nextDoubleOrNull() throws IOException; + + float nextFloat() throws IOException; + + @Nullable + Float nextFloatOrNull() throws IOException; + + void nextNull() throws IOException; + + void setLenient(boolean lenient); + + void skipValue() throws IOException; +} diff --git a/sentry/src/main/java/io/sentry/ObjectWriter.java b/sentry/src/main/java/io/sentry/ObjectWriter.java index ea8d4e83ea..91e64a0c8b 100644 --- a/sentry/src/main/java/io/sentry/ObjectWriter.java +++ b/sentry/src/main/java/io/sentry/ObjectWriter.java @@ -17,6 +17,8 @@ public interface ObjectWriter { ObjectWriter value(final @Nullable String value) throws IOException; + ObjectWriter jsonValue(final @Nullable String value) throws IOException; + ObjectWriter nullValue() throws IOException; ObjectWriter value(final boolean value) throws IOException; @@ -31,4 +33,6 @@ public interface ObjectWriter { ObjectWriter value(final @NotNull ILogger logger, final @Nullable Object object) throws IOException; + + void setLenient(boolean lenient); } diff --git a/sentry/src/main/java/io/sentry/ProfilingTraceData.java b/sentry/src/main/java/io/sentry/ProfilingTraceData.java index d1410245af..17332b5931 100644 --- a/sentry/src/main/java/io/sentry/ProfilingTraceData.java +++ b/sentry/src/main/java/io/sentry/ProfilingTraceData.java @@ -463,7 +463,7 @@ public static final class Deserializer implements JsonDeserializer unknown = null; diff --git a/sentry/src/main/java/io/sentry/ProfilingTransactionData.java b/sentry/src/main/java/io/sentry/ProfilingTransactionData.java index 46ba9bba44..045b859f05 100644 --- a/sentry/src/main/java/io/sentry/ProfilingTransactionData.java +++ b/sentry/src/main/java/io/sentry/ProfilingTransactionData.java @@ -179,7 +179,7 @@ public static final class Deserializer implements JsonDeserializer unknown = null; diff --git a/sentry/src/main/java/io/sentry/ReplayBreadcrumbConverter.java b/sentry/src/main/java/io/sentry/ReplayBreadcrumbConverter.java new file mode 100644 index 0000000000..dadd5d9b6f --- /dev/null +++ b/sentry/src/main/java/io/sentry/ReplayBreadcrumbConverter.java @@ -0,0 +1,12 @@ +package io.sentry; + +import io.sentry.rrweb.RRWebEvent; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +@ApiStatus.Internal +public interface ReplayBreadcrumbConverter { + @Nullable + RRWebEvent convert(@NotNull Breadcrumb breadcrumb); +} diff --git a/sentry/src/main/java/io/sentry/ReplayController.java b/sentry/src/main/java/io/sentry/ReplayController.java new file mode 100644 index 0000000000..caaa847423 --- /dev/null +++ b/sentry/src/main/java/io/sentry/ReplayController.java @@ -0,0 +1,31 @@ +package io.sentry; + +import io.sentry.protocol.SentryId; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +@ApiStatus.Internal +public interface ReplayController { + void start(); + + void stop(); + + void pause(); + + void resume(); + + boolean isRecording(); + + void sendReplayForEvent(@NotNull SentryEvent event, @NotNull Hint hint); + + void sendReplay(@Nullable Boolean isCrashed, @Nullable String eventId, @Nullable Hint hint); + + @NotNull + SentryId getReplayId(); + + void setBreadcrumbConverter(@NotNull ReplayBreadcrumbConverter converter); + + @NotNull + ReplayBreadcrumbConverter getBreadcrumbConverter(); +} diff --git a/sentry/src/main/java/io/sentry/ReplayRecording.java b/sentry/src/main/java/io/sentry/ReplayRecording.java new file mode 100644 index 0000000000..ca1c676dbd --- /dev/null +++ b/sentry/src/main/java/io/sentry/ReplayRecording.java @@ -0,0 +1,237 @@ +package io.sentry; + +import io.sentry.rrweb.RRWebBreadcrumbEvent; +import io.sentry.rrweb.RRWebEvent; +import io.sentry.rrweb.RRWebEventType; +import io.sentry.rrweb.RRWebIncrementalSnapshotEvent; +import io.sentry.rrweb.RRWebInteractionEvent; +import io.sentry.rrweb.RRWebInteractionMoveEvent; +import io.sentry.rrweb.RRWebMetaEvent; +import io.sentry.rrweb.RRWebSpanEvent; +import io.sentry.rrweb.RRWebVideoEvent; +import io.sentry.util.MapObjectReader; +import io.sentry.util.Objects; +import io.sentry.vendor.gson.stream.JsonToken; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +public final class ReplayRecording implements JsonUnknown, JsonSerializable { + + public static final class JsonKeys { + public static final String SEGMENT_ID = "segment_id"; + } + + private @Nullable Integer segmentId; + private @Nullable List payload; + private @Nullable Map unknown; + + @Nullable + public Integer getSegmentId() { + return segmentId; + } + + public void setSegmentId(final @Nullable Integer segmentId) { + this.segmentId = segmentId; + } + + @Nullable + public List getPayload() { + return payload; + } + + public void setPayload(final @Nullable List payload) { + this.payload = payload; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + ReplayRecording that = (ReplayRecording) o; + return Objects.equals(segmentId, that.segmentId) && Objects.equals(payload, that.payload); + } + + @Override + public int hashCode() { + return Objects.hash(segmentId, payload); + } + + @Override + public void serialize(final @NotNull ObjectWriter writer, final @NotNull ILogger logger) + throws IOException { + writer.beginObject(); + if (segmentId != null) { + writer.name(JsonKeys.SEGMENT_ID).value(segmentId); + } + + if (unknown != null) { + for (String key : unknown.keySet()) { + Object value = unknown.get(key); + writer.name(key).value(logger, value); + } + } + writer.endObject(); + + // {"segment_id":0}\n{json-serialized-rrweb-protocol} + + writer.setLenient(true); + writer.jsonValue("\n"); + if (payload != null) { + writer.value(logger, payload); + } + writer.setLenient(false); + } + + @Override + public @Nullable Map getUnknown() { + return unknown; + } + + @Override + public void setUnknown(@Nullable Map unknown) { + this.unknown = unknown; + } + + public static final class Deserializer implements JsonDeserializer { + + @SuppressWarnings("unchecked") + @Override + public @NotNull ReplayRecording deserialize( + @NotNull ObjectReader reader, @NotNull ILogger logger) throws Exception { + + final ReplayRecording replay = new ReplayRecording(); + + @Nullable Map unknown = null; + @Nullable Integer segmentId = null; + @Nullable List payload = null; + + reader.beginObject(); + while (reader.peek() == JsonToken.NAME) { + final String nextName = reader.nextName(); + switch (nextName) { + case JsonKeys.SEGMENT_ID: + segmentId = reader.nextIntegerOrNull(); + break; + default: + if (unknown == null) { + unknown = new HashMap<>(); + } + reader.nextUnknown(logger, unknown, nextName); + break; + } + } + reader.endObject(); + + // {"segment_id":0}\n{json-serialized-rrweb-protocol} + + reader.setLenient(true); + List events = (List) reader.nextObjectOrNull(); + reader.setLenient(false); + + // since we lose the type of an rrweb event at runtime, we have to recover it from a map + if (events != null) { + payload = new ArrayList<>(events.size()); + for (Object event : events) { + if (event instanceof Map) { + final Map eventMap = (Map) event; + final ObjectReader mapReader = new MapObjectReader(eventMap); + for (final Map.Entry entry : eventMap.entrySet()) { + final String key = entry.getKey(); + final Object value = entry.getValue(); + if (key.equals(RRWebEvent.JsonKeys.TYPE)) { + final RRWebEventType type = RRWebEventType.values()[(int) value]; + switch (type) { + case IncrementalSnapshot: + @Nullable + Map incrementalData = + (Map) eventMap.get("data"); + if (incrementalData == null) { + incrementalData = Collections.emptyMap(); + } + final Integer sourceInt = + (Integer) + incrementalData.get(RRWebIncrementalSnapshotEvent.JsonKeys.SOURCE); + if (sourceInt != null) { + final RRWebIncrementalSnapshotEvent.IncrementalSource source = + RRWebIncrementalSnapshotEvent.IncrementalSource.values()[sourceInt]; + switch (source) { + case MouseInteraction: + final RRWebInteractionEvent interactionEvent = + new RRWebInteractionEvent.Deserializer() + .deserialize(mapReader, logger); + payload.add(interactionEvent); + break; + case TouchMove: + final RRWebInteractionMoveEvent interactionMoveEvent = + new RRWebInteractionMoveEvent.Deserializer() + .deserialize(mapReader, logger); + payload.add(interactionMoveEvent); + break; + default: + logger.log( + SentryLevel.DEBUG, + "Unsupported rrweb incremental snapshot type %s", + source); + break; + } + } + break; + case Meta: + final RRWebEvent metaEvent = + new RRWebMetaEvent.Deserializer().deserialize(mapReader, logger); + payload.add(metaEvent); + break; + case Custom: + @Nullable + Map customData = (Map) eventMap.get("data"); + if (customData == null) { + customData = Collections.emptyMap(); + } + final String tag = (String) customData.get(RRWebEvent.JsonKeys.TAG); + if (tag != null) { + switch (tag) { + case RRWebVideoEvent.EVENT_TAG: + final RRWebEvent videoEvent = + new RRWebVideoEvent.Deserializer().deserialize(mapReader, logger); + payload.add(videoEvent); + break; + case RRWebBreadcrumbEvent.EVENT_TAG: + final RRWebEvent breadcrumbEvent = + new RRWebBreadcrumbEvent.Deserializer() + .deserialize(mapReader, logger); + payload.add(breadcrumbEvent); + break; + case RRWebSpanEvent.EVENT_TAG: + final RRWebEvent spanEvent = + new RRWebSpanEvent.Deserializer().deserialize(mapReader, logger); + payload.add(spanEvent); + break; + default: + logger.log(SentryLevel.DEBUG, "Unsupported rrweb event type %s", type); + break; + } + } + break; + default: + logger.log(SentryLevel.DEBUG, "Unsupported rrweb event type %s", type); + break; + } + } + } + } + } + } + + replay.setSegmentId(segmentId); + replay.setPayload(payload); + replay.setUnknown(unknown); + return replay; + } + } +} diff --git a/sentry/src/main/java/io/sentry/Scope.java b/sentry/src/main/java/io/sentry/Scope.java index 356ee2b57c..be24c34dfb 100644 --- a/sentry/src/main/java/io/sentry/Scope.java +++ b/sentry/src/main/java/io/sentry/Scope.java @@ -3,6 +3,7 @@ import io.sentry.protocol.App; import io.sentry.protocol.Contexts; import io.sentry.protocol.Request; +import io.sentry.protocol.SentryId; import io.sentry.protocol.TransactionNameSource; import io.sentry.protocol.User; import io.sentry.util.CollectionUtils; @@ -80,6 +81,9 @@ public final class Scope implements IScope { private @NotNull PropagationContext propagationContext; + /** Scope's session replay id */ + private @NotNull SentryId replayId = SentryId.EMPTY_ID; + /** * Scope's ctor * @@ -101,6 +105,7 @@ private Scope(final @NotNull Scope scope) { final User userRef = scope.user; this.user = userRef != null ? new User(userRef) : null; this.screen = scope.screen; + this.replayId = scope.replayId; final Request requestRef = scope.request; this.request = requestRef != null ? new Request(requestRef) : null; @@ -312,6 +317,18 @@ public void setScreen(final @Nullable String screen) { } } + @Override + public @NotNull SentryId getReplayId() { + return replayId; + } + + @Override + public void setReplayId(final @NotNull SentryId replayId) { + this.replayId = replayId; + + // TODO: set to contexts and notify observers to persist this as well + } + /** * Returns the Scope's request * diff --git a/sentry/src/main/java/io/sentry/SentryAppStartProfilingOptions.java b/sentry/src/main/java/io/sentry/SentryAppStartProfilingOptions.java index d98ec2c32f..a9828792d7 100644 --- a/sentry/src/main/java/io/sentry/SentryAppStartProfilingOptions.java +++ b/sentry/src/main/java/io/sentry/SentryAppStartProfilingOptions.java @@ -151,7 +151,7 @@ public static final class Deserializer @Override public @NotNull SentryAppStartProfilingOptions deserialize( - @NotNull JsonObjectReader reader, @NotNull ILogger logger) throws Exception { + @NotNull ObjectReader reader, @NotNull ILogger logger) throws Exception { reader.beginObject(); SentryAppStartProfilingOptions options = new SentryAppStartProfilingOptions(); Map unknown = null; diff --git a/sentry/src/main/java/io/sentry/SentryBaseEvent.java b/sentry/src/main/java/io/sentry/SentryBaseEvent.java index c247342cc2..58435194a7 100644 --- a/sentry/src/main/java/io/sentry/SentryBaseEvent.java +++ b/sentry/src/main/java/io/sentry/SentryBaseEvent.java @@ -395,7 +395,7 @@ public static final class Deserializer { public boolean deserializeValue( @NotNull SentryBaseEvent baseEvent, @NotNull String nextName, - @NotNull JsonObjectReader reader, + @NotNull ObjectReader reader, @NotNull ILogger logger) throws Exception { switch (nextName) { diff --git a/sentry/src/main/java/io/sentry/SentryClient.java b/sentry/src/main/java/io/sentry/SentryClient.java index 31d4377dbb..8d27793e1a 100644 --- a/sentry/src/main/java/io/sentry/SentryClient.java +++ b/sentry/src/main/java/io/sentry/SentryClient.java @@ -199,6 +199,10 @@ private boolean shouldApplyScopeData(final @NotNull CheckIn event, final @NotNul sentryId = event.getEventId(); } + if (event != null) { + options.getReplayController().sendReplayForEvent(event, hint); + } + try { @Nullable TraceContext traceContext = null; if (HintUtils.hasType(hint, Backfillable.class)) { @@ -235,20 +239,93 @@ private boolean shouldApplyScopeData(final @NotNull CheckIn event, final @NotNul } // if we encountered a crash/abnormal exit finish tracing in order to persist and send - // any running transaction / profiling data + // any running transaction / profiling data. We also finish session replay, and it has priority + // over transactions as it takes longer to finalize replay than transactions, therefore + // the replay_id will be the trigger for flushing and unblocking the thread in case of a crash if (scope != null) { - final @Nullable ITransaction transaction = scope.getTransaction(); - if (transaction != null) { - if (HintUtils.hasType(hint, TransactionEnd.class)) { - final Object sentrySdkHint = HintUtils.getSentrySdkHint(hint); - if (sentrySdkHint instanceof DiskFlushNotification) { - ((DiskFlushNotification) sentrySdkHint).setFlushable(transaction.getEventId()); - transaction.forceFinish(SpanStatus.ABORTED, false, hint); - } else { - transaction.forceFinish(SpanStatus.ABORTED, false, null); - } + finalizeTransaction(scope, hint); + finalizeReplay(scope, hint); + } + + return sentryId; + } + + private void finalizeTransaction(final @NotNull IScope scope, final @NotNull Hint hint) { + final @Nullable ITransaction transaction = scope.getTransaction(); + if (transaction != null) { + if (HintUtils.hasType(hint, TransactionEnd.class)) { + final Object sentrySdkHint = HintUtils.getSentrySdkHint(hint); + if (sentrySdkHint instanceof DiskFlushNotification) { + ((DiskFlushNotification) sentrySdkHint).setFlushable(transaction.getEventId()); + transaction.forceFinish(SpanStatus.ABORTED, false, hint); + } else { + transaction.forceFinish(SpanStatus.ABORTED, false, null); + } + } + } + } + + private void finalizeReplay(final @NotNull IScope scope, final @NotNull Hint hint) { + final @Nullable SentryId replayId = scope.getReplayId(); + if (!SentryId.EMPTY_ID.equals(replayId)) { + if (HintUtils.hasType(hint, TransactionEnd.class)) { + final Object sentrySdkHint = HintUtils.getSentrySdkHint(hint); + if (sentrySdkHint instanceof DiskFlushNotification) { + ((DiskFlushNotification) sentrySdkHint).setFlushable(replayId); + } + } + } + } + + @Override + public @NotNull SentryId captureReplayEvent( + @NotNull SentryReplayEvent event, final @Nullable IScope scope, @Nullable Hint hint) { + Objects.requireNonNull(event, "SessionReplay is required."); + + if (hint == null) { + hint = new Hint(); + } + + if (shouldApplyScopeData(event, hint)) { + applyScope(event, scope); + } + + options.getLogger().log(SentryLevel.DEBUG, "Capturing session replay: %s", event.getEventId()); + + SentryId sentryId = SentryId.EMPTY_ID; + if (event.getEventId() != null) { + sentryId = event.getEventId(); + } + + event = processReplayEvent(event, hint, options.getEventProcessors()); + + if (event == null) { + options.getLogger().log(SentryLevel.DEBUG, "Replay was dropped by Event processors."); + return SentryId.EMPTY_ID; + } + + try { + @Nullable TraceContext traceContext = null; + if (scope != null) { + final @Nullable ITransaction transaction = scope.getTransaction(); + if (transaction != null) { + traceContext = transaction.traceContext(); + } else { + final @NotNull PropagationContext propagationContext = + TracingUtils.maybeUpdateBaggage(scope, options); + traceContext = propagationContext.traceContext(); } } + + final SentryEnvelope envelope = buildEnvelope(event, hint.getReplayRecording(), traceContext); + + hint.clear(); + transport.send(envelope, hint); + } catch (IOException e) { + options.getLogger().log(SentryLevel.WARNING, e, "Capturing event %s failed.", sentryId); + + // if there was an error capturing the event, we return an emptyId + sentryId = SentryId.EMPTY_ID; } return sentryId; @@ -460,6 +537,40 @@ private SentryTransaction processTransaction( return transaction; } + @Nullable + private SentryReplayEvent processReplayEvent( + @NotNull SentryReplayEvent replayEvent, + final @NotNull Hint hint, + final @NotNull List eventProcessors) { + for (final EventProcessor processor : eventProcessors) { + try { + replayEvent = processor.process(replayEvent, hint); + } catch (Throwable e) { + options + .getLogger() + .log( + SentryLevel.ERROR, + e, + "An exception occurred while processing replay event by processor: %s", + processor.getClass().getName()); + } + + if (replayEvent == null) { + options + .getLogger() + .log( + SentryLevel.DEBUG, + "Replay event was dropped by a processor: %s", + processor.getClass().getName()); + options + .getClientReportRecorder() + .recordLostEvent(DiscardReason.EVENT_PROCESSOR, DataCategory.Replay); + break; + } + } + return replayEvent; + } + @Override public void captureUserFeedback(final @NotNull UserFeedback userFeedback) { Objects.requireNonNull(userFeedback, "SentryEvent is required."); @@ -513,6 +624,24 @@ public void captureUserFeedback(final @NotNull UserFeedback userFeedback) { return new SentryEnvelope(envelopeHeader, envelopeItems); } + private @NotNull SentryEnvelope buildEnvelope( + final @NotNull SentryReplayEvent event, + final @Nullable ReplayRecording replayRecording, + final @Nullable TraceContext traceContext) { + final List envelopeItems = new ArrayList<>(); + + final SentryEnvelopeItem replayItem = + SentryEnvelopeItem.fromReplay( + options.getSerializer(), options.getLogger(), event, replayRecording); + envelopeItems.add(replayItem); + final SentryId sentryId = event.getEventId(); + + final SentryEnvelopeHeader envelopeHeader = + new SentryEnvelopeHeader(sentryId, options.getSdkVersion(), traceContext); + + return new SentryEnvelope(envelopeHeader, envelopeItems); + } + /** * Updates the session data based on the event, hint and scope data * @@ -867,6 +996,47 @@ public void captureSession(final @NotNull Session session, final @Nullable Hint return checkIn; } + private @NotNull SentryReplayEvent applyScope( + final @NotNull SentryReplayEvent replayEvent, final @Nullable IScope scope) { + // no breadcrumbs and extras for replay events + if (scope != null) { + if (replayEvent.getRequest() == null) { + replayEvent.setRequest(scope.getRequest()); + } + if (replayEvent.getUser() == null) { + replayEvent.setUser(scope.getUser()); + } + if (replayEvent.getTags() == null) { + replayEvent.setTags(new HashMap<>(scope.getTags())); + } else { + for (Map.Entry item : scope.getTags().entrySet()) { + if (!replayEvent.getTags().containsKey(item.getKey())) { + replayEvent.getTags().put(item.getKey(), item.getValue()); + } + } + } + final Contexts contexts = replayEvent.getContexts(); + for (Map.Entry entry : new Contexts(scope.getContexts()).entrySet()) { + if (!contexts.containsKey(entry.getKey())) { + contexts.put(entry.getKey(), entry.getValue()); + } + } + + // Set trace data from active span to connect replays with transactions + final ISpan span = scope.getSpan(); + if (replayEvent.getContexts().getTrace() == null) { + if (span == null) { + replayEvent + .getContexts() + .setTrace(TransactionContext.fromPropagationContext(scope.getPropagationContext())); + } else { + replayEvent.getContexts().setTrace(span.getSpanContext()); + } + } + } + return replayEvent; + } + private @NotNull T applyScope( final @NotNull T sentryBaseEvent, final @Nullable IScope scope) { if (scope != null) { diff --git a/sentry/src/main/java/io/sentry/SentryEnvelopeHeader.java b/sentry/src/main/java/io/sentry/SentryEnvelopeHeader.java index ceb7e7bdd5..3e9525d307 100644 --- a/sentry/src/main/java/io/sentry/SentryEnvelopeHeader.java +++ b/sentry/src/main/java/io/sentry/SentryEnvelopeHeader.java @@ -117,7 +117,7 @@ public void serialize(final @NotNull ObjectWriter writer, final @NotNull ILogger public static final class Deserializer implements JsonDeserializer { @Override public @NotNull SentryEnvelopeHeader deserialize( - @NotNull JsonObjectReader reader, @NotNull ILogger logger) throws Exception { + @NotNull ObjectReader reader, @NotNull ILogger logger) throws Exception { reader.beginObject(); SentryId eventId = null; diff --git a/sentry/src/main/java/io/sentry/SentryEnvelopeItem.java b/sentry/src/main/java/io/sentry/SentryEnvelopeItem.java index 45efecfc50..856976b589 100644 --- a/sentry/src/main/java/io/sentry/SentryEnvelopeItem.java +++ b/sentry/src/main/java/io/sentry/SentryEnvelopeItem.java @@ -21,7 +21,11 @@ import java.io.OutputStreamWriter; import java.io.Reader; import java.io.Writer; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; import java.nio.charset.Charset; +import java.util.LinkedHashMap; +import java.util.Map; import java.util.concurrent.Callable; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; @@ -103,8 +107,7 @@ public final class SentryEnvelopeItem { } public static @NotNull SentryEnvelopeItem fromEvent( - final @NotNull ISerializer serializer, final @NotNull SentryBaseEvent event) - throws IOException { + final @NotNull ISerializer serializer, final @NotNull SentryBaseEvent event) { Objects.requireNonNull(serializer, "ISerializer is required."); Objects.requireNonNull(event, "SentryEvent is required."); @@ -365,6 +368,67 @@ public ClientReport getClientReport(final @NotNull ISerializer serializer) throw } } + public static SentryEnvelopeItem fromReplay( + final @NotNull ISerializer serializer, + final @NotNull ILogger logger, + final @NotNull SentryReplayEvent replayEvent, + final @Nullable ReplayRecording replayRecording) { + + final File replayVideo = replayEvent.getVideoFile(); + + final CachedItem cachedItem = + new CachedItem( + () -> { + try { + try (final ByteArrayOutputStream stream = new ByteArrayOutputStream(); + final Writer writer = + new BufferedWriter(new OutputStreamWriter(stream, UTF_8))) { + // relay expects the payload to be in this exact order: [event,rrweb,video] + final Map replayPayload = new LinkedHashMap<>(); + // first serialize replay event json bytes + serializer.serialize(replayEvent, writer); + replayPayload.put(SentryItemType.ReplayEvent.getItemType(), stream.toByteArray()); + stream.reset(); + + // next serialize replay recording + if (replayRecording != null) { + serializer.serialize(replayRecording, writer); + replayPayload.put( + SentryItemType.ReplayRecording.getItemType(), stream.toByteArray()); + stream.reset(); + } + + // next serialize replay video bytes from given file + if (replayVideo != null && replayVideo.exists()) { + final byte[] videoBytes = + readBytesFromFile( + replayVideo.getPath(), SentryReplayEvent.REPLAY_VIDEO_MAX_SIZE); + if (videoBytes.length > 0) { + replayPayload.put(SentryItemType.ReplayVideo.getItemType(), videoBytes); + } + } + + return serializeToMsgpack(replayPayload); + } + } catch (Throwable t) { + logger.log(SentryLevel.ERROR, "Could not serialize replay recording", t); + return null; + } finally { + if (replayVideo != null) { + replayVideo.delete(); + } + } + }); + + final SentryEnvelopeItemHeader itemHeader = + new SentryEnvelopeItemHeader( + SentryItemType.ReplayVideo, () -> cachedItem.getBytes().length, null, null); + + // avoid method refs on Android due to some issues with older AGP setups + // noinspection Convert2MethodRef + return new SentryEnvelopeItem(itemHeader, () -> cachedItem.getBytes()); + } + private static class CachedItem { private @Nullable byte[] bytes; private final @Nullable Callable dataFactory; @@ -384,4 +448,35 @@ public CachedItem(final @Nullable Callable dataFactory) { return bytes != null ? bytes : new byte[] {}; } } + + @SuppressWarnings({"UnnecessaryParentheses"}) + private static byte[] serializeToMsgpack(final @NotNull Map map) + throws IOException { + try (final ByteArrayOutputStream baos = new ByteArrayOutputStream()) { + + // Write map header + baos.write((byte) (0x80 | map.size())); + + // Iterate over the map and serialize each key-value pair + for (final Map.Entry entry : map.entrySet()) { + // Pack the key as a string + final byte[] keyBytes = entry.getKey().getBytes(UTF_8); + final int keyLength = keyBytes.length; + // string up to 255 chars + baos.write((byte) (0xd9)); + baos.write((byte) (keyLength)); + baos.write(keyBytes); + + // Pack the value as a binary string + final byte[] valueBytes = entry.getValue(); + final int valueLength = valueBytes.length; + // We will always use the 4 bytes data length for simplicity. + baos.write((byte) (0xc6)); + baos.write(ByteBuffer.allocate(4).order(ByteOrder.BIG_ENDIAN).putInt(valueLength).array()); + baos.write(valueBytes); + } + + return baos.toByteArray(); + } + } } diff --git a/sentry/src/main/java/io/sentry/SentryEnvelopeItemHeader.java b/sentry/src/main/java/io/sentry/SentryEnvelopeItemHeader.java index 1ca9a1c8c2..6903d9b1bb 100644 --- a/sentry/src/main/java/io/sentry/SentryEnvelopeItemHeader.java +++ b/sentry/src/main/java/io/sentry/SentryEnvelopeItemHeader.java @@ -130,7 +130,7 @@ public void serialize(final @NotNull ObjectWriter writer, final @NotNull ILogger public static final class Deserializer implements JsonDeserializer { @Override public @NotNull SentryEnvelopeItemHeader deserialize( - @NotNull JsonObjectReader reader, @NotNull ILogger logger) throws Exception { + @NotNull ObjectReader reader, @NotNull ILogger logger) throws Exception { reader.beginObject(); String contentType = null; diff --git a/sentry/src/main/java/io/sentry/SentryEvent.java b/sentry/src/main/java/io/sentry/SentryEvent.java index 5bd1cf3877..d370458acb 100644 --- a/sentry/src/main/java/io/sentry/SentryEvent.java +++ b/sentry/src/main/java/io/sentry/SentryEvent.java @@ -311,8 +311,8 @@ public static final class Deserializer implements JsonDeserializer @SuppressWarnings("unchecked") @Override - public @NotNull SentryEvent deserialize( - @NotNull JsonObjectReader reader, @NotNull ILogger logger) throws Exception { + public @NotNull SentryEvent deserialize(@NotNull ObjectReader reader, @NotNull ILogger logger) + throws Exception { reader.beginObject(); SentryEvent event = new SentryEvent(); Map unknown = null; diff --git a/sentry/src/main/java/io/sentry/SentryItemType.java b/sentry/src/main/java/io/sentry/SentryItemType.java index db299a12da..f37b972454 100644 --- a/sentry/src/main/java/io/sentry/SentryItemType.java +++ b/sentry/src/main/java/io/sentry/SentryItemType.java @@ -18,6 +18,7 @@ public enum SentryItemType implements JsonSerializable { ClientReport("client_report"), ReplayEvent("replay_event"), ReplayRecording("replay_recording"), + ReplayVideo("replay_video"), CheckIn("check_in"), Statsd("statsd"), Unknown("__unknown__"); // DataCategory.Unknown @@ -65,7 +66,7 @@ static final class Deserializer implements JsonDeserializer { @Override public @NotNull SentryItemType deserialize( - @NotNull JsonObjectReader reader, @NotNull ILogger logger) throws Exception { + @NotNull ObjectReader reader, @NotNull ILogger logger) throws Exception { return SentryItemType.valueOfLabel(reader.nextString().toLowerCase(Locale.ROOT)); } } diff --git a/sentry/src/main/java/io/sentry/SentryLevel.java b/sentry/src/main/java/io/sentry/SentryLevel.java index ac179c9831..76b07c6b37 100644 --- a/sentry/src/main/java/io/sentry/SentryLevel.java +++ b/sentry/src/main/java/io/sentry/SentryLevel.java @@ -18,11 +18,11 @@ public void serialize(final @NotNull ObjectWriter writer, final @NotNull ILogger writer.value(name().toLowerCase(Locale.ROOT)); } - static final class Deserializer implements JsonDeserializer { + public static final class Deserializer implements JsonDeserializer { @Override - public @NotNull SentryLevel deserialize( - @NotNull JsonObjectReader reader, @NotNull ILogger logger) throws Exception { + public @NotNull SentryLevel deserialize(@NotNull ObjectReader reader, @NotNull ILogger logger) + throws Exception { return SentryLevel.valueOf(reader.nextString().toUpperCase(Locale.ROOT)); } } diff --git a/sentry/src/main/java/io/sentry/SentryLockReason.java b/sentry/src/main/java/io/sentry/SentryLockReason.java index f376317f6b..bd04f48ab0 100644 --- a/sentry/src/main/java/io/sentry/SentryLockReason.java +++ b/sentry/src/main/java/io/sentry/SentryLockReason.java @@ -147,7 +147,7 @@ public static final class Deserializer implements JsonDeserializer unknown = null; reader.beginObject(); diff --git a/sentry/src/main/java/io/sentry/SentryOptions.java b/sentry/src/main/java/io/sentry/SentryOptions.java index fe0dad0144..3ff84c48de 100644 --- a/sentry/src/main/java/io/sentry/SentryOptions.java +++ b/sentry/src/main/java/io/sentry/SentryOptions.java @@ -479,6 +479,16 @@ public class SentryOptions { @ApiStatus.Experimental private @Nullable Cron cron = null; + private final @NotNull ExperimentalOptions experimental = new ExperimentalOptions(); + + private @NotNull ReplayController replayController = NoOpReplayController.getInstance(); + + /** + * Controls whether to enable screen tracking. When enabled, the SDK will automatically capture + * screen transitions as context for events. + */ + @ApiStatus.Experimental private boolean enableScreenTracking = true; + /** * Adds an event processor * @@ -2385,6 +2395,30 @@ public void setCron(@Nullable Cron cron) { this.cron = cron; } + @NotNull + public ExperimentalOptions getExperimental() { + return experimental; + } + + public @NotNull ReplayController getReplayController() { + return replayController; + } + + public void setReplayController(final @Nullable ReplayController replayController) { + this.replayController = + replayController != null ? replayController : NoOpReplayController.getInstance(); + } + + @ApiStatus.Experimental + public boolean isEnableScreenTracking() { + return enableScreenTracking; + } + + @ApiStatus.Experimental + public void setEnableScreenTracking(final boolean enableScreenTracking) { + this.enableScreenTracking = enableScreenTracking; + } + /** The BeforeSend callback */ public interface BeforeSendCallback { diff --git a/sentry/src/main/java/io/sentry/SentryReplayEvent.java b/sentry/src/main/java/io/sentry/SentryReplayEvent.java new file mode 100644 index 0000000000..95623d2ff6 --- /dev/null +++ b/sentry/src/main/java/io/sentry/SentryReplayEvent.java @@ -0,0 +1,319 @@ +package io.sentry; + +import io.sentry.protocol.SentryId; +import io.sentry.util.Objects; +import io.sentry.vendor.gson.stream.JsonToken; +import java.io.File; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Date; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +public final class SentryReplayEvent extends SentryBaseEvent + implements JsonUnknown, JsonSerializable { + + public enum ReplayType implements JsonSerializable { + SESSION, + BUFFER; + + @Override + public void serialize(final @NotNull ObjectWriter writer, final @NotNull ILogger logger) + throws IOException { + writer.value(name().toLowerCase(Locale.ROOT)); + } + + public static final class Deserializer implements JsonDeserializer { + @Override + public @NotNull ReplayType deserialize( + final @NotNull ObjectReader reader, final @NotNull ILogger logger) throws Exception { + return ReplayType.valueOf(reader.nextString().toUpperCase(Locale.ROOT)); + } + } + } + + public static final long REPLAY_VIDEO_MAX_SIZE = 10 * 1024 * 1024; + public static final String REPLAY_EVENT_TYPE = "replay_event"; + + private @Nullable File videoFile; + private @NotNull String type; + private @NotNull ReplayType replayType; + private @Nullable SentryId replayId; + private int segmentId; + private @NotNull Date timestamp; + private @Nullable Date replayStartTimestamp; + private @Nullable List urls; + private @Nullable List errorIds; + private @Nullable List traceIds; + private @Nullable Map unknown; + + public SentryReplayEvent() { + super(); + this.replayId = new SentryId(); + this.type = REPLAY_EVENT_TYPE; + this.replayType = ReplayType.SESSION; + this.errorIds = new ArrayList<>(); + this.traceIds = new ArrayList<>(); + this.urls = new ArrayList<>(); + timestamp = DateUtils.getCurrentDateTime(); + } + + @Nullable + public File getVideoFile() { + return videoFile; + } + + public void setVideoFile(final @Nullable File videoFile) { + this.videoFile = videoFile; + } + + @NotNull + public String getType() { + return type; + } + + public void setType(final @NotNull String type) { + this.type = type; + } + + @Nullable + public SentryId getReplayId() { + return replayId; + } + + public void setReplayId(final @Nullable SentryId replayId) { + this.replayId = replayId; + } + + public int getSegmentId() { + return segmentId; + } + + public void setSegmentId(final int segmentId) { + this.segmentId = segmentId; + } + + @NotNull + public Date getTimestamp() { + return timestamp; + } + + public void setTimestamp(final @NotNull Date timestamp) { + this.timestamp = timestamp; + } + + @Nullable + public Date getReplayStartTimestamp() { + return replayStartTimestamp; + } + + public void setReplayStartTimestamp(final @Nullable Date replayStartTimestamp) { + this.replayStartTimestamp = replayStartTimestamp; + } + + @Nullable + public List getUrls() { + return urls; + } + + public void setUrls(final @Nullable List urls) { + this.urls = urls; + } + + @Nullable + public List getErrorIds() { + return errorIds; + } + + public void setErrorIds(final @Nullable List errorIds) { + this.errorIds = errorIds; + } + + @Nullable + public List getTraceIds() { + return traceIds; + } + + public void setTraceIds(final @Nullable List traceIds) { + this.traceIds = traceIds; + } + + @NotNull + public ReplayType getReplayType() { + return replayType; + } + + public void setReplayType(final @NotNull ReplayType replayType) { + this.replayType = replayType; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + SentryReplayEvent that = (SentryReplayEvent) o; + return segmentId == that.segmentId + && Objects.equals(type, that.type) + && replayType == that.replayType + && Objects.equals(replayId, that.replayId) + && Objects.equals(urls, that.urls) + && Objects.equals(errorIds, that.errorIds) + && Objects.equals(traceIds, that.traceIds); + } + + @Override + public int hashCode() { + return Objects.hash(type, replayType, replayId, segmentId, urls, errorIds, traceIds); + } + + // region json + public static final class JsonKeys { + public static final String TYPE = "type"; + public static final String REPLAY_TYPE = "replay_type"; + public static final String REPLAY_ID = "replay_id"; + public static final String SEGMENT_ID = "segment_id"; + public static final String TIMESTAMP = "timestamp"; + public static final String REPLAY_START_TIMESTAMP = "replay_start_timestamp"; + public static final String URLS = "urls"; + public static final String ERROR_IDS = "error_ids"; + public static final String TRACE_IDS = "trace_ids"; + } + + @Override + @SuppressWarnings("JdkObsolete") + public void serialize(final @NotNull ObjectWriter writer, final @NotNull ILogger logger) + throws IOException { + writer.beginObject(); + writer.name(JsonKeys.TYPE).value(type); + writer.name(JsonKeys.REPLAY_TYPE).value(logger, replayType); + writer.name(JsonKeys.SEGMENT_ID).value(segmentId); + writer.name(JsonKeys.TIMESTAMP).value(logger, timestamp); + if (replayId != null) { + writer.name(JsonKeys.REPLAY_ID).value(logger, replayId); + } + if (replayStartTimestamp != null) { + writer.name(JsonKeys.REPLAY_START_TIMESTAMP).value(logger, replayStartTimestamp); + } + if (urls != null) { + writer.name(JsonKeys.URLS).value(logger, urls); + } + if (errorIds != null) { + writer.name(JsonKeys.ERROR_IDS).value(logger, errorIds); + } + if (traceIds != null) { + writer.name(JsonKeys.TRACE_IDS).value(logger, traceIds); + } + + new SentryBaseEvent.Serializer().serialize(this, writer, logger); + + if (unknown != null) { + for (String key : unknown.keySet()) { + Object value = unknown.get(key); + writer.name(key).value(logger, value); + } + } + writer.endObject(); + } + + @Override + public @Nullable Map getUnknown() { + return unknown; + } + + @Override + public void setUnknown(final @Nullable Map unknown) { + this.unknown = unknown; + } + + public static final class Deserializer implements JsonDeserializer { + + @SuppressWarnings("unchecked") + @Override + public @NotNull SentryReplayEvent deserialize( + final @NotNull ObjectReader reader, final @NotNull ILogger logger) throws Exception { + + final SentryBaseEvent.Deserializer baseEventDeserializer = new SentryBaseEvent.Deserializer(); + + final SentryReplayEvent replay = new SentryReplayEvent(); + + @Nullable Map unknown = null; + @Nullable String type = null; + @Nullable ReplayType replayType = null; + @Nullable SentryId replayId = null; + @Nullable Integer segmentId = null; + @Nullable Date timestamp = null; + @Nullable Date replayStartTimestamp = null; + @Nullable List urls = null; + @Nullable List errorIds = null; + @Nullable List traceIds = null; + + reader.beginObject(); + while (reader.peek() == JsonToken.NAME) { + final String nextName = reader.nextName(); + switch (nextName) { + case JsonKeys.TYPE: + type = reader.nextStringOrNull(); + break; + case JsonKeys.REPLAY_TYPE: + replayType = reader.nextOrNull(logger, new ReplayType.Deserializer()); + break; + case JsonKeys.REPLAY_ID: + replayId = reader.nextOrNull(logger, new SentryId.Deserializer()); + break; + case JsonKeys.SEGMENT_ID: + segmentId = reader.nextIntegerOrNull(); + break; + case JsonKeys.TIMESTAMP: + timestamp = reader.nextDateOrNull(logger); + break; + case JsonKeys.REPLAY_START_TIMESTAMP: + replayStartTimestamp = reader.nextDateOrNull(logger); + break; + case JsonKeys.URLS: + urls = (List) reader.nextObjectOrNull(); + break; + case JsonKeys.ERROR_IDS: + errorIds = (List) reader.nextObjectOrNull(); + break; + case JsonKeys.TRACE_IDS: + traceIds = (List) reader.nextObjectOrNull(); + break; + default: + if (!baseEventDeserializer.deserializeValue(replay, nextName, reader, logger)) { + if (unknown == null) { + unknown = new HashMap<>(); + } + reader.nextUnknown(logger, unknown, nextName); + } + break; + } + } + reader.endObject(); + + if (type != null) { + replay.setType(type); + } + if (replayType != null) { + replay.setReplayType(replayType); + } + if (segmentId != null) { + replay.setSegmentId(segmentId); + } + if (timestamp != null) { + replay.setTimestamp(timestamp); + } + replay.setReplayId(replayId); + replay.setReplayStartTimestamp(replayStartTimestamp); + replay.setUrls(urls); + replay.setErrorIds(errorIds); + replay.setTraceIds(traceIds); + replay.setUnknown(unknown); + return replay; + } + } + // endregion json +} diff --git a/sentry/src/main/java/io/sentry/SentryReplayOptions.java b/sentry/src/main/java/io/sentry/SentryReplayOptions.java new file mode 100644 index 0000000000..db230f2a30 --- /dev/null +++ b/sentry/src/main/java/io/sentry/SentryReplayOptions.java @@ -0,0 +1,196 @@ +package io.sentry; + +import io.sentry.util.SampleRateUtils; +import java.util.Set; +import java.util.concurrent.CopyOnWriteArraySet; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +public final class SentryReplayOptions { + + public enum SentryReplayQuality { + /** Video Scale: 80% Bit Rate: 50.000 */ + LOW(0.8f, 50_000), + + /** Video Scale: 100% Bit Rate: 75.000 */ + MEDIUM(1.0f, 75_000), + + /** Video Scale: 100% Bit Rate: 100.000 */ + HIGH(1.0f, 100_000); + + /** The scale related to the window size (in dp) at which the replay will be created. */ + public final float sizeScale; + + /** + * Defines the quality of the session replay. Higher bit rates have better replay quality, but + * also affect the final payload size to transfer, defaults to 40kbps. + */ + public final int bitRate; + + SentryReplayQuality(final float sizeScale, final int bitRate) { + this.sizeScale = sizeScale; + this.bitRate = bitRate; + } + } + + /** + * Indicates the percentage in which the replay for the session will be created. Specifying 0 + * means never, 1.0 means always. The value needs to be >= 0.0 and <= 1.0 The default is null + * (disabled). + */ + private @Nullable Double sessionSampleRate; + + /** + * Indicates the percentage in which a 30 seconds replay will be send with error events. + * Specifying 0 means never, 1.0 means always. The value needs to be >= 0.0 and <= 1.0. The + * default is null (disabled). + */ + private @Nullable Double errorSampleRate; + + /** + * Redact all text content. Draws a rectangle of text bounds with text color on top. By default + * only views extending TextView are redacted. + * + *

Default is enabled. + */ + private boolean redactAllText = true; + + /** + * Redact all image content. Draws a rectangle of image bounds with image's dominant color on top. + * By default only views extending ImageView with BitmapDrawable or custom Drawable type are + * redacted. ColorDrawable, InsetDrawable, VectorDrawable are all considered non-PII, as they come + * from the apk. + * + *

Default is enabled. + */ + private boolean redactAllImages = true; + + /** + * Redact all views with the specified class names. The class name is the fully qualified class + * name of the view, e.g. android.widget.TextView. + * + *

Default is empty. + */ + private Set redactClasses = new CopyOnWriteArraySet<>(); + + /** + * Defines the quality of the session replay. The higher the quality, the more accurate the replay + * will be, but also more data to transfer and more CPU load, defaults to MEDIUM. + */ + private SentryReplayQuality quality = SentryReplayQuality.MEDIUM; + + /** + * Number of frames per second of the replay. The bigger the number, the more accurate the replay + * will be, but also more data to transfer and more CPU load, defaults to 1fps. + */ + private int frameRate = 1; + + /** The maximum duration of replays for error events, defaults to 30s. */ + private long errorReplayDuration = 30_000L; + + /** The maximum duration of the segment of a session replay, defaults to 5s. */ + private long sessionSegmentDuration = 5000L; + + /** The maximum duration of a full session replay, defaults to 1h. */ + private long sessionDuration = 60 * 60 * 1000L; + + public SentryReplayOptions() {} + + public SentryReplayOptions( + final @Nullable Double sessionSampleRate, final @Nullable Double errorSampleRate) { + this.sessionSampleRate = sessionSampleRate; + this.errorSampleRate = errorSampleRate; + } + + @Nullable + public Double getErrorSampleRate() { + return errorSampleRate; + } + + public boolean isSessionReplayEnabled() { + return (getSessionSampleRate() != null && getSessionSampleRate() > 0); + } + + public void setErrorSampleRate(final @Nullable Double errorSampleRate) { + if (!SampleRateUtils.isValidSampleRate(errorSampleRate)) { + throw new IllegalArgumentException( + "The value " + + errorSampleRate + + " is not valid. Use null to disable or values >= 0.0 and <= 1.0."); + } + this.errorSampleRate = errorSampleRate; + } + + @Nullable + public Double getSessionSampleRate() { + return sessionSampleRate; + } + + public boolean isSessionReplayForErrorsEnabled() { + return (getErrorSampleRate() != null && getErrorSampleRate() > 0); + } + + public void setSessionSampleRate(final @Nullable Double sessionSampleRate) { + if (!SampleRateUtils.isValidSampleRate(sessionSampleRate)) { + throw new IllegalArgumentException( + "The value " + + sessionSampleRate + + " is not valid. Use null to disable or values >= 0.0 and <= 1.0."); + } + this.sessionSampleRate = sessionSampleRate; + } + + public boolean getRedactAllText() { + return redactAllText; + } + + public void setRedactAllText(final boolean redactAllText) { + this.redactAllText = redactAllText; + } + + public boolean getRedactAllImages() { + return redactAllImages; + } + + public void setRedactAllImages(final boolean redactAllImages) { + this.redactAllImages = redactAllImages; + } + + public Set getRedactClasses() { + return this.redactClasses; + } + + public void addClassToRedact(final String className) { + this.redactClasses.add(className); + } + + @ApiStatus.Internal + public @NotNull SentryReplayQuality getQuality() { + return quality; + } + + public void setQuality(final @NotNull SentryReplayQuality quality) { + this.quality = quality; + } + + @ApiStatus.Internal + public int getFrameRate() { + return frameRate; + } + + @ApiStatus.Internal + public long getErrorReplayDuration() { + return errorReplayDuration; + } + + @ApiStatus.Internal + public long getSessionSegmentDuration() { + return sessionSegmentDuration; + } + + @ApiStatus.Internal + public long getSessionDuration() { + return sessionDuration; + } +} diff --git a/sentry/src/main/java/io/sentry/SentryTracer.java b/sentry/src/main/java/io/sentry/SentryTracer.java index 8086acd02e..99418d5c8b 100644 --- a/sentry/src/main/java/io/sentry/SentryTracer.java +++ b/sentry/src/main/java/io/sentry/SentryTracer.java @@ -593,12 +593,18 @@ private void updateBaggageValues() { synchronized (this) { if (baggage.isMutable()) { final AtomicReference userAtomicReference = new AtomicReference<>(); + final AtomicReference replayId = new AtomicReference<>(); hub.configureScope( scope -> { userAtomicReference.set(scope.getUser()); + replayId.set(scope.getReplayId()); }); baggage.setValuesFromTransaction( - this, userAtomicReference.get(), hub.getOptions(), this.getSamplingDecision()); + this, + userAtomicReference.get(), + replayId.get(), + hub.getOptions(), + this.getSamplingDecision()); baggage.freeze(); } } diff --git a/sentry/src/main/java/io/sentry/Session.java b/sentry/src/main/java/io/sentry/Session.java index 500da919fe..482b055b67 100644 --- a/sentry/src/main/java/io/sentry/Session.java +++ b/sentry/src/main/java/io/sentry/Session.java @@ -426,7 +426,7 @@ public static final class Deserializer implements JsonDeserializer { @SuppressWarnings("unchecked") @Override - public @NotNull Session deserialize(@NotNull JsonObjectReader reader, @NotNull ILogger logger) + public @NotNull Session deserialize(@NotNull ObjectReader reader, @NotNull ILogger logger) throws Exception { reader.beginObject(); diff --git a/sentry/src/main/java/io/sentry/SpanContext.java b/sentry/src/main/java/io/sentry/SpanContext.java index be428708cb..5a43ff845e 100644 --- a/sentry/src/main/java/io/sentry/SpanContext.java +++ b/sentry/src/main/java/io/sentry/SpanContext.java @@ -292,8 +292,8 @@ public void setUnknown(@Nullable Map unknown) { public static final class Deserializer implements JsonDeserializer { @SuppressWarnings("unchecked") @Override - public @NotNull SpanContext deserialize( - @NotNull JsonObjectReader reader, @NotNull ILogger logger) throws Exception { + public @NotNull SpanContext deserialize(@NotNull ObjectReader reader, @NotNull ILogger logger) + throws Exception { reader.beginObject(); SentryId traceId = null; SpanId spanId = null; diff --git a/sentry/src/main/java/io/sentry/SpanDataConvention.java b/sentry/src/main/java/io/sentry/SpanDataConvention.java index f8fb82c3c8..ffe2414af3 100644 --- a/sentry/src/main/java/io/sentry/SpanDataConvention.java +++ b/sentry/src/main/java/io/sentry/SpanDataConvention.java @@ -23,4 +23,6 @@ public interface SpanDataConvention { String FRAMES_DELAY = "frames.delay"; String CONTRIBUTES_TTID = "ui.contributes_to_ttid"; String CONTRIBUTES_TTFD = "ui.contributes_to_ttfd"; + String HTTP_START_TIMESTAMP = "http.start_timestamp"; + String HTTP_END_TIMESTAMP = "http.end_timestamp"; } diff --git a/sentry/src/main/java/io/sentry/SpanId.java b/sentry/src/main/java/io/sentry/SpanId.java index 7e221775ce..70608fb7cb 100644 --- a/sentry/src/main/java/io/sentry/SpanId.java +++ b/sentry/src/main/java/io/sentry/SpanId.java @@ -53,7 +53,7 @@ public void serialize(final @NotNull ObjectWriter writer, final @NotNull ILogger public static final class Deserializer implements JsonDeserializer { @Override - public @NotNull SpanId deserialize(@NotNull JsonObjectReader reader, @NotNull ILogger logger) + public @NotNull SpanId deserialize(@NotNull ObjectReader reader, @NotNull ILogger logger) throws Exception { return new SpanId(reader.nextString()); } diff --git a/sentry/src/main/java/io/sentry/SpanStatus.java b/sentry/src/main/java/io/sentry/SpanStatus.java index b0b1bf78c8..5185d27e05 100644 --- a/sentry/src/main/java/io/sentry/SpanStatus.java +++ b/sentry/src/main/java/io/sentry/SpanStatus.java @@ -114,8 +114,8 @@ public void serialize(final @NotNull ObjectWriter writer, final @NotNull ILogger public static final class Deserializer implements JsonDeserializer { @Override - public @NotNull SpanStatus deserialize( - @NotNull JsonObjectReader reader, @NotNull ILogger logger) throws Exception { + public @NotNull SpanStatus deserialize(@NotNull ObjectReader reader, @NotNull ILogger logger) + throws Exception { return SpanStatus.valueOf(reader.nextString().toUpperCase(Locale.ROOT)); } } diff --git a/sentry/src/main/java/io/sentry/TraceContext.java b/sentry/src/main/java/io/sentry/TraceContext.java index 56c9ee586f..f3d603b7c0 100644 --- a/sentry/src/main/java/io/sentry/TraceContext.java +++ b/sentry/src/main/java/io/sentry/TraceContext.java @@ -21,12 +21,13 @@ public final class TraceContext implements JsonUnknown, JsonSerializable { private final @Nullable String transaction; private final @Nullable String sampleRate; private final @Nullable String sampled; + private final @Nullable SentryId replayId; @SuppressWarnings("unused") private @Nullable Map unknown; TraceContext(@NotNull SentryId traceId, @NotNull String publicKey) { - this(traceId, publicKey, null, null, null, null, null, null); + this(traceId, publicKey, null, null, null, null, null, null, null); } TraceContext( @@ -37,8 +38,19 @@ public final class TraceContext implements JsonUnknown, JsonSerializable { @Nullable String userId, @Nullable String transaction, @Nullable String sampleRate, - @Nullable String sampled) { - this(traceId, publicKey, release, environment, userId, null, transaction, sampleRate, sampled); + @Nullable String sampled, + @Nullable SentryId replayId) { + this( + traceId, + publicKey, + release, + environment, + userId, + null, + transaction, + sampleRate, + sampled, + replayId); } /** @@ -54,7 +66,8 @@ public final class TraceContext implements JsonUnknown, JsonSerializable { @Nullable String userSegment, @Nullable String transaction, @Nullable String sampleRate, - @Nullable String sampled) { + @Nullable String sampled, + @Nullable SentryId replayId) { this.traceId = traceId; this.publicKey = publicKey; this.release = release; @@ -64,6 +77,7 @@ public final class TraceContext implements JsonUnknown, JsonSerializable { this.transaction = transaction; this.sampleRate = sampleRate; this.sampled = sampled; + this.replayId = replayId; } @SuppressWarnings("UnusedMethod") @@ -116,6 +130,10 @@ public final class TraceContext implements JsonUnknown, JsonSerializable { return sampled; } + public @Nullable SentryId getReplayId() { + return replayId; + } + /** * @deprecated only here to support parsing legacy JSON with non flattened user */ @@ -165,7 +183,7 @@ public static final class JsonKeys { public static final class Deserializer implements JsonDeserializer { @Override public @NotNull TraceContextUser deserialize( - @NotNull JsonObjectReader reader, @NotNull ILogger logger) throws Exception { + @NotNull ObjectReader reader, @NotNull ILogger logger) throws Exception { reader.beginObject(); String id = null; @@ -222,6 +240,7 @@ public static final class JsonKeys { public static final String TRANSACTION = "transaction"; public static final String SAMPLE_RATE = "sample_rate"; public static final String SAMPLED = "sampled"; + public static final String REPLAY_ID = "replay_id"; } @Override @@ -251,6 +270,9 @@ public void serialize(final @NotNull ObjectWriter writer, final @NotNull ILogger if (sampled != null) { writer.name(TraceContext.JsonKeys.SAMPLED).value(sampled); } + if (replayId != null) { + writer.name(TraceContext.JsonKeys.REPLAY_ID).value(logger, replayId); + } if (unknown != null) { for (String key : unknown.keySet()) { Object value = unknown.get(key); @@ -263,8 +285,8 @@ public void serialize(final @NotNull ObjectWriter writer, final @NotNull ILogger public static final class Deserializer implements JsonDeserializer { @Override - public @NotNull TraceContext deserialize( - @NotNull JsonObjectReader reader, @NotNull ILogger logger) throws Exception { + public @NotNull TraceContext deserialize(@NotNull ObjectReader reader, @NotNull ILogger logger) + throws Exception { reader.beginObject(); SentryId traceId = null; @@ -277,6 +299,7 @@ public static final class Deserializer implements JsonDeserializer String transaction = null; String sampleRate = null; String sampled = null; + SentryId replayId = null; Map unknown = null; while (reader.peek() == JsonToken.NAME) { @@ -312,6 +335,9 @@ public static final class Deserializer implements JsonDeserializer case TraceContext.JsonKeys.SAMPLED: sampled = reader.nextStringOrNull(); break; + case TraceContext.JsonKeys.REPLAY_ID: + replayId = new SentryId.Deserializer().deserialize(reader, logger); + break; default: if (unknown == null) { unknown = new ConcurrentHashMap<>(); @@ -344,7 +370,8 @@ public static final class Deserializer implements JsonDeserializer userSegment, transaction, sampleRate, - sampled); + sampled, + replayId); traceContext.setUnknown(unknown); reader.endObject(); return traceContext; diff --git a/sentry/src/main/java/io/sentry/UserFeedback.java b/sentry/src/main/java/io/sentry/UserFeedback.java index 27086188fe..b580744ee7 100644 --- a/sentry/src/main/java/io/sentry/UserFeedback.java +++ b/sentry/src/main/java/io/sentry/UserFeedback.java @@ -174,8 +174,8 @@ public void serialize(final @NotNull ObjectWriter writer, final @NotNull ILogger public static final class Deserializer implements JsonDeserializer { @Override - public @NotNull UserFeedback deserialize( - @NotNull JsonObjectReader reader, @NotNull ILogger logger) throws Exception { + public @NotNull UserFeedback deserialize(@NotNull ObjectReader reader, @NotNull ILogger logger) + throws Exception { SentryId sentryId = null; String name = null; String email = null; diff --git a/sentry/src/main/java/io/sentry/clientreport/ClientReport.java b/sentry/src/main/java/io/sentry/clientreport/ClientReport.java index 66c3188116..e1b8abcaea 100644 --- a/sentry/src/main/java/io/sentry/clientreport/ClientReport.java +++ b/sentry/src/main/java/io/sentry/clientreport/ClientReport.java @@ -3,9 +3,9 @@ import io.sentry.DateUtils; import io.sentry.ILogger; import io.sentry.JsonDeserializer; -import io.sentry.JsonObjectReader; import io.sentry.JsonSerializable; import io.sentry.JsonUnknown; +import io.sentry.ObjectReader; import io.sentry.ObjectWriter; import io.sentry.SentryLevel; import io.sentry.vendor.gson.stream.JsonToken; @@ -74,8 +74,8 @@ public void serialize(final @NotNull ObjectWriter writer, final @NotNull ILogger public static final class Deserializer implements JsonDeserializer { @Override - public @NotNull ClientReport deserialize( - @NotNull JsonObjectReader reader, @NotNull ILogger logger) throws Exception { + public @NotNull ClientReport deserialize(@NotNull ObjectReader reader, @NotNull ILogger logger) + throws Exception { Date timestamp = null; List discardedEvents = new ArrayList<>(); Map unknown = null; diff --git a/sentry/src/main/java/io/sentry/clientreport/DiscardedEvent.java b/sentry/src/main/java/io/sentry/clientreport/DiscardedEvent.java index 8fb5da3165..10b12b0fed 100644 --- a/sentry/src/main/java/io/sentry/clientreport/DiscardedEvent.java +++ b/sentry/src/main/java/io/sentry/clientreport/DiscardedEvent.java @@ -2,9 +2,9 @@ import io.sentry.ILogger; import io.sentry.JsonDeserializer; -import io.sentry.JsonObjectReader; import io.sentry.JsonSerializable; import io.sentry.JsonUnknown; +import io.sentry.ObjectReader; import io.sentry.ObjectWriter; import io.sentry.SentryLevel; import io.sentry.vendor.gson.stream.JsonToken; @@ -93,7 +93,7 @@ public void serialize(final @NotNull ObjectWriter writer, final @NotNull ILogger public static final class Deserializer implements JsonDeserializer { @Override public @NotNull DiscardedEvent deserialize( - @NotNull JsonObjectReader reader, @NotNull ILogger logger) throws Exception { + @NotNull ObjectReader reader, @NotNull ILogger logger) throws Exception { String reason = null; String category = null; Long quanity = null; diff --git a/sentry/src/main/java/io/sentry/profilemeasurements/ProfileMeasurement.java b/sentry/src/main/java/io/sentry/profilemeasurements/ProfileMeasurement.java index 94e77edbfb..1e6ff5fb41 100644 --- a/sentry/src/main/java/io/sentry/profilemeasurements/ProfileMeasurement.java +++ b/sentry/src/main/java/io/sentry/profilemeasurements/ProfileMeasurement.java @@ -2,9 +2,9 @@ import io.sentry.ILogger; import io.sentry.JsonDeserializer; -import io.sentry.JsonObjectReader; import io.sentry.JsonSerializable; import io.sentry.JsonUnknown; +import io.sentry.ObjectReader; import io.sentry.ObjectWriter; import io.sentry.util.Objects; import io.sentry.vendor.gson.stream.JsonToken; @@ -118,7 +118,7 @@ public static final class Deserializer implements JsonDeserializer unknown = null; diff --git a/sentry/src/main/java/io/sentry/profilemeasurements/ProfileMeasurementValue.java b/sentry/src/main/java/io/sentry/profilemeasurements/ProfileMeasurementValue.java index 9639ba892f..b0cebf5439 100644 --- a/sentry/src/main/java/io/sentry/profilemeasurements/ProfileMeasurementValue.java +++ b/sentry/src/main/java/io/sentry/profilemeasurements/ProfileMeasurementValue.java @@ -2,9 +2,9 @@ import io.sentry.ILogger; import io.sentry.JsonDeserializer; -import io.sentry.JsonObjectReader; import io.sentry.JsonSerializable; import io.sentry.JsonUnknown; +import io.sentry.ObjectReader; import io.sentry.ObjectWriter; import io.sentry.util.Objects; import io.sentry.vendor.gson.stream.JsonToken; @@ -92,7 +92,7 @@ public static final class Deserializer implements JsonDeserializer unknown = null; diff --git a/sentry/src/main/java/io/sentry/protocol/App.java b/sentry/src/main/java/io/sentry/protocol/App.java index bec57d22f3..b949f93c1e 100644 --- a/sentry/src/main/java/io/sentry/protocol/App.java +++ b/sentry/src/main/java/io/sentry/protocol/App.java @@ -2,9 +2,9 @@ import io.sentry.ILogger; import io.sentry.JsonDeserializer; -import io.sentry.JsonObjectReader; import io.sentry.JsonSerializable; import io.sentry.JsonUnknown; +import io.sentry.ObjectReader; import io.sentry.ObjectWriter; import io.sentry.util.CollectionUtils; import io.sentry.util.Objects; @@ -273,7 +273,7 @@ public void serialize(final @NotNull ObjectWriter writer, final @NotNull ILogger public static final class Deserializer implements JsonDeserializer { @SuppressWarnings("unchecked") @Override - public @NotNull App deserialize(@NotNull JsonObjectReader reader, @NotNull ILogger logger) + public @NotNull App deserialize(@NotNull ObjectReader reader, @NotNull ILogger logger) throws Exception { reader.beginObject(); App app = new App(); diff --git a/sentry/src/main/java/io/sentry/protocol/Browser.java b/sentry/src/main/java/io/sentry/protocol/Browser.java index 99fe427c27..ed32be5ea2 100644 --- a/sentry/src/main/java/io/sentry/protocol/Browser.java +++ b/sentry/src/main/java/io/sentry/protocol/Browser.java @@ -2,9 +2,9 @@ import io.sentry.ILogger; import io.sentry.JsonDeserializer; -import io.sentry.JsonObjectReader; import io.sentry.JsonSerializable; import io.sentry.JsonUnknown; +import io.sentry.ObjectReader; import io.sentry.ObjectWriter; import io.sentry.util.CollectionUtils; import io.sentry.util.Objects; @@ -102,7 +102,7 @@ public void serialize(final @NotNull ObjectWriter writer, final @NotNull ILogger public static final class Deserializer implements JsonDeserializer { @Override - public @NotNull Browser deserialize(@NotNull JsonObjectReader reader, @NotNull ILogger logger) + public @NotNull Browser deserialize(@NotNull ObjectReader reader, @NotNull ILogger logger) throws Exception { reader.beginObject(); Browser browser = new Browser(); diff --git a/sentry/src/main/java/io/sentry/protocol/Contexts.java b/sentry/src/main/java/io/sentry/protocol/Contexts.java index 21be9fd8a5..28d2e8d2a4 100644 --- a/sentry/src/main/java/io/sentry/protocol/Contexts.java +++ b/sentry/src/main/java/io/sentry/protocol/Contexts.java @@ -2,8 +2,8 @@ import io.sentry.ILogger; import io.sentry.JsonDeserializer; -import io.sentry.JsonObjectReader; import io.sentry.JsonSerializable; +import io.sentry.ObjectReader; import io.sentry.ObjectWriter; import io.sentry.SpanContext; import io.sentry.util.HintUtils; @@ -160,7 +160,7 @@ public static final class Deserializer implements JsonDeserializer { @Override public @NotNull Contexts deserialize( - final @NotNull JsonObjectReader reader, final @NotNull ILogger logger) throws Exception { + final @NotNull ObjectReader reader, final @NotNull ILogger logger) throws Exception { final Contexts contexts = new Contexts(); reader.beginObject(); while (reader.peek() == JsonToken.NAME) { diff --git a/sentry/src/main/java/io/sentry/protocol/DebugImage.java b/sentry/src/main/java/io/sentry/protocol/DebugImage.java index d26432033e..e769e2c2ca 100644 --- a/sentry/src/main/java/io/sentry/protocol/DebugImage.java +++ b/sentry/src/main/java/io/sentry/protocol/DebugImage.java @@ -2,9 +2,9 @@ import io.sentry.ILogger; import io.sentry.JsonDeserializer; -import io.sentry.JsonObjectReader; import io.sentry.JsonSerializable; import io.sentry.JsonUnknown; +import io.sentry.ObjectReader; import io.sentry.ObjectWriter; import io.sentry.vendor.gson.stream.JsonToken; import java.io.IOException; @@ -314,8 +314,8 @@ public void serialize(final @NotNull ObjectWriter writer, final @NotNull ILogger public static final class Deserializer implements JsonDeserializer { @Override - public @NotNull DebugImage deserialize( - @NotNull JsonObjectReader reader, @NotNull ILogger logger) throws Exception { + public @NotNull DebugImage deserialize(@NotNull ObjectReader reader, @NotNull ILogger logger) + throws Exception { DebugImage debugImage = new DebugImage(); Map unknown = null; diff --git a/sentry/src/main/java/io/sentry/protocol/DebugMeta.java b/sentry/src/main/java/io/sentry/protocol/DebugMeta.java index 134947507a..458c4de631 100644 --- a/sentry/src/main/java/io/sentry/protocol/DebugMeta.java +++ b/sentry/src/main/java/io/sentry/protocol/DebugMeta.java @@ -2,9 +2,9 @@ import io.sentry.ILogger; import io.sentry.JsonDeserializer; -import io.sentry.JsonObjectReader; import io.sentry.JsonSerializable; import io.sentry.JsonUnknown; +import io.sentry.ObjectReader; import io.sentry.ObjectWriter; import io.sentry.vendor.gson.stream.JsonToken; import java.io.IOException; @@ -95,7 +95,7 @@ public void serialize(final @NotNull ObjectWriter writer, final @NotNull ILogger public static final class Deserializer implements JsonDeserializer { @Override - public @NotNull DebugMeta deserialize(@NotNull JsonObjectReader reader, @NotNull ILogger logger) + public @NotNull DebugMeta deserialize(@NotNull ObjectReader reader, @NotNull ILogger logger) throws Exception { DebugMeta debugMeta = new DebugMeta(); diff --git a/sentry/src/main/java/io/sentry/protocol/Device.java b/sentry/src/main/java/io/sentry/protocol/Device.java index 4f06f74995..25cfa41fd1 100644 --- a/sentry/src/main/java/io/sentry/protocol/Device.java +++ b/sentry/src/main/java/io/sentry/protocol/Device.java @@ -2,9 +2,9 @@ import io.sentry.ILogger; import io.sentry.JsonDeserializer; -import io.sentry.JsonObjectReader; import io.sentry.JsonSerializable; import io.sentry.JsonUnknown; +import io.sentry.ObjectReader; import io.sentry.ObjectWriter; import io.sentry.util.CollectionUtils; import io.sentry.util.Objects; @@ -544,7 +544,7 @@ public void serialize(final @NotNull ObjectWriter writer, final @NotNull ILogger public static final class Deserializer implements JsonDeserializer { @Override public @NotNull DeviceOrientation deserialize( - @NotNull JsonObjectReader reader, @NotNull ILogger logger) throws Exception { + @NotNull ObjectReader reader, @NotNull ILogger logger) throws Exception { return DeviceOrientation.valueOf(reader.nextString().toUpperCase(Locale.ROOT)); } } @@ -726,7 +726,7 @@ public void setUnknown(@Nullable Map unknown) { public static final class Deserializer implements JsonDeserializer { @Override - public @NotNull Device deserialize(@NotNull JsonObjectReader reader, @NotNull ILogger logger) + public @NotNull Device deserialize(@NotNull ObjectReader reader, @NotNull ILogger logger) throws Exception { reader.beginObject(); Device device = new Device(); diff --git a/sentry/src/main/java/io/sentry/protocol/Geo.java b/sentry/src/main/java/io/sentry/protocol/Geo.java index fefc340e1b..c9094223ab 100644 --- a/sentry/src/main/java/io/sentry/protocol/Geo.java +++ b/sentry/src/main/java/io/sentry/protocol/Geo.java @@ -2,9 +2,9 @@ import io.sentry.ILogger; import io.sentry.JsonDeserializer; -import io.sentry.JsonObjectReader; import io.sentry.JsonSerializable; import io.sentry.JsonUnknown; +import io.sentry.ObjectReader; import io.sentry.ObjectWriter; import io.sentry.vendor.gson.stream.JsonToken; import java.io.IOException; @@ -161,7 +161,8 @@ public void serialize(final @NotNull ObjectWriter writer, final @NotNull ILogger public static final class Deserializer implements JsonDeserializer { @Override - public Geo deserialize(JsonObjectReader reader, ILogger logger) throws Exception { + public @NotNull Geo deserialize(@NotNull ObjectReader reader, @NotNull ILogger logger) + throws Exception { reader.beginObject(); final Geo geo = new Geo(); Map unknown = null; diff --git a/sentry/src/main/java/io/sentry/protocol/Gpu.java b/sentry/src/main/java/io/sentry/protocol/Gpu.java index 0dfe85f68f..b4a8344e2d 100644 --- a/sentry/src/main/java/io/sentry/protocol/Gpu.java +++ b/sentry/src/main/java/io/sentry/protocol/Gpu.java @@ -2,9 +2,9 @@ import io.sentry.ILogger; import io.sentry.JsonDeserializer; -import io.sentry.JsonObjectReader; import io.sentry.JsonSerializable; import io.sentry.JsonUnknown; +import io.sentry.ObjectReader; import io.sentry.ObjectWriter; import io.sentry.util.CollectionUtils; import io.sentry.util.Objects; @@ -229,7 +229,7 @@ public void setUnknown(@Nullable Map unknown) { public static final class Deserializer implements JsonDeserializer { @Override - public @NotNull Gpu deserialize(@NotNull JsonObjectReader reader, @NotNull ILogger logger) + public @NotNull Gpu deserialize(@NotNull ObjectReader reader, @NotNull ILogger logger) throws Exception { reader.beginObject(); Gpu gpu = new Gpu(); diff --git a/sentry/src/main/java/io/sentry/protocol/MeasurementValue.java b/sentry/src/main/java/io/sentry/protocol/MeasurementValue.java index f7fa7277a1..aca5b40c09 100644 --- a/sentry/src/main/java/io/sentry/protocol/MeasurementValue.java +++ b/sentry/src/main/java/io/sentry/protocol/MeasurementValue.java @@ -2,9 +2,9 @@ import io.sentry.ILogger; import io.sentry.JsonDeserializer; -import io.sentry.JsonObjectReader; import io.sentry.JsonSerializable; import io.sentry.JsonUnknown; +import io.sentry.ObjectReader; import io.sentry.ObjectWriter; import io.sentry.SentryLevel; import io.sentry.vendor.gson.stream.JsonToken; @@ -102,7 +102,7 @@ public void serialize(final @NotNull ObjectWriter writer, final @NotNull ILogger public static final class Deserializer implements JsonDeserializer { @Override public @NotNull MeasurementValue deserialize( - @NotNull JsonObjectReader reader, @NotNull ILogger logger) throws Exception { + @NotNull ObjectReader reader, @NotNull ILogger logger) throws Exception { reader.beginObject(); String unit = null; diff --git a/sentry/src/main/java/io/sentry/protocol/Mechanism.java b/sentry/src/main/java/io/sentry/protocol/Mechanism.java index 648aed39c2..fac8808f2d 100644 --- a/sentry/src/main/java/io/sentry/protocol/Mechanism.java +++ b/sentry/src/main/java/io/sentry/protocol/Mechanism.java @@ -2,9 +2,9 @@ import io.sentry.ILogger; import io.sentry.JsonDeserializer; -import io.sentry.JsonObjectReader; import io.sentry.JsonSerializable; import io.sentry.JsonUnknown; +import io.sentry.ObjectReader; import io.sentry.ObjectWriter; import io.sentry.util.CollectionUtils; import io.sentry.vendor.gson.stream.JsonToken; @@ -205,7 +205,7 @@ public void serialize(final @NotNull ObjectWriter writer, final @NotNull ILogger public static final class Deserializer implements JsonDeserializer { @SuppressWarnings("unchecked") @Override - public @NotNull Mechanism deserialize(@NotNull JsonObjectReader reader, @NotNull ILogger logger) + public @NotNull Mechanism deserialize(@NotNull ObjectReader reader, @NotNull ILogger logger) throws Exception { Mechanism mechanism = new Mechanism(); Map unknown = null; diff --git a/sentry/src/main/java/io/sentry/protocol/Message.java b/sentry/src/main/java/io/sentry/protocol/Message.java index a1c79e2198..9aceea56a6 100644 --- a/sentry/src/main/java/io/sentry/protocol/Message.java +++ b/sentry/src/main/java/io/sentry/protocol/Message.java @@ -2,9 +2,9 @@ import io.sentry.ILogger; import io.sentry.JsonDeserializer; -import io.sentry.JsonObjectReader; import io.sentry.JsonSerializable; import io.sentry.JsonUnknown; +import io.sentry.ObjectReader; import io.sentry.ObjectWriter; import io.sentry.util.CollectionUtils; import io.sentry.vendor.gson.stream.JsonToken; @@ -131,7 +131,7 @@ public static final class Deserializer implements JsonDeserializer { @SuppressWarnings("unchecked") @Override - public @NotNull Message deserialize(@NotNull JsonObjectReader reader, @NotNull ILogger logger) + public @NotNull Message deserialize(@NotNull ObjectReader reader, @NotNull ILogger logger) throws Exception { reader.beginObject(); Message message = new Message(); diff --git a/sentry/src/main/java/io/sentry/protocol/MetricSummary.java b/sentry/src/main/java/io/sentry/protocol/MetricSummary.java index db4f0b6ba5..f4a8b6de53 100644 --- a/sentry/src/main/java/io/sentry/protocol/MetricSummary.java +++ b/sentry/src/main/java/io/sentry/protocol/MetricSummary.java @@ -2,9 +2,9 @@ import io.sentry.ILogger; import io.sentry.JsonDeserializer; -import io.sentry.JsonObjectReader; import io.sentry.JsonSerializable; import io.sentry.JsonUnknown; +import io.sentry.ObjectReader; import io.sentry.ObjectWriter; import io.sentry.util.CollectionUtils; import io.sentry.vendor.gson.stream.JsonToken; @@ -121,7 +121,7 @@ public static final class Deserializer implements JsonDeserializer unknown = null; diff --git a/sentry/src/main/java/io/sentry/protocol/OperatingSystem.java b/sentry/src/main/java/io/sentry/protocol/OperatingSystem.java index 796a4ea1a0..ecfb59542b 100644 --- a/sentry/src/main/java/io/sentry/protocol/OperatingSystem.java +++ b/sentry/src/main/java/io/sentry/protocol/OperatingSystem.java @@ -2,9 +2,9 @@ import io.sentry.ILogger; import io.sentry.JsonDeserializer; -import io.sentry.JsonObjectReader; import io.sentry.JsonSerializable; import io.sentry.JsonUnknown; +import io.sentry.ObjectReader; import io.sentry.ObjectWriter; import io.sentry.util.CollectionUtils; import io.sentry.util.Objects; @@ -180,7 +180,7 @@ public static final class Deserializer implements JsonDeserializer unknown = null; diff --git a/sentry/src/main/java/io/sentry/protocol/Request.java b/sentry/src/main/java/io/sentry/protocol/Request.java index 14f5403844..44e205a390 100644 --- a/sentry/src/main/java/io/sentry/protocol/Request.java +++ b/sentry/src/main/java/io/sentry/protocol/Request.java @@ -2,9 +2,9 @@ import io.sentry.ILogger; import io.sentry.JsonDeserializer; -import io.sentry.JsonObjectReader; import io.sentry.JsonSerializable; import io.sentry.JsonUnknown; +import io.sentry.ObjectReader; import io.sentry.ObjectWriter; import io.sentry.util.CollectionUtils; import io.sentry.util.Objects; @@ -326,7 +326,7 @@ public void serialize(final @NotNull ObjectWriter writer, final @NotNull ILogger @SuppressWarnings("unchecked") public static final class Deserializer implements JsonDeserializer { @Override - public @NotNull Request deserialize(@NotNull JsonObjectReader reader, @NotNull ILogger logger) + public @NotNull Request deserialize(@NotNull ObjectReader reader, @NotNull ILogger logger) throws Exception { reader.beginObject(); Request request = new Request(); diff --git a/sentry/src/main/java/io/sentry/protocol/Response.java b/sentry/src/main/java/io/sentry/protocol/Response.java index 23a16c78f8..f1a9303710 100644 --- a/sentry/src/main/java/io/sentry/protocol/Response.java +++ b/sentry/src/main/java/io/sentry/protocol/Response.java @@ -2,9 +2,9 @@ import io.sentry.ILogger; import io.sentry.JsonDeserializer; -import io.sentry.JsonObjectReader; import io.sentry.JsonSerializable; import io.sentry.JsonUnknown; +import io.sentry.ObjectReader; import io.sentry.ObjectWriter; import io.sentry.util.CollectionUtils; import io.sentry.vendor.gson.stream.JsonToken; @@ -154,7 +154,7 @@ public void serialize(final @NotNull ObjectWriter writer, final @NotNull ILogger public static final class Deserializer implements JsonDeserializer { @Override public @NotNull Response deserialize( - final @NotNull JsonObjectReader reader, final @NotNull ILogger logger) throws Exception { + final @NotNull ObjectReader reader, final @NotNull ILogger logger) throws Exception { reader.beginObject(); final Response response = new Response(); Map unknown = null; diff --git a/sentry/src/main/java/io/sentry/protocol/SdkInfo.java b/sentry/src/main/java/io/sentry/protocol/SdkInfo.java index ee3ac1eb16..928a8b522d 100644 --- a/sentry/src/main/java/io/sentry/protocol/SdkInfo.java +++ b/sentry/src/main/java/io/sentry/protocol/SdkInfo.java @@ -2,9 +2,9 @@ import io.sentry.ILogger; import io.sentry.JsonDeserializer; -import io.sentry.JsonObjectReader; import io.sentry.JsonSerializable; import io.sentry.JsonUnknown; +import io.sentry.ObjectReader; import io.sentry.ObjectWriter; import io.sentry.vendor.gson.stream.JsonToken; import java.io.IOException; @@ -116,7 +116,7 @@ public void serialize(final @NotNull ObjectWriter writer, final @NotNull ILogger public static final class Deserializer implements JsonDeserializer { @Override - public @NotNull SdkInfo deserialize(@NotNull JsonObjectReader reader, @NotNull ILogger logger) + public @NotNull SdkInfo deserialize(@NotNull ObjectReader reader, @NotNull ILogger logger) throws Exception { SdkInfo sdkInfo = new SdkInfo(); diff --git a/sentry/src/main/java/io/sentry/protocol/SdkVersion.java b/sentry/src/main/java/io/sentry/protocol/SdkVersion.java index f7ba230463..aa997910be 100644 --- a/sentry/src/main/java/io/sentry/protocol/SdkVersion.java +++ b/sentry/src/main/java/io/sentry/protocol/SdkVersion.java @@ -2,9 +2,9 @@ import io.sentry.ILogger; import io.sentry.JsonDeserializer; -import io.sentry.JsonObjectReader; import io.sentry.JsonSerializable; import io.sentry.JsonUnknown; +import io.sentry.ObjectReader; import io.sentry.ObjectWriter; import io.sentry.SentryIntegrationPackageStorage; import io.sentry.SentryLevel; @@ -224,8 +224,8 @@ public void serialize(final @NotNull ObjectWriter writer, final @NotNull ILogger @SuppressWarnings("unchecked") public static final class Deserializer implements JsonDeserializer { @Override - public @NotNull SdkVersion deserialize( - @NotNull JsonObjectReader reader, @NotNull ILogger logger) throws Exception { + public @NotNull SdkVersion deserialize(@NotNull ObjectReader reader, @NotNull ILogger logger) + throws Exception { String name = null; String version = null; diff --git a/sentry/src/main/java/io/sentry/protocol/SentryException.java b/sentry/src/main/java/io/sentry/protocol/SentryException.java index 5ee9464a3c..4d56e12747 100644 --- a/sentry/src/main/java/io/sentry/protocol/SentryException.java +++ b/sentry/src/main/java/io/sentry/protocol/SentryException.java @@ -2,9 +2,9 @@ import io.sentry.ILogger; import io.sentry.JsonDeserializer; -import io.sentry.JsonObjectReader; import io.sentry.JsonSerializable; import io.sentry.JsonUnknown; +import io.sentry.ObjectReader; import io.sentry.ObjectWriter; import io.sentry.vendor.gson.stream.JsonToken; import java.io.IOException; @@ -223,7 +223,7 @@ public static final class Deserializer implements JsonDeserializer unknown = null; reader.beginObject(); diff --git a/sentry/src/main/java/io/sentry/protocol/SentryId.java b/sentry/src/main/java/io/sentry/protocol/SentryId.java index c1e5ea1819..109655fdf2 100644 --- a/sentry/src/main/java/io/sentry/protocol/SentryId.java +++ b/sentry/src/main/java/io/sentry/protocol/SentryId.java @@ -2,8 +2,8 @@ import io.sentry.ILogger; import io.sentry.JsonDeserializer; -import io.sentry.JsonObjectReader; import io.sentry.JsonSerializable; +import io.sentry.ObjectReader; import io.sentry.ObjectWriter; import io.sentry.util.StringUtils; import java.io.IOException; @@ -82,7 +82,7 @@ public void serialize(final @NotNull ObjectWriter writer, final @NotNull ILogger public static final class Deserializer implements JsonDeserializer { @Override - public @NotNull SentryId deserialize(@NotNull JsonObjectReader reader, @NotNull ILogger logger) + public @NotNull SentryId deserialize(@NotNull ObjectReader reader, @NotNull ILogger logger) throws Exception { return new SentryId(reader.nextString()); } diff --git a/sentry/src/main/java/io/sentry/protocol/SentryPackage.java b/sentry/src/main/java/io/sentry/protocol/SentryPackage.java index cea6bb8497..aa2358d8df 100644 --- a/sentry/src/main/java/io/sentry/protocol/SentryPackage.java +++ b/sentry/src/main/java/io/sentry/protocol/SentryPackage.java @@ -2,9 +2,9 @@ import io.sentry.ILogger; import io.sentry.JsonDeserializer; -import io.sentry.JsonObjectReader; import io.sentry.JsonSerializable; import io.sentry.JsonUnknown; +import io.sentry.ObjectReader; import io.sentry.ObjectWriter; import io.sentry.SentryLevel; import io.sentry.util.Objects; @@ -100,8 +100,8 @@ public void serialize(final @NotNull ObjectWriter writer, final @NotNull ILogger public static final class Deserializer implements JsonDeserializer { @Override - public @NotNull SentryPackage deserialize( - @NotNull JsonObjectReader reader, @NotNull ILogger logger) throws Exception { + public @NotNull SentryPackage deserialize(@NotNull ObjectReader reader, @NotNull ILogger logger) + throws Exception { String name = null; String version = null; diff --git a/sentry/src/main/java/io/sentry/protocol/SentryRuntime.java b/sentry/src/main/java/io/sentry/protocol/SentryRuntime.java index 751e664ae6..7d2ed8fa1e 100644 --- a/sentry/src/main/java/io/sentry/protocol/SentryRuntime.java +++ b/sentry/src/main/java/io/sentry/protocol/SentryRuntime.java @@ -2,9 +2,9 @@ import io.sentry.ILogger; import io.sentry.JsonDeserializer; -import io.sentry.JsonObjectReader; import io.sentry.JsonSerializable; import io.sentry.JsonUnknown; +import io.sentry.ObjectReader; import io.sentry.ObjectWriter; import io.sentry.util.CollectionUtils; import io.sentry.vendor.gson.stream.JsonToken; @@ -110,8 +110,8 @@ public void setUnknown(@Nullable Map unknown) { public static final class Deserializer implements JsonDeserializer { @Override - public @NotNull SentryRuntime deserialize( - @NotNull JsonObjectReader reader, @NotNull ILogger logger) throws Exception { + public @NotNull SentryRuntime deserialize(@NotNull ObjectReader reader, @NotNull ILogger logger) + throws Exception { reader.beginObject(); SentryRuntime runtime = new SentryRuntime(); Map unknown = null; diff --git a/sentry/src/main/java/io/sentry/protocol/SentrySpan.java b/sentry/src/main/java/io/sentry/protocol/SentrySpan.java index 2be4411d44..f4c8d20efa 100644 --- a/sentry/src/main/java/io/sentry/protocol/SentrySpan.java +++ b/sentry/src/main/java/io/sentry/protocol/SentrySpan.java @@ -3,9 +3,9 @@ import io.sentry.DateUtils; import io.sentry.ILogger; import io.sentry.JsonDeserializer; -import io.sentry.JsonObjectReader; import io.sentry.JsonSerializable; import io.sentry.JsonUnknown; +import io.sentry.ObjectReader; import io.sentry.ObjectWriter; import io.sentry.SentryLevel; import io.sentry.Span; @@ -257,8 +257,8 @@ public static final class Deserializer implements JsonDeserializer { @SuppressWarnings("unchecked") @Override - public @NotNull SentrySpan deserialize( - @NotNull JsonObjectReader reader, @NotNull ILogger logger) throws Exception { + public @NotNull SentrySpan deserialize(@NotNull ObjectReader reader, @NotNull ILogger logger) + throws Exception { reader.beginObject(); Double startTimestamp = null; diff --git a/sentry/src/main/java/io/sentry/protocol/SentryStackFrame.java b/sentry/src/main/java/io/sentry/protocol/SentryStackFrame.java index fcb93eb2e8..03d64e2172 100644 --- a/sentry/src/main/java/io/sentry/protocol/SentryStackFrame.java +++ b/sentry/src/main/java/io/sentry/protocol/SentryStackFrame.java @@ -2,9 +2,9 @@ import io.sentry.ILogger; import io.sentry.JsonDeserializer; -import io.sentry.JsonObjectReader; import io.sentry.JsonSerializable; import io.sentry.JsonUnknown; +import io.sentry.ObjectReader; import io.sentry.ObjectWriter; import io.sentry.SentryLockReason; import io.sentry.vendor.gson.stream.JsonToken; @@ -398,7 +398,7 @@ public void serialize(final @NotNull ObjectWriter writer, final @NotNull ILogger public static final class Deserializer implements JsonDeserializer { @Override public @NotNull SentryStackFrame deserialize( - @NotNull JsonObjectReader reader, @NotNull ILogger logger) throws Exception { + @NotNull ObjectReader reader, @NotNull ILogger logger) throws Exception { SentryStackFrame sentryStackFrame = new SentryStackFrame(); Map unknown = null; reader.beginObject(); diff --git a/sentry/src/main/java/io/sentry/protocol/SentryStackTrace.java b/sentry/src/main/java/io/sentry/protocol/SentryStackTrace.java index 90b42666c8..e79e8e7ec0 100644 --- a/sentry/src/main/java/io/sentry/protocol/SentryStackTrace.java +++ b/sentry/src/main/java/io/sentry/protocol/SentryStackTrace.java @@ -2,9 +2,9 @@ import io.sentry.ILogger; import io.sentry.JsonDeserializer; -import io.sentry.JsonObjectReader; import io.sentry.JsonSerializable; import io.sentry.JsonUnknown; +import io.sentry.ObjectReader; import io.sentry.ObjectWriter; import io.sentry.util.CollectionUtils; import io.sentry.vendor.gson.stream.JsonToken; @@ -154,7 +154,7 @@ public static final class Deserializer implements JsonDeserializer unknown = null; reader.beginObject(); diff --git a/sentry/src/main/java/io/sentry/protocol/SentryThread.java b/sentry/src/main/java/io/sentry/protocol/SentryThread.java index 1d57e35b10..accb05968e 100644 --- a/sentry/src/main/java/io/sentry/protocol/SentryThread.java +++ b/sentry/src/main/java/io/sentry/protocol/SentryThread.java @@ -2,9 +2,9 @@ import io.sentry.ILogger; import io.sentry.JsonDeserializer; -import io.sentry.JsonObjectReader; import io.sentry.JsonSerializable; import io.sentry.JsonUnknown; +import io.sentry.ObjectReader; import io.sentry.ObjectWriter; import io.sentry.SentryLockReason; import io.sentry.vendor.gson.stream.JsonToken; @@ -303,8 +303,8 @@ public void serialize(final @NotNull ObjectWriter writer, final @NotNull ILogger public static final class Deserializer implements JsonDeserializer { @SuppressWarnings("unchecked") @Override - public @NotNull SentryThread deserialize( - @NotNull JsonObjectReader reader, @NotNull ILogger logger) throws Exception { + public @NotNull SentryThread deserialize(@NotNull ObjectReader reader, @NotNull ILogger logger) + throws Exception { SentryThread sentryThread = new SentryThread(); Map unknown = null; reader.beginObject(); diff --git a/sentry/src/main/java/io/sentry/protocol/SentryTransaction.java b/sentry/src/main/java/io/sentry/protocol/SentryTransaction.java index 0ca789270e..3bc42e4208 100644 --- a/sentry/src/main/java/io/sentry/protocol/SentryTransaction.java +++ b/sentry/src/main/java/io/sentry/protocol/SentryTransaction.java @@ -3,9 +3,9 @@ import io.sentry.DateUtils; import io.sentry.ILogger; import io.sentry.JsonDeserializer; -import io.sentry.JsonObjectReader; import io.sentry.JsonSerializable; import io.sentry.JsonUnknown; +import io.sentry.ObjectReader; import io.sentry.ObjectWriter; import io.sentry.SentryBaseEvent; import io.sentry.SentryTracer; @@ -259,7 +259,7 @@ public static final class Deserializer implements JsonDeserializer { @SuppressWarnings("unchecked") @Override - public @NotNull User deserialize(@NotNull JsonObjectReader reader, @NotNull ILogger logger) + public @NotNull User deserialize(@NotNull ObjectReader reader, @NotNull ILogger logger) throws Exception { reader.beginObject(); User user = new User(); diff --git a/sentry/src/main/java/io/sentry/protocol/ViewHierarchy.java b/sentry/src/main/java/io/sentry/protocol/ViewHierarchy.java index 69e5156040..791c9bbbd6 100644 --- a/sentry/src/main/java/io/sentry/protocol/ViewHierarchy.java +++ b/sentry/src/main/java/io/sentry/protocol/ViewHierarchy.java @@ -2,9 +2,9 @@ import io.sentry.ILogger; import io.sentry.JsonDeserializer; -import io.sentry.JsonObjectReader; import io.sentry.JsonSerializable; import io.sentry.JsonUnknown; +import io.sentry.ObjectReader; import io.sentry.ObjectWriter; import io.sentry.vendor.gson.stream.JsonToken; import java.io.IOException; @@ -73,8 +73,8 @@ public void setUnknown(@Nullable Map unknown) { public static final class Deserializer implements JsonDeserializer { @Override - public @NotNull ViewHierarchy deserialize( - @NotNull JsonObjectReader reader, @NotNull ILogger logger) throws Exception { + public @NotNull ViewHierarchy deserialize(@NotNull ObjectReader reader, @NotNull ILogger logger) + throws Exception { @Nullable String renderingSystem = null; @Nullable List windows = null; diff --git a/sentry/src/main/java/io/sentry/protocol/ViewHierarchyNode.java b/sentry/src/main/java/io/sentry/protocol/ViewHierarchyNode.java index 923eb95877..525d644fdc 100644 --- a/sentry/src/main/java/io/sentry/protocol/ViewHierarchyNode.java +++ b/sentry/src/main/java/io/sentry/protocol/ViewHierarchyNode.java @@ -2,9 +2,9 @@ import io.sentry.ILogger; import io.sentry.JsonDeserializer; -import io.sentry.JsonObjectReader; import io.sentry.JsonSerializable; import io.sentry.JsonUnknown; +import io.sentry.ObjectReader; import io.sentry.ObjectWriter; import io.sentry.vendor.gson.stream.JsonToken; import java.io.IOException; @@ -205,7 +205,7 @@ public static final class Deserializer implements JsonDeserializer unknown = null; @NotNull final ViewHierarchyNode node = new ViewHierarchyNode(); diff --git a/sentry/src/main/java/io/sentry/rrweb/RRWebBreadcrumbEvent.java b/sentry/src/main/java/io/sentry/rrweb/RRWebBreadcrumbEvent.java new file mode 100644 index 0000000000..6fb269c405 --- /dev/null +++ b/sentry/src/main/java/io/sentry/rrweb/RRWebBreadcrumbEvent.java @@ -0,0 +1,317 @@ +package io.sentry.rrweb; + +import io.sentry.ILogger; +import io.sentry.JsonDeserializer; +import io.sentry.JsonSerializable; +import io.sentry.JsonUnknown; +import io.sentry.ObjectReader; +import io.sentry.ObjectWriter; +import io.sentry.SentryLevel; +import io.sentry.util.CollectionUtils; +import io.sentry.vendor.gson.stream.JsonToken; +import java.io.IOException; +import java.math.BigDecimal; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +public final class RRWebBreadcrumbEvent extends RRWebEvent + implements JsonUnknown, JsonSerializable { + public static final String EVENT_TAG = "breadcrumb"; + + private @NotNull String tag; + private double breadcrumbTimestamp; + private @Nullable String breadcrumbType; + private @Nullable String category; + private @Nullable String message; + private @Nullable SentryLevel level; + private @Nullable Map data; + // to support unknown json attributes with nesting, we have to have unknown map for each of the + // nested object in json: { ..., "data": { ..., "payload": { ... } } } + private @Nullable Map unknown; + private @Nullable Map payloadUnknown; + private @Nullable Map dataUnknown; + + public RRWebBreadcrumbEvent() { + super(RRWebEventType.Custom); + tag = EVENT_TAG; + } + + @NotNull + public String getTag() { + return tag; + } + + public void setTag(final @NotNull String tag) { + this.tag = tag; + } + + public double getBreadcrumbTimestamp() { + return breadcrumbTimestamp; + } + + public void setBreadcrumbTimestamp(final double breadcrumbTimestamp) { + this.breadcrumbTimestamp = breadcrumbTimestamp; + } + + @Nullable + public String getBreadcrumbType() { + return breadcrumbType; + } + + public void setBreadcrumbType(final @Nullable String breadcrumbType) { + this.breadcrumbType = breadcrumbType; + } + + @Nullable + public String getCategory() { + return category; + } + + public void setCategory(final @Nullable String category) { + this.category = category; + } + + @Nullable + public String getMessage() { + return message; + } + + public void setMessage(final @Nullable String message) { + this.message = message; + } + + @Nullable + public SentryLevel getLevel() { + return level; + } + + public void setLevel(final @Nullable SentryLevel level) { + this.level = level; + } + + @Nullable + public Map getData() { + return data; + } + + public void setData(final @Nullable Map data) { + this.data = data == null ? null : new ConcurrentHashMap<>(data); + } + + public @Nullable Map getPayloadUnknown() { + return payloadUnknown; + } + + public void setPayloadUnknown(final @Nullable Map payloadUnknown) { + this.payloadUnknown = payloadUnknown; + } + + public @Nullable Map getDataUnknown() { + return dataUnknown; + } + + public void setDataUnknown(final @Nullable Map dataUnknown) { + this.dataUnknown = dataUnknown; + } + + @Override + public @Nullable Map getUnknown() { + return unknown; + } + + @Override + public void setUnknown(final @Nullable Map unknown) { + this.unknown = unknown; + } + + // region json + + // rrweb uses camelCase hence the json keys are in camelCase here + public static final class JsonKeys { + public static final String DATA = "data"; + public static final String PAYLOAD = "payload"; + public static final String TIMESTAMP = "timestamp"; + public static final String TYPE = "type"; + public static final String CATEGORY = "category"; + public static final String MESSAGE = "message"; + public static final String LEVEL = "level"; + } + + @Override + public void serialize(@NotNull ObjectWriter writer, @NotNull ILogger logger) throws IOException { + writer.beginObject(); + new RRWebEvent.Serializer().serialize(this, writer, logger); + writer.name(JsonKeys.DATA); + serializeData(writer, logger); + if (unknown != null) { + for (final String key : unknown.keySet()) { + final Object value = unknown.get(key); + writer.name(key); + writer.value(logger, value); + } + } + writer.endObject(); + } + + private void serializeData(final @NotNull ObjectWriter writer, final @NotNull ILogger logger) + throws IOException { + writer.beginObject(); + writer.name(RRWebEvent.JsonKeys.TAG).value(tag); + writer.name(JsonKeys.PAYLOAD); + serializePayload(writer, logger); + if (dataUnknown != null) { + for (String key : dataUnknown.keySet()) { + Object value = dataUnknown.get(key); + writer.name(key); + writer.value(logger, value); + } + } + writer.endObject(); + } + + private void serializePayload(final @NotNull ObjectWriter writer, final @NotNull ILogger logger) + throws IOException { + writer.beginObject(); + if (breadcrumbType != null) { + writer.name(JsonKeys.TYPE).value(breadcrumbType); + } + writer.name(JsonKeys.TIMESTAMP).value(logger, BigDecimal.valueOf(breadcrumbTimestamp)); + if (category != null) { + writer.name(JsonKeys.CATEGORY).value(category); + } + if (message != null) { + writer.name(JsonKeys.MESSAGE).value(message); + } + if (level != null) { + writer.name(JsonKeys.LEVEL).value(logger, level); + } + if (data != null) { + writer.name(JsonKeys.DATA).value(logger, data); + } + if (payloadUnknown != null) { + for (final String key : payloadUnknown.keySet()) { + final Object value = payloadUnknown.get(key); + writer.name(key); + writer.value(logger, value); + } + } + writer.endObject(); + } + + public static final class Deserializer implements JsonDeserializer { + + @Override + public @NotNull RRWebBreadcrumbEvent deserialize( + @NotNull ObjectReader reader, @NotNull ILogger logger) throws Exception { + reader.beginObject(); + @Nullable Map unknown = null; + + final RRWebBreadcrumbEvent event = new RRWebBreadcrumbEvent(); + final RRWebEvent.Deserializer baseEventDeserializer = new RRWebEvent.Deserializer(); + + while (reader.peek() == JsonToken.NAME) { + final String nextName = reader.nextName(); + switch (nextName) { + case JsonKeys.DATA: + deserializeData(event, reader, logger); + break; + default: + if (!baseEventDeserializer.deserializeValue(event, nextName, reader, logger)) { + if (unknown == null) { + unknown = new HashMap<>(); + } + reader.nextUnknown(logger, unknown, nextName); + } + break; + } + } + + event.setUnknown(unknown); + reader.endObject(); + return event; + } + + private void deserializeData( + final @NotNull RRWebBreadcrumbEvent event, + final @NotNull ObjectReader reader, + final @NotNull ILogger logger) + throws Exception { + @Nullable Map dataUnknown = null; + + reader.beginObject(); + while (reader.peek() == JsonToken.NAME) { + final String nextName = reader.nextName(); + switch (nextName) { + case RRWebEvent.JsonKeys.TAG: + final String tag = reader.nextStringOrNull(); + event.tag = tag == null ? "" : tag; + break; + case JsonKeys.PAYLOAD: + deserializePayload(event, reader, logger); + break; + default: + if (dataUnknown == null) { + dataUnknown = new ConcurrentHashMap<>(); + } + reader.nextUnknown(logger, dataUnknown, nextName); + } + } + event.setDataUnknown(dataUnknown); + reader.endObject(); + } + + @SuppressWarnings("unchecked") + private void deserializePayload( + final @NotNull RRWebBreadcrumbEvent event, + final @NotNull ObjectReader reader, + final @NotNull ILogger logger) + throws Exception { + @Nullable Map payloadUnknown = null; + + reader.beginObject(); + while (reader.peek() == JsonToken.NAME) { + final String nextName = reader.nextName(); + switch (nextName) { + case JsonKeys.TYPE: + event.breadcrumbType = reader.nextStringOrNull(); + break; + case JsonKeys.TIMESTAMP: + event.breadcrumbTimestamp = reader.nextDouble(); + break; + case JsonKeys.CATEGORY: + event.category = reader.nextStringOrNull(); + break; + case JsonKeys.MESSAGE: + event.message = reader.nextStringOrNull(); + break; + case JsonKeys.LEVEL: + try { + event.level = new SentryLevel.Deserializer().deserialize(reader, logger); + } catch (Exception exception) { + logger.log(SentryLevel.DEBUG, exception, "Error when deserializing SentryLevel"); + } + break; + case JsonKeys.DATA: + Map deserializedData = + CollectionUtils.newConcurrentHashMap( + (Map) reader.nextObjectOrNull()); + if (deserializedData != null) { + event.data = deserializedData; + } + break; + default: + if (payloadUnknown == null) { + payloadUnknown = new ConcurrentHashMap<>(); + } + reader.nextUnknown(logger, payloadUnknown, nextName); + } + } + event.setPayloadUnknown(payloadUnknown); + reader.endObject(); + } + } + // endregion json +} diff --git a/sentry/src/main/java/io/sentry/rrweb/RRWebEvent.java b/sentry/src/main/java/io/sentry/rrweb/RRWebEvent.java new file mode 100644 index 0000000000..07b2b9a70f --- /dev/null +++ b/sentry/src/main/java/io/sentry/rrweb/RRWebEvent.java @@ -0,0 +1,94 @@ +package io.sentry.rrweb; + +import io.sentry.ILogger; +import io.sentry.ObjectReader; +import io.sentry.ObjectWriter; +import io.sentry.util.Objects; +import java.io.IOException; +import org.jetbrains.annotations.NotNull; + +public abstract class RRWebEvent { + + private @NotNull RRWebEventType type; + private long timestamp; + + protected RRWebEvent(final @NotNull RRWebEventType type) { + this.type = type; + this.timestamp = System.currentTimeMillis(); + } + + protected RRWebEvent() { + this(RRWebEventType.Custom); + } + + @NotNull + public RRWebEventType getType() { + return type; + } + + public void setType(final @NotNull RRWebEventType type) { + this.type = type; + } + + public long getTimestamp() { + return timestamp; + } + + public void setTimestamp(final long timestamp) { + this.timestamp = timestamp; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof RRWebEvent)) return false; + RRWebEvent that = (RRWebEvent) o; + return timestamp == that.timestamp && type == that.type; + } + + @Override + public int hashCode() { + return Objects.hash(type, timestamp); + } + + // region json + public static final class JsonKeys { + public static final String TYPE = "type"; + public static final String TIMESTAMP = "timestamp"; + public static final String TAG = "tag"; + } + + public static final class Serializer { + public void serialize( + final @NotNull RRWebEvent baseEvent, + final @NotNull ObjectWriter writer, + final @NotNull ILogger logger) + throws IOException { + writer.name(JsonKeys.TYPE).value(logger, baseEvent.type); + writer.name(JsonKeys.TIMESTAMP).value(baseEvent.timestamp); + } + } + + public static final class Deserializer { + @SuppressWarnings("unchecked") + public boolean deserializeValue( + final @NotNull RRWebEvent baseEvent, + final @NotNull String nextName, + final @NotNull ObjectReader reader, + final @NotNull ILogger logger) + throws Exception { + switch (nextName) { + case JsonKeys.TYPE: + baseEvent.type = + Objects.requireNonNull( + reader.nextOrNull(logger, new RRWebEventType.Deserializer()), ""); + return true; + case JsonKeys.TIMESTAMP: + baseEvent.timestamp = reader.nextLong(); + return true; + } + return false; + } + } + // endregion json +} diff --git a/sentry/src/main/java/io/sentry/rrweb/RRWebEventType.java b/sentry/src/main/java/io/sentry/rrweb/RRWebEventType.java new file mode 100644 index 0000000000..fc9c8c7e69 --- /dev/null +++ b/sentry/src/main/java/io/sentry/rrweb/RRWebEventType.java @@ -0,0 +1,33 @@ +package io.sentry.rrweb; + +import io.sentry.ILogger; +import io.sentry.JsonDeserializer; +import io.sentry.JsonSerializable; +import io.sentry.ObjectReader; +import io.sentry.ObjectWriter; +import java.io.IOException; +import org.jetbrains.annotations.NotNull; + +public enum RRWebEventType implements JsonSerializable { + DomContentLoaded, + Load, + FullSnapshot, + IncrementalSnapshot, + Meta, + Custom, + Plugin; + + @Override + public void serialize(final @NotNull ObjectWriter writer, final @NotNull ILogger logger) + throws IOException { + writer.value(ordinal()); + } + + public static final class Deserializer implements JsonDeserializer { + @Override + public @NotNull RRWebEventType deserialize( + final @NotNull ObjectReader reader, final @NotNull ILogger logger) throws Exception { + return RRWebEventType.values()[reader.nextInt()]; + } + } +} diff --git a/sentry/src/main/java/io/sentry/rrweb/RRWebIncrementalSnapshotEvent.java b/sentry/src/main/java/io/sentry/rrweb/RRWebIncrementalSnapshotEvent.java new file mode 100644 index 0000000000..aff3c55ac3 --- /dev/null +++ b/sentry/src/main/java/io/sentry/rrweb/RRWebIncrementalSnapshotEvent.java @@ -0,0 +1,95 @@ +package io.sentry.rrweb; + +import io.sentry.ILogger; +import io.sentry.JsonDeserializer; +import io.sentry.JsonSerializable; +import io.sentry.ObjectReader; +import io.sentry.ObjectWriter; +import io.sentry.util.Objects; +import java.io.IOException; +import org.jetbrains.annotations.NotNull; + +public abstract class RRWebIncrementalSnapshotEvent extends RRWebEvent { + + public enum IncrementalSource implements JsonSerializable { + Mutation, + MouseMove, + MouseInteraction, + Scroll, + ViewportResize, + Input, + TouchMove, + MediaInteraction, + StyleSheetRule, + CanvasMutation, + Font, + Log, + Drag, + StyleDeclaration, + Selection, + AdoptedStyleSheet, + CustomElement; + + @Override + public void serialize(@NotNull ObjectWriter writer, @NotNull ILogger logger) + throws IOException { + writer.value(ordinal()); + } + + public static final class Deserializer implements JsonDeserializer { + @Override + public @NotNull IncrementalSource deserialize( + final @NotNull ObjectReader reader, final @NotNull ILogger logger) throws Exception { + return IncrementalSource.values()[reader.nextInt()]; + } + } + } + + private IncrementalSource source; + + public RRWebIncrementalSnapshotEvent(final @NotNull IncrementalSource source) { + super(RRWebEventType.IncrementalSnapshot); + this.source = source; + } + + public IncrementalSource getSource() { + return source; + } + + public void setSource(final IncrementalSource source) { + this.source = source; + } + + // region json + public static final class JsonKeys { + public static final String SOURCE = "source"; + } + + public static final class Serializer { + public void serialize( + final @NotNull RRWebIncrementalSnapshotEvent baseEvent, + final @NotNull ObjectWriter writer, + final @NotNull ILogger logger) + throws IOException { + writer.name(JsonKeys.SOURCE).value(logger, baseEvent.source); + } + } + + public static final class Deserializer { + public boolean deserializeValue( + final @NotNull RRWebIncrementalSnapshotEvent baseEvent, + final @NotNull String nextName, + final @NotNull ObjectReader reader, + final @NotNull ILogger logger) + throws Exception { + if (nextName.equals(JsonKeys.SOURCE)) { + baseEvent.source = + Objects.requireNonNull( + reader.nextOrNull(logger, new IncrementalSource.Deserializer()), ""); + return true; + } + return false; + } + } + // endregion json +} diff --git a/sentry/src/main/java/io/sentry/rrweb/RRWebInteractionEvent.java b/sentry/src/main/java/io/sentry/rrweb/RRWebInteractionEvent.java new file mode 100644 index 0000000000..c7bd613c1b --- /dev/null +++ b/sentry/src/main/java/io/sentry/rrweb/RRWebInteractionEvent.java @@ -0,0 +1,268 @@ +package io.sentry.rrweb; + +import io.sentry.ILogger; +import io.sentry.JsonDeserializer; +import io.sentry.JsonSerializable; +import io.sentry.JsonUnknown; +import io.sentry.ObjectReader; +import io.sentry.ObjectWriter; +import io.sentry.vendor.gson.stream.JsonToken; +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +@SuppressWarnings("SameNameButDifferent") +public final class RRWebInteractionEvent extends RRWebIncrementalSnapshotEvent + implements JsonSerializable, JsonUnknown { + + public enum InteractionType implements JsonSerializable { + MouseUp, + MouseDown, + Click, + ContextMenu, + DblClick, + Focus, + Blur, + TouchStart, + TouchMove_Departed, + TouchEnd, + TouchCancel; + + @Override + public void serialize(@NotNull ObjectWriter writer, @NotNull ILogger logger) + throws IOException { + writer.value(ordinal()); + } + + public static final class Deserializer implements JsonDeserializer { + @Override + public @NotNull InteractionType deserialize( + final @NotNull ObjectReader reader, final @NotNull ILogger logger) throws Exception { + return InteractionType.values()[reader.nextInt()]; + } + } + } + + private static final int POINTER_TYPE_TOUCH = 2; + + private @Nullable InteractionType interactionType; + + private int id; + + private float x; + + private float y; + + private int pointerType = POINTER_TYPE_TOUCH; + + private int pointerId; + + // to support unknown json attributes with nesting, we have to have unknown map for each of the + // nested object in json: { ..., "data": { ... } } + private @Nullable Map unknown; + private @Nullable Map dataUnknown; + + public RRWebInteractionEvent() { + super(IncrementalSource.MouseInteraction); + } + + @Nullable + public InteractionType getInteractionType() { + return interactionType; + } + + public void setInteractionType(final @Nullable InteractionType type) { + this.interactionType = type; + } + + public int getId() { + return id; + } + + public void setId(final int id) { + this.id = id; + } + + public float getX() { + return x; + } + + public void setX(final float x) { + this.x = x; + } + + public float getY() { + return y; + } + + public void setY(final float y) { + this.y = y; + } + + public int getPointerType() { + return pointerType; + } + + public void setPointerType(final int pointerType) { + this.pointerType = pointerType; + } + + public int getPointerId() { + return pointerId; + } + + public void setPointerId(final int pointerId) { + this.pointerId = pointerId; + } + + @Nullable + public Map getDataUnknown() { + return dataUnknown; + } + + public void setDataUnknown(final @Nullable Map dataUnknown) { + this.dataUnknown = dataUnknown; + } + + @Override + public @Nullable Map getUnknown() { + return unknown; + } + + @Override + public void setUnknown(final @Nullable Map unknown) { + this.unknown = unknown; + } + + // region json + + // rrweb uses camelCase hence the json keys are in camelCase here + public static final class JsonKeys { + public static final String DATA = "data"; + public static final String TYPE = "type"; + public static final String ID = "id"; + public static final String X = "x"; + public static final String Y = "y"; + public static final String POINTER_TYPE = "pointerType"; + public static final String POINTER_ID = "pointerId"; + } + + @Override + public void serialize(@NotNull ObjectWriter writer, @NotNull ILogger logger) throws IOException { + writer.beginObject(); + new RRWebEvent.Serializer().serialize(this, writer, logger); + writer.name(JsonKeys.DATA); + serializeData(writer, logger); + if (unknown != null) { + for (final String key : unknown.keySet()) { + final Object value = unknown.get(key); + writer.name(key); + writer.value(logger, value); + } + } + writer.endObject(); + } + + private void serializeData(final @NotNull ObjectWriter writer, final @NotNull ILogger logger) + throws IOException { + writer.beginObject(); + new RRWebIncrementalSnapshotEvent.Serializer().serialize(this, writer, logger); + writer.name(JsonKeys.TYPE).value(logger, interactionType); + writer.name(JsonKeys.ID).value(id); + writer.name(JsonKeys.X).value(x); + writer.name(JsonKeys.Y).value(y); + writer.name(JsonKeys.POINTER_TYPE).value(pointerType); + writer.name(JsonKeys.POINTER_ID).value(pointerId); + if (dataUnknown != null) { + for (String key : dataUnknown.keySet()) { + Object value = dataUnknown.get(key); + writer.name(key); + writer.value(logger, value); + } + } + writer.endObject(); + } + + public static final class Deserializer implements JsonDeserializer { + + @Override + public @NotNull RRWebInteractionEvent deserialize( + @NotNull ObjectReader reader, @NotNull ILogger logger) throws Exception { + reader.beginObject(); + @Nullable Map unknown = null; + + final RRWebInteractionEvent event = new RRWebInteractionEvent(); + final RRWebEvent.Deserializer baseEventDeserializer = new RRWebEvent.Deserializer(); + + while (reader.peek() == JsonToken.NAME) { + final String nextName = reader.nextName(); + switch (nextName) { + case JsonKeys.DATA: + deserializeData(event, reader, logger); + break; + default: + if (!baseEventDeserializer.deserializeValue(event, nextName, reader, logger)) { + if (unknown == null) { + unknown = new HashMap<>(); + } + reader.nextUnknown(logger, unknown, nextName); + } + break; + } + } + + event.setUnknown(unknown); + reader.endObject(); + return event; + } + + private void deserializeData( + final @NotNull RRWebInteractionEvent event, + final @NotNull ObjectReader reader, + final @NotNull ILogger logger) + throws Exception { + @Nullable Map dataUnknown = null; + + final RRWebIncrementalSnapshotEvent.Deserializer baseEventDeserializer = + new RRWebIncrementalSnapshotEvent.Deserializer(); + + reader.beginObject(); + while (reader.peek() == JsonToken.NAME) { + final String nextName = reader.nextName(); + switch (nextName) { + case JsonKeys.TYPE: + event.interactionType = reader.nextOrNull(logger, new InteractionType.Deserializer()); + break; + case JsonKeys.ID: + event.id = reader.nextInt(); + break; + case JsonKeys.X: + event.x = reader.nextFloat(); + break; + case JsonKeys.Y: + event.y = reader.nextFloat(); + break; + case JsonKeys.POINTER_TYPE: + event.pointerType = reader.nextInt(); + break; + case JsonKeys.POINTER_ID: + event.pointerId = reader.nextInt(); + break; + default: + if (!baseEventDeserializer.deserializeValue(event, nextName, reader, logger)) { + if (dataUnknown == null) { + dataUnknown = new HashMap<>(); + } + reader.nextUnknown(logger, dataUnknown, nextName); + } + break; + } + } + event.setDataUnknown(dataUnknown); + reader.endObject(); + } + } + // endregion json +} diff --git a/sentry/src/main/java/io/sentry/rrweb/RRWebInteractionMoveEvent.java b/sentry/src/main/java/io/sentry/rrweb/RRWebInteractionMoveEvent.java new file mode 100644 index 0000000000..d3acf9a882 --- /dev/null +++ b/sentry/src/main/java/io/sentry/rrweb/RRWebInteractionMoveEvent.java @@ -0,0 +1,303 @@ +package io.sentry.rrweb; + +import io.sentry.ILogger; +import io.sentry.JsonDeserializer; +import io.sentry.JsonSerializable; +import io.sentry.JsonUnknown; +import io.sentry.ObjectReader; +import io.sentry.ObjectWriter; +import io.sentry.vendor.gson.stream.JsonToken; +import java.io.IOException; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +@SuppressWarnings("SameNameButDifferent") +public final class RRWebInteractionMoveEvent extends RRWebIncrementalSnapshotEvent + implements JsonSerializable, JsonUnknown { + + public static final class Position implements JsonSerializable, JsonUnknown { + + private int id; + + private float x; + + private float y; + + private long timeOffset; + + private @Nullable Map unknown; + + public int getId() { + return id; + } + + public void setId(final int id) { + this.id = id; + } + + public float getX() { + return x; + } + + public void setX(final float x) { + this.x = x; + } + + public float getY() { + return y; + } + + public void setY(final float y) { + this.y = y; + } + + public long getTimeOffset() { + return timeOffset; + } + + public void setTimeOffset(final long timeOffset) { + this.timeOffset = timeOffset; + } + + @Override + public @Nullable Map getUnknown() { + return unknown; + } + + @Override + public void setUnknown(final @Nullable Map unknown) { + this.unknown = unknown; + } + + // region json + + // rrweb uses camelCase hence the json keys are in camelCase here + public static final class JsonKeys { + public static final String ID = "id"; + public static final String X = "x"; + public static final String Y = "y"; + public static final String TIME_OFFSET = "timeOffset"; + } + + @Override + public void serialize(@NotNull ObjectWriter writer, @NotNull ILogger logger) + throws IOException { + writer.beginObject(); + writer.name(JsonKeys.ID).value(id); + writer.name(JsonKeys.X).value(x); + writer.name(JsonKeys.Y).value(y); + writer.name(JsonKeys.TIME_OFFSET).value(timeOffset); + if (unknown != null) { + for (final String key : unknown.keySet()) { + final Object value = unknown.get(key); + writer.name(key); + writer.value(logger, value); + } + } + writer.endObject(); + } + + public static final class Deserializer implements JsonDeserializer { + + @Override + public @NotNull Position deserialize(@NotNull ObjectReader reader, @NotNull ILogger logger) + throws Exception { + reader.beginObject(); + @Nullable Map unknown = null; + + final Position position = new Position(); + + while (reader.peek() == JsonToken.NAME) { + final String nextName = reader.nextName(); + switch (nextName) { + case JsonKeys.ID: + position.id = reader.nextInt(); + break; + case JsonKeys.X: + position.x = reader.nextFloat(); + break; + case JsonKeys.Y: + position.y = reader.nextFloat(); + break; + case JsonKeys.TIME_OFFSET: + position.timeOffset = reader.nextLong(); + break; + default: + if (unknown == null) { + unknown = new HashMap<>(); + } + reader.nextUnknown(logger, unknown, nextName); + break; + } + } + + position.setUnknown(unknown); + reader.endObject(); + return position; + } + } + // endregion json + } + + private int pointerId; + private @Nullable List positions; + // to support unknown json attributes with nesting, we have to have unknown map for each of the + // nested object in json: { ..., "data": { ... } } + private @Nullable Map unknown; + private @Nullable Map dataUnknown; + + public RRWebInteractionMoveEvent() { + super(IncrementalSource.TouchMove); + } + + @Nullable + public Map getDataUnknown() { + return dataUnknown; + } + + public void setDataUnknown(final @Nullable Map dataUnknown) { + this.dataUnknown = dataUnknown; + } + + @Override + public @Nullable Map getUnknown() { + return unknown; + } + + @Override + public void setUnknown(final @Nullable Map unknown) { + this.unknown = unknown; + } + + @Nullable + public List getPositions() { + return positions; + } + + public void setPositions(final @Nullable List positions) { + this.positions = positions; + } + + public int getPointerId() { + return pointerId; + } + + public void setPointerId(final int pointerId) { + this.pointerId = pointerId; + } + + // region json + + // rrweb uses camelCase hence the json keys are in camelCase here + public static final class JsonKeys { + public static final String DATA = "data"; + public static final String POSITIONS = "positions"; + public static final String POINTER_ID = "pointerId"; + } + + @Override + public void serialize(@NotNull ObjectWriter writer, @NotNull ILogger logger) throws IOException { + writer.beginObject(); + new RRWebEvent.Serializer().serialize(this, writer, logger); + writer.name(JsonKeys.DATA); + serializeData(writer, logger); + if (unknown != null) { + for (final String key : unknown.keySet()) { + final Object value = unknown.get(key); + writer.name(key); + writer.value(logger, value); + } + } + writer.endObject(); + } + + private void serializeData(final @NotNull ObjectWriter writer, final @NotNull ILogger logger) + throws IOException { + writer.beginObject(); + new RRWebIncrementalSnapshotEvent.Serializer().serialize(this, writer, logger); + if (positions != null && !positions.isEmpty()) { + writer.name(JsonKeys.POSITIONS).value(logger, positions); + } + writer.name(JsonKeys.POINTER_ID).value(pointerId); + if (dataUnknown != null) { + for (String key : dataUnknown.keySet()) { + Object value = dataUnknown.get(key); + writer.name(key); + writer.value(logger, value); + } + } + writer.endObject(); + } + + public static final class Deserializer implements JsonDeserializer { + + @Override + public @NotNull RRWebInteractionMoveEvent deserialize( + @NotNull ObjectReader reader, @NotNull ILogger logger) throws Exception { + reader.beginObject(); + @Nullable Map unknown = null; + + final RRWebInteractionMoveEvent event = new RRWebInteractionMoveEvent(); + final RRWebEvent.Deserializer baseEventDeserializer = new RRWebEvent.Deserializer(); + + while (reader.peek() == JsonToken.NAME) { + final String nextName = reader.nextName(); + switch (nextName) { + case JsonKeys.DATA: + deserializeData(event, reader, logger); + break; + default: + if (!baseEventDeserializer.deserializeValue(event, nextName, reader, logger)) { + if (unknown == null) { + unknown = new HashMap<>(); + } + reader.nextUnknown(logger, unknown, nextName); + } + break; + } + } + + event.setUnknown(unknown); + reader.endObject(); + return event; + } + + private void deserializeData( + final @NotNull RRWebInteractionMoveEvent event, + final @NotNull ObjectReader reader, + final @NotNull ILogger logger) + throws Exception { + @Nullable Map dataUnknown = null; + + final RRWebIncrementalSnapshotEvent.Deserializer baseEventDeserializer = + new RRWebIncrementalSnapshotEvent.Deserializer(); + + reader.beginObject(); + while (reader.peek() == JsonToken.NAME) { + final String nextName = reader.nextName(); + switch (nextName) { + case JsonKeys.POSITIONS: + event.positions = reader.nextListOrNull(logger, new Position.Deserializer()); + break; + case JsonKeys.POINTER_ID: + event.pointerId = reader.nextInt(); + break; + default: + if (!baseEventDeserializer.deserializeValue(event, nextName, reader, logger)) { + if (dataUnknown == null) { + dataUnknown = new HashMap<>(); + } + reader.nextUnknown(logger, dataUnknown, nextName); + } + break; + } + } + event.setDataUnknown(dataUnknown); + reader.endObject(); + } + } + // endregion json +} diff --git a/sentry/src/main/java/io/sentry/rrweb/RRWebMetaEvent.java b/sentry/src/main/java/io/sentry/rrweb/RRWebMetaEvent.java new file mode 100644 index 0000000000..b0aca2f337 --- /dev/null +++ b/sentry/src/main/java/io/sentry/rrweb/RRWebMetaEvent.java @@ -0,0 +1,191 @@ +package io.sentry.rrweb; + +import io.sentry.ILogger; +import io.sentry.JsonDeserializer; +import io.sentry.JsonSerializable; +import io.sentry.JsonUnknown; +import io.sentry.ObjectReader; +import io.sentry.ObjectWriter; +import io.sentry.util.Objects; +import io.sentry.vendor.gson.stream.JsonToken; +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +public final class RRWebMetaEvent extends RRWebEvent implements JsonUnknown, JsonSerializable { + + private @NotNull String href; + private int height; + private int width; + // to support unknown json attributes with nesting, we have to have unknown map for each of the + // nested object in json: { ..., "data": { ... } } + private @Nullable Map unknown; + private @Nullable Map dataUnknown; + + public RRWebMetaEvent() { + super(RRWebEventType.Meta); + this.href = ""; + } + + @NotNull + public String getHref() { + return href; + } + + public void setHref(final @NotNull String href) { + this.href = href; + } + + public int getHeight() { + return height; + } + + public void setHeight(final int height) { + this.height = height; + } + + public int getWidth() { + return width; + } + + public void setWidth(final int width) { + this.width = width; + } + + @Nullable + public Map getDataUnknown() { + return dataUnknown; + } + + public void setDataUnknown(final @Nullable Map dataUnknown) { + this.dataUnknown = dataUnknown; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + if (!super.equals(o)) return false; + RRWebMetaEvent metaEvent = (RRWebMetaEvent) o; + return height == metaEvent.height + && width == metaEvent.width + && Objects.equals(href, metaEvent.href); + } + + @Override + public int hashCode() { + return Objects.hash(super.hashCode(), href, height, width); + } + + public static final class JsonKeys { + public static final String DATA = "data"; + public static final String HREF = "href"; + public static final String HEIGHT = "height"; + public static final String WIDTH = "width"; + } + + @Override + public void serialize(@NotNull ObjectWriter writer, @NotNull ILogger logger) throws IOException { + writer.beginObject(); + new RRWebEvent.Serializer().serialize(this, writer, logger); + writer.name(JsonKeys.DATA); + serializeData(writer, logger); + writer.endObject(); + } + + private void serializeData(final @NotNull ObjectWriter writer, final @NotNull ILogger logger) + throws IOException { + writer.beginObject(); + writer.name(JsonKeys.HREF).value(href); + writer.name(JsonKeys.HEIGHT).value(height); + writer.name(JsonKeys.WIDTH).value(width); + if (unknown != null) { + for (String key : unknown.keySet()) { + Object value = unknown.get(key); + writer.name(key); + writer.value(logger, value); + } + } + writer.endObject(); + } + + @Override + public @Nullable Map getUnknown() { + return unknown; + } + + @Override + public void setUnknown(final @Nullable Map unknown) { + this.unknown = unknown; + } + + public static final class Deserializer implements JsonDeserializer { + + @SuppressWarnings("unchecked") + @Override + public @NotNull RRWebMetaEvent deserialize( + final @NotNull ObjectReader reader, final @NotNull ILogger logger) throws Exception { + reader.beginObject(); + @Nullable Map unknown = null; + final RRWebMetaEvent event = new RRWebMetaEvent(); + final RRWebEvent.Deserializer baseEventDeserializer = new RRWebEvent.Deserializer(); + + while (reader.peek() == JsonToken.NAME) { + final String nextName = reader.nextName(); + switch (nextName) { + case JsonKeys.DATA: + deserializeData(event, reader, logger); + break; + default: + if (!baseEventDeserializer.deserializeValue(event, nextName, reader, logger)) { + if (unknown == null) { + unknown = new HashMap<>(); + } + reader.nextUnknown(logger, unknown, nextName); + } + break; + } + } + event.setUnknown(unknown); + reader.endObject(); + return event; + } + + private void deserializeData( + final @NotNull RRWebMetaEvent event, + final @NotNull ObjectReader reader, + final @NotNull ILogger logger) + throws Exception { + @Nullable Map unknown = null; + + reader.beginObject(); + while (reader.peek() == JsonToken.NAME) { + final String nextName = reader.nextName(); + switch (nextName) { + case JsonKeys.HREF: + final String href = reader.nextStringOrNull(); + event.href = href == null ? "" : href; + break; + case JsonKeys.HEIGHT: + final Integer height = reader.nextIntegerOrNull(); + event.height = height == null ? 0 : height; + break; + case JsonKeys.WIDTH: + final Integer width = reader.nextIntegerOrNull(); + event.width = width == null ? 0 : width; + break; + default: + if (unknown == null) { + unknown = new ConcurrentHashMap<>(); + } + reader.nextUnknown(logger, unknown, nextName); + } + } + event.setDataUnknown(unknown); + reader.endObject(); + } + } +} diff --git a/sentry/src/main/java/io/sentry/rrweb/RRWebSpanEvent.java b/sentry/src/main/java/io/sentry/rrweb/RRWebSpanEvent.java new file mode 100644 index 0000000000..5bdc667f40 --- /dev/null +++ b/sentry/src/main/java/io/sentry/rrweb/RRWebSpanEvent.java @@ -0,0 +1,289 @@ +package io.sentry.rrweb; + +import io.sentry.ILogger; +import io.sentry.JsonDeserializer; +import io.sentry.JsonSerializable; +import io.sentry.JsonUnknown; +import io.sentry.ObjectReader; +import io.sentry.ObjectWriter; +import io.sentry.util.CollectionUtils; +import io.sentry.vendor.gson.stream.JsonToken; +import java.io.IOException; +import java.math.BigDecimal; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +public final class RRWebSpanEvent extends RRWebEvent implements JsonSerializable, JsonUnknown { + public static final String EVENT_TAG = "performanceSpan"; + + private @NotNull String tag; + private @Nullable String op; + private @Nullable String description; + private double startTimestamp; + private double endTimestamp; + private @Nullable Map data; + // to support unknown json attributes with nesting, we have to have unknown map for each of the + // nested object in json: { ..., "data": { ..., "payload": { ... } } } + private @Nullable Map unknown; + private @Nullable Map payloadUnknown; + private @Nullable Map dataUnknown; + + public RRWebSpanEvent() { + super(RRWebEventType.Custom); + tag = EVENT_TAG; + } + + @NotNull + public String getTag() { + return tag; + } + + public void setTag(final @NotNull String tag) { + this.tag = tag; + } + + @Nullable + public String getOp() { + return op; + } + + public void setOp(final @Nullable String op) { + this.op = op; + } + + @Nullable + public String getDescription() { + return description; + } + + public void setDescription(final @Nullable String description) { + this.description = description; + } + + public double getStartTimestamp() { + return startTimestamp; + } + + public void setStartTimestamp(final double startTimestamp) { + this.startTimestamp = startTimestamp; + } + + public double getEndTimestamp() { + return endTimestamp; + } + + public void setEndTimestamp(final double endTimestamp) { + this.endTimestamp = endTimestamp; + } + + @Nullable + public Map getData() { + return data; + } + + public void setData(final @Nullable Map data) { + this.data = data == null ? null : new ConcurrentHashMap<>(data); + } + + public @Nullable Map getPayloadUnknown() { + return payloadUnknown; + } + + public void setPayloadUnknown(final @Nullable Map payloadUnknown) { + this.payloadUnknown = payloadUnknown; + } + + public @Nullable Map getDataUnknown() { + return dataUnknown; + } + + public void setDataUnknown(final @Nullable Map dataUnknown) { + this.dataUnknown = dataUnknown; + } + + @Override + public @Nullable Map getUnknown() { + return unknown; + } + + @Override + public void setUnknown(final @Nullable Map unknown) { + this.unknown = unknown; + } + + // region json + public static final class JsonKeys { + public static final String DATA = "data"; + public static final String PAYLOAD = "payload"; + public static final String OP = "op"; + public static final String DESCRIPTION = "description"; + public static final String START_TIMESTAMP = "startTimestamp"; + public static final String END_TIMESTAMP = "endTimestamp"; + } + + @Override + public void serialize(@NotNull ObjectWriter writer, @NotNull ILogger logger) throws IOException { + writer.beginObject(); + new RRWebEvent.Serializer().serialize(this, writer, logger); + writer.name(RRWebBreadcrumbEvent.JsonKeys.DATA); + serializeData(writer, logger); + if (unknown != null) { + for (final String key : unknown.keySet()) { + final Object value = unknown.get(key); + writer.name(key); + writer.value(logger, value); + } + } + writer.endObject(); + } + + private void serializeData(final @NotNull ObjectWriter writer, final @NotNull ILogger logger) + throws IOException { + writer.beginObject(); + writer.name(RRWebEvent.JsonKeys.TAG).value(tag); + writer.name(RRWebBreadcrumbEvent.JsonKeys.PAYLOAD); + serializePayload(writer, logger); + if (dataUnknown != null) { + for (String key : dataUnknown.keySet()) { + Object value = dataUnknown.get(key); + writer.name(key); + writer.value(logger, value); + } + } + writer.endObject(); + } + + private void serializePayload(final @NotNull ObjectWriter writer, final @NotNull ILogger logger) + throws IOException { + writer.beginObject(); + if (op != null) { + writer.name(JsonKeys.OP).value(op); + } + if (description != null) { + writer.name(JsonKeys.DESCRIPTION).value(description); + } + writer.name(JsonKeys.START_TIMESTAMP).value(logger, BigDecimal.valueOf(startTimestamp)); + writer.name(JsonKeys.END_TIMESTAMP).value(logger, BigDecimal.valueOf(endTimestamp)); + if (data != null) { + writer.name(JsonKeys.DATA).value(logger, data); + } + if (payloadUnknown != null) { + for (final String key : payloadUnknown.keySet()) { + final Object value = payloadUnknown.get(key); + writer.name(key); + writer.value(logger, value); + } + } + writer.endObject(); + } + + public static final class Deserializer implements JsonDeserializer { + + @Override + public @NotNull RRWebSpanEvent deserialize( + @NotNull ObjectReader reader, @NotNull ILogger logger) throws Exception { + reader.beginObject(); + @Nullable Map unknown = null; + + final RRWebSpanEvent event = new RRWebSpanEvent(); + final RRWebEvent.Deserializer baseEventDeserializer = new RRWebEvent.Deserializer(); + + while (reader.peek() == JsonToken.NAME) { + final String nextName = reader.nextName(); + switch (nextName) { + case JsonKeys.DATA: + deserializeData(event, reader, logger); + break; + default: + if (!baseEventDeserializer.deserializeValue(event, nextName, reader, logger)) { + if (unknown == null) { + unknown = new HashMap<>(); + } + reader.nextUnknown(logger, unknown, nextName); + } + break; + } + } + + event.setUnknown(unknown); + reader.endObject(); + return event; + } + + private void deserializeData( + final @NotNull RRWebSpanEvent event, + final @NotNull ObjectReader reader, + final @NotNull ILogger logger) + throws Exception { + @Nullable Map dataUnknown = null; + + reader.beginObject(); + while (reader.peek() == JsonToken.NAME) { + final String nextName = reader.nextName(); + switch (nextName) { + case RRWebEvent.JsonKeys.TAG: + final String tag = reader.nextStringOrNull(); + event.tag = tag == null ? "" : tag; + break; + case JsonKeys.PAYLOAD: + deserializePayload(event, reader, logger); + break; + default: + if (dataUnknown == null) { + dataUnknown = new ConcurrentHashMap<>(); + } + reader.nextUnknown(logger, dataUnknown, nextName); + } + } + event.setDataUnknown(dataUnknown); + reader.endObject(); + } + + @SuppressWarnings("unchecked") + private void deserializePayload( + final @NotNull RRWebSpanEvent event, + final @NotNull ObjectReader reader, + final @NotNull ILogger logger) + throws Exception { + @Nullable Map payloadUnknown = null; + + reader.beginObject(); + while (reader.peek() == JsonToken.NAME) { + final String nextName = reader.nextName(); + switch (nextName) { + case JsonKeys.OP: + event.op = reader.nextStringOrNull(); + break; + case JsonKeys.DESCRIPTION: + event.description = reader.nextStringOrNull(); + break; + case JsonKeys.START_TIMESTAMP: + event.startTimestamp = reader.nextDouble(); + break; + case JsonKeys.END_TIMESTAMP: + event.endTimestamp = reader.nextDouble(); + break; + case JsonKeys.DATA: + Map deserializedData = + CollectionUtils.newConcurrentHashMap( + (Map) reader.nextObjectOrNull()); + if (deserializedData != null) { + event.data = deserializedData; + } + break; + default: + if (payloadUnknown == null) { + payloadUnknown = new ConcurrentHashMap<>(); + } + reader.nextUnknown(logger, payloadUnknown, nextName); + } + } + event.setPayloadUnknown(payloadUnknown); + reader.endObject(); + } + } + // endregion json +} diff --git a/sentry/src/main/java/io/sentry/rrweb/RRWebVideoEvent.java b/sentry/src/main/java/io/sentry/rrweb/RRWebVideoEvent.java new file mode 100644 index 0000000000..1ba9f19c72 --- /dev/null +++ b/sentry/src/main/java/io/sentry/rrweb/RRWebVideoEvent.java @@ -0,0 +1,433 @@ +package io.sentry.rrweb; + +import io.sentry.ILogger; +import io.sentry.JsonDeserializer; +import io.sentry.JsonSerializable; +import io.sentry.JsonUnknown; +import io.sentry.ObjectReader; +import io.sentry.ObjectWriter; +import io.sentry.util.Objects; +import io.sentry.vendor.gson.stream.JsonToken; +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +public final class RRWebVideoEvent extends RRWebEvent implements JsonUnknown, JsonSerializable { + + public static final String EVENT_TAG = "video"; + public static final String REPLAY_ENCODING = "h264"; + public static final String REPLAY_CONTAINER = "mp4"; + public static final String REPLAY_FRAME_RATE_TYPE_CONSTANT = "constant"; + public static final String REPLAY_FRAME_RATE_TYPE_VARIABLE = "variable"; + + private @NotNull String tag; + private int segmentId; + private long size; + private long durationMs; + private @NotNull String encoding = REPLAY_ENCODING; + private @NotNull String container = REPLAY_CONTAINER; + private int height; + private int width; + private int frameCount; + private @NotNull String frameRateType = REPLAY_FRAME_RATE_TYPE_CONSTANT; + private int frameRate; + private int left; + private int top; + // to support unknown json attributes with nesting, we have to have unknown map for each of the + // nested object in json: { ..., "data": { ..., "payload": { ... } } } + private @Nullable Map unknown; + private @Nullable Map payloadUnknown; + private @Nullable Map dataUnknown; + + public RRWebVideoEvent() { + super(RRWebEventType.Custom); + tag = EVENT_TAG; + } + + @NotNull + public String getTag() { + return tag; + } + + public void setTag(final @NotNull String tag) { + this.tag = tag; + } + + public int getSegmentId() { + return segmentId; + } + + public void setSegmentId(final int segmentId) { + this.segmentId = segmentId; + } + + public long getSize() { + return size; + } + + public void setSize(final long size) { + this.size = size; + } + + public long getDurationMs() { + return durationMs; + } + + public void setDurationMs(final long durationMs) { + this.durationMs = durationMs; + } + + @NotNull + public String getEncoding() { + return encoding; + } + + public void setEncoding(final @NotNull String encoding) { + this.encoding = encoding; + } + + @NotNull + public String getContainer() { + return container; + } + + public void setContainer(final @NotNull String container) { + this.container = container; + } + + public int getHeight() { + return height; + } + + public void setHeight(final int height) { + this.height = height; + } + + public int getWidth() { + return width; + } + + public void setWidth(final int width) { + this.width = width; + } + + public int getFrameCount() { + return frameCount; + } + + public void setFrameCount(final int frameCount) { + this.frameCount = frameCount; + } + + @NotNull + public String getFrameRateType() { + return frameRateType; + } + + public void setFrameRateType(final @NotNull String frameRateType) { + this.frameRateType = frameRateType; + } + + public int getFrameRate() { + return frameRate; + } + + public void setFrameRate(final int frameRate) { + this.frameRate = frameRate; + } + + public int getLeft() { + return left; + } + + public void setLeft(final int left) { + this.left = left; + } + + public int getTop() { + return top; + } + + public void setTop(final int top) { + this.top = top; + } + + public @Nullable Map getPayloadUnknown() { + return payloadUnknown; + } + + public void setPayloadUnknown(final @Nullable Map payloadUnknown) { + this.payloadUnknown = payloadUnknown; + } + + public @Nullable Map getDataUnknown() { + return dataUnknown; + } + + public void setDataUnknown(final @Nullable Map dataUnknown) { + this.dataUnknown = dataUnknown; + } + + @Override + public @Nullable Map getUnknown() { + return unknown; + } + + @Override + public void setUnknown(final @Nullable Map unknown) { + this.unknown = unknown; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + if (!super.equals(o)) return false; + RRWebVideoEvent that = (RRWebVideoEvent) o; + return segmentId == that.segmentId + && size == that.size + && durationMs == that.durationMs + && height == that.height + && width == that.width + && frameCount == that.frameCount + && frameRate == that.frameRate + && left == that.left + && top == that.top + && Objects.equals(tag, that.tag) + && Objects.equals(encoding, that.encoding) + && Objects.equals(container, that.container) + && Objects.equals(frameRateType, that.frameRateType); + } + + @Override + public int hashCode() { + return Objects.hash( + super.hashCode(), + tag, + segmentId, + size, + durationMs, + encoding, + container, + height, + width, + frameCount, + frameRateType, + frameRate, + left, + top); + } + + // region json + + // rrweb uses camelCase hence the json keys are in camelCase here + public static final class JsonKeys { + public static final String DATA = "data"; + public static final String PAYLOAD = "payload"; + public static final String SEGMENT_ID = "segmentId"; + public static final String SIZE = "size"; + public static final String DURATION = "duration"; + public static final String ENCODING = "encoding"; + public static final String CONTAINER = "container"; + public static final String HEIGHT = "height"; + public static final String WIDTH = "width"; + public static final String FRAME_COUNT = "frameCount"; + public static final String FRAME_RATE_TYPE = "frameRateType"; + public static final String FRAME_RATE = "frameRate"; + public static final String LEFT = "left"; + public static final String TOP = "top"; + } + + @Override + public void serialize(final @NotNull ObjectWriter writer, final @NotNull ILogger logger) + throws IOException { + writer.beginObject(); + new RRWebEvent.Serializer().serialize(this, writer, logger); + writer.name(JsonKeys.DATA); + serializeData(writer, logger); + if (unknown != null) { + for (final String key : unknown.keySet()) { + final Object value = unknown.get(key); + writer.name(key); + writer.value(logger, value); + } + } + writer.endObject(); + } + + private void serializeData(final @NotNull ObjectWriter writer, final @NotNull ILogger logger) + throws IOException { + writer.beginObject(); + writer.name(RRWebEvent.JsonKeys.TAG).value(tag); + writer.name(JsonKeys.PAYLOAD); + serializePayload(writer, logger); + if (dataUnknown != null) { + for (String key : dataUnknown.keySet()) { + Object value = dataUnknown.get(key); + writer.name(key); + writer.value(logger, value); + } + } + writer.endObject(); + } + + private void serializePayload(final @NotNull ObjectWriter writer, final @NotNull ILogger logger) + throws IOException { + writer.beginObject(); + writer.name(JsonKeys.SEGMENT_ID).value(segmentId); + writer.name(JsonKeys.SIZE).value(size); + writer.name(JsonKeys.DURATION).value(durationMs); + writer.name(JsonKeys.ENCODING).value(encoding); + writer.name(JsonKeys.CONTAINER).value(container); + writer.name(JsonKeys.HEIGHT).value(height); + writer.name(JsonKeys.WIDTH).value(width); + writer.name(JsonKeys.FRAME_COUNT).value(frameCount); + writer.name(JsonKeys.FRAME_RATE).value(frameRate); + writer.name(JsonKeys.FRAME_RATE_TYPE).value(frameRateType); + writer.name(JsonKeys.LEFT).value(left); + writer.name(JsonKeys.TOP).value(top); + if (payloadUnknown != null) { + for (final String key : payloadUnknown.keySet()) { + final Object value = payloadUnknown.get(key); + writer.name(key); + writer.value(logger, value); + } + } + writer.endObject(); + } + + public static final class Deserializer implements JsonDeserializer { + + @SuppressWarnings("unchecked") + @Override + public @NotNull RRWebVideoEvent deserialize( + final @NotNull ObjectReader reader, final @NotNull ILogger logger) throws Exception { + reader.beginObject(); + @Nullable Map unknown = null; + + final RRWebVideoEvent event = new RRWebVideoEvent(); + final RRWebEvent.Deserializer baseEventDeserializer = new RRWebEvent.Deserializer(); + + while (reader.peek() == JsonToken.NAME) { + final String nextName = reader.nextName(); + switch (nextName) { + case RRWebMetaEvent.JsonKeys.DATA: + deserializeData(event, reader, logger); + break; + default: + if (!baseEventDeserializer.deserializeValue(event, nextName, reader, logger)) { + if (unknown == null) { + unknown = new HashMap<>(); + } + reader.nextUnknown(logger, unknown, nextName); + } + break; + } + } + event.setUnknown(unknown); + reader.endObject(); + return event; + } + + private void deserializeData( + final @NotNull RRWebVideoEvent event, + final @NotNull ObjectReader reader, + final @NotNull ILogger logger) + throws Exception { + @Nullable Map dataUnknown = null; + + reader.beginObject(); + while (reader.peek() == JsonToken.NAME) { + final String nextName = reader.nextName(); + switch (nextName) { + case RRWebEvent.JsonKeys.TAG: + final String tag = reader.nextStringOrNull(); + event.tag = tag == null ? "" : tag; + break; + case JsonKeys.PAYLOAD: + deserializePayload(event, reader, logger); + break; + default: + if (dataUnknown == null) { + dataUnknown = new ConcurrentHashMap<>(); + } + reader.nextUnknown(logger, dataUnknown, nextName); + } + } + event.setDataUnknown(dataUnknown); + reader.endObject(); + } + + private void deserializePayload( + final @NotNull RRWebVideoEvent event, + final @NotNull ObjectReader reader, + final @NotNull ILogger logger) + throws Exception { + @Nullable Map payloadUnknown = null; + + reader.beginObject(); + while (reader.peek() == JsonToken.NAME) { + final String nextName = reader.nextName(); + switch (nextName) { + case JsonKeys.SEGMENT_ID: + event.segmentId = reader.nextInt(); + break; + case JsonKeys.SIZE: + final Long size = reader.nextLongOrNull(); + event.size = size == null ? 0 : size; + break; + case JsonKeys.DURATION: + event.durationMs = reader.nextLong(); + break; + case JsonKeys.CONTAINER: + final String container = reader.nextStringOrNull(); + event.container = container == null ? "" : container; + break; + case JsonKeys.ENCODING: + final String encoding = reader.nextStringOrNull(); + event.encoding = encoding == null ? "" : encoding; + break; + case JsonKeys.HEIGHT: + final Integer height = reader.nextIntegerOrNull(); + event.height = height == null ? 0 : height; + break; + case JsonKeys.WIDTH: + final Integer width = reader.nextIntegerOrNull(); + event.width = width == null ? 0 : width; + break; + case JsonKeys.FRAME_COUNT: + final Integer frameCount = reader.nextIntegerOrNull(); + event.frameCount = frameCount == null ? 0 : frameCount; + break; + case JsonKeys.FRAME_RATE: + final Integer frameRate = reader.nextIntegerOrNull(); + event.frameRate = frameRate == null ? 0 : frameRate; + break; + case JsonKeys.FRAME_RATE_TYPE: + final String frameRateType = reader.nextStringOrNull(); + event.frameRateType = frameRateType == null ? "" : frameRateType; + break; + case JsonKeys.LEFT: + final Integer left = reader.nextIntegerOrNull(); + event.left = left == null ? 0 : left; + break; + case JsonKeys.TOP: + final Integer top = reader.nextIntegerOrNull(); + event.top = top == null ? 0 : top; + break; + default: + if (payloadUnknown == null) { + payloadUnknown = new ConcurrentHashMap<>(); + } + reader.nextUnknown(logger, payloadUnknown, nextName); + } + } + event.setPayloadUnknown(payloadUnknown); + reader.endObject(); + } + } + // endregion json +} diff --git a/sentry/src/main/java/io/sentry/util/MapObjectReader.java b/sentry/src/main/java/io/sentry/util/MapObjectReader.java new file mode 100644 index 0000000000..b04fbb9675 --- /dev/null +++ b/sentry/src/main/java/io/sentry/util/MapObjectReader.java @@ -0,0 +1,413 @@ +package io.sentry.util; + +import io.sentry.ILogger; +import io.sentry.JsonDeserializer; +import io.sentry.ObjectReader; +import io.sentry.SentryLevel; +import io.sentry.vendor.gson.stream.JsonToken; +import java.io.IOException; +import java.util.AbstractMap; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Date; +import java.util.Deque; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.TimeZone; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +@SuppressWarnings("unchecked") +public final class MapObjectReader implements ObjectReader { + + private final Deque> stack; + + public MapObjectReader(final Map root) { + stack = new ArrayDeque<>(); + stack.addLast(new AbstractMap.SimpleEntry<>(null, root)); + } + + @Override + public void nextUnknown( + final @NotNull ILogger logger, final Map unknown, final String name) { + try { + unknown.put(name, nextObjectOrNull()); + } catch (Exception exception) { + logger.log(SentryLevel.ERROR, exception, "Error deserializing unknown key: %s", name); + } + } + + @Nullable + @Override + public List nextListOrNull( + final @NotNull ILogger logger, final @NotNull JsonDeserializer deserializer) + throws IOException { + if (peek() == JsonToken.NULL) { + nextNull(); + return null; + } + try { + beginArray(); + List list = new ArrayList<>(); + if (hasNext()) { + do { + try { + list.add(deserializer.deserialize(this, logger)); + } catch (Exception e) { + logger.log(SentryLevel.WARNING, "Failed to deserialize object in list.", e); + } + } while (peek() == JsonToken.BEGIN_OBJECT); + } + endArray(); + return list; + } catch (Exception e) { + throw new IOException(e); + } + } + + @Nullable + @Override + public Map nextMapOrNull( + final @NotNull ILogger logger, final @NotNull JsonDeserializer deserializer) + throws IOException { + if (peek() == JsonToken.NULL) { + nextNull(); + return null; + } + try { + beginObject(); + Map map = new HashMap<>(); + if (hasNext()) { + do { + try { + String key = nextName(); + map.put(key, deserializer.deserialize(this, logger)); + } catch (Exception e) { + logger.log(SentryLevel.WARNING, "Failed to deserialize object in map.", e); + } + } while (peek() == JsonToken.BEGIN_OBJECT || peek() == JsonToken.NAME); + } + endObject(); + return map; + } catch (Exception e) { + throw new IOException(e); + } + } + + @Override + public @Nullable Map> nextMapOfListOrNull( + @NotNull ILogger logger, @NotNull JsonDeserializer deserializer) throws IOException { + if (peek() == JsonToken.NULL) { + nextNull(); + return null; + } + final @NotNull Map> result = new HashMap<>(); + + try { + beginObject(); + if (hasNext()) { + do { + final @NotNull String key = nextName(); + final @Nullable List list = nextListOrNull(logger, deserializer); + if (list != null) { + result.put(key, list); + } + } while (peek() == JsonToken.BEGIN_OBJECT || peek() == JsonToken.NAME); + } + endObject(); + return result; + } catch (Exception e) { + throw new IOException(e); + } + } + + @Nullable + @Override + public T nextOrNull( + final @NotNull ILogger logger, final @NotNull JsonDeserializer deserializer) + throws Exception { + return nextValueOrNull(logger, deserializer); + } + + @Nullable + @Override + public Date nextDateOrNull(final @NotNull ILogger logger) throws IOException { + final String dateString = nextStringOrNull(); + return ObjectReader.dateOrNull(dateString, logger); + } + + @Nullable + @Override + public TimeZone nextTimeZoneOrNull(final @NotNull ILogger logger) throws IOException { + final String timeZoneId = nextStringOrNull(); + return timeZoneId != null ? TimeZone.getTimeZone(timeZoneId) : null; + } + + @Nullable + @Override + public Object nextObjectOrNull() throws IOException { + return nextValueOrNull(); + } + + @NotNull + @Override + public JsonToken peek() throws IOException { + if (stack.isEmpty()) { + return JsonToken.END_DOCUMENT; + } + + final Map.Entry currentEntry = stack.peekLast(); + if (currentEntry == null) { + return JsonToken.END_DOCUMENT; + } + + if (currentEntry.getKey() != null) { + return JsonToken.NAME; + } + + final Object value = currentEntry.getValue(); + + if (value instanceof Map) { + return JsonToken.BEGIN_OBJECT; + } else if (value instanceof List) { + return JsonToken.BEGIN_ARRAY; + } else if (value instanceof String) { + return JsonToken.STRING; + } else if (value instanceof Number) { + return JsonToken.NUMBER; + } else if (value instanceof Boolean) { + return JsonToken.BOOLEAN; + } else if (value instanceof JsonToken) { + return (JsonToken) value; + } else { + return JsonToken.END_DOCUMENT; + } + } + + @NotNull + @Override + public String nextName() throws IOException { + final Map.Entry currentEntry = stack.peekLast(); + if (currentEntry != null && currentEntry.getKey() != null) { + return currentEntry.getKey(); + } + throw new IOException("Expected a name but was " + peek()); + } + + @Override + public void beginObject() throws IOException { + final Map.Entry currentEntry = stack.removeLast(); + if (currentEntry == null) { + throw new IOException("No more entries"); + } + final Object value = currentEntry.getValue(); + if (value instanceof Map) { + // insert a dummy entry to indicate end of an object + stack.addLast(new AbstractMap.SimpleEntry<>(null, JsonToken.END_OBJECT)); + // extract map entries onto the stack + for (Map.Entry entry : ((Map) value).entrySet()) { + stack.addLast(entry); + } + } else { + throw new IOException("Current token is not an object"); + } + } + + @Override + public void endObject() throws IOException { + if (stack.size() > 1) { + stack.removeLast(); // Pop the current map from stack + } + } + + @Override + public void beginArray() throws IOException { + final Map.Entry currentEntry = stack.removeLast(); + if (currentEntry == null) { + throw new IOException("No more entries"); + } + final Object value = currentEntry.getValue(); + if (value instanceof List) { + // insert a dummy entry to indicate end of an object + stack.addLast(new AbstractMap.SimpleEntry<>(null, JsonToken.END_ARRAY)); + // extract map entries onto the stack + for (int i = ((List) value).size() - 1; i >= 0; i--) { + final Object entry = ((List) value).get(i); + stack.addLast(new AbstractMap.SimpleEntry<>(null, entry)); + } + } else { + throw new IOException("Current token is not an object"); + } + } + + @Override + public void endArray() throws IOException { + if (stack.size() > 1) { + stack.removeLast(); // Pop the current array from stack + } + } + + @Override + public boolean hasNext() throws IOException { + return !stack.isEmpty(); + } + + @Override + public int nextInt() throws IOException { + final Object value = nextValueOrNull(); + if (value instanceof Number) { + return ((Number) value).intValue(); + } else { + throw new IOException("Expected int"); + } + } + + @Nullable + @Override + public Integer nextIntegerOrNull() throws IOException { + final Object value = nextValueOrNull(); + if (value instanceof Number) { + return ((Number) value).intValue(); + } + return null; + } + + @Override + public long nextLong() throws IOException { + final Object value = nextValueOrNull(); + if (value instanceof Number) { + return ((Number) value).longValue(); + } else { + throw new IOException("Expected long"); + } + } + + @Nullable + @Override + public Long nextLongOrNull() throws IOException { + final Object value = nextValueOrNull(); + if (value instanceof Number) { + return ((Number) value).longValue(); + } + return null; + } + + @Override + public String nextString() throws IOException { + final String value = nextValueOrNull(); + if (value != null) { + return value; + } else { + throw new IOException("Expected string"); + } + } + + @Nullable + @Override + public String nextStringOrNull() throws IOException { + return nextValueOrNull(); + } + + @Override + public boolean nextBoolean() throws IOException { + final Boolean value = nextValueOrNull(); + if (value != null) { + return value; + } else { + throw new IOException("Expected boolean"); + } + } + + @Nullable + @Override + public Boolean nextBooleanOrNull() throws IOException { + return nextValueOrNull(); + } + + @Override + public double nextDouble() throws IOException { + final Object value = nextValueOrNull(); + if (value instanceof Number) { + return ((Number) value).doubleValue(); + } else { + throw new IOException("Expected double"); + } + } + + @Nullable + @Override + public Double nextDoubleOrNull() throws IOException { + final Object value = nextValueOrNull(); + if (value instanceof Number) { + return ((Number) value).doubleValue(); + } + return null; + } + + @Nullable + @Override + public Float nextFloatOrNull() throws IOException { + final Object value = nextValueOrNull(); + if (value instanceof Number) { + return ((Number) value).floatValue(); + } + return null; + } + + @Override + public float nextFloat() throws IOException { + final Object value = nextValueOrNull(); + if (value instanceof Number) { + return ((Number) value).floatValue(); + } else { + throw new IOException("Expected float"); + } + } + + @Override + public void nextNull() throws IOException { + final Object value = nextValueOrNull(); + if (value != null) { + throw new IOException("Expected null but was " + peek()); + } + } + + @Override + public void setLenient(final boolean lenient) {} + + @Override + public void skipValue() throws IOException {} + + @SuppressWarnings("TypeParameterUnusedInFormals") + @Nullable + private T nextValueOrNull() throws IOException { + try { + return nextValueOrNull(null, null); + } catch (Exception e) { + throw new IOException(e); + } + } + + @SuppressWarnings("TypeParameterUnusedInFormals") + @Nullable + private T nextValueOrNull( + final @Nullable ILogger logger, final @Nullable JsonDeserializer deserializer) + throws Exception { + final Map.Entry currentEntry = stack.peekLast(); + if (currentEntry == null) { + return null; + } + final T value = (T) currentEntry.getValue(); + if (deserializer != null && logger != null) { + return deserializer.deserialize(this, logger); + } + stack.removeLast(); + return value; + } + + @Override + public void close() throws IOException { + stack.clear(); + } +} diff --git a/sentry/src/main/java/io/sentry/util/MapObjectWriter.java b/sentry/src/main/java/io/sentry/util/MapObjectWriter.java index 26f80eddc2..0bbc70a779 100644 --- a/sentry/src/main/java/io/sentry/util/MapObjectWriter.java +++ b/sentry/src/main/java/io/sentry/util/MapObjectWriter.java @@ -120,6 +120,11 @@ public MapObjectWriter value(final @NotNull ILogger logger, final @Nullable Obje return this; } + @Override + public void setLenient(boolean lenient) { + // no-op + } + @Override public MapObjectWriter beginArray() throws IOException { stack.add(new ArrayList<>()); @@ -151,6 +156,12 @@ public MapObjectWriter value(final @Nullable String value) throws IOException { return this; } + @Override + public ObjectWriter jsonValue(@Nullable String value) throws IOException { + // no-op + return this; + } + @Override public MapObjectWriter nullValue() throws IOException { postValue((Object) null); diff --git a/sentry/src/test/java/io/sentry/BaggageTest.kt b/sentry/src/test/java/io/sentry/BaggageTest.kt index eb1cfa0383..c24731e92a 100644 --- a/sentry/src/test/java/io/sentry/BaggageTest.kt +++ b/sentry/src/test/java/io/sentry/BaggageTest.kt @@ -527,15 +527,13 @@ class BaggageTest { @Test fun `unknown returns sentry- prefixed keys that are not known and passes them on to TraceContext`() { - val baggage = Baggage.fromHeader(listOf("sentry-trace_id=${SentryId()},sentry-public_key=b, sentry-replay_id=def", "sentry-transaction=sentryTransaction, sentry-anewkey=abc")) + val baggage = Baggage.fromHeader(listOf("sentry-trace_id=${SentryId()},sentry-public_key=b, sentry-replay_id=${SentryId()}", "sentry-transaction=sentryTransaction, sentry-anewkey=abc")) val unknown = baggage.unknown - assertEquals(2, unknown.size) - assertEquals("def", unknown["replay_id"]) + assertEquals(1, unknown.size) assertEquals("abc", unknown["anewkey"]) val traceContext = baggage.toTraceContext()!! - assertEquals(2, traceContext.unknown!!.size) - assertEquals("def", traceContext.unknown!!["replay_id"]) + assertEquals(1, traceContext.unknown!!.size) assertEquals("abc", traceContext.unknown!!["anewkey"]) } diff --git a/sentry/src/test/java/io/sentry/HubTest.kt b/sentry/src/test/java/io/sentry/HubTest.kt index cb66736e24..50f996ccdd 100644 --- a/sentry/src/test/java/io/sentry/HubTest.kt +++ b/sentry/src/test/java/io/sentry/HubTest.kt @@ -2106,6 +2106,27 @@ class HubTest { assertEquals(span.spanContext.parentSpanId, txn.spanContext.spanId) } + // region replay event tests + @Test + fun `when captureReplay is called on disabled client, do nothing`() { + val (sut, mockClient) = getEnabledHub() + sut.close() + + sut.captureReplay(SentryReplayEvent(), Hint()) + verify(mockClient, never()).captureReplayEvent(any(), any(), any()) + } + + @Test + fun `when captureReplay is called with a valid argument, captureReplay on the client should be called`() { + val (sut, mockClient) = getEnabledHub() + + val event = SentryReplayEvent() + val hints = HintUtils.createWithTypeCheckHint({}) + sut.captureReplay(event, hints) + verify(mockClient).captureReplayEvent(eq(event), any(), eq(hints)) + } + // endregion replay event tests + private val dsnTest = "https://key@sentry.io/proj" private fun generateHub(optionsConfiguration: Sentry.OptionsConfiguration? = null): IHub { diff --git a/sentry/src/test/java/io/sentry/JsonObjectReaderTest.kt b/sentry/src/test/java/io/sentry/JsonObjectReaderTest.kt index 276c0d986e..b28efd2fc4 100644 --- a/sentry/src/test/java/io/sentry/JsonObjectReaderTest.kt +++ b/sentry/src/test/java/io/sentry/JsonObjectReaderTest.kt @@ -327,7 +327,7 @@ class JsonObjectReaderTest { var bar: String? = null ) { class Deserializer : JsonDeserializer { - override fun deserialize(reader: JsonObjectReader, logger: ILogger): Deserializable { + override fun deserialize(reader: ObjectReader, logger: ILogger): Deserializable { return Deserializable().apply { reader.beginObject() reader.nextName() diff --git a/sentry/src/test/java/io/sentry/JsonSerializerTest.kt b/sentry/src/test/java/io/sentry/JsonSerializerTest.kt index 30f337dce4..ba8ee84d51 100644 --- a/sentry/src/test/java/io/sentry/JsonSerializerTest.kt +++ b/sentry/src/test/java/io/sentry/JsonSerializerTest.kt @@ -3,9 +3,11 @@ package io.sentry import io.sentry.profilemeasurements.ProfileMeasurement import io.sentry.profilemeasurements.ProfileMeasurementValue import io.sentry.protocol.Device +import io.sentry.protocol.ReplayRecordingSerializationTest import io.sentry.protocol.Request import io.sentry.protocol.SdkVersion import io.sentry.protocol.SentryId +import io.sentry.protocol.SentryReplayEventSerializationTest import io.sentry.protocol.SentrySpan import io.sentry.protocol.SentryTransaction import org.junit.After @@ -443,16 +445,16 @@ class JsonSerializerTest { @Test fun `serializes trace context`() { - val traceContext = SentryEnvelopeHeader(null, null, TraceContext(SentryId("3367f5196c494acaae85bbbd535379ac"), "key", "release", "environment", "userId", "segment", "transaction", "0.5", "true")) - val expected = """{"trace":{"trace_id":"3367f5196c494acaae85bbbd535379ac","public_key":"key","release":"release","environment":"environment","user_id":"userId","user_segment":"segment","transaction":"transaction","sample_rate":"0.5","sampled":"true"}}""" + val traceContext = SentryEnvelopeHeader(null, null, TraceContext(SentryId("3367f5196c494acaae85bbbd535379ac"), "key", "release", "environment", "userId", "segment", "transaction", "0.5", "true", SentryId("3367f5196c494acaae85bbbd535379aa"))) + val expected = """{"trace":{"trace_id":"3367f5196c494acaae85bbbd535379ac","public_key":"key","release":"release","environment":"environment","user_id":"userId","user_segment":"segment","transaction":"transaction","sample_rate":"0.5","sampled":"true","replay_id":"3367f5196c494acaae85bbbd535379aa"}}""" val json = serializeToString(traceContext) assertEquals(expected, json) } @Test fun `serializes trace context with user having null id and segment`() { - val traceContext = SentryEnvelopeHeader(null, null, TraceContext(SentryId("3367f5196c494acaae85bbbd535379ac"), "key", "release", "environment", null, null, "transaction", "0.6", "false")) - val expected = """{"trace":{"trace_id":"3367f5196c494acaae85bbbd535379ac","public_key":"key","release":"release","environment":"environment","transaction":"transaction","sample_rate":"0.6","sampled":"false"}}""" + val traceContext = SentryEnvelopeHeader(null, null, TraceContext(SentryId("3367f5196c494acaae85bbbd535379ac"), "key", "release", "environment", null, null, "transaction", "0.6", "false", SentryId("3367f5196c494acaae85bbbd535379aa"))) + val expected = """{"trace":{"trace_id":"3367f5196c494acaae85bbbd535379ac","public_key":"key","release":"release","environment":"environment","transaction":"transaction","sample_rate":"0.6","sampled":"false","replay_id":"3367f5196c494acaae85bbbd535379aa"}}""" val json = serializeToString(traceContext) assertEquals(expected, json) } @@ -1231,6 +1233,20 @@ class JsonSerializerTest { ) } + @Test + fun `ser deser replay data`() { + val replayEvent = SentryReplayEventSerializationTest.Fixture().getSut() + val replayRecording = ReplayRecordingSerializationTest.Fixture().getSut() + val serializedEvent = serializeToString(replayEvent) + val serializedRecording = serializeToString(replayRecording) + + val deserializedEvent = fixture.serializer.deserialize(StringReader(serializedEvent), SentryReplayEvent::class.java) + val deserializedRecording = fixture.serializer.deserialize(StringReader(serializedRecording), ReplayRecording::class.java) + + assertEquals(replayEvent, deserializedEvent) + assertEquals(replayRecording, deserializedRecording) + } + private fun assertSessionData(expectedSession: Session?) { assertNotNull(expectedSession) assertEquals(UUID.fromString("c81d4e2e-bcf2-11e6-869b-7df92533d2db"), expectedSession.sessionId) diff --git a/sentry/src/test/java/io/sentry/MainEventProcessorTest.kt b/sentry/src/test/java/io/sentry/MainEventProcessorTest.kt index ec932ebc86..00214e92c5 100644 --- a/sentry/src/test/java/io/sentry/MainEventProcessorTest.kt +++ b/sentry/src/test/java/io/sentry/MainEventProcessorTest.kt @@ -603,6 +603,22 @@ class MainEventProcessorTest { } } + @Test + fun `enriches ReplayEvent`() { + val sut = fixture.getSut(tags = mapOf("tag1" to "value1")) + + var replayEvent = SentryReplayEvent() + replayEvent = sut.process(replayEvent, Hint()) + + assertEquals("release", replayEvent.release) + assertEquals("environment", replayEvent.environment) + assertEquals("dist", replayEvent.dist) + assertEquals("1.2.3", replayEvent.sdk!!.version) + assertEquals("test", replayEvent.sdk!!.name) + assertEquals("java", replayEvent.platform) + assertEquals("value1", replayEvent.tags!!["tag1"]) + } + private fun generateCrashedEvent(crashedThread: Thread = Thread.currentThread()) = SentryEvent().apply { val mockThrowable = mock() diff --git a/sentry/src/test/java/io/sentry/SentryClientTest.kt b/sentry/src/test/java/io/sentry/SentryClientTest.kt index f70d7e0584..9b883d5ef2 100644 --- a/sentry/src/test/java/io/sentry/SentryClientTest.kt +++ b/sentry/src/test/java/io/sentry/SentryClientTest.kt @@ -1,11 +1,11 @@ package io.sentry import io.sentry.Scope.IWithPropagationContext +import io.sentry.SentryLevel.WARNING import io.sentry.Session.State.Crashed import io.sentry.clientreport.ClientReportTestHelper.Companion.assertClientReport import io.sentry.clientreport.DiscardReason import io.sentry.clientreport.DiscardedEvent -import io.sentry.clientreport.DropEverythingEventProcessor import io.sentry.exception.SentryEnvelopeException import io.sentry.hints.AbnormalExit import io.sentry.hints.ApplyScopeData @@ -42,6 +42,7 @@ import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.verifyNoMoreInteractions import org.mockito.kotlin.whenever +import org.msgpack.core.MessagePack import java.io.ByteArrayInputStream import java.io.ByteArrayOutputStream import java.io.File @@ -2359,6 +2360,41 @@ class SentryClientTest { @Test fun `when event has DiskFlushNotification, TransactionEnds set transaction id as flushable`() { val sut = fixture.getSut() + val replayId = SentryId() + val scope = mock { + whenever(it.replayId).thenReturn(replayId) + whenever(it.breadcrumbs).thenReturn(LinkedList()) + whenever(it.extras).thenReturn(emptyMap()) + whenever(it.contexts).thenReturn(Contexts()) + } + val scopePropagationContext = PropagationContext() + whenever(scope.propagationContext).thenReturn(scopePropagationContext) + doAnswer { (it.arguments[0] as IWithPropagationContext).accept(scopePropagationContext); scopePropagationContext }.whenever(scope).withPropagationContext(any()) + + var capturedEventId: SentryId? = null + val transactionEnd = object : TransactionEnd, DiskFlushNotification { + override fun markFlushed() {} + override fun isFlushable(eventId: SentryId?): Boolean = true + override fun setFlushable(eventId: SentryId) { + capturedEventId = eventId + } + } + val transactionEndHint = HintUtils.createWithTypeCheckHint(transactionEnd) + + sut.captureEvent(SentryEvent(), scope, transactionEndHint) + + assertEquals(replayId, capturedEventId) + verify(fixture.transport).send( + check { + assertEquals(1, it.items.count()) + }, + any() + ) + } + + @Test + fun `when event has DiskFlushNotification, TransactionEnds set replay id as flushable`() { + val sut = fixture.getSut() // build up a running transaction val spanContext = SpanContext("op.load") @@ -2373,6 +2409,7 @@ class SentryClientTest { whenever(scope.breadcrumbs).thenReturn(LinkedList()) whenever(scope.extras).thenReturn(emptyMap()) whenever(scope.contexts).thenReturn(Contexts()) + whenever(scope.replayId).thenReturn(SentryId.EMPTY_ID) val scopePropagationContext = PropagationContext() whenever(scope.propagationContext).thenReturn(scopePropagationContext) doAnswer { (it.arguments[0] as IWithPropagationContext).accept(scopePropagationContext); scopePropagationContext }.whenever(scope).withPropagationContext(any()) @@ -2445,6 +2482,7 @@ class SentryClientTest { whenever(scope.breadcrumbs).thenReturn(LinkedList()) whenever(scope.extras).thenReturn(emptyMap()) whenever(scope.contexts).thenReturn(Contexts()) + whenever(scope.replayId).thenReturn(SentryId()) val scopePropagationContext = PropagationContext() whenever(scope.propagationContext).thenReturn(scopePropagationContext) doAnswer { (it.arguments[0] as IWithPropagationContext).accept(scopePropagationContext); scopePropagationContext }.whenever(scope).withPropagationContext(any()) @@ -2513,6 +2551,8 @@ class SentryClientTest { whenever(scope.breadcrumbs).thenReturn(LinkedList()) whenever(scope.extras).thenReturn(emptyMap()) whenever(scope.contexts).thenReturn(Contexts()) + val replayId = SentryId() + whenever(scope.replayId).thenReturn(replayId) val scopePropagationContext = PropagationContext() doAnswer { (it.arguments[0] as IWithPropagationContext).accept(scopePropagationContext); scopePropagationContext }.whenever(scope).withPropagationContext(any()) whenever(scope.propagationContext).thenReturn(scopePropagationContext) @@ -2525,6 +2565,7 @@ class SentryClientTest { check { assertNotNull(it.header.traceContext) assertEquals(scopePropagationContext.traceId, it.header.traceContext!!.traceId) + assertEquals(replayId, it.header.traceContext!!.replayId) }, any() ) @@ -2609,6 +2650,120 @@ class SentryClientTest { assertNotSame(NoopMetricsAggregator.getInstance(), sut.metricsAggregator) } + @Test + fun `when captureReplayEvent, envelope is sent`() { + val sut = fixture.getSut() + val replayEvent = createReplayEvent() + + sut.captureReplayEvent(replayEvent, null, null) + + verify(fixture.transport).send( + check { actual -> + assertEquals(replayEvent.eventId, actual.header.eventId) + assertEquals(fixture.sentryOptions.sdkVersion, actual.header.sdkVersion) + + assertEquals(1, actual.items.count()) + val item = actual.items.first() + assertEquals(SentryItemType.ReplayVideo, item.header.type) + + val unpacker = MessagePack.newDefaultUnpacker(item.data) + val mapSize = unpacker.unpackMapHeader() + assertEquals(1, mapSize) + }, + any() + ) + } + + @Test + fun `when captureReplayEvent with recording, adds it to payload`() { + val sut = fixture.getSut() + val replayEvent = createReplayEvent() + + val hint = Hint().apply { replayRecording = createReplayRecording() } + sut.captureReplayEvent(replayEvent, null, hint) + + verify(fixture.transport).send( + check { actual -> + assertEquals(replayEvent.eventId, actual.header.eventId) + assertEquals(fixture.sentryOptions.sdkVersion, actual.header.sdkVersion) + + assertEquals(1, actual.items.count()) + val item = actual.items.first() + assertEquals(SentryItemType.ReplayVideo, item.header.type) + + val unpacker = MessagePack.newDefaultUnpacker(item.data) + val mapSize = unpacker.unpackMapHeader() + assertEquals(2, mapSize) + }, + any() + ) + } + + @Test + fun `when captureReplayEvent, omits breadcrumbs and extras from scope`() { + val sut = fixture.getSut() + val replayEvent = createReplayEvent() + + sut.captureReplayEvent(replayEvent, createScope(), null) + + verify(fixture.transport).send( + check { actual -> + val item = actual.items.first() + + val unpacker = MessagePack.newDefaultUnpacker(item.data) + val mapSize = unpacker.unpackMapHeader() + for (i in 0 until mapSize) { + val key = unpacker.unpackString() + when (key) { + SentryItemType.ReplayEvent.itemType -> { + val replayEventLength = unpacker.unpackBinaryHeader() + val replayEventBytes = unpacker.readPayload(replayEventLength) + val actualReplayEvent = fixture.sentryOptions.serializer.deserialize( + InputStreamReader(replayEventBytes.inputStream()), + SentryReplayEvent::class.java + ) + // sanity check + assertEquals("id", actualReplayEvent!!.user!!.id) + + assertNull(actualReplayEvent.breadcrumbs) + assertNull(actualReplayEvent.extras) + } + } + } + }, + any() + ) + } + + @Test + fun `when replay event is dropped, captures client report with datacategory replay`() { + fixture.sentryOptions.addEventProcessor(DropEverythingEventProcessor()) + val sut = fixture.getSut() + val replayEvent = createReplayEvent() + + sut.captureReplayEvent(replayEvent, createScope(), null) + + assertClientReport( + fixture.sentryOptions.clientReportRecorder, + listOf(DiscardedEvent(DiscardReason.EVENT_PROCESSOR.reason, DataCategory.Replay.category, 1)) + ) + } + + @Test + fun `calls sendReplayForEvent on replay controller for error events`() { + var called = false + fixture.sentryOptions.setReplayController(object : ReplayController by NoOpReplayController.getInstance() { + override fun sendReplayForEvent(event: SentryEvent, hint: Hint) { + assertEquals("Test", event.message?.formatted) + called = true + } + }) + val sut = fixture.getSut() + + sut.captureMessage("Test", WARNING) + assertTrue(called) + } + private fun givenScopeWithStartedSession(errored: Boolean = false, crashed: Boolean = false): IScope { val scope = createScope(fixture.sentryOptions) scope.startSession() @@ -2667,6 +2822,21 @@ class SentryClientTest { } } + private fun createReplayEvent(): SentryReplayEvent = SentryReplayEvent().apply { + replayId = SentryId("f715e1d64ef64ea3ad7744b5230813c3") + segmentId = 0 + timestamp = DateUtils.getDateTimeWithMillisPrecision("987654321.123") + replayStartTimestamp = DateUtils.getDateTimeWithMillisPrecision("987654321.123") + urls = listOf("ScreenOne") + errorIds = listOf("ab3a347a4cc14fd4b4cf1dc56b670c5b") + traceIds = listOf("340cfef948204549ac07c3b353c81c50") + } + + private fun createReplayRecording(): ReplayRecording = ReplayRecording().apply { + segmentId = 0 + payload = emptyList() + } + private fun createScope(options: SentryOptions = SentryOptions()): IScope { return Scope(options).apply { addBreadcrumb( @@ -2850,4 +3020,8 @@ class DropEverythingEventProcessor : EventProcessor { ): SentryTransaction? { return null } + + override fun process(event: SentryReplayEvent, hint: Hint): SentryReplayEvent? { + return null + } } diff --git a/sentry/src/test/java/io/sentry/SentryEnvelopeItemTest.kt b/sentry/src/test/java/io/sentry/SentryEnvelopeItemTest.kt index 9817897651..efc5e5cadf 100644 --- a/sentry/src/test/java/io/sentry/SentryEnvelopeItemTest.kt +++ b/sentry/src/test/java/io/sentry/SentryEnvelopeItemTest.kt @@ -1,6 +1,8 @@ package io.sentry import io.sentry.exception.SentryEnvelopeException +import io.sentry.protocol.ReplayRecordingSerializationTest +import io.sentry.protocol.SentryReplayEventSerializationTest import io.sentry.protocol.User import io.sentry.protocol.ViewHierarchy import io.sentry.test.injectForField @@ -10,12 +12,15 @@ import org.mockito.kotlin.any import org.mockito.kotlin.mock import org.mockito.kotlin.verify import org.mockito.kotlin.whenever +import org.msgpack.core.MessagePack import java.io.BufferedWriter import java.io.ByteArrayOutputStream import java.io.File import java.io.IOException +import java.io.InputStreamReader import java.io.OutputStreamWriter import java.nio.charset.Charset +import java.nio.file.Files import kotlin.test.AfterTest import kotlin.test.Test import kotlin.test.assertEquals @@ -66,7 +71,12 @@ class SentryEnvelopeItemTest { fun `fromAttachment with bytes`() { val attachment = Attachment(fixture.bytesAllowed, fixture.filename) - val item = SentryEnvelopeItem.fromAttachment(fixture.serializer, fixture.options.logger, attachment, fixture.maxAttachmentSize) + val item = SentryEnvelopeItem.fromAttachment( + fixture.serializer, + fixture.options.logger, + attachment, + fixture.maxAttachmentSize + ) assertAttachment(attachment, fixture.bytesAllowed, item) } @@ -78,7 +88,12 @@ class SentryEnvelopeItemTest { val attachment = Attachment(viewHierarchy, fixture.filename, "text/plain", null, false) - val item = SentryEnvelopeItem.fromAttachment(fixture.serializer, fixture.options.logger, attachment, fixture.maxAttachmentSize) + val item = SentryEnvelopeItem.fromAttachment( + fixture.serializer, + fixture.options.logger, + attachment, + fixture.maxAttachmentSize + ) assertAttachment(attachment, viewHierarchySerialized, item) } @@ -87,7 +102,12 @@ class SentryEnvelopeItemTest { fun `fromAttachment with attachmentType`() { val attachment = Attachment(fixture.pathname, fixture.filename, "", true, "event.minidump") - val item = SentryEnvelopeItem.fromAttachment(fixture.serializer, fixture.options.logger, attachment, fixture.maxAttachmentSize) + val item = SentryEnvelopeItem.fromAttachment( + fixture.serializer, + fixture.options.logger, + attachment, + fixture.maxAttachmentSize + ) assertEquals("event.minidump", item.header.attachmentType) } @@ -98,7 +118,12 @@ class SentryEnvelopeItemTest { file.writeBytes(fixture.bytesAllowed) val attachment = Attachment(file.path) - val item = SentryEnvelopeItem.fromAttachment(fixture.serializer, fixture.options.logger, attachment, fixture.maxAttachmentSize) + val item = SentryEnvelopeItem.fromAttachment( + fixture.serializer, + fixture.options.logger, + attachment, + fixture.maxAttachmentSize + ) assertAttachment(attachment, fixture.bytesAllowed, item) } @@ -110,7 +135,12 @@ class SentryEnvelopeItemTest { file.writeBytes(twoMB) val attachment = Attachment(file.absolutePath) - val item = SentryEnvelopeItem.fromAttachment(fixture.serializer, fixture.options.logger, attachment, fixture.maxAttachmentSize) + val item = SentryEnvelopeItem.fromAttachment( + fixture.serializer, + fixture.options.logger, + attachment, + fixture.maxAttachmentSize + ) assertAttachment(attachment, twoMB, item) } @@ -119,7 +149,12 @@ class SentryEnvelopeItemTest { fun `fromAttachment with non existent file`() { val attachment = Attachment("I don't exist", "file.txt") - val item = SentryEnvelopeItem.fromAttachment(fixture.serializer, fixture.options.logger, attachment, fixture.maxAttachmentSize) + val item = SentryEnvelopeItem.fromAttachment( + fixture.serializer, + fixture.options.logger, + attachment, + fixture.maxAttachmentSize + ) assertFailsWith( "Reading the attachment ${attachment.pathname} failed, because the file located at " + @@ -139,7 +174,12 @@ class SentryEnvelopeItemTest { if (changedFileReadPermission) { val attachment = Attachment(file.path, "file.txt") - val item = SentryEnvelopeItem.fromAttachment(fixture.serializer, fixture.options.logger, attachment, fixture.maxAttachmentSize) + val item = SentryEnvelopeItem.fromAttachment( + fixture.serializer, + fixture.options.logger, + attachment, + fixture.maxAttachmentSize + ) assertFailsWith( "Reading the attachment ${attachment.pathname} failed, " + @@ -162,7 +202,12 @@ class SentryEnvelopeItemTest { val securityManager = DenyReadFileSecurityManager(fixture.pathname) System.setSecurityManager(securityManager) - val item = SentryEnvelopeItem.fromAttachment(fixture.serializer, fixture.options.logger, attachment, fixture.maxAttachmentSize) + val item = SentryEnvelopeItem.fromAttachment( + fixture.serializer, + fixture.options.logger, + attachment, + fixture.maxAttachmentSize + ) assertFailsWith("Reading the attachment ${attachment.pathname} failed.") { item.data @@ -181,7 +226,12 @@ class SentryEnvelopeItemTest { // reflection instead. attachment.injectForField("pathname", null) - val item = SentryEnvelopeItem.fromAttachment(fixture.serializer, fixture.options.logger, attachment, fixture.maxAttachmentSize) + val item = SentryEnvelopeItem.fromAttachment( + fixture.serializer, + fixture.options.logger, + attachment, + fixture.maxAttachmentSize + ) assertFailsWith( "Couldn't attach the attachment ${attachment.filename}.\n" + @@ -196,7 +246,12 @@ class SentryEnvelopeItemTest { val image = this::class.java.classLoader.getResource("Tongariro.jpg")!! val attachment = Attachment(image.path) - val item = SentryEnvelopeItem.fromAttachment(fixture.serializer, fixture.options.logger, attachment, fixture.maxAttachmentSize) + val item = SentryEnvelopeItem.fromAttachment( + fixture.serializer, + fixture.options.logger, + attachment, + fixture.maxAttachmentSize + ) assertAttachment(attachment, image.readBytes(), item) } @@ -204,7 +259,12 @@ class SentryEnvelopeItemTest { fun `fromAttachment with bytes too big`() { val attachment = Attachment(fixture.bytesTooBig, fixture.filename) val exception = assertFailsWith { - SentryEnvelopeItem.fromAttachment(fixture.serializer, fixture.options.logger, attachment, fixture.maxAttachmentSize).data + SentryEnvelopeItem.fromAttachment( + fixture.serializer, + fixture.options.logger, + attachment, + fixture.maxAttachmentSize + ).data } assertEquals( @@ -227,7 +287,12 @@ class SentryEnvelopeItemTest { val attachment = Attachment(serializable, fixture.filename, "text/plain", null, false) val exception = assertFailsWith { - SentryEnvelopeItem.fromAttachment(fixture.serializer, fixture.options.logger, attachment, fixture.maxAttachmentSize).data + SentryEnvelopeItem.fromAttachment( + fixture.serializer, + fixture.options.logger, + attachment, + fixture.maxAttachmentSize + ).data } assertEquals( @@ -246,7 +311,12 @@ class SentryEnvelopeItemTest { val attachment = Attachment(file.path) val exception = assertFailsWith { - SentryEnvelopeItem.fromAttachment(fixture.serializer, fixture.options.logger, attachment, fixture.maxAttachmentSize).data + SentryEnvelopeItem.fromAttachment( + fixture.serializer, + fixture.options.logger, + attachment, + fixture.maxAttachmentSize + ).data } assertEquals( @@ -261,7 +331,12 @@ class SentryEnvelopeItemTest { fun `fromAttachment with bytesFrom serializable are null`() { val attachment = Attachment(mock(), "mock-file-name", null, null, false) - val item = SentryEnvelopeItem.fromAttachment(fixture.errorSerializer, fixture.options.logger, attachment, fixture.maxAttachmentSize) + val item = SentryEnvelopeItem.fromAttachment( + fixture.errorSerializer, + fixture.options.logger, + attachment, + fixture.maxAttachmentSize + ) assertFailsWith( "Couldn't attach the attachment ${attachment.filename}.\n" + @@ -279,8 +354,13 @@ class SentryEnvelopeItemTest { } file.writeBytes(fixture.bytes) - SentryEnvelopeItem.fromProfilingTrace(profilingTraceData, fixture.maxAttachmentSize, fixture.serializer).data - verify(profilingTraceData).sampledProfile = Base64.encodeToString(fixture.bytes, Base64.NO_WRAP or Base64.NO_PADDING) + SentryEnvelopeItem.fromProfilingTrace( + profilingTraceData, + fixture.maxAttachmentSize, + fixture.serializer + ).data + verify(profilingTraceData).sampledProfile = + Base64.encodeToString(fixture.bytes, Base64.NO_WRAP or Base64.NO_PADDING) } @Test @@ -292,7 +372,11 @@ class SentryEnvelopeItemTest { file.writeBytes(fixture.bytes) assert(file.exists()) - val traceData = SentryEnvelopeItem.fromProfilingTrace(profilingTraceData, fixture.maxAttachmentSize, mock()) + val traceData = SentryEnvelopeItem.fromProfilingTrace( + profilingTraceData, + fixture.maxAttachmentSize, + mock() + ) assert(file.exists()) traceData.data assertFalse(file.exists()) @@ -306,7 +390,11 @@ class SentryEnvelopeItemTest { } assertFailsWith("Dropping profiling trace data, because the file ${file.path} doesn't exists") { - SentryEnvelopeItem.fromProfilingTrace(profilingTraceData, fixture.maxAttachmentSize, mock()).data + SentryEnvelopeItem.fromProfilingTrace( + profilingTraceData, + fixture.maxAttachmentSize, + mock() + ).data } } @@ -319,7 +407,11 @@ class SentryEnvelopeItemTest { file.writeBytes(fixture.bytes) file.setReadable(false) assertFailsWith("Dropping profiling trace data, because the file ${file.path} doesn't exists") { - SentryEnvelopeItem.fromProfilingTrace(profilingTraceData, fixture.maxAttachmentSize, mock()).data + SentryEnvelopeItem.fromProfilingTrace( + profilingTraceData, + fixture.maxAttachmentSize, + mock() + ).data } } @@ -331,7 +423,11 @@ class SentryEnvelopeItemTest { whenever(it.traceFile).thenReturn(file) } - val traceData = SentryEnvelopeItem.fromProfilingTrace(profilingTraceData, fixture.maxAttachmentSize, mock()) + val traceData = SentryEnvelopeItem.fromProfilingTrace( + profilingTraceData, + fixture.maxAttachmentSize, + mock() + ) assertFailsWith("Profiling trace file is empty") { traceData.data } @@ -346,7 +442,11 @@ class SentryEnvelopeItemTest { } val exception = assertFailsWith { - SentryEnvelopeItem.fromProfilingTrace(profilingTraceData, fixture.maxAttachmentSize, mock()).data + SentryEnvelopeItem.fromProfilingTrace( + profilingTraceData, + fixture.maxAttachmentSize, + mock() + ).data } assertEquals( @@ -357,6 +457,58 @@ class SentryEnvelopeItemTest { ) } + @Test + fun `fromReplay encodes payload into msgpack`() { + val file = Files.createTempFile("replay", "").toFile() + val videoBytes = + this::class.java.classLoader.getResource("Tongariro.jpg")!!.readBytes() + file.writeBytes(videoBytes) + + val replayEvent = SentryReplayEventSerializationTest.Fixture().getSut().apply { + videoFile = file + } + val replayRecording = ReplayRecordingSerializationTest.Fixture().getSut() + val replayItem = SentryEnvelopeItem + .fromReplay(fixture.serializer, fixture.options.logger, replayEvent, replayRecording) + + assertEquals(SentryItemType.ReplayVideo, replayItem.header.type) + + assertPayload(replayItem, replayEvent, replayRecording, videoBytes) + } + + @Test + fun `fromReplay does not add video item when no bytes`() { + val file = File(fixture.pathname) + file.writeBytes(ByteArray(0)) + + val replayEvent = SentryReplayEventSerializationTest.Fixture().getSut().apply { + videoFile = file + } + + val replayItem = SentryEnvelopeItem + .fromReplay(fixture.serializer, fixture.options.logger, replayEvent, null) + replayItem.data + assertPayload(replayItem, replayEvent, null, ByteArray(0)) { mapSize -> + assertEquals(1, mapSize) + } + } + + @Test + fun `fromReplay deletes file only after reading data`() { + val file = File(fixture.pathname) + val replayEvent = SentryReplayEventSerializationTest.Fixture().getSut().apply { + videoFile = file + } + + file.writeBytes(fixture.bytes) + assert(file.exists()) + val replayItem = SentryEnvelopeItem + .fromReplay(fixture.serializer, fixture.options.logger, replayEvent, null) + assert(file.exists()) + replayItem.data + assertFalse(file.exists()) + } + private fun createSession(): Session { return Session("dis", User(), "env", "rel") } @@ -379,4 +531,45 @@ class SentryEnvelopeItemTest { } } } + + private fun assertPayload( + replayItem: SentryEnvelopeItem, + replayEvent: SentryReplayEvent, + replayRecording: ReplayRecording?, + videoBytes: ByteArray, + mapSizeAsserter: (mapSize: Int) -> Unit = {} + ) { + val unpacker = MessagePack.newDefaultUnpacker(replayItem.data) + val mapSize = unpacker.unpackMapHeader() + mapSizeAsserter(mapSize) + for (i in 0 until mapSize) { + val key = unpacker.unpackString() + when (key) { + SentryItemType.ReplayEvent.itemType -> { + val replayEventLength = unpacker.unpackBinaryHeader() + val replayEventBytes = unpacker.readPayload(replayEventLength) + val actualReplayEvent = fixture.serializer.deserialize( + InputStreamReader(replayEventBytes.inputStream()), + SentryReplayEvent::class.java + ) + assertEquals(replayEvent, actualReplayEvent) + } + SentryItemType.ReplayRecording.itemType -> { + val replayRecordingLength = unpacker.unpackBinaryHeader() + val replayRecordingBytes = unpacker.readPayload(replayRecordingLength) + val actualReplayRecording = fixture.serializer.deserialize( + InputStreamReader(replayRecordingBytes.inputStream()), + ReplayRecording::class.java + ) + assertEquals(replayRecording, actualReplayRecording) + } + SentryItemType.ReplayVideo.itemType -> { + val videoLength = unpacker.unpackBinaryHeader() + val actualBytes = unpacker.readPayload(videoLength) + assertArrayEquals(videoBytes, actualBytes) + } + } + } + unpacker.close() + } } diff --git a/sentry/src/test/java/io/sentry/SentryReplayOptionsTest.kt b/sentry/src/test/java/io/sentry/SentryReplayOptionsTest.kt new file mode 100644 index 0000000000..01843dfc90 --- /dev/null +++ b/sentry/src/test/java/io/sentry/SentryReplayOptionsTest.kt @@ -0,0 +1,32 @@ +package io.sentry + +import kotlin.test.Test +import kotlin.test.assertEquals + +class SentryReplayOptionsTest { + + @Test + fun `uses medium quality as default`() { + val replayOptions = SentryReplayOptions() + + assertEquals(SentryReplayOptions.SentryReplayQuality.MEDIUM, replayOptions.quality) + assertEquals(75_000, replayOptions.quality.bitRate) + assertEquals(1.0f, replayOptions.quality.sizeScale) + } + + @Test + fun `low quality`() { + val replayOptions = SentryReplayOptions().apply { quality = SentryReplayOptions.SentryReplayQuality.LOW } + + assertEquals(50_000, replayOptions.quality.bitRate) + assertEquals(0.8f, replayOptions.quality.sizeScale) + } + + @Test + fun `high quality`() { + val replayOptions = SentryReplayOptions().apply { quality = SentryReplayOptions.SentryReplayQuality.HIGH } + + assertEquals(100_000, replayOptions.quality.bitRate) + assertEquals(1.0f, replayOptions.quality.sizeScale) + } +} diff --git a/sentry/src/test/java/io/sentry/SentryTracerTest.kt b/sentry/src/test/java/io/sentry/SentryTracerTest.kt index 6bd835716e..b22f585f6d 100644 --- a/sentry/src/test/java/io/sentry/SentryTracerTest.kt +++ b/sentry/src/test/java/io/sentry/SentryTracerTest.kt @@ -1,5 +1,6 @@ package io.sentry +import io.sentry.protocol.SentryId import io.sentry.protocol.TransactionNameSource import io.sentry.protocol.User import io.sentry.util.thread.IMainThreadChecker @@ -581,6 +582,8 @@ class SentryTracerTest { others = mapOf("segment" to "pro") } ) + val replayId = SentryId() + fixture.hub.configureScope { it.replayId = replayId } val trace = transaction.traceContext() assertNotNull(trace) { assertEquals(transaction.spanContext.traceId, it.traceId) @@ -588,6 +591,7 @@ class SentryTracerTest { assertEquals("environment", it.environment) assertEquals("release@3.0.0", it.release) assertEquals(transaction.name, it.transaction) + assertEquals(replayId, it.replayId) } } @@ -656,6 +660,8 @@ class SentryTracerTest { others = mapOf("segment" to "pro") } ) + val replayId = SentryId() + fixture.hub.configureScope { it.replayId = replayId } val header = transaction.toBaggageHeader(null) assertNotNull(header) { @@ -669,6 +675,7 @@ class SentryTracerTest { assertTrue(it.value.contains("sentry-transaction=name,")) // assertTrue(it.value.contains("sentry-user_id=userId12345,")) assertTrue(it.value.contains("sentry-user_segment=pro$".toRegex())) + assertTrue(it.value.contains("sentry-replay_id=$replayId")) } } diff --git a/sentry/src/test/java/io/sentry/TraceContextSerializationTest.kt b/sentry/src/test/java/io/sentry/TraceContextSerializationTest.kt index e79e5ebf8c..876ec12831 100644 --- a/sentry/src/test/java/io/sentry/TraceContextSerializationTest.kt +++ b/sentry/src/test/java/io/sentry/TraceContextSerializationTest.kt @@ -24,7 +24,8 @@ class TraceContextSerializationTest { "f7d8662b-5551-4ef8-b6a8-090f0561a530", "0252ec25-cd0a-4230-bd2f-936a4585637e", "0.00000021", - "true" + "true", + SentryId("3367f5196c494acaae85bbbd535379aa") ) } private val fixture = Fixture() @@ -62,6 +63,7 @@ class TraceContextSerializationTest { id = "user-id" others = mapOf("segment" to "pro") }, + SentryId(), SentryOptions().apply { dsn = dsnString environment = "prod" diff --git a/sentry/src/test/java/io/sentry/protocol/ReplayRecordingSerializationTest.kt b/sentry/src/test/java/io/sentry/protocol/ReplayRecordingSerializationTest.kt new file mode 100644 index 0000000000..cff08ee2ab --- /dev/null +++ b/sentry/src/test/java/io/sentry/protocol/ReplayRecordingSerializationTest.kt @@ -0,0 +1,53 @@ +package io.sentry.protocol + +import io.sentry.FileFromResources +import io.sentry.ILogger +import io.sentry.ReplayRecording +import io.sentry.protocol.SerializationUtils.deserializeJson +import io.sentry.protocol.SerializationUtils.serializeToString +import io.sentry.rrweb.RRWebBreadcrumbEventSerializationTest +import io.sentry.rrweb.RRWebInteractionEventSerializationTest +import io.sentry.rrweb.RRWebInteractionMoveEventSerializationTest +import io.sentry.rrweb.RRWebMetaEventSerializationTest +import io.sentry.rrweb.RRWebSpanEventSerializationTest +import io.sentry.rrweb.RRWebVideoEventSerializationTest +import org.junit.Test +import org.mockito.kotlin.mock +import kotlin.test.assertEquals + +class ReplayRecordingSerializationTest { + class Fixture { + val logger = mock() + + fun getSut() = ReplayRecording().apply { + segmentId = 0 + payload = listOf( + RRWebMetaEventSerializationTest.Fixture().getSut(), + RRWebVideoEventSerializationTest.Fixture().getSut(), + RRWebBreadcrumbEventSerializationTest.Fixture().getSut(), + RRWebSpanEventSerializationTest.Fixture().getSut(), + RRWebInteractionEventSerializationTest.Fixture().getSut(), + RRWebInteractionMoveEventSerializationTest.Fixture().getSut() + ) + } + } + + private val fixture = Fixture() + + @Test + fun serialize() { + val expected = FileFromResources.invoke("json/replay_recording.json") + .substringBeforeLast("\n") + val actual = serializeToString(fixture.getSut(), fixture.logger) + assertEquals(expected, actual) + } + + @Test + fun deserialize() { + val expectedJson = FileFromResources.invoke("json/replay_recording.json") + .substringBeforeLast("\n") + val actual = deserializeJson(expectedJson, ReplayRecording.Deserializer(), fixture.logger) + val actualJson = serializeToString(actual, fixture.logger) + assertEquals(expectedJson, actualJson) + } +} diff --git a/sentry/src/test/java/io/sentry/protocol/SentryBaseEventSerializationTest.kt b/sentry/src/test/java/io/sentry/protocol/SentryBaseEventSerializationTest.kt index 4bc13559da..3da517ef56 100644 --- a/sentry/src/test/java/io/sentry/protocol/SentryBaseEventSerializationTest.kt +++ b/sentry/src/test/java/io/sentry/protocol/SentryBaseEventSerializationTest.kt @@ -2,8 +2,8 @@ package io.sentry.protocol import io.sentry.ILogger import io.sentry.JsonDeserializer -import io.sentry.JsonObjectReader import io.sentry.JsonSerializable +import io.sentry.ObjectReader import io.sentry.ObjectWriter import io.sentry.SentryBaseEvent import io.sentry.SentryIntegrationPackageStorage @@ -27,7 +27,7 @@ class SentryBaseEventSerializationTest { } class Deserializer : JsonDeserializer { - override fun deserialize(reader: JsonObjectReader, logger: ILogger): Sut { + override fun deserialize(reader: ObjectReader, logger: ILogger): Sut { val sut = Sut() reader.beginObject() diff --git a/sentry/src/test/java/io/sentry/protocol/SentryReplayEventSerializationTest.kt b/sentry/src/test/java/io/sentry/protocol/SentryReplayEventSerializationTest.kt new file mode 100644 index 0000000000..6ecd680076 --- /dev/null +++ b/sentry/src/test/java/io/sentry/protocol/SentryReplayEventSerializationTest.kt @@ -0,0 +1,62 @@ +package io.sentry.protocol + +import io.sentry.DateUtils +import io.sentry.ILogger +import io.sentry.SentryIntegrationPackageStorage +import io.sentry.SentryReplayEvent +import io.sentry.protocol.SerializationUtils.deserializeJson +import io.sentry.protocol.SerializationUtils.sanitizedFile +import io.sentry.protocol.SerializationUtils.serializeToString +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.mockito.kotlin.mock +import kotlin.test.assertEquals + +class SentryReplayEventSerializationTest { + + class Fixture { + val logger = mock() + + fun getSut() = SentryReplayEvent().apply { + replayId = SentryId("f715e1d64ef64ea3ad7744b5230813c3") + segmentId = 0 + timestamp = DateUtils.getDateTime("1942-07-09T12:55:34.000Z") + replayStartTimestamp = DateUtils.getDateTime("1942-07-09T12:55:34.000Z") + urls = listOf("ScreenOne") + errorIds = listOf("ab3a347a4cc14fd4b4cf1dc56b670c5b") + traceIds = listOf("340cfef948204549ac07c3b353c81c50") + SentryBaseEventSerializationTest.Fixture().update(this) + // irrelevant for replay + serverName = null + breadcrumbs = null + extras = null + } + } + private val fixture = Fixture() + + @Before + fun setup() { + SentryIntegrationPackageStorage.getInstance().clearStorage() + } + + @After + fun teardown() { + SentryIntegrationPackageStorage.getInstance().clearStorage() + } + + @Test + fun serialize() { + val expected = sanitizedFile("json/sentry_replay_event.json") + val actual = serializeToString(fixture.getSut(), fixture.logger) + assertEquals(expected, actual) + } + + @Test + fun deserialize() { + val expectedJson = sanitizedFile("json/sentry_replay_event.json") + val actual = deserializeJson(expectedJson, SentryReplayEvent.Deserializer(), fixture.logger) + val actualJson = serializeToString(actual, fixture.logger) + assertEquals(expectedJson, actualJson) + } +} diff --git a/sentry/src/test/java/io/sentry/rrweb/RRWebBreadcrumbEventSerializationTest.kt b/sentry/src/test/java/io/sentry/rrweb/RRWebBreadcrumbEventSerializationTest.kt new file mode 100644 index 0000000000..9dfffef8d2 --- /dev/null +++ b/sentry/src/test/java/io/sentry/rrweb/RRWebBreadcrumbEventSerializationTest.kt @@ -0,0 +1,45 @@ +package io.sentry.rrweb + +import io.sentry.ILogger +import io.sentry.SentryLevel.INFO +import io.sentry.protocol.SerializationUtils +import org.junit.Test +import org.mockito.kotlin.mock +import kotlin.test.assertEquals + +class RRWebBreadcrumbEventSerializationTest { + class Fixture { + val logger = mock() + + fun getSut() = RRWebBreadcrumbEvent().apply { + timestamp = 12345678901 + breadcrumbType = "default" + breadcrumbTimestamp = 12345678.901 + category = "navigation" + message = "message" + level = INFO + data = mapOf( + "screen" to "MainActivity", + "state" to "resumed" + ) + } + } + + private val fixture = Fixture() + + @Test + fun serialize() { + val expected = SerializationUtils.sanitizedFile("json/rrweb_breadcrumb_event.json") + val actual = SerializationUtils.serializeToString(fixture.getSut(), fixture.logger) + assertEquals(expected, actual) + } + + @Test + fun deserialize() { + val expectedJson = SerializationUtils.sanitizedFile("json/rrweb_breadcrumb_event.json") + val actual = + SerializationUtils.deserializeJson(expectedJson, RRWebBreadcrumbEvent.Deserializer(), fixture.logger) + val actualJson = SerializationUtils.serializeToString(actual, fixture.logger) + assertEquals(expectedJson, actualJson) + } +} diff --git a/sentry/src/test/java/io/sentry/rrweb/RRWebEventSerializationTest.kt b/sentry/src/test/java/io/sentry/rrweb/RRWebEventSerializationTest.kt new file mode 100644 index 0000000000..2c2b60cd28 --- /dev/null +++ b/sentry/src/test/java/io/sentry/rrweb/RRWebEventSerializationTest.kt @@ -0,0 +1,78 @@ +package io.sentry.rrweb + +import io.sentry.ILogger +import io.sentry.JsonDeserializer +import io.sentry.JsonSerializable +import io.sentry.ObjectReader +import io.sentry.ObjectWriter +import io.sentry.protocol.SerializationUtils.deserializeJson +import io.sentry.protocol.SerializationUtils.sanitizedFile +import io.sentry.protocol.SerializationUtils.serializeToString +import io.sentry.rrweb.RRWebEventType.Custom +import io.sentry.vendor.gson.stream.JsonToken +import org.junit.Test +import org.mockito.kotlin.mock +import kotlin.test.assertEquals + +class RRWebEventSerializationTest { + + /** + * Make subclass, as `RRWebEvent` initializers are protected. + */ + class Sut : RRWebEvent(), JsonSerializable { + override fun serialize(writer: ObjectWriter, logger: ILogger) { + writer.beginObject() + Serializer().serialize(this, writer, logger) + writer.endObject() + } + + class Deserializer : JsonDeserializer { + override fun deserialize(reader: ObjectReader, logger: ILogger): Sut { + val sut = Sut() + reader.beginObject() + + val baseEventDeserializer = RRWebEvent.Deserializer() + do { + val nextName = reader.nextName() + baseEventDeserializer.deserializeValue(sut, nextName, reader, logger) + } while (reader.hasNext() && reader.peek() == JsonToken.NAME) + reader.endObject() + return sut + } + } + } + + class Fixture { + val logger = mock() + + fun update(rrWebEvent: RRWebEvent) { + rrWebEvent.apply { + type = Custom + timestamp = 9999999 + } + } + } + private val fixture = Fixture() + + @Test + fun serialize() { + val expected = sanitizedFile("json/rrweb_event.json") + val sut = Sut().apply { fixture.update(this) } + val actual = serializeToString(sut, fixture.logger) + + assertEquals(expected, actual) + } + + @Test + fun deserialize() { + val expectedJson = sanitizedFile("json/rrweb_event.json") + val actual = deserializeJson( + expectedJson, + Sut.Deserializer(), + fixture.logger + ) + val actualJson = serializeToString(actual, fixture.logger) + + assertEquals(expectedJson, actualJson) + } +} diff --git a/sentry/src/test/java/io/sentry/rrweb/RRWebInteractionEventSerializationTest.kt b/sentry/src/test/java/io/sentry/rrweb/RRWebInteractionEventSerializationTest.kt new file mode 100644 index 0000000000..21ec522d51 --- /dev/null +++ b/sentry/src/test/java/io/sentry/rrweb/RRWebInteractionEventSerializationTest.kt @@ -0,0 +1,41 @@ +package io.sentry.rrweb + +import io.sentry.ILogger +import io.sentry.protocol.SerializationUtils +import io.sentry.rrweb.RRWebInteractionEvent.InteractionType.TouchStart +import org.junit.Test +import org.mockito.kotlin.mock +import kotlin.test.assertEquals + +class RRWebInteractionEventSerializationTest { + class Fixture { + val logger = mock() + + fun getSut() = RRWebInteractionEvent().apply { + timestamp = 12345678901 + id = 1 + x = 1.0f + y = 2.0f + interactionType = TouchStart + pointerId = 1 + } + } + + private val fixture = Fixture() + + @Test + fun serialize() { + val expected = SerializationUtils.sanitizedFile("json/rrweb_interaction_event.json") + val actual = SerializationUtils.serializeToString(fixture.getSut(), fixture.logger) + assertEquals(expected, actual) + } + + @Test + fun deserialize() { + val expectedJson = SerializationUtils.sanitizedFile("json/rrweb_interaction_event.json") + val actual = + SerializationUtils.deserializeJson(expectedJson, RRWebInteractionEvent.Deserializer(), fixture.logger) + val actualJson = SerializationUtils.serializeToString(actual, fixture.logger) + assertEquals(expectedJson, actualJson) + } +} diff --git a/sentry/src/test/java/io/sentry/rrweb/RRWebInteractionMoveEventSerializationTest.kt b/sentry/src/test/java/io/sentry/rrweb/RRWebInteractionMoveEventSerializationTest.kt new file mode 100644 index 0000000000..b114a4e092 --- /dev/null +++ b/sentry/src/test/java/io/sentry/rrweb/RRWebInteractionMoveEventSerializationTest.kt @@ -0,0 +1,45 @@ +package io.sentry.rrweb + +import io.sentry.ILogger +import io.sentry.protocol.SerializationUtils +import io.sentry.rrweb.RRWebInteractionMoveEvent.Position +import org.junit.Test +import org.mockito.kotlin.mock +import kotlin.test.assertEquals + +class RRWebInteractionMoveEventSerializationTest { + class Fixture { + val logger = mock() + + fun getSut() = RRWebInteractionMoveEvent().apply { + timestamp = 12345678901 + positions = listOf( + Position().apply { + id = 1 + x = 1.0f + y = 2.0f + timeOffset = 100 + } + ) + pointerId = 1 + } + } + + private val fixture = Fixture() + + @Test + fun serialize() { + val expected = SerializationUtils.sanitizedFile("json/rrweb_interaction_move_event.json") + val actual = SerializationUtils.serializeToString(fixture.getSut(), fixture.logger) + assertEquals(expected, actual) + } + + @Test + fun deserialize() { + val expectedJson = SerializationUtils.sanitizedFile("json/rrweb_interaction_move_event.json") + val actual = + SerializationUtils.deserializeJson(expectedJson, RRWebInteractionMoveEvent.Deserializer(), fixture.logger) + val actualJson = SerializationUtils.serializeToString(actual, fixture.logger) + assertEquals(expectedJson, actualJson) + } +} diff --git a/sentry/src/test/java/io/sentry/rrweb/RRWebMetaEventSerializationTest.kt b/sentry/src/test/java/io/sentry/rrweb/RRWebMetaEventSerializationTest.kt new file mode 100644 index 0000000000..29ec354333 --- /dev/null +++ b/sentry/src/test/java/io/sentry/rrweb/RRWebMetaEventSerializationTest.kt @@ -0,0 +1,42 @@ +package io.sentry.rrweb + +import io.sentry.ILogger +import io.sentry.protocol.SerializationUtils.deserializeJson +import io.sentry.protocol.SerializationUtils.sanitizedFile +import io.sentry.protocol.SerializationUtils.serializeToString +import io.sentry.rrweb.RRWebEventType.Meta +import org.junit.Test +import org.mockito.kotlin.mock +import kotlin.test.assertEquals + +class RRWebMetaEventSerializationTest { + + class Fixture { + val logger = mock() + + fun getSut() = RRWebMetaEvent().apply { + href = "https://sentry.io" + height = 1920 + width = 1080 + type = Meta + timestamp = 1234567890 + } + } + + private val fixture = Fixture() + + @Test + fun serialize() { + val expected = sanitizedFile("json/rrweb_meta_event.json") + val actual = serializeToString(fixture.getSut(), fixture.logger) + assertEquals(expected, actual) + } + + @Test + fun deserialize() { + val expectedJson = sanitizedFile("json/rrweb_meta_event.json") + val actual = deserializeJson(expectedJson, RRWebMetaEvent.Deserializer(), fixture.logger) + val actualJson = serializeToString(actual, fixture.logger) + assertEquals(expectedJson, actualJson) + } +} diff --git a/sentry/src/test/java/io/sentry/rrweb/RRWebSpanEventSerializationTest.kt b/sentry/src/test/java/io/sentry/rrweb/RRWebSpanEventSerializationTest.kt new file mode 100644 index 0000000000..034a1ded99 --- /dev/null +++ b/sentry/src/test/java/io/sentry/rrweb/RRWebSpanEventSerializationTest.kt @@ -0,0 +1,43 @@ +package io.sentry.rrweb + +import io.sentry.ILogger +import io.sentry.protocol.SerializationUtils +import org.junit.Test +import org.mockito.kotlin.mock +import kotlin.test.assertEquals + +class RRWebSpanEventSerializationTest { + class Fixture { + val logger = mock() + + fun getSut() = RRWebSpanEvent().apply { + timestamp = 12345678901 + op = "resource.http" + description = "https://api.github.com/users/getsentry/repos" + startTimestamp = 12345678.901 + endTimestamp = 12345679.901 + data = mapOf( + "method" to "POST", + "status_code" to 200 + ) + } + } + + private val fixture = Fixture() + + @Test + fun serialize() { + val expected = SerializationUtils.sanitizedFile("json/rrweb_span_event.json") + val actual = SerializationUtils.serializeToString(fixture.getSut(), fixture.logger) + assertEquals(expected, actual) + } + + @Test + fun deserialize() { + val expectedJson = SerializationUtils.sanitizedFile("json/rrweb_span_event.json") + val actual = + SerializationUtils.deserializeJson(expectedJson, RRWebSpanEvent.Deserializer(), fixture.logger) + val actualJson = SerializationUtils.serializeToString(actual, fixture.logger) + assertEquals(expectedJson, actualJson) + } +} diff --git a/sentry/src/test/java/io/sentry/rrweb/RRWebVideoEventSerializationTest.kt b/sentry/src/test/java/io/sentry/rrweb/RRWebVideoEventSerializationTest.kt new file mode 100644 index 0000000000..17a790b5cd --- /dev/null +++ b/sentry/src/test/java/io/sentry/rrweb/RRWebVideoEventSerializationTest.kt @@ -0,0 +1,47 @@ +package io.sentry.rrweb + +import io.sentry.ILogger +import io.sentry.protocol.SerializationUtils +import io.sentry.rrweb.RRWebEventType.Custom +import org.junit.Test +import org.mockito.kotlin.mock +import kotlin.test.assertEquals + +class RRWebVideoEventSerializationTest { + class Fixture { + val logger = mock() + + fun getSut() = RRWebVideoEvent().apply { + type = Custom + timestamp = 12345678901 + tag = "video" + segmentId = 0 + size = 4_000_000L + durationMs = 5000 + height = 1920 + width = 1080 + frameCount = 5 + frameRate = 1 + left = 100 + top = 100 + } + } + + private val fixture = Fixture() + + @Test + fun serialize() { + val expected = SerializationUtils.sanitizedFile("json/rrweb_video_event.json") + val actual = SerializationUtils.serializeToString(fixture.getSut(), fixture.logger) + assertEquals(expected, actual) + } + + @Test + fun deserialize() { + val expectedJson = SerializationUtils.sanitizedFile("json/rrweb_video_event.json") + val actual = + SerializationUtils.deserializeJson(expectedJson, RRWebVideoEvent.Deserializer(), fixture.logger) + val actualJson = SerializationUtils.serializeToString(actual, fixture.logger) + assertEquals(expectedJson, actualJson) + } +} diff --git a/sentry/src/test/java/io/sentry/util/MapObjectReaderTest.kt b/sentry/src/test/java/io/sentry/util/MapObjectReaderTest.kt new file mode 100644 index 0000000000..a335fc71f8 --- /dev/null +++ b/sentry/src/test/java/io/sentry/util/MapObjectReaderTest.kt @@ -0,0 +1,151 @@ +package io.sentry.util + +import io.sentry.ILogger +import io.sentry.JsonDeserializer +import io.sentry.JsonSerializable +import io.sentry.NoOpLogger +import io.sentry.ObjectReader +import io.sentry.ObjectWriter +import io.sentry.vendor.gson.stream.JsonToken +import java.math.BigDecimal +import java.net.URI +import java.util.Currency +import java.util.Date +import java.util.Locale +import java.util.TimeZone +import java.util.UUID +import kotlin.test.Test +import kotlin.test.assertEquals + +class MapObjectReaderTest { + + enum class BasicEnum { + A + } + + data class BasicSerializable(var test: String = "string") : JsonSerializable { + + override fun serialize(writer: ObjectWriter, logger: ILogger) { + writer.beginObject() + .name("test") + .value(test) + .endObject() + } + + class Deserializer : JsonDeserializer { + override fun deserialize(reader: ObjectReader, logger: ILogger): BasicSerializable { + val basicSerializable = BasicSerializable() + reader.beginObject() + if (reader.nextName() == "test") { + basicSerializable.test = reader.nextString() + } + reader.endObject() + return basicSerializable + } + } + } + + @Test + fun `deserializes data correctly`() { + val logger = NoOpLogger.getInstance() + val data = mutableMapOf() + val writer = MapObjectWriter(data) + + writer.name("null").nullValue() + writer.name("int").value(1) + writer.name("boolean").value(true) + writer.name("long").value(Long.MAX_VALUE) + writer.name("double").value(Double.MAX_VALUE) + writer.name("number").value(BigDecimal(123)) + writer.name("date").value(logger, Date(0)) + writer.name("string").value("string") + + writer.name("TimeZone").value(logger, TimeZone.getTimeZone("Vienna")) + writer.name("JsonSerializable").value( + logger, + BasicSerializable() + ) + writer.name("Collection").value(logger, listOf("a", "b")) + writer.name("Arrays").value(logger, arrayOf("b", "c")) + writer.name("Map").value(logger, mapOf(kotlin.Pair("key", "value"))) + writer.name("MapOfLists").value(logger, mapOf("metric_a" to listOf("foo"))) + writer.name("Locale").value(logger, Locale.US) + writer.name("URI").value(logger, URI.create("http://www.example.com")) + writer.name("UUID").value(logger, UUID.fromString("00000000-1111-2222-3333-444444444444")) + writer.name("Currency").value(logger, Currency.getInstance("EUR")) + writer.name("Enum").value(logger, MapObjectWriterTest.BasicEnum.A) + writer.name("data").value(logger, mapOf("screen" to "MainActivity")) + writer.name("ListOfObjects").value(logger, listOf(BasicSerializable())) + writer.name("MapOfObjects").value(logger, mapOf("key" to BasicSerializable())) + writer.name("MapOfListsObjects").value(logger, mapOf("key" to listOf(BasicSerializable()))) + + val reader = MapObjectReader(data) + reader.beginObject() + assertEquals(JsonToken.NAME, reader.peek()) + assertEquals("MapOfListsObjects", reader.nextName()) + assertEquals(mapOf("key" to listOf(BasicSerializable())), reader.nextMapOfListOrNull(logger, BasicSerializable.Deserializer())) + assertEquals("MapOfObjects", reader.nextName()) + assertEquals(mapOf("key" to BasicSerializable()), reader.nextMapOrNull(logger, BasicSerializable.Deserializer())) + assertEquals("ListOfObjects", reader.nextName()) + assertEquals(listOf(BasicSerializable()), reader.nextListOrNull(logger, BasicSerializable.Deserializer())) + assertEquals("data", reader.nextName()) + assertEquals(mapOf("screen" to "MainActivity"), reader.nextObjectOrNull()) + assertEquals("Enum", reader.nextName()) + assertEquals(BasicEnum.A, BasicEnum.valueOf(reader.nextString())) + assertEquals("Currency", reader.nextName()) + assertEquals(Currency.getInstance("EUR"), Currency.getInstance(reader.nextString())) + assertEquals("UUID", reader.nextName()) + assertEquals( + UUID.fromString("00000000-1111-2222-3333-444444444444"), + UUID.fromString(reader.nextString()) + ) + assertEquals("URI", reader.nextName()) + assertEquals(URI.create("http://www.example.com"), URI.create(reader.nextString())) + assertEquals("Locale", reader.nextName()) + assertEquals(Locale.US.toString(), reader.nextString()) + assertEquals("MapOfLists", reader.nextName()) + reader.beginObject() + assertEquals("metric_a", reader.nextName()) + reader.beginArray() + assertEquals("foo", reader.nextStringOrNull()) + reader.endArray() + reader.endObject() + assertEquals("Map", reader.nextName()) + // nested object + reader.beginObject() + assertEquals("key", reader.nextName()) + assertEquals("value", reader.nextStringOrNull()) + reader.endObject() + assertEquals("Arrays", reader.nextName()) + reader.beginArray() + assertEquals("b", reader.nextString()) + assertEquals("c", reader.nextString()) + reader.endArray() + assertEquals("Collection", reader.nextName()) + reader.beginArray() + assertEquals("a", reader.nextString()) + assertEquals("b", reader.nextString()) + reader.endArray() + assertEquals("JsonSerializable", reader.nextName()) + assertEquals(BasicSerializable(), reader.nextOrNull(logger, BasicSerializable.Deserializer())) + assertEquals("TimeZone", reader.nextName()) + assertEquals(TimeZone.getTimeZone("Vienna"), reader.nextTimeZoneOrNull(logger)) + assertEquals("string", reader.nextName()) + assertEquals("string", reader.nextString()) + assertEquals("date", reader.nextName()) + assertEquals(Date(0), reader.nextDateOrNull(logger)) + assertEquals("number", reader.nextName()) + assertEquals(BigDecimal(123), reader.nextObjectOrNull()) + assertEquals("double", reader.nextName()) + assertEquals(Double.MAX_VALUE, reader.nextDoubleOrNull()) + assertEquals("long", reader.nextName()) + assertEquals(Long.MAX_VALUE, reader.nextLongOrNull()) + assertEquals("boolean", reader.nextName()) + assertEquals(true, reader.nextBoolean()) + assertEquals("int", reader.nextName()) + assertEquals(1, reader.nextInt()) + assertEquals("null", reader.nextName()) + reader.nextNull() + reader.endObject() + } +} diff --git a/sentry/src/test/resources/json/replay_recording.json b/sentry/src/test/resources/json/replay_recording.json new file mode 100644 index 0000000000..021c78b020 --- /dev/null +++ b/sentry/src/test/resources/json/replay_recording.json @@ -0,0 +1,2 @@ +{"segment_id":0} +[{"type":4,"timestamp":1234567890,"data":{"href":"https://sentry.io","height":1920,"width":1080}},{"type":5,"timestamp":12345678901,"data":{"tag":"video","payload":{"segmentId":0,"size":4000000,"duration":5000,"encoding":"h264","container":"mp4","height":1920,"width":1080,"frameCount":5,"frameRate":1,"frameRateType":"constant","left":100,"top":100}}},{"type":5,"timestamp":12345678901,"data":{"tag":"breadcrumb","payload":{"type":"default","timestamp":12345678.901,"category":"navigation","message":"message","level":"info","data":{"screen":"MainActivity","state":"resumed"}}}},{"type":5,"timestamp":12345678901,"data":{"tag":"performanceSpan","payload":{"op":"resource.http","description":"https://api.github.com/users/getsentry/repos","startTimestamp":12345678.901,"endTimestamp":12345679.901,"data":{"status_code":200,"method":"POST"}}}},{"type":3,"timestamp":12345678901,"data":{"source":2,"type":7,"id":1,"x":1.0,"y":2.0,"pointerType":2,"pointerId":1}},{"type":3,"timestamp":12345678901,"data":{"source":6,"positions":[{"id":1,"x":1.0,"y":2.0,"timeOffset":100}],"pointerId":1}}] diff --git a/sentry/src/test/resources/json/rrweb_breadcrumb_event.json b/sentry/src/test/resources/json/rrweb_breadcrumb_event.json new file mode 100644 index 0000000000..e1fbe676fa --- /dev/null +++ b/sentry/src/test/resources/json/rrweb_breadcrumb_event.json @@ -0,0 +1,18 @@ +{ + "type": 5, + "timestamp": 12345678901, + "data": { + "tag": "breadcrumb", + "payload": { + "type": "default", + "timestamp": 12345678.901, + "category": "navigation", + "message": "message", + "level": "info", + "data": { + "screen": "MainActivity", + "state": "resumed" + } + } + } +} diff --git a/sentry/src/test/resources/json/rrweb_event.json b/sentry/src/test/resources/json/rrweb_event.json new file mode 100644 index 0000000000..d5610238e9 --- /dev/null +++ b/sentry/src/test/resources/json/rrweb_event.json @@ -0,0 +1,4 @@ +{ + "type": 5, + "timestamp": 9999999 +} diff --git a/sentry/src/test/resources/json/rrweb_interaction_event.json b/sentry/src/test/resources/json/rrweb_interaction_event.json new file mode 100644 index 0000000000..1af66d4afd --- /dev/null +++ b/sentry/src/test/resources/json/rrweb_interaction_event.json @@ -0,0 +1,13 @@ +{ + "type": 3, + "timestamp": 12345678901, + "data": { + "source": 2, + "type": 7, + "id": 1, + "x": 1.0, + "y": 2.0, + "pointerType": 2, + "pointerId": 1 + } +} diff --git a/sentry/src/test/resources/json/rrweb_interaction_move_event.json b/sentry/src/test/resources/json/rrweb_interaction_move_event.json new file mode 100644 index 0000000000..0a815067ce --- /dev/null +++ b/sentry/src/test/resources/json/rrweb_interaction_move_event.json @@ -0,0 +1,16 @@ +{ + "type": 3, + "timestamp": 12345678901, + "data": { + "source": 6, + "positions": [ + { + "id": 1, + "x": 1.0, + "y": 2.0, + "timeOffset": 100 + } + ], + "pointerId": 1 + } +} diff --git a/sentry/src/test/resources/json/rrweb_meta_event.json b/sentry/src/test/resources/json/rrweb_meta_event.json new file mode 100644 index 0000000000..5eb561a78d --- /dev/null +++ b/sentry/src/test/resources/json/rrweb_meta_event.json @@ -0,0 +1,9 @@ +{ + "type": 4, + "timestamp": 1234567890, + "data": { + "href": "https://sentry.io", + "height": 1920, + "width": 1080 + } +} diff --git a/sentry/src/test/resources/json/rrweb_span_event.json b/sentry/src/test/resources/json/rrweb_span_event.json new file mode 100644 index 0000000000..6ec906a3e3 --- /dev/null +++ b/sentry/src/test/resources/json/rrweb_span_event.json @@ -0,0 +1,17 @@ +{ + "type": 5, + "timestamp": 12345678901, + "data": { + "tag": "performanceSpan", + "payload": { + "op": "resource.http", + "description": "https://api.github.com/users/getsentry/repos", + "startTimestamp": 12345678.901, + "endTimestamp": 12345679.901, + "data": { + "status_code": 200, + "method": "POST" + } + } + } +} diff --git a/sentry/src/test/resources/json/rrweb_video_event.json b/sentry/src/test/resources/json/rrweb_video_event.json new file mode 100644 index 0000000000..692dafe879 --- /dev/null +++ b/sentry/src/test/resources/json/rrweb_video_event.json @@ -0,0 +1,21 @@ +{ + "type": 5, + "timestamp": 12345678901, + "data": { + "tag": "video", + "payload": { + "segmentId": 0, + "size": 4000000, + "duration": 5000, + "encoding":"h264", + "container":"mp4", + "height": 1920, + "width": 1080, + "frameCount": 5, + "frameRate": 1, + "frameRateType": "constant", + "left": 100, + "top": 100 + } + } +} diff --git a/sentry/src/test/resources/json/sentry_envelope_header.json b/sentry/src/test/resources/json/sentry_envelope_header.json index 14c144f820..5f6b3b25e7 100644 --- a/sentry/src/test/resources/json/sentry_envelope_header.json +++ b/sentry/src/test/resources/json/sentry_envelope_header.json @@ -27,7 +27,8 @@ "user_segment": "f7d8662b-5551-4ef8-b6a8-090f0561a530", "transaction": "0252ec25-cd0a-4230-bd2f-936a4585637e", "sample_rate": "0.00000021", - "sampled": "true" + "sampled": "true", + "replay_id": "3367f5196c494acaae85bbbd535379aa" }, "sent_at": "2020-02-07T14:16:00.000Z" } diff --git a/sentry/src/test/resources/json/sentry_replay_event.json b/sentry/src/test/resources/json/sentry_replay_event.json new file mode 100644 index 0000000000..f026c9fee4 --- /dev/null +++ b/sentry/src/test/resources/json/sentry_replay_event.json @@ -0,0 +1,240 @@ +{ + "type": "replay_event", + "replay_type": "session", + "segment_id": 0, + "timestamp": "1942-07-09T12:55:34.000Z", + "replay_id": "f715e1d64ef64ea3ad7744b5230813c3", + "replay_start_timestamp": "1942-07-09T12:55:34.000Z", + "urls": + [ + "ScreenOne" + ], + "error_ids": + [ + "ab3a347a4cc14fd4b4cf1dc56b670c5b" + ], + "trace_ids": + [ + "340cfef948204549ac07c3b353c81c50" + ], + "event_id": "afcb46b1140ade5187c4bbb5daa804df", + "contexts": + { + "app": + { + "app_identifier": "3b7a3313-53b4-43f4-a6a1-7a7c36a9b0db", + "app_start_time": "1918-11-17T07:46:04.000Z", + "device_app_hash": "3d1fcf36-2c25-4378-bdf8-1e65239f1df4", + "build_type": "d78c56cd-eb0f-4213-8899-cd10ddf20763", + "app_name": "873656fd-f620-4edf-bb7a-a0d13325dba0", + "app_version": "801aab22-ad4b-44fb-995c-bacb5387e20c", + "app_build": "660f0cde-eedb-49dc-a973-8aa1c04f4a28", + "permissions": + { + "WRITE_EXTERNAL_STORAGE": "not_granted", + "CAMERA": "granted" + }, + "in_foreground": true, + "view_names": ["MainActivity", "SidebarActivity"], + "start_type": "cold" + }, + "browser": + { + "name": "e1c723db-7408-4043-baa7-f4e96234e5dc", + "version": "724a48e9-2d35-416b-9f79-132beba2473a" + }, + "device": + { + "name": "83f1de77-fdb0-470e-8249-8f5c5d894ec4", + "manufacturer": "e21b2405-e378-4a0b-ad2c-4822d97cd38c", + "brand": "1abbd13e-d1ca-4d81-bd1b-24aa2c339cf9", + "family": "67a4b8ea-6c38-4c33-8579-7697f538685c", + "model": "d6ca2f35-bcc5-4dd3-ad64-7c3b585e02fd", + "model_id": "d3f133bd-b0a2-4aa4-9eed-875eba93652e", + "archs": + [ + "856e5da3-774c-4663-a830-d19f0b7dbb5b", + "b345bd5a-90a5-4301-a5a2-6c102d7589b6", + "fd7ed64e-a591-49e0-8dc1-578234356d23", + "8cec4101-0305-480b-91ee-f3c007f668c3", + "22583b9b-195e-49bf-bfe8-825ae3a346f2", + "8675b7aa-5b94-42d0-bc14-72ea1bb7112e" + ], + "battery_level": 0.45770407, + "charging": false, + "online": true, + "orientation": "portrait", + "simulator": true, + "memory_size": -6712323365568152393, + "free_memory": -953384122080236886, + "usable_memory": -8999512249221323968, + "low_memory": false, + "storage_size": -3227905175393990709, + "free_storage": -3749039933924297357, + "external_storage_size": -7739608324159255302, + "external_free_storage": -1562576688560812557, + "screen_width_pixels": 1101873181, + "screen_height_pixels": 1902392170, + "screen_density": 0.9829039, + "screen_dpi": -2092079070, + "boot_time": "2004-11-04T08:38:00.000Z", + "timezone": "Europe/Vienna", + "id": "e0fa5c8d-83f5-4e70-bc60-1e82ad30e196", + "language": "6dd45f60-111d-42d8-9204-0452cc836ad8", + "connection_type": "9ceb3a6c-5292-4ed9-8665-5732495e8ed4", + "battery_temperature": 0.14775127, + "processor_count": 4, + "processor_frequency": 800.0, + "cpu_description": "cpu0" + }, + "gpu": + { + "name": "d623a6b5-e1ab-4402-931b-c06f5a43a5ae", + "id": -596576280, + "vendor_id": "1874778041", + "vendor_name": "d732cf76-07dc-48e2-8920-96d6bfc2439d", + "memory_size": -1484004451, + "api_type": "95dfc8bc-88ae-4d66-b85f-6c88ad45b80f", + "multi_threaded_rendering": true, + "version": "3f3f73c3-83a2-423a-8a6f-bb3de0d4a6ae", + "npot_support": "e06b074a-463c-45de-a959-cbabd461d99d" + }, + "os": + { + "name": "686a11a8-eae7-4393-aa10-a1368d523cb2", + "version": "3033f32d-6a27-4715-80c8-b232ce84ca61", + "raw_description": "eb2d0c5e-f5d4-49c7-b876-d8a654ee87cf", + "build": "bd197b97-eb68-49c3-9d07-ef789caf3069", + "kernel_version": "1df24aec-3a6f-49a9-8b50-69ae5f9dde08", + "rooted": true + }, + "response": + { + "cookies": "PHPSESSID=298zf09hf012fh2; csrftoken=u32t4o3tb3gg43; _gat=1;", + "headers": { + "content-type": "text/html" + }, + "status_code": 500, + "body_size": 1000, + "data": + { + "d9d709db-b666-40cc-bcbb-093bb12aad26": "1631d0e6-96b7-4632-85f8-ef69e8bcfb16" + }, + "arbitrary_field": "arbitrary" + }, + "runtime": + { + "name": "4ed019c4-9af9-43e0-830e-bfde9fe4461c", + "version": "16534f6b-1670-4bb8-aec2-647a1b97669b", + "raw_description": "773b5b05-a0f9-4ee6-9f3b-13155c37ad6e" + }, + "trace": + { + "trace_id": "afcb46b1140ade5187c4bbb5daa804df", + "span_id": "bf6b582d-8ce3-412b-a334-f4c5539b9602", + "parent_span_id": "c7500f2a-d4e6-4f5f-a0f4-6bb67e98d5a2", + "op": "e481581d-35a4-4e97-8a1c-b554bf49f23e", + "description": "c204b6c7-9753-4d45-927d-b19789bfc9a5", + "status": "resource_exhausted", + "origin": "auto.test.unit.spancontext", + "tags": + { + "2a5fa3f5-7b87-487f-aaa5-84567aa73642": "4781d51a-c5af-47f2-a4ed-f030c9b3e194", + "29106d7d-7fa4-444f-9d34-b9d7510c69ab": "218c23ea-694a-497e-bf6d-e5f26f1ad7bd", + "ba9ce913-269f-4c03-882d-8ca5e6991b14": "35a74e90-8db8-4610-a411-872cbc1030ac" + } + } + }, + "sdk": + { + "name": "3e934135-3f2b-49bc-8756-9f025b55143e", + "version": "3e31738e-4106-42d0-8be2-4a3a1bc648d3", + "packages": + [ + { + "name": "b59a1949-9950-4203-b394-ddd8d02c9633", + "version": "3d7790f3-7f32-43f7-b82f-9f5bc85205a8" + } + ], + "integrations": + [ + "daec50ae-8729-49b5-82f7-991446745cd5", + "8fc94968-3499-4a2c-b4d7-ecc058d9c1b0" + ] + }, + "request": + { + "url": "67369bc9-64d3-4d31-bfba-37393b145682", + "method": "8185abc3-5411-4041-a0d9-374180081044", + "query_string": "e3dc7659-f42e-413c-a07c-52b24bf9d60d", + "data": + { + "d9d709db-b666-40cc-bcbb-093bb12aad26": "1631d0e6-96b7-4632-85f8-ef69e8bcfb16" + }, + "cookies": "d84f4cfc-5310-4818-ad4f-3f8d22ceaca8", + "headers": + { + "c4991f66-9af9-4914-ac5e-e4854a5a4822": "37714d22-25a7-469b-b762-289b456fbec3" + }, + "env": + { + "6d569c89-5d5e-40e0-a4fc-109b20a53778": "ccadf763-44e4-475c-830c-de6ba0dbd202" + }, + "other": + { + "669ff1c1-517b-46dc-a889-131555364a56": "89043294-f6e1-4e2e-b152-1fdf9b1102fc" + }, + "fragment": "fragment", + "body_size": 1000, + "api_target": "graphql" + }, + "tags": + { + "79ba41db-8dc6-4156-b53e-6cf6d742eb88": "690ce82f-4d5d-4d81-b467-461a41dd9419" + }, + "release": "be9b8133-72f5-497b-adeb-b0a245eebad6", + "environment": "89204175-e462-4628-8acb-3a7fa8d8da7d", + "platform": "38decc78-2711-4a6a-a0be-abb61bfa5a6e", + "user": + { + "email": "c4d61c1b-c144-431e-868f-37a46be5e5f2", + "id": "efb2084b-1871-4b59-8897-b4bd9f196a01", + "username": "60c05dff-7140-4d94-9a61-c9cdd9ca9b96", + "ip_address": "51d22b77-f663-4dbe-8103-8b749d1d9a48", + "name": "c8c60762-b1cf-11ed-afa1-0242ac120002", + "geo": { + "city": "0e6ed0b0-b1c5-11ed-afa1-0242ac120002", + "country_code": "JP", + "region": "273a3d0a-b1c5-11ed-afa1-0242ac120002" + }, + "data": + { + "dc2813d0-0f66-4a3f-a995-71268f61a8fa": "991659ad-7c59-4dd3-bb89-0bd5c74014bd" + } + }, + "dist": "27022a08-aace-40c6-8d0a-358a27fcaa7a", + "debug_meta": + { + "sdk_info": + { + "sdk_name": "182c4407-c1e1-4427-9b5a-ad2e22b1046a", + "version_major": 2045114005, + "version_minor": 1436566288, + "version_patchlevel": 1637914973 + }, + "images": + [ + { + "uuid": "8994027e-1cd9-4be8-b611-88ce08cf16e6", + "type": "fd6e053b-a7fe-4754-916e-bfb3ab77177d", + "debug_id": "8c653f5a-3418-4823-ba91-29a84c9c1235", + "debug_file": "55cc15dd-51f3-4cad-803c-6fd90eac21f6", + "code_id": "01230ece-f729-4af4-8b48-df74700aa4bf", + "code_file": "415c8995-1cb4-4bed-ba5c-5b3d6ba1ad47", + "image_addr": "8a258c81-641d-4e54-b06e-a0f56b1ee2ef", + "image_size": -7905338721846826571, + "arch": "d00d5bea-fb5c-43c9-85f0-dc1350d957a4" + } + ] + } +} diff --git a/sentry/src/test/resources/json/trace_state.json b/sentry/src/test/resources/json/trace_state.json index 17a95fdc33..6ca0e48e61 100644 --- a/sentry/src/test/resources/json/trace_state.json +++ b/sentry/src/test/resources/json/trace_state.json @@ -7,5 +7,6 @@ "user_segment": "f7d8662b-5551-4ef8-b6a8-090f0561a530", "transaction": "0252ec25-cd0a-4230-bd2f-936a4585637e", "sample_rate": "0.00000021", - "sampled": "true" + "sampled": "true", + "replay_id": "3367f5196c494acaae85bbbd535379aa" } diff --git a/settings.gradle.kts b/settings.gradle.kts index 028037372d..760c6e6905 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -21,6 +21,7 @@ include( "sentry-android-fragment", "sentry-android-navigation", "sentry-android-sqlite", + "sentry-android-replay", "sentry-compose", "sentry-compose-helper", "sentry-apollo", From 2937c11b8aad353cee57cc4742a25a727fa4b9a6 Mon Sep 17 00:00:00 2001 From: getsentry-bot Date: Mon, 15 Jul 2024 17:14:42 +0000 Subject: [PATCH 04/92] release: 7.12.0 --- CHANGELOG.md | 2 +- gradle.properties | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 45f45011bd..7dd0136c30 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## Unreleased +## 7.12.0 ### Features diff --git a/gradle.properties b/gradle.properties index 827ee0034e..83537f27e4 100644 --- a/gradle.properties +++ b/gradle.properties @@ -10,7 +10,7 @@ android.useAndroidX=true android.defaults.buildfeatures.buildconfig=true # Release information -versionName=7.12.0-alpha.4 +versionName=7.12.0 # Override the SDK name on native crashes on Android sentryAndroidSdkName=sentry.native.android From a449452c420469898e4dc2be8b6e0788a994205d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 16 Jul 2024 05:31:19 -0700 Subject: [PATCH 05/92] Bump github/codeql-action from 3.25.10 to 3.25.11 (#3529) Bumps [github/codeql-action](https://github.com/github/codeql-action) from 3.25.10 to 3.25.11. - [Release notes](https://github.com/github/codeql-action/releases) - [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/github/codeql-action/compare/23acc5c183826b7a8a97bce3cecc52db901f8251...b611370bb5703a7efb587f9d136a52ea24c5c38c) --- updated-dependencies: - dependency-name: github/codeql-action 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> --- .github/workflows/codeql-analysis.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 6636dc019d..320a7298b5 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -41,7 +41,7 @@ jobs: gradle-home-cache-cleanup: true - name: Initialize CodeQL - uses: github/codeql-action/init@23acc5c183826b7a8a97bce3cecc52db901f8251 # pin@v2 + uses: github/codeql-action/init@b611370bb5703a7efb587f9d136a52ea24c5c38c # pin@v2 with: languages: ${{ matrix.language }} @@ -55,4 +55,4 @@ jobs: ./gradlew buildForCodeQL - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@23acc5c183826b7a8a97bce3cecc52db901f8251 # pin@v2 + uses: github/codeql-action/analyze@b611370bb5703a7efb587f9d136a52ea24c5c38c # pin@v2 From 028e2255d839ceec52d8ae80e4c9f12dff5013a7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 16 Jul 2024 05:31:32 -0700 Subject: [PATCH 06/92] Bump JamesIves/github-pages-deploy-action from 4.5.0 to 4.6.1 (#3531) Bumps [JamesIves/github-pages-deploy-action](https://github.com/jamesives/github-pages-deploy-action) from 4.5.0 to 4.6.1. - [Release notes](https://github.com/jamesives/github-pages-deploy-action/releases) - [Commits](https://github.com/jamesives/github-pages-deploy-action/compare/65b5dfd4f5bcd3a7403bbc2959c144256167464e...5c6e9e9f3672ce8fd37b9856193d2a537941e66c) --- updated-dependencies: - dependency-name: JamesIves/github-pages-deploy-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> --- .github/workflows/generate-javadocs.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/generate-javadocs.yml b/.github/workflows/generate-javadocs.yml index 635a87609b..49db195c61 100644 --- a/.github/workflows/generate-javadocs.yml +++ b/.github/workflows/generate-javadocs.yml @@ -28,7 +28,7 @@ jobs: run: | ./gradlew aggregateJavadocs - name: Deploy - uses: JamesIves/github-pages-deploy-action@65b5dfd4f5bcd3a7403bbc2959c144256167464e # pin@4.5.0 + uses: JamesIves/github-pages-deploy-action@5c6e9e9f3672ce8fd37b9856193d2a537941e66c # pin@4.6.1 with: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} BRANCH: gh-pages From 83b0c04879ed1004f53874e279729b18290acb5e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 16 Jul 2024 05:31:49 -0700 Subject: [PATCH 07/92] Bump gradle/actions (#3532) Bumps [gradle/actions](https://github.com/gradle/actions) from 2cd2a6e951bd0b53f55a08e4e4c6f2586f3a36b9 to cdbbabd09cff07936e1c9dbe8c9d4b6ac2ef7156. - [Release notes](https://github.com/gradle/actions/releases) - [Commits](https://github.com/gradle/actions/compare/2cd2a6e951bd0b53f55a08e4e4c6f2586f3a36b9...cdbbabd09cff07936e1c9dbe8c9d4b6ac2ef7156) --- updated-dependencies: - dependency-name: gradle/actions dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/agp-matrix.yml | 2 +- .github/workflows/build.yml | 2 +- .github/workflows/codeql-analysis.yml | 2 +- .github/workflows/enforce-license-compliance.yml | 2 +- .github/workflows/generate-javadocs.yml | 2 +- .github/workflows/integration-tests-benchmarks.yml | 4 ++-- .github/workflows/integration-tests-ui.yml | 2 +- .github/workflows/release-build.yml | 2 +- .github/workflows/system-tests-backend.yml | 2 +- 9 files changed, 10 insertions(+), 10 deletions(-) diff --git a/.github/workflows/agp-matrix.yml b/.github/workflows/agp-matrix.yml index 80c85e71b8..ae1b7724c2 100644 --- a/.github/workflows/agp-matrix.yml +++ b/.github/workflows/agp-matrix.yml @@ -38,7 +38,7 @@ jobs: java-version: '17' - name: Setup Gradle - uses: gradle/actions/setup-gradle@2cd2a6e951bd0b53f55a08e4e4c6f2586f3a36b9 # pin@v3 + uses: gradle/actions/setup-gradle@cdbbabd09cff07936e1c9dbe8c9d4b6ac2ef7156 # pin@v3 with: gradle-home-cache-cleanup: true diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 8816d5fde6..c0e696ac75 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -27,7 +27,7 @@ jobs: java-version: '17' - name: Setup Gradle - uses: gradle/actions/setup-gradle@2cd2a6e951bd0b53f55a08e4e4c6f2586f3a36b9 # pin@v3 + uses: gradle/actions/setup-gradle@cdbbabd09cff07936e1c9dbe8c9d4b6ac2ef7156 # pin@v3 with: gradle-home-cache-cleanup: true diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 320a7298b5..d3c2994890 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -36,7 +36,7 @@ jobs: java-version: '17' - name: Setup Gradle - uses: gradle/actions/setup-gradle@2cd2a6e951bd0b53f55a08e4e4c6f2586f3a36b9 # pin@v3 + uses: gradle/actions/setup-gradle@cdbbabd09cff07936e1c9dbe8c9d4b6ac2ef7156 # pin@v3 with: gradle-home-cache-cleanup: true diff --git a/.github/workflows/enforce-license-compliance.yml b/.github/workflows/enforce-license-compliance.yml index 2c93ed9e4b..aa3ba87ba1 100644 --- a/.github/workflows/enforce-license-compliance.yml +++ b/.github/workflows/enforce-license-compliance.yml @@ -11,7 +11,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Setup Gradle - uses: gradle/actions/setup-gradle@2cd2a6e951bd0b53f55a08e4e4c6f2586f3a36b9 # pin@v3 + uses: gradle/actions/setup-gradle@cdbbabd09cff07936e1c9dbe8c9d4b6ac2ef7156 # pin@v3 with: gradle-home-cache-cleanup: true diff --git a/.github/workflows/generate-javadocs.yml b/.github/workflows/generate-javadocs.yml index 49db195c61..c34ec6452c 100644 --- a/.github/workflows/generate-javadocs.yml +++ b/.github/workflows/generate-javadocs.yml @@ -20,7 +20,7 @@ jobs: java-version: '17' - name: Setup Gradle - uses: gradle/actions/setup-gradle@2cd2a6e951bd0b53f55a08e4e4c6f2586f3a36b9 # pin@v3 + uses: gradle/actions/setup-gradle@cdbbabd09cff07936e1c9dbe8c9d4b6ac2ef7156 # pin@v3 with: gradle-home-cache-cleanup: true diff --git a/.github/workflows/integration-tests-benchmarks.yml b/.github/workflows/integration-tests-benchmarks.yml index 2e885359ad..c27b334086 100644 --- a/.github/workflows/integration-tests-benchmarks.yml +++ b/.github/workflows/integration-tests-benchmarks.yml @@ -37,7 +37,7 @@ jobs: java-version: '17' - name: Setup Gradle - uses: gradle/actions/setup-gradle@2cd2a6e951bd0b53f55a08e4e4c6f2586f3a36b9 # pin@v3 + uses: gradle/actions/setup-gradle@cdbbabd09cff07936e1c9dbe8c9d4b6ac2ef7156 # pin@v3 with: gradle-home-cache-cleanup: true @@ -86,7 +86,7 @@ jobs: java-version: '17' - name: Setup Gradle - uses: gradle/actions/setup-gradle@2cd2a6e951bd0b53f55a08e4e4c6f2586f3a36b9 # pin@v3 + uses: gradle/actions/setup-gradle@cdbbabd09cff07936e1c9dbe8c9d4b6ac2ef7156 # pin@v3 with: gradle-home-cache-cleanup: true diff --git a/.github/workflows/integration-tests-ui.yml b/.github/workflows/integration-tests-ui.yml index cd5134d38f..1025ec61bb 100644 --- a/.github/workflows/integration-tests-ui.yml +++ b/.github/workflows/integration-tests-ui.yml @@ -32,7 +32,7 @@ jobs: java-version: '17' - name: Setup Gradle - uses: gradle/actions/setup-gradle@2cd2a6e951bd0b53f55a08e4e4c6f2586f3a36b9 # pin@v3 + uses: gradle/actions/setup-gradle@cdbbabd09cff07936e1c9dbe8c9d4b6ac2ef7156 # pin@v3 with: gradle-home-cache-cleanup: true diff --git a/.github/workflows/release-build.yml b/.github/workflows/release-build.yml index b021a6d8ec..e8c8952a14 100644 --- a/.github/workflows/release-build.yml +++ b/.github/workflows/release-build.yml @@ -26,7 +26,7 @@ jobs: java-version: '17' - name: Setup Gradle - uses: gradle/actions/setup-gradle@2cd2a6e951bd0b53f55a08e4e4c6f2586f3a36b9 # pin@v3 + uses: gradle/actions/setup-gradle@cdbbabd09cff07936e1c9dbe8c9d4b6ac2ef7156 # pin@v3 with: gradle-home-cache-cleanup: true diff --git a/.github/workflows/system-tests-backend.yml b/.github/workflows/system-tests-backend.yml index d794ecb118..f5ecc9f893 100644 --- a/.github/workflows/system-tests-backend.yml +++ b/.github/workflows/system-tests-backend.yml @@ -40,7 +40,7 @@ jobs: java-version: '17' - name: Setup Gradle - uses: gradle/actions/setup-gradle@2cd2a6e951bd0b53f55a08e4e4c6f2586f3a36b9 # pin@v3 + uses: gradle/actions/setup-gradle@cdbbabd09cff07936e1c9dbe8c9d4b6ac2ef7156 # pin@v3 with: gradle-home-cache-cleanup: true From 7620eaccff39f78216ae40caed04ddb6ffa63dd0 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Tue, 16 Jul 2024 14:32:03 +0200 Subject: [PATCH 08/92] Add sentry-android-replay module to craft and readme (#3578) --- .craft.yml | 1 + README.md | 1 + 2 files changed, 2 insertions(+) diff --git a/.craft.yml b/.craft.yml index d50705f433..37174e148a 100644 --- a/.craft.yml +++ b/.craft.yml @@ -56,3 +56,4 @@ targets: maven:io.sentry:sentry-compose-desktop: maven:io.sentry:sentry-apollo-3: maven:io.sentry:sentry-android-sqlite: + maven:io.sentry:sentry-android-replay: diff --git a/README.md b/README.md index 338a59eba5..cba39883f2 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,7 @@ Sentry SDK for Java and Android | sentry-android-fragment | [![Maven Central](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-android-fragment/badge.svg)](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-android-fragment) | 19 | | sentry-android-navigation | [![Maven Central](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-android-navigation/badge.svg)](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-android-navigation) | 19 | | sentry-android-sqlite | [![Maven Central](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-android-sqlite/badge.svg)](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-android-sqlite) | 19 | +| sentry-android-replay | [![Maven Central](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-android-replay/badge.svg)](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-android-replay) | 26 | | sentry-compose-android | [![Maven Central](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-compose-android/badge.svg)](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-compose-android) | 21 | | sentry-compose-desktop | [![Maven Central](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-compose-desktop/badge.svg)](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-compose-desktop) | | sentry-compose | [![Maven Central](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-compose/badge.svg)](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-compose) | From 73237da99b1e3a7edfa5e72ed9afa9b09622462a Mon Sep 17 00:00:00 2001 From: Stefano Date: Wed, 17 Jul 2024 10:10:45 +0200 Subject: [PATCH 09/92] Check app start spans time and foreground state (#3550) * App start now takes AppStartMetrics.appLaunchedInForeground variable to add spans to the transaction * App starts longer than 1 minute are dropped (same as Firebase) * added Activity lifecycle registration to check start launch time and foreground status * added AppStartMetrics.registerApplicationForegroundCheck in the SentryPerformanceProvider and SentryAndroid.init, other than AppStartMetrics.onApplicationCreate * ActivityLifecycleIntegration now reads first activity creation on create instead of class instantiation * AppStartMetrics stops app start profiler if no activity is being created --- CHANGELOG.md | 7 + .../api/sentry-android-core.api | 5 +- .../core/ActivityLifecycleIntegration.java | 12 +- .../io/sentry/android/core/SentryAndroid.java | 5 + .../core/SentryPerformanceProvider.java | 1 + .../core/performance/AppStartMetrics.java | 86 +++++++- .../core/ActivityLifecycleIntegrationTest.kt | 80 ++++++- .../PerformanceAndroidEventProcessorTest.kt | 205 +++++++++++------- .../sentry/android/core/SentryAndroidTest.kt | 9 + .../core/SentryPerformanceProviderTest.kt | 4 +- .../core/performance/AppStartMetricsTest.kt | 156 +++++++++++++ 11 files changed, 485 insertions(+), 85 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7dd0136c30..cac7585958 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## Unreleased + +### Fixes + +- Check app start spans time and ignore background app starts ([#3550](https://github.com/getsentry/sentry-java/pull/3550)) + - This should eliminate long-lasting App Start transactions + ## 7.12.0 ### Features diff --git a/sentry-android-core/api/sentry-android-core.api b/sentry-android-core/api/sentry-android-core.api index 0e493f54a7..478a1ddd3c 100644 --- a/sentry-android-core/api/sentry-android-core.api +++ b/sentry-android-core/api/sentry-android-core.api @@ -427,7 +427,7 @@ public class io/sentry/android/core/performance/ActivityLifecycleTimeSpan : java public final fun getOnStart ()Lio/sentry/android/core/performance/TimeSpan; } -public class io/sentry/android/core/performance/AppStartMetrics { +public class io/sentry/android/core/performance/AppStartMetrics : io/sentry/android/core/performance/ActivityLifecycleCallbacksAdapter { public fun ()V public fun addActivityLifecycleTimeSpans (Lio/sentry/android/core/performance/ActivityLifecycleTimeSpan;)V public fun clear ()V @@ -443,10 +443,13 @@ public class io/sentry/android/core/performance/AppStartMetrics { public static fun getInstance ()Lio/sentry/android/core/performance/AppStartMetrics; public fun getSdkInitTimeSpan ()Lio/sentry/android/core/performance/TimeSpan; public fun isAppLaunchedInForeground ()Z + public fun onActivityCreated (Landroid/app/Activity;Landroid/os/Bundle;)V public static fun onApplicationCreate (Landroid/app/Application;)V public static fun onApplicationPostCreate (Landroid/app/Application;)V public static fun onContentProviderCreate (Landroid/content/ContentProvider;)V public static fun onContentProviderPostCreate (Landroid/content/ContentProvider;)V + public fun registerApplicationForegroundCheck (Landroid/app/Application;)V + public fun setAppLaunchedInForeground (Z)V public fun setAppStartProfiler (Lio/sentry/ITransactionProfiler;)V public fun setAppStartSamplingDecision (Lio/sentry/TracesSamplingDecision;)V public fun setAppStartType (Lio/sentry/android/core/performance/AppStartMetrics$AppStartType;)V diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ActivityLifecycleIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/ActivityLifecycleIntegration.java index 205360b8f1..7556afb235 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/ActivityLifecycleIntegration.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ActivityLifecycleIntegration.java @@ -21,6 +21,7 @@ import io.sentry.NoOpTransaction; import io.sentry.SentryDate; import io.sentry.SentryLevel; +import io.sentry.SentryNanotimeDate; import io.sentry.SentryOptions; import io.sentry.SpanStatus; import io.sentry.TracesSamplingDecision; @@ -37,6 +38,7 @@ import java.io.Closeable; import java.io.IOException; import java.lang.ref.WeakReference; +import java.util.Date; import java.util.Map; import java.util.WeakHashMap; import java.util.concurrent.Future; @@ -75,7 +77,7 @@ public final class ActivityLifecycleIntegration private @Nullable ISpan appStartSpan; private final @NotNull WeakHashMap ttidSpanMap = new WeakHashMap<>(); private final @NotNull WeakHashMap ttfdSpanMap = new WeakHashMap<>(); - private @NotNull SentryDate lastPausedTime = AndroidDateUtils.getCurrentSentryDateTime(); + private @NotNull SentryDate lastPausedTime = new SentryNanotimeDate(new Date(0), 0); private final @NotNull Handler mainHandler = new Handler(Looper.getMainLooper()); private @Nullable Future ttfdAutoCloseFuture = null; @@ -627,6 +629,14 @@ WeakHashMap getTtfdSpanMap() { } private void setColdStart(final @Nullable Bundle savedInstanceState) { + // The very first activity start timestamp cannot be set to the class instantiation time, as it + // may happen before an activity is started (service, broadcast receiver, etc). So we set it + // here. + if (hub != null && lastPausedTime.nanoTimestamp() == 0) { + lastPausedTime = hub.getOptions().getDateProvider().now(); + } else if (lastPausedTime.nanoTimestamp() == 0) { + lastPausedTime = AndroidDateUtils.getCurrentSentryDateTime(); + } if (!firstActivityCreated) { // if Activity has savedInstanceState then its a warm start // https://developer.android.com/topic/performance/vitals/launch-time#warm diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroid.java b/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroid.java index d444d08cb0..0c1a74edb0 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroid.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroid.java @@ -1,6 +1,7 @@ package io.sentry.android.core; import android.annotation.SuppressLint; +import android.app.Application; import android.content.Context; import android.os.Process; import android.os.SystemClock; @@ -141,6 +142,10 @@ public static synchronized void init( appStartTimeSpan.setStartedAt(Process.getStartUptimeMillis()); } } + if (context.getApplicationContext() instanceof Application) { + appStartMetrics.registerApplicationForegroundCheck( + (Application) context.getApplicationContext()); + } final @NotNull TimeSpan sdkInitTimeSpan = appStartMetrics.getSdkInitTimeSpan(); if (sdkInitTimeSpan.hasNotStarted()) { sdkInitTimeSpan.setStartedAt(sdkInitMillis); diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/SentryPerformanceProvider.java b/sentry-android-core/src/main/java/io/sentry/android/core/SentryPerformanceProvider.java index 354448c4f2..2ad465f1e3 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/SentryPerformanceProvider.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/SentryPerformanceProvider.java @@ -201,6 +201,7 @@ private void onAppLaunched( final @NotNull TimeSpan appStartTimespan = appStartMetrics.getAppStartTimeSpan(); appStartTimespan.setStartedAt(Process.getStartUptimeMillis()); + appStartMetrics.registerApplicationForegroundCheck(app); final AtomicBoolean firstDrawDone = new AtomicBoolean(false); diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/performance/AppStartMetrics.java b/sentry-android-core/src/main/java/io/sentry/android/core/performance/AppStartMetrics.java index 5c29e95b63..a220f5eb4a 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/performance/AppStartMetrics.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/performance/AppStartMetrics.java @@ -1,10 +1,18 @@ package io.sentry.android.core.performance; +import android.app.Activity; import android.app.Application; import android.content.ContentProvider; +import android.os.Bundle; +import android.os.Handler; +import android.os.Looper; import android.os.SystemClock; +import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; import io.sentry.ITransactionProfiler; +import io.sentry.SentryDate; +import io.sentry.SentryNanotimeDate; import io.sentry.TracesSamplingDecision; import io.sentry.android.core.ContextUtils; import io.sentry.android.core.SentryAndroidOptions; @@ -13,6 +21,7 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.concurrent.TimeUnit; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.TestOnly; @@ -23,7 +32,7 @@ * transformed into SDK specific txn/span data structures. */ @ApiStatus.Internal -public class AppStartMetrics { +public class AppStartMetrics extends ActivityLifecycleCallbacksAdapter { public enum AppStartType { UNKNOWN, @@ -45,6 +54,9 @@ public enum AppStartType { private final @NotNull List activityLifecycles; private @Nullable ITransactionProfiler appStartProfiler = null; private @Nullable TracesSamplingDecision appStartSamplingDecision = null; + private @Nullable SentryDate onCreateTime = null; + private boolean appLaunchTooLong = false; + private boolean isCallbackRegistered = false; public static @NotNull AppStartMetrics getInstance() { @@ -65,6 +77,7 @@ public AppStartMetrics() { applicationOnCreate = new TimeSpan(); contentProviderOnCreates = new HashMap<>(); activityLifecycles = new ArrayList<>(); + appLaunchedInForeground = ContextUtils.isForegroundImportance(); } /** @@ -102,6 +115,11 @@ public boolean isAppLaunchedInForeground() { return appLaunchedInForeground; } + @VisibleForTesting + public void setAppLaunchedInForeground(final boolean appLaunchedInForeground) { + this.appLaunchedInForeground = appLaunchedInForeground; + } + /** * Provides all collected content provider onCreate time spans * @@ -137,12 +155,20 @@ public long getClassLoadedUptimeMs() { // Only started when sdk version is >= N final @NotNull TimeSpan appStartSpan = getAppStartTimeSpan(); if (appStartSpan.hasStarted()) { - return appStartSpan; + return validateAppStartSpan(appStartSpan); } } // fallback: use sdk init time span, as it will always have a start time set - return getSdkInitTimeSpan(); + return validateAppStartSpan(getSdkInitTimeSpan()); + } + + private @NotNull TimeSpan validateAppStartSpan(final @NotNull TimeSpan appStartSpan) { + // If the app launch took too long or it was launched in the background we return an empty span + if (appLaunchTooLong || !appLaunchedInForeground) { + return new TimeSpan(); + } + return appStartSpan; } @TestOnly @@ -158,6 +184,10 @@ public void clear() { } appStartProfiler = null; appStartSamplingDecision = null; + appLaunchTooLong = false; + appLaunchedInForeground = false; + onCreateTime = null; + isCallbackRegistered = false; } public @Nullable ITransactionProfiler getAppStartProfiler() { @@ -195,7 +225,55 @@ public static void onApplicationCreate(final @NotNull Application application) { final @NotNull AppStartMetrics instance = getInstance(); if (instance.applicationOnCreate.hasNotStarted()) { instance.applicationOnCreate.setStartedAt(now); - instance.appLaunchedInForeground = ContextUtils.isForegroundImportance(); + instance.registerApplicationForegroundCheck(application); + } + } + + /** + * Register a callback to check if an activity was started after the application was created + * + * @param application The application object to register the callback to + */ + public void registerApplicationForegroundCheck(final @NotNull Application application) { + if (isCallbackRegistered) { + return; + } + isCallbackRegistered = true; + appLaunchedInForeground = appLaunchedInForeground || ContextUtils.isForegroundImportance(); + application.registerActivityLifecycleCallbacks(instance); + new Handler(Looper.getMainLooper()) + .post( + () -> { + // if no activity has ever been created, app was launched in background + if (onCreateTime == null) { + appLaunchedInForeground = false; + } + application.unregisterActivityLifecycleCallbacks(instance); + // we stop the app start profiler, as it's useless and likely to timeout + if (appStartProfiler != null && appStartProfiler.isRunning()) { + appStartProfiler.close(); + appStartProfiler = null; + } + }); + } + + @Override + public void onActivityCreated(@NonNull Activity activity, @Nullable Bundle savedInstanceState) { + // An activity already called onCreate() + if (!appLaunchedInForeground || onCreateTime != null) { + return; + } + onCreateTime = new SentryNanotimeDate(); + + final long spanStartMillis = appStartSpan.getStartTimestampMs(); + final long spanEndMillis = + appStartSpan.hasStopped() + ? appStartSpan.getProjectedStopTimestampMs() + : System.currentTimeMillis(); + final long durationMillis = spanEndMillis - spanStartMillis; + // If the app was launched more than 1 minute ago, it's likely wrong + if (durationMillis > TimeUnit.MINUTES.toMillis(1)) { + appLaunchTooLong = true; } } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/ActivityLifecycleIntegrationTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/ActivityLifecycleIntegrationTest.kt index f936b6251c..b355075ff1 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/ActivityLifecycleIntegrationTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/ActivityLifecycleIntegrationTest.kt @@ -54,6 +54,7 @@ import org.robolectric.shadow.api.Shadow import org.robolectric.shadows.ShadowActivityManager import java.util.Date import java.util.concurrent.Future +import java.util.concurrent.TimeUnit import kotlin.test.AfterTest import kotlin.test.BeforeTest import kotlin.test.Test @@ -94,6 +95,7 @@ class ActivityLifecycleIntegrationTest { whenever(hub.options).thenReturn(options) + AppStartMetrics.getInstance().isAppLaunchedInForeground = true // We let the ActivityLifecycleIntegration create the proper transaction here val optionCaptor = argumentCaptor() val contextCaptor = argumentCaptor() @@ -709,15 +711,19 @@ class ActivityLifecycleIntegrationTest { sut.register(fixture.hub, fixture.options) val date = SentryNanotimeDate(Date(1), 0) + val date2 = SentryNanotimeDate(Date(2), 2) setAppStartTime(date) val activity = mock() + // The activity onCreate date will be ignored + fixture.options.dateProvider = SentryDateProvider { date2 } sut.onActivityCreated(activity, fixture.bundle) verify(fixture.hub).startTransaction( any(), check { assertEquals(date.nanoTimestamp(), it.startTimestamp!!.nanoTimestamp()) + assertNotEquals(date2.nanoTimestamp(), it.startTimestamp!!.nanoTimestamp()) assertFalse(it.isAppStartTransaction) } ) @@ -756,6 +762,30 @@ class ActivityLifecycleIntegrationTest { ) } + @Test + fun `When firstActivityCreated is true and no app start time is set, default to onActivityCreated time`() { + val sut = fixture.getSut() + fixture.options.tracesSampleRate = 1.0 + sut.register(fixture.hub, fixture.options) + + // usually set by SentryPerformanceProvider + val date = SentryNanotimeDate(Date(1), 0) + val date2 = SentryNanotimeDate(Date(2), 2) + + val activity = mock() + // Activity onCreate date will be used + fixture.options.dateProvider = SentryDateProvider { date2 } + sut.onActivityCreated(activity, fixture.bundle) + + verify(fixture.hub).startTransaction( + any(), + check { + assertEquals(date2.nanoTimestamp(), it.startTimestamp!!.nanoTimestamp()) + assertNotEquals(date.nanoTimestamp(), it.startTimestamp!!.nanoTimestamp()) + } + ) + } + @Test fun `Create and finish app start span immediately in case SDK init is deferred`() { val sut = fixture.getSut(importance = RunningAppProcessInfo.IMPORTANCE_FOREGROUND) @@ -940,6 +970,46 @@ class ActivityLifecycleIntegrationTest { assertEquals(span.startDate.nanoTimestamp(), date.nanoTimestamp()) } + @Test + fun `When firstActivityCreated is true and app started more than 1 minute ago, app start spans are dropped`() { + val sut = fixture.getSut() + fixture.options.tracesSampleRate = 1.0 + sut.register(fixture.hub, fixture.options) + + val date = SentryNanotimeDate(Date(1), 0) + val duration = TimeUnit.MINUTES.toMillis(1) + 2 + val durationNanos = TimeUnit.MILLISECONDS.toNanos(duration) + val stopDate = SentryNanotimeDate(Date(duration), durationNanos) + setAppStartTime(date, stopDate) + + val activity = mock() + sut.onActivityCreated(activity, null) + + val appStartSpan = fixture.transaction.children.firstOrNull { + it.description == "Cold Start" + } + assertNull(appStartSpan) + } + + @Test + fun `When firstActivityCreated is true and app started in background, app start spans are dropped`() { + val sut = fixture.getSut() + AppStartMetrics.getInstance().isAppLaunchedInForeground = false + fixture.options.tracesSampleRate = 1.0 + sut.register(fixture.hub, fixture.options) + + val date = SentryNanotimeDate(Date(1), 0) + setAppStartTime(date) + + val activity = mock() + sut.onActivityCreated(activity, null) + + val appStartSpan = fixture.transaction.children.firstOrNull { + it.description == "Cold Start" + } + assertNull(appStartSpan) + } + @Test fun `When firstActivityCreated is false, start transaction but not with given appStartTime`() { val sut = fixture.getSut() @@ -1412,18 +1482,22 @@ class ActivityLifecycleIntegrationTest { shadowOf(Looper.getMainLooper()).idle() } - private fun setAppStartTime(date: SentryDate = SentryNanotimeDate(Date(1), 0)) { + private fun setAppStartTime(date: SentryDate = SentryNanotimeDate(Date(1), 0), stopDate: SentryDate? = null) { // set by SentryPerformanceProvider so forcing it here val sdkAppStartTimeSpan = AppStartMetrics.getInstance().sdkInitTimeSpan val appStartTimeSpan = AppStartMetrics.getInstance().appStartTimeSpan val millis = DateUtils.nanosToMillis(date.nanoTimestamp().toDouble()).toLong() + val stopMillis = DateUtils.nanosToMillis(stopDate?.nanoTimestamp()?.toDouble() ?: 0.0).toLong() sdkAppStartTimeSpan.setStartedAt(millis) sdkAppStartTimeSpan.setStartUnixTimeMs(millis) - sdkAppStartTimeSpan.setStoppedAt(0) + sdkAppStartTimeSpan.setStoppedAt(stopMillis) appStartTimeSpan.setStartedAt(millis) appStartTimeSpan.setStartUnixTimeMs(millis) - appStartTimeSpan.setStoppedAt(0) + appStartTimeSpan.setStoppedAt(stopMillis) + if (stopDate != null) { + AppStartMetrics.getInstance().onActivityCreated(mock(), mock()) + } } } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/PerformanceAndroidEventProcessorTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/PerformanceAndroidEventProcessorTest.kt index 4283326677..23ab5a3bc8 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/PerformanceAndroidEventProcessorTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/PerformanceAndroidEventProcessorTest.kt @@ -18,12 +18,14 @@ import io.sentry.android.core.performance.ActivityLifecycleTimeSpan import io.sentry.android.core.performance.AppStartMetrics import io.sentry.android.core.performance.AppStartMetrics.AppStartType import io.sentry.protocol.MeasurementValue +import io.sentry.protocol.SentryId import io.sentry.protocol.SentrySpan import io.sentry.protocol.SentryTransaction import org.junit.runner.RunWith import org.mockito.kotlin.any import org.mockito.kotlin.mock import org.mockito.kotlin.whenever +import java.util.concurrent.TimeUnit import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals @@ -46,6 +48,7 @@ class PerformanceAndroidEventProcessorTest { tracesSampleRate: Double? = 1.0, enablePerformanceV2: Boolean = false ): PerformanceAndroidEventProcessor { + AppStartMetrics.getInstance().isAppLaunchedInForeground = true options.tracesSampleRate = tracesSampleRate options.isEnablePerformanceV2 = enablePerformanceV2 whenever(hub.options).thenReturn(options) @@ -56,6 +59,24 @@ class PerformanceAndroidEventProcessorTest { private val fixture = Fixture() + private fun createAppStartSpan(traceId: SentryId) = SentrySpan( + 0.0, + 1.0, + traceId, + SpanId(), + null, + APP_START_COLD, + "App Start", + SpanStatus.OK, + null, + emptyMap(), + emptyMap(), + null, + null + ).also { + AppStartMetrics.getInstance().onActivityCreated(mock(), mock()) + } + @BeforeTest fun `reset instance`() { AppStartMetrics.getInstance().clear() @@ -233,21 +254,7 @@ class PerformanceAndroidEventProcessorTest { var tr = SentryTransaction(tracer) // and it contains an app.start.cold span - val appStartSpan = SentrySpan( - 0.0, - 1.0, - tr.contexts.trace!!.traceId, - SpanId(), - null, - APP_START_COLD, - "App Start", - SpanStatus.OK, - null, - emptyMap(), - emptyMap(), - null, - null - ) + val appStartSpan = createAppStartSpan(tr.contexts.trace!!.traceId) tr.spans.add(appStartSpan) // then the app start metrics should be attached @@ -285,6 +292,110 @@ class PerformanceAndroidEventProcessorTest { ) } + @Test + fun `when app launched from background, app start spans are dropped`() { + // given some app start metrics + val appStartMetrics = AppStartMetrics.getInstance() + appStartMetrics.appStartType = AppStartType.COLD + appStartMetrics.appStartTimeSpan.setStartedAt(123) + appStartMetrics.appStartTimeSpan.setStoppedAt(456) + + val contentProvider = mock() + AppStartMetrics.onContentProviderCreate(contentProvider) + AppStartMetrics.onContentProviderPostCreate(contentProvider) + + appStartMetrics.applicationOnCreateTimeSpan.apply { + setStartedAt(10) + setStoppedAt(42) + } + + val activityTimeSpan = ActivityLifecycleTimeSpan() + activityTimeSpan.onCreate.description = "MainActivity.onCreate" + activityTimeSpan.onStart.description = "MainActivity.onStart" + + activityTimeSpan.onCreate.setStartedAt(200) + activityTimeSpan.onStart.setStartedAt(220) + activityTimeSpan.onStart.setStoppedAt(240) + activityTimeSpan.onCreate.setStoppedAt(260) + appStartMetrics.addActivityLifecycleTimeSpans(activityTimeSpan) + + // when an activity transaction is created + val sut = fixture.getSut(enablePerformanceV2 = true) + val context = TransactionContext("Activity", UI_LOAD_OP) + val tracer = SentryTracer(context, fixture.hub) + var tr = SentryTransaction(tracer) + + // and it contains an app.start.cold span + val appStartSpan = createAppStartSpan(tr.contexts.trace!!.traceId) + tr.spans.add(appStartSpan) + + // but app is launched in background + AppStartMetrics.getInstance().isAppLaunchedInForeground = false + + // then the app start metrics are not attached + tr = sut.process(tr, Hint()) + + assertFalse( + tr.spans.any { + "process.load" == it.op || + "contentprovider.load" == it.op || + "application.load" == it.op || + "activity.load" == it.op + } + ) + } + + @Test + fun `when app start takes more than 1 minute, app start spans are dropped`() { + // given some app start metrics + val appStartMetrics = AppStartMetrics.getInstance() + appStartMetrics.appStartType = AppStartType.COLD + appStartMetrics.appStartTimeSpan.setStartedAt(123) + // and app start takes more than 1 minute + appStartMetrics.appStartTimeSpan.setStoppedAt(TimeUnit.MINUTES.toMillis(1) + 124) + + val contentProvider = mock() + AppStartMetrics.onContentProviderCreate(contentProvider) + AppStartMetrics.onContentProviderPostCreate(contentProvider) + + appStartMetrics.applicationOnCreateTimeSpan.apply { + setStartedAt(10) + setStoppedAt(42) + } + + val activityTimeSpan = ActivityLifecycleTimeSpan() + activityTimeSpan.onCreate.description = "MainActivity.onCreate" + activityTimeSpan.onStart.description = "MainActivity.onStart" + + activityTimeSpan.onCreate.setStartedAt(200) + activityTimeSpan.onStart.setStartedAt(220) + activityTimeSpan.onStart.setStoppedAt(240) + activityTimeSpan.onCreate.setStoppedAt(260) + appStartMetrics.addActivityLifecycleTimeSpans(activityTimeSpan) + + // when an activity transaction is created + val sut = fixture.getSut(enablePerformanceV2 = true) + val context = TransactionContext("Activity", UI_LOAD_OP) + val tracer = SentryTracer(context, fixture.hub) + var tr = SentryTransaction(tracer) + + // and it contains an app.start.cold span + val appStartSpan = createAppStartSpan(tr.contexts.trace!!.traceId) + tr.spans.add(appStartSpan) + + // then the app start metrics are not attached + tr = sut.process(tr, Hint()) + + assertFalse( + tr.spans.any { + "process.load" == it.op || + "contentprovider.load" == it.op || + "application.load" == it.op || + "activity.load" == it.op + } + ) + } + @Test fun `does not add app start metrics to app start txn when it is not a cold start`() { // given some WARM app start metrics @@ -330,21 +441,7 @@ class PerformanceAndroidEventProcessorTest { val context = TransactionContext("Activity", UI_LOAD_OP) val tracer = SentryTracer(context, fixture.hub) var tr = SentryTransaction(tracer) - val appStartSpan = SentrySpan( - 0.0, - 1.0, - tr.contexts.trace!!.traceId, - SpanId(), - null, - APP_START_COLD, - "App Start", - SpanStatus.OK, - null, - emptyMap(), - emptyMap(), - null, - null - ) + val appStartSpan = createAppStartSpan(tr.contexts.trace!!.traceId) tr.spans.add(appStartSpan) // then the app start metrics should not be attached @@ -381,21 +478,7 @@ class PerformanceAndroidEventProcessorTest { val context = TransactionContext("Activity", UI_LOAD_OP) val tracer = SentryTracer(context, fixture.hub) var tr = SentryTransaction(tracer) - val appStartSpan = SentrySpan( - 0.0, - 1.0, - tr.contexts.trace!!.traceId, - SpanId(), - null, - APP_START_COLD, - "App Start", - SpanStatus.OK, - null, - emptyMap(), - emptyMap(), - null, - null - ) + val appStartSpan = createAppStartSpan(tr.contexts.trace!!.traceId) tr.spans.add(appStartSpan) // when the processor attaches the app start spans @@ -428,21 +511,7 @@ class PerformanceAndroidEventProcessorTest { val context = TransactionContext("Activity", UI_LOAD_OP) val tracer = SentryTracer(context, fixture.hub) var tr = SentryTransaction(tracer) - val appStartSpan = SentrySpan( - 0.0, - 1.0, - tr.contexts.trace!!.traceId, - SpanId(), - null, - APP_START_COLD, - "App Start", - SpanStatus.OK, - null, - emptyMap(), - emptyMap(), - null, - null - ) + val appStartSpan = createAppStartSpan(tr.contexts.trace!!.traceId) tr.spans.add(appStartSpan) // when the processor attaches the app start spans @@ -493,21 +562,7 @@ class PerformanceAndroidEventProcessorTest { val tracer = SentryTracer(context, fixture.hub) var tr = SentryTransaction(tracer) - val appStartSpan = SentrySpan( - 0.0, - 1.0, - tr.contexts.trace!!.traceId, - SpanId(), - null, - APP_START_COLD, - "App Start", - SpanStatus.OK, - null, - emptyMap(), - emptyMap(), - null, - null - ) + val appStartSpan = createAppStartSpan(tr.contexts.trace!!.traceId) tr.spans.add(appStartSpan) // when the processor attaches the app start spans diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidTest.kt index cf00173513..aa721bdb41 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidTest.kt @@ -348,6 +348,15 @@ class SentryAndroidTest { } } + @Test + fun `When initializing Sentry a callback is added to application by appStartMetrics`() { + val mockContext = ContextUtilsTestHelper.createMockContext(true) + SentryAndroid.init(mockContext) { + it.dsn = "https://key@sentry.io/123" + } + verify(mockContext.applicationContext as Application).registerActivityLifecycleCallbacks(eq(AppStartMetrics.getInstance())) + } + private fun initSentryWithForegroundImportance( inForeground: Boolean, optionsConfig: (SentryAndroidOptions) -> Unit = {}, diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/SentryPerformanceProviderTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/SentryPerformanceProviderTest.kt index db68009589..ff6a299bed 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/SentryPerformanceProviderTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/SentryPerformanceProviderTest.kt @@ -18,6 +18,7 @@ import org.mockito.kotlin.any import org.mockito.kotlin.eq import org.mockito.kotlin.mock import org.mockito.kotlin.never +import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import org.robolectric.annotation.Config @@ -164,7 +165,8 @@ class SentryPerformanceProviderTest { fun `provider properly registers and unregisters ActivityLifecycleCallbacks`() { val provider = fixture.getSut() - verify(fixture.mockContext).registerActivityLifecycleCallbacks(any()) + // It register once for the provider itself and once for the appStartMetrics + verify(fixture.mockContext, times(2)).registerActivityLifecycleCallbacks(any()) provider.onAppStartDone() verify(fixture.mockContext).unregisterActivityLifecycleCallbacks(any()) } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/performance/AppStartMetricsTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/performance/AppStartMetricsTest.kt index 0885024b00..1f2eab8a9a 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/performance/AppStartMetricsTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/performance/AppStartMetricsTest.kt @@ -3,15 +3,25 @@ package io.sentry.android.core.performance import android.app.Application import android.content.ContentProvider import android.os.Build +import android.os.Looper import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.sentry.ITransactionProfiler import io.sentry.android.core.SentryAndroidOptions import io.sentry.android.core.SentryShadowProcess import org.junit.Before import org.junit.runner.RunWith +import org.mockito.kotlin.eq import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.robolectric.Shadows import org.robolectric.annotation.Config +import java.util.concurrent.TimeUnit import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertNotEquals import kotlin.test.assertNull import kotlin.test.assertSame @@ -28,6 +38,7 @@ class AppStartMetricsTest { fun setup() { AppStartMetrics.getInstance().clear() SentryShadowProcess.setStartUptimeMillis(42) + AppStartMetrics.getInstance().isAppLaunchedInForeground = true } @Test @@ -106,4 +117,149 @@ class AppStartMetricsTest { fun `class load time is set`() { assertNotEquals(0, AppStartMetrics.getInstance().classLoadedUptimeMs) } + + @Test + fun `if app is launched in background, appStartTimeSpanWithFallback returns an empty span`() { + AppStartMetrics.getInstance().isAppLaunchedInForeground = false + val appStartTimeSpan = AppStartMetrics.getInstance().appStartTimeSpan + appStartTimeSpan.start() + assertTrue(appStartTimeSpan.hasStarted()) + AppStartMetrics.getInstance().onActivityCreated(mock(), mock()) + Shadows.shadowOf(Looper.getMainLooper()).idle() + + val options = SentryAndroidOptions().apply { + isEnablePerformanceV2 = false + } + + val timeSpan = AppStartMetrics.getInstance().getAppStartTimeSpanWithFallback(options) + assertFalse(timeSpan.hasStarted()) + } + + @Test + fun `if app is launched in background with perfV2, appStartTimeSpanWithFallback returns an empty span`() { + val appStartTimeSpan = AppStartMetrics.getInstance().appStartTimeSpan + appStartTimeSpan.start() + assertTrue(appStartTimeSpan.hasStarted()) + AppStartMetrics.getInstance().isAppLaunchedInForeground = false + AppStartMetrics.getInstance().onActivityCreated(mock(), mock()) + + val options = SentryAndroidOptions().apply { + isEnablePerformanceV2 = true + } + + val timeSpan = AppStartMetrics.getInstance().getAppStartTimeSpanWithFallback(options) + assertFalse(timeSpan.hasStarted()) + } + + @Test + fun `if app start span is at most 1 minute, appStartTimeSpanWithFallback returns the app start span`() { + val appStartTimeSpan = AppStartMetrics.getInstance().appStartTimeSpan + appStartTimeSpan.start() + appStartTimeSpan.stop() + appStartTimeSpan.setStartedAt(1) + appStartTimeSpan.setStoppedAt(TimeUnit.MINUTES.toMillis(1) + 1) + assertTrue(appStartTimeSpan.hasStarted()) + AppStartMetrics.getInstance().onActivityCreated(mock(), mock()) + + val options = SentryAndroidOptions().apply { + isEnablePerformanceV2 = true + } + + val timeSpan = AppStartMetrics.getInstance().getAppStartTimeSpanWithFallback(options) + assertTrue(timeSpan.hasStarted()) + assertSame(appStartTimeSpan, timeSpan) + } + + @Test + fun `if activity is never started, returns an empty span`() { + AppStartMetrics.getInstance().registerApplicationForegroundCheck(mock()) + val appStartTimeSpan = AppStartMetrics.getInstance().appStartTimeSpan + appStartTimeSpan.setStartedAt(1) + assertTrue(appStartTimeSpan.hasStarted()) + // Job on main thread checks if activity was launched + Shadows.shadowOf(Looper.getMainLooper()).idle() + + val timeSpan = AppStartMetrics.getInstance().getAppStartTimeSpanWithFallback(SentryAndroidOptions()) + assertFalse(timeSpan.hasStarted()) + } + + @Test + fun `if activity is never started, stops app start profiler if running`() { + val profiler = mock() + whenever(profiler.isRunning).thenReturn(true) + AppStartMetrics.getInstance().appStartProfiler = profiler + + AppStartMetrics.getInstance().registerApplicationForegroundCheck(mock()) + // Job on main thread checks if activity was launched + Shadows.shadowOf(Looper.getMainLooper()).idle() + + verify(profiler).close() + } + + @Test + fun `if app start span is longer than 1 minute, appStartTimeSpanWithFallback returns an empty span`() { + val appStartTimeSpan = AppStartMetrics.getInstance().appStartTimeSpan + appStartTimeSpan.start() + appStartTimeSpan.stop() + appStartTimeSpan.setStartedAt(1) + appStartTimeSpan.setStoppedAt(TimeUnit.MINUTES.toMillis(1) + 2) + assertTrue(appStartTimeSpan.hasStarted()) + AppStartMetrics.getInstance().onActivityCreated(mock(), mock()) + + val options = SentryAndroidOptions().apply { + isEnablePerformanceV2 = true + } + + val timeSpan = AppStartMetrics.getInstance().getAppStartTimeSpanWithFallback(options) + assertFalse(timeSpan.hasStarted()) + } + + @Test + fun `when multiple registerApplicationForegroundCheck, only one callback is registered to application`() { + val application = mock() + AppStartMetrics.getInstance().registerApplicationForegroundCheck(application) + AppStartMetrics.getInstance().registerApplicationForegroundCheck(application) + verify(application, times(1)).registerActivityLifecycleCallbacks(eq(AppStartMetrics.getInstance())) + } + + @Test + fun `when registerApplicationForegroundCheck, a callback is registered to application`() { + val application = mock() + AppStartMetrics.getInstance().registerApplicationForegroundCheck(application) + verify(application).registerActivityLifecycleCallbacks(eq(AppStartMetrics.getInstance())) + } + + @Test + fun `when registerApplicationForegroundCheck, a job is posted on main thread to unregistered the callback`() { + val application = mock() + AppStartMetrics.getInstance().registerApplicationForegroundCheck(application) + verify(application).registerActivityLifecycleCallbacks(eq(AppStartMetrics.getInstance())) + verify(application, never()).unregisterActivityLifecycleCallbacks(eq(AppStartMetrics.getInstance())) + Shadows.shadowOf(Looper.getMainLooper()).idle() + verify(application).unregisterActivityLifecycleCallbacks(eq(AppStartMetrics.getInstance())) + } + + @Test + fun `registerApplicationForegroundCheck set foreground state to false if no activity is running`() { + val application = mock() + AppStartMetrics.getInstance().isAppLaunchedInForeground = true + AppStartMetrics.getInstance().registerApplicationForegroundCheck(application) + assertTrue(AppStartMetrics.getInstance().isAppLaunchedInForeground) + // Main thread performs the check and sets the flag to false if no activity was created + Shadows.shadowOf(Looper.getMainLooper()).idle() + assertFalse(AppStartMetrics.getInstance().isAppLaunchedInForeground) + } + + @Test + fun `registerApplicationForegroundCheck keeps foreground state to true if an activity is running`() { + val application = mock() + AppStartMetrics.getInstance().isAppLaunchedInForeground = true + AppStartMetrics.getInstance().registerApplicationForegroundCheck(application) + assertTrue(AppStartMetrics.getInstance().isAppLaunchedInForeground) + // An activity was created + AppStartMetrics.getInstance().onActivityCreated(mock(), null) + // Main thread performs the check and keeps the flag to true + Shadows.shadowOf(Looper.getMainLooper()).idle() + assertTrue(AppStartMetrics.getInstance().isAppLaunchedInForeground) + } } From 391c19960617e278def5134db70e4f8eb6197243 Mon Sep 17 00:00:00 2001 From: getsentry-bot Date: Tue, 23 Jul 2024 09:53:28 +0000 Subject: [PATCH 10/92] release: 7.12.1 --- CHANGELOG.md | 2 +- gradle.properties | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cac7585958..8337f988c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## Unreleased +## 7.12.1 ### Fixes diff --git a/gradle.properties b/gradle.properties index 83537f27e4..16dd5a0948 100644 --- a/gradle.properties +++ b/gradle.properties @@ -10,7 +10,7 @@ android.useAndroidX=true android.defaults.buildfeatures.buildconfig=true # Release information -versionName=7.12.0 +versionName=7.12.1 # Override the SDK name on native crashes on Android sentryAndroidSdkName=sentry.native.android From 60865fe81f29794056ec8c3d72eb4eff13a035c4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 23 Jul 2024 17:07:19 +0200 Subject: [PATCH 11/92] Bump github/codeql-action from 3.25.11 to 3.25.13 (#3591) Bumps [github/codeql-action](https://github.com/github/codeql-action) from 3.25.11 to 3.25.13. - [Release notes](https://github.com/github/codeql-action/releases) - [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/github/codeql-action/compare/b611370bb5703a7efb587f9d136a52ea24c5c38c...2d790406f505036ef40ecba973cc774a50395aac) --- updated-dependencies: - dependency-name: github/codeql-action 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> --- .github/workflows/codeql-analysis.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index d3c2994890..556e4558a0 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -41,7 +41,7 @@ jobs: gradle-home-cache-cleanup: true - name: Initialize CodeQL - uses: github/codeql-action/init@b611370bb5703a7efb587f9d136a52ea24c5c38c # pin@v2 + uses: github/codeql-action/init@2d790406f505036ef40ecba973cc774a50395aac # pin@v2 with: languages: ${{ matrix.language }} @@ -55,4 +55,4 @@ jobs: ./gradlew buildForCodeQL - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@b611370bb5703a7efb587f9d136a52ea24c5c38c # pin@v2 + uses: github/codeql-action/analyze@2d790406f505036ef40ecba973cc774a50395aac # pin@v2 From 485ff61b104323f196c64cd3d55f8f5132520a7e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 23 Jul 2024 17:23:55 +0200 Subject: [PATCH 12/92] Bump reactivecircus/android-emulator-runner from 2.31.0 to 2.32.0 (#3573) 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> --- .github/workflows/agp-matrix.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/agp-matrix.yml b/.github/workflows/agp-matrix.yml index ae1b7724c2..ac75e58857 100644 --- a/.github/workflows/agp-matrix.yml +++ b/.github/workflows/agp-matrix.yml @@ -59,7 +59,7 @@ jobs: # We tried to use the cache action to cache gradle stuff, but it made tests slower and timeout - name: Run instrumentation tests - uses: reactivecircus/android-emulator-runner@77986be26589807b8ebab3fde7bbf5c60dabec32 # pin@v2 + uses: reactivecircus/android-emulator-runner@f0d1ed2dcad93c7479e8b2f2226c83af54494915 # pin@v2 with: api-level: 30 force-avd-creation: false From 74ed0f6497e970f83afe70c9d3c2df67d2339d27 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 23 Jul 2024 15:28:36 +0000 Subject: [PATCH 13/92] Bump JamesIves/github-pages-deploy-action from 4.6.1 to 4.6.3 (#3590) Bumps [JamesIves/github-pages-deploy-action](https://github.com/jamesives/github-pages-deploy-action) from 4.6.1 to 4.6.3. - [Release notes](https://github.com/jamesives/github-pages-deploy-action/releases) - [Commits](https://github.com/jamesives/github-pages-deploy-action/compare/5c6e9e9f3672ce8fd37b9856193d2a537941e66c...94f3c658273cf92fb48ef99e5fbc02bd2dc642b2) --- updated-dependencies: - dependency-name: JamesIves/github-pages-deploy-action 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> --- .github/workflows/generate-javadocs.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/generate-javadocs.yml b/.github/workflows/generate-javadocs.yml index c34ec6452c..b56c7943cc 100644 --- a/.github/workflows/generate-javadocs.yml +++ b/.github/workflows/generate-javadocs.yml @@ -28,7 +28,7 @@ jobs: run: | ./gradlew aggregateJavadocs - name: Deploy - uses: JamesIves/github-pages-deploy-action@5c6e9e9f3672ce8fd37b9856193d2a537941e66c # pin@4.6.1 + uses: JamesIves/github-pages-deploy-action@94f3c658273cf92fb48ef99e5fbc02bd2dc642b2 # pin@4.6.3 with: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} BRANCH: gh-pages From a0423a0cffa15351d7ebc58c4e8b2561574a2128 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 23 Jul 2024 15:48:30 +0000 Subject: [PATCH 14/92] Bump gradle/actions (#3597) Bumps [gradle/actions](https://github.com/gradle/actions) from cdbbabd09cff07936e1c9dbe8c9d4b6ac2ef7156 to fd87365911aa12c016c307ea21313f351dc53551. - [Release notes](https://github.com/gradle/actions/releases) - [Commits](https://github.com/gradle/actions/compare/cdbbabd09cff07936e1c9dbe8c9d4b6ac2ef7156...fd87365911aa12c016c307ea21313f351dc53551) --- updated-dependencies: - dependency-name: gradle/actions dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/agp-matrix.yml | 2 +- .github/workflows/build.yml | 2 +- .github/workflows/codeql-analysis.yml | 2 +- .github/workflows/enforce-license-compliance.yml | 2 +- .github/workflows/generate-javadocs.yml | 2 +- .github/workflows/integration-tests-benchmarks.yml | 4 ++-- .github/workflows/integration-tests-ui.yml | 2 +- .github/workflows/release-build.yml | 2 +- .github/workflows/system-tests-backend.yml | 2 +- 9 files changed, 10 insertions(+), 10 deletions(-) diff --git a/.github/workflows/agp-matrix.yml b/.github/workflows/agp-matrix.yml index ac75e58857..d19ecd50b8 100644 --- a/.github/workflows/agp-matrix.yml +++ b/.github/workflows/agp-matrix.yml @@ -38,7 +38,7 @@ jobs: java-version: '17' - name: Setup Gradle - uses: gradle/actions/setup-gradle@cdbbabd09cff07936e1c9dbe8c9d4b6ac2ef7156 # pin@v3 + uses: gradle/actions/setup-gradle@fd87365911aa12c016c307ea21313f351dc53551 # pin@v3 with: gradle-home-cache-cleanup: true diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c0e696ac75..1623c48d16 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -27,7 +27,7 @@ jobs: java-version: '17' - name: Setup Gradle - uses: gradle/actions/setup-gradle@cdbbabd09cff07936e1c9dbe8c9d4b6ac2ef7156 # pin@v3 + uses: gradle/actions/setup-gradle@fd87365911aa12c016c307ea21313f351dc53551 # pin@v3 with: gradle-home-cache-cleanup: true diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 556e4558a0..2fa0010a2e 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -36,7 +36,7 @@ jobs: java-version: '17' - name: Setup Gradle - uses: gradle/actions/setup-gradle@cdbbabd09cff07936e1c9dbe8c9d4b6ac2ef7156 # pin@v3 + uses: gradle/actions/setup-gradle@fd87365911aa12c016c307ea21313f351dc53551 # pin@v3 with: gradle-home-cache-cleanup: true diff --git a/.github/workflows/enforce-license-compliance.yml b/.github/workflows/enforce-license-compliance.yml index aa3ba87ba1..294e520eb9 100644 --- a/.github/workflows/enforce-license-compliance.yml +++ b/.github/workflows/enforce-license-compliance.yml @@ -11,7 +11,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Setup Gradle - uses: gradle/actions/setup-gradle@cdbbabd09cff07936e1c9dbe8c9d4b6ac2ef7156 # pin@v3 + uses: gradle/actions/setup-gradle@fd87365911aa12c016c307ea21313f351dc53551 # pin@v3 with: gradle-home-cache-cleanup: true diff --git a/.github/workflows/generate-javadocs.yml b/.github/workflows/generate-javadocs.yml index b56c7943cc..bc0cb396ef 100644 --- a/.github/workflows/generate-javadocs.yml +++ b/.github/workflows/generate-javadocs.yml @@ -20,7 +20,7 @@ jobs: java-version: '17' - name: Setup Gradle - uses: gradle/actions/setup-gradle@cdbbabd09cff07936e1c9dbe8c9d4b6ac2ef7156 # pin@v3 + uses: gradle/actions/setup-gradle@fd87365911aa12c016c307ea21313f351dc53551 # pin@v3 with: gradle-home-cache-cleanup: true diff --git a/.github/workflows/integration-tests-benchmarks.yml b/.github/workflows/integration-tests-benchmarks.yml index c27b334086..cbdf2d4011 100644 --- a/.github/workflows/integration-tests-benchmarks.yml +++ b/.github/workflows/integration-tests-benchmarks.yml @@ -37,7 +37,7 @@ jobs: java-version: '17' - name: Setup Gradle - uses: gradle/actions/setup-gradle@cdbbabd09cff07936e1c9dbe8c9d4b6ac2ef7156 # pin@v3 + uses: gradle/actions/setup-gradle@fd87365911aa12c016c307ea21313f351dc53551 # pin@v3 with: gradle-home-cache-cleanup: true @@ -86,7 +86,7 @@ jobs: java-version: '17' - name: Setup Gradle - uses: gradle/actions/setup-gradle@cdbbabd09cff07936e1c9dbe8c9d4b6ac2ef7156 # pin@v3 + uses: gradle/actions/setup-gradle@fd87365911aa12c016c307ea21313f351dc53551 # pin@v3 with: gradle-home-cache-cleanup: true diff --git a/.github/workflows/integration-tests-ui.yml b/.github/workflows/integration-tests-ui.yml index 1025ec61bb..c62dd4a771 100644 --- a/.github/workflows/integration-tests-ui.yml +++ b/.github/workflows/integration-tests-ui.yml @@ -32,7 +32,7 @@ jobs: java-version: '17' - name: Setup Gradle - uses: gradle/actions/setup-gradle@cdbbabd09cff07936e1c9dbe8c9d4b6ac2ef7156 # pin@v3 + uses: gradle/actions/setup-gradle@fd87365911aa12c016c307ea21313f351dc53551 # pin@v3 with: gradle-home-cache-cleanup: true diff --git a/.github/workflows/release-build.yml b/.github/workflows/release-build.yml index e8c8952a14..74f9174be4 100644 --- a/.github/workflows/release-build.yml +++ b/.github/workflows/release-build.yml @@ -26,7 +26,7 @@ jobs: java-version: '17' - name: Setup Gradle - uses: gradle/actions/setup-gradle@cdbbabd09cff07936e1c9dbe8c9d4b6ac2ef7156 # pin@v3 + uses: gradle/actions/setup-gradle@fd87365911aa12c016c307ea21313f351dc53551 # pin@v3 with: gradle-home-cache-cleanup: true diff --git a/.github/workflows/system-tests-backend.yml b/.github/workflows/system-tests-backend.yml index f5ecc9f893..90acfba2ab 100644 --- a/.github/workflows/system-tests-backend.yml +++ b/.github/workflows/system-tests-backend.yml @@ -40,7 +40,7 @@ jobs: java-version: '17' - name: Setup Gradle - uses: gradle/actions/setup-gradle@cdbbabd09cff07936e1c9dbe8c9d4b6ac2ef7156 # pin@v3 + uses: gradle/actions/setup-gradle@fd87365911aa12c016c307ea21313f351dc53551 # pin@v3 with: gradle-home-cache-cleanup: true From fc84053cf45a312c14ed11e3e9233ac5301b769f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 23 Jul 2024 16:07:58 +0000 Subject: [PATCH 15/92] Bump gradle/wrapper-validation-action from 3.4.2 to 3.5.0 (#3589) Bumps [gradle/wrapper-validation-action](https://github.com/gradle/wrapper-validation-action) from 3.4.2 to 3.5.0. - [Release notes](https://github.com/gradle/wrapper-validation-action/releases) - [Commits](https://github.com/gradle/wrapper-validation-action/compare/88425854a36845f9c881450d9660b5fd46bee142...f9c9c575b8b21b6485636a91ffecd10e558c62f6) --- updated-dependencies: - dependency-name: gradle/wrapper-validation-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> --- .github/workflows/gradle-wrapper-validation.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/gradle-wrapper-validation.yml b/.github/workflows/gradle-wrapper-validation.yml index d4981c5583..4b2fe0a78a 100644 --- a/.github/workflows/gradle-wrapper-validation.yml +++ b/.github/workflows/gradle-wrapper-validation.yml @@ -13,4 +13,4 @@ jobs: - uses: actions/checkout@v4 with: submodules: 'recursive' - - uses: gradle/wrapper-validation-action@88425854a36845f9c881450d9660b5fd46bee142 # pin@v1 + - uses: gradle/wrapper-validation-action@f9c9c575b8b21b6485636a91ffecd10e558c62f6 # pin@v1 From 3a89243fa0ec48fc16193070c2224833d94f9156 Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Mon, 29 Jul 2024 14:20:53 +0200 Subject: [PATCH 16/92] Bump Spring Boot to 3.3.2 (#3541) * Bump Spring Boot to 3.3.1 * changelog * add specific classpath dependency for commons compress * bump to latest patch release of spring boot --------- Co-authored-by: Lukas Bloder --- CHANGELOG.md | 6 ++++++ build.gradle.kts | 1 + buildSrc/src/main/java/Config.kt | 3 ++- 3 files changed, 9 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8337f988c7..2e24537f31 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +### Dependencies + +- Bump Spring Boot to 3.3.2 ([#3541](https://github.com/getsentry/sentry-java/pull/3541)) + ## 7.12.1 ### Fixes diff --git a/build.gradle.kts b/build.gradle.kts index f44f541015..7985a55486 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -41,6 +41,7 @@ buildscript { classpath(Config.QualityPlugins.binaryCompatibilityValidatorPlugin) classpath(Config.BuildPlugins.composeGradlePlugin) + classpath(Config.BuildPlugins.commonsCompressOverride) } } diff --git a/buildSrc/src/main/java/Config.kt b/buildSrc/src/main/java/Config.kt index 2da41627ab..8777d926a9 100644 --- a/buildSrc/src/main/java/Config.kt +++ b/buildSrc/src/main/java/Config.kt @@ -7,7 +7,7 @@ object Config { val kotlinStdLib = "stdlib-jdk8" val springBootVersion = "2.7.5" - val springBoot3Version = "3.2.0" + val springBoot3Version = "3.3.2" val kotlinCompatibleLanguageVersion = "1.4" val composeVersion = "1.5.3" @@ -27,6 +27,7 @@ object Config { val dokkaPlugin = "org.jetbrains.dokka:dokka-gradle-plugin:1.7.10" val dokkaPluginAlias = "org.jetbrains.dokka" val composeGradlePlugin = "org.jetbrains.compose:compose-gradle-plugin:$composeVersion" + val commonsCompressOverride = "org.apache.commons:commons-compress:1.25.0" } object Android { From e039872d80a9a352d10e642ff630a3d905ff89d0 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Tue, 30 Jul 2024 21:40:05 +0200 Subject: [PATCH 17/92] [SR] Capture Replays for ANRs and crashes (#3565) --- CHANGELOG.md | 13 + .../android/core/AnrV2EventProcessor.java | 82 ++++ .../android/core/AnrV2EventProcessorTest.kt | 77 +++- .../api/sentry-android-replay.api | 9 +- .../DefaultReplayBreadcrumbConverter.kt | 17 +- .../io/sentry/android/replay/ReplayCache.kt | 211 ++++++++++- .../android/replay/ReplayIntegration.kt | 108 ++++-- .../replay/capture/BaseCaptureStrategy.kt | 320 +++++++--------- .../replay/capture/BufferCaptureStrategy.kt | 221 ++++++----- .../android/replay/capture/CaptureStrategy.kt | 213 ++++++++++- .../replay/capture/SessionCaptureStrategy.kt | 82 ++-- .../sentry/android/replay/util/Executors.kt | 20 + .../sentry/android/replay/util/Persistable.kt | 53 +++ .../replay/video/SimpleVideoEncoder.kt | 2 +- .../DefaultReplayBreadcrumbConverterTest.kt | 22 ++ .../sentry/android/replay/ReplayCacheTest.kt | 269 ++++++++++++- .../android/replay/ReplayIntegrationTest.kt | 221 +++++++++-- .../ReplayIntegrationWithRecorderTest.kt | 5 +- .../sentry/android/replay/ReplaySmokeTest.kt | 13 +- .../capture/BufferCaptureStrategyTest.kt | 270 +++++++++++++ .../capture/SessionCaptureStrategyTest.kt | 355 ++++++++++++++++++ sentry/api/sentry.api | 24 +- sentry/src/main/java/io/sentry/Baggage.java | 9 +- .../main/java/io/sentry/IOptionsObserver.java | 2 + .../main/java/io/sentry/IScopeObserver.java | 5 +- .../java/io/sentry/NoOpReplayController.java | 6 +- .../java/io/sentry/PropagationContext.java | 6 + .../main/java/io/sentry/ReplayController.java | 4 +- .../main/java/io/sentry/ReplayRecording.java | 4 +- sentry/src/main/java/io/sentry/Scope.java | 15 +- .../java/io/sentry/ScopeObserverAdapter.java | 6 +- sentry/src/main/java/io/sentry/Sentry.java | 2 + .../src/main/java/io/sentry/SentryClient.java | 40 +- .../java/io/sentry/SentryEnvelopeItem.java | 10 +- .../cache/PersistingOptionsObserver.java | 10 + .../sentry/cache/PersistingScopeObserver.java | 23 +- .../java/io/sentry/protocol/Contexts.java | 1 + sentry/src/test/java/io/sentry/ScopeTest.kt | 24 +- .../test/java/io/sentry/SentryClientTest.kt | 114 ++++-- .../java/io/sentry/SentryEnvelopeItemTest.kt | 29 +- sentry/src/test/java/io/sentry/SentryTest.kt | 8 + .../cache/PersistingOptionsObserverTest.kt | 36 +- .../cache/PersistingScopeObserverTest.kt | 83 ++-- 43 files changed, 2488 insertions(+), 556 deletions(-) create mode 100644 sentry-android-replay/src/main/java/io/sentry/android/replay/util/Persistable.kt create mode 100644 sentry-android-replay/src/test/java/io/sentry/android/replay/capture/BufferCaptureStrategyTest.kt create mode 100644 sentry-android-replay/src/test/java/io/sentry/android/replay/capture/SessionCaptureStrategyTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e24537f31..7037e72370 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,19 @@ ## Unreleased +### Features + +- Session Replay: ([#3565](https://github.com/getsentry/sentry-java/pull/3565)) ([#3609](https://github.com/getsentry/sentry-java/pull/3609)) + - Capture remaining replay segment for ANRs on next app launch + - Capture remaining replay segment for unhandled crashes on next app launch + +### Fixes + +- Session Replay: ([#3565](https://github.com/getsentry/sentry-java/pull/3565)) ([#3609](https://github.com/getsentry/sentry-java/pull/3609)) + - Fix stopping replay in `session` mode at 1 hour deadline + - Never encode full frames for a video segment, only do partial updates. This further reduces size of the replay segment + - Use propagation context when no active transaction for ANRs + ### Dependencies - Bump Spring Boot to 3.3.2 ([#3541](https://github.com/getsentry/sentry-java/pull/3541)) diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2EventProcessor.java b/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2EventProcessor.java index 45f997542b..b1751d5cc8 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2EventProcessor.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2EventProcessor.java @@ -4,16 +4,19 @@ import static io.sentry.cache.PersistingOptionsObserver.ENVIRONMENT_FILENAME; import static io.sentry.cache.PersistingOptionsObserver.PROGUARD_UUID_FILENAME; import static io.sentry.cache.PersistingOptionsObserver.RELEASE_FILENAME; +import static io.sentry.cache.PersistingOptionsObserver.REPLAY_ERROR_SAMPLE_RATE_FILENAME; import static io.sentry.cache.PersistingOptionsObserver.SDK_VERSION_FILENAME; import static io.sentry.cache.PersistingScopeObserver.BREADCRUMBS_FILENAME; import static io.sentry.cache.PersistingScopeObserver.CONTEXTS_FILENAME; import static io.sentry.cache.PersistingScopeObserver.EXTRAS_FILENAME; import static io.sentry.cache.PersistingScopeObserver.FINGERPRINT_FILENAME; import static io.sentry.cache.PersistingScopeObserver.LEVEL_FILENAME; +import static io.sentry.cache.PersistingScopeObserver.REPLAY_FILENAME; import static io.sentry.cache.PersistingScopeObserver.REQUEST_FILENAME; import static io.sentry.cache.PersistingScopeObserver.TRACE_FILENAME; import static io.sentry.cache.PersistingScopeObserver.TRANSACTION_FILENAME; import static io.sentry.cache.PersistingScopeObserver.USER_FILENAME; +import static io.sentry.protocol.Contexts.REPLAY_ID; import android.annotation.SuppressLint; import android.app.ActivityManager; @@ -51,6 +54,8 @@ import io.sentry.protocol.SentryTransaction; import io.sentry.protocol.User; import io.sentry.util.HintUtils; +import java.io.File; +import java.security.SecureRandom; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; @@ -78,13 +83,24 @@ public final class AnrV2EventProcessor implements BackfillingEventProcessor { private final @NotNull SentryExceptionFactory sentryExceptionFactory; + private final @Nullable SecureRandom random; + public AnrV2EventProcessor( final @NotNull Context context, final @NotNull SentryAndroidOptions options, final @NotNull BuildInfoProvider buildInfoProvider) { + this(context, options, buildInfoProvider, null); + } + + AnrV2EventProcessor( + final @NotNull Context context, + final @NotNull SentryAndroidOptions options, + final @NotNull BuildInfoProvider buildInfoProvider, + final @Nullable SecureRandom random) { this.context = context; this.options = options; this.buildInfoProvider = buildInfoProvider; + this.random = random; final SentryStackTraceFactory sentryStackTraceFactory = new SentryStackTraceFactory(this.options); @@ -151,6 +167,72 @@ private void backfillScope(final @NotNull SentryEvent event, final @NotNull Obje setFingerprints(event, hint); setLevel(event); setTrace(event); + setReplayId(event); + } + + private boolean sampleReplay(final @NotNull SentryEvent event) { + final @Nullable String replayErrorSampleRate = + PersistingOptionsObserver.read(options, REPLAY_ERROR_SAMPLE_RATE_FILENAME, String.class); + + if (replayErrorSampleRate == null) { + return false; + } + + try { + // we have to sample here with the old sample rate, because it may change between app launches + final @NotNull SecureRandom random = this.random != null ? this.random : new SecureRandom(); + final double replayErrorSampleRateDouble = Double.parseDouble(replayErrorSampleRate); + if (replayErrorSampleRateDouble < random.nextDouble()) { + options + .getLogger() + .log( + SentryLevel.DEBUG, + "Not capturing replay for ANR %s due to not being sampled.", + event.getEventId()); + return false; + } + } catch (Throwable e) { + options.getLogger().log(SentryLevel.ERROR, "Error parsing replay sample rate.", e); + return false; + } + + return true; + } + + private void setReplayId(final @NotNull SentryEvent event) { + @Nullable + String persistedReplayId = PersistingScopeObserver.read(options, REPLAY_FILENAME, String.class); + final @NotNull File replayFolder = + new File(options.getCacheDirPath(), "replay_" + persistedReplayId); + if (!replayFolder.exists()) { + if (!sampleReplay(event)) { + return; + } + // if the replay folder does not exist (e.g. running in buffer mode), we need to find the + // latest replay folder that was modified before the ANR event. + persistedReplayId = null; + long lastModified = Long.MIN_VALUE; + final File[] dirs = new File(options.getCacheDirPath()).listFiles(); + if (dirs != null) { + for (File dir : dirs) { + if (dir.isDirectory() && dir.getName().startsWith("replay_")) { + if (dir.lastModified() > lastModified + && dir.lastModified() <= event.getTimestamp().getTime()) { + lastModified = dir.lastModified(); + persistedReplayId = dir.getName().substring("replay_".length()); + } + } + } + } + } + + if (persistedReplayId == null) { + return; + } + + // store the relevant replayId so ReplayIntegration can pick it up and finalize that replay + PersistingScopeObserver.store(options, persistedReplayId, REPLAY_FILENAME); + event.getContexts().put(REPLAY_ID, persistedReplayId); } private void setTrace(final @NotNull SentryEvent event) { diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/AnrV2EventProcessorTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/AnrV2EventProcessorTest.kt index b581856fe0..80ae946711 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/AnrV2EventProcessorTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/AnrV2EventProcessorTest.kt @@ -15,18 +15,20 @@ import io.sentry.SentryEvent import io.sentry.SentryLevel import io.sentry.SentryLevel.DEBUG import io.sentry.SpanContext -import io.sentry.cache.PersistingOptionsObserver import io.sentry.cache.PersistingOptionsObserver.DIST_FILENAME import io.sentry.cache.PersistingOptionsObserver.ENVIRONMENT_FILENAME import io.sentry.cache.PersistingOptionsObserver.OPTIONS_CACHE import io.sentry.cache.PersistingOptionsObserver.PROGUARD_UUID_FILENAME import io.sentry.cache.PersistingOptionsObserver.RELEASE_FILENAME +import io.sentry.cache.PersistingOptionsObserver.REPLAY_ERROR_SAMPLE_RATE_FILENAME import io.sentry.cache.PersistingOptionsObserver.SDK_VERSION_FILENAME +import io.sentry.cache.PersistingScopeObserver import io.sentry.cache.PersistingScopeObserver.BREADCRUMBS_FILENAME import io.sentry.cache.PersistingScopeObserver.CONTEXTS_FILENAME import io.sentry.cache.PersistingScopeObserver.EXTRAS_FILENAME import io.sentry.cache.PersistingScopeObserver.FINGERPRINT_FILENAME import io.sentry.cache.PersistingScopeObserver.LEVEL_FILENAME +import io.sentry.cache.PersistingScopeObserver.REPLAY_FILENAME import io.sentry.cache.PersistingScopeObserver.REQUEST_FILENAME import io.sentry.cache.PersistingScopeObserver.SCOPE_CACHE import io.sentry.cache.PersistingScopeObserver.TAGS_FILENAME @@ -44,6 +46,7 @@ import io.sentry.protocol.OperatingSystem import io.sentry.protocol.Request import io.sentry.protocol.Response import io.sentry.protocol.SdkVersion +import io.sentry.protocol.SentryId import io.sentry.protocol.SentryStackFrame import io.sentry.protocol.SentryStackTrace import io.sentry.protocol.SentryThread @@ -75,7 +78,9 @@ class AnrV2EventProcessorTest { val tmpDir = TemporaryFolder() class Fixture { - + companion object { + const val REPLAY_ID = "64cf554cc8d74c6eafa3e08b7c984f6d" + } val buildInfo = mock() lateinit var context: Context val options = SentryAndroidOptions().apply { @@ -87,7 +92,8 @@ class AnrV2EventProcessorTest { dir: TemporaryFolder, currentSdk: Int = Build.VERSION_CODES.LOLLIPOP, populateScopeCache: Boolean = false, - populateOptionsCache: Boolean = false + populateOptionsCache: Boolean = false, + replayErrorSampleRate: Double? = null ): AnrV2EventProcessor { options.cacheDirPath = dir.newFolder().absolutePath options.environment = "release" @@ -118,6 +124,7 @@ class AnrV2EventProcessorTest { REQUEST_FILENAME, Request().apply { url = "google.com"; method = "GET" } ) + persistScope(REPLAY_FILENAME, SentryId(REPLAY_ID)) } if (populateOptionsCache) { @@ -126,7 +133,10 @@ class AnrV2EventProcessorTest { persistOptions(SDK_VERSION_FILENAME, SdkVersion("sentry.java.android", "6.15.0")) persistOptions(DIST_FILENAME, "232") persistOptions(ENVIRONMENT_FILENAME, "debug") - persistOptions(PersistingOptionsObserver.TAGS_FILENAME, mapOf("option" to "tag")) + persistOptions(TAGS_FILENAME, mapOf("option" to "tag")) + replayErrorSampleRate?.let { + persistOptions(REPLAY_ERROR_SAMPLE_RATE_FILENAME, it.toString()) + } } return AnrV2EventProcessor(context, options, buildInfo) @@ -544,6 +554,65 @@ class AnrV2EventProcessorTest { assertEquals(listOf("{{ default }}", "foreground-anr"), processedForeground.fingerprints) } + @Test + fun `sets replayId when replay folder exists`() { + val hint = HintUtils.createWithTypeCheckHint(BackfillableHint()) + val processor = fixture.getSut(tmpDir, populateScopeCache = true) + val replayFolder = File(fixture.options.cacheDirPath, "replay_${Fixture.REPLAY_ID}").also { it.mkdirs() } + + val processed = processor.process(SentryEvent(), hint)!! + + assertEquals(Fixture.REPLAY_ID, processed.contexts[Contexts.REPLAY_ID].toString()) + } + + @Test + fun `does not set replayId when replay folder does not exist and no sample rate persisted`() { + val hint = HintUtils.createWithTypeCheckHint(BackfillableHint()) + val processor = fixture.getSut(tmpDir, populateScopeCache = true) + val replayId1 = SentryId() + val replayId2 = SentryId() + + val replayFolder1 = File(fixture.options.cacheDirPath, "replay_$replayId1").also { it.mkdirs() } + val replayFolder2 = File(fixture.options.cacheDirPath, "replay_$replayId2").also { it.mkdirs() } + + val processed = processor.process(SentryEvent(), hint)!! + + assertNull(processed.contexts[Contexts.REPLAY_ID]) + } + + @Test + fun `does not set replayId when replay folder does not exist and not sampled`() { + val hint = HintUtils.createWithTypeCheckHint(BackfillableHint()) + val processor = fixture.getSut(tmpDir, populateScopeCache = true, populateOptionsCache = true, replayErrorSampleRate = 0.0) + val replayId1 = SentryId() + val replayId2 = SentryId() + + val replayFolder1 = File(fixture.options.cacheDirPath, "replay_$replayId1").also { it.mkdirs() } + val replayFolder2 = File(fixture.options.cacheDirPath, "replay_$replayId2").also { it.mkdirs() } + + val processed = processor.process(SentryEvent(), hint)!! + + assertNull(processed.contexts[Contexts.REPLAY_ID]) + } + + @Test + fun `set replayId of the last modified folder`() { + val hint = HintUtils.createWithTypeCheckHint(BackfillableHint()) + val processor = fixture.getSut(tmpDir, populateScopeCache = true, populateOptionsCache = true, replayErrorSampleRate = 1.0) + val replayId1 = SentryId() + val replayId2 = SentryId() + + val replayFolder1 = File(fixture.options.cacheDirPath, "replay_$replayId1").also { it.mkdirs() } + val replayFolder2 = File(fixture.options.cacheDirPath, "replay_$replayId2").also { it.mkdirs() } + replayFolder1.setLastModified(1000) + replayFolder2.setLastModified(500) + + val processed = processor.process(SentryEvent(), hint)!! + + assertEquals(replayId1.toString(), processed.contexts[Contexts.REPLAY_ID].toString()) + assertEquals(replayId1.toString(), PersistingScopeObserver.read(fixture.options, REPLAY_FILENAME, String::class.java)) + } + private fun processEvent( hint: Hint, populateScopeCache: Boolean = false, diff --git a/sentry-android-replay/api/sentry-android-replay.api b/sentry-android-replay/api/sentry-android-replay.api index e8b85a0ae9..2a81b45b82 100644 --- a/sentry-android-replay/api/sentry-android-replay.api +++ b/sentry-android-replay/api/sentry-android-replay.api @@ -34,18 +34,25 @@ public abstract interface class io/sentry/android/replay/Recorder : java/io/Clos } public final class io/sentry/android/replay/ReplayCache : java/io/Closeable { + public static final field Companion Lio/sentry/android/replay/ReplayCache$Companion; public fun (Lio/sentry/SentryOptions;Lio/sentry/protocol/SentryId;Lio/sentry/android/replay/ScreenshotRecorderConfig;)V public final fun addFrame (Ljava/io/File;J)V public fun close ()V public final fun createVideoOf (JJIIILjava/io/File;)Lio/sentry/android/replay/GeneratedVideo; public static synthetic fun createVideoOf$default (Lio/sentry/android/replay/ReplayCache;JJIIILjava/io/File;ILjava/lang/Object;)Lio/sentry/android/replay/GeneratedVideo; + public final fun persistSegmentValues (Ljava/lang/String;Ljava/lang/String;)V public final fun rotate (J)V } +public final class io/sentry/android/replay/ReplayCache$Companion { + public final fun makeReplayCacheDir (Lio/sentry/SentryOptions;Lio/sentry/protocol/SentryId;)Ljava/io/File; +} + public final class io/sentry/android/replay/ReplayIntegration : android/content/ComponentCallbacks, io/sentry/Integration, io/sentry/ReplayController, io/sentry/android/replay/ScreenshotRecorderCallback, io/sentry/android/replay/TouchRecorderCallback, java/io/Closeable { public fun (Landroid/content/Context;Lio/sentry/transport/ICurrentDateProvider;)V public fun (Landroid/content/Context;Lio/sentry/transport/ICurrentDateProvider;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;)V public synthetic fun (Landroid/content/Context;Lio/sentry/transport/ICurrentDateProvider;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun captureReplay (Ljava/lang/Boolean;)V public fun close ()V public fun getBreadcrumbConverter ()Lio/sentry/ReplayBreadcrumbConverter; public final fun getReplayCacheDir ()Ljava/io/File; @@ -59,8 +66,6 @@ public final class io/sentry/android/replay/ReplayIntegration : android/content/ public fun pause ()V public fun register (Lio/sentry/IHub;Lio/sentry/SentryOptions;)V public fun resume ()V - public fun sendReplay (Ljava/lang/Boolean;Ljava/lang/String;Lio/sentry/Hint;)V - public fun sendReplayForEvent (Lio/sentry/SentryEvent;Lio/sentry/Hint;)V public fun setBreadcrumbConverter (Lio/sentry/ReplayBreadcrumbConverter;)V public fun start ()V public fun stop ()V diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/DefaultReplayBreadcrumbConverter.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/DefaultReplayBreadcrumbConverter.kt index 504c4adf21..c95b72088a 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/DefaultReplayBreadcrumbConverter.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/DefaultReplayBreadcrumbConverter.kt @@ -131,14 +131,23 @@ public open class DefaultReplayBreadcrumbConverter : ReplayBreadcrumbConverter { private fun Breadcrumb.toRRWebSpanEvent(): RRWebSpanEvent { val breadcrumb = this + val httpStartTimestamp = breadcrumb.data[SpanDataConvention.HTTP_START_TIMESTAMP] + val httpEndTimestamp = breadcrumb.data[SpanDataConvention.HTTP_END_TIMESTAMP] return RRWebSpanEvent().apply { timestamp = breadcrumb.timestamp.time op = "resource.http" description = breadcrumb.data["url"] as String - startTimestamp = - (breadcrumb.data[SpanDataConvention.HTTP_START_TIMESTAMP] as Long) / 1000.0 - endTimestamp = - (breadcrumb.data[SpanDataConvention.HTTP_END_TIMESTAMP] as Long) / 1000.0 + // can be double if it was serialized to disk + startTimestamp = if (httpStartTimestamp is Double) { + httpStartTimestamp / 1000.0 + } else { + (httpStartTimestamp as Long) / 1000.0 + } + endTimestamp = if (httpEndTimestamp is Double) { + httpEndTimestamp / 1000.0 + } else { + (httpEndTimestamp as Long) / 1000.0 + } val breadcrumbData = mutableMapOf() for ((key, value) in breadcrumb.data) { diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt index f49abfaa84..549db2566b 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt @@ -3,15 +3,25 @@ package io.sentry.android.replay import android.graphics.Bitmap import android.graphics.Bitmap.CompressFormat.JPEG import android.graphics.BitmapFactory +import io.sentry.DateUtils +import io.sentry.ReplayRecording import io.sentry.SentryLevel.DEBUG import io.sentry.SentryLevel.ERROR import io.sentry.SentryLevel.WARNING import io.sentry.SentryOptions +import io.sentry.SentryReplayEvent.ReplayType +import io.sentry.SentryReplayEvent.ReplayType.SESSION import io.sentry.android.replay.video.MuxerConfig import io.sentry.android.replay.video.SimpleVideoEncoder import io.sentry.protocol.SentryId +import io.sentry.rrweb.RRWebEvent +import io.sentry.util.FileUtils import java.io.Closeable import java.io.File +import java.io.StringReader +import java.util.Date +import java.util.LinkedList +import java.util.concurrent.atomic.AtomicBoolean /** * A basic in-memory and disk cache for Session Replay frames. Frames are stored in order under the @@ -50,24 +60,30 @@ public class ReplayCache internal constructor( ).also { it.start() } }) + private val isClosed = AtomicBoolean(false) private val encoderLock = Any() private var encoder: SimpleVideoEncoder? = null internal val replayCacheDir: File? by lazy { - if (options.cacheDirPath.isNullOrEmpty()) { - options.logger.log( - WARNING, - "SentryOptions.cacheDirPath is not set, session replay is no-op" - ) - null - } else { - File(options.cacheDirPath!!, "replay_$replayId").also { it.mkdirs() } - } + makeReplayCacheDir(options, replayId) } // TODO: maybe account for multi-threaded access internal val frames = mutableListOf() + private val ongoingSegment = LinkedHashMap() + private val ongoingSegmentFile: File? by lazy { + if (replayCacheDir == null) { + return@lazy null + } + + val file = File(replayCacheDir, ONGOING_SEGMENT) + if (!file.exists()) { + file.createNewFile() + } + file + } + /** * Stores the current frame screenshot to in-memory cache as well as disk with [frameTimestamp] * as filename. Uses [Bitmap.CompressFormat.JPEG] format with quality 80. The frames are stored @@ -79,7 +95,7 @@ public class ReplayCache internal constructor( * @param frameTimestamp the timestamp when the frame screenshot was taken */ internal fun addFrame(bitmap: Bitmap, frameTimestamp: Long) { - if (replayCacheDir == null) { + if (replayCacheDir == null || bitmap.isRecycled) { return } @@ -136,6 +152,9 @@ public class ReplayCache internal constructor( width: Int, videoFile: File = File(replayCacheDir, "$segmentId.mp4") ): GeneratedVideo? { + if (videoFile.exists() && videoFile.length() > 0) { + videoFile.delete() + } if (frames.isEmpty()) { options.logger.log( DEBUG, @@ -237,9 +256,181 @@ public class ReplayCache internal constructor( encoder?.release() encoder = null } + isClosed.set(true) + } + + // TODO: it's awful, choose a better serialization format + @Synchronized + fun persistSegmentValues(key: String, value: String?) { + if (isClosed.get()) { + return + } + if (ongoingSegment.isEmpty()) { + ongoingSegmentFile?.useLines { lines -> + lines.associateTo(ongoingSegment) { + val (k, v) = it.split("=", limit = 2) + k to v + } + } + } + if (value == null) { + ongoingSegment.remove(key) + } else { + ongoingSegment[key] = value + } + ongoingSegmentFile?.writeText(ongoingSegment.entries.joinToString("\n") { (k, v) -> "$k=$v" }) + } + + companion object { + internal const val ONGOING_SEGMENT = ".ongoing_segment" + + internal const val SEGMENT_KEY_HEIGHT = "config.height" + internal const val SEGMENT_KEY_WIDTH = "config.width" + internal const val SEGMENT_KEY_FRAME_RATE = "config.frame-rate" + internal const val SEGMENT_KEY_BIT_RATE = "config.bit-rate" + internal const val SEGMENT_KEY_TIMESTAMP = "segment.timestamp" + internal const val SEGMENT_KEY_REPLAY_ID = "replay.id" + internal const val SEGMENT_KEY_REPLAY_TYPE = "replay.type" + internal const val SEGMENT_KEY_REPLAY_SCREEN_AT_START = "replay.screen-at-start" + internal const val SEGMENT_KEY_REPLAY_RECORDING = "replay.recording" + internal const val SEGMENT_KEY_ID = "segment.id" + + fun makeReplayCacheDir(options: SentryOptions, replayId: SentryId): File? { + return if (options.cacheDirPath.isNullOrEmpty()) { + options.logger.log( + WARNING, + "SentryOptions.cacheDirPath is not set, session replay is no-op" + ) + null + } else { + File(options.cacheDirPath!!, "replay_$replayId").also { it.mkdirs() } + } + } + + internal fun fromDisk(options: SentryOptions, replayId: SentryId, replayCacheProvider: ((replayId: SentryId, recorderConfig: ScreenshotRecorderConfig) -> ReplayCache)? = null): LastSegmentData? { + val replayCacheDir = makeReplayCacheDir(options, replayId) + val lastSegmentFile = File(replayCacheDir, ONGOING_SEGMENT) + if (!lastSegmentFile.exists()) { + options.logger.log(DEBUG, "No ongoing segment found for replay: %s", replayId) + FileUtils.deleteRecursively(replayCacheDir) + return null + } + + val lastSegment = LinkedHashMap() + lastSegmentFile.useLines { lines -> + lines.associateTo(lastSegment) { + val (k, v) = it.split("=", limit = 2) + k to v + } + } + + val height = lastSegment[SEGMENT_KEY_HEIGHT]?.toIntOrNull() + val width = lastSegment[SEGMENT_KEY_WIDTH]?.toIntOrNull() + val frameRate = lastSegment[SEGMENT_KEY_FRAME_RATE]?.toIntOrNull() + val bitRate = lastSegment[SEGMENT_KEY_BIT_RATE]?.toIntOrNull() + val segmentId = lastSegment[SEGMENT_KEY_ID]?.toIntOrNull() + val segmentTimestamp = try { + DateUtils.getDateTime(lastSegment[SEGMENT_KEY_TIMESTAMP].orEmpty()) + } catch (e: Throwable) { + null + } + val replayType = try { + ReplayType.valueOf(lastSegment[SEGMENT_KEY_REPLAY_TYPE].orEmpty()) + } catch (e: Throwable) { + null + } + if (height == null || width == null || frameRate == null || bitRate == null || + (segmentId == null || segmentId == -1) || segmentTimestamp == null || replayType == null + ) { + options.logger.log( + DEBUG, + "Incorrect segment values found for replay: %s, deleting the replay", + replayId + ) + FileUtils.deleteRecursively(replayCacheDir) + return null + } + + val recorderConfig = ScreenshotRecorderConfig( + recordingHeight = height, + recordingWidth = width, + frameRate = frameRate, + bitRate = bitRate, + // these are not used for already captured frames, so we just hardcode them + scaleFactorX = 1.0f, + scaleFactorY = 1.0f + ) + + val cache = replayCacheProvider?.invoke(replayId, recorderConfig) ?: ReplayCache(options, replayId, recorderConfig) + cache.replayCacheDir?.listFiles { dir, name -> + if (name.endsWith(".jpg")) { + val file = File(dir, name) + val timestamp = file.nameWithoutExtension.toLongOrNull() + if (timestamp != null) { + cache.addFrame(file, timestamp) + } + } + false + } + + if (cache.frames.isEmpty()) { + options.logger.log( + DEBUG, + "No frames found for replay: %s, deleting the replay", + replayId + ) + FileUtils.deleteRecursively(replayCacheDir) + return null + } + + cache.frames.sortBy { it.timestamp } + // TODO: this should be removed when we start sending buffered segments on next launch + val normalizedSegmentId = if (replayType == SESSION) segmentId else 0 + val normalizedTimestamp = if (replayType == SESSION) { + segmentTimestamp + } else { + // in buffer mode we have to set the timestamp of the first frame as the actual start + DateUtils.getDateTime(cache.frames.first().timestamp) + } + + // add one frame to include breadcrumbs/events happened after the frame was captured + val duration = cache.frames.last().timestamp - normalizedTimestamp.time + (1000 / frameRate) + + val events = lastSegment[SEGMENT_KEY_REPLAY_RECORDING]?.let { + val reader = StringReader(it) + val recording = options.serializer.deserialize(reader, ReplayRecording::class.java) + if (recording?.payload != null) { + LinkedList(recording.payload!!) + } else { + null + } + } ?: emptyList() + + return LastSegmentData( + recorderConfig = recorderConfig, + cache = cache, + timestamp = normalizedTimestamp, + id = normalizedSegmentId, + duration = duration, + replayType = replayType, + screenAtStart = lastSegment[SEGMENT_KEY_REPLAY_SCREEN_AT_START], + events = events.sortedBy { it.timestamp } + ) + } } } +internal data class LastSegmentData( + val recorderConfig: ScreenshotRecorderConfig, + val cache: ReplayCache, + val timestamp: Date, + val id: Int, + val duration: Long, + val replayType: ReplayType, + val screenAtStart: String?, + val events: List +) + internal data class ReplayFrame( val screenshot: File, val timestamp: Long diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt index d207cf0331..e99aec2c90 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt @@ -6,30 +6,38 @@ import android.content.res.Configuration import android.graphics.Bitmap import android.os.Build import android.view.MotionEvent -import io.sentry.Hint +import io.sentry.Breadcrumb import io.sentry.IHub import io.sentry.Integration import io.sentry.NoOpReplayBreadcrumbConverter import io.sentry.ReplayBreadcrumbConverter import io.sentry.ReplayController import io.sentry.ScopeObserverAdapter -import io.sentry.SentryEvent import io.sentry.SentryIntegrationPackageStorage import io.sentry.SentryLevel.DEBUG import io.sentry.SentryLevel.INFO import io.sentry.SentryOptions import io.sentry.android.replay.capture.BufferCaptureStrategy import io.sentry.android.replay.capture.CaptureStrategy +import io.sentry.android.replay.capture.CaptureStrategy.ReplaySegment import io.sentry.android.replay.capture.SessionCaptureStrategy import io.sentry.android.replay.util.MainLooperHandler import io.sentry.android.replay.util.sample +import io.sentry.android.replay.util.submitSafely +import io.sentry.cache.PersistingScopeObserver +import io.sentry.cache.PersistingScopeObserver.BREADCRUMBS_FILENAME +import io.sentry.cache.PersistingScopeObserver.REPLAY_FILENAME +import io.sentry.hints.Backfillable import io.sentry.protocol.Contexts import io.sentry.protocol.SentryId import io.sentry.transport.ICurrentDateProvider +import io.sentry.util.FileUtils +import io.sentry.util.HintUtils import io.sentry.util.IntegrationUtils.addIntegrationToSdkVersion import java.io.Closeable import java.io.File import java.security.SecureRandom +import java.util.LinkedList import java.util.concurrent.atomic.AtomicBoolean public class ReplayIntegration( @@ -112,6 +120,8 @@ public class ReplayIntegration( addIntegrationToSdkVersion(javaClass) SentryIntegrationPackageStorage.getInstance() .addPackage("maven:io.sentry:sentry-android-replay", BuildConfig.VERSION_NAME) + + finalizePreviousReplay() } override fun isRecording() = isRecording.get() @@ -138,12 +148,12 @@ public class ReplayIntegration( recorderConfig = recorderConfigProvider?.invoke(false) ?: ScreenshotRecorderConfig.from(context, options.experimental.sessionReplay) captureStrategy = replayCaptureStrategyProvider?.invoke(isFullSession) ?: if (isFullSession) { - SessionCaptureStrategy(options, hub, dateProvider, recorderConfig, replayCacheProvider = replayCacheProvider) + SessionCaptureStrategy(options, hub, dateProvider, replayCacheProvider = replayCacheProvider) } else { - BufferCaptureStrategy(options, hub, dateProvider, recorderConfig, random, replayCacheProvider) + BufferCaptureStrategy(options, hub, dateProvider, random, replayCacheProvider = replayCacheProvider) } - captureStrategy?.start() + captureStrategy?.start(recorderConfig) recorder?.start(recorderConfig) } @@ -156,34 +166,23 @@ public class ReplayIntegration( recorder?.resume() } - override fun sendReplayForEvent(event: SentryEvent, hint: Hint) { - if (!isEnabled.get() || !isRecording.get()) { - return - } - - if (!(event.isErrored || event.isCrashed)) { - options.logger.log(DEBUG, "Event is not error or crash, not capturing for event %s", event.eventId) - return - } - - sendReplay(event.isCrashed, event.eventId.toString(), hint) - } - - override fun sendReplay(isCrashed: Boolean?, eventId: String?, hint: Hint?) { + override fun captureReplay(isTerminating: Boolean?) { if (!isEnabled.get() || !isRecording.get()) { return } - if (SentryId.EMPTY_ID.equals(captureStrategy?.currentReplayId?.get())) { - options.logger.log(DEBUG, "Replay id is not set, not capturing for event %s", eventId) + if (SentryId.EMPTY_ID.equals(captureStrategy?.currentReplayId)) { + options.logger.log(DEBUG, "Replay id is not set, not capturing for event") return } - captureStrategy?.sendReplayForEvent(isCrashed == true, eventId, hint, onSegmentSent = { captureStrategy?.currentSegment?.getAndIncrement() }) + captureStrategy?.captureReplay(isTerminating == true, onSegmentSent = { + captureStrategy?.currentSegment = captureStrategy?.currentSegment!! + 1 + }) captureStrategy = captureStrategy?.convert() } - override fun getReplayId(): SentryId = captureStrategy?.currentReplayId?.get() ?: SentryId.EMPTY_ID + override fun getReplayId(): SentryId = captureStrategy?.currentReplayId ?: SentryId.EMPTY_ID override fun setBreadcrumbConverter(converter: ReplayBreadcrumbConverter) { replayBreadcrumbConverter = converter @@ -257,4 +256,67 @@ public class ReplayIntegration( override fun onTouchEvent(event: MotionEvent) { captureStrategy?.onTouchEvent(event) } + + private fun cleanupReplays(unfinishedReplayId: String = "") { + // clean up old replays + options.cacheDirPath?.let { cacheDir -> + File(cacheDir).listFiles()?.forEach { file -> + val name = file.name + if (name.startsWith("replay_") && + !name.contains(replayId.toString()) && + !(unfinishedReplayId.isNotBlank() && name.contains(unfinishedReplayId)) + ) { + FileUtils.deleteRecursively(file) + } + } + } + } + + private fun finalizePreviousReplay() { + // TODO: read persisted options/scope values form the + // TODO: previous run and set them directly to the ReplayEvent so they don't get overwritten in MainEventProcessor + + options.executorService.submitSafely(options, "ReplayIntegration.finalize_previous_replay") { + val previousReplayIdString = PersistingScopeObserver.read(options, REPLAY_FILENAME, String::class.java) ?: run { + cleanupReplays() + return@submitSafely + } + val previousReplayId = SentryId(previousReplayIdString) + if (previousReplayId == SentryId.EMPTY_ID) { + cleanupReplays() + return@submitSafely + } + val lastSegment = ReplayCache.fromDisk(options, previousReplayId, replayCacheProvider) ?: run { + cleanupReplays() + return@submitSafely + } + val breadcrumbs = PersistingScopeObserver.read(options, BREADCRUMBS_FILENAME, List::class.java, Breadcrumb.Deserializer()) as? List + val segment = CaptureStrategy.createSegment( + hub = hub, + options = options, + duration = lastSegment.duration, + currentSegmentTimestamp = lastSegment.timestamp, + replayId = previousReplayId, + segmentId = lastSegment.id, + height = lastSegment.recorderConfig.recordingHeight, + width = lastSegment.recorderConfig.recordingWidth, + frameRate = lastSegment.recorderConfig.frameRate, + cache = lastSegment.cache, + replayType = lastSegment.replayType, + screenAtStart = lastSegment.screenAtStart, + breadcrumbs = breadcrumbs, + events = LinkedList(lastSegment.events) + ) + + if (segment is ReplaySegment.Created) { + val hint = HintUtils.createWithTypeCheckHint(PreviousReplayHint()) + segment.capture(hub, hint) + } + cleanupReplays(unfinishedReplayId = previousReplayIdString) // will be cleaned up after the envelope is assembled + } + } + + private class PreviousReplayHint : Backfillable { + override fun shouldEnrich(): Boolean = false + } } diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt index 79a75f816c..62bba00cd0 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt @@ -1,45 +1,55 @@ package io.sentry.android.replay.capture import android.view.MotionEvent +import io.sentry.Breadcrumb import io.sentry.DateUtils -import io.sentry.Hint import io.sentry.IHub -import io.sentry.ReplayRecording import io.sentry.SentryOptions -import io.sentry.SentryReplayEvent import io.sentry.SentryReplayEvent.ReplayType +import io.sentry.SentryReplayEvent.ReplayType.BUFFER import io.sentry.SentryReplayEvent.ReplayType.SESSION import io.sentry.android.replay.ReplayCache +import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_BIT_RATE +import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_FRAME_RATE +import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_HEIGHT +import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_ID +import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_REPLAY_ID +import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_REPLAY_RECORDING +import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_REPLAY_SCREEN_AT_START +import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_REPLAY_TYPE +import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_TIMESTAMP +import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_WIDTH import io.sentry.android.replay.ScreenshotRecorderConfig +import io.sentry.android.replay.capture.CaptureStrategy.Companion.createSegment +import io.sentry.android.replay.capture.CaptureStrategy.Companion.currentEventsLock +import io.sentry.android.replay.capture.CaptureStrategy.ReplaySegment +import io.sentry.android.replay.util.PersistableLinkedList import io.sentry.android.replay.util.gracefullyShutdown import io.sentry.android.replay.util.submitSafely import io.sentry.protocol.SentryId -import io.sentry.rrweb.RRWebBreadcrumbEvent import io.sentry.rrweb.RRWebEvent import io.sentry.rrweb.RRWebIncrementalSnapshotEvent import io.sentry.rrweb.RRWebInteractionEvent import io.sentry.rrweb.RRWebInteractionEvent.InteractionType import io.sentry.rrweb.RRWebInteractionMoveEvent import io.sentry.rrweb.RRWebInteractionMoveEvent.Position -import io.sentry.rrweb.RRWebMetaEvent -import io.sentry.rrweb.RRWebVideoEvent import io.sentry.transport.ICurrentDateProvider -import io.sentry.util.FileUtils import java.io.File import java.util.Date import java.util.LinkedList import java.util.concurrent.Executors import java.util.concurrent.ScheduledExecutorService import java.util.concurrent.ThreadFactory -import java.util.concurrent.atomic.AtomicInteger +import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicLong import java.util.concurrent.atomic.AtomicReference +import kotlin.properties.ReadWriteProperty +import kotlin.reflect.KProperty internal abstract class BaseCaptureStrategy( private val options: SentryOptions, private val hub: IHub?, private val dateProvider: ICurrentDateProvider, - protected var recorderConfig: ScreenshotRecorderConfig, executor: ScheduledExecutorService? = null, private val replayCacheProvider: ((replayId: SentryId, recorderConfig: ScreenshotRecorderConfig) -> ReplayCache)? = null ) : CaptureStrategy { @@ -52,16 +62,38 @@ internal abstract class BaseCaptureStrategy( private const val CAPTURE_MOVE_EVENT_THRESHOLD = 500 } + private val persistingExecutor: ScheduledExecutorService by lazy { + Executors.newSingleThreadScheduledExecutor(ReplayPersistingExecutorServiceThreadFactory()) + } + + protected val isTerminating = AtomicBoolean(false) protected var cache: ReplayCache? = null - protected val segmentTimestamp = AtomicReference() + protected var recorderConfig: ScreenshotRecorderConfig by persistableAtomic { _, _, newValue -> + if (newValue == null) { + // recorderConfig is only nullable on init, but never after + return@persistableAtomic + } + cache?.persistSegmentValues(SEGMENT_KEY_HEIGHT, newValue.recordingHeight.toString()) + cache?.persistSegmentValues(SEGMENT_KEY_WIDTH, newValue.recordingWidth.toString()) + cache?.persistSegmentValues(SEGMENT_KEY_FRAME_RATE, newValue.frameRate.toString()) + cache?.persistSegmentValues(SEGMENT_KEY_BIT_RATE, newValue.bitRate.toString()) + } + protected var segmentTimestamp by persistableAtomicNullable(propertyName = SEGMENT_KEY_TIMESTAMP) { _, _, newValue -> + cache?.persistSegmentValues(SEGMENT_KEY_TIMESTAMP, if (newValue == null) null else DateUtils.getTimestamp(newValue)) + } protected val replayStartTimestamp = AtomicLong() - protected val screenAtStart = AtomicReference() - override val currentReplayId = AtomicReference(SentryId.EMPTY_ID) - override val currentSegment = AtomicInteger(0) + protected var screenAtStart by persistableAtomicNullable(propertyName = SEGMENT_KEY_REPLAY_SCREEN_AT_START) + override var currentReplayId: SentryId by persistableAtomic(initialValue = SentryId.EMPTY_ID, propertyName = SEGMENT_KEY_REPLAY_ID) + override var currentSegment: Int by persistableAtomic(initialValue = -1, propertyName = SEGMENT_KEY_ID) override val replayCacheDir: File? get() = cache?.replayCacheDir - protected val currentEvents = LinkedList() - private val currentEventsLock = Any() + private var replayType by persistableAtomic(propertyName = SEGMENT_KEY_REPLAY_TYPE) + protected val currentEvents: LinkedList = PersistableLinkedList( + propertyName = SEGMENT_KEY_REPLAY_RECORDING, + options, + persistingExecutor, + cacheProvider = { cache } + ) private val currentPositions = LinkedHashMap>(10) private var touchMoveBaseline = 0L private var lastCapturedMoveEvent = 0L @@ -70,170 +102,67 @@ internal abstract class BaseCaptureStrategy( executor ?: Executors.newSingleThreadScheduledExecutor(ReplayExecutorServiceThreadFactory()) } - override fun start(segmentId: Int, replayId: SentryId, cleanupOldReplays: Boolean) { - currentSegment.set(segmentId) - currentReplayId.set(replayId) - - if (cleanupOldReplays) { - replayExecutor.submitSafely(options, "$TAG.replays_cleanup") { - // clean up old replays - options.cacheDirPath?.let { cacheDir -> - File(cacheDir).listFiles { dir, name -> - // TODO: also exclude persisted replay_id from scope when implementing ANRs - if (name.startsWith("replay_") && !name.contains( - currentReplayId.get().toString() - ) - ) { - FileUtils.deleteRecursively(File(dir, name)) - } - false - } - } - } - } + override fun start( + recorderConfig: ScreenshotRecorderConfig, + segmentId: Int, + replayId: SentryId + ) { + cache = replayCacheProvider?.invoke(replayId, recorderConfig) ?: ReplayCache(options, replayId, recorderConfig) - cache = - replayCacheProvider?.invoke(replayId, recorderConfig) ?: ReplayCache(options, replayId, recorderConfig) + // TODO: this should be persisted even after conversion + replayType = if (this is SessionCaptureStrategy) SESSION else BUFFER + this.recorderConfig = recorderConfig + currentSegment = segmentId + currentReplayId = replayId - // TODO: replace it with dateProvider.currentTimeMillis to also test it - segmentTimestamp.set(DateUtils.getCurrentDateTime()) + segmentTimestamp = DateUtils.getCurrentDateTime() replayStartTimestamp.set(dateProvider.currentTimeMillis) - // TODO: finalize old recording if there's some left on disk and send it using the replayId from persisted scope (e.g. for ANRs) } override fun resume() { - // TODO: replace it with dateProvider.currentTimeMillis to also test it - segmentTimestamp.set(DateUtils.getCurrentDateTime()) + segmentTimestamp = DateUtils.getCurrentDateTime() } override fun pause() = Unit override fun stop() { cache?.close() - currentSegment.set(0) + currentSegment = -1 replayStartTimestamp.set(0) - segmentTimestamp.set(null) - currentReplayId.set(SentryId.EMPTY_ID) + segmentTimestamp = null + currentReplayId = SentryId.EMPTY_ID } - protected fun createSegment( + protected fun createSegmentInternal( duration: Long, currentSegmentTimestamp: Date, replayId: SentryId, segmentId: Int, height: Int, width: Int, - replayType: ReplayType = SESSION - ): ReplaySegment { - val generatedVideo = cache?.createVideoOf( + replayType: ReplayType = SESSION, + cache: ReplayCache? = this.cache, + frameRate: Int = recorderConfig.frameRate, + screenAtStart: String? = this.screenAtStart, + breadcrumbs: List? = null, + events: LinkedList = this.currentEvents + ): ReplaySegment = + createSegment( + hub, + options, duration, - currentSegmentTimestamp.time, - segmentId, - height, - width - ) ?: return ReplaySegment.Failed - - val (video, frameCount, videoDuration) = generatedVideo - return buildReplay( - video, - replayId, currentSegmentTimestamp, + replayId, segmentId, height, width, - frameCount, - videoDuration, - replayType - ) - } - - private fun buildReplay( - video: File, - currentReplayId: SentryId, - segmentTimestamp: Date, - segmentId: Int, - height: Int, - width: Int, - frameCount: Int, - duration: Long, - replayType: ReplayType - ): ReplaySegment { - val endTimestamp = DateUtils.getDateTime(segmentTimestamp.time + duration) - val replay = SentryReplayEvent().apply { - eventId = currentReplayId - replayId = currentReplayId - this.segmentId = segmentId - this.timestamp = endTimestamp - replayStartTimestamp = segmentTimestamp - this.replayType = replayType - videoFile = video - } - - val recordingPayload = mutableListOf() - recordingPayload += RRWebMetaEvent().apply { - this.timestamp = segmentTimestamp.time - this.height = height - this.width = width - } - recordingPayload += RRWebVideoEvent().apply { - this.timestamp = segmentTimestamp.time - this.segmentId = segmentId - this.durationMs = duration - this.frameCount = frameCount - size = video.length() - frameRate = recorderConfig.frameRate - this.height = height - this.width = width - // TODO: support non-fullscreen windows later - left = 0 - top = 0 - } - - val urls = LinkedList() - hub?.configureScope { scope -> - scope.breadcrumbs.forEach { breadcrumb -> - if (breadcrumb.timestamp.time >= segmentTimestamp.time && - breadcrumb.timestamp.time < endTimestamp.time - ) { - val rrwebEvent = options - .replayController - .breadcrumbConverter - .convert(breadcrumb) - - if (rrwebEvent != null) { - recordingPayload += rrwebEvent - - // fill in the urls array from navigation breadcrumbs - if ((rrwebEvent as? RRWebBreadcrumbEvent)?.category == "navigation") { - urls.add(rrwebEvent.data!!["to"] as String) - } - } - } - } - } - - if (screenAtStart.get() != null && urls.firstOrNull() != screenAtStart.get()) { - urls.addFirst(screenAtStart.get()) - } - - rotateCurrentEvents(endTimestamp.time) { event -> - if (event.timestamp >= segmentTimestamp.time) { - recordingPayload += event - } - } - - val recording = ReplayRecording().apply { - this.segmentId = segmentId - payload = recordingPayload.sortedBy { it.timestamp } - } - - replay.urls = urls - return ReplaySegment.Created( - videoDuration = duration, - replay = replay, - recording = recording + replayType, + cache, + frameRate, + screenAtStart, + breadcrumbs, + events ) - } override fun onConfigurationChanged(recorderConfig: ScreenshotRecorderConfig) { this.recorderConfig = recorderConfig @@ -252,20 +181,6 @@ internal abstract class BaseCaptureStrategy( replayExecutor.gracefullyShutdown(options) } - protected fun rotateCurrentEvents( - until: Long, - callback: ((RRWebEvent) -> Unit)? = null - ) { - synchronized(currentEventsLock) { - var event = currentEvents.peek() - while (event != null && event.timestamp < until) { - callback?.invoke(event) - currentEvents.remove() - event = currentEvents.peek() - } - } - } - private class ReplayExecutorServiceThreadFactory : ThreadFactory { private var cnt = 0 override fun newThread(r: Runnable): Thread { @@ -275,25 +190,12 @@ internal abstract class BaseCaptureStrategy( } } - protected sealed class ReplaySegment { - object Failed : ReplaySegment() - data class Created( - val videoDuration: Long, - val replay: SentryReplayEvent, - val recording: ReplayRecording - ) : ReplaySegment() { - fun capture(hub: IHub?, hint: Hint = Hint()) { - hub?.captureReplay(replay, hint.apply { replayRecording = recording }) - } - - fun setSegmentId(segmentId: Int) { - replay.segmentId = segmentId - recording.payload?.forEach { - when (it) { - is RRWebVideoEvent -> it.segmentId = segmentId - } - } - } + private class ReplayPersistingExecutorServiceThreadFactory : ThreadFactory { + private var cnt = 0 + override fun newThread(r: Runnable): Thread { + val ret = Thread(r, "SentryReplayPersister-" + cnt++) + ret.setDaemon(true) + return ret } } @@ -416,4 +318,52 @@ internal abstract class BaseCaptureStrategy( else -> null } } + + private inline fun persistableAtomicNullable( + initialValue: T? = null, + propertyName: String, + crossinline onChange: (propertyName: String?, oldValue: T?, newValue: T?) -> Unit = { _, _, newValue -> + cache?.persistSegmentValues(propertyName, newValue.toString()) + } + ): ReadWriteProperty = + object : ReadWriteProperty { + private val value = AtomicReference(initialValue) + + private fun runInBackground(task: () -> Unit) { + if (options.mainThreadChecker.isMainThread) { + persistingExecutor.submitSafely(options, "$TAG.runInBackground") { + task() + } + } else { + task() + } + } + + init { + runInBackground { onChange(propertyName, initialValue, initialValue) } + } + + override fun getValue(thisRef: Any?, property: KProperty<*>): T? = value.get() + + override fun setValue(thisRef: Any?, property: KProperty<*>, value: T?) { + val oldValue = this.value.getAndSet(value) + if (oldValue != value) { + runInBackground { onChange(propertyName, oldValue, value) } + } + } + } + + private inline fun persistableAtomic( + initialValue: T? = null, + propertyName: String, + crossinline onChange: (propertyName: String?, oldValue: T?, newValue: T?) -> Unit = { _, _, newValue -> + cache?.persistSegmentValues(propertyName, newValue.toString()) + } + ): ReadWriteProperty = + persistableAtomicNullable(initialValue, propertyName, onChange) as ReadWriteProperty + + private inline fun persistableAtomic( + crossinline onChange: (propertyName: String?, oldValue: T?, newValue: T?) -> Unit + ): ReadWriteProperty = + persistableAtomicNullable(null, "", onChange) as ReadWriteProperty } diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BufferCaptureStrategy.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BufferCaptureStrategy.kt index 96d54735d5..a49c7bf789 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BufferCaptureStrategy.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BufferCaptureStrategy.kt @@ -3,14 +3,16 @@ package io.sentry.android.replay.capture import android.graphics.Bitmap import android.view.MotionEvent import io.sentry.DateUtils -import io.sentry.Hint import io.sentry.IHub +import io.sentry.SentryLevel.DEBUG import io.sentry.SentryLevel.ERROR import io.sentry.SentryLevel.INFO import io.sentry.SentryOptions import io.sentry.SentryReplayEvent.ReplayType.BUFFER import io.sentry.android.replay.ReplayCache import io.sentry.android.replay.ScreenshotRecorderConfig +import io.sentry.android.replay.capture.CaptureStrategy.Companion.rotateEvents +import io.sentry.android.replay.capture.CaptureStrategy.ReplaySegment import io.sentry.android.replay.util.sample import io.sentry.android.replay.util.submitSafely import io.sentry.protocol.SentryId @@ -18,29 +20,38 @@ import io.sentry.transport.ICurrentDateProvider import io.sentry.util.FileUtils import java.io.File import java.security.SecureRandom +import java.util.concurrent.ScheduledExecutorService internal class BufferCaptureStrategy( private val options: SentryOptions, private val hub: IHub?, private val dateProvider: ICurrentDateProvider, - recorderConfig: ScreenshotRecorderConfig, private val random: SecureRandom, + executor: ScheduledExecutorService? = null, replayCacheProvider: ((replayId: SentryId, recorderConfig: ScreenshotRecorderConfig) -> ReplayCache)? = null -) : BaseCaptureStrategy(options, hub, dateProvider, recorderConfig, replayCacheProvider = replayCacheProvider) { +) : BaseCaptureStrategy(options, hub, dateProvider, executor = executor, replayCacheProvider = replayCacheProvider) { + // TODO: capture envelopes for buffered segments instead, but don't send them until buffer is triggered private val bufferedSegments = mutableListOf() + + // TODO: rework this bs, it doesn't work with sending replay on restart private val bufferedScreensLock = Any() private val bufferedScreens = mutableListOf>() internal companion object { private const val TAG = "BufferCaptureStrategy" + private const val ENVELOPE_PROCESSING_DELAY: Long = 100L } - override fun start(segmentId: Int, replayId: SentryId, cleanupOldReplays: Boolean) { - super.start(segmentId, replayId, cleanupOldReplays) + override fun start( + recorderConfig: ScreenshotRecorderConfig, + segmentId: Int, + replayId: SentryId + ) { + super.start(recorderConfig, segmentId, replayId) hub?.configureScope { - val screen = it.screen + val screen = it.screen?.substringAfterLast('.') if (screen != null) { synchronized(bufferedScreensLock) { bufferedScreens.add(screen to dateProvider.currentTimeMillis) @@ -58,6 +69,17 @@ internal class BufferCaptureStrategy( } } + override fun pause() { + createCurrentSegment("pause") { segment -> + if (segment is ReplaySegment.Created) { + bufferedSegments += segment + + currentSegment++ + } + } + super.pause() + } + override fun stop() { val replayCacheDir = cache?.replayCacheDir replayExecutor.submitSafely(options, "$TAG.stop") { @@ -66,64 +88,40 @@ internal class BufferCaptureStrategy( super.stop() } - override fun sendReplayForEvent( - isCrashed: Boolean, - eventId: String?, - hint: Hint?, + override fun captureReplay( + isTerminating: Boolean, onSegmentSent: () -> Unit ) { val sampled = random.sample(options.experimental.sessionReplay.errorSampleRate) if (!sampled) { - options.logger.log(INFO, "Replay wasn't sampled by errorSampleRate, not capturing for event %s", eventId) + options.logger.log(INFO, "Replay wasn't sampled by errorSampleRate, not capturing for event") return } // write replayId to scope right away, so it gets picked up by the event that caused buffer // to flush hub?.configureScope { - it.replayId = currentReplayId.get() + it.replayId = currentReplayId } - val errorReplayDuration = options.experimental.sessionReplay.errorReplayDuration - val now = dateProvider.currentTimeMillis - val currentSegmentTimestamp = if (cache?.frames?.isNotEmpty() == true) { - // in buffer mode we have to set the timestamp of the first frame as the actual start - DateUtils.getDateTime(cache!!.frames.first().timestamp) - } else { - DateUtils.getDateTime(now - errorReplayDuration) + if (isTerminating) { + this.isTerminating.set(true) + // avoid capturing replay, because the video will be malformed + options.logger.log(DEBUG, "Not capturing replay for crashed event, will be captured on next launch") + return } - val segmentId = currentSegment.get() - val replayId = currentReplayId.get() - val height = recorderConfig.recordingHeight - val width = recorderConfig.recordingWidth - findAndSetStartScreen(currentSegmentTimestamp.time) + createCurrentSegment("capture_replay") { segment -> + bufferedSegments.capture() - replayExecutor.submitSafely(options, "$TAG.send_replay_for_event") { - var bufferedSegment = bufferedSegments.removeFirstOrNull() - while (bufferedSegment != null) { - // capture without hint, so the buffered segments don't trigger flush notification - bufferedSegment.capture(hub) - bufferedSegment = bufferedSegments.removeFirstOrNull() - Thread.sleep(100L) - } - val segment = - createSegment( - now - currentSegmentTimestamp.time, - currentSegmentTimestamp, - replayId, - segmentId, - height, - width, - BUFFER - ) if (segment is ReplaySegment.Created) { - segment.capture(hub, hint ?: Hint()) + segment.capture(hub) // we only want to increment segment_id in the case of success, but currentSegment // might be irrelevant since we changed strategies, so in the callback we increment // it on the new strategy already + // TODO: also pass new segmentTimestamp to the new strategy onSegmentSent() } } @@ -139,78 +137,36 @@ internal class BufferCaptureStrategy( val now = dateProvider.currentTimeMillis val bufferLimit = now - options.experimental.sessionReplay.errorReplayDuration cache?.rotate(bufferLimit) - - var removed = false - bufferedSegments.removeAll { - // it can be that the buffered segment is half-way older than the buffer limit, but - // we only drop it if its end timestamp is older - if (it.replay.timestamp.time < bufferLimit) { - currentSegment.decrementAndGet() - deleteFile(it.replay.videoFile) - removed = true - return@removeAll true - } - return@removeAll false - } - if (removed) { - // shift segmentIds after rotating buffered segments - bufferedSegments.forEachIndexed { index, segment -> - segment.setSegmentId(index) - } - } - } - } - - private fun deleteFile(file: File?) { - if (file == null) { - return - } - try { - if (!file.delete()) { - options.logger.log(ERROR, "Failed to delete replay segment: %s", file.absolutePath) - } - } catch (e: Throwable) { - options.logger.log(ERROR, e, "Failed to delete replay segment: %s", file.absolutePath) + bufferedSegments.rotate(bufferLimit) } } override fun onConfigurationChanged(recorderConfig: ScreenshotRecorderConfig) { - val errorReplayDuration = options.experimental.sessionReplay.errorReplayDuration - val now = dateProvider.currentTimeMillis - val currentSegmentTimestamp = if (cache?.frames?.isNotEmpty() == true) { - // in buffer mode we have to set the timestamp of the first frame as the actual start - DateUtils.getDateTime(cache!!.frames.first().timestamp) - } else { - DateUtils.getDateTime(now - errorReplayDuration) - } - val segmentId = currentSegment.get() - val duration = now - currentSegmentTimestamp.time - val replayId = currentReplayId.get() - val height = this.recorderConfig.recordingHeight - val width = this.recorderConfig.recordingWidth - replayExecutor.submitSafely(options, "$TAG.onConfigurationChanged") { - val segment = - createSegment(duration, currentSegmentTimestamp, replayId, segmentId, height, width, BUFFER) + createCurrentSegment("configuration_changed") { segment -> if (segment is ReplaySegment.Created) { bufferedSegments += segment - currentSegment.getAndIncrement() + currentSegment++ } } super.onConfigurationChanged(recorderConfig) } override fun convert(): CaptureStrategy { + if (isTerminating.get()) { + options.logger.log(DEBUG, "Not converting to session mode, because the process is about to terminate") + return this + } // we hand over replayExecutor to the new strategy to preserve order of execution - val captureStrategy = SessionCaptureStrategy(options, hub, dateProvider, recorderConfig, replayExecutor) - captureStrategy.start(segmentId = currentSegment.get(), replayId = currentReplayId.get(), cleanupOldReplays = false) + val captureStrategy = SessionCaptureStrategy(options, hub, dateProvider, replayExecutor) + captureStrategy.start(recorderConfig, segmentId = currentSegment, replayId = currentReplayId) return captureStrategy } override fun onTouchEvent(event: MotionEvent) { super.onTouchEvent(event) val bufferLimit = dateProvider.currentTimeMillis - options.experimental.sessionReplay.errorReplayDuration - rotateCurrentEvents(bufferLimit) + rotateEvents(currentEvents, bufferLimit) } private fun findAndSetStartScreen(segmentStart: Long) { @@ -221,10 +177,81 @@ internal class BufferCaptureStrategy( // if no screen is found before the segment start, this likely means the buffer is from the // app start, and the start screen will be taken from the navigation crumbs if (startScreen != null) { - screenAtStart.set(startScreen) + screenAtStart = startScreen } // can clear as we switch to session mode and don't care anymore about buffering - bufferedSegments.clear() + bufferedScreens.clear() + } + } + + private fun deleteFile(file: File?) { + if (file == null) { + return + } + try { + if (!file.delete()) { + options.logger.log(ERROR, "Failed to delete replay segment: %s", file.absolutePath) + } + } catch (e: Throwable) { + options.logger.log(ERROR, e, "Failed to delete replay segment: %s", file.absolutePath) + } + } + + private fun MutableList.capture() { + var bufferedSegment = removeFirstOrNull() + while (bufferedSegment != null) { + bufferedSegment.capture(hub) + bufferedSegment = removeFirstOrNull() + // a short delay between processing envelopes to avoid bursting our server and hitting + // another rate limit https://develop.sentry.dev/sdk/features/#additional-capabilities + // InterruptedException will be handled by the outer try-catch + Thread.sleep(ENVELOPE_PROCESSING_DELAY) + } + } + + private fun MutableList.rotate(bufferLimit: Long) { + // TODO: can be a single while-loop + var removed = false + removeAll { + // it can be that the buffered segment is half-way older than the buffer limit, but + // we only drop it if its end timestamp is older + if (it.replay.timestamp.time < bufferLimit) { + currentSegment-- + deleteFile(it.replay.videoFile) + removed = true + return@removeAll true + } + return@removeAll false + } + if (removed) { + // shift segmentIds after rotating buffered segments + forEachIndexed { index, segment -> + segment.setSegmentId(index) + } + } + } + + private fun createCurrentSegment(taskName: String, onSegmentCreated: (ReplaySegment) -> Unit) { + val errorReplayDuration = options.experimental.sessionReplay.errorReplayDuration + val now = dateProvider.currentTimeMillis + val currentSegmentTimestamp = if (cache?.frames?.isNotEmpty() == true) { + // in buffer mode we have to set the timestamp of the first frame as the actual start + DateUtils.getDateTime(cache!!.frames.first().timestamp) + } else { + DateUtils.getDateTime(now - errorReplayDuration) + } + val segmentId = currentSegment + val duration = now - currentSegmentTimestamp.time + val replayId = currentReplayId + val height = this.recorderConfig.recordingHeight + val width = this.recorderConfig.recordingWidth + + findAndSetStartScreen(currentSegmentTimestamp.time) + + replayExecutor.submitSafely(options, "$TAG.$taskName") { + val segment = + createSegmentInternal(duration, currentSegmentTimestamp, replayId, segmentId, height, width, BUFFER) + onSegmentCreated(segment) } } } diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/CaptureStrategy.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/CaptureStrategy.kt index 3233556615..c3be520b84 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/CaptureStrategy.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/CaptureStrategy.kt @@ -2,20 +2,35 @@ package io.sentry.android.replay.capture import android.graphics.Bitmap import android.view.MotionEvent +import io.sentry.Breadcrumb +import io.sentry.DateUtils import io.sentry.Hint +import io.sentry.IHub +import io.sentry.ReplayRecording +import io.sentry.SentryOptions +import io.sentry.SentryReplayEvent +import io.sentry.SentryReplayEvent.ReplayType import io.sentry.android.replay.ReplayCache import io.sentry.android.replay.ScreenshotRecorderConfig import io.sentry.protocol.SentryId +import io.sentry.rrweb.RRWebBreadcrumbEvent +import io.sentry.rrweb.RRWebEvent +import io.sentry.rrweb.RRWebMetaEvent +import io.sentry.rrweb.RRWebVideoEvent import java.io.File -import java.util.concurrent.atomic.AtomicInteger -import java.util.concurrent.atomic.AtomicReference +import java.util.Date +import java.util.LinkedList internal interface CaptureStrategy { - val currentSegment: AtomicInteger - val currentReplayId: AtomicReference + var currentSegment: Int + var currentReplayId: SentryId val replayCacheDir: File? - fun start(segmentId: Int = 0, replayId: SentryId = SentryId(), cleanupOldReplays: Boolean = true) + fun start( + recorderConfig: ScreenshotRecorderConfig, + segmentId: Int = 0, + replayId: SentryId = SentryId() + ) fun stop() @@ -23,7 +38,7 @@ internal interface CaptureStrategy { fun resume() - fun sendReplayForEvent(isCrashed: Boolean, eventId: String?, hint: Hint?, onSegmentSent: () -> Unit) + fun captureReplay(isTerminating: Boolean, onSegmentSent: () -> Unit) fun onScreenshotRecorded(bitmap: Bitmap? = null, store: ReplayCache.(frameTimestamp: Long) -> Unit) @@ -36,4 +51,190 @@ internal interface CaptureStrategy { fun convert(): CaptureStrategy fun close() + + companion object { + internal val currentEventsLock = Any() + + fun createSegment( + hub: IHub?, + options: SentryOptions, + duration: Long, + currentSegmentTimestamp: Date, + replayId: SentryId, + segmentId: Int, + height: Int, + width: Int, + replayType: ReplayType, + cache: ReplayCache?, + frameRate: Int, + screenAtStart: String?, + breadcrumbs: List?, + events: LinkedList + ): ReplaySegment { + val generatedVideo = cache?.createVideoOf( + duration, + currentSegmentTimestamp.time, + segmentId, + height, + width + ) ?: return ReplaySegment.Failed + + val (video, frameCount, videoDuration) = generatedVideo + + val replayBreadcrumbs: List = if (breadcrumbs == null) { + var crumbs = emptyList() + hub?.configureScope { scope -> + crumbs = ArrayList(scope.breadcrumbs) + } + crumbs + } else { + breadcrumbs + } + + return buildReplay( + options, + video, + replayId, + currentSegmentTimestamp, + segmentId, + height, + width, + frameCount, + frameRate, + videoDuration, + replayType, + screenAtStart, + replayBreadcrumbs, + events + ) + } + + private fun buildReplay( + options: SentryOptions, + video: File, + currentReplayId: SentryId, + segmentTimestamp: Date, + segmentId: Int, + height: Int, + width: Int, + frameCount: Int, + frameRate: Int, + videoDuration: Long, + replayType: ReplayType, + screenAtStart: String?, + breadcrumbs: List, + events: LinkedList + ): ReplaySegment { + val endTimestamp = DateUtils.getDateTime(segmentTimestamp.time + videoDuration) + val replay = SentryReplayEvent().apply { + this.eventId = currentReplayId + this.replayId = currentReplayId + this.segmentId = segmentId + this.timestamp = endTimestamp + this.replayStartTimestamp = segmentTimestamp + this.replayType = replayType + this.videoFile = video + } + + val recordingPayload = mutableListOf() + recordingPayload += RRWebMetaEvent().apply { + this.timestamp = segmentTimestamp.time + this.height = height + this.width = width + } + recordingPayload += RRWebVideoEvent().apply { + this.timestamp = segmentTimestamp.time + this.segmentId = segmentId + this.durationMs = videoDuration + this.frameCount = frameCount + this.size = video.length() + this.frameRate = frameRate + this.height = height + this.width = width + // TODO: support non-fullscreen windows later + this.left = 0 + this.top = 0 + } + + val urls = LinkedList() + breadcrumbs.forEach { breadcrumb -> + if (breadcrumb.timestamp.time >= segmentTimestamp.time && + breadcrumb.timestamp.time < endTimestamp.time + ) { + val rrwebEvent = options + .replayController + .breadcrumbConverter + .convert(breadcrumb) + + if (rrwebEvent != null) { + recordingPayload += rrwebEvent + + // fill in the urls array from navigation breadcrumbs + if ((rrwebEvent as? RRWebBreadcrumbEvent)?.category == "navigation") { + urls.add(rrwebEvent.data!!["to"] as String) + } + } + } + } + + if (screenAtStart != null && urls.firstOrNull() != screenAtStart) { + urls.addFirst(screenAtStart) + } + + rotateEvents(events, endTimestamp.time) { event -> + if (event.timestamp >= segmentTimestamp.time) { + recordingPayload += event + } + } + + val recording = ReplayRecording().apply { + this.segmentId = segmentId + this.payload = recordingPayload.sortedBy { it.timestamp } + } + + replay.urls = urls + return ReplaySegment.Created( + videoDuration = videoDuration, + replay = replay, + recording = recording + ) + } + + internal fun rotateEvents( + events: LinkedList, + until: Long, + callback: ((RRWebEvent) -> Unit)? = null + ) { + synchronized(currentEventsLock) { + var event = events.peek() + while (event != null && event.timestamp < until) { + callback?.invoke(event) + events.remove() + event = events.peek() + } + } + } + } + + sealed class ReplaySegment { + object Failed : ReplaySegment() + data class Created( + val videoDuration: Long, + val replay: SentryReplayEvent, + val recording: ReplayRecording + ) : ReplaySegment() { + fun capture(hub: IHub?, hint: Hint = Hint()) { + hub?.captureReplay(replay, hint.apply { replayRecording = recording }) + } + + fun setSegmentId(segmentId: Int) { + replay.segmentId = segmentId + recording.payload?.forEach { + when (it) { + is RRWebVideoEvent -> it.segmentId = segmentId + } + } + } + } + } } diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/SessionCaptureStrategy.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/SessionCaptureStrategy.kt index 02687201b8..d0fd2ce1e1 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/SessionCaptureStrategy.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/SessionCaptureStrategy.kt @@ -2,7 +2,6 @@ package io.sentry.android.replay.capture import android.graphics.Bitmap import io.sentry.DateUtils -import io.sentry.Hint import io.sentry.IConnectionStatusProvider.ConnectionStatus.DISCONNECTED import io.sentry.IHub import io.sentry.SentryLevel.DEBUG @@ -10,6 +9,7 @@ import io.sentry.SentryLevel.INFO import io.sentry.SentryOptions import io.sentry.android.replay.ReplayCache import io.sentry.android.replay.ScreenshotRecorderConfig +import io.sentry.android.replay.capture.CaptureStrategy.ReplaySegment import io.sentry.android.replay.util.submitSafely import io.sentry.protocol.SentryId import io.sentry.transport.ICurrentDateProvider @@ -20,22 +20,25 @@ internal class SessionCaptureStrategy( private val options: SentryOptions, private val hub: IHub?, private val dateProvider: ICurrentDateProvider, - recorderConfig: ScreenshotRecorderConfig, executor: ScheduledExecutorService? = null, replayCacheProvider: ((replayId: SentryId, recorderConfig: ScreenshotRecorderConfig) -> ReplayCache)? = null -) : BaseCaptureStrategy(options, hub, dateProvider, recorderConfig, executor, replayCacheProvider) { +) : BaseCaptureStrategy(options, hub, dateProvider, executor, replayCacheProvider) { internal companion object { private const val TAG = "SessionCaptureStrategy" } - override fun start(segmentId: Int, replayId: SentryId, cleanupOldReplays: Boolean) { - super.start(segmentId, replayId, cleanupOldReplays) + override fun start( + recorderConfig: ScreenshotRecorderConfig, + segmentId: Int, + replayId: SentryId + ) { + super.start(recorderConfig, segmentId, replayId) // only set replayId on the scope if it's a full session, otherwise all events will be // tagged with the replay that might never be sent when we're recording in buffer mode hub?.configureScope { - it.replayId = currentReplayId.get() - screenAtStart.set(it.screen) + it.replayId = currentReplayId + screenAtStart = it.screen } } @@ -44,7 +47,7 @@ internal class SessionCaptureStrategy( if (segment is ReplaySegment.Created) { segment.capture(hub) - currentSegment.getAndIncrement() + currentSegment++ } } super.pause() @@ -62,17 +65,9 @@ internal class SessionCaptureStrategy( super.stop() } - override fun sendReplayForEvent(isCrashed: Boolean, eventId: String?, hint: Hint?, onSegmentSent: () -> Unit) { - if (!isCrashed) { - options.logger.log(DEBUG, "Replay is already running in 'session' mode, not capturing for event %s", eventId) - } else { - options.logger.log(DEBUG, "Replay is already running in 'session' mode, capturing last segment for crashed event %s", eventId) - createCurrentSegment("send_replay_for_event") { segment -> - if (segment is ReplaySegment.Created) { - segment.capture(hub, hint ?: Hint()) - } - } - } + override fun captureReplay(isTerminating: Boolean, onSegmentSent: () -> Unit) { + options.logger.log(DEBUG, "Replay is already running in 'session' mode, not capturing for event") + this.isTerminating.set(isTerminating) } override fun onScreenshotRecorded(bitmap: Bitmap?, store: ReplayCache.(frameTimestamp: Long) -> Unit) { @@ -89,43 +84,52 @@ internal class SessionCaptureStrategy( replayExecutor.submitSafely(options, "$TAG.add_frame") { cache?.store(frameTimestamp) - val now = dateProvider.currentTimeMillis - if ((now - segmentTimestamp.get().time >= options.experimental.sessionReplay.sessionSegmentDuration)) { - val currentSegmentTimestamp = segmentTimestamp.get() - val segmentId = currentSegment.get() - val replayId = currentReplayId.get() + val currentSegmentTimestamp = segmentTimestamp + currentSegmentTimestamp ?: run { + options.logger.log(DEBUG, "Segment timestamp is not set, not recording frame") + return@submitSafely + } + if (isTerminating.get()) { + options.logger.log(DEBUG, "Not capturing segment, because the app is terminating, will be captured on next launch") + return@submitSafely + } + + val now = dateProvider.currentTimeMillis + if ((now - currentSegmentTimestamp.time >= options.experimental.sessionReplay.sessionSegmentDuration)) { val segment = - createSegment( + createSegmentInternal( options.experimental.sessionReplay.sessionSegmentDuration, currentSegmentTimestamp, - replayId, - segmentId, + currentReplayId, + currentSegment, height, width ) if (segment is ReplaySegment.Created) { segment.capture(hub) - currentSegment.getAndIncrement() + currentSegment++ // set next segment timestamp as close to the previous one as possible to avoid gaps - segmentTimestamp.set(DateUtils.getDateTime(currentSegmentTimestamp.time + segment.videoDuration)) + segmentTimestamp = DateUtils.getDateTime(currentSegmentTimestamp.time + segment.videoDuration) } - } else if ((now - replayStartTimestamp.get() >= options.experimental.sessionReplay.sessionDuration)) { - stop() + } + + if ((now - replayStartTimestamp.get() >= options.experimental.sessionReplay.sessionDuration)) { + options.replayController.stop() options.logger.log(INFO, "Session replay deadline exceeded (1h), stopping recording") } } } override fun onConfigurationChanged(recorderConfig: ScreenshotRecorderConfig) { - val currentSegmentTimestamp = segmentTimestamp.get() + val currentSegmentTimestamp = segmentTimestamp ?: return createCurrentSegment("onConfigurationChanged") { segment -> if (segment is ReplaySegment.Created) { segment.capture(hub) - currentSegment.getAndIncrement() + currentSegment++ // set next segment timestamp as close to the previous one as possible to avoid gaps - segmentTimestamp.set(DateUtils.getDateTime(currentSegmentTimestamp.time + segment.videoDuration)) + segmentTimestamp = DateUtils.getDateTime(currentSegmentTimestamp.time + segment.videoDuration) } } @@ -137,15 +141,15 @@ internal class SessionCaptureStrategy( private fun createCurrentSegment(taskName: String, onSegmentCreated: (ReplaySegment) -> Unit) { val now = dateProvider.currentTimeMillis - val currentSegmentTimestamp = segmentTimestamp.get() - val segmentId = currentSegment.get() - val duration = now - (currentSegmentTimestamp?.time ?: 0) - val replayId = currentReplayId.get() + val currentSegmentTimestamp = segmentTimestamp ?: return + val segmentId = currentSegment + val duration = now - currentSegmentTimestamp.time + val replayId = currentReplayId val height = recorderConfig.recordingHeight val width = recorderConfig.recordingWidth replayExecutor.submitSafely(options, "$TAG.$taskName") { val segment = - createSegment(duration, currentSegmentTimestamp, replayId, segmentId, height, width) + createSegmentInternal(duration, currentSegmentTimestamp, replayId, segmentId, height, width) onSegmentCreated(segment) } } diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Executors.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Executors.kt index 093416f9bb..453ff49df2 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Executors.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Executors.kt @@ -1,5 +1,6 @@ package io.sentry.android.replay.util +import io.sentry.ISentryExecutorService import io.sentry.SentryLevel.ERROR import io.sentry.SentryOptions import java.util.concurrent.ExecutorService @@ -25,6 +26,25 @@ internal fun ExecutorService.gracefullyShutdown(options: SentryOptions) { } } +internal fun ISentryExecutorService.submitSafely( + options: SentryOptions, + taskName: String, + task: Runnable +): Future<*>? { + return try { + submit { + try { + task.run() + } catch (e: Throwable) { + options.logger.log(ERROR, "Failed to execute task $taskName", e) + } + } + } catch (e: Throwable) { + options.logger.log(ERROR, "Failed to submit task $taskName to executor", e) + null + } +} + internal fun ExecutorService.submitSafely( options: SentryOptions, taskName: String, diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Persistable.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Persistable.kt new file mode 100644 index 0000000000..553bae8dee --- /dev/null +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Persistable.kt @@ -0,0 +1,53 @@ +// ktlint-disable filename +package io.sentry.android.replay.util + +import io.sentry.ReplayRecording +import io.sentry.SentryOptions +import io.sentry.android.replay.ReplayCache +import io.sentry.rrweb.RRWebEvent +import java.io.BufferedWriter +import java.io.StringWriter +import java.util.LinkedList +import java.util.concurrent.ScheduledExecutorService + +internal class PersistableLinkedList( + private val propertyName: String, + private val options: SentryOptions, + private val persistingExecutor: ScheduledExecutorService, + private val cacheProvider: () -> ReplayCache? +) : LinkedList() { + // only overriding methods that we use, to observe the collection + override fun addAll(elements: Collection): Boolean { + val result = super.addAll(elements) + persistRecording() + return result + } + + override fun add(element: RRWebEvent): Boolean { + val result = super.add(element) + persistRecording() + return result + } + + override fun remove(): RRWebEvent { + val result = super.remove() + persistRecording() + return result + } + + private fun persistRecording() { + val cache = cacheProvider() ?: return + val recording = ReplayRecording().apply { payload = ArrayList(this@PersistableLinkedList) } + if (options.mainThreadChecker.isMainThread) { + persistingExecutor.submit { + val stringWriter = StringWriter() + options.serializer.serialize(recording, BufferedWriter(stringWriter)) + cache.persistSegmentValues(propertyName, stringWriter.toString()) + } + } else { + val stringWriter = StringWriter() + options.serializer.serialize(recording, BufferedWriter(stringWriter)) + cache.persistSegmentValues(propertyName, stringWriter.toString()) + } + } +} diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleVideoEncoder.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleVideoEncoder.kt index 54a3bc1f89..fd770131d8 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleVideoEncoder.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleVideoEncoder.kt @@ -120,7 +120,7 @@ internal class SimpleVideoEncoder( ) format.setInteger(MediaFormat.KEY_BIT_RATE, bitRate) format.setFloat(MediaFormat.KEY_FRAME_RATE, muxerConfig.frameRate.toFloat()) - format.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, 10) + format.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, -1) // use -1 to force always non-key frames, meaning only partial updates to save the video size format } diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/DefaultReplayBreadcrumbConverterTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/DefaultReplayBreadcrumbConverterTest.kt index 0dfb3d39c8..a659f7f596 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/DefaultReplayBreadcrumbConverterTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/DefaultReplayBreadcrumbConverterTest.kt @@ -61,6 +61,28 @@ class DefaultReplayBreadcrumbConverterTest { assertEquals(400, rrwebEvent.data!!["requestBodySize"]) } + @Test + fun `convert RRWebSpanEvent works with floating timestamps`() { + val converter = fixture.getSut() + + val breadcrumb = Breadcrumb(Date(123L)).apply { + category = "http" + data["url"] = "http://example.com" + data["status_code"] = 404 + data["method"] = "GET" + data[SpanDataConvention.HTTP_START_TIMESTAMP] = 1234.0 + data[SpanDataConvention.HTTP_END_TIMESTAMP] = 2234.0 + data["http.response_content_length"] = 300 + data["http.request_content_length"] = 400 + } + + val rrwebEvent = converter.convert(breadcrumb) + + check(rrwebEvent is RRWebSpanEvent) + assertEquals(1.234, rrwebEvent.startTimestamp) + assertEquals(2.234, rrwebEvent.endTimestamp) + } + @Test fun `returns null if not eligible for RRWebSpanEvent`() { val converter = fixture.getSut() diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayCacheTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayCacheTest.kt index fe0b50c9c8..0dae78e723 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayCacheTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayCacheTest.kt @@ -5,10 +5,24 @@ import android.graphics.Bitmap.CompressFormat.JPEG import android.graphics.Bitmap.Config.ARGB_8888 import android.media.MediaCodec import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.sentry.DateUtils import io.sentry.SentryOptions +import io.sentry.SentryReplayEvent.ReplayType +import io.sentry.android.replay.ReplayCache.Companion.ONGOING_SEGMENT +import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_BIT_RATE +import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_FRAME_RATE +import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_HEIGHT +import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_ID +import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_REPLAY_RECORDING +import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_REPLAY_TYPE +import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_TIMESTAMP +import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_WIDTH import io.sentry.android.replay.video.MuxerConfig import io.sentry.android.replay.video.SimpleVideoEncoder import io.sentry.protocol.SentryId +import io.sentry.rrweb.RRWebInteractionEvent +import io.sentry.rrweb.RRWebInteractionEvent.InteractionType.TouchEnd +import io.sentry.rrweb.RRWebInteractionEvent.InteractionType.TouchStart import org.junit.Rule import org.junit.rules.TemporaryFolder import org.junit.runner.RunWith @@ -18,6 +32,7 @@ import java.util.concurrent.TimeUnit.MICROSECONDS import java.util.concurrent.TimeUnit.MILLISECONDS import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertNull import kotlin.test.assertTrue @@ -219,6 +234,20 @@ class ReplayCacheTest { assertEquals(File(replayCache.replayCacheDir, "0.mp4"), segment0.video) } + @Test + fun `does not add frame when bitmap is recycled`() { + val replayCache = fixture.getSut( + tmpDir, + frameRate = 1, + framesToEncode = 5 + ) + + val bitmap = Bitmap.createBitmap(1, 1, ARGB_8888).also { it.recycle() } + replayCache.addFrame(bitmap, 1) + + assertTrue(replayCache.frames.isEmpty()) + } + @Test fun `addFrame with File path works`() { val replayCache = fixture.getSut( @@ -232,10 +261,7 @@ class ReplayCacheTest { val screenshot = File(flutterCacheDir, "1.jpg").also { it.createNewFile() } val video = File(flutterCacheDir, "flutter_0.mp4") - screenshot.outputStream().use { - Bitmap.createBitmap(1, 1, ARGB_8888).compress(JPEG, 80, it) - it.flush() - } + val bitmap = Bitmap.createBitmap(1, 1, ARGB_8888).also { it.recycle() } replayCache.addFrame(screenshot, frameTimestamp = 1) val segment0 = replayCache.createVideoOf(5000L, 0, 0, 100, 200, videoFile = video) @@ -264,4 +290,239 @@ class ReplayCacheTest { assertEquals(1, replayCache.frames.size) assertTrue(replayCache.replayCacheDir!!.listFiles()!!.none { it.name == "1.jpg" || it.name == "1001.jpg" }) } + + @Test + fun `does not persist segment if already closed`() { + val replayId = SentryId() + val replayCache = fixture.getSut( + tmpDir, + replayId, + frameRate = 1 + ) + + replayCache.close() + + replayCache.persistSegmentValues("key", "value") + assertFalse(File(replayCache.replayCacheDir, ONGOING_SEGMENT).exists()) + } + + @Test + fun `stores segment key value pairs`() { + val replayId = SentryId() + val replayCache = fixture.getSut( + tmpDir, + replayId, + frameRate = 1 + ) + + replayCache.persistSegmentValues("key1", "value1") + replayCache.persistSegmentValues("key2", "value2") + + val segmentValues = File(replayCache.replayCacheDir, ONGOING_SEGMENT).readLines() + assertEquals("key1=value1", segmentValues[0]) + assertEquals("key2=value2", segmentValues[1]) + } + + @Test + fun `removes segment key value pair, if the value is null`() { + val replayId = SentryId() + val replayCache = fixture.getSut( + tmpDir, + replayId, + frameRate = 1 + ) + + replayCache.persistSegmentValues("key1", "value1") + replayCache.persistSegmentValues("key2", "value2") + + replayCache.persistSegmentValues("key1", null) + + val segmentValues = File(replayCache.replayCacheDir, ONGOING_SEGMENT).readLines() + assertEquals(1, segmentValues.size) + assertEquals("key2=value2", segmentValues[0]) + } + + @Test + fun `if no ongoing_segment file exists, deletes replay folder`() { + fixture.options.run { + cacheDirPath = tmpDir.newFolder()?.absolutePath + } + val replayId = SentryId() + val replayCacheFolder = File(fixture.options.cacheDirPath!!, "replay_$replayId") + val lastSegment = ReplayCache.fromDisk(fixture.options, replayId) + + assertNull(lastSegment) + assertFalse(replayCacheFolder.exists()) + } + + @Test + fun `if one of the required segment values is not present, deletes replay folder`() { + fixture.options.run { + cacheDirPath = tmpDir.newFolder()?.absolutePath + } + val replayId = SentryId() + val replayCacheFolder = File(fixture.options.cacheDirPath!!, "replay_$replayId").also { it.mkdirs() } + File(replayCacheFolder, ONGOING_SEGMENT).also { + it.writeText( + """ + $SEGMENT_KEY_HEIGHT=912 + $SEGMENT_KEY_WIDTH=416 + $SEGMENT_KEY_FRAME_RATE=1 + $SEGMENT_KEY_BIT_RATE=75000 + $SEGMENT_KEY_ID=0 + $SEGMENT_KEY_TIMESTAMP=2024-07-11T10:25:21.454Z + """.trimIndent() + ) + // omitting replay type, which is required, for the test + } + + val lastSegment = ReplayCache.fromDisk(fixture.options, replayId) + + assertNull(lastSegment) + assertFalse(replayCacheFolder.exists()) + } + + @Test + fun `returns last segment data when all values are present`() { + fixture.options.run { + cacheDirPath = tmpDir.newFolder()?.absolutePath + } + val replayId = SentryId() + val replayCacheFolder = File(fixture.options.cacheDirPath!!, "replay_$replayId").also { it.mkdirs() } + File(replayCacheFolder, ONGOING_SEGMENT).also { + it.writeText( + """ + $SEGMENT_KEY_HEIGHT=912 + $SEGMENT_KEY_WIDTH=416 + $SEGMENT_KEY_FRAME_RATE=1 + $SEGMENT_KEY_BIT_RATE=75000 + $SEGMENT_KEY_ID=0 + $SEGMENT_KEY_TIMESTAMP=2024-07-11T10:25:21.454Z + $SEGMENT_KEY_REPLAY_TYPE=SESSION + $SEGMENT_KEY_REPLAY_RECORDING={}[{"type":3,"timestamp":1720693523997,"data":{"source":2,"type":7,"id":0,"x":314.2979431152344,"y":625.44140625,"pointerType":2,"pointerId":0}},{"type":3,"timestamp":1720693524774,"data":{"source":2,"type":9,"id":0,"x":322.00390625,"y":424.4384765625,"pointerType":2,"pointerId":0}}] + """.trimIndent() + ) + } + + val screenshot = File(replayCacheFolder, "1720693523997.jpg").also { it.createNewFile() } + screenshot.outputStream().use { + Bitmap.createBitmap(1, 1, ARGB_8888).compress(JPEG, 80, it) + it.flush() + } + + val lastSegment = ReplayCache.fromDisk(fixture.options, replayId)!! + + assertEquals(912, lastSegment.recorderConfig.recordingHeight) + assertEquals(416, lastSegment.recorderConfig.recordingWidth) + assertEquals(1, lastSegment.recorderConfig.frameRate) + assertEquals(75000, lastSegment.recorderConfig.bitRate) + assertEquals(0, lastSegment.id) + assertEquals("2024-07-11T10:25:21.454Z", DateUtils.getTimestamp(lastSegment.timestamp)) + assertEquals(ReplayType.SESSION, lastSegment.replayType) + assertEquals(3543, lastSegment.duration) // duration + 1 frame duration + assertTrue { + val firstEvent = lastSegment.events.first() as RRWebInteractionEvent + firstEvent.timestamp == 1720693523997 && + firstEvent.interactionType == TouchStart && + firstEvent.x.toDouble() == 314.2979431152344 && + firstEvent.y.toDouble() == 625.44140625 + } + assertTrue { + val lastEvent = lastSegment.events.last() as RRWebInteractionEvent + lastEvent.timestamp == 1720693524774 && + lastEvent.interactionType == TouchEnd && + lastEvent.x.toDouble() == 322.00390625 && + lastEvent.y.toDouble() == 424.4384765625 + } + } + + @Test + fun `fills in cache with frames from disk`() { + fixture.options.run { + cacheDirPath = tmpDir.newFolder()?.absolutePath + } + val replayId = SentryId() + val replayCacheFolder = File(fixture.options.cacheDirPath!!, "replay_$replayId").also { it.mkdirs() } + File(replayCacheFolder, ONGOING_SEGMENT).also { + it.writeText( + """ + $SEGMENT_KEY_HEIGHT=912 + $SEGMENT_KEY_WIDTH=416 + $SEGMENT_KEY_FRAME_RATE=1 + $SEGMENT_KEY_BIT_RATE=75000 + $SEGMENT_KEY_ID=0 + $SEGMENT_KEY_TIMESTAMP=2024-07-11T10:25:21.454Z + $SEGMENT_KEY_REPLAY_TYPE=SESSION + """.trimIndent() + ) + } + + val screenshot = File(replayCacheFolder, "1.jpg").also { it.createNewFile() } + screenshot.outputStream().use { + Bitmap.createBitmap(1, 1, ARGB_8888).compress(JPEG, 80, it) + it.flush() + } + + val lastSegment = ReplayCache.fromDisk(fixture.options, replayId)!! + + assertEquals(1, lastSegment.cache.frames.size) + assertEquals(1, lastSegment.cache.frames.first().timestamp) + assertEquals("1.jpg", lastSegment.cache.frames.first().screenshot.name) + } + + @Test + fun `when videoFile exists and is not empty, deletes it before writing`() { + val replayCache = fixture.getSut( + tmpDir, + frameRate = 1, + framesToEncode = 3 + ) + + val oldVideoFile = File(replayCache.replayCacheDir, "0.mp4").also { + it.createNewFile() + it.writeBytes(byteArrayOf(1, 2, 3)) + } + val bitmap = Bitmap.createBitmap(1, 1, ARGB_8888) + replayCache.addFrame(bitmap, 1) + replayCache.addFrame(bitmap, 1001) + replayCache.addFrame(bitmap, 2001) + + val segment0 = replayCache.createVideoOf(3000L, 0, 0, 100, 200, oldVideoFile) + assertEquals(3, segment0!!.frameCount) + assertEquals(3000, segment0.duration) + assertTrue { segment0.video.exists() && segment0.video.length() > 0 } + assertEquals(File(replayCache.replayCacheDir, "0.mp4"), segment0.video) + } + + @Test + fun `sets segmentId to 0 for buffer mode`() { + fixture.options.run { + cacheDirPath = tmpDir.newFolder()?.absolutePath + } + val replayId = SentryId() + val replayCacheFolder = File(fixture.options.cacheDirPath!!, "replay_$replayId").also { it.mkdirs() } + File(replayCacheFolder, ONGOING_SEGMENT).also { + it.writeText( + """ + $SEGMENT_KEY_HEIGHT=912 + $SEGMENT_KEY_WIDTH=416 + $SEGMENT_KEY_FRAME_RATE=1 + $SEGMENT_KEY_BIT_RATE=75000 + $SEGMENT_KEY_ID=2 + $SEGMENT_KEY_TIMESTAMP=2024-07-11T10:25:21.454Z + $SEGMENT_KEY_REPLAY_TYPE=BUFFER + """.trimIndent() + ) + } + + val screenshot = File(replayCacheFolder, "1720693523997.jpg").also { it.createNewFile() } + screenshot.outputStream().use { + Bitmap.createBitmap(1, 1, ARGB_8888).compress(JPEG, 80, it) + it.flush() + } + + val lastSegment = ReplayCache.fromDisk(fixture.options, replayId)!! + + assertEquals(0, lastSegment.id) + } } diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationTest.kt index cb236b6318..a98e344277 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationTest.kt @@ -1,25 +1,49 @@ package io.sentry.android.replay import android.content.Context +import android.graphics.Bitmap +import android.graphics.Bitmap.CompressFormat.JPEG +import android.graphics.Bitmap.Config.ARGB_8888 import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.sentry.Breadcrumb +import io.sentry.DateUtils import io.sentry.Hint import io.sentry.IHub import io.sentry.SentryEvent import io.sentry.SentryIntegrationPackageStorage import io.sentry.SentryOptions -import io.sentry.android.replay.ReplayCacheTest.Fixture +import io.sentry.SentryReplayEvent.ReplayType +import io.sentry.android.replay.ReplayCache.Companion.ONGOING_SEGMENT +import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_BIT_RATE +import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_FRAME_RATE +import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_HEIGHT +import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_ID +import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_REPLAY_RECORDING +import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_REPLAY_TYPE +import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_TIMESTAMP +import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_WIDTH import io.sentry.android.replay.capture.CaptureStrategy +import io.sentry.android.replay.capture.SessionCaptureStrategyTest.Fixture.Companion.VIDEO_DURATION +import io.sentry.cache.PersistingScopeObserver import io.sentry.protocol.SentryException import io.sentry.protocol.SentryId +import io.sentry.rrweb.RRWebBreadcrumbEvent +import io.sentry.rrweb.RRWebInteractionEvent +import io.sentry.rrweb.RRWebInteractionEvent.InteractionType +import io.sentry.rrweb.RRWebMetaEvent +import io.sentry.rrweb.RRWebVideoEvent import io.sentry.transport.CurrentDateProvider import io.sentry.transport.ICurrentDateProvider import org.junit.Rule import org.junit.rules.TemporaryFolder import org.junit.runner.RunWith +import org.mockito.ArgumentMatchers.anyInt +import org.mockito.ArgumentMatchers.anyLong import org.mockito.kotlin.any -import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.argThat +import org.mockito.kotlin.check +import org.mockito.kotlin.doAnswer import org.mockito.kotlin.eq import org.mockito.kotlin.mock import org.mockito.kotlin.never @@ -27,7 +51,7 @@ import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import org.robolectric.annotation.Config -import java.util.concurrent.atomic.AtomicReference +import java.io.File import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals @@ -37,14 +61,30 @@ import kotlin.test.assertTrue @RunWith(AndroidJUnit4::class) @Config(sdk = [26]) class ReplayIntegrationTest { - // write tests for ReplayIntegration with mocked context and other android things @get:Rule val tmpDir = TemporaryFolder() internal class Fixture { - val options = SentryOptions() + val options = SentryOptions().apply { + setReplayController( + mock { + on { breadcrumbConverter }.thenReturn(DefaultReplayBreadcrumbConverter()) + } + ) + executorService = mock { + doAnswer { + (it.arguments[0] as Runnable).run() + }.whenever(mock).submit(any()) + } + } val hub = mock() + val replayCache = mock { + on { frames }.thenReturn(mutableListOf(ReplayFrame(File("1720693523997.jpg"), 1720693523997))) + on { createVideoOf(anyLong(), anyLong(), anyInt(), anyInt(), anyInt(), any()) } + .thenReturn(GeneratedVideo(File("0.mp4"), 5, VIDEO_DURATION)) + } + fun getSut( context: Context, sessionSampleRate: Double = 1.0, @@ -63,7 +103,7 @@ class ReplayIntegrationTest { dateProvider, recorderProvider, recorderConfigProvider = recorderConfigProvider, - replayCacheProvider = null, + replayCacheProvider = { _, _ -> replayCache }, replayCaptureStrategyProvider = replayCaptureStrategyProvider ) } @@ -120,7 +160,7 @@ class ReplayIntegrationTest { replay.start() - verify(captureStrategy, never()).start() + verify(captureStrategy, never()).start(any(), any(), any()) } @Test @@ -143,7 +183,11 @@ class ReplayIntegrationTest { replay.start() replay.start() - verify(captureStrategy, times(1)).start(eq(0), argThat { this != SentryId.EMPTY_ID }, eq(true)) + verify(captureStrategy, times(1)).start( + any(), + eq(0), + argThat { this != SentryId.EMPTY_ID } + ) } @Test @@ -154,7 +198,11 @@ class ReplayIntegrationTest { replay.register(fixture.hub, fixture.options) replay.start() - verify(captureStrategy, never()).start(eq(0), argThat { this != SentryId.EMPTY_ID }, eq(true)) + verify(captureStrategy, never()).start( + any(), + eq(0), + argThat { this != SentryId.EMPTY_ID } + ) } @Test @@ -165,7 +213,11 @@ class ReplayIntegrationTest { replay.register(fixture.hub, fixture.options) replay.start() - verify(captureStrategy, times(1)).start(eq(0), argThat { this != SentryId.EMPTY_ID }, eq(true)) + verify(captureStrategy, times(1)).start( + any(), + eq(0), + argThat { this != SentryId.EMPTY_ID } + ) } @Test @@ -205,7 +257,7 @@ class ReplayIntegrationTest { } @Test - fun `sendReplayForEvent does nothing when not recording`() { + fun `captureReplay does nothing when not recording`() { val captureStrategy = mock() val replay = fixture.getSut(context, replayCaptureStrategyProvider = { captureStrategy }) @@ -214,29 +266,15 @@ class ReplayIntegrationTest { val event = SentryEvent().apply { exceptions = listOf(SentryException()) } - replay.sendReplayForEvent(event, Hint()) + replay.captureReplay(event.isCrashed) - verify(captureStrategy, never()).sendReplayForEvent(any(), anyOrNull(), anyOrNull(), any()) + verify(captureStrategy, never()).captureReplay(any(), any()) } @Test - fun `sendReplayForEvent does nothing for non errored events`() { - val captureStrategy = mock() - val replay = fixture.getSut(context, replayCaptureStrategyProvider = { captureStrategy }) - - replay.register(fixture.hub, fixture.options) - replay.start() - - val event = SentryEvent() - replay.sendReplayForEvent(event, Hint()) - - verify(captureStrategy, never()).sendReplayForEvent(any(), anyOrNull(), anyOrNull(), any()) - } - - @Test - fun `sendReplayForEvent does nothing when currentReplayId is not set`() { + fun `captureReplay does nothing when currentReplayId is not set`() { val captureStrategy = mock { - whenever(mock.currentReplayId).thenReturn(AtomicReference(SentryId.EMPTY_ID)) + whenever(mock.currentReplayId).thenReturn(SentryId.EMPTY_ID) } val replay = fixture.getSut(context, replayCaptureStrategyProvider = { captureStrategy }) @@ -246,15 +284,15 @@ class ReplayIntegrationTest { val event = SentryEvent().apply { exceptions = listOf(SentryException()) } - replay.sendReplayForEvent(event, Hint()) + replay.captureReplay(event.isCrashed) - verify(captureStrategy, never()).sendReplayForEvent(any(), anyOrNull(), anyOrNull(), any()) + verify(captureStrategy, never()).captureReplay(any(), any()) } @Test - fun `sendReplayForEvent calls and converts strategy`() { + fun `captureReplay calls and converts strategy`() { val captureStrategy = mock { - whenever(mock.currentReplayId).thenReturn(AtomicReference(SentryId())) + whenever(mock.currentReplayId).thenReturn(SentryId()) } val replay = fixture.getSut(context, replayCaptureStrategyProvider = { captureStrategy }) @@ -267,9 +305,9 @@ class ReplayIntegrationTest { } event.eventId = id val hint = Hint() - replay.sendReplayForEvent(event, hint) + replay.captureReplay(event.isCrashed) - verify(captureStrategy).sendReplayForEvent(eq(false), eq(id.toString()), eq(hint), any()) + verify(captureStrategy).captureReplay(eq(false), any()) verify(captureStrategy).convert() } @@ -378,4 +416,117 @@ class ReplayIntegrationTest { verify(recorder, times(2)).start(eq(recorderConfig)) assertTrue(configChanged) } + + @Test + fun `register finalizes previous replay`() { + val oldReplayId = SentryId() + + fixture.options.cacheDirPath = tmpDir.newFolder().absolutePath + val oldReplay = + File(fixture.options.cacheDirPath, "replay_$oldReplayId").also { it.mkdirs() } + val screenshot = File(oldReplay, "1720693523997.jpg").also { it.createNewFile() } + screenshot.outputStream().use { + Bitmap.createBitmap(1, 1, ARGB_8888).compress(JPEG, 80, it) + it.flush() + } + val scopeCache = File( + fixture.options.cacheDirPath, + PersistingScopeObserver.SCOPE_CACHE + ).also { it.mkdirs() } + File(scopeCache, PersistingScopeObserver.REPLAY_FILENAME).also { + it.createNewFile() + it.writeText("\"$oldReplayId\"") + } + val breadcrumbsFile = File(scopeCache, PersistingScopeObserver.BREADCRUMBS_FILENAME) + fixture.options.serializer.serialize( + listOf( + Breadcrumb(DateUtils.getDateTime("2024-07-11T10:25:23.454Z")).apply { + category = "navigation" + type = "navigation" + setData("from", "from") + setData("to", "to") + } + ), + breadcrumbsFile.writer() + ) + File(oldReplay, ONGOING_SEGMENT).also { + it.writeText( + """ + $SEGMENT_KEY_HEIGHT=912 + $SEGMENT_KEY_WIDTH=416 + $SEGMENT_KEY_FRAME_RATE=1 + $SEGMENT_KEY_BIT_RATE=75000 + $SEGMENT_KEY_ID=1 + $SEGMENT_KEY_TIMESTAMP=2024-07-11T10:25:21.454Z + $SEGMENT_KEY_REPLAY_TYPE=SESSION + $SEGMENT_KEY_REPLAY_RECORDING={}[{"type":3,"timestamp":1720693523997,"data":{"source":2,"type":7,"id":0,"x":314.2979431152344,"y":625.44140625,"pointerType":2,"pointerId":0}},{"type":3,"timestamp":1720693524774,"data":{"source":2,"type":9,"id":0,"x":322.00390625,"y":424.4384765625,"pointerType":2,"pointerId":0}}] + """.trimIndent() + ) + } + + val replay = fixture.getSut(context) + replay.register(fixture.hub, fixture.options) + + assertTrue(oldReplay.exists()) // should not be deleted until the video is packed into envelope + verify(fixture.hub).captureReplay( + check { + assertEquals(oldReplayId, it.replayId) + assertEquals(ReplayType.SESSION, it.replayType) + assertEquals("0.mp4", it.videoFile?.name) + }, + check { + val metaEvents = it.replayRecording?.payload?.filterIsInstance() + assertEquals(912, metaEvents?.first()?.height) + assertEquals(416, metaEvents?.first()?.width) // clamped to power of 16 + + val videoEvents = it.replayRecording?.payload?.filterIsInstance() + assertEquals(912, videoEvents?.first()?.height) + assertEquals(416, videoEvents?.first()?.width) // clamped to power of 16 + assertEquals(5000, videoEvents?.first()?.durationMs) + assertEquals(5, videoEvents?.first()?.frameCount) + assertEquals(1, videoEvents?.first()?.frameRate) + assertEquals(1, videoEvents?.first()?.segmentId) + + val breadcrumbEvents = + it.replayRecording?.payload?.filterIsInstance() + assertEquals("navigation", breadcrumbEvents?.first()?.category) + assertEquals("to", breadcrumbEvents?.first()?.data?.get("to")) + + val interactionEvents = + it.replayRecording?.payload?.filterIsInstance() + assertEquals( + InteractionType.TouchStart, + interactionEvents?.first()?.interactionType + ) + assertEquals(314.29794f, interactionEvents?.first()?.x) + assertEquals(625.4414f, interactionEvents?.first()?.y) + + assertEquals(InteractionType.TouchEnd, interactionEvents?.last()?.interactionType) + assertEquals(322.0039f, interactionEvents?.last()?.x) + assertEquals(424.43848f, interactionEvents?.last()?.y) + } + ) + } + + @Test + fun `register cleans up old replays`() { + val replayId = SentryId() + + fixture.options.cacheDirPath = tmpDir.newFolder().absolutePath + val evenOlderReplay = + File(fixture.options.cacheDirPath, "replay_${SentryId()}").also { it.mkdirs() } + val scopeCache = File( + fixture.options.cacheDirPath, + PersistingScopeObserver.SCOPE_CACHE + ).also { it.mkdirs() } + + val captureStrategy = mock { + on { currentReplayId }.thenReturn(replayId) + } + val replay = fixture.getSut(context, replayCaptureStrategyProvider = { captureStrategy }) + replay.register(fixture.hub, fixture.options) + + assertTrue(scopeCache.exists()) + assertFalse(evenOlderReplay.exists()) + } } diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationWithRecorderTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationWithRecorderTest.kt index f7e4da2304..8e3bef2c2f 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationWithRecorderTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationWithRecorderTest.kt @@ -22,6 +22,7 @@ import io.sentry.rrweb.RRWebMetaEvent import io.sentry.rrweb.RRWebVideoEvent import io.sentry.transport.CurrentDateProvider import io.sentry.transport.ICurrentDateProvider +import io.sentry.util.thread.NoOpMainThreadChecker import org.awaitility.kotlin.await import org.junit.Rule import org.junit.Test @@ -49,7 +50,9 @@ class ReplayIntegrationWithRecorderTest { val tmpDir = TemporaryFolder() internal class Fixture { - val options = SentryOptions() + val options = SentryOptions().apply { + mainThreadChecker = NoOpMainThreadChecker.getInstance() + } val hub = mock() var encoder: SimpleVideoEncoder? = null diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplaySmokeTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplaySmokeTest.kt index 22f35b157b..53ef7c009e 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplaySmokeTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplaySmokeTest.kt @@ -13,17 +13,13 @@ import android.widget.LinearLayout.LayoutParams import android.widget.TextView import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 -import io.sentry.Hint import io.sentry.IHub import io.sentry.Scope import io.sentry.ScopeCallback -import io.sentry.SentryEvent import io.sentry.SentryOptions import io.sentry.SentryReplayEvent.ReplayType import io.sentry.android.replay.video.MuxerConfig import io.sentry.android.replay.video.SimpleVideoEncoder -import io.sentry.protocol.Mechanism -import io.sentry.protocol.SentryException import io.sentry.rrweb.RRWebMetaEvent import io.sentry.rrweb.RRWebVideoEvent import io.sentry.transport.CurrentDateProvider @@ -221,14 +217,7 @@ class ReplaySmokeTest { } catch (e: ConditionTimeoutException) { } - val crash = SentryEvent().apply { - exceptions = listOf( - SentryException().apply { - mechanism = Mechanism().apply { isHandled = false } - } - ) - } - replay.sendReplayForEvent(crash, Hint()) + replay.captureReplay(isTerminating = false) await.timeout(Duration.ofSeconds(5)).untilTrue(captured) diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/BufferCaptureStrategyTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/BufferCaptureStrategyTest.kt new file mode 100644 index 0000000000..5e5130aae8 --- /dev/null +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/BufferCaptureStrategyTest.kt @@ -0,0 +1,270 @@ +package io.sentry.android.replay.capture + +import android.graphics.Bitmap +import android.view.MotionEvent +import io.sentry.IHub +import io.sentry.Scope +import io.sentry.ScopeCallback +import io.sentry.SentryOptions +import io.sentry.SentryReplayEvent.ReplayType +import io.sentry.android.replay.DefaultReplayBreadcrumbConverter +import io.sentry.android.replay.GeneratedVideo +import io.sentry.android.replay.ReplayCache +import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_ID +import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_REPLAY_ID +import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_REPLAY_TYPE +import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_TIMESTAMP +import io.sentry.android.replay.ReplayFrame +import io.sentry.android.replay.ScreenshotRecorderConfig +import io.sentry.protocol.SentryId +import io.sentry.transport.CurrentDateProvider +import io.sentry.transport.ICurrentDateProvider +import org.awaitility.kotlin.await +import org.junit.Rule +import org.junit.rules.TemporaryFolder +import org.mockito.ArgumentMatchers.anyInt +import org.mockito.ArgumentMatchers.anyLong +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.doAnswer +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import java.io.File +import java.security.SecureRandom +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class BufferCaptureStrategyTest { + + @get:Rule + val tmpDir = TemporaryFolder() + + internal class Fixture { + companion object { + const val VIDEO_DURATION = 5000L + } + + val options = SentryOptions().apply { + setReplayController( + mock { + on { breadcrumbConverter }.thenReturn(DefaultReplayBreadcrumbConverter()) + } + ) + } + val scope = Scope(options) + val hub = mock { + doAnswer { + (it.arguments[0] as ScopeCallback).run(scope) + }.whenever(it).configureScope(any()) + } + var persistedSegment = mutableMapOf() + val replayCache = mock { + on { frames }.thenReturn(mutableListOf(ReplayFrame(File("1720693523997.jpg"), 1720693523997))) + on { persistSegmentValues(any(), anyOrNull()) }.then { + persistedSegment.put(it.arguments[0].toString(), it.arguments[1]?.toString()) + } + on { createVideoOf(anyLong(), anyLong(), anyInt(), anyInt(), anyInt(), any()) } + .thenReturn(GeneratedVideo(File("0.mp4"), 5, VIDEO_DURATION)) + } + val recorderConfig = ScreenshotRecorderConfig( + recordingWidth = 1080, + recordingHeight = 1920, + scaleFactorX = 1f, + scaleFactorY = 1f, + frameRate = 1, + bitRate = 20_000 + ) + + fun getSut( + errorSampleRate: Double = 1.0, + dateProvider: ICurrentDateProvider = CurrentDateProvider.getInstance(), + replayCacheDir: File? = null + ): BufferCaptureStrategy { + replayCacheDir?.let { + whenever(replayCache.replayCacheDir).thenReturn(it) + } + options.run { + experimental.sessionReplay.errorSampleRate = errorSampleRate + } + return BufferCaptureStrategy( + options, + hub, + dateProvider, + SecureRandom(), + mock { + doAnswer { invocation -> + (invocation.arguments[0] as Runnable).run() + null + }.whenever(it).submit(any()) + } + ) { _, _ -> replayCache } + } + + fun mockedMotionEvent(action: Int): MotionEvent = mock { + on { actionMasked }.thenReturn(action) + on { getPointerId(anyInt()) }.thenReturn(0) + on { findPointerIndex(anyInt()) }.thenReturn(0) + on { getX(anyInt()) }.thenReturn(1f) + on { getY(anyInt()) }.thenReturn(1f) + } + } + + private val fixture = Fixture() + + @Test + fun `start does not set replayId on scope for buffered session`() { + val strategy = fixture.getSut() + val replayId = SentryId() + + strategy.start(fixture.recorderConfig, 0, replayId) + + assertEquals(SentryId.EMPTY_ID, fixture.scope.replayId) + assertEquals(replayId, strategy.currentReplayId) + assertEquals(0, strategy.currentSegment) + } + + @Test + fun `start persists segment values`() { + val strategy = fixture.getSut() + val replayId = SentryId() + + strategy.start(fixture.recorderConfig, 0, replayId) + + assertEquals("0", fixture.persistedSegment[SEGMENT_KEY_ID]) + assertEquals(replayId.toString(), fixture.persistedSegment[SEGMENT_KEY_REPLAY_ID]) + assertEquals( + ReplayType.BUFFER.toString(), + fixture.persistedSegment[SEGMENT_KEY_REPLAY_TYPE] + ) + assertTrue(fixture.persistedSegment[SEGMENT_KEY_TIMESTAMP]?.isNotEmpty() == true) + } + + @Test + fun `pause creates but does not capture current segment`() { + val strategy = fixture.getSut() + strategy.start(fixture.recorderConfig, 0, SentryId()) + + strategy.pause() + + await.until { strategy.currentSegment == 1 } + + verify(fixture.hub, never()).captureReplay(any(), any()) + assertEquals(1, strategy.currentSegment) + } + + @Test + fun `stop clears replay cache dir`() { + val replayId = SentryId() + val currentReplay = + File(fixture.options.cacheDirPath, "replay_$replayId").also { it.mkdirs() } + + val strategy = fixture.getSut(replayCacheDir = currentReplay) + strategy.start(fixture.recorderConfig, 0, replayId) + + strategy.stop() + + verify(fixture.hub, never()).captureReplay(any(), any()) + + assertEquals(SentryId.EMPTY_ID, strategy.currentReplayId) + assertEquals(-1, strategy.currentSegment) + assertFalse(currentReplay.exists()) + verify(fixture.replayCache).close() + } + + @Test + fun `onScreenshotRecorded adds screenshot to cache`() { + val now = + System.currentTimeMillis() + (fixture.options.experimental.sessionReplay.errorReplayDuration * 5) + val strategy = fixture.getSut( + dateProvider = { now } + ) + strategy.start(fixture.recorderConfig) + + strategy.onScreenshotRecorded(mock()) { frameTimestamp -> + assertEquals(now, frameTimestamp) + } + } + + @Test + fun `onScreenshotRecorded rotates screenshots when out of buffer bounds`() { + val now = + System.currentTimeMillis() + (fixture.options.experimental.sessionReplay.errorReplayDuration * 5) + val strategy = fixture.getSut( + dateProvider = { now } + ) + strategy.start(fixture.recorderConfig) + + strategy.onScreenshotRecorded(mock()) { frameTimestamp -> + assertEquals(now, frameTimestamp) + } + verify(fixture.replayCache).rotate(eq(now - fixture.options.experimental.sessionReplay.errorReplayDuration)) + } + + @Test + fun `onConfigurationChanged creates new segment and updates config`() { + val strategy = fixture.getSut() + strategy.start(fixture.recorderConfig) + + val newConfig = fixture.recorderConfig.copy(recordingHeight = 1080, recordingWidth = 1920) + strategy.onConfigurationChanged(newConfig) + + await.until { strategy.currentSegment == 1 } + + verify(fixture.hub, never()).captureReplay(any(), any()) + assertEquals(1, strategy.currentSegment) + } + + @Test + fun `convert does nothing when process is terminating`() { + val strategy = fixture.getSut() + strategy.start(fixture.recorderConfig) + + strategy.captureReplay(true) {} + + val converted = strategy.convert() + assertTrue(converted is BufferCaptureStrategy) + } + + @Test + fun `convert converts to session strategy and sets replayId to scope`() { + val strategy = fixture.getSut() + strategy.start(fixture.recorderConfig) + + val converted = strategy.convert() + assertTrue(converted is SessionCaptureStrategy) + assertEquals(strategy.currentReplayId, fixture.scope.replayId) + } + + @Test + fun `captureReplay does not replayId to scope when not sampled`() { + val strategy = fixture.getSut(errorSampleRate = 0.0) + strategy.start(fixture.recorderConfig) + + strategy.captureReplay(false) {} + + assertEquals(SentryId.EMPTY_ID, fixture.scope.replayId) + } + + @Test + fun `captureReplay sets replayId to scope and captures buffered segments`() { + var called = false + val strategy = fixture.getSut() + strategy.start(fixture.recorderConfig) + strategy.pause() + + strategy.captureReplay(false) { + called = true + } + + // buffered + current = 2 + verify(fixture.hub, times(2)).captureReplay(any(), any()) + assertEquals(strategy.currentReplayId, fixture.scope.replayId) + assertTrue(called) + } +} diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/SessionCaptureStrategyTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/SessionCaptureStrategyTest.kt new file mode 100644 index 0000000000..ac593f6c27 --- /dev/null +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/SessionCaptureStrategyTest.kt @@ -0,0 +1,355 @@ +package io.sentry.android.replay.capture + +import android.graphics.Bitmap +import io.sentry.Breadcrumb +import io.sentry.DateUtils +import io.sentry.IHub +import io.sentry.Scope +import io.sentry.ScopeCallback +import io.sentry.SentryOptions +import io.sentry.SentryReplayEvent +import io.sentry.SentryReplayEvent.ReplayType +import io.sentry.android.replay.DefaultReplayBreadcrumbConverter +import io.sentry.android.replay.GeneratedVideo +import io.sentry.android.replay.ReplayCache +import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_BIT_RATE +import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_FRAME_RATE +import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_HEIGHT +import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_ID +import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_REPLAY_ID +import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_REPLAY_TYPE +import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_TIMESTAMP +import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_WIDTH +import io.sentry.android.replay.ReplayFrame +import io.sentry.android.replay.ScreenshotRecorderConfig +import io.sentry.protocol.SentryId +import io.sentry.rrweb.RRWebBreadcrumbEvent +import io.sentry.rrweb.RRWebMetaEvent +import io.sentry.transport.CurrentDateProvider +import io.sentry.transport.ICurrentDateProvider +import org.junit.Rule +import org.junit.rules.TemporaryFolder +import org.mockito.ArgumentMatchers.anyInt +import org.mockito.ArgumentMatchers.anyLong +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.argThat +import org.mockito.kotlin.check +import org.mockito.kotlin.doAnswer +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import java.io.File +import java.util.Date +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class SessionCaptureStrategyTest { + + @get:Rule + val tmpDir = TemporaryFolder() + + internal class Fixture { + companion object { + const val VIDEO_DURATION = 5000L + } + + val options = SentryOptions().apply { + setReplayController( + mock { + on { breadcrumbConverter }.thenReturn(DefaultReplayBreadcrumbConverter()) + } + ) + } + val scope = Scope(options) + val hub = mock { + doAnswer { + (it.arguments[0] as ScopeCallback).run(scope) + }.whenever(it).configureScope(any()) + } + var persistedSegment = mutableMapOf() + val replayCache = mock { + on { frames }.thenReturn(mutableListOf(ReplayFrame(File("1720693523997.jpg"), 1720693523997))) + on { persistSegmentValues(any(), anyOrNull()) }.then { + persistedSegment.put(it.arguments[0].toString(), it.arguments[1]?.toString()) + } + on { createVideoOf(anyLong(), anyLong(), anyInt(), anyInt(), anyInt(), any()) } + .thenReturn(GeneratedVideo(File("0.mp4"), 5, VIDEO_DURATION)) + } + val recorderConfig = ScreenshotRecorderConfig( + recordingWidth = 1080, + recordingHeight = 1920, + scaleFactorX = 1f, + scaleFactorY = 1f, + frameRate = 1, + bitRate = 20_000 + ) + + fun getSut( + dateProvider: ICurrentDateProvider = CurrentDateProvider.getInstance(), + replayCacheDir: File? = null + ): SessionCaptureStrategy { + replayCacheDir?.let { + whenever(replayCache.replayCacheDir).thenReturn(it) + } + return SessionCaptureStrategy( + options, + hub, + dateProvider, + mock { + doAnswer { invocation -> + (invocation.arguments[0] as Runnable).run() + null + }.whenever(it).submit(any()) + } + ) { _, _ -> replayCache } + } + } + + private val fixture = Fixture() + + @Test + fun `start sets replayId on scope for full session`() { + val strategy = fixture.getSut() + val replayId = SentryId() + + strategy.start(fixture.recorderConfig, 0, replayId) + + assertEquals(replayId, fixture.scope.replayId) + assertEquals(replayId, strategy.currentReplayId) + assertEquals(0, strategy.currentSegment) + } + + @Test + fun `start persists segment values`() { + val strategy = fixture.getSut() + val replayId = SentryId() + + strategy.start(fixture.recorderConfig, 0, replayId) + + assertEquals("0", fixture.persistedSegment[SEGMENT_KEY_ID]) + assertEquals(replayId.toString(), fixture.persistedSegment[SEGMENT_KEY_REPLAY_ID]) + assertEquals( + ReplayType.SESSION.toString(), + fixture.persistedSegment[SEGMENT_KEY_REPLAY_TYPE] + ) + assertEquals( + fixture.recorderConfig.recordingWidth.toString(), + fixture.persistedSegment[SEGMENT_KEY_WIDTH] + ) + assertEquals( + fixture.recorderConfig.recordingHeight.toString(), + fixture.persistedSegment[SEGMENT_KEY_HEIGHT] + ) + assertEquals( + fixture.recorderConfig.frameRate.toString(), + fixture.persistedSegment[SEGMENT_KEY_FRAME_RATE] + ) + assertEquals( + fixture.recorderConfig.bitRate.toString(), + fixture.persistedSegment[SEGMENT_KEY_BIT_RATE] + ) + assertTrue(fixture.persistedSegment[SEGMENT_KEY_TIMESTAMP]?.isNotEmpty() == true) + } + + @Test + fun `pause creates and captures current segment`() { + val strategy = fixture.getSut() + strategy.start(fixture.recorderConfig, 0, SentryId()) + + strategy.pause() + + verify(fixture.hub).captureReplay( + argThat { event -> + event is SentryReplayEvent && event.segmentId == 0 + }, + any() + ) + assertEquals(1, strategy.currentSegment) + } + + @Test + fun `stop creates and captures current segment and clears replayId from scope`() { + val replayId = SentryId() + val currentReplay = + File(fixture.options.cacheDirPath, "replay_$replayId").also { it.mkdirs() } + + val strategy = fixture.getSut(replayCacheDir = currentReplay) + strategy.start(fixture.recorderConfig, 0, replayId) + + strategy.stop() + + verify(fixture.hub).captureReplay( + argThat { event -> + event is SentryReplayEvent && event.segmentId == 0 + }, + any() + ) + assertEquals(SentryId.EMPTY_ID, fixture.scope.replayId) + assertEquals(SentryId.EMPTY_ID, strategy.currentReplayId) + assertEquals(-1, strategy.currentSegment) + assertFalse(currentReplay.exists()) + verify(fixture.replayCache).close() + } + + @Test + fun `captureReplay does nothing for non-crashed event`() { + val strategy = fixture.getSut() + strategy.start(fixture.recorderConfig) + + strategy.captureReplay(false) {} + + verify(fixture.hub, never()).captureReplay(any(), any()) + } + + @Test + fun `when process is crashing, onScreenshotRecorded does not create new segment`() { + val now = + System.currentTimeMillis() + (fixture.options.experimental.sessionReplay.sessionSegmentDuration * 5) + val strategy = fixture.getSut( + dateProvider = { now } + ) + strategy.start(fixture.recorderConfig) + + strategy.captureReplay(true) {} + strategy.onScreenshotRecorded(mock()) {} + + verify(fixture.hub, never()).captureReplay(any(), any()) + } + + @Test + fun `onScreenshotRecorded creates new segment when segment duration exceeded`() { + val now = + System.currentTimeMillis() + (fixture.options.experimental.sessionReplay.sessionSegmentDuration * 5) + val strategy = fixture.getSut( + dateProvider = { now } + ) + strategy.start(fixture.recorderConfig) + + strategy.onScreenshotRecorded(mock()) { frameTimestamp -> + assertEquals(now, frameTimestamp) + } + + var segmentTimestamp: Date? = null + verify(fixture.hub).captureReplay( + argThat { event -> + segmentTimestamp = event.replayStartTimestamp + event is SentryReplayEvent && event.segmentId == 0 + }, + any() + ) + assertEquals(1, strategy.currentSegment) + + segmentTimestamp!!.time = segmentTimestamp!!.time.plus(Fixture.VIDEO_DURATION) + // timestamp should be updated with video duration + assertEquals( + DateUtils.getTimestamp(segmentTimestamp!!), + fixture.persistedSegment[SEGMENT_KEY_TIMESTAMP] + ) + } + + @Test + fun `onScreenshotRecorded stops replay when replay duration exceeded`() { + val now = + System.currentTimeMillis() + (fixture.options.experimental.sessionReplay.sessionDuration * 2) + var count = 0 + val strategy = fixture.getSut( + dateProvider = { + // we only need to fake value for the 3rd call (first two is for replayStartTimestamp and frameTimestamp) + if (count++ == 2) { + now + } else { + System.currentTimeMillis() + } + } + ) + strategy.start(fixture.recorderConfig) + + strategy.onScreenshotRecorded(mock()) {} + + verify(fixture.options.replayController).stop() + } + + @Test + fun `onConfigurationChanged creates new segment and updates config`() { + val strategy = fixture.getSut() + strategy.start(fixture.recorderConfig) + + val newConfig = fixture.recorderConfig.copy(recordingHeight = 1080, recordingWidth = 1920) + strategy.onConfigurationChanged(newConfig) + + var segmentTimestamp: Date? = null + verify(fixture.hub).captureReplay( + argThat { event -> + segmentTimestamp = event.replayStartTimestamp + event is SentryReplayEvent && event.segmentId == 0 + }, + check { + val metaEvents = it.replayRecording?.payload?.filterIsInstance() + // should still capture with the old values + assertEquals(1920, metaEvents?.first()?.height) + assertEquals(1080, metaEvents?.first()?.width) + } + ) + assertEquals(1, strategy.currentSegment) + + segmentTimestamp!!.time = segmentTimestamp!!.time.plus(Fixture.VIDEO_DURATION) + assertEquals("1080", fixture.persistedSegment[SEGMENT_KEY_HEIGHT]) + assertEquals("1920", fixture.persistedSegment[SEGMENT_KEY_WIDTH]) + // timestamp should be updated with video duration + assertEquals( + DateUtils.getTimestamp(segmentTimestamp!!), + fixture.persistedSegment[SEGMENT_KEY_TIMESTAMP] + ) + } + + @Test + fun `fills replay urls from navigation breadcrumbs`() { + val now = + System.currentTimeMillis() + (fixture.options.experimental.sessionReplay.sessionSegmentDuration * 5) + val strategy = fixture.getSut(dateProvider = { now }) + strategy.start(fixture.recorderConfig) + + fixture.scope.addBreadcrumb(Breadcrumb.navigation("from", "to")) + + strategy.onScreenshotRecorded(mock()) {} + + verify(fixture.hub).captureReplay( + check { + assertEquals("to", it.urls!!.first()) + }, + check { + val breadcrumbEvents = + it.replayRecording?.payload?.filterIsInstance() + assertEquals("navigation", breadcrumbEvents?.first()?.category) + assertEquals("to", breadcrumbEvents?.first()?.data?.get("to")) + } + ) + } + + @Test + fun `sets screen from scope as replay url`() { + fixture.scope.screen = "MainActivity" + + val now = + System.currentTimeMillis() + (fixture.options.experimental.sessionReplay.sessionSegmentDuration * 5) + val strategy = fixture.getSut(dateProvider = { now }) + strategy.start(fixture.recorderConfig) + + strategy.onScreenshotRecorded(mock()) {} + + verify(fixture.hub).captureReplay( + check { + assertEquals("MainActivity", it.urls!!.first()) + }, + check { + val breadcrumbEvents = + it.replayRecording?.payload?.filterIsInstance() + assertTrue(breadcrumbEvents?.isEmpty() == true) + } + ) + } +} diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index af9ffe9fc4..598b2c7b6b 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -658,6 +658,7 @@ public abstract interface class io/sentry/IOptionsObserver { public abstract fun setEnvironment (Ljava/lang/String;)V public abstract fun setProguardUuid (Ljava/lang/String;)V public abstract fun setRelease (Ljava/lang/String;)V + public abstract fun setReplayErrorSampleRate (Ljava/lang/Double;)V public abstract fun setSdkVersion (Lio/sentry/protocol/SdkVersion;)V public abstract fun setTags (Ljava/util/Map;)V } @@ -743,10 +744,11 @@ public abstract interface class io/sentry/IScopeObserver { public abstract fun setExtras (Ljava/util/Map;)V public abstract fun setFingerprint (Ljava/util/Collection;)V public abstract fun setLevel (Lio/sentry/SentryLevel;)V + public abstract fun setReplayId (Lio/sentry/protocol/SentryId;)V public abstract fun setRequest (Lio/sentry/protocol/Request;)V public abstract fun setTag (Ljava/lang/String;Ljava/lang/String;)V public abstract fun setTags (Ljava/util/Map;)V - public abstract fun setTrace (Lio/sentry/SpanContext;)V + public abstract fun setTrace (Lio/sentry/SpanContext;Lio/sentry/IScope;)V public abstract fun setTransaction (Ljava/lang/String;)V public abstract fun setUser (Lio/sentry/protocol/User;)V } @@ -1259,14 +1261,13 @@ public final class io/sentry/NoOpReplayBreadcrumbConverter : io/sentry/ReplayBre } public final class io/sentry/NoOpReplayController : io/sentry/ReplayController { + public fun captureReplay (Ljava/lang/Boolean;)V public fun getBreadcrumbConverter ()Lio/sentry/ReplayBreadcrumbConverter; public static fun getInstance ()Lio/sentry/NoOpReplayController; public fun getReplayId ()Lio/sentry/protocol/SentryId; public fun isRecording ()Z public fun pause ()V public fun resume ()V - public fun sendReplay (Ljava/lang/Boolean;Ljava/lang/String;Lio/sentry/Hint;)V - public fun sendReplayForEvent (Lio/sentry/SentryEvent;Lio/sentry/Hint;)V public fun setBreadcrumbConverter (Lio/sentry/ReplayBreadcrumbConverter;)V public fun start ()V public fun stop ()V @@ -1666,6 +1667,7 @@ public final class io/sentry/PropagationContext { public fun setSampled (Ljava/lang/Boolean;)V public fun setSpanId (Lio/sentry/SpanId;)V public fun setTraceId (Lio/sentry/protocol/SentryId;)V + public fun toSpanContext ()Lio/sentry/SpanContext; public fun traceContext ()Lio/sentry/TraceContext; } @@ -1674,13 +1676,12 @@ public abstract interface class io/sentry/ReplayBreadcrumbConverter { } public abstract interface class io/sentry/ReplayController { + public abstract fun captureReplay (Ljava/lang/Boolean;)V public abstract fun getBreadcrumbConverter ()Lio/sentry/ReplayBreadcrumbConverter; public abstract fun getReplayId ()Lio/sentry/protocol/SentryId; public abstract fun isRecording ()Z public abstract fun pause ()V public abstract fun resume ()V - public abstract fun sendReplay (Ljava/lang/Boolean;Ljava/lang/String;Lio/sentry/Hint;)V - public abstract fun sendReplayForEvent (Lio/sentry/SentryEvent;Lio/sentry/Hint;)V public abstract fun setBreadcrumbConverter (Lio/sentry/ReplayBreadcrumbConverter;)V public abstract fun start ()V public abstract fun stop ()V @@ -1804,10 +1805,11 @@ public abstract class io/sentry/ScopeObserverAdapter : io/sentry/IScopeObserver public fun setExtras (Ljava/util/Map;)V public fun setFingerprint (Ljava/util/Collection;)V public fun setLevel (Lio/sentry/SentryLevel;)V + public fun setReplayId (Lio/sentry/protocol/SentryId;)V public fun setRequest (Lio/sentry/protocol/Request;)V public fun setTag (Ljava/lang/String;Ljava/lang/String;)V public fun setTags (Ljava/util/Map;)V - public fun setTrace (Lio/sentry/SpanContext;)V + public fun setTrace (Lio/sentry/SpanContext;Lio/sentry/IScope;)V public fun setTransaction (Ljava/lang/String;)V public fun setUser (Lio/sentry/protocol/User;)V } @@ -2117,7 +2119,7 @@ public final class io/sentry/SentryEnvelopeItem { public static fun fromEvent (Lio/sentry/ISerializer;Lio/sentry/SentryBaseEvent;)Lio/sentry/SentryEnvelopeItem; public static fun fromMetrics (Lio/sentry/metrics/EncodedMetrics;)Lio/sentry/SentryEnvelopeItem; public static fun fromProfilingTrace (Lio/sentry/ProfilingTraceData;JLio/sentry/ISerializer;)Lio/sentry/SentryEnvelopeItem; - public static fun fromReplay (Lio/sentry/ISerializer;Lio/sentry/ILogger;Lio/sentry/SentryReplayEvent;Lio/sentry/ReplayRecording;)Lio/sentry/SentryEnvelopeItem; + public static fun fromReplay (Lio/sentry/ISerializer;Lio/sentry/ILogger;Lio/sentry/SentryReplayEvent;Lio/sentry/ReplayRecording;Z)Lio/sentry/SentryEnvelopeItem; public static fun fromSession (Lio/sentry/ISerializer;Lio/sentry/Session;)Lio/sentry/SentryEnvelopeItem; public static fun fromUserFeedback (Lio/sentry/ISerializer;Lio/sentry/UserFeedback;)Lio/sentry/SentryEnvelopeItem; public fun getClientReport (Lio/sentry/ISerializer;)Lio/sentry/clientreport/ClientReport; @@ -3329,6 +3331,7 @@ public final class io/sentry/cache/PersistingOptionsObserver : io/sentry/IOption public static final field OPTIONS_CACHE Ljava/lang/String; public static final field PROGUARD_UUID_FILENAME Ljava/lang/String; public static final field RELEASE_FILENAME Ljava/lang/String; + public static final field REPLAY_ERROR_SAMPLE_RATE_FILENAME Ljava/lang/String; public static final field SDK_VERSION_FILENAME Ljava/lang/String; public static final field TAGS_FILENAME Ljava/lang/String; public fun (Lio/sentry/SentryOptions;)V @@ -3338,6 +3341,7 @@ public final class io/sentry/cache/PersistingOptionsObserver : io/sentry/IOption public fun setEnvironment (Ljava/lang/String;)V public fun setProguardUuid (Ljava/lang/String;)V public fun setRelease (Ljava/lang/String;)V + public fun setReplayErrorSampleRate (Ljava/lang/Double;)V public fun setSdkVersion (Lio/sentry/protocol/SdkVersion;)V public fun setTags (Ljava/util/Map;)V } @@ -3348,6 +3352,7 @@ public final class io/sentry/cache/PersistingScopeObserver : io/sentry/ScopeObse public static final field EXTRAS_FILENAME Ljava/lang/String; public static final field FINGERPRINT_FILENAME Ljava/lang/String; public static final field LEVEL_FILENAME Ljava/lang/String; + public static final field REPLAY_FILENAME Ljava/lang/String; public static final field REQUEST_FILENAME Ljava/lang/String; public static final field SCOPE_CACHE Ljava/lang/String; public static final field TAGS_FILENAME Ljava/lang/String; @@ -3362,11 +3367,13 @@ public final class io/sentry/cache/PersistingScopeObserver : io/sentry/ScopeObse public fun setExtras (Ljava/util/Map;)V public fun setFingerprint (Ljava/util/Collection;)V public fun setLevel (Lio/sentry/SentryLevel;)V + public fun setReplayId (Lio/sentry/protocol/SentryId;)V public fun setRequest (Lio/sentry/protocol/Request;)V public fun setTags (Ljava/util/Map;)V - public fun setTrace (Lio/sentry/SpanContext;)V + public fun setTrace (Lio/sentry/SpanContext;Lio/sentry/IScope;)V public fun setTransaction (Ljava/lang/String;)V public fun setUser (Lio/sentry/protocol/User;)V + public static fun store (Lio/sentry/SentryOptions;Ljava/lang/Object;Ljava/lang/String;)V } public final class io/sentry/clientreport/ClientReport : io/sentry/JsonSerializable, io/sentry/JsonUnknown { @@ -3974,6 +3981,7 @@ public final class io/sentry/protocol/Browser$JsonKeys { } public final class io/sentry/protocol/Contexts : java/util/concurrent/ConcurrentHashMap, io/sentry/JsonSerializable { + public static final field REPLAY_ID Ljava/lang/String; public fun ()V public fun (Lio/sentry/protocol/Contexts;)V public fun getApp ()Lio/sentry/protocol/App; diff --git a/sentry/src/main/java/io/sentry/Baggage.java b/sentry/src/main/java/io/sentry/Baggage.java index de7cf95a20..d736358ee1 100644 --- a/sentry/src/main/java/io/sentry/Baggage.java +++ b/sentry/src/main/java/io/sentry/Baggage.java @@ -1,5 +1,7 @@ package io.sentry; +import static io.sentry.protocol.Contexts.REPLAY_ID; + import io.sentry.protocol.SentryId; import io.sentry.protocol.TransactionNameSource; import io.sentry.protocol.User; @@ -141,7 +143,12 @@ public static Baggage fromEvent( // we don't persist sample rate baggage.setSampleRate(null); baggage.setSampled(null); - // TODO: add replay_id later + final @Nullable Object replayId = event.getContexts().get(REPLAY_ID); + if (replayId != null && !replayId.toString().equals(SentryId.EMPTY_ID.toString())) { + baggage.setReplayId(replayId.toString()); + // relay will set it from the DSC, we don't need to send it + event.getContexts().remove(REPLAY_ID); + } baggage.freeze(); return baggage; } diff --git a/sentry/src/main/java/io/sentry/IOptionsObserver.java b/sentry/src/main/java/io/sentry/IOptionsObserver.java index 54cacc666a..5a2ddcc9b5 100644 --- a/sentry/src/main/java/io/sentry/IOptionsObserver.java +++ b/sentry/src/main/java/io/sentry/IOptionsObserver.java @@ -22,4 +22,6 @@ public interface IOptionsObserver { void setDist(@Nullable String dist); void setTags(@NotNull Map tags); + + void setReplayErrorSampleRate(@Nullable Double replayErrorSampleRate); } diff --git a/sentry/src/main/java/io/sentry/IScopeObserver.java b/sentry/src/main/java/io/sentry/IScopeObserver.java index 4a103668d2..a43ccf6b69 100644 --- a/sentry/src/main/java/io/sentry/IScopeObserver.java +++ b/sentry/src/main/java/io/sentry/IScopeObserver.java @@ -2,6 +2,7 @@ import io.sentry.protocol.Contexts; import io.sentry.protocol.Request; +import io.sentry.protocol.SentryId; import io.sentry.protocol.User; import java.util.Collection; import java.util.Map; @@ -41,5 +42,7 @@ public interface IScopeObserver { void setTransaction(@Nullable String transaction); - void setTrace(@Nullable SpanContext spanContext); + void setTrace(@Nullable SpanContext spanContext, @NotNull IScope scope); + + void setReplayId(@NotNull SentryId replayId); } diff --git a/sentry/src/main/java/io/sentry/NoOpReplayController.java b/sentry/src/main/java/io/sentry/NoOpReplayController.java index d365f650ea..e868038db2 100644 --- a/sentry/src/main/java/io/sentry/NoOpReplayController.java +++ b/sentry/src/main/java/io/sentry/NoOpReplayController.java @@ -32,11 +32,7 @@ public boolean isRecording() { } @Override - public void sendReplayForEvent(@NotNull SentryEvent event, @NotNull Hint hint) {} - - @Override - public void sendReplay( - @Nullable Boolean isCrashed, @Nullable String eventId, @Nullable Hint hint) {} + public void captureReplay(@Nullable Boolean isTerminating) {} @Override public @NotNull SentryId getReplayId() { diff --git a/sentry/src/main/java/io/sentry/PropagationContext.java b/sentry/src/main/java/io/sentry/PropagationContext.java index 9a29e8c161..b0debc2a9d 100644 --- a/sentry/src/main/java/io/sentry/PropagationContext.java +++ b/sentry/src/main/java/io/sentry/PropagationContext.java @@ -139,4 +139,10 @@ public void setSampled(final @Nullable Boolean sampled) { return null; } + + public @NotNull SpanContext toSpanContext() { + final SpanContext spanContext = new SpanContext(traceId, spanId, "default", null, null); + spanContext.setOrigin("auto"); + return spanContext; + } } diff --git a/sentry/src/main/java/io/sentry/ReplayController.java b/sentry/src/main/java/io/sentry/ReplayController.java index caaa847423..01c0f9da12 100644 --- a/sentry/src/main/java/io/sentry/ReplayController.java +++ b/sentry/src/main/java/io/sentry/ReplayController.java @@ -17,9 +17,7 @@ public interface ReplayController { boolean isRecording(); - void sendReplayForEvent(@NotNull SentryEvent event, @NotNull Hint hint); - - void sendReplay(@Nullable Boolean isCrashed, @Nullable String eventId, @Nullable Hint hint); + void captureReplay(@Nullable Boolean isTerminating); @NotNull SentryId getReplayId(); diff --git a/sentry/src/main/java/io/sentry/ReplayRecording.java b/sentry/src/main/java/io/sentry/ReplayRecording.java index ca1c676dbd..a83eddd380 100644 --- a/sentry/src/main/java/io/sentry/ReplayRecording.java +++ b/sentry/src/main/java/io/sentry/ReplayRecording.java @@ -81,7 +81,9 @@ public void serialize(final @NotNull ObjectWriter writer, final @NotNull ILogger // {"segment_id":0}\n{json-serialized-rrweb-protocol} writer.setLenient(true); - writer.jsonValue("\n"); + if (segmentId != null) { + writer.jsonValue("\n"); + } if (payload != null) { writer.value(logger, payload); } diff --git a/sentry/src/main/java/io/sentry/Scope.java b/sentry/src/main/java/io/sentry/Scope.java index be24c34dfb..f071cb8c5e 100644 --- a/sentry/src/main/java/io/sentry/Scope.java +++ b/sentry/src/main/java/io/sentry/Scope.java @@ -243,10 +243,10 @@ public void setTransaction(final @Nullable ITransaction transaction) { for (final IScopeObserver observer : options.getScopeObservers()) { if (transaction != null) { observer.setTransaction(transaction.getName()); - observer.setTrace(transaction.getSpanContext()); + observer.setTrace(transaction.getSpanContext(), this); } else { observer.setTransaction(null); - observer.setTrace(null); + observer.setTrace(null, this); } } } @@ -326,7 +326,9 @@ public void setScreen(final @Nullable String screen) { public void setReplayId(final @NotNull SentryId replayId) { this.replayId = replayId; - // TODO: set to contexts and notify observers to persist this as well + for (final IScopeObserver observer : options.getScopeObservers()) { + observer.setReplayId(replayId); + } } /** @@ -486,7 +488,7 @@ public void clearTransaction() { for (final IScopeObserver observer : options.getScopeObservers()) { observer.setTransaction(null); - observer.setTrace(null); + observer.setTrace(null, this); } } @@ -940,6 +942,11 @@ public void clearSession() { @Override public void setPropagationContext(final @NotNull PropagationContext propagationContext) { this.propagationContext = propagationContext; + + final @NotNull SpanContext spanContext = propagationContext.toSpanContext(); + for (final IScopeObserver observer : options.getScopeObservers()) { + observer.setTrace(spanContext, this); + } } @ApiStatus.Internal diff --git a/sentry/src/main/java/io/sentry/ScopeObserverAdapter.java b/sentry/src/main/java/io/sentry/ScopeObserverAdapter.java index 38d0cdf7a1..f0ec6448e0 100644 --- a/sentry/src/main/java/io/sentry/ScopeObserverAdapter.java +++ b/sentry/src/main/java/io/sentry/ScopeObserverAdapter.java @@ -2,6 +2,7 @@ import io.sentry.protocol.Contexts; import io.sentry.protocol.Request; +import io.sentry.protocol.SentryId; import io.sentry.protocol.User; import java.util.Collection; import java.util.Map; @@ -52,5 +53,8 @@ public void setContexts(@NotNull Contexts contexts) {} public void setTransaction(@Nullable String transaction) {} @Override - public void setTrace(@Nullable SpanContext spanContext) {} + public void setTrace(@Nullable SpanContext spanContext, @NotNull IScope scope) {} + + @Override + public void setReplayId(@NotNull SentryId replayId) {} } diff --git a/sentry/src/main/java/io/sentry/Sentry.java b/sentry/src/main/java/io/sentry/Sentry.java index f157256ce5..13cf9ab389 100644 --- a/sentry/src/main/java/io/sentry/Sentry.java +++ b/sentry/src/main/java/io/sentry/Sentry.java @@ -356,6 +356,8 @@ private static void notifyOptionsObservers(final @NotNull SentryOptions options) observer.setDist(options.getDist()); observer.setEnvironment(options.getEnvironment()); observer.setTags(options.getTags()); + observer.setReplayErrorSampleRate( + options.getExperimental().getSessionReplay().getErrorSampleRate()); } }); } catch (Throwable e) { diff --git a/sentry/src/main/java/io/sentry/SentryClient.java b/sentry/src/main/java/io/sentry/SentryClient.java index 8d27793e1a..6868894340 100644 --- a/sentry/src/main/java/io/sentry/SentryClient.java +++ b/sentry/src/main/java/io/sentry/SentryClient.java @@ -199,13 +199,16 @@ private boolean shouldApplyScopeData(final @NotNull CheckIn event, final @NotNul sentryId = event.getEventId(); } - if (event != null) { - options.getReplayController().sendReplayForEvent(event, hint); + final boolean isBackfillable = HintUtils.hasType(hint, Backfillable.class); + // if event is backfillable we don't wanna trigger capture replay, because it's an event from + // the past + if (event != null && !isBackfillable && (event.isErrored() || event.isCrashed())) { + options.getReplayController().captureReplay(event.isCrashed()); } try { @Nullable TraceContext traceContext = null; - if (HintUtils.hasType(hint, Backfillable.class)) { + if (isBackfillable) { // for backfillable hint we synthesize Baggage from event values if (event != null) { final Baggage baggage = Baggage.fromEvent(event, options); @@ -239,12 +242,9 @@ private boolean shouldApplyScopeData(final @NotNull CheckIn event, final @NotNul } // if we encountered a crash/abnormal exit finish tracing in order to persist and send - // any running transaction / profiling data. We also finish session replay, and it has priority - // over transactions as it takes longer to finalize replay than transactions, therefore - // the replay_id will be the trigger for flushing and unblocking the thread in case of a crash + // any running transaction / profiling data. if (scope != null) { finalizeTransaction(scope, hint); - finalizeReplay(scope, hint); } return sentryId; @@ -265,18 +265,6 @@ private void finalizeTransaction(final @NotNull IScope scope, final @NotNull Hin } } - private void finalizeReplay(final @NotNull IScope scope, final @NotNull Hint hint) { - final @Nullable SentryId replayId = scope.getReplayId(); - if (!SentryId.EMPTY_ID.equals(replayId)) { - if (HintUtils.hasType(hint, TransactionEnd.class)) { - final Object sentrySdkHint = HintUtils.getSentrySdkHint(hint); - if (sentrySdkHint instanceof DiskFlushNotification) { - ((DiskFlushNotification) sentrySdkHint).setFlushable(replayId); - } - } - } - } - @Override public @NotNull SentryId captureReplayEvent( @NotNull SentryReplayEvent event, final @Nullable IScope scope, @Nullable Hint hint) { @@ -305,6 +293,7 @@ private void finalizeReplay(final @NotNull IScope scope, final @NotNull Hint hin } try { + // TODO: check if event is Backfillable and backfill traceContext from the event values @Nullable TraceContext traceContext = null; if (scope != null) { final @Nullable ITransaction transaction = scope.getTransaction(); @@ -317,7 +306,9 @@ private void finalizeReplay(final @NotNull IScope scope, final @NotNull Hint hin } } - final SentryEnvelope envelope = buildEnvelope(event, hint.getReplayRecording(), traceContext); + final boolean cleanupReplayFolder = HintUtils.hasType(hint, Backfillable.class); + final SentryEnvelope envelope = + buildEnvelope(event, hint.getReplayRecording(), traceContext, cleanupReplayFolder); hint.clear(); transport.send(envelope, hint); @@ -627,12 +618,17 @@ public void captureUserFeedback(final @NotNull UserFeedback userFeedback) { private @NotNull SentryEnvelope buildEnvelope( final @NotNull SentryReplayEvent event, final @Nullable ReplayRecording replayRecording, - final @Nullable TraceContext traceContext) { + final @Nullable TraceContext traceContext, + final boolean cleanupReplayFolder) { final List envelopeItems = new ArrayList<>(); final SentryEnvelopeItem replayItem = SentryEnvelopeItem.fromReplay( - options.getSerializer(), options.getLogger(), event, replayRecording); + options.getSerializer(), + options.getLogger(), + event, + replayRecording, + cleanupReplayFolder); envelopeItems.add(replayItem); final SentryId sentryId = event.getEventId(); diff --git a/sentry/src/main/java/io/sentry/SentryEnvelopeItem.java b/sentry/src/main/java/io/sentry/SentryEnvelopeItem.java index 856976b589..7862c8d664 100644 --- a/sentry/src/main/java/io/sentry/SentryEnvelopeItem.java +++ b/sentry/src/main/java/io/sentry/SentryEnvelopeItem.java @@ -8,6 +8,7 @@ import io.sentry.exception.SentryEnvelopeException; import io.sentry.metrics.EncodedMetrics; import io.sentry.protocol.SentryTransaction; +import io.sentry.util.FileUtils; import io.sentry.util.JsonSerializationUtils; import io.sentry.util.Objects; import io.sentry.vendor.Base64; @@ -372,7 +373,8 @@ public static SentryEnvelopeItem fromReplay( final @NotNull ISerializer serializer, final @NotNull ILogger logger, final @NotNull SentryReplayEvent replayEvent, - final @Nullable ReplayRecording replayRecording) { + final @Nullable ReplayRecording replayRecording, + final boolean cleanupReplayFolder) { final File replayVideo = replayEvent.getVideoFile(); @@ -415,7 +417,11 @@ public static SentryEnvelopeItem fromReplay( return null; } finally { if (replayVideo != null) { - replayVideo.delete(); + if (cleanupReplayFolder) { + FileUtils.deleteRecursively(replayVideo.getParentFile()); + } else { + replayVideo.delete(); + } } } }); diff --git a/sentry/src/main/java/io/sentry/cache/PersistingOptionsObserver.java b/sentry/src/main/java/io/sentry/cache/PersistingOptionsObserver.java index bb1bb71572..49ec2da904 100644 --- a/sentry/src/main/java/io/sentry/cache/PersistingOptionsObserver.java +++ b/sentry/src/main/java/io/sentry/cache/PersistingOptionsObserver.java @@ -16,6 +16,7 @@ public final class PersistingOptionsObserver implements IOptionsObserver { public static final String ENVIRONMENT_FILENAME = "environment.json"; public static final String DIST_FILENAME = "dist.json"; public static final String TAGS_FILENAME = "tags.json"; + public static final String REPLAY_ERROR_SAMPLE_RATE_FILENAME = "replay-error-sample-rate.json"; private final @NotNull SentryOptions options; @@ -73,6 +74,15 @@ public void setTags(@NotNull Map tags) { store(tags, TAGS_FILENAME); } + @Override + public void setReplayErrorSampleRate(@Nullable Double replayErrorSampleRate) { + if (replayErrorSampleRate == null) { + delete(REPLAY_ERROR_SAMPLE_RATE_FILENAME); + } else { + store(replayErrorSampleRate.toString(), REPLAY_ERROR_SAMPLE_RATE_FILENAME); + } + } + private void store(final @NotNull T entity, final @NotNull String fileName) { CacheUtils.store(options, entity, OPTIONS_CACHE, fileName); } diff --git a/sentry/src/main/java/io/sentry/cache/PersistingScopeObserver.java b/sentry/src/main/java/io/sentry/cache/PersistingScopeObserver.java index 0c4a110733..7c186cf99d 100644 --- a/sentry/src/main/java/io/sentry/cache/PersistingScopeObserver.java +++ b/sentry/src/main/java/io/sentry/cache/PersistingScopeObserver.java @@ -3,6 +3,7 @@ import static io.sentry.SentryLevel.ERROR; import io.sentry.Breadcrumb; +import io.sentry.IScope; import io.sentry.JsonDeserializer; import io.sentry.ScopeObserverAdapter; import io.sentry.SentryLevel; @@ -10,6 +11,7 @@ import io.sentry.SpanContext; import io.sentry.protocol.Contexts; import io.sentry.protocol.Request; +import io.sentry.protocol.SentryId; import io.sentry.protocol.User; import java.util.Collection; import java.util.Map; @@ -29,6 +31,7 @@ public final class PersistingScopeObserver extends ScopeObserverAdapter { public static final String FINGERPRINT_FILENAME = "fingerprint.json"; public static final String TRANSACTION_FILENAME = "transaction.json"; public static final String TRACE_FILENAME = "trace.json"; + public static final String REPLAY_FILENAME = "replay.json"; private final @NotNull SentryOptions options; @@ -105,11 +108,13 @@ public void setTransaction(@Nullable String transaction) { } @Override - public void setTrace(@Nullable SpanContext spanContext) { + public void setTrace(@Nullable SpanContext spanContext, @NotNull IScope scope) { serializeToDisk( () -> { if (spanContext == null) { - delete(TRACE_FILENAME); + // we always need a trace_id to properly link with traces/replays, so we fallback to + // propagation context values and create a fake SpanContext + store(scope.getPropagationContext().toSpanContext(), TRACE_FILENAME); } else { store(spanContext, TRACE_FILENAME); } @@ -121,6 +126,11 @@ public void setContexts(@NotNull Contexts contexts) { serializeToDisk(() -> store(contexts, CONTEXTS_FILENAME)); } + @Override + public void setReplayId(@NotNull SentryId replayId) { + serializeToDisk(() -> store(replayId, REPLAY_FILENAME)); + } + @SuppressWarnings("FutureReturnValueIgnored") private void serializeToDisk(final @NotNull Runnable task) { try { @@ -140,13 +150,20 @@ private void serializeToDisk(final @NotNull Runnable task) { } private void store(final @NotNull T entity, final @NotNull String fileName) { - CacheUtils.store(options, entity, SCOPE_CACHE, fileName); + store(options, entity, fileName); } private void delete(final @NotNull String fileName) { CacheUtils.delete(options, SCOPE_CACHE, fileName); } + public static void store( + final @NotNull SentryOptions options, + final @NotNull T entity, + final @NotNull String fileName) { + CacheUtils.store(options, entity, SCOPE_CACHE, fileName); + } + public static @Nullable T read( final @NotNull SentryOptions options, final @NotNull String fileName, diff --git a/sentry/src/main/java/io/sentry/protocol/Contexts.java b/sentry/src/main/java/io/sentry/protocol/Contexts.java index 28d2e8d2a4..ba4cbe51cb 100644 --- a/sentry/src/main/java/io/sentry/protocol/Contexts.java +++ b/sentry/src/main/java/io/sentry/protocol/Contexts.java @@ -19,6 +19,7 @@ public final class Contexts extends ConcurrentHashMap implements JsonSerializable { private static final long serialVersionUID = 252445813254943011L; + public static final String REPLAY_ID = "replay_id"; /** Response lock, Ops should be atomic */ private final @NotNull Object responseLock = new Object(); diff --git a/sentry/src/test/java/io/sentry/ScopeTest.kt b/sentry/src/test/java/io/sentry/ScopeTest.kt index 906c897c62..07b9176de7 100644 --- a/sentry/src/test/java/io/sentry/ScopeTest.kt +++ b/sentry/src/test/java/io/sentry/ScopeTest.kt @@ -2,6 +2,7 @@ package io.sentry import io.sentry.SentryLevel.WARNING import io.sentry.protocol.Request +import io.sentry.protocol.SentryId import io.sentry.protocol.User import io.sentry.test.callMethod import org.junit.Assert.assertArrayEquals @@ -738,7 +739,7 @@ class ScopeTest { whenever(mock.spanContext).thenReturn(SpanContext("ui.load")) } verify(observer).setTransaction(eq("main")) - verify(observer).setTrace(argThat { operation == "ui.load" }) + verify(observer).setTrace(argThat { operation == "ui.load" }, eq(scope)) } @Test @@ -751,7 +752,7 @@ class ScopeTest { scope.transaction = null verify(observer).setTransaction(null) - verify(observer).setTrace(null) + verify(observer).setTrace(null, scope) } @Test @@ -767,11 +768,11 @@ class ScopeTest { whenever(mock.spanContext).thenReturn(SpanContext("ui.load")) } verify(observer).setTransaction(eq("main")) - verify(observer).setTrace(argThat { operation == "ui.load" }) + verify(observer).setTrace(argThat { operation == "ui.load" }, eq(scope)) scope.clearTransaction() verify(observer).setTransaction(null) - verify(observer).setTrace(null) + verify(observer).setTrace(null, scope) } @Test @@ -819,6 +820,21 @@ class ScopeTest { ) } + @Test + fun `Scope set propagation context sync scopes`() { + val observer = mock() + val options = SentryOptions().apply { + addScopeObserver(observer) + } + val scope = Scope(options) + + scope.propagationContext = PropagationContext(SentryId("64cf554cc8d74c6eafa3e08b7c984f6d"), SpanId(), null, null, null) + verify(observer).setTrace( + argThat { traceId.toString() == "64cf554cc8d74c6eafa3e08b7c984f6d" }, + eq(scope) + ) + } + @Test fun `Scope getTransaction returns the transaction if there is no active span`() { val scope = Scope(SentryOptions()) diff --git a/sentry/src/test/java/io/sentry/SentryClientTest.kt b/sentry/src/test/java/io/sentry/SentryClientTest.kt index 9b883d5ef2..f87a148bf1 100644 --- a/sentry/src/test/java/io/sentry/SentryClientTest.kt +++ b/sentry/src/test/java/io/sentry/SentryClientTest.kt @@ -28,6 +28,8 @@ import io.sentry.transport.ITransport import io.sentry.transport.ITransportGate import io.sentry.util.HintUtils import org.junit.Assert.assertArrayEquals +import org.junit.Rule +import org.junit.rules.TemporaryFolder import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.argumentCaptor @@ -67,6 +69,9 @@ import kotlin.test.assertTrue class SentryClientTest { + @get:Rule + val tmpDir = TemporaryFolder() + class Fixture { var transport = mock() var factory = mock() @@ -852,6 +857,7 @@ class SentryClientTest { val event = SentryEvent().apply { environment = "release" release = "io.sentry.samples@22.1.1" + contexts[Contexts.REPLAY_ID] = "64cf554cc8d74c6eafa3e08b7c984f6d" contexts.trace = SpanContext(traceId, SpanId(), "ui.load", null, null) transaction = "MainActivity" } @@ -866,6 +872,7 @@ class SentryClientTest { assertEquals("io.sentry.samples@22.1.1", it.header.traceContext!!.release) assertEquals(traceId, it.header.traceContext!!.traceId) assertEquals("MainActivity", it.header.traceContext!!.transaction) + assertEquals(SentryId("64cf554cc8d74c6eafa3e08b7c984f6d"), it.header.traceContext!!.replayId) }, anyOrNull() ) @@ -2360,41 +2367,6 @@ class SentryClientTest { @Test fun `when event has DiskFlushNotification, TransactionEnds set transaction id as flushable`() { val sut = fixture.getSut() - val replayId = SentryId() - val scope = mock { - whenever(it.replayId).thenReturn(replayId) - whenever(it.breadcrumbs).thenReturn(LinkedList()) - whenever(it.extras).thenReturn(emptyMap()) - whenever(it.contexts).thenReturn(Contexts()) - } - val scopePropagationContext = PropagationContext() - whenever(scope.propagationContext).thenReturn(scopePropagationContext) - doAnswer { (it.arguments[0] as IWithPropagationContext).accept(scopePropagationContext); scopePropagationContext }.whenever(scope).withPropagationContext(any()) - - var capturedEventId: SentryId? = null - val transactionEnd = object : TransactionEnd, DiskFlushNotification { - override fun markFlushed() {} - override fun isFlushable(eventId: SentryId?): Boolean = true - override fun setFlushable(eventId: SentryId) { - capturedEventId = eventId - } - } - val transactionEndHint = HintUtils.createWithTypeCheckHint(transactionEnd) - - sut.captureEvent(SentryEvent(), scope, transactionEndHint) - - assertEquals(replayId, capturedEventId) - verify(fixture.transport).send( - check { - assertEquals(1, it.items.count()) - }, - any() - ) - } - - @Test - fun `when event has DiskFlushNotification, TransactionEnds set replay id as flushable`() { - val sut = fixture.getSut() // build up a running transaction val spanContext = SpanContext("op.load") @@ -2750,20 +2722,84 @@ class SentryClientTest { } @Test - fun `calls sendReplayForEvent on replay controller for error events`() { + fun `calls captureReplay on replay controller for error events`() { var called = false fixture.sentryOptions.setReplayController(object : ReplayController by NoOpReplayController.getInstance() { - override fun sendReplayForEvent(event: SentryEvent, hint: Hint) { - assertEquals("Test", event.message?.formatted) + override fun captureReplay(isTerminating: Boolean?) { called = true } }) val sut = fixture.getSut() - sut.captureMessage("Test", WARNING) + sut.captureEvent(SentryEvent().apply { exceptions = listOf(SentryException()) }) assertTrue(called) } + @Test + fun `calls captureReplay on replay controller for crash events and sets isTerminating`() { + var terminated: Boolean? = false + fixture.sentryOptions.setReplayController(object : ReplayController by NoOpReplayController.getInstance() { + override fun captureReplay(isTerminating: Boolean?) { + terminated = isTerminating + } + }) + val sut = fixture.getSut() + + sut.captureEvent( + SentryEvent().apply { + exceptions = listOf( + SentryException().apply { + mechanism = Mechanism().apply { isHandled = false } + } + ) + } + ) + assertTrue(terminated == true) + } + + @Test + fun `cleans up replay folder for Backfillable replay events`() { + val dir = File(tmpDir.newFolder().absolutePath) + val sut = fixture.getSut() + val replayEvent = createReplayEvent().apply { + videoFile = File(dir, "hello.txt").apply { writeText("hello") } + } + + sut.captureReplayEvent(replayEvent, createScope(), HintUtils.createWithTypeCheckHint(BackfillableHint())) + + verify(fixture.transport).send( + check { actual -> + val item = actual.items.first() + item.data + assertFalse(dir.exists()) + }, + any() + ) + } + + @Test + fun `does not captureReplay for backfillable events`() { + var called = false + fixture.sentryOptions.setReplayController(object : ReplayController by NoOpReplayController.getInstance() { + override fun captureReplay(isTerminating: Boolean?) { + called = true + } + }) + val sut = fixture.getSut() + + sut.captureEvent( + SentryEvent().apply { + exceptions = listOf( + SentryException().apply { + mechanism = Mechanism().apply { isHandled = false } + } + ) + }, + HintUtils.createWithTypeCheckHint(BackfillableHint()) + ) + assertFalse(called) + } + private fun givenScopeWithStartedSession(errored: Boolean = false, crashed: Boolean = false): IScope { val scope = createScope(fixture.sentryOptions) scope.startSession() diff --git a/sentry/src/test/java/io/sentry/SentryEnvelopeItemTest.kt b/sentry/src/test/java/io/sentry/SentryEnvelopeItemTest.kt index efc5e5cadf..760d1270e5 100644 --- a/sentry/src/test/java/io/sentry/SentryEnvelopeItemTest.kt +++ b/sentry/src/test/java/io/sentry/SentryEnvelopeItemTest.kt @@ -8,6 +8,8 @@ import io.sentry.protocol.ViewHierarchy import io.sentry.test.injectForField import io.sentry.vendor.Base64 import org.junit.Assert.assertArrayEquals +import org.junit.Rule +import org.junit.rules.TemporaryFolder import org.mockito.kotlin.any import org.mockito.kotlin.mock import org.mockito.kotlin.verify @@ -31,6 +33,9 @@ import kotlin.test.assertNull class SentryEnvelopeItemTest { + @get:Rule + val tmpDir = TemporaryFolder() + private class Fixture { val options = SentryOptions() val serializer = JsonSerializer(options) @@ -469,7 +474,7 @@ class SentryEnvelopeItemTest { } val replayRecording = ReplayRecordingSerializationTest.Fixture().getSut() val replayItem = SentryEnvelopeItem - .fromReplay(fixture.serializer, fixture.options.logger, replayEvent, replayRecording) + .fromReplay(fixture.serializer, fixture.options.logger, replayEvent, replayRecording, false) assertEquals(SentryItemType.ReplayVideo, replayItem.header.type) @@ -486,7 +491,7 @@ class SentryEnvelopeItemTest { } val replayItem = SentryEnvelopeItem - .fromReplay(fixture.serializer, fixture.options.logger, replayEvent, null) + .fromReplay(fixture.serializer, fixture.options.logger, replayEvent, null, false) replayItem.data assertPayload(replayItem, replayEvent, null, ByteArray(0)) { mapSize -> assertEquals(1, mapSize) @@ -503,10 +508,28 @@ class SentryEnvelopeItemTest { file.writeBytes(fixture.bytes) assert(file.exists()) val replayItem = SentryEnvelopeItem - .fromReplay(fixture.serializer, fixture.options.logger, replayEvent, null) + .fromReplay(fixture.serializer, fixture.options.logger, replayEvent, null, false) + assert(file.exists()) + replayItem.data + assertFalse(file.exists()) + } + + @Test + fun `fromReplay cleans up video folder if cleanupReplayFolder is set`() { + val dir = File(tmpDir.newFolder().absolutePath) + val file = File(dir, fixture.pathname) + val replayEvent = SentryReplayEventSerializationTest.Fixture().getSut().apply { + videoFile = file + } + + file.writeBytes(fixture.bytes) + assert(file.exists()) + val replayItem = SentryEnvelopeItem + .fromReplay(fixture.serializer, fixture.options.logger, replayEvent, null, true) assert(file.exists()) replayItem.data assertFalse(file.exists()) + assertFalse(dir.exists()) } private fun createSession(): Session { diff --git a/sentry/src/test/java/io/sentry/SentryTest.kt b/sentry/src/test/java/io/sentry/SentryTest.kt index 0f4966b44a..5ef764bc5d 100644 --- a/sentry/src/test/java/io/sentry/SentryTest.kt +++ b/sentry/src/test/java/io/sentry/SentryTest.kt @@ -737,6 +737,7 @@ class SentryTest { it.sdkVersion = SdkVersion("sentry.java.android", "6.13.0") it.environment = "debug" it.setTag("one", "two") + it.experimental.sessionReplay.errorSampleRate = 0.5 } assertEquals("io.sentry.sample@1.1.0+220", optionsObserver.release) @@ -745,6 +746,7 @@ class SentryTest { assertEquals("uuid", optionsObserver.proguardUuid) assertEquals(mapOf("one" to "two"), optionsObserver.tags) assertEquals(SdkVersion("sentry.java.android", "6.13.0"), optionsObserver.sdkVersion) + assertEquals(0.5, optionsObserver.replayErrorSampleRate) } @Test @@ -1164,6 +1166,8 @@ class SentryTest { private set var tags: Map = mapOf() private set + var replayErrorSampleRate: Double? = null + private set override fun setRelease(release: String?) { this.release = release @@ -1188,6 +1192,10 @@ class SentryTest { override fun setTags(tags: MutableMap) { this.tags = tags } + + override fun setReplayErrorSampleRate(replayErrorSampleRate: Double?) { + this.replayErrorSampleRate = replayErrorSampleRate + } } private class CustomMainThreadChecker : IMainThreadChecker { diff --git a/sentry/src/test/java/io/sentry/cache/PersistingOptionsObserverTest.kt b/sentry/src/test/java/io/sentry/cache/PersistingOptionsObserverTest.kt index ded3908f14..3c325bd640 100644 --- a/sentry/src/test/java/io/sentry/cache/PersistingOptionsObserverTest.kt +++ b/sentry/src/test/java/io/sentry/cache/PersistingOptionsObserverTest.kt @@ -5,6 +5,7 @@ import io.sentry.cache.PersistingOptionsObserver.DIST_FILENAME import io.sentry.cache.PersistingOptionsObserver.ENVIRONMENT_FILENAME import io.sentry.cache.PersistingOptionsObserver.PROGUARD_UUID_FILENAME import io.sentry.cache.PersistingOptionsObserver.RELEASE_FILENAME +import io.sentry.cache.PersistingOptionsObserver.REPLAY_ERROR_SAMPLE_RATE_FILENAME import io.sentry.cache.PersistingOptionsObserver.SDK_VERSION_FILENAME import io.sentry.cache.PersistingOptionsObserver.TAGS_FILENAME import io.sentry.protocol.SdkVersion @@ -28,13 +29,18 @@ class DeleteOptionsValue(private val delete: PersistingOptionsObserver.() -> Uni } } +class ReadOptionsValue(private val read: (options: SentryOptions) -> T) { + operator fun invoke(options: SentryOptions) = read(options) +} + @RunWith(Parameterized::class) class PersistingOptionsObserverTest( private val entity: T, private val store: StoreOptionsValue, private val filename: String, private val delete: DeleteOptionsValue, - private val deletedEntity: T? + private val deletedEntity: T?, + private val read: ReadOptionsValue? ) { @get:Rule @@ -60,7 +66,7 @@ class PersistingOptionsObserverTest( val sut = fixture.getSut(tmpDir) store(entity, sut) - val persisted = read() + val persisted = read?.invoke(fixture.options) ?: read() assertEquals(entity, persisted) delete(sut) @@ -81,6 +87,7 @@ class PersistingOptionsObserverTest( StoreOptionsValue { setRelease(it) }, RELEASE_FILENAME, DeleteOptionsValue { setRelease(null) }, + null, null ) @@ -89,6 +96,7 @@ class PersistingOptionsObserverTest( StoreOptionsValue { setProguardUuid(it) }, PROGUARD_UUID_FILENAME, DeleteOptionsValue { setProguardUuid(null) }, + null, null ) @@ -97,6 +105,7 @@ class PersistingOptionsObserverTest( StoreOptionsValue { setSdkVersion(it) }, SDK_VERSION_FILENAME, DeleteOptionsValue { setSdkVersion(null) }, + null, null ) @@ -105,6 +114,7 @@ class PersistingOptionsObserverTest( StoreOptionsValue { setDist(it) }, DIST_FILENAME, DeleteOptionsValue { setDist(null) }, + null, null ) @@ -113,6 +123,7 @@ class PersistingOptionsObserverTest( StoreOptionsValue { setEnvironment(it) }, ENVIRONMENT_FILENAME, DeleteOptionsValue { setEnvironment(null) }, + null, null ) @@ -124,7 +135,23 @@ class PersistingOptionsObserverTest( StoreOptionsValue> { setTags(it) }, TAGS_FILENAME, DeleteOptionsValue { setTags(emptyMap()) }, - emptyMap() + emptyMap(), + null + ) + + private fun replaysErrorSampleRate(): Array = arrayOf( + 0.5, + StoreOptionsValue { setReplayErrorSampleRate(it) }, + REPLAY_ERROR_SAMPLE_RATE_FILENAME, + DeleteOptionsValue { setReplayErrorSampleRate(null) }, + null, + ReadOptionsValue { + PersistingOptionsObserver.read( + it, + REPLAY_ERROR_SAMPLE_RATE_FILENAME, + String::class.java + )!!.toDouble() + } ) @JvmStatic @@ -136,7 +163,8 @@ class PersistingOptionsObserverTest( dist(), environment(), sdkVersion(), - tags() + tags(), + replaysErrorSampleRate() ) } } diff --git a/sentry/src/test/java/io/sentry/cache/PersistingScopeObserverTest.kt b/sentry/src/test/java/io/sentry/cache/PersistingScopeObserverTest.kt index d31b7088cf..e1927438e5 100644 --- a/sentry/src/test/java/io/sentry/cache/PersistingScopeObserverTest.kt +++ b/sentry/src/test/java/io/sentry/cache/PersistingScopeObserverTest.kt @@ -3,6 +3,7 @@ package io.sentry.cache import io.sentry.Breadcrumb import io.sentry.DateUtils import io.sentry.JsonDeserializer +import io.sentry.Scope import io.sentry.SentryLevel import io.sentry.SentryOptions import io.sentry.SpanContext @@ -12,6 +13,7 @@ import io.sentry.cache.PersistingScopeObserver.CONTEXTS_FILENAME import io.sentry.cache.PersistingScopeObserver.EXTRAS_FILENAME import io.sentry.cache.PersistingScopeObserver.FINGERPRINT_FILENAME import io.sentry.cache.PersistingScopeObserver.LEVEL_FILENAME +import io.sentry.cache.PersistingScopeObserver.REPLAY_FILENAME import io.sentry.cache.PersistingScopeObserver.REQUEST_FILENAME import io.sentry.cache.PersistingScopeObserver.TAGS_FILENAME import io.sentry.cache.PersistingScopeObserver.TRACE_FILENAME @@ -35,15 +37,21 @@ import org.junit.runners.Parameterized import kotlin.test.Test import kotlin.test.assertEquals -class StoreScopeValue(private val store: PersistingScopeObserver.(T) -> Unit) { - operator fun invoke(value: T, observer: PersistingScopeObserver) { - observer.store(value) +class StoreScopeValue(private val store: PersistingScopeObserver.(T, Scope) -> Unit) { + operator fun invoke(value: T, observer: PersistingScopeObserver, scope: Scope) { + observer.store(value, scope) } } -class DeleteScopeValue(private val delete: PersistingScopeObserver.() -> Unit) { - operator fun invoke(observer: PersistingScopeObserver) { - observer.delete() +class DeleteScopeValue(private val delete: PersistingScopeObserver.(Scope) -> Unit) { + operator fun invoke(observer: PersistingScopeObserver, scope: Scope) { + observer.delete(scope) + } +} + +class DeletedEntityProvider(private val provider: (Scope) -> T?) { + operator fun invoke(scope: Scope): T? { + return provider(scope) } } @@ -53,7 +61,7 @@ class PersistingScopeObserverTest( private val store: StoreScopeValue, private val filename: String, private val delete: DeleteScopeValue, - private val deletedEntity: T?, + private val deletedEntity: DeletedEntityProvider, private val elementDeserializer: JsonDeserializer? ) { @@ -63,6 +71,7 @@ class PersistingScopeObserverTest( class Fixture { val options = SentryOptions() + val scope = Scope(options) fun getSut(cacheDir: TemporaryFolder): PersistingScopeObserver { options.run { @@ -78,14 +87,14 @@ class PersistingScopeObserverTest( @Test fun `store and delete scope value`() { val sut = fixture.getSut(tmpDir) - store(entity, sut) + store(entity, sut, fixture.scope) val persisted = read() assertEquals(entity, persisted) - delete(sut) + delete(sut, fixture.scope) val persistedAfterDeletion = read() - assertEquals(deletedEntity, persistedAfterDeletion) + assertEquals(deletedEntity(fixture.scope), persistedAfterDeletion) } private fun read(): T? = PersistingScopeObserver.read( @@ -103,10 +112,10 @@ class PersistingScopeObserverTest( id = "c4d61c1b-c144-431e-868f-37a46be5e5f2" ipAddress = "192.168.0.1" }, - StoreScopeValue { setUser(it) }, + StoreScopeValue { user, _ -> setUser(user) }, USER_FILENAME, DeleteScopeValue { setUser(null) }, - null, + DeletedEntityProvider { null }, null ) @@ -115,10 +124,10 @@ class PersistingScopeObserverTest( Breadcrumb.navigation("one", "two"), Breadcrumb.userInteraction("click", "viewId", "viewClass") ), - StoreScopeValue> { setBreadcrumbs(it) }, + StoreScopeValue> { breadcrumbs, _ -> setBreadcrumbs(breadcrumbs) }, BREADCRUMBS_FILENAME, DeleteScopeValue { setBreadcrumbs(emptyList()) }, - emptyList(), + DeletedEntityProvider { emptyList() }, Breadcrumb.Deserializer() ) @@ -127,10 +136,10 @@ class PersistingScopeObserverTest( "one" to "two", "tag" to "none" ), - StoreScopeValue> { setTags(it) }, + StoreScopeValue> { tags, _ -> setTags(tags) }, TAGS_FILENAME, DeleteScopeValue { setTags(emptyMap()) }, - emptyMap(), + DeletedEntityProvider { emptyMap() }, null ) @@ -140,10 +149,10 @@ class PersistingScopeObserverTest( "two" to 2, "three" to 3.2 ), - StoreScopeValue> { setExtras(it) }, + StoreScopeValue> { extras, _ -> setExtras(extras) }, EXTRAS_FILENAME, DeleteScopeValue { setExtras(emptyMap()) }, - emptyMap(), + DeletedEntityProvider { emptyMap() }, null ) @@ -156,46 +165,46 @@ class PersistingScopeObserverTest( fragment = "fragment" bodySize = 1000 }, - StoreScopeValue { setRequest(it) }, + StoreScopeValue { request, _ -> setRequest(request) }, REQUEST_FILENAME, DeleteScopeValue { setRequest(null) }, - null, + DeletedEntityProvider { null }, null ) private fun fingerprint(): Array = arrayOf( listOf("finger", "print"), - StoreScopeValue> { setFingerprint(it) }, + StoreScopeValue> { fingerprint, _ -> setFingerprint(fingerprint) }, FINGERPRINT_FILENAME, DeleteScopeValue { setFingerprint(emptyList()) }, - emptyList(), + DeletedEntityProvider { emptyList() }, null ) private fun level(): Array = arrayOf( SentryLevel.WARNING, - StoreScopeValue { setLevel(it) }, + StoreScopeValue { level, _ -> setLevel(level) }, LEVEL_FILENAME, DeleteScopeValue { setLevel(null) }, - null, + DeletedEntityProvider { null }, null ) private fun transaction(): Array = arrayOf( "MainActivity", - StoreScopeValue { setTransaction(it) }, + StoreScopeValue { transaction, _ -> setTransaction(transaction) }, TRANSACTION_FILENAME, DeleteScopeValue { setTransaction(null) }, - null, + DeletedEntityProvider { null }, null ) private fun trace(): Array = arrayOf( SpanContext(SentryId(), SpanId(), "ui.load", null, null), - StoreScopeValue { setTrace(it) }, + StoreScopeValue { trace, scope -> setTrace(trace, scope) }, TRACE_FILENAME, - DeleteScopeValue { setTrace(null) }, - null, + DeleteScopeValue { scope -> setTrace(null, scope) }, + DeletedEntityProvider { scope -> scope.propagationContext.toSpanContext() }, null ) @@ -257,10 +266,19 @@ class PersistingScopeObserverTest( } ) }, - StoreScopeValue { setContexts(it) }, + StoreScopeValue { contexts, _ -> setContexts(contexts) }, CONTEXTS_FILENAME, DeleteScopeValue { setContexts(Contexts()) }, - Contexts(), + DeletedEntityProvider { Contexts() }, + null + ) + + private fun replayId(): Array = arrayOf( + "64cf554cc8d74c6eafa3e08b7c984f6d", + StoreScopeValue { replayId, _ -> setReplayId(SentryId(replayId)) }, + REPLAY_FILENAME, + DeleteScopeValue { setReplayId(SentryId.EMPTY_ID) }, + DeletedEntityProvider { SentryId.EMPTY_ID.toString() }, null ) @@ -277,7 +295,8 @@ class PersistingScopeObserverTest( level(), transaction(), trace(), - contexts() + contexts(), + replayId() ) } } From b64477e7cba3d064d48d386f1daf374747358ec8 Mon Sep 17 00:00:00 2001 From: getsentry-bot Date: Tue, 30 Jul 2024 21:08:17 +0000 Subject: [PATCH 18/92] release: 7.13.0 --- CHANGELOG.md | 2 +- gradle.properties | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7037e72370..499769447e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## Unreleased +## 7.13.0 ### Features diff --git a/gradle.properties b/gradle.properties index 16dd5a0948..1077713f7c 100644 --- a/gradle.properties +++ b/gradle.properties @@ -10,7 +10,7 @@ android.useAndroidX=true android.defaults.buildfeatures.buildconfig=true # Release information -versionName=7.12.1 +versionName=7.13.0 # Override the SDK name on native crashes on Android sentryAndroidSdkName=sentry.native.android From 7c34b375943d75191d491bd09c70c4ced0631bcf Mon Sep 17 00:00:00 2001 From: Stefano Date: Thu, 1 Aug 2024 16:30:00 +0200 Subject: [PATCH 19/92] Fix lazy select queries instrumentation (#3604) * added SentryCrossProcessCursor wrapper * SQLiteSpanManager now wraps CrossProcessCursors to start a span only when the cursor is filled with data --- CHANGELOG.md | 6 + .../android/sqlite/SQLiteSpanManager.kt | 21 ++- .../sqlite/SentryCrossProcessCursor.kt | 51 +++++++ .../android/sqlite/SQLiteSpanManagerTest.kt | 15 +++ .../sqlite/SentryCrossProcessCursorTest.kt | 124 ++++++++++++++++++ 5 files changed, 214 insertions(+), 3 deletions(-) create mode 100644 sentry-android-sqlite/src/main/java/io/sentry/android/sqlite/SentryCrossProcessCursor.kt create mode 100644 sentry-android-sqlite/src/test/java/io/sentry/android/sqlite/SentryCrossProcessCursorTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 499769447e..fd8abb9a3d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +### Fixes + +- Fix lazy select queries instrumentation ([#3604](https://github.com/getsentry/sentry-java/pull/3604)) + ## 7.13.0 ### Features diff --git a/sentry-android-sqlite/src/main/java/io/sentry/android/sqlite/SQLiteSpanManager.kt b/sentry-android-sqlite/src/main/java/io/sentry/android/sqlite/SQLiteSpanManager.kt index 3bfa855d53..c45d31aa17 100644 --- a/sentry-android-sqlite/src/main/java/io/sentry/android/sqlite/SQLiteSpanManager.kt +++ b/sentry-android-sqlite/src/main/java/io/sentry/android/sqlite/SQLiteSpanManager.kt @@ -1,8 +1,11 @@ package io.sentry.android.sqlite +import android.database.CrossProcessCursor import android.database.SQLException import io.sentry.HubAdapter import io.sentry.IHub +import io.sentry.ISpan +import io.sentry.Instrumenter import io.sentry.SentryIntegrationPackageStorage import io.sentry.SentryStackTraceFactory import io.sentry.SpanDataConvention @@ -27,16 +30,28 @@ internal class SQLiteSpanManager( * @param operation The sql operation to execute. * In case of an error the surrounding span will have its status set to INTERNAL_ERROR */ - @Suppress("TooGenericExceptionCaught") + @Suppress("TooGenericExceptionCaught", "UNCHECKED_CAST") @Throws(SQLException::class) fun performSql(sql: String, operation: () -> T): T { - val span = hub.span?.startChild("db.sql.query", sql) - span?.spanContext?.origin = TRACE_ORIGIN + val startTimestamp = hub.getOptions().dateProvider.now() + var span: ISpan? = null return try { val result = operation() + /* + * SQLiteCursor - that extends CrossProcessCursor - executes the query lazily, when one of + * getCount() or onMove() is called. In this case we don't have to start the span here. + * Otherwise we start the span with the timestamp taken before the operation started. + */ + if (result is CrossProcessCursor) { + return SentryCrossProcessCursor(result, this, sql) as T + } + span = hub.span?.startChild("db.sql.query", sql, startTimestamp, Instrumenter.SENTRY) + span?.spanContext?.origin = TRACE_ORIGIN span?.status = SpanStatus.OK result } catch (e: Throwable) { + span = hub.span?.startChild("db.sql.query", sql, startTimestamp, Instrumenter.SENTRY) + span?.spanContext?.origin = TRACE_ORIGIN span?.status = SpanStatus.INTERNAL_ERROR span?.throwable = e throw e diff --git a/sentry-android-sqlite/src/main/java/io/sentry/android/sqlite/SentryCrossProcessCursor.kt b/sentry-android-sqlite/src/main/java/io/sentry/android/sqlite/SentryCrossProcessCursor.kt new file mode 100644 index 0000000000..962e8bbb71 --- /dev/null +++ b/sentry-android-sqlite/src/main/java/io/sentry/android/sqlite/SentryCrossProcessCursor.kt @@ -0,0 +1,51 @@ +package io.sentry.android.sqlite + +import android.database.CrossProcessCursor +import android.database.CursorWindow + +/* + * SQLiteCursor executes the query lazily, when one of getCount() and onMove() is called. + * Also, by docs, fillWindow() can be used to fill the cursor with data. + * So we wrap these methods to create a span. + * SQLiteCursor is never used directly in the code, but only the Cursor interface. + * This means we can use CrossProcessCursor - that extends Cursor - as wrapper, since + * CrossProcessCursor is an interface and we can use Kotlin delegation. + */ +internal class SentryCrossProcessCursor( + private val delegate: CrossProcessCursor, + private val spanManager: SQLiteSpanManager, + private val sql: String +) : CrossProcessCursor by delegate { + // We have to start the span only the first time, regardless of how many times its methods get called. + private var isSpanStarted = false + + override fun getCount(): Int { + if (isSpanStarted) { + return delegate.count + } + isSpanStarted = true + return spanManager.performSql(sql) { + delegate.count + } + } + + override fun onMove(oldPosition: Int, newPosition: Int): Boolean { + if (isSpanStarted) { + return delegate.onMove(oldPosition, newPosition) + } + isSpanStarted = true + return spanManager.performSql(sql) { + delegate.onMove(oldPosition, newPosition) + } + } + + override fun fillWindow(position: Int, window: CursorWindow?) { + if (isSpanStarted) { + return delegate.fillWindow(position, window) + } + isSpanStarted = true + return spanManager.performSql(sql) { + delegate.fillWindow(position, window) + } + } +} diff --git a/sentry-android-sqlite/src/test/java/io/sentry/android/sqlite/SQLiteSpanManagerTest.kt b/sentry-android-sqlite/src/test/java/io/sentry/android/sqlite/SQLiteSpanManagerTest.kt index e2fa0c2e4d..425932113c 100644 --- a/sentry-android-sqlite/src/test/java/io/sentry/android/sqlite/SQLiteSpanManagerTest.kt +++ b/sentry-android-sqlite/src/test/java/io/sentry/android/sqlite/SQLiteSpanManagerTest.kt @@ -1,5 +1,6 @@ package io.sentry.android.sqlite +import android.database.CrossProcessCursor import android.database.SQLException import io.sentry.IHub import io.sentry.SentryIntegrationPackageStorage @@ -15,6 +16,7 @@ import org.mockito.kotlin.whenever import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse +import kotlin.test.assertIs import kotlin.test.assertNotNull import kotlin.test.assertNull import kotlin.test.assertTrue @@ -140,4 +142,17 @@ class SQLiteSpanManagerTest { assertEquals(span.data[SpanDataConvention.DB_SYSTEM_KEY], "in-memory") } + + @Test + fun `when performSql returns a CrossProcessCursor, does not start a span and returns a SentryCrossProcessCursor`() { + val sut = fixture.getSut() + + // When performSql returns a CrossProcessCursor + val result = sut.performSql("sql") { mock() } + + // Returns a SentryCrossProcessCursor + assertIs(result) + // And no span is started + assertNull(fixture.sentryTracer.children.firstOrNull()) + } } diff --git a/sentry-android-sqlite/src/test/java/io/sentry/android/sqlite/SentryCrossProcessCursorTest.kt b/sentry-android-sqlite/src/test/java/io/sentry/android/sqlite/SentryCrossProcessCursorTest.kt new file mode 100644 index 0000000000..13ddf4500c --- /dev/null +++ b/sentry-android-sqlite/src/test/java/io/sentry/android/sqlite/SentryCrossProcessCursorTest.kt @@ -0,0 +1,124 @@ +package io.sentry.android.sqlite + +import android.database.CrossProcessCursor +import io.sentry.IHub +import io.sentry.ISpan +import io.sentry.SentryOptions +import io.sentry.SentryTracer +import io.sentry.SpanStatus +import io.sentry.TransactionContext +import org.mockito.kotlin.any +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class SentryCrossProcessCursorTest { + private class Fixture { + private val hub = mock() + private val spanManager = SQLiteSpanManager(hub) + val mockCursor = mock() + lateinit var options: SentryOptions + lateinit var sentryTracer: SentryTracer + + fun getSut(sql: String, isSpanActive: Boolean = true): SentryCrossProcessCursor { + options = SentryOptions().apply { + dsn = "https://key@sentry.io/proj" + } + whenever(hub.options).thenReturn(options) + sentryTracer = SentryTracer(TransactionContext("name", "op"), hub) + + if (isSpanActive) { + whenever(hub.span).thenReturn(sentryTracer) + } + return SentryCrossProcessCursor(mockCursor, spanManager, sql) + } + } + + private val fixture = Fixture() + + @Test + fun `all calls are propagated to the delegate`() { + val sql = "sql" + val cursor = fixture.getSut(sql) + + cursor.onMove(0, 1) + verify(fixture.mockCursor).onMove(eq(0), eq(1)) + + cursor.count + verify(fixture.mockCursor).count + + cursor.fillWindow(0, mock()) + verify(fixture.mockCursor).fillWindow(eq(0), any()) + + // Let's verify other methods are delegated, even if not explicitly + cursor.close() + verify(fixture.mockCursor).close() + + cursor.getString(1) + verify(fixture.mockCursor).getString(eq(1)) + } + + @Test + fun `getCount creates a span if a span is running`() { + val sql = "execute" + val sut = fixture.getSut(sql) + assertEquals(0, fixture.sentryTracer.children.size) + sut.count + val span = fixture.sentryTracer.children.firstOrNull() + assertSqlSpanCreated(sql, span) + } + + @Test + fun `getCount does not create a span if no span is running`() { + val sut = fixture.getSut("execute", isSpanActive = false) + sut.count + assertEquals(0, fixture.sentryTracer.children.size) + } + + @Test + fun `onMove creates a span if a span is running`() { + val sql = "execute" + val sut = fixture.getSut(sql) + assertEquals(0, fixture.sentryTracer.children.size) + sut.onMove(0, 5) + val span = fixture.sentryTracer.children.firstOrNull() + assertSqlSpanCreated(sql, span) + } + + @Test + fun `onMove does not create a span if no span is running`() { + val sut = fixture.getSut("execute", isSpanActive = false) + sut.onMove(0, 5) + assertEquals(0, fixture.sentryTracer.children.size) + } + + @Test + fun `fillWindow creates a span if a span is running`() { + val sql = "execute" + val sut = fixture.getSut(sql) + assertEquals(0, fixture.sentryTracer.children.size) + sut.fillWindow(0, mock()) + val span = fixture.sentryTracer.children.firstOrNull() + assertSqlSpanCreated(sql, span) + } + + @Test + fun `fillWindow does not create a span if no span is running`() { + val sut = fixture.getSut("execute", isSpanActive = false) + sut.fillWindow(0, mock()) + assertEquals(0, fixture.sentryTracer.children.size) + } + + private fun assertSqlSpanCreated(sql: String, span: ISpan?) { + assertNotNull(span) + assertEquals("db.sql.query", span.operation) + assertEquals(sql, span.description) + assertEquals(SpanStatus.OK, span.status) + assertTrue(span.isFinished) + } +} From 09dab51e4e0dd95774991beae59ff7cb3b93cb23 Mon Sep 17 00:00:00 2001 From: Stefano Date: Thu, 1 Aug 2024 16:38:45 +0200 Subject: [PATCH 20/92] Avoid ArrayIndexOutOfBoundsException on Android cpu data collection (#3598) * added ArrayIndexOutOfBoundsException to try catch block in AndroidCpuCollector --- CHANGELOG.md | 1 + .../main/java/io/sentry/android/core/AndroidCpuCollector.java | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fd8abb9a3d..db99b442cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Fixes +- Avoid ArrayIndexOutOfBoundsException on Android cpu data collection ([#3598](https://github.com/getsentry/sentry-java/pull/3598)) - Fix lazy select queries instrumentation ([#3604](https://github.com/getsentry/sentry-java/pull/3604)) ## 7.13.0 diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidCpuCollector.java b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidCpuCollector.java index 9f23154aa4..8f54305e6f 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidCpuCollector.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidCpuCollector.java @@ -115,7 +115,7 @@ private long readTotalCpuNanos() { // Amount of clock ticks this process' waited-for children has been scheduled in kernel mode long csTime = Long.parseLong(stats[16]); return (long) ((uTime + sTime + cuTime + csTime) * nanosecondsPerClockTick); - } catch (NumberFormatException e) { + } catch (NumberFormatException | ArrayIndexOutOfBoundsException e) { logger.log(SentryLevel.ERROR, "Error parsing /proc/self/stat file.", e); return 0; } From d4b1f82d2bdf3415427ba550509483bbf210d787 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Fri, 2 Aug 2024 10:25:59 +0200 Subject: [PATCH 21/92] [SR] ANR with buffered Replay integration test (#3612) --- .github/workflows/system-tests-backend.yml | 2 +- CHANGELOG.md | 4 + sentry-android-replay/build.gradle.kts | 1 + .../io/sentry/android/replay/ReplayCache.kt | 35 ++- .../replay/AnrWithReplayIntegrationTest.kt | 218 ++++++++++++++++++ .../sentry/android/replay/ReplayCacheTest.kt | 78 +++---- .../ReplayIntegrationWithRecorderTest.kt | 63 +---- .../sentry/android/replay/ReplaySmokeTest.kt | 64 +---- .../replay/util/ReplayShadowMediaCodec.kt | 60 +++++ 9 files changed, 344 insertions(+), 181 deletions(-) create mode 100644 sentry-android-replay/src/test/java/io/sentry/android/replay/AnrWithReplayIntegrationTest.kt create mode 100644 sentry-android-replay/src/test/java/io/sentry/android/replay/util/ReplayShadowMediaCodec.kt diff --git a/.github/workflows/system-tests-backend.yml b/.github/workflows/system-tests-backend.yml index 90acfba2ab..0c04c86fd8 100644 --- a/.github/workflows/system-tests-backend.yml +++ b/.github/workflows/system-tests-backend.yml @@ -46,7 +46,7 @@ jobs: - name: Exclude android modules from build run: | - sed -i -e '/.*"sentry-android-ndk",/d' -e '/.*"sentry-android",/d' -e '/.*"sentry-compose",/d' -e '/.*"sentry-android-core",/d' -e '/.*"sentry-android-fragment",/d' -e '/.*"sentry-android-navigation",/d' -e '/.*"sentry-android-okhttp",/d' -e '/.*"sentry-android-sqlite",/d' -e '/.*"sentry-android-timber",/d' -e '/.*"sentry-android-integration-tests:sentry-uitest-android-benchmark",/d' -e '/.*"sentry-android-integration-tests:sentry-uitest-android",/d' -e '/.*"sentry-android-integration-tests:test-app-sentry",/d' -e '/.*"sentry-samples:sentry-samples-android",/d' settings.gradle.kts + sed -i -e '/.*"sentry-android-ndk",/d' -e '/.*"sentry-android",/d' -e '/.*"sentry-compose",/d' -e '/.*"sentry-android-core",/d' -e '/.*"sentry-android-fragment",/d' -e '/.*"sentry-android-navigation",/d' -e '/.*"sentry-android-okhttp",/d' -e '/.*"sentry-android-sqlite",/d' -e '/.*"sentry-android-timber",/d' -e '/.*"sentry-android-integration-tests:sentry-uitest-android-benchmark",/d' -e '/.*"sentry-android-integration-tests:sentry-uitest-android",/d' -e '/.*"sentry-android-integration-tests:test-app-sentry",/d' -e '/.*"sentry-samples:sentry-samples-android",/d' -e '/.*"sentry-android-replay",/d' settings.gradle.kts - name: Exclude android modules from ignore list run: | diff --git a/CHANGELOG.md b/CHANGELOG.md index db99b442cc..25cbbdffba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ - Avoid ArrayIndexOutOfBoundsException on Android cpu data collection ([#3598](https://github.com/getsentry/sentry-java/pull/3598)) - Fix lazy select queries instrumentation ([#3604](https://github.com/getsentry/sentry-java/pull/3604)) +### Chores + +- Introduce `ReplayShadowMediaCodec` and refactor tests using custom encoder ([#3612](https://github.com/getsentry/sentry-java/pull/3612)) + ## 7.13.0 ### Features diff --git a/sentry-android-replay/build.gradle.kts b/sentry-android-replay/build.gradle.kts index bd9b5d961b..2e74641268 100644 --- a/sentry-android-replay/build.gradle.kts +++ b/sentry-android-replay/build.gradle.kts @@ -69,6 +69,7 @@ dependencies { // tests testImplementation(projects.sentryTestSupport) + testImplementation(projects.sentryAndroidCore) testImplementation(Config.TestLibs.robolectric) testImplementation(Config.TestLibs.kotlinTestJunit) testImplementation(Config.TestLibs.androidxRunner) diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt index 549db2566b..21dbd6ec21 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt @@ -36,30 +36,12 @@ import java.util.concurrent.atomic.AtomicBoolean * @param replayId the current replay id, used for giving a unique name to the replay folder * @param recorderConfig ScreenshotRecorderConfig, used for video resolution and frame-rate */ -public class ReplayCache internal constructor( +public class ReplayCache( private val options: SentryOptions, private val replayId: SentryId, - private val recorderConfig: ScreenshotRecorderConfig, - private val encoderProvider: (videoFile: File, height: Int, width: Int) -> SimpleVideoEncoder + private val recorderConfig: ScreenshotRecorderConfig ) : Closeable { - public constructor( - options: SentryOptions, - replayId: SentryId, - recorderConfig: ScreenshotRecorderConfig - ) : this(options, replayId, recorderConfig, encoderProvider = { videoFile, height, width -> - SimpleVideoEncoder( - options, - MuxerConfig( - file = videoFile, - recordingHeight = height, - recordingWidth = width, - frameRate = recorderConfig.frameRate, - bitRate = recorderConfig.bitRate - ) - ).also { it.start() } - }) - private val isClosed = AtomicBoolean(false) private val encoderLock = Any() private var encoder: SimpleVideoEncoder? = null @@ -164,7 +146,18 @@ public class ReplayCache internal constructor( } // TODO: reuse instance of encoder and just change file path to create a different muxer - encoder = synchronized(encoderLock) { encoderProvider(videoFile, height, width) } + encoder = synchronized(encoderLock) { + SimpleVideoEncoder( + options, + MuxerConfig( + file = videoFile, + recordingHeight = height, + recordingWidth = width, + frameRate = recorderConfig.frameRate, + bitRate = recorderConfig.bitRate + ) + ).also { it.start() } + } val step = 1000 / recorderConfig.frameRate.toLong() var frameCount = 0 diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/AnrWithReplayIntegrationTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/AnrWithReplayIntegrationTest.kt new file mode 100644 index 0000000000..262225d77c --- /dev/null +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/AnrWithReplayIntegrationTest.kt @@ -0,0 +1,218 @@ +package io.sentry.android.replay + +import android.app.ActivityManager +import android.app.ApplicationExitInfo +import android.content.Context +import android.graphics.Bitmap +import android.graphics.Bitmap.CompressFormat.JPEG +import android.graphics.Bitmap.Config.ARGB_8888 +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.sentry.EventProcessor +import io.sentry.Hint +import io.sentry.Sentry +import io.sentry.SentryOptions +import io.sentry.SentryReplayEvent +import io.sentry.SentryReplayEvent.ReplayType +import io.sentry.SystemOutLogger +import io.sentry.android.core.SentryAndroid +import io.sentry.android.core.performance.AppStartMetrics +import io.sentry.android.replay.ReplayCache.Companion.ONGOING_SEGMENT +import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_BIT_RATE +import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_FRAME_RATE +import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_HEIGHT +import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_ID +import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_REPLAY_TYPE +import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_TIMESTAMP +import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_WIDTH +import io.sentry.android.replay.util.ReplayShadowMediaCodec +import io.sentry.cache.PersistingOptionsObserver.OPTIONS_CACHE +import io.sentry.cache.PersistingOptionsObserver.REPLAY_ERROR_SAMPLE_RATE_FILENAME +import io.sentry.protocol.Contexts +import io.sentry.protocol.SentryId +import io.sentry.rrweb.RRWebMetaEvent +import io.sentry.rrweb.RRWebVideoEvent +import org.awaitility.kotlin.await +import org.awaitility.kotlin.withAlias +import org.junit.Rule +import org.junit.rules.TemporaryFolder +import org.junit.runner.RunWith +import org.mockito.kotlin.spy +import org.mockito.kotlin.whenever +import org.robolectric.annotation.Config +import org.robolectric.shadow.api.Shadow +import org.robolectric.shadows.ShadowActivityManager +import org.robolectric.shadows.ShadowActivityManager.ApplicationExitInfoBuilder +import java.io.File +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals + +@RunWith(AndroidJUnit4::class) +@Config( + sdk = [30], + shadows = [ReplayShadowMediaCodec::class] +) +class AnrWithReplayIntegrationTest { + + @get:Rule + val tmpDir = TemporaryFolder() + + private class Fixture { + lateinit var shadowActivityManager: ShadowActivityManager + + fun addAppExitInfo( + reason: Int? = ApplicationExitInfo.REASON_ANR, + timestamp: Long? = null, + importance: Int? = null + ) { + val builder = ApplicationExitInfoBuilder.newBuilder() + if (reason != null) { + builder.setReason(reason) + } + if (timestamp != null) { + builder.setTimestamp(timestamp) + } + if (importance != null) { + builder.setImportance(importance) + } + val exitInfo = spy(builder.build()) { + whenever(mock.traceInputStream).thenReturn( + """ +"main" prio=5 tid=1 Blocked + | group="main" sCount=1 ucsCount=0 flags=1 obj=0x72a985e0 self=0xb400007cabc57380 + | sysTid=28941 nice=-10 cgrp=top-app sched=0/0 handle=0x7deceb74f8 + | state=S schedstat=( 324804784 183300334 997 ) utm=23 stm=8 core=3 HZ=100 + | stack=0x7ff93a9000-0x7ff93ab000 stackSize=8188KB + | held mutexes= + at io.sentry.samples.android.MainActivity${'$'}2.run(MainActivity.java:177) + - waiting to lock <0x0d3a2f0a> (a java.lang.Object) held by thread 5 + at android.os.Handler.handleCallback(Handler.java:942) + at android.os.Handler.dispatchMessage(Handler.java:99) + at android.os.Looper.loopOnce(Looper.java:201) + at android.os.Looper.loop(Looper.java:288) + at android.app.ActivityThread.main(ActivityThread.java:7872) + at java.lang.reflect.Method.invoke(Native method) + at com.android.internal.os.RuntimeInit${'$'}MethodAndArgsCaller.run(RuntimeInit.java:548) + at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:936) + +"perfetto_hprof_listener" prio=10 tid=7 Native (still starting up) + | group="" sCount=1 ucsCount=0 flags=1 obj=0x0 self=0xb400007cabc5ab20 + | sysTid=28959 nice=-20 cgrp=top-app sched=0/0 handle=0x7b2021bcb0 + | state=S schedstat=( 72750 1679167 1 ) utm=0 stm=0 core=3 HZ=100 + | stack=0x7b20124000-0x7b20126000 stackSize=991KB + | held mutexes= + native: #00 pc 00000000000a20f4 /apex/com.android.runtime/lib64/bionic/libc.so (read+4) (BuildId: 01331f74b0bb2cb958bdc15282b8ec7b) + native: #01 pc 000000000001d840 /apex/com.android.art/lib64/libperfetto_hprof.so (void* std::__1::__thread_proxy >, ArtPlugin_Initialize::${'$'}_34> >(void*)+260) (BuildId: 525cc92a7dc49130157aeb74f6870364) + native: #02 pc 00000000000b63b0 /apex/com.android.runtime/lib64/bionic/libc.so (__pthread_start(void*)+208) (BuildId: 01331f74b0bb2cb958bdc15282b8ec7b) + native: #03 pc 00000000000530b8 /apex/com.android.runtime/lib64/bionic/libc.so (__start_thread+64) (BuildId: 01331f74b0bb2cb958bdc15282b8ec7b) + (no managed stack frames) + """.trimIndent().byteInputStream() + ) + } + shadowActivityManager.addApplicationExitInfo(exitInfo) + } + + fun prefillOptionsCache(cacheDir: String) { + val optionsDir = File(cacheDir, OPTIONS_CACHE).also { it.mkdirs() } + File(optionsDir, REPLAY_ERROR_SAMPLE_RATE_FILENAME).writeText("\"1.0\"") + } + } + + private val fixture = Fixture() + private lateinit var context: Context + + @BeforeTest + fun `set up`() { + ReplayShadowMediaCodec.framesToEncode = 5 + Sentry.close() + AppStartMetrics.getInstance().clear() + context = ApplicationProvider.getApplicationContext() + val activityManager = context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager? + fixture.shadowActivityManager = Shadow.extract(activityManager) + } + + @Test + fun `replay is being captured for ANRs in buffer mode`() { + ReplayShadowMediaCodec.framesToEncode = 1 + + val cacheDir = tmpDir.newFolder().absolutePath + val oneDayAgo = System.currentTimeMillis() - TimeUnit.DAYS.toMillis(1) + fixture.addAppExitInfo(timestamp = oneDayAgo) + val asserted = AtomicBoolean(false) + + val replayId1 = SentryId() + val replayId2 = SentryId() + + SentryAndroid.init(context) { + it.dsn = "https://key@sentry.io/123" + it.cacheDirPath = cacheDir + it.isDebug = true + it.setLogger(SystemOutLogger()) + it.experimental.sessionReplay.errorSampleRate = 1.0 + // beforeSend is called after event processors are applied, so we can assert here + // against the enriched ANR event + it.beforeSend = SentryOptions.BeforeSendCallback { event, _ -> + assertEquals(replayId2.toString(), event.contexts[Contexts.REPLAY_ID]) + event + } + it.addEventProcessor(object : EventProcessor { + override fun process(event: SentryReplayEvent, hint: Hint): SentryReplayEvent { + assertEquals(replayId2, event.replayId) + assertEquals(ReplayType.BUFFER, event.replayType) + assertEquals("0.mp4", event.videoFile?.name) + + val metaEvents = + hint.replayRecording?.payload?.filterIsInstance() + assertEquals(912, metaEvents?.first()?.height) + assertEquals(416, metaEvents?.first()?.width) // clamped to power of 16 + + val videoEvents = + hint.replayRecording?.payload?.filterIsInstance() + assertEquals(912, videoEvents?.first()?.height) + assertEquals(416, videoEvents?.first()?.width) // clamped to power of 16 + assertEquals(1000, videoEvents?.first()?.durationMs) + assertEquals(1, videoEvents?.first()?.frameCount) + assertEquals(1, videoEvents?.first()?.frameRate) + assertEquals(0, videoEvents?.first()?.segmentId) + asserted.set(true) + return event + } + }) + + // have to do it after the cacheDir is set to options, because it adds a dsn hash after + fixture.prefillOptionsCache(it.cacheDirPath!!) + + val replayFolder1 = File(it.cacheDirPath!!, "replay_$replayId1").also { it.mkdirs() } + val replayFolder2 = File(it.cacheDirPath!!, "replay_$replayId2").also { it.mkdirs() } + + File(replayFolder2, ONGOING_SEGMENT).also { file -> + file.writeText( + """ + $SEGMENT_KEY_HEIGHT=912 + $SEGMENT_KEY_WIDTH=416 + $SEGMENT_KEY_FRAME_RATE=1 + $SEGMENT_KEY_BIT_RATE=75000 + $SEGMENT_KEY_ID=0 + $SEGMENT_KEY_TIMESTAMP=2024-07-11T10:25:21.454Z + $SEGMENT_KEY_REPLAY_TYPE=BUFFER + """.trimIndent() + ) + } + + val screenshot = File(replayFolder2, "1720693523997.jpg").also { it.createNewFile() } + screenshot.outputStream().use { os -> + Bitmap.createBitmap(1, 1, ARGB_8888).compress(JPEG, 80, os) + os.flush() + } + + replayFolder1.setLastModified(oneDayAgo - 1000) + replayFolder2.setLastModified(oneDayAgo - 500) + } + + await.withAlias("Failed because of BeforeSend callback above, but we swallow BeforeSend exceptions, hence the timeout") + .untilTrue(asserted) + } +} diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayCacheTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayCacheTest.kt index 0dae78e723..99a308f53a 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayCacheTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayCacheTest.kt @@ -3,7 +3,6 @@ package io.sentry.android.replay import android.graphics.Bitmap import android.graphics.Bitmap.CompressFormat.JPEG import android.graphics.Bitmap.Config.ARGB_8888 -import android.media.MediaCodec import androidx.test.ext.junit.runners.AndroidJUnit4 import io.sentry.DateUtils import io.sentry.SentryOptions @@ -17,8 +16,7 @@ import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_REPLAY_RECORDI import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_REPLAY_TYPE import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_TIMESTAMP import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_WIDTH -import io.sentry.android.replay.video.MuxerConfig -import io.sentry.android.replay.video.SimpleVideoEncoder +import io.sentry.android.replay.util.ReplayShadowMediaCodec import io.sentry.protocol.SentryId import io.sentry.rrweb.RRWebInteractionEvent import io.sentry.rrweb.RRWebInteractionEvent.InteractionType.TouchEnd @@ -28,8 +26,7 @@ import org.junit.rules.TemporaryFolder import org.junit.runner.RunWith import org.robolectric.annotation.Config import java.io.File -import java.util.concurrent.TimeUnit.MICROSECONDS -import java.util.concurrent.TimeUnit.MILLISECONDS +import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -37,7 +34,10 @@ import kotlin.test.assertNull import kotlin.test.assertTrue @RunWith(AndroidJUnit4::class) -@Config(sdk = [26]) +@Config( + sdk = [26], + shadows = [ReplayShadowMediaCodec::class] +) class ReplayCacheTest { @get:Rule @@ -45,46 +45,26 @@ class ReplayCacheTest { internal class Fixture { val options = SentryOptions() - var encoder: SimpleVideoEncoder? = null fun getSut( dir: TemporaryFolder?, replayId: SentryId = SentryId(), - frameRate: Int, - framesToEncode: Int = 0 + frameRate: Int ): ReplayCache { val recorderConfig = ScreenshotRecorderConfig(100, 200, 1f, 1f, frameRate = frameRate, bitRate = 20_000) options.run { cacheDirPath = dir?.newFolder()?.absolutePath } - return ReplayCache(options, replayId, recorderConfig, encoderProvider = { videoFile, height, width -> - encoder = SimpleVideoEncoder( - options, - MuxerConfig( - file = videoFile, - recordingHeight = height, - recordingWidth = width, - frameRate = recorderConfig.frameRate, - bitRate = recorderConfig.bitRate - ), - onClose = { - encodeFrame(framesToEncode, frameRate, size = 0, flags = MediaCodec.BUFFER_FLAG_END_OF_STREAM) - } - ).also { it.start() } - repeat(framesToEncode) { encodeFrame(it, frameRate) } - - encoder!! - }) - } - - fun encodeFrame(index: Int, frameRate: Int, size: Int = 10, flags: Int = 0) { - val presentationTime = MICROSECONDS.convert(index * (1000L / frameRate), MILLISECONDS) - encoder!!.mediaCodec.dequeueInputBuffer(0) - encoder!!.mediaCodec.queueInputBuffer(index, index * size, size, presentationTime, flags) + return ReplayCache(options, replayId, recorderConfig) } } private val fixture = Fixture() + @BeforeTest + fun `set up`() { + ReplayShadowMediaCodec.framesToEncode = 5 + } + @Test fun `when no cacheDirPath specified, does not store screenshots`() { val replayId = SentryId() @@ -132,10 +112,10 @@ class ReplayCacheTest { @Test fun `deletes frames after creating a video`() { + ReplayShadowMediaCodec.framesToEncode = 3 val replayCache = fixture.getSut( tmpDir, - frameRate = 1, - framesToEncode = 3 + frameRate = 1 ) val bitmap = Bitmap.createBitmap(1, 1, ARGB_8888) @@ -157,8 +137,7 @@ class ReplayCacheTest { fun `repeats last known frame for the segment duration`() { val replayCache = fixture.getSut( tmpDir, - frameRate = 1, - framesToEncode = 5 + frameRate = 1 ) val bitmap = Bitmap.createBitmap(1, 1, ARGB_8888) @@ -175,8 +154,7 @@ class ReplayCacheTest { fun `repeats last known frame for the segment duration for each timespan`() { val replayCache = fixture.getSut( tmpDir, - frameRate = 1, - framesToEncode = 5 + frameRate = 1 ) val bitmap = Bitmap.createBitmap(1, 1, ARGB_8888) @@ -194,8 +172,7 @@ class ReplayCacheTest { fun `repeats last known frame for each segment`() { val replayCache = fixture.getSut( tmpDir, - frameRate = 1, - framesToEncode = 5 + frameRate = 1 ) val bitmap = Bitmap.createBitmap(1, 1, ARGB_8888) @@ -216,10 +193,11 @@ class ReplayCacheTest { @Test fun `respects frameRate`() { + ReplayShadowMediaCodec.framesToEncode = 6 + val replayCache = fixture.getSut( tmpDir, - frameRate = 2, - framesToEncode = 6 + frameRate = 2 ) val bitmap = Bitmap.createBitmap(1, 1, ARGB_8888) @@ -238,8 +216,7 @@ class ReplayCacheTest { fun `does not add frame when bitmap is recycled`() { val replayCache = fixture.getSut( tmpDir, - frameRate = 1, - framesToEncode = 5 + frameRate = 1 ) val bitmap = Bitmap.createBitmap(1, 1, ARGB_8888).also { it.recycle() } @@ -252,8 +229,7 @@ class ReplayCacheTest { fun `addFrame with File path works`() { val replayCache = fixture.getSut( tmpDir, - frameRate = 1, - framesToEncode = 5 + frameRate = 1 ) val flutterCacheDir = @@ -276,8 +252,7 @@ class ReplayCacheTest { fun `rotates frames`() { val replayCache = fixture.getSut( tmpDir, - frameRate = 1, - framesToEncode = 5 + frameRate = 1 ) val bitmap = Bitmap.createBitmap(1, 1, ARGB_8888) @@ -472,10 +447,11 @@ class ReplayCacheTest { @Test fun `when videoFile exists and is not empty, deletes it before writing`() { + ReplayShadowMediaCodec.framesToEncode = 3 + val replayCache = fixture.getSut( tmpDir, - frameRate = 1, - framesToEncode = 3 + frameRate = 1 ) val oldVideoFile = File(replayCache.replayCacheDir, "0.mp4").also { diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationWithRecorderTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationWithRecorderTest.kt index 8e3bef2c2f..6bab0f6549 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationWithRecorderTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationWithRecorderTest.kt @@ -4,7 +4,6 @@ import android.content.Context import android.graphics.Bitmap import android.graphics.Bitmap.CompressFormat.JPEG import android.graphics.Bitmap.Config.ARGB_8888 -import android.media.MediaCodec import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 import io.sentry.IHub @@ -16,8 +15,7 @@ import io.sentry.android.replay.ReplayIntegrationWithRecorderTest.LifecycleState import io.sentry.android.replay.ReplayIntegrationWithRecorderTest.LifecycleState.RESUMED import io.sentry.android.replay.ReplayIntegrationWithRecorderTest.LifecycleState.STARTED import io.sentry.android.replay.ReplayIntegrationWithRecorderTest.LifecycleState.STOPPED -import io.sentry.android.replay.video.MuxerConfig -import io.sentry.android.replay.video.SimpleVideoEncoder +import io.sentry.android.replay.util.ReplayShadowMediaCodec import io.sentry.rrweb.RRWebMetaEvent import io.sentry.rrweb.RRWebVideoEvent import io.sentry.transport.CurrentDateProvider @@ -36,14 +34,15 @@ import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import org.robolectric.annotation.Config import java.io.File -import java.util.concurrent.TimeUnit.MICROSECONDS -import java.util.concurrent.TimeUnit.MILLISECONDS import java.util.concurrent.atomic.AtomicBoolean import kotlin.test.BeforeTest import kotlin.test.assertEquals @RunWith(AndroidJUnit4::class) -@Config(sdk = [26]) +@Config( + sdk = [26], + shadows = [ReplayShadowMediaCodec::class] +) class ReplayIntegrationWithRecorderTest { @get:Rule @@ -54,63 +53,18 @@ class ReplayIntegrationWithRecorderTest { mainThreadChecker = NoOpMainThreadChecker.getInstance() } val hub = mock() - var encoder: SimpleVideoEncoder? = null fun getSut( context: Context, recorder: Recorder, recorderConfig: ScreenshotRecorderConfig, - dateProvider: ICurrentDateProvider = CurrentDateProvider.getInstance(), - framesToEncode: Int = 0 + dateProvider: ICurrentDateProvider = CurrentDateProvider.getInstance() ): ReplayIntegration { return ReplayIntegration( context, dateProvider, recorderProvider = { recorder }, - recorderConfigProvider = { recorderConfig }, - // this is just needed for testing to encode a fake video - replayCacheProvider = { replayId, config -> - ReplayCache( - options, - replayId, - config, - encoderProvider = { videoFile, height, width -> - encoder = SimpleVideoEncoder( - options, - MuxerConfig( - file = videoFile, - recordingHeight = height, - recordingWidth = width, - frameRate = recorderConfig.frameRate, - bitRate = recorderConfig.bitRate - ), - onClose = { - encodeFrame( - framesToEncode, - recorderConfig.frameRate, - size = 0, - flags = MediaCodec.BUFFER_FLAG_END_OF_STREAM - ) - } - ).also { it.start() } - repeat(framesToEncode) { encodeFrame(it, recorderConfig.frameRate) } - - encoder!! - } - ) - } - ) - } - - private fun encodeFrame(index: Int, frameRate: Int, size: Int = 10, flags: Int = 0) { - val presentationTime = MICROSECONDS.convert(index * (1000L / frameRate), MILLISECONDS) - encoder!!.mediaCodec.dequeueInputBuffer(0) - encoder!!.mediaCodec.queueInputBuffer( - index, - index * size, - size, - presentationTime, - flags + recorderConfigProvider = { recorderConfig } ) } } @@ -120,6 +74,7 @@ class ReplayIntegrationWithRecorderTest { @BeforeTest fun `set up`() { + ReplayShadowMediaCodec.framesToEncode = 5 context = ApplicationProvider.getApplicationContext() } @@ -164,7 +119,7 @@ class ReplayIntegrationWithRecorderTest { } } - replay = fixture.getSut(context, recorder, recorderConfig, dateProvider, framesToEncode = 5) + replay = fixture.getSut(context, recorder, recorderConfig, dateProvider) replay.register(fixture.hub, fixture.options) assertEquals(INITALIZED, recorder.state) diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplaySmokeTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplaySmokeTest.kt index 53ef7c009e..415697f68d 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplaySmokeTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplaySmokeTest.kt @@ -3,9 +3,9 @@ package io.sentry.android.replay import android.app.Activity import android.content.Context import android.graphics.drawable.Drawable -import android.media.MediaCodec import android.os.Bundle import android.os.Handler +import android.os.Handler.Callback import android.os.Looper import android.widget.ImageView import android.widget.LinearLayout @@ -18,8 +18,7 @@ import io.sentry.Scope import io.sentry.ScopeCallback import io.sentry.SentryOptions import io.sentry.SentryReplayEvent.ReplayType -import io.sentry.android.replay.video.MuxerConfig -import io.sentry.android.replay.video.SimpleVideoEncoder +import io.sentry.android.replay.util.ReplayShadowMediaCodec import io.sentry.rrweb.RRWebMetaEvent import io.sentry.rrweb.RRWebVideoEvent import io.sentry.transport.CurrentDateProvider @@ -43,15 +42,13 @@ import org.robolectric.annotation.Config import org.robolectric.shadows.ShadowPixelCopy import java.time.Duration import java.util.concurrent.TimeUnit -import java.util.concurrent.TimeUnit.MICROSECONDS -import java.util.concurrent.TimeUnit.MILLISECONDS import java.util.concurrent.atomic.AtomicBoolean import kotlin.test.BeforeTest import kotlin.test.assertEquals @RunWith(AndroidJUnit4::class) @Config( - shadows = [ShadowPixelCopy::class], + shadows = [ShadowPixelCopy::class, ReplayShadowMediaCodec::class], sdk = [28], qualifiers = "w360dp-h640dp-xxhdpi" ) @@ -68,53 +65,21 @@ class ReplaySmokeTest { (it.arguments[0] as ScopeCallback).run(scope) }.whenever(it).configureScope(any()) } - var encoder: SimpleVideoEncoder? = null var count: Int = 0 private class ImmediateHandler : Handler(Callback { it.callback?.run(); true }) fun getSut( context: Context, - dateProvider: ICurrentDateProvider = CurrentDateProvider.getInstance(), - framesToEncode: Int = 0 + dateProvider: ICurrentDateProvider = CurrentDateProvider.getInstance() ): ReplayIntegration { return ReplayIntegration( context, dateProvider, recorderProvider = null, recorderConfigProvider = null, - // this is just needed for testing to encode a fake video - replayCacheProvider = { replayId, recorderConfig -> - ReplayCache( - options, - replayId, - recorderConfig, - encoderProvider = { videoFile, height, width -> - encoder = SimpleVideoEncoder( - options, - MuxerConfig( - file = videoFile, - recordingHeight = height, - recordingWidth = width, - frameRate = recorderConfig.frameRate, - bitRate = recorderConfig.bitRate - ), - onClose = { - encodeFrame( - framesToEncode, - recorderConfig.frameRate, - size = 0, - flags = MediaCodec.BUFFER_FLAG_END_OF_STREAM - ) - } - ).also { it.start() } - repeat(framesToEncode) { encodeFrame(it, recorderConfig.frameRate) } - - encoder!! - } - ) - }, replayCaptureStrategyProvider = null, + replayCacheProvider = null, mainLooperHandler = mock { whenever(mock.handler).thenReturn(ImmediateHandler()) whenever(mock.post(any())).then { @@ -124,18 +89,6 @@ class ReplaySmokeTest { } ) } - - private fun encodeFrame(index: Int, frameRate: Int, size: Int = 10, flags: Int = 0) { - val presentationTime = MICROSECONDS.convert(index * (1000L / frameRate), MILLISECONDS) - encoder!!.mediaCodec.dequeueInputBuffer(0) - encoder!!.mediaCodec.queueInputBuffer( - index, - index * size, - size, - presentationTime, - flags - ) - } } private val fixture = Fixture() @@ -143,6 +96,7 @@ class ReplaySmokeTest { @BeforeTest fun `set up`() { + ReplayShadowMediaCodec.framesToEncode = 5 context = ApplicationProvider.getApplicationContext() } @@ -156,7 +110,7 @@ class ReplaySmokeTest { fixture.options.experimental.sessionReplay.sessionSampleRate = 1.0 fixture.options.cacheDirPath = tmpDir.newFolder().absolutePath - val replay: ReplayIntegration = fixture.getSut(context, framesToEncode = 5) + val replay: ReplayIntegration = fixture.getSut(context) replay.register(fixture.hub, fixture.options) val controller = buildActivity(ExampleActivity::class.java, null).setup() @@ -193,6 +147,8 @@ class ReplaySmokeTest { @Test fun `works in buffer mode`() { + ReplayShadowMediaCodec.framesToEncode = 10 + val captured = AtomicBoolean(false) whenever(fixture.hub.captureReplay(any(), anyOrNull())).then { captured.set(true) @@ -201,7 +157,7 @@ class ReplaySmokeTest { fixture.options.experimental.sessionReplay.errorSampleRate = 1.0 fixture.options.cacheDirPath = tmpDir.newFolder().absolutePath - val replay: ReplayIntegration = fixture.getSut(context, framesToEncode = 10) + val replay: ReplayIntegration = fixture.getSut(context) replay.register(fixture.hub, fixture.options) val controller = buildActivity(ExampleActivity::class.java, null).setup() diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/util/ReplayShadowMediaCodec.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/util/ReplayShadowMediaCodec.kt new file mode 100644 index 0000000000..c46c49ded0 --- /dev/null +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/util/ReplayShadowMediaCodec.kt @@ -0,0 +1,60 @@ +package io.sentry.android.replay.util + +import android.media.MediaCodec +import android.media.MediaCodec.BufferInfo +import org.robolectric.annotation.Implementation +import org.robolectric.annotation.Implements +import org.robolectric.shadows.ShadowMediaCodec +import java.nio.ByteBuffer +import java.util.concurrent.TimeUnit.MICROSECONDS +import java.util.concurrent.TimeUnit.MILLISECONDS +import java.util.concurrent.atomic.AtomicBoolean + +@Implements(MediaCodec::class) +class ReplayShadowMediaCodec : ShadowMediaCodec() { + + companion object { + var frameRate = 1 + var framesToEncode = 5 + } + + private val encoded = AtomicBoolean(false) + + @Implementation + fun start() { + super.native_start() + } + + @Implementation + fun signalEndOfInputStream() { + encodeFrame(framesToEncode, frameRate, 0, MediaCodec.BUFFER_FLAG_END_OF_STREAM) + } + + @Implementation + fun getOutputBuffers(): Array { + return super.getBuffers(false) + } + + @Implementation + fun dequeueOutputBuffer(info: BufferInfo, timeoutUs: Long): Int { + val encoderStatus = super.native_dequeueOutputBuffer(info, timeoutUs) + super.validateOutputByteBuffer(getOutputBuffers(), encoderStatus, info) + if (encoderStatus == MediaCodec.INFO_TRY_AGAIN_LATER && !encoded.getAndSet(true)) { + // MediaMuxer is initialized now, so we can start encoding frames + repeat(framesToEncode) { encodeFrame(it, frameRate) } + } + return encoderStatus + } + + private fun encodeFrame(index: Int, frameRate: Int, size: Int = 10, flags: Int = 0) { + val presentationTime = MICROSECONDS.convert(index * (1000L / frameRate), MILLISECONDS) + super.native_dequeueInputBuffer(0) + super.native_queueInputBuffer( + index, + index * size, + size, + presentationTime, + flags + ) + } +} From 9486895b563ad8d76e8c09e3a41e74b300bc6554 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Fri, 9 Aug 2024 10:51:26 +0200 Subject: [PATCH 22/92] [SR] Buffer mode improvements (#3622) * Persist buffer replay type when switching to session * Ensure no gaps in segment timestamps when converting strategies * Properly store screen name at start for buffer mode * Changelog --- CHANGELOG.md | 4 ++ .../api/sentry-android-replay.api | 5 +- .../io/sentry/android/replay/ReplayCache.kt | 18 ++++-- .../android/replay/ReplayIntegration.kt | 15 ++--- .../replay/capture/BaseCaptureStrategy.kt | 16 ++--- .../replay/capture/BufferCaptureStrategy.kt | 59 ++----------------- .../android/replay/capture/CaptureStrategy.kt | 9 +-- .../replay/capture/SessionCaptureStrategy.kt | 16 ++--- .../sentry/android/replay/ReplayCacheTest.kt | 17 ++++++ .../android/replay/ReplayIntegrationTest.kt | 40 +++++++++++-- .../capture/BufferCaptureStrategyTest.kt | 26 ++++++++ 11 files changed, 129 insertions(+), 96 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 25cbbdffba..ca4cf89943 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ - Avoid ArrayIndexOutOfBoundsException on Android cpu data collection ([#3598](https://github.com/getsentry/sentry-java/pull/3598)) - Fix lazy select queries instrumentation ([#3604](https://github.com/getsentry/sentry-java/pull/3604)) +- Session Replay: buffer mode improvements ([#3622](https://github.com/getsentry/sentry-java/pull/3622)) + - Align next segment timestamp with the end of the buffered segment when converting from buffer mode to session mode + - Persist `buffer` replay type for the entire replay when converting from buffer mode to session mode + - Properly store screen names for `buffer` mode ### Chores diff --git a/sentry-android-replay/api/sentry-android-replay.api b/sentry-android-replay/api/sentry-android-replay.api index 2a81b45b82..caf0a3e37c 100644 --- a/sentry-android-replay/api/sentry-android-replay.api +++ b/sentry-android-replay/api/sentry-android-replay.api @@ -36,12 +36,13 @@ public abstract interface class io/sentry/android/replay/Recorder : java/io/Clos public final class io/sentry/android/replay/ReplayCache : java/io/Closeable { public static final field Companion Lio/sentry/android/replay/ReplayCache$Companion; public fun (Lio/sentry/SentryOptions;Lio/sentry/protocol/SentryId;Lio/sentry/android/replay/ScreenshotRecorderConfig;)V - public final fun addFrame (Ljava/io/File;J)V + public final fun addFrame (Ljava/io/File;JLjava/lang/String;)V + public static synthetic fun addFrame$default (Lio/sentry/android/replay/ReplayCache;Ljava/io/File;JLjava/lang/String;ILjava/lang/Object;)V public fun close ()V public final fun createVideoOf (JJIIILjava/io/File;)Lio/sentry/android/replay/GeneratedVideo; public static synthetic fun createVideoOf$default (Lio/sentry/android/replay/ReplayCache;JJIIILjava/io/File;ILjava/lang/Object;)Lio/sentry/android/replay/GeneratedVideo; public final fun persistSegmentValues (Ljava/lang/String;Ljava/lang/String;)V - public final fun rotate (J)V + public final fun rotate (J)Ljava/lang/String; } public final class io/sentry/android/replay/ReplayCache$Companion { diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt index 21dbd6ec21..c1bfeb1e52 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt @@ -76,7 +76,7 @@ public class ReplayCache( * @param bitmap the frame screenshot * @param frameTimestamp the timestamp when the frame screenshot was taken */ - internal fun addFrame(bitmap: Bitmap, frameTimestamp: Long) { + internal fun addFrame(bitmap: Bitmap, frameTimestamp: Long, screen: String? = null) { if (replayCacheDir == null || bitmap.isRecycled) { return } @@ -89,7 +89,7 @@ public class ReplayCache( it.flush() } - addFrame(screenshot, frameTimestamp) + addFrame(screenshot, frameTimestamp, screen) } /** @@ -101,8 +101,8 @@ public class ReplayCache( * @param screenshot file containing the frame screenshot * @param frameTimestamp the timestamp when the frame screenshot was taken */ - public fun addFrame(screenshot: File, frameTimestamp: Long) { - val frame = ReplayFrame(screenshot, frameTimestamp) + public fun addFrame(screenshot: File, frameTimestamp: Long, screen: String? = null) { + val frame = ReplayFrame(screenshot, frameTimestamp, screen) frames += frame } @@ -233,15 +233,20 @@ public class ReplayCache( * Removes frames from the in-memory and disk cache from start to [until]. * * @param until value until whose the frames should be removed, represented as unix timestamp + * @return the first screen in the rotated buffer, if any */ - fun rotate(until: Long) { + fun rotate(until: Long): String? { + var screen: String? = null frames.removeAll { if (it.timestamp < until) { deleteFile(it.screenshot) return@removeAll true + } else if (screen == null) { + screen = it.screen } return@removeAll false } + return screen } override fun close() { @@ -426,7 +431,8 @@ internal data class LastSegmentData( internal data class ReplayFrame( val screenshot: File, - val timestamp: Long + val timestamp: Long, + val screen: String? = null ) public data class GeneratedVideo( diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt index e99aec2c90..6e6d9ea052 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt @@ -12,7 +12,6 @@ import io.sentry.Integration import io.sentry.NoOpReplayBreadcrumbConverter import io.sentry.ReplayBreadcrumbConverter import io.sentry.ReplayController -import io.sentry.ScopeObserverAdapter import io.sentry.SentryIntegrationPackageStorage import io.sentry.SentryLevel.DEBUG import io.sentry.SentryLevel.INFO @@ -28,7 +27,6 @@ import io.sentry.cache.PersistingScopeObserver import io.sentry.cache.PersistingScopeObserver.BREADCRUMBS_FILENAME import io.sentry.cache.PersistingScopeObserver.REPLAY_FILENAME import io.sentry.hints.Backfillable -import io.sentry.protocol.Contexts import io.sentry.protocol.SentryId import io.sentry.transport.ICurrentDateProvider import io.sentry.util.FileUtils @@ -102,12 +100,6 @@ public class ReplayIntegration( } this.hub = hub - this.options.addScopeObserver(object : ScopeObserverAdapter() { - override fun setContexts(contexts: Contexts) { - // scope screen has fully-qualified name - captureStrategy?.onScreenChanged(contexts.app?.viewNames?.lastOrNull()?.substringAfterLast('.')) - } - }) recorder = recorderProvider?.invoke() ?: WindowRecorder(options, this, this, mainLooperHandler) isEnabled.set(true) @@ -176,8 +168,9 @@ public class ReplayIntegration( return } - captureStrategy?.captureReplay(isTerminating == true, onSegmentSent = { + captureStrategy?.captureReplay(isTerminating == true, onSegmentSent = { newTimestamp -> captureStrategy?.currentSegment = captureStrategy?.currentSegment!! + 1 + captureStrategy?.segmentTimestamp = newTimestamp }) captureStrategy = captureStrategy?.convert() } @@ -212,8 +205,10 @@ public class ReplayIntegration( } override fun onScreenshotRecorded(bitmap: Bitmap) { + var screen: String? = null + hub?.configureScope { screen = it.screen?.substringAfterLast('.') } captureStrategy?.onScreenshotRecorded(bitmap) { frameTimeStamp -> - addFrame(bitmap, frameTimeStamp) + addFrame(bitmap, frameTimeStamp, screen) } } diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt index 62bba00cd0..d888c2e33d 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt @@ -78,7 +78,7 @@ internal abstract class BaseCaptureStrategy( cache?.persistSegmentValues(SEGMENT_KEY_FRAME_RATE, newValue.frameRate.toString()) cache?.persistSegmentValues(SEGMENT_KEY_BIT_RATE, newValue.bitRate.toString()) } - protected var segmentTimestamp by persistableAtomicNullable(propertyName = SEGMENT_KEY_TIMESTAMP) { _, _, newValue -> + override var segmentTimestamp by persistableAtomicNullable(propertyName = SEGMENT_KEY_TIMESTAMP) { _, _, newValue -> cache?.persistSegmentValues(SEGMENT_KEY_TIMESTAMP, if (newValue == null) null else DateUtils.getTimestamp(newValue)) } protected val replayStartTimestamp = AtomicLong() @@ -87,7 +87,7 @@ internal abstract class BaseCaptureStrategy( override var currentSegment: Int by persistableAtomic(initialValue = -1, propertyName = SEGMENT_KEY_ID) override val replayCacheDir: File? get() = cache?.replayCacheDir - private var replayType by persistableAtomic(propertyName = SEGMENT_KEY_REPLAY_TYPE) + override var replayType by persistableAtomic(propertyName = SEGMENT_KEY_REPLAY_TYPE) protected val currentEvents: LinkedList = PersistableLinkedList( propertyName = SEGMENT_KEY_REPLAY_RECORDING, options, @@ -105,15 +105,15 @@ internal abstract class BaseCaptureStrategy( override fun start( recorderConfig: ScreenshotRecorderConfig, segmentId: Int, - replayId: SentryId + replayId: SentryId, + replayType: ReplayType? ) { cache = replayCacheProvider?.invoke(replayId, recorderConfig) ?: ReplayCache(options, replayId, recorderConfig) - // TODO: this should be persisted even after conversion - replayType = if (this is SessionCaptureStrategy) SESSION else BUFFER + this.replayType = replayType ?: (if (this is SessionCaptureStrategy) SESSION else BUFFER) this.recorderConfig = recorderConfig - currentSegment = segmentId - currentReplayId = replayId + this.currentSegment = segmentId + this.currentReplayId = replayId segmentTimestamp = DateUtils.getCurrentDateTime() replayStartTimestamp.set(dateProvider.currentTimeMillis) @@ -140,7 +140,7 @@ internal abstract class BaseCaptureStrategy( segmentId: Int, height: Int, width: Int, - replayType: ReplayType = SESSION, + replayType: ReplayType = this.replayType, cache: ReplayCache? = this.cache, frameRate: Int = recorderConfig.frameRate, screenAtStart: String? = this.screenAtStart, diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BufferCaptureStrategy.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BufferCaptureStrategy.kt index a49c7bf789..9acbbe6c11 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BufferCaptureStrategy.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BufferCaptureStrategy.kt @@ -20,6 +20,7 @@ import io.sentry.transport.ICurrentDateProvider import io.sentry.util.FileUtils import java.io.File import java.security.SecureRandom +import java.util.Date import java.util.concurrent.ScheduledExecutorService internal class BufferCaptureStrategy( @@ -34,41 +35,11 @@ internal class BufferCaptureStrategy( // TODO: capture envelopes for buffered segments instead, but don't send them until buffer is triggered private val bufferedSegments = mutableListOf() - // TODO: rework this bs, it doesn't work with sending replay on restart - private val bufferedScreensLock = Any() - private val bufferedScreens = mutableListOf>() - internal companion object { private const val TAG = "BufferCaptureStrategy" private const val ENVELOPE_PROCESSING_DELAY: Long = 100L } - override fun start( - recorderConfig: ScreenshotRecorderConfig, - segmentId: Int, - replayId: SentryId - ) { - super.start(recorderConfig, segmentId, replayId) - - hub?.configureScope { - val screen = it.screen?.substringAfterLast('.') - if (screen != null) { - synchronized(bufferedScreensLock) { - bufferedScreens.add(screen to dateProvider.currentTimeMillis) - } - } - } - } - - override fun onScreenChanged(screen: String?) { - synchronized(bufferedScreensLock) { - val lastKnownScreen = bufferedScreens.lastOrNull()?.first - if (screen != null && lastKnownScreen != screen) { - bufferedScreens.add(screen to dateProvider.currentTimeMillis) - } - } - } - override fun pause() { createCurrentSegment("pause") { segment -> if (segment is ReplaySegment.Created) { @@ -90,7 +61,7 @@ internal class BufferCaptureStrategy( override fun captureReplay( isTerminating: Boolean, - onSegmentSent: () -> Unit + onSegmentSent: (Date) -> Unit ) { val sampled = random.sample(options.experimental.sessionReplay.errorSampleRate) @@ -121,8 +92,7 @@ internal class BufferCaptureStrategy( // we only want to increment segment_id in the case of success, but currentSegment // might be irrelevant since we changed strategies, so in the callback we increment // it on the new strategy already - // TODO: also pass new segmentTimestamp to the new strategy - onSegmentSent() + onSegmentSent(segment.replay.timestamp) } } } @@ -136,7 +106,7 @@ internal class BufferCaptureStrategy( val now = dateProvider.currentTimeMillis val bufferLimit = now - options.experimental.sessionReplay.errorReplayDuration - cache?.rotate(bufferLimit) + screenAtStart = cache?.rotate(bufferLimit) bufferedSegments.rotate(bufferLimit) } } @@ -159,7 +129,7 @@ internal class BufferCaptureStrategy( } // we hand over replayExecutor to the new strategy to preserve order of execution val captureStrategy = SessionCaptureStrategy(options, hub, dateProvider, replayExecutor) - captureStrategy.start(recorderConfig, segmentId = currentSegment, replayId = currentReplayId) + captureStrategy.start(recorderConfig, segmentId = currentSegment, replayId = currentReplayId, replayType = BUFFER) return captureStrategy } @@ -169,21 +139,6 @@ internal class BufferCaptureStrategy( rotateEvents(currentEvents, bufferLimit) } - private fun findAndSetStartScreen(segmentStart: Long) { - synchronized(bufferedScreensLock) { - val startScreen = bufferedScreens.lastOrNull { (_, timestamp) -> - timestamp <= segmentStart - }?.first - // if no screen is found before the segment start, this likely means the buffer is from the - // app start, and the start screen will be taken from the navigation crumbs - if (startScreen != null) { - screenAtStart = startScreen - } - // can clear as we switch to session mode and don't care anymore about buffering - bufferedScreens.clear() - } - } - private fun deleteFile(file: File?) { if (file == null) { return @@ -246,11 +201,9 @@ internal class BufferCaptureStrategy( val height = this.recorderConfig.recordingHeight val width = this.recorderConfig.recordingWidth - findAndSetStartScreen(currentSegmentTimestamp.time) - replayExecutor.submitSafely(options, "$TAG.$taskName") { val segment = - createSegmentInternal(duration, currentSegmentTimestamp, replayId, segmentId, height, width, BUFFER) + createSegmentInternal(duration, currentSegmentTimestamp, replayId, segmentId, height, width) onSegmentCreated(segment) } } diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/CaptureStrategy.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/CaptureStrategy.kt index c3be520b84..7e9168df22 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/CaptureStrategy.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/CaptureStrategy.kt @@ -25,11 +25,14 @@ internal interface CaptureStrategy { var currentSegment: Int var currentReplayId: SentryId val replayCacheDir: File? + var replayType: ReplayType + var segmentTimestamp: Date? fun start( recorderConfig: ScreenshotRecorderConfig, segmentId: Int = 0, - replayId: SentryId = SentryId() + replayId: SentryId = SentryId(), + replayType: ReplayType? = null ) fun stop() @@ -38,7 +41,7 @@ internal interface CaptureStrategy { fun resume() - fun captureReplay(isTerminating: Boolean, onSegmentSent: () -> Unit) + fun captureReplay(isTerminating: Boolean, onSegmentSent: (Date) -> Unit) fun onScreenshotRecorded(bitmap: Bitmap? = null, store: ReplayCache.(frameTimestamp: Long) -> Unit) @@ -194,7 +197,6 @@ internal interface CaptureStrategy { replay.urls = urls return ReplaySegment.Created( - videoDuration = videoDuration, replay = replay, recording = recording ) @@ -219,7 +221,6 @@ internal interface CaptureStrategy { sealed class ReplaySegment { object Failed : ReplaySegment() data class Created( - val videoDuration: Long, val replay: SentryReplayEvent, val recording: ReplayRecording ) : ReplaySegment() { diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/SessionCaptureStrategy.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/SessionCaptureStrategy.kt index d0fd2ce1e1..61a98b6914 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/SessionCaptureStrategy.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/SessionCaptureStrategy.kt @@ -1,12 +1,12 @@ package io.sentry.android.replay.capture import android.graphics.Bitmap -import io.sentry.DateUtils import io.sentry.IConnectionStatusProvider.ConnectionStatus.DISCONNECTED import io.sentry.IHub import io.sentry.SentryLevel.DEBUG import io.sentry.SentryLevel.INFO import io.sentry.SentryOptions +import io.sentry.SentryReplayEvent.ReplayType import io.sentry.android.replay.ReplayCache import io.sentry.android.replay.ScreenshotRecorderConfig import io.sentry.android.replay.capture.CaptureStrategy.ReplaySegment @@ -14,6 +14,7 @@ import io.sentry.android.replay.util.submitSafely import io.sentry.protocol.SentryId import io.sentry.transport.ICurrentDateProvider import io.sentry.util.FileUtils +import java.util.Date import java.util.concurrent.ScheduledExecutorService internal class SessionCaptureStrategy( @@ -31,14 +32,15 @@ internal class SessionCaptureStrategy( override fun start( recorderConfig: ScreenshotRecorderConfig, segmentId: Int, - replayId: SentryId + replayId: SentryId, + replayType: ReplayType? ) { - super.start(recorderConfig, segmentId, replayId) + super.start(recorderConfig, segmentId, replayId, replayType) // only set replayId on the scope if it's a full session, otherwise all events will be // tagged with the replay that might never be sent when we're recording in buffer mode hub?.configureScope { it.replayId = currentReplayId - screenAtStart = it.screen + screenAtStart = it.screen?.substringAfterLast('.') } } @@ -65,7 +67,7 @@ internal class SessionCaptureStrategy( super.stop() } - override fun captureReplay(isTerminating: Boolean, onSegmentSent: () -> Unit) { + override fun captureReplay(isTerminating: Boolean, onSegmentSent: (Date) -> Unit) { options.logger.log(DEBUG, "Replay is already running in 'session' mode, not capturing for event") this.isTerminating.set(isTerminating) } @@ -110,7 +112,7 @@ internal class SessionCaptureStrategy( segment.capture(hub) currentSegment++ // set next segment timestamp as close to the previous one as possible to avoid gaps - segmentTimestamp = DateUtils.getDateTime(currentSegmentTimestamp.time + segment.videoDuration) + segmentTimestamp = segment.replay.timestamp } } @@ -129,7 +131,7 @@ internal class SessionCaptureStrategy( currentSegment++ // set next segment timestamp as close to the previous one as possible to avoid gaps - segmentTimestamp = DateUtils.getDateTime(currentSegmentTimestamp.time + segment.videoDuration) + segmentTimestamp = segment.replay.timestamp } } diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayCacheTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayCacheTest.kt index 99a308f53a..91a17f5192 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayCacheTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayCacheTest.kt @@ -266,6 +266,23 @@ class ReplayCacheTest { assertTrue(replayCache.replayCacheDir!!.listFiles()!!.none { it.name == "1.jpg" || it.name == "1001.jpg" }) } + @Test + fun `rotate returns first screen in buffer`() { + val replayCache = fixture.getSut( + tmpDir, + frameRate = 1 + ) + + val bitmap = Bitmap.createBitmap(1, 1, ARGB_8888) + replayCache.addFrame(bitmap, 1, "MainActivity") + replayCache.addFrame(bitmap, 1001, "SecondActivity") + replayCache.addFrame(bitmap, 2001, "ThirdActivity") + replayCache.addFrame(bitmap, 3001, "FourthActivity") + + val screen = replayCache.rotate(2000) + assertEquals("ThirdActivity", screen) + } + @Test fun `does not persist segment if already closed`() { val replayId = SentryId() diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationTest.kt index a98e344277..e96375dfa6 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationTest.kt @@ -10,6 +10,8 @@ import io.sentry.Breadcrumb import io.sentry.DateUtils import io.sentry.Hint import io.sentry.IHub +import io.sentry.Scope +import io.sentry.ScopeCallback import io.sentry.SentryEvent import io.sentry.SentryIntegrationPackageStorage import io.sentry.SentryOptions @@ -41,6 +43,7 @@ import org.junit.runner.RunWith import org.mockito.ArgumentMatchers.anyInt import org.mockito.ArgumentMatchers.anyLong import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.argThat import org.mockito.kotlin.check import org.mockito.kotlin.doAnswer @@ -77,7 +80,12 @@ class ReplayIntegrationTest { }.whenever(mock).submit(any()) } } - val hub = mock() + val scope = Scope(options) + val hub = mock { + doAnswer { + ((it.arguments[0]) as ScopeCallback).run(scope) + }.whenever(mock).configureScope(any()) + } val replayCache = mock { on { frames }.thenReturn(mutableListOf(ReplayFrame(File("1720693523997.jpg"), 1720693523997))) @@ -148,7 +156,6 @@ class ReplayIntegrationTest { replay.register(fixture.hub, fixture.options) assertTrue(replay.isEnabled.get()) - assertEquals(1, fixture.options.scopeObservers.size) assertTrue(recorderCreated) assertTrue(SentryIntegrationPackageStorage.getInstance().integrations.contains("Replay")) } @@ -160,7 +167,7 @@ class ReplayIntegrationTest { replay.start() - verify(captureStrategy, never()).start(any(), any(), any()) + verify(captureStrategy, never()).start(any(), any(), any(), anyOrNull()) } @Test @@ -186,7 +193,8 @@ class ReplayIntegrationTest { verify(captureStrategy, times(1)).start( any(), eq(0), - argThat { this != SentryId.EMPTY_ID } + argThat { this != SentryId.EMPTY_ID }, + anyOrNull() ) } @@ -201,7 +209,8 @@ class ReplayIntegrationTest { verify(captureStrategy, never()).start( any(), eq(0), - argThat { this != SentryId.EMPTY_ID } + argThat { this != SentryId.EMPTY_ID }, + anyOrNull() ) } @@ -216,7 +225,8 @@ class ReplayIntegrationTest { verify(captureStrategy, times(1)).start( any(), eq(0), - argThat { this != SentryId.EMPTY_ID } + argThat { this != SentryId.EMPTY_ID }, + anyOrNull() ) } @@ -529,4 +539,22 @@ class ReplayIntegrationTest { assertTrue(scopeCache.exists()) assertFalse(evenOlderReplay.exists()) } + + @Test + fun `onScreenshotRecorded supplies screen from scope to replay cache`() { + val captureStrategy = mock { + doAnswer { + ((it.arguments[1] as ReplayCache.(frameTimestamp: Long) -> Unit)).invoke(fixture.replayCache, 1720693523997) + }.whenever(mock).onScreenshotRecorded(anyOrNull(), any()) + } + val replay = fixture.getSut(context, replayCaptureStrategyProvider = { captureStrategy }) + + fixture.hub.configureScope { it.screen = "MainActivity" } + replay.register(fixture.hub, fixture.options) + replay.start() + + replay.onScreenshotRecorded(mock()) + + verify(fixture.replayCache).addFrame(any(), any(), eq("MainActivity")) + } } diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/BufferCaptureStrategyTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/BufferCaptureStrategyTest.kt index 5e5130aae8..bbad02444d 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/BufferCaptureStrategyTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/BufferCaptureStrategyTest.kt @@ -16,6 +16,7 @@ import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_REPLAY_TYPE import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_TIMESTAMP import io.sentry.android.replay.ReplayFrame import io.sentry.android.replay.ScreenshotRecorderConfig +import io.sentry.android.replay.capture.BufferCaptureStrategyTest.Fixture.Companion.VIDEO_DURATION import io.sentry.protocol.SentryId import io.sentry.transport.CurrentDateProvider import io.sentry.transport.ICurrentDateProvider @@ -241,6 +242,18 @@ class BufferCaptureStrategyTest { assertEquals(strategy.currentReplayId, fixture.scope.replayId) } + @Test + fun `convert persists buffer replayType when converting to session strategy`() { + val strategy = fixture.getSut() + strategy.start(fixture.recorderConfig) + + val converted = strategy.convert() + assertEquals( + ReplayType.BUFFER, + converted.replayType + ) + } + @Test fun `captureReplay does not replayId to scope when not sampled`() { val strategy = fixture.getSut(errorSampleRate = 0.0) @@ -267,4 +280,17 @@ class BufferCaptureStrategyTest { assertEquals(strategy.currentReplayId, fixture.scope.replayId) assertTrue(called) } + + @Test + fun `captureReplay sets new segment timestamp to new strategy after successful creation`() { + val strategy = fixture.getSut() + strategy.start(fixture.recorderConfig) + val oldTimestamp = strategy.segmentTimestamp + + strategy.captureReplay(false) { newTimestamp -> + assertEquals(oldTimestamp!!.time + VIDEO_DURATION, newTimestamp.time) + } + + verify(fixture.hub).captureReplay(any(), any()) + } } From 19d98e8eae24445e897068c5055a4ded1390c49a Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Fri, 9 Aug 2024 11:09:09 +0200 Subject: [PATCH 23/92] [SR] Gesture/touch support for Flutter (#3623) * Persist buffer replay type when switching to session * Ensure no gaps in segment timestamps when converting strategies * Properly store screen name at start for buffer mode * Changelog * Decouple gesture tracking from WindowRecorder * Format code * Add tests * Changelog --- CHANGELOG.md | 4 + .../api/sentry-android-replay.api | 15 +- .../android/replay/ReplayIntegration.kt | 30 ++- .../android/replay/ScreenshotRecorder.kt | 12 + .../sentry/android/replay/WindowRecorder.kt | 76 +----- .../replay/capture/BaseCaptureStrategy.kt | 136 +--------- .../replay/gestures/GestureRecorder.kt | 85 +++++++ .../replay/gestures/ReplayGestureConverter.kt | 144 +++++++++++ .../android/replay/ReplayIntegrationTest.kt | 16 +- .../replay/gestures/GestureRecorderTest.kt | 131 ++++++++++ .../gestures/ReplayGestureConverterTest.kt | 240 ++++++++++++++++++ 11 files changed, 676 insertions(+), 213 deletions(-) create mode 100644 sentry-android-replay/src/main/java/io/sentry/android/replay/gestures/GestureRecorder.kt create mode 100644 sentry-android-replay/src/main/java/io/sentry/android/replay/gestures/ReplayGestureConverter.kt create mode 100644 sentry-android-replay/src/test/java/io/sentry/android/replay/gestures/GestureRecorderTest.kt create mode 100644 sentry-android-replay/src/test/java/io/sentry/android/replay/gestures/ReplayGestureConverterTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index ca4cf89943..09ecbba6f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Features + +- Session Replay: Gesture/touch support for Flutter ([#3623](https://github.com/getsentry/sentry-java/pull/3623)) + ### Fixes - Avoid ArrayIndexOutOfBoundsException on Android cpu data collection ([#3598](https://github.com/getsentry/sentry-java/pull/3598)) diff --git a/sentry-android-replay/api/sentry-android-replay.api b/sentry-android-replay/api/sentry-android-replay.api index caf0a3e37c..f103957999 100644 --- a/sentry-android-replay/api/sentry-android-replay.api +++ b/sentry-android-replay/api/sentry-android-replay.api @@ -49,7 +49,7 @@ public final class io/sentry/android/replay/ReplayCache$Companion { public final fun makeReplayCacheDir (Lio/sentry/SentryOptions;Lio/sentry/protocol/SentryId;)Ljava/io/File; } -public final class io/sentry/android/replay/ReplayIntegration : android/content/ComponentCallbacks, io/sentry/Integration, io/sentry/ReplayController, io/sentry/android/replay/ScreenshotRecorderCallback, io/sentry/android/replay/TouchRecorderCallback, java/io/Closeable { +public final class io/sentry/android/replay/ReplayIntegration : android/content/ComponentCallbacks, io/sentry/Integration, io/sentry/ReplayController, io/sentry/android/replay/ScreenshotRecorderCallback, io/sentry/android/replay/gestures/TouchRecorderCallback, java/io/Closeable { public fun (Landroid/content/Context;Lio/sentry/transport/ICurrentDateProvider;)V public fun (Landroid/content/Context;Lio/sentry/transport/ICurrentDateProvider;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;)V public synthetic fun (Landroid/content/Context;Lio/sentry/transport/ICurrentDateProvider;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;ILkotlin/jvm/internal/DefaultConstructorMarker;)V @@ -103,7 +103,18 @@ public final class io/sentry/android/replay/ScreenshotRecorderConfig$Companion { public final fun from (Landroid/content/Context;Lio/sentry/SentryReplayOptions;)Lio/sentry/android/replay/ScreenshotRecorderConfig; } -public abstract interface class io/sentry/android/replay/TouchRecorderCallback { +public final class io/sentry/android/replay/gestures/GestureRecorder : io/sentry/android/replay/OnRootViewsChangedListener { + public fun (Lio/sentry/SentryOptions;Lio/sentry/android/replay/gestures/TouchRecorderCallback;)V + public fun onRootViewsChanged (Landroid/view/View;Z)V + public final fun stop ()V +} + +public final class io/sentry/android/replay/gestures/ReplayGestureConverter { + public fun (Lio/sentry/transport/ICurrentDateProvider;)V + public final fun convert (Landroid/view/MotionEvent;Lio/sentry/android/replay/ScreenshotRecorderConfig;)Ljava/util/List; +} + +public abstract interface class io/sentry/android/replay/gestures/TouchRecorderCallback { public abstract fun onTouchEvent (Landroid/view/MotionEvent;)V } diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt index 6e6d9ea052..996f7d2ba4 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt @@ -20,6 +20,8 @@ import io.sentry.android.replay.capture.BufferCaptureStrategy import io.sentry.android.replay.capture.CaptureStrategy import io.sentry.android.replay.capture.CaptureStrategy.ReplaySegment import io.sentry.android.replay.capture.SessionCaptureStrategy +import io.sentry.android.replay.gestures.GestureRecorder +import io.sentry.android.replay.gestures.TouchRecorderCallback import io.sentry.android.replay.util.MainLooperHandler import io.sentry.android.replay.util.sample import io.sentry.android.replay.util.submitSafely @@ -37,6 +39,7 @@ import java.io.File import java.security.SecureRandom import java.util.LinkedList import java.util.concurrent.atomic.AtomicBoolean +import kotlin.LazyThreadSafetyMode.NONE public class ReplayIntegration( private val context: Context, @@ -62,16 +65,20 @@ public class ReplayIntegration( recorderConfigProvider: ((configChanged: Boolean) -> ScreenshotRecorderConfig)?, replayCacheProvider: ((replayId: SentryId, recorderConfig: ScreenshotRecorderConfig) -> ReplayCache)?, replayCaptureStrategyProvider: ((isFullSession: Boolean) -> CaptureStrategy)? = null, - mainLooperHandler: MainLooperHandler? = null + mainLooperHandler: MainLooperHandler? = null, + gestureRecorderProvider: (() -> GestureRecorder)? = null ) : this(context, dateProvider, recorderProvider, recorderConfigProvider, replayCacheProvider) { this.replayCaptureStrategyProvider = replayCaptureStrategyProvider this.mainLooperHandler = mainLooperHandler ?: MainLooperHandler() + this.gestureRecorderProvider = gestureRecorderProvider } private lateinit var options: SentryOptions private var hub: IHub? = null private var recorder: Recorder? = null + private var gestureRecorder: GestureRecorder? = null private val random by lazy { SecureRandom() } + private val rootViewsSpy by lazy(NONE) { RootViewsSpy.install() } // TODO: probably not everything has to be thread-safe here internal val isEnabled = AtomicBoolean(false) @@ -81,6 +88,7 @@ public class ReplayIntegration( private var replayBreadcrumbConverter: ReplayBreadcrumbConverter = NoOpReplayBreadcrumbConverter.getInstance() private var replayCaptureStrategyProvider: ((isFullSession: Boolean) -> CaptureStrategy)? = null private var mainLooperHandler: MainLooperHandler = MainLooperHandler() + private var gestureRecorderProvider: (() -> GestureRecorder)? = null private lateinit var recorderConfig: ScreenshotRecorderConfig @@ -100,7 +108,8 @@ public class ReplayIntegration( } this.hub = hub - recorder = recorderProvider?.invoke() ?: WindowRecorder(options, this, this, mainLooperHandler) + recorder = recorderProvider?.invoke() ?: WindowRecorder(options, this, mainLooperHandler) + gestureRecorder = gestureRecorderProvider?.invoke() ?: GestureRecorder(options, this) isEnabled.set(true) try { @@ -147,6 +156,7 @@ public class ReplayIntegration( captureStrategy?.start(recorderConfig) recorder?.start(recorderConfig) + registerRootViewListeners() } override fun resume() { @@ -197,7 +207,9 @@ public class ReplayIntegration( return } + unregisterRootViewListeners() recorder?.stop() + gestureRecorder?.stop() captureStrategy?.stop() isRecording.set(false) captureStrategy?.close() @@ -252,6 +264,20 @@ public class ReplayIntegration( captureStrategy?.onTouchEvent(event) } + private fun registerRootViewListeners() { + if (recorder is OnRootViewsChangedListener) { + rootViewsSpy.listeners += (recorder as OnRootViewsChangedListener) + } + rootViewsSpy.listeners += gestureRecorder + } + + private fun unregisterRootViewListeners() { + if (recorder is OnRootViewsChangedListener) { + rootViewsSpy.listeners -= (recorder as OnRootViewsChangedListener) + } + rootViewsSpy.listeners -= gestureRecorder + } + private fun cleanupReplays(unfinishedReplayId: String = "") { // clean up old replays options.cacheDirPath?.let { cacheDir -> diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt index 40fb6ef931..9680c2a3ad 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt @@ -288,6 +288,18 @@ public data class ScreenshotRecorderConfig( val frameRate: Int, val bitRate: Int ) { + internal constructor( + scaleFactorX: Float, + scaleFactorY: Float + ) : this( + recordingWidth = 0, + recordingHeight = 0, + scaleFactorX = scaleFactorX, + scaleFactorY = scaleFactorY, + frameRate = 0, + bitRate = 0 + ) + companion object { /** * Since codec block size is 16, so we have to adjust the width and height to it, otherwise diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/WindowRecorder.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/WindowRecorder.kt index 01147e3a7f..9e846dfcf0 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/WindowRecorder.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/WindowRecorder.kt @@ -1,13 +1,8 @@ package io.sentry.android.replay import android.annotation.TargetApi -import android.view.MotionEvent import android.view.View -import android.view.Window -import io.sentry.SentryLevel.DEBUG -import io.sentry.SentryLevel.ERROR import io.sentry.SentryOptions -import io.sentry.android.replay.util.FixedWindowCallback import io.sentry.android.replay.util.MainLooperHandler import io.sentry.android.replay.util.gracefullyShutdown import io.sentry.android.replay.util.scheduleAtFixedRateSafely @@ -17,24 +12,18 @@ import java.util.concurrent.ScheduledFuture import java.util.concurrent.ThreadFactory import java.util.concurrent.TimeUnit.MILLISECONDS import java.util.concurrent.atomic.AtomicBoolean -import kotlin.LazyThreadSafetyMode.NONE @TargetApi(26) internal class WindowRecorder( private val options: SentryOptions, private val screenshotRecorderCallback: ScreenshotRecorderCallback? = null, - private val touchRecorderCallback: TouchRecorderCallback? = null, private val mainLooperHandler: MainLooperHandler -) : Recorder { +) : Recorder, OnRootViewsChangedListener { internal companion object { private const val TAG = "WindowRecorder" } - private val rootViewsSpy by lazy(NONE) { - RootViewsSpy.install() - } - private val isRecording = AtomicBoolean(false) private val rootViews = ArrayList>() private var recorder: ScreenshotRecorder? = null @@ -43,15 +32,11 @@ internal class WindowRecorder( Executors.newSingleThreadScheduledExecutor(RecorderExecutorServiceThreadFactory()) } - private val onRootViewsChangedListener = OnRootViewsChangedListener { root, added -> + override fun onRootViewsChanged(root: View, added: Boolean) { if (added) { rootViews.add(WeakReference(root)) recorder?.bind(root) - - root.startGestureTracking() } else { - root.stopGestureTracking() - recorder?.unbind(root) rootViews.removeAll { it.get() == root } @@ -68,11 +53,10 @@ internal class WindowRecorder( } recorder = ScreenshotRecorder(recorderConfig, options, mainLooperHandler, screenshotRecorderCallback) - rootViewsSpy.listeners += onRootViewsChangedListener capturingTask = capturer.scheduleAtFixedRateSafely( options, "$TAG.capture", - 0L, + 100L, // delay the first run by a bit, to allow root view listener to register 1000L / recorderConfig.frameRate, MILLISECONDS ) { @@ -88,7 +72,6 @@ internal class WindowRecorder( } override fun stop() { - rootViewsSpy.listeners -= onRootViewsChangedListener rootViews.forEach { recorder?.unbind(it.get()) } recorder?.close() rootViews.clear() @@ -103,55 +86,6 @@ internal class WindowRecorder( capturer.gracefullyShutdown(options) } - private fun View.startGestureTracking() { - val window = phoneWindow - if (window == null) { - options.logger.log(DEBUG, "Window is invalid, not tracking gestures") - return - } - - if (touchRecorderCallback == null) { - options.logger.log(DEBUG, "TouchRecorderCallback is null, not tracking gestures") - return - } - - val delegate = window.callback - window.callback = SentryReplayGestureRecorder(options, touchRecorderCallback, delegate) - } - - private fun View.stopGestureTracking() { - val window = phoneWindow - if (window == null) { - options.logger.log(DEBUG, "Window was null in stopGestureTracking") - return - } - - if (window.callback is SentryReplayGestureRecorder) { - val delegate = (window.callback as SentryReplayGestureRecorder).delegate - window.callback = delegate - } - } - - private class SentryReplayGestureRecorder( - private val options: SentryOptions, - private val touchRecorderCallback: TouchRecorderCallback?, - delegate: Window.Callback? - ) : FixedWindowCallback(delegate) { - override fun dispatchTouchEvent(event: MotionEvent?): Boolean { - if (event != null) { - val copy: MotionEvent = MotionEvent.obtainNoHistory(event) - try { - touchRecorderCallback?.onTouchEvent(copy) - } catch (e: Throwable) { - options.logger.log(ERROR, "Error dispatching touch event", e) - } finally { - copy.recycle() - } - } - return super.dispatchTouchEvent(event) - } - } - private class RecorderExecutorServiceThreadFactory : ThreadFactory { private var cnt = 0 override fun newThread(r: Runnable): Thread { @@ -161,7 +95,3 @@ internal class WindowRecorder( } } } - -public interface TouchRecorderCallback { - fun onTouchEvent(event: MotionEvent) -} diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt index d888c2e33d..97cb386109 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt @@ -23,16 +23,12 @@ import io.sentry.android.replay.ScreenshotRecorderConfig import io.sentry.android.replay.capture.CaptureStrategy.Companion.createSegment import io.sentry.android.replay.capture.CaptureStrategy.Companion.currentEventsLock import io.sentry.android.replay.capture.CaptureStrategy.ReplaySegment +import io.sentry.android.replay.gestures.ReplayGestureConverter import io.sentry.android.replay.util.PersistableLinkedList import io.sentry.android.replay.util.gracefullyShutdown import io.sentry.android.replay.util.submitSafely import io.sentry.protocol.SentryId import io.sentry.rrweb.RRWebEvent -import io.sentry.rrweb.RRWebIncrementalSnapshotEvent -import io.sentry.rrweb.RRWebInteractionEvent -import io.sentry.rrweb.RRWebInteractionEvent.InteractionType -import io.sentry.rrweb.RRWebInteractionMoveEvent -import io.sentry.rrweb.RRWebInteractionMoveEvent.Position import io.sentry.transport.ICurrentDateProvider import java.io.File import java.util.Date @@ -56,15 +52,12 @@ internal abstract class BaseCaptureStrategy( internal companion object { private const val TAG = "CaptureStrategy" - - // rrweb values - private const val TOUCH_MOVE_DEBOUNCE_THRESHOLD = 50 - private const val CAPTURE_MOVE_EVENT_THRESHOLD = 500 } private val persistingExecutor: ScheduledExecutorService by lazy { Executors.newSingleThreadScheduledExecutor(ReplayPersistingExecutorServiceThreadFactory()) } + private val gestureConverter = ReplayGestureConverter(dateProvider) protected val isTerminating = AtomicBoolean(false) protected var cache: ReplayCache? = null @@ -94,9 +87,6 @@ internal abstract class BaseCaptureStrategy( persistingExecutor, cacheProvider = { cache } ) - private val currentPositions = LinkedHashMap>(10) - private var touchMoveBaseline = 0L - private var lastCapturedMoveEvent = 0L protected val replayExecutor: ScheduledExecutorService by lazy { executor ?: Executors.newSingleThreadScheduledExecutor(ReplayExecutorServiceThreadFactory()) @@ -169,7 +159,7 @@ internal abstract class BaseCaptureStrategy( } override fun onTouchEvent(event: MotionEvent) { - val rrwebEvents = event.toRRWebIncrementalSnapshotEvent() + val rrwebEvents = gestureConverter.convert(event, recorderConfig) if (rrwebEvents != null) { synchronized(currentEventsLock) { currentEvents += rrwebEvents @@ -199,126 +189,6 @@ internal abstract class BaseCaptureStrategy( } } - private fun MotionEvent.toRRWebIncrementalSnapshotEvent(): List? { - val event = this - return when (event.actionMasked) { - MotionEvent.ACTION_MOVE -> { - // we only throttle move events as those can be overwhelming - val now = dateProvider.currentTimeMillis - if (lastCapturedMoveEvent != 0L && lastCapturedMoveEvent + TOUCH_MOVE_DEBOUNCE_THRESHOLD > now) { - return null - } - lastCapturedMoveEvent = now - - currentPositions.keys.forEach { pId -> - val pIndex = event.findPointerIndex(pId) - - if (pIndex == -1) { - // no data for this pointer - return@forEach - } - - // idk why but rrweb does it like dis - if (touchMoveBaseline == 0L) { - touchMoveBaseline = now - } - - currentPositions[pId]!! += Position().apply { - x = event.getX(pIndex) * recorderConfig.scaleFactorX - y = event.getY(pIndex) * recorderConfig.scaleFactorY - id = 0 // html node id, but we don't have it, so hardcode to 0 to align with FE - timeOffset = now - touchMoveBaseline - } - } - - val totalOffset = now - touchMoveBaseline - return if (totalOffset > CAPTURE_MOVE_EVENT_THRESHOLD) { - val moveEvents = mutableListOf() - for ((pointerId, positions) in currentPositions) { - if (positions.isNotEmpty()) { - moveEvents += RRWebInteractionMoveEvent().apply { - this.timestamp = now - this.positions = positions.map { pos -> - pos.timeOffset -= totalOffset - pos - } - this.pointerId = pointerId - } - currentPositions[pointerId]!!.clear() - } - } - touchMoveBaseline = 0L - moveEvents - } else { - null - } - } - - MotionEvent.ACTION_DOWN, - MotionEvent.ACTION_POINTER_DOWN -> { - val pId = event.getPointerId(event.actionIndex) - val pIndex = event.findPointerIndex(pId) - - if (pIndex == -1) { - // no data for this pointer - return null - } - - // new finger down - add a new pointer for tracking movement - currentPositions[pId] = ArrayList() - listOf( - RRWebInteractionEvent().apply { - timestamp = dateProvider.currentTimeMillis - x = event.getX(pIndex) * recorderConfig.scaleFactorX - y = event.getY(pIndex) * recorderConfig.scaleFactorY - id = 0 // html node id, but we don't have it, so hardcode to 0 to align with FE - pointerId = pId - interactionType = InteractionType.TouchStart - } - ) - } - MotionEvent.ACTION_UP, - MotionEvent.ACTION_POINTER_UP -> { - val pId = event.getPointerId(event.actionIndex) - val pIndex = event.findPointerIndex(pId) - - if (pIndex == -1) { - // no data for this pointer - return null - } - - // finger lift up - remove the pointer from tracking - currentPositions.remove(pId) - listOf( - RRWebInteractionEvent().apply { - timestamp = dateProvider.currentTimeMillis - x = event.getX(pIndex) * recorderConfig.scaleFactorX - y = event.getY(pIndex) * recorderConfig.scaleFactorY - id = 0 // html node id, but we don't have it, so hardcode to 0 to align with FE - pointerId = pId - interactionType = InteractionType.TouchEnd - } - ) - } - MotionEvent.ACTION_CANCEL -> { - // gesture cancelled - remove all pointers from tracking - currentPositions.clear() - listOf( - RRWebInteractionEvent().apply { - timestamp = dateProvider.currentTimeMillis - x = event.x * recorderConfig.scaleFactorX - y = event.y * recorderConfig.scaleFactorY - id = 0 // html node id, but we don't have it, so hardcode to 0 to align with FE - pointerId = 0 // the pointerId is not used for TouchCancel, so just set it to 0 - interactionType = InteractionType.TouchCancel - } - ) - } - - else -> null - } - } - private inline fun persistableAtomicNullable( initialValue: T? = null, propertyName: String, diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/gestures/GestureRecorder.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/gestures/GestureRecorder.kt new file mode 100644 index 0000000000..57302aaac1 --- /dev/null +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/gestures/GestureRecorder.kt @@ -0,0 +1,85 @@ +package io.sentry.android.replay.gestures + +import android.view.MotionEvent +import android.view.View +import android.view.Window +import io.sentry.SentryLevel.DEBUG +import io.sentry.SentryLevel.ERROR +import io.sentry.SentryOptions +import io.sentry.android.replay.OnRootViewsChangedListener +import io.sentry.android.replay.phoneWindow +import io.sentry.android.replay.util.FixedWindowCallback +import java.lang.ref.WeakReference + +class GestureRecorder( + private val options: SentryOptions, + private val touchRecorderCallback: TouchRecorderCallback +) : OnRootViewsChangedListener { + + private val rootViews = ArrayList>() + + override fun onRootViewsChanged(root: View, added: Boolean) { + if (added) { + rootViews.add(WeakReference(root)) + root.startGestureTracking() + } else { + root.stopGestureTracking() + rootViews.removeAll { it.get() == root } + } + } + + fun stop() { + rootViews.forEach { it.get()?.stopGestureTracking() } + rootViews.clear() + } + + private fun View.startGestureTracking() { + val window = phoneWindow + if (window == null) { + options.logger.log(DEBUG, "Window is invalid, not tracking gestures") + return + } + + val delegate = window.callback + if (delegate !is SentryReplayGestureRecorder) { + window.callback = SentryReplayGestureRecorder(options, touchRecorderCallback, delegate) + } + } + + private fun View.stopGestureTracking() { + val window = phoneWindow + if (window == null) { + options.logger.log(DEBUG, "Window was null in stopGestureTracking") + return + } + + if (window.callback is SentryReplayGestureRecorder) { + val delegate = (window.callback as SentryReplayGestureRecorder).delegate + window.callback = delegate + } + } + + internal class SentryReplayGestureRecorder( + private val options: SentryOptions, + private val touchRecorderCallback: TouchRecorderCallback?, + delegate: Window.Callback? + ) : FixedWindowCallback(delegate) { + override fun dispatchTouchEvent(event: MotionEvent?): Boolean { + if (event != null) { + val copy: MotionEvent = MotionEvent.obtainNoHistory(event) + try { + touchRecorderCallback?.onTouchEvent(copy) + } catch (e: Throwable) { + options.logger.log(ERROR, "Error dispatching touch event", e) + } finally { + copy.recycle() + } + } + return super.dispatchTouchEvent(event) + } + } +} + +public interface TouchRecorderCallback { + fun onTouchEvent(event: MotionEvent) +} diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/gestures/ReplayGestureConverter.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/gestures/ReplayGestureConverter.kt new file mode 100644 index 0000000000..59d6b30bce --- /dev/null +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/gestures/ReplayGestureConverter.kt @@ -0,0 +1,144 @@ +package io.sentry.android.replay.gestures + +import android.view.MotionEvent +import io.sentry.android.replay.ScreenshotRecorderConfig +import io.sentry.rrweb.RRWebIncrementalSnapshotEvent +import io.sentry.rrweb.RRWebInteractionEvent +import io.sentry.rrweb.RRWebInteractionEvent.InteractionType +import io.sentry.rrweb.RRWebInteractionMoveEvent +import io.sentry.rrweb.RRWebInteractionMoveEvent.Position +import io.sentry.transport.ICurrentDateProvider + +class ReplayGestureConverter( + private val dateProvider: ICurrentDateProvider +) { + + internal companion object { + // rrweb values + private const val TOUCH_MOVE_DEBOUNCE_THRESHOLD = 50 + private const val CAPTURE_MOVE_EVENT_THRESHOLD = 500 + } + + private val currentPositions = LinkedHashMap>(10) + private var touchMoveBaseline = 0L + private var lastCapturedMoveEvent = 0L + + fun convert(event: MotionEvent, recorderConfig: ScreenshotRecorderConfig): List? { + return when (event.actionMasked) { + MotionEvent.ACTION_MOVE -> { + // we only throttle move events as those can be overwhelming + val now = dateProvider.currentTimeMillis + if (lastCapturedMoveEvent != 0L && lastCapturedMoveEvent + TOUCH_MOVE_DEBOUNCE_THRESHOLD > now) { + return null + } + lastCapturedMoveEvent = now + + currentPositions.keys.forEach { pId -> + val pIndex = event.findPointerIndex(pId) + + if (pIndex == -1) { + // no data for this pointer + return@forEach + } + + // idk why but rrweb does it like dis + if (touchMoveBaseline == 0L) { + touchMoveBaseline = now + } + + currentPositions[pId]!! += Position().apply { + x = event.getX(pIndex) * recorderConfig.scaleFactorX + y = event.getY(pIndex) * recorderConfig.scaleFactorY + id = 0 // html node id, but we don't have it, so hardcode to 0 to align with FE + timeOffset = now - touchMoveBaseline + } + } + + val totalOffset = now - touchMoveBaseline + return if (totalOffset > CAPTURE_MOVE_EVENT_THRESHOLD) { + val moveEvents = mutableListOf() + for ((pointerId, positions) in currentPositions) { + if (positions.isNotEmpty()) { + moveEvents += RRWebInteractionMoveEvent().apply { + this.timestamp = now + this.positions = positions.map { pos -> + pos.timeOffset -= totalOffset + pos + } + this.pointerId = pointerId + } + currentPositions[pointerId]!!.clear() + } + } + touchMoveBaseline = 0L + moveEvents + } else { + null + } + } + + MotionEvent.ACTION_DOWN, + MotionEvent.ACTION_POINTER_DOWN -> { + val pId = event.getPointerId(event.actionIndex) + val pIndex = event.findPointerIndex(pId) + + if (pIndex == -1) { + // no data for this pointer + return null + } + + // new finger down - add a new pointer for tracking movement + currentPositions[pId] = ArrayList() + listOf( + RRWebInteractionEvent().apply { + timestamp = dateProvider.currentTimeMillis + x = event.getX(pIndex) * recorderConfig.scaleFactorX + y = event.getY(pIndex) * recorderConfig.scaleFactorY + id = 0 // html node id, but we don't have it, so hardcode to 0 to align with FE + pointerId = pId + interactionType = InteractionType.TouchStart + } + ) + } + MotionEvent.ACTION_UP, + MotionEvent.ACTION_POINTER_UP -> { + val pId = event.getPointerId(event.actionIndex) + val pIndex = event.findPointerIndex(pId) + + if (pIndex == -1) { + // no data for this pointer + return null + } + + // finger lift up - remove the pointer from tracking + currentPositions.remove(pId) + listOf( + RRWebInteractionEvent().apply { + timestamp = dateProvider.currentTimeMillis + x = event.getX(pIndex) * recorderConfig.scaleFactorX + y = event.getY(pIndex) * recorderConfig.scaleFactorY + id = 0 // html node id, but we don't have it, so hardcode to 0 to align with FE + pointerId = pId + interactionType = InteractionType.TouchEnd + } + ) + } + MotionEvent.ACTION_CANCEL -> { + // gesture cancelled - remove all pointers from tracking + currentPositions.clear() + listOf( + RRWebInteractionEvent().apply { + timestamp = dateProvider.currentTimeMillis + x = event.x * recorderConfig.scaleFactorX + y = event.y * recorderConfig.scaleFactorY + id = 0 // html node id, but we don't have it, so hardcode to 0 to align with FE + pointerId = 0 // the pointerId is not used for TouchCancel, so just set it to 0 + interactionType = InteractionType.TouchCancel + } + ) + } + + else -> null + } + } +} diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationTest.kt index e96375dfa6..1518b41a81 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationTest.kt @@ -27,6 +27,7 @@ import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_TIMESTAMP import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_WIDTH import io.sentry.android.replay.capture.CaptureStrategy import io.sentry.android.replay.capture.SessionCaptureStrategyTest.Fixture.Companion.VIDEO_DURATION +import io.sentry.android.replay.gestures.GestureRecorder import io.sentry.cache.PersistingScopeObserver import io.sentry.protocol.SentryException import io.sentry.protocol.SentryId @@ -100,6 +101,7 @@ class ReplayIntegrationTest { recorderProvider: (() -> Recorder)? = null, replayCaptureStrategyProvider: ((isFullSession: Boolean) -> CaptureStrategy)? = null, recorderConfigProvider: ((configChanged: Boolean) -> ScreenshotRecorderConfig)? = null, + gestureRecorderProvider: (() -> GestureRecorder)? = null, dateProvider: ICurrentDateProvider = CurrentDateProvider.getInstance() ): ReplayIntegration { options.run { @@ -112,7 +114,8 @@ class ReplayIntegrationTest { recorderProvider, recorderConfigProvider = recorderConfigProvider, replayCacheProvider = { _, _ -> replayCache }, - replayCaptureStrategyProvider = replayCaptureStrategyProvider + replayCaptureStrategyProvider = replayCaptureStrategyProvider, + gestureRecorderProvider = gestureRecorderProvider ) } } @@ -360,10 +363,16 @@ class ReplayIntegrationTest { } @Test - fun `stop calls stop for recorder and strategy and sets recording to false`() { + fun `stop calls stop for recorders and strategy and sets recording to false`() { val captureStrategy = mock() val recorder = mock() - val replay = fixture.getSut(context, recorderProvider = { recorder }, replayCaptureStrategyProvider = { captureStrategy }) + val gestureRecorder = mock() + val replay = fixture.getSut( + context, + recorderProvider = { recorder }, + replayCaptureStrategyProvider = { captureStrategy }, + gestureRecorderProvider = { gestureRecorder } + ) replay.register(fixture.hub, fixture.options) replay.start() @@ -371,6 +380,7 @@ class ReplayIntegrationTest { verify(captureStrategy).stop() verify(recorder).stop() + verify(gestureRecorder).stop() assertFalse(replay.isRecording) } diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/gestures/GestureRecorderTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/gestures/GestureRecorderTest.kt new file mode 100644 index 0000000000..bb2de2b7c8 --- /dev/null +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/gestures/GestureRecorderTest.kt @@ -0,0 +1,131 @@ +package io.sentry.android.replay.gestures + +import android.R +import android.app.Activity +import android.os.Bundle +import android.view.MotionEvent +import android.view.View +import android.widget.LinearLayout +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.sentry.SentryOptions +import io.sentry.android.core.internal.gestures.NoOpWindowCallback +import io.sentry.android.replay.gestures.GestureRecorder.SentryReplayGestureRecorder +import io.sentry.android.replay.phoneWindow +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.annotation.Config +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +@RunWith(AndroidJUnit4::class) +@Config(sdk = [30]) +class GestureRecorderTest { + internal class Fixture { + + val options = SentryOptions() + + fun getSut( + touchRecorderCallback: TouchRecorderCallback = NoOpTouchRecorderCallback() + ): GestureRecorder { + return GestureRecorder(options, touchRecorderCallback) + } + } + + private val fixture = Fixture() + private class NoOpTouchRecorderCallback : TouchRecorderCallback { + override fun onTouchEvent(event: MotionEvent) = Unit + } + + @Test + fun `when new window added and window callback is already wrapped, does not wrap it again`() { + val activity = Robolectric.buildActivity(TestActivity::class.java).setup().get() + val gestureRecorder = fixture.getSut() + + activity.root.phoneWindow?.callback = SentryReplayGestureRecorder(fixture.options, null, null) + gestureRecorder.onRootViewsChanged(activity.root, true) + + assertFalse((activity.root.phoneWindow?.callback as SentryReplayGestureRecorder).delegate is SentryReplayGestureRecorder) + } + + @Test + fun `when new window added tracks touch events`() { + var called = false + val activity = Robolectric.buildActivity(TestActivity::class.java).setup().get() + val motionEvent = MotionEvent.obtain(0, 0, MotionEvent.ACTION_DOWN, 0f, 0f, 0) + val gestureRecorder = fixture.getSut( + touchRecorderCallback = object : TouchRecorderCallback { + override fun onTouchEvent(event: MotionEvent) { + assertEquals(MotionEvent.ACTION_DOWN, event.action) + called = true + } + } + ) + + gestureRecorder.onRootViewsChanged(activity.root, true) + + activity.root.phoneWindow?.callback?.dispatchTouchEvent(motionEvent) + assertTrue(called) + } + + @Test + fun `when window removed and window is not sentry recorder does nothing`() { + val activity = Robolectric.buildActivity(TestActivity::class.java).setup().get() + val gestureRecorder = fixture.getSut() + + activity.root.phoneWindow?.callback = NoOpWindowCallback() + gestureRecorder.onRootViewsChanged(activity.root, false) + + assertTrue(activity.root.phoneWindow?.callback is NoOpWindowCallback) + } + + @Test + fun `when window removed stops tracking touch events`() { + val activity = Robolectric.buildActivity(TestActivity::class.java).setup().get() + val gestureRecorder = fixture.getSut() + + gestureRecorder.onRootViewsChanged(activity.root, true) + gestureRecorder.onRootViewsChanged(activity.root, false) + + assertFalse(activity.root.phoneWindow?.callback is SentryReplayGestureRecorder) + } + + @Test + fun `when stopped stops tracking all windows`() { + val activity1 = Robolectric.buildActivity(TestActivity::class.java).setup().get() + val activity2 = Robolectric.buildActivity(TestActivity2::class.java).setup().get() + val gestureRecorder = fixture.getSut() + + gestureRecorder.onRootViewsChanged(activity1.root, true) + gestureRecorder.onRootViewsChanged(activity2.root, true) + gestureRecorder.stop() + + assertFalse(activity1.root.phoneWindow?.callback is SentryReplayGestureRecorder) + assertFalse(activity2.root.phoneWindow?.callback is SentryReplayGestureRecorder) + } +} + +private class TestActivity : Activity() { + lateinit var root: View + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setTheme(R.style.Theme_Holo_Light) + root = LinearLayout(this) + setContentView(root) + actionBar!!.setIcon(R.drawable.ic_lock_power_off) + } +} + +private class TestActivity2 : Activity() { + lateinit var root: View + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setTheme(R.style.Theme_Holo_Light) + root = LinearLayout(this) + setContentView(root) + actionBar!!.setIcon(R.drawable.ic_lock_power_off) + } +} diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/gestures/ReplayGestureConverterTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/gestures/ReplayGestureConverterTest.kt new file mode 100644 index 0000000000..00ae93af4a --- /dev/null +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/gestures/ReplayGestureConverterTest.kt @@ -0,0 +1,240 @@ +package io.sentry.android.replay.gestures + +import android.view.MotionEvent +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.sentry.android.replay.ScreenshotRecorderConfig +import io.sentry.rrweb.RRWebInteractionEvent +import io.sentry.rrweb.RRWebInteractionMoveEvent +import io.sentry.transport.ICurrentDateProvider +import org.junit.runner.RunWith +import org.robolectric.annotation.Config +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +@RunWith(AndroidJUnit4::class) +@Config(sdk = [30]) +class ReplayGestureConverterTest { + internal class Fixture { + var now: Long = 1000L + + fun getSut( + dateProvider: ICurrentDateProvider = ICurrentDateProvider { now } + ): ReplayGestureConverter { + return ReplayGestureConverter(dateProvider) + } + } + + private val fixture = Fixture() + + @Test + fun `convert ACTION_DOWN event`() { + val sut = fixture.getSut() + val recorderConfig = ScreenshotRecorderConfig(scaleFactorX = 1f, scaleFactorY = 1f) + val event = MotionEvent.obtain(0, 0, MotionEvent.ACTION_DOWN, 100f, 200f, 0) + + val result = sut.convert(event, recorderConfig) + + assertNotNull(result) + assertEquals(1, result.size) + assertTrue(result[0] is RRWebInteractionEvent) + with(result[0] as RRWebInteractionEvent) { + assertEquals(1000L, timestamp) + assertEquals(100f, x) + assertEquals(200f, y) + assertEquals(0, id) + assertEquals(0, pointerId) + assertEquals(RRWebInteractionEvent.InteractionType.TouchStart, interactionType) + } + + event.recycle() + } + + @Test + fun `convert ACTION_MOVE event with debounce`() { + val sut = fixture.getSut() + val recorderConfig = ScreenshotRecorderConfig(scaleFactorX = 1f, scaleFactorY = 1f) + val event = MotionEvent.obtain(0, 0, MotionEvent.ACTION_MOVE, 100f, 200f, 0) + + // First call should pass + var result = sut.convert(event, recorderConfig) + assertNotNull(result) + + // Second call within debounce threshold should be null + fixture.now += 40 // Increase time by 40ms + result = sut.convert(event, recorderConfig) + assertNull(result) + + event.recycle() + } + + @Test + fun `convert ACTION_MOVE event with capture threshold`() { + val sut = fixture.getSut() + val recorderConfig = ScreenshotRecorderConfig(scaleFactorX = 1f, scaleFactorY = 1f) + val downEvent = MotionEvent.obtain(0, 0, MotionEvent.ACTION_DOWN, 100f, 200f, 0) + val moveEvent = MotionEvent.obtain(0, 0, MotionEvent.ACTION_MOVE, 110f, 210f, 0) + + // Add a pointer to currentPositions + sut.convert(downEvent, recorderConfig) + + // First call should not trigger capture + var result = sut.convert(moveEvent, recorderConfig) + assertNull(result) + + // Second call should trigger capture + fixture.now += 600 // Increase time by 600ms + result = sut.convert(moveEvent, recorderConfig) + assertNotNull(result) + with(result[0] as RRWebInteractionMoveEvent) { + assertEquals(1600L, timestamp) + assertEquals(2, positions!!.size) + assertEquals(110f, positions!![0].x) + assertEquals(210f, positions!![0].y) + assertEquals(0, positions!![0].id) + assertEquals(0, pointerId) + } + + downEvent.recycle() + moveEvent.recycle() + } + + @Test + fun `convert ACTION_UP event`() { + val sut = fixture.getSut() + val recorderConfig = ScreenshotRecorderConfig(scaleFactorX = 1f, scaleFactorY = 1f) + val event = MotionEvent.obtain(0, 0, MotionEvent.ACTION_UP, 100f, 200f, 0) + + val result = sut.convert(event, recorderConfig) + + assertNotNull(result) + assertEquals(1, result.size) + assertTrue(result[0] is RRWebInteractionEvent) + with(result[0] as RRWebInteractionEvent) { + assertEquals(1000L, timestamp) + assertEquals(100f, x) + assertEquals(200f, y) + assertEquals(0, id) + assertEquals(0, pointerId) + assertEquals(RRWebInteractionEvent.InteractionType.TouchEnd, interactionType) + } + + event.recycle() + } + + @Test + fun `convert ACTION_CANCEL event`() { + val sut = fixture.getSut() + val recorderConfig = ScreenshotRecorderConfig(scaleFactorX = 1f, scaleFactorY = 1f) + val event = MotionEvent.obtain(0, 0, MotionEvent.ACTION_CANCEL, 100f, 200f, 0) + + val result = sut.convert(event, recorderConfig) + + assertNotNull(result) + assertEquals(1, result.size) + assertTrue(result[0] is RRWebInteractionEvent) + with(result[0] as RRWebInteractionEvent) { + assertEquals(1000L, timestamp) + assertEquals(100f, x) + assertEquals(200f, y) + assertEquals(0, id) + assertEquals(0, pointerId) + assertEquals(RRWebInteractionEvent.InteractionType.TouchCancel, interactionType) + } + + event.recycle() + } + + @Test + fun `convert event with different scale factors`() { + val sut = fixture.getSut() + val customRecorderConfig = ScreenshotRecorderConfig(scaleFactorX = 0.5f, scaleFactorY = 1.5f) + val event = MotionEvent.obtain(0, 0, MotionEvent.ACTION_DOWN, 100f, 200f, 0) + + val result = sut.convert(event, customRecorderConfig) + + assertNotNull(result) + assertEquals(1, result.size) + assertTrue(result[0] is RRWebInteractionEvent) + with(result[0] as RRWebInteractionEvent) { + assertEquals(1000L, timestamp) + assertEquals(50f, x) // 100 * 0.5 + assertEquals(300f, y) // 200 * 1.5 + assertEquals(0, id) + assertEquals(0, pointerId) + assertEquals(RRWebInteractionEvent.InteractionType.TouchStart, interactionType) + } + + event.recycle() + } + + @Test + fun `convert multi-pointer events`() { + val sut = fixture.getSut() + val recorderConfig = ScreenshotRecorderConfig(scaleFactorX = 1f, scaleFactorY = 1f) + + // Simulate first finger down + var event = MotionEvent.obtain(0, 0, MotionEvent.ACTION_DOWN, 100f, 100f, 0) + var result = sut.convert(event, recorderConfig) + assertNotNull(result) + assertTrue(result[0] is RRWebInteractionEvent) + assertEquals(RRWebInteractionEvent.InteractionType.TouchStart, (result[0] as RRWebInteractionEvent).interactionType) + event.recycle() + + // Simulate second finger down + val properties = MotionEvent.PointerProperties() + properties.id = 1 + properties.toolType = MotionEvent.TOOL_TYPE_FINGER + val pointerProperties = arrayOf(MotionEvent.PointerProperties(), properties) + val pointerCoords = arrayOf( + MotionEvent.PointerCoords().apply { x = 100f; y = 100f }, + MotionEvent.PointerCoords().apply { x = 200f; y = 200f } + ) + event = MotionEvent.obtain(0, 1, MotionEvent.ACTION_POINTER_DOWN or (1 shl MotionEvent.ACTION_POINTER_INDEX_SHIFT), 2, pointerProperties, pointerCoords, 0, 0, 1f, 1f, 0, 0, 0, 0) + fixture.now += 100 // Increase time by 100ms + result = sut.convert(event, recorderConfig) + assertNotNull(result) + assertTrue(result[0] is RRWebInteractionEvent) + assertEquals(RRWebInteractionEvent.InteractionType.TouchStart, (result[0] as RRWebInteractionEvent).interactionType) + assertEquals(1, (result[0] as RRWebInteractionEvent).pointerId) + event.recycle() + + // Simulate move event + pointerCoords[0].x = 90f + pointerCoords[0].y = 90f + pointerCoords[1].x = 210f + pointerCoords[1].y = 210f + event = MotionEvent.obtain(0, 2, MotionEvent.ACTION_MOVE, 2, pointerProperties, pointerCoords, 0, 0, 1f, 1f, 0, 0, 0, 0) + // First call should not trigger capture + result = sut.convert(event, recorderConfig) + assertNull(result) + + fixture.now += 600 // Increase time by 600ms to trigger move capture + result = sut.convert(event, recorderConfig) + assertNotNull(result) + assertTrue((result[0] as RRWebInteractionMoveEvent).positions!!.size == 2) + event.recycle() + + // Simulate second finger up + event = MotionEvent.obtain(0, 3, MotionEvent.ACTION_POINTER_UP or (1 shl MotionEvent.ACTION_POINTER_INDEX_SHIFT), 2, pointerProperties, pointerCoords, 0, 0, 1f, 1f, 0, 0, 0, 0) + fixture.now += 100 // Increase time by 100ms + result = sut.convert(event, recorderConfig) + assertNotNull(result) + assertTrue(result[0] is RRWebInteractionEvent) + assertEquals(RRWebInteractionEvent.InteractionType.TouchEnd, (result[0] as RRWebInteractionEvent).interactionType) + assertEquals(1, (result[0] as RRWebInteractionEvent).pointerId) + event.recycle() + + // Simulate first finger up + event = MotionEvent.obtain(0, 4, MotionEvent.ACTION_UP, 90f, 90f, 0) + fixture.now += 100 // Increase time by 100ms + result = sut.convert(event, recorderConfig) + assertNotNull(result) + assertTrue(result[0] is RRWebInteractionEvent) + assertEquals(RRWebInteractionEvent.InteractionType.TouchEnd, (result[0] as RRWebInteractionEvent).interactionType) + assertEquals(0, (result[0] as RRWebInteractionEvent).pointerId) + event.recycle() + } +} From 32eed6acd65803e4b13d659006a32fd5c44b79b4 Mon Sep 17 00:00:00 2001 From: Stefano Date: Mon, 12 Aug 2024 18:39:50 +0200 Subject: [PATCH 24/92] Fix app start spans missing from Pixel devices (#3634) * added post on main thread to post check on main thread --- CHANGELOG.md | 1 + .../sentry/android/core/performance/AppStartMetrics.java | 8 ++++++++ 2 files changed, 9 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 09ecbba6f4..7b500b004f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ ### Fixes +- Fix app start spans missing from Pixel devices ([#3634](https://github.com/getsentry/sentry-java/pull/3634)) - Avoid ArrayIndexOutOfBoundsException on Android cpu data collection ([#3598](https://github.com/getsentry/sentry-java/pull/3598)) - Fix lazy select queries instrumentation ([#3604](https://github.com/getsentry/sentry-java/pull/3604)) - Session Replay: buffer mode improvements ([#3622](https://github.com/getsentry/sentry-java/pull/3622)) diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/performance/AppStartMetrics.java b/sentry-android-core/src/main/java/io/sentry/android/core/performance/AppStartMetrics.java index a220f5eb4a..90c49ed07a 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/performance/AppStartMetrics.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/performance/AppStartMetrics.java @@ -241,6 +241,14 @@ public void registerApplicationForegroundCheck(final @NotNull Application applic isCallbackRegistered = true; appLaunchedInForeground = appLaunchedInForeground || ContextUtils.isForegroundImportance(); application.registerActivityLifecycleCallbacks(instance); + // We post on the main thread a task to post a check on the main thread. On Pixel devices + // (possibly others) the first task posted on the main thread is called before the + // Activity.onCreate callback. This is a workaround for that, so that the Activity.onCreate + // callback is called before the application one. + new Handler(Looper.getMainLooper()).post(() -> checkCreateTimeOnMain(application)); + } + + private void checkCreateTimeOnMain(final @NotNull Application application) { new Handler(Looper.getMainLooper()) .post( () -> { From f6e97b16af433985fe3ace28ffa383740322fe9f Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Mon, 12 Aug 2024 19:36:44 +0200 Subject: [PATCH 25/92] [SR] Fix Session Replay crashes (#3628) --- CHANGELOG.md | 5 ++++ .../android/replay/ScreenshotRecorder.kt | 3 +- .../replay/capture/BaseCaptureStrategy.kt | 4 +-- .../replay/capture/SessionCaptureStrategy.kt | 1 - .../io/sentry/android/replay/util/Views.kt | 21 ++++++++++++-- .../replay/video/SimpleVideoEncoder.kt | 29 +++++++++++++++---- .../replay/viewhierarchy/ViewHierarchyNode.kt | 3 +- .../capture/BufferCaptureStrategyTest.kt | 17 ++++++++++- .../capture/SessionCaptureStrategyTest.kt | 17 ++++++++++- 9 files changed, 86 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b500b004f..f5576ba86d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,11 @@ - Align next segment timestamp with the end of the buffered segment when converting from buffer mode to session mode - Persist `buffer` replay type for the entire replay when converting from buffer mode to session mode - Properly store screen names for `buffer` mode +- Session Replay: fix various crashes and issues ([#3628](https://github.com/getsentry/sentry-java/pull/3628)) + - Fix video not being encoded on Pixel devices + - Fix SIGABRT native crashes on Xiaomi devices when encoding a video + - Fix `RejectedExecutionException` when redacting a screenshot + - Fix `FileNotFoundException` when persisting segment values ### Chores diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt index 9680c2a3ad..5ec142b3e5 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt @@ -26,6 +26,7 @@ import io.sentry.SentryReplayOptions import io.sentry.android.replay.util.MainLooperHandler import io.sentry.android.replay.util.getVisibleRects import io.sentry.android.replay.util.gracefullyShutdown +import io.sentry.android.replay.util.submitSafely import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode.ImageViewHierarchyNode import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode.TextViewHierarchyNode @@ -122,7 +123,7 @@ internal class ScreenshotRecorder( val viewHierarchy = ViewHierarchyNode.fromView(root, null, 0, options) root.traverse(viewHierarchy) - recorder.submit { + recorder.submitSafely(options, "screenshot_recorder.redact") { val canvas = Canvas(bitmap) canvas.setMatrix(prescaledMatrix) viewHierarchy.traverse { node -> diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt index 97cb386109..fcd2d11293 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt @@ -100,10 +100,10 @@ internal abstract class BaseCaptureStrategy( ) { cache = replayCacheProvider?.invoke(replayId, recorderConfig) ?: ReplayCache(options, replayId, recorderConfig) + this.currentReplayId = replayId + this.currentSegment = segmentId this.replayType = replayType ?: (if (this is SessionCaptureStrategy) SESSION else BUFFER) this.recorderConfig = recorderConfig - this.currentSegment = segmentId - this.currentReplayId = replayId segmentTimestamp = DateUtils.getCurrentDateTime() replayStartTimestamp.set(dateProvider.currentTimeMillis) diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/SessionCaptureStrategy.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/SessionCaptureStrategy.kt index 61a98b6914..7b416d18b7 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/SessionCaptureStrategy.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/SessionCaptureStrategy.kt @@ -124,7 +124,6 @@ internal class SessionCaptureStrategy( } override fun onConfigurationChanged(recorderConfig: ScreenshotRecorderConfig) { - val currentSegmentTimestamp = segmentTimestamp ?: return createCurrentSegment("onConfigurationChanged") { segment -> if (segment is ReplaySegment.Created) { segment.capture(hub) diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Views.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Views.kt index 58accf0b77..a44508eac6 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Views.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Views.kt @@ -14,6 +14,8 @@ import android.os.Build.VERSION import android.os.Build.VERSION_CODES import android.text.Layout import android.view.View +import android.widget.TextView +import java.lang.NullPointerException /** * Adapted copy of AccessibilityNodeInfo from https://cs.android.com/android/platform/superproject/+/master:frameworks/base/core/java/android/view/View.java;l=10718 @@ -26,7 +28,7 @@ internal fun View.isVisibleToUser(): Pair { } // An invisible predecessor or one with alpha zero means // that this view is not visible to the user. - var current: Any = this + var current: Any? = this while (current is View) { val view = current val transitionAlpha = if (VERSION.SDK_INT >= VERSION_CODES.Q) view.transitionAlpha else 1f @@ -53,7 +55,10 @@ internal fun Drawable?.isRedactable(): Boolean { // TODO: otherwise maybe check for the bitmap size and don't redact those that take a lot of height (e.g. a background of a whatsapp chat) return when (this) { is InsetDrawable, is ColorDrawable, is VectorDrawable, is GradientDrawable -> false - is BitmapDrawable -> !bitmap.isRecycled && bitmap.height > 10 && bitmap.width > 10 + is BitmapDrawable -> { + val bmp = bitmap ?: return false + return !bmp.isRecycled && bmp.height > 10 && bmp.width > 10 + } else -> true } } @@ -84,3 +89,15 @@ internal fun Layout?.getVisibleRects(globalRect: Rect, paddingLeft: Int, padding } return rects } + +/** + * [TextView.getVerticalOffset] which is used by [TextView.getTotalPaddingTop] may throw an NPE on + * some devices (Redmi), so we try-catch it specifically for an NPE and then fallback to + * [TextView.getExtendedPaddingTop] + */ +internal val TextView.totalPaddingTopSafe: Int + get() = try { + totalPaddingTop + } catch (e: NullPointerException) { + extendedPaddingTop + } diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleVideoEncoder.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleVideoEncoder.kt index fd770131d8..baf521a2e6 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleVideoEncoder.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleVideoEncoder.kt @@ -33,7 +33,9 @@ import android.annotation.TargetApi import android.graphics.Bitmap import android.media.MediaCodec import android.media.MediaCodecInfo +import android.media.MediaCodecList import android.media.MediaFormat +import android.os.Build import android.view.Surface import io.sentry.SentryLevel.DEBUG import io.sentry.SentryOptions @@ -50,8 +52,22 @@ internal class SimpleVideoEncoder( val onClose: (() -> Unit)? = null ) { + private val hasExynosCodec: Boolean by lazy(NONE) { + // MediaCodecList ctor will initialize an internal in-memory static cache of codecs, so this + // call is only expensive the first time + MediaCodecList(MediaCodecList.REGULAR_CODECS) + .codecInfos + .any { it.name.contains("c2.exynos") } + } + internal val mediaCodec: MediaCodec = run { - val codec = MediaCodec.createEncoderByType(muxerConfig.mimeType) + // c2.exynos.h264.encoder seems to have problems encoding the video (Pixel and Samsung devices) + // so we use the default encoder instead + val codec = if (hasExynosCodec) { + MediaCodec.createByCodecName("c2.android.avc.encoder") + } else { + MediaCodec.createEncoderByType(muxerConfig.mimeType) + } codec } @@ -139,10 +155,13 @@ internal class SimpleVideoEncoder( } fun encode(image: Bitmap) { - // NOTE do not use `lockCanvas` like what is done in bitmap2video - // This is because https://developer.android.com/reference/android/media/MediaCodec#createInputSurface() - // says that, "Surface.lockCanvas(android.graphics.Rect) may fail or produce unexpected results." - val canvas = surface?.lockHardwareCanvas() + // it seems that Xiaomi devices have problems with hardware canvas, so we have to use + // lockCanvas instead https://stackoverflow.com/a/73520742 + val canvas = if (Build.MANUFACTURER.contains("xiaomi", ignoreCase = true)) { + surface?.lockCanvas(null) + } else { + surface?.lockHardwareCanvas() + } canvas?.drawBitmap(image, 0f, 0f, null) surface?.unlockCanvasAndPost(canvas) drainCodec(false) diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ViewHierarchyNode.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ViewHierarchyNode.kt index 1a94b295f7..145cefff3d 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ViewHierarchyNode.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ViewHierarchyNode.kt @@ -9,6 +9,7 @@ import android.widget.TextView import io.sentry.SentryOptions import io.sentry.android.replay.util.isRedactable import io.sentry.android.replay.util.isVisibleToUser +import io.sentry.android.replay.util.totalPaddingTopSafe @TargetApi(26) sealed class ViewHierarchyNode( @@ -245,7 +246,7 @@ sealed class ViewHierarchyNode( layout = view.layout, dominantColor = view.currentTextColor.toOpaque(), paddingLeft = view.totalPaddingLeft, - paddingTop = view.totalPaddingTop, + paddingTop = view.totalPaddingTopSafe, x = view.x, y = view.y, width = view.width, diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/BufferCaptureStrategyTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/BufferCaptureStrategyTest.kt index bbad02444d..1cd266ecb2 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/BufferCaptureStrategyTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/BufferCaptureStrategyTest.kt @@ -64,7 +64,7 @@ class BufferCaptureStrategyTest { (it.arguments[0] as ScopeCallback).run(scope) }.whenever(it).configureScope(any()) } - var persistedSegment = mutableMapOf() + var persistedSegment = LinkedHashMap() val replayCache = mock { on { frames }.thenReturn(mutableListOf(ReplayFrame(File("1720693523997.jpg"), 1720693523997))) on { persistSegmentValues(any(), anyOrNull()) }.then { @@ -293,4 +293,19 @@ class BufferCaptureStrategyTest { verify(fixture.hub).captureReplay(any(), any()) } + + @Test + fun `replayId should be set and serialized first`() { + val strategy = fixture.getSut() + val replayId = SentryId() + + strategy.start(fixture.recorderConfig, 0, replayId) + + assertEquals( + replayId.toString(), + fixture.persistedSegment.values.first(), + "The replayId must be set first, so when we clean up stale replays" + + "the current replay cache folder is not being deleted." + ) + } } diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/SessionCaptureStrategyTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/SessionCaptureStrategyTest.kt index ac593f6c27..12eb10c3f4 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/SessionCaptureStrategyTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/SessionCaptureStrategyTest.kt @@ -70,7 +70,7 @@ class SessionCaptureStrategyTest { (it.arguments[0] as ScopeCallback).run(scope) }.whenever(it).configureScope(any()) } - var persistedSegment = mutableMapOf() + var persistedSegment = LinkedHashMap() val replayCache = mock { on { frames }.thenReturn(mutableListOf(ReplayFrame(File("1720693523997.jpg"), 1720693523997))) on { persistSegmentValues(any(), anyOrNull()) }.then { @@ -352,4 +352,19 @@ class SessionCaptureStrategyTest { } ) } + + @Test + fun `replayId should be set and serialized first`() { + val strategy = fixture.getSut() + val replayId = SentryId() + + strategy.start(fixture.recorderConfig, 0, replayId) + + assertEquals( + replayId.toString(), + fixture.persistedSegment.values.first(), + "The replayId must be set first, so when we clean up stale replays" + + "the current replay cache folder is not being deleted." + ) + } } From 65295e4b3aed5df89d9b5337bc3e470176f93a5b Mon Sep 17 00:00:00 2001 From: getsentry-bot Date: Mon, 12 Aug 2024 21:35:45 +0000 Subject: [PATCH 26/92] release: 7.14.0 --- CHANGELOG.md | 2 +- gradle.properties | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f5576ba86d..d26f36b970 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## Unreleased +## 7.14.0 ### Features diff --git a/gradle.properties b/gradle.properties index 1077713f7c..514c0500b4 100644 --- a/gradle.properties +++ b/gradle.properties @@ -10,7 +10,7 @@ android.useAndroidX=true android.defaults.buildfeatures.buildconfig=true # Release information -versionName=7.13.0 +versionName=7.14.0 # Override the SDK name on native crashes on Android sentryAndroidSdkName=sentry.native.android From a22aea031f5b9d85fbc5aa9d52430fee81ea265d Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Thu, 29 Aug 2024 16:06:44 +0200 Subject: [PATCH 27/92] Verify sentry-android-replay for Google Play SDK Console (#3651) * Add verification file for Play Console * Update Changelog * Update Changelog --- .../io/sentry/sentry-android-replay/verification.properties | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 sentry-android-replay/src/main/resources/META-INF/io/sentry/sentry-android-replay/verification.properties diff --git a/sentry-android-replay/src/main/resources/META-INF/io/sentry/sentry-android-replay/verification.properties b/sentry-android-replay/src/main/resources/META-INF/io/sentry/sentry-android-replay/verification.properties new file mode 100644 index 0000000000..5e20f67b37 --- /dev/null +++ b/sentry-android-replay/src/main/resources/META-INF/io/sentry/sentry-android-replay/verification.properties @@ -0,0 +1,3 @@ +#This is the verification token for the io.sentry:sentry-android-replay SDK. +#Tue Aug 20 03:48:30 PDT 2024 +token=MNMM3TDLWFC5DOCIOFYQJO7JWI From 014dbeffbf5952e3ec7863c2493870fc15dc965f Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Tue, 3 Sep 2024 12:31:00 +0200 Subject: [PATCH 28/92] [SR] Rename errorSampleRate to onErrorSampleRate (#3637) * Rename errorSampleRate to onErrorSampleRate * pr id --- CHANGELOG.md | 7 +++++++ .../android/core/ManifestMetadataReader.java | 10 +++++----- .../core/ManifestMetadataReaderTest.kt | 14 ++++++------- .../sentry/android/core/SentryAndroidTest.kt | 2 +- .../android/replay/ReplayIntegration.kt | 2 +- .../replay/capture/BufferCaptureStrategy.kt | 4 ++-- .../replay/AnrWithReplayIntegrationTest.kt | 2 +- .../android/replay/ReplayIntegrationTest.kt | 6 +++--- .../sentry/android/replay/ReplaySmokeTest.kt | 2 +- .../capture/BufferCaptureStrategyTest.kt | 6 +++--- sentry/api/sentry.api | 4 ++-- sentry/src/main/java/io/sentry/Sentry.java | 2 +- .../java/io/sentry/SentryReplayOptions.java | 20 +++++++++---------- sentry/src/test/java/io/sentry/SentryTest.kt | 2 +- 14 files changed, 45 insertions(+), 38 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d26f36b970..d922586198 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## Unreleased + +*Breaking changes*: + +- `options.experimental.sessionReplay.errorSampleRate` was renamed to `options.experimental.sessionReplay.onErrorSampleRate` ([#3637](https://github.com/getsentry/sentry-java/pull/3637)) +- Manifest option `io.sentry.session-replay.error-sample-rate` was renamed to `io.sentry.session-replay.on-error-sample-rate` ([#3637](https://github.com/getsentry/sentry-java/pull/3637)) + ## 7.14.0 ### Features diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java b/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java index e1f227e90c..846ed78f3c 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java @@ -106,7 +106,7 @@ final class ManifestMetadataReader { static final String REPLAYS_SESSION_SAMPLE_RATE = "io.sentry.session-replay.session-sample-rate"; - static final String REPLAYS_ERROR_SAMPLE_RATE = "io.sentry.session-replay.error-sample-rate"; + static final String REPLAYS_ERROR_SAMPLE_RATE = "io.sentry.session-replay.on-error-sample-rate"; static final String REPLAYS_REDACT_ALL_TEXT = "io.sentry.session-replay.redact-all-text"; @@ -399,10 +399,10 @@ static void applyMetadata( } } - if (options.getExperimental().getSessionReplay().getErrorSampleRate() == null) { - final Double errorSampleRate = readDouble(metadata, logger, REPLAYS_ERROR_SAMPLE_RATE); - if (errorSampleRate != -1) { - options.getExperimental().getSessionReplay().setErrorSampleRate(errorSampleRate); + if (options.getExperimental().getSessionReplay().getOnErrorSampleRate() == null) { + final Double onErrorSampleRate = readDouble(metadata, logger, REPLAYS_ERROR_SAMPLE_RATE); + if (onErrorSampleRate != -1) { + options.getExperimental().getSessionReplay().setOnErrorSampleRate(onErrorSampleRate); } } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt index 162b1fde71..615caf5550 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt @@ -1422,7 +1422,7 @@ class ManifestMetadataReaderTest { } @Test - fun `applyMetadata reads replays errorSampleRate from metadata`() { + fun `applyMetadata reads replays onErrorSampleRate from metadata`() { // Arrange val expectedSampleRate = 0.99f @@ -1433,14 +1433,14 @@ class ManifestMetadataReaderTest { ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) // Assert - assertEquals(expectedSampleRate.toDouble(), fixture.options.experimental.sessionReplay.errorSampleRate) + assertEquals(expectedSampleRate.toDouble(), fixture.options.experimental.sessionReplay.onErrorSampleRate) } @Test - fun `applyMetadata does not override replays errorSampleRate from options`() { + fun `applyMetadata does not override replays onErrorSampleRate from options`() { // Arrange val expectedSampleRate = 0.99f - fixture.options.experimental.sessionReplay.errorSampleRate = expectedSampleRate.toDouble() + fixture.options.experimental.sessionReplay.onErrorSampleRate = expectedSampleRate.toDouble() val bundle = bundleOf(ManifestMetadataReader.REPLAYS_ERROR_SAMPLE_RATE to 0.1f) val context = fixture.getContext(metaData = bundle) @@ -1448,11 +1448,11 @@ class ManifestMetadataReaderTest { ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) // Assert - assertEquals(expectedSampleRate.toDouble(), fixture.options.experimental.sessionReplay.errorSampleRate) + assertEquals(expectedSampleRate.toDouble(), fixture.options.experimental.sessionReplay.onErrorSampleRate) } @Test - fun `applyMetadata without specifying replays errorSampleRate, stays null`() { + fun `applyMetadata without specifying replays onErrorSampleRate, stays null`() { // Arrange val context = fixture.getContext() @@ -1460,7 +1460,7 @@ class ManifestMetadataReaderTest { ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) // Assert - assertNull(fixture.options.experimental.sessionReplay.errorSampleRate) + assertNull(fixture.options.experimental.sessionReplay.onErrorSampleRate) } @Test diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidTest.kt index aa721bdb41..18b8407a5e 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidTest.kt @@ -369,7 +369,7 @@ class SentryAndroidTest { options.release = "prod" options.dsn = "https://key@sentry.io/123" options.isEnableAutoSessionTracking = true - options.experimental.sessionReplay.errorSampleRate = 1.0 + options.experimental.sessionReplay.onErrorSampleRate = 1.0 optionsConfig(options) } diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt index 996f7d2ba4..e261411815 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt @@ -143,7 +143,7 @@ public class ReplayIntegration( val isFullSession = random.sample(options.experimental.sessionReplay.sessionSampleRate) if (!isFullSession && !options.experimental.sessionReplay.isSessionReplayForErrorsEnabled) { - options.logger.log(INFO, "Session replay is not started, full session was not sampled and errorSampleRate is not specified") + options.logger.log(INFO, "Session replay is not started, full session was not sampled and onErrorSampleRate is not specified") return } diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BufferCaptureStrategy.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BufferCaptureStrategy.kt index 9acbbe6c11..247888f47d 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BufferCaptureStrategy.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BufferCaptureStrategy.kt @@ -63,10 +63,10 @@ internal class BufferCaptureStrategy( isTerminating: Boolean, onSegmentSent: (Date) -> Unit ) { - val sampled = random.sample(options.experimental.sessionReplay.errorSampleRate) + val sampled = random.sample(options.experimental.sessionReplay.onErrorSampleRate) if (!sampled) { - options.logger.log(INFO, "Replay wasn't sampled by errorSampleRate, not capturing for event") + options.logger.log(INFO, "Replay wasn't sampled by onErrorSampleRate, not capturing for event") return } diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/AnrWithReplayIntegrationTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/AnrWithReplayIntegrationTest.kt index 262225d77c..a050bd885f 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/AnrWithReplayIntegrationTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/AnrWithReplayIntegrationTest.kt @@ -151,7 +151,7 @@ class AnrWithReplayIntegrationTest { it.cacheDirPath = cacheDir it.isDebug = true it.setLogger(SystemOutLogger()) - it.experimental.sessionReplay.errorSampleRate = 1.0 + it.experimental.sessionReplay.onErrorSampleRate = 1.0 // beforeSend is called after event processors are applied, so we can assert here // against the enriched ANR event it.beforeSend = SentryOptions.BeforeSendCallback { event, _ -> diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationTest.kt index 1518b41a81..f503268dff 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationTest.kt @@ -97,7 +97,7 @@ class ReplayIntegrationTest { fun getSut( context: Context, sessionSampleRate: Double = 1.0, - errorSampleRate: Double = 1.0, + onErrorSampleRate: Double = 1.0, recorderProvider: (() -> Recorder)? = null, replayCaptureStrategyProvider: ((isFullSession: Boolean) -> CaptureStrategy)? = null, recorderConfigProvider: ((configChanged: Boolean) -> ScreenshotRecorderConfig)? = null, @@ -105,7 +105,7 @@ class ReplayIntegrationTest { dateProvider: ICurrentDateProvider = CurrentDateProvider.getInstance() ): ReplayIntegration { options.run { - experimental.sessionReplay.errorSampleRate = errorSampleRate + experimental.sessionReplay.onErrorSampleRate = onErrorSampleRate experimental.sessionReplay.sessionSampleRate = sessionSampleRate } return ReplayIntegration( @@ -204,7 +204,7 @@ class ReplayIntegrationTest { @Test fun `does not start replay when session is not sampled`() { val captureStrategy = mock() - val replay = fixture.getSut(context, errorSampleRate = 0.0, sessionSampleRate = 0.0, replayCaptureStrategyProvider = { captureStrategy }) + val replay = fixture.getSut(context, onErrorSampleRate = 0.0, sessionSampleRate = 0.0, replayCaptureStrategyProvider = { captureStrategy }) replay.register(fixture.hub, fixture.options) replay.start() diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplaySmokeTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplaySmokeTest.kt index 415697f68d..5e6052347a 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplaySmokeTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplaySmokeTest.kt @@ -154,7 +154,7 @@ class ReplaySmokeTest { captured.set(true) } - fixture.options.experimental.sessionReplay.errorSampleRate = 1.0 + fixture.options.experimental.sessionReplay.onErrorSampleRate = 1.0 fixture.options.cacheDirPath = tmpDir.newFolder().absolutePath val replay: ReplayIntegration = fixture.getSut(context) diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/BufferCaptureStrategyTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/BufferCaptureStrategyTest.kt index 1cd266ecb2..337ba525fc 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/BufferCaptureStrategyTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/BufferCaptureStrategyTest.kt @@ -83,7 +83,7 @@ class BufferCaptureStrategyTest { ) fun getSut( - errorSampleRate: Double = 1.0, + onErrorSampleRate: Double = 1.0, dateProvider: ICurrentDateProvider = CurrentDateProvider.getInstance(), replayCacheDir: File? = null ): BufferCaptureStrategy { @@ -91,7 +91,7 @@ class BufferCaptureStrategyTest { whenever(replayCache.replayCacheDir).thenReturn(it) } options.run { - experimental.sessionReplay.errorSampleRate = errorSampleRate + experimental.sessionReplay.onErrorSampleRate = onErrorSampleRate } return BufferCaptureStrategy( options, @@ -256,7 +256,7 @@ class BufferCaptureStrategyTest { @Test fun `captureReplay does not replayId to scope when not sampled`() { - val strategy = fixture.getSut(errorSampleRate = 0.0) + val strategy = fixture.getSut(onErrorSampleRate = 0.0) strategy.start(fixture.recorderConfig) strategy.captureReplay(false) {} diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 598b2c7b6b..ceab2fc326 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -2703,8 +2703,8 @@ public final class io/sentry/SentryReplayOptions { public fun (Ljava/lang/Double;Ljava/lang/Double;)V public fun addClassToRedact (Ljava/lang/String;)V public fun getErrorReplayDuration ()J - public fun getErrorSampleRate ()Ljava/lang/Double; public fun getFrameRate ()I + public fun getOnErrorSampleRate ()Ljava/lang/Double; public fun getQuality ()Lio/sentry/SentryReplayOptions$SentryReplayQuality; public fun getRedactAllImages ()Z public fun getRedactAllText ()Z @@ -2714,7 +2714,7 @@ public final class io/sentry/SentryReplayOptions { public fun getSessionSegmentDuration ()J public fun isSessionReplayEnabled ()Z public fun isSessionReplayForErrorsEnabled ()Z - public fun setErrorSampleRate (Ljava/lang/Double;)V + public fun setOnErrorSampleRate (Ljava/lang/Double;)V public fun setQuality (Lio/sentry/SentryReplayOptions$SentryReplayQuality;)V public fun setRedactAllImages (Z)V public fun setRedactAllText (Z)V diff --git a/sentry/src/main/java/io/sentry/Sentry.java b/sentry/src/main/java/io/sentry/Sentry.java index 13cf9ab389..08571e151a 100644 --- a/sentry/src/main/java/io/sentry/Sentry.java +++ b/sentry/src/main/java/io/sentry/Sentry.java @@ -357,7 +357,7 @@ private static void notifyOptionsObservers(final @NotNull SentryOptions options) observer.setEnvironment(options.getEnvironment()); observer.setTags(options.getTags()); observer.setReplayErrorSampleRate( - options.getExperimental().getSessionReplay().getErrorSampleRate()); + options.getExperimental().getSessionReplay().getOnErrorSampleRate()); } }); } catch (Throwable e) { diff --git a/sentry/src/main/java/io/sentry/SentryReplayOptions.java b/sentry/src/main/java/io/sentry/SentryReplayOptions.java index db230f2a30..0024708048 100644 --- a/sentry/src/main/java/io/sentry/SentryReplayOptions.java +++ b/sentry/src/main/java/io/sentry/SentryReplayOptions.java @@ -46,7 +46,7 @@ public enum SentryReplayQuality { * Specifying 0 means never, 1.0 means always. The value needs to be >= 0.0 and <= 1.0. The * default is null (disabled). */ - private @Nullable Double errorSampleRate; + private @Nullable Double onErrorSampleRate; /** * Redact all text content. Draws a rectangle of text bounds with text color on top. By default @@ -98,28 +98,28 @@ public enum SentryReplayQuality { public SentryReplayOptions() {} public SentryReplayOptions( - final @Nullable Double sessionSampleRate, final @Nullable Double errorSampleRate) { + final @Nullable Double sessionSampleRate, final @Nullable Double onErrorSampleRate) { this.sessionSampleRate = sessionSampleRate; - this.errorSampleRate = errorSampleRate; + this.onErrorSampleRate = onErrorSampleRate; } @Nullable - public Double getErrorSampleRate() { - return errorSampleRate; + public Double getOnErrorSampleRate() { + return onErrorSampleRate; } public boolean isSessionReplayEnabled() { return (getSessionSampleRate() != null && getSessionSampleRate() > 0); } - public void setErrorSampleRate(final @Nullable Double errorSampleRate) { - if (!SampleRateUtils.isValidSampleRate(errorSampleRate)) { + public void setOnErrorSampleRate(final @Nullable Double onErrorSampleRate) { + if (!SampleRateUtils.isValidSampleRate(onErrorSampleRate)) { throw new IllegalArgumentException( "The value " - + errorSampleRate + + onErrorSampleRate + " is not valid. Use null to disable or values >= 0.0 and <= 1.0."); } - this.errorSampleRate = errorSampleRate; + this.onErrorSampleRate = onErrorSampleRate; } @Nullable @@ -128,7 +128,7 @@ public Double getSessionSampleRate() { } public boolean isSessionReplayForErrorsEnabled() { - return (getErrorSampleRate() != null && getErrorSampleRate() > 0); + return (getOnErrorSampleRate() != null && getOnErrorSampleRate() > 0); } public void setSessionSampleRate(final @Nullable Double sessionSampleRate) { diff --git a/sentry/src/test/java/io/sentry/SentryTest.kt b/sentry/src/test/java/io/sentry/SentryTest.kt index 5ef764bc5d..ae34ad870b 100644 --- a/sentry/src/test/java/io/sentry/SentryTest.kt +++ b/sentry/src/test/java/io/sentry/SentryTest.kt @@ -737,7 +737,7 @@ class SentryTest { it.sdkVersion = SdkVersion("sentry.java.android", "6.13.0") it.environment = "debug" it.setTag("one", "two") - it.experimental.sessionReplay.errorSampleRate = 0.5 + it.experimental.sessionReplay.onErrorSampleRate = 0.5 } assertEquals("io.sentry.sample@1.1.0+220", optionsObserver.release) From 70d1da1f5aebf619b7be4d948ab7da09a17519dd Mon Sep 17 00:00:00 2001 From: Stefano Date: Tue, 3 Sep 2024 15:14:02 +0200 Subject: [PATCH 29/92] Avoid stopping appStartProfiler after application creation (#3630) * AppStartMetrics stops appStartProfiler only if no activity ever started --- CHANGELOG.md | 4 ++++ .../android/core/performance/AppStartMetrics.java | 11 ++++++----- .../core/performance/AppStartMetricsTest.kt | 14 ++++++++++++++ 3 files changed, 24 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d922586198..418c20a938 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Fixes + +- Avoid stopping appStartProfiler after application creation ([#3630](https://github.com/getsentry/sentry-java/pull/3630)) + *Breaking changes*: - `options.experimental.sessionReplay.errorSampleRate` was renamed to `options.experimental.sessionReplay.onErrorSampleRate` ([#3637](https://github.com/getsentry/sentry-java/pull/3637)) diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/performance/AppStartMetrics.java b/sentry-android-core/src/main/java/io/sentry/android/core/performance/AppStartMetrics.java index 90c49ed07a..461ee5eed6 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/performance/AppStartMetrics.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/performance/AppStartMetrics.java @@ -255,13 +255,14 @@ private void checkCreateTimeOnMain(final @NotNull Application application) { // if no activity has ever been created, app was launched in background if (onCreateTime == null) { appLaunchedInForeground = false; + + // we stop the app start profiler, as it's useless and likely to timeout + if (appStartProfiler != null && appStartProfiler.isRunning()) { + appStartProfiler.close(); + appStartProfiler = null; + } } application.unregisterActivityLifecycleCallbacks(instance); - // we stop the app start profiler, as it's useless and likely to timeout - if (appStartProfiler != null && appStartProfiler.isRunning()) { - appStartProfiler.close(); - appStartProfiler = null; - } }); } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/performance/AppStartMetricsTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/performance/AppStartMetricsTest.kt index 1f2eab8a9a..eb0e85dc28 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/performance/AppStartMetricsTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/performance/AppStartMetricsTest.kt @@ -196,6 +196,20 @@ class AppStartMetricsTest { verify(profiler).close() } + @Test + fun `if activity is started, does not stop app start profiler if running`() { + val profiler = mock() + whenever(profiler.isRunning).thenReturn(true) + AppStartMetrics.getInstance().appStartProfiler = profiler + AppStartMetrics.getInstance().onActivityCreated(mock(), mock()) + + AppStartMetrics.getInstance().registerApplicationForegroundCheck(mock()) + // Job on main thread checks if activity was launched + Shadows.shadowOf(Looper.getMainLooper()).idle() + + verify(profiler, never()).close() + } + @Test fun `if app start span is longer than 1 minute, appStartTimeSpanWithFallback returns an empty span`() { val appStartTimeSpan = AppStartMetrics.getInstance().appStartTimeSpan From 1aaf7d14d5b583f2ebce53e8d3ad1b84e5cdd683 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 9 Sep 2024 18:18:00 +0200 Subject: [PATCH 30/92] Bump JamesIves/github-pages-deploy-action from 4.6.3 to 4.6.4 (#3681) Bumps [JamesIves/github-pages-deploy-action](https://github.com/jamesives/github-pages-deploy-action) from 4.6.3 to 4.6.4. - [Release notes](https://github.com/jamesives/github-pages-deploy-action/releases) - [Commits](https://github.com/jamesives/github-pages-deploy-action/compare/94f3c658273cf92fb48ef99e5fbc02bd2dc642b2...920cbb300dcd3f0568dbc42700c61e2fd9e6139c) --- updated-dependencies: - dependency-name: JamesIves/github-pages-deploy-action 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> --- .github/workflows/generate-javadocs.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/generate-javadocs.yml b/.github/workflows/generate-javadocs.yml index bc0cb396ef..528d801190 100644 --- a/.github/workflows/generate-javadocs.yml +++ b/.github/workflows/generate-javadocs.yml @@ -28,7 +28,7 @@ jobs: run: | ./gradlew aggregateJavadocs - name: Deploy - uses: JamesIves/github-pages-deploy-action@94f3c658273cf92fb48ef99e5fbc02bd2dc642b2 # pin@4.6.3 + uses: JamesIves/github-pages-deploy-action@920cbb300dcd3f0568dbc42700c61e2fd9e6139c # pin@4.6.4 with: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} BRANCH: gh-pages From 8586d1fe888254e57186cfaa74266703741da4cf Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 9 Sep 2024 18:38:13 +0200 Subject: [PATCH 31/92] Bump github/codeql-action from 3.25.13 to 3.26.6 (#3672) Bumps [github/codeql-action](https://github.com/github/codeql-action) from 3.25.13 to 3.26.6. - [Release notes](https://github.com/github/codeql-action/releases) - [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/github/codeql-action/compare/2d790406f505036ef40ecba973cc774a50395aac...4dd16135b69a43b6c8efb853346f8437d92d3c93) --- updated-dependencies: - dependency-name: github/codeql-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> --- .github/workflows/codeql-analysis.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 2fa0010a2e..a0053fba72 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -41,7 +41,7 @@ jobs: gradle-home-cache-cleanup: true - name: Initialize CodeQL - uses: github/codeql-action/init@2d790406f505036ef40ecba973cc774a50395aac # pin@v2 + uses: github/codeql-action/init@4dd16135b69a43b6c8efb853346f8437d92d3c93 # pin@v2 with: languages: ${{ matrix.language }} @@ -55,4 +55,4 @@ jobs: ./gradlew buildForCodeQL - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@2d790406f505036ef40ecba973cc774a50395aac # pin@v2 + uses: github/codeql-action/analyze@4dd16135b69a43b6c8efb853346f8437d92d3c93 # pin@v2 From 731ae5a451ac624fd4e8eb4279656e29286b50ea Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Wed, 11 Sep 2024 10:28:52 +0200 Subject: [PATCH 32/92] [SR] Detect dominant color for TextViews with Spans (#3682) --- CHANGELOG.md | 1 + .../android/replay/ScreenshotRecorder.kt | 8 +- .../io/sentry/android/replay/util/Views.kt | 33 ++++++ .../replay/util/TextViewDominantColorTest.kt | 104 ++++++++++++++++++ 4 files changed, 143 insertions(+), 3 deletions(-) create mode 100644 sentry-android-replay/src/test/java/io/sentry/android/replay/util/TextViewDominantColorTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 418c20a938..cbedaf37f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### Fixes - Avoid stopping appStartProfiler after application creation ([#3630](https://github.com/getsentry/sentry-java/pull/3630)) +- Session Replay: Correctly detect dominant color for `TextView`s with Spans ([#3682](https://github.com/getsentry/sentry-java/pull/3682)) *Breaking changes*: diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt index 5ec142b3e5..fdab9f442d 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt @@ -24,6 +24,7 @@ import io.sentry.SentryLevel.WARNING import io.sentry.SentryOptions import io.sentry.SentryReplayOptions import io.sentry.android.replay.util.MainLooperHandler +import io.sentry.android.replay.util.dominantTextColor import io.sentry.android.replay.util.getVisibleRects import io.sentry.android.replay.util.gracefullyShutdown import io.sentry.android.replay.util.submitSafely @@ -142,13 +143,14 @@ internal class ScreenshotRecorder( } is TextViewHierarchyNode -> { - // TODO: find a way to get the correct text color for RN - // TODO: now it always returns black + val textColor = node.layout.dominantTextColor + ?: node.dominantColor + ?: Color.BLACK node.layout.getVisibleRects( node.visibleRect, node.paddingLeft, node.paddingTop - ) to (node.dominantColor ?: Color.BLACK) + ) to textColor } else -> { diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Views.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Views.kt index a44508eac6..86c75f2e9d 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Views.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Views.kt @@ -13,6 +13,8 @@ import android.graphics.drawable.VectorDrawable import android.os.Build.VERSION import android.os.Build.VERSION_CODES import android.text.Layout +import android.text.Spanned +import android.text.style.ForegroundColorSpan import android.view.View import android.widget.TextView import java.lang.NullPointerException @@ -101,3 +103,34 @@ internal val TextView.totalPaddingTopSafe: Int } catch (e: NullPointerException) { extendedPaddingTop } + +/** + * Returns the dominant text color of the layout by looking at the [ForegroundColorSpan] spans if + * this text is a [Spanned] text. If the text is not a [Spanned] text or there are no spans, it + * returns null. + */ +internal val Layout?.dominantTextColor: Int? get() { + this ?: return null + + if (text !is Spanned) return null + + val spans = (text as Spanned).getSpans(0, text.length, ForegroundColorSpan::class.java) + + // determine the dominant color by the span with the longest range + var longestSpan = Int.MIN_VALUE + var dominantColor: Int? = null + for (span in spans) { + val spanStart = (text as Spanned).getSpanStart(span) + val spanEnd = (text as Spanned).getSpanEnd(span) + if (spanStart == -1 || spanEnd == -1) { + // the span is not attached + continue + } + val spanLength = spanEnd - spanStart + if (spanLength > longestSpan) { + longestSpan = spanLength + dominantColor = span.foregroundColor + } + } + return dominantColor +} diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/util/TextViewDominantColorTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/util/TextViewDominantColorTest.kt new file mode 100644 index 0000000000..ec545ed109 --- /dev/null +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/util/TextViewDominantColorTest.kt @@ -0,0 +1,104 @@ +package io.sentry.android.replay.util + +import android.app.Activity +import android.graphics.Color +import android.os.Bundle +import android.os.Looper +import android.text.SpannableString +import android.text.Spanned +import android.text.style.ForegroundColorSpan +import android.widget.LinearLayout +import android.widget.LinearLayout.LayoutParams +import android.widget.TextView +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.sentry.SentryOptions +import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode +import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode.TextViewHierarchyNode +import org.junit.runner.RunWith +import org.robolectric.Robolectric.buildActivity +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +@RunWith(AndroidJUnit4::class) +@Config(sdk = [30]) +class TextViewDominantColorTest { + + @Test + fun `when no spans, returns currentTextColor`() { + val controller = buildActivity(TextViewActivity::class.java, null).setup() + controller.create().start().resume() + + TextViewActivity.textView?.setTextColor(Color.WHITE) + + val node = ViewHierarchyNode.fromView(TextViewActivity.textView!!, null, 0, SentryOptions()) + assertTrue(node is TextViewHierarchyNode) + assertNull(node.layout.dominantTextColor) + } + + @Test + fun `when has a foreground color span, returns its color`() { + val controller = buildActivity(TextViewActivity::class.java, null).setup() + controller.create().start().resume() + + val text = "Hello, World!" + TextViewActivity.textView?.text = SpannableString(text).apply { + setSpan(ForegroundColorSpan(Color.RED), 0, text.length, Spanned.SPAN_INCLUSIVE_INCLUSIVE) + } + TextViewActivity.textView?.setTextColor(Color.WHITE) + TextViewActivity.textView?.requestLayout() + + shadowOf(Looper.getMainLooper()).idle() + + val node = ViewHierarchyNode.fromView(TextViewActivity.textView!!, null, 0, SentryOptions()) + assertTrue(node is TextViewHierarchyNode) + assertEquals(Color.RED, node.layout.dominantTextColor) + } + + @Test + fun `when has multiple foreground color spans, returns color of the longest span`() { + val controller = buildActivity(TextViewActivity::class.java, null).setup() + controller.create().start().resume() + + val text = "Hello, World!" + TextViewActivity.textView?.text = SpannableString(text).apply { + setSpan(ForegroundColorSpan(Color.RED), 0, 5, Spanned.SPAN_INCLUSIVE_INCLUSIVE) + setSpan(ForegroundColorSpan(Color.BLACK), 6, text.length, Spanned.SPAN_INCLUSIVE_INCLUSIVE) + } + TextViewActivity.textView?.setTextColor(Color.WHITE) + TextViewActivity.textView?.requestLayout() + + shadowOf(Looper.getMainLooper()).idle() + + val node = ViewHierarchyNode.fromView(TextViewActivity.textView!!, null, 0, SentryOptions()) + assertTrue(node is TextViewHierarchyNode) + assertEquals(Color.BLACK, node.layout.dominantTextColor) + } +} + +private class TextViewActivity : Activity() { + + companion object { + var textView: TextView? = null + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val linearLayout = LinearLayout(this).apply { + setBackgroundColor(android.R.color.white) + orientation = LinearLayout.VERTICAL + layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT) + } + + textView = TextView(this).apply { + text = "Hello, World!" + layoutParams = LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT) + } + linearLayout.addView(textView) + + setContentView(linearLayout) + } +} From 9e9e16de25f4e9dfa373aa748b5aa6385bd8679b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 16 Sep 2024 11:41:33 +0200 Subject: [PATCH 33/92] Bump github/codeql-action from 3.26.6 to 3.26.7 (#3692) Bumps [github/codeql-action](https://github.com/github/codeql-action) from 3.26.6 to 3.26.7. - [Release notes](https://github.com/github/codeql-action/releases) - [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/github/codeql-action/compare/4dd16135b69a43b6c8efb853346f8437d92d3c93...8214744c546c1e5c8f03dde8fab3a7353211988d) --- updated-dependencies: - dependency-name: github/codeql-action 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> --- .github/workflows/codeql-analysis.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index a0053fba72..0e2550a70e 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -41,7 +41,7 @@ jobs: gradle-home-cache-cleanup: true - name: Initialize CodeQL - uses: github/codeql-action/init@4dd16135b69a43b6c8efb853346f8437d92d3c93 # pin@v2 + uses: github/codeql-action/init@8214744c546c1e5c8f03dde8fab3a7353211988d # pin@v2 with: languages: ${{ matrix.language }} @@ -55,4 +55,4 @@ jobs: ./gradlew buildForCodeQL - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@4dd16135b69a43b6c8efb853346f8437d92d3c93 # pin@v2 + uses: github/codeql-action/analyze@8214744c546c1e5c8f03dde8fab3a7353211988d # pin@v2 From 80d590839e348f5dc519a9e4ca81e16ebf54f5e0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 16 Sep 2024 09:58:12 +0000 Subject: [PATCH 34/92] Bump gradle/actions (#3691) Bumps [gradle/actions](https://github.com/gradle/actions) from fd87365911aa12c016c307ea21313f351dc53551 to 0d30c9111cf47a838eb69c06d13f3f51ab2ed76f. - [Release notes](https://github.com/gradle/actions/releases) - [Commits](https://github.com/gradle/actions/compare/fd87365911aa12c016c307ea21313f351dc53551...0d30c9111cf47a838eb69c06d13f3f51ab2ed76f) --- updated-dependencies: - dependency-name: gradle/actions dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/agp-matrix.yml | 2 +- .github/workflows/build.yml | 2 +- .github/workflows/codeql-analysis.yml | 2 +- .github/workflows/enforce-license-compliance.yml | 2 +- .github/workflows/generate-javadocs.yml | 2 +- .github/workflows/integration-tests-benchmarks.yml | 4 ++-- .github/workflows/integration-tests-ui.yml | 2 +- .github/workflows/release-build.yml | 2 +- .github/workflows/system-tests-backend.yml | 2 +- 9 files changed, 10 insertions(+), 10 deletions(-) diff --git a/.github/workflows/agp-matrix.yml b/.github/workflows/agp-matrix.yml index d19ecd50b8..b43d40697a 100644 --- a/.github/workflows/agp-matrix.yml +++ b/.github/workflows/agp-matrix.yml @@ -38,7 +38,7 @@ jobs: java-version: '17' - name: Setup Gradle - uses: gradle/actions/setup-gradle@fd87365911aa12c016c307ea21313f351dc53551 # pin@v3 + uses: gradle/actions/setup-gradle@0d30c9111cf47a838eb69c06d13f3f51ab2ed76f # pin@v3 with: gradle-home-cache-cleanup: true diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 1623c48d16..f4b8d8431c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -27,7 +27,7 @@ jobs: java-version: '17' - name: Setup Gradle - uses: gradle/actions/setup-gradle@fd87365911aa12c016c307ea21313f351dc53551 # pin@v3 + uses: gradle/actions/setup-gradle@0d30c9111cf47a838eb69c06d13f3f51ab2ed76f # pin@v3 with: gradle-home-cache-cleanup: true diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 0e2550a70e..fe85514b4a 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -36,7 +36,7 @@ jobs: java-version: '17' - name: Setup Gradle - uses: gradle/actions/setup-gradle@fd87365911aa12c016c307ea21313f351dc53551 # pin@v3 + uses: gradle/actions/setup-gradle@0d30c9111cf47a838eb69c06d13f3f51ab2ed76f # pin@v3 with: gradle-home-cache-cleanup: true diff --git a/.github/workflows/enforce-license-compliance.yml b/.github/workflows/enforce-license-compliance.yml index 294e520eb9..c2ddec5865 100644 --- a/.github/workflows/enforce-license-compliance.yml +++ b/.github/workflows/enforce-license-compliance.yml @@ -11,7 +11,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Setup Gradle - uses: gradle/actions/setup-gradle@fd87365911aa12c016c307ea21313f351dc53551 # pin@v3 + uses: gradle/actions/setup-gradle@0d30c9111cf47a838eb69c06d13f3f51ab2ed76f # pin@v3 with: gradle-home-cache-cleanup: true diff --git a/.github/workflows/generate-javadocs.yml b/.github/workflows/generate-javadocs.yml index 528d801190..dd171af5a2 100644 --- a/.github/workflows/generate-javadocs.yml +++ b/.github/workflows/generate-javadocs.yml @@ -20,7 +20,7 @@ jobs: java-version: '17' - name: Setup Gradle - uses: gradle/actions/setup-gradle@fd87365911aa12c016c307ea21313f351dc53551 # pin@v3 + uses: gradle/actions/setup-gradle@0d30c9111cf47a838eb69c06d13f3f51ab2ed76f # pin@v3 with: gradle-home-cache-cleanup: true diff --git a/.github/workflows/integration-tests-benchmarks.yml b/.github/workflows/integration-tests-benchmarks.yml index cbdf2d4011..f0beaa60b5 100644 --- a/.github/workflows/integration-tests-benchmarks.yml +++ b/.github/workflows/integration-tests-benchmarks.yml @@ -37,7 +37,7 @@ jobs: java-version: '17' - name: Setup Gradle - uses: gradle/actions/setup-gradle@fd87365911aa12c016c307ea21313f351dc53551 # pin@v3 + uses: gradle/actions/setup-gradle@0d30c9111cf47a838eb69c06d13f3f51ab2ed76f # pin@v3 with: gradle-home-cache-cleanup: true @@ -86,7 +86,7 @@ jobs: java-version: '17' - name: Setup Gradle - uses: gradle/actions/setup-gradle@fd87365911aa12c016c307ea21313f351dc53551 # pin@v3 + uses: gradle/actions/setup-gradle@0d30c9111cf47a838eb69c06d13f3f51ab2ed76f # pin@v3 with: gradle-home-cache-cleanup: true diff --git a/.github/workflows/integration-tests-ui.yml b/.github/workflows/integration-tests-ui.yml index c62dd4a771..771b4b5c8c 100644 --- a/.github/workflows/integration-tests-ui.yml +++ b/.github/workflows/integration-tests-ui.yml @@ -32,7 +32,7 @@ jobs: java-version: '17' - name: Setup Gradle - uses: gradle/actions/setup-gradle@fd87365911aa12c016c307ea21313f351dc53551 # pin@v3 + uses: gradle/actions/setup-gradle@0d30c9111cf47a838eb69c06d13f3f51ab2ed76f # pin@v3 with: gradle-home-cache-cleanup: true diff --git a/.github/workflows/release-build.yml b/.github/workflows/release-build.yml index 74f9174be4..cb6752bb93 100644 --- a/.github/workflows/release-build.yml +++ b/.github/workflows/release-build.yml @@ -26,7 +26,7 @@ jobs: java-version: '17' - name: Setup Gradle - uses: gradle/actions/setup-gradle@fd87365911aa12c016c307ea21313f351dc53551 # pin@v3 + uses: gradle/actions/setup-gradle@0d30c9111cf47a838eb69c06d13f3f51ab2ed76f # pin@v3 with: gradle-home-cache-cleanup: true diff --git a/.github/workflows/system-tests-backend.yml b/.github/workflows/system-tests-backend.yml index 0c04c86fd8..31331229fe 100644 --- a/.github/workflows/system-tests-backend.yml +++ b/.github/workflows/system-tests-backend.yml @@ -40,7 +40,7 @@ jobs: java-version: '17' - name: Setup Gradle - uses: gradle/actions/setup-gradle@fd87365911aa12c016c307ea21313f351dc53551 # pin@v3 + uses: gradle/actions/setup-gradle@0d30c9111cf47a838eb69c06d13f3f51ab2ed76f # pin@v3 with: gradle-home-cache-cleanup: true From 6368d4f2127dbeb8b4255a9203db9a0854edacbf Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Mon, 16 Sep 2024 19:04:40 +0200 Subject: [PATCH 35/92] [SR] Add custom redaction options (#3689) --- CHANGELOG.md | 7 + .../android/core/ManifestMetadataReader.java | 14 +- .../core/ManifestMetadataReaderTest.kt | 9 +- .../api/sentry-android-replay.api | 12 + .../io/sentry/android/replay/ReplayCache.kt | 1 + .../android/replay/SessionReplayOptions.kt | 31 ++ .../sentry/android/replay/ViewExtensions.kt | 18 ++ .../java/io/sentry/android/replay/Windows.kt | 18 +- .../replay/viewhierarchy/ViewHierarchyNode.kt | 49 ++- sentry-android-replay/src/main/res/public.xml | 4 - .../src/main/res/values/public.xml | 5 + .../viewhierarchy/RedactionOptionsTest.kt | 278 ++++++++++++++++++ sentry/api/sentry.api | 10 +- .../java/io/sentry/SentryReplayOptions.java | 91 ++++-- 14 files changed, 476 insertions(+), 71 deletions(-) create mode 100644 sentry-android-replay/src/main/java/io/sentry/android/replay/SessionReplayOptions.kt create mode 100644 sentry-android-replay/src/main/java/io/sentry/android/replay/ViewExtensions.kt delete mode 100644 sentry-android-replay/src/main/res/public.xml create mode 100644 sentry-android-replay/src/main/res/values/public.xml create mode 100644 sentry-android-replay/src/test/java/io/sentry/android/replay/viewhierarchy/RedactionOptionsTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index cbedaf37f7..2835d1525b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,13 @@ - Avoid stopping appStartProfiler after application creation ([#3630](https://github.com/getsentry/sentry-java/pull/3630)) - Session Replay: Correctly detect dominant color for `TextView`s with Spans ([#3682](https://github.com/getsentry/sentry-java/pull/3682)) +- Session Replay: Add options to selectively redact/ignore views from being captured. The following options are available: ([#3689](https://github.com/getsentry/sentry-java/pull/3689)) + - `android:tag="sentry-redact|sentry-ignore"` in XML or `view.setTag("sentry-redact|sentry-ignore")` in code tags + - if you already have a tag set for a view, you can set a tag by id: `` in XML or `view.setTag(io.sentry.android.replay.R.id.sentry_privacy, "redact|ignore")` in code + - `view.sentryReplayRedact()` or `view.sentryReplayIgnore()` extension functions + - redact/ignore `View`s of a certain type by adding fully-qualified classname to one of the lists `options.experimental.sessionReplay.addRedactViewClass()` or `options.experimental.sessionReplay.addIgnoreViewClass()`. Note, that all of the view subclasses/subtypes will be redacted/ignored as well + - For example, (this is already a default behavior) to redact all `TextView`s and their subclasses (`RadioButton`, `EditText`, etc.): `options.experimental.sessionReplay.addRedactViewClass("android.widget.TextView")` + - If you're using code obfuscation, adjust your proguard-rules accordingly, so your custom view class name is not minified *Breaking changes*: diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java b/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java index 846ed78f3c..fc66c9d6ee 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java @@ -409,22 +409,12 @@ static void applyMetadata( options .getExperimental() .getSessionReplay() - .setRedactAllText( - readBool( - metadata, - logger, - REPLAYS_REDACT_ALL_TEXT, - options.getExperimental().getSessionReplay().getRedactAllText())); + .setRedactAllText(readBool(metadata, logger, REPLAYS_REDACT_ALL_TEXT, true)); options .getExperimental() .getSessionReplay() - .setRedactAllImages( - readBool( - metadata, - logger, - REPLAYS_REDACT_ALL_IMAGES, - options.getExperimental().getSessionReplay().getRedactAllImages())); + .setRedactAllImages(readBool(metadata, logger, REPLAYS_REDACT_ALL_IMAGES, true)); } options diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt index 615caf5550..8a86fcb2c5 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt @@ -6,6 +6,7 @@ import androidx.core.os.bundleOf import androidx.test.ext.junit.runners.AndroidJUnit4 import io.sentry.ILogger import io.sentry.SentryLevel +import io.sentry.SentryReplayOptions import org.junit.runner.RunWith import org.mockito.kotlin.any import org.mockito.kotlin.eq @@ -1473,8 +1474,8 @@ class ManifestMetadataReaderTest { ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) // Assert - assertFalse(fixture.options.experimental.sessionReplay.redactAllImages) - assertFalse(fixture.options.experimental.sessionReplay.redactAllText) + assertTrue(fixture.options.experimental.sessionReplay.ignoreViewClasses.contains(SentryReplayOptions.IMAGE_VIEW_CLASS_NAME)) + assertTrue(fixture.options.experimental.sessionReplay.ignoreViewClasses.contains(SentryReplayOptions.TEXT_VIEW_CLASS_NAME)) } @Test @@ -1486,7 +1487,7 @@ class ManifestMetadataReaderTest { ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) // Assert - assertTrue(fixture.options.experimental.sessionReplay.redactAllImages) - assertTrue(fixture.options.experimental.sessionReplay.redactAllText) + assertTrue(fixture.options.experimental.sessionReplay.redactViewClasses.contains(SentryReplayOptions.IMAGE_VIEW_CLASS_NAME)) + assertTrue(fixture.options.experimental.sessionReplay.redactViewClasses.contains(SentryReplayOptions.TEXT_VIEW_CLASS_NAME)) } } diff --git a/sentry-android-replay/api/sentry-android-replay.api b/sentry-android-replay/api/sentry-android-replay.api index f103957999..1c08379a49 100644 --- a/sentry-android-replay/api/sentry-android-replay.api +++ b/sentry-android-replay/api/sentry-android-replay.api @@ -103,6 +103,18 @@ public final class io/sentry/android/replay/ScreenshotRecorderConfig$Companion { public final fun from (Landroid/content/Context;Lio/sentry/SentryReplayOptions;)Lio/sentry/android/replay/ScreenshotRecorderConfig; } +public final class io/sentry/android/replay/SessionReplayOptionsKt { + public static final fun getRedactAllImages (Lio/sentry/SentryReplayOptions;)Z + public static final fun getRedactAllText (Lio/sentry/SentryReplayOptions;)Z + public static final fun setRedactAllImages (Lio/sentry/SentryReplayOptions;Z)V + public static final fun setRedactAllText (Lio/sentry/SentryReplayOptions;Z)V +} + +public final class io/sentry/android/replay/ViewExtensionsKt { + public static final fun sentryReplayIgnore (Landroid/view/View;)V + public static final fun sentryReplayRedact (Landroid/view/View;)V +} + public final class io/sentry/android/replay/gestures/GestureRecorder : io/sentry/android/replay/OnRootViewsChangedListener { public fun (Lio/sentry/SentryOptions;Lio/sentry/android/replay/gestures/TouchRecorderCallback;)V public fun onRootViewsChanged (Landroid/view/View;Z)V diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt index c1bfeb1e52..3db92ea5d8 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt @@ -80,6 +80,7 @@ public class ReplayCache( if (replayCacheDir == null || bitmap.isRecycled) { return } + replayCacheDir?.mkdirs() val screenshot = File(replayCacheDir, "$frameTimestamp.jpg").also { it.createNewFile() diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/SessionReplayOptions.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/SessionReplayOptions.kt new file mode 100644 index 0000000000..e3e6605a96 --- /dev/null +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/SessionReplayOptions.kt @@ -0,0 +1,31 @@ +package io.sentry.android.replay + +import io.sentry.SentryReplayOptions + +// since we don't have getters for redactAllText and redactAllImages, they won't be accessible as +// properties in Kotlin, therefore we create these extensions where a getter is dummy, but a setter +// delegates to the corresponding method in SentryReplayOptions + +/** + * Redact all text content. Draws a rectangle of text bounds with text color on top. By default + * only views extending TextView are redacted. + * + *

Default is enabled. + */ +var SentryReplayOptions.redactAllText: Boolean + @Deprecated("Getter is unsupported.", level = DeprecationLevel.ERROR) + get() = error("Getter not supported") + set(value) = setRedactAllText(value) + +/** + * Redact all image content. Draws a rectangle of image bounds with image's dominant color on top. + * By default only views extending ImageView with BitmapDrawable or custom Drawable type are + * redacted. ColorDrawable, InsetDrawable, VectorDrawable are all considered non-PII, as they come + * from the apk. + * + *

Default is enabled. + */ +var SentryReplayOptions.redactAllImages: Boolean + @Deprecated("Getter is unsupported.", level = DeprecationLevel.ERROR) + get() = error("Getter not supported") + set(value) = setRedactAllImages(value) diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ViewExtensions.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ViewExtensions.kt new file mode 100644 index 0000000000..37061a5b77 --- /dev/null +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ViewExtensions.kt @@ -0,0 +1,18 @@ +package io.sentry.android.replay + +import android.view.View + +/** + * Marks this view to be redacted in session replay. + */ +fun View.sentryReplayRedact() { + setTag(R.id.sentry_privacy, "redact") +} + +/** + * Marks this view to be ignored from redaction in session. + * All its content will be visible in the replay, use with caution. + */ +fun View.sentryReplayIgnore() { + setTag(R.id.sentry_privacy, "ignore") +} diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/Windows.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/Windows.kt index 8ef595f193..48c7eb5813 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/Windows.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/Windows.kt @@ -134,7 +134,7 @@ internal fun interface OnRootViewsChangedListener { /** * A utility that holds the list of root views that WindowManager updates. */ -internal class RootViewsSpy private constructor() { +internal object RootViewsSpy { val listeners: CopyOnWriteArrayList = object : CopyOnWriteArrayList() { override fun add(element: OnRootViewsChangedListener?): Boolean { @@ -168,15 +168,13 @@ internal class RootViewsSpy private constructor() { } } - companion object { - fun install(): RootViewsSpy { - return RootViewsSpy().apply { - // had to do this as a first message of the main thread queue, otherwise if this is - // called from ContentProvider, it might be too early and the listener won't be installed - Handler(Looper.getMainLooper()).postAtFrontOfQueue { - WindowManagerSpy.swapWindowManagerGlobalMViews { mViews -> - delegatingViewList.apply { addAll(mViews) } - } + fun install(): RootViewsSpy { + return apply { + // had to do this as a first message of the main thread queue, otherwise if this is + // called from ContentProvider, it might be too early and the listener won't be installed + Handler(Looper.getMainLooper()).postAtFrontOfQueue { + WindowManagerSpy.swapWindowManagerGlobalMViews { mViews -> + delegatingViewList.apply { addAll(mViews) } } } } diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ViewHierarchyNode.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ViewHierarchyNode.kt index 145cefff3d..90b96f134b 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ViewHierarchyNode.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ViewHierarchyNode.kt @@ -7,6 +7,7 @@ import android.view.View import android.widget.ImageView import android.widget.TextView import io.sentry.SentryOptions +import io.sentry.android.replay.R import io.sentry.android.replay.util.isRedactable import io.sentry.android.replay.util.isVisibleToUser import io.sentry.android.replay.util.totalPaddingTopSafe @@ -233,14 +234,46 @@ sealed class ViewHierarchyNode( } } - private fun shouldRedact(view: View, options: SentryOptions): Boolean { - return options.experimental.sessionReplay.redactClasses.contains(view.javaClass.canonicalName) + private const val SENTRY_IGNORE_TAG = "sentry-ignore" + private const val SENTRY_REDACT_TAG = "sentry-redact" + + private fun Class<*>.isAssignableFrom(set: Set): Boolean { + var cls: Class<*>? = this + while (cls != null) { + val canonicalName = cls.canonicalName + if (canonicalName != null && set.contains(canonicalName)) { + return true + } + cls = cls.superclass + } + return false + } + + private fun View.shouldRedact(options: SentryOptions): Boolean { + if ((tag as? String)?.lowercase()?.contains(SENTRY_IGNORE_TAG) == true || + getTag(R.id.sentry_privacy) == "ignore" + ) { + return false + } + + if ((tag as? String)?.lowercase()?.contains(SENTRY_REDACT_TAG) == true || + getTag(R.id.sentry_privacy) == "redact" + ) { + return true + } + + if (this.javaClass.isAssignableFrom(options.experimental.sessionReplay.ignoreViewClasses)) { + return false + } + + return this.javaClass.isAssignableFrom(options.experimental.sessionReplay.redactViewClasses) } fun fromView(view: View, parent: ViewHierarchyNode?, distance: Int, options: SentryOptions): ViewHierarchyNode { val (isVisible, visibleRect) = view.isVisibleToUser() - when { - view is TextView && options.experimental.sessionReplay.redactAllText -> { + val shouldRedact = isVisible && view.shouldRedact(options) + when (view) { + is TextView -> { parent.setImportantForCaptureToAncestors(true) return TextViewHierarchyNode( layout = view.layout, @@ -252,7 +285,7 @@ sealed class ViewHierarchyNode( width = view.width, height = view.height, elevation = (parent?.elevation ?: 0f) + view.elevation, - shouldRedact = isVisible, + shouldRedact = shouldRedact, distance = distance, parent = parent, isImportantForContentCapture = true, @@ -261,7 +294,7 @@ sealed class ViewHierarchyNode( ) } - view is ImageView && options.experimental.sessionReplay.redactAllImages -> { + is ImageView -> { parent.setImportantForCaptureToAncestors(true) return ImageViewHierarchyNode( x = view.x, @@ -273,7 +306,7 @@ sealed class ViewHierarchyNode( parent = parent, isVisible = isVisible, isImportantForContentCapture = true, - shouldRedact = isVisible && view.drawable?.isRedactable() == true, + shouldRedact = shouldRedact && view.drawable?.isRedactable() == true, visibleRect = visibleRect ) } @@ -287,7 +320,7 @@ sealed class ViewHierarchyNode( (parent?.elevation ?: 0f) + view.elevation, distance = distance, parent = parent, - shouldRedact = isVisible && shouldRedact(view, options), + shouldRedact = shouldRedact, isImportantForContentCapture = false, /* will be set by children */ isVisible = isVisible, visibleRect = visibleRect diff --git a/sentry-android-replay/src/main/res/public.xml b/sentry-android-replay/src/main/res/public.xml deleted file mode 100644 index 379be515be..0000000000 --- a/sentry-android-replay/src/main/res/public.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/sentry-android-replay/src/main/res/values/public.xml b/sentry-android-replay/src/main/res/values/public.xml new file mode 100644 index 0000000000..cc60000bcd --- /dev/null +++ b/sentry-android-replay/src/main/res/values/public.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/viewhierarchy/RedactionOptionsTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/viewhierarchy/RedactionOptionsTest.kt new file mode 100644 index 0000000000..8ffffd046d --- /dev/null +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/viewhierarchy/RedactionOptionsTest.kt @@ -0,0 +1,278 @@ +package io.sentry.android.replay.viewhierarchy + +import android.app.Activity +import android.content.Context +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.drawable.Drawable +import android.os.Bundle +import android.view.View +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.LinearLayout.LayoutParams +import android.widget.RadioButton +import android.widget.TextView +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.sentry.SentryOptions +import io.sentry.android.replay.redactAllImages +import io.sentry.android.replay.redactAllText +import io.sentry.android.replay.sentryReplayIgnore +import io.sentry.android.replay.sentryReplayRedact +import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode.ImageViewHierarchyNode +import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode.TextViewHierarchyNode +import org.junit.Before +import org.junit.runner.RunWith +import org.robolectric.Robolectric.buildActivity +import org.robolectric.annotation.Config +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +@RunWith(AndroidJUnit4::class) +@Config(sdk = [30]) +class RedactionOptionsTest { + + @Before + fun setup() { + System.setProperty("robolectric.areWindowsMarkedVisible", "true") + } + + @Test + fun `when redactAllText is set all TextView nodes are redacted`() { + buildActivity(ExampleActivity::class.java).setup() + + val options = SentryOptions().apply { + experimental.sessionReplay.redactAllText = true + } + + val textNode = ViewHierarchyNode.fromView(ExampleActivity.textView!!, null, 0, options) + val radioButtonNode = ViewHierarchyNode.fromView(ExampleActivity.radioButton!!, null, 0, options) + + assertTrue(textNode is TextViewHierarchyNode) + assertTrue(textNode.shouldRedact) + + assertTrue(radioButtonNode is TextViewHierarchyNode) + assertTrue(radioButtonNode.shouldRedact) + } + + @Test + fun `when redactAllText is set to false all TextView nodes are ignored`() { + buildActivity(ExampleActivity::class.java).setup() + + val options = SentryOptions().apply { + experimental.sessionReplay.redactAllText = false + } + + val textNode = ViewHierarchyNode.fromView(ExampleActivity.textView!!, null, 0, options) + val radioButtonNode = ViewHierarchyNode.fromView(ExampleActivity.radioButton!!, null, 0, options) + + assertTrue(textNode is TextViewHierarchyNode) + assertFalse(textNode.shouldRedact) + + assertTrue(radioButtonNode is TextViewHierarchyNode) + assertFalse(radioButtonNode.shouldRedact) + } + + @Test + fun `when redactAllImages is set all ImageView nodes are redacted`() { + buildActivity(ExampleActivity::class.java).setup() + + val options = SentryOptions().apply { + experimental.sessionReplay.redactAllImages = true + } + + val imageNode = ViewHierarchyNode.fromView(ExampleActivity.imageView!!, null, 0, options) + + assertTrue(imageNode is ImageViewHierarchyNode) + assertTrue(imageNode.shouldRedact) + } + + @Test + fun `when redactAllImages is set to false all ImageView nodes are ignored`() { + buildActivity(ExampleActivity::class.java).setup() + + val options = SentryOptions().apply { + experimental.sessionReplay.redactAllImages = false + } + + val imageNode = ViewHierarchyNode.fromView(ExampleActivity.imageView!!, null, 0, options) + + assertTrue(imageNode is ImageViewHierarchyNode) + assertFalse(imageNode.shouldRedact) + } + + @Test + fun `when sentry-redact tag is set redacts the view`() { + buildActivity(ExampleActivity::class.java).setup() + + val options = SentryOptions().apply { + experimental.sessionReplay.redactAllText = false + } + + ExampleActivity.textView!!.tag = "sentry-redact" + val textNode = ViewHierarchyNode.fromView(ExampleActivity.textView!!, null, 0, options) + + assertTrue(textNode.shouldRedact) + } + + @Test + fun `when sentry-ignore tag is set ignores the view`() { + buildActivity(ExampleActivity::class.java).setup() + + val options = SentryOptions().apply { + experimental.sessionReplay.redactAllText = true + } + + ExampleActivity.textView!!.tag = "sentry-ignore" + val textNode = ViewHierarchyNode.fromView(ExampleActivity.textView!!, null, 0, options) + + assertFalse(textNode.shouldRedact) + } + + @Test + fun `when sentry-privacy tag is set to redact redacts the view`() { + buildActivity(ExampleActivity::class.java).setup() + + val options = SentryOptions().apply { + experimental.sessionReplay.redactAllText = false + } + + ExampleActivity.textView!!.sentryReplayRedact() + val textNode = ViewHierarchyNode.fromView(ExampleActivity.textView!!, null, 0, options) + + assertTrue(textNode.shouldRedact) + } + + @Test + fun `when sentry-privacy tag is set to ignore ignores the view`() { + buildActivity(ExampleActivity::class.java).setup() + + val options = SentryOptions().apply { + experimental.sessionReplay.redactAllText = true + } + + ExampleActivity.textView!!.sentryReplayIgnore() + val textNode = ViewHierarchyNode.fromView(ExampleActivity.textView!!, null, 0, options) + + assertFalse(textNode.shouldRedact) + } + + @Test + fun `when view is not visible, does not redact the view`() { + buildActivity(ExampleActivity::class.java).setup() + + val options = SentryOptions().apply { + experimental.sessionReplay.redactAllText = true + } + + ExampleActivity.textView!!.visibility = View.GONE + val textNode = ViewHierarchyNode.fromView(ExampleActivity.textView!!, null, 0, options) + + assertFalse(textNode.shouldRedact) + } + + @Test + fun `when added to redact list redacts custom view`() { + buildActivity(ExampleActivity::class.java).setup() + + val options = SentryOptions().apply { + experimental.sessionReplay.redactViewClasses.add(CustomView::class.java.canonicalName) + } + + val customViewNode = ViewHierarchyNode.fromView(ExampleActivity.customView!!, null, 0, options) + + assertTrue(customViewNode.shouldRedact) + } + + @Test + fun `when subclass is added to ignored classes ignores all instances of that class`() { + buildActivity(ExampleActivity::class.java).setup() + + val options = SentryOptions().apply { + experimental.sessionReplay.redactAllText = true // all TextView subclasses + experimental.sessionReplay.ignoreViewClasses.add(RadioButton::class.java.canonicalName) + } + + val textNode = ViewHierarchyNode.fromView(ExampleActivity.textView!!, null, 0, options) + val radioButtonNode = ViewHierarchyNode.fromView(ExampleActivity.radioButton!!, null, 0, options) + + assertTrue(textNode.shouldRedact) + assertFalse(radioButtonNode.shouldRedact) + } + + @Test + fun `when a container view is ignored its children are not ignored`() { + buildActivity(ExampleActivity::class.java).setup() + + val options = SentryOptions().apply { + experimental.sessionReplay.ignoreViewClasses.add(LinearLayout::class.java.canonicalName) + } + + val linearLayoutNode = ViewHierarchyNode.fromView(ExampleActivity.textView!!.parent as LinearLayout, null, 0, options) + val textNode = ViewHierarchyNode.fromView(ExampleActivity.textView!!, null, 0, options) + val imageNode = ViewHierarchyNode.fromView(ExampleActivity.imageView!!, null, 0, options) + + assertFalse(linearLayoutNode.shouldRedact) + assertTrue(textNode.shouldRedact) + assertTrue(imageNode.shouldRedact) + } +} + +private class CustomView(context: Context) : View(context) { + + override fun onDraw(canvas: Canvas) { + super.onDraw(canvas) + canvas.drawColor(Color.BLACK) + } +} + +private class ExampleActivity : Activity() { + + companion object { + var textView: TextView? = null + var radioButton: RadioButton? = null + var imageView: ImageView? = null + var customView: CustomView? = null + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val linearLayout = LinearLayout(this).apply { + setBackgroundColor(android.R.color.white) + orientation = LinearLayout.VERTICAL + layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT) + } + + textView = TextView(this).apply { + text = "Hello, World!" + layoutParams = LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT) + } + linearLayout.addView(textView) + + val image = this::class.java.classLoader.getResource("Tongariro.jpg")!! + imageView = ImageView(this).apply { + setImageDrawable(Drawable.createFromPath(image.path)) + layoutParams = LayoutParams(50, 50).apply { + setMargins(0, 16, 0, 0) + } + } + linearLayout.addView(imageView) + + radioButton = RadioButton(this).apply { + text = "Radio Button" + layoutParams = LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT).apply { + setMargins(0, 16, 0, 0) + } + } + linearLayout.addView(radioButton) + + customView = CustomView(this).apply { + layoutParams = LayoutParams(50, 50).apply { + setMargins(0, 16, 0, 0) + } + } + linearLayout.addView(customView) + + setContentView(linearLayout) + } +} diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index ceab2fc326..e53d175081 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -2699,16 +2699,18 @@ public final class io/sentry/SentryReplayEvent$ReplayType$Deserializer : io/sent } public final class io/sentry/SentryReplayOptions { + public static final field IMAGE_VIEW_CLASS_NAME Ljava/lang/String; + public static final field TEXT_VIEW_CLASS_NAME Ljava/lang/String; public fun ()V public fun (Ljava/lang/Double;Ljava/lang/Double;)V - public fun addClassToRedact (Ljava/lang/String;)V + public fun addIgnoreViewClass (Ljava/lang/String;)V + public fun addRedactViewClass (Ljava/lang/String;)V public fun getErrorReplayDuration ()J public fun getFrameRate ()I + public fun getIgnoreViewClasses ()Ljava/util/Set; public fun getOnErrorSampleRate ()Ljava/lang/Double; public fun getQuality ()Lio/sentry/SentryReplayOptions$SentryReplayQuality; - public fun getRedactAllImages ()Z - public fun getRedactAllText ()Z - public fun getRedactClasses ()Ljava/util/Set; + public fun getRedactViewClasses ()Ljava/util/Set; public fun getSessionDuration ()J public fun getSessionSampleRate ()Ljava/lang/Double; public fun getSessionSegmentDuration ()J diff --git a/sentry/src/main/java/io/sentry/SentryReplayOptions.java b/sentry/src/main/java/io/sentry/SentryReplayOptions.java index 0024708048..7656b088a1 100644 --- a/sentry/src/main/java/io/sentry/SentryReplayOptions.java +++ b/sentry/src/main/java/io/sentry/SentryReplayOptions.java @@ -9,6 +9,9 @@ public final class SentryReplayOptions { + public static final String TEXT_VIEW_CLASS_NAME = "android.widget.TextView"; + public static final String IMAGE_VIEW_CLASS_NAME = "android.widget.ImageView"; + public enum SentryReplayQuality { /** Video Scale: 80% Bit Rate: 50.000 */ LOW(0.8f, 50_000), @@ -49,30 +52,28 @@ public enum SentryReplayQuality { private @Nullable Double onErrorSampleRate; /** - * Redact all text content. Draws a rectangle of text bounds with text color on top. By default - * only views extending TextView are redacted. + * Redact all views with the specified class names. The class name is the fully qualified class + * name of the view, e.g. android.widget.TextView. The subclasses of the specified classes will be + * redacted as well. * - *

Default is enabled. - */ - private boolean redactAllText = true; - - /** - * Redact all image content. Draws a rectangle of image bounds with image's dominant color on top. - * By default only views extending ImageView with BitmapDrawable or custom Drawable type are - * redacted. ColorDrawable, InsetDrawable, VectorDrawable are all considered non-PII, as they come - * from the apk. + *

If you're using an obfuscation tool, make sure to add the respective proguard rules to keep + * the class names. * - *

Default is enabled. + *

Default is empty. */ - private boolean redactAllImages = true; + private Set redactViewClasses = new CopyOnWriteArraySet<>(); /** - * Redact all views with the specified class names. The class name is the fully qualified class - * name of the view, e.g. android.widget.TextView. + * Ignore all views with the specified class names from redaction. The class name is the fully + * qualified class name of the view, e.g. android.widget.TextView. The subclasses of the specified + * classes will be ignored as well. + * + *

If you're using an obfuscation tool, make sure to add the respective proguard rules to keep + * the class names. * *

Default is empty. */ - private Set redactClasses = new CopyOnWriteArraySet<>(); + private Set ignoreViewClasses = new CopyOnWriteArraySet<>(); /** * Defines the quality of the session replay. The higher the quality, the more accurate the replay @@ -95,10 +96,14 @@ public enum SentryReplayQuality { /** The maximum duration of a full session replay, defaults to 1h. */ private long sessionDuration = 60 * 60 * 1000L; - public SentryReplayOptions() {} + public SentryReplayOptions() { + setRedactAllText(true); + setRedactAllImages(true); + } public SentryReplayOptions( final @Nullable Double sessionSampleRate, final @Nullable Double onErrorSampleRate) { + this(); this.sessionSampleRate = sessionSampleRate; this.onErrorSampleRate = onErrorSampleRate; } @@ -141,28 +146,56 @@ public void setSessionSampleRate(final @Nullable Double sessionSampleRate) { this.sessionSampleRate = sessionSampleRate; } - public boolean getRedactAllText() { - return redactAllText; + /** + * Redact all text content. Draws a rectangle of text bounds with text color on top. By default + * only views extending TextView are redacted. + * + *

Default is enabled. + */ + public void setRedactAllText(final boolean redactAllText) { + if (redactAllText) { + addRedactViewClass(TEXT_VIEW_CLASS_NAME); + ignoreViewClasses.remove(TEXT_VIEW_CLASS_NAME); + } else { + addIgnoreViewClass(TEXT_VIEW_CLASS_NAME); + redactViewClasses.remove(TEXT_VIEW_CLASS_NAME); + } } - public void setRedactAllText(final boolean redactAllText) { - this.redactAllText = redactAllText; + /** + * Redact all image content. Draws a rectangle of image bounds with image's dominant color on top. + * By default only views extending ImageView with BitmapDrawable or custom Drawable type are + * redacted. ColorDrawable, InsetDrawable, VectorDrawable are all considered non-PII, as they come + * from the apk. + * + *

Default is enabled. + */ + public void setRedactAllImages(final boolean redactAllImages) { + if (redactAllImages) { + addRedactViewClass(IMAGE_VIEW_CLASS_NAME); + ignoreViewClasses.remove(IMAGE_VIEW_CLASS_NAME); + } else { + addIgnoreViewClass(IMAGE_VIEW_CLASS_NAME); + redactViewClasses.remove(IMAGE_VIEW_CLASS_NAME); + } } - public boolean getRedactAllImages() { - return redactAllImages; + @NotNull + public Set getRedactViewClasses() { + return this.redactViewClasses; } - public void setRedactAllImages(final boolean redactAllImages) { - this.redactAllImages = redactAllImages; + public void addRedactViewClass(final @NotNull String className) { + this.redactViewClasses.add(className); } - public Set getRedactClasses() { - return this.redactClasses; + @NotNull + public Set getIgnoreViewClasses() { + return this.ignoreViewClasses; } - public void addClassToRedact(final String className) { - this.redactClasses.add(className); + public void addIgnoreViewClass(final @NotNull String className) { + this.ignoreViewClasses.add(className); } @ApiStatus.Internal From 61c8d80abb339b6ed87466e712d5a8d06aa97fba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Denis=20Andra=C5=A1ec?= Date: Tue, 17 Sep 2024 11:44:51 +0200 Subject: [PATCH 36/92] Add support for `feedback` envelope header item type (#3687) --- CHANGELOG.md | 4 + sentry/api/sentry.api | 7 ++ .../main/java/io/sentry/SentryItemType.java | 3 +- .../SentryItemTypeSerializationTest.kt | 74 +++++++++++++++++++ 4 files changed, 87 insertions(+), 1 deletion(-) create mode 100644 sentry/src/test/java/io/sentry/protocol/SentryItemTypeSerializationTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 2835d1525b..4bf79a0739 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Features + +- Add support for `feedback` envelope header item type ([#3687](https://github.com/getsentry/sentry-java/pull/3687)) + ### Fixes - Avoid stopping appStartProfiler after application creation ([#3630](https://github.com/getsentry/sentry-java/pull/3630)) diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index e53d175081..3cf11a434d 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -2247,6 +2247,7 @@ public final class io/sentry/SentryItemType : java/lang/Enum, io/sentry/JsonSeri public static final field CheckIn Lio/sentry/SentryItemType; public static final field ClientReport Lio/sentry/SentryItemType; public static final field Event Lio/sentry/SentryItemType; + public static final field Feedback Lio/sentry/SentryItemType; public static final field Profile Lio/sentry/SentryItemType; public static final field ReplayEvent Lio/sentry/SentryItemType; public static final field ReplayRecording Lio/sentry/SentryItemType; @@ -2264,6 +2265,12 @@ public final class io/sentry/SentryItemType : java/lang/Enum, io/sentry/JsonSeri public static fun values ()[Lio/sentry/SentryItemType; } +public final class io/sentry/SentryItemType$Deserializer : io/sentry/JsonDeserializer { + public fun ()V + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/SentryItemType; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; +} + public final class io/sentry/SentryLevel : java/lang/Enum, io/sentry/JsonSerializable { public static final field DEBUG Lio/sentry/SentryLevel; public static final field ERROR Lio/sentry/SentryLevel; diff --git a/sentry/src/main/java/io/sentry/SentryItemType.java b/sentry/src/main/java/io/sentry/SentryItemType.java index f37b972454..128b0888ba 100644 --- a/sentry/src/main/java/io/sentry/SentryItemType.java +++ b/sentry/src/main/java/io/sentry/SentryItemType.java @@ -21,6 +21,7 @@ public enum SentryItemType implements JsonSerializable { ReplayVideo("replay_video"), CheckIn("check_in"), Statsd("statsd"), + Feedback("feedback"), Unknown("__unknown__"); // DataCategory.Unknown private final String itemType; @@ -62,7 +63,7 @@ public void serialize(final @NotNull ObjectWriter writer, final @NotNull ILogger writer.value(itemType); } - static final class Deserializer implements JsonDeserializer { + public static final class Deserializer implements JsonDeserializer { @Override public @NotNull SentryItemType deserialize( diff --git a/sentry/src/test/java/io/sentry/protocol/SentryItemTypeSerializationTest.kt b/sentry/src/test/java/io/sentry/protocol/SentryItemTypeSerializationTest.kt new file mode 100644 index 0000000000..c50a12b80d --- /dev/null +++ b/sentry/src/test/java/io/sentry/protocol/SentryItemTypeSerializationTest.kt @@ -0,0 +1,74 @@ +package io.sentry.protocol + +import io.sentry.ILogger +import io.sentry.JsonObjectReader +import io.sentry.JsonObjectWriter +import io.sentry.SentryItemType +import org.junit.Test +import org.mockito.kotlin.mock +import java.io.StringReader +import java.io.StringWriter +import kotlin.test.assertEquals + +class SentryItemTypeSerializationTest { + + class Fixture { + val logger = mock() + } + private val fixture = Fixture() + + @Test + fun serialize() { + assertEquals(serialize(SentryItemType.Session), json("session")) + assertEquals(serialize(SentryItemType.Event), json("event")) + assertEquals(serialize(SentryItemType.UserFeedback), json("user_report")) + assertEquals(serialize(SentryItemType.Attachment), json("attachment")) + assertEquals(serialize(SentryItemType.Transaction), json("transaction")) + assertEquals(serialize(SentryItemType.Profile), json("profile")) + assertEquals(serialize(SentryItemType.ClientReport), json("client_report")) + assertEquals(serialize(SentryItemType.ReplayEvent), json("replay_event")) + assertEquals(serialize(SentryItemType.ReplayRecording), json("replay_recording")) + assertEquals(serialize(SentryItemType.ReplayVideo), json("replay_video")) + assertEquals(serialize(SentryItemType.CheckIn), json("check_in")) + assertEquals(serialize(SentryItemType.Statsd), json("statsd")) + assertEquals(serialize(SentryItemType.Feedback), json("feedback")) + } + + @Test + fun deserialize() { + assertEquals(deserialize(json("session")), SentryItemType.Session) + assertEquals(deserialize(json("event")), SentryItemType.Event) + assertEquals(deserialize(json("user_report")), SentryItemType.UserFeedback) + assertEquals(deserialize(json("attachment")), SentryItemType.Attachment) + assertEquals(deserialize(json("transaction")), SentryItemType.Transaction) + assertEquals(deserialize(json("profile")), SentryItemType.Profile) + assertEquals(deserialize(json("client_report")), SentryItemType.ClientReport) + assertEquals(deserialize(json("replay_event")), SentryItemType.ReplayEvent) + assertEquals(deserialize(json("replay_recording")), SentryItemType.ReplayRecording) + assertEquals(deserialize(json("replay_video")), SentryItemType.ReplayVideo) + assertEquals(deserialize(json("check_in")), SentryItemType.CheckIn) + assertEquals(deserialize(json("statsd")), SentryItemType.Statsd) + assertEquals(deserialize(json("feedback")), SentryItemType.Feedback) + } + + private fun json(type: String): String { + return "{\"type\":\"${type}\"}" + } + + private fun serialize(src: SentryItemType): String { + val wrt = StringWriter() + val jsonWrt = JsonObjectWriter(wrt, 100) + jsonWrt.beginObject() + jsonWrt.name("type") + src.serialize(jsonWrt, fixture.logger) + jsonWrt.endObject() + return wrt.toString() + } + + private fun deserialize(json: String): SentryItemType { + val reader = JsonObjectReader(StringReader(json)) + reader.beginObject() + reader.nextName() + return SentryItemType.Deserializer().deserialize(reader, fixture.logger) + } +} From 6c8acb80b8a3a1991a7f614347c12c460a90c746 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 25 Sep 2024 16:40:29 +0200 Subject: [PATCH 37/92] Bump github/codeql-action from 3.26.7 to 3.26.8 (#3708) Bumps [github/codeql-action](https://github.com/github/codeql-action) from 3.26.7 to 3.26.8. - [Release notes](https://github.com/github/codeql-action/releases) - [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/github/codeql-action/compare/8214744c546c1e5c8f03dde8fab3a7353211988d...294a9d92911152fe08befb9ec03e240add280cb3) --- updated-dependencies: - dependency-name: github/codeql-action 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> --- .github/workflows/codeql-analysis.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index fe85514b4a..fa0bbbff64 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -41,7 +41,7 @@ jobs: gradle-home-cache-cleanup: true - name: Initialize CodeQL - uses: github/codeql-action/init@8214744c546c1e5c8f03dde8fab3a7353211988d # pin@v2 + uses: github/codeql-action/init@294a9d92911152fe08befb9ec03e240add280cb3 # pin@v2 with: languages: ${{ matrix.language }} @@ -55,4 +55,4 @@ jobs: ./gradlew buildForCodeQL - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@8214744c546c1e5c8f03dde8fab3a7353211988d # pin@v2 + uses: github/codeql-action/analyze@294a9d92911152fe08befb9ec03e240add280cb3 # pin@v2 From b11dc55cfc01379e481be60de78b512191128a25 Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Thu, 26 Sep 2024 16:13:42 +0200 Subject: [PATCH 38/92] Ensure app context is used even when SDK is initialized via Activity Context (#3669) * Ensure app context is used even when SDK is initialized via Activity Context * Update Changelog * Exclude saucelabs from leakcanary * Exclude saucelabs TouchListener from leakCanary * Allow leakcanary for non-debug ui-test builds * Fix lint --- CHANGELOG.md | 1 + buildSrc/src/main/java/Config.kt | 3 +- .../api/sentry-android-core.api | 1 + .../core/AndroidOptionsInitializer.java | 5 +- .../core/AndroidTransactionProfiler.java | 4 +- .../sentry/android/core/AnrIntegration.java | 2 +- .../android/core/AnrV2EventProcessor.java | 2 +- .../sentry/android/core/AnrV2Integration.java | 2 +- .../AppComponentsBreadcrumbsIntegration.java | 3 +- .../io/sentry/android/core/ContextUtils.java | 15 ++++++ .../core/DefaultAndroidEventProcessor.java | 6 ++- .../sentry/android/core/DeviceInfoUtil.java | 2 +- .../core/NetworkBreadcrumbsIntegration.java | 3 +- .../PhoneStateBreadcrumbsIntegration.java | 3 +- .../core/SentryPerformanceProvider.java | 5 +- .../SystemEventsBreadcrumbsIntegration.java | 3 +- .../TempSensorBreadcrumbsIntegration.java | 3 +- .../debugmeta/AssetsDebugMetaLoader.java | 3 +- .../internal/modules/AssetsModulesLoader.java | 3 +- .../util/AndroidConnectionStatusProvider.java | 3 +- .../util/SentryFrameMetricsCollector.java | 9 ++-- .../sentry/android/core/ContextUtilsTest.kt | 18 +++++++ .../sentry/android/core/SentryAndroidTest.kt | 2 +- .../sentry-uitest-android/build.gradle.kts | 2 + .../io/sentry/uitest/android/BaseUiTest.kt | 1 + .../io/sentry/uitest/android/SdkInitTests.kt | 52 +++++++++++++++++-- .../src/main/res/values/values.xml | 4 ++ .../android/replay/ReplayIntegration.kt | 5 +- .../io/sentry/android/replay/util/Context.kt | 5 ++ 29 files changed, 138 insertions(+), 32 deletions(-) create mode 100644 sentry-android-integration-tests/sentry-uitest-android/src/main/res/values/values.xml create mode 100644 sentry-android-replay/src/main/java/io/sentry/android/replay/util/Context.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 4bf79a0739..21ac3f0b3e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ - redact/ignore `View`s of a certain type by adding fully-qualified classname to one of the lists `options.experimental.sessionReplay.addRedactViewClass()` or `options.experimental.sessionReplay.addIgnoreViewClass()`. Note, that all of the view subclasses/subtypes will be redacted/ignored as well - For example, (this is already a default behavior) to redact all `TextView`s and their subclasses (`RadioButton`, `EditText`, etc.): `options.experimental.sessionReplay.addRedactViewClass("android.widget.TextView")` - If you're using code obfuscation, adjust your proguard-rules accordingly, so your custom view class name is not minified +- Fix ensure Application Context is used even when SDK is initialized via Activity Context ([#3669](https://github.com/getsentry/sentry-java/pull/3669)) *Breaking changes*: diff --git a/buildSrc/src/main/java/Config.kt b/buildSrc/src/main/java/Config.kt index 8777d926a9..f74fcb4953 100644 --- a/buildSrc/src/main/java/Config.kt +++ b/buildSrc/src/main/java/Config.kt @@ -53,7 +53,7 @@ object Config { val appCompat = "androidx.appcompat:appcompat:1.3.0" val timber = "com.jakewharton.timber:timber:4.7.1" val okhttp = "com.squareup.okhttp3:okhttp:$okHttpVersion" - val leakCanary = "com.squareup.leakcanary:leakcanary-android:2.8.1" + val leakCanary = "com.squareup.leakcanary:leakcanary-android:2.14" val constraintLayout = "androidx.constraintlayout:constraintlayout:2.1.3" private val lifecycleVersion = "2.2.0" @@ -197,6 +197,7 @@ object Config { val hsqldb = "org.hsqldb:hsqldb:2.6.1" val javaFaker = "com.github.javafaker:javafaker:1.0.2" val msgpack = "org.msgpack:msgpack-core:0.9.8" + val leakCanaryInstrumentation = "com.squareup.leakcanary:leakcanary-android-instrumentation:2.14" } object QualityPlugins { diff --git a/sentry-android-core/api/sentry-android-core.api b/sentry-android-core/api/sentry-android-core.api index 478a1ddd3c..f525e056f6 100644 --- a/sentry-android-core/api/sentry-android-core.api +++ b/sentry-android-core/api/sentry-android-core.api @@ -158,6 +158,7 @@ public final class io/sentry/android/core/BuildInfoProvider { } public final class io/sentry/android/core/ContextUtils { + public static fun getApplicationContext (Landroid/content/Context;)Landroid/content/Context; public static fun isForegroundImportance ()Z } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java index 2d559fd781..d5dfce77b2 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java @@ -90,10 +90,7 @@ static void loadDefaultAndMetadataOptions( final @NotNull BuildInfoProvider buildInfoProvider) { Objects.requireNonNull(context, "The context is required."); - // it returns null if ContextImpl, so let's check for nullability - if (context.getApplicationContext() != null) { - context = context.getApplicationContext(); - } + context = ContextUtils.getApplicationContext(context); Objects.requireNonNull(options, "The options object is required."); Objects.requireNonNull(logger, "The ILogger object is required."); diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidTransactionProfiler.java b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidTransactionProfiler.java index d9ece7fb46..41e57a886a 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidTransactionProfiler.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidTransactionProfiler.java @@ -87,7 +87,9 @@ public AndroidTransactionProfiler( final boolean isProfilingEnabled, final int profilingTracesHz, final @NotNull ISentryExecutorService executorService) { - this.context = Objects.requireNonNull(context, "The application context is required"); + this.context = + Objects.requireNonNull( + ContextUtils.getApplicationContext(context), "The application context is required"); this.logger = Objects.requireNonNull(logger, "ILogger is required"); this.frameMetricsCollector = Objects.requireNonNull(frameMetricsCollector, "SentryFrameMetricsCollector is required"); diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AnrIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/AnrIntegration.java index 0ad2c242da..1c7b0f2eaf 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/AnrIntegration.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AnrIntegration.java @@ -33,7 +33,7 @@ public final class AnrIntegration implements Integration, Closeable { private final @NotNull Object startLock = new Object(); public AnrIntegration(final @NotNull Context context) { - this.context = context; + this.context = ContextUtils.getApplicationContext(context); } /** diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2EventProcessor.java b/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2EventProcessor.java index b1751d5cc8..d58d04b7f8 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2EventProcessor.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2EventProcessor.java @@ -97,7 +97,7 @@ public AnrV2EventProcessor( final @NotNull SentryAndroidOptions options, final @NotNull BuildInfoProvider buildInfoProvider, final @Nullable SecureRandom random) { - this.context = context; + this.context = ContextUtils.getApplicationContext(context); this.options = options; this.buildInfoProvider = buildInfoProvider; this.random = random; diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2Integration.java b/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2Integration.java index 669233bb09..b6be55e90a 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2Integration.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2Integration.java @@ -63,7 +63,7 @@ public AnrV2Integration(final @NotNull Context context) { AnrV2Integration( final @NotNull Context context, final @NotNull ICurrentDateProvider dateProvider) { - this.context = context; + this.context = ContextUtils.getApplicationContext(context); this.dateProvider = dateProvider; } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AppComponentsBreadcrumbsIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/AppComponentsBreadcrumbsIntegration.java index eef4707683..97c7d06ce7 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/AppComponentsBreadcrumbsIntegration.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AppComponentsBreadcrumbsIntegration.java @@ -29,7 +29,8 @@ public final class AppComponentsBreadcrumbsIntegration private @Nullable SentryAndroidOptions options; public AppComponentsBreadcrumbsIntegration(final @NotNull Context context) { - this.context = Objects.requireNonNull(context, "Context is required"); + this.context = + Objects.requireNonNull(ContextUtils.getApplicationContext(context), "Context is required"); } @Override diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ContextUtils.java b/sentry-android-core/src/main/java/io/sentry/android/core/ContextUtils.java index 2e76de4d12..89fe856631 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/ContextUtils.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ContextUtils.java @@ -383,4 +383,19 @@ static void setAppPackageInfo( } app.setPermissions(permissions); } + + /** + * Get the app context + * + * @return the app context, or if not available, the provided context + */ + @NotNull + public static Context getApplicationContext(final @NotNull Context context) { + // it returns null if ContextImpl, so let's check for nullability + final @Nullable Context appContext = context.getApplicationContext(); + if (appContext != null) { + return appContext; + } + return context; + } } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/DefaultAndroidEventProcessor.java b/sentry-android-core/src/main/java/io/sentry/android/core/DefaultAndroidEventProcessor.java index 5ef35cbfe1..a2833d2b34 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/DefaultAndroidEventProcessor.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/DefaultAndroidEventProcessor.java @@ -47,7 +47,9 @@ public DefaultAndroidEventProcessor( final @NotNull Context context, final @NotNull BuildInfoProvider buildInfoProvider, final @NotNull SentryAndroidOptions options) { - this.context = Objects.requireNonNull(context, "The application context is required."); + this.context = + Objects.requireNonNull( + ContextUtils.getApplicationContext(context), "The application context is required."); this.buildInfoProvider = Objects.requireNonNull(buildInfoProvider, "The BuildInfoProvider is required."); this.options = Objects.requireNonNull(options, "The options object is required."); @@ -57,7 +59,7 @@ public DefaultAndroidEventProcessor( // some device info performs disk I/O, but it's result is cached, let's pre-cache it final @NotNull ExecutorService executorService = Executors.newSingleThreadExecutor(); this.deviceInfoUtil = - executorService.submit(() -> DeviceInfoUtil.getInstance(context, options)); + executorService.submit(() -> DeviceInfoUtil.getInstance(this.context, options)); executorService.shutdown(); } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/DeviceInfoUtil.java b/sentry-android-core/src/main/java/io/sentry/android/core/DeviceInfoUtil.java index f1debc5d23..e2dfee2705 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/DeviceInfoUtil.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/DeviceInfoUtil.java @@ -76,7 +76,7 @@ public static DeviceInfoUtil getInstance( if (instance == null) { synchronized (DeviceInfoUtil.class) { if (instance == null) { - instance = new DeviceInfoUtil(context.getApplicationContext(), options); + instance = new DeviceInfoUtil(ContextUtils.getApplicationContext(context), options); } } } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/NetworkBreadcrumbsIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/NetworkBreadcrumbsIntegration.java index 1cd42e9dab..e30dfb681c 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/NetworkBreadcrumbsIntegration.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/NetworkBreadcrumbsIntegration.java @@ -42,7 +42,8 @@ public NetworkBreadcrumbsIntegration( final @NotNull Context context, final @NotNull BuildInfoProvider buildInfoProvider, final @NotNull ILogger logger) { - this.context = Objects.requireNonNull(context, "Context is required"); + this.context = + Objects.requireNonNull(ContextUtils.getApplicationContext(context), "Context is required"); this.buildInfoProvider = Objects.requireNonNull(buildInfoProvider, "BuildInfoProvider is required"); this.logger = Objects.requireNonNull(logger, "ILogger is required"); diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/PhoneStateBreadcrumbsIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/PhoneStateBreadcrumbsIntegration.java index c10d25b057..2da0452698 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/PhoneStateBreadcrumbsIntegration.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/PhoneStateBreadcrumbsIntegration.java @@ -28,7 +28,8 @@ public final class PhoneStateBreadcrumbsIntegration implements Integration, Clos private final @NotNull Object startLock = new Object(); public PhoneStateBreadcrumbsIntegration(final @NotNull Context context) { - this.context = Objects.requireNonNull(context, "Context is required"); + this.context = + Objects.requireNonNull(ContextUtils.getApplicationContext(context), "Context is required"); } @Override diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/SentryPerformanceProvider.java b/sentry-android-core/src/main/java/io/sentry/android/core/SentryPerformanceProvider.java index 2ad465f1e3..971ead378f 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/SentryPerformanceProvider.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/SentryPerformanceProvider.java @@ -159,10 +159,9 @@ private void launchAppStartProfiler(final @NotNull AppStartMetrics appStartMetri final @NotNull ITransactionProfiler appStartProfiler = new AndroidTransactionProfiler( - context.getApplicationContext(), + context, buildInfoProvider, - new SentryFrameMetricsCollector( - context.getApplicationContext(), logger, buildInfoProvider), + new SentryFrameMetricsCollector(context, logger, buildInfoProvider), logger, profilingOptions.getProfilingTracesDirPath(), profilingOptions.isProfilingEnabled(), diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/SystemEventsBreadcrumbsIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/SystemEventsBreadcrumbsIntegration.java index dcd92e8bf8..f196b7ca90 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/SystemEventsBreadcrumbsIntegration.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/SystemEventsBreadcrumbsIntegration.java @@ -77,7 +77,8 @@ public SystemEventsBreadcrumbsIntegration(final @NotNull Context context) { public SystemEventsBreadcrumbsIntegration( final @NotNull Context context, final @NotNull List actions) { - this.context = Objects.requireNonNull(context, "Context is required"); + this.context = + Objects.requireNonNull(ContextUtils.getApplicationContext(context), "Context is required"); this.actions = Objects.requireNonNull(actions, "Actions list is required"); } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/TempSensorBreadcrumbsIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/TempSensorBreadcrumbsIntegration.java index eaf5c64991..41e1860184 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/TempSensorBreadcrumbsIntegration.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/TempSensorBreadcrumbsIntegration.java @@ -34,7 +34,8 @@ public final class TempSensorBreadcrumbsIntegration private final @NotNull Object startLock = new Object(); public TempSensorBreadcrumbsIntegration(final @NotNull Context context) { - this.context = Objects.requireNonNull(context, "Context is required"); + this.context = + Objects.requireNonNull(ContextUtils.getApplicationContext(context), "Context is required"); } @Override diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/internal/debugmeta/AssetsDebugMetaLoader.java b/sentry-android-core/src/main/java/io/sentry/android/core/internal/debugmeta/AssetsDebugMetaLoader.java index 6c5bed5ae2..568b67f0b0 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/internal/debugmeta/AssetsDebugMetaLoader.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/internal/debugmeta/AssetsDebugMetaLoader.java @@ -6,6 +6,7 @@ import android.content.res.AssetManager; import io.sentry.ILogger; import io.sentry.SentryLevel; +import io.sentry.android.core.ContextUtils; import io.sentry.internal.debugmeta.IDebugMetaLoader; import java.io.BufferedInputStream; import java.io.FileNotFoundException; @@ -24,7 +25,7 @@ public final class AssetsDebugMetaLoader implements IDebugMetaLoader { private final @NotNull ILogger logger; public AssetsDebugMetaLoader(final @NotNull Context context, final @NotNull ILogger logger) { - this.context = context; + this.context = ContextUtils.getApplicationContext(context); this.logger = logger; } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/internal/modules/AssetsModulesLoader.java b/sentry-android-core/src/main/java/io/sentry/android/core/internal/modules/AssetsModulesLoader.java index 6d6f3737cb..b6374a32e3 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/internal/modules/AssetsModulesLoader.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/internal/modules/AssetsModulesLoader.java @@ -3,6 +3,7 @@ import android.content.Context; import io.sentry.ILogger; import io.sentry.SentryLevel; +import io.sentry.android.core.ContextUtils; import io.sentry.internal.modules.ModulesLoader; import java.io.FileNotFoundException; import java.io.IOException; @@ -19,7 +20,7 @@ public final class AssetsModulesLoader extends ModulesLoader { public AssetsModulesLoader(final @NotNull Context context, final @NotNull ILogger logger) { super(logger); - this.context = context; + this.context = ContextUtils.getApplicationContext(context); } @Override diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/AndroidConnectionStatusProvider.java b/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/AndroidConnectionStatusProvider.java index b8279edcb1..0afd2bce97 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/AndroidConnectionStatusProvider.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/AndroidConnectionStatusProvider.java @@ -12,6 +12,7 @@ import io.sentry.ILogger; import io.sentry.SentryLevel; import io.sentry.android.core.BuildInfoProvider; +import io.sentry.android.core.ContextUtils; import java.util.HashMap; import java.util.Map; import org.jetbrains.annotations.ApiStatus; @@ -37,7 +38,7 @@ public AndroidConnectionStatusProvider( @NotNull Context context, @NotNull ILogger logger, @NotNull BuildInfoProvider buildInfoProvider) { - this.context = context; + this.context = ContextUtils.getApplicationContext(context); this.logger = logger; this.buildInfoProvider = buildInfoProvider; this.registeredCallbacks = new HashMap<>(); diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/SentryFrameMetricsCollector.java b/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/SentryFrameMetricsCollector.java index 27731e48cf..25ff5da2bd 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/SentryFrameMetricsCollector.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/SentryFrameMetricsCollector.java @@ -17,6 +17,7 @@ import io.sentry.SentryLevel; import io.sentry.SentryOptions; import io.sentry.android.core.BuildInfoProvider; +import io.sentry.android.core.ContextUtils; import io.sentry.util.Objects; import java.lang.ref.WeakReference; import java.lang.reflect.Field; @@ -84,7 +85,9 @@ public SentryFrameMetricsCollector( final @NotNull ILogger logger, final @NotNull BuildInfoProvider buildInfoProvider, final @NotNull WindowFrameMetricsManager windowFrameMetricsManager) { - Objects.requireNonNull(context, "The context is required"); + final @NotNull Context appContext = + Objects.requireNonNull( + ContextUtils.getApplicationContext(context), "The context is required"); this.logger = Objects.requireNonNull(logger, "Logger is required"); this.buildInfoProvider = Objects.requireNonNull(buildInfoProvider, "BuildInfoProvider is required"); @@ -92,7 +95,7 @@ public SentryFrameMetricsCollector( Objects.requireNonNull(windowFrameMetricsManager, "WindowFrameMetricsManager is required"); // registerActivityLifecycleCallbacks is only available if Context is an AppContext - if (!(context instanceof Application)) { + if (!(appContext instanceof Application)) { return; } // FrameMetrics api is only available since sdk version N @@ -110,7 +113,7 @@ public SentryFrameMetricsCollector( // We have to register the lifecycle callback, even if no profile is started, otherwise when we // start a profile, we wouldn't have the current activity and couldn't get the frameMetrics. - ((Application) context).registerActivityLifecycleCallbacks(this); + ((Application) appContext).registerActivityLifecycleCallbacks(this); // Most considerations regarding timestamps of frames are inspired from JankStats library: // https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:metrics/metrics-performance/src/main/java/androidx/metrics/performance/JankStatsApi24Impl.kt diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/ContextUtilsTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/ContextUtilsTest.kt index b758fae1f8..588a32a656 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/ContextUtilsTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/ContextUtilsTest.kt @@ -29,6 +29,7 @@ import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertNotNull import kotlin.test.assertNull +import kotlin.test.assertSame import kotlin.test.assertTrue @Config(sdk = [33]) @@ -213,4 +214,21 @@ class ContextUtilsTest { ) assertFalse(ContextUtils.isForegroundImportance()) } + + @Test + fun `getApplicationContext returns context if app context is null`() { + val contextMock = mock() + val appContext = ContextUtils.getApplicationContext(contextMock) + assertSame(contextMock, appContext) + } + + @Test + fun `getApplicationContext returns app context`() { + val contextMock = mock() + val appContextMock = mock() + whenever(contextMock.applicationContext).thenReturn(appContextMock) + + val appContext = ContextUtils.getApplicationContext(contextMock) + assertSame(appContextMock, appContext) + } } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidTest.kt index 18b8407a5e..6ba69ffdcb 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidTest.kt @@ -362,7 +362,7 @@ class SentryAndroidTest { optionsConfig: (SentryAndroidOptions) -> Unit = {}, callback: (session: Session?) -> Unit ) { - Mockito.mockStatic(ContextUtils::class.java).use { mockedContextUtils -> + Mockito.mockStatic(ContextUtils::class.java, Mockito.CALLS_REAL_METHODS).use { mockedContextUtils -> mockedContextUtils.`when` { ContextUtils.isForegroundImportance() } .thenReturn(inForeground) SentryAndroid.init(context) { options -> diff --git a/sentry-android-integration-tests/sentry-uitest-android/build.gradle.kts b/sentry-android-integration-tests/sentry-uitest-android/build.gradle.kts index 98b00eb4f8..7755166a4e 100644 --- a/sentry-android-integration-tests/sentry-uitest-android/build.gradle.kts +++ b/sentry-android-integration-tests/sentry-uitest-android/build.gradle.kts @@ -109,6 +109,7 @@ dependencies { implementation(Config.Libs.androidxRecylerView) implementation(Config.Libs.constraintLayout) implementation(Config.TestLibs.espressoIdlingResource) + implementation(Config.Libs.leakCanary) compileOnly(Config.CompileOnly.nopen) errorprone(Config.CompileOnly.nopenChecker) @@ -123,6 +124,7 @@ dependencies { androidTestImplementation(Config.TestLibs.androidxTestCoreKtx) androidTestImplementation(Config.TestLibs.mockWebserver) androidTestImplementation(Config.TestLibs.androidxJunit) + androidTestImplementation(Config.TestLibs.leakCanaryInstrumentation) androidTestUtil(Config.TestLibs.androidxTestOrchestrator) } diff --git a/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/BaseUiTest.kt b/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/BaseUiTest.kt index d6d3d1b666..bfac0c3b5f 100644 --- a/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/BaseUiTest.kt +++ b/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/BaseUiTest.kt @@ -77,6 +77,7 @@ abstract class BaseUiTest { */ protected fun initSentry( relayWaitForRequests: Boolean = false, + context: Context = this.context, optionsConfiguration: ((options: SentryAndroidOptions) -> Unit)? = null ) { relay.waitForRequests = relayWaitForRequests diff --git a/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/SdkInitTests.kt b/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/SdkInitTests.kt index c942783548..cd7c60ac94 100644 --- a/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/SdkInitTests.kt +++ b/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/SdkInitTests.kt @@ -9,7 +9,12 @@ import io.sentry.android.core.AndroidLogger import io.sentry.android.core.SentryAndroidOptions import io.sentry.assertEnvelopeTransaction import io.sentry.protocol.SentryTransaction +import leakcanary.LeakAssertions +import leakcanary.LeakCanary import org.junit.runner.RunWith +import shark.AndroidReferenceMatchers +import shark.IgnoredReferenceMatcher +import shark.ReferencePattern import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertTrue @@ -63,7 +68,10 @@ class SdkInitTests : BaseUiTest() { relay.assert { findEnvelope { - assertEnvelopeTransaction(it.items.toList(), AndroidLogger()).transaction == "e2etests2" + assertEnvelopeTransaction( + it.items.toList(), + AndroidLogger() + ).transaction == "e2etests2" }.assert { val transactionItem: SentryTransaction = it.assertTransaction() // Profiling uses executorService, so if the executorService is shutdown it would fail @@ -105,7 +113,10 @@ class SdkInitTests : BaseUiTest() { Sentry.startTransaction("afterRestart", "emptyTransaction").finish() // We assert for less than 1 second just to account for slow devices in saucelabs or headless emulator - assertTrue(restartMs < 1000, "Expected less than 1000 ms for SDK restart. Got $restartMs ms") + assertTrue( + restartMs < 1000, + "Expected less than 1000 ms for SDK restart. Got $restartMs ms" + ) relay.assert { findEnvelope { @@ -152,6 +163,41 @@ class SdkInitTests : BaseUiTest() { } val afterRestart = System.currentTimeMillis() val restartMs = afterRestart - beforeRestart - assertTrue(restartMs > 3000, "Expected more than 3000 ms for SDK close and restart. Got $restartMs ms") + assertTrue( + restartMs > 3000, + "Expected more than 3000 ms for SDK close and restart. Got $restartMs ms" + ) + } + + @Test + fun initViaActivityDoesNotLeak() { + LeakCanary.config = LeakCanary.config.copy( + referenceMatchers = AndroidReferenceMatchers.appDefaults + + listOf( + IgnoredReferenceMatcher( + ReferencePattern.InstanceFieldPattern( + "com.saucelabs.rdcinjector.testfairy.TestFairyEventQueue", + "context" + ) + ), + IgnoredReferenceMatcher( + ReferencePattern.StaticFieldPattern( + "com.testfairy.modules.capture.TouchListener", + "k" + ) + ) + ) + ) + + val activityScenario = launchActivity() + activityScenario.moveToState(Lifecycle.State.RESUMED) + + activityScenario.onActivity { activity -> + initSentry(context = activity) + } + + activityScenario.moveToState(Lifecycle.State.DESTROYED) + + LeakAssertions.assertNoLeaks() } } diff --git a/sentry-android-integration-tests/sentry-uitest-android/src/main/res/values/values.xml b/sentry-android-integration-tests/sentry-uitest-android/src/main/res/values/values.xml new file mode 100644 index 0000000000..7763c15cd1 --- /dev/null +++ b/sentry-android-integration-tests/sentry-uitest-android/src/main/res/values/values.xml @@ -0,0 +1,4 @@ + + + true + diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt index e261411815..82d20cf3c1 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt @@ -23,6 +23,7 @@ import io.sentry.android.replay.capture.SessionCaptureStrategy import io.sentry.android.replay.gestures.GestureRecorder import io.sentry.android.replay.gestures.TouchRecorderCallback import io.sentry.android.replay.util.MainLooperHandler +import io.sentry.android.replay.util.appContext import io.sentry.android.replay.util.sample import io.sentry.android.replay.util.submitSafely import io.sentry.cache.PersistingScopeObserver @@ -51,7 +52,7 @@ public class ReplayIntegration( // needed for the Java's call site constructor(context: Context, dateProvider: ICurrentDateProvider) : this( - context, + context.appContext(), dateProvider, null, null, @@ -67,7 +68,7 @@ public class ReplayIntegration( replayCaptureStrategyProvider: ((isFullSession: Boolean) -> CaptureStrategy)? = null, mainLooperHandler: MainLooperHandler? = null, gestureRecorderProvider: (() -> GestureRecorder)? = null - ) : this(context, dateProvider, recorderProvider, recorderConfigProvider, replayCacheProvider) { + ) : this(context.appContext(), dateProvider, recorderProvider, recorderConfigProvider, replayCacheProvider) { this.replayCaptureStrategyProvider = replayCaptureStrategyProvider this.mainLooperHandler = mainLooperHandler ?: MainLooperHandler() this.gestureRecorderProvider = gestureRecorderProvider diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Context.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Context.kt new file mode 100644 index 0000000000..3c5be33115 --- /dev/null +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Context.kt @@ -0,0 +1,5 @@ +package io.sentry.android.replay.util + +import android.content.Context + +internal fun Context.appContext() = this.applicationContext ?: this From 7e57220ee4ffa7af590fff675708884c4a07370c Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Tue, 1 Oct 2024 14:48:10 +0300 Subject: [PATCH 39/92] Adds breadcrumb origin field (#3727) * Adds breadcrumb origin field * Updates api dump * Updates test cases * Updates serialisation tests * Adds changelog * Exclude testfairy from obfuscation * Revert "Exclude testfairy from obfuscation" This reverts commit 9900d1e61ab818716c526f6a58c643d555d9f97d. * Exclude any testfairy touchlistener static fields from LeakCanary --------- Co-authored-by: Markus Hintersteiner --- CHANGELOG.md | 1 + .../io/sentry/uitest/android/SdkInitTests.kt | 13 +++--- sentry/api/sentry.api | 3 ++ .../src/main/java/io/sentry/Breadcrumb.java | 42 ++++++++++++++++++- .../src/test/java/io/sentry/BreadcrumbTest.kt | 6 +++ .../protocol/BreadcrumbSerializationTest.kt | 3 ++ .../src/test/resources/json/breadcrumb.json | 1 + .../resources/json/sentry_base_event.json | 1 + .../sentry_base_event_with_null_extra.json | 1 + .../src/test/resources/json/sentry_event.json | 1 + .../resources/json/sentry_transaction.json | 1 + ...sentry_transaction_legacy_date_format.json | 1 + ...entry_transaction_no_measurement_unit.json | 1 + 13 files changed, 68 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 21ac3f0b3e..49f43332c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### Features - Add support for `feedback` envelope header item type ([#3687](https://github.com/getsentry/sentry-java/pull/3687)) +- Add breadcrumb.origin field ([#3727](https://github.com/getsentry/sentry-java/pull/3727)) ### Fixes diff --git a/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/SdkInitTests.kt b/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/SdkInitTests.kt index cd7c60ac94..cb6c772bb9 100644 --- a/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/SdkInitTests.kt +++ b/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/SdkInitTests.kt @@ -179,14 +179,15 @@ class SdkInitTests : BaseUiTest() { "com.saucelabs.rdcinjector.testfairy.TestFairyEventQueue", "context" ) - ), - IgnoredReferenceMatcher( - ReferencePattern.StaticFieldPattern( - "com.testfairy.modules.capture.TouchListener", - "k" - ) + ) + ) + ('a'..'z').map { char -> + IgnoredReferenceMatcher( + ReferencePattern.StaticFieldPattern( + "com.testfairy.modules.capture.TouchListener", + "$char" ) ) + } ) val activityScenario = launchActivity() diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 3cf11a434d..5f8b061acf 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -109,6 +109,7 @@ public final class io/sentry/Breadcrumb : io/sentry/JsonSerializable, io/sentry/ public fun getData (Ljava/lang/String;)Ljava/lang/Object; public fun getLevel ()Lio/sentry/SentryLevel; public fun getMessage ()Ljava/lang/String; + public fun getOrigin ()Ljava/lang/String; public fun getTimestamp ()Ljava/util/Date; public fun getType ()Ljava/lang/String; public fun getUnknown ()Ljava/util/Map; @@ -127,6 +128,7 @@ public final class io/sentry/Breadcrumb : io/sentry/JsonSerializable, io/sentry/ public fun setData (Ljava/lang/String;Ljava/lang/Object;)V public fun setLevel (Lio/sentry/SentryLevel;)V public fun setMessage (Ljava/lang/String;)V + public fun setOrigin (Ljava/lang/String;)V public fun setType (Ljava/lang/String;)V public fun setUnknown (Ljava/util/Map;)V public static fun transaction (Ljava/lang/String;)Lio/sentry/Breadcrumb; @@ -148,6 +150,7 @@ public final class io/sentry/Breadcrumb$JsonKeys { public static final field DATA Ljava/lang/String; public static final field LEVEL Ljava/lang/String; public static final field MESSAGE Ljava/lang/String; + public static final field ORIGIN Ljava/lang/String; public static final field TIMESTAMP Ljava/lang/String; public static final field TYPE Ljava/lang/String; public fun ()V diff --git a/sentry/src/main/java/io/sentry/Breadcrumb.java b/sentry/src/main/java/io/sentry/Breadcrumb.java index da1453bc68..954c57c36a 100644 --- a/sentry/src/main/java/io/sentry/Breadcrumb.java +++ b/sentry/src/main/java/io/sentry/Breadcrumb.java @@ -34,6 +34,12 @@ public final class Breadcrumb implements JsonUnknown, JsonSerializable { /** Dotted strings that indicate what the crumb is or where it comes from. */ private @Nullable String category; + /** + * Origin of the breadcrumb that is used to identify source of the breadcrumb. For example hybrid + * SDKs can identify native breadcrumbs from JS or Flutter. + */ + private @Nullable String origin; + /** The level of the event. */ private @Nullable SentryLevel level; @@ -54,6 +60,7 @@ public Breadcrumb(final @NotNull Date timestamp) { this.message = breadcrumb.message; this.type = breadcrumb.type; this.category = breadcrumb.category; + this.origin = breadcrumb.origin; final Map dataClone = CollectionUtils.newConcurrentHashMap(breadcrumb.data); if (dataClone != null) { this.data = dataClone; @@ -78,6 +85,7 @@ public static Breadcrumb fromMap( String type = null; @NotNull Map data = new ConcurrentHashMap<>(); String category = null; + String origin = null; SentryLevel level = null; Map unknown = null; @@ -116,6 +124,9 @@ public static Breadcrumb fromMap( case JsonKeys.CATEGORY: category = (value instanceof String) ? (String) value : null; break; + case JsonKeys.ORIGIN: + origin = (value instanceof String) ? (String) value : null; + break; case JsonKeys.LEVEL: String levelString = (value instanceof String) ? (String) value : null; if (levelString != null) { @@ -140,6 +151,7 @@ public static Breadcrumb fromMap( breadcrumb.type = type; breadcrumb.data = data; breadcrumb.category = category; + breadcrumb.origin = origin; breadcrumb.level = level; breadcrumb.setUnknown(unknown); @@ -610,6 +622,24 @@ public void setCategory(@Nullable String category) { this.category = category; } + /** + * Returns the origin + * + * @return the origin + */ + public @Nullable String getOrigin() { + return origin; + } + + /** + * Sets the origin + * + * @param origin the origin + */ + public void setOrigin(@Nullable String origin) { + this.origin = origin; + } + /** * Returns the SentryLevel * @@ -638,12 +668,13 @@ public boolean equals(Object o) { && Objects.equals(message, that.message) && Objects.equals(type, that.type) && Objects.equals(category, that.category) + && Objects.equals(origin, that.origin) && level == that.level; } @Override public int hashCode() { - return Objects.hash(timestamp, message, type, category, level); + return Objects.hash(timestamp, message, type, category, origin, level); } // region json @@ -665,6 +696,7 @@ public static final class JsonKeys { public static final String TYPE = "type"; public static final String DATA = "data"; public static final String CATEGORY = "category"; + public static final String ORIGIN = "origin"; public static final String LEVEL = "level"; } @@ -683,6 +715,9 @@ public void serialize(final @NotNull ObjectWriter writer, final @NotNull ILogger if (category != null) { writer.name(JsonKeys.CATEGORY).value(category); } + if (origin != null) { + writer.name(JsonKeys.ORIGIN).value(origin); + } if (level != null) { writer.name(JsonKeys.LEVEL).value(logger, level); } @@ -707,6 +742,7 @@ public static final class Deserializer implements JsonDeserializer { String type = null; @NotNull Map data = new ConcurrentHashMap<>(); String category = null; + String origin = null; SentryLevel level = null; Map unknown = null; @@ -736,6 +772,9 @@ public static final class Deserializer implements JsonDeserializer { case JsonKeys.CATEGORY: category = reader.nextStringOrNull(); break; + case JsonKeys.ORIGIN: + origin = reader.nextStringOrNull(); + break; case JsonKeys.LEVEL: try { level = new SentryLevel.Deserializer().deserialize(reader, logger); @@ -757,6 +796,7 @@ public static final class Deserializer implements JsonDeserializer { breadcrumb.type = type; breadcrumb.data = data; breadcrumb.category = category; + breadcrumb.origin = origin; breadcrumb.level = level; breadcrumb.setUnknown(unknown); diff --git a/sentry/src/test/java/io/sentry/BreadcrumbTest.kt b/sentry/src/test/java/io/sentry/BreadcrumbTest.kt index 2c15a13abe..048d761799 100644 --- a/sentry/src/test/java/io/sentry/BreadcrumbTest.kt +++ b/sentry/src/test/java/io/sentry/BreadcrumbTest.kt @@ -21,6 +21,7 @@ class BreadcrumbTest { val level = SentryLevel.DEBUG breadcrumb.level = level breadcrumb.category = "category" + breadcrumb.origin = "origin" val clone = Breadcrumb(breadcrumb) @@ -44,6 +45,7 @@ class BreadcrumbTest { val level = SentryLevel.DEBUG breadcrumb.level = level breadcrumb.category = "category" + breadcrumb.origin = "origin" val clone = Breadcrumb(breadcrumb) @@ -53,6 +55,7 @@ class BreadcrumbTest { assertEquals("type", clone.type) assertEquals(SentryLevel.DEBUG, clone.level) assertEquals("category", clone.category) + assertEquals("origin", clone.origin) } @Test @@ -67,6 +70,7 @@ class BreadcrumbTest { val level = SentryLevel.DEBUG breadcrumb.level = level breadcrumb.category = "category" + breadcrumb.origin = "origin" val clone = Breadcrumb(breadcrumb) @@ -77,6 +81,7 @@ class BreadcrumbTest { breadcrumb.type = "newType" breadcrumb.level = SentryLevel.FATAL breadcrumb.category = "newCategory" + breadcrumb.origin = "newOrigin" assertEquals("message", clone.message) assertEquals("data", clone.data["data"]) @@ -86,6 +91,7 @@ class BreadcrumbTest { assertEquals("type", clone.type) assertEquals(SentryLevel.DEBUG, clone.level) assertEquals("category", clone.category) + assertEquals("origin", clone.origin) } @Test diff --git a/sentry/src/test/java/io/sentry/protocol/BreadcrumbSerializationTest.kt b/sentry/src/test/java/io/sentry/protocol/BreadcrumbSerializationTest.kt index fed8c4649d..0e43168897 100644 --- a/sentry/src/test/java/io/sentry/protocol/BreadcrumbSerializationTest.kt +++ b/sentry/src/test/java/io/sentry/protocol/BreadcrumbSerializationTest.kt @@ -28,6 +28,7 @@ class BreadcrumbSerializationTest { type = "ace57e2e-305e-4048-abf0-6c8538ea7bf4" setData("6607d106-d426-462b-af74-f29fce978e48", "149bb94a-1387-4484-90be-2df15d1322ab") category = "b6eea851-5ae5-40ed-8fdd-5e1a655a879c" + origin = "4d8085ef-22fc-49d5-801e-55d509fd1a1c" level = SentryLevel.DEBUG } } @@ -59,6 +60,7 @@ class BreadcrumbSerializationTest { "6607d106-d426-462b-af74-f29fce978e48" to "149bb94a-1387-4484-90be-2df15d1322ab" ), "category" to "b6eea851-5ae5-40ed-8fdd-5e1a655a879c", + "origin" to "4d8085ef-22fc-49d5-801e-55d509fd1a1c", "level" to "debug" ) val actual = Breadcrumb.fromMap(map, SentryOptions()) @@ -69,6 +71,7 @@ class BreadcrumbSerializationTest { assertEquals(expected.type, actual?.type) assertEquals(expected.data, actual?.data) assertEquals(expected.category, actual?.category) + assertEquals(expected.origin, actual?.origin) assertEquals(expected.level, actual?.level) } diff --git a/sentry/src/test/resources/json/breadcrumb.json b/sentry/src/test/resources/json/breadcrumb.json index 053d538fbe..a8873b1a2d 100644 --- a/sentry/src/test/resources/json/breadcrumb.json +++ b/sentry/src/test/resources/json/breadcrumb.json @@ -7,5 +7,6 @@ "6607d106-d426-462b-af74-f29fce978e48": "149bb94a-1387-4484-90be-2df15d1322ab" }, "category": "b6eea851-5ae5-40ed-8fdd-5e1a655a879c", + "origin": "4d8085ef-22fc-49d5-801e-55d509fd1a1c", "level": "debug" } diff --git a/sentry/src/test/resources/json/sentry_base_event.json b/sentry/src/test/resources/json/sentry_base_event.json index afaf040863..c889fe6bdc 100644 --- a/sentry/src/test/resources/json/sentry_base_event.json +++ b/sentry/src/test/resources/json/sentry_base_event.json @@ -207,6 +207,7 @@ "6607d106-d426-462b-af74-f29fce978e48": "149bb94a-1387-4484-90be-2df15d1322ab" }, "category": "b6eea851-5ae5-40ed-8fdd-5e1a655a879c", + "origin": "4d8085ef-22fc-49d5-801e-55d509fd1a1c", "level": "debug" } ], diff --git a/sentry/src/test/resources/json/sentry_base_event_with_null_extra.json b/sentry/src/test/resources/json/sentry_base_event_with_null_extra.json index 018cc7aae7..02f3c1502a 100644 --- a/sentry/src/test/resources/json/sentry_base_event_with_null_extra.json +++ b/sentry/src/test/resources/json/sentry_base_event_with_null_extra.json @@ -207,6 +207,7 @@ "6607d106-d426-462b-af74-f29fce978e48": "149bb94a-1387-4484-90be-2df15d1322ab" }, "category": "b6eea851-5ae5-40ed-8fdd-5e1a655a879c", + "origin": "4d8085ef-22fc-49d5-801e-55d509fd1a1c", "level": "debug" } ], diff --git a/sentry/src/test/resources/json/sentry_event.json b/sentry/src/test/resources/json/sentry_event.json index 6d0b351ed4..7ae1ba107f 100644 --- a/sentry/src/test/resources/json/sentry_event.json +++ b/sentry/src/test/resources/json/sentry_event.json @@ -342,6 +342,7 @@ "6607d106-d426-462b-af74-f29fce978e48": "149bb94a-1387-4484-90be-2df15d1322ab" }, "category": "b6eea851-5ae5-40ed-8fdd-5e1a655a879c", + "origin": "4d8085ef-22fc-49d5-801e-55d509fd1a1c", "level": "debug" } ], diff --git a/sentry/src/test/resources/json/sentry_transaction.json b/sentry/src/test/resources/json/sentry_transaction.json index 944a0bfe92..7363dd35d6 100644 --- a/sentry/src/test/resources/json/sentry_transaction.json +++ b/sentry/src/test/resources/json/sentry_transaction.json @@ -290,6 +290,7 @@ "6607d106-d426-462b-af74-f29fce978e48": "149bb94a-1387-4484-90be-2df15d1322ab" }, "category": "b6eea851-5ae5-40ed-8fdd-5e1a655a879c", + "origin": "4d8085ef-22fc-49d5-801e-55d509fd1a1c", "level": "debug" } ], diff --git a/sentry/src/test/resources/json/sentry_transaction_legacy_date_format.json b/sentry/src/test/resources/json/sentry_transaction_legacy_date_format.json index 789c4fe2a9..a0f8a675ae 100644 --- a/sentry/src/test/resources/json/sentry_transaction_legacy_date_format.json +++ b/sentry/src/test/resources/json/sentry_transaction_legacy_date_format.json @@ -290,6 +290,7 @@ "6607d106-d426-462b-af74-f29fce978e48": "149bb94a-1387-4484-90be-2df15d1322ab" }, "category": "b6eea851-5ae5-40ed-8fdd-5e1a655a879c", + "origin": "4d8085ef-22fc-49d5-801e-55d509fd1a1c", "level": "debug" } ], diff --git a/sentry/src/test/resources/json/sentry_transaction_no_measurement_unit.json b/sentry/src/test/resources/json/sentry_transaction_no_measurement_unit.json index 330555c1ec..8ffb9a8f5d 100644 --- a/sentry/src/test/resources/json/sentry_transaction_no_measurement_unit.json +++ b/sentry/src/test/resources/json/sentry_transaction_no_measurement_unit.json @@ -257,6 +257,7 @@ "6607d106-d426-462b-af74-f29fce978e48": "149bb94a-1387-4484-90be-2df15d1322ab" }, "category": "b6eea851-5ae5-40ed-8fdd-5e1a655a879c", + "origin": "4d8085ef-22fc-49d5-801e-55d509fd1a1c", "level": "debug" } ], From b5b093e5f805d0d02c7be344403804e7a7605398 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Thu, 3 Oct 2024 16:15:30 +0200 Subject: [PATCH 40/92] Replace Calendar.getInstance with System.currentTimeMillis for breadcrumb ctor (#3736) --- CHANGELOG.md | 1 + sentry/api/sentry.api | 1 + .../src/main/java/io/sentry/Breadcrumb.java | 34 ++++++++++++++----- .../src/test/java/io/sentry/BreadcrumbTest.kt | 7 ++++ 4 files changed, 35 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 49f43332c4..4e6bc7c535 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ - For example, (this is already a default behavior) to redact all `TextView`s and their subclasses (`RadioButton`, `EditText`, etc.): `options.experimental.sessionReplay.addRedactViewClass("android.widget.TextView")` - If you're using code obfuscation, adjust your proguard-rules accordingly, so your custom view class name is not minified - Fix ensure Application Context is used even when SDK is initialized via Activity Context ([#3669](https://github.com/getsentry/sentry-java/pull/3669)) +- Fix potential ANRs due to `Calendar.getInstance` usage in Breadcrumbs constructor ([#3736](https://github.com/getsentry/sentry-java/pull/3736)) *Breaking changes*: diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 5f8b061acf..530d8241f5 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -98,6 +98,7 @@ public final class io/sentry/BaggageHeader { public final class io/sentry/Breadcrumb : io/sentry/JsonSerializable, io/sentry/JsonUnknown { public fun ()V + public fun (J)V public fun (Ljava/lang/String;)V public fun (Ljava/util/Date;)V public static fun debug (Ljava/lang/String;)Lio/sentry/Breadcrumb; diff --git a/sentry/src/main/java/io/sentry/Breadcrumb.java b/sentry/src/main/java/io/sentry/Breadcrumb.java index 954c57c36a..10b3c951d3 100644 --- a/sentry/src/main/java/io/sentry/Breadcrumb.java +++ b/sentry/src/main/java/io/sentry/Breadcrumb.java @@ -19,8 +19,11 @@ /** Series of application events */ public final class Breadcrumb implements JsonUnknown, JsonSerializable { - /** A timestamp representing when the breadcrumb occurred. */ - private final @NotNull Date timestamp; + /** A timestamp representing when the breadcrumb occurred in milliseconds. */ + private @Nullable final Long timestampMs; + + /** A timestamp representing when the breadcrumb occurred as java.util.Date. */ + private @Nullable Date timestamp; /** If a message is provided, its rendered as text and the whitespace is preserved. */ private @Nullable String message; @@ -51,12 +54,20 @@ public final class Breadcrumb implements JsonUnknown, JsonSerializable { * * @param timestamp the timestamp */ + @SuppressWarnings("JavaUtilDate") public Breadcrumb(final @NotNull Date timestamp) { this.timestamp = timestamp; + this.timestampMs = null; + } + + public Breadcrumb(final long timestamp) { + this.timestampMs = timestamp; + this.timestamp = null; } Breadcrumb(final @NotNull Breadcrumb breadcrumb) { this.timestamp = breadcrumb.timestamp; + this.timestampMs = breadcrumb.timestampMs; this.message = breadcrumb.message; this.type = breadcrumb.type; this.category = breadcrumb.category; @@ -504,7 +515,7 @@ public static Breadcrumb fromMap( /** Breadcrumb ctor */ public Breadcrumb() { - this(DateUtils.getCurrentDateTime()); + this(System.currentTimeMillis()); } /** @@ -518,13 +529,20 @@ public Breadcrumb(@Nullable String message) { } /** - * Returns the Breadcrumb's timestamp + * Returns the Breadcrumb's timestamp as java.util.Date * * @return the timestamp */ - @SuppressWarnings({"JdkObsolete", "JavaUtilDate"}) + @SuppressWarnings("JavaUtilDate") public @NotNull Date getTimestamp() { - return (Date) timestamp.clone(); + if (timestamp != null) { + return (Date) timestamp.clone(); + } else if (timestampMs != null) { + // we memoize it here into timestamp to avoid instantiating Calendar again and again + timestamp = DateUtils.getDateTime(timestampMs); + return timestamp; + } + throw new IllegalStateException("No timestamp set for breadcrumb"); } /** @@ -664,7 +682,7 @@ public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; Breadcrumb that = (Breadcrumb) o; - return timestamp.getTime() == that.timestamp.getTime() + return getTimestamp().getTime() == that.getTimestamp().getTime() && Objects.equals(message, that.message) && Objects.equals(type, that.type) && Objects.equals(category, that.category) @@ -704,7 +722,7 @@ public static final class JsonKeys { public void serialize(final @NotNull ObjectWriter writer, final @NotNull ILogger logger) throws IOException { writer.beginObject(); - writer.name(JsonKeys.TIMESTAMP).value(logger, timestamp); + writer.name(JsonKeys.TIMESTAMP).value(logger, getTimestamp()); if (message != null) { writer.name(JsonKeys.MESSAGE).value(message); } diff --git a/sentry/src/test/java/io/sentry/BreadcrumbTest.kt b/sentry/src/test/java/io/sentry/BreadcrumbTest.kt index 048d761799..bac143812a 100644 --- a/sentry/src/test/java/io/sentry/BreadcrumbTest.kt +++ b/sentry/src/test/java/io/sentry/BreadcrumbTest.kt @@ -1,5 +1,6 @@ package io.sentry +import java.util.Date import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -100,6 +101,12 @@ class BreadcrumbTest { assertNotNull(breadcrumb.timestamp) } + @Test + fun `breadcrumb can be created with Date timestamp`() { + val breadcrumb = Breadcrumb(Date(123L)) + assertEquals(123L, breadcrumb.timestamp.time) + } + @Test fun `breadcrumb takes message on ctor`() { val breadcrumb = Breadcrumb("this is a test") From 955c6ee65d8abbb53d4b2eb9578011e428c1c914 Mon Sep 17 00:00:00 2001 From: Karl Heinz Struggl Date: Tue, 8 Oct 2024 01:54:25 -0700 Subject: [PATCH 41/92] chore(readme): Add info about updated release channels (#3773) * Update README.md * fix copy/paste error --- README.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/README.md b/README.md index cba39883f2..1e51fc26c9 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,13 @@ Sentry SDK for Java and Android | sentry-opentelemetry-core | [![Maven Central](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-opentelemetry-core/badge.svg)](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-opentelemetry-core) | | sentry-okhttp | [![Maven Central](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-okhttp/badge.svg)](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-okhttp) | +# Releases +This repo uses the following ways to release SDK updates: + +- `Pre-release`: We create pre-releases (alpha, beta, RC,…) for larger and potentially more impactful changes, such as new features or major versions. +- `Latest`: We continuously release major/minor/hotfix versions from the `main` branch. These releases go through all our internal quality gates and are very safe to use and intended to be the default for most teams. +- `Stable`: We promote releases from `Latest` when they have been used in the field for some time and in scale, considering time since release, adoption, and other quality and stability metrics. These releases will be indicated on the releases page (https://github.com/getsentry/sentry-java/releases/) with the `Stable` suffix. # Useful links and docs From 503f916349ca4a62a037b30fd36d12cbda620433 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Tue, 8 Oct 2024 18:09:47 +0200 Subject: [PATCH 42/92] [QA] Lazily load SentryOptions members (#3749) * Make AtomicClientReportStorage constructor lazy * Lazily initialize things we dont need * Empty experimental options * Format code * Make LazyEvaluator thread-safe * Fix tests * Fix .api * Use LazyEvaluator for heavy SentryOptions * Changelog * revert * tests --------- Co-authored-by: Sentry Github Bot Co-authored-by: Markus Hintersteiner --- CHANGELOG.md | 1 + .../android/replay/ScreenshotRecorder.kt | 4 +++ .../src/main/AndroidManifest.xml | 1 - sentry/api/sentry.api | 5 +-- .../java/io/sentry/ExperimentalOptions.java | 6 +++- .../main/java/io/sentry/SentryOptions.java | 32 +++++++++-------- .../java/io/sentry/SentryReplayOptions.java | 10 +++--- .../java/io/sentry/cache/CacheStrategy.java | 15 ++++---- .../java/io/sentry/cache/EnvelopeCache.java | 12 +++---- .../AtomicClientReportStorage.java | 34 +++++++++++-------- .../java/io/sentry/util/LazyEvaluator.java | 19 +++++++++-- .../java/io/sentry/SentryReplayOptionsTest.kt | 6 ++-- .../java/io/sentry/cache/CacheStrategyTest.kt | 16 ++++----- 13 files changed, 98 insertions(+), 63 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e6bc7c535..0cbed67dcf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ - If you're using code obfuscation, adjust your proguard-rules accordingly, so your custom view class name is not minified - Fix ensure Application Context is used even when SDK is initialized via Activity Context ([#3669](https://github.com/getsentry/sentry-java/pull/3669)) - Fix potential ANRs due to `Calendar.getInstance` usage in Breadcrumbs constructor ([#3736](https://github.com/getsentry/sentry-java/pull/3736)) +- Lazily initialize heavy `SentryOptions` members to avoid ANRs on app start ([#3749](https://github.com/getsentry/sentry-java/pull/3749)) *Breaking changes*: diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt index fdab9f442d..0ea3fad6ab 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt @@ -13,6 +13,7 @@ import android.graphics.Rect import android.graphics.RectF import android.os.Build.VERSION import android.os.Build.VERSION_CODES +import android.util.Log import android.view.PixelCopy import android.view.View import android.view.ViewGroup @@ -101,6 +102,7 @@ internal class ScreenshotRecorder( Bitmap.Config.ARGB_8888 ) + val timeStart = System.nanoTime() // postAtFrontOfQueue to ensure the view hierarchy and bitmap are ase close in-sync as possible mainLooperHandler.post { try { @@ -123,6 +125,8 @@ internal class ScreenshotRecorder( val viewHierarchy = ViewHierarchyNode.fromView(root, null, 0, options) root.traverse(viewHierarchy) + val timeEnd = System.nanoTime() + Log.e("TIME", String.format("%.2f", ((timeEnd - timeStart) / 1_000_000.0))) recorder.submitSafely(options, "screenshot_recorder.redact") { val canvas = Canvas(bitmap) diff --git a/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml b/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml index 8876efd66d..997fa5ff55 100644 --- a/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml +++ b/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml @@ -167,6 +167,5 @@ - diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 530d8241f5..059262d2c3 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -315,7 +315,7 @@ public abstract interface class io/sentry/EventProcessor { } public final class io/sentry/ExperimentalOptions { - public fun ()V + public fun (Z)V public fun getSessionReplay ()Lio/sentry/SentryReplayOptions; public fun setSessionReplay (Lio/sentry/SentryReplayOptions;)V } @@ -2712,8 +2712,8 @@ public final class io/sentry/SentryReplayEvent$ReplayType$Deserializer : io/sent public final class io/sentry/SentryReplayOptions { public static final field IMAGE_VIEW_CLASS_NAME Ljava/lang/String; public static final field TEXT_VIEW_CLASS_NAME Ljava/lang/String; - public fun ()V public fun (Ljava/lang/Double;Ljava/lang/Double;)V + public fun (Z)V public fun addIgnoreViewClass (Ljava/lang/String;)V public fun addRedactViewClass (Ljava/lang/String;)V public fun getErrorReplayDuration ()J @@ -5698,6 +5698,7 @@ public final class io/sentry/util/JsonSerializationUtils { public final class io/sentry/util/LazyEvaluator { public fun (Lio/sentry/util/LazyEvaluator$Evaluator;)V public fun getValue ()Ljava/lang/Object; + public fun setValue (Ljava/lang/Object;)V } public abstract interface class io/sentry/util/LazyEvaluator$Evaluator { diff --git a/sentry/src/main/java/io/sentry/ExperimentalOptions.java b/sentry/src/main/java/io/sentry/ExperimentalOptions.java index f587996bd8..4a0e7de78d 100644 --- a/sentry/src/main/java/io/sentry/ExperimentalOptions.java +++ b/sentry/src/main/java/io/sentry/ExperimentalOptions.java @@ -9,7 +9,11 @@ *

Beware that experimental options can change at any time. */ public final class ExperimentalOptions { - private @NotNull SentryReplayOptions sessionReplay = new SentryReplayOptions(); + private @NotNull SentryReplayOptions sessionReplay; + + public ExperimentalOptions(final boolean empty) { + this.sessionReplay = new SentryReplayOptions(empty); + } @NotNull public SentryReplayOptions getSessionReplay() { diff --git a/sentry/src/main/java/io/sentry/SentryOptions.java b/sentry/src/main/java/io/sentry/SentryOptions.java index 3ff84c48de..61c721847c 100644 --- a/sentry/src/main/java/io/sentry/SentryOptions.java +++ b/sentry/src/main/java/io/sentry/SentryOptions.java @@ -19,13 +19,13 @@ import io.sentry.transport.ITransportGate; import io.sentry.transport.NoOpEnvelopeCache; import io.sentry.transport.NoOpTransportGate; +import io.sentry.util.LazyEvaluator; import io.sentry.util.Platform; import io.sentry.util.SampleRateUtils; import io.sentry.util.StringUtils; import io.sentry.util.thread.IMainThreadChecker; import io.sentry.util.thread.NoOpMainThreadChecker; import java.io.File; -import java.net.Proxy; import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; @@ -118,11 +118,13 @@ public class SentryOptions { /** minimum LogLevel to be used if debug is enabled */ private @NotNull SentryLevel diagnosticLevel = DEFAULT_DIAGNOSTIC_LEVEL; - /** Envelope reader interface */ - private @NotNull IEnvelopeReader envelopeReader = new EnvelopeReader(new JsonSerializer(this)); - /** Serializer interface to serialize/deserialize json events */ - private @NotNull ISerializer serializer = new JsonSerializer(this); + private final @NotNull LazyEvaluator serializer = + new LazyEvaluator<>(() -> new JsonSerializer(this)); + + /** Envelope reader interface */ + private final @NotNull LazyEvaluator envelopeReader = + new LazyEvaluator<>(() -> new EnvelopeReader(serializer.getValue())); /** Max depth when serializing object graphs with reflection. * */ private int maxDepth = 100; @@ -416,7 +418,8 @@ public class SentryOptions { /** Date provider to retrieve the current date from. */ @ApiStatus.Internal - private @NotNull SentryDateProvider dateProvider = new SentryAutoDateProvider(); + private final @NotNull LazyEvaluator dateProvider = + new LazyEvaluator<>(() -> new SentryAutoDateProvider()); private final @NotNull List performanceCollectors = new ArrayList<>(); @@ -479,7 +482,7 @@ public class SentryOptions { @ApiStatus.Experimental private @Nullable Cron cron = null; - private final @NotNull ExperimentalOptions experimental = new ExperimentalOptions(); + private final @NotNull ExperimentalOptions experimental; private @NotNull ReplayController replayController = NoOpReplayController.getInstance(); @@ -605,7 +608,7 @@ public void setDiagnosticLevel(@Nullable final SentryLevel diagnosticLevel) { * @return the serializer */ public @NotNull ISerializer getSerializer() { - return serializer; + return serializer.getValue(); } /** @@ -614,7 +617,7 @@ public void setDiagnosticLevel(@Nullable final SentryLevel diagnosticLevel) { * @param serializer the serializer */ public void setSerializer(@Nullable ISerializer serializer) { - this.serializer = serializer != null ? serializer : NoOpSerializer.getInstance(); + this.serializer.setValue(serializer != null ? serializer : NoOpSerializer.getInstance()); } /** @@ -636,12 +639,12 @@ public void setMaxDepth(int maxDepth) { } public @NotNull IEnvelopeReader getEnvelopeReader() { - return envelopeReader; + return envelopeReader.getValue(); } public void setEnvelopeReader(final @Nullable IEnvelopeReader envelopeReader) { - this.envelopeReader = - envelopeReader != null ? envelopeReader : NoOpEnvelopeReader.getInstance(); + this.envelopeReader.setValue( + envelopeReader != null ? envelopeReader : NoOpEnvelopeReader.getInstance()); } /** @@ -2212,7 +2215,7 @@ public void setIgnoredCheckIns(final @Nullable List ignoredCheckIns) { /** Returns the current {@link SentryDateProvider} that is used to retrieve the current date. */ @ApiStatus.Internal public @NotNull SentryDateProvider getDateProvider() { - return dateProvider; + return dateProvider.getValue(); } /** @@ -2223,7 +2226,7 @@ public void setIgnoredCheckIns(final @Nullable List ignoredCheckIns) { */ @ApiStatus.Internal public void setDateProvider(final @NotNull SentryDateProvider dateProvider) { - this.dateProvider = dateProvider; + this.dateProvider.setValue(dateProvider); } /** @@ -2540,6 +2543,7 @@ public SentryOptions() { * @param empty if options should be empty. */ private SentryOptions(final boolean empty) { + experimental = new ExperimentalOptions(empty); if (!empty) { // SentryExecutorService should be initialized before any // SendCachedEventFireAndForgetIntegration diff --git a/sentry/src/main/java/io/sentry/SentryReplayOptions.java b/sentry/src/main/java/io/sentry/SentryReplayOptions.java index 7656b088a1..097f72c921 100644 --- a/sentry/src/main/java/io/sentry/SentryReplayOptions.java +++ b/sentry/src/main/java/io/sentry/SentryReplayOptions.java @@ -96,14 +96,16 @@ public enum SentryReplayQuality { /** The maximum duration of a full session replay, defaults to 1h. */ private long sessionDuration = 60 * 60 * 1000L; - public SentryReplayOptions() { - setRedactAllText(true); - setRedactAllImages(true); + public SentryReplayOptions(final boolean empty) { + if (!empty) { + setRedactAllText(true); + setRedactAllImages(true); + } } public SentryReplayOptions( final @Nullable Double sessionSampleRate, final @Nullable Double onErrorSampleRate) { - this(); + this(false); this.sessionSampleRate = sessionSampleRate; this.onErrorSampleRate = onErrorSampleRate; } diff --git a/sentry/src/main/java/io/sentry/cache/CacheStrategy.java b/sentry/src/main/java/io/sentry/cache/CacheStrategy.java index dbb6a49c19..d48cc3108d 100644 --- a/sentry/src/main/java/io/sentry/cache/CacheStrategy.java +++ b/sentry/src/main/java/io/sentry/cache/CacheStrategy.java @@ -10,6 +10,7 @@ import io.sentry.SentryOptions; import io.sentry.Session; import io.sentry.clientreport.DiscardReason; +import io.sentry.util.LazyEvaluator; import io.sentry.util.Objects; import java.io.BufferedInputStream; import java.io.BufferedReader; @@ -36,8 +37,9 @@ abstract class CacheStrategy { @SuppressWarnings("CharsetObjectCanBeUsed") protected static final Charset UTF_8 = Charset.forName("UTF-8"); - protected final @NotNull SentryOptions options; - protected final @NotNull ISerializer serializer; + protected @NotNull SentryOptions options; + protected final @NotNull LazyEvaluator serializer = + new LazyEvaluator<>(() -> options.getSerializer()); protected final @NotNull File directory; private final int maxSize; @@ -48,7 +50,6 @@ abstract class CacheStrategy { Objects.requireNonNull(directoryPath, "Directory is required."); this.options = Objects.requireNonNull(options, "SentryOptions is required."); - this.serializer = options.getSerializer(); this.directory = new File(directoryPath); this.maxSize = maxSize; @@ -177,7 +178,7 @@ private void moveInitFlagIfNecessary( && currentSession.getSessionId().equals(session.getSessionId())) { session.setInitAsTrue(); try { - newSessionItem = SentryEnvelopeItem.fromSession(serializer, session); + newSessionItem = SentryEnvelopeItem.fromSession(serializer.getValue(), session); // remove item from envelope items so we can replace with the new one that has the // init flag true itemsIterator.remove(); @@ -216,7 +217,7 @@ private void moveInitFlagIfNecessary( private @Nullable SentryEnvelope readEnvelope(final @NotNull File file) { try (final InputStream inputStream = new BufferedInputStream(new FileInputStream(file))) { - return serializer.deserializeEnvelope(inputStream); + return serializer.getValue().deserializeEnvelope(inputStream); } catch (IOException e) { options.getLogger().log(ERROR, "Failed to deserialize the envelope.", e); } @@ -258,7 +259,7 @@ private boolean isSessionType(final @Nullable SentryEnvelopeItem item) { try (final Reader reader = new BufferedReader( new InputStreamReader(new ByteArrayInputStream(item.getData()), UTF_8))) { - return serializer.deserialize(reader, Session.class); + return serializer.getValue().deserialize(reader, Session.class); } catch (Throwable e) { options.getLogger().log(ERROR, "Failed to deserialize the session.", e); } @@ -268,7 +269,7 @@ private boolean isSessionType(final @Nullable SentryEnvelopeItem item) { private void saveNewEnvelope( final @NotNull SentryEnvelope envelope, final @NotNull File file, final long timestamp) { try (final OutputStream outputStream = new FileOutputStream(file)) { - serializer.serialize(envelope, outputStream); + serializer.getValue().serialize(envelope, outputStream); // we need to set the same timestamp so the sorting from oldest to newest wont break. file.setLastModified(timestamp); } catch (Throwable e) { diff --git a/sentry/src/main/java/io/sentry/cache/EnvelopeCache.java b/sentry/src/main/java/io/sentry/cache/EnvelopeCache.java index 3be857a4b2..82636ac6c1 100644 --- a/sentry/src/main/java/io/sentry/cache/EnvelopeCache.java +++ b/sentry/src/main/java/io/sentry/cache/EnvelopeCache.java @@ -116,7 +116,7 @@ public void store(final @NotNull SentryEnvelope envelope, final @NotNull Hint hi try (final Reader reader = new BufferedReader( new InputStreamReader(new FileInputStream(currentSessionFile), UTF_8))) { - final Session session = serializer.deserialize(reader, Session.class); + final Session session = serializer.getValue().deserialize(reader, Session.class); if (session != null) { writeSessionToDisk(previousSessionFile, session); } @@ -204,7 +204,7 @@ private void tryEndPreviousSession(final @NotNull Hint hint) { try (final Reader reader = new BufferedReader( new InputStreamReader(new FileInputStream(previousSessionFile), UTF_8))) { - final Session session = serializer.deserialize(reader, Session.class); + final Session session = serializer.getValue().deserialize(reader, Session.class); if (session != null) { final AbnormalExit abnormalHint = (AbnormalExit) sdkHint; final @Nullable Long abnormalExitTimestamp = abnormalHint.timestamp(); @@ -263,7 +263,7 @@ private void updateCurrentSession( try (final Reader reader = new BufferedReader( new InputStreamReader(new ByteArrayInputStream(item.getData()), UTF_8))) { - final Session session = serializer.deserialize(reader, Session.class); + final Session session = serializer.getValue().deserialize(reader, Session.class); if (session == null) { options .getLogger() @@ -304,7 +304,7 @@ private void writeEnvelopeToDisk( } try (final OutputStream outputStream = new FileOutputStream(file)) { - serializer.serialize(envelope, outputStream); + serializer.getValue().serialize(envelope, outputStream); } catch (Throwable e) { options .getLogger() @@ -324,7 +324,7 @@ private void writeSessionToDisk(final @NotNull File file, final @NotNull Session try (final OutputStream outputStream = new FileOutputStream(file); final Writer writer = new BufferedWriter(new OutputStreamWriter(outputStream, UTF_8))) { - serializer.serialize(session, writer); + serializer.getValue().serialize(session, writer); } catch (Throwable e) { options .getLogger() @@ -388,7 +388,7 @@ public void discard(final @NotNull SentryEnvelope envelope) { for (final File file : allCachedEnvelopes) { try (final InputStream is = new BufferedInputStream(new FileInputStream(file))) { - ret.add(serializer.deserializeEnvelope(is)); + ret.add(serializer.getValue().deserializeEnvelope(is)); } catch (FileNotFoundException e) { options .getLogger() diff --git a/sentry/src/main/java/io/sentry/clientreport/AtomicClientReportStorage.java b/sentry/src/main/java/io/sentry/clientreport/AtomicClientReportStorage.java index 2e7f0c27a7..fd1f9a9de9 100644 --- a/sentry/src/main/java/io/sentry/clientreport/AtomicClientReportStorage.java +++ b/sentry/src/main/java/io/sentry/clientreport/AtomicClientReportStorage.java @@ -1,10 +1,12 @@ package io.sentry.clientreport; import io.sentry.DataCategory; +import io.sentry.util.LazyEvaluator; import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.atomic.AtomicLong; import org.jetbrains.annotations.ApiStatus; @@ -14,25 +16,28 @@ @ApiStatus.Internal final class AtomicClientReportStorage implements IClientReportStorage { - private final @NotNull Map lostEventCounts; + private final @NotNull LazyEvaluator> lostEventCounts = + new LazyEvaluator<>( + () -> { + final Map modifyableEventCountsForInit = + new ConcurrentHashMap<>(); - public AtomicClientReportStorage() { - final Map modifyableEventCountsForInit = new ConcurrentHashMap<>(); + for (final DiscardReason discardReason : DiscardReason.values()) { + for (final DataCategory category : DataCategory.values()) { + modifyableEventCountsForInit.put( + new ClientReportKey(discardReason.getReason(), category.getCategory()), + new AtomicLong(0)); + } + } - for (final DiscardReason discardReason : DiscardReason.values()) { - for (final DataCategory category : DataCategory.values()) { - modifyableEventCountsForInit.put( - new ClientReportKey(discardReason.getReason(), category.getCategory()), - new AtomicLong(0)); - } - } + return Collections.unmodifiableMap(modifyableEventCountsForInit); + }); - lostEventCounts = Collections.unmodifiableMap(modifyableEventCountsForInit); - } + public AtomicClientReportStorage() {} @Override public void addCount(ClientReportKey key, Long count) { - final @Nullable AtomicLong quantity = lostEventCounts.get(key); + final @Nullable AtomicLong quantity = lostEventCounts.getValue().get(key); if (quantity != null) { quantity.addAndGet(count); @@ -43,7 +48,8 @@ public void addCount(ClientReportKey key, Long count) { public List resetCountsAndGet() { final List discardedEvents = new ArrayList<>(); - for (final Map.Entry entry : lostEventCounts.entrySet()) { + Set> entrySet = lostEventCounts.getValue().entrySet(); + for (final Map.Entry entry : entrySet) { final Long quantity = entry.getValue().getAndSet(0); if (quantity > 0) { discardedEvents.add( diff --git a/sentry/src/main/java/io/sentry/util/LazyEvaluator.java b/sentry/src/main/java/io/sentry/util/LazyEvaluator.java index 5db376e710..d540cbe508 100644 --- a/sentry/src/main/java/io/sentry/util/LazyEvaluator.java +++ b/sentry/src/main/java/io/sentry/util/LazyEvaluator.java @@ -10,7 +10,8 @@ */ @ApiStatus.Internal public final class LazyEvaluator { - private @Nullable T value = null; + + private volatile @Nullable T value = null; private final @NotNull Evaluator evaluator; /** @@ -28,13 +29,25 @@ public LazyEvaluator(final @NotNull Evaluator evaluator) { * * @return The result of the evaluator function. */ - public synchronized @NotNull T getValue() { + public @NotNull T getValue() { if (value == null) { - value = evaluator.evaluate(); + synchronized (this) { + if (value == null) { + value = evaluator.evaluate(); + } + } } + + //noinspection DataFlowIssue return value; } + public void setValue(final @Nullable T value) { + synchronized (this) { + this.value = value; + } + } + public interface Evaluator { @NotNull T evaluate(); diff --git a/sentry/src/test/java/io/sentry/SentryReplayOptionsTest.kt b/sentry/src/test/java/io/sentry/SentryReplayOptionsTest.kt index 01843dfc90..794a3dac09 100644 --- a/sentry/src/test/java/io/sentry/SentryReplayOptionsTest.kt +++ b/sentry/src/test/java/io/sentry/SentryReplayOptionsTest.kt @@ -7,7 +7,7 @@ class SentryReplayOptionsTest { @Test fun `uses medium quality as default`() { - val replayOptions = SentryReplayOptions() + val replayOptions = SentryReplayOptions(true) assertEquals(SentryReplayOptions.SentryReplayQuality.MEDIUM, replayOptions.quality) assertEquals(75_000, replayOptions.quality.bitRate) @@ -16,7 +16,7 @@ class SentryReplayOptionsTest { @Test fun `low quality`() { - val replayOptions = SentryReplayOptions().apply { quality = SentryReplayOptions.SentryReplayQuality.LOW } + val replayOptions = SentryReplayOptions(true).apply { quality = SentryReplayOptions.SentryReplayQuality.LOW } assertEquals(50_000, replayOptions.quality.bitRate) assertEquals(0.8f, replayOptions.quality.sizeScale) @@ -24,7 +24,7 @@ class SentryReplayOptionsTest { @Test fun `high quality`() { - val replayOptions = SentryReplayOptions().apply { quality = SentryReplayOptions.SentryReplayQuality.HIGH } + val replayOptions = SentryReplayOptions(true).apply { quality = SentryReplayOptions.SentryReplayQuality.HIGH } assertEquals(100_000, replayOptions.quality.bitRate) assertEquals(1.0f, replayOptions.quality.sizeScale) diff --git a/sentry/src/test/java/io/sentry/cache/CacheStrategyTest.kt b/sentry/src/test/java/io/sentry/cache/CacheStrategyTest.kt index e67a6616ce..3c3e6d18d0 100644 --- a/sentry/src/test/java/io/sentry/cache/CacheStrategyTest.kt +++ b/sentry/src/test/java/io/sentry/cache/CacheStrategyTest.kt @@ -108,13 +108,13 @@ class CacheStrategyTest { val files = createTempFilesSortByOldestToNewest() val okSession = createSessionMockData(Session.State.Ok, true) - val okEnvelope = SentryEnvelope.from(sut.serializer, okSession, null) - sut.serializer.serialize(okEnvelope, files[0].outputStream()) + val okEnvelope = SentryEnvelope.from(sut.serializer.value, okSession, null) + sut.serializer.value.serialize(okEnvelope, files[0].outputStream()) val updatedOkSession = okSession.clone() updatedOkSession.update(null, null, true) - val updatedOkEnvelope = SentryEnvelope.from(sut.serializer, updatedOkSession, null) - sut.serializer.serialize(updatedOkEnvelope, files[1].outputStream()) + val updatedOkEnvelope = SentryEnvelope.from(sut.serializer.value, updatedOkSession, null) + sut.serializer.value.serialize(updatedOkEnvelope, files[1].outputStream()) saveSessionToFile(files[2], sut, Session.State.Exited, null) @@ -178,17 +178,17 @@ class CacheStrategyTest { ) private fun getSessionFromFile(file: File, sut: CacheStrategy): Session { - val envelope = sut.serializer.deserializeEnvelope(file.inputStream()) + val envelope = sut.serializer.value.deserializeEnvelope(file.inputStream()) val item = envelope!!.items.first() val reader = InputStreamReader(ByteArrayInputStream(item.data), Charsets.UTF_8) - return sut.serializer.deserialize(reader, Session::class.java)!! + return sut.serializer.value.deserialize(reader, Session::class.java)!! } private fun saveSessionToFile(file: File, sut: CacheStrategy, state: Session.State = Session.State.Ok, init: Boolean? = true) { val okSession = createSessionMockData(state, init) - val okEnvelope = SentryEnvelope.from(sut.serializer, okSession, null) - sut.serializer.serialize(okEnvelope, file.outputStream()) + val okEnvelope = SentryEnvelope.from(sut.serializer.value, okSession, null) + sut.serializer.value.serialize(okEnvelope, file.outputStream()) } private fun getOptionsWithRealSerializer(): SentryOptions { From 160762108453416271f7135b4e1d4dfc329fe871 Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Wed, 9 Oct 2024 16:42:24 +0200 Subject: [PATCH 43/92] [QA] Fix potential ANRs due to default integrations (#3778) * Fix don't register any full drawn listeners if feature is not enabled * Offload app components breadcrumbs to background thread * Offload system events breadcrumbs to background thread * Update Changelog * Fix flaky tests * Remove session breadcrumbs * Fix tests * Address PR feedback --- CHANGELOG.md | 1 + .../core/ActivityLifecycleIntegration.java | 2 +- .../AppComponentsBreadcrumbsIntegration.java | 72 +++++++++++------ .../sentry/android/core/LifecycleWatcher.java | 8 -- .../core/NetworkBreadcrumbsIntegration.java | 81 +++++++++++++------ .../io/sentry/android/core/SentryAndroid.java | 2 - .../SystemEventsBreadcrumbsIntegration.java | 46 ++++++++--- .../core/ActivityLifecycleIntegrationTest.kt | 22 ++++- ...AppComponentsBreadcrumbsIntegrationTest.kt | 34 ++++++-- .../android/core/LifecycleWatcherTest.kt | 43 ---------- .../core/NetworkBreadcrumbsIntegrationTest.kt | 51 ++++++++++-- sentry/api/sentry.api | 1 + .../main/java/io/sentry/SentryOptions.java | 9 ++- 13 files changed, 237 insertions(+), 135 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0cbed67dcf..71190dc668 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ - If you're using code obfuscation, adjust your proguard-rules accordingly, so your custom view class name is not minified - Fix ensure Application Context is used even when SDK is initialized via Activity Context ([#3669](https://github.com/getsentry/sentry-java/pull/3669)) - Fix potential ANRs due to `Calendar.getInstance` usage in Breadcrumbs constructor ([#3736](https://github.com/getsentry/sentry-java/pull/3736)) +- Fix potential ANRs due to default integrations ([#3778](https://github.com/getsentry/sentry-java/pull/3778)) - Lazily initialize heavy `SentryOptions` members to avoid ANRs on app start ([#3749](https://github.com/getsentry/sentry-java/pull/3749)) *Breaking changes*: diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ActivityLifecycleIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/ActivityLifecycleIntegration.java index 7556afb235..6e7a22ac05 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/ActivityLifecycleIntegration.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ActivityLifecycleIntegration.java @@ -382,7 +382,7 @@ public synchronized void onActivityCreated( firstActivityCreated = true; - if (fullyDisplayedReporter != null) { + if (performanceEnabled && ttfdSpan != null && fullyDisplayedReporter != null) { fullyDisplayedReporter.registerFullyDrawnListener(() -> onFullFrameDrawn(ttfdSpan)); } } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AppComponentsBreadcrumbsIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/AppComponentsBreadcrumbsIntegration.java index 97c7d06ce7..f2d9f6a2a7 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/AppComponentsBreadcrumbsIntegration.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AppComponentsBreadcrumbsIntegration.java @@ -85,43 +85,25 @@ public void close() throws IOException { @SuppressWarnings("deprecation") @Override public void onConfigurationChanged(@NotNull Configuration newConfig) { - if (hub != null) { - final Device.DeviceOrientation deviceOrientation = - DeviceOrientations.getOrientation(context.getResources().getConfiguration().orientation); - - String orientation; - if (deviceOrientation != null) { - orientation = deviceOrientation.name().toLowerCase(Locale.ROOT); - } else { - orientation = "undefined"; - } - - final Breadcrumb breadcrumb = new Breadcrumb(); - breadcrumb.setType("navigation"); - breadcrumb.setCategory("device.orientation"); - breadcrumb.setData("position", orientation); - breadcrumb.setLevel(SentryLevel.INFO); - - final Hint hint = new Hint(); - hint.set(ANDROID_CONFIGURATION, newConfig); - - hub.addBreadcrumb(breadcrumb, hint); - } + final long now = System.currentTimeMillis(); + executeInBackground(() -> captureConfigurationChangedBreadcrumb(now, newConfig)); } @Override public void onLowMemory() { - createLowMemoryBreadcrumb(null); + final long now = System.currentTimeMillis(); + executeInBackground(() -> captureLowMemoryBreadcrumb(now, null)); } @Override public void onTrimMemory(final int level) { - createLowMemoryBreadcrumb(level); + final long now = System.currentTimeMillis(); + executeInBackground(() -> captureLowMemoryBreadcrumb(now, level)); } - private void createLowMemoryBreadcrumb(final @Nullable Integer level) { + private void captureLowMemoryBreadcrumb(final long timeMs, final @Nullable Integer level) { if (hub != null) { - final Breadcrumb breadcrumb = new Breadcrumb(); + final Breadcrumb breadcrumb = new Breadcrumb(timeMs); if (level != null) { // only add breadcrumb if TRIM_MEMORY_BACKGROUND, TRIM_MEMORY_MODERATE or // TRIM_MEMORY_COMPLETE. @@ -147,4 +129,42 @@ private void createLowMemoryBreadcrumb(final @Nullable Integer level) { hub.addBreadcrumb(breadcrumb); } } + + private void captureConfigurationChangedBreadcrumb( + final long timeMs, final @NotNull Configuration newConfig) { + if (hub != null) { + final Device.DeviceOrientation deviceOrientation = + DeviceOrientations.getOrientation(context.getResources().getConfiguration().orientation); + + String orientation; + if (deviceOrientation != null) { + orientation = deviceOrientation.name().toLowerCase(Locale.ROOT); + } else { + orientation = "undefined"; + } + + final Breadcrumb breadcrumb = new Breadcrumb(timeMs); + breadcrumb.setType("navigation"); + breadcrumb.setCategory("device.orientation"); + breadcrumb.setData("position", orientation); + breadcrumb.setLevel(SentryLevel.INFO); + + final Hint hint = new Hint(); + hint.set(ANDROID_CONFIGURATION, newConfig); + + hub.addBreadcrumb(breadcrumb, hint); + } + } + + private void executeInBackground(final @NotNull Runnable runnable) { + if (options != null) { + try { + options.getExecutorService().submit(runnable); + } catch (Throwable t) { + options + .getLogger() + .log(SentryLevel.ERROR, t, "Failed to submit app components breadcrumb task"); + } + } + } } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/LifecycleWatcher.java b/sentry-android-core/src/main/java/io/sentry/android/core/LifecycleWatcher.java index 81e77a75fb..23072265eb 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/LifecycleWatcher.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/LifecycleWatcher.java @@ -6,7 +6,6 @@ import io.sentry.IHub; import io.sentry.SentryLevel; import io.sentry.Session; -import io.sentry.android.core.internal.util.BreadcrumbFactory; import io.sentry.transport.CurrentDateProvider; import io.sentry.transport.ICurrentDateProvider; import java.util.Timer; @@ -90,7 +89,6 @@ private void startSession() { if (lastUpdatedSession == 0L || (lastUpdatedSession + sessionIntervalMillis) <= currentTimeMillis) { if (enableSessionTracking) { - addSessionBreadcrumb("start"); hub.startSession(); } hub.getOptions().getReplayController().start(); @@ -125,7 +123,6 @@ private void scheduleEndSession() { @Override public void run() { if (enableSessionTracking) { - addSessionBreadcrumb("end"); hub.endSession(); } hub.getOptions().getReplayController().stop(); @@ -157,11 +154,6 @@ private void addAppBreadcrumb(final @NotNull String state) { } } - private void addSessionBreadcrumb(final @NotNull String state) { - final Breadcrumb breadcrumb = BreadcrumbFactory.forSession(state); - hub.addBreadcrumb(breadcrumb); - } - @TestOnly @Nullable TimerTask getTimerTask() { diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/NetworkBreadcrumbsIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/NetworkBreadcrumbsIntegration.java index e30dfb681c..fa5724e8c5 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/NetworkBreadcrumbsIntegration.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/NetworkBreadcrumbsIntegration.java @@ -31,12 +31,13 @@ public final class NetworkBreadcrumbsIntegration implements Integration, Closeable { private final @NotNull Context context; - private final @NotNull BuildInfoProvider buildInfoProvider; - private final @NotNull ILogger logger; + private final @NotNull Object lock = new Object(); + private volatile boolean isClosed; + private @Nullable SentryOptions options; - @TestOnly @Nullable NetworkBreadcrumbsNetworkCallback networkCallback; + @TestOnly @Nullable volatile NetworkBreadcrumbsNetworkCallback networkCallback; public NetworkBreadcrumbsIntegration( final @NotNull Context context, @@ -63,40 +64,74 @@ public void register(final @NotNull IHub hub, final @NotNull SentryOptions optio "NetworkBreadcrumbsIntegration enabled: %s", androidOptions.isEnableNetworkEventBreadcrumbs()); + this.options = options; + if (androidOptions.isEnableNetworkEventBreadcrumbs()) { // The specific error is logged in the ConnectivityChecker method - if (buildInfoProvider.getSdkInfoVersion() < Build.VERSION_CODES.LOLLIPOP) { - networkCallback = null; - logger.log(SentryLevel.DEBUG, "NetworkBreadcrumbsIntegration requires Android 5+"); + if (buildInfoProvider.getSdkInfoVersion() < Build.VERSION_CODES.N) { + logger.log(SentryLevel.DEBUG, "NetworkCallbacks need Android N+."); return; } - networkCallback = - new NetworkBreadcrumbsNetworkCallback(hub, buildInfoProvider, options.getDateProvider()); - final boolean registered = - AndroidConnectionStatusProvider.registerNetworkCallback( - context, logger, buildInfoProvider, networkCallback); + try { + options + .getExecutorService() + .submit( + new Runnable() { + @Override + public void run() { + // in case integration is closed before the task is executed, simply return + if (isClosed) { + return; + } - // The specific error is logged in the ConnectivityChecker method - if (!registered) { - networkCallback = null; - logger.log(SentryLevel.DEBUG, "NetworkBreadcrumbsIntegration not installed."); - return; + synchronized (lock) { + networkCallback = + new NetworkBreadcrumbsNetworkCallback( + hub, buildInfoProvider, options.getDateProvider()); + + final boolean registered = + AndroidConnectionStatusProvider.registerNetworkCallback( + context, logger, buildInfoProvider, networkCallback); + if (registered) { + logger.log(SentryLevel.DEBUG, "NetworkBreadcrumbsIntegration installed."); + addIntegrationToSdkVersion(getClass()); + } else { + logger.log( + SentryLevel.DEBUG, "NetworkBreadcrumbsIntegration not installed."); + // The specific error is logged by AndroidConnectionStatusProvider + } + } + } + }); + } catch (Throwable t) { + logger.log(SentryLevel.ERROR, "Error submitting NetworkBreadcrumbsIntegration task.", t); } - logger.log(SentryLevel.DEBUG, "NetworkBreadcrumbsIntegration installed."); - addIntegrationToSdkVersion(getClass()); } } @Override public void close() throws IOException { - if (networkCallback != null) { - AndroidConnectionStatusProvider.unregisterNetworkCallback( - context, logger, buildInfoProvider, networkCallback); - logger.log(SentryLevel.DEBUG, "NetworkBreadcrumbsIntegration remove."); + isClosed = true; + + try { + Objects.requireNonNull(options, "Options is required") + .getExecutorService() + .submit( + () -> { + synchronized (lock) { + if (networkCallback != null) { + AndroidConnectionStatusProvider.unregisterNetworkCallback( + context, logger, buildInfoProvider, networkCallback); + logger.log(SentryLevel.DEBUG, "NetworkBreadcrumbsIntegration removed."); + } + networkCallback = null; + } + }); + } catch (Throwable t) { + logger.log(SentryLevel.ERROR, "Error submitting NetworkBreadcrumbsIntegration task.", t); } - networkCallback = null; } @SuppressLint("ObsoleteSdkInt") diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroid.java b/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroid.java index 0c1a74edb0..e6e677334c 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroid.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroid.java @@ -13,7 +13,6 @@ import io.sentry.SentryLevel; import io.sentry.SentryOptions; import io.sentry.Session; -import io.sentry.android.core.internal.util.BreadcrumbFactory; import io.sentry.android.core.performance.AppStartMetrics; import io.sentry.android.core.performance.TimeSpan; import io.sentry.android.fragment.FragmentLifecycleIntegration; @@ -173,7 +172,6 @@ public static synchronized void init( } }); if (!sessionStarted.get()) { - hub.addBreadcrumb(BreadcrumbFactory.forSession("session.start")); hub.startSession(); } } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/SystemEventsBreadcrumbsIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/SystemEventsBreadcrumbsIntegration.java index f196b7ca90..76422fdf6e 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/SystemEventsBreadcrumbsIntegration.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/SystemEventsBreadcrumbsIntegration.java @@ -211,7 +211,7 @@ static final class SystemEventsBroadcastReceiver extends BroadcastReceiver { private static final long DEBOUNCE_WAIT_TIME_MS = 60 * 1000; private final @NotNull IHub hub; private final @NotNull SentryAndroidOptions options; - private final @NotNull Debouncer debouncer = + private final @NotNull Debouncer batteryChangedDebouncer = new Debouncer(AndroidCurrentDateProvider.getInstance(), DEBOUNCE_WAIT_TIME_MS, 0); SystemEventsBroadcastReceiver( @@ -221,19 +221,43 @@ static final class SystemEventsBroadcastReceiver extends BroadcastReceiver { } @Override - public void onReceive(Context context, Intent intent) { - final boolean shouldDebounce = debouncer.checkForDebounce(); - final String action = intent.getAction(); + public void onReceive(final Context context, final @NotNull Intent intent) { + final @Nullable String action = intent.getAction(); final boolean isBatteryChanged = ACTION_BATTERY_CHANGED.equals(action); - if (isBatteryChanged && shouldDebounce) { - // aligning with iOS which only captures battery status changes every minute at maximum + + // aligning with iOS which only captures battery status changes every minute at maximum + if (isBatteryChanged && batteryChangedDebouncer.checkForDebounce()) { return; } - final Breadcrumb breadcrumb = new Breadcrumb(); + final long now = System.currentTimeMillis(); + try { + options + .getExecutorService() + .submit( + () -> { + final Breadcrumb breadcrumb = + createBreadcrumb(now, intent, action, isBatteryChanged); + final Hint hint = new Hint(); + hint.set(ANDROID_INTENT, intent); + hub.addBreadcrumb(breadcrumb, hint); + }); + } catch (Throwable t) { + options + .getLogger() + .log(SentryLevel.ERROR, t, "Failed to submit system event breadcrumb action."); + } + } + + private @NotNull Breadcrumb createBreadcrumb( + final long timeMs, + final @NotNull Intent intent, + final @Nullable String action, + boolean isBatteryChanged) { + final Breadcrumb breadcrumb = new Breadcrumb(timeMs); breadcrumb.setType("system"); breadcrumb.setCategory("device.event"); - String shortAction = StringUtils.getStringAfterDot(action); + final String shortAction = StringUtils.getStringAfterDot(action); if (shortAction != null) { breadcrumb.setData("action", shortAction); } @@ -273,11 +297,7 @@ public void onReceive(Context context, Intent intent) { } } breadcrumb.setLevel(SentryLevel.INFO); - - final Hint hint = new Hint(); - hint.set(ANDROID_INTENT, intent); - - hub.addBreadcrumb(breadcrumb, hint); + return breadcrumb; } } } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/ActivityLifecycleIntegrationTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/ActivityLifecycleIntegrationTest.kt index b355075ff1..addeb94819 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/ActivityLifecycleIntegrationTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/ActivityLifecycleIntegrationTest.kt @@ -78,7 +78,6 @@ class ActivityLifecycleIntegrationTest { } val bundle = mock() val activityFramesTracker = mock() - val fullyDisplayedReporter = FullyDisplayedReporter.getInstance() val transactionFinishedCallback = mock() lateinit var shadowActivityManager: ShadowActivityManager @@ -619,11 +618,30 @@ class ActivityLifecycleIntegrationTest { sut.onActivityCreated(activity, mock()) val ttfdSpan = sut.ttfdSpanMap[activity] sut.ttidSpanMap.values.first().finish() - fixture.fullyDisplayedReporter.reportFullyDrawn() + fixture.options.fullyDisplayedReporter.reportFullyDrawn() assertTrue(ttfdSpan!!.isFinished) assertNotEquals(SpanStatus.CANCELLED, ttfdSpan.status) } + @Test + fun `if ttfd is disabled, no listener is registered for FullyDisplayedReporter`() { + val ttfdReporter = mock() + + val sut = fixture.getSut() + fixture.options.apply { + tracesSampleRate = 1.0 + isEnableTimeToFullDisplayTracing = false + fullyDisplayedReporter = ttfdReporter + } + + sut.register(fixture.hub, fixture.options) + + val activity = mock() + sut.onActivityCreated(activity, mock()) + + verify(ttfdReporter, never()).registerFullyDrawnListener(any()) + } + @Test fun `App start is Cold when savedInstanceState is null`() { val sut = fixture.getSut() diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/AppComponentsBreadcrumbsIntegrationTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/AppComponentsBreadcrumbsIntegrationTest.kt index 15a6d690e5..8f45c23802 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/AppComponentsBreadcrumbsIntegrationTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/AppComponentsBreadcrumbsIntegrationTest.kt @@ -7,6 +7,7 @@ import androidx.test.ext.junit.runners.AndroidJUnit4 import io.sentry.Breadcrumb import io.sentry.IHub import io.sentry.SentryLevel +import io.sentry.test.ImmediateExecutorService import org.junit.runner.RunWith import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull @@ -36,7 +37,9 @@ class AppComponentsBreadcrumbsIntegrationTest { @Test fun `When app components breadcrumb is enabled, it registers callback`() { val sut = fixture.getSut() - val options = SentryAndroidOptions() + val options = SentryAndroidOptions().apply { + executorService = ImmediateExecutorService() + } val hub = mock() sut.register(hub, options) verify(fixture.context).registerComponentCallbacks(any()) @@ -45,7 +48,9 @@ class AppComponentsBreadcrumbsIntegrationTest { @Test fun `When app components breadcrumb is enabled, but ComponentCallbacks is not ready, do not throw`() { val sut = fixture.getSut() - val options = SentryAndroidOptions() + val options = SentryAndroidOptions().apply { + executorService = ImmediateExecutorService() + } val hub = mock() sut.register(hub, options) whenever(fixture.context.registerComponentCallbacks(any())).thenThrow(NullPointerException()) @@ -58,6 +63,7 @@ class AppComponentsBreadcrumbsIntegrationTest { val sut = fixture.getSut() val options = SentryAndroidOptions().apply { isEnableAppComponentBreadcrumbs = false + executorService = ImmediateExecutorService() } val hub = mock() sut.register(hub, options) @@ -67,7 +73,9 @@ class AppComponentsBreadcrumbsIntegrationTest { @Test fun `When AppComponentsBreadcrumbsIntegrationTest is closed, it should unregister the callback`() { val sut = fixture.getSut() - val options = SentryAndroidOptions() + val options = SentryAndroidOptions().apply { + executorService = ImmediateExecutorService() + } val hub = mock() sut.register(hub, options) sut.close() @@ -77,7 +85,9 @@ class AppComponentsBreadcrumbsIntegrationTest { @Test fun `When app components breadcrumb is closed, but ComponentCallbacks is not ready, do not throw`() { val sut = fixture.getSut() - val options = SentryAndroidOptions() + val options = SentryAndroidOptions().apply { + executorService = ImmediateExecutorService() + } val hub = mock() whenever(fixture.context.registerComponentCallbacks(any())).thenThrow(NullPointerException()) whenever(fixture.context.unregisterComponentCallbacks(any())).thenThrow(NullPointerException()) @@ -88,7 +98,9 @@ class AppComponentsBreadcrumbsIntegrationTest { @Test fun `When low memory event, a breadcrumb with type, category and level should be set`() { val sut = fixture.getSut() - val options = SentryAndroidOptions() + val options = SentryAndroidOptions().apply { + executorService = ImmediateExecutorService() + } val hub = mock() sut.register(hub, options) sut.onLowMemory() @@ -104,7 +116,9 @@ class AppComponentsBreadcrumbsIntegrationTest { @Test fun `When trim memory event with level, a breadcrumb with type, category and level should be set`() { val sut = fixture.getSut() - val options = SentryAndroidOptions() + val options = SentryAndroidOptions().apply { + executorService = ImmediateExecutorService() + } val hub = mock() sut.register(hub, options) sut.onTrimMemory(ComponentCallbacks2.TRIM_MEMORY_BACKGROUND) @@ -120,7 +134,9 @@ class AppComponentsBreadcrumbsIntegrationTest { @Test fun `When trim memory event with level not so high, do not add a breadcrumb`() { val sut = fixture.getSut() - val options = SentryAndroidOptions() + val options = SentryAndroidOptions().apply { + executorService = ImmediateExecutorService() + } val hub = mock() sut.register(hub, options) sut.onTrimMemory(ComponentCallbacks2.TRIM_MEMORY_RUNNING_CRITICAL) @@ -130,7 +146,9 @@ class AppComponentsBreadcrumbsIntegrationTest { @Test fun `When device orientation event, a breadcrumb with type, category and level should be set`() { val sut = AppComponentsBreadcrumbsIntegration(ApplicationProvider.getApplicationContext()) - val options = SentryAndroidOptions() + val options = SentryAndroidOptions().apply { + executorService = ImmediateExecutorService() + } val hub = mock() sut.register(hub, options) sut.onConfigurationChanged(mock()) diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/LifecycleWatcherTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/LifecycleWatcherTest.kt index 388bfbe274..1bc88961da 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/LifecycleWatcherTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/LifecycleWatcherTest.kt @@ -137,49 +137,6 @@ class LifecycleWatcherTest { verify(fixture.hub, never()).endSession() } - @Test - fun `When session tracking is enabled, add breadcrumb on start`() { - val watcher = fixture.getSUT(enableAppLifecycleBreadcrumbs = false) - watcher.onStart(fixture.ownerMock) - verify(fixture.hub).addBreadcrumb( - check { - assertEquals("app.lifecycle", it.category) - assertEquals("session", it.type) - assertEquals(SentryLevel.INFO, it.level) - // cant assert data, its not a public API - } - ) - } - - @Test - fun `When session tracking is enabled, add breadcrumb on stop`() { - val watcher = fixture.getSUT(enableAppLifecycleBreadcrumbs = false) - watcher.onStop(fixture.ownerMock) - verify(fixture.hub, timeout(10000)).endSession() - verify(fixture.hub).addBreadcrumb( - check { - assertEquals("app.lifecycle", it.category) - assertEquals("session", it.type) - assertEquals(SentryLevel.INFO, it.level) - // cant assert data, its not a public API - } - ) - } - - @Test - fun `When session tracking is disabled, do not add breadcrumb on start`() { - val watcher = fixture.getSUT(enableAutoSessionTracking = false, enableAppLifecycleBreadcrumbs = false) - watcher.onStart(fixture.ownerMock) - verify(fixture.hub, never()).addBreadcrumb(any()) - } - - @Test - fun `When session tracking is disabled, do not add breadcrumb on stop`() { - val watcher = fixture.getSUT(enableAutoSessionTracking = false, enableAppLifecycleBreadcrumbs = false) - watcher.onStop(fixture.ownerMock) - verify(fixture.hub, never()).addBreadcrumb(any()) - } - @Test fun `When app lifecycle breadcrumbs is enabled, add breadcrumb on start`() { val watcher = fixture.getSUT(enableAutoSessionTracking = false) diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/NetworkBreadcrumbsIntegrationTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/NetworkBreadcrumbsIntegrationTest.kt index 146f229fdf..c664d98699 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/NetworkBreadcrumbsIntegrationTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/NetworkBreadcrumbsIntegrationTest.kt @@ -9,12 +9,15 @@ import android.os.Build import io.sentry.Breadcrumb import io.sentry.DateUtils import io.sentry.IHub +import io.sentry.ISentryExecutorService import io.sentry.SentryDateProvider import io.sentry.SentryLevel import io.sentry.SentryNanotimeDate import io.sentry.TypeCheckHint import io.sentry.android.core.NetworkBreadcrumbsIntegration.NetworkBreadcrumbConnectionDetail import io.sentry.android.core.NetworkBreadcrumbsIntegration.NetworkBreadcrumbsNetworkCallback +import io.sentry.test.DeferredExecutorService +import io.sentry.test.ImmediateExecutorService import org.mockito.kotlin.KInOrder import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull @@ -47,14 +50,22 @@ class NetworkBreadcrumbsIntegrationTest { init { whenever(mockBuildInfoProvider.sdkInfoVersion).thenReturn(Build.VERSION_CODES.N) - whenever(context.getSystemService(eq(Context.CONNECTIVITY_SERVICE))).thenReturn(connectivityManager) + whenever(context.getSystemService(eq(Context.CONNECTIVITY_SERVICE))).thenReturn( + connectivityManager + ) } - fun getSut(enableNetworkEventBreadcrumbs: Boolean = true, buildInfo: BuildInfoProvider = mockBuildInfoProvider): NetworkBreadcrumbsIntegration { + fun getSut( + enableNetworkEventBreadcrumbs: Boolean = true, + buildInfo: BuildInfoProvider = mockBuildInfoProvider, + executor: ISentryExecutorService = ImmediateExecutorService() + ): NetworkBreadcrumbsIntegration { options = SentryAndroidOptions().apply { + executorService = executor isEnableNetworkEventBreadcrumbs = enableNetworkEventBreadcrumbs dateProvider = SentryDateProvider { - val nowNanos = TimeUnit.MILLISECONDS.toNanos(nowMs ?: System.currentTimeMillis()) + val nowNanos = + TimeUnit.MILLISECONDS.toNanos(nowMs ?: System.currentTimeMillis()) SentryNanotimeDate(DateUtils.nanosToDate(nowNanos), nowNanos) } } @@ -117,7 +128,10 @@ class NetworkBreadcrumbsIntegrationTest { sut.register(fixture.hub, fixture.options) sut.close() - verify(fixture.connectivityManager, never()).unregisterNetworkCallback(any()) + verify( + fixture.connectivityManager, + never() + ).unregisterNetworkCallback(any()) assertNull(sut.networkCallback) } @@ -482,11 +496,27 @@ class NetworkBreadcrumbsIntegrationTest { } } + @Test + fun `If integration is opened and closed immediately it still properly unregisters`() { + val executor = DeferredExecutorService() + val sut = fixture.getSut(executor = executor) + + sut.register(fixture.hub, fixture.options) + sut.close() + + executor.runAll() + + assertNull(sut.networkCallback) + verify(fixture.connectivityManager, never()).registerDefaultNetworkCallback(any()) + verify(fixture.connectivityManager, never()).unregisterNetworkCallback(any()) + } + private fun KInOrder.verifyBreadcrumbInOrder(check: (detail: NetworkBreadcrumbConnectionDetail) -> Unit) { verify(fixture.hub, times(1)).addBreadcrumb( any(), check { - val connectionDetail = it[TypeCheckHint.ANDROID_NETWORK_CAPABILITIES] as NetworkBreadcrumbConnectionDetail + val connectionDetail = + it[TypeCheckHint.ANDROID_NETWORK_CAPABILITIES] as NetworkBreadcrumbConnectionDetail check(connectionDetail) } ) @@ -496,7 +526,8 @@ class NetworkBreadcrumbsIntegrationTest { verify(fixture.hub).addBreadcrumb( any(), check { - val connectionDetail = it[TypeCheckHint.ANDROID_NETWORK_CAPABILITIES] as NetworkBreadcrumbConnectionDetail + val connectionDetail = + it[TypeCheckHint.ANDROID_NETWORK_CAPABILITIES] as NetworkBreadcrumbConnectionDetail check(connectionDetail) } ) @@ -516,9 +547,13 @@ class NetworkBreadcrumbsIntegrationTest { whenever(capabilities.linkUpstreamBandwidthKbps).thenReturn(upstreamBandwidthKbps) whenever(capabilities.signalStrength).thenReturn(signalStrength) whenever(capabilities.hasTransport(NetworkCapabilities.TRANSPORT_VPN)).thenReturn(isVpn) - whenever(capabilities.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET)).thenReturn(isEthernet) + whenever(capabilities.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET)).thenReturn( + isEthernet + ) whenever(capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)).thenReturn(isWifi) - whenever(capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)).thenReturn(isCellular) + whenever(capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)).thenReturn( + isCellular + ) return capabilities } diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 059262d2c3..f8dc3b52ee 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -2523,6 +2523,7 @@ public class io/sentry/SentryOptions { public fun setEnvironment (Ljava/lang/String;)V public fun setExecutorService (Lio/sentry/ISentryExecutorService;)V public fun setFlushTimeoutMillis (J)V + public fun setFullyDisplayedReporter (Lio/sentry/FullyDisplayedReporter;)V public fun setGestureTargetLocators (Ljava/util/List;)V public fun setIdleTimeout (Ljava/lang/Long;)V public fun setIgnoredCheckIns (Ljava/util/List;)V diff --git a/sentry/src/main/java/io/sentry/SentryOptions.java b/sentry/src/main/java/io/sentry/SentryOptions.java index 61c721847c..3873bc9380 100644 --- a/sentry/src/main/java/io/sentry/SentryOptions.java +++ b/sentry/src/main/java/io/sentry/SentryOptions.java @@ -431,7 +431,7 @@ public class SentryOptions { private boolean enableTimeToFullDisplayTracing = false; /** Screen fully displayed reporter, used for time-to-full-display spans. */ - private final @NotNull FullyDisplayedReporter fullyDisplayedReporter = + private @NotNull FullyDisplayedReporter fullyDisplayedReporter = FullyDisplayedReporter.getInstance(); private @NotNull IConnectionStatusProvider connectionStatusProvider = @@ -2100,6 +2100,13 @@ public void setEnableTimeToFullDisplayTracing(final boolean enableTimeToFullDisp return fullyDisplayedReporter; } + @ApiStatus.Internal + @TestOnly + public void setFullyDisplayedReporter( + final @NotNull FullyDisplayedReporter fullyDisplayedReporter) { + this.fullyDisplayedReporter = fullyDisplayedReporter; + } + /** * Whether OPTIONS requests should be traced. * From 654882517e1a1e804da565437811ae3df1764202 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Wed, 9 Oct 2024 17:06:40 +0200 Subject: [PATCH 44/92] [SR] Support Jetpack Compose redaction (#3739) * WIP * Compose works * Custom redaction works for Compose * Formatting * Clean up * Test * Add tests * Changelog * Replace logo with sentry * formatting * Faster boundsInWindow for compose * api dump * Dont use liveliterals --- CHANGELOG.md | 16 +- buildSrc/src/main/java/Config.kt | 3 + .../api/sentry-android-replay.api | 54 +++- sentry-android-replay/build.gradle.kts | 25 +- sentry-android-replay/proguard-rules.pro | 17 ++ .../android/replay/ModifierExtensions.kt | 29 +++ .../android/replay/ScreenshotRecorder.kt | 36 +-- .../io/sentry/android/replay/util/Nodes.kt | 206 +++++++++++++++ .../sentry/android/replay/util/TextLayout.kt | 21 ++ .../io/sentry/android/replay/util/Views.kt | 98 +++++-- .../viewhierarchy/ComposeViewHierarchyNode.kt | 213 ++++++++++++++++ .../replay/viewhierarchy/ViewHierarchyNode.kt | 43 ++-- .../src/test/AndroidManifest.xml | 24 ++ .../replay/util/TextViewDominantColorTest.kt | 6 +- .../ComposeRedactionOptionsTest.kt | 240 ++++++++++++++++++ .../viewhierarchy/RedactionOptionsTest.kt | 74 +++--- .../sentry-samples-android/build.gradle.kts | 1 + .../src/main/AndroidManifest.xml | 3 +- .../android/compose/ComposeActivity.kt | 20 +- .../src/main/res/drawable/sentry_glyph.xml | 9 + 20 files changed, 1006 insertions(+), 132 deletions(-) create mode 100644 sentry-android-replay/src/main/java/io/sentry/android/replay/ModifierExtensions.kt create mode 100644 sentry-android-replay/src/main/java/io/sentry/android/replay/util/Nodes.kt create mode 100644 sentry-android-replay/src/main/java/io/sentry/android/replay/util/TextLayout.kt create mode 100644 sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ComposeViewHierarchyNode.kt create mode 100644 sentry-android-replay/src/test/AndroidManifest.xml create mode 100644 sentry-android-replay/src/test/java/io/sentry/android/replay/viewhierarchy/ComposeRedactionOptionsTest.kt create mode 100644 sentry-samples/sentry-samples-android/src/main/res/drawable/sentry_glyph.xml diff --git a/CHANGELOG.md b/CHANGELOG.md index 71190dc668..fc4c2d1009 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,18 +6,20 @@ - Add support for `feedback` envelope header item type ([#3687](https://github.com/getsentry/sentry-java/pull/3687)) - Add breadcrumb.origin field ([#3727](https://github.com/getsentry/sentry-java/pull/3727)) +- Session Replay: Add options to selectively redact/ignore views from being captured. The following options are available: ([#3689](https://github.com/getsentry/sentry-java/pull/3689)) + - `android:tag="sentry-redact|sentry-ignore"` in XML or `view.setTag("sentry-redact|sentry-ignore")` in code tags + - if you already have a tag set for a view, you can set a tag by id: `` in XML or `view.setTag(io.sentry.android.replay.R.id.sentry_privacy, "redact|ignore")` in code + - `view.sentryReplayRedact()` or `view.sentryReplayIgnore()` extension functions + - redact/ignore `View`s of a certain type by adding fully-qualified classname to one of the lists `options.experimental.sessionReplay.addRedactViewClass()` or `options.experimental.sessionReplay.addIgnoreViewClass()`. Note, that all of the view subclasses/subtypes will be redacted/ignored as well + - For example, (this is already a default behavior) to redact all `TextView`s and their subclasses (`RadioButton`, `EditText`, etc.): `options.experimental.sessionReplay.addRedactViewClass("android.widget.TextView")` + - If you're using code obfuscation, adjust your proguard-rules accordingly, so your custom view class name is not minified +- Session Replay: Support Jetpack Compose masking ([#3739](https://github.com/getsentry/sentry-java/pull/3739)) + - To selectively mask/unmask @Composables, use `Modifier.sentryReplayRedact()` and `Modifier.sentryReplayIgnore()` modifiers ### Fixes - Avoid stopping appStartProfiler after application creation ([#3630](https://github.com/getsentry/sentry-java/pull/3630)) - Session Replay: Correctly detect dominant color for `TextView`s with Spans ([#3682](https://github.com/getsentry/sentry-java/pull/3682)) -- Session Replay: Add options to selectively redact/ignore views from being captured. The following options are available: ([#3689](https://github.com/getsentry/sentry-java/pull/3689)) - - `android:tag="sentry-redact|sentry-ignore"` in XML or `view.setTag("sentry-redact|sentry-ignore")` in code tags - - if you already have a tag set for a view, you can set a tag by id: `` in XML or `view.setTag(io.sentry.android.replay.R.id.sentry_privacy, "redact|ignore")` in code - - `view.sentryReplayRedact()` or `view.sentryReplayIgnore()` extension functions - - redact/ignore `View`s of a certain type by adding fully-qualified classname to one of the lists `options.experimental.sessionReplay.addRedactViewClass()` or `options.experimental.sessionReplay.addIgnoreViewClass()`. Note, that all of the view subclasses/subtypes will be redacted/ignored as well - - For example, (this is already a default behavior) to redact all `TextView`s and their subclasses (`RadioButton`, `EditText`, etc.): `options.experimental.sessionReplay.addRedactViewClass("android.widget.TextView")` - - If you're using code obfuscation, adjust your proguard-rules accordingly, so your custom view class name is not minified - Fix ensure Application Context is used even when SDK is initialized via Activity Context ([#3669](https://github.com/getsentry/sentry-java/pull/3669)) - Fix potential ANRs due to `Calendar.getInstance` usage in Breadcrumbs constructor ([#3736](https://github.com/getsentry/sentry-java/pull/3736)) - Fix potential ANRs due to default integrations ([#3778](https://github.com/getsentry/sentry-java/pull/3778)) diff --git a/buildSrc/src/main/java/Config.kt b/buildSrc/src/main/java/Config.kt index f74fcb4953..8e4b6832fb 100644 --- a/buildSrc/src/main/java/Config.kt +++ b/buildSrc/src/main/java/Config.kt @@ -147,8 +147,11 @@ object Config { val composeActivity = "androidx.activity:activity-compose:1.4.0" val composeFoundation = "androidx.compose.foundation:foundation:$composeVersion" val composeUi = "androidx.compose.ui:ui:$composeVersion" + + val composeUiReplay = "androidx.compose.ui:ui:1.5.0" // Note: don't change without testing forwards compatibility val composeFoundationLayout = "androidx.compose.foundation:foundation-layout:$composeVersion" val composeMaterial = "androidx.compose.material3:material3:1.0.0-alpha13" + val composeCoil = "io.coil-kt:coil-compose:2.6.0" val apolloKotlin = "com.apollographql.apollo3:apollo-runtime:3.8.2" diff --git a/sentry-android-replay/api/sentry-android-replay.api b/sentry-android-replay/api/sentry-android-replay.api index 1c08379a49..4b4c59b9a2 100644 --- a/sentry-android-replay/api/sentry-android-replay.api +++ b/sentry-android-replay/api/sentry-android-replay.api @@ -7,11 +7,13 @@ public final class io/sentry/android/replay/BuildConfig { } public class io/sentry/android/replay/DefaultReplayBreadcrumbConverter : io/sentry/ReplayBreadcrumbConverter { + public static final field $stable I public fun ()V public fun convert (Lio/sentry/Breadcrumb;)Lio/sentry/rrweb/RRWebEvent; } public final class io/sentry/android/replay/GeneratedVideo { + public static final field $stable I public fun (Ljava/io/File;IJ)V public final fun component1 ()Ljava/io/File; public final fun component2 ()I @@ -26,6 +28,11 @@ public final class io/sentry/android/replay/GeneratedVideo { public fun toString ()Ljava/lang/String; } +public final class io/sentry/android/replay/ModifierExtensionsKt { + public static final fun sentryReplayIgnore (Landroidx/compose/ui/Modifier;)Landroidx/compose/ui/Modifier; + public static final fun sentryReplayRedact (Landroidx/compose/ui/Modifier;)Landroidx/compose/ui/Modifier; +} + public abstract interface class io/sentry/android/replay/Recorder : java/io/Closeable { public abstract fun pause ()V public abstract fun resume ()V @@ -34,6 +41,7 @@ public abstract interface class io/sentry/android/replay/Recorder : java/io/Clos } public final class io/sentry/android/replay/ReplayCache : java/io/Closeable { + public static final field $stable I public static final field Companion Lio/sentry/android/replay/ReplayCache$Companion; public fun (Lio/sentry/SentryOptions;Lio/sentry/protocol/SentryId;Lio/sentry/android/replay/ScreenshotRecorderConfig;)V public final fun addFrame (Ljava/io/File;JLjava/lang/String;)V @@ -50,6 +58,7 @@ public final class io/sentry/android/replay/ReplayCache$Companion { } public final class io/sentry/android/replay/ReplayIntegration : android/content/ComponentCallbacks, io/sentry/Integration, io/sentry/ReplayController, io/sentry/android/replay/ScreenshotRecorderCallback, io/sentry/android/replay/gestures/TouchRecorderCallback, java/io/Closeable { + public static final field $stable I public fun (Landroid/content/Context;Lio/sentry/transport/ICurrentDateProvider;)V public fun (Landroid/content/Context;Lio/sentry/transport/ICurrentDateProvider;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;)V public synthetic fun (Landroid/content/Context;Lio/sentry/transport/ICurrentDateProvider;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;ILkotlin/jvm/internal/DefaultConstructorMarker;)V @@ -78,6 +87,7 @@ public abstract interface class io/sentry/android/replay/ScreenshotRecorderCallb } public final class io/sentry/android/replay/ScreenshotRecorderConfig { + public static final field $stable I public static final field Companion Lio/sentry/android/replay/ScreenshotRecorderConfig$Companion; public fun (IIFFII)V public final fun component1 ()I @@ -103,6 +113,12 @@ public final class io/sentry/android/replay/ScreenshotRecorderConfig$Companion { public final fun from (Landroid/content/Context;Lio/sentry/SentryReplayOptions;)Lio/sentry/android/replay/ScreenshotRecorderConfig; } +public final class io/sentry/android/replay/SentryReplayModifiers { + public static final field $stable I + public static final field INSTANCE Lio/sentry/android/replay/SentryReplayModifiers; + public final fun getSentryPrivacy ()Landroidx/compose/ui/semantics/SemanticsPropertyKey; +} + public final class io/sentry/android/replay/SessionReplayOptionsKt { public static final fun getRedactAllImages (Lio/sentry/SentryReplayOptions;)Z public static final fun getRedactAllText (Lio/sentry/SentryReplayOptions;)Z @@ -116,12 +132,14 @@ public final class io/sentry/android/replay/ViewExtensionsKt { } public final class io/sentry/android/replay/gestures/GestureRecorder : io/sentry/android/replay/OnRootViewsChangedListener { + public static final field $stable I public fun (Lio/sentry/SentryOptions;Lio/sentry/android/replay/gestures/TouchRecorderCallback;)V public fun onRootViewsChanged (Landroid/view/View;Z)V public final fun stop ()V } public final class io/sentry/android/replay/gestures/ReplayGestureConverter { + public static final field $stable I public fun (Lio/sentry/transport/ICurrentDateProvider;)V public final fun convert (Landroid/view/MotionEvent;Lio/sentry/android/replay/ScreenshotRecorderConfig;)Ljava/util/List; } @@ -130,6 +148,19 @@ public abstract interface class io/sentry/android/replay/gestures/TouchRecorderC public abstract fun onTouchEvent (Landroid/view/MotionEvent;)V } +public final class io/sentry/android/replay/util/AndroidTextLayout : io/sentry/android/replay/util/TextLayout { + public static final field $stable I + public fun (Landroid/text/Layout;)V + public fun getDominantTextColor ()Ljava/lang/Integer; + public fun getEllipsisCount (I)I + public fun getLineBottom (I)I + public fun getLineCount ()I + public fun getLineStart (I)I + public fun getLineTop (I)I + public fun getLineVisibleEnd (I)I + public fun getPrimaryHorizontal (II)F +} + public class io/sentry/android/replay/util/FixedWindowCallback : android/view/Window$Callback { public final field delegate Landroid/view/Window$Callback; public fun (Landroid/view/Window$Callback;)V @@ -160,6 +191,17 @@ public class io/sentry/android/replay/util/FixedWindowCallback : android/view/Wi public fun onWindowStartingActionMode (Landroid/view/ActionMode$Callback;I)Landroid/view/ActionMode; } +public abstract interface class io/sentry/android/replay/util/TextLayout { + public abstract fun getDominantTextColor ()Ljava/lang/Integer; + public abstract fun getEllipsisCount (I)I + public abstract fun getLineBottom (I)I + public abstract fun getLineCount ()I + public abstract fun getLineStart (I)I + public abstract fun getLineTop (I)I + public abstract fun getLineVisibleEnd (I)I + public abstract fun getPrimaryHorizontal (II)F +} + public abstract interface class io/sentry/android/replay/video/SimpleFrameMuxer { public abstract fun getVideoTime ()J public abstract fun isStarted ()Z @@ -169,6 +211,7 @@ public abstract interface class io/sentry/android/replay/video/SimpleFrameMuxer } public final class io/sentry/android/replay/video/SimpleMp4FrameMuxer : io/sentry/android/replay/video/SimpleFrameMuxer { + public static final field $stable I public fun (Ljava/lang/String;F)V public fun getVideoTime ()J public fun isStarted ()Z @@ -178,6 +221,7 @@ public final class io/sentry/android/replay/video/SimpleMp4FrameMuxer : io/sentr } public abstract class io/sentry/android/replay/viewhierarchy/ViewHierarchyNode { + public static final field $stable I public static final field Companion Lio/sentry/android/replay/viewhierarchy/ViewHierarchyNode$Companion; public synthetic fun (FFIIFILio/sentry/android/replay/viewhierarchy/ViewHierarchyNode;ZZZLandroid/graphics/Rect;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public synthetic fun (FFIIFILio/sentry/android/replay/viewhierarchy/ViewHierarchyNode;ZZZLandroid/graphics/Rect;Lkotlin/jvm/internal/DefaultConstructorMarker;)V @@ -195,6 +239,7 @@ public abstract class io/sentry/android/replay/viewhierarchy/ViewHierarchyNode { public final fun isObscured (Lio/sentry/android/replay/viewhierarchy/ViewHierarchyNode;)Z public final fun isVisible ()Z public final fun setChildren (Ljava/util/List;)V + public final fun setImportantForCaptureToAncestors (Z)V public final fun setImportantForContentCapture (Z)V public final fun traverse (Lkotlin/jvm/functions/Function1;)V } @@ -204,20 +249,23 @@ public final class io/sentry/android/replay/viewhierarchy/ViewHierarchyNode$Comp } public final class io/sentry/android/replay/viewhierarchy/ViewHierarchyNode$GenericViewHierarchyNode : io/sentry/android/replay/viewhierarchy/ViewHierarchyNode { + public static final field $stable I public fun (FFIIFILio/sentry/android/replay/viewhierarchy/ViewHierarchyNode;ZZZLandroid/graphics/Rect;)V public synthetic fun (FFIIFILio/sentry/android/replay/viewhierarchy/ViewHierarchyNode;ZZZLandroid/graphics/Rect;ILkotlin/jvm/internal/DefaultConstructorMarker;)V } public final class io/sentry/android/replay/viewhierarchy/ViewHierarchyNode$ImageViewHierarchyNode : io/sentry/android/replay/viewhierarchy/ViewHierarchyNode { + public static final field $stable I public fun (FFIIFILio/sentry/android/replay/viewhierarchy/ViewHierarchyNode;ZZZLandroid/graphics/Rect;)V public synthetic fun (FFIIFILio/sentry/android/replay/viewhierarchy/ViewHierarchyNode;ZZZLandroid/graphics/Rect;ILkotlin/jvm/internal/DefaultConstructorMarker;)V } public final class io/sentry/android/replay/viewhierarchy/ViewHierarchyNode$TextViewHierarchyNode : io/sentry/android/replay/viewhierarchy/ViewHierarchyNode { - public fun (Landroid/text/Layout;Ljava/lang/Integer;IIFFIIFILio/sentry/android/replay/viewhierarchy/ViewHierarchyNode;ZZZLandroid/graphics/Rect;)V - public synthetic fun (Landroid/text/Layout;Ljava/lang/Integer;IIFFIIFILio/sentry/android/replay/viewhierarchy/ViewHierarchyNode;ZZZLandroid/graphics/Rect;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public static final field $stable I + public fun (Lio/sentry/android/replay/util/TextLayout;Ljava/lang/Integer;IIFFIIFILio/sentry/android/replay/viewhierarchy/ViewHierarchyNode;ZZZLandroid/graphics/Rect;)V + public synthetic fun (Lio/sentry/android/replay/util/TextLayout;Ljava/lang/Integer;IIFFIIFILio/sentry/android/replay/viewhierarchy/ViewHierarchyNode;ZZZLandroid/graphics/Rect;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun getDominantColor ()Ljava/lang/Integer; - public final fun getLayout ()Landroid/text/Layout; + public final fun getLayout ()Lio/sentry/android/replay/util/TextLayout; public final fun getPaddingLeft ()I public final fun getPaddingTop ()I } diff --git a/sentry-android-replay/build.gradle.kts b/sentry-android-replay/build.gradle.kts index 2e74641268..15713bb6f4 100644 --- a/sentry-android-replay/build.gradle.kts +++ b/sentry-android-replay/build.gradle.kts @@ -1,5 +1,6 @@ import io.gitlab.arturbosch.detekt.Detekt import org.jetbrains.kotlin.config.KotlinCompilerVersion +import org.jetbrains.kotlin.gradle.tasks.KotlinCompilationTask plugins { id("com.android.library") @@ -25,9 +26,20 @@ android { buildConfigField("String", "VERSION_NAME", "\"${project.version}\"") } + buildFeatures { + compose = true + } + + composeOptions { + kotlinCompilerExtensionVersion = Config.androidComposeCompilerVersion + useLiveLiterals = false + } + buildTypes { getByName("debug") - getByName("release") + getByName("release") { + consumerProguardFiles("proguard-rules.pro") + } } kotlinOptions { @@ -65,6 +77,7 @@ kotlin { dependencies { api(projects.sentry) + compileOnly(Config.Libs.composeUiReplay) implementation(kotlin(Config.kotlinStdLib, KotlinCompilerVersion.VERSION)) // tests @@ -77,9 +90,19 @@ dependencies { testImplementation(Config.TestLibs.mockitoKotlin) testImplementation(Config.TestLibs.mockitoInline) testImplementation(Config.TestLibs.awaitility) + testImplementation(Config.Libs.composeActivity) + testImplementation(Config.Libs.composeUi) + testImplementation(Config.Libs.composeCoil) + testImplementation(Config.Libs.composeFoundation) + testImplementation(Config.Libs.composeFoundationLayout) + testImplementation(Config.Libs.composeMaterial) } tasks.withType { // Target version of the generated JVM bytecode. It is used for type resolution. jvmTarget = JavaVersion.VERSION_1_8.toString() } + +tasks.withType>().configureEach { + compilerOptions.freeCompilerArgs.add("-opt-in=androidx.compose.ui.ExperimentalComposeUiApi") +} diff --git a/sentry-android-replay/proguard-rules.pro b/sentry-android-replay/proguard-rules.pro index 738204b4c8..445c89b526 100644 --- a/sentry-android-replay/proguard-rules.pro +++ b/sentry-android-replay/proguard-rules.pro @@ -1,3 +1,20 @@ # Uncomment this to preserve the line number information for # debugging stack traces. -keepattributes SourceFile,LineNumberTable + +# Rules to detect Images/Icons and redact them +-dontwarn androidx.compose.ui.graphics.painter.Painter +-keepnames class * extends androidx.compose.ui.graphics.painter.Painter +-keepclasseswithmembernames class * { + androidx.compose.ui.graphics.painter.Painter painter; +} +# Rules to detect Text colors and if they have Modifier.fillMaxWidth to later redact them +-dontwarn androidx.compose.ui.graphics.ColorProducer +-dontwarn androidx.compose.foundation.layout.FillElement +-keepnames class androidx.compose.foundation.layout.FillElement +-keepclasseswithmembernames class * { + androidx.compose.ui.graphics.ColorProducer color; +} +# Rules to detect a compose view to parse its hierarchy +-dontwarn androidx.compose.ui.platform.AndroidComposeView +-keepnames class androidx.compose.ui.platform.AndroidComposeView diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ModifierExtensions.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ModifierExtensions.kt new file mode 100644 index 0000000000..b1b119a89c --- /dev/null +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ModifierExtensions.kt @@ -0,0 +1,29 @@ +package io.sentry.android.replay + +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.SemanticsPropertyKey +import androidx.compose.ui.semantics.semantics +import io.sentry.android.replay.SentryReplayModifiers.SentryPrivacy + +public object SentryReplayModifiers { + val SentryPrivacy = SemanticsPropertyKey( + name = "SentryPrivacy", + mergePolicy = { parentValue, _ -> parentValue } + ) +} + +public fun Modifier.sentryReplayRedact(): Modifier { + return semantics( + properties = { + this[SentryPrivacy] = "redact" + } + ) +} + +public fun Modifier.sentryReplayIgnore(): Modifier { + return semantics( + properties = { + this[SentryPrivacy] = "ignore" + } + ) +} diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt index 0ea3fad6ab..5b779babe0 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt @@ -13,10 +13,8 @@ import android.graphics.Rect import android.graphics.RectF import android.os.Build.VERSION import android.os.Build.VERSION_CODES -import android.util.Log import android.view.PixelCopy import android.view.View -import android.view.ViewGroup import android.view.ViewTreeObserver import android.view.WindowManager import io.sentry.SentryLevel.DEBUG @@ -25,10 +23,10 @@ import io.sentry.SentryLevel.WARNING import io.sentry.SentryOptions import io.sentry.SentryReplayOptions import io.sentry.android.replay.util.MainLooperHandler -import io.sentry.android.replay.util.dominantTextColor import io.sentry.android.replay.util.getVisibleRects import io.sentry.android.replay.util.gracefullyShutdown import io.sentry.android.replay.util.submitSafely +import io.sentry.android.replay.util.traverse import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode.ImageViewHierarchyNode import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode.TextViewHierarchyNode @@ -102,7 +100,6 @@ internal class ScreenshotRecorder( Bitmap.Config.ARGB_8888 ) - val timeStart = System.nanoTime() // postAtFrontOfQueue to ensure the view hierarchy and bitmap are ase close in-sync as possible mainLooperHandler.post { try { @@ -117,6 +114,7 @@ internal class ScreenshotRecorder( return@request } + // TODO: handle animations with heuristics (e.g. if we fall under this condition 2 times in a row, we should capture) if (contentChanged.get()) { options.logger.log(INFO, "Failed to determine view hierarchy, not capturing") bitmap.recycle() @@ -124,9 +122,7 @@ internal class ScreenshotRecorder( } val viewHierarchy = ViewHierarchyNode.fromView(root, null, 0, options) - root.traverse(viewHierarchy) - val timeEnd = System.nanoTime() - Log.e("TIME", String.format("%.2f", ((timeEnd - timeStart) / 1_000_000.0))) + root.traverse(viewHierarchy, options) recorder.submitSafely(options, "screenshot_recorder.redact") { val canvas = Canvas(bitmap) @@ -147,7 +143,7 @@ internal class ScreenshotRecorder( } is TextViewHierarchyNode -> { - val textColor = node.layout.dominantTextColor + val textColor = node.layout?.dominantTextColor ?: node.dominantColor ?: Color.BLACK node.layout.getVisibleRects( @@ -206,6 +202,8 @@ internal class ScreenshotRecorder( // next bind the new root rootView = WeakReference(root) root.viewTreeObserver?.addOnDrawListener(this) + // invalidate the flag to capture the first frame after new window is attached + contentChanged.set(true) } fun unbind(root: View?) { @@ -255,28 +253,6 @@ internal class ScreenshotRecorder( return singlePixelBitmap.getPixel(0, 0) } - private fun View.traverse(parentNode: ViewHierarchyNode) { - if (this !is ViewGroup) { - return - } - - if (this.childCount == 0) { - return - } - - val childNodes = ArrayList(this.childCount) - for (i in 0 until childCount) { - val child = getChildAt(i) - if (child != null) { - val childNode = - ViewHierarchyNode.fromView(child, parentNode, indexOfChild(child), options) - childNodes.add(childNode) - child.traverse(childNode) - } - } - parentNode.children = childNodes - } - private class RecorderExecutorServiceThreadFactory : ThreadFactory { private var cnt = 0 override fun newThread(r: Runnable): Thread { diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Nodes.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Nodes.kt new file mode 100644 index 0000000000..12152f50cb --- /dev/null +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Nodes.kt @@ -0,0 +1,206 @@ +@file:Suppress("INVISIBLE_MEMBER", "INVISIBLE_REFERENCE") // to access internal vals and classes + +package io.sentry.android.replay.util + +import android.graphics.Rect +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorProducer +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.layout.LayoutCoordinates +import androidx.compose.ui.node.LayoutNode +import androidx.compose.ui.text.TextLayoutResult +import kotlin.math.roundToInt + +internal class ComposeTextLayout(internal val layout: TextLayoutResult, private val hasFillModifier: Boolean) : TextLayout { + override val lineCount: Int get() = layout.lineCount + override val dominantTextColor: Int? get() = null + override fun getPrimaryHorizontal(line: Int, offset: Int): Float { + val horizontalPos = layout.getHorizontalPosition(offset, usePrimaryDirection = true) + // when there's no `fill` modifier on a Text composable, compose still thinks that there's + // one and wrongly calculates horizontal position relative to node's start, not text's start + // for some reason. This is only the case for single-line text (multiline works fien). + // So we subtract line's left to get the correct position + return if (!hasFillModifier && lineCount == 1) { + horizontalPos - layout.getLineLeft(line) + } else { + horizontalPos + } + } + override fun getEllipsisCount(line: Int): Int = if (layout.isLineEllipsized(line)) 1 else 0 + override fun getLineVisibleEnd(line: Int): Int = layout.getLineEnd(line, visibleEnd = true) + override fun getLineTop(line: Int): Int = layout.getLineTop(line).roundToInt() + override fun getLineBottom(line: Int): Int = layout.getLineBottom(line).roundToInt() + override fun getLineStart(line: Int): Int = layout.getLineStart(line) +} + +// TODO: probably most of the below we can do via bytecode instrumentation and speed up at runtime + +/** + * This method is necessary to redact images in Compose. + * + * We heuristically look up for classes that have a [Painter] modifier, usually they all have a + * `Painter` string in their name, e.g. PainterElement, PainterModifierNodeElement or + * ContentPainterModifier for Coil. + * + * That's not going to cover all cases, but probably 90%. + * + * We also add special proguard rules to keep the `Painter` class names and their `painter` member. + */ +internal fun LayoutNode.findPainter(): Painter? { + val modifierInfos = getModifierInfo() + for (index in modifierInfos.indices) { + val modifier = modifierInfos[index].modifier + if (modifier::class.java.name.contains("Painter")) { + return try { + modifier::class.java.getDeclaredField("painter") + .apply { isAccessible = true } + .get(modifier) as? Painter + } catch (e: Throwable) { + null + } + } + } + return null +} + +/** + * We heuristically check the known classes that are coming from local assets usually: + * [androidx.compose.ui.graphics.vector.VectorPainter] + * [androidx.compose.ui.graphics.painter.ColorPainter] + * [androidx.compose.ui.graphics.painter.BrushPainter] + * + * In theory, [androidx.compose.ui.graphics.painter.BitmapPainter] can also come from local assets, + * but it can as well come from a network resource, so we preemptively redact it. + */ +internal fun Painter.isRedactable(): Boolean { + val className = this::class.java.name + return !className.contains("Vector") && + !className.contains("Color") && + !className.contains("Brush") +} + +internal data class TextAttributes(val color: Color?, val hasFillModifier: Boolean) + +/** + * This method is necessary to redact text in Compose. + * + * We heuristically look up for classes that have a [Text] modifier, usually they all have a + * `Text` string in their name, e.g. TextStringSimpleElement or TextAnnotatedStringElement. We then + * get the color from the modifier, to be able to redact it with the correct color. + * + * We also look up for classes that have a [Fill] modifier, usually they all have a `Fill` string in + * their name, e.g. FillElement. This is necessary to workaround a Compose bug where single-line + * text composable without a `fill` modifier still thinks that there's one and wrongly calculates + * horizontal position. + * + * We also add special proguard rules to keep the `Text` class names and their `color` member. + */ +internal fun LayoutNode.findTextAttributes(): TextAttributes { + val modifierInfos = getModifierInfo() + var color: Color? = null + var hasFillModifier = false + for (index in modifierInfos.indices) { + val modifier = modifierInfos[index].modifier + val modifierClassName = modifier::class.java.name + if (modifierClassName.contains("Text")) { + color = try { + ( + modifier::class.java.getDeclaredField("color") + .apply { isAccessible = true } + .get(modifier) as? ColorProducer + ) + ?.invoke() + } catch (e: Throwable) { + null + } + } else if (modifierClassName.contains("Fill")) { + hasFillModifier = true + } + } + return TextAttributes(color, hasFillModifier) +} + +/** + * Returns the smaller of the given values. If any value is NaN, returns NaN. Preferred over + * `kotlin.comparisons.minOf()` for 4 arguments as it avoids allocating an array because of the + * varargs. + */ +private inline fun fastMinOf(a: Float, b: Float, c: Float, d: Float): Float { + return minOf(a, minOf(b, minOf(c, d))) +} + +/** + * Returns the largest of the given values. If any value is NaN, returns NaN. Preferred over + * `kotlin.comparisons.maxOf()` for 4 arguments as it avoids allocating an array because of the + * varargs. + */ +private inline fun fastMaxOf(a: Float, b: Float, c: Float, d: Float): Float { + return maxOf(a, maxOf(b, maxOf(c, d))) +} + +/** + * Returns this float value clamped in the inclusive range defined by [minimumValue] and + * [maximumValue]. Unlike [Float.coerceIn], the range is not validated: the caller must ensure that + * [minimumValue] is less than [maximumValue]. + */ +private inline fun Float.fastCoerceIn(minimumValue: Float, maximumValue: Float) = + this.fastCoerceAtLeast(minimumValue).fastCoerceAtMost(maximumValue) + +/** Ensures that this value is not less than the specified [minimumValue]. */ +private inline fun Float.fastCoerceAtLeast(minimumValue: Float): Float { + return if (this < minimumValue) minimumValue else this +} + +/** Ensures that this value is not greater than the specified [maximumValue]. */ +private inline fun Float.fastCoerceAtMost(maximumValue: Float): Float { + return if (this > maximumValue) maximumValue else this +} + +/** + * A faster copy of https://github.com/androidx/androidx/blob/fc7df0dd68466ac3bb16b1c79b7a73dd0bfdd4c1/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/layout/LayoutCoordinates.kt#L187 + * + * Since we traverse the tree from the root, we don't need to find it again from the leaf node and + * just pass it as an argument. + * + * @return boundaries of this layout relative to the window's origin. + */ +internal fun LayoutCoordinates.boundsInWindow(root: LayoutCoordinates?): Rect { + root ?: return Rect() + + val rootWidth = root.size.width.toFloat() + val rootHeight = root.size.height.toFloat() + + val bounds = root.localBoundingBoxOf(this) + val boundsLeft = bounds.left.fastCoerceIn(0f, rootWidth) + val boundsTop = bounds.top.fastCoerceIn(0f, rootHeight) + val boundsRight = bounds.right.fastCoerceIn(0f, rootWidth) + val boundsBottom = bounds.bottom.fastCoerceIn(0f, rootHeight) + + if (boundsLeft == boundsRight || boundsTop == boundsBottom) { + return Rect() + } + + val topLeft = root.localToWindow(Offset(boundsLeft, boundsTop)) + val topRight = root.localToWindow(Offset(boundsRight, boundsTop)) + val bottomRight = root.localToWindow(Offset(boundsRight, boundsBottom)) + val bottomLeft = root.localToWindow(Offset(boundsLeft, boundsBottom)) + + val topLeftX = topLeft.x + val topRightX = topRight.x + val bottomLeftX = bottomLeft.x + val bottomRightX = bottomRight.x + + val left = fastMinOf(topLeftX, topRightX, bottomLeftX, bottomRightX) + val right = fastMaxOf(topLeftX, topRightX, bottomLeftX, bottomRightX) + + val topLeftY = topLeft.y + val topRightY = topRight.y + val bottomLeftY = bottomLeft.y + val bottomRightY = bottomRight.y + + val top = fastMinOf(topLeftY, topRightY, bottomLeftY, bottomRightY) + val bottom = fastMaxOf(topLeftY, topRightY, bottomLeftY, bottomRightY) + + return Rect(left.toInt(), top.toInt(), right.toInt(), bottom.toInt()) +} diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/util/TextLayout.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/util/TextLayout.kt new file mode 100644 index 0000000000..cd07c6d170 --- /dev/null +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/util/TextLayout.kt @@ -0,0 +1,21 @@ +package io.sentry.android.replay.util + +/** + * An abstraction over [android.text.Layout] with different implementations for Views and Compose. + */ +interface TextLayout { + val lineCount: Int + + /** + * Returns the dominant text color of the layout by looking at the [ForegroundColorSpan] spans if + * this text is a [Spanned] text. If the text is not a [Spanned] text or there are no spans, it + * returns null. + */ + val dominantTextColor: Int? + fun getPrimaryHorizontal(line: Int, offset: Int): Float + fun getEllipsisCount(line: Int): Int + fun getLineVisibleEnd(line: Int): Int + fun getLineTop(line: Int): Int + fun getLineBottom(line: Int): Int + fun getLineStart(line: Int): Int +} diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Views.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Views.kt index 86c75f2e9d..1c6111c1b0 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Views.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Views.kt @@ -16,9 +16,45 @@ import android.text.Layout import android.text.Spanned import android.text.style.ForegroundColorSpan import android.view.View +import android.view.ViewGroup import android.widget.TextView +import io.sentry.SentryOptions +import io.sentry.android.replay.viewhierarchy.ComposeViewHierarchyNode +import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode import java.lang.NullPointerException +/** + * Recursively traverses the view hierarchy and creates a [ViewHierarchyNode] for each view. + * Supports Compose view hierarchy as well. + */ +internal fun View.traverse(parentNode: ViewHierarchyNode, options: SentryOptions) { + if (this !is ViewGroup) { + return + } + + if (ComposeViewHierarchyNode.fromView(this, parentNode, options)) { + // if it's a compose view, we can skip the children as they are already traversed in + // the ComposeViewHierarchyNode.fromView method + return + } + + if (this.childCount == 0) { + return + } + + val childNodes = ArrayList(this.childCount) + for (i in 0 until childCount) { + val child = getChildAt(i) + if (child != null) { + val childNode = + ViewHierarchyNode.fromView(child, parentNode, indexOfChild(child), options) + childNodes.add(childNode) + child.traverse(childNode, options) + } + } + parentNode.children = childNodes +} + /** * Adapted copy of AccessibilityNodeInfo from https://cs.android.com/android/platform/superproject/+/master:frameworks/base/core/java/android/view/View.java;l=10718 */ @@ -65,19 +101,20 @@ internal fun Drawable?.isRedactable(): Boolean { } } -internal fun Layout?.getVisibleRects(globalRect: Rect, paddingLeft: Int, paddingTop: Int): List { +internal fun TextLayout?.getVisibleRects(globalRect: Rect, paddingLeft: Int, paddingTop: Int): List { if (this == null) { return listOf(globalRect) } val rects = mutableListOf() for (i in 0 until lineCount) { - val lineStart = getPrimaryHorizontal(getLineStart(i)).toInt() + val lineStart = getPrimaryHorizontal(i, getLineStart(i)).toInt() val ellipsisCount = getEllipsisCount(i) - var lineEnd = getPrimaryHorizontal(getLineVisibleEnd(i) - ellipsisCount + if (ellipsisCount > 0) 1 else 0).toInt() - if (lineEnd == 0) { + val lineVisibleEnd = getLineVisibleEnd(i) + var lineEnd = getPrimaryHorizontal(i, lineVisibleEnd - ellipsisCount + if (ellipsisCount > 0) 1 else 0).toInt() + if (lineEnd == 0 && lineVisibleEnd > 0) { // looks like the case for when emojis are present in text - lineEnd = getPrimaryHorizontal(getLineVisibleEnd(i) - 1).toInt() + 1 + lineEnd = getPrimaryHorizontal(i, lineVisibleEnd - 1).toInt() + 1 } val lineTop = getLineTop(i) val lineBottom = getLineBottom(i) @@ -105,32 +142,39 @@ internal val TextView.totalPaddingTopSafe: Int } /** - * Returns the dominant text color of the layout by looking at the [ForegroundColorSpan] spans if - * this text is a [Spanned] text. If the text is not a [Spanned] text or there are no spans, it - * returns null. + * Converts an [Int] ARGB color to an opaque color by setting the alpha channel to 255. */ -internal val Layout?.dominantTextColor: Int? get() { - this ?: return null +internal fun Int.toOpaque() = this or 0xFF000000.toInt() - if (text !is Spanned) return null +class AndroidTextLayout(private val layout: Layout) : TextLayout { + override val lineCount: Int get() = layout.lineCount + override val dominantTextColor: Int? get() { + if (layout.text !is Spanned) return null - val spans = (text as Spanned).getSpans(0, text.length, ForegroundColorSpan::class.java) + val spans = (layout.text as Spanned).getSpans(0, layout.text.length, ForegroundColorSpan::class.java) - // determine the dominant color by the span with the longest range - var longestSpan = Int.MIN_VALUE - var dominantColor: Int? = null - for (span in spans) { - val spanStart = (text as Spanned).getSpanStart(span) - val spanEnd = (text as Spanned).getSpanEnd(span) - if (spanStart == -1 || spanEnd == -1) { - // the span is not attached - continue - } - val spanLength = spanEnd - spanStart - if (spanLength > longestSpan) { - longestSpan = spanLength - dominantColor = span.foregroundColor + // determine the dominant color by the span with the longest range + var longestSpan = Int.MIN_VALUE + var dominantColor: Int? = null + for (span in spans) { + val spanStart = (layout.text as Spanned).getSpanStart(span) + val spanEnd = (layout.text as Spanned).getSpanEnd(span) + if (spanStart == -1 || spanEnd == -1) { + // the span is not attached + continue + } + val spanLength = spanEnd - spanStart + if (spanLength > longestSpan) { + longestSpan = spanLength + dominantColor = span.foregroundColor + } } + return dominantColor?.toOpaque() } - return dominantColor + override fun getPrimaryHorizontal(line: Int, offset: Int): Float = layout.getPrimaryHorizontal(offset) + override fun getEllipsisCount(line: Int): Int = layout.getEllipsisCount(line) + override fun getLineVisibleEnd(line: Int): Int = layout.getLineVisibleEnd(line) + override fun getLineTop(line: Int): Int = layout.getLineTop(line) + override fun getLineBottom(line: Int): Int = layout.getLineBottom(line) + override fun getLineStart(line: Int): Int = layout.getLineStart(line) } diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ComposeViewHierarchyNode.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ComposeViewHierarchyNode.kt new file mode 100644 index 0000000000..c611b91b47 --- /dev/null +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ComposeViewHierarchyNode.kt @@ -0,0 +1,213 @@ +@file:Suppress("INVISIBLE_MEMBER", "INVISIBLE_REFERENCE") // to access internal vals + +package io.sentry.android.replay.viewhierarchy + +import android.annotation.TargetApi +import android.view.View +import androidx.compose.ui.graphics.isUnspecified +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.layout.LayoutCoordinates +import androidx.compose.ui.layout.findRootCoordinates +import androidx.compose.ui.layout.positionInWindow +import androidx.compose.ui.node.LayoutNode +import androidx.compose.ui.node.Owner +import androidx.compose.ui.semantics.SemanticsActions +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.semantics.getOrNull +import androidx.compose.ui.text.TextLayoutResult +import io.sentry.SentryLevel +import io.sentry.SentryOptions +import io.sentry.SentryReplayOptions +import io.sentry.android.replay.SentryReplayModifiers +import io.sentry.android.replay.util.ComposeTextLayout +import io.sentry.android.replay.util.boundsInWindow +import io.sentry.android.replay.util.findPainter +import io.sentry.android.replay.util.findTextAttributes +import io.sentry.android.replay.util.isRedactable +import io.sentry.android.replay.util.toOpaque +import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode.GenericViewHierarchyNode +import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode.ImageViewHierarchyNode +import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode.TextViewHierarchyNode + +@TargetApi(26) +internal object ComposeViewHierarchyNode { + + /** + * Since Compose doesn't have a concept of a View class (they are all composable functions), + * we need to map the semantics node to a corresponding old view system class. + */ + private fun LayoutNode.getProxyClassName(isImage: Boolean): String { + return when { + isImage -> SentryReplayOptions.IMAGE_VIEW_CLASS_NAME + collapsedSemantics?.contains(SemanticsProperties.Text) == true || + collapsedSemantics?.contains(SemanticsActions.SetText) == true -> SentryReplayOptions.TEXT_VIEW_CLASS_NAME + else -> "android.view.View" + } + } + + private fun LayoutNode.shouldRedact(isImage: Boolean, options: SentryOptions): Boolean { + val sentryPrivacyModifier = collapsedSemantics?.getOrNull(SentryReplayModifiers.SentryPrivacy) + if (sentryPrivacyModifier == "ignore") { + return false + } + + if (sentryPrivacyModifier == "redact") { + return true + } + + val className = getProxyClassName(isImage) + if (options.experimental.sessionReplay.ignoreViewClasses.contains(className)) { + return false + } + + return options.experimental.sessionReplay.redactViewClasses.contains(className) + } + + private var _rootCoordinates: LayoutCoordinates? = null + + private fun fromComposeNode( + node: LayoutNode, + parent: ViewHierarchyNode?, + distance: Int, + isComposeRoot: Boolean, + options: SentryOptions + ): ViewHierarchyNode? { + val isInTree = node.isPlaced && node.isAttached + if (!isInTree) { + return null + } + + if (isComposeRoot) { + _rootCoordinates = node.coordinates.findRootCoordinates() + } + + val semantics = node.collapsedSemantics + val visibleRect = node.coordinates.boundsInWindow(_rootCoordinates) + val isVisible = !node.outerCoordinator.isTransparent() && + (semantics == null || !semantics.contains(SemanticsProperties.InvisibleToUser)) && + visibleRect.height() > 0 && visibleRect.width() > 0 + val isEditable = semantics?.contains(SemanticsActions.SetText) == true + val positionInWindow = node.coordinates.positionInWindow() + return when { + semantics?.contains(SemanticsProperties.Text) == true || isEditable -> { + val shouldRedact = isVisible && node.shouldRedact(isImage = false, options) + + parent?.setImportantForCaptureToAncestors(true) + val textLayoutResults = mutableListOf() + semantics?.getOrNull(SemanticsActions.GetTextLayoutResult) + ?.action + ?.invoke(textLayoutResults) + + val (color, hasFillModifier) = node.findTextAttributes() + var textColor = textLayoutResults.firstOrNull()?.layoutInput?.style?.color + if (textColor?.isUnspecified == true) { + textColor = color + } + // TODO: support multiple text layouts + // TODO: support editable text (currently there's a way to get @Composable's padding only via reflection, and we can't reliably mask input fields based on TextLayout, so we mask the whole view instead) + TextViewHierarchyNode( + layout = if (textLayoutResults.isNotEmpty() && !isEditable) ComposeTextLayout(textLayoutResults.first(), hasFillModifier) else null, + dominantColor = textColor?.toArgb()?.toOpaque(), + x = positionInWindow.x, + y = positionInWindow.y, + width = node.width, + height = node.height, + elevation = (parent?.elevation ?: 0f), + distance = distance, + parent = parent, + shouldRedact = shouldRedact, + isImportantForContentCapture = true, + isVisible = isVisible, + visibleRect = visibleRect + ) + } + else -> { + val painter = node.findPainter() + if (painter != null) { + val shouldRedact = isVisible && node.shouldRedact(isImage = true, options) + + parent?.setImportantForCaptureToAncestors(true) + ImageViewHierarchyNode( + x = positionInWindow.x, + y = positionInWindow.y, + width = node.width, + height = node.height, + elevation = (parent?.elevation ?: 0f), + distance = distance, + parent = parent, + isVisible = isVisible, + isImportantForContentCapture = true, + shouldRedact = shouldRedact && painter.isRedactable(), + visibleRect = visibleRect + ) + } else { + val shouldRedact = isVisible && node.shouldRedact(isImage = false, options) + + // TODO: this currently does not support embedded AndroidViews, we'd have to + // TODO: traverse the ViewHierarchyNode here again. For now we can recommend + // TODO: using custom modifiers to obscure the entire node if it's sensitive + GenericViewHierarchyNode( + x = positionInWindow.x, + y = positionInWindow.y, + width = node.width, + height = node.height, + elevation = (parent?.elevation ?: 0f), + distance = distance, + parent = parent, + shouldRedact = shouldRedact, + isImportantForContentCapture = false, /* will be set by children */ + isVisible = isVisible, + visibleRect = visibleRect + ) + } + } + } + } + + fun fromView(view: View, parent: ViewHierarchyNode?, options: SentryOptions): Boolean { + if (!view::class.java.name.contains("AndroidComposeView")) { + return false + } + + if (parent == null) { + return false + } + + try { + val rootNode = (view as? Owner)?.root ?: return false + rootNode.traverse(parent, isComposeRoot = true, options) + } catch (e: Throwable) { + options.logger.log( + SentryLevel.ERROR, + e, + """ + Error traversing Compose tree. Most likely you're using an unsupported version of + androidx.compose.ui:ui. The minimum supported version is 1.5.0. If it's a newer + version, please open a github issue with the version you're using, so we can add + support for it. + """.trimIndent() + ) + return false + } + + return true + } + + private fun LayoutNode.traverse(parentNode: ViewHierarchyNode, isComposeRoot: Boolean, options: SentryOptions) { + val children = this.children + if (children.isEmpty()) { + return + } + + val childNodes = ArrayList(children.size) + for (index in children.indices) { + val child = children[index] + val childNode = fromComposeNode(child, parentNode, index, isComposeRoot, options) + if (childNode != null) { + childNodes.add(childNode) + child.traverse(childNode, isComposeRoot = false, options) + } + } + parentNode.children = childNodes + } +} diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ViewHierarchyNode.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ViewHierarchyNode.kt index 90b96f134b..a231e4f3d2 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ViewHierarchyNode.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ViewHierarchyNode.kt @@ -2,14 +2,16 @@ package io.sentry.android.replay.viewhierarchy import android.annotation.TargetApi import android.graphics.Rect -import android.text.Layout import android.view.View import android.widget.ImageView import android.widget.TextView import io.sentry.SentryOptions import io.sentry.android.replay.R +import io.sentry.android.replay.util.AndroidTextLayout +import io.sentry.android.replay.util.TextLayout import io.sentry.android.replay.util.isRedactable import io.sentry.android.replay.util.isVisibleToUser +import io.sentry.android.replay.util.toOpaque import io.sentry.android.replay.util.totalPaddingTopSafe @TargetApi(26) @@ -46,7 +48,7 @@ sealed class ViewHierarchyNode( ) : ViewHierarchyNode(x, y, width, height, elevation, distance, parent, shouldRedact, isImportantForContentCapture, isVisible, visibleRect) class TextViewHierarchyNode( - val layout: Layout? = null, + val layout: TextLayout? = null, val dominantColor: Int? = null, val paddingLeft: Int = 0, val paddingTop: Int = 0, @@ -77,6 +79,20 @@ sealed class ViewHierarchyNode( visibleRect: Rect? = null ) : ViewHierarchyNode(x, y, width, height, elevation, distance, parent, shouldRedact, isImportantForContentCapture, isVisible, visibleRect) + /** + * Basically replicating this: https://developer.android.com/reference/android/view/View#isImportantForContentCapture() + * but for lower APIs and with less overhead. If we take a look at how it's set in Android: + * https://cs.android.com/search?q=IMPORTANT_FOR_CONTENT_CAPTURE_YES&ss=android%2Fplatform%2Fsuperproject%2Fmain + * we see that they just set it as important for views containing TextViews, ImageViews and WebViews. + */ + fun setImportantForCaptureToAncestors(isImportant: Boolean) { + var parent = this.parent + while (parent != null) { + parent.isImportantForContentCapture = isImportant + parent = parent.parent + } + } + /** * Traverses the view hierarchy starting from this node. The traversal is done in a depth-first * manner. @@ -217,23 +233,6 @@ sealed class ViewHierarchyNode( ) companion object { - - private fun Int.toOpaque() = this or 0xFF000000.toInt() - - /** - * Basically replicating this: https://developer.android.com/reference/android/view/View#isImportantForContentCapture() - * but for lower APIs and with less overhead. If we take a look at how it's set in Android: - * https://cs.android.com/search?q=IMPORTANT_FOR_CONTENT_CAPTURE_YES&ss=android%2Fplatform%2Fsuperproject%2Fmain - * we see that they just set it as important for views containing TextViews, ImageViews and WebViews. - */ - private fun ViewHierarchyNode?.setImportantForCaptureToAncestors(isImportant: Boolean) { - var parent = this?.parent - while (parent != null) { - parent.isImportantForContentCapture = isImportant - parent = parent.parent - } - } - private const val SENTRY_IGNORE_TAG = "sentry-ignore" private const val SENTRY_REDACT_TAG = "sentry-redact" @@ -274,9 +273,9 @@ sealed class ViewHierarchyNode( val shouldRedact = isVisible && view.shouldRedact(options) when (view) { is TextView -> { - parent.setImportantForCaptureToAncestors(true) + parent?.setImportantForCaptureToAncestors(true) return TextViewHierarchyNode( - layout = view.layout, + layout = view.layout?.let { AndroidTextLayout(it) }, dominantColor = view.currentTextColor.toOpaque(), paddingLeft = view.totalPaddingLeft, paddingTop = view.totalPaddingTopSafe, @@ -295,7 +294,7 @@ sealed class ViewHierarchyNode( } is ImageView -> { - parent.setImportantForCaptureToAncestors(true) + parent?.setImportantForCaptureToAncestors(true) return ImageViewHierarchyNode( x = view.x, y = view.y, diff --git a/sentry-android-replay/src/test/AndroidManifest.xml b/sentry-android-replay/src/test/AndroidManifest.xml new file mode 100644 index 0000000000..c8f45a53bb --- /dev/null +++ b/sentry-android-replay/src/test/AndroidManifest.xml @@ -0,0 +1,24 @@ + + + + + diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/util/TextViewDominantColorTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/util/TextViewDominantColorTest.kt index ec545ed109..9a5b805ad7 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/util/TextViewDominantColorTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/util/TextViewDominantColorTest.kt @@ -36,7 +36,7 @@ class TextViewDominantColorTest { val node = ViewHierarchyNode.fromView(TextViewActivity.textView!!, null, 0, SentryOptions()) assertTrue(node is TextViewHierarchyNode) - assertNull(node.layout.dominantTextColor) + assertNull(node.layout?.dominantTextColor) } @Test @@ -55,7 +55,7 @@ class TextViewDominantColorTest { val node = ViewHierarchyNode.fromView(TextViewActivity.textView!!, null, 0, SentryOptions()) assertTrue(node is TextViewHierarchyNode) - assertEquals(Color.RED, node.layout.dominantTextColor) + assertEquals(Color.RED, node.layout?.dominantTextColor) } @Test @@ -75,7 +75,7 @@ class TextViewDominantColorTest { val node = ViewHierarchyNode.fromView(TextViewActivity.textView!!, null, 0, SentryOptions()) assertTrue(node is TextViewHierarchyNode) - assertEquals(Color.BLACK, node.layout.dominantTextColor) + assertEquals(Color.BLACK, node.layout?.dominantTextColor) } } diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/viewhierarchy/ComposeRedactionOptionsTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/viewhierarchy/ComposeRedactionOptionsTest.kt new file mode 100644 index 0000000000..981e351408 --- /dev/null +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/viewhierarchy/ComposeRedactionOptionsTest.kt @@ -0,0 +1,240 @@ +package io.sentry.android.replay.viewhierarchy + +import android.app.Activity +import android.net.Uri +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.invisibleToUser +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.unit.dp +import androidx.test.ext.junit.runners.AndroidJUnit4 +import coil.compose.AsyncImage +import io.sentry.SentryOptions +import io.sentry.android.replay.redactAllImages +import io.sentry.android.replay.redactAllText +import io.sentry.android.replay.sentryReplayIgnore +import io.sentry.android.replay.sentryReplayRedact +import io.sentry.android.replay.util.ComposeTextLayout +import io.sentry.android.replay.util.traverse +import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode.GenericViewHierarchyNode +import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode.ImageViewHierarchyNode +import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode.TextViewHierarchyNode +import org.junit.Before +import org.junit.runner.RunWith +import org.robolectric.Robolectric.buildActivity +import org.robolectric.annotation.Config +import java.io.File +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +@RunWith(AndroidJUnit4::class) +@Config(sdk = [30]) +class ComposeRedactionOptionsTest { + + @Before + fun setup() { + System.setProperty("robolectric.areWindowsMarkedVisible", "true") + ComposeRedactionOptionsActivity.textModifierApplier = null + ComposeRedactionOptionsActivity.containerModifierApplier = null + } + + @Test + fun `when redactAllText is set all Text nodes are redacted`() { + val activity = buildActivity(ComposeRedactionOptionsActivity::class.java).setup() + + val options = SentryOptions().apply { + experimental.sessionReplay.redactAllText = true + } + + val textNodes = activity.get().collectNodesOfType(options) + assertEquals(4, textNodes.size) // [TextField, Text, Button, Activity Title] + assertTrue(textNodes.all { it.shouldRedact }) + // just a sanity check for parsing the tree + assertEquals("Random repo", (textNodes[1].layout as ComposeTextLayout).layout.layoutInput.text.text) + } + + @Test + fun `when redactAllText is set to false all Text nodes are ignored`() { + val activity = buildActivity(ComposeRedactionOptionsActivity::class.java).setup() + + val options = SentryOptions().apply { + experimental.sessionReplay.redactAllText = false + } + + val textNodes = activity.get().collectNodesOfType(options) + assertEquals(4, textNodes.size) // [TextField, Text, Button, Activity Title] + assertTrue(textNodes.none { it.shouldRedact }) + } + + @Test + fun `when redactAllImages is set all Image nodes are redacted`() { + val activity = buildActivity(ComposeRedactionOptionsActivity::class.java).setup() + + val options = SentryOptions().apply { + experimental.sessionReplay.redactAllImages = true + } + + val imageNodes = activity.get().collectNodesOfType(options) + assertEquals(1, imageNodes.size) // [AsyncImage] + assertTrue(imageNodes.all { it.shouldRedact }) + } + + @Test + fun `when redactAllImages is set to false all Image nodes are ignored`() { + val activity = buildActivity(ComposeRedactionOptionsActivity::class.java).setup() + + val options = SentryOptions().apply { + experimental.sessionReplay.redactAllImages = false + } + + val imageNodes = activity.get().collectNodesOfType(options) + assertEquals(1, imageNodes.size) // [AsyncImage] + assertTrue(imageNodes.none { it.shouldRedact }) + } + + @Test + fun `when sentry-redact modifier is set redacts the node`() { + ComposeRedactionOptionsActivity.textModifierApplier = { Modifier.sentryReplayRedact() } + val activity = buildActivity(ComposeRedactionOptionsActivity::class.java).setup() + + val options = SentryOptions().apply { + experimental.sessionReplay.redactAllText = false + } + + val textNodes = activity.get().collectNodesOfType(options) + assertEquals(4, textNodes.size) // [TextField, Text, Button, Activity Title] + textNodes.forEach { + if ((it.layout as? ComposeTextLayout)?.layout?.layoutInput?.text?.text == "Make Request") { + assertTrue(it.shouldRedact) + } else { + assertFalse(it.shouldRedact) + } + } + } + + @Test + fun `when sentry-ignore modifier is set ignores the node`() { + ComposeRedactionOptionsActivity.textModifierApplier = { Modifier.sentryReplayIgnore() } + val activity = buildActivity(ComposeRedactionOptionsActivity::class.java).setup() + + val options = SentryOptions().apply { + experimental.sessionReplay.redactAllText = true + } + + val textNodes = activity.get().collectNodesOfType(options) + assertEquals(4, textNodes.size) // [TextField, Text, Button, Activity Title] + textNodes.forEach { + if ((it.layout as? ComposeTextLayout)?.layout?.layoutInput?.text?.text == "Make Request") { + assertFalse(it.shouldRedact) + } else { + assertTrue(it.shouldRedact) + } + } + } + + @Test + fun `when view is not visible, does not redact the view`() { + ComposeRedactionOptionsActivity.textModifierApplier = { Modifier.semantics { invisibleToUser() } } + val activity = buildActivity(ComposeRedactionOptionsActivity::class.java).setup() + + val options = SentryOptions().apply { + experimental.sessionReplay.redactAllText = true + } + + val textNodes = activity.get().collectNodesOfType(options) + textNodes.forEach { + if ((it.layout as? ComposeTextLayout)?.layout?.layoutInput?.text?.text == "Make Request") { + assertFalse(it.shouldRedact) + } else { + assertTrue(it.shouldRedact) + } + } + } + + @Test + fun `when a container view is ignored its children are not ignored`() { + ComposeRedactionOptionsActivity.containerModifierApplier = { Modifier.sentryReplayIgnore() } + val activity = buildActivity(ComposeRedactionOptionsActivity::class.java).setup() + + val options = SentryOptions() + + val allNodes = activity.get().collectNodesOfType(options) + val imageNodes = allNodes.filterIsInstance() + val textNodes = allNodes.filterIsInstance() + val genericNodes = allNodes.filterIsInstance() + assertTrue(imageNodes.all { it.shouldRedact }) + assertTrue(textNodes.all { it.shouldRedact }) + assertTrue(genericNodes.none { it.shouldRedact }) + } + + private inline fun Activity.collectNodesOfType(options: SentryOptions): List { + val root = window.decorView + val viewHierarchy = ViewHierarchyNode.fromView(root, null, 0, options) + root.traverse(viewHierarchy, options) + + val nodes = mutableListOf() + viewHierarchy.traverse { + if (it is T) { + nodes += it + } + return@traverse true + } + return nodes + } +} + +private class ComposeRedactionOptionsActivity : ComponentActivity() { + + companion object { + var textModifierApplier: (() -> Modifier)? = null + var containerModifierApplier: (() -> Modifier)? = null + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val image = this::class.java.classLoader.getResource("Tongariro.jpg")!! + + setContent { + Column( + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .fillMaxSize() + .then(containerModifierApplier?.invoke() ?: Modifier) + ) { + AsyncImage( + model = Uri.fromFile(File(image.toURI())), + contentDescription = null, + modifier = Modifier.padding(vertical = 16.dp) + ) + TextField( + value = TextFieldValue("Placeholder"), + onValueChange = { _ -> } + ) + Text("Random repo") + Button( + onClick = {}, + modifier = Modifier + .testTag("button_list_repos_async") + .padding(top = 32.dp) + ) { + Text("Make Request", modifier = Modifier.then(textModifierApplier?.invoke() ?: Modifier)) + } + } + } + } +} diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/viewhierarchy/RedactionOptionsTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/viewhierarchy/RedactionOptionsTest.kt index 8ffffd046d..c1a50f7a62 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/viewhierarchy/RedactionOptionsTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/viewhierarchy/RedactionOptionsTest.kt @@ -20,10 +20,10 @@ import io.sentry.android.replay.sentryReplayIgnore import io.sentry.android.replay.sentryReplayRedact import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode.ImageViewHierarchyNode import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode.TextViewHierarchyNode -import org.junit.Before import org.junit.runner.RunWith import org.robolectric.Robolectric.buildActivity import org.robolectric.annotation.Config +import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertFalse import kotlin.test.assertTrue @@ -32,21 +32,21 @@ import kotlin.test.assertTrue @Config(sdk = [30]) class RedactionOptionsTest { - @Before + @BeforeTest fun setup() { System.setProperty("robolectric.areWindowsMarkedVisible", "true") } @Test fun `when redactAllText is set all TextView nodes are redacted`() { - buildActivity(ExampleActivity::class.java).setup() + buildActivity(RedactionOptionsActivity::class.java).setup() val options = SentryOptions().apply { experimental.sessionReplay.redactAllText = true } - val textNode = ViewHierarchyNode.fromView(ExampleActivity.textView!!, null, 0, options) - val radioButtonNode = ViewHierarchyNode.fromView(ExampleActivity.radioButton!!, null, 0, options) + val textNode = ViewHierarchyNode.fromView(RedactionOptionsActivity.textView!!, null, 0, options) + val radioButtonNode = ViewHierarchyNode.fromView(RedactionOptionsActivity.radioButton!!, null, 0, options) assertTrue(textNode is TextViewHierarchyNode) assertTrue(textNode.shouldRedact) @@ -57,14 +57,14 @@ class RedactionOptionsTest { @Test fun `when redactAllText is set to false all TextView nodes are ignored`() { - buildActivity(ExampleActivity::class.java).setup() + buildActivity(RedactionOptionsActivity::class.java).setup() val options = SentryOptions().apply { experimental.sessionReplay.redactAllText = false } - val textNode = ViewHierarchyNode.fromView(ExampleActivity.textView!!, null, 0, options) - val radioButtonNode = ViewHierarchyNode.fromView(ExampleActivity.radioButton!!, null, 0, options) + val textNode = ViewHierarchyNode.fromView(RedactionOptionsActivity.textView!!, null, 0, options) + val radioButtonNode = ViewHierarchyNode.fromView(RedactionOptionsActivity.radioButton!!, null, 0, options) assertTrue(textNode is TextViewHierarchyNode) assertFalse(textNode.shouldRedact) @@ -75,13 +75,13 @@ class RedactionOptionsTest { @Test fun `when redactAllImages is set all ImageView nodes are redacted`() { - buildActivity(ExampleActivity::class.java).setup() + buildActivity(RedactionOptionsActivity::class.java).setup() val options = SentryOptions().apply { experimental.sessionReplay.redactAllImages = true } - val imageNode = ViewHierarchyNode.fromView(ExampleActivity.imageView!!, null, 0, options) + val imageNode = ViewHierarchyNode.fromView(RedactionOptionsActivity.imageView!!, null, 0, options) assertTrue(imageNode is ImageViewHierarchyNode) assertTrue(imageNode.shouldRedact) @@ -89,13 +89,13 @@ class RedactionOptionsTest { @Test fun `when redactAllImages is set to false all ImageView nodes are ignored`() { - buildActivity(ExampleActivity::class.java).setup() + buildActivity(RedactionOptionsActivity::class.java).setup() val options = SentryOptions().apply { experimental.sessionReplay.redactAllImages = false } - val imageNode = ViewHierarchyNode.fromView(ExampleActivity.imageView!!, null, 0, options) + val imageNode = ViewHierarchyNode.fromView(RedactionOptionsActivity.imageView!!, null, 0, options) assertTrue(imageNode is ImageViewHierarchyNode) assertFalse(imageNode.shouldRedact) @@ -103,98 +103,98 @@ class RedactionOptionsTest { @Test fun `when sentry-redact tag is set redacts the view`() { - buildActivity(ExampleActivity::class.java).setup() + buildActivity(RedactionOptionsActivity::class.java).setup() val options = SentryOptions().apply { experimental.sessionReplay.redactAllText = false } - ExampleActivity.textView!!.tag = "sentry-redact" - val textNode = ViewHierarchyNode.fromView(ExampleActivity.textView!!, null, 0, options) + RedactionOptionsActivity.textView!!.tag = "sentry-redact" + val textNode = ViewHierarchyNode.fromView(RedactionOptionsActivity.textView!!, null, 0, options) assertTrue(textNode.shouldRedact) } @Test fun `when sentry-ignore tag is set ignores the view`() { - buildActivity(ExampleActivity::class.java).setup() + buildActivity(RedactionOptionsActivity::class.java).setup() val options = SentryOptions().apply { experimental.sessionReplay.redactAllText = true } - ExampleActivity.textView!!.tag = "sentry-ignore" - val textNode = ViewHierarchyNode.fromView(ExampleActivity.textView!!, null, 0, options) + RedactionOptionsActivity.textView!!.tag = "sentry-ignore" + val textNode = ViewHierarchyNode.fromView(RedactionOptionsActivity.textView!!, null, 0, options) assertFalse(textNode.shouldRedact) } @Test fun `when sentry-privacy tag is set to redact redacts the view`() { - buildActivity(ExampleActivity::class.java).setup() + buildActivity(RedactionOptionsActivity::class.java).setup() val options = SentryOptions().apply { experimental.sessionReplay.redactAllText = false } - ExampleActivity.textView!!.sentryReplayRedact() - val textNode = ViewHierarchyNode.fromView(ExampleActivity.textView!!, null, 0, options) + RedactionOptionsActivity.textView!!.sentryReplayRedact() + val textNode = ViewHierarchyNode.fromView(RedactionOptionsActivity.textView!!, null, 0, options) assertTrue(textNode.shouldRedact) } @Test fun `when sentry-privacy tag is set to ignore ignores the view`() { - buildActivity(ExampleActivity::class.java).setup() + buildActivity(RedactionOptionsActivity::class.java).setup() val options = SentryOptions().apply { experimental.sessionReplay.redactAllText = true } - ExampleActivity.textView!!.sentryReplayIgnore() - val textNode = ViewHierarchyNode.fromView(ExampleActivity.textView!!, null, 0, options) + RedactionOptionsActivity.textView!!.sentryReplayIgnore() + val textNode = ViewHierarchyNode.fromView(RedactionOptionsActivity.textView!!, null, 0, options) assertFalse(textNode.shouldRedact) } @Test fun `when view is not visible, does not redact the view`() { - buildActivity(ExampleActivity::class.java).setup() + buildActivity(RedactionOptionsActivity::class.java).setup() val options = SentryOptions().apply { experimental.sessionReplay.redactAllText = true } - ExampleActivity.textView!!.visibility = View.GONE - val textNode = ViewHierarchyNode.fromView(ExampleActivity.textView!!, null, 0, options) + RedactionOptionsActivity.textView!!.visibility = View.GONE + val textNode = ViewHierarchyNode.fromView(RedactionOptionsActivity.textView!!, null, 0, options) assertFalse(textNode.shouldRedact) } @Test fun `when added to redact list redacts custom view`() { - buildActivity(ExampleActivity::class.java).setup() + buildActivity(RedactionOptionsActivity::class.java).setup() val options = SentryOptions().apply { experimental.sessionReplay.redactViewClasses.add(CustomView::class.java.canonicalName) } - val customViewNode = ViewHierarchyNode.fromView(ExampleActivity.customView!!, null, 0, options) + val customViewNode = ViewHierarchyNode.fromView(RedactionOptionsActivity.customView!!, null, 0, options) assertTrue(customViewNode.shouldRedact) } @Test fun `when subclass is added to ignored classes ignores all instances of that class`() { - buildActivity(ExampleActivity::class.java).setup() + buildActivity(RedactionOptionsActivity::class.java).setup() val options = SentryOptions().apply { experimental.sessionReplay.redactAllText = true // all TextView subclasses experimental.sessionReplay.ignoreViewClasses.add(RadioButton::class.java.canonicalName) } - val textNode = ViewHierarchyNode.fromView(ExampleActivity.textView!!, null, 0, options) - val radioButtonNode = ViewHierarchyNode.fromView(ExampleActivity.radioButton!!, null, 0, options) + val textNode = ViewHierarchyNode.fromView(RedactionOptionsActivity.textView!!, null, 0, options) + val radioButtonNode = ViewHierarchyNode.fromView(RedactionOptionsActivity.radioButton!!, null, 0, options) assertTrue(textNode.shouldRedact) assertFalse(radioButtonNode.shouldRedact) @@ -202,15 +202,15 @@ class RedactionOptionsTest { @Test fun `when a container view is ignored its children are not ignored`() { - buildActivity(ExampleActivity::class.java).setup() + buildActivity(RedactionOptionsActivity::class.java).setup() val options = SentryOptions().apply { experimental.sessionReplay.ignoreViewClasses.add(LinearLayout::class.java.canonicalName) } - val linearLayoutNode = ViewHierarchyNode.fromView(ExampleActivity.textView!!.parent as LinearLayout, null, 0, options) - val textNode = ViewHierarchyNode.fromView(ExampleActivity.textView!!, null, 0, options) - val imageNode = ViewHierarchyNode.fromView(ExampleActivity.imageView!!, null, 0, options) + val linearLayoutNode = ViewHierarchyNode.fromView(RedactionOptionsActivity.textView!!.parent as LinearLayout, null, 0, options) + val textNode = ViewHierarchyNode.fromView(RedactionOptionsActivity.textView!!, null, 0, options) + val imageNode = ViewHierarchyNode.fromView(RedactionOptionsActivity.imageView!!, null, 0, options) assertFalse(linearLayoutNode.shouldRedact) assertTrue(textNode.shouldRedact) @@ -226,7 +226,7 @@ private class CustomView(context: Context) : View(context) { } } -private class ExampleActivity : Activity() { +private class RedactionOptionsActivity : Activity() { companion object { var textView: TextView? = null diff --git a/sentry-samples/sentry-samples-android/build.gradle.kts b/sentry-samples/sentry-samples-android/build.gradle.kts index a8d8897519..204ef83fc2 100644 --- a/sentry-samples/sentry-samples-android/build.gradle.kts +++ b/sentry-samples/sentry-samples-android/build.gradle.kts @@ -132,6 +132,7 @@ dependencies { implementation(Config.Libs.composeFoundationLayout) implementation(Config.Libs.composeNavigation) implementation(Config.Libs.composeMaterial) + implementation(Config.Libs.composeCoil) debugImplementation(Config.Libs.leakCanary) } diff --git a/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml b/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml index 997fa5ff55..703685d6f0 100644 --- a/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml +++ b/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml @@ -166,6 +166,7 @@ - + + diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/compose/ComposeActivity.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/compose/ComposeActivity.kt index 1a4929b0b7..03d9e8d049 100644 --- a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/compose/ComposeActivity.kt +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/compose/ComposeActivity.kt @@ -5,6 +5,7 @@ package io.sentry.samples.android.compose import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent +import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize @@ -22,7 +23,10 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.unit.dp import androidx.navigation.NavHostController @@ -31,10 +35,13 @@ import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController import androidx.navigation.navArgument +import coil.compose.AsyncImage +import io.sentry.android.replay.sentryReplayIgnore import io.sentry.compose.SentryTraced import io.sentry.compose.withSentryObservableEffect import io.sentry.samples.android.GithubAPI import kotlinx.coroutines.launch +import io.sentry.samples.android.R as IR class ComposeActivity : ComponentActivity() { @@ -109,6 +116,17 @@ fun Github( modifier = Modifier .fillMaxSize() ) { + Image( + painter = painterResource(IR.drawable.sentry_glyph), + contentDescription = "LOGO", + colorFilter = ColorFilter.tint(Color.Black), + modifier = Modifier.padding(vertical = 16.dp) + ) + AsyncImage( + model = "https://i.imgur.com/tie6A3J.jpeg", + contentDescription = null, + modifier = Modifier.padding(vertical = 16.dp) + ) TextField( value = user, onValueChange = { newText -> @@ -127,7 +145,7 @@ fun Github( .testTag("button_list_repos_async") .padding(top = 32.dp) ) { - Text("Make Request") + Text("Make Request", modifier = Modifier.sentryReplayIgnore()) } } } diff --git a/sentry-samples/sentry-samples-android/src/main/res/drawable/sentry_glyph.xml b/sentry-samples/sentry-samples-android/src/main/res/drawable/sentry_glyph.xml new file mode 100644 index 0000000000..28a3442987 --- /dev/null +++ b/sentry-samples/sentry-samples-android/src/main/res/drawable/sentry_glyph.xml @@ -0,0 +1,9 @@ + + + From 0ab3bb34d63db186be39273cff42b96b1a983fe4 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Wed, 9 Oct 2024 18:28:14 +0200 Subject: [PATCH 45/92] [SR] Change terminology from redact/ignore to mask/unmask (#3741) * WIP * Compose works * Custom redaction works for Compose * Formatting * Clean up * Test * Add tests * Changelog * Change terminology from redact/ignore to mask/unmask * Changelog * [SR] Mask web and video views (#3775) * Replace logo with sentry * Add missing proguard rules * formatting * Faster boundsInWindow for compose * api dump * Dont use liveliterals * Remove redundant test * Increase timeout in failing test --- CHANGELOG.md | 16 +- .../android/core/ManifestMetadataReader.java | 8 +- .../core/ManifestMetadataReaderTest.kt | 14 +- .../sentry/android/core/SentryAndroidTest.kt | 2 +- .../api/sentry-android-replay.api | 18 +- sentry-android-replay/proguard-rules.pro | 12 +- .../android/replay/ModifierExtensions.kt | 8 +- .../android/replay/ScreenshotRecorder.kt | 4 +- .../android/replay/SessionReplayOptions.kt | 18 +- .../sentry/android/replay/ViewExtensions.kt | 12 +- .../io/sentry/android/replay/util/Nodes.kt | 10 +- .../io/sentry/android/replay/util/Views.kt | 4 +- .../replay/video/SimpleVideoEncoder.kt | 2 +- .../viewhierarchy/ComposeViewHierarchyNode.kt | 24 +- .../replay/viewhierarchy/ViewHierarchyNode.kt | 42 +-- ...nsTest.kt => ComposeMaskingOptionsTest.kt} | 96 +++--- .../viewhierarchy/MaskingOptionsTest.kt | 278 ++++++++++++++++++ .../viewhierarchy/RedactionOptionsTest.kt | 278 ------------------ .../src/main/AndroidManifest.xml | 3 +- .../android/compose/ComposeActivity.kt | 4 +- sentry/api/sentry.api | 17 +- .../java/io/sentry/SentryReplayOptions.java | 75 +++-- 22 files changed, 485 insertions(+), 460 deletions(-) rename sentry-android-replay/src/test/java/io/sentry/android/replay/viewhierarchy/{ComposeRedactionOptionsTest.kt => ComposeMaskingOptionsTest.kt} (67%) create mode 100644 sentry-android-replay/src/test/java/io/sentry/android/replay/viewhierarchy/MaskingOptionsTest.kt delete mode 100644 sentry-android-replay/src/test/java/io/sentry/android/replay/viewhierarchy/RedactionOptionsTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index fc4c2d1009..ce2bd2cf4e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,15 +6,16 @@ - Add support for `feedback` envelope header item type ([#3687](https://github.com/getsentry/sentry-java/pull/3687)) - Add breadcrumb.origin field ([#3727](https://github.com/getsentry/sentry-java/pull/3727)) -- Session Replay: Add options to selectively redact/ignore views from being captured. The following options are available: ([#3689](https://github.com/getsentry/sentry-java/pull/3689)) - - `android:tag="sentry-redact|sentry-ignore"` in XML or `view.setTag("sentry-redact|sentry-ignore")` in code tags - - if you already have a tag set for a view, you can set a tag by id: `` in XML or `view.setTag(io.sentry.android.replay.R.id.sentry_privacy, "redact|ignore")` in code - - `view.sentryReplayRedact()` or `view.sentryReplayIgnore()` extension functions - - redact/ignore `View`s of a certain type by adding fully-qualified classname to one of the lists `options.experimental.sessionReplay.addRedactViewClass()` or `options.experimental.sessionReplay.addIgnoreViewClass()`. Note, that all of the view subclasses/subtypes will be redacted/ignored as well - - For example, (this is already a default behavior) to redact all `TextView`s and their subclasses (`RadioButton`, `EditText`, etc.): `options.experimental.sessionReplay.addRedactViewClass("android.widget.TextView")` +- Session Replay: Add options to selectively mask/unmask views captured in replay. The following options are available: ([#3689](https://github.com/getsentry/sentry-java/pull/3689)) + - `android:tag="sentry-mask|sentry-unmask"` in XML or `view.setTag("sentry-mask|sentry-unmask")` in code tags + - if you already have a tag set for a view, you can set a tag by id: `` in XML or `view.setTag(io.sentry.android.replay.R.id.sentry_privacy, "mask|unmask")` in code + - `view.sentryReplayMask()` or `view.sentryReplayUnmask()` extension functions + - mask/unmask `View`s of a certain type by adding fully-qualified classname to one of the lists `options.experimental.sessionReplay.addMaskViewClass()` or `options.experimental.sessionReplay.addUnmaskViewClass()`. Note, that all of the view subclasses/subtypes will be masked/unmasked as well + - For example, (this is already a default behavior) to mask all `TextView`s and their subclasses (`RadioButton`, `EditText`, etc.): `options.experimental.sessionReplay.addMaskViewClass("android.widget.TextView")` - If you're using code obfuscation, adjust your proguard-rules accordingly, so your custom view class name is not minified - Session Replay: Support Jetpack Compose masking ([#3739](https://github.com/getsentry/sentry-java/pull/3739)) - - To selectively mask/unmask @Composables, use `Modifier.sentryReplayRedact()` and `Modifier.sentryReplayIgnore()` modifiers + - To selectively mask/unmask @Composables, use `Modifier.sentryReplayMask()` and `Modifier.sentryReplayUnmask()` modifiers +- Session Replay: Mask `WebView`, `VideoView` and `androidx.media3.ui.PlayerView` by default ([#3775](https://github.com/getsentry/sentry-java/pull/3775)) ### Fixes @@ -29,6 +30,7 @@ - `options.experimental.sessionReplay.errorSampleRate` was renamed to `options.experimental.sessionReplay.onErrorSampleRate` ([#3637](https://github.com/getsentry/sentry-java/pull/3637)) - Manifest option `io.sentry.session-replay.error-sample-rate` was renamed to `io.sentry.session-replay.on-error-sample-rate` ([#3637](https://github.com/getsentry/sentry-java/pull/3637)) +- Change `redactAllText` and `redactAllImages` to `maskAllText` and `maskAllImages` ([#3741](https://github.com/getsentry/sentry-java/pull/3741)) ## 7.14.0 diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java b/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java index fc66c9d6ee..96d54d98de 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java @@ -108,9 +108,9 @@ final class ManifestMetadataReader { static final String REPLAYS_ERROR_SAMPLE_RATE = "io.sentry.session-replay.on-error-sample-rate"; - static final String REPLAYS_REDACT_ALL_TEXT = "io.sentry.session-replay.redact-all-text"; + static final String REPLAYS_MASK_ALL_TEXT = "io.sentry.session-replay.mask-all-text"; - static final String REPLAYS_REDACT_ALL_IMAGES = "io.sentry.session-replay.redact-all-images"; + static final String REPLAYS_MASK_ALL_IMAGES = "io.sentry.session-replay.mask-all-images"; /** ManifestMetadataReader ctor */ private ManifestMetadataReader() {} @@ -409,12 +409,12 @@ static void applyMetadata( options .getExperimental() .getSessionReplay() - .setRedactAllText(readBool(metadata, logger, REPLAYS_REDACT_ALL_TEXT, true)); + .setMaskAllText(readBool(metadata, logger, REPLAYS_MASK_ALL_TEXT, true)); options .getExperimental() .getSessionReplay() - .setRedactAllImages(readBool(metadata, logger, REPLAYS_REDACT_ALL_IMAGES, true)); + .setMaskAllImages(readBool(metadata, logger, REPLAYS_MASK_ALL_IMAGES, true)); } options diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt index 8a86fcb2c5..e068af7b1c 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt @@ -1465,21 +1465,21 @@ class ManifestMetadataReaderTest { } @Test - fun `applyMetadata reads session replay redact flags to options`() { + fun `applyMetadata reads session replay mask flags to options`() { // Arrange - val bundle = bundleOf(ManifestMetadataReader.REPLAYS_REDACT_ALL_TEXT to false, ManifestMetadataReader.REPLAYS_REDACT_ALL_IMAGES to false) + val bundle = bundleOf(ManifestMetadataReader.REPLAYS_MASK_ALL_TEXT to false, ManifestMetadataReader.REPLAYS_MASK_ALL_IMAGES to false) val context = fixture.getContext(metaData = bundle) // Act ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) // Assert - assertTrue(fixture.options.experimental.sessionReplay.ignoreViewClasses.contains(SentryReplayOptions.IMAGE_VIEW_CLASS_NAME)) - assertTrue(fixture.options.experimental.sessionReplay.ignoreViewClasses.contains(SentryReplayOptions.TEXT_VIEW_CLASS_NAME)) + assertTrue(fixture.options.experimental.sessionReplay.unmaskViewClasses.contains(SentryReplayOptions.IMAGE_VIEW_CLASS_NAME)) + assertTrue(fixture.options.experimental.sessionReplay.unmaskViewClasses.contains(SentryReplayOptions.TEXT_VIEW_CLASS_NAME)) } @Test - fun `applyMetadata reads session replay redact flags to options and keeps default if not found`() { + fun `applyMetadata reads session replay mask flags to options and keeps default if not found`() { // Arrange val context = fixture.getContext() @@ -1487,7 +1487,7 @@ class ManifestMetadataReaderTest { ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) // Assert - assertTrue(fixture.options.experimental.sessionReplay.redactViewClasses.contains(SentryReplayOptions.IMAGE_VIEW_CLASS_NAME)) - assertTrue(fixture.options.experimental.sessionReplay.redactViewClasses.contains(SentryReplayOptions.TEXT_VIEW_CLASS_NAME)) + assertTrue(fixture.options.experimental.sessionReplay.maskViewClasses.contains(SentryReplayOptions.IMAGE_VIEW_CLASS_NAME)) + assertTrue(fixture.options.experimental.sessionReplay.maskViewClasses.contains(SentryReplayOptions.TEXT_VIEW_CLASS_NAME)) } } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidTest.kt index 6ba69ffdcb..d75e0f88a2 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidTest.kt @@ -440,7 +440,7 @@ class SentryAndroidTest { .untilTrue(asserted) // assert that persisted values have changed - options.executorService.close(5000L) // finalizes all enqueued persisting tasks + options.executorService.close(10000L) // finalizes all enqueued persisting tasks assertEquals( "TestActivity", PersistingScopeObserver.read(options, TRANSACTION_FILENAME, String::class.java) diff --git a/sentry-android-replay/api/sentry-android-replay.api b/sentry-android-replay/api/sentry-android-replay.api index 4b4c59b9a2..a08fb1dd98 100644 --- a/sentry-android-replay/api/sentry-android-replay.api +++ b/sentry-android-replay/api/sentry-android-replay.api @@ -29,8 +29,8 @@ public final class io/sentry/android/replay/GeneratedVideo { } public final class io/sentry/android/replay/ModifierExtensionsKt { - public static final fun sentryReplayIgnore (Landroidx/compose/ui/Modifier;)Landroidx/compose/ui/Modifier; - public static final fun sentryReplayRedact (Landroidx/compose/ui/Modifier;)Landroidx/compose/ui/Modifier; + public static final fun sentryReplayMask (Landroidx/compose/ui/Modifier;)Landroidx/compose/ui/Modifier; + public static final fun sentryReplayUnmask (Landroidx/compose/ui/Modifier;)Landroidx/compose/ui/Modifier; } public abstract interface class io/sentry/android/replay/Recorder : java/io/Closeable { @@ -120,15 +120,15 @@ public final class io/sentry/android/replay/SentryReplayModifiers { } public final class io/sentry/android/replay/SessionReplayOptionsKt { - public static final fun getRedactAllImages (Lio/sentry/SentryReplayOptions;)Z - public static final fun getRedactAllText (Lio/sentry/SentryReplayOptions;)Z - public static final fun setRedactAllImages (Lio/sentry/SentryReplayOptions;Z)V - public static final fun setRedactAllText (Lio/sentry/SentryReplayOptions;Z)V + public static final fun getMaskAllImages (Lio/sentry/SentryReplayOptions;)Z + public static final fun getMaskAllText (Lio/sentry/SentryReplayOptions;)Z + public static final fun setMaskAllImages (Lio/sentry/SentryReplayOptions;Z)V + public static final fun setMaskAllText (Lio/sentry/SentryReplayOptions;Z)V } public final class io/sentry/android/replay/ViewExtensionsKt { - public static final fun sentryReplayIgnore (Landroid/view/View;)V - public static final fun sentryReplayRedact (Landroid/view/View;)V + public static final fun sentryReplayMask (Landroid/view/View;)V + public static final fun sentryReplayUnmask (Landroid/view/View;)V } public final class io/sentry/android/replay/gestures/GestureRecorder : io/sentry/android/replay/OnRootViewsChangedListener { @@ -230,7 +230,7 @@ public abstract class io/sentry/android/replay/viewhierarchy/ViewHierarchyNode { public final fun getElevation ()F public final fun getHeight ()I public final fun getParent ()Lio/sentry/android/replay/viewhierarchy/ViewHierarchyNode; - public final fun getShouldRedact ()Z + public final fun getShouldMask ()Z public final fun getVisibleRect ()Landroid/graphics/Rect; public final fun getWidth ()I public final fun getX ()F diff --git a/sentry-android-replay/proguard-rules.pro b/sentry-android-replay/proguard-rules.pro index 445c89b526..378c0964f8 100644 --- a/sentry-android-replay/proguard-rules.pro +++ b/sentry-android-replay/proguard-rules.pro @@ -2,13 +2,13 @@ # debugging stack traces. -keepattributes SourceFile,LineNumberTable -# Rules to detect Images/Icons and redact them +# Rules to detect Images/Icons and mask them -dontwarn androidx.compose.ui.graphics.painter.Painter -keepnames class * extends androidx.compose.ui.graphics.painter.Painter -keepclasseswithmembernames class * { androidx.compose.ui.graphics.painter.Painter painter; } -# Rules to detect Text colors and if they have Modifier.fillMaxWidth to later redact them +# Rules to detect Text colors and if they have Modifier.fillMaxWidth to later mask them -dontwarn androidx.compose.ui.graphics.ColorProducer -dontwarn androidx.compose.foundation.layout.FillElement -keepnames class androidx.compose.foundation.layout.FillElement @@ -18,3 +18,11 @@ # Rules to detect a compose view to parse its hierarchy -dontwarn androidx.compose.ui.platform.AndroidComposeView -keepnames class androidx.compose.ui.platform.AndroidComposeView +# Rules to detect a media player view to later mask it +-dontwarn androidx.media3.ui.PlayerView +-keepnames class androidx.media3.ui.PlayerView +# Rules to detect a ExoPlayer view to later mask it +-dontwarn com.google.android.exoplayer2.ui.PlayerView +-keepnames class com.google.android.exoplayer2.ui.PlayerView +-dontwarn com.google.android.exoplayer2.ui.StyledPlayerView +-keepnames class com.google.android.exoplayer2.ui.StyledPlayerView diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ModifierExtensions.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ModifierExtensions.kt index b1b119a89c..b5d5222388 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ModifierExtensions.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ModifierExtensions.kt @@ -12,18 +12,18 @@ public object SentryReplayModifiers { ) } -public fun Modifier.sentryReplayRedact(): Modifier { +public fun Modifier.sentryReplayMask(): Modifier { return semantics( properties = { - this[SentryPrivacy] = "redact" + this[SentryPrivacy] = "mask" } ) } -public fun Modifier.sentryReplayIgnore(): Modifier { +public fun Modifier.sentryReplayUnmask(): Modifier { return semantics( properties = { - this[SentryPrivacy] = "ignore" + this[SentryPrivacy] = "unmask" } ) } diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt index 5b779babe0..8f823fa17c 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt @@ -124,11 +124,11 @@ internal class ScreenshotRecorder( val viewHierarchy = ViewHierarchyNode.fromView(root, null, 0, options) root.traverse(viewHierarchy, options) - recorder.submitSafely(options, "screenshot_recorder.redact") { + recorder.submitSafely(options, "screenshot_recorder.mask") { val canvas = Canvas(bitmap) canvas.setMatrix(prescaledMatrix) viewHierarchy.traverse { node -> - if (node.shouldRedact && (node.width > 0 && node.height > 0)) { + if (node.shouldMask && (node.width > 0 && node.height > 0)) { node.visibleRect ?: return@traverse false // TODO: investigate why it returns true on RN when it shouldn't diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/SessionReplayOptions.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/SessionReplayOptions.kt index e3e6605a96..fb5105565b 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/SessionReplayOptions.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/SessionReplayOptions.kt @@ -2,30 +2,30 @@ package io.sentry.android.replay import io.sentry.SentryReplayOptions -// since we don't have getters for redactAllText and redactAllImages, they won't be accessible as +// since we don't have getters for maskAllText and maskAllimages, they won't be accessible as // properties in Kotlin, therefore we create these extensions where a getter is dummy, but a setter // delegates to the corresponding method in SentryReplayOptions /** - * Redact all text content. Draws a rectangle of text bounds with text color on top. By default - * only views extending TextView are redacted. + * Mask all text content. Draws a rectangle of text bounds with text color on top. By default + * only views extending TextView are masked. * *

Default is enabled. */ -var SentryReplayOptions.redactAllText: Boolean +var SentryReplayOptions.maskAllText: Boolean @Deprecated("Getter is unsupported.", level = DeprecationLevel.ERROR) get() = error("Getter not supported") - set(value) = setRedactAllText(value) + set(value) = setMaskAllText(value) /** - * Redact all image content. Draws a rectangle of image bounds with image's dominant color on top. + * Mask all image content. Draws a rectangle of image bounds with image's dominant color on top. * By default only views extending ImageView with BitmapDrawable or custom Drawable type are - * redacted. ColorDrawable, InsetDrawable, VectorDrawable are all considered non-PII, as they come + * masked. ColorDrawable, InsetDrawable, VectorDrawable are all considered non-PII, as they come * from the apk. * *

Default is enabled. */ -var SentryReplayOptions.redactAllImages: Boolean +var SentryReplayOptions.maskAllImages: Boolean @Deprecated("Getter is unsupported.", level = DeprecationLevel.ERROR) get() = error("Getter not supported") - set(value) = setRedactAllImages(value) + set(value) = setMaskAllImages(value) diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ViewExtensions.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ViewExtensions.kt index 37061a5b77..2625399c99 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ViewExtensions.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ViewExtensions.kt @@ -3,16 +3,16 @@ package io.sentry.android.replay import android.view.View /** - * Marks this view to be redacted in session replay. + * Marks this view to be masked in session replay. */ -fun View.sentryReplayRedact() { - setTag(R.id.sentry_privacy, "redact") +fun View.sentryReplayMask() { + setTag(R.id.sentry_privacy, "mask") } /** - * Marks this view to be ignored from redaction in session. + * Marks this view to be unmasked in session replay. * All its content will be visible in the replay, use with caution. */ -fun View.sentryReplayIgnore() { - setTag(R.id.sentry_privacy, "ignore") +fun View.sentryReplayUnmask() { + setTag(R.id.sentry_privacy, "unmask") } diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Nodes.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Nodes.kt index 12152f50cb..5608371722 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Nodes.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Nodes.kt @@ -37,7 +37,7 @@ internal class ComposeTextLayout(internal val layout: TextLayoutResult, private // TODO: probably most of the below we can do via bytecode instrumentation and speed up at runtime /** - * This method is necessary to redact images in Compose. + * This method is necessary to mask images in Compose. * * We heuristically look up for classes that have a [Painter] modifier, usually they all have a * `Painter` string in their name, e.g. PainterElement, PainterModifierNodeElement or @@ -71,9 +71,9 @@ internal fun LayoutNode.findPainter(): Painter? { * [androidx.compose.ui.graphics.painter.BrushPainter] * * In theory, [androidx.compose.ui.graphics.painter.BitmapPainter] can also come from local assets, - * but it can as well come from a network resource, so we preemptively redact it. + * but it can as well come from a network resource, so we preemptively mask it. */ -internal fun Painter.isRedactable(): Boolean { +internal fun Painter.isMaskable(): Boolean { val className = this::class.java.name return !className.contains("Vector") && !className.contains("Color") && @@ -83,11 +83,11 @@ internal fun Painter.isRedactable(): Boolean { internal data class TextAttributes(val color: Color?, val hasFillModifier: Boolean) /** - * This method is necessary to redact text in Compose. + * This method is necessary to mask text in Compose. * * We heuristically look up for classes that have a [Text] modifier, usually they all have a * `Text` string in their name, e.g. TextStringSimpleElement or TextAnnotatedStringElement. We then - * get the color from the modifier, to be able to redact it with the correct color. + * get the color from the modifier, to be able to mask it with the correct color. * * We also look up for classes that have a [Fill] modifier, usually they all have a `Fill` string in * their name, e.g. FillElement. This is necessary to workaround a Compose bug where single-line diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Views.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Views.kt index 1c6111c1b0..0a0656de52 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Views.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Views.kt @@ -88,9 +88,9 @@ internal fun View.isVisibleToUser(): Pair { @SuppressLint("ObsoleteSdkInt") @TargetApi(21) -internal fun Drawable?.isRedactable(): Boolean { +internal fun Drawable?.isMaskable(): Boolean { // TODO: maybe find a way how to check if the drawable is coming from the apk or loaded from network - // TODO: otherwise maybe check for the bitmap size and don't redact those that take a lot of height (e.g. a background of a whatsapp chat) + // TODO: otherwise maybe check for the bitmap size and don't mask those that take a lot of height (e.g. a background of a whatsapp chat) return when (this) { is InsetDrawable, is ColorDrawable, is VectorDrawable, is GradientDrawable -> false is BitmapDrawable -> { diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleVideoEncoder.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleVideoEncoder.kt index baf521a2e6..211decc098 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleVideoEncoder.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleVideoEncoder.kt @@ -136,7 +136,7 @@ internal class SimpleVideoEncoder( ) format.setInteger(MediaFormat.KEY_BIT_RATE, bitRate) format.setFloat(MediaFormat.KEY_FRAME_RATE, muxerConfig.frameRate.toFloat()) - format.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, -1) // use -1 to force always non-key frames, meaning only partial updates to save the video size + format.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, 6) // use 6 to force non-key frames, meaning only partial updates to save the video size. Every 6th second is a key frame, which is useful for buffer mode format } diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ComposeViewHierarchyNode.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ComposeViewHierarchyNode.kt index c611b91b47..888528f769 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ComposeViewHierarchyNode.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ComposeViewHierarchyNode.kt @@ -23,7 +23,7 @@ import io.sentry.android.replay.util.ComposeTextLayout import io.sentry.android.replay.util.boundsInWindow import io.sentry.android.replay.util.findPainter import io.sentry.android.replay.util.findTextAttributes -import io.sentry.android.replay.util.isRedactable +import io.sentry.android.replay.util.isMaskable import io.sentry.android.replay.util.toOpaque import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode.GenericViewHierarchyNode import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode.ImageViewHierarchyNode @@ -45,22 +45,22 @@ internal object ComposeViewHierarchyNode { } } - private fun LayoutNode.shouldRedact(isImage: Boolean, options: SentryOptions): Boolean { + private fun LayoutNode.shouldMask(isImage: Boolean, options: SentryOptions): Boolean { val sentryPrivacyModifier = collapsedSemantics?.getOrNull(SentryReplayModifiers.SentryPrivacy) - if (sentryPrivacyModifier == "ignore") { + if (sentryPrivacyModifier == "unmask") { return false } - if (sentryPrivacyModifier == "redact") { + if (sentryPrivacyModifier == "mask") { return true } val className = getProxyClassName(isImage) - if (options.experimental.sessionReplay.ignoreViewClasses.contains(className)) { + if (options.experimental.sessionReplay.unmaskViewClasses.contains(className)) { return false } - return options.experimental.sessionReplay.redactViewClasses.contains(className) + return options.experimental.sessionReplay.maskViewClasses.contains(className) } private var _rootCoordinates: LayoutCoordinates? = null @@ -90,7 +90,7 @@ internal object ComposeViewHierarchyNode { val positionInWindow = node.coordinates.positionInWindow() return when { semantics?.contains(SemanticsProperties.Text) == true || isEditable -> { - val shouldRedact = isVisible && node.shouldRedact(isImage = false, options) + val shouldMask = isVisible && node.shouldMask(isImage = false, options) parent?.setImportantForCaptureToAncestors(true) val textLayoutResults = mutableListOf() @@ -115,7 +115,7 @@ internal object ComposeViewHierarchyNode { elevation = (parent?.elevation ?: 0f), distance = distance, parent = parent, - shouldRedact = shouldRedact, + shouldMask = shouldMask, isImportantForContentCapture = true, isVisible = isVisible, visibleRect = visibleRect @@ -124,7 +124,7 @@ internal object ComposeViewHierarchyNode { else -> { val painter = node.findPainter() if (painter != null) { - val shouldRedact = isVisible && node.shouldRedact(isImage = true, options) + val shouldMask = isVisible && node.shouldMask(isImage = true, options) parent?.setImportantForCaptureToAncestors(true) ImageViewHierarchyNode( @@ -137,11 +137,11 @@ internal object ComposeViewHierarchyNode { parent = parent, isVisible = isVisible, isImportantForContentCapture = true, - shouldRedact = shouldRedact && painter.isRedactable(), + shouldMask = shouldMask && painter.isMaskable(), visibleRect = visibleRect ) } else { - val shouldRedact = isVisible && node.shouldRedact(isImage = false, options) + val shouldMask = isVisible && node.shouldMask(isImage = false, options) // TODO: this currently does not support embedded AndroidViews, we'd have to // TODO: traverse the ViewHierarchyNode here again. For now we can recommend @@ -154,7 +154,7 @@ internal object ComposeViewHierarchyNode { elevation = (parent?.elevation ?: 0f), distance = distance, parent = parent, - shouldRedact = shouldRedact, + shouldMask = shouldMask, isImportantForContentCapture = false, /* will be set by children */ isVisible = isVisible, visibleRect = visibleRect diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ViewHierarchyNode.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ViewHierarchyNode.kt index a231e4f3d2..ef05ecb029 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ViewHierarchyNode.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ViewHierarchyNode.kt @@ -9,7 +9,7 @@ import io.sentry.SentryOptions import io.sentry.android.replay.R import io.sentry.android.replay.util.AndroidTextLayout import io.sentry.android.replay.util.TextLayout -import io.sentry.android.replay.util.isRedactable +import io.sentry.android.replay.util.isMaskable import io.sentry.android.replay.util.isVisibleToUser import io.sentry.android.replay.util.toOpaque import io.sentry.android.replay.util.totalPaddingTopSafe @@ -25,7 +25,7 @@ sealed class ViewHierarchyNode( /* Distance to the parent (index) */ val distance: Int, val parent: ViewHierarchyNode? = null, - val shouldRedact: Boolean = false, + val shouldMask: Boolean = false, /* Whether the node is important for content capture (=non-empty container) */ var isImportantForContentCapture: Boolean = false, val isVisible: Boolean = false, @@ -41,11 +41,11 @@ sealed class ViewHierarchyNode( elevation: Float, distance: Int, parent: ViewHierarchyNode? = null, - shouldRedact: Boolean = false, + shouldMask: Boolean = false, isImportantForContentCapture: Boolean = false, isVisible: Boolean = false, visibleRect: Rect? = null - ) : ViewHierarchyNode(x, y, width, height, elevation, distance, parent, shouldRedact, isImportantForContentCapture, isVisible, visibleRect) + ) : ViewHierarchyNode(x, y, width, height, elevation, distance, parent, shouldMask, isImportantForContentCapture, isVisible, visibleRect) class TextViewHierarchyNode( val layout: TextLayout? = null, @@ -59,11 +59,11 @@ sealed class ViewHierarchyNode( elevation: Float, distance: Int, parent: ViewHierarchyNode? = null, - shouldRedact: Boolean = false, + shouldMask: Boolean = false, isImportantForContentCapture: Boolean = false, isVisible: Boolean = false, visibleRect: Rect? = null - ) : ViewHierarchyNode(x, y, width, height, elevation, distance, parent, shouldRedact, isImportantForContentCapture, isVisible, visibleRect) + ) : ViewHierarchyNode(x, y, width, height, elevation, distance, parent, shouldMask, isImportantForContentCapture, isVisible, visibleRect) class ImageViewHierarchyNode( x: Float, @@ -73,11 +73,11 @@ sealed class ViewHierarchyNode( elevation: Float, distance: Int, parent: ViewHierarchyNode? = null, - shouldRedact: Boolean = false, + shouldMask: Boolean = false, isImportantForContentCapture: Boolean = false, isVisible: Boolean = false, visibleRect: Rect? = null - ) : ViewHierarchyNode(x, y, width, height, elevation, distance, parent, shouldRedact, isImportantForContentCapture, isVisible, visibleRect) + ) : ViewHierarchyNode(x, y, width, height, elevation, distance, parent, shouldMask, isImportantForContentCapture, isVisible, visibleRect) /** * Basically replicating this: https://developer.android.com/reference/android/view/View#isImportantForContentCapture() @@ -233,8 +233,8 @@ sealed class ViewHierarchyNode( ) companion object { - private const val SENTRY_IGNORE_TAG = "sentry-ignore" - private const val SENTRY_REDACT_TAG = "sentry-redact" + private const val SENTRY_UNMASK_TAG = "sentry-unmask" + private const val SENTRY_MASK_TAG = "sentry-mask" private fun Class<*>.isAssignableFrom(set: Set): Boolean { var cls: Class<*>? = this @@ -248,29 +248,29 @@ sealed class ViewHierarchyNode( return false } - private fun View.shouldRedact(options: SentryOptions): Boolean { - if ((tag as? String)?.lowercase()?.contains(SENTRY_IGNORE_TAG) == true || - getTag(R.id.sentry_privacy) == "ignore" + private fun View.shouldMask(options: SentryOptions): Boolean { + if ((tag as? String)?.lowercase()?.contains(SENTRY_UNMASK_TAG) == true || + getTag(R.id.sentry_privacy) == "unmask" ) { return false } - if ((tag as? String)?.lowercase()?.contains(SENTRY_REDACT_TAG) == true || - getTag(R.id.sentry_privacy) == "redact" + if ((tag as? String)?.lowercase()?.contains(SENTRY_MASK_TAG) == true || + getTag(R.id.sentry_privacy) == "mask" ) { return true } - if (this.javaClass.isAssignableFrom(options.experimental.sessionReplay.ignoreViewClasses)) { + if (this.javaClass.isAssignableFrom(options.experimental.sessionReplay.unmaskViewClasses)) { return false } - return this.javaClass.isAssignableFrom(options.experimental.sessionReplay.redactViewClasses) + return this.javaClass.isAssignableFrom(options.experimental.sessionReplay.maskViewClasses) } fun fromView(view: View, parent: ViewHierarchyNode?, distance: Int, options: SentryOptions): ViewHierarchyNode { val (isVisible, visibleRect) = view.isVisibleToUser() - val shouldRedact = isVisible && view.shouldRedact(options) + val shouldMask = isVisible && view.shouldMask(options) when (view) { is TextView -> { parent?.setImportantForCaptureToAncestors(true) @@ -284,7 +284,7 @@ sealed class ViewHierarchyNode( width = view.width, height = view.height, elevation = (parent?.elevation ?: 0f) + view.elevation, - shouldRedact = shouldRedact, + shouldMask = shouldMask, distance = distance, parent = parent, isImportantForContentCapture = true, @@ -305,7 +305,7 @@ sealed class ViewHierarchyNode( parent = parent, isVisible = isVisible, isImportantForContentCapture = true, - shouldRedact = shouldRedact && view.drawable?.isRedactable() == true, + shouldMask = shouldMask && view.drawable?.isMaskable() == true, visibleRect = visibleRect ) } @@ -319,7 +319,7 @@ sealed class ViewHierarchyNode( (parent?.elevation ?: 0f) + view.elevation, distance = distance, parent = parent, - shouldRedact = shouldRedact, + shouldMask = shouldMask, isImportantForContentCapture = false, /* will be set by children */ isVisible = isVisible, visibleRect = visibleRect diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/viewhierarchy/ComposeRedactionOptionsTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/viewhierarchy/ComposeMaskingOptionsTest.kt similarity index 67% rename from sentry-android-replay/src/test/java/io/sentry/android/replay/viewhierarchy/ComposeRedactionOptionsTest.kt rename to sentry-android-replay/src/test/java/io/sentry/android/replay/viewhierarchy/ComposeMaskingOptionsTest.kt index 981e351408..e5330fa827 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/viewhierarchy/ComposeRedactionOptionsTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/viewhierarchy/ComposeMaskingOptionsTest.kt @@ -22,10 +22,10 @@ import androidx.compose.ui.unit.dp import androidx.test.ext.junit.runners.AndroidJUnit4 import coil.compose.AsyncImage import io.sentry.SentryOptions -import io.sentry.android.replay.redactAllImages -import io.sentry.android.replay.redactAllText -import io.sentry.android.replay.sentryReplayIgnore -import io.sentry.android.replay.sentryReplayRedact +import io.sentry.android.replay.maskAllImages +import io.sentry.android.replay.maskAllText +import io.sentry.android.replay.sentryReplayMask +import io.sentry.android.replay.sentryReplayUnmask import io.sentry.android.replay.util.ComposeTextLayout import io.sentry.android.replay.util.traverse import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode.GenericViewHierarchyNode @@ -43,132 +43,132 @@ import kotlin.test.assertTrue @RunWith(AndroidJUnit4::class) @Config(sdk = [30]) -class ComposeRedactionOptionsTest { +class ComposeMaskingOptionsTest { @Before fun setup() { System.setProperty("robolectric.areWindowsMarkedVisible", "true") - ComposeRedactionOptionsActivity.textModifierApplier = null - ComposeRedactionOptionsActivity.containerModifierApplier = null + ComposeMaskingOptionsActivity.textModifierApplier = null + ComposeMaskingOptionsActivity.containerModifierApplier = null } @Test - fun `when redactAllText is set all Text nodes are redacted`() { - val activity = buildActivity(ComposeRedactionOptionsActivity::class.java).setup() + fun `when maskAllText is set all Text nodes are masked`() { + val activity = buildActivity(ComposeMaskingOptionsActivity::class.java).setup() val options = SentryOptions().apply { - experimental.sessionReplay.redactAllText = true + experimental.sessionReplay.maskAllText = true } val textNodes = activity.get().collectNodesOfType(options) assertEquals(4, textNodes.size) // [TextField, Text, Button, Activity Title] - assertTrue(textNodes.all { it.shouldRedact }) + assertTrue(textNodes.all { it.shouldMask }) // just a sanity check for parsing the tree assertEquals("Random repo", (textNodes[1].layout as ComposeTextLayout).layout.layoutInput.text.text) } @Test - fun `when redactAllText is set to false all Text nodes are ignored`() { - val activity = buildActivity(ComposeRedactionOptionsActivity::class.java).setup() + fun `when maskAllText is set to false all Text nodes are unmasked`() { + val activity = buildActivity(ComposeMaskingOptionsActivity::class.java).setup() val options = SentryOptions().apply { - experimental.sessionReplay.redactAllText = false + experimental.sessionReplay.maskAllText = false } val textNodes = activity.get().collectNodesOfType(options) assertEquals(4, textNodes.size) // [TextField, Text, Button, Activity Title] - assertTrue(textNodes.none { it.shouldRedact }) + assertTrue(textNodes.none { it.shouldMask }) } @Test - fun `when redactAllImages is set all Image nodes are redacted`() { - val activity = buildActivity(ComposeRedactionOptionsActivity::class.java).setup() + fun `when maskAllImages is set all Image nodes are masked`() { + val activity = buildActivity(ComposeMaskingOptionsActivity::class.java).setup() val options = SentryOptions().apply { - experimental.sessionReplay.redactAllImages = true + experimental.sessionReplay.maskAllImages = true } val imageNodes = activity.get().collectNodesOfType(options) assertEquals(1, imageNodes.size) // [AsyncImage] - assertTrue(imageNodes.all { it.shouldRedact }) + assertTrue(imageNodes.all { it.shouldMask }) } @Test - fun `when redactAllImages is set to false all Image nodes are ignored`() { - val activity = buildActivity(ComposeRedactionOptionsActivity::class.java).setup() + fun `when maskAllImages is set to false all Image nodes are unmasked`() { + val activity = buildActivity(ComposeMaskingOptionsActivity::class.java).setup() val options = SentryOptions().apply { - experimental.sessionReplay.redactAllImages = false + experimental.sessionReplay.maskAllImages = false } val imageNodes = activity.get().collectNodesOfType(options) assertEquals(1, imageNodes.size) // [AsyncImage] - assertTrue(imageNodes.none { it.shouldRedact }) + assertTrue(imageNodes.none { it.shouldMask }) } @Test - fun `when sentry-redact modifier is set redacts the node`() { - ComposeRedactionOptionsActivity.textModifierApplier = { Modifier.sentryReplayRedact() } - val activity = buildActivity(ComposeRedactionOptionsActivity::class.java).setup() + fun `when sentry-mask modifier is set masks the node`() { + ComposeMaskingOptionsActivity.textModifierApplier = { Modifier.sentryReplayMask() } + val activity = buildActivity(ComposeMaskingOptionsActivity::class.java).setup() val options = SentryOptions().apply { - experimental.sessionReplay.redactAllText = false + experimental.sessionReplay.maskAllText = false } val textNodes = activity.get().collectNodesOfType(options) assertEquals(4, textNodes.size) // [TextField, Text, Button, Activity Title] textNodes.forEach { if ((it.layout as? ComposeTextLayout)?.layout?.layoutInput?.text?.text == "Make Request") { - assertTrue(it.shouldRedact) + assertTrue(it.shouldMask) } else { - assertFalse(it.shouldRedact) + assertFalse(it.shouldMask) } } } @Test - fun `when sentry-ignore modifier is set ignores the node`() { - ComposeRedactionOptionsActivity.textModifierApplier = { Modifier.sentryReplayIgnore() } - val activity = buildActivity(ComposeRedactionOptionsActivity::class.java).setup() + fun `when sentry-unmask modifier is set unmasks the node`() { + ComposeMaskingOptionsActivity.textModifierApplier = { Modifier.sentryReplayUnmask() } + val activity = buildActivity(ComposeMaskingOptionsActivity::class.java).setup() val options = SentryOptions().apply { - experimental.sessionReplay.redactAllText = true + experimental.sessionReplay.maskAllText = true } val textNodes = activity.get().collectNodesOfType(options) assertEquals(4, textNodes.size) // [TextField, Text, Button, Activity Title] textNodes.forEach { if ((it.layout as? ComposeTextLayout)?.layout?.layoutInput?.text?.text == "Make Request") { - assertFalse(it.shouldRedact) + assertFalse(it.shouldMask) } else { - assertTrue(it.shouldRedact) + assertTrue(it.shouldMask) } } } @Test - fun `when view is not visible, does not redact the view`() { - ComposeRedactionOptionsActivity.textModifierApplier = { Modifier.semantics { invisibleToUser() } } - val activity = buildActivity(ComposeRedactionOptionsActivity::class.java).setup() + fun `when view is not visible, does not mask the view`() { + ComposeMaskingOptionsActivity.textModifierApplier = { Modifier.semantics { invisibleToUser() } } + val activity = buildActivity(ComposeMaskingOptionsActivity::class.java).setup() val options = SentryOptions().apply { - experimental.sessionReplay.redactAllText = true + experimental.sessionReplay.maskAllText = true } val textNodes = activity.get().collectNodesOfType(options) textNodes.forEach { if ((it.layout as? ComposeTextLayout)?.layout?.layoutInput?.text?.text == "Make Request") { - assertFalse(it.shouldRedact) + assertFalse(it.shouldMask) } else { - assertTrue(it.shouldRedact) + assertTrue(it.shouldMask) } } } @Test - fun `when a container view is ignored its children are not ignored`() { - ComposeRedactionOptionsActivity.containerModifierApplier = { Modifier.sentryReplayIgnore() } - val activity = buildActivity(ComposeRedactionOptionsActivity::class.java).setup() + fun `when a container view is unmasked its children are not unmasked`() { + ComposeMaskingOptionsActivity.containerModifierApplier = { Modifier.sentryReplayUnmask() } + val activity = buildActivity(ComposeMaskingOptionsActivity::class.java).setup() val options = SentryOptions() @@ -176,9 +176,9 @@ class ComposeRedactionOptionsTest { val imageNodes = allNodes.filterIsInstance() val textNodes = allNodes.filterIsInstance() val genericNodes = allNodes.filterIsInstance() - assertTrue(imageNodes.all { it.shouldRedact }) - assertTrue(textNodes.all { it.shouldRedact }) - assertTrue(genericNodes.none { it.shouldRedact }) + assertTrue(imageNodes.all { it.shouldMask }) + assertTrue(textNodes.all { it.shouldMask }) + assertTrue(genericNodes.none { it.shouldMask }) } private inline fun Activity.collectNodesOfType(options: SentryOptions): List { @@ -197,7 +197,7 @@ class ComposeRedactionOptionsTest { } } -private class ComposeRedactionOptionsActivity : ComponentActivity() { +private class ComposeMaskingOptionsActivity : ComponentActivity() { companion object { var textModifierApplier: (() -> Modifier)? = null diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/viewhierarchy/MaskingOptionsTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/viewhierarchy/MaskingOptionsTest.kt new file mode 100644 index 0000000000..4a40e0a915 --- /dev/null +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/viewhierarchy/MaskingOptionsTest.kt @@ -0,0 +1,278 @@ +package io.sentry.android.replay.viewhierarchy + +import android.app.Activity +import android.content.Context +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.drawable.Drawable +import android.os.Bundle +import android.view.View +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.LinearLayout.LayoutParams +import android.widget.RadioButton +import android.widget.TextView +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.sentry.SentryOptions +import io.sentry.android.replay.maskAllImages +import io.sentry.android.replay.maskAllText +import io.sentry.android.replay.sentryReplayMask +import io.sentry.android.replay.sentryReplayUnmask +import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode.ImageViewHierarchyNode +import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode.TextViewHierarchyNode +import org.junit.runner.RunWith +import org.robolectric.Robolectric.buildActivity +import org.robolectric.annotation.Config +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +@RunWith(AndroidJUnit4::class) +@Config(sdk = [30]) +class MaskingOptionsTest { + + @BeforeTest + fun setup() { + System.setProperty("robolectric.areWindowsMarkedVisible", "true") + } + + @Test + fun `when maskAllText is set all TextView nodes are masked`() { + buildActivity(MaskingOptionsActivity::class.java).setup() + + val options = SentryOptions().apply { + experimental.sessionReplay.maskAllText = true + } + + val textNode = ViewHierarchyNode.fromView(MaskingOptionsActivity.textView!!, null, 0, options) + val radioButtonNode = ViewHierarchyNode.fromView(MaskingOptionsActivity.radioButton!!, null, 0, options) + + assertTrue(textNode is TextViewHierarchyNode) + assertTrue(textNode.shouldMask) + + assertTrue(radioButtonNode is TextViewHierarchyNode) + assertTrue(radioButtonNode.shouldMask) + } + + @Test + fun `when maskAllText is set to false all TextView nodes are unmasked`() { + buildActivity(MaskingOptionsActivity::class.java).setup() + + val options = SentryOptions().apply { + experimental.sessionReplay.maskAllText = false + } + + val textNode = ViewHierarchyNode.fromView(MaskingOptionsActivity.textView!!, null, 0, options) + val radioButtonNode = ViewHierarchyNode.fromView(MaskingOptionsActivity.radioButton!!, null, 0, options) + + assertTrue(textNode is TextViewHierarchyNode) + assertFalse(textNode.shouldMask) + + assertTrue(radioButtonNode is TextViewHierarchyNode) + assertFalse(radioButtonNode.shouldMask) + } + + @Test + fun `when maskAllImages is set all ImageView nodes are masked`() { + buildActivity(MaskingOptionsActivity::class.java).setup() + + val options = SentryOptions().apply { + experimental.sessionReplay.maskAllImages = true + } + + val imageNode = ViewHierarchyNode.fromView(MaskingOptionsActivity.imageView!!, null, 0, options) + + assertTrue(imageNode is ImageViewHierarchyNode) + assertTrue(imageNode.shouldMask) + } + + @Test + fun `when maskAllImages is set to false all ImageView nodes are unmasked`() { + buildActivity(MaskingOptionsActivity::class.java).setup() + + val options = SentryOptions().apply { + experimental.sessionReplay.maskAllImages = false + } + + val imageNode = ViewHierarchyNode.fromView(MaskingOptionsActivity.imageView!!, null, 0, options) + + assertTrue(imageNode is ImageViewHierarchyNode) + assertFalse(imageNode.shouldMask) + } + + @Test + fun `when sentry-mask tag is set mask the view`() { + buildActivity(MaskingOptionsActivity::class.java).setup() + + val options = SentryOptions().apply { + experimental.sessionReplay.maskAllText = false + } + + MaskingOptionsActivity.textView!!.tag = "sentry-mask" + val textNode = ViewHierarchyNode.fromView(MaskingOptionsActivity.textView!!, null, 0, options) + + assertTrue(textNode.shouldMask) + } + + @Test + fun `when sentry-unmask tag is set unmasks the view`() { + buildActivity(MaskingOptionsActivity::class.java).setup() + + val options = SentryOptions().apply { + experimental.sessionReplay.maskAllText = true + } + + MaskingOptionsActivity.textView!!.tag = "sentry-unmask" + val textNode = ViewHierarchyNode.fromView(MaskingOptionsActivity.textView!!, null, 0, options) + + assertFalse(textNode.shouldMask) + } + + @Test + fun `when sentry-privacy tag is set to mask masks the view`() { + buildActivity(MaskingOptionsActivity::class.java).setup() + + val options = SentryOptions().apply { + experimental.sessionReplay.maskAllText = false + } + + MaskingOptionsActivity.textView!!.sentryReplayMask() + val textNode = ViewHierarchyNode.fromView(MaskingOptionsActivity.textView!!, null, 0, options) + + assertTrue(textNode.shouldMask) + } + + @Test + fun `when sentry-privacy tag is set to unmask unmasks the view`() { + buildActivity(MaskingOptionsActivity::class.java).setup() + + val options = SentryOptions().apply { + experimental.sessionReplay.maskAllText = true + } + + MaskingOptionsActivity.textView!!.sentryReplayUnmask() + val textNode = ViewHierarchyNode.fromView(MaskingOptionsActivity.textView!!, null, 0, options) + + assertFalse(textNode.shouldMask) + } + + @Test + fun `when view is not visible, does not mask the view`() { + buildActivity(MaskingOptionsActivity::class.java).setup() + + val options = SentryOptions().apply { + experimental.sessionReplay.maskAllText = true + } + + MaskingOptionsActivity.textView!!.visibility = View.GONE + val textNode = ViewHierarchyNode.fromView(MaskingOptionsActivity.textView!!, null, 0, options) + + assertFalse(textNode.shouldMask) + } + + @Test + fun `when added to mask list masks custom view`() { + buildActivity(MaskingOptionsActivity::class.java).setup() + + val options = SentryOptions().apply { + experimental.sessionReplay.maskViewClasses.add(CustomView::class.java.canonicalName) + } + + val customViewNode = ViewHierarchyNode.fromView(MaskingOptionsActivity.customView!!, null, 0, options) + + assertTrue(customViewNode.shouldMask) + } + + @Test + fun `when subclass is added to ignored classes ignores all instances of that class`() { + buildActivity(MaskingOptionsActivity::class.java).setup() + + val options = SentryOptions().apply { + experimental.sessionReplay.maskAllText = true // all TextView subclasses + experimental.sessionReplay.unmaskViewClasses.add(RadioButton::class.java.canonicalName) + } + + val textNode = ViewHierarchyNode.fromView(MaskingOptionsActivity.textView!!, null, 0, options) + val radioButtonNode = ViewHierarchyNode.fromView(MaskingOptionsActivity.radioButton!!, null, 0, options) + + assertTrue(textNode.shouldMask) + assertFalse(radioButtonNode.shouldMask) + } + + @Test + fun `when a container view is ignored its children are not ignored`() { + buildActivity(MaskingOptionsActivity::class.java).setup() + + val options = SentryOptions().apply { + experimental.sessionReplay.unmaskViewClasses.add(LinearLayout::class.java.canonicalName) + } + + val linearLayoutNode = ViewHierarchyNode.fromView(MaskingOptionsActivity.textView!!.parent as LinearLayout, null, 0, options) + val textNode = ViewHierarchyNode.fromView(MaskingOptionsActivity.textView!!, null, 0, options) + val imageNode = ViewHierarchyNode.fromView(MaskingOptionsActivity.imageView!!, null, 0, options) + + assertFalse(linearLayoutNode.shouldMask) + assertTrue(textNode.shouldMask) + assertTrue(imageNode.shouldMask) + } +} + +private class CustomView(context: Context) : View(context) { + + override fun onDraw(canvas: Canvas) { + super.onDraw(canvas) + canvas.drawColor(Color.BLACK) + } +} + +private class MaskingOptionsActivity : Activity() { + + companion object { + var textView: TextView? = null + var radioButton: RadioButton? = null + var imageView: ImageView? = null + var customView: CustomView? = null + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val linearLayout = LinearLayout(this).apply { + setBackgroundColor(android.R.color.white) + orientation = LinearLayout.VERTICAL + layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT) + } + + textView = TextView(this).apply { + text = "Hello, World!" + layoutParams = LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT) + } + linearLayout.addView(textView) + + val image = this::class.java.classLoader.getResource("Tongariro.jpg")!! + imageView = ImageView(this).apply { + setImageDrawable(Drawable.createFromPath(image.path)) + layoutParams = LayoutParams(50, 50).apply { + setMargins(0, 16, 0, 0) + } + } + linearLayout.addView(imageView) + + radioButton = RadioButton(this).apply { + text = "Radio Button" + layoutParams = LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT).apply { + setMargins(0, 16, 0, 0) + } + } + linearLayout.addView(radioButton) + + customView = CustomView(this).apply { + layoutParams = LayoutParams(50, 50).apply { + setMargins(0, 16, 0, 0) + } + } + linearLayout.addView(customView) + + setContentView(linearLayout) + } +} diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/viewhierarchy/RedactionOptionsTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/viewhierarchy/RedactionOptionsTest.kt deleted file mode 100644 index c1a50f7a62..0000000000 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/viewhierarchy/RedactionOptionsTest.kt +++ /dev/null @@ -1,278 +0,0 @@ -package io.sentry.android.replay.viewhierarchy - -import android.app.Activity -import android.content.Context -import android.graphics.Canvas -import android.graphics.Color -import android.graphics.drawable.Drawable -import android.os.Bundle -import android.view.View -import android.widget.ImageView -import android.widget.LinearLayout -import android.widget.LinearLayout.LayoutParams -import android.widget.RadioButton -import android.widget.TextView -import androidx.test.ext.junit.runners.AndroidJUnit4 -import io.sentry.SentryOptions -import io.sentry.android.replay.redactAllImages -import io.sentry.android.replay.redactAllText -import io.sentry.android.replay.sentryReplayIgnore -import io.sentry.android.replay.sentryReplayRedact -import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode.ImageViewHierarchyNode -import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode.TextViewHierarchyNode -import org.junit.runner.RunWith -import org.robolectric.Robolectric.buildActivity -import org.robolectric.annotation.Config -import kotlin.test.BeforeTest -import kotlin.test.Test -import kotlin.test.assertFalse -import kotlin.test.assertTrue - -@RunWith(AndroidJUnit4::class) -@Config(sdk = [30]) -class RedactionOptionsTest { - - @BeforeTest - fun setup() { - System.setProperty("robolectric.areWindowsMarkedVisible", "true") - } - - @Test - fun `when redactAllText is set all TextView nodes are redacted`() { - buildActivity(RedactionOptionsActivity::class.java).setup() - - val options = SentryOptions().apply { - experimental.sessionReplay.redactAllText = true - } - - val textNode = ViewHierarchyNode.fromView(RedactionOptionsActivity.textView!!, null, 0, options) - val radioButtonNode = ViewHierarchyNode.fromView(RedactionOptionsActivity.radioButton!!, null, 0, options) - - assertTrue(textNode is TextViewHierarchyNode) - assertTrue(textNode.shouldRedact) - - assertTrue(radioButtonNode is TextViewHierarchyNode) - assertTrue(radioButtonNode.shouldRedact) - } - - @Test - fun `when redactAllText is set to false all TextView nodes are ignored`() { - buildActivity(RedactionOptionsActivity::class.java).setup() - - val options = SentryOptions().apply { - experimental.sessionReplay.redactAllText = false - } - - val textNode = ViewHierarchyNode.fromView(RedactionOptionsActivity.textView!!, null, 0, options) - val radioButtonNode = ViewHierarchyNode.fromView(RedactionOptionsActivity.radioButton!!, null, 0, options) - - assertTrue(textNode is TextViewHierarchyNode) - assertFalse(textNode.shouldRedact) - - assertTrue(radioButtonNode is TextViewHierarchyNode) - assertFalse(radioButtonNode.shouldRedact) - } - - @Test - fun `when redactAllImages is set all ImageView nodes are redacted`() { - buildActivity(RedactionOptionsActivity::class.java).setup() - - val options = SentryOptions().apply { - experimental.sessionReplay.redactAllImages = true - } - - val imageNode = ViewHierarchyNode.fromView(RedactionOptionsActivity.imageView!!, null, 0, options) - - assertTrue(imageNode is ImageViewHierarchyNode) - assertTrue(imageNode.shouldRedact) - } - - @Test - fun `when redactAllImages is set to false all ImageView nodes are ignored`() { - buildActivity(RedactionOptionsActivity::class.java).setup() - - val options = SentryOptions().apply { - experimental.sessionReplay.redactAllImages = false - } - - val imageNode = ViewHierarchyNode.fromView(RedactionOptionsActivity.imageView!!, null, 0, options) - - assertTrue(imageNode is ImageViewHierarchyNode) - assertFalse(imageNode.shouldRedact) - } - - @Test - fun `when sentry-redact tag is set redacts the view`() { - buildActivity(RedactionOptionsActivity::class.java).setup() - - val options = SentryOptions().apply { - experimental.sessionReplay.redactAllText = false - } - - RedactionOptionsActivity.textView!!.tag = "sentry-redact" - val textNode = ViewHierarchyNode.fromView(RedactionOptionsActivity.textView!!, null, 0, options) - - assertTrue(textNode.shouldRedact) - } - - @Test - fun `when sentry-ignore tag is set ignores the view`() { - buildActivity(RedactionOptionsActivity::class.java).setup() - - val options = SentryOptions().apply { - experimental.sessionReplay.redactAllText = true - } - - RedactionOptionsActivity.textView!!.tag = "sentry-ignore" - val textNode = ViewHierarchyNode.fromView(RedactionOptionsActivity.textView!!, null, 0, options) - - assertFalse(textNode.shouldRedact) - } - - @Test - fun `when sentry-privacy tag is set to redact redacts the view`() { - buildActivity(RedactionOptionsActivity::class.java).setup() - - val options = SentryOptions().apply { - experimental.sessionReplay.redactAllText = false - } - - RedactionOptionsActivity.textView!!.sentryReplayRedact() - val textNode = ViewHierarchyNode.fromView(RedactionOptionsActivity.textView!!, null, 0, options) - - assertTrue(textNode.shouldRedact) - } - - @Test - fun `when sentry-privacy tag is set to ignore ignores the view`() { - buildActivity(RedactionOptionsActivity::class.java).setup() - - val options = SentryOptions().apply { - experimental.sessionReplay.redactAllText = true - } - - RedactionOptionsActivity.textView!!.sentryReplayIgnore() - val textNode = ViewHierarchyNode.fromView(RedactionOptionsActivity.textView!!, null, 0, options) - - assertFalse(textNode.shouldRedact) - } - - @Test - fun `when view is not visible, does not redact the view`() { - buildActivity(RedactionOptionsActivity::class.java).setup() - - val options = SentryOptions().apply { - experimental.sessionReplay.redactAllText = true - } - - RedactionOptionsActivity.textView!!.visibility = View.GONE - val textNode = ViewHierarchyNode.fromView(RedactionOptionsActivity.textView!!, null, 0, options) - - assertFalse(textNode.shouldRedact) - } - - @Test - fun `when added to redact list redacts custom view`() { - buildActivity(RedactionOptionsActivity::class.java).setup() - - val options = SentryOptions().apply { - experimental.sessionReplay.redactViewClasses.add(CustomView::class.java.canonicalName) - } - - val customViewNode = ViewHierarchyNode.fromView(RedactionOptionsActivity.customView!!, null, 0, options) - - assertTrue(customViewNode.shouldRedact) - } - - @Test - fun `when subclass is added to ignored classes ignores all instances of that class`() { - buildActivity(RedactionOptionsActivity::class.java).setup() - - val options = SentryOptions().apply { - experimental.sessionReplay.redactAllText = true // all TextView subclasses - experimental.sessionReplay.ignoreViewClasses.add(RadioButton::class.java.canonicalName) - } - - val textNode = ViewHierarchyNode.fromView(RedactionOptionsActivity.textView!!, null, 0, options) - val radioButtonNode = ViewHierarchyNode.fromView(RedactionOptionsActivity.radioButton!!, null, 0, options) - - assertTrue(textNode.shouldRedact) - assertFalse(radioButtonNode.shouldRedact) - } - - @Test - fun `when a container view is ignored its children are not ignored`() { - buildActivity(RedactionOptionsActivity::class.java).setup() - - val options = SentryOptions().apply { - experimental.sessionReplay.ignoreViewClasses.add(LinearLayout::class.java.canonicalName) - } - - val linearLayoutNode = ViewHierarchyNode.fromView(RedactionOptionsActivity.textView!!.parent as LinearLayout, null, 0, options) - val textNode = ViewHierarchyNode.fromView(RedactionOptionsActivity.textView!!, null, 0, options) - val imageNode = ViewHierarchyNode.fromView(RedactionOptionsActivity.imageView!!, null, 0, options) - - assertFalse(linearLayoutNode.shouldRedact) - assertTrue(textNode.shouldRedact) - assertTrue(imageNode.shouldRedact) - } -} - -private class CustomView(context: Context) : View(context) { - - override fun onDraw(canvas: Canvas) { - super.onDraw(canvas) - canvas.drawColor(Color.BLACK) - } -} - -private class RedactionOptionsActivity : Activity() { - - companion object { - var textView: TextView? = null - var radioButton: RadioButton? = null - var imageView: ImageView? = null - var customView: CustomView? = null - } - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - val linearLayout = LinearLayout(this).apply { - setBackgroundColor(android.R.color.white) - orientation = LinearLayout.VERTICAL - layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT) - } - - textView = TextView(this).apply { - text = "Hello, World!" - layoutParams = LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT) - } - linearLayout.addView(textView) - - val image = this::class.java.classLoader.getResource("Tongariro.jpg")!! - imageView = ImageView(this).apply { - setImageDrawable(Drawable.createFromPath(image.path)) - layoutParams = LayoutParams(50, 50).apply { - setMargins(0, 16, 0, 0) - } - } - linearLayout.addView(imageView) - - radioButton = RadioButton(this).apply { - text = "Radio Button" - layoutParams = LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT).apply { - setMargins(0, 16, 0, 0) - } - } - linearLayout.addView(radioButton) - - customView = CustomView(this).apply { - layoutParams = LayoutParams(50, 50).apply { - setMargins(0, 16, 0, 0) - } - } - linearLayout.addView(customView) - - setContentView(linearLayout) - } -} diff --git a/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml b/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml index 703685d6f0..058ad3710c 100644 --- a/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml +++ b/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml @@ -166,7 +166,6 @@ - - + diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/compose/ComposeActivity.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/compose/ComposeActivity.kt index 03d9e8d049..3d2e670495 100644 --- a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/compose/ComposeActivity.kt +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/compose/ComposeActivity.kt @@ -36,7 +36,7 @@ import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController import androidx.navigation.navArgument import coil.compose.AsyncImage -import io.sentry.android.replay.sentryReplayIgnore +import io.sentry.android.replay.sentryReplayUnmask import io.sentry.compose.SentryTraced import io.sentry.compose.withSentryObservableEffect import io.sentry.samples.android.GithubAPI @@ -145,7 +145,7 @@ fun Github( .testTag("button_list_repos_async") .padding(top = 32.dp) ) { - Text("Make Request", modifier = Modifier.sentryReplayIgnore()) + Text("Make Request", modifier = Modifier.sentryReplayUnmask()) } } } diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index f8dc3b52ee..ac1eb2bc8a 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -2711,27 +2711,32 @@ public final class io/sentry/SentryReplayEvent$ReplayType$Deserializer : io/sent } public final class io/sentry/SentryReplayOptions { + public static final field ANDROIDX_MEDIA_VIEW_CLASS_NAME Ljava/lang/String; + public static final field EXOPLAYER_CLASS_NAME Ljava/lang/String; + public static final field EXOPLAYER_STYLED_CLASS_NAME Ljava/lang/String; public static final field IMAGE_VIEW_CLASS_NAME Ljava/lang/String; public static final field TEXT_VIEW_CLASS_NAME Ljava/lang/String; + public static final field VIDEO_VIEW_CLASS_NAME Ljava/lang/String; + public static final field WEB_VIEW_CLASS_NAME Ljava/lang/String; public fun (Ljava/lang/Double;Ljava/lang/Double;)V public fun (Z)V - public fun addIgnoreViewClass (Ljava/lang/String;)V - public fun addRedactViewClass (Ljava/lang/String;)V + public fun addMaskViewClass (Ljava/lang/String;)V + public fun addUnmaskViewClass (Ljava/lang/String;)V public fun getErrorReplayDuration ()J public fun getFrameRate ()I - public fun getIgnoreViewClasses ()Ljava/util/Set; + public fun getMaskViewClasses ()Ljava/util/Set; public fun getOnErrorSampleRate ()Ljava/lang/Double; public fun getQuality ()Lio/sentry/SentryReplayOptions$SentryReplayQuality; - public fun getRedactViewClasses ()Ljava/util/Set; public fun getSessionDuration ()J public fun getSessionSampleRate ()Ljava/lang/Double; public fun getSessionSegmentDuration ()J + public fun getUnmaskViewClasses ()Ljava/util/Set; public fun isSessionReplayEnabled ()Z public fun isSessionReplayForErrorsEnabled ()Z + public fun setMaskAllImages (Z)V + public fun setMaskAllText (Z)V public fun setOnErrorSampleRate (Ljava/lang/Double;)V public fun setQuality (Lio/sentry/SentryReplayOptions$SentryReplayQuality;)V - public fun setRedactAllImages (Z)V - public fun setRedactAllText (Z)V public fun setSessionSampleRate (Ljava/lang/Double;)V } diff --git a/sentry/src/main/java/io/sentry/SentryReplayOptions.java b/sentry/src/main/java/io/sentry/SentryReplayOptions.java index 097f72c921..0c99085726 100644 --- a/sentry/src/main/java/io/sentry/SentryReplayOptions.java +++ b/sentry/src/main/java/io/sentry/SentryReplayOptions.java @@ -11,6 +11,12 @@ public final class SentryReplayOptions { public static final String TEXT_VIEW_CLASS_NAME = "android.widget.TextView"; public static final String IMAGE_VIEW_CLASS_NAME = "android.widget.ImageView"; + public static final String WEB_VIEW_CLASS_NAME = "android.webkit.WebView"; + public static final String VIDEO_VIEW_CLASS_NAME = "android.widget.VideoView"; + public static final String ANDROIDX_MEDIA_VIEW_CLASS_NAME = "androidx.media3.ui.PlayerView"; + public static final String EXOPLAYER_CLASS_NAME = "com.google.android.exoplayer2.ui.PlayerView"; + public static final String EXOPLAYER_STYLED_CLASS_NAME = + "com.google.android.exoplayer2.ui.StyledPlayerView"; public enum SentryReplayQuality { /** Video Scale: 80% Bit Rate: 50.000 */ @@ -52,19 +58,19 @@ public enum SentryReplayQuality { private @Nullable Double onErrorSampleRate; /** - * Redact all views with the specified class names. The class name is the fully qualified class - * name of the view, e.g. android.widget.TextView. The subclasses of the specified classes will be - * redacted as well. + * Mask all views with the specified class names. The class name is the fully qualified class name + * of the view, e.g. android.widget.TextView. The subclasses of the specified classes will be + * masked as well. * *

If you're using an obfuscation tool, make sure to add the respective proguard rules to keep * the class names. * *

Default is empty. */ - private Set redactViewClasses = new CopyOnWriteArraySet<>(); + private Set maskViewClasses = new CopyOnWriteArraySet<>(); /** - * Ignore all views with the specified class names from redaction. The class name is the fully + * Ignore all views with the specified class names from masking. The class name is the fully * qualified class name of the view, e.g. android.widget.TextView. The subclasses of the specified * classes will be ignored as well. * @@ -73,7 +79,7 @@ public enum SentryReplayQuality { * *

Default is empty. */ - private Set ignoreViewClasses = new CopyOnWriteArraySet<>(); + private Set unmaskViewClasses = new CopyOnWriteArraySet<>(); /** * Defines the quality of the session replay. The higher the quality, the more accurate the replay @@ -98,8 +104,13 @@ public enum SentryReplayQuality { public SentryReplayOptions(final boolean empty) { if (!empty) { - setRedactAllText(true); - setRedactAllImages(true); + setMaskAllText(true); + setMaskAllImages(true); + maskViewClasses.add(WEB_VIEW_CLASS_NAME); + maskViewClasses.add(VIDEO_VIEW_CLASS_NAME); + maskViewClasses.add(ANDROIDX_MEDIA_VIEW_CLASS_NAME); + maskViewClasses.add(EXOPLAYER_CLASS_NAME); + maskViewClasses.add(EXOPLAYER_STYLED_CLASS_NAME); } } @@ -149,55 +160,55 @@ public void setSessionSampleRate(final @Nullable Double sessionSampleRate) { } /** - * Redact all text content. Draws a rectangle of text bounds with text color on top. By default - * only views extending TextView are redacted. + * Mask all text content. Draws a rectangle of text bounds with text color on top. By default only + * views extending TextView are masked. * *

Default is enabled. */ - public void setRedactAllText(final boolean redactAllText) { - if (redactAllText) { - addRedactViewClass(TEXT_VIEW_CLASS_NAME); - ignoreViewClasses.remove(TEXT_VIEW_CLASS_NAME); + public void setMaskAllText(final boolean maskAllText) { + if (maskAllText) { + addMaskViewClass(TEXT_VIEW_CLASS_NAME); + unmaskViewClasses.remove(TEXT_VIEW_CLASS_NAME); } else { - addIgnoreViewClass(TEXT_VIEW_CLASS_NAME); - redactViewClasses.remove(TEXT_VIEW_CLASS_NAME); + addUnmaskViewClass(TEXT_VIEW_CLASS_NAME); + maskViewClasses.remove(TEXT_VIEW_CLASS_NAME); } } /** - * Redact all image content. Draws a rectangle of image bounds with image's dominant color on top. + * Mask all image content. Draws a rectangle of image bounds with image's dominant color on top. * By default only views extending ImageView with BitmapDrawable or custom Drawable type are - * redacted. ColorDrawable, InsetDrawable, VectorDrawable are all considered non-PII, as they come + * masked. ColorDrawable, InsetDrawable, VectorDrawable are all considered non-PII, as they come * from the apk. * *

Default is enabled. */ - public void setRedactAllImages(final boolean redactAllImages) { - if (redactAllImages) { - addRedactViewClass(IMAGE_VIEW_CLASS_NAME); - ignoreViewClasses.remove(IMAGE_VIEW_CLASS_NAME); + public void setMaskAllImages(final boolean maskAllImages) { + if (maskAllImages) { + addMaskViewClass(IMAGE_VIEW_CLASS_NAME); + unmaskViewClasses.remove(IMAGE_VIEW_CLASS_NAME); } else { - addIgnoreViewClass(IMAGE_VIEW_CLASS_NAME); - redactViewClasses.remove(IMAGE_VIEW_CLASS_NAME); + addUnmaskViewClass(IMAGE_VIEW_CLASS_NAME); + maskViewClasses.remove(IMAGE_VIEW_CLASS_NAME); } } @NotNull - public Set getRedactViewClasses() { - return this.redactViewClasses; + public Set getMaskViewClasses() { + return this.maskViewClasses; } - public void addRedactViewClass(final @NotNull String className) { - this.redactViewClasses.add(className); + public void addMaskViewClass(final @NotNull String className) { + this.maskViewClasses.add(className); } @NotNull - public Set getIgnoreViewClasses() { - return this.ignoreViewClasses; + public Set getUnmaskViewClasses() { + return this.unmaskViewClasses; } - public void addIgnoreViewClass(final @NotNull String className) { - this.ignoreViewClasses.add(className); + public void addUnmaskViewClass(final @NotNull String className) { + this.unmaskViewClasses.add(className); } @ApiStatus.Internal From f74af4a8271a05cad169d47f05b801f90446dbdb Mon Sep 17 00:00:00 2001 From: getsentry-bot Date: Wed, 9 Oct 2024 16:29:05 +0000 Subject: [PATCH 46/92] release: 7.15.0 --- CHANGELOG.md | 2 +- gradle.properties | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ce2bd2cf4e..3037df38d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## Unreleased +## 7.15.0 ### Features diff --git a/gradle.properties b/gradle.properties index 514c0500b4..170db6c944 100644 --- a/gradle.properties +++ b/gradle.properties @@ -10,7 +10,7 @@ android.useAndroidX=true android.defaults.buildfeatures.buildconfig=true # Release information -versionName=7.14.0 +versionName=7.15.0 # Override the SDK name on native crashes on Android sentryAndroidSdkName=sentry.native.android From f79c9c10fa6dd6663639c38ea94bc71374d955a3 Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Thu, 10 Oct 2024 08:51:07 +0200 Subject: [PATCH 47/92] Deprecate `enableTracing` in v7 (#3777) * deprecate enableTracing * changelog --- CHANGELOG.md | 6 ++++++ .../io/sentry/android/core/ManifestMetadataReader.java | 2 +- sentry/src/main/java/io/sentry/SentryOptions.java | 9 ++++++++- sentry/src/main/java/io/sentry/TracesSampler.java | 1 + 4 files changed, 16 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3037df38d5..f6db963eb3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +### Fixes + +- Deprecate `enableTracing` option ([#3777](https://github.com/getsentry/sentry-java/pull/3777)) + ## 7.15.0 ### Features diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java b/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java index 96d54d98de..e42f68cab0 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java @@ -56,7 +56,7 @@ final class ManifestMetadataReader { static final String UNCAUGHT_EXCEPTION_HANDLER_ENABLE = "io.sentry.uncaught-exception-handler.enable"; - static final String TRACING_ENABLE = "io.sentry.traces.enable"; + @Deprecated static final String TRACING_ENABLE = "io.sentry.traces.enable"; static final String TRACES_SAMPLE_RATE = "io.sentry.traces.sample-rate"; static final String TRACES_ACTIVITY_ENABLE = "io.sentry.traces.activity.enable"; static final String TRACES_ACTIVITY_AUTO_FINISH_ENABLE = diff --git a/sentry/src/main/java/io/sentry/SentryOptions.java b/sentry/src/main/java/io/sentry/SentryOptions.java index 3873bc9380..0eb3bace91 100644 --- a/sentry/src/main/java/io/sentry/SentryOptions.java +++ b/sentry/src/main/java/io/sentry/SentryOptions.java @@ -913,14 +913,21 @@ public void setSampleRate(Double sampleRate) { *

NOTE: There is also {@link SentryOptions#isTracingEnabled()} which checks other options as * well. * + * @deprecated We're removing enableTracing in 8.0 * @return true if enabled, false if disabled, null can mean enabled if {@link * SentryOptions#getTracesSampleRate()} or {@link SentryOptions#getTracesSampler()} are set. */ + @Deprecated public @Nullable Boolean getEnableTracing() { return enableTracing; } - /** Enables generation of transactions and propagation of trace data. */ + /** + * Enables generation of transactions and propagation of trace data. + * + * @deprecated We're removing enableTracing in 8.0 + */ + @Deprecated public void setEnableTracing(@Nullable Boolean enableTracing) { this.enableTracing = enableTracing; } diff --git a/sentry/src/main/java/io/sentry/TracesSampler.java b/sentry/src/main/java/io/sentry/TracesSampler.java index 3b83a815cf..e0ce111037 100644 --- a/sentry/src/main/java/io/sentry/TracesSampler.java +++ b/sentry/src/main/java/io/sentry/TracesSampler.java @@ -22,6 +22,7 @@ public TracesSampler(final @NotNull SentryOptions options) { this.random = random; } + @SuppressWarnings("deprecation") @NotNull TracesSamplingDecision sample(final @NotNull SamplingContext samplingContext) { final TracesSamplingDecision samplingContextSamplingDecision = From 2ab34ebca967b5bdac6e49a3b12f4b7e982b72be Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Mon, 14 Oct 2024 09:00:54 +0200 Subject: [PATCH 48/92] [QA] Replace SecureRandom with vendored Random (#3783) * Replace SecureRandom with vendored Random * Changelog * Fix failing test --- CHANGELOG.md | 1 + .../android/core/AnrV2EventProcessor.java | 8 +- .../android/replay/ReplayIntegration.kt | 4 +- .../replay/capture/BufferCaptureStrategy.kt | 4 +- .../io/sentry/android/replay/util/Sampling.kt | 4 +- .../capture/BufferCaptureStrategyTest.kt | 4 +- sentry/api/sentry.api | 13 + .../src/main/java/io/sentry/SentryClient.java | 6 +- .../main/java/io/sentry/TracesSampler.java | 8 +- .../sentry/cache/PersistingScopeObserver.java | 6 + .../java/io/sentry/metrics/MetricsHelper.java | 4 +- .../src/main/java/io/sentry/util/Random.java | 466 ++++++++++++++++++ .../test/java/io/sentry/TracesSamplerTest.kt | 4 +- 13 files changed, 509 insertions(+), 23 deletions(-) create mode 100644 sentry/src/main/java/io/sentry/util/Random.java diff --git a/CHANGELOG.md b/CHANGELOG.md index f6db963eb3..84040594a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### Fixes - Deprecate `enableTracing` option ([#3777](https://github.com/getsentry/sentry-java/pull/3777)) +- Vendor `java.util.Random` and replace `java.security.SecureRandom` usages ([#3783](https://github.com/getsentry/sentry-java/pull/3783)) ## 7.15.0 diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2EventProcessor.java b/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2EventProcessor.java index d58d04b7f8..0399b634fb 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2EventProcessor.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2EventProcessor.java @@ -54,8 +54,8 @@ import io.sentry.protocol.SentryTransaction; import io.sentry.protocol.User; import io.sentry.util.HintUtils; +import io.sentry.util.Random; import java.io.File; -import java.security.SecureRandom; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; @@ -83,7 +83,7 @@ public final class AnrV2EventProcessor implements BackfillingEventProcessor { private final @NotNull SentryExceptionFactory sentryExceptionFactory; - private final @Nullable SecureRandom random; + private final @Nullable Random random; public AnrV2EventProcessor( final @NotNull Context context, @@ -96,7 +96,7 @@ public AnrV2EventProcessor( final @NotNull Context context, final @NotNull SentryAndroidOptions options, final @NotNull BuildInfoProvider buildInfoProvider, - final @Nullable SecureRandom random) { + final @Nullable Random random) { this.context = ContextUtils.getApplicationContext(context); this.options = options; this.buildInfoProvider = buildInfoProvider; @@ -180,7 +180,7 @@ private boolean sampleReplay(final @NotNull SentryEvent event) { try { // we have to sample here with the old sample rate, because it may change between app launches - final @NotNull SecureRandom random = this.random != null ? this.random : new SecureRandom(); + final @NotNull Random random = this.random != null ? this.random : new Random(); final double replayErrorSampleRateDouble = Double.parseDouble(replayErrorSampleRate); if (replayErrorSampleRateDouble < random.nextDouble()) { options diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt index 82d20cf3c1..ee9223cc80 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt @@ -35,9 +35,9 @@ import io.sentry.transport.ICurrentDateProvider import io.sentry.util.FileUtils import io.sentry.util.HintUtils import io.sentry.util.IntegrationUtils.addIntegrationToSdkVersion +import io.sentry.util.Random import java.io.Closeable import java.io.File -import java.security.SecureRandom import java.util.LinkedList import java.util.concurrent.atomic.AtomicBoolean import kotlin.LazyThreadSafetyMode.NONE @@ -78,7 +78,7 @@ public class ReplayIntegration( private var hub: IHub? = null private var recorder: Recorder? = null private var gestureRecorder: GestureRecorder? = null - private val random by lazy { SecureRandom() } + private val random by lazy { Random() } private val rootViewsSpy by lazy(NONE) { RootViewsSpy.install() } // TODO: probably not everything has to be thread-safe here diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BufferCaptureStrategy.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BufferCaptureStrategy.kt index 247888f47d..2dbb2a1746 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BufferCaptureStrategy.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BufferCaptureStrategy.kt @@ -18,8 +18,8 @@ import io.sentry.android.replay.util.submitSafely import io.sentry.protocol.SentryId import io.sentry.transport.ICurrentDateProvider import io.sentry.util.FileUtils +import io.sentry.util.Random import java.io.File -import java.security.SecureRandom import java.util.Date import java.util.concurrent.ScheduledExecutorService @@ -27,7 +27,7 @@ internal class BufferCaptureStrategy( private val options: SentryOptions, private val hub: IHub?, private val dateProvider: ICurrentDateProvider, - private val random: SecureRandom, + private val random: Random, executor: ScheduledExecutorService? = null, replayCacheProvider: ((replayId: SentryId, recorderConfig: ScreenshotRecorderConfig) -> ReplayCache)? = null ) : BaseCaptureStrategy(options, hub, dateProvider, executor = executor, replayCacheProvider = replayCacheProvider) { diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Sampling.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Sampling.kt index 8acb6b00a6..5ec46ea962 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Sampling.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Sampling.kt @@ -1,8 +1,8 @@ package io.sentry.android.replay.util -import java.security.SecureRandom +import io.sentry.util.Random -internal fun SecureRandom.sample(rate: Double?): Boolean { +internal fun Random.sample(rate: Double?): Boolean { if (rate != null) { return !(rate < this.nextDouble()) // bad luck } diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/BufferCaptureStrategyTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/BufferCaptureStrategyTest.kt index 337ba525fc..625306cb8e 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/BufferCaptureStrategyTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/BufferCaptureStrategyTest.kt @@ -20,6 +20,7 @@ import io.sentry.android.replay.capture.BufferCaptureStrategyTest.Fixture.Compan import io.sentry.protocol.SentryId import io.sentry.transport.CurrentDateProvider import io.sentry.transport.ICurrentDateProvider +import io.sentry.util.Random import org.awaitility.kotlin.await import org.junit.Rule import org.junit.rules.TemporaryFolder @@ -35,7 +36,6 @@ import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import java.io.File -import java.security.SecureRandom import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -97,7 +97,7 @@ class BufferCaptureStrategyTest { options, hub, dateProvider, - SecureRandom(), + Random(), mock { doAnswer { invocation -> (invocation.arguments[0] as Runnable).run() diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index ac1eb2bc8a..89684c7ae5 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -5808,6 +5808,19 @@ public final class io/sentry/util/PropagationTargetsUtils { public static fun contain (Ljava/util/List;Ljava/net/URI;)Z } +public final class io/sentry/util/Random : java/io/Serializable { + public fun ()V + public fun (J)V + public fun nextBoolean ()Z + public fun nextBytes ([B)V + public fun nextDouble ()D + public fun nextFloat ()F + public fun nextInt ()I + public fun nextInt (I)I + public fun nextLong ()J + public fun setSeed (J)V +} + public final class io/sentry/util/SampleRateUtils { public fun ()V public static fun isValidProfilesSampleRate (Ljava/lang/Double;)Z diff --git a/sentry/src/main/java/io/sentry/SentryClient.java b/sentry/src/main/java/io/sentry/SentryClient.java index 6868894340..27529d100b 100644 --- a/sentry/src/main/java/io/sentry/SentryClient.java +++ b/sentry/src/main/java/io/sentry/SentryClient.java @@ -17,10 +17,10 @@ import io.sentry.util.CheckInUtils; import io.sentry.util.HintUtils; import io.sentry.util.Objects; +import io.sentry.util.Random; import io.sentry.util.TracingUtils; import java.io.Closeable; import java.io.IOException; -import java.security.SecureRandom; import java.util.ArrayList; import java.util.Collection; import java.util.Collections; @@ -40,7 +40,7 @@ public final class SentryClient implements ISentryClient, IMetricsClient { private final @NotNull SentryOptions options; private final @NotNull ITransport transport; - private final @Nullable SecureRandom random; + private final @Nullable Random random; private final @NotNull SortBreadcrumbsByDate sortBreadcrumbsByDate = new SortBreadcrumbsByDate(); private final @NotNull IMetricsAggregator metricsAggregator; @@ -67,7 +67,7 @@ public boolean isEnabled() { ? new MetricsAggregator(options, this) : NoopMetricsAggregator.getInstance(); - this.random = options.getSampleRate() == null ? null : new SecureRandom(); + this.random = options.getSampleRate() == null ? null : new Random(); } private boolean shouldApplyScopeData( diff --git a/sentry/src/main/java/io/sentry/TracesSampler.java b/sentry/src/main/java/io/sentry/TracesSampler.java index e0ce111037..5e5b808333 100644 --- a/sentry/src/main/java/io/sentry/TracesSampler.java +++ b/sentry/src/main/java/io/sentry/TracesSampler.java @@ -1,7 +1,7 @@ package io.sentry; import io.sentry.util.Objects; -import java.security.SecureRandom; +import io.sentry.util.Random; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import org.jetbrains.annotations.TestOnly; @@ -10,14 +10,14 @@ final class TracesSampler { private static final @NotNull Double DEFAULT_TRACES_SAMPLE_RATE = 1.0; private final @NotNull SentryOptions options; - private final @NotNull SecureRandom random; + private final @NotNull Random random; public TracesSampler(final @NotNull SentryOptions options) { - this(Objects.requireNonNull(options, "options are required"), new SecureRandom()); + this(Objects.requireNonNull(options, "options are required"), new Random()); } @TestOnly - TracesSampler(final @NotNull SentryOptions options, final @NotNull SecureRandom random) { + TracesSampler(final @NotNull SentryOptions options, final @NotNull Random random) { this.options = options; this.random = random; } diff --git a/sentry/src/main/java/io/sentry/cache/PersistingScopeObserver.java b/sentry/src/main/java/io/sentry/cache/PersistingScopeObserver.java index 7c186cf99d..908e2c66e4 100644 --- a/sentry/src/main/java/io/sentry/cache/PersistingScopeObserver.java +++ b/sentry/src/main/java/io/sentry/cache/PersistingScopeObserver.java @@ -133,6 +133,12 @@ public void setReplayId(@NotNull SentryId replayId) { @SuppressWarnings("FutureReturnValueIgnored") private void serializeToDisk(final @NotNull Runnable task) { + if (Thread.currentThread().getName().contains("SentryExecutor")) { + // we're already on the sentry executor thread, so we can just execute it directly + task.run(); + return; + } + try { options .getExecutorService() diff --git a/sentry/src/main/java/io/sentry/metrics/MetricsHelper.java b/sentry/src/main/java/io/sentry/metrics/MetricsHelper.java index c4353c53a3..37fd4523a4 100644 --- a/sentry/src/main/java/io/sentry/metrics/MetricsHelper.java +++ b/sentry/src/main/java/io/sentry/metrics/MetricsHelper.java @@ -1,7 +1,7 @@ package io.sentry.metrics; import io.sentry.MeasurementUnit; -import java.security.SecureRandom; +import io.sentry.util.Random; import java.util.Collection; import java.util.Collections; import java.util.HashMap; @@ -27,7 +27,7 @@ public final class MetricsHelper { private static final char TAGS_ESCAPE_CHAR = '\\'; private static long FLUSH_SHIFT_MS = - (long) (new SecureRandom().nextFloat() * (ROLLUP_IN_SECONDS * 1000f)); + (long) (new Random().nextFloat() * (ROLLUP_IN_SECONDS * 1000f)); public static long getTimeBucketKey(final long timestampMs) { final long seconds = timestampMs / 1000; diff --git a/sentry/src/main/java/io/sentry/util/Random.java b/sentry/src/main/java/io/sentry/util/Random.java new file mode 100644 index 0000000000..cbd81824df --- /dev/null +++ b/sentry/src/main/java/io/sentry/util/Random.java @@ -0,0 +1,466 @@ +/* + * Copyright (c) 1995, 2013, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ + +package io.sentry.util; + +import java.util.concurrent.atomic.AtomicLong; +import org.jetbrains.annotations.ApiStatus; + +/** + * A simplified version of {@link java.util.Random} that we use for sampling, which is much faster + * than {@link java.security.SecureRandom}. This is necessary so that some security tools do not + * flag our Random usage as potentially insecure. + */ +@ApiStatus.Internal +public final class Random implements java.io.Serializable { + /** use serialVersionUID from JDK 1.1 for interoperability */ + private static final long serialVersionUID = 3905348978240129619L; + + /** + * The internal state associated with this pseudorandom number generator. (The specs for the + * methods in this class describe the ongoing computation of this value.) + */ + private final AtomicLong seed; + + private static final long multiplier = 0x5DEECE66DL; + private static final long addend = 0xBL; + private static final long mask = (1L << 48) - 1; + + private static final double DOUBLE_UNIT = 0x1.0p-53; // 1.0 / (1L << 53) + + // IllegalArgumentException messages + static final String BadBound = "bound must be positive"; + + /** + * Creates a new random number generator. This constructor sets the seed of the random number + * generator to a value very likely to be distinct from any other invocation of this constructor. + */ + public Random() { + this(seedUniquifier() ^ System.nanoTime()); + } + + private static long seedUniquifier() { + // L'Ecuyer, "Tables of Linear Congruential Generators of + // Different Sizes and Good Lattice Structure", 1999 + for (; ; ) { + long current = seedUniquifier.get(); + long next = current * 1181783497276652981L; + if (seedUniquifier.compareAndSet(current, next)) return next; + } + } + + private static final AtomicLong seedUniquifier = new AtomicLong(8682522807148012L); + + /** + * Creates a new random number generator using a single {@code long} seed. The seed is the initial + * value of the internal state of the pseudorandom number generator which is maintained by method + * {@link #next}. + * + *

The invocation {@code new Random(seed)} is equivalent to: + * + *

{@code
+   * Random rnd = new Random();
+   * rnd.setSeed(seed);
+   * }
+ * + * @param seed the initial seed + * @see #setSeed(long) + */ + public Random(long seed) { + if (getClass() == Random.class) this.seed = new AtomicLong(initialScramble(seed)); + else { + // subclass might have overriden setSeed + this.seed = new AtomicLong(); + setSeed(seed); + } + } + + private static long initialScramble(long seed) { + return (seed ^ multiplier) & mask; + } + + /** + * Sets the seed of this random number generator using a single {@code long} seed. The general + * contract of {@code setSeed} is that it alters the state of this random number generator object + * so as to be in exactly the same state as if it had just been created with the argument {@code + * seed} as a seed. The method {@code setSeed} is implemented by class {@code Random} by + * atomically updating the seed to + * + *
{@code (seed ^ 0x5DEECE66DL) & ((1L << 48) - 1)}
+ * + *

The implementation of {@code setSeed} by class {@code Random} happens to use only 48 bits of + * the given seed. In general, however, an overriding method may use all 64 bits of the {@code + * long} argument as a seed value. + * + * @param seed the initial seed + */ + public synchronized void setSeed(long seed) { + this.seed.set(initialScramble(seed)); + } + + /** + * Generates the next pseudorandom number. Subclasses should override this, as this is used by all + * other methods. + * + *

The general contract of {@code next} is that it returns an {@code int} value and if the + * argument {@code bits} is between {@code 1} and {@code 32} (inclusive), then that many low-order + * bits of the returned value will be (approximately) independently chosen bit values, each of + * which is (approximately) equally likely to be {@code 0} or {@code 1}. The method {@code next} + * is implemented by class {@code Random} by atomically updating the seed to + * + *

{@code (seed * 0x5DEECE66DL + 0xBL) & ((1L << 48) - 1)}
+ * + * and returning + * + *
{@code (int)(seed >>> (48 - bits))}.
+ * + * This is a linear congruential pseudorandom number generator, as defined by D. H. Lehmer and + * described by Donald E. Knuth in The Art of Computer Programming, Volume 2: + * Seminumerical Algorithms, section 3.2.1. + * + * @param bits random bits + * @return the next pseudorandom value from this random number generator's sequence + * @since 1.1 + */ + private int next(int bits) { + long oldseed, nextseed; + AtomicLong seed = this.seed; + do { + oldseed = seed.get(); + nextseed = (oldseed * multiplier + addend) & mask; + } while (!seed.compareAndSet(oldseed, nextseed)); + return (int) (nextseed >>> (48 - bits)); + } + + /** + * Generates random bytes and places them into a user-supplied byte array. The number of random + * bytes produced is equal to the length of the byte array. + * + *

The method {@code nextBytes} is implemented by class {@code Random} as if by: + * + *

{@code
+   * public void nextBytes(byte[] bytes) {
+   *   for (int i = 0; i < bytes.length; )
+   *     for (int rnd = nextInt(), n = Math.min(bytes.length - i, 4);
+   *          n-- > 0; rnd >>= 8)
+   *       bytes[i++] = (byte)rnd;
+   * }
+   * }
+ * + * @param bytes the byte array to fill with random bytes + * @throws NullPointerException if the byte array is null + * @since 1.1 + */ + public void nextBytes(byte[] bytes) { + for (int i = 0, len = bytes.length; i < len; ) + for (int rnd = nextInt(), n = Math.min(len - i, Integer.SIZE / Byte.SIZE); + n-- > 0; + rnd >>= Byte.SIZE) bytes[i++] = (byte) rnd; + } + + /** + * The form of nextLong used by LongStream Spliterators. If origin is greater than bound, acts as + * unbounded form of nextLong, else as bounded form. + * + * @param origin the least value, unless greater than bound + * @param bound the upper bound (exclusive), must not equal origin + * @return a pseudorandom value + */ + final long internalNextLong(long origin, long bound) { + long r = nextLong(); + if (origin < bound) { + long n = bound - origin, m = n - 1; + if ((n & m) == 0L) // power of two + r = (r & m) + origin; + else if (n > 0L) { // reject over-represented candidates + for (long u = r >>> 1; // ensure nonnegative + u + m - (r = u % n) < 0L; // rejection check + u = nextLong() >>> 1) // retry + ; + r += origin; + } else { // range not representable as long + while (r < origin || r >= bound) r = nextLong(); + } + } + return r; + } + + /** + * The form of nextInt used by IntStream Spliterators. For the unbounded case: uses nextInt(). For + * the bounded case with representable range: uses nextInt(int bound) For the bounded case with + * unrepresentable range: uses nextInt() + * + * @param origin the least value, unless greater than bound + * @param bound the upper bound (exclusive), must not equal origin + * @return a pseudorandom value + */ + final int internalNextInt(int origin, int bound) { + if (origin < bound) { + int n = bound - origin; + if (n > 0) { + return nextInt(n) + origin; + } else { // range not representable as int + int r; + do { + r = nextInt(); + } while (r < origin || r >= bound); + return r; + } + } else { + return nextInt(); + } + } + + /** + * The form of nextDouble used by DoubleStream Spliterators. + * + * @param origin the least value, unless greater than bound + * @param bound the upper bound (exclusive), must not equal origin + * @return a pseudorandom value + */ + final double internalNextDouble(double origin, double bound) { + double r = nextDouble(); + if (origin < bound) { + r = r * (bound - origin) + origin; + if (r >= bound) // correct for rounding + r = Double.longBitsToDouble(Double.doubleToLongBits(bound) - 1); + } + return r; + } + + /** + * Returns the next pseudorandom, uniformly distributed {@code int} value from this random number + * generator's sequence. The general contract of {@code nextInt} is that one {@code int} value is + * pseudorandomly generated and returned. All 232 possible {@code int} values are + * produced with (approximately) equal probability. + * + *

The method {@code nextInt} is implemented by class {@code Random} as if by: + * + *

{@code
+   * public int nextInt() {
+   *   return next(32);
+   * }
+   * }
+ * + * @return the next pseudorandom, uniformly distributed {@code int} value from this random number + * generator's sequence + */ + public int nextInt() { + return next(32); + } + + /** + * Returns a pseudorandom, uniformly distributed {@code int} value between 0 (inclusive) and the + * specified value (exclusive), drawn from this random number generator's sequence. The general + * contract of {@code nextInt} is that one {@code int} value in the specified range is + * pseudorandomly generated and returned. All {@code bound} possible {@code int} values are + * produced with (approximately) equal probability. The method {@code nextInt(int bound)} is + * implemented by class {@code Random} as if by: + * + *
{@code
+   * public int nextInt(int bound) {
+   *   if (bound <= 0)
+   *     throw new IllegalArgumentException("bound must be positive");
+   *
+   *   if ((bound & -bound) == bound)  // i.e., bound is a power of 2
+   *     return (int)((bound * (long)next(31)) >> 31);
+   *
+   *   int bits, val;
+   *   do {
+   *       bits = next(31);
+   *       val = bits % bound;
+   *   } while (bits - val + (bound-1) < 0);
+   *   return val;
+   * }
+   * }
+ * + *

The hedge "approximately" is used in the foregoing description only because the next method + * is only approximately an unbiased source of independently chosen bits. If it were a perfect + * source of randomly chosen bits, then the algorithm shown would choose {@code int} values from + * the stated range with perfect uniformity. + * + *

The algorithm is slightly tricky. It rejects values that would result in an uneven + * distribution (due to the fact that 2^31 is not divisible by n). The probability of a value + * being rejected depends on n. The worst case is n=2^30+1, for which the probability of a reject + * is 1/2, and the expected number of iterations before the loop terminates is 2. + * + *

The algorithm treats the case where n is a power of two specially: it returns the correct + * number of high-order bits from the underlying pseudo-random number generator. In the absence of + * special treatment, the correct number of low-order bits would be returned. Linear + * congruential pseudo-random number generators such as the one implemented by this class are + * known to have short periods in the sequence of values of their low-order bits. Thus, this + * special case greatly increases the length of the sequence of values returned by successive + * calls to this method if n is a small power of two. + * + * @param bound the upper bound (exclusive). Must be positive. + * @return the next pseudorandom, uniformly distributed {@code int} value between zero (inclusive) + * and {@code bound} (exclusive) from this random number generator's sequence + * @throws IllegalArgumentException if bound is not positive + * @since 1.2 + */ + public int nextInt(int bound) { + if (bound <= 0) throw new IllegalArgumentException(BadBound); + + int r = next(31); + int m = bound - 1; + if ((bound & m) == 0) // i.e., bound is a power of 2 + r = (int) ((bound * (long) r) >> 31); + else { + for (int u = r; u - (r = u % bound) + m < 0; u = next(31)) + ; + } + return r; + } + + /** + * Returns the next pseudorandom, uniformly distributed {@code long} value from this random number + * generator's sequence. The general contract of {@code nextLong} is that one {@code long} value + * is pseudorandomly generated and returned. + * + *

The method {@code nextLong} is implemented by class {@code Random} as if by: + * + *

{@code
+   * public long nextLong() {
+   *   return ((long)next(32) << 32) + next(32);
+   * }
+   * }
+ * + * Because class {@code Random} uses a seed with only 48 bits, this algorithm will not return all + * possible {@code long} values. + * + * @return the next pseudorandom, uniformly distributed {@code long} value from this random number + * generator's sequence + */ + @SuppressWarnings("UnnecessaryParentheses") + public long nextLong() { + // it's okay that the bottom word remains signed. + return ((long) (next(32)) << 32) + next(32); + } + + /** + * Returns the next pseudorandom, uniformly distributed {@code boolean} value from this random + * number generator's sequence. The general contract of {@code nextBoolean} is that one {@code + * boolean} value is pseudorandomly generated and returned. The values {@code true} and {@code + * false} are produced with (approximately) equal probability. + * + *

The method {@code nextBoolean} is implemented by class {@code Random} as if by: + * + *

{@code
+   * public boolean nextBoolean() {
+   *   return next(1) != 0;
+   * }
+   * }
+ * + * @return the next pseudorandom, uniformly distributed {@code boolean} value from this random + * number generator's sequence + * @since 1.2 + */ + public boolean nextBoolean() { + return next(1) != 0; + } + + /** + * Returns the next pseudorandom, uniformly distributed {@code float} value between {@code 0.0} + * and {@code 1.0} from this random number generator's sequence. + * + *

The general contract of {@code nextFloat} is that one {@code float} value, chosen + * (approximately) uniformly from the range {@code 0.0f} (inclusive) to {@code 1.0f} (exclusive), + * is pseudorandomly generated and returned. All 224 possible {@code float} values of + * the form m x 2-24, where m is a positive integer less than + * 224, are produced with (approximately) equal probability. + * + *

The method {@code nextFloat} is implemented by class {@code Random} as if by: + * + *

{@code
+   * public float nextFloat() {
+   *   return next(24) / ((float)(1 << 24));
+   * }
+   * }
+ * + *

The hedge "approximately" is used in the foregoing description only because the next method + * is only approximately an unbiased source of independently chosen bits. If it were a perfect + * source of randomly chosen bits, then the algorithm shown would choose {@code float} values from + * the stated range with perfect uniformity. + * + *

[In early versions of Java, the result was incorrectly calculated as: + * + *

{@code
+   * return next(30) / ((float)(1 << 30));
+   * }
+ * + * This might seem to be equivalent, if not better, but in fact it introduced a slight + * nonuniformity because of the bias in the rounding of floating-point numbers: it was slightly + * more likely that the low-order bit of the significand would be 0 than that it would be 1.] + * + * @return the next pseudorandom, uniformly distributed {@code float} value between {@code 0.0} + * and {@code 1.0} from this random number generator's sequence + */ + public float nextFloat() { + return next(24) / ((float) (1 << 24)); + } + + /** + * Returns the next pseudorandom, uniformly distributed {@code double} value between {@code 0.0} + * and {@code 1.0} from this random number generator's sequence. + * + *

The general contract of {@code nextDouble} is that one {@code double} value, chosen + * (approximately) uniformly from the range {@code 0.0d} (inclusive) to {@code 1.0d} (exclusive), + * is pseudorandomly generated and returned. + * + *

The method {@code nextDouble} is implemented by class {@code Random} as if by: + * + *

{@code
+   * public double nextDouble() {
+   *   return (((long)next(26) << 27) + next(27))
+   *     / (double)(1L << 53);
+   * }
+   * }
+ * + *

The hedge "approximately" is used in the foregoing description only because the {@code next} + * method is only approximately an unbiased source of independently chosen bits. If it were a + * perfect source of randomly chosen bits, then the algorithm shown would choose {@code double} + * values from the stated range with perfect uniformity. + * + *

[In early versions of Java, the result was incorrectly calculated as: + * + *

{@code
+   * return (((long)next(27) << 27) + next(27))
+   *   / (double)(1L << 54);
+   * }
+ * + * This might seem to be equivalent, if not better, but in fact it introduced a large + * nonuniformity because of the bias in the rounding of floating-point numbers: it was three times + * as likely that the low-order bit of the significand would be 0 than that it would be 1! This + * nonuniformity probably doesn't matter much in practice, but we strive for perfection.] + * + * @return the next pseudorandom, uniformly distributed {@code double} value between {@code 0.0} + * and {@code 1.0} from this random number generator's sequence + * @see Math#random + */ + @SuppressWarnings("UnnecessaryParentheses") + public double nextDouble() { + return (((long) (next(26)) << 27) + next(27)) * DOUBLE_UNIT; + } +} diff --git a/sentry/src/test/java/io/sentry/TracesSamplerTest.kt b/sentry/src/test/java/io/sentry/TracesSamplerTest.kt index 11d58766d6..52e294c54d 100644 --- a/sentry/src/test/java/io/sentry/TracesSamplerTest.kt +++ b/sentry/src/test/java/io/sentry/TracesSamplerTest.kt @@ -1,11 +1,11 @@ package io.sentry +import io.sentry.util.Random import org.mockito.kotlin.any import org.mockito.kotlin.eq import org.mockito.kotlin.mock import org.mockito.kotlin.verify import org.mockito.kotlin.whenever -import java.security.SecureRandom import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -23,7 +23,7 @@ class TracesSamplerTest { profilesSamplerCallback: SentryOptions.ProfilesSamplerCallback? = null, logger: ILogger? = null ): TracesSampler { - val random = mock() + val random = mock() if (randomResult != null) { whenever(random.nextDouble()).thenReturn(randomResult) } From a9c767ee938ced7114056047fc5e2ebe0e9fe6e1 Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Mon, 14 Oct 2024 13:32:51 +0200 Subject: [PATCH 49/92] [QA] Move NDK scope sync to background thread (#3754) * Move NDK scope sync to background thread * Update Changelog * Cleanup code --- CHANGELOG.md | 1 + sentry-android-ndk/build.gradle.kts | 2 +- .../sentry/android/ndk/NdkScopeObserver.java | 70 ++++++++++++------- .../android/ndk/NdkScopeObserverTest.kt | 38 ++++++++++ 4 files changed, 84 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 84040594a6..a18600c608 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - Deprecate `enableTracing` option ([#3777](https://github.com/getsentry/sentry-java/pull/3777)) - Vendor `java.util.Random` and replace `java.security.SecureRandom` usages ([#3783](https://github.com/getsentry/sentry-java/pull/3783)) +- Fix potential ANRs due to NDK scope sync ([#3754](https://github.com/getsentry/sentry-java/pull/3754)) ## 7.15.0 diff --git a/sentry-android-ndk/build.gradle.kts b/sentry-android-ndk/build.gradle.kts index f6564cd97f..f1c4873053 100644 --- a/sentry-android-ndk/build.gradle.kts +++ b/sentry-android-ndk/build.gradle.kts @@ -105,6 +105,6 @@ dependencies { testImplementation(kotlin(Config.kotlinStdLib, KotlinCompilerVersion.VERSION)) testImplementation(Config.TestLibs.kotlinTestJunit) - testImplementation(Config.TestLibs.mockitoKotlin) + testImplementation(projects.sentryTestSupport) } diff --git a/sentry-android-ndk/src/main/java/io/sentry/android/ndk/NdkScopeObserver.java b/sentry-android-ndk/src/main/java/io/sentry/android/ndk/NdkScopeObserver.java index 009bba9b81..2350c419be 100644 --- a/sentry-android-ndk/src/main/java/io/sentry/android/ndk/NdkScopeObserver.java +++ b/sentry-android-ndk/src/main/java/io/sentry/android/ndk/NdkScopeObserver.java @@ -31,12 +31,18 @@ public NdkScopeObserver(final @NotNull SentryOptions options) { @Override public void setUser(final @Nullable User user) { try { - if (user == null) { - // remove user if its null - nativeScope.removeUser(); - } else { - nativeScope.setUser(user.getId(), user.getEmail(), user.getIpAddress(), user.getUsername()); - } + options + .getExecutorService() + .submit( + () -> { + if (user == null) { + // remove user if its null + nativeScope.removeUser(); + } else { + nativeScope.setUser( + user.getId(), user.getEmail(), user.getIpAddress(), user.getUsername()); + } + }); } catch (Throwable e) { options.getLogger().log(SentryLevel.ERROR, e, "Scope sync setUser has an error."); } @@ -45,24 +51,36 @@ public void setUser(final @Nullable User user) { @Override public void addBreadcrumb(final @NotNull Breadcrumb crumb) { try { - String level = null; - if (crumb.getLevel() != null) { - level = crumb.getLevel().name().toLowerCase(Locale.ROOT); - } - final String timestamp = DateUtils.getTimestamp(crumb.getTimestamp()); + options + .getExecutorService() + .submit( + () -> { + String level = null; + if (crumb.getLevel() != null) { + level = crumb.getLevel().name().toLowerCase(Locale.ROOT); + } + final String timestamp = DateUtils.getTimestamp(crumb.getTimestamp()); - String data = null; - try { - final Map dataRef = crumb.getData(); - if (!dataRef.isEmpty()) { - data = options.getSerializer().serialize(dataRef); - } - } catch (Throwable e) { - options.getLogger().log(SentryLevel.ERROR, e, "Breadcrumb data is not serializable."); - } + String data = null; + try { + final Map dataRef = crumb.getData(); + if (!dataRef.isEmpty()) { + data = options.getSerializer().serialize(dataRef); + } + } catch (Throwable e) { + options + .getLogger() + .log(SentryLevel.ERROR, e, "Breadcrumb data is not serializable."); + } - nativeScope.addBreadcrumb( - level, crumb.getMessage(), crumb.getCategory(), crumb.getType(), timestamp, data); + nativeScope.addBreadcrumb( + level, + crumb.getMessage(), + crumb.getCategory(), + crumb.getType(), + timestamp, + data); + }); } catch (Throwable e) { options.getLogger().log(SentryLevel.ERROR, e, "Scope sync addBreadcrumb has an error."); } @@ -71,7 +89,7 @@ public void addBreadcrumb(final @NotNull Breadcrumb crumb) { @Override public void setTag(final @NotNull String key, final @NotNull String value) { try { - nativeScope.setTag(key, value); + options.getExecutorService().submit(() -> nativeScope.setTag(key, value)); } catch (Throwable e) { options.getLogger().log(SentryLevel.ERROR, e, "Scope sync setTag(%s) has an error.", key); } @@ -80,7 +98,7 @@ public void setTag(final @NotNull String key, final @NotNull String value) { @Override public void removeTag(final @NotNull String key) { try { - nativeScope.removeTag(key); + options.getExecutorService().submit(() -> nativeScope.removeTag(key)); } catch (Throwable e) { options.getLogger().log(SentryLevel.ERROR, e, "Scope sync removeTag(%s) has an error.", key); } @@ -89,7 +107,7 @@ public void removeTag(final @NotNull String key) { @Override public void setExtra(final @NotNull String key, final @NotNull String value) { try { - nativeScope.setExtra(key, value); + options.getExecutorService().submit(() -> nativeScope.setExtra(key, value)); } catch (Throwable e) { options.getLogger().log(SentryLevel.ERROR, e, "Scope sync setExtra(%s) has an error.", key); } @@ -98,7 +116,7 @@ public void setExtra(final @NotNull String key, final @NotNull String value) { @Override public void removeExtra(final @NotNull String key) { try { - nativeScope.removeExtra(key); + options.getExecutorService().submit(() -> nativeScope.removeExtra(key)); } catch (Throwable e) { options .getLogger() diff --git a/sentry-android-ndk/src/test/java/io/sentry/android/ndk/NdkScopeObserverTest.kt b/sentry-android-ndk/src/test/java/io/sentry/android/ndk/NdkScopeObserverTest.kt index 335a7679e1..110f794cf9 100644 --- a/sentry-android-ndk/src/test/java/io/sentry/android/ndk/NdkScopeObserverTest.kt +++ b/sentry-android-ndk/src/test/java/io/sentry/android/ndk/NdkScopeObserverTest.kt @@ -6,8 +6,13 @@ import io.sentry.JsonSerializer import io.sentry.SentryLevel import io.sentry.SentryOptions import io.sentry.protocol.User +import io.sentry.test.DeferredExecutorService +import io.sentry.test.ImmediateExecutorService +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.eq import org.mockito.kotlin.mock +import org.mockito.kotlin.never import org.mockito.kotlin.verify import kotlin.test.Test @@ -17,6 +22,7 @@ class NdkScopeObserverTest { val nativeScope = mock() val options = SentryOptions().apply { setSerializer(JsonSerializer(mock())) + executorService = ImmediateExecutorService() } fun getSut(): NdkScopeObserver { @@ -111,4 +117,36 @@ class NdkScopeObserverTest { eq(data) ) } + + @Test + fun `scope sync utilizes executor service`() { + val executorService = DeferredExecutorService() + fixture.options.executorService = executorService + val sut = fixture.getSut() + + sut.setTag("a", "b") + sut.removeTag("a") + sut.setExtra("a", "b") + sut.removeExtra("a") + sut.setUser(User()) + sut.addBreadcrumb(Breadcrumb()) + + // as long as the executor service is not run, the scope sync is not called + verify(fixture.nativeScope, never()).setTag(any(), any()) + verify(fixture.nativeScope, never()).removeTag(any()) + verify(fixture.nativeScope, never()).setExtra(any(), any()) + verify(fixture.nativeScope, never()).removeExtra(any()) + verify(fixture.nativeScope, never()).setUser(any(), any(), any(), any()) + verify(fixture.nativeScope, never()).addBreadcrumb(any(), any(), any(), any(), any(), any()) + + // when the executor service is run, the scope sync is called + executorService.runAll() + + verify(fixture.nativeScope).setTag(any(), any()) + verify(fixture.nativeScope).removeTag(any()) + verify(fixture.nativeScope).setExtra(any(), any()) + verify(fixture.nativeScope).removeExtra(any()) + verify(fixture.nativeScope).setUser(anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull()) + verify(fixture.nativeScope).addBreadcrumb(anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull()) + } } From 6259a9fdb9ec4b75001c93401e1d0b1c18541dfb Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Mon, 14 Oct 2024 15:53:13 +0200 Subject: [PATCH 50/92] [QA] Offload System.loadLibrary call to background thread (#3670) * Offload System.loadLibrary call to background thread * Update Changelog * Update CHANGELOG.md --- CHANGELOG.md | 1 + .../java/io/sentry/android/ndk/SentryNdk.java | 54 ++++++++++++++----- 2 files changed, 41 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a18600c608..cf0d89cca0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ - Deprecate `enableTracing` option ([#3777](https://github.com/getsentry/sentry-java/pull/3777)) - Vendor `java.util.Random` and replace `java.security.SecureRandom` usages ([#3783](https://github.com/getsentry/sentry-java/pull/3783)) - Fix potential ANRs due to NDK scope sync ([#3754](https://github.com/getsentry/sentry-java/pull/3754)) +- Fix potential ANRs due to NDK System.loadLibrary calls ([#3670](https://github.com/getsentry/sentry-java/pull/3670)) ## 7.15.0 diff --git a/sentry-android-ndk/src/main/java/io/sentry/android/ndk/SentryNdk.java b/sentry-android-ndk/src/main/java/io/sentry/android/ndk/SentryNdk.java index 1ddc04c524..7245516b49 100644 --- a/sentry-android-ndk/src/main/java/io/sentry/android/ndk/SentryNdk.java +++ b/sentry-android-ndk/src/main/java/io/sentry/android/ndk/SentryNdk.java @@ -1,23 +1,40 @@ package io.sentry.android.ndk; import io.sentry.android.core.SentryAndroidOptions; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; @ApiStatus.Internal public final class SentryNdk { + private static final @NotNull CountDownLatch loadLibraryLatch = new CountDownLatch(1); + private SentryNdk() {} static { - // On older Android versions, it was necessary to manually call "`System.loadLibrary` on all - // transitive dependencies before loading [the] main library." - // The dependencies of `libsentry.so` are currently `lib{c,m,dl,log}.so`. - // See - // https://android.googlesource.com/platform/bionic/+/master/android-changes-for-ndk-developers.md#changes-to-library-dependency-resolution - System.loadLibrary("log"); - System.loadLibrary("sentry"); - System.loadLibrary("sentry-android"); + new Thread( + () -> { + // On older Android versions, it was necessary to manually call "`System.loadLibrary` + // on all + // transitive dependencies before loading [the] main library." + // The dependencies of `libsentry.so` are currently `lib{c,m,dl,log}.so`. + // See + // https://android.googlesource.com/platform/bionic/+/master/android-changes-for-ndk-developers.md#changes-to-library-dependency-resolution + try { + System.loadLibrary("log"); + System.loadLibrary("sentry"); + System.loadLibrary("sentry-android"); + } catch (Throwable t) { + // ignored + // if loadLibrary() fails, the later init() will throw an exception anyway + } finally { + loadLibraryLatch.countDown(); + } + }, + "SentryNdkLoadLibs") + .start(); } private static native void initSentryNative(@NotNull final SentryAndroidOptions options); @@ -31,14 +48,23 @@ private SentryNdk() {} */ public static void init(@NotNull final SentryAndroidOptions options) { SentryNdkUtil.addPackage(options.getSdkVersion()); - initSentryNative(options); + try { + if (loadLibraryLatch.await(2000, TimeUnit.MILLISECONDS)) { + initSentryNative(options); - // only add scope sync observer if the scope sync is enabled. - if (options.isEnableScopeSync()) { - options.addScopeObserver(new NdkScopeObserver(options)); - } + // only add scope sync observer if the scope sync is enabled. + if (options.isEnableScopeSync()) { + options.addScopeObserver(new NdkScopeObserver(options)); + } - options.setDebugImagesLoader(new DebugImagesLoader(options, new NativeModuleListLoader())); + options.setDebugImagesLoader(new DebugImagesLoader(options, new NativeModuleListLoader())); + } else { + throw new IllegalStateException("Timeout waiting for Sentry NDK library to load"); + } + } catch (InterruptedException e) { + throw new IllegalStateException( + "Thread interrupted while waiting for NDK libs to be loaded", e); + } } /** Closes the NDK integration */ From 9c193193a14a18f407e55d14eb051c0a4e55eec6 Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Mon, 14 Oct 2024 21:36:37 +0200 Subject: [PATCH 51/92] Add meta option to attach ANR thread dumps (#3791) * Add meta option to attach ANR thread dumps * Update Changelog --- CHANGELOG.md | 4 +++ .../android/core/ManifestMetadataReader.java | 5 +++- .../core/ManifestMetadataReaderTest.kt | 25 +++++++++++++++++++ 3 files changed, 33 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cf0d89cca0..09d00018de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Features + +- Add meta option to attach ANR thread dumps ([#3791](https://github.com/getsentry/sentry-java/pull/3791)) + ### Fixes - Deprecate `enableTracing` option ([#3777](https://github.com/getsentry/sentry-java/pull/3777)) diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java b/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java index e42f68cab0..56cd506fe6 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java @@ -26,8 +26,8 @@ final class ManifestMetadataReader { static final String SAMPLE_RATE = "io.sentry.sample-rate"; static final String ANR_ENABLE = "io.sentry.anr.enable"; static final String ANR_REPORT_DEBUG = "io.sentry.anr.report-debug"; - static final String ANR_TIMEOUT_INTERVAL_MILLIS = "io.sentry.anr.timeout-interval-millis"; + static final String ANR_ATTACH_THREAD_DUMPS = "io.sentry.anr.attach-thread-dumps"; static final String AUTO_INIT = "io.sentry.auto-init"; static final String NDK_ENABLE = "io.sentry.ndk.enable"; @@ -176,6 +176,9 @@ static void applyMetadata( ANR_TIMEOUT_INTERVAL_MILLIS, options.getAnrTimeoutIntervalMillis())); + options.setAttachAnrThreadDump( + readBool(metadata, logger, ANR_ATTACH_THREAD_DUMPS, options.isAttachAnrThreadDump())); + final String dsn = readString(metadata, logger, DSN, options.getDsn()); final boolean enabled = readBool(metadata, logger, ENABLE_SENTRY, options.isEnabled()); diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt index e068af7b1c..25b2e0191c 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt @@ -246,6 +246,31 @@ class ManifestMetadataReaderTest { assertEquals(5000.toLong(), fixture.options.anrTimeoutIntervalMillis) } + @Test + fun `applyMetadata reads anr attach thread dump to options`() { + // Arrange + val bundle = bundleOf(ManifestMetadataReader.ANR_ATTACH_THREAD_DUMPS to true) + val context = fixture.getContext(metaData = bundle) + + // Act + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + // Assert + assertEquals(true, fixture.options.isAttachAnrThreadDump) + } + + @Test + fun `applyMetadata reads anr attach thread dump to options and keeps default`() { + // Arrange + val context = fixture.getContext() + + // Act + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + // Assert + assertEquals(false, fixture.options.isAttachAnrThreadDump) + } + @Test fun `applyMetadata reads activity breadcrumbs to options`() { // Arrange From 8fdee54dfe034a0d21efbddc1613263d542fdf1c Mon Sep 17 00:00:00 2001 From: Stefano Date: Tue, 15 Oct 2024 12:39:29 +0200 Subject: [PATCH 52/92] fix invalid profiles when the transaction name is empty (#3747) * when the transaction name is empty, the profiles default to "unknown" --- CHANGELOG.md | 1 + .../android/core/AndroidTransactionProfilerTest.kt | 11 +++++++++++ .../src/main/java/io/sentry/ProfilingTraceData.java | 2 +- .../main/java/io/sentry/ProfilingTransactionData.java | 2 +- sentry/src/test/java/io/sentry/JsonSerializerTest.kt | 4 ++-- 5 files changed, 16 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 09d00018de..3f3171a03b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ ### Fixes +- fix invalid profiles when the transaction name is empty ([#3747](https://github.com/getsentry/sentry-java/pull/3747)) - Deprecate `enableTracing` option ([#3777](https://github.com/getsentry/sentry-java/pull/3777)) - Vendor `java.util.Random` and replace `java.security.SecureRandom` usages ([#3783](https://github.com/getsentry/sentry-java/pull/3783)) - Fix potential ANRs due to NDK scope sync ([#3754](https://github.com/getsentry/sentry-java/pull/3754)) diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/AndroidTransactionProfilerTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/AndroidTransactionProfilerTest.kt index 02cda7d23b..df64e20b16 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/AndroidTransactionProfilerTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/AndroidTransactionProfilerTest.kt @@ -360,6 +360,17 @@ class AndroidTransactionProfilerTest { verify(mockExecutorService, never()).submit(any>()) } + @Test + fun `profiling transaction with empty name fallbacks to unknown`() { + val profiler = fixture.getSut(context) + profiler.start() + profiler.bindTransaction(fixture.transaction1) + val profilingTraceData = profiler.onTransactionFinish(fixture.transaction1, null, fixture.options) + assertNotNull(profilingTraceData) + assertEquals("unknown", profilingTraceData.transactionName) + assertEquals("unknown", profilingTraceData.transactions.first().name) + } + @Test fun `profiler does not throw if traces cannot be written to disk`() { File(fixture.options.profilingTracesDirPath!!).setWritable(false) diff --git a/sentry/src/main/java/io/sentry/ProfilingTraceData.java b/sentry/src/main/java/io/sentry/ProfilingTraceData.java index 17332b5931..3998735917 100644 --- a/sentry/src/main/java/io/sentry/ProfilingTraceData.java +++ b/sentry/src/main/java/io/sentry/ProfilingTraceData.java @@ -144,7 +144,7 @@ public ProfilingTraceData( // Transaction info this.transactions = transactions; - this.transactionName = transactionName; + this.transactionName = transactionName.isEmpty() ? "unknown" : transactionName; this.durationNs = durationNanos; // App info diff --git a/sentry/src/main/java/io/sentry/ProfilingTransactionData.java b/sentry/src/main/java/io/sentry/ProfilingTransactionData.java index 045b859f05..84d149ef15 100644 --- a/sentry/src/main/java/io/sentry/ProfilingTransactionData.java +++ b/sentry/src/main/java/io/sentry/ProfilingTransactionData.java @@ -29,7 +29,7 @@ public ProfilingTransactionData( @NotNull ITransaction transaction, @NotNull Long startNs, @NotNull Long startCpuMs) { this.id = transaction.getEventId().toString(); this.traceId = transaction.getSpanContext().getTraceId().toString(); - this.name = transaction.getName(); + this.name = transaction.getName().isEmpty() ? "unknown" : transaction.getName(); this.relativeStartNs = startNs; this.relativeStartCpuMs = startCpuMs; } diff --git a/sentry/src/test/java/io/sentry/JsonSerializerTest.kt b/sentry/src/test/java/io/sentry/JsonSerializerTest.kt index ba8ee84d51..37c1870288 100644 --- a/sentry/src/test/java/io/sentry/JsonSerializerTest.kt +++ b/sentry/src/test/java/io/sentry/JsonSerializerTest.kt @@ -569,7 +569,7 @@ class JsonSerializerTest { mapOf( "trace_id" to "00000000000000000000000000000000", "relative_cpu_end_ms" to null, - "name" to "", + "name" to "unknown", "relative_start_ns" to 1, "relative_end_ns" to null, "id" to "00000000000000000000000000000000", @@ -578,7 +578,7 @@ class JsonSerializerTest { mapOf( "trace_id" to "00000000000000000000000000000000", "relative_cpu_end_ms" to null, - "name" to "", + "name" to "unknown", "relative_start_ns" to 2, "relative_end_ns" to null, "id" to "00000000000000000000000000000000", From 274c2956310c25995e412e743b2187b4664d1e29 Mon Sep 17 00:00:00 2001 From: Karl Heinz Struggl Date: Tue, 15 Oct 2024 06:23:20 -0700 Subject: [PATCH 53/92] chore: Add action to warn about potentially risky PR changes (#3726) * adds config+action to warn about risky PR changes * updates wording of warning PR comment * added risky files --- .github/file-filters.yml | 12 +++++ .../workflows/changes-in-high-risk-code.yml | 49 +++++++++++++++++++ 2 files changed, 61 insertions(+) create mode 100644 .github/file-filters.yml create mode 100644 .github/workflows/changes-in-high-risk-code.yml diff --git a/.github/file-filters.yml b/.github/file-filters.yml new file mode 100644 index 0000000000..2b81e2f0b6 --- /dev/null +++ b/.github/file-filters.yml @@ -0,0 +1,12 @@ +# This is used by the action https://github.com/dorny/paths-filter + +high_risk_code: &high_risk_code + # Transport classes + - "sentry/src/main/java/io/sentry/transport/AsyncHttpTransport.java" + - "sentry/src/main/java/io/sentry/transport/HttpConnection.java" + - "sentry/src/main/java/io/sentry/transport/QueuedThreadPoolExecutor.java" + - "sentry/src/main/java/io/sentry/transport/RateLimiter.java" + - "sentry-apache-http-client-5/src/main/java/io/sentry/transport/apache/ApacheHttpClientTransport.java" + + # Class used by hybrid SDKs + - "sentry-android-core/src/main/java/io/sentry/android/core/InternalSentrySdk.java" diff --git a/.github/workflows/changes-in-high-risk-code.yml b/.github/workflows/changes-in-high-risk-code.yml new file mode 100644 index 0000000000..64decbe48f --- /dev/null +++ b/.github/workflows/changes-in-high-risk-code.yml @@ -0,0 +1,49 @@ +name: Changes In High Risk Code +on: + pull_request: + +# https://docs.github.com/en/actions/using-jobs/using-concurrency#example-using-a-fallback-value +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +jobs: + files-changed: + name: Detect changed files + runs-on: ubuntu-latest + # Map a step output to a job output + outputs: + high_risk_code: ${{ steps.changes.outputs.high_risk_code }} + high_risk_code_files: ${{ steps.changes.outputs.high_risk_code_files }} + steps: + - uses: actions/checkout@v4 + - name: Get changed files + id: changes + uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2 + with: + token: ${{ github.token }} + filters: .github/file-filters.yml + + # Enable listing of files matching each filter. + # Paths to files will be available in `${FILTER_NAME}_files` output variable. + list-files: csv + + validate-high-risk-code: + if: needs.files-changed.outputs.high_risk_code == 'true' + needs: files-changed + runs-on: ubuntu-latest + steps: + - name: Comment on PR to notify of changes in high risk files + uses: actions/github-script@v7 + env: + high_risk_code: ${{ needs.files-changed.outputs.high_risk_code_files }} + with: + script: | + const highRiskFiles = process.env.high_risk_code; + const fileList = highRiskFiles.split(',').map(file => `- [ ] ${file}`).join('\n'); + github.rest.issues.createComment({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + body: `### 🚨 Detected changes in high risk code 🚨 \n High-risk code has higher potential to break the SDK and may be hard to test. To prevent severe bugs, apply the rollout process for releasing such changes and be extra careful when changing and reviewing these files:\n ${fileList}` + }) From 654c2dc2576fa848240bbeefad3f2c6d7665f7cc Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 16 Oct 2024 07:50:51 +0000 Subject: [PATCH 54/92] Bump reactivecircus/android-emulator-runner from 2.32.0 to 2.33.0 (#3786) Bumps [reactivecircus/android-emulator-runner](https://github.com/reactivecircus/android-emulator-runner) from 2.32.0 to 2.33.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/f0d1ed2dcad93c7479e8b2f2226c83af54494915...62dbb605bba737720e10b196cb4220d374026a6d) --- 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> --- .github/workflows/agp-matrix.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/agp-matrix.yml b/.github/workflows/agp-matrix.yml index b43d40697a..fdb3137209 100644 --- a/.github/workflows/agp-matrix.yml +++ b/.github/workflows/agp-matrix.yml @@ -59,7 +59,7 @@ jobs: # We tried to use the cache action to cache gradle stuff, but it made tests slower and timeout - name: Run instrumentation tests - uses: reactivecircus/android-emulator-runner@f0d1ed2dcad93c7479e8b2f2226c83af54494915 # pin@v2 + uses: reactivecircus/android-emulator-runner@62dbb605bba737720e10b196cb4220d374026a6d # pin@v2 with: api-level: 30 force-avd-creation: false From 55ea3ccbe309f398d71562c57513d9f16e17a240 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Wed, 16 Oct 2024 12:14:52 +0200 Subject: [PATCH 55/92] [QA] Make logging faster on startup (#3793) * Make logging faster on startup * Changelog --- CHANGELOG.md | 1 + .../io/sentry/android/core/AndroidLogger.java | 12 ++++++++++-- .../android/core/ManifestMetadataReader.java | 17 ++++++++--------- 3 files changed, 19 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f3171a03b..1fe35c8e0f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ - Vendor `java.util.Random` and replace `java.security.SecureRandom` usages ([#3783](https://github.com/getsentry/sentry-java/pull/3783)) - Fix potential ANRs due to NDK scope sync ([#3754](https://github.com/getsentry/sentry-java/pull/3754)) - Fix potential ANRs due to NDK System.loadLibrary calls ([#3670](https://github.com/getsentry/sentry-java/pull/3670)) +- Fix slow `Log` calls on app startup ([#3793](https://github.com/getsentry/sentry-java/pull/3793)) ## 7.15.0 diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidLogger.java b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidLogger.java index b0f6b8ac92..ef943c1696 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidLogger.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidLogger.java @@ -26,7 +26,11 @@ public void log( final @NotNull SentryLevel level, final @NotNull String message, final @Nullable Object... args) { - Log.println(toLogcatLevel(level), tag, String.format(message, args)); + if (args == null || args.length == 0) { + Log.println(toLogcatLevel(level), tag, message); + } else { + Log.println(toLogcatLevel(level), tag, String.format(message, args)); + } } @SuppressWarnings("AnnotateFormatMethod") @@ -36,7 +40,11 @@ public void log( final @Nullable Throwable throwable, final @NotNull String message, final @Nullable Object... args) { - log(level, String.format(message, args), throwable); + if (args == null || args.length == 0) { + log(level, message, throwable); + } else { + log(level, String.format(message, args), throwable); + } } @Override diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java b/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java index 56cd506fe6..babcfdfc98 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java @@ -437,7 +437,7 @@ private static boolean readBool( final @NotNull String key, final boolean defaultValue) { final boolean value = metadata.getBoolean(key, defaultValue); - logger.log(SentryLevel.DEBUG, "%s read: %s", key, value); + logger.log(SentryLevel.DEBUG, key + " read: " + value); return value; } @@ -450,10 +450,10 @@ private static boolean readBool( if (metadata.getSerializable(key) != null) { final boolean nonNullDefault = defaultValue == null ? false : true; final boolean bool = metadata.getBoolean(key, nonNullDefault); - logger.log(SentryLevel.DEBUG, "%s read: %s", key, bool); + logger.log(SentryLevel.DEBUG, key + " read: " + bool); return bool; } else { - logger.log(SentryLevel.DEBUG, "%s used default %s", key, defaultValue); + logger.log(SentryLevel.DEBUG, key + " used default " + defaultValue); return defaultValue; } } @@ -464,7 +464,7 @@ private static boolean readBool( final @NotNull String key, final @Nullable String defaultValue) { final String value = metadata.getString(key, defaultValue); - logger.log(SentryLevel.DEBUG, "%s read: %s", key, value); + logger.log(SentryLevel.DEBUG, key + " read: " + value); return value; } @@ -474,14 +474,14 @@ private static boolean readBool( final @NotNull String key, final @NotNull String defaultValue) { final String value = metadata.getString(key, defaultValue); - logger.log(SentryLevel.DEBUG, "%s read: %s", key, value); + logger.log(SentryLevel.DEBUG, key + " read: " + value); return value; } private static @Nullable List readList( final @NotNull Bundle metadata, final @NotNull ILogger logger, final @NotNull String key) { final String value = metadata.getString(key); - logger.log(SentryLevel.DEBUG, "%s read: %s", key, value); + logger.log(SentryLevel.DEBUG, key + " read: " + value); if (value != null) { return Arrays.asList(value.split(",", -1)); } else { @@ -493,7 +493,7 @@ private static boolean readBool( final @NotNull Bundle metadata, final @NotNull ILogger logger, final @NotNull String key) { // manifest meta-data only reads float final Double value = ((Float) metadata.getFloat(key, -1)).doubleValue(); - logger.log(SentryLevel.DEBUG, "%s read: %s", key, value); + logger.log(SentryLevel.DEBUG, key + " read: " + value); return value; } @@ -504,7 +504,7 @@ private static long readLong( final long defaultValue) { // manifest meta-data only reads int if the value is not big enough final long value = metadata.getInt(key, (int) defaultValue); - logger.log(SentryLevel.DEBUG, "%s read: %s", key, value); + logger.log(SentryLevel.DEBUG, key + " read: " + value); return value; } @@ -524,7 +524,6 @@ static boolean isAutoInit(final @NotNull Context context, final @NotNull ILogger if (metadata != null) { autoInit = readBool(metadata, logger, AUTO_INIT, true); } - logger.log(SentryLevel.INFO, "Retrieving auto-init from AndroidManifest.xml"); } catch (Throwable e) { logger.log(SentryLevel.ERROR, "Failed to read auto-init from android manifest metadata.", e); } From 94071dac205bde6a3126cae0a3e35b559e5e915d Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Wed, 16 Oct 2024 14:10:55 +0200 Subject: [PATCH 56/92] [QA] Hardcode integration names (#3794) * Hardcode integration names for tracking * Changelog * Fix tests --- CHANGELOG.md | 1 + .../android/core/ActivityBreadcrumbsIntegration.java | 2 +- .../android/core/ActivityLifecycleIntegration.java | 2 +- .../java/io/sentry/android/core/AnrIntegration.java | 2 +- .../java/io/sentry/android/core/AnrV2Integration.java | 2 +- .../core/AppComponentsBreadcrumbsIntegration.java | 2 +- .../sentry/android/core/AppLifecycleIntegration.java | 2 +- .../java/io/sentry/android/core/NdkIntegration.java | 2 +- .../android/core/NetworkBreadcrumbsIntegration.java | 2 +- .../android/core/PhoneStateBreadcrumbsIntegration.java | 2 +- .../sentry/android/core/ScreenshotEventProcessor.java | 2 +- .../android/core/SendCachedEnvelopeIntegration.java | 3 +++ .../core/SystemEventsBreadcrumbsIntegration.java | 2 +- .../android/core/TempSensorBreadcrumbsIntegration.java | 2 +- .../android/core/UserInteractionIntegration.java | 2 +- .../android/core/ViewHierarchyEventProcessor.java | 2 +- .../android/fragment/FragmentLifecycleIntegration.kt | 2 +- .../android/navigation/SentryNavigationListener.kt | 2 +- .../sentry/android/okhttp/SentryOkHttpInterceptor.kt | 2 +- .../java/io/sentry/android/replay/ReplayIntegration.kt | 2 +- .../sentry/android/timber/SentryTimberIntegration.kt | 2 +- .../java/io/sentry/apollo/SentryApolloInterceptor.kt | 2 +- .../java/io/sentry/okhttp/SentryOkHttpInterceptor.kt | 2 +- sentry/api/sentry.api | 1 - .../SendCachedEnvelopeFireAndForgetIntegration.java | 2 +- .../main/java/io/sentry/ShutdownHookIntegration.java | 2 +- .../io/sentry/UncaughtExceptionHandlerIntegration.java | 2 +- .../src/main/java/io/sentry/util/IntegrationUtils.java | 10 ---------- 28 files changed, 28 insertions(+), 35 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1fe35c8e0f..b2ec56c00c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ - Fix potential ANRs due to NDK scope sync ([#3754](https://github.com/getsentry/sentry-java/pull/3754)) - Fix potential ANRs due to NDK System.loadLibrary calls ([#3670](https://github.com/getsentry/sentry-java/pull/3670)) - Fix slow `Log` calls on app startup ([#3793](https://github.com/getsentry/sentry-java/pull/3793)) +- Fix slow Integration name parsing ([#3794](https://github.com/getsentry/sentry-java/pull/3794)) ## 7.15.0 diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ActivityBreadcrumbsIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/ActivityBreadcrumbsIntegration.java index dc03abe808..d9bfb5cea3 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/ActivityBreadcrumbsIntegration.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ActivityBreadcrumbsIntegration.java @@ -46,7 +46,7 @@ public void register(final @NotNull IHub hub, final @NotNull SentryOptions optio if (enabled) { application.registerActivityLifecycleCallbacks(this); options.getLogger().log(SentryLevel.DEBUG, "ActivityBreadcrumbIntegration installed."); - addIntegrationToSdkVersion(getClass()); + addIntegrationToSdkVersion("ActivityBreadcrumbs"); } } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ActivityLifecycleIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/ActivityLifecycleIntegration.java index 6e7a22ac05..14b7ec98fb 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/ActivityLifecycleIntegration.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ActivityLifecycleIntegration.java @@ -118,7 +118,7 @@ public void register(final @NotNull IHub hub, final @NotNull SentryOptions optio application.registerActivityLifecycleCallbacks(this); this.options.getLogger().log(SentryLevel.DEBUG, "ActivityLifecycleIntegration installed."); - addIntegrationToSdkVersion(getClass()); + addIntegrationToSdkVersion("ActivityLifecycle"); } private boolean isPerformanceEnabled(final @NotNull SentryAndroidOptions options) { diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AnrIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/AnrIntegration.java index 1c7b0f2eaf..14fac4753d 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/AnrIntegration.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AnrIntegration.java @@ -59,7 +59,7 @@ private void register(final @NotNull IHub hub, final @NotNull SentryAndroidOptio .log(SentryLevel.DEBUG, "AnrIntegration enabled: %s", options.isAnrEnabled()); if (options.isAnrEnabled()) { - addIntegrationToSdkVersion(getClass()); + addIntegrationToSdkVersion("Anr"); try { options .getExecutorService() diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2Integration.java b/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2Integration.java index b6be55e90a..618f53554f 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2Integration.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2Integration.java @@ -95,7 +95,7 @@ public void register(@NotNull IHub hub, @NotNull SentryOptions options) { options.getLogger().log(SentryLevel.DEBUG, "Failed to start AnrProcessor.", e); } options.getLogger().log(SentryLevel.DEBUG, "AnrV2Integration installed."); - addIntegrationToSdkVersion(getClass()); + addIntegrationToSdkVersion("AnrV2"); } } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AppComponentsBreadcrumbsIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/AppComponentsBreadcrumbsIntegration.java index f2d9f6a2a7..e11bd5d3b9 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/AppComponentsBreadcrumbsIntegration.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AppComponentsBreadcrumbsIntegration.java @@ -55,7 +55,7 @@ public void register(final @NotNull IHub hub, final @NotNull SentryOptions optio options .getLogger() .log(SentryLevel.DEBUG, "AppComponentsBreadcrumbsIntegration installed."); - addIntegrationToSdkVersion(getClass()); + addIntegrationToSdkVersion("AppComponentsBreadcrumbs"); } catch (Throwable e) { this.options.setEnableAppComponentBreadcrumbs(false); options.getLogger().log(SentryLevel.INFO, e, "ComponentCallbacks2 is not available."); diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AppLifecycleIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/AppLifecycleIntegration.java index 3e8fe6383f..f730f4bc76 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/AppLifecycleIntegration.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AppLifecycleIntegration.java @@ -96,7 +96,7 @@ private void addObserver(final @NotNull IHub hub) { try { ProcessLifecycleOwner.get().getLifecycle().addObserver(watcher); options.getLogger().log(SentryLevel.DEBUG, "AppLifecycleIntegration installed."); - addIntegrationToSdkVersion(getClass()); + addIntegrationToSdkVersion("AppLifecycle"); } catch (Throwable e) { // This is to handle a potential 'AbstractMethodError' gracefully. The error is triggered in // connection with conflicting dependencies of the androidx.lifecycle. diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/NdkIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/NdkIntegration.java index 3a4a91498e..78bcadeade 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/NdkIntegration.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/NdkIntegration.java @@ -55,7 +55,7 @@ public final void register(final @NotNull IHub hub, final @NotNull SentryOptions method.invoke(null, args); this.options.getLogger().log(SentryLevel.DEBUG, "NdkIntegration installed."); - addIntegrationToSdkVersion(getClass()); + addIntegrationToSdkVersion("Ndk"); } catch (NoSuchMethodException e) { disableNdkIntegration(this.options); this.options diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/NetworkBreadcrumbsIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/NetworkBreadcrumbsIntegration.java index fa5724e8c5..7610a804f3 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/NetworkBreadcrumbsIntegration.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/NetworkBreadcrumbsIntegration.java @@ -96,7 +96,7 @@ public void run() { context, logger, buildInfoProvider, networkCallback); if (registered) { logger.log(SentryLevel.DEBUG, "NetworkBreadcrumbsIntegration installed."); - addIntegrationToSdkVersion(getClass()); + addIntegrationToSdkVersion("NetworkBreadcrumbs"); } else { logger.log( SentryLevel.DEBUG, "NetworkBreadcrumbsIntegration not installed."); diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/PhoneStateBreadcrumbsIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/PhoneStateBreadcrumbsIntegration.java index 2da0452698..249904fd16 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/PhoneStateBreadcrumbsIntegration.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/PhoneStateBreadcrumbsIntegration.java @@ -81,7 +81,7 @@ private void startTelephonyListener( telephonyManager.listen(listener, android.telephony.PhoneStateListener.LISTEN_CALL_STATE); options.getLogger().log(SentryLevel.DEBUG, "PhoneStateBreadcrumbsIntegration installed."); - addIntegrationToSdkVersion(getClass()); + addIntegrationToSdkVersion("PhoneStateBreadcrumbs"); } catch (Throwable e) { options .getLogger() diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ScreenshotEventProcessor.java b/sentry-android-core/src/main/java/io/sentry/android/core/ScreenshotEventProcessor.java index 5e07a44078..8cdc2461d2 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/ScreenshotEventProcessor.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ScreenshotEventProcessor.java @@ -46,7 +46,7 @@ public ScreenshotEventProcessor( DEBOUNCE_MAX_EXECUTIONS); if (options.isAttachScreenshot()) { - addIntegrationToSdkVersion(getClass()); + addIntegrationToSdkVersion("Screenshot"); } } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/SendCachedEnvelopeIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/SendCachedEnvelopeIntegration.java index 66e534bb7d..6d24508c12 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/SendCachedEnvelopeIntegration.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/SendCachedEnvelopeIntegration.java @@ -1,5 +1,7 @@ package io.sentry.android.core; +import static io.sentry.util.IntegrationUtils.addIntegrationToSdkVersion; + import io.sentry.DataCategory; import io.sentry.IConnectionStatusProvider; import io.sentry.IHub; @@ -54,6 +56,7 @@ public void register(@NotNull IHub hub, @NotNull SentryOptions options) { options.getLogger().log(SentryLevel.ERROR, "No cache dir path is defined in options."); return; } + addIntegrationToSdkVersion("SendCachedEnvelope"); sendCachedEnvelopes(hub, this.options); } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/SystemEventsBreadcrumbsIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/SystemEventsBreadcrumbsIntegration.java index 76422fdf6e..ea838975cd 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/SystemEventsBreadcrumbsIntegration.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/SystemEventsBreadcrumbsIntegration.java @@ -132,7 +132,7 @@ private void startSystemEventsReceiver( // registerReceiver can throw SecurityException but it's not documented in the official docs ContextUtils.registerReceiver(context, options, receiver, filter); options.getLogger().log(SentryLevel.DEBUG, "SystemEventsBreadcrumbsIntegration installed."); - addIntegrationToSdkVersion(getClass()); + addIntegrationToSdkVersion("SystemEventsBreadcrumbs"); } catch (Throwable e) { options.setEnableSystemEventBreadcrumbs(false); options diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/TempSensorBreadcrumbsIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/TempSensorBreadcrumbsIntegration.java index 41e1860184..b94a06b976 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/TempSensorBreadcrumbsIntegration.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/TempSensorBreadcrumbsIntegration.java @@ -87,7 +87,7 @@ private void startSensorListener(final @NotNull SentryOptions options) { sensorManager.registerListener(this, defaultSensor, SensorManager.SENSOR_DELAY_NORMAL); options.getLogger().log(SentryLevel.DEBUG, "TempSensorBreadcrumbsIntegration installed."); - addIntegrationToSdkVersion(getClass()); + addIntegrationToSdkVersion("TempSensorBreadcrumbs"); } else { options.getLogger().log(SentryLevel.INFO, "TYPE_AMBIENT_TEMPERATURE is not available."); } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/UserInteractionIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/UserInteractionIntegration.java index c361529671..a0ad359166 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/UserInteractionIntegration.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/UserInteractionIntegration.java @@ -121,7 +121,7 @@ public void register(@NotNull IHub hub, @NotNull SentryOptions options) { if (isAndroidXAvailable) { application.registerActivityLifecycleCallbacks(this); this.options.getLogger().log(SentryLevel.DEBUG, "UserInteractionIntegration installed."); - addIntegrationToSdkVersion(getClass()); + addIntegrationToSdkVersion("UserInteraction"); } else { options .getLogger() diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ViewHierarchyEventProcessor.java b/sentry-android-core/src/main/java/io/sentry/android/core/ViewHierarchyEventProcessor.java index 30e9f8de11..eaa9aaa560 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/ViewHierarchyEventProcessor.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ViewHierarchyEventProcessor.java @@ -55,7 +55,7 @@ public ViewHierarchyEventProcessor(final @NotNull SentryAndroidOptions options) DEBOUNCE_MAX_EXECUTIONS); if (options.isAttachViewHierarchy()) { - addIntegrationToSdkVersion(getClass()); + addIntegrationToSdkVersion("ViewHierarchy"); } } diff --git a/sentry-android-fragment/src/main/java/io/sentry/android/fragment/FragmentLifecycleIntegration.kt b/sentry-android-fragment/src/main/java/io/sentry/android/fragment/FragmentLifecycleIntegration.kt index 4129ea4356..30aa306dc1 100644 --- a/sentry-android-fragment/src/main/java/io/sentry/android/fragment/FragmentLifecycleIntegration.kt +++ b/sentry-android-fragment/src/main/java/io/sentry/android/fragment/FragmentLifecycleIntegration.kt @@ -49,7 +49,7 @@ class FragmentLifecycleIntegration( application.registerActivityLifecycleCallbacks(this) options.logger.log(DEBUG, "FragmentLifecycleIntegration installed.") - addIntegrationToSdkVersion(javaClass) + addIntegrationToSdkVersion("FragmentLifecycle") SentryIntegrationPackageStorage.getInstance() .addPackage("maven:io.sentry:sentry-android-fragment", BuildConfig.VERSION_NAME) } diff --git a/sentry-android-navigation/src/main/java/io/sentry/android/navigation/SentryNavigationListener.kt b/sentry-android-navigation/src/main/java/io/sentry/android/navigation/SentryNavigationListener.kt index dac8e54e80..3d020f5a56 100644 --- a/sentry-android-navigation/src/main/java/io/sentry/android/navigation/SentryNavigationListener.kt +++ b/sentry-android-navigation/src/main/java/io/sentry/android/navigation/SentryNavigationListener.kt @@ -49,7 +49,7 @@ class SentryNavigationListener @JvmOverloads constructor( private var activeTransaction: ITransaction? = null init { - addIntegrationToSdkVersion(javaClass) + addIntegrationToSdkVersion("NavigationListener") SentryIntegrationPackageStorage.getInstance() .addPackage("maven:io.sentry:sentry-android-navigation", BuildConfig.VERSION_NAME) } diff --git a/sentry-android-okhttp/src/main/java/io/sentry/android/okhttp/SentryOkHttpInterceptor.kt b/sentry-android-okhttp/src/main/java/io/sentry/android/okhttp/SentryOkHttpInterceptor.kt index 28c242c82c..6e58b7bb10 100644 --- a/sentry-android-okhttp/src/main/java/io/sentry/android/okhttp/SentryOkHttpInterceptor.kt +++ b/sentry-android-okhttp/src/main/java/io/sentry/android/okhttp/SentryOkHttpInterceptor.kt @@ -54,7 +54,7 @@ class SentryOkHttpInterceptor( constructor(beforeSpan: BeforeSpanCallback) : this(HubAdapter.getInstance(), beforeSpan) init { - addIntegrationToSdkVersion(javaClass) + addIntegrationToSdkVersion("OkHttp") SentryIntegrationPackageStorage.getInstance() .addPackage("maven:io.sentry:sentry-android-okhttp", BuildConfig.VERSION_NAME) } diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt index ee9223cc80..90832585cd 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt @@ -119,7 +119,7 @@ public class ReplayIntegration( options.logger.log(INFO, "ComponentCallbacks is not available, orientation changes won't be handled by Session replay", e) } - addIntegrationToSdkVersion(javaClass) + addIntegrationToSdkVersion("Replay") SentryIntegrationPackageStorage.getInstance() .addPackage("maven:io.sentry:sentry-android-replay", BuildConfig.VERSION_NAME) diff --git a/sentry-android-timber/src/main/java/io/sentry/android/timber/SentryTimberIntegration.kt b/sentry-android-timber/src/main/java/io/sentry/android/timber/SentryTimberIntegration.kt index d043faa5f6..f5350512a5 100644 --- a/sentry-android-timber/src/main/java/io/sentry/android/timber/SentryTimberIntegration.kt +++ b/sentry-android-timber/src/main/java/io/sentry/android/timber/SentryTimberIntegration.kt @@ -29,7 +29,7 @@ class SentryTimberIntegration( logger.log(SentryLevel.DEBUG, "SentryTimberIntegration installed.") SentryIntegrationPackageStorage.getInstance().addPackage("maven:io.sentry:sentry-android-timber", VERSION_NAME) - addIntegrationToSdkVersion(javaClass) + addIntegrationToSdkVersion("Timber") } override fun close() { diff --git a/sentry-apollo/src/main/java/io/sentry/apollo/SentryApolloInterceptor.kt b/sentry-apollo/src/main/java/io/sentry/apollo/SentryApolloInterceptor.kt index faa8a549a9..8191e48e4a 100644 --- a/sentry-apollo/src/main/java/io/sentry/apollo/SentryApolloInterceptor.kt +++ b/sentry-apollo/src/main/java/io/sentry/apollo/SentryApolloInterceptor.kt @@ -40,7 +40,7 @@ class SentryApolloInterceptor( constructor(beforeSpan: BeforeSpanCallback) : this(HubAdapter.getInstance(), beforeSpan) init { - addIntegrationToSdkVersion(javaClass) + addIntegrationToSdkVersion("Apollo") SentryIntegrationPackageStorage.getInstance().addPackage("maven:io.sentry:sentry-apollo", BuildConfig.VERSION_NAME) } diff --git a/sentry-okhttp/src/main/java/io/sentry/okhttp/SentryOkHttpInterceptor.kt b/sentry-okhttp/src/main/java/io/sentry/okhttp/SentryOkHttpInterceptor.kt index 5bf93be060..66a9fcc75c 100644 --- a/sentry-okhttp/src/main/java/io/sentry/okhttp/SentryOkHttpInterceptor.kt +++ b/sentry-okhttp/src/main/java/io/sentry/okhttp/SentryOkHttpInterceptor.kt @@ -54,7 +54,7 @@ public open class SentryOkHttpInterceptor( public constructor(beforeSpan: BeforeSpanCallback) : this(HubAdapter.getInstance(), beforeSpan) init { - addIntegrationToSdkVersion(javaClass) + addIntegrationToSdkVersion("OkHttp") SentryIntegrationPackageStorage.getInstance() .addPackage("maven:io.sentry:sentry-okhttp", BuildConfig.VERSION_NAME) } diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 89684c7ae5..5ca8c00da1 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -5690,7 +5690,6 @@ public final class io/sentry/util/HttpUtils { public final class io/sentry/util/IntegrationUtils { public fun ()V - public static fun addIntegrationToSdkVersion (Ljava/lang/Class;)V public static fun addIntegrationToSdkVersion (Ljava/lang/String;)V } diff --git a/sentry/src/main/java/io/sentry/SendCachedEnvelopeFireAndForgetIntegration.java b/sentry/src/main/java/io/sentry/SendCachedEnvelopeFireAndForgetIntegration.java index bc813fdd1e..c9e208e6c2 100644 --- a/sentry/src/main/java/io/sentry/SendCachedEnvelopeFireAndForgetIntegration.java +++ b/sentry/src/main/java/io/sentry/SendCachedEnvelopeFireAndForgetIntegration.java @@ -79,7 +79,7 @@ public void register(final @NotNull IHub hub, final @NotNull SentryOptions optio options .getLogger() .log(SentryLevel.DEBUG, "SendCachedEventFireAndForgetIntegration installed."); - addIntegrationToSdkVersion(getClass()); + addIntegrationToSdkVersion("SendCachedEnvelopeFireAndForget"); sendCachedEnvelopes(hub, options); } diff --git a/sentry/src/main/java/io/sentry/ShutdownHookIntegration.java b/sentry/src/main/java/io/sentry/ShutdownHookIntegration.java index 4ffb47d7d1..d08cbc6ee4 100644 --- a/sentry/src/main/java/io/sentry/ShutdownHookIntegration.java +++ b/sentry/src/main/java/io/sentry/ShutdownHookIntegration.java @@ -37,7 +37,7 @@ public void register(final @NotNull IHub hub, final @NotNull SentryOptions optio () -> { runtime.addShutdownHook(thread); options.getLogger().log(SentryLevel.DEBUG, "ShutdownHookIntegration installed."); - addIntegrationToSdkVersion(getClass()); + addIntegrationToSdkVersion("ShutdownHook"); }); } else { options.getLogger().log(SentryLevel.INFO, "enableShutdownHook is disabled."); diff --git a/sentry/src/main/java/io/sentry/UncaughtExceptionHandlerIntegration.java b/sentry/src/main/java/io/sentry/UncaughtExceptionHandlerIntegration.java index da594e4cb4..1eddb762aa 100644 --- a/sentry/src/main/java/io/sentry/UncaughtExceptionHandlerIntegration.java +++ b/sentry/src/main/java/io/sentry/UncaughtExceptionHandlerIntegration.java @@ -90,7 +90,7 @@ public void register(final @NotNull IHub hub, final @NotNull SentryOptions optio this.options .getLogger() .log(SentryLevel.DEBUG, "UncaughtExceptionHandlerIntegration installed."); - addIntegrationToSdkVersion(getClass()); + addIntegrationToSdkVersion("UncaughtExceptionHandler"); } } diff --git a/sentry/src/main/java/io/sentry/util/IntegrationUtils.java b/sentry/src/main/java/io/sentry/util/IntegrationUtils.java index 6d504c1451..4edbb7aedd 100644 --- a/sentry/src/main/java/io/sentry/util/IntegrationUtils.java +++ b/sentry/src/main/java/io/sentry/util/IntegrationUtils.java @@ -6,16 +6,6 @@ @ApiStatus.Internal public final class IntegrationUtils { - public static void addIntegrationToSdkVersion(final @NotNull Class clazz) { - final String name = - clazz - .getSimpleName() - .replace("Sentry", "") - .replace("Integration", "") - .replace("Interceptor", "") - .replace("EventProcessor", ""); - addIntegrationToSdkVersion(name); - } public static void addIntegrationToSdkVersion(final @NotNull String name) { SentryIntegrationPackageStorage.getInstance().addIntegration(name); From bd82483aef2b10e164d6978b2b51201d2b0a2abd Mon Sep 17 00:00:00 2001 From: Stefano Date: Thu, 17 Oct 2024 00:17:03 +0200 Subject: [PATCH 57/92] Fix ANRv2 test flakyness (#3798) * try to fix ANRv2 test flakyness * try to fix ANRv2 test flakyness --- .../test/java/io/sentry/android/core/SentryAndroidTest.kt | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidTest.kt index d75e0f88a2..c31076d1ff 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidTest.kt @@ -6,6 +6,7 @@ import android.app.ApplicationExitInfo import android.content.Context import android.os.Build import android.os.Bundle +import android.os.Looper import android.os.SystemClock import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 @@ -55,6 +56,7 @@ import org.mockito.kotlin.spy import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.whenever +import org.robolectric.Shadows import org.robolectric.annotation.Config import org.robolectric.shadow.api.Shadow import org.robolectric.shadows.ShadowActivityManager @@ -439,8 +441,10 @@ class SentryAndroidTest { await.withAlias("Failed because of BeforeSend callback above, but we swallow BeforeSend exceptions, hence the timeout") .untilTrue(asserted) + // Execute all posted tasks + Shadows.shadowOf(Looper.getMainLooper()).idle() + // assert that persisted values have changed - options.executorService.close(10000L) // finalizes all enqueued persisting tasks assertEquals( "TestActivity", PersistingScopeObserver.read(options, TRANSACTION_FILENAME, String::class.java) From ee6ab9509af7490dc71c5d3d88ad6afe221f2b5a Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Thu, 17 Oct 2024 00:26:13 +0200 Subject: [PATCH 58/92] [QA] Make replay lazy and faster (#3799) * Make replay lazy and faster * Changelog --- CHANGELOG.md | 1 + .../DefaultReplayBreadcrumbConverter.kt | 18 ++++++++------- .../android/replay/ScreenshotRecorder.kt | 23 +++++++++++-------- .../replay/capture/BaseCaptureStrategy.kt | 4 ---- .../viewhierarchy/ComposeViewHierarchyNode.kt | 16 ++++++------- .../replay/viewhierarchy/ViewHierarchyNode.kt | 4 ++-- 6 files changed, 35 insertions(+), 31 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b2ec56c00c..32cf654261 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ - Fix potential ANRs due to NDK System.loadLibrary calls ([#3670](https://github.com/getsentry/sentry-java/pull/3670)) - Fix slow `Log` calls on app startup ([#3793](https://github.com/getsentry/sentry-java/pull/3793)) - Fix slow Integration name parsing ([#3794](https://github.com/getsentry/sentry-java/pull/3794)) +- Session Replay: Reduce startup and capture overhead ([#3799](https://github.com/getsentry/sentry-java/pull/3799)) ## 7.15.0 diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/DefaultReplayBreadcrumbConverter.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/DefaultReplayBreadcrumbConverter.kt index c95b72088a..1a6f3fed37 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/DefaultReplayBreadcrumbConverter.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/DefaultReplayBreadcrumbConverter.kt @@ -12,14 +12,16 @@ import kotlin.LazyThreadSafetyMode.NONE public open class DefaultReplayBreadcrumbConverter : ReplayBreadcrumbConverter { internal companion object { private val snakecasePattern by lazy(NONE) { "_[a-z]".toRegex() } - private val supportedNetworkData = setOf( - "status_code", - "method", - "response_content_length", - "request_content_length", - "http.response_content_length", - "http.request_content_length" - ) + private val supportedNetworkData by lazy(NONE) { + setOf( + "status_code", + "method", + "response_content_length", + "request_content_length", + "http.response_content_length", + "http.request_content_length" + ) + } } private var lastConnectivityState: String? = null diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt index 8f823fa17c..54f92a8958 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt @@ -36,6 +36,7 @@ import java.util.concurrent.Executors import java.util.concurrent.ThreadFactory import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicReference +import kotlin.LazyThreadSafetyMode.NONE import kotlin.math.roundToInt @TargetApi(26) @@ -51,15 +52,19 @@ internal class ScreenshotRecorder( } private var rootView: WeakReference? = null private val pendingViewHierarchy = AtomicReference() - private val maskingPaint = Paint() - private val singlePixelBitmap: Bitmap = Bitmap.createBitmap( - 1, - 1, - Bitmap.Config.ARGB_8888 - ) - private val singlePixelBitmapCanvas: Canvas = Canvas(singlePixelBitmap) - private val prescaledMatrix = Matrix().apply { - preScale(config.scaleFactorX, config.scaleFactorY) + private val maskingPaint by lazy(NONE) { Paint() } + private val singlePixelBitmap: Bitmap by lazy(NONE) { + Bitmap.createBitmap( + 1, + 1, + Bitmap.Config.ARGB_8888 + ) + } + private val singlePixelBitmapCanvas: Canvas by lazy(NONE) { Canvas(singlePixelBitmap) } + private val prescaledMatrix by lazy(NONE) { + Matrix().apply { + preScale(config.scaleFactorX, config.scaleFactorY) + } } private val contentChanged = AtomicBoolean(false) private val isCapturing = AtomicBoolean(true) diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt index fcd2d11293..4b37eafc78 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt @@ -209,10 +209,6 @@ internal abstract class BaseCaptureStrategy( } } - init { - runInBackground { onChange(propertyName, initialValue, initialValue) } - } - override fun getValue(thisRef: Any?, property: KProperty<*>): T? = value.get() override fun setValue(thisRef: Any?, property: KProperty<*>, value: T?) { diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ComposeViewHierarchyNode.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ComposeViewHierarchyNode.kt index 888528f769..5640fbc96f 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ComposeViewHierarchyNode.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ComposeViewHierarchyNode.kt @@ -8,7 +8,6 @@ import androidx.compose.ui.graphics.isUnspecified import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.layout.LayoutCoordinates import androidx.compose.ui.layout.findRootCoordinates -import androidx.compose.ui.layout.positionInWindow import androidx.compose.ui.node.LayoutNode import androidx.compose.ui.node.Owner import androidx.compose.ui.semantics.SemanticsActions @@ -87,12 +86,13 @@ internal object ComposeViewHierarchyNode { (semantics == null || !semantics.contains(SemanticsProperties.InvisibleToUser)) && visibleRect.height() > 0 && visibleRect.width() > 0 val isEditable = semantics?.contains(SemanticsActions.SetText) == true - val positionInWindow = node.coordinates.positionInWindow() return when { semantics?.contains(SemanticsProperties.Text) == true || isEditable -> { val shouldMask = isVisible && node.shouldMask(isImage = false, options) parent?.setImportantForCaptureToAncestors(true) + // TODO: if we get reports that it's slow, we can drop this, and just mask + // TODO: the whole view instead of per-line val textLayoutResults = mutableListOf() semantics?.getOrNull(SemanticsActions.GetTextLayoutResult) ?.action @@ -108,8 +108,8 @@ internal object ComposeViewHierarchyNode { TextViewHierarchyNode( layout = if (textLayoutResults.isNotEmpty() && !isEditable) ComposeTextLayout(textLayoutResults.first(), hasFillModifier) else null, dominantColor = textColor?.toArgb()?.toOpaque(), - x = positionInWindow.x, - y = positionInWindow.y, + x = visibleRect.left.toFloat(), + y = visibleRect.top.toFloat(), width = node.width, height = node.height, elevation = (parent?.elevation ?: 0f), @@ -128,8 +128,8 @@ internal object ComposeViewHierarchyNode { parent?.setImportantForCaptureToAncestors(true) ImageViewHierarchyNode( - x = positionInWindow.x, - y = positionInWindow.y, + x = visibleRect.left.toFloat(), + y = visibleRect.top.toFloat(), width = node.width, height = node.height, elevation = (parent?.elevation ?: 0f), @@ -147,8 +147,8 @@ internal object ComposeViewHierarchyNode { // TODO: traverse the ViewHierarchyNode here again. For now we can recommend // TODO: using custom modifiers to obscure the entire node if it's sensitive GenericViewHierarchyNode( - x = positionInWindow.x, - y = positionInWindow.y, + x = visibleRect.left.toFloat(), + y = visibleRect.top.toFloat(), width = node.width, height = node.height, elevation = (parent?.elevation ?: 0f), diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ViewHierarchyNode.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ViewHierarchyNode.kt index ef05ecb029..03cb37ad3e 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ViewHierarchyNode.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ViewHierarchyNode.kt @@ -239,8 +239,8 @@ sealed class ViewHierarchyNode( private fun Class<*>.isAssignableFrom(set: Set): Boolean { var cls: Class<*>? = this while (cls != null) { - val canonicalName = cls.canonicalName - if (canonicalName != null && set.contains(canonicalName)) { + val canonicalName = cls.name + if (set.contains(canonicalName)) { return true } cls = cls.superclass From 31f96ce3eb4e57cd0fd8591dc8d90c38590bdf56 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 17 Oct 2024 08:20:59 +0000 Subject: [PATCH 59/92] Bump codecov/codecov-action from 4.5.0 to 4.6.0 (#3768) Bumps [codecov/codecov-action](https://github.com/codecov/codecov-action) from 4.5.0 to 4.6.0. - [Release notes](https://github.com/codecov/codecov-action/releases) - [Changelog](https://github.com/codecov/codecov-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/codecov/codecov-action/compare/e28ff129e5465c2c0dcc6f003fc735cb6ae0c673...b9fd7d16f6d7d1b5d2bec1a2887e65ceed900238) --- updated-dependencies: - dependency-name: codecov/codecov-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> --- .github/workflows/build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f4b8d8431c..2ddd821739 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -35,7 +35,7 @@ jobs: run: make preMerge - name: Upload coverage to Codecov - uses: codecov/codecov-action@e28ff129e5465c2c0dcc6f003fc735cb6ae0c673 # pin@v4 + uses: codecov/codecov-action@b9fd7d16f6d7d1b5d2bec1a2887e65ceed900238 # pin@v4 with: name: sentry-java fail_ci_if_error: false From 2a8b4fef3ab168d7505cdc0f80c5dfbc485c34e7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 17 Oct 2024 08:28:56 +0000 Subject: [PATCH 60/92] Bump github/codeql-action from 3.26.8 to 3.26.12 (#3787) Bumps [github/codeql-action](https://github.com/github/codeql-action) from 3.26.8 to 3.26.12. - [Release notes](https://github.com/github/codeql-action/releases) - [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/github/codeql-action/compare/294a9d92911152fe08befb9ec03e240add280cb3...c36620d31ac7c881962c3d9dd939c40ec9434f2b) --- updated-dependencies: - dependency-name: github/codeql-action 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> --- .github/workflows/codeql-analysis.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index fa0bbbff64..10c73810e7 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -41,7 +41,7 @@ jobs: gradle-home-cache-cleanup: true - name: Initialize CodeQL - uses: github/codeql-action/init@294a9d92911152fe08befb9ec03e240add280cb3 # pin@v2 + uses: github/codeql-action/init@c36620d31ac7c881962c3d9dd939c40ec9434f2b # pin@v2 with: languages: ${{ matrix.language }} @@ -55,4 +55,4 @@ jobs: ./gradlew buildForCodeQL - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@294a9d92911152fe08befb9ec03e240add280cb3 # pin@v2 + uses: github/codeql-action/analyze@c36620d31ac7c881962c3d9dd939c40ec9434f2b # pin@v2 From 87bdc75724133fcfb97153e3d674fad90582d2c4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 17 Oct 2024 08:35:41 +0000 Subject: [PATCH 61/92] Bump gradle/actions (#3788) Bumps [gradle/actions](https://github.com/gradle/actions) from 0d30c9111cf47a838eb69c06d13f3f51ab2ed76f to bb0c460cbf5354b0cddd15bacdf0d6aaa3e5a32b. - [Release notes](https://github.com/gradle/actions/releases) - [Commits](https://github.com/gradle/actions/compare/0d30c9111cf47a838eb69c06d13f3f51ab2ed76f...bb0c460cbf5354b0cddd15bacdf0d6aaa3e5a32b) --- updated-dependencies: - dependency-name: gradle/actions dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/agp-matrix.yml | 2 +- .github/workflows/build.yml | 2 +- .github/workflows/codeql-analysis.yml | 2 +- .github/workflows/enforce-license-compliance.yml | 2 +- .github/workflows/generate-javadocs.yml | 2 +- .github/workflows/integration-tests-benchmarks.yml | 4 ++-- .github/workflows/integration-tests-ui.yml | 2 +- .github/workflows/release-build.yml | 2 +- .github/workflows/system-tests-backend.yml | 2 +- 9 files changed, 10 insertions(+), 10 deletions(-) diff --git a/.github/workflows/agp-matrix.yml b/.github/workflows/agp-matrix.yml index fdb3137209..182c0fa19b 100644 --- a/.github/workflows/agp-matrix.yml +++ b/.github/workflows/agp-matrix.yml @@ -38,7 +38,7 @@ jobs: java-version: '17' - name: Setup Gradle - uses: gradle/actions/setup-gradle@0d30c9111cf47a838eb69c06d13f3f51ab2ed76f # pin@v3 + uses: gradle/actions/setup-gradle@bb0c460cbf5354b0cddd15bacdf0d6aaa3e5a32b # pin@v3 with: gradle-home-cache-cleanup: true diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 2ddd821739..6b885942e9 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -27,7 +27,7 @@ jobs: java-version: '17' - name: Setup Gradle - uses: gradle/actions/setup-gradle@0d30c9111cf47a838eb69c06d13f3f51ab2ed76f # pin@v3 + uses: gradle/actions/setup-gradle@bb0c460cbf5354b0cddd15bacdf0d6aaa3e5a32b # pin@v3 with: gradle-home-cache-cleanup: true diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 10c73810e7..7ae4f25c65 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -36,7 +36,7 @@ jobs: java-version: '17' - name: Setup Gradle - uses: gradle/actions/setup-gradle@0d30c9111cf47a838eb69c06d13f3f51ab2ed76f # pin@v3 + uses: gradle/actions/setup-gradle@bb0c460cbf5354b0cddd15bacdf0d6aaa3e5a32b # pin@v3 with: gradle-home-cache-cleanup: true diff --git a/.github/workflows/enforce-license-compliance.yml b/.github/workflows/enforce-license-compliance.yml index c2ddec5865..2b87c1d278 100644 --- a/.github/workflows/enforce-license-compliance.yml +++ b/.github/workflows/enforce-license-compliance.yml @@ -11,7 +11,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Setup Gradle - uses: gradle/actions/setup-gradle@0d30c9111cf47a838eb69c06d13f3f51ab2ed76f # pin@v3 + uses: gradle/actions/setup-gradle@bb0c460cbf5354b0cddd15bacdf0d6aaa3e5a32b # pin@v3 with: gradle-home-cache-cleanup: true diff --git a/.github/workflows/generate-javadocs.yml b/.github/workflows/generate-javadocs.yml index dd171af5a2..2a167621f3 100644 --- a/.github/workflows/generate-javadocs.yml +++ b/.github/workflows/generate-javadocs.yml @@ -20,7 +20,7 @@ jobs: java-version: '17' - name: Setup Gradle - uses: gradle/actions/setup-gradle@0d30c9111cf47a838eb69c06d13f3f51ab2ed76f # pin@v3 + uses: gradle/actions/setup-gradle@bb0c460cbf5354b0cddd15bacdf0d6aaa3e5a32b # pin@v3 with: gradle-home-cache-cleanup: true diff --git a/.github/workflows/integration-tests-benchmarks.yml b/.github/workflows/integration-tests-benchmarks.yml index f0beaa60b5..58fc933752 100644 --- a/.github/workflows/integration-tests-benchmarks.yml +++ b/.github/workflows/integration-tests-benchmarks.yml @@ -37,7 +37,7 @@ jobs: java-version: '17' - name: Setup Gradle - uses: gradle/actions/setup-gradle@0d30c9111cf47a838eb69c06d13f3f51ab2ed76f # pin@v3 + uses: gradle/actions/setup-gradle@bb0c460cbf5354b0cddd15bacdf0d6aaa3e5a32b # pin@v3 with: gradle-home-cache-cleanup: true @@ -86,7 +86,7 @@ jobs: java-version: '17' - name: Setup Gradle - uses: gradle/actions/setup-gradle@0d30c9111cf47a838eb69c06d13f3f51ab2ed76f # pin@v3 + uses: gradle/actions/setup-gradle@bb0c460cbf5354b0cddd15bacdf0d6aaa3e5a32b # pin@v3 with: gradle-home-cache-cleanup: true diff --git a/.github/workflows/integration-tests-ui.yml b/.github/workflows/integration-tests-ui.yml index 771b4b5c8c..6c4ad6564f 100644 --- a/.github/workflows/integration-tests-ui.yml +++ b/.github/workflows/integration-tests-ui.yml @@ -32,7 +32,7 @@ jobs: java-version: '17' - name: Setup Gradle - uses: gradle/actions/setup-gradle@0d30c9111cf47a838eb69c06d13f3f51ab2ed76f # pin@v3 + uses: gradle/actions/setup-gradle@bb0c460cbf5354b0cddd15bacdf0d6aaa3e5a32b # pin@v3 with: gradle-home-cache-cleanup: true diff --git a/.github/workflows/release-build.yml b/.github/workflows/release-build.yml index cb6752bb93..83a3c82f8c 100644 --- a/.github/workflows/release-build.yml +++ b/.github/workflows/release-build.yml @@ -26,7 +26,7 @@ jobs: java-version: '17' - name: Setup Gradle - uses: gradle/actions/setup-gradle@0d30c9111cf47a838eb69c06d13f3f51ab2ed76f # pin@v3 + uses: gradle/actions/setup-gradle@bb0c460cbf5354b0cddd15bacdf0d6aaa3e5a32b # pin@v3 with: gradle-home-cache-cleanup: true diff --git a/.github/workflows/system-tests-backend.yml b/.github/workflows/system-tests-backend.yml index 31331229fe..0098644d97 100644 --- a/.github/workflows/system-tests-backend.yml +++ b/.github/workflows/system-tests-backend.yml @@ -40,7 +40,7 @@ jobs: java-version: '17' - name: Setup Gradle - uses: gradle/actions/setup-gradle@0d30c9111cf47a838eb69c06d13f3f51ab2ed76f # pin@v3 + uses: gradle/actions/setup-gradle@bb0c460cbf5354b0cddd15bacdf0d6aaa3e5a32b # pin@v3 with: gradle-home-cache-cleanup: true From a8c72dd392b42bca983d2e56a9e1753774b2683f Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Thu, 17 Oct 2024 10:45:41 +0200 Subject: [PATCH 62/92] Fix ensure ndk libs are loaded before calling close (#3797) --- .../main/java/io/sentry/android/ndk/SentryNdk.java | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/sentry-android-ndk/src/main/java/io/sentry/android/ndk/SentryNdk.java b/sentry-android-ndk/src/main/java/io/sentry/android/ndk/SentryNdk.java index 7245516b49..3429780eec 100644 --- a/sentry-android-ndk/src/main/java/io/sentry/android/ndk/SentryNdk.java +++ b/sentry-android-ndk/src/main/java/io/sentry/android/ndk/SentryNdk.java @@ -69,6 +69,15 @@ public static void init(@NotNull final SentryAndroidOptions options) { /** Closes the NDK integration */ public static void close() { - shutdown(); + try { + if (loadLibraryLatch.await(2000, TimeUnit.MILLISECONDS)) { + shutdown(); + } else { + throw new IllegalStateException("Timeout waiting for Sentry NDK library to load"); + } + } catch (InterruptedException e) { + throw new IllegalStateException( + "Thread interrupted while waiting for NDK libs to be loaded", e); + } } } From eb5d294f88fe988387ca8fb267d6315a45758c95 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcin=20Kozi=C5=84ski?= Date: Thu, 17 Oct 2024 10:57:50 +0200 Subject: [PATCH 63/92] docs(okhttp): update documented default value to match actual (#3800) Co-authored-by: Alexander Dinauer Co-authored-by: Stefano --- .../src/main/java/io/sentry/okhttp/SentryOkHttpInterceptor.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sentry-okhttp/src/main/java/io/sentry/okhttp/SentryOkHttpInterceptor.kt b/sentry-okhttp/src/main/java/io/sentry/okhttp/SentryOkHttpInterceptor.kt index 66a9fcc75c..601e6d56d4 100644 --- a/sentry-okhttp/src/main/java/io/sentry/okhttp/SentryOkHttpInterceptor.kt +++ b/sentry-okhttp/src/main/java/io/sentry/okhttp/SentryOkHttpInterceptor.kt @@ -33,7 +33,7 @@ import java.io.IOException * @param hub The [IHub], internal and only used for testing. * @param beforeSpan The [ISpan] can be customized or dropped with the [BeforeSpanCallback]. * @param captureFailedRequests The SDK will only capture HTTP Client errors if it is enabled, - * Defaults to false. + * Defaults to true. * @param failedRequestStatusCodes The SDK will only capture HTTP Client errors if the HTTP Response * status code is within the defined ranges. * @param failedRequestTargets The SDK will only capture HTTP Client errors if the HTTP Request URL From 143f91afb8523188e7319e3324b9d7064ba83a9f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 17 Oct 2024 09:03:52 +0000 Subject: [PATCH 64/92] Bump JamesIves/github-pages-deploy-action from 4.6.4 to 4.6.8 (#3728) Bumps [JamesIves/github-pages-deploy-action](https://github.com/jamesives/github-pages-deploy-action) from 4.6.4 to 4.6.8. - [Release notes](https://github.com/jamesives/github-pages-deploy-action/releases) - [Commits](https://github.com/jamesives/github-pages-deploy-action/compare/920cbb300dcd3f0568dbc42700c61e2fd9e6139c...881db5376404c5c8d621010bcbec0310b58d5e29) --- updated-dependencies: - dependency-name: JamesIves/github-pages-deploy-action 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> --- .github/workflows/generate-javadocs.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/generate-javadocs.yml b/.github/workflows/generate-javadocs.yml index 2a167621f3..b540441fb7 100644 --- a/.github/workflows/generate-javadocs.yml +++ b/.github/workflows/generate-javadocs.yml @@ -28,7 +28,7 @@ jobs: run: | ./gradlew aggregateJavadocs - name: Deploy - uses: JamesIves/github-pages-deploy-action@920cbb300dcd3f0568dbc42700c61e2fd9e6139c # pin@4.6.4 + uses: JamesIves/github-pages-deploy-action@881db5376404c5c8d621010bcbec0310b58d5e29 # pin@4.6.8 with: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} BRANCH: gh-pages From 9182d863db86e7fd645d03ad6d19b441930b1067 Mon Sep 17 00:00:00 2001 From: Stefano Date: Thu, 17 Oct 2024 11:45:58 +0200 Subject: [PATCH 65/92] [QA] Cache parsed Dsn (#3796) * parsed Dsn object is now cached * updated LazyEvaluator javadoc * setting a new dsn resets the cached parsed dsn --- CHANGELOG.md | 1 + sentry/api/sentry.api | 1 + sentry/src/main/java/io/sentry/Baggage.java | 6 +++--- sentry/src/main/java/io/sentry/DsnUtil.java | 2 +- .../java/io/sentry/RequestDetailsResolver.java | 2 +- sentry/src/main/java/io/sentry/Sentry.java | 4 ++-- .../src/main/java/io/sentry/SentryOptions.java | 17 ++++++++++++++++- .../main/java/io/sentry/util/LazyEvaluator.java | 13 ++++++++++++- .../java/io/sentry/util/LazyEvaluatorTest.kt | 14 ++++++++++++++ 9 files changed, 51 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 32cf654261..76776ce6a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ ### Fixes +- Cache parsed Dsn ([#3796](https://github.com/getsentry/sentry-java/pull/3796)) - fix invalid profiles when the transaction name is empty ([#3747](https://github.com/getsentry/sentry-java/pull/3747)) - Deprecate `enableTracing` option ([#3777](https://github.com/getsentry/sentry-java/pull/3777)) - Vendor `java.util.Random` and replace `java.security.SecureRandom` usages ([#3783](https://github.com/getsentry/sentry-java/pull/3783)) diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 5ca8c00da1..ed06b29b35 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -5703,6 +5703,7 @@ public final class io/sentry/util/JsonSerializationUtils { public final class io/sentry/util/LazyEvaluator { public fun (Lio/sentry/util/LazyEvaluator$Evaluator;)V public fun getValue ()Ljava/lang/Object; + public fun resetValue ()V public fun setValue (Ljava/lang/Object;)V } diff --git a/sentry/src/main/java/io/sentry/Baggage.java b/sentry/src/main/java/io/sentry/Baggage.java index d736358ee1..0a80b154bf 100644 --- a/sentry/src/main/java/io/sentry/Baggage.java +++ b/sentry/src/main/java/io/sentry/Baggage.java @@ -134,7 +134,7 @@ public static Baggage fromEvent( final Baggage baggage = new Baggage(options.getLogger()); final SpanContext trace = event.getContexts().getTrace(); baggage.setTraceId(trace != null ? trace.getTraceId().toString() : null); - baggage.setPublicKey(new Dsn(options.getDsn()).getPublicKey()); + baggage.setPublicKey(options.getParsedDsn().getPublicKey()); baggage.setRelease(event.getRelease()); baggage.setEnvironment(event.getEnvironment()); final User user = event.getUser(); @@ -405,7 +405,7 @@ public void setValuesFromTransaction( final @NotNull SentryOptions sentryOptions, final @Nullable TracesSamplingDecision samplingDecision) { setTraceId(transaction.getSpanContext().getTraceId().toString()); - setPublicKey(new Dsn(sentryOptions.getDsn()).getPublicKey()); + setPublicKey(sentryOptions.getParsedDsn().getPublicKey()); setRelease(sentryOptions.getRelease()); setEnvironment(sentryOptions.getEnvironment()); setUserSegment(user != null ? getSegment(user) : null); @@ -427,7 +427,7 @@ public void setValuesFromScope( final @Nullable User user = scope.getUser(); final @NotNull SentryId replayId = scope.getReplayId(); setTraceId(propagationContext.getTraceId().toString()); - setPublicKey(new Dsn(options.getDsn()).getPublicKey()); + setPublicKey(options.getParsedDsn().getPublicKey()); setRelease(options.getRelease()); setEnvironment(options.getEnvironment()); if (!SentryId.EMPTY_ID.equals(replayId)) { diff --git a/sentry/src/main/java/io/sentry/DsnUtil.java b/sentry/src/main/java/io/sentry/DsnUtil.java index 3c48e4a43e..6cc0dc360b 100644 --- a/sentry/src/main/java/io/sentry/DsnUtil.java +++ b/sentry/src/main/java/io/sentry/DsnUtil.java @@ -23,7 +23,7 @@ public static boolean urlContainsDsnHost(@Nullable SentryOptions options, @Nulla return false; } - final @NotNull Dsn dsn = new Dsn(dsnString); + final @NotNull Dsn dsn = options.getParsedDsn(); final @NotNull URI sentryUri = dsn.getSentryUri(); final @Nullable String dsnHost = sentryUri.getHost(); diff --git a/sentry/src/main/java/io/sentry/RequestDetailsResolver.java b/sentry/src/main/java/io/sentry/RequestDetailsResolver.java index b4474893a2..6083c69e99 100644 --- a/sentry/src/main/java/io/sentry/RequestDetailsResolver.java +++ b/sentry/src/main/java/io/sentry/RequestDetailsResolver.java @@ -21,7 +21,7 @@ public RequestDetailsResolver(final @NotNull SentryOptions options) { @NotNull RequestDetails resolve() { - final Dsn dsn = new Dsn(options.getDsn()); + final Dsn dsn = options.getParsedDsn(); final URI sentryUri = dsn.getSentryUri(); final String envelopeUrl = sentryUri.resolve(sentryUri.getPath() + "/envelope/").toString(); diff --git a/sentry/src/main/java/io/sentry/Sentry.java b/sentry/src/main/java/io/sentry/Sentry.java index 08571e151a..7c34720d19 100644 --- a/sentry/src/main/java/io/sentry/Sentry.java +++ b/sentry/src/main/java/io/sentry/Sentry.java @@ -381,8 +381,8 @@ private static boolean initConfigurations(final @NotNull SentryOptions options) "DSN is required. Use empty string or set enabled to false in SentryOptions to disable SDK."); } - @SuppressWarnings("unused") - final Dsn parsedDsn = new Dsn(dsn); + // This creates the DSN object and performs some checks + options.getParsedDsn(); ILogger logger = options.getLogger(); diff --git a/sentry/src/main/java/io/sentry/SentryOptions.java b/sentry/src/main/java/io/sentry/SentryOptions.java index 0eb3bace91..91a1372fed 100644 --- a/sentry/src/main/java/io/sentry/SentryOptions.java +++ b/sentry/src/main/java/io/sentry/SentryOptions.java @@ -82,6 +82,9 @@ public class SentryOptions { */ private @Nullable String dsn; + /** Parsed DSN to avoid parsing it every time. */ + private final @NotNull LazyEvaluator parsedDsn = new LazyEvaluator<>(() -> new Dsn(dsn)); + /** dsnHash is used as a subfolder of cacheDirPath to isolate events when rotating DSNs */ private @Nullable String dsnHash; @@ -529,7 +532,7 @@ public void addIntegration(@NotNull Integration integration) { } /** - * Returns the DSN + * Returns the DSN. * * @return the DSN or null if not set */ @@ -537,6 +540,17 @@ public void addIntegration(@NotNull Integration integration) { return dsn; } + /** + * Evaluates and parses the DSN. May throw an exception if the DSN is invalid. + * + * @return the parsed DSN or throws if dsn is invalid + */ + @ApiStatus.Internal + @NotNull + Dsn getParsedDsn() throws IllegalArgumentException { + return parsedDsn.getValue(); + } + /** * Sets the DSN * @@ -544,6 +558,7 @@ public void addIntegration(@NotNull Integration integration) { */ public void setDsn(final @Nullable String dsn) { this.dsn = dsn; + this.parsedDsn.resetValue(); dsnHash = StringUtils.calculateStringHash(this.dsn, logger); } diff --git a/sentry/src/main/java/io/sentry/util/LazyEvaluator.java b/sentry/src/main/java/io/sentry/util/LazyEvaluator.java index d540cbe508..8b3a6cce53 100644 --- a/sentry/src/main/java/io/sentry/util/LazyEvaluator.java +++ b/sentry/src/main/java/io/sentry/util/LazyEvaluator.java @@ -25,7 +25,8 @@ public LazyEvaluator(final @NotNull Evaluator evaluator) { } /** - * Executes the evaluator function and caches its result, so that it's called only once. + * Executes the evaluator function and caches its result, so that it's called only once, unless + * resetValue is called. * * @return The result of the evaluator function. */ @@ -48,6 +49,16 @@ public void setValue(final @Nullable T value) { } } + /** + * Resets the internal value and forces the evaluator function to be called the next time + * getValue() is called. + */ + public void resetValue() { + synchronized (this) { + this.value = null; + } + } + public interface Evaluator { @NotNull T evaluate(); diff --git a/sentry/src/test/java/io/sentry/util/LazyEvaluatorTest.kt b/sentry/src/test/java/io/sentry/util/LazyEvaluatorTest.kt index 8f0e3bc0a7..a238205405 100644 --- a/sentry/src/test/java/io/sentry/util/LazyEvaluatorTest.kt +++ b/sentry/src/test/java/io/sentry/util/LazyEvaluatorTest.kt @@ -38,4 +38,18 @@ class LazyEvaluatorTest { assertEquals(1, evaluator.value) assertEquals(1, fixture.count) } + + @Test + fun `evaluates again after resetValue`() { + val evaluator = fixture.getSut() + assertEquals(0, fixture.count) + assertEquals(1, evaluator.value) + assertEquals(1, evaluator.value) + assertEquals(1, fixture.count) + // Evaluate again, only once + evaluator.resetValue() + assertEquals(2, evaluator.value) + assertEquals(2, evaluator.value) + assertEquals(2, fixture.count) + } } From f14a2996d18d2973bdb511c584966b63bb1cf99c Mon Sep 17 00:00:00 2001 From: getsentry-bot Date: Thu, 17 Oct 2024 12:31:48 +0000 Subject: [PATCH 66/92] release: 7.16.0-alpha.1 --- CHANGELOG.md | 2 +- gradle.properties | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 76776ce6a7..7cf8b80cac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## Unreleased +## 7.16.0-alpha.1 ### Features diff --git a/gradle.properties b/gradle.properties index 170db6c944..73dc376308 100644 --- a/gradle.properties +++ b/gradle.properties @@ -10,7 +10,7 @@ android.useAndroidX=true android.defaults.buildfeatures.buildconfig=true # Release information -versionName=7.15.0 +versionName=7.16.0-alpha.1 # Override the SDK name on native crashes on Android sentryAndroidSdkName=sentry.native.android From 0132cddd4dc4f595e45fb47526244ad32cee1c4f Mon Sep 17 00:00:00 2001 From: Stefano Date: Thu, 17 Oct 2024 15:42:41 +0200 Subject: [PATCH 67/92] [QA] Replace setOf with HashSet.add (#3801) * remove kotlin setOf() and replace it with HashSet<>() + add() --- CHANGELOG.md | 1 + .../api/sentry-android-fragment.api | 5 +++++ .../fragment/FragmentLifecycleIntegration.kt | 4 ++-- .../android/fragment/FragmentLifecycleState.kt | 18 +++++++++++++++++- .../SentryFragmentLifecycleCallbacks.kt | 4 ++-- .../fragment/FragmentLifecycleStateTest.kt | 11 +++++++++++ .../SentryFragmentLifecycleCallbacksTest.kt | 2 +- .../replay/DefaultReplayBreadcrumbConverter.kt | 16 +++++++--------- 8 files changed, 46 insertions(+), 15 deletions(-) create mode 100644 sentry-android-fragment/src/test/java/io/sentry/android/fragment/FragmentLifecycleStateTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 7cf8b80cac..c7e5c4149b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ ### Fixes +- Replace setOf with HashSet.add ([#3801](https://github.com/getsentry/sentry-java/pull/3801)) - Cache parsed Dsn ([#3796](https://github.com/getsentry/sentry-java/pull/3796)) - fix invalid profiles when the transaction name is empty ([#3747](https://github.com/getsentry/sentry-java/pull/3747)) - Deprecate `enableTracing` option ([#3777](https://github.com/getsentry/sentry-java/pull/3777)) diff --git a/sentry-android-fragment/api/sentry-android-fragment.api b/sentry-android-fragment/api/sentry-android-fragment.api index 4b3487c36e..d850abedca 100644 --- a/sentry-android-fragment/api/sentry-android-fragment.api +++ b/sentry-android-fragment/api/sentry-android-fragment.api @@ -24,6 +24,7 @@ public final class io/sentry/android/fragment/FragmentLifecycleIntegration : and public final class io/sentry/android/fragment/FragmentLifecycleState : java/lang/Enum { public static final field ATTACHED Lio/sentry/android/fragment/FragmentLifecycleState; public static final field CREATED Lio/sentry/android/fragment/FragmentLifecycleState; + public static final field Companion Lio/sentry/android/fragment/FragmentLifecycleState$Companion; public static final field DESTROYED Lio/sentry/android/fragment/FragmentLifecycleState; public static final field DETACHED Lio/sentry/android/fragment/FragmentLifecycleState; public static final field PAUSED Lio/sentry/android/fragment/FragmentLifecycleState; @@ -37,6 +38,10 @@ public final class io/sentry/android/fragment/FragmentLifecycleState : java/lang public static fun values ()[Lio/sentry/android/fragment/FragmentLifecycleState; } +public final class io/sentry/android/fragment/FragmentLifecycleState$Companion { + public final fun getStates ()Ljava/util/HashSet; +} + public final class io/sentry/android/fragment/SentryFragmentLifecycleCallbacks : androidx/fragment/app/FragmentManager$FragmentLifecycleCallbacks { public static final field Companion Lio/sentry/android/fragment/SentryFragmentLifecycleCallbacks$Companion; public static final field FRAGMENT_LOAD_OP Ljava/lang/String; diff --git a/sentry-android-fragment/src/main/java/io/sentry/android/fragment/FragmentLifecycleIntegration.kt b/sentry-android-fragment/src/main/java/io/sentry/android/fragment/FragmentLifecycleIntegration.kt index 30aa306dc1..bd94c58684 100644 --- a/sentry-android-fragment/src/main/java/io/sentry/android/fragment/FragmentLifecycleIntegration.kt +++ b/sentry-android-fragment/src/main/java/io/sentry/android/fragment/FragmentLifecycleIntegration.kt @@ -24,7 +24,7 @@ class FragmentLifecycleIntegration( constructor(application: Application) : this( application = application, - filterFragmentLifecycleBreadcrumbs = FragmentLifecycleState.values().toSet(), + filterFragmentLifecycleBreadcrumbs = FragmentLifecycleState.states, enableAutoFragmentLifecycleTracing = false ) @@ -34,7 +34,7 @@ class FragmentLifecycleIntegration( enableAutoFragmentLifecycleTracing: Boolean ) : this( application = application, - filterFragmentLifecycleBreadcrumbs = FragmentLifecycleState.values().toSet() + filterFragmentLifecycleBreadcrumbs = FragmentLifecycleState.states .takeIf { enableFragmentLifecycleBreadcrumbs } .orEmpty(), enableAutoFragmentLifecycleTracing = enableAutoFragmentLifecycleTracing diff --git a/sentry-android-fragment/src/main/java/io/sentry/android/fragment/FragmentLifecycleState.kt b/sentry-android-fragment/src/main/java/io/sentry/android/fragment/FragmentLifecycleState.kt index cdc5ea999d..fd52437d60 100644 --- a/sentry-android-fragment/src/main/java/io/sentry/android/fragment/FragmentLifecycleState.kt +++ b/sentry-android-fragment/src/main/java/io/sentry/android/fragment/FragmentLifecycleState.kt @@ -11,5 +11,21 @@ enum class FragmentLifecycleState(internal val breadcrumbName: String) { STOPPED("stopped"), VIEW_DESTROYED("view destroyed"), DESTROYED("destroyed"), - DETACHED("detached") + DETACHED("detached"); + + companion object { + val states = HashSet().apply { + add(ATTACHED) + add(SAVE_INSTANCE_STATE) + add(CREATED) + add(VIEW_CREATED) + add(STARTED) + add(RESUMED) + add(PAUSED) + add(STOPPED) + add(VIEW_DESTROYED) + add(DESTROYED) + add(DETACHED) + } + } } diff --git a/sentry-android-fragment/src/main/java/io/sentry/android/fragment/SentryFragmentLifecycleCallbacks.kt b/sentry-android-fragment/src/main/java/io/sentry/android/fragment/SentryFragmentLifecycleCallbacks.kt index 18468b99c1..983d17464b 100644 --- a/sentry-android-fragment/src/main/java/io/sentry/android/fragment/SentryFragmentLifecycleCallbacks.kt +++ b/sentry-android-fragment/src/main/java/io/sentry/android/fragment/SentryFragmentLifecycleCallbacks.kt @@ -31,7 +31,7 @@ class SentryFragmentLifecycleCallbacks( enableAutoFragmentLifecycleTracing: Boolean ) : this( hub = hub, - filterFragmentLifecycleBreadcrumbs = FragmentLifecycleState.values().toSet() + filterFragmentLifecycleBreadcrumbs = FragmentLifecycleState.states .takeIf { enableFragmentLifecycleBreadcrumbs } .orEmpty(), enableAutoFragmentLifecycleTracing = enableAutoFragmentLifecycleTracing @@ -42,7 +42,7 @@ class SentryFragmentLifecycleCallbacks( enableAutoFragmentLifecycleTracing: Boolean = false ) : this( hub = HubAdapter.getInstance(), - filterFragmentLifecycleBreadcrumbs = FragmentLifecycleState.values().toSet() + filterFragmentLifecycleBreadcrumbs = FragmentLifecycleState.states .takeIf { enableFragmentLifecycleBreadcrumbs } .orEmpty(), enableAutoFragmentLifecycleTracing = enableAutoFragmentLifecycleTracing diff --git a/sentry-android-fragment/src/test/java/io/sentry/android/fragment/FragmentLifecycleStateTest.kt b/sentry-android-fragment/src/test/java/io/sentry/android/fragment/FragmentLifecycleStateTest.kt new file mode 100644 index 0000000000..b5ab1f19d2 --- /dev/null +++ b/sentry-android-fragment/src/test/java/io/sentry/android/fragment/FragmentLifecycleStateTest.kt @@ -0,0 +1,11 @@ +package io.sentry.android.fragment + +import kotlin.test.Test +import kotlin.test.assertEquals + +class FragmentLifecycleStateTest { + @Test + fun `states contains all states`() { + assertEquals(FragmentLifecycleState.states, FragmentLifecycleState.values().toSet()) + } +} diff --git a/sentry-android-fragment/src/test/java/io/sentry/android/fragment/SentryFragmentLifecycleCallbacksTest.kt b/sentry-android-fragment/src/test/java/io/sentry/android/fragment/SentryFragmentLifecycleCallbacksTest.kt index 812d78de30..249d40d299 100644 --- a/sentry-android-fragment/src/test/java/io/sentry/android/fragment/SentryFragmentLifecycleCallbacksTest.kt +++ b/sentry-android-fragment/src/test/java/io/sentry/android/fragment/SentryFragmentLifecycleCallbacksTest.kt @@ -40,7 +40,7 @@ class SentryFragmentLifecycleCallbacksTest { val span = mock() fun getSut( - loggedFragmentLifecycleStates: Set = FragmentLifecycleState.values().toSet(), + loggedFragmentLifecycleStates: Set = FragmentLifecycleState.states, enableAutoFragmentLifecycleTracing: Boolean = false, tracesSampleRate: Double? = 1.0, isAdded: Boolean = true diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/DefaultReplayBreadcrumbConverter.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/DefaultReplayBreadcrumbConverter.kt index 1a6f3fed37..d5c666b5b0 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/DefaultReplayBreadcrumbConverter.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/DefaultReplayBreadcrumbConverter.kt @@ -12,15 +12,13 @@ import kotlin.LazyThreadSafetyMode.NONE public open class DefaultReplayBreadcrumbConverter : ReplayBreadcrumbConverter { internal companion object { private val snakecasePattern by lazy(NONE) { "_[a-z]".toRegex() } - private val supportedNetworkData by lazy(NONE) { - setOf( - "status_code", - "method", - "response_content_length", - "request_content_length", - "http.response_content_length", - "http.request_content_length" - ) + private val supportedNetworkData = HashSet().apply { + add("status_code") + add("method") + add("response_content_length") + add("request_content_length") + add("http.response_content_length") + add("http.request_content_length") } } From 0cc92623d159ad340653a539366b226986e20090 Mon Sep 17 00:00:00 2001 From: Stefano Date: Fri, 18 Oct 2024 10:47:11 +0200 Subject: [PATCH 68/92] [QA] Load lazy fields on init in the background (#3803) * Lazy fields in SentryOptions are now loaded on init in the background * use option's executor instead of a new thread to load fields --- CHANGELOG.md | 8 +++++++- sentry/src/main/java/io/sentry/Sentry.java | 16 ++++++++++++++++ .../src/main/java/io/sentry/SentryOptions.java | 11 +++++++++++ 3 files changed, 34 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c7e5c4149b..9eb6d75604 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## Unreleased + +### Fixes + +- Load lazy fields on init in the background ([#3803](https://github.com/getsentry/sentry-java/pull/3803)) +- Replace setOf with HashSet.add ([#3801](https://github.com/getsentry/sentry-java/pull/3801)) + ## 7.16.0-alpha.1 ### Features @@ -8,7 +15,6 @@ ### Fixes -- Replace setOf with HashSet.add ([#3801](https://github.com/getsentry/sentry-java/pull/3801)) - Cache parsed Dsn ([#3796](https://github.com/getsentry/sentry-java/pull/3796)) - fix invalid profiles when the transaction name is empty ([#3747](https://github.com/getsentry/sentry-java/pull/3747)) - Deprecate `enableTracing` option ([#3777](https://github.com/getsentry/sentry-java/pull/3777)) diff --git a/sentry/src/main/java/io/sentry/Sentry.java b/sentry/src/main/java/io/sentry/Sentry.java index 7c34720d19..6e4a2530a7 100644 --- a/sentry/src/main/java/io/sentry/Sentry.java +++ b/sentry/src/main/java/io/sentry/Sentry.java @@ -217,6 +217,10 @@ public static void init(final @NotNull SentryOptions options) { * @param options options the SentryOptions * @param globalHubMode the globalHubMode */ + @SuppressWarnings({ + "Convert2MethodRef", + "FutureReturnValueIgnored" + }) // older AGP versions do not support method references private static synchronized void init( final @NotNull SentryOptions options, final boolean globalHubMode) { if (isEnabled()) { @@ -231,6 +235,18 @@ private static synchronized void init( return; } + // load lazy fields of the options in a separate thread + try { + options.getExecutorService().submit(() -> options.loadLazyFields()); + } catch (RejectedExecutionException e) { + options + .getLogger() + .log( + SentryLevel.DEBUG, + "Failed to call the executor. Lazy fields will not be loaded. Did you call Sentry.close()?", + e); + } + options.getLogger().log(SentryLevel.INFO, "GlobalHubMode: '%s'", String.valueOf(globalHubMode)); Sentry.globalHubMode = globalHubMode; diff --git a/sentry/src/main/java/io/sentry/SentryOptions.java b/sentry/src/main/java/io/sentry/SentryOptions.java index 91a1372fed..3c286f2bff 100644 --- a/sentry/src/main/java/io/sentry/SentryOptions.java +++ b/sentry/src/main/java/io/sentry/SentryOptions.java @@ -2451,6 +2451,17 @@ public void setEnableScreenTracking(final boolean enableScreenTracking) { this.enableScreenTracking = enableScreenTracking; } + /** + * Load the lazy fields. Useful to load in the background, so that results are already cached. DO + * NOT CALL THIS METHOD ON THE MAIN THREAD. + */ + void loadLazyFields() { + getSerializer(); + getParsedDsn(); + getEnvelopeReader(); + getDateProvider(); + } + /** The BeforeSend callback */ public interface BeforeSendCallback { From 4988d5bf9e0a5be708853cf53d07b8503e4c77be Mon Sep 17 00:00:00 2001 From: Krystof Woldrich <31292499+krystofwoldrich@users.noreply.github.com> Date: Mon, 21 Oct 2024 16:37:40 +0200 Subject: [PATCH 69/92] test(ui): Add critical tests run by Maestro (#3802) --- .../integration-tests-ui-critical.yml | 106 ++++++++++++++++++ .github/workflows/system-tests-backend.yml | 33 +++++- Makefile | 10 +- build.gradle.kts | 1 + scripts/test-ui-critical.sh | 35 ++++++ .../sentry-uitest-android-critical/.gitignore | 2 + .../build.gradle.kts | 69 ++++++++++++ .../maestro/corruptEnvelope.yaml | 11 ++ .../maestro/crash.yaml | 6 + .../proguard-rules.pro | 21 ++++ .../src/main/AndroidManifest.xml | 21 ++++ .../uitest/android/critical/MainActivity.kt | 51 +++++++++ settings.gradle.kts | 1 + 13 files changed, 363 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/integration-tests-ui-critical.yml create mode 100755 scripts/test-ui-critical.sh create mode 100644 sentry-android-integration-tests/sentry-uitest-android-critical/.gitignore create mode 100644 sentry-android-integration-tests/sentry-uitest-android-critical/build.gradle.kts create mode 100644 sentry-android-integration-tests/sentry-uitest-android-critical/maestro/corruptEnvelope.yaml create mode 100644 sentry-android-integration-tests/sentry-uitest-android-critical/maestro/crash.yaml create mode 100644 sentry-android-integration-tests/sentry-uitest-android-critical/proguard-rules.pro create mode 100644 sentry-android-integration-tests/sentry-uitest-android-critical/src/main/AndroidManifest.xml create mode 100644 sentry-android-integration-tests/sentry-uitest-android-critical/src/main/java/io/sentry/uitest/android/critical/MainActivity.kt diff --git a/.github/workflows/integration-tests-ui-critical.yml b/.github/workflows/integration-tests-ui-critical.yml new file mode 100644 index 0000000000..6729e40ca5 --- /dev/null +++ b/.github/workflows/integration-tests-ui-critical.yml @@ -0,0 +1,106 @@ +name: UI Tests Critical + +on: + push: + branches: + - main + pull_request: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + BASE_PATH: "sentry-android-integration-tests/sentry-uitest-android-critical" + BUILD_PATH: "build/outputs/apk/release" + APK_NAME: "sentry-uitest-android-critical-release.apk" + APK_ARTIFACT_NAME: "sentry-uitest-android-critical-release" + MAESTRO_VERSION: "1.39.0" + +jobs: + build: + name: Build sentry-uitest-android-critical + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Java 17 + uses: actions/setup-java@v4 + with: + distribution: 'temurin' + java-version: '17' + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@bb0c460cbf5354b0cddd15bacdf0d6aaa3e5a32b # pin@v3 + with: + gradle-home-cache-cleanup: true + + - name: Build debug APK + run: make assembleUiTestCriticalRelease + + - name: Upload APK artifact + uses: actions/upload-artifact@v4 + with: + name: ${{env.APK_ARTIFACT_NAME}} + path: "${{env.BASE_PATH}}/${{env.BUILD_PATH}}/${{env.APK_NAME}}" + retention-days: 1 + + run-maestro-tests: + name: Run Maestro Tests + needs: build + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup KVM + shell: bash + run: | + # check if virtualization is supported... + sudo apt install -y --no-install-recommends cpu-checker coreutils && echo "CPUs=$(nproc --all)" && kvm-ok + # allow access to KVM to run the emulator + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' \ + | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + + - name: Download APK artifact + uses: actions/download-artifact@v4 + with: + name: ${{env.APK_ARTIFACT_NAME}} + + - name: Install Maestro + uses: dniHze/maestro-test-action@bda8a93211c86d0a05b7a4597c5ad134566fbde4 # pin@v1.0.0 + with: + version: ${{env.MAESTRO_VERSION}} + + - name: Run tests + uses: reactivecircus/android-emulator-runner@f0d1ed2dcad93c7479e8b2f2226c83af54494915 # pin@v2.32.0 + with: + api-level: 30 + force-avd-creation: false + disable-animations: true + disable-spellchecker: true + target: 'aosp_atd' + channel: canary # Necessary for ATDs + emulator-options: > + -no-window + -no-snapshot-save + -gpu swiftshader_indirect + -noaudio + -no-boot-anim + -camera-back none + -camera-front none + -timezone US/Pacific + script: | + adb install -r -d "${{env.APK_NAME}}" + maestro test "${{env.BASE_PATH}}/maestro" --debug-output "${{env.BASE_PATH}}/maestro-logs" + + - name: Upload Maestro test results + if: failure() + uses: actions/upload-artifact@v4 + with: + name: maestro-logs + path: "${{env.BASE_PATH}}/maestro-logs" + retention-days: 1 diff --git a/.github/workflows/system-tests-backend.yml b/.github/workflows/system-tests-backend.yml index 0098644d97..2222f910ad 100644 --- a/.github/workflows/system-tests-backend.yml +++ b/.github/workflows/system-tests-backend.yml @@ -46,11 +46,33 @@ jobs: - name: Exclude android modules from build run: | - sed -i -e '/.*"sentry-android-ndk",/d' -e '/.*"sentry-android",/d' -e '/.*"sentry-compose",/d' -e '/.*"sentry-android-core",/d' -e '/.*"sentry-android-fragment",/d' -e '/.*"sentry-android-navigation",/d' -e '/.*"sentry-android-okhttp",/d' -e '/.*"sentry-android-sqlite",/d' -e '/.*"sentry-android-timber",/d' -e '/.*"sentry-android-integration-tests:sentry-uitest-android-benchmark",/d' -e '/.*"sentry-android-integration-tests:sentry-uitest-android",/d' -e '/.*"sentry-android-integration-tests:test-app-sentry",/d' -e '/.*"sentry-samples:sentry-samples-android",/d' -e '/.*"sentry-android-replay",/d' settings.gradle.kts + sed -i \ + -e '/.*"sentry-android-ndk",/d' \ + -e '/.*"sentry-android",/d' \ + -e '/.*"sentry-compose",/d' \ + -e '/.*"sentry-android-core",/d' \ + -e '/.*"sentry-android-fragment",/d' \ + -e '/.*"sentry-android-navigation",/d' \ + -e '/.*"sentry-android-okhttp",/d' \ + -e '/.*"sentry-android-sqlite",/d' \ + -e '/.*"sentry-android-timber",/d' \ + -e '/.*"sentry-android-integration-tests:sentry-uitest-android-benchmark",/d' \ + -e '/.*"sentry-android-integration-tests:sentry-uitest-android",/d' \ + -e '/.*"sentry-android-integration-tests:sentry-uitest-android-critical",/d' \ + -e '/.*"sentry-android-integration-tests:test-app-sentry",/d' \ + -e '/.*"sentry-samples:sentry-samples-android",/d' \ + -e '/.*"sentry-android-replay",/d' \ + settings.gradle.kts - name: Exclude android modules from ignore list run: | - sed -i -e '/.*"sentry-uitest-android",/d' -e '/.*"sentry-uitest-android-benchmark",/d' -e '/.*"test-app-sentry",/d' -e '/.*"sentry-samples-android",/d' build.gradle.kts + sed -i \ + -e '/.*"sentry-uitest-android",/d' \ + -e '/.*"sentry-uitest-android-benchmark",/d' \ + -e '/.*"sentry-uitest-android-critical",/d' \ + -e '/.*"test-app-sentry",/d' \ + -e '/.*"sentry-samples-android",/d' \ + build.gradle.kts - name: Build server jar run: | @@ -58,7 +80,12 @@ jobs: - name: Start server and run integration test for sentry-cli commands run: | - test/system-test-sentry-server-start.sh > sentry-mock-server.txt 2>&1 & test/system-test-spring-server-start.sh "${{ matrix.sample }}" > spring-server.txt 2>&1 & test/wait-for-spring.sh && ./gradlew :sentry-samples:${{ matrix.sample }}:systemTest + test/system-test-sentry-server-start.sh \ + > sentry-mock-server.txt 2>&1 & \ + test/system-test-spring-server-start.sh "${{ matrix.sample }}" \ + > spring-server.txt 2>&1 & \ + test/wait-for-spring.sh && \ + ./gradlew :sentry-samples:${{ matrix.sample }}:systemTest - name: Upload test results if: always() diff --git a/Makefile b/Makefile index 2117e6da21..62e6e258f3 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: all clean compile javadocs dryRelease update stop checkFormat format api assembleBenchmarkTestRelease assembleUiTestRelease createCoverageReports check preMerge publish +.PHONY: all clean compile javadocs dryRelease update stop checkFormat format api assembleBenchmarkTestRelease assembleUiTestRelease assembleUiTestCriticalRelease createCoverageReports runUiTestCritical check preMerge publish all: stop clean javadocs compile createCoverageReports assembleBenchmarks: assembleBenchmarkTestRelease @@ -53,6 +53,14 @@ assembleUiTestRelease: ./gradlew :sentry-android-integration-tests:sentry-uitest-android:assembleRelease ./gradlew :sentry-android-integration-tests:sentry-uitest-android:assembleAndroidTest -DtestBuildType=release +# Assemble release of the uitest-android-critical module +assembleUiTestCriticalRelease: + ./gradlew :sentry-android-integration-tests:sentry-uitest-android-critical:assembleRelease + +# Run Maestro tests for the uitest-android-critical module +runUiTestCritical: + ./scripts/test-ui-critical.sh + # Create coverage reports # - Jacoco for Java & Android modules # - Kover for KMP modules e.g sentry-compose diff --git a/build.gradle.kts b/build.gradle.kts index 7985a55486..9d53252562 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -68,6 +68,7 @@ apiValidation { "sentry-samples-spring-boot-webflux-jakarta", "sentry-uitest-android", "sentry-uitest-android-benchmark", + "sentry-uitest-android-critical", "test-app-plain", "test-app-sentry", "sentry-samples-netflix-dgs" diff --git a/scripts/test-ui-critical.sh b/scripts/test-ui-critical.sh new file mode 100755 index 0000000000..7bb36eebec --- /dev/null +++ b/scripts/test-ui-critical.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +set -e + +echo "Checking if ADB is installed..." +if ! command -v adb &> /dev/null; then + echo "ADB is not installed or not in PATH. Please install Android SDK platform tools and ensure ADB is in your PATH." + exit 1 +fi + +echo "Checking if an Android emulator is running..." +if ! adb devices | grep -q "emulator"; then + echo "No Android emulator is currently running. Please start an emulator before running this script." + exit 1 +fi + +echo "Checking if Maestro is installed..." +if ! command -v maestro &> /dev/null; then + echo "Maestro is not installed. Please install Maestro before running this script." + exit 1 +fi + +echo "Building the UI Test Critical app..." +make assembleUiTestCriticalRelease + +echo "Installing the UI Test Critical app on the emulator..." +baseDir="sentry-android-integration-tests/sentry-uitest-android-critical" +buildDir="build/outputs/apk/release" +apkName="sentry-uitest-android-critical-release.apk" +appPath="${baseDir}/${buildDir}/${apkName}" +adb install -r -d "$appPath" + +echo "Running the Maestro tests..." +maestro test \ + "${baseDir}/maestro" \ + --debug-output "${baseDir}/maestro-logs" diff --git a/sentry-android-integration-tests/sentry-uitest-android-critical/.gitignore b/sentry-android-integration-tests/sentry-uitest-android-critical/.gitignore new file mode 100644 index 0000000000..48fc28dcf5 --- /dev/null +++ b/sentry-android-integration-tests/sentry-uitest-android-critical/.gitignore @@ -0,0 +1,2 @@ +/build +/maestro-logs diff --git a/sentry-android-integration-tests/sentry-uitest-android-critical/build.gradle.kts b/sentry-android-integration-tests/sentry-uitest-android-critical/build.gradle.kts new file mode 100644 index 0000000000..cebf744a24 --- /dev/null +++ b/sentry-android-integration-tests/sentry-uitest-android-critical/build.gradle.kts @@ -0,0 +1,69 @@ +import io.gitlab.arturbosch.detekt.Detekt + +plugins { + id("com.android.application") + kotlin("android") +} + +android { + compileSdk = Config.Android.compileSdkVersion + namespace = "io.sentry.uitest.android.critical" + + signingConfigs { + getByName("debug") { + // Debug config remains unchanged + } + } + + defaultConfig { + applicationId = "io.sentry.uitest.android.critical" + minSdk = Config.Android.minSdkVersionCompose + targetSdk = Config.Android.targetSdkVersion + versionCode = 1 + versionName = "1.0" + } + + buildTypes { + release { + isMinifyEnabled = false + signingConfig = signingConfigs.getByName("debug") + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + kotlinOptions { + jvmTarget = JavaVersion.VERSION_1_8.toString() + } + buildFeatures { + compose = true + } + composeOptions { + kotlinCompilerExtensionVersion = Config.androidComposeCompilerVersion + } + variantFilter { + if (Config.Android.shouldSkipDebugVariant(buildType.name)) { + ignore = true + } + } +} + +dependencies { + implementation(kotlin(Config.kotlinStdLib, org.jetbrains.kotlin.config.KotlinCompilerVersion.VERSION)) + implementation(Config.Libs.androidxCore) + implementation(Config.Libs.composeActivity) + implementation(Config.Libs.composeFoundation) + implementation(Config.Libs.composeMaterial) + implementation(Config.Libs.constraintLayout) + implementation(projects.sentryAndroidCore) +} + +tasks.withType { + // Target version of the generated JVM bytecode. It is used for type resolution. + jvmTarget = JavaVersion.VERSION_1_8.toString() +} + +kotlin { + explicitApi() +} diff --git a/sentry-android-integration-tests/sentry-uitest-android-critical/maestro/corruptEnvelope.yaml b/sentry-android-integration-tests/sentry-uitest-android-critical/maestro/corruptEnvelope.yaml new file mode 100644 index 0000000000..dec889731b --- /dev/null +++ b/sentry-android-integration-tests/sentry-uitest-android-critical/maestro/corruptEnvelope.yaml @@ -0,0 +1,11 @@ +appId: io.sentry.uitest.android.critical +--- +- launchApp +- tapOn: "Write Corrupted Envelope" +# The close here ensures the next corrupted envelope +# will be present on the next app launch +- tapOn: "Close SDK" +- tapOn: "Write Corrupted Envelope" +- stopApp +- launchApp +- assertVisible: "Welcome!" diff --git a/sentry-android-integration-tests/sentry-uitest-android-critical/maestro/crash.yaml b/sentry-android-integration-tests/sentry-uitest-android-critical/maestro/crash.yaml new file mode 100644 index 0000000000..f9543f365c --- /dev/null +++ b/sentry-android-integration-tests/sentry-uitest-android-critical/maestro/crash.yaml @@ -0,0 +1,6 @@ +appId: io.sentry.uitest.android.critical +--- +- launchApp +- tapOn: "Crash" +- launchApp +- assertVisible: "Welcome!" diff --git a/sentry-android-integration-tests/sentry-uitest-android-critical/proguard-rules.pro b/sentry-android-integration-tests/sentry-uitest-android-critical/proguard-rules.pro new file mode 100644 index 0000000000..f1b424510d --- /dev/null +++ b/sentry-android-integration-tests/sentry-uitest-android-critical/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile diff --git a/sentry-android-integration-tests/sentry-uitest-android-critical/src/main/AndroidManifest.xml b/sentry-android-integration-tests/sentry-uitest-android-critical/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..0ab5e6052d --- /dev/null +++ b/sentry-android-integration-tests/sentry-uitest-android-critical/src/main/AndroidManifest.xml @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + diff --git a/sentry-android-integration-tests/sentry-uitest-android-critical/src/main/java/io/sentry/uitest/android/critical/MainActivity.kt b/sentry-android-integration-tests/sentry-uitest-android-critical/src/main/java/io/sentry/uitest/android/critical/MainActivity.kt new file mode 100644 index 0000000000..8802f3dca2 --- /dev/null +++ b/sentry-android-integration-tests/sentry-uitest-android-critical/src/main/java/io/sentry/uitest/android/critical/MainActivity.kt @@ -0,0 +1,51 @@ +package io.sentry.uitest.android.critical + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.layout.Column +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import io.sentry.Sentry +import java.io.File + +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val outboxPath = Sentry.getCurrentHub().options.outboxPath + ?: throw RuntimeException("Outbox path is not set.") + + setContent { + MaterialTheme { + Surface() { + Column() { + Text(text = "Welcome!") + Button(onClick = { + throw RuntimeException("Crash the test app.") + }) { + Text("Crash") + } + Button(onClick = { + Sentry.close() + }) { + Text("Close SDK") + } + Button(onClick = { + val file = File(outboxPath, "corrupted.envelope") + val corruptedEnvelopeContent = """ + {"event_id":"1990b5bc31904b7395fd07feb72daf1c","sdk":{"name":"sentry.java.android","version":"7.21.0"}} + {"type":"test","length":50} + """.trimIndent() + file.writeText(corruptedEnvelopeContent) + println("Wrote corrupted envelope to: ${file.absolutePath}") + }) { + Text("Write Corrupted Envelope") + } + } + } + } + } + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 760c6e6905..77b3be021d 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -61,6 +61,7 @@ include( "sentry-samples:sentry-samples-spring-boot-webflux", "sentry-samples:sentry-samples-spring-boot-webflux-jakarta", "sentry-samples:sentry-samples-netflix-dgs", + "sentry-android-integration-tests:sentry-uitest-android-critical", "sentry-android-integration-tests:sentry-uitest-android-benchmark", "sentry-android-integration-tests:sentry-uitest-android", "sentry-android-integration-tests:test-app-plain", From 28b6ac91bec6b033b065a308e323020150a2a234 Mon Sep 17 00:00:00 2001 From: Krystof Woldrich <31292499+krystofwoldrich@users.noreply.github.com> Date: Wed, 23 Oct 2024 13:07:12 +0200 Subject: [PATCH 70/92] ci(build): Lower gradle workers to max 2 (#3814) --- gradle.properties | 3 +++ 1 file changed, 3 insertions(+) diff --git a/gradle.properties b/gradle.properties index 73dc376308..b333f4b906 100644 --- a/gradle.properties +++ b/gradle.properties @@ -3,6 +3,9 @@ org.gradle.jvmargs=-Xmx12g -XX:MaxMetaspaceSize=4g -XX:+CrashOnOutOfMemoryError org.gradle.caching=true org.gradle.parallel=true +# Daemons workers +org.gradle.workers.max=2 + # AndroidX required by AGP >= 3.6.x android.useAndroidX=true From 285450bbbbbd2f1769481620d5a78abd919bd9a9 Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Wed, 23 Oct 2024 14:35:17 +0200 Subject: [PATCH 71/92] Prepare Changelog for 7.16.0 release (#3816) --- CHANGELOG.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9eb6d75604..b8d4fa9f85 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,8 +2,21 @@ ## Unreleased +### Features + +- Add meta option to attach ANR thread dumps ([#3791](https://github.com/getsentry/sentry-java/pull/3791)) + ### Fixes +- Cache parsed Dsn ([#3796](https://github.com/getsentry/sentry-java/pull/3796)) +- fix invalid profiles when the transaction name is empty ([#3747](https://github.com/getsentry/sentry-java/pull/3747)) +- Deprecate `enableTracing` option ([#3777](https://github.com/getsentry/sentry-java/pull/3777)) +- Vendor `java.util.Random` and replace `java.security.SecureRandom` usages ([#3783](https://github.com/getsentry/sentry-java/pull/3783)) +- Fix potential ANRs due to NDK scope sync ([#3754](https://github.com/getsentry/sentry-java/pull/3754)) +- Fix potential ANRs due to NDK System.loadLibrary calls ([#3670](https://github.com/getsentry/sentry-java/pull/3670)) +- Fix slow `Log` calls on app startup ([#3793](https://github.com/getsentry/sentry-java/pull/3793)) +- Fix slow Integration name parsing ([#3794](https://github.com/getsentry/sentry-java/pull/3794)) +- Session Replay: Reduce startup and capture overhead ([#3799](https://github.com/getsentry/sentry-java/pull/3799)) - Load lazy fields on init in the background ([#3803](https://github.com/getsentry/sentry-java/pull/3803)) - Replace setOf with HashSet.add ([#3801](https://github.com/getsentry/sentry-java/pull/3801)) From 95f6443bdcdb65a2855452a8e6f9fa3bd524d5f7 Mon Sep 17 00:00:00 2001 From: getsentry-bot Date: Wed, 23 Oct 2024 12:36:13 +0000 Subject: [PATCH 72/92] release: 7.16.0 --- CHANGELOG.md | 2 +- gradle.properties | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b8d4fa9f85..c6d8bdfc83 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## Unreleased +## 7.16.0 ### Features diff --git a/gradle.properties b/gradle.properties index b333f4b906..4268e10785 100644 --- a/gradle.properties +++ b/gradle.properties @@ -13,7 +13,7 @@ android.useAndroidX=true android.defaults.buildfeatures.buildconfig=true # Release information -versionName=7.16.0-alpha.1 +versionName=7.16.0 # Override the SDK name on native crashes on Android sentryAndroidSdkName=sentry.native.android From 378d12c8be1519ed7caea8bb7db8bcb0ab8275a0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 25 Oct 2024 09:37:13 +0000 Subject: [PATCH 73/92] Bump github/codeql-action from 3.26.12 to 3.26.13 (#3808) Bumps [github/codeql-action](https://github.com/github/codeql-action) from 3.26.12 to 3.26.13. - [Release notes](https://github.com/github/codeql-action/releases) - [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/github/codeql-action/compare/c36620d31ac7c881962c3d9dd939c40ec9434f2b...f779452ac5af1c261dce0346a8f964149f49322b) --- updated-dependencies: - dependency-name: github/codeql-action 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> --- .github/workflows/codeql-analysis.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 7ae4f25c65..599b815657 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -41,7 +41,7 @@ jobs: gradle-home-cache-cleanup: true - name: Initialize CodeQL - uses: github/codeql-action/init@c36620d31ac7c881962c3d9dd939c40ec9434f2b # pin@v2 + uses: github/codeql-action/init@f779452ac5af1c261dce0346a8f964149f49322b # pin@v2 with: languages: ${{ matrix.language }} @@ -55,4 +55,4 @@ jobs: ./gradlew buildForCodeQL - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@c36620d31ac7c881962c3d9dd939c40ec9434f2b # pin@v2 + uses: github/codeql-action/analyze@f779452ac5af1c261dce0346a8f964149f49322b # pin@v2 From 771d13f99c77186b890e379bc9b27aae2a5bdd94 Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Mon, 28 Oct 2024 09:31:51 +0100 Subject: [PATCH 74/92] Bump androidx test libraries (#3743) --- buildSrc/src/main/java/Config.kt | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/buildSrc/src/main/java/Config.kt b/buildSrc/src/main/java/Config.kt index 8e4b6832fb..2715dd5767 100644 --- a/buildSrc/src/main/java/Config.kt +++ b/buildSrc/src/main/java/Config.kt @@ -177,18 +177,17 @@ object Config { } object TestLibs { - private val androidxTestVersion = "1.5.0" private val espressoVersion = "3.5.0" val androidJUnitRunner = "androidx.test.runner.AndroidJUnitRunner" val kotlinTestJunit = "org.jetbrains.kotlin:kotlin-test-junit:$kotlinVersion" - val androidxCore = "androidx.test:core:$androidxTestVersion" - val androidxRunner = "androidx.test:runner:$androidxTestVersion" - val androidxTestCoreKtx = "androidx.test:core-ktx:$androidxTestVersion" - val androidxTestRules = "androidx.test:rules:$androidxTestVersion" + val androidxCore = "androidx.test:core:1.6.1" + val androidxRunner = "androidx.test:runner:1.6.2" + val androidxTestCoreKtx = "androidx.test:core-ktx:1.6.1" + val androidxTestRules = "androidx.test:rules:1.6.1" val espressoCore = "androidx.test.espresso:espresso-core:$espressoVersion" val espressoIdlingResource = "androidx.test.espresso:espresso-idling-resource:$espressoVersion" - val androidxTestOrchestrator = "androidx.test:orchestrator:1.4.2" + val androidxTestOrchestrator = "androidx.test:orchestrator:1.5.0" val androidxJunit = "androidx.test.ext:junit:1.1.5" val androidxCoreKtx = "androidx.core:core-ktx:1.7.0" val robolectric = "org.robolectric:robolectric:4.10.3" From 92ab1d93a6b3de7c557e8aeb51c1506c216a51b6 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Mon, 28 Oct 2024 11:36:20 +0100 Subject: [PATCH 75/92] Add callout about `addIntegrationToSdkVersion` breaking change (#3829) --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c6d8bdfc83..676cf4da5c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,10 @@ - Load lazy fields on init in the background ([#3803](https://github.com/getsentry/sentry-java/pull/3803)) - Replace setOf with HashSet.add ([#3801](https://github.com/getsentry/sentry-java/pull/3801)) +### Breaking changes + +- The method `addIntegrationToSdkVersion(Ljava/lang/Class;)V` has been removed from the core (`io.sentry:sentry`) package. Please make sure all of the packages (e.g. `io.sentry:sentry-android-core`, `io.sentry:sentry-android-fragment`, `io.sentry:sentry-okhttp` and others) are all aligned and using the same version to prevent the `NoSuchMethodError` exception. + ## 7.16.0-alpha.1 ### Features From 283c6ccfe35090f5b96ff635bf08eae105305120 Mon Sep 17 00:00:00 2001 From: Stefano Date: Mon, 28 Oct 2024 12:59:32 +0100 Subject: [PATCH 76/92] Accept manifest integer values when requiring floating values (#3823) * ManifestMetadataReader now accepts integers other than floats --- CHANGELOG.md | 6 +++++ .../android/core/ManifestMetadataReader.java | 2 +- .../core/ManifestMetadataReaderTest.kt | 25 +++++++++++++++++++ .../src/main/AndroidManifest.xml | 4 +-- 4 files changed, 34 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 676cf4da5c..813e9e65bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +### Fixes + +- Accept manifest integer values when requiring floating values ([#3823](https://github.com/getsentry/sentry-java/pull/3823)) + ## 7.16.0 ### Features diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java b/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java index babcfdfc98..adfd4f22ad 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java @@ -492,7 +492,7 @@ private static boolean readBool( private static @NotNull Double readDouble( final @NotNull Bundle metadata, final @NotNull ILogger logger, final @NotNull String key) { // manifest meta-data only reads float - final Double value = ((Float) metadata.getFloat(key, -1)).doubleValue(); + final Double value = ((Number) metadata.getFloat(key, metadata.getInt(key, -1))).doubleValue(); logger.log(SentryLevel.DEBUG, key + " read: " + value); return value; } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt index 25b2e0191c..17f0f3950b 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt @@ -1515,4 +1515,29 @@ class ManifestMetadataReaderTest { assertTrue(fixture.options.experimental.sessionReplay.maskViewClasses.contains(SentryReplayOptions.IMAGE_VIEW_CLASS_NAME)) assertTrue(fixture.options.experimental.sessionReplay.maskViewClasses.contains(SentryReplayOptions.TEXT_VIEW_CLASS_NAME)) } + + @Test + fun `applyMetadata reads integers even when expecting floats`() { + // Arrange + val expectedSampleRate: Int = 1 + + val bundle = bundleOf( + ManifestMetadataReader.SAMPLE_RATE to expectedSampleRate, + ManifestMetadataReader.TRACES_SAMPLE_RATE to expectedSampleRate, + ManifestMetadataReader.PROFILES_SAMPLE_RATE to expectedSampleRate, + ManifestMetadataReader.REPLAYS_SESSION_SAMPLE_RATE to expectedSampleRate, + ManifestMetadataReader.REPLAYS_ERROR_SAMPLE_RATE to expectedSampleRate + ) + val context = fixture.getContext(metaData = bundle) + + // Act + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + // Assert + assertEquals(expectedSampleRate.toDouble(), fixture.options.sampleRate) + assertEquals(expectedSampleRate.toDouble(), fixture.options.tracesSampleRate) + assertEquals(expectedSampleRate.toDouble(), fixture.options.profilesSampleRate) + assertEquals(expectedSampleRate.toDouble(), fixture.options.experimental.sessionReplay.sessionSampleRate) + assertEquals(expectedSampleRate.toDouble(), fixture.options.experimental.sessionReplay.onErrorSampleRate) + } } diff --git a/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml b/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml index 058ad3710c..2327573a43 100644 --- a/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml +++ b/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml @@ -114,7 +114,7 @@ - + @@ -165,7 +165,7 @@ - + From 58da2a3ebb66b60b1c67584060ffb2f35182f9f2 Mon Sep 17 00:00:00 2001 From: Stefano Date: Mon, 28 Oct 2024 13:36:42 +0100 Subject: [PATCH 77/92] update benchmark devices on saucelabs (#3822) --- .sauce/sentry-uitest-android-benchmark.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.sauce/sentry-uitest-android-benchmark.yml b/.sauce/sentry-uitest-android-benchmark.yml index 6b8120cfa7..48737b5fa5 100644 --- a/.sauce/sentry-uitest-android-benchmark.yml +++ b/.sauce/sentry-uitest-android-benchmark.yml @@ -33,7 +33,7 @@ suites: useTestOrchestrator: true devices: - id: Samsung_Galaxy_S10_Plus_11_real_us # Samsung Galaxy S10+ - api 30 (11) - high end - - id: Samsung_Galaxy_A71_5G_real_us # Samsung Galaxy A71 5G - api 30 (11) - mid end + - id: Google_Pixel_4a_real_us # Google Pixel 4a - api 30 (11) - mid end - id: Google_Pixel_3a_real # Google Pixel 3a - api 30 (11) - low end - name: "Android 10 (api 29)" @@ -42,7 +42,7 @@ suites: useTestOrchestrator: true devices: - id: Google_Pixel_3a_XL_real # Google Pixel 3a XL - api 29 (10) - - id: Motorola_Moto_G_Power_real_us # Motorola Moto G Power - api 29 (10) + - id: OnePlus_6T_real # OnePlus 6T - api 29 (10) # At the time of writing (July, 4, 2022), the market share per android version is: # 12.0 = 17.54%, 11.0 = 31.65%, 10.0 = 21.92% From 4c5d0ff63726f2c31a0ebf46081e9bc8ced004ee Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 28 Oct 2024 12:45:31 +0000 Subject: [PATCH 78/92] Bump reactivecircus/android-emulator-runner from 2.32.0 to 2.33.0 (#3826) Bumps [reactivecircus/android-emulator-runner](https://github.com/reactivecircus/android-emulator-runner) from 2.32.0 to 2.33.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/v2.32.0...62dbb605bba737720e10b196cb4220d374026a6d) --- 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> --- .github/workflows/integration-tests-ui-critical.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/integration-tests-ui-critical.yml b/.github/workflows/integration-tests-ui-critical.yml index 6729e40ca5..d30b3f7045 100644 --- a/.github/workflows/integration-tests-ui-critical.yml +++ b/.github/workflows/integration-tests-ui-critical.yml @@ -76,7 +76,7 @@ jobs: version: ${{env.MAESTRO_VERSION}} - name: Run tests - uses: reactivecircus/android-emulator-runner@f0d1ed2dcad93c7479e8b2f2226c83af54494915 # pin@v2.32.0 + uses: reactivecircus/android-emulator-runner@62dbb605bba737720e10b196cb4220d374026a6d # pin@v2.33.0 with: api-level: 30 force-avd-creation: false From c362c98b2d0a0b1d25765922c415835d1dbfbc3c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 28 Oct 2024 12:50:37 +0000 Subject: [PATCH 79/92] Bump github/codeql-action from 3.26.13 to 3.27.0 (#3827) Bumps [github/codeql-action](https://github.com/github/codeql-action) from 3.26.13 to 3.27.0. - [Release notes](https://github.com/github/codeql-action/releases) - [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/github/codeql-action/compare/f779452ac5af1c261dce0346a8f964149f49322b...662472033e021d55d94146f66f6058822b0b39fd) --- updated-dependencies: - dependency-name: github/codeql-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> --- .github/workflows/codeql-analysis.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 599b815657..68855cbd8d 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -41,7 +41,7 @@ jobs: gradle-home-cache-cleanup: true - name: Initialize CodeQL - uses: github/codeql-action/init@f779452ac5af1c261dce0346a8f964149f49322b # pin@v2 + uses: github/codeql-action/init@662472033e021d55d94146f66f6058822b0b39fd # pin@v2 with: languages: ${{ matrix.language }} @@ -55,4 +55,4 @@ jobs: ./gradlew buildForCodeQL - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@f779452ac5af1c261dce0346a8f964149f49322b # pin@v2 + uses: github/codeql-action/analyze@662472033e021d55d94146f66f6058822b0b39fd # pin@v2 From 5183da95e2282e6b581c6fc6599673fc11eba696 Mon Sep 17 00:00:00 2001 From: Krystof Woldrich <31292499+krystofwoldrich@users.noreply.github.com> Date: Mon, 28 Oct 2024 14:02:25 +0100 Subject: [PATCH 80/92] test(critical): Add API Level matrix (#3810) --- .../integration-tests-ui-critical.yml | 32 ++++++++++++++++--- 1 file changed, 27 insertions(+), 5 deletions(-) diff --git a/.github/workflows/integration-tests-ui-critical.yml b/.github/workflows/integration-tests-ui-critical.yml index d30b3f7045..815adf9b61 100644 --- a/.github/workflows/integration-tests-ui-critical.yml +++ b/.github/workflows/integration-tests-ui-critical.yml @@ -19,7 +19,7 @@ env: jobs: build: - name: Build sentry-uitest-android-critical + name: Build runs-on: ubuntu-latest steps: - name: Checkout code @@ -47,9 +47,30 @@ jobs: retention-days: 1 run-maestro-tests: - name: Run Maestro Tests + name: Run Tests for API Level ${{ matrix.api-level }} needs: build runs-on: ubuntu-latest + strategy: + # we want that the matrix keeps running, default is to cancel them if it fails. + fail-fast: false + matrix: + include: + - api-level: 30 # Android 11 + target: aosp_atd + channel: canary # Necessary for ATDs + arch: x86_64 + - api-level: 31 # Android 12 + target: aosp_atd + channel: canary # Necessary for ATDs + arch: x86_64 + - api-level: 33 # Android 13 + target: aosp_atd + channel: canary # Necessary for ATDs + arch: x86_64 + - api-level: 34 # Android 14 + target: aosp_atd + channel: canary # Necessary for ATDs + arch: x86_64 steps: - name: Checkout code uses: actions/checkout@v4 @@ -78,12 +99,13 @@ jobs: - name: Run tests uses: reactivecircus/android-emulator-runner@62dbb605bba737720e10b196cb4220d374026a6d # pin@v2.33.0 with: - api-level: 30 + api-level: ${{ matrix.api-level }} force-avd-creation: false disable-animations: true disable-spellchecker: true - target: 'aosp_atd' - channel: canary # Necessary for ATDs + target: ${{ matrix.target }} + channel: ${{ matrix.channel }} + arch: ${{ matrix.arch }} emulator-options: > -no-window -no-snapshot-save From 28a11a7925b8e45bec38684f2002192787f13477 Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Thu, 31 Oct 2024 06:48:34 +0100 Subject: [PATCH 81/92] Use `Random` through `ThreadLocal` (#3835) * Use Random as a ThreadLocal<> * changelog * code review changes --- CHANGELOG.md | 4 ++ .../android/core/AnrV2EventProcessor.java | 16 +------ sentry/api/sentry.api | 5 +++ .../src/main/java/io/sentry/SentryClient.java | 5 +-- .../main/java/io/sentry/TracesSampler.java | 16 +++++-- .../java/io/sentry/util/SentryRandom.java | 37 +++++++++++++++ .../java/io/sentry/util/SentryRandomTest.kt | 45 +++++++++++++++++++ 7 files changed, 107 insertions(+), 21 deletions(-) create mode 100644 sentry/src/main/java/io/sentry/util/SentryRandom.java create mode 100644 sentry/src/test/java/io/sentry/util/SentryRandomTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 813e9e65bc..cd68c3158e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Features + +- Use a separate `Random` instance per thread to improve SDK performance ([#3835](https://github.com/getsentry/sentry-java/pull/3835)) + ### Fixes - Accept manifest integer values when requiring floating values ([#3823](https://github.com/getsentry/sentry-java/pull/3823)) diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2EventProcessor.java b/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2EventProcessor.java index 0399b634fb..e914029c30 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2EventProcessor.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2EventProcessor.java @@ -54,7 +54,7 @@ import io.sentry.protocol.SentryTransaction; import io.sentry.protocol.User; import io.sentry.util.HintUtils; -import io.sentry.util.Random; +import io.sentry.util.SentryRandom; import java.io.File; import java.util.ArrayList; import java.util.Arrays; @@ -83,24 +83,13 @@ public final class AnrV2EventProcessor implements BackfillingEventProcessor { private final @NotNull SentryExceptionFactory sentryExceptionFactory; - private final @Nullable Random random; - public AnrV2EventProcessor( final @NotNull Context context, final @NotNull SentryAndroidOptions options, final @NotNull BuildInfoProvider buildInfoProvider) { - this(context, options, buildInfoProvider, null); - } - - AnrV2EventProcessor( - final @NotNull Context context, - final @NotNull SentryAndroidOptions options, - final @NotNull BuildInfoProvider buildInfoProvider, - final @Nullable Random random) { this.context = ContextUtils.getApplicationContext(context); this.options = options; this.buildInfoProvider = buildInfoProvider; - this.random = random; final SentryStackTraceFactory sentryStackTraceFactory = new SentryStackTraceFactory(this.options); @@ -180,9 +169,8 @@ private boolean sampleReplay(final @NotNull SentryEvent event) { try { // we have to sample here with the old sample rate, because it may change between app launches - final @NotNull Random random = this.random != null ? this.random : new Random(); final double replayErrorSampleRateDouble = Double.parseDouble(replayErrorSampleRate); - if (replayErrorSampleRateDouble < random.nextDouble()) { + if (replayErrorSampleRateDouble < SentryRandom.current().nextDouble()) { options .getLogger() .log( diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index ed06b29b35..8e266bcdba 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -5829,6 +5829,11 @@ public final class io/sentry/util/SampleRateUtils { public static fun isValidTracesSampleRate (Ljava/lang/Double;Z)Z } +public final class io/sentry/util/SentryRandom { + public fun ()V + public static fun current ()Lio/sentry/util/Random; +} + public final class io/sentry/util/StringUtils { public static fun byteCountToString (J)Ljava/lang/String; public static fun calculateStringHash (Ljava/lang/String;Lio/sentry/ILogger;)Ljava/lang/String; diff --git a/sentry/src/main/java/io/sentry/SentryClient.java b/sentry/src/main/java/io/sentry/SentryClient.java index 27529d100b..b053230ce5 100644 --- a/sentry/src/main/java/io/sentry/SentryClient.java +++ b/sentry/src/main/java/io/sentry/SentryClient.java @@ -18,6 +18,7 @@ import io.sentry.util.HintUtils; import io.sentry.util.Objects; import io.sentry.util.Random; +import io.sentry.util.SentryRandom; import io.sentry.util.TracingUtils; import java.io.Closeable; import java.io.IOException; @@ -40,7 +41,6 @@ public final class SentryClient implements ISentryClient, IMetricsClient { private final @NotNull SentryOptions options; private final @NotNull ITransport transport; - private final @Nullable Random random; private final @NotNull SortBreadcrumbsByDate sortBreadcrumbsByDate = new SortBreadcrumbsByDate(); private final @NotNull IMetricsAggregator metricsAggregator; @@ -66,8 +66,6 @@ public boolean isEnabled() { options.isEnableMetrics() ? new MetricsAggregator(options, this) : NoopMetricsAggregator.getInstance(); - - this.random = options.getSampleRate() == null ? null : new Random(); } private boolean shouldApplyScopeData( @@ -1183,6 +1181,7 @@ public boolean isHealthy() { } private boolean sample() { + final @Nullable Random random = options.getSampleRate() == null ? null : SentryRandom.current(); // https://docs.sentry.io/development/sdk-dev/features/#event-sampling if (options.getSampleRate() != null && random != null) { final double sampling = options.getSampleRate(); diff --git a/sentry/src/main/java/io/sentry/TracesSampler.java b/sentry/src/main/java/io/sentry/TracesSampler.java index 5e5b808333..ef04cae369 100644 --- a/sentry/src/main/java/io/sentry/TracesSampler.java +++ b/sentry/src/main/java/io/sentry/TracesSampler.java @@ -2,6 +2,7 @@ import io.sentry.util.Objects; import io.sentry.util.Random; +import io.sentry.util.SentryRandom; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import org.jetbrains.annotations.TestOnly; @@ -10,14 +11,14 @@ final class TracesSampler { private static final @NotNull Double DEFAULT_TRACES_SAMPLE_RATE = 1.0; private final @NotNull SentryOptions options; - private final @NotNull Random random; + private final @Nullable Random random; public TracesSampler(final @NotNull SentryOptions options) { - this(Objects.requireNonNull(options, "options are required"), new Random()); + this(Objects.requireNonNull(options, "options are required"), null); } @TestOnly - TracesSampler(final @NotNull SentryOptions options, final @NotNull Random random) { + TracesSampler(final @NotNull SentryOptions options, final @Nullable Random random) { this.options = options; this.random = random; } @@ -90,6 +91,13 @@ TracesSamplingDecision sample(final @NotNull SamplingContext samplingContext) { } private boolean sample(final @NotNull Double aDouble) { - return !(aDouble < random.nextDouble()); + return !(aDouble < getRandom().nextDouble()); + } + + private Random getRandom() { + if (random == null) { + return SentryRandom.current(); + } + return random; } } diff --git a/sentry/src/main/java/io/sentry/util/SentryRandom.java b/sentry/src/main/java/io/sentry/util/SentryRandom.java new file mode 100644 index 0000000000..f6e1d0a974 --- /dev/null +++ b/sentry/src/main/java/io/sentry/util/SentryRandom.java @@ -0,0 +1,37 @@ +package io.sentry.util; + +import org.jetbrains.annotations.NotNull; + +/** + * This SentryRandom is a compromise used for improving performance of the SDK. + * + *

We did some testing where using Random from multiple threads degrades performance + * significantly. We opted for this approach as it wasn't easily possible to vendor + * ThreadLocalRandom since it's using advanced features that can cause java.lang.IllegalAccessError. + */ +public final class SentryRandom { + + private static final @NotNull SentryRandomThreadLocal instance = new SentryRandomThreadLocal(); + + /** + * Returns the current threads instance of {@link Random}. An instance of {@link Random} will be + * created the first time this is invoked on each thread. + * + *

NOTE: Avoid holding a reference to the returned {@link Random} instance as sharing a + * reference across threads (while being thread-safe) will likely degrade performance + * significantly. + * + * @return random + */ + public static @NotNull Random current() { + return instance.get(); + } + + private static class SentryRandomThreadLocal extends ThreadLocal { + + @Override + protected Random initialValue() { + return new Random(); + } + } +} diff --git a/sentry/src/test/java/io/sentry/util/SentryRandomTest.kt b/sentry/src/test/java/io/sentry/util/SentryRandomTest.kt new file mode 100644 index 0000000000..c812c6cbdc --- /dev/null +++ b/sentry/src/test/java/io/sentry/util/SentryRandomTest.kt @@ -0,0 +1,45 @@ +package io.sentry.util + +import kotlin.test.Test +import kotlin.test.assertNotSame +import kotlin.test.assertSame + +class SentryRandomTest { + + @Test + fun `thread local creates a new instance per thread but keeps re-using it for the same thread`() { + val mainThreadRandom1 = SentryRandom.current() + val mainThreadRandom2 = SentryRandom.current() + assertSame(mainThreadRandom1, mainThreadRandom2) + + var thread1Random1: Random? = null + var thread1Random2: Random? = null + + val thread1 = Thread() { + thread1Random1 = SentryRandom.current() + thread1Random2 = SentryRandom.current() + } + + var thread2Random1: Random? = null + var thread2Random2: Random? = null + + val thread2 = Thread() { + thread2Random1 = SentryRandom.current() + thread2Random2 = SentryRandom.current() + } + + thread1.start() + thread2.start() + thread1.join() + thread2.join() + + assertSame(thread1Random1, thread1Random2) + assertNotSame(mainThreadRandom1, thread1Random1) + + assertSame(thread2Random1, thread2Random2) + assertNotSame(mainThreadRandom1, thread2Random1) + + val mainThreadRandom3 = SentryRandom.current() + assertSame(mainThreadRandom1, mainThreadRandom3) + } +} From 2af8d1ae1af717314bb479e07d163f6461205387 Mon Sep 17 00:00:00 2001 From: LucasZF Date: Mon, 4 Nov 2024 14:54:06 +0000 Subject: [PATCH 82/92] Fix: Allow MaxBreadcrumb 0 / Expose MaxBreadcrumb metadata. (#3836) * expose max-breadcrumbs on meta data and implement disabled queue when maxbreadcrumbs sets to 0 * missing queue class and test * update changelog --------- Co-authored-by: Lucas Co-authored-by: Stefano --- CHANGELOG.md | 2 + .../android/core/ManifestMetadataReader.java | 5 + .../core/ManifestMetadataReaderTest.kt | 25 ++++ .../src/main/AndroidManifest.xml | 3 + .../main/java/io/sentry/DisabledQueue.java | 115 ++++++++++++++++++ sentry/src/main/java/io/sentry/Scope.java | 4 +- .../test/java/io/sentry/DisabledQueueTest.kt | 93 ++++++++++++++ sentry/src/test/java/io/sentry/ScopeTest.kt | 11 ++ 8 files changed, 257 insertions(+), 1 deletion(-) create mode 100644 sentry/src/main/java/io/sentry/DisabledQueue.java create mode 100644 sentry/src/test/java/io/sentry/DisabledQueueTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index cd68c3158e..da49a05efd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,10 +4,12 @@ ### Features +- Add meta option to set the maximum amount of breadcrumbs to be logged. ([#3836](https://github.com/getsentry/sentry-java/pull/3836)) - Use a separate `Random` instance per thread to improve SDK performance ([#3835](https://github.com/getsentry/sentry-java/pull/3835)) ### Fixes +- Using MaxBreadcrumb with value 0 no longer crashes. ([#3836](https://github.com/getsentry/sentry-java/pull/3836)) - Accept manifest integer values when requiring floating values ([#3823](https://github.com/getsentry/sentry-java/pull/3823)) ## 7.16.0 diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java b/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java index adfd4f22ad..eb60c5d9c4 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java @@ -104,6 +104,8 @@ final class ManifestMetadataReader { static final String ENABLE_METRICS = "io.sentry.enable-metrics"; + static final String MAX_BREADCRUMBS = "io.sentry.max-breadcrumbs"; + static final String REPLAYS_SESSION_SAMPLE_RATE = "io.sentry.session-replay.session-sample-rate"; static final String REPLAYS_ERROR_SAMPLE_RATE = "io.sentry.session-replay.on-error-sample-rate"; @@ -213,6 +215,9 @@ static void applyMetadata( SESSION_TRACKING_TIMEOUT_INTERVAL_MILLIS, options.getSessionTrackingIntervalMillis())); + options.setMaxBreadcrumbs( + (int) readLong(metadata, logger, MAX_BREADCRUMBS, options.getMaxBreadcrumbs())); + options.setEnableActivityLifecycleBreadcrumbs( readBool( metadata, diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt index 17f0f3950b..ee4b4ae39a 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt @@ -1516,6 +1516,31 @@ class ManifestMetadataReaderTest { assertTrue(fixture.options.experimental.sessionReplay.maskViewClasses.contains(SentryReplayOptions.TEXT_VIEW_CLASS_NAME)) } + @Test + fun `applyMetadata reads maxBreadcrumbs to options and sets the value if found`() { + // Arrange + val bundle = bundleOf(ManifestMetadataReader.MAX_BREADCRUMBS to 1) + val context = fixture.getContext(metaData = bundle) + + // Act + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + // Assert + assertEquals(1, fixture.options.maxBreadcrumbs) + } + + @Test + fun `applyMetadata reads maxBreadcrumbs to options and keeps default if not found`() { + // Arrange + val context = fixture.getContext() + + // Act + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + // Assert + assertEquals(100, fixture.options.maxBreadcrumbs) + } + @Test fun `applyMetadata reads integers even when expecting floats`() { // Arrange diff --git a/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml b/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml index 2327573a43..d8ae6c709d 100644 --- a/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml +++ b/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml @@ -152,6 +152,9 @@ + + + diff --git a/sentry/src/main/java/io/sentry/DisabledQueue.java b/sentry/src/main/java/io/sentry/DisabledQueue.java new file mode 100644 index 0000000000..afef111af4 --- /dev/null +++ b/sentry/src/main/java/io/sentry/DisabledQueue.java @@ -0,0 +1,115 @@ +package io.sentry; + +import java.io.Serializable; +import java.util.AbstractCollection; +import java.util.Iterator; +import java.util.NoSuchElementException; +import java.util.Queue; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +final class DisabledQueue extends AbstractCollection implements Queue, Serializable { + + /** Serialization version. */ + private static final long serialVersionUID = -8423413834657610417L; + + /** Constructor that creates a queue that does not accept any element. */ + public DisabledQueue() {} + + // ----------------------------------------------------------------------- + /** + * Returns the number of elements stored in the queue. + * + * @return this queue's size + */ + @Override + public int size() { + return 0; + } + + /** + * Returns true if this queue is empty; false otherwise. + * + * @return false + */ + @Override + public boolean isEmpty() { + return false; + } + + /** Does nothing. */ + @Override + public void clear() {} + + /** + * Since the queue is disabled, the element will not be added. + * + * @param element the element to add + * @return false, always + */ + @Override + public boolean add(final @NotNull E element) { + return false; + } + + // ----------------------------------------------------------------------- + + /** + * Receives an element but do nothing with it. + * + * @param element the element to add + * @return false, always + */ + @Override + public boolean offer(@NotNull E element) { + return false; + } + + @Override + public @Nullable E poll() { + return null; + } + + @Override + public @Nullable E element() { + return null; + } + + @Override + public @Nullable E peek() { + return null; + } + + @Override + public @NotNull E remove() { + throw new NoSuchElementException("queue is disabled"); + } + + // ----------------------------------------------------------------------- + + /** + * Returns an iterator over this queue's elements. + * + * @return an iterator over this queue's elements + */ + @Override + public @NotNull Iterator iterator() { + return new Iterator() { + + @Override + public boolean hasNext() { + return false; + } + + @Override + public E next() { + throw new NoSuchElementException(); + } + + @Override + public void remove() { + throw new IllegalStateException(); + } + }; + } +} diff --git a/sentry/src/main/java/io/sentry/Scope.java b/sentry/src/main/java/io/sentry/Scope.java index f071cb8c5e..c74fabce25 100644 --- a/sentry/src/main/java/io/sentry/Scope.java +++ b/sentry/src/main/java/io/sentry/Scope.java @@ -754,7 +754,9 @@ public void clearAttachments() { * @return the breadcrumbs queue */ private @NotNull Queue createBreadcrumbsList(final int maxBreadcrumb) { - return SynchronizedQueue.synchronizedQueue(new CircularFifoQueue<>(maxBreadcrumb)); + return maxBreadcrumb > 0 + ? SynchronizedQueue.synchronizedQueue(new CircularFifoQueue<>(maxBreadcrumb)) + : SynchronizedQueue.synchronizedQueue(new DisabledQueue<>()); } /** diff --git a/sentry/src/test/java/io/sentry/DisabledQueueTest.kt b/sentry/src/test/java/io/sentry/DisabledQueueTest.kt new file mode 100644 index 0000000000..351f87eaff --- /dev/null +++ b/sentry/src/test/java/io/sentry/DisabledQueueTest.kt @@ -0,0 +1,93 @@ +package io.sentry +import org.junit.Assert.assertThrows +import java.util.NoSuchElementException +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull + +class DisabledQueueTest { + + @Test + fun `size starts empty`() { + val queue = DisabledQueue() + assertEquals(0, queue.size, "Size should always be zero.") + } + + @Test + fun `add does not add elements`() { + val queue = DisabledQueue() + assertFalse(queue.add(1), "add should always return false.") + assertEquals(0, queue.size, "Size should still be zero after attempting to add an element.") + } + + @Test + fun `isEmpty returns false when created`() { + val queue = DisabledQueue() + assertFalse(queue.isEmpty(), "isEmpty should always return false.") + } + + @Test + fun `isEmpty always returns false if add function was called`() { + val queue = DisabledQueue() + queue.add(1) + + assertFalse(queue.isEmpty(), "isEmpty should always return false.") + } + + @Test + fun `offer does not add elements`() { + val queue = DisabledQueue() + assertFalse(queue.offer(1), "offer should always return false.") + assertEquals(0, queue.size, "Size should still be zero after attempting to offer an element.") + } + + @Test + fun `poll returns null`() { + val queue = DisabledQueue() + queue.add(1) + assertNull(queue.poll(), "poll should always return null.") + } + + @Test + fun `peek returns null`() { + val queue = DisabledQueue() + queue.add(1) + + assertNull(queue.peek(), "peek should always return null.") + } + + @Test + fun `element returns null`() { + val queue = DisabledQueue() + assertNull(queue.element(), "element should always return null.") + } + + @Test + fun `remove throws NoSuchElementException`() { + val queue = DisabledQueue() + assertThrows(NoSuchElementException::class.java) { queue.remove() } + } + + @Test + fun `clear does nothing`() { + val queue = DisabledQueue() + queue.clear() // Should not throw an exception + assertEquals(0, queue.size, "Size should remain zero after clear.") + } + + @Test + fun `iterator has no elements`() { + val queue = DisabledQueue() + val iterator = queue.iterator() + assertFalse(iterator.hasNext(), "Iterator should have no elements.") + assertThrows(NoSuchElementException::class.java) { iterator.next() } + } + + @Test + fun `iterator remove throws IllegalStateException`() { + val queue = DisabledQueue() + val iterator = queue.iterator() + assertThrows(IllegalStateException::class.java) { iterator.remove() } + } +} diff --git a/sentry/src/test/java/io/sentry/ScopeTest.kt b/sentry/src/test/java/io/sentry/ScopeTest.kt index 07b9176de7..a0f54d5205 100644 --- a/sentry/src/test/java/io/sentry/ScopeTest.kt +++ b/sentry/src/test/java/io/sentry/ScopeTest.kt @@ -1029,6 +1029,17 @@ class ScopeTest { ) } + @Test + fun `creating a new scope won't crash if max breadcrumbs is set to zero`() { + val options = SentryOptions().apply { + maxBreadcrumbs = 0 + } + val scope = Scope(options) + + // expect no exception to be thrown + // previously was crashing, see https://github.com/getsentry/sentry-java/issues/3313 + } + private fun eventProcessor(): EventProcessor { return object : EventProcessor { override fun process(event: SentryEvent, hint: Hint): SentryEvent? { From fd1151b5c7696d17711c40c2facee553a43d07c3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 4 Nov 2024 15:14:32 +0000 Subject: [PATCH 83/92] Bump gradle/actions (#3842) Bumps [gradle/actions](https://github.com/gradle/actions) from bb0c460cbf5354b0cddd15bacdf0d6aaa3e5a32b to 707359876a764dbcdb9da0b0ed08291818310c3d. - [Release notes](https://github.com/gradle/actions/releases) - [Commits](https://github.com/gradle/actions/compare/bb0c460cbf5354b0cddd15bacdf0d6aaa3e5a32b...707359876a764dbcdb9da0b0ed08291818310c3d) --- updated-dependencies: - dependency-name: gradle/actions dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/agp-matrix.yml | 2 +- .github/workflows/build.yml | 2 +- .github/workflows/codeql-analysis.yml | 2 +- .github/workflows/enforce-license-compliance.yml | 2 +- .github/workflows/generate-javadocs.yml | 2 +- .github/workflows/integration-tests-benchmarks.yml | 4 ++-- .github/workflows/integration-tests-ui-critical.yml | 2 +- .github/workflows/integration-tests-ui.yml | 2 +- .github/workflows/release-build.yml | 2 +- .github/workflows/system-tests-backend.yml | 2 +- 10 files changed, 11 insertions(+), 11 deletions(-) diff --git a/.github/workflows/agp-matrix.yml b/.github/workflows/agp-matrix.yml index 182c0fa19b..56a4f74fae 100644 --- a/.github/workflows/agp-matrix.yml +++ b/.github/workflows/agp-matrix.yml @@ -38,7 +38,7 @@ jobs: java-version: '17' - name: Setup Gradle - uses: gradle/actions/setup-gradle@bb0c460cbf5354b0cddd15bacdf0d6aaa3e5a32b # pin@v3 + uses: gradle/actions/setup-gradle@707359876a764dbcdb9da0b0ed08291818310c3d # pin@v3 with: gradle-home-cache-cleanup: true diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 6b885942e9..5988e27888 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -27,7 +27,7 @@ jobs: java-version: '17' - name: Setup Gradle - uses: gradle/actions/setup-gradle@bb0c460cbf5354b0cddd15bacdf0d6aaa3e5a32b # pin@v3 + uses: gradle/actions/setup-gradle@707359876a764dbcdb9da0b0ed08291818310c3d # pin@v3 with: gradle-home-cache-cleanup: true diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 68855cbd8d..9ce3d9f9bd 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -36,7 +36,7 @@ jobs: java-version: '17' - name: Setup Gradle - uses: gradle/actions/setup-gradle@bb0c460cbf5354b0cddd15bacdf0d6aaa3e5a32b # pin@v3 + uses: gradle/actions/setup-gradle@707359876a764dbcdb9da0b0ed08291818310c3d # pin@v3 with: gradle-home-cache-cleanup: true diff --git a/.github/workflows/enforce-license-compliance.yml b/.github/workflows/enforce-license-compliance.yml index 2b87c1d278..c4a22a909b 100644 --- a/.github/workflows/enforce-license-compliance.yml +++ b/.github/workflows/enforce-license-compliance.yml @@ -11,7 +11,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Setup Gradle - uses: gradle/actions/setup-gradle@bb0c460cbf5354b0cddd15bacdf0d6aaa3e5a32b # pin@v3 + uses: gradle/actions/setup-gradle@707359876a764dbcdb9da0b0ed08291818310c3d # pin@v3 with: gradle-home-cache-cleanup: true diff --git a/.github/workflows/generate-javadocs.yml b/.github/workflows/generate-javadocs.yml index b540441fb7..5d358bdf50 100644 --- a/.github/workflows/generate-javadocs.yml +++ b/.github/workflows/generate-javadocs.yml @@ -20,7 +20,7 @@ jobs: java-version: '17' - name: Setup Gradle - uses: gradle/actions/setup-gradle@bb0c460cbf5354b0cddd15bacdf0d6aaa3e5a32b # pin@v3 + uses: gradle/actions/setup-gradle@707359876a764dbcdb9da0b0ed08291818310c3d # pin@v3 with: gradle-home-cache-cleanup: true diff --git a/.github/workflows/integration-tests-benchmarks.yml b/.github/workflows/integration-tests-benchmarks.yml index 58fc933752..34eea1733c 100644 --- a/.github/workflows/integration-tests-benchmarks.yml +++ b/.github/workflows/integration-tests-benchmarks.yml @@ -37,7 +37,7 @@ jobs: java-version: '17' - name: Setup Gradle - uses: gradle/actions/setup-gradle@bb0c460cbf5354b0cddd15bacdf0d6aaa3e5a32b # pin@v3 + uses: gradle/actions/setup-gradle@707359876a764dbcdb9da0b0ed08291818310c3d # pin@v3 with: gradle-home-cache-cleanup: true @@ -86,7 +86,7 @@ jobs: java-version: '17' - name: Setup Gradle - uses: gradle/actions/setup-gradle@bb0c460cbf5354b0cddd15bacdf0d6aaa3e5a32b # pin@v3 + uses: gradle/actions/setup-gradle@707359876a764dbcdb9da0b0ed08291818310c3d # pin@v3 with: gradle-home-cache-cleanup: true diff --git a/.github/workflows/integration-tests-ui-critical.yml b/.github/workflows/integration-tests-ui-critical.yml index 815adf9b61..a28400e50f 100644 --- a/.github/workflows/integration-tests-ui-critical.yml +++ b/.github/workflows/integration-tests-ui-critical.yml @@ -32,7 +32,7 @@ jobs: java-version: '17' - name: Setup Gradle - uses: gradle/actions/setup-gradle@bb0c460cbf5354b0cddd15bacdf0d6aaa3e5a32b # pin@v3 + uses: gradle/actions/setup-gradle@707359876a764dbcdb9da0b0ed08291818310c3d # pin@v3 with: gradle-home-cache-cleanup: true diff --git a/.github/workflows/integration-tests-ui.yml b/.github/workflows/integration-tests-ui.yml index 6c4ad6564f..059e3c7424 100644 --- a/.github/workflows/integration-tests-ui.yml +++ b/.github/workflows/integration-tests-ui.yml @@ -32,7 +32,7 @@ jobs: java-version: '17' - name: Setup Gradle - uses: gradle/actions/setup-gradle@bb0c460cbf5354b0cddd15bacdf0d6aaa3e5a32b # pin@v3 + uses: gradle/actions/setup-gradle@707359876a764dbcdb9da0b0ed08291818310c3d # pin@v3 with: gradle-home-cache-cleanup: true diff --git a/.github/workflows/release-build.yml b/.github/workflows/release-build.yml index 83a3c82f8c..c625efa8eb 100644 --- a/.github/workflows/release-build.yml +++ b/.github/workflows/release-build.yml @@ -26,7 +26,7 @@ jobs: java-version: '17' - name: Setup Gradle - uses: gradle/actions/setup-gradle@bb0c460cbf5354b0cddd15bacdf0d6aaa3e5a32b # pin@v3 + uses: gradle/actions/setup-gradle@707359876a764dbcdb9da0b0ed08291818310c3d # pin@v3 with: gradle-home-cache-cleanup: true diff --git a/.github/workflows/system-tests-backend.yml b/.github/workflows/system-tests-backend.yml index 2222f910ad..3703e26168 100644 --- a/.github/workflows/system-tests-backend.yml +++ b/.github/workflows/system-tests-backend.yml @@ -40,7 +40,7 @@ jobs: java-version: '17' - name: Setup Gradle - uses: gradle/actions/setup-gradle@bb0c460cbf5354b0cddd15bacdf0d6aaa3e5a32b # pin@v3 + uses: gradle/actions/setup-gradle@707359876a764dbcdb9da0b0ed08291818310c3d # pin@v3 with: gradle-home-cache-cleanup: true From fba10b88355deba03a45d039d8d3103d5455e2a7 Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Tue, 12 Nov 2024 09:23:04 +0100 Subject: [PATCH 84/92] Limit emulator size to 4096M (#3875) CI runs recently started to fail due to disk size issues when creating the emulator: > ERROR | Not enough space to create userdata partition. Available: 7329.925781 MB at /home/runner/.android/avd/../avd/test.avd, need 7372.800000 MB. #skip-changelog --- .github/workflows/agp-matrix.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/agp-matrix.yml b/.github/workflows/agp-matrix.yml index 56a4f74fae..b93bc364ac 100644 --- a/.github/workflows/agp-matrix.yml +++ b/.github/workflows/agp-matrix.yml @@ -69,6 +69,7 @@ jobs: target: 'aosp_atd' arch: x86 channel: canary # Necessary for ATDs + disk-size: 4096M script: ./gradlew sentry-android-integration-tests:sentry-uitest-android:connectedReleaseAndroidTest -DtestBuildType=release --daemon - name: Upload test results From 566da7624407dd4df6ea3a128431cc223ecc2035 Mon Sep 17 00:00:00 2001 From: Lukas Bloder Date: Tue, 12 Nov 2024 14:12:53 +0100 Subject: [PATCH 85/92] Fix standalone tomcat jndi issue (#3873) * fix standalone tomcat jndi issue * format * add changelog entry * adapt changelog --- CHANGELOG.md | 3 +++ sentry/src/main/java/io/sentry/Baggage.java | 6 +++--- sentry/src/main/java/io/sentry/DsnUtil.java | 2 +- .../src/main/java/io/sentry/RequestDetailsResolver.java | 2 +- sentry/src/main/java/io/sentry/Sentry.java | 2 +- sentry/src/main/java/io/sentry/SentryOptions.java | 8 +++++--- 6 files changed, 14 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index da49a05efd..34fa731c2b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,9 @@ - Using MaxBreadcrumb with value 0 no longer crashes. ([#3836](https://github.com/getsentry/sentry-java/pull/3836)) - Accept manifest integer values when requiring floating values ([#3823](https://github.com/getsentry/sentry-java/pull/3823)) +- Fix standalone tomcat jndi issue ([#3873](https://github.com/getsentry/sentry-java/pull/3873)) + - Using Sentry Spring Boot on a standalone tomcat caused the following error: + - Failed to bind properties under 'sentry.parsed-dsn' to io.sentry.Dsn ## 7.16.0 diff --git a/sentry/src/main/java/io/sentry/Baggage.java b/sentry/src/main/java/io/sentry/Baggage.java index 0a80b154bf..befdead4a5 100644 --- a/sentry/src/main/java/io/sentry/Baggage.java +++ b/sentry/src/main/java/io/sentry/Baggage.java @@ -134,7 +134,7 @@ public static Baggage fromEvent( final Baggage baggage = new Baggage(options.getLogger()); final SpanContext trace = event.getContexts().getTrace(); baggage.setTraceId(trace != null ? trace.getTraceId().toString() : null); - baggage.setPublicKey(options.getParsedDsn().getPublicKey()); + baggage.setPublicKey(options.retrieveParsedDsn().getPublicKey()); baggage.setRelease(event.getRelease()); baggage.setEnvironment(event.getEnvironment()); final User user = event.getUser(); @@ -405,7 +405,7 @@ public void setValuesFromTransaction( final @NotNull SentryOptions sentryOptions, final @Nullable TracesSamplingDecision samplingDecision) { setTraceId(transaction.getSpanContext().getTraceId().toString()); - setPublicKey(sentryOptions.getParsedDsn().getPublicKey()); + setPublicKey(sentryOptions.retrieveParsedDsn().getPublicKey()); setRelease(sentryOptions.getRelease()); setEnvironment(sentryOptions.getEnvironment()); setUserSegment(user != null ? getSegment(user) : null); @@ -427,7 +427,7 @@ public void setValuesFromScope( final @Nullable User user = scope.getUser(); final @NotNull SentryId replayId = scope.getReplayId(); setTraceId(propagationContext.getTraceId().toString()); - setPublicKey(options.getParsedDsn().getPublicKey()); + setPublicKey(options.retrieveParsedDsn().getPublicKey()); setRelease(options.getRelease()); setEnvironment(options.getEnvironment()); if (!SentryId.EMPTY_ID.equals(replayId)) { diff --git a/sentry/src/main/java/io/sentry/DsnUtil.java b/sentry/src/main/java/io/sentry/DsnUtil.java index 6cc0dc360b..b6902ad274 100644 --- a/sentry/src/main/java/io/sentry/DsnUtil.java +++ b/sentry/src/main/java/io/sentry/DsnUtil.java @@ -23,7 +23,7 @@ public static boolean urlContainsDsnHost(@Nullable SentryOptions options, @Nulla return false; } - final @NotNull Dsn dsn = options.getParsedDsn(); + final @NotNull Dsn dsn = options.retrieveParsedDsn(); final @NotNull URI sentryUri = dsn.getSentryUri(); final @Nullable String dsnHost = sentryUri.getHost(); diff --git a/sentry/src/main/java/io/sentry/RequestDetailsResolver.java b/sentry/src/main/java/io/sentry/RequestDetailsResolver.java index 6083c69e99..bba4dc19ac 100644 --- a/sentry/src/main/java/io/sentry/RequestDetailsResolver.java +++ b/sentry/src/main/java/io/sentry/RequestDetailsResolver.java @@ -21,7 +21,7 @@ public RequestDetailsResolver(final @NotNull SentryOptions options) { @NotNull RequestDetails resolve() { - final Dsn dsn = options.getParsedDsn(); + final Dsn dsn = options.retrieveParsedDsn(); final URI sentryUri = dsn.getSentryUri(); final String envelopeUrl = sentryUri.resolve(sentryUri.getPath() + "/envelope/").toString(); diff --git a/sentry/src/main/java/io/sentry/Sentry.java b/sentry/src/main/java/io/sentry/Sentry.java index 6e4a2530a7..f075876325 100644 --- a/sentry/src/main/java/io/sentry/Sentry.java +++ b/sentry/src/main/java/io/sentry/Sentry.java @@ -398,7 +398,7 @@ private static boolean initConfigurations(final @NotNull SentryOptions options) } // This creates the DSN object and performs some checks - options.getParsedDsn(); + options.retrieveParsedDsn(); ILogger logger = options.getLogger(); diff --git a/sentry/src/main/java/io/sentry/SentryOptions.java b/sentry/src/main/java/io/sentry/SentryOptions.java index 3c286f2bff..8614abc287 100644 --- a/sentry/src/main/java/io/sentry/SentryOptions.java +++ b/sentry/src/main/java/io/sentry/SentryOptions.java @@ -541,13 +541,15 @@ public void addIntegration(@NotNull Integration integration) { } /** - * Evaluates and parses the DSN. May throw an exception if the DSN is invalid. + * Evaluates and parses the DSN. May throw an exception if the DSN is invalid. Renamed from + * `getParsedDsn` as this would cause an error when deploying as WAR to Tomcat due to `JNDI` + * property binding. * * @return the parsed DSN or throws if dsn is invalid */ @ApiStatus.Internal @NotNull - Dsn getParsedDsn() throws IllegalArgumentException { + Dsn retrieveParsedDsn() throws IllegalArgumentException { return parsedDsn.getValue(); } @@ -2457,7 +2459,7 @@ public void setEnableScreenTracking(final boolean enableScreenTracking) { */ void loadLazyFields() { getSerializer(); - getParsedDsn(); + retrieveParsedDsn(); getEnvelopeReader(); getDateProvider(); } From 4bd1aa38243dd26abd3c26af70868c2fe5e94048 Mon Sep 17 00:00:00 2001 From: getsentry-bot Date: Tue, 12 Nov 2024 14:05:40 +0000 Subject: [PATCH 86/92] release: 7.17.0 --- CHANGELOG.md | 2 +- gradle.properties | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 34fa731c2b..d7a41e65dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## Unreleased +## 7.17.0 ### Features diff --git a/gradle.properties b/gradle.properties index 4268e10785..f9cfa874ad 100644 --- a/gradle.properties +++ b/gradle.properties @@ -13,7 +13,7 @@ android.useAndroidX=true android.defaults.buildfeatures.buildconfig=true # Release information -versionName=7.16.0 +versionName=7.17.0 # Override the SDK name on native crashes on Android sentryAndroidSdkName=sentry.native.android From 17a41c14c3928588cb52b7d8546e787c473d8850 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Wed, 13 Nov 2024 21:43:41 +0100 Subject: [PATCH 87/92] Ensure android initialization process continues even if options configuration block throws an exception (#3887) * Ensure android event processors are added even if options configuration block throws * Changelog --- CHANGELOG.md | 6 ++++++ .../java/io/sentry/android/core/SentryAndroid.java | 12 +++++++++++- .../io/sentry/android/core/SentryAndroidTest.kt | 13 +++++++++++++ 3 files changed, 30 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d7a41e65dc..98595beaa8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +### Fixes + +- Ensure android initialization process continues even if options configuration block throws an exception ([#3887](https://github.com/getsentry/sentry-java/pull/3887)) + ## 7.17.0 ### Features diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroid.java b/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroid.java index e6e677334c..adeb451332 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroid.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroid.java @@ -129,7 +129,17 @@ public static synchronized void init( isTimberAvailable, isReplayAvailable); - configuration.configure(options); + try { + configuration.configure(options); + } catch (Throwable t) { + // let it slip, but log it + options + .getLogger() + .log( + SentryLevel.ERROR, + "Error in the 'OptionsConfiguration.configure' callback.", + t); + } // if SentryPerformanceProvider was disabled or removed, // we set the app start / sdk init time here instead diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidTest.kt index c31076d1ff..17c11475c9 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidTest.kt @@ -517,6 +517,19 @@ class SentryAndroidTest { assertEquals(99, AppStartMetrics.getInstance().appStartTimeSpan.startUptimeMs) } + @Test + fun `if the config options block throws still intializes android event processors`() { + lateinit var optionsRef: SentryOptions + fixture.initSut(context = mock()) { options -> + optionsRef = options + options.dsn = "https://key@sentry.io/123" + throw RuntimeException("Boom!") + } + + assertTrue(optionsRef.eventProcessors.any { it is DefaultAndroidEventProcessor }) + assertTrue(optionsRef.eventProcessors.any { it is AnrV2EventProcessor }) + } + private fun prefillScopeCache(cacheDir: String) { val scopeDir = File(cacheDir, SCOPE_CACHE).also { it.mkdirs() } File(scopeDir, BREADCRUMBS_FILENAME).writeText( From 091f84ef1280c98a0e9ad85f142135458ad91a18 Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Thu, 14 Nov 2024 10:21:00 +0100 Subject: [PATCH 88/92] Add support for 16KB page sizes (Android 15) (#3620) * Add support for 16KB page sizes (Android 15) * Update Changelog * Revert NDK/min API level bump, properly apply link options instead * Fix Changelog * Exclude sentry-native Java code from being spotless-checked * Bump sentry-native to 0.7.8 * Update Changelog * Update CHANGELOG.md * release: 7.17.0-alpha.1 --------- Co-authored-by: getsentry-bot Co-authored-by: getsentry-bot --- CHANGELOG.md | 11 +++++++++++ build.gradle.kts | 2 +- sentry-android-ndk/CMakeLists.txt | 5 +++++ sentry-android-ndk/build.gradle.kts | 7 +++++++ sentry-android-ndk/sentry-native | 2 +- sentry-samples/sentry-samples-android/CMakeLists.txt | 5 +++++ .../sentry-samples-android/build.gradle.kts | 7 +++++++ 7 files changed, 37 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 98595beaa8..00744b33f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,10 +2,21 @@ ## Unreleased +### Features + +- Android 15: Add support for 16KB page sizes ([#3620](https://github.com/getsentry/sentry-java/pull/3620)) + - See https://developer.android.com/guide/practices/page-sizes for more details + ### Fixes - Ensure android initialization process continues even if options configuration block throws an exception ([#3887](https://github.com/getsentry/sentry-java/pull/3887)) +### Dependencies + +- Bump Native SDK from v0.7.2 to v0.7.8 ([#3620](https://github.com/getsentry/sentry-java/pull/3620)) + - [changelog](https://github.com/getsentry/sentry-native/blob/master/CHANGELOG.md#078) + - [diff](https://github.com/getsentry/sentry-native/compare/0.7.2...0.7.8) + ## 7.17.0 ### Features diff --git a/build.gradle.kts b/build.gradle.kts index 9d53252562..86cd98d54a 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -226,7 +226,7 @@ spotless { target("**/*.java") removeUnusedImports() googleJavaFormat() - targetExclude("**/generated/**", "**/vendor/**") + targetExclude("**/generated/**", "**/vendor/**", "**/sentry-native/**") } kotlin { target("**/*.kt") diff --git a/sentry-android-ndk/CMakeLists.txt b/sentry-android-ndk/CMakeLists.txt index c9a0181935..ff5fc2540b 100644 --- a/sentry-android-ndk/CMakeLists.txt +++ b/sentry-android-ndk/CMakeLists.txt @@ -15,3 +15,8 @@ add_subdirectory(${SENTRY_NATIVE_SRC} sentry_build) target_link_libraries(sentry-android PRIVATE $ ) + +# Android 15: Support 16KB page sizes +# see https://developer.android.com/guide/practices/page-sizes +target_link_options(sentry PRIVATE "-Wl,-z,max-page-size=16384") +target_link_options(sentry-android PRIVATE "-Wl,-z,max-page-size=16384") diff --git a/sentry-android-ndk/build.gradle.kts b/sentry-android-ndk/build.gradle.kts index f1c4873053..ee0819eb83 100644 --- a/sentry-android-ndk/build.gradle.kts +++ b/sentry-android-ndk/build.gradle.kts @@ -95,6 +95,13 @@ android { ignore = true } } + + @Suppress("UnstableApiUsage") + packagingOptions { + jniLibs { + useLegacyPackaging = true + } + } } dependencies { diff --git a/sentry-android-ndk/sentry-native b/sentry-android-ndk/sentry-native index 0f1d664759..f44ab0be7c 160000 --- a/sentry-android-ndk/sentry-native +++ b/sentry-android-ndk/sentry-native @@ -1 +1 @@ -Subproject commit 0f1d664759cba187a846a562f9d55f3c62dffaa3 +Subproject commit f44ab0be7c9d46bbaf24536fb15e7d55b98d6716 diff --git a/sentry-samples/sentry-samples-android/CMakeLists.txt b/sentry-samples/sentry-samples-android/CMakeLists.txt index ad170fe404..03a2da3f66 100644 --- a/sentry-samples/sentry-samples-android/CMakeLists.txt +++ b/sentry-samples/sentry-samples-android/CMakeLists.txt @@ -15,3 +15,8 @@ target_link_libraries(native-sample PRIVATE ${LOG_LIB} $ ) + +# Android 15: Support 16KB page sizes +# see https://developer.android.com/guide/practices/page-sizes +target_link_options(native-sample PRIVATE "-Wl,-z,max-page-size=16384") + diff --git a/sentry-samples/sentry-samples-android/build.gradle.kts b/sentry-samples/sentry-samples-android/build.gradle.kts index 204ef83fc2..90c71b8289 100644 --- a/sentry-samples/sentry-samples-android/build.gradle.kts +++ b/sentry-samples/sentry-samples-android/build.gradle.kts @@ -99,6 +99,13 @@ android { ignore = true } } + + @Suppress("UnstableApiUsage") + packagingOptions { + jniLibs { + useLegacyPackaging = true + } + } } dependencies { From a1831635b97fd324c127bc4857fc307171d8394b Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Thu, 14 Nov 2024 10:59:58 +0100 Subject: [PATCH 89/92] Do not report parsing ANR error when there are no threads (#3888) * Ensure android event processors are added even if options configuration block throws * Changelog * Do not report parsing ANR error when there are no threads * Changelog * Fix tests --- CHANGELOG.md | 2 + .../sentry/android/core/AnrV2Integration.java | 7 +- .../android/core/AnrV2IntegrationTest.kt | 50 +- .../threaddump/ThreadDumpParserTest.kt | 12 + .../test/resources/thread_dump_bad_data.txt | 1029 +++++++++++++++++ 5 files changed, 1093 insertions(+), 7 deletions(-) create mode 100644 sentry-android-core/src/test/resources/thread_dump_bad_data.txt diff --git a/CHANGELOG.md b/CHANGELOG.md index 00744b33f4..92d7d2ef4b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,8 @@ ### Fixes - Ensure android initialization process continues even if options configuration block throws an exception ([#3887](https://github.com/getsentry/sentry-java/pull/3887)) +- Do not report parsing ANR error when there are no threads ([#3888](https://github.com/getsentry/sentry-java/pull/3888)) + - This should significantly reduce the number of events with message "Sentry Android SDK failed to parse system thread dump..." reported ### Dependencies diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2Integration.java b/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2Integration.java index 618f53554f..c19c3aeac6 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2Integration.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2Integration.java @@ -313,8 +313,11 @@ private void reportAsSentryEvent( final ThreadDumpParser threadDumpParser = new ThreadDumpParser(options, isBackground); final List threads = threadDumpParser.parse(lines); if (threads.isEmpty()) { - // if the list is empty this means our regex matching is garbage and this is still error - return new ParseResult(ParseResult.Type.ERROR, dump); + // if the list is empty this means the system failed to capture a proper thread dump of + // the android threads, and only contains kernel-level threads and statuses, those ANRs + // are not actionable and neither they are reported by Google Play Console, so we just + // fall back to not reporting them + return new ParseResult(ParseResult.Type.NO_DUMP); } return new ParseResult(ParseResult.Type.DUMP, dump, threads); } catch (Throwable e) { diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/AnrV2IntegrationTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/AnrV2IntegrationTest.kt index 885ad22c8f..a658a24505 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/AnrV2IntegrationTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/AnrV2IntegrationTest.kt @@ -101,7 +101,8 @@ class AnrV2IntegrationTest { reason: Int? = ApplicationExitInfo.REASON_ANR, timestamp: Long? = null, importance: Int? = null, - addTrace: Boolean = true + addTrace: Boolean = true, + addBadTrace: Boolean = false ) { val builder = ApplicationExitInfoBuilder.newBuilder() if (reason != null) { @@ -117,8 +118,36 @@ class AnrV2IntegrationTest { if (!addTrace) { return } - whenever(mock.traceInputStream).thenReturn( - """ + if (addBadTrace) { + whenever(mock.traceInputStream).thenReturn( + """ + Subject: Input dispatching timed out (7985007 com.example.app/com.example.app.ui.MainActivity (server) is not responding. Waited 5000ms for FocusEvent(hasFocus=false)) + Here are no Binder-related exception messages available. + Pid(12233) have D state thread(tid:12236 name:Signal Catcher) + + + RssHwmKb: 823716 + RssKb: 548348 + RssAnonKb: 382156 + RssShmemKb: 13304 + VmSwapKb: 82484 + + + --- CriticalEventLog --- + capacity: 20 + timestamp_ms: 1731507490032 + window_ms: 300000 + + ----- dumping pid: 12233 at 313446151 + libdebuggerd_client: unexpected registration response: 0 + + ----- Waiting Channels: pid 12233 at 2024-11-13 19:48:09.980104540+0530 ----- + Cmd line: com.example.app:mainProcess + """.trimIndent().byteInputStream() + ) + } else { + whenever(mock.traceInputStream).thenReturn( + """ "main" prio=5 tid=1 Blocked | group="main" sCount=1 ucsCount=0 flags=1 obj=0x72a985e0 self=0xb400007cabc57380 | sysTid=28941 nice=-10 cgrp=top-app sched=0/0 handle=0x7deceb74f8 @@ -147,8 +176,9 @@ class AnrV2IntegrationTest { native: #02 pc 00000000000b63b0 /apex/com.android.runtime/lib64/bionic/libc.so (__pthread_start(void*)+208) (BuildId: 01331f74b0bb2cb958bdc15282b8ec7b) native: #03 pc 00000000000530b8 /apex/com.android.runtime/lib64/bionic/libc.so (__start_thread+64) (BuildId: 01331f74b0bb2cb958bdc15282b8ec7b) (no managed stack frames) - """.trimIndent().byteInputStream() - ) + """.trimIndent().byteInputStream() + ) + } } shadowActivityManager.addApplicationExitInfo(exitInfo) } @@ -551,4 +581,14 @@ class AnrV2IntegrationTest { verify(fixture.hub, never()).captureEvent(any(), anyOrNull()) } + + @Test + fun `when traceInputStream has bad data, does not report ANR`() { + val integration = fixture.getSut(tmpDir, lastReportedAnrTimestamp = oldTimestamp) + fixture.addAppExitInfo(timestamp = newTimestamp, addBadTrace = true) + + integration.register(fixture.hub, fixture.options) + + verify(fixture.hub, never()).captureEvent(any(), anyOrNull()) + } } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/internal/threaddump/ThreadDumpParserTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/internal/threaddump/ThreadDumpParserTest.kt index 9ef22d6a13..19de2e4935 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/internal/threaddump/ThreadDumpParserTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/internal/threaddump/ThreadDumpParserTest.kt @@ -7,6 +7,7 @@ import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNotNull import kotlin.test.assertNull +import kotlin.test.assertTrue class ThreadDumpParserTest { @@ -95,4 +96,15 @@ class ThreadDumpParserTest { assertEquals(28, lastFrame.lineno) assertNull(lastFrame.isInApp) } + + @Test + fun `thread dump garbage`() { + val lines = Lines.readLines(File("src/test/resources/thread_dump_bad_data.txt")) + val parser = ThreadDumpParser( + SentryOptions().apply { addInAppInclude("io.sentry.samples") }, + false + ) + val threads = parser.parse(lines) + assertTrue(threads.isEmpty()) + } } diff --git a/sentry-android-core/src/test/resources/thread_dump_bad_data.txt b/sentry-android-core/src/test/resources/thread_dump_bad_data.txt new file mode 100644 index 0000000000..abbe042fce --- /dev/null +++ b/sentry-android-core/src/test/resources/thread_dump_bad_data.txt @@ -0,0 +1,1029 @@ +Subject: Input dispatching timed out (7985007 com.example.app/com.example.app.ui.MainActivity (server) is not responding. Waited 5000ms for FocusEvent(hasFocus=false)) +Here are no Binder-related exception messages available. +Pid(12233) have D state thread(tid:12236 name:Signal Catcher) + + +RssHwmKb: 823716 +RssKb: 548348 +RssAnonKb: 382156 +RssShmemKb: 13304 +VmSwapKb: 82484 + + +--- CriticalEventLog --- +capacity: 20 +timestamp_ms: 1731507490032 +window_ms: 300000 + +----- dumping pid: 12233 at 313446151 +libdebuggerd_client: unexpected registration response: 0 + +----- Waiting Channels: pid 12233 at 2024-11-13 19:48:09.980104540+0530 ----- +Cmd line: com.example.app:mainProcess + +sysTid=12233 state=R 0 +sysTid=12236 state=S do_sigtimedwait +sysTid=12237 state=S futex_wait_queue_me +sysTid=12238 state=S futex_wait_queue_me +sysTid=12239 state=S futex_wait_queue_me +sysTid=12240 state=S futex_wait_queue_me +sysTid=12241 state=S futex_wait_queue_me +sysTid=12242 state=S binder_wait_for_work +sysTid=12243 state=S binder_wait_for_work +sysTid=12245 state=S binder_wait_for_work +sysTid=12252 state=S futex_wait_queue_me +sysTid=12254 state=S inotify_read +sysTid=12257 state=S __arm64_sys_nanosleep +sysTid=12259 state=R 0 +sysTid=12260 state=S __arm64_sys_nanosleep +sysTid=12268 state=R 0 +sysTid=12269 state=S __arm64_sys_nanosleep +sysTid=12270 state=S __arm64_sys_nanosleep +sysTid=12278 state=S futex_wait_queue_me +sysTid=12279 state=S futex_wait_queue_me +sysTid=12280 state=S futex_wait_queue_me +sysTid=12283 state=S futex_wait_queue_me +sysTid=12287 state=S futex_wait_queue_me +sysTid=12290 state=S futex_wait_queue_me +sysTid=12291 state=S futex_wait_queue_me +sysTid=12292 state=S futex_wait_queue_me +sysTid=12295 state=S futex_wait_queue_me +sysTid=12296 state=S futex_wait_queue_me +sysTid=12297 state=S futex_wait_queue_me +sysTid=12298 state=S futex_wait_queue_me +sysTid=12304 state=S futex_wait_queue_me +sysTid=12310 state=S futex_wait_queue_me +sysTid=12311 state=S futex_wait_queue_me +sysTid=12312 state=S do_epoll_wait +sysTid=12314 state=S futex_wait_queue_me +sysTid=12315 state=S futex_wait_queue_me +sysTid=12316 state=S futex_wait_queue_me +sysTid=12317 state=S futex_wait_queue_me +sysTid=12319 state=S futex_wait_queue_me +sysTid=12320 state=S do_epoll_wait +sysTid=12321 state=S futex_wait_queue_me +sysTid=12323 state=S futex_wait_queue_me +sysTid=12324 state=S futex_wait_queue_me +sysTid=12325 state=S futex_wait_queue_me +sysTid=12328 state=S do_epoll_wait +sysTid=12341 state=S futex_wait_queue_me +sysTid=12343 state=S futex_wait_queue_me +sysTid=12344 state=S futex_wait_queue_me +sysTid=12349 state=S futex_wait_queue_me +sysTid=12350 state=S futex_wait_queue_me +sysTid=12351 state=S futex_wait_queue_me +sysTid=12352 state=S futex_wait_queue_me +sysTid=12353 state=S binder_wait_for_work +sysTid=12354 state=S futex_wait_queue_me +sysTid=12355 state=S futex_wait_queue_me +sysTid=12358 state=R lock_page_maybe_drop_mmap +sysTid=12362 state=S futex_wait_queue_me +sysTid=12363 state=S futex_wait_queue_me +sysTid=12365 state=S do_epoll_wait +sysTid=12366 state=S futex_wait_queue_me +sysTid=12367 state=S futex_wait_queue_me +sysTid=12368 state=S futex_wait_queue_me +sysTid=12370 state=S futex_wait_queue_me +sysTid=12371 state=S futex_wait_queue_me +sysTid=12373 state=S futex_wait_queue_me +sysTid=12384 state=S binder_wait_for_work +sysTid=12391 state=S futex_wait_queue_me +sysTid=12399 state=S do_epoll_wait +sysTid=12401 state=S futex_wait_queue_me +sysTid=12402 state=S futex_wait_queue_me +sysTid=12404 state=S futex_wait_queue_me +sysTid=12405 state=S do_epoll_wait +sysTid=12407 state=S futex_wait_queue_me +sysTid=12408 state=S futex_wait_queue_me +sysTid=12409 state=S do_wait +sysTid=12410 state=S futex_wait_queue_me +sysTid=12412 state=S do_epoll_wait +sysTid=12435 state=S do_epoll_wait +sysTid=12468 state=S do_epoll_wait +sysTid=12514 state=S futex_wait_queue_me +sysTid=12550 state=S futex_wait_queue_me +sysTid=12561 state=S binder_wait_for_work +sysTid=12567 state=S binder_wait_for_work +sysTid=12580 state=S futex_wait_queue_me +sysTid=12619 state=S futex_wait_queue_me +sysTid=12627 state=S futex_wait_queue_me +sysTid=12644 state=S futex_wait_queue_me +sysTid=12887 state=S futex_wait_queue_me +sysTid=13430 state=S futex_wait_queue_me +sysTid=13438 state=S futex_wait_queue_me +sysTid=13443 state=S futex_wait_queue_me +sysTid=13454 state=S futex_wait_queue_me +sysTid=13455 state=S futex_wait_queue_me +sysTid=13564 state=S binder_wait_for_work +sysTid=13576 state=S binder_wait_for_work +sysTid=13579 state=S binder_wait_for_work +sysTid=13616 state=S binder_wait_for_work +sysTid=13624 state=S futex_wait_queue_me +sysTid=13706 state=S futex_wait_queue_me +sysTid=13722 state=S futex_wait_queue_me +sysTid=13724 state=S futex_wait_queue_me +sysTid=13730 state=S futex_wait_queue_me +sysTid=13740 state=S futex_wait_queue_me +sysTid=13744 state=S futex_wait_queue_me +sysTid=13745 state=S futex_wait_queue_me +sysTid=13748 state=S futex_wait_queue_me +sysTid=13754 state=S futex_wait_queue_me +sysTid=13756 state=S futex_wait_queue_me +sysTid=13757 state=S futex_wait_queue_me +sysTid=13758 state=S futex_wait_queue_me +sysTid=13759 state=S futex_wait_queue_me +sysTid=13763 state=S futex_wait_queue_me +sysTid=13767 state=S futex_wait_queue_me +sysTid=13768 state=S futex_wait_queue_me +sysTid=13769 state=S futex_wait_queue_me +sysTid=13773 state=S futex_wait_queue_me +sysTid=13776 state=S futex_wait_queue_me +sysTid=13781 state=S futex_wait_queue_me +sysTid=13782 state=S futex_wait_queue_me +sysTid=13783 state=S futex_wait_queue_me +sysTid=13784 state=S futex_wait_queue_me +sysTid=13786 state=S futex_wait_queue_me +sysTid=13791 state=S futex_wait_queue_me +sysTid=13792 state=S futex_wait_queue_me +sysTid=13793 state=S futex_wait_queue_me +sysTid=13794 state=S futex_wait_queue_me +sysTid=13795 state=S futex_wait_queue_me +sysTid=13796 state=S futex_wait_queue_me +sysTid=13797 state=S futex_wait_queue_me +sysTid=13798 state=S futex_wait_queue_me +sysTid=13799 state=S futex_wait_queue_me +sysTid=13800 state=S futex_wait_queue_me +sysTid=13806 state=S futex_wait_queue_me +sysTid=13809 state=S futex_wait_queue_me +sysTid=13814 state=S futex_wait_queue_me +sysTid=13815 state=S futex_wait_queue_me +sysTid=13816 state=S futex_wait_queue_me +sysTid=13817 state=S futex_wait_queue_me +sysTid=13818 state=S futex_wait_queue_me +sysTid=13820 state=S futex_wait_queue_me +sysTid=13825 state=S futex_wait_queue_me +sysTid=13830 state=S futex_wait_queue_me +sysTid=13831 state=S futex_wait_queue_me +sysTid=13832 state=S futex_wait_queue_me +sysTid=13833 state=S futex_wait_queue_me +sysTid=13834 state=S futex_wait_queue_me +sysTid=13835 state=S futex_wait_queue_me +sysTid=13836 state=S futex_wait_queue_me +sysTid=13841 state=S futex_wait_queue_me +sysTid=13847 state=S futex_wait_queue_me +sysTid=13848 state=S futex_wait_queue_me +sysTid=13849 state=S futex_wait_queue_me +sysTid=13850 state=S futex_wait_queue_me +sysTid=13851 state=S futex_wait_queue_me +sysTid=13852 state=S futex_wait_queue_me +sysTid=13853 state=S futex_wait_queue_me +sysTid=13854 state=S futex_wait_queue_me +sysTid=13857 state=S futex_wait_queue_me +sysTid=13863 state=S futex_wait_queue_me +sysTid=13867 state=S futex_wait_queue_me +sysTid=13880 state=S futex_wait_queue_me +sysTid=13920 state=S futex_wait_queue_me +sysTid=13949 state=S futex_wait_queue_me +sysTid=13953 state=S futex_wait_queue_me +sysTid=13954 state=S futex_wait_queue_me +sysTid=13955 state=S futex_wait_queue_me +sysTid=13958 state=S futex_wait_queue_me +sysTid=13959 state=S futex_wait_queue_me +sysTid=13967 state=S futex_wait_queue_me +sysTid=13980 state=S futex_wait_queue_me +sysTid=13981 state=S futex_wait_queue_me +sysTid=13982 state=S futex_wait_queue_me +sysTid=13983 state=S futex_wait_queue_me +sysTid=13984 state=S futex_wait_queue_me +sysTid=13986 state=S futex_wait_queue_me +sysTid=13987 state=S futex_wait_queue_me +sysTid=13991 state=S futex_wait_queue_me +sysTid=13998 state=S futex_wait_queue_me +sysTid=13999 state=S futex_wait_queue_me +sysTid=14000 state=S futex_wait_queue_me +sysTid=14001 state=S futex_wait_queue_me +sysTid=14002 state=S futex_wait_queue_me +sysTid=14003 state=S futex_wait_queue_me +sysTid=14004 state=S futex_wait_queue_me +sysTid=14005 state=S futex_wait_queue_me +sysTid=14006 state=S futex_wait_queue_me +sysTid=14007 state=S futex_wait_queue_me +sysTid=14026 state=S futex_wait_queue_me +sysTid=14052 state=S futex_wait_queue_me +sysTid=14057 state=S futex_wait_queue_me +sysTid=14060 state=S futex_wait_queue_me +sysTid=14063 state=S futex_wait_queue_me +sysTid=14069 state=S futex_wait_queue_me +sysTid=14072 state=S futex_wait_queue_me +sysTid=14075 state=S futex_wait_queue_me +sysTid=14081 state=S futex_wait_queue_me +sysTid=14084 state=S futex_wait_queue_me +sysTid=14089 state=S futex_wait_queue_me +sysTid=14090 state=S futex_wait_queue_me +sysTid=14091 state=S futex_wait_queue_me +sysTid=14092 state=S futex_wait_queue_me +sysTid=14093 state=S futex_wait_queue_me +sysTid=14094 state=S futex_wait_queue_me +sysTid=14095 state=S futex_wait_queue_me +sysTid=14096 state=S futex_wait_queue_me +sysTid=14097 state=S futex_wait_queue_me +sysTid=14098 state=S futex_wait_queue_me +sysTid=14099 state=S futex_wait_queue_me +sysTid=14100 state=S futex_wait_queue_me +sysTid=14101 state=S futex_wait_queue_me +sysTid=14102 state=S futex_wait_queue_me +sysTid=14103 state=S futex_wait_queue_me +sysTid=14104 state=S futex_wait_queue_me +sysTid=14106 state=S futex_wait_queue_me +sysTid=14111 state=S futex_wait_queue_me +sysTid=14117 state=S futex_wait_queue_me +sysTid=14120 state=S futex_wait_queue_me +sysTid=14121 state=S futex_wait_queue_me +sysTid=14122 state=S futex_wait_queue_me +sysTid=14123 state=S futex_wait_queue_me +sysTid=14124 state=S futex_wait_queue_me +sysTid=14129 state=S futex_wait_queue_me +sysTid=14130 state=S futex_wait_queue_me +sysTid=14131 state=S futex_wait_queue_me +sysTid=14132 state=S futex_wait_queue_me +sysTid=14136 state=S futex_wait_queue_me +sysTid=14144 state=S futex_wait_queue_me +sysTid=14148 state=S futex_wait_queue_me +sysTid=14154 state=S futex_wait_queue_me +sysTid=14158 state=S futex_wait_queue_me +sysTid=14164 state=S futex_wait_queue_me +sysTid=14167 state=S futex_wait_queue_me +sysTid=14168 state=S futex_wait_queue_me +sysTid=14169 state=S futex_wait_queue_me +sysTid=14170 state=S futex_wait_queue_me +sysTid=14171 state=S futex_wait_queue_me +sysTid=14172 state=S futex_wait_queue_me +sysTid=14173 state=S futex_wait_queue_me +sysTid=14174 state=S futex_wait_queue_me +sysTid=14175 state=S futex_wait_queue_me +sysTid=14176 state=S futex_wait_queue_me +sysTid=14177 state=S futex_wait_queue_me +sysTid=14178 state=S futex_wait_queue_me +sysTid=14179 state=S futex_wait_queue_me +sysTid=14180 state=S futex_wait_queue_me +sysTid=14181 state=S futex_wait_queue_me +sysTid=14182 state=S futex_wait_queue_me +sysTid=14190 state=S futex_wait_queue_me +sysTid=14195 state=S futex_wait_queue_me +sysTid=14198 state=S futex_wait_queue_me +sysTid=14207 state=S futex_wait_queue_me +sysTid=14209 state=S futex_wait_queue_me +sysTid=14210 state=S futex_wait_queue_me +sysTid=14214 state=S futex_wait_queue_me +sysTid=14220 state=S futex_wait_queue_me +sysTid=14223 state=S futex_wait_queue_me +sysTid=14227 state=S futex_wait_queue_me +sysTid=14235 state=S futex_wait_queue_me +sysTid=14242 state=S futex_wait_queue_me +sysTid=14243 state=S futex_wait_queue_me +sysTid=14244 state=S futex_wait_queue_me +sysTid=14245 state=S futex_wait_queue_me +sysTid=14246 state=S futex_wait_queue_me +sysTid=14247 state=S futex_wait_queue_me +sysTid=14248 state=S futex_wait_queue_me +sysTid=14249 state=S futex_wait_queue_me +sysTid=14250 state=S futex_wait_queue_me +sysTid=14251 state=S futex_wait_queue_me +sysTid=14253 state=S futex_wait_queue_me +sysTid=14259 state=S futex_wait_queue_me +sysTid=14264 state=S futex_wait_queue_me +sysTid=14269 state=S futex_wait_queue_me +sysTid=14272 state=S futex_wait_queue_me +sysTid=14277 state=S futex_wait_queue_me +sysTid=14282 state=S futex_wait_queue_me +sysTid=14296 state=S futex_wait_queue_me +sysTid=14302 state=S futex_wait_queue_me +sysTid=14309 state=S futex_wait_queue_me +sysTid=14314 state=S futex_wait_queue_me +sysTid=14319 state=S futex_wait_queue_me +sysTid=14324 state=S futex_wait_queue_me +sysTid=14325 state=S futex_wait_queue_me +sysTid=14327 state=S futex_wait_queue_me +sysTid=14328 state=S futex_wait_queue_me +sysTid=14329 state=S futex_wait_queue_me +sysTid=14331 state=S futex_wait_queue_me +sysTid=14348 state=S futex_wait_queue_me +sysTid=14349 state=S futex_wait_queue_me +sysTid=14350 state=S futex_wait_queue_me +sysTid=14351 state=S futex_wait_queue_me +sysTid=14352 state=S futex_wait_queue_me +sysTid=14353 state=S futex_wait_queue_me +sysTid=14357 state=S futex_wait_queue_me +sysTid=14358 state=S futex_wait_queue_me +sysTid=14359 state=S futex_wait_queue_me +sysTid=14360 state=S futex_wait_queue_me +sysTid=14361 state=S futex_wait_queue_me +sysTid=14363 state=S futex_wait_queue_me +sysTid=14364 state=S futex_wait_queue_me +sysTid=14365 state=S futex_wait_queue_me +sysTid=14366 state=S futex_wait_queue_me +sysTid=14367 state=S futex_wait_queue_me +sysTid=14368 state=S futex_wait_queue_me +sysTid=14369 state=S futex_wait_queue_me +sysTid=14380 state=S futex_wait_queue_me +sysTid=14400 state=S futex_wait_queue_me +sysTid=14414 state=S futex_wait_queue_me +sysTid=14423 state=S futex_wait_queue_me +sysTid=14431 state=S futex_wait_queue_me +sysTid=14439 state=S futex_wait_queue_me +sysTid=14442 state=S futex_wait_queue_me +sysTid=14451 state=S futex_wait_queue_me +sysTid=14453 state=S futex_wait_queue_me +sysTid=14454 state=S futex_wait_queue_me +sysTid=14456 state=S futex_wait_queue_me +sysTid=14457 state=S futex_wait_queue_me +sysTid=14459 state=S futex_wait_queue_me +sysTid=14460 state=S futex_wait_queue_me +sysTid=14461 state=S futex_wait_queue_me +sysTid=14462 state=S futex_wait_queue_me +sysTid=14465 state=S futex_wait_queue_me +sysTid=14466 state=S futex_wait_queue_me +sysTid=14467 state=S futex_wait_queue_me +sysTid=14473 state=S futex_wait_queue_me +sysTid=14485 state=S futex_wait_queue_me +sysTid=14491 state=S futex_wait_queue_me +sysTid=14493 state=S futex_wait_queue_me +sysTid=14500 state=S futex_wait_queue_me +sysTid=14514 state=S futex_wait_queue_me +sysTid=14522 state=S futex_wait_queue_me +sysTid=14529 state=S futex_wait_queue_me +sysTid=14531 state=S futex_wait_queue_me +sysTid=14538 state=S futex_wait_queue_me +sysTid=14542 state=S futex_wait_queue_me +sysTid=14550 state=S futex_wait_queue_me +sysTid=14551 state=S futex_wait_queue_me +sysTid=14552 state=S futex_wait_queue_me +sysTid=14554 state=S futex_wait_queue_me +sysTid=14555 state=S futex_wait_queue_me +sysTid=14556 state=S futex_wait_queue_me +sysTid=14557 state=S futex_wait_queue_me +sysTid=14558 state=S futex_wait_queue_me +sysTid=14559 state=S futex_wait_queue_me +sysTid=14560 state=S futex_wait_queue_me +sysTid=14561 state=S futex_wait_queue_me +sysTid=14562 state=S futex_wait_queue_me +sysTid=14563 state=S futex_wait_queue_me +sysTid=14564 state=S futex_wait_queue_me +sysTid=14565 state=S futex_wait_queue_me +sysTid=14566 state=S futex_wait_queue_me +sysTid=14567 state=S futex_wait_queue_me +sysTid=14568 state=S futex_wait_queue_me +sysTid=14570 state=S futex_wait_queue_me +sysTid=14573 state=S futex_wait_queue_me +sysTid=14580 state=S futex_wait_queue_me +sysTid=14585 state=S futex_wait_queue_me +sysTid=14594 state=S futex_wait_queue_me +sysTid=14606 state=S futex_wait_queue_me +sysTid=14608 state=S futex_wait_queue_me +sysTid=14622 state=S futex_wait_queue_me +sysTid=14646 state=S futex_wait_queue_me +sysTid=14660 state=S futex_wait_queue_me +sysTid=14664 state=S futex_wait_queue_me +sysTid=14673 state=S futex_wait_queue_me +sysTid=14676 state=S futex_wait_queue_me +sysTid=14691 state=S futex_wait_queue_me +sysTid=14694 state=S futex_wait_queue_me +sysTid=14695 state=S futex_wait_queue_me +sysTid=14696 state=S futex_wait_queue_me +sysTid=14697 state=S futex_wait_queue_me +sysTid=14698 state=S futex_wait_queue_me +sysTid=14699 state=S futex_wait_queue_me +sysTid=14700 state=S futex_wait_queue_me +sysTid=14701 state=S futex_wait_queue_me +sysTid=14702 state=S futex_wait_queue_me +sysTid=14703 state=S futex_wait_queue_me +sysTid=14704 state=S futex_wait_queue_me +sysTid=14705 state=S futex_wait_queue_me +sysTid=14706 state=S futex_wait_queue_me +sysTid=14707 state=S futex_wait_queue_me +sysTid=14708 state=S futex_wait_queue_me +sysTid=14709 state=S futex_wait_queue_me +sysTid=14710 state=S futex_wait_queue_me +sysTid=14711 state=S futex_wait_queue_me +sysTid=14712 state=S futex_wait_queue_me +sysTid=14713 state=S futex_wait_queue_me +sysTid=14714 state=S futex_wait_queue_me +sysTid=14715 state=S futex_wait_queue_me +sysTid=14716 state=S futex_wait_queue_me +sysTid=14717 state=S futex_wait_queue_me +sysTid=14718 state=S futex_wait_queue_me +sysTid=14719 state=S futex_wait_queue_me +sysTid=14720 state=S futex_wait_queue_me +sysTid=14721 state=S futex_wait_queue_me +sysTid=14722 state=S futex_wait_queue_me +sysTid=14723 state=S futex_wait_queue_me +sysTid=14724 state=S futex_wait_queue_me +sysTid=14725 state=S futex_wait_queue_me +sysTid=14726 state=S futex_wait_queue_me +sysTid=14727 state=S futex_wait_queue_me +sysTid=14728 state=S futex_wait_queue_me +sysTid=14731 state=S futex_wait_queue_me +sysTid=14737 state=S futex_wait_queue_me +sysTid=14744 state=S futex_wait_queue_me +sysTid=14749 state=S futex_wait_queue_me +sysTid=14756 state=S futex_wait_queue_me +sysTid=14764 state=S futex_wait_queue_me +sysTid=14766 state=S futex_wait_queue_me +sysTid=14770 state=S futex_wait_queue_me +sysTid=14780 state=S futex_wait_queue_me +sysTid=14783 state=S futex_wait_queue_me +sysTid=14787 state=S futex_wait_queue_me +sysTid=14794 state=S futex_wait_queue_me +sysTid=14799 state=S futex_wait_queue_me +sysTid=14807 state=S futex_wait_queue_me +sysTid=14813 state=S futex_wait_queue_me +sysTid=14817 state=S futex_wait_queue_me +sysTid=14818 state=S futex_wait_queue_me +sysTid=14819 state=S futex_wait_queue_me +sysTid=14820 state=S futex_wait_queue_me +sysTid=14824 state=S futex_wait_queue_me +sysTid=14825 state=S futex_wait_queue_me +sysTid=14826 state=S futex_wait_queue_me +sysTid=14827 state=S futex_wait_queue_me +sysTid=14828 state=S futex_wait_queue_me +sysTid=14829 state=S futex_wait_queue_me +sysTid=14830 state=S futex_wait_queue_me +sysTid=14835 state=S futex_wait_queue_me +sysTid=14842 state=S futex_wait_queue_me +sysTid=14852 state=S futex_wait_queue_me +sysTid=14854 state=S futex_wait_queue_me +sysTid=14862 state=S futex_wait_queue_me +sysTid=14868 state=S futex_wait_queue_me +sysTid=14869 state=S futex_wait_queue_me +sysTid=14870 state=S futex_wait_queue_me +sysTid=14871 state=S futex_wait_queue_me +sysTid=14872 state=S futex_wait_queue_me +sysTid=14873 state=S futex_wait_queue_me +sysTid=14874 state=S futex_wait_queue_me +sysTid=14875 state=S futex_wait_queue_me +sysTid=14876 state=S futex_wait_queue_me +sysTid=14877 state=S futex_wait_queue_me +sysTid=14878 state=S futex_wait_queue_me +sysTid=14879 state=S futex_wait_queue_me +sysTid=14880 state=S futex_wait_queue_me +sysTid=14881 state=S futex_wait_queue_me +sysTid=14882 state=S futex_wait_queue_me +sysTid=14883 state=S futex_wait_queue_me +sysTid=14884 state=S futex_wait_queue_me +sysTid=14885 state=S futex_wait_queue_me +sysTid=14887 state=S futex_wait_queue_me +sysTid=14888 state=S futex_wait_queue_me +sysTid=14889 state=S futex_wait_queue_me +sysTid=14890 state=S futex_wait_queue_me +sysTid=14891 state=S futex_wait_queue_me +sysTid=14892 state=S futex_wait_queue_me +sysTid=14893 state=S futex_wait_queue_me +sysTid=14897 state=S futex_wait_queue_me +sysTid=14903 state=S futex_wait_queue_me +sysTid=14911 state=S futex_wait_queue_me +sysTid=14915 state=S futex_wait_queue_me +sysTid=14920 state=S futex_wait_queue_me +sysTid=14924 state=S futex_wait_queue_me +sysTid=14932 state=S futex_wait_queue_me +sysTid=14972 state=S futex_wait_queue_me +sysTid=14974 state=S futex_wait_queue_me +sysTid=15011 state=S futex_wait_queue_me +sysTid=15019 state=S futex_wait_queue_me +sysTid=15032 state=S futex_wait_queue_me +sysTid=15054 state=S futex_wait_queue_me +sysTid=15124 state=S futex_wait_queue_me +sysTid=15177 state=S futex_wait_queue_me +sysTid=15217 state=S futex_wait_queue_me +sysTid=15228 state=S futex_wait_queue_me +sysTid=15236 state=S futex_wait_queue_me +sysTid=15248 state=S futex_wait_queue_me +sysTid=15265 state=S futex_wait_queue_me +sysTid=15272 state=S futex_wait_queue_me +sysTid=15276 state=S futex_wait_queue_me +sysTid=15344 state=S sk_wait_data +sysTid=15400 state=S sk_wait_data +sysTid=15415 state=S sk_wait_data +sysTid=15421 state=S sk_wait_data +sysTid=15449 state=S sk_wait_data +sysTid=15463 state=S sk_wait_data +sysTid=15471 state=S sk_wait_data +sysTid=15479 state=S sk_wait_data +sysTid=15486 state=S sk_wait_data +sysTid=15509 state=S sk_wait_data +sysTid=15515 state=S sk_wait_data +sysTid=15525 state=S sk_wait_data +sysTid=15530 state=S sk_wait_data +sysTid=15536 state=S sk_wait_data +sysTid=15541 state=S sk_wait_data +sysTid=15578 state=S futex_wait_queue_me +sysTid=16256 state=S futex_wait_queue_me +sysTid=16261 state=S futex_wait_queue_me +sysTid=16262 state=S futex_wait_queue_me + +----- end 12233 ----- + +libdebuggerd_client: unexpected registration response: 0 + +----- Waiting Channels: pid 12233 at 2024-11-13 19:48:10.010218499+0530 ----- +Cmd line: com.example.app:gameProcess + +sysTid=12233 state=R 0 +sysTid=12236 state=D swap_readpage +sysTid=12237 state=S futex_wait_queue_me +sysTid=12238 state=S futex_wait_queue_me +sysTid=12239 state=S futex_wait_queue_me +sysTid=12240 state=S futex_wait_queue_me +sysTid=12241 state=S futex_wait_queue_me +sysTid=12242 state=S binder_wait_for_work +sysTid=12243 state=S binder_wait_for_work +sysTid=12245 state=S binder_wait_for_work +sysTid=12252 state=S futex_wait_queue_me +sysTid=12254 state=S inotify_read +sysTid=12257 state=S __arm64_sys_nanosleep +sysTid=12259 state=R 0 +sysTid=12260 state=S __arm64_sys_nanosleep +sysTid=12268 state=S __arm64_sys_nanosleep +sysTid=12269 state=S __arm64_sys_nanosleep +sysTid=12270 state=S __arm64_sys_nanosleep +sysTid=12278 state=S futex_wait_queue_me +sysTid=12279 state=S futex_wait_queue_me +sysTid=12280 state=S futex_wait_queue_me +sysTid=12283 state=S futex_wait_queue_me +sysTid=12287 state=S futex_wait_queue_me +sysTid=12290 state=S futex_wait_queue_me +sysTid=12291 state=S futex_wait_queue_me +sysTid=12292 state=S futex_wait_queue_me +sysTid=12295 state=S futex_wait_queue_me +sysTid=12296 state=S futex_wait_queue_me +sysTid=12297 state=S futex_wait_queue_me +sysTid=12298 state=S futex_wait_queue_me +sysTid=12304 state=S futex_wait_queue_me +sysTid=12310 state=S futex_wait_queue_me +sysTid=12311 state=S futex_wait_queue_me +sysTid=12312 state=S do_epoll_wait +sysTid=12314 state=S futex_wait_queue_me +sysTid=12315 state=S futex_wait_queue_me +sysTid=12316 state=S futex_wait_queue_me +sysTid=12317 state=S futex_wait_queue_me +sysTid=12319 state=S futex_wait_queue_me +sysTid=12320 state=S do_epoll_wait +sysTid=12321 state=S futex_wait_queue_me +sysTid=12323 state=S futex_wait_queue_me +sysTid=12324 state=S futex_wait_queue_me +sysTid=12325 state=S futex_wait_queue_me +sysTid=12328 state=S do_epoll_wait +sysTid=12341 state=S futex_wait_queue_me +sysTid=12343 state=S futex_wait_queue_me +sysTid=12344 state=S futex_wait_queue_me +sysTid=12349 state=S futex_wait_queue_me +sysTid=12350 state=S futex_wait_queue_me +sysTid=12351 state=S futex_wait_queue_me +sysTid=12352 state=S futex_wait_queue_me +sysTid=12353 state=S binder_wait_for_work +sysTid=12354 state=S futex_wait_queue_me +sysTid=12355 state=S futex_wait_queue_me +sysTid=12358 state=R 0 +sysTid=12362 state=S futex_wait_queue_me +sysTid=12363 state=S futex_wait_queue_me +sysTid=12365 state=S do_epoll_wait +sysTid=12366 state=S futex_wait_queue_me +sysTid=12367 state=S futex_wait_queue_me +sysTid=12368 state=S futex_wait_queue_me +sysTid=12370 state=S futex_wait_queue_me +sysTid=12371 state=S futex_wait_queue_me +sysTid=12373 state=S futex_wait_queue_me +sysTid=12384 state=S binder_wait_for_work +sysTid=12391 state=S futex_wait_queue_me +sysTid=12399 state=S do_epoll_wait +sysTid=12401 state=S futex_wait_queue_me +sysTid=12402 state=S futex_wait_queue_me +sysTid=12404 state=S futex_wait_queue_me +sysTid=12405 state=S do_epoll_wait +sysTid=12407 state=S futex_wait_queue_me +sysTid=12408 state=S futex_wait_queue_me +sysTid=12409 state=S do_wait +sysTid=12410 state=S futex_wait_queue_me +sysTid=12412 state=S do_epoll_wait +sysTid=12435 state=S do_epoll_wait +sysTid=12468 state=S futex_wait_queue_me +sysTid=12514 state=S futex_wait_queue_me +sysTid=12550 state=S futex_wait_queue_me +sysTid=12561 state=S binder_wait_for_work +sysTid=12567 state=S binder_wait_for_work +sysTid=12580 state=S futex_wait_queue_me +sysTid=12619 state=S futex_wait_queue_me +sysTid=12627 state=S futex_wait_queue_me +sysTid=12644 state=S futex_wait_queue_me +sysTid=12887 state=S futex_wait_queue_me +sysTid=13430 state=S futex_wait_queue_me +sysTid=13438 state=S futex_wait_queue_me +sysTid=13443 state=S futex_wait_queue_me +sysTid=13454 state=S futex_wait_queue_me +sysTid=13455 state=S futex_wait_queue_me +sysTid=13564 state=S binder_wait_for_work +sysTid=13576 state=S binder_wait_for_work +sysTid=13579 state=S binder_wait_for_work +sysTid=13616 state=S binder_wait_for_work +sysTid=13624 state=S futex_wait_queue_me +sysTid=13706 state=S futex_wait_queue_me +sysTid=13722 state=S futex_wait_queue_me +sysTid=13724 state=S futex_wait_queue_me +sysTid=13730 state=S futex_wait_queue_me +sysTid=13740 state=S futex_wait_queue_me +sysTid=13744 state=S futex_wait_queue_me +sysTid=13745 state=S futex_wait_queue_me +sysTid=13748 state=S futex_wait_queue_me +sysTid=13754 state=S futex_wait_queue_me +sysTid=13756 state=S futex_wait_queue_me +sysTid=13757 state=S futex_wait_queue_me +sysTid=13758 state=S futex_wait_queue_me +sysTid=13759 state=S futex_wait_queue_me +sysTid=13763 state=S futex_wait_queue_me +sysTid=13767 state=S futex_wait_queue_me +sysTid=13768 state=S futex_wait_queue_me +sysTid=13769 state=S futex_wait_queue_me +sysTid=13773 state=S futex_wait_queue_me +sysTid=13776 state=S futex_wait_queue_me +sysTid=13781 state=S futex_wait_queue_me +sysTid=13782 state=S futex_wait_queue_me +sysTid=13783 state=S futex_wait_queue_me +sysTid=13784 state=S futex_wait_queue_me +sysTid=13786 state=S futex_wait_queue_me +sysTid=13791 state=S futex_wait_queue_me +sysTid=13792 state=S futex_wait_queue_me +sysTid=13793 state=S futex_wait_queue_me +sysTid=13794 state=S futex_wait_queue_me +sysTid=13795 state=S futex_wait_queue_me +sysTid=13796 state=S futex_wait_queue_me +sysTid=13797 state=S futex_wait_queue_me +sysTid=13798 state=S futex_wait_queue_me +sysTid=13799 state=S futex_wait_queue_me +sysTid=13800 state=S futex_wait_queue_me +sysTid=13806 state=S futex_wait_queue_me +sysTid=13809 state=S futex_wait_queue_me +sysTid=13814 state=S futex_wait_queue_me +sysTid=13815 state=S futex_wait_queue_me +sysTid=13816 state=S futex_wait_queue_me +sysTid=13817 state=S futex_wait_queue_me +sysTid=13818 state=S futex_wait_queue_me +sysTid=13820 state=S futex_wait_queue_me +sysTid=13825 state=S futex_wait_queue_me +sysTid=13830 state=S futex_wait_queue_me +sysTid=13831 state=S futex_wait_queue_me +sysTid=13832 state=S futex_wait_queue_me +sysTid=13833 state=S futex_wait_queue_me +sysTid=13834 state=S futex_wait_queue_me +sysTid=13835 state=S futex_wait_queue_me +sysTid=13836 state=S futex_wait_queue_me +sysTid=13841 state=S futex_wait_queue_me +sysTid=13847 state=S futex_wait_queue_me +sysTid=13848 state=S futex_wait_queue_me +sysTid=13849 state=S futex_wait_queue_me +sysTid=13850 state=S futex_wait_queue_me +sysTid=13851 state=S futex_wait_queue_me +sysTid=13852 state=S futex_wait_queue_me +sysTid=13853 state=S futex_wait_queue_me +sysTid=13854 state=S futex_wait_queue_me +sysTid=13857 state=S futex_wait_queue_me +sysTid=13863 state=S futex_wait_queue_me +sysTid=13867 state=S futex_wait_queue_me +sysTid=13880 state=S futex_wait_queue_me +sysTid=13920 state=S futex_wait_queue_me +sysTid=13949 state=S futex_wait_queue_me +sysTid=13953 state=S futex_wait_queue_me +sysTid=13954 state=S futex_wait_queue_me +sysTid=13955 state=S futex_wait_queue_me +sysTid=13958 state=S futex_wait_queue_me +sysTid=13959 state=S futex_wait_queue_me +sysTid=13967 state=S futex_wait_queue_me +sysTid=13980 state=S futex_wait_queue_me +sysTid=13981 state=S futex_wait_queue_me +sysTid=13982 state=S futex_wait_queue_me +sysTid=13983 state=S futex_wait_queue_me +sysTid=13984 state=S futex_wait_queue_me +sysTid=13986 state=S futex_wait_queue_me +sysTid=13987 state=S futex_wait_queue_me +sysTid=13991 state=S futex_wait_queue_me +sysTid=13998 state=S futex_wait_queue_me +sysTid=13999 state=S futex_wait_queue_me +sysTid=14000 state=S futex_wait_queue_me +sysTid=14001 state=S futex_wait_queue_me +sysTid=14002 state=S futex_wait_queue_me +sysTid=14003 state=S futex_wait_queue_me +sysTid=14004 state=S futex_wait_queue_me +sysTid=14005 state=S futex_wait_queue_me +sysTid=14006 state=S futex_wait_queue_me +sysTid=14007 state=S futex_wait_queue_me +sysTid=14026 state=S futex_wait_queue_me +sysTid=14052 state=S futex_wait_queue_me +sysTid=14057 state=S futex_wait_queue_me +sysTid=14060 state=S futex_wait_queue_me +sysTid=14063 state=S futex_wait_queue_me +sysTid=14069 state=S futex_wait_queue_me +sysTid=14072 state=S futex_wait_queue_me +sysTid=14075 state=S futex_wait_queue_me +sysTid=14081 state=S futex_wait_queue_me +sysTid=14084 state=S futex_wait_queue_me +sysTid=14089 state=S futex_wait_queue_me +sysTid=14090 state=S futex_wait_queue_me +sysTid=14091 state=S futex_wait_queue_me +sysTid=14092 state=S futex_wait_queue_me +sysTid=14093 state=S futex_wait_queue_me +sysTid=14094 state=S futex_wait_queue_me +sysTid=14095 state=S futex_wait_queue_me +sysTid=14096 state=S futex_wait_queue_me +sysTid=14097 state=S futex_wait_queue_me +sysTid=14098 state=S futex_wait_queue_me +sysTid=14099 state=S futex_wait_queue_me +sysTid=14100 state=S futex_wait_queue_me +sysTid=14101 state=S futex_wait_queue_me +sysTid=14102 state=S futex_wait_queue_me +sysTid=14103 state=S futex_wait_queue_me +sysTid=14104 state=S futex_wait_queue_me +sysTid=14106 state=S futex_wait_queue_me +sysTid=14111 state=S futex_wait_queue_me +sysTid=14117 state=S futex_wait_queue_me +sysTid=14120 state=S futex_wait_queue_me +sysTid=14121 state=S futex_wait_queue_me +sysTid=14122 state=S futex_wait_queue_me +sysTid=14123 state=S futex_wait_queue_me +sysTid=14124 state=S futex_wait_queue_me +sysTid=14129 state=S futex_wait_queue_me +sysTid=14130 state=S futex_wait_queue_me +sysTid=14131 state=S futex_wait_queue_me +sysTid=14132 state=S futex_wait_queue_me +sysTid=14136 state=S futex_wait_queue_me +sysTid=14144 state=S futex_wait_queue_me +sysTid=14148 state=S futex_wait_queue_me +sysTid=14154 state=S futex_wait_queue_me +sysTid=14158 state=S futex_wait_queue_me +sysTid=14164 state=S futex_wait_queue_me +sysTid=14167 state=S futex_wait_queue_me +sysTid=14168 state=S futex_wait_queue_me +sysTid=14169 state=S futex_wait_queue_me +sysTid=14170 state=S futex_wait_queue_me +sysTid=14171 state=S futex_wait_queue_me +sysTid=14172 state=S futex_wait_queue_me +sysTid=14173 state=S futex_wait_queue_me +sysTid=14174 state=S futex_wait_queue_me +sysTid=14175 state=S futex_wait_queue_me +sysTid=14176 state=S futex_wait_queue_me +sysTid=14177 state=S futex_wait_queue_me +sysTid=14178 state=S futex_wait_queue_me +sysTid=14179 state=S futex_wait_queue_me +sysTid=14180 state=S futex_wait_queue_me +sysTid=14181 state=S futex_wait_queue_me +sysTid=14182 state=S futex_wait_queue_me +sysTid=14190 state=S futex_wait_queue_me +sysTid=14195 state=S futex_wait_queue_me +sysTid=14198 state=S futex_wait_queue_me +sysTid=14207 state=S futex_wait_queue_me +sysTid=14209 state=S futex_wait_queue_me +sysTid=14210 state=S futex_wait_queue_me +sysTid=14214 state=S futex_wait_queue_me +sysTid=14220 state=S futex_wait_queue_me +sysTid=14223 state=S futex_wait_queue_me +sysTid=14227 state=S futex_wait_queue_me +sysTid=14235 state=S futex_wait_queue_me +sysTid=14242 state=S futex_wait_queue_me +sysTid=14243 state=S futex_wait_queue_me +sysTid=14244 state=S futex_wait_queue_me +sysTid=14245 state=S futex_wait_queue_me +sysTid=14246 state=S futex_wait_queue_me +sysTid=14247 state=S futex_wait_queue_me +sysTid=14248 state=S futex_wait_queue_me +sysTid=14249 state=S futex_wait_queue_me +sysTid=14250 state=S futex_wait_queue_me +sysTid=14251 state=S futex_wait_queue_me +sysTid=14253 state=S futex_wait_queue_me +sysTid=14259 state=S futex_wait_queue_me +sysTid=14264 state=S futex_wait_queue_me +sysTid=14269 state=S futex_wait_queue_me +sysTid=14272 state=S futex_wait_queue_me +sysTid=14277 state=S futex_wait_queue_me +sysTid=14282 state=S futex_wait_queue_me +sysTid=14296 state=S futex_wait_queue_me +sysTid=14302 state=S futex_wait_queue_me +sysTid=14309 state=S futex_wait_queue_me +sysTid=14314 state=S futex_wait_queue_me +sysTid=14319 state=S futex_wait_queue_me +sysTid=14324 state=S futex_wait_queue_me +sysTid=14325 state=S futex_wait_queue_me +sysTid=14327 state=S futex_wait_queue_me +sysTid=14328 state=S futex_wait_queue_me +sysTid=14329 state=S futex_wait_queue_me +sysTid=14331 state=S futex_wait_queue_me +sysTid=14348 state=S futex_wait_queue_me +sysTid=14349 state=S futex_wait_queue_me +sysTid=14350 state=S futex_wait_queue_me +sysTid=14351 state=S futex_wait_queue_me +sysTid=14352 state=S futex_wait_queue_me +sysTid=14353 state=S futex_wait_queue_me +sysTid=14357 state=S futex_wait_queue_me +sysTid=14358 state=S futex_wait_queue_me +sysTid=14359 state=S futex_wait_queue_me +sysTid=14360 state=S futex_wait_queue_me +sysTid=14361 state=S futex_wait_queue_me +sysTid=14363 state=S futex_wait_queue_me +sysTid=14364 state=S futex_wait_queue_me +sysTid=14365 state=S futex_wait_queue_me +sysTid=14366 state=S futex_wait_queue_me +sysTid=14367 state=S futex_wait_queue_me +sysTid=14368 state=S futex_wait_queue_me +sysTid=14369 state=S futex_wait_queue_me +sysTid=14380 state=S futex_wait_queue_me +sysTid=14400 state=S futex_wait_queue_me +sysTid=14414 state=S futex_wait_queue_me +sysTid=14423 state=S futex_wait_queue_me +sysTid=14431 state=S futex_wait_queue_me +sysTid=14439 state=S futex_wait_queue_me +sysTid=14442 state=S futex_wait_queue_me +sysTid=14451 state=S futex_wait_queue_me +sysTid=14453 state=S futex_wait_queue_me +sysTid=14454 state=S futex_wait_queue_me +sysTid=14456 state=S futex_wait_queue_me +sysTid=14457 state=S futex_wait_queue_me +sysTid=14459 state=S futex_wait_queue_me +sysTid=14460 state=S futex_wait_queue_me +sysTid=14461 state=S futex_wait_queue_me +sysTid=14462 state=S futex_wait_queue_me +sysTid=14465 state=S futex_wait_queue_me +sysTid=14466 state=S futex_wait_queue_me +sysTid=14467 state=S futex_wait_queue_me +sysTid=14473 state=S futex_wait_queue_me +sysTid=14485 state=S futex_wait_queue_me +sysTid=14491 state=S futex_wait_queue_me +sysTid=14493 state=S futex_wait_queue_me +sysTid=14500 state=S futex_wait_queue_me +sysTid=14514 state=S futex_wait_queue_me +sysTid=14522 state=S futex_wait_queue_me +sysTid=14529 state=S futex_wait_queue_me +sysTid=14531 state=S futex_wait_queue_me +sysTid=14538 state=S futex_wait_queue_me +sysTid=14542 state=S futex_wait_queue_me +sysTid=14550 state=S futex_wait_queue_me +sysTid=14551 state=S futex_wait_queue_me +sysTid=14552 state=S futex_wait_queue_me +sysTid=14554 state=S futex_wait_queue_me +sysTid=14555 state=S futex_wait_queue_me +sysTid=14556 state=S futex_wait_queue_me +sysTid=14557 state=S futex_wait_queue_me +sysTid=14558 state=S futex_wait_queue_me +sysTid=14559 state=S futex_wait_queue_me +sysTid=14560 state=S futex_wait_queue_me +sysTid=14561 state=S futex_wait_queue_me +sysTid=14562 state=S futex_wait_queue_me +sysTid=14563 state=S futex_wait_queue_me +sysTid=14564 state=S futex_wait_queue_me +sysTid=14565 state=S futex_wait_queue_me +sysTid=14566 state=S futex_wait_queue_me +sysTid=14567 state=S futex_wait_queue_me +sysTid=14568 state=S futex_wait_queue_me +sysTid=14570 state=S futex_wait_queue_me +sysTid=14573 state=S futex_wait_queue_me +sysTid=14580 state=S futex_wait_queue_me +sysTid=14585 state=S futex_wait_queue_me +sysTid=14594 state=S futex_wait_queue_me +sysTid=14606 state=S futex_wait_queue_me +sysTid=14608 state=S futex_wait_queue_me +sysTid=14622 state=S futex_wait_queue_me +sysTid=14646 state=S futex_wait_queue_me +sysTid=14660 state=S futex_wait_queue_me +sysTid=14664 state=S futex_wait_queue_me +sysTid=14673 state=S futex_wait_queue_me +sysTid=14676 state=S futex_wait_queue_me +sysTid=14691 state=S futex_wait_queue_me +sysTid=14694 state=S futex_wait_queue_me +sysTid=14695 state=S futex_wait_queue_me +sysTid=14696 state=S futex_wait_queue_me +sysTid=14697 state=S futex_wait_queue_me +sysTid=14698 state=S futex_wait_queue_me +sysTid=14699 state=S futex_wait_queue_me +sysTid=14700 state=S futex_wait_queue_me +sysTid=14701 state=S futex_wait_queue_me +sysTid=14702 state=S futex_wait_queue_me +sysTid=14703 state=S futex_wait_queue_me +sysTid=14704 state=S futex_wait_queue_me +sysTid=14705 state=S futex_wait_queue_me +sysTid=14706 state=S futex_wait_queue_me +sysTid=14707 state=S futex_wait_queue_me +sysTid=14708 state=S futex_wait_queue_me +sysTid=14709 state=S futex_wait_queue_me +sysTid=14710 state=S futex_wait_queue_me +sysTid=14711 state=S futex_wait_queue_me +sysTid=14712 state=S futex_wait_queue_me +sysTid=14713 state=S futex_wait_queue_me +sysTid=14714 state=S futex_wait_queue_me +sysTid=14715 state=S futex_wait_queue_me +sysTid=14716 state=S futex_wait_queue_me +sysTid=14717 state=S futex_wait_queue_me +sysTid=14718 state=S futex_wait_queue_me +sysTid=14719 state=S futex_wait_queue_me +sysTid=14720 state=S futex_wait_queue_me +sysTid=14721 state=S futex_wait_queue_me +sysTid=14722 state=S futex_wait_queue_me +sysTid=14723 state=S futex_wait_queue_me +sysTid=14724 state=S futex_wait_queue_me +sysTid=14725 state=S futex_wait_queue_me +sysTid=14726 state=S futex_wait_queue_me +sysTid=14727 state=S futex_wait_queue_me +sysTid=14728 state=S futex_wait_queue_me +sysTid=14731 state=S futex_wait_queue_me +sysTid=14737 state=S futex_wait_queue_me +sysTid=14744 state=S futex_wait_queue_me +sysTid=14749 state=S futex_wait_queue_me +sysTid=14756 state=S futex_wait_queue_me +sysTid=14764 state=S futex_wait_queue_me +sysTid=14766 state=S futex_wait_queue_me +sysTid=14770 state=S futex_wait_queue_me +sysTid=14780 state=S futex_wait_queue_me +sysTid=14783 state=S futex_wait_queue_me +sysTid=14787 state=S futex_wait_queue_me +sysTid=14794 state=S futex_wait_queue_me +sysTid=14799 state=S futex_wait_queue_me +sysTid=14807 state=S futex_wait_queue_me +sysTid=14813 state=S futex_wait_queue_me +sysTid=14817 state=S futex_wait_queue_me +sysTid=14818 state=S futex_wait_queue_me +sysTid=14819 state=S futex_wait_queue_me +sysTid=14820 state=S futex_wait_queue_me +sysTid=14824 state=S futex_wait_queue_me +sysTid=14825 state=S futex_wait_queue_me +sysTid=14826 state=S futex_wait_queue_me +sysTid=14827 state=S futex_wait_queue_me +sysTid=14828 state=S futex_wait_queue_me +sysTid=14829 state=S futex_wait_queue_me +sysTid=14830 state=S futex_wait_queue_me +sysTid=14835 state=S futex_wait_queue_me +sysTid=14842 state=S futex_wait_queue_me +sysTid=14852 state=S futex_wait_queue_me +sysTid=14854 state=S futex_wait_queue_me +sysTid=14862 state=S futex_wait_queue_me +sysTid=14868 state=S futex_wait_queue_me +sysTid=14869 state=S futex_wait_queue_me +sysTid=14870 state=S futex_wait_queue_me +sysTid=14871 state=S futex_wait_queue_me +sysTid=14872 state=S futex_wait_queue_me +sysTid=14873 state=S futex_wait_queue_me +sysTid=14874 state=S futex_wait_queue_me +sysTid=14875 state=S futex_wait_queue_me +sysTid=14876 state=S futex_wait_queue_me +sysTid=14877 state=S futex_wait_queue_me +sysTid=14878 state=S futex_wait_queue_me +sysTid=14879 state=S futex_wait_queue_me +sysTid=14880 state=S futex_wait_queue_me +sysTid=14881 state=S futex_wait_queue_me +sysTid=14882 state=S futex_wait_queue_me +sysTid=14883 state=S futex_wait_queue_me +sysTid=14884 state=S futex_wait_queue_me +sysTid=14885 state=S futex_wait_queue_me +sysTid=14887 state=S futex_wait_queue_me +sysTid=14888 state=S futex_wait_queue_me +sysTid=14889 state=S futex_wait_queue_me +sysTid=14890 state=S futex_wait_queue_me +sysTid=14891 state=S futex_wait_queue_me +sysTid=14892 state=S futex_wait_queue_me +sysTid=14893 state=S futex_wait_queue_me +sysTid=14897 state=S futex_wait_queue_me +sysTid=14903 state=S futex_wait_queue_me +sysTid=14911 state=S futex_wait_queue_me +sysTid=14915 state=S futex_wait_queue_me +sysTid=14920 state=S futex_wait_queue_me +sysTid=14924 state=S futex_wait_queue_me +sysTid=14932 state=S futex_wait_queue_me +sysTid=14972 state=S futex_wait_queue_me +sysTid=14974 state=S futex_wait_queue_me +sysTid=15011 state=S futex_wait_queue_me +sysTid=15019 state=S futex_wait_queue_me +sysTid=15032 state=S futex_wait_queue_me +sysTid=15054 state=S futex_wait_queue_me +sysTid=15124 state=S futex_wait_queue_me +sysTid=15177 state=S futex_wait_queue_me +sysTid=15217 state=S futex_wait_queue_me +sysTid=15228 state=S futex_wait_queue_me +sysTid=15236 state=S futex_wait_queue_me +sysTid=15248 state=S futex_wait_queue_me +sysTid=15265 state=S futex_wait_queue_me +sysTid=15272 state=S futex_wait_queue_me +sysTid=15276 state=S futex_wait_queue_me +sysTid=15344 state=S sk_wait_data +sysTid=15400 state=S sk_wait_data +sysTid=15415 state=S sk_wait_data +sysTid=15421 state=S sk_wait_data +sysTid=15449 state=S sk_wait_data +sysTid=15463 state=S sk_wait_data +sysTid=15471 state=S sk_wait_data +sysTid=15479 state=S sk_wait_data +sysTid=15486 state=S sk_wait_data +sysTid=15509 state=S sk_wait_data +sysTid=15515 state=S sk_wait_data +sysTid=15525 state=S sk_wait_data +sysTid=15530 state=S sk_wait_data +sysTid=15536 state=S sk_wait_data +sysTid=15541 state=S sk_wait_data +sysTid=15578 state=S futex_wait_queue_me +sysTid=16256 state=S futex_wait_queue_me +sysTid=16261 state=S futex_wait_queue_me +sysTid=16262 state=S futex_wait_queue_me + +----- end 12233 ----- From dab52e252d6b38f0d2433637bdb93500decbb967 Mon Sep 17 00:00:00 2001 From: Stefano Date: Thu, 14 Nov 2024 14:48:46 +0100 Subject: [PATCH 90/92] [QA] Avoid collecting normal frames (#3782) * avoid keeping normal frames in memory when collecting slow/frozen frames * renamed SpanFrameMetricsCollector.getTotalFrameCount method to getSlowFrozenFrameCount * confirm no delay in UI test when there are no slow/frozen frames collected --- CHANGELOG.md | 1 + .../android/core/SentryFrameMetrics.java | 23 ++++-------------- .../core/SpanFrameMetricsCollector.java | 24 ++++++++++--------- .../android/core/SentryFrameMetricsTest.kt | 21 +++++----------- .../core/SpanFrameMetricsCollectorTest.kt | 11 +++++---- 5 files changed, 30 insertions(+), 50 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 92d7d2ef4b..ba0e84d5d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ ### Fixes +- Avoid collecting normal frames ([#3782](https://github.com/getsentry/sentry-java/pull/3782)) - Ensure android initialization process continues even if options configuration block throws an exception ([#3887](https://github.com/getsentry/sentry-java/pull/3887)) - Do not report parsing ANR error when there are no threads ([#3888](https://github.com/getsentry/sentry-java/pull/3888)) - This should significantly reduce the number of events with message "Sentry Android SDK failed to parse system thread dump..." reported diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/SentryFrameMetrics.java b/sentry-android-core/src/main/java/io/sentry/android/core/SentryFrameMetrics.java index 23409eadea..cf2241757c 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/SentryFrameMetrics.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/SentryFrameMetrics.java @@ -6,7 +6,6 @@ @ApiStatus.Internal final class SentryFrameMetrics { - private int normalFrameCount; private int slowFrameCount; private int frozenFrameCount; @@ -18,15 +17,11 @@ final class SentryFrameMetrics { public SentryFrameMetrics() {} public SentryFrameMetrics( - final int normalFrameCount, final int slowFrameCount, final long slowFrameDelayNanos, final int frozenFrameCount, final long frozenFrameDelayNanos, final long totalDurationNanos) { - - this.normalFrameCount = normalFrameCount; - this.slowFrameCount = slowFrameCount; this.slowFrameDelayNanos = slowFrameDelayNanos; @@ -47,15 +42,9 @@ public void addFrame( } else if (isSlow) { slowFrameDelayNanos += delayNanos; slowFrameCount += 1; - } else { - normalFrameCount += 1; } } - public int getNormalFrameCount() { - return normalFrameCount; - } - public int getSlowFrameCount() { return slowFrameCount; } @@ -72,8 +61,9 @@ public long getFrozenFrameDelayNanos() { return frozenFrameDelayNanos; } - public int getTotalFrameCount() { - return normalFrameCount + slowFrameCount + frozenFrameCount; + /** Returns the sum of the slow and frozen frames. */ + public int getSlowFrozenFrameCount() { + return slowFrameCount + frozenFrameCount; } public long getTotalDurationNanos() { @@ -81,8 +71,6 @@ public long getTotalDurationNanos() { } public void clear() { - normalFrameCount = 0; - slowFrameCount = 0; slowFrameDelayNanos = 0; @@ -95,7 +83,6 @@ public void clear() { @NotNull public SentryFrameMetrics duplicate() { return new SentryFrameMetrics( - normalFrameCount, slowFrameCount, slowFrameDelayNanos, frozenFrameCount, @@ -110,7 +97,6 @@ public SentryFrameMetrics duplicate() { @NotNull public SentryFrameMetrics diffTo(final @NotNull SentryFrameMetrics other) { return new SentryFrameMetrics( - normalFrameCount - other.normalFrameCount, slowFrameCount - other.slowFrameCount, slowFrameDelayNanos - other.slowFrameDelayNanos, frozenFrameCount - other.frozenFrameCount, @@ -123,8 +109,7 @@ public SentryFrameMetrics diffTo(final @NotNull SentryFrameMetrics other) { * to 0 */ public boolean containsValidData() { - return normalFrameCount >= 0 - && slowFrameCount >= 0 + return slowFrameCount >= 0 && slowFrameDelayNanos >= 0 && frozenFrameCount >= 0 && frozenFrameDelayNanos >= 0 diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/SpanFrameMetricsCollector.java b/sentry-android-core/src/main/java/io/sentry/android/core/SpanFrameMetricsCollector.java index 5535bccb91..b4279db13f 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/SpanFrameMetricsCollector.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/SpanFrameMetricsCollector.java @@ -192,7 +192,7 @@ private void captureFrameMetrics(@NotNull final ISpan span) { } } - int totalFrameCount = frameMetrics.getTotalFrameCount(); + int totalFrameCount = frameMetrics.getSlowFrozenFrameCount(); final long nextScheduledFrameNanos = frameMetricsCollector.getLastKnownFrameStartTimeNanos(); // nextScheduledFrameNanos might be -1 if no frames have been scheduled for drawing yet @@ -254,15 +254,17 @@ public void onFrameMetricCollected( (long) ((double) ONE_SECOND_NANOS / (double) refreshRate); lastKnownFrameDurationNanos = expectedFrameDurationNanos; - frames.add( - new Frame( - frameStartNanos, - frameEndNanos, - durationNanos, - delayNanos, - isSlow, - isFrozen, - expectedFrameDurationNanos)); + if (isSlow || isFrozen) { + frames.add( + new Frame( + frameStartNanos, + frameEndNanos, + durationNanos, + delayNanos, + isSlow, + isFrozen, + expectedFrameDurationNanos)); + } } private static int interpolateFrameCount( @@ -277,7 +279,7 @@ private static int interpolateFrameCount( final long frameMetricsDurationNanos = frameMetrics.getTotalDurationNanos(); final long nonRenderedDuration = spanDurationNanos - frameMetricsDurationNanos; if (nonRenderedDuration > 0) { - return (int) (nonRenderedDuration / frameDurationNanos); + return (int) Math.ceil((double) nonRenderedDuration / frameDurationNanos); } return 0; } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/SentryFrameMetricsTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/SentryFrameMetricsTest.kt index 1e992041b0..a8138b61ff 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/SentryFrameMetricsTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/SentryFrameMetricsTest.kt @@ -6,15 +6,6 @@ import kotlin.test.assertFalse import kotlin.test.assertTrue class SentryFrameMetricsTest { - @Test - fun addFastFrame() { - val frameMetrics = SentryFrameMetrics() - frameMetrics.addFrame(10, 0, false, false) - assertEquals(1, frameMetrics.normalFrameCount) - - frameMetrics.addFrame(10, 0, false, false) - assertEquals(2, frameMetrics.normalFrameCount) - } @Test fun addSlowFrame() { @@ -43,10 +34,12 @@ class SentryFrameMetricsTest { @Test fun totalFrameCount() { val frameMetrics = SentryFrameMetrics() + // Normal frames are ignored frameMetrics.addFrame(10, 0, false, false) + // Slow and frozen frames are considered frameMetrics.addFrame(116, 100, true, false) frameMetrics.addFrame(1016, 1000, true, true) - assertEquals(3, frameMetrics.totalFrameCount) + assertEquals(2, frameMetrics.slowFrozenFrameCount) } @Test @@ -57,12 +50,11 @@ class SentryFrameMetricsTest { frameMetrics.addFrame(1016, 1000, true, true) val dup = frameMetrics.duplicate() - assertEquals(1, dup.normalFrameCount) assertEquals(1, dup.slowFrameCount) assertEquals(100, dup.slowFrameDelayNanos) assertEquals(1, dup.frozenFrameCount) assertEquals(1000, dup.frozenFrameDelayNanos) - assertEquals(3, dup.totalFrameCount) + assertEquals(2, dup.slowFrozenFrameCount) } @Test @@ -89,7 +81,7 @@ class SentryFrameMetricsTest { assertEquals(1, diff.frozenFrameCount) assertEquals(1000, diff.frozenFrameDelayNanos) - assertEquals(2, diff.totalFrameCount) + assertEquals(2, diff.slowFrozenFrameCount) } @Test @@ -102,12 +94,11 @@ class SentryFrameMetricsTest { frameMetrics.clear() - assertEquals(0, frameMetrics.normalFrameCount) assertEquals(0, frameMetrics.slowFrameCount) assertEquals(0, frameMetrics.slowFrameDelayNanos) assertEquals(0, frameMetrics.frozenFrameCount) assertEquals(0, frameMetrics.frozenFrameDelayNanos) - assertEquals(0, frameMetrics.totalFrameCount) + assertEquals(0, frameMetrics.slowFrozenFrameCount) } @Test diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/SpanFrameMetricsCollectorTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/SpanFrameMetricsCollectorTest.kt index d8ff8fde2e..0527baf284 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/SpanFrameMetricsCollectorTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/SpanFrameMetricsCollectorTest.kt @@ -192,7 +192,7 @@ class SpanFrameMetricsCollectorTest { sut.onFrameMetricCollected(0, 10, 10, 0, false, false, 60.0f) sut.onFrameMetricCollected(16, 48, 32, 16, true, false, 60.0f) sut.onFrameMetricCollected(60, 92, 32, 16, true, false, 60.0f) - sut.onFrameMetricCollected(100, 800, 800, 784, true, true, 60.0f) + sut.onFrameMetricCollected(100, 800, 700, 784, true, true, 60.0f) // then a second span starts fixture.timeNanos = 800 @@ -337,10 +337,11 @@ class SpanFrameMetricsCollectorTest { fixture.timeNanos = TimeUnit.SECONDS.toNanos(2) sut.onSpanFinished(span) - // then still 60 frames should be reported (1 second at 60fps) - verify(span).setData("frames.total", 60) + // then still 61 frames should be reported (1 second at 60fps with approximation) + verify(span).setData("frames.total", 61) verify(span).setData("frames.slow", 0) verify(span).setData("frames.frozen", 0) + verify(span).setData("frames.delay", 0.0) } @Test @@ -364,9 +365,9 @@ class SpanFrameMetricsCollectorTest { sut.onSpanFinished(span) // then - // still 60 fps should be reported for 1 seconds + // still 61 fps should be reported for 1 seconds (with approximation) // and one frame with frame delay should be reported (1s - 16ms) - verify(span).setData("frames.total", 61) + verify(span).setData("frames.total", 62) verify(span).setData("frames.slow", 0) verify(span).setData("frames.frozen", 1) verify(span).setData(eq("frames.delay"), AdditionalMatchers.eq(0.983333334, 0.01)) From 05024c49434acfeb6fc6b1947a05577f407a490d Mon Sep 17 00:00:00 2001 From: maxhov <14804474+maxhov@users.noreply.github.com> Date: Thu, 14 Nov 2024 21:06:01 +0100 Subject: [PATCH 91/92] Extend CheckInUtils with check-in for a specific environment --- .../java/io/sentry/util/CheckInUtils.java | 44 ++++++++++++++++++- .../java/io/sentry/util/CheckInUtilsTest.kt | 33 ++++++++++++++ 2 files changed, 76 insertions(+), 1 deletion(-) diff --git a/sentry/src/main/java/io/sentry/util/CheckInUtils.java b/sentry/src/main/java/io/sentry/util/CheckInUtils.java index e15603adaf..fb1172b8d6 100644 --- a/sentry/src/main/java/io/sentry/util/CheckInUtils.java +++ b/sentry/src/main/java/io/sentry/util/CheckInUtils.java @@ -21,12 +21,14 @@ public final class CheckInUtils { * * @param monitorSlug - the slug of the monitor * @param monitorConfig - configuration of the monitor, can be used for upserting schedule + * @param environment - the name of the environment * @param callable - the {@link Callable} to be called * @return the return value of the {@link Callable} * @param - the result type of the {@link Callable} */ public static U withCheckIn( final @NotNull String monitorSlug, + final @Nullable String environment, final @Nullable MonitorConfig monitorConfig, final @NotNull Callable callable) throws Exception { @@ -41,6 +43,9 @@ public static U withCheckIn( if (monitorConfig != null) { inProgressCheckIn.setMonitorConfig(monitorConfig); } + if (environment != null) { + inProgressCheckIn.setEnvironment(environment); + } @Nullable SentryId checkInId = hub.captureCheckIn(inProgressCheckIn); try { return callable.call(); @@ -50,12 +55,49 @@ public static U withCheckIn( } finally { final @NotNull CheckInStatus status = didError ? CheckInStatus.ERROR : CheckInStatus.OK; CheckIn checkIn = new CheckIn(checkInId, monitorSlug, status); + if (environment != null) { + checkIn.setEnvironment(environment); + } checkIn.setDuration(DateUtils.millisToSeconds(System.currentTimeMillis() - startTime)); hub.captureCheckIn(checkIn); hub.popScope(); } } + /** + * Helper method to send monitor check-ins for a {@link Callable} + * + * @param monitorSlug - the slug of the monitor + * @param monitorConfig - configuration of the monitor, can be used for upserting schedule + * @param callable - the {@link Callable} to be called + * @return the return value of the {@link Callable} + * @param - the result type of the {@link Callable} + */ + public static U withCheckIn( + final @NotNull String monitorSlug, + final @Nullable MonitorConfig monitorConfig, + final @NotNull Callable callable) + throws Exception { + return withCheckIn(monitorSlug, null, monitorConfig, callable); + } + + /** + * Helper method to send monitor check-ins for a {@link Callable} + * + * @param monitorSlug - the slug of the monitor + * @param environment - the name of the environment + * @param callable - the {@link Callable} to be called + * @return the return value of the {@link Callable} + * @param - the result type of the {@link Callable} + */ + public static U withCheckIn( + final @NotNull String monitorSlug, + final @Nullable String environment, + final @NotNull Callable callable) + throws Exception { + return withCheckIn(monitorSlug, environment, null, callable); + } + /** * Helper method to send monitor check-ins for a {@link Callable} * @@ -66,7 +108,7 @@ public static U withCheckIn( */ public static U withCheckIn( final @NotNull String monitorSlug, final @NotNull Callable callable) throws Exception { - return withCheckIn(monitorSlug, null, callable); + return withCheckIn(monitorSlug, null, null, callable); } /** Checks if a check-in for a monitor (CRON) has been ignored. */ diff --git a/sentry/src/test/java/io/sentry/util/CheckInUtilsTest.kt b/sentry/src/test/java/io/sentry/util/CheckInUtilsTest.kt index a285cd5832..94fa3ce48c 100644 --- a/sentry/src/test/java/io/sentry/util/CheckInUtilsTest.kt +++ b/sentry/src/test/java/io/sentry/util/CheckInUtilsTest.kt @@ -84,6 +84,39 @@ class CheckInUtilsTest { } } + @Test + fun `sends check-in for wrapped supplier with environment`() { + Mockito.mockStatic(Sentry::class.java).use { sentry -> + val hub = mock() + sentry.`when` { Sentry.getCurrentHub() }.thenReturn(hub) + whenever(hub.options).thenReturn(SentryOptions()) + val returnValue = CheckInUtils.withCheckIn("monitor-1", "environment-1") { + return@withCheckIn "test1" + } + + assertEquals("test1", returnValue) + inOrder(hub) { + verify(hub).pushScope() + verify(hub).configureScope(any()) + verify(hub).captureCheckIn( + check { + assertEquals("monitor-1", it.monitorSlug) + assertEquals("environment-1", it.environment) + assertEquals(CheckInStatus.IN_PROGRESS.apiName(), it.status) + } + ) + verify(hub).captureCheckIn( + check { + assertEquals("monitor-1", it.monitorSlug) + assertEquals("environment-1", it.environment) + assertEquals(CheckInStatus.OK.apiName(), it.status) + } + ) + verify(hub).popScope() + } + } + } + @Test fun `sends check-in for wrapped supplier with exception`() { Mockito.mockStatic(Sentry::class.java).use { sentry -> From 49624de930de2e06f0a609508fb388e5d977d6f2 Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Mon, 18 Nov 2024 11:53:27 +0100 Subject: [PATCH 92/92] changelog --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 36705ed654..b46b50517d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +### Features + +- Allow passing `environment` to `CheckinUtils.withCheckIn` ([3889](https://github.com/getsentry/sentry-java/pull/3889)) + ## 8.0.0-beta.2 ### Breaking Changes