From e34c467f7cb9b1709c7394de525c041c83416cdf Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Mon, 15 Jul 2024 19:11:45 +0200 Subject: [PATCH 01/13] [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 zhQsw35Ic{HFc4ksSC>V&06o?JdUf}<}DM2(DxOjFj(rF92ONXQY%y58LdU=>wbwMy3& zpe?|GQ;q=Se3iv*Z2=&H+Jz|erfHD0uaM6kjGtI^emAVCBE{_)@~w<$<%m*OR~aZo zPgCYlkOUTy7V!oRc0e_1A42QdR{fFgtE_P}%Xwt_4^Ea_mbOxs;Xrn!(3dK4fPwJ#~nt%nAw}zZGweXQ_`B zj*HDljiiQYr;3{&N&40}l~Bq{olfk>aU0kMsIJUvDn$>O{{Sif00&ief7JpyY$GA=+D@OP!b`2kbe2EX-6hgb z3ZFcGs~s(Z*3>W&+bw;5&aqT{ilW8OPmiI&&{0hkkr-*Ap=zk8l(oTXsw!%#2x>fJ z6BKUKD6?1sNGW)juBjwusPj1g00<}jRq0|d6rzAD7w!!isB%sQkO8I+7~-@&Cz@-5?-XI^R1owUS2?LXcnl2Eqvg=N-IJAv zDMK|jUm}&5yv=1r8sRc^n9P1=rn45ic^@N+qhzMZ($dor9C6f1B0(mGKn#A`j?%2` z(SjTSLqkDQc=>)@cy+lhS4x20%4<=K(ws4j`S8b6as9ozadmYw)Z{W4wUSEespPDm zCbJrXA$Fb*C1S+zuq1xmdL@;Bjrf0V9lS9pty4q9R)Bd@pRj0HZ1mB*Q{ z5$D&W?mVtY(o@&aW3zHnBSj1ux@uh8OC@Tg62V5f8d`{{s;e=x2Ap|IG;Z3>i49lV z?a;JsLLcI&960dwsOxahqI_hb9M||zvTSyzXp!B2oRb(DF`T_q&XL?CpQ1XeLvn|oqeCy8Z_IpzU@ z2Q0Oy^QVyphP--NYR$mA<< z*nEXX$ljFsTxQkV6$utUGoQ=s?defLS-NW|sA>$cQRC}UuC<~sD=QMry2|w2N+sMc z$SAZ>R+T@5YfxwhnIjd&b4H{B6_dcW4Xo7DjeN}s0N2yc_H;!50MX9!{`=uNv#$Oz zcgD)gOI?+x%H;P=D5&aH!>uF9gQvq~=x}n;VN*YGG~#@ihZ|ov+Q;UM34o?e%ELp< zw`y&I3>H>xKmgP@W)-OwTIu7SiHsBLMUD+Kmoy*{3Z5F4gfF3?pgA2*-91OUHav2} zy0$)716@^Ke~DG(@N&{%^g~B3pA~EoiaAnQHAyY1Cz3$NLDQq!6}f`*^)2p|g02)X zu1}Ype7Fvmh*~uXJOpYvY6=Pu@O<-Ll6c+IlEh~*xVh<*DT;bp%1Y*1dLIl)Up$RZ zQk8Z}nsAPvoy|O7Hw5TVveY|}tLYUG7j&iPsDGG%oRHF*e;+%YjIDTCtvlz

UFLX)}qHfEXAm++FDUZHZDqtrKd_-DwLI;qwS=UaWE2GAW~|Q+f5uSV+IBl z6d-wIHhBGne%_F}Ttw3pkcoy43G|^_3e(SlIrQP*+2!jEZ51_ac#Pcv$I<4isqvA7 zuA{1?g!!rwWQHl}T9X;jwq<*jtGPzWO-5Lj4B6JmRo(Y z2;!a)@*0}@gTQ$KPd+207q^njs#u(=1p=O6k3TYNnhFE?bfwsTC-&C%rjPiA4Q4YU z^wL8N)l{&IMOAGkN~)Twiy4xNzI3enrJ{K?tkQ@eAD)JZAK6d%p!N;N zT@%Hbin@z&)4Y?=!#ynN<7#?(*-*HerOho%C~Yj7!C4U9fffYQa^1z0-YA<>X~NLd z{&b-y{x7Cc8b@i8Lc|(Z0F3a-^7KFPOnbX*;*FD-rln;jzIZ>+q(oZ-eeK#9f4n;{JsuLOnns3t%w)NUnkr^$cqwQ; z+wUif$R$;AD=M>kUf|ptN4#xQ5u*A%euUD$Wk9VuOxCwCfL;@)_WuBbhe5tWWa2*l zizt+Mx`|`ek7_zp6mrqWMI$m&%To%ZaaKaiHLKW$W(%lT`!q|MtrhC0An^vieK1qy zk^H)Er-Dh}BU@8V_EgXqY068ad1T8QOkl{LXs+zZ2^Z9hs zmZYztrt-%fLeWVz(8k#KD5EEqNvWhl@|lc^#4u4A<4HcGN{Jm*uQNRzRUJCYtV)n0U!~6{*?i(fkc~(7_26ng9>4$6ByG0$M(WCM z6^n1=w(d7??Onkp&}^+v6>67pwNQfzxvDV~yMm@nZpWyih}6QhOBG`ytb#bfYXJ=Q zHi-%Pws)#)qLwR)yn2F~u{0f|>8Y+b4ApLK7G`^W6t0t~0iFb{YA7?2kZF#F`1YSz zSJGk`w-$FhEo4+Q5aV|4H#Jc~lQ}9GX2kyh5dI5Ek*=e}M^?`*U3}EiM+^a2h1Q{p zC7R^S(g>6lsRf7}4LIOb3~BQ*m0ijrv!hj#hpFvF5?Dgq$oA~?J6*M_a{dVNs zJ39-2N>w=uT+Labdc`!5ns{VwxlStSDr+lJ3U!I)l1WaGjO9=?H+jj@$RyQV9N<)w zLFNF@006CeajYTJ;ua~82bF7pKWWDr^o-np6sX~*hMNVsGFiCcine$$BC?7RHA$*! zxl7X|BCZ&usHkB&cw$FhNG$w5yYXS)2(B_g81(cQ9C+8Jn`>2a6^WqW2;-m6iQ&VK zKwW>vZTz%Q#Y;9ZtFp@+6qU8Om#3?ixK?n}`jh-OYDwQ+`ix$A8e2K^S6U%2Td0UXO5rRc3Mra4EJuA}|z}O!kC90&V!R&ge zX=x>@#!^={;-<#qGYJ;7IE=CcEIp>XU78|5yC~*_s9Ke; zr78%om3w^2=|!t<(khm>SB{u0K(2V^hP3%-h^2Zq+jpjWHnv(ke&WW`XVW2(qfDMh z0fo+H-KfNlt&(Xl7|H1I@Y5n`y-U*hRy}4r6+X+J`5>o)T&-!I-2Q$?>^Z>ca@q%P z*9xHL0H3pl0sQ!Rb;DzH4tpiuJ#N~x49-8 zfY$((dKLvptttwEQYk{L%0_EkbTMc8>nF45WXEB;#~rz|Q|EThCvawSos+b%n4Dtf z69{Nz$kXmR%IcbozTnQ~B3S4imW?a_0B|eDwNP2Lio)j331YpTE5eK#F&nsGnu&EF z(u8pw5{ErD(@6xS1-wlTrS2pOGg69bD_oQ6)dSC<2jpjG=lZI%cl6%d+gn??_qH1o z*Ik3Yc3)fV8mdjnO^w}Kw=Ib5I!w+sIy|o5#o@OG!l$Ab=;`albu3LZi82UebuijC zc()5=+!|Gm5G3v_1vHgZ#tyA#AXHL>jMJnyo1;ZMwksRR%1|&+P8~}&qD3^|A0tYN zfC&bl;;&@ZR)4wUwa9ZCdP3HnyL1V(REt{h*5$g3f6+A zo>V?S0pvzHC%vcgceeV2bobnOs*L_aIZ3uVvTnDh#4bZ0vT;;Ty{U1XOqC5)gv=M+ zHQRm!s)n#c^pQbM=*BZr3!GYQ+g;_YhlL8Y!I~n#oRtcEKyolzf`cRWbe=nd4aCQD zoe|{wLKr=M5Op;uYf;-y zC#7~*%_B^WBC*v~ryVpIp&8A3liEG^)41N<>FQrS7E^QWeeJpMwHZlksQ&{4K76-(r!J!1U0yPh90IYUT+;vDq?zSxu-Bg=M@TN zRD68zDmtkEpdm__Z*3|BmW+~`SLdFpKjoLg4Bve3oL2Mb z?$x5|D%{m>;CC;l~QSpE`*VV zn}0Ri*~DU=7nM|Y&?x<+nw->Eq8r_fu4NL#C`NyX5>MGc1qB5$({9H2d)hryilN!n zRoLnF-5j*Fb(HwL7nz_V8CnPJ>wq;W8e zf=xi<7>=B(ht%HG?Kf5zlB71!vjfG*0;PC>E84a5 zAY(jn)vhgX(%$OnScsueNCcfBm>^d0@#gF-!K16GalYk%3hS_f6i~*5XSjtRgM}tCA1J zk`#*2aQ&3&t4k@QM7WynM&UpzI2r+xHLfvW7$r0XPCfuYhpp#9;w8#DT0Y>5gEmhPY+ zQIrEr5nP%I8hNUFD}NhUT~aPc)F`Cqf%DCN(_gS>hvBx>c`2*waTqF`qA%VyPk2;h zx3xf#Vy0?IDrnklej1`mInm^ZOHU$P=~&}XK}5Ir2&F?24FIAbU$-a7jBxq%^pUKT zY7Kur?>txLKjOMp)pW*Qo|Y;n-}Dq1_o<^=ntVnQJTuf$QpUzfo=70c#aCH1Kvqbt zO#*6SS&FDNl6aikh^*Ku*HF|zso{WBarOP4eNaOx_@(XzPsn5QAoV479x9Ezjg`yQ z(IqOvXcH5U0&1bDp0;Z0*y^h$sjDw?so{y5#31DRbyZeYmq*wOR1ZNx>FQ5faLkH~ zMm+@yKDqw@2Ux4?Ftl-KhCJ;YRI)uxsZqG|IIU7?Gcr|D;LL4a|dDjR1U!O>PzjR};W#Edt8BV50NmhXtN}n{6c-%)7 zJSruYGA|+4!aI#uzqNr;)t|y*0rdR;0OGn$R#pRAivHi*_Vj_5<@Er`X6W&`b;wfD zwLLU6ldV~ZtxY>iOH{QH#T+u4hK#Gv9FH7JaCDJxY)J~l@pcU?wWupzJU&O|)3MCb zB%Rp?l%e$F=}(=1*hA!gOlY9pS-tI)pxRq|1w)a_jI`X+VyTU3IM`~c>U_1Ic_POU zhD3%nl6lYtW(vOix~>{1t)kaf;7&~etq%{86|Op|CM{-X(xGx`!_vNgVFMj8W&TG^ zF4u~e4Hot6YB3E4eihsJx+JEP8N6}vs;+8k+Ul69>#G8Y>7j~ne5YKH7=dP^kTiERmEJ{Ca6Nd|w7}t9 zdinKsuB0UuP>?g~2dB^dTsny!?r5?U_^4{?zS&+`@{5xG8wEpTB%VcrO8AzRqsu;A z)~KV3D5+97V`(mR8?ybd4-i$!#RfmmOd63~{#{rP)JBW}#2n}RA3Ot29Y)FT53;IA z^K=zc&qq2_$3+`KTP*Z>8oG*jQjud^bs@*pOGZOTf6Yb3oGUThVo8*xMi!=@FVBdt z`TEqFVqqmHJc%5Cm#?4a)U1Bt+|>_NCRPl7HlC8DWya9ri)5=mgV#k)C@X57N*KH~ z`8ZG&rj9m-o(7F|lo}YiZwMl~E3iC#vN(Z4a4GS6o|fCRp{PL5=14xgJh%^+Sv8z} zJd$LhrE19_5m3VwRI))V)dO99LHjkQ$jd9#%(0|LNe`;we1%q|BQyAAf-2PGO)-I9 z8S*3LPMp-iR4`iV$DjJK<;Ra%X3tmc{AEhkLy*P{mR3k%r>Ll@oo1M-2&t$W4-Q6_ zjwzzhfv1%|-BD4K>i+F+VUkH6qfuN_K6tHZpI#*AHLqTkWGWGGi^nwbsREvUpYdHm z-h29ot!Q$4$8l~fbrw5lJO;jc+WJ~kkfN5Fi89f=RPoDGB{m{z;1-$%5?0Fb#B@1n z0hO4RR)!ek)Y2a+fjxqoKpp)2p<>=9vpKI-muC&GyqgO7lT> z8bH0uO&ibvGDxNU(+ zK2nfW*5RgliDH_vU%qUjQ@pg$POIXjhy+6VkFcYy!YPtT2r3ArXhjBi0%#AZr$QL~ zCpu9nS{l-$mUS8QTdOwj2lAbY*(MpVy<>?(!+P;ogPbD=}sX5b*a*N zK8<5l2bDj_KjQkD*`4r|)iCY7#a@FQTLm|o3^A!$E0SqusK-SGMRg&TqbH80o%O`z ztWcQ|ETw(#>24ZW(CyMQGLirU)Kaws3JpG#`+C@#6@lC9BLPhbs00#mUp(*`=|9+= z-O&AcpV&DL-`!grZ)~nYvloMpb5U!|U@I$pRCLtYDu^0_5AOa*S3FN8Vi5#Ul3jge zOf0s|##;-C?VGVp8a&msjYgt}JSZ?p=^e%OtXB|6HG8N-;)14_JZbC1e}k-Dq1HVm zpT}jlHFny3T}C$@4Q^UKwW7pHO_qk9Rj89E4i7O|OO2|hrv>F&WT>WT;wMLzRZCvW zc6gSghK-nfV3MCYiZCOARSV(w95Uq;R?kJ3!cABzF;Dgl<9MOF#5Joi7Pyh__RGQa?Jt9Rje_76 zBzddkeU$@;4??IC_SM6LFKkC}6$}R)t`F=dj~w)i++QDiA1RB*&stG|OfkLt*ewhm1*U{+qZ)vn$A!*}^v9Khtp$4Of85!bf$L-Hd-0o1i zEEOY300UZ#;1h#^`##Q#?q>&+i*@HV?9{n-j+N)3pDTr~f*_1VO<2XyB#7T`lt!ad zu!w|K3vF5oB)stLip3i*jmCr;QvjTL{ks;|t{JuF4((mMF7G7S|~L8qsY;|0nGypaV(Cqdi_ zN~%*CB7lE@WBpanMGT0}8YgPzlr<+A`R4=t-6r$(Rdq2}YsJ1_H;=|igRF>2Urf-N z>WUPtrm9Iyf5a!6Igr96RgMVh!InnVc6uWjP)PuMjeUH9p!uGdlt>uhlTR^E@(PbG zI6X08aiMTZX*^iV>TLSyCf%Pm5xtid%P zQ0!h01?2-(PO5#B^ZdF>28lz6{u9E!XQKmw-dmchIwuBcBAkB%k_!0g>vD}1 zMA;OrC}pk4Qc}|Y0F)Ufjp8#6VV*Fo#nW5GZ48nY)(v~AJ8R|g^v9MuFp6nzCyFq- z8yKO_6P$|r{k;ek8=p0brk;XnQnxDdQPT>kQUr@392HSXku@0B0xWbQmr9K&%;XIp zTl-?tZA^rr5dnc(kIOuM-k-aNLb(!FTKNhbpFfvNeYe^Gd1teJ58_8KM23sHZ zHB_%U(zKNHA9ia00Cq+MF^vI67E#WZX0|UIM+DLaJV6An&pt!!C#F*BL}h7J57@_m+~k>1$6+ zgZUGK`+7Q8j(E!grBBZz{a-Qu9=Y#LuR)*3QsQ?TsiT#lc@n79y(E;bzDH;KUnvrK z4y8K4RxC*kVi}I?!6Z{!-aI!VgC@Lqie|ktaco%%mOdZZQT}d&x?Yg#dbY2}Pk@{4 z1I=0H+qD_VKZqG!VwGT;58s|f1gI00ge;se(g(B4ZTEU_5g%|}K#Y}BQSz$b{D}Pe zO&yFfYEvI*^IGTm{{Sw744*^w4qGRZ!qU{%;4AEmxP8N&udJ%Z*hBW!n7C@8@=?kn z($Tu=^6F#HARVp$05QgqO$E|R56uRBMxX)te7b42`*S9ZMLtK<*P?U1y5A?)xj8B1 zn;|qb=1D1|o6A!}kcy$_lkaKaU1?1=mH-(6kVrs3jY8eGbdPX_T#Ygu5O6^rUPO)=$E36hsO z8y~$Qt7MKQhfOU&AsvLSS4>g3O>J>JmnbEGfM{!ic!Nw8r`mo%bs^hC@kFr5%Q3H| zKGE~YrvdwVD?QWk7qR2sBDWFpzhlt#zGbnyXK-!!HV$J6y7s=(+u3uOp`qG4gBx3x zrKrf_YAq?Fs+t-J3(T#iDda+;9%Hgt;7g6X$>698#f=MTAd&!4LsP=0l&K_Cur}M3 znVBx=k~n7o3eit!1d>e!0jZ}N0a|os_vTNbxBmcJ?`@M`kH}%_>R$m>f!sL_!MGYM z#`N0RY-Z-gZW$;rn7WKsDm;GS##1bE(zKzJHjo}Oa$8%7t!`nsg)63{f@y9-|*0~DK9hwk?cHqVAEl_fo9A0?H|%`NG%w3O~%eNl#c z0-*ZSWEXb7Qui91F;bKyfkJgxfED|4GtpGLi>1MaIEYXKSLNma^$MoGKv3qL3HdJ8 z+<4}hTRBPLu4a;>6Ey)~e9K1)eVfAc$xlikSJsjxQ?YgRgR1Ff*`ie?<;XsOSF6Xm zJ1_{J%$|#eCvNS%gPiO>!rBygj0SIgWUBI+Ey0hY%uQ0BRBD=rGFN4?Mwx1J*&52J z*B$mtGPh{{TEGw~`3N+9J}=T99&1Xh)?hTz`|I zi(Bz>r+8-fWZTo^FUTj{d#XOZZR1_HC}?(8A2E^G6GJZ3tDv7LiLJ#obv`p6KqhJ# zxv!QA`g1041bL6}^qB#KF5ydksX?4g~?!amJMg9W@TxlDoUyOk}E>{35Ca z8_Y578R1_ph3}L2JeK({hcWKAj|?6J8XBDAk4Ag6R}xyZw(wSD zV0|m=!z2($#xug0=$!1&jeWb+-Jve`=^Wm|p?c}M@3Hr0-R+8VL!R0AjK(6nADP%x zRU2~{aG1@@LAP?XzjsuA<~9?=Lc;oxh%Wc2@9ZVBy|pCDjEcwwDx_(VK{|je2W~1W z#2$++>}0W=O%#Y@Ndq-Wtr#6jNE9Rklbq6&==`7u_%1RgBKk%eAst+PZ2~a}`ZHMvodwmLpLL+in+IMZL&WNi_;GHE2lT zLI@#&`Hqa{w~G2$OBP)`2m-V}Xd;KsqZR71yGNyRy#bS|>`G4Bt;KCjb}Myks;u=c z7Y_#G*jop4)zRm3c?@(k^U^lv&Oj+9rim#jd~(H8t>GWI;7hP`WVg#LiLq0B=n1Y? zO+=^!kw7V$5=hNDN4?q)7Aus03e8tQR0s6J0agQ1C76o4QiPhCc^iJEqu3j-sxkPA z{kOfan=1pjcEElPyh(t;gqR1UXC=0~=kC$;9<5RZJ_Xq^N|{B8rU& z6W`tLR`$&sNZ%nUTY}$Q~O$O6B3^vBvF<_(H89G_F3&5Gow%Ez+nhKLu zs*RR7nTVamiOq*F1nipCR48&Bl2n!_RX`QaqA^ikJr>Wl)vB|(S0EJtY5_%8B-0cg zpDwHuq%%9msy6=jrtiuP-%GRM*qc`|K0|o!idvo9NmoI)o|k@Pj_KI-)Hom9`&4?C zc_OGvi2Sj-Bejxl*A}~^_HAIQWl^G`RZ_Ji9VBYvNCu}A1Ep56y{)vu;!+n~X_9o& zN5l;?On?SzPaceaUiUXy_oqwO_McwnG8H{@i>a4v)a7?AZC1yvr>VtMK}R5;JGfXt z)Up{MXscwNSgN%EWCe==t@V}E7XsYG4Zs?xDnS%ABTjLgQxxdZ)_CSbaOXodk-!~Q zpc%$ZDV*1!k0qJeIl66@Hg90BumFnNrdO_a*fBh%6esAb31WoTqcs%sLS z2_dK^Vq8b5l~flsj^6i`8-9;eR$5>HoH(E^ey zk29R`C;lvShTgq*y>~`OXk~CapKWb={hPlrTe~4yka%--GEf={no+hArka6snY?^E zcOh2N3YyAayU9+I8DxSV3mxsZklM#~lUa#UMIaiL^Qb0=8T1vPrYd?%bqtd|xsKyO zR_+uAqMlVBXB=j^#RClg0BCle*u`%O3HxIUzjGCP!dh*t;oO^Y zOw~OS;c#fI7DQS%nyn{KM9~BxcpxsO7xOh zt~@DYj^$cbdIA9&2hS%XG{@UstCMqVJ-yUFJ5S1k>FKJ14k z)lVOmgkx5C@wj@bxM@KI%U=u=)fluqN{<~vD;tYn6=S=#W2{q9Ac0T-G+ypCGyu?X zt_W3hleA>pP5}pj6em0?dw6-&XNN#lZuH)p-yqwbYFeBgFCmA*QPN}cHI=lP97L74 z%E!sn=V)mv9$JdYW2r@^nl;oEun6Dh4PJRfP{cHr3{IiJfIL-*tuSkvSI^O~ie6W0 zfJG>4RW#s5X<=>Pz&D*fH79XtW_M_qQ6!B+A_vay*rpP^Q5BH}T z7_7xK6aq)2WJuOe=taV5#OEIXec#L;>fOGbA`wdH7BSLThV3Fza;3_=&e4(b*JoOZm zO;MMw6g5=X9Ewt=HyuG0Y@$3?KX2mdW5D4n#y+6ENr6w?nNO6x^?OA3Aqp)BjRP*A zX40jOH6y%SR`G1OB% z1q@KVgkjtUQ9abRFuX8Y#Q~~@AOKi-f&Og%&YO-TibZ*5c^4!pBy6Gq?w^maM`7BP)!@*U8#cn;pUsF)> z%TJD%Hqug2t5ecODrKp?IzdVUlQxtI5+_G*9ZYups_Ybyr;b1XpHCxFK;hH6!v%Ds z$O$S9JHF2>e!)!io9$|D#M=FNKUwW)$L2*d)qhE<3s2UQq;UZ>Qy`|9F|Bn1n& z1%qin5Uz1bk5YXp(806wnYntSud^GXQPrD^Z0$eed*k>q$58ELuZB~AufXJ+7Y!vI z-oRAJT<)TPJxUnfpw&YhAzRSS)|-(1+4_QMboXGDZs0=^QKWGs)E^^WywP~}U*n*r zgsCg!R~b>pzLd|A6zYE8-?>VCw^>tH92w01$x z!{gwljw!0NRLdM|EYZg#{vcDkBobaTD+RU$vCo(}q4QE}Krvd=0}HGkNUiEuRNMte zmjTCtr80BVWq)ez9A5aN>wULQx!v4vn%)@ru~K6(_{t>CG#iU?CU&Bf?X0a0K20Vm zPL+={EDQuXfCY(mmuq`X&Rdc6k`4yChDMd8YurB&81u(ZM|=`V?8J4rPd~zOlUh&} z^dwiyIOtHI+Vs`+^_7xjDx^$}M8^e@s>V^_vbgBut*4S&rliV@y$Z)F%wtuN>aPh? z7mNUaU9ehMUHH0f=)>nxO5@Ws9l))AeFyhhu5HRcjj2z_f(n z{e6VS?rqIeOGUSQnJIAfTUbSj-BmTXtjq4^4DCjj+yT0CX{08%j72h)uKBAKc3#W-;3-ygR{ z26JR$@q3#ofUDcJv9?nLVW7xtZN*de^$(84R$=S0shp?5;r{aU(Nk7KB1{UqGJ0Ge zCeF7nd2bY@E?XL@P$`nYRCgW+jy#FT6T`c#3vTkj9?(&#T`f)lD?^Vy1Go+Xy+_-4 ztlkaxWb$pEv)g!zjp1KYNS#rX`^rgW$x=i2mBOYfT5sZei$9d}RaMO;H563R$RbfQ z29=PVvh4s1J4lhG2|jc8oicjttLc#AwNX@hvMlpU9%M{7^Ehll=nv8Ivxi(E2k9c9p#<1-lt(!6yb(nEfv#&za0_!EAL+M- zW}_)jkEx-b8&!{|%~i)0R1|f_x|RyMs-3-GHF1)s4@L^ZQj*0LOmnA{4 zd5m|EENaA!L%0w0z>3iPwR+>C+3c?pFkzXPs00IwXCQlYPyrMm7C)G}1m&rLi~!=Y!{t(8EAf{vurP*Cv5;A{5(0L8X%96eSx zj;A5BE2G*u*aJ{UmWDcM8oC;mp18wNLt71G4%Kx<0)=EOBQE?Y0X2oo_}Xt3rH-bG z1wcFr@}Q^7<(Xn#!5$>Z|IhJY}j~V-E315+I-`HWNcSMc@qsiwXjC@EI5sK9&1=D3;z>42v^s z#Q~`UR}yG)f0xJ@PLs)wYG}X8A)&_VU)zq1 zYO|2+N%p9+t1hV#APO)}GJhdbe%_K5X*JPV=hf+)1H;aMc~Erqw{uZsq*cS@C9J4| zdW6WsJJeEAVW_I{&{Sj~nm`PDpN)`xXsi>%_oz|Ij zyDGorj?~h3;2p5h&uD?TY|SfU4>i4wO|>t6H&!I8oWI#pPxoBOw$Jv ztCdm4gjdp_dHz)DCv0wA`?KkA)ZJ-{r#Wb9Z8ls|VKSKbXrOwtQ;NyrtK|FUmN_N# ztYLjofvV(yt6{RUtgE>x;gCb>ss4}@BhNfR>C2{?IFwoeTKb9)Tp#m(&WOKp_7>pZ zU4dPi>^Lem{(hnidgN>4z~?box|O4%f(mKrv6VG76!87TaUzoo{kUOki}N7u(OW@o zvDwTX5KRW4Nd27>Ue9qegL59201D8Gk6Lj1Y0ycV+!ga<1w0#$y(y~x(w?54p1P_y zr>b%OPYJ|NQq;lqMyah8K+$do(7_^zw}{a$q31zS`SIxyW1~o8Pfzwb#SRvF_^M-; zXd5%(jV7a|lAq4OJO!Rwr8R8q8MF|koHU#sJ-M!w{tqsxP9XeU33!^B)}7#jwm`Jf zh-CY4k*Ax%1hOkfHd2~c-ZLhW8FhMYc_apOQkCP^nn0$SYx#ex{a?%f)HR#ux-sx$ zv$YvKjM%tqDb|l0O;FS{VxEQsFFjlov~5n5Xu_eCvpTzJxv}*aLhj*jBx33*_GY{> zz`+9#)rPRDXFZE-q8taXr zQ?@ZQl-X?UUf{_~HGO?(nlwdPQ5uSNo)b_iqlL?+r(+`v>0kwjU27!DDy($VC;aE6 zmkSfHT^S?&ROyScI-6*AMn4&trp`@@$l>xkXK-S(xXEcNaI;n6a}e$f1ukluww|C@ z;i>bO_^Kk3H;SSuQH&E`fGOIqB({)~_<`e(A`L$xN7?Jq4cshf(UKWYt%WrZE1azJ9N-uHcStVmU& zk6u6K>APr3)RHMo^bY2Bg4ga!-NT05+0VLsd>A??t2ZS^R{^%L^yJ9!?R~#FTwdJ! zhX!e?1OldM53Th2Kyz@vnV?uaL!@w^0RG;N?5&VW(vg!-Gx-mf+0GuZ9gAG*qD(sHm&u%Dk5@af|iTnb%;i1O89!8&aWa;+g6tpm}-{Wbjw&gV(lT<^vaFEkOB`9i2DmrMVx-!hV6*_JA z42??&aE3Va{)GcMd}{wdi)n!k-{Av?CKiOIIy! zTX1F`nlO{o(l}uaP!0m>V04x+GzNSJ{t_s0{!cp7qSJFC%3{=X0r3tWXV268Jxcjg z@_%f6a@v?lcRoGp@!P_`Gl9b6Dey4lah3U8HU_hC(qOW*iA7mY1y<&&#)=v_Cs6>X zms1cH%$^Gi&Bfp8_y>+9U@E^BGmQO{Gg_ZABiPdKaEd8|y>T0At<<8l-EQRzcHI!uzI zD3BBlUq^Rh(%HjzD_cl~XiZk9;}od?gF+}PLJ!ZUEu@0s#oI={GQD+VH}q#3 zjw7QFxV!JYw?AR*9pBbHmxIT3-rcRHm$Y_}@QR5h zicuO=Qq4+7kgJ9EQEh2vE$(eD?TnYse@KB+O$h>&rabu6AC?!%Jm%sV;bluz9pbdb zc-Q?@r$dKg_U75^Ja*-qaAh|Y77ra2USlhp+*LIhr>(}+!wk;YK~~j`n8Q-VT@oUM z5JT!8*kB7StY*BLC@dBwk%<{PLei%um}5%uHLpu%xQ5m_9_gH-(}1Zp^d4hBl{zoG zljXNs(RJP%e9e>J5>oD+u~)e;bsxeXa{mCh40*{mKXBwFqMYII$8$@CpX}gjrL3ql 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 02/13] 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 03/13] 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 04/13] 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 05/13] 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 06/13] 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 07/13] 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 08/13] 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 09/13] 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 10/13] 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 11/13] 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 12/13] 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 13/13] 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