diff --git a/.craft.yml b/.craft.yml index d50705f433..3d0a878a4c 100644 --- a/.craft.yml +++ b/.craft.yml @@ -37,7 +37,6 @@ targets: maven:io.sentry:sentry-android-core: maven:io.sentry:sentry-android-ndk: maven:io.sentry:sentry-android-timber: - maven:io.sentry:sentry-android-okhttp: maven:io.sentry:sentry-kotlin-extensions: maven:io.sentry:sentry-android-fragment: maven:io.sentry:sentry-bom: @@ -48,6 +47,8 @@ targets: maven:io.sentry:sentry-apollo: maven:io.sentry:sentry-jdbc: maven:io.sentry:sentry-graphql: +# maven:io.sentry:sentry-graphql-core: +# maven:io.sentry:sentry-graphql-22: maven:io.sentry:sentry-quartz: maven:io.sentry:sentry-okhttp: maven:io.sentry:sentry-android-navigation: @@ -56,3 +57,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/.github/ISSUE_TEMPLATE/bug_report_android.yml b/.github/ISSUE_TEMPLATE/bug_report_android.yml index 9b6bfc9ff6..20db87e363 100644 --- a/.github/ISSUE_TEMPLATE/bug_report_android.yml +++ b/.github/ISSUE_TEMPLATE/bug_report_android.yml @@ -10,13 +10,13 @@ body: options: - sentry-android - sentry-android-ndk - - sentry-android-okhttp - sentry-android-timber - sentry-android-fragment - sentry-android-sqlite - sentry-apollo - - sentry-compose - sentry-apollo-3 + - sentry-compose + - sentry-okhttp - other validations: required: true diff --git a/.github/ISSUE_TEMPLATE/bug_report_java.yml b/.github/ISSUE_TEMPLATE/bug_report_java.yml index f802c3a0cc..f95244f5b1 100644 --- a/.github/ISSUE_TEMPLATE/bug_report_java.yml +++ b/.github/ISSUE_TEMPLATE/bug_report_java.yml @@ -27,6 +27,7 @@ body: - sentry-logback - sentry-log4j2 - sentry-graphql + - sentry-graphql-22 - sentry-quartz - sentry-openfeign - sentry-apache-http-client-5 diff --git a/.github/workflows/agp-matrix.yml b/.github/workflows/agp-matrix.yml index a9c4293292..b43d40697a 100644 --- a/.github/workflows/agp-matrix.yml +++ b/.github/workflows/agp-matrix.yml @@ -38,7 +38,7 @@ jobs: java-version: '17' - name: Setup Gradle - uses: gradle/actions/setup-gradle@2cd2a6e951bd0b53f55a08e4e4c6f2586f3a36b9 # pin@v3 + uses: gradle/actions/setup-gradle@0d30c9111cf47a838eb69c06d13f3f51ab2ed76f # pin@v3 with: gradle-home-cache-cleanup: true @@ -59,7 +59,7 @@ jobs: # We tried to use the cache action to cache gradle stuff, but it made tests slower and timeout - name: Run instrumentation tests - uses: reactivecircus/android-emulator-runner@6b0df4b0efb23bb0ec63d881db79aefbc976e4b2 # pin@v2 + uses: reactivecircus/android-emulator-runner@f0d1ed2dcad93c7479e8b2f2226c83af54494915 # pin@v2 with: api-level: 30 force-avd-creation: false diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 969ad6135e..f4b8d8431c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -27,7 +27,7 @@ jobs: java-version: '17' - name: Setup Gradle - uses: gradle/actions/setup-gradle@2cd2a6e951bd0b53f55a08e4e4c6f2586f3a36b9 # pin@v3 + uses: gradle/actions/setup-gradle@0d30c9111cf47a838eb69c06d13f3f51ab2ed76f # pin@v3 with: gradle-home-cache-cleanup: true @@ -35,7 +35,7 @@ jobs: run: make preMerge - name: Upload coverage to Codecov - uses: codecov/codecov-action@5ecb98a3c6b747ed38dc09f787459979aebb39be # pin@v4 + uses: codecov/codecov-action@e28ff129e5465c2c0dcc6f003fc735cb6ae0c673 # pin@v4 with: name: sentry-java fail_ci_if_error: false diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 6636dc019d..144c89ed48 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -20,8 +20,6 @@ jobs: strategy: fail-fast: false - matrix: - language: ['cpp', 'java'] steps: - name: Checkout Repo @@ -36,23 +34,18 @@ jobs: java-version: '17' - name: Setup Gradle - uses: gradle/actions/setup-gradle@2cd2a6e951bd0b53f55a08e4e4c6f2586f3a36b9 # pin@v3 + uses: gradle/actions/setup-gradle@0d30c9111cf47a838eb69c06d13f3f51ab2ed76f # pin@v3 with: gradle-home-cache-cleanup: true - name: Initialize CodeQL - uses: github/codeql-action/init@23acc5c183826b7a8a97bce3cecc52db901f8251 # pin@v2 + uses: github/codeql-action/init@294a9d92911152fe08befb9ec03e240add280cb3 # pin@v2 with: languages: ${{ matrix.language }} - - if: matrix.language == 'cpp' - name: Build Cpp - run: | - ./gradlew sentry-android-ndk:buildCMakeRelWithDebInfo - - if: matrix.language == 'java' - name: Build Java + - name: Build Java run: | ./gradlew buildForCodeQL - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@23acc5c183826b7a8a97bce3cecc52db901f8251 # pin@v2 + uses: github/codeql-action/analyze@294a9d92911152fe08befb9ec03e240add280cb3 # pin@v2 diff --git a/.github/workflows/enforce-license-compliance.yml b/.github/workflows/enforce-license-compliance.yml index 2c93ed9e4b..c2ddec5865 100644 --- a/.github/workflows/enforce-license-compliance.yml +++ b/.github/workflows/enforce-license-compliance.yml @@ -11,7 +11,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Setup Gradle - uses: gradle/actions/setup-gradle@2cd2a6e951bd0b53f55a08e4e4c6f2586f3a36b9 # pin@v3 + uses: gradle/actions/setup-gradle@0d30c9111cf47a838eb69c06d13f3f51ab2ed76f # pin@v3 with: gradle-home-cache-cleanup: true diff --git a/.github/workflows/generate-javadocs.yml b/.github/workflows/generate-javadocs.yml index 635a87609b..dd171af5a2 100644 --- a/.github/workflows/generate-javadocs.yml +++ b/.github/workflows/generate-javadocs.yml @@ -20,7 +20,7 @@ jobs: java-version: '17' - name: Setup Gradle - uses: gradle/actions/setup-gradle@2cd2a6e951bd0b53f55a08e4e4c6f2586f3a36b9 # pin@v3 + uses: gradle/actions/setup-gradle@0d30c9111cf47a838eb69c06d13f3f51ab2ed76f # pin@v3 with: gradle-home-cache-cleanup: true @@ -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@920cbb300dcd3f0568dbc42700c61e2fd9e6139c # pin@4.6.4 with: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} BRANCH: gh-pages 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 diff --git a/.github/workflows/integration-tests-benchmarks.yml b/.github/workflows/integration-tests-benchmarks.yml index 2e885359ad..f0beaa60b5 100644 --- a/.github/workflows/integration-tests-benchmarks.yml +++ b/.github/workflows/integration-tests-benchmarks.yml @@ -37,7 +37,7 @@ jobs: java-version: '17' - name: Setup Gradle - uses: gradle/actions/setup-gradle@2cd2a6e951bd0b53f55a08e4e4c6f2586f3a36b9 # pin@v3 + uses: gradle/actions/setup-gradle@0d30c9111cf47a838eb69c06d13f3f51ab2ed76f # pin@v3 with: gradle-home-cache-cleanup: true @@ -86,7 +86,7 @@ jobs: java-version: '17' - name: Setup Gradle - uses: gradle/actions/setup-gradle@2cd2a6e951bd0b53f55a08e4e4c6f2586f3a36b9 # pin@v3 + uses: gradle/actions/setup-gradle@0d30c9111cf47a838eb69c06d13f3f51ab2ed76f # pin@v3 with: gradle-home-cache-cleanup: true diff --git a/.github/workflows/integration-tests-ui.yml b/.github/workflows/integration-tests-ui.yml index cd5134d38f..771b4b5c8c 100644 --- a/.github/workflows/integration-tests-ui.yml +++ b/.github/workflows/integration-tests-ui.yml @@ -32,7 +32,7 @@ jobs: java-version: '17' - name: Setup Gradle - uses: gradle/actions/setup-gradle@2cd2a6e951bd0b53f55a08e4e4c6f2586f3a36b9 # pin@v3 + uses: gradle/actions/setup-gradle@0d30c9111cf47a838eb69c06d13f3f51ab2ed76f # pin@v3 with: gradle-home-cache-cleanup: true diff --git a/.github/workflows/release-build.yml b/.github/workflows/release-build.yml index b021a6d8ec..cb6752bb93 100644 --- a/.github/workflows/release-build.yml +++ b/.github/workflows/release-build.yml @@ -26,7 +26,7 @@ jobs: java-version: '17' - name: Setup Gradle - uses: gradle/actions/setup-gradle@2cd2a6e951bd0b53f55a08e4e4c6f2586f3a36b9 # pin@v3 + uses: gradle/actions/setup-gradle@0d30c9111cf47a838eb69c06d13f3f51ab2ed76f # pin@v3 with: gradle-home-cache-cleanup: true diff --git a/.github/workflows/system-tests-backend.yml b/.github/workflows/system-tests-backend.yml index d794ecb118..4b84c3a18e 100644 --- a/.github/workflows/system-tests-backend.yml +++ b/.github/workflows/system-tests-backend.yml @@ -20,10 +20,13 @@ jobs: fail-fast: false matrix: sample: [ "sentry-samples-spring-boot-jakarta" ] + agent: [ "0" ] include: - sample: "sentry-samples-spring-boot" - sample: "sentry-samples-spring-boot-webflux-jakarta" - sample: "sentry-samples-spring-boot-webflux" + - sample: "sentry-samples-spring-boot-jakarta-opentelemetry" + agent: "1" steps: - uses: actions/checkout@v4 with: @@ -40,13 +43,13 @@ jobs: java-version: '17' - name: Setup Gradle - uses: gradle/actions/setup-gradle@2cd2a6e951bd0b53f55a08e4e4c6f2586f3a36b9 # pin@v3 + uses: gradle/actions/setup-gradle@0d30c9111cf47a838eb69c06d13f3f51ab2ed76f # pin@v3 with: gradle-home-cache-cleanup: true - name: Exclude android modules from build run: | - sed -i -e '/.*"sentry-android-ndk",/d' -e '/.*"sentry-android",/d' -e '/.*"sentry-compose",/d' -e '/.*"sentry-android-core",/d' -e '/.*"sentry-android-fragment",/d' -e '/.*"sentry-android-navigation",/d' -e '/.*"sentry-android-okhttp",/d' -e '/.*"sentry-android-sqlite",/d' -e '/.*"sentry-android-timber",/d' -e '/.*"sentry-android-integration-tests:sentry-uitest-android-benchmark",/d' -e '/.*"sentry-android-integration-tests:sentry-uitest-android",/d' -e '/.*"sentry-android-integration-tests:test-app-sentry",/d' -e '/.*"sentry-samples:sentry-samples-android",/d' settings.gradle.kts + sed -i -e '/.*"sentry-android-ndk",/d' -e '/.*"sentry-android",/d' -e '/.*"sentry-compose",/d' -e '/.*"sentry-android-core",/d' -e '/.*"sentry-android-fragment",/d' -e '/.*"sentry-android-navigation",/d' -e '/.*"sentry-android-sqlite",/d' -e '/.*"sentry-android-timber",/d' -e '/.*"sentry-android-integration-tests:sentry-uitest-android-benchmark",/d' -e '/.*"sentry-android-integration-tests:sentry-uitest-android",/d' -e '/.*"sentry-android-integration-tests:test-app-sentry",/d' -e '/.*"sentry-samples:sentry-samples-android",/d' -e '/.*"sentry-android-replay",/d' settings.gradle.kts - name: Exclude android modules from ignore list run: | @@ -56,9 +59,13 @@ jobs: run: | ./gradlew :sentry-samples:${{ matrix.sample }}:bootJar + - name: Build agent jar + run: | + ./gradlew :sentry-opentelemetry:sentry-opentelemetry-agent:assemble + - name: Start server and run integration test for sentry-cli commands run: | - test/system-test-sentry-server-start.sh > sentry-mock-server.txt 2>&1 & test/system-test-spring-server-start.sh "${{ matrix.sample }}" > spring-server.txt 2>&1 & test/wait-for-spring.sh && ./gradlew :sentry-samples:${{ matrix.sample }}:systemTest + test/system-test-sentry-server-start.sh > sentry-mock-server.txt 2>&1 & test/system-test-spring-server-start.sh "${{ matrix.sample }}" "${{ matrix.agent }}" > spring-server.txt 2>&1 & test/wait-for-spring.sh && ./gradlew :sentry-samples:${{ matrix.sample }}:systemTest - name: Upload test results if: always() diff --git a/.github/workflows/update-deps.yml b/.github/workflows/update-deps.yml index 24fce64050..83d90bb919 100644 --- a/.github/workflows/update-deps.yml +++ b/.github/workflows/update-deps.yml @@ -13,7 +13,7 @@ jobs: native: uses: getsentry/github-workflows/.github/workflows/updater.yml@v2 with: - path: sentry-android-ndk/sentry-native + path: scripts/update-sentry-native-ndk.sh name: Native SDK secrets: # If a custom token is used instead, a CI would be triggered on a created PR. diff --git a/.gitmodules b/.gitmodules index fe6c3b7cc0..e69de29bb2 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +0,0 @@ -[submodule "sentry-android-ndk/sentry-native"] - path = sentry-android-ndk/sentry-native - url = https://github.com/getsentry/sentry-native diff --git a/CHANGELOG.md b/CHANGELOG.md index f67618933f..3a67bbba09 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,380 @@ # Changelog +## Unreleased + +### Features + +- Add `globalHubMode` to options ([#3805](https://github.com/getsentry/sentry-java/pull/3805)) + - `globalHubMode` used to only be a param on `Sentry.init`. To make it easier to be used in e.g. Desktop environments, we now additionally added it as an option on SentryOptions that can also be set via `sentry.properties`. + - If both the param on `Sentry.init` and the option are set, the option will win. By default the option is set to `null` meaning whatever is passed to `Sentry.init` takes effect. +- Lazy uuid generation for SentryId and SpanId ([#3770](https://github.com/getsentry/sentry-java/pull/3770)) + +### Fixes + +- Add `auto.graphql.graphql22` to ignored span origins when using OpenTelemetry ([#3828](https://github.com/getsentry/sentry-java/pull/3828)) +- The Spring Boot 3 WebFlux sample now uses our GraphQL v22 integration ([#3828](https://github.com/getsentry/sentry-java/pull/3828)) + +### Behavioural Changes + +- (Android) Enable Performance V2 by default ([#3824](https://github.com/getsentry/sentry-java/pull/3824)) + - With this change cold app start spans will include spans for ContentProviders, Application and Activity load. + +## 8.0.0-beta.1 + +### Breaking Changes + +- Throw IllegalArgumentException when calling Sentry.init on Android ([#3596](https://github.com/getsentry/sentry-java/pull/3596)) +- Metrics have been removed from the SDK ([#3774](https://github.com/getsentry/sentry-java/pull/3774)) + - Metrics will return but we don't know in what exact form yet +- `enableTracing` option (a.k.a `enable-tracing`) has been removed from the SDK ([#3776](https://github.com/getsentry/sentry-java/pull/3776)) + - Please set `tracesSampleRate` to a value >= 0.0 for enabling performance instead. The default value is `null` which means performance is disabled. +- Change OkHttp sub-spans to span attributes ([#3556](https://github.com/getsentry/sentry-java/pull/3556)) + - This will reduce the number of spans created by the SDK +- Replace `synchronized` methods and blocks with `ReentrantLock` (`AutoClosableReentrantLock`) ([#3715](https://github.com/getsentry/sentry-java/pull/3715)) + - If you are subclassing any Sentry classes, please check if the parent class used `synchronized` before. Please make sure to use the same lock object as the parent class in that case. +- `traceOrigins` option (`io.sentry.traces.tracing-origins` in manifest) has been removed, please use `tracePropagationTargets` (`io.sentry.traces.trace-propagation-targets` in manifest`) instead ([#3780](https://github.com/getsentry/sentry-java/pull/3780)) +- `profilingEnabled` option (`io.sentry.traces.profiling.enable` in manifest) has been removed, please use `profilesSampleRate` (`io.sentry.traces.profiling.sample-rate` instead) instead ([#3780](https://github.com/getsentry/sentry-java/pull/3780)) +- `shutdownTimeout` option has been removed, please use `shutdownTimeoutMillis` instead ([#3780](https://github.com/getsentry/sentry-java/pull/3780)) +- `profilingTracesIntervalMillis` option for Android has been removed ([#3780](https://github.com/getsentry/sentry-java/pull/3780)) +- `io.sentry.session-tracking.enable` manifest option has been removed ([#3780](https://github.com/getsentry/sentry-java/pull/3780)) +- `Sentry.traceHeaders()` method has been removed, please use `Sentry.getTraceparent()` instead ([#3718](https://github.com/getsentry/sentry-java/pull/3718)) +- `Sentry.reportFullDisplayed()` method has been removed, please use `Sentry.reportFullyDisplayed()` instead ([#3717](https://github.com/getsentry/sentry-java/pull/3717)) +- `User.other` has been removed, please use `data` instead ([#3780](https://github.com/getsentry/sentry-java/pull/3780)) +- `SdkVersion.getIntegrations()` has been removed, please use `getIntegrationSet` instead ([#3780](https://github.com/getsentry/sentry-java/pull/3780)) +- `SdkVersion.getPackages()` has been removed, please use `getPackageSet()` instead ([#3780](https://github.com/getsentry/sentry-java/pull/3780)) +- `Device.language` has been removed, please use `locale` instead ([#3780](https://github.com/getsentry/sentry-java/pull/3780)) +- `TraceContext.user` and `TraceContextUser` class have been removed, please use `userId` on `TraceContext` instead ([#3780](https://github.com/getsentry/sentry-java/pull/3780)) +- `TransactionContext.fromSentryTrace()` has been removed, please use `Sentry.continueTrace()` instead ([#3780](https://github.com/getsentry/sentry-java/pull/3780)) +- `SentryDataFetcherExceptionHandler` has been removed, please use `SentryGenericDataFetcherExceptionHandler` in combination with `SentryInstrumentation` instead ([#3780](https://github.com/getsentry/sentry-java/pull/3780)) +- One of the `AndroidTransactionProfiler` constructors has been removed, please use a different one ([#3780](https://github.com/getsentry/sentry-java/pull/3780)) + +### Features + +- Add init priority settings ([#3674](https://github.com/getsentry/sentry-java/pull/3674)) + - You may now set `forceInit=true` (`force-init` for `.properties` files) to ensure a call to Sentry.init / SentryAndroid.init takes effect +- Add force init option to Android Manifest ([#3675](https://github.com/getsentry/sentry-java/pull/3675)) + - Use `` to ensure Sentry Android auto init is not easily overwritten +- Attach request body for `application/x-www-form-urlencoded` requests in Spring ([#3731](https://github.com/getsentry/sentry-java/pull/3731)) + - Previously request body was only attached for `application/json` requests +- Set breadcrumb level based on http status ([#3771](https://github.com/getsentry/sentry-java/pull/3771)) +- Support `graphql-java` v22 via a new module `sentry-graphql-22` ([#3740](https://github.com/getsentry/sentry-java/pull/3740)) + - If you are using `graphql-java` v21 or earlier, you can use the `sentry-graphql` module + - For `graphql-java` v22 and newer please use the `sentry-graphql-22` module +- We now provide a `SentryInstrumenter` bean directly for Spring (Boot) if there is none yet instead of using `GraphQlSourceBuilderCustomizer` to add the instrumentation ([#3744](https://github.com/getsentry/sentry-java/pull/3744)) + - It is now also possible to provide a bean of type `SentryGraphqlInstrumentation.BeforeSpanCallback` which is then used by `SentryInstrumenter` +- Emit transaction.data inside contexts.trace.data ([#3735](https://github.com/getsentry/sentry-java/pull/3735)) + - Also does not emit `transaction.data` in `exras` anymore + +### Fixes + +- Use OpenTelemetry span name as fallback for transaction name ([#3557](https://github.com/getsentry/sentry-java/pull/3557)) + - In certain cases we were sending transactions as "" when using OpenTelemetry +- Add OpenTelemetry span data to Sentry span ([#3593](https://github.com/getsentry/sentry-java/pull/3593)) +- No longer selectively copy OpenTelemetry attributes to Sentry spans / transactions `data` ([#3663](https://github.com/getsentry/sentry-java/pull/3663)) +- Remove `PROCESS_COMMAND_ARGS` (`process.command_args`) OpenTelemetry span attribute as it can be very large ([#3664](https://github.com/getsentry/sentry-java/pull/3664)) +- Use RECORD_ONLY sampling decision if performance is disabled ([#3659](https://github.com/getsentry/sentry-java/pull/3659)) + - Also fix check whether Performance is enabled when making a sampling decision in the OpenTelemetry sampler +- Sentry OpenTelemetry Java Agent now sets Instrumenter to SENTRY (used to be OTEL) ([#3697](https://github.com/getsentry/sentry-java/pull/3697)) +- Set span origin in `ActivityLifecycleIntegration` on span options instead of after creating the span / transaction ([#3702](https://github.com/getsentry/sentry-java/pull/3702)) + - This allows spans to be filtered by span origin on creation +- Honor ignored span origins in `SentryTracer.startChild` ([#3704](https://github.com/getsentry/sentry-java/pull/3704)) +- Add `enable-spotlight` and `spotlight-connection-url` to external options and check if spotlight is enabled when deciding whether to inspect an OpenTelemetry span for connecting to splotlight ([#3709](https://github.com/getsentry/sentry-java/pull/3709)) +- Trace context on `Contexts.setTrace` has been marked `@NotNull` ([#3721](https://github.com/getsentry/sentry-java/pull/3721)) + - Setting it to `null` would cause an exception. + - Transactions are dropped if trace context is missing +- Remove internal annotation on `SpanOptions` ([#3722](https://github.com/getsentry/sentry-java/pull/3722)) +- `SentryLogbackInitializer` is now public ([#3723](https://github.com/getsentry/sentry-java/pull/3723)) +- Fix order of calling `close` on previous Sentry instance when re-initializing ([#3750](https://github.com/getsentry/sentry-java/pull/3750)) + - Previously some parts of Sentry were immediately closed after re-init that should have stayed open and some parts of the previous init were never closed + +### Behavioural Changes + +- (Android) Replace thread id with kernel thread id in span data ([#3706](https://github.com/getsentry/sentry-java/pull/3706)) + +### Dependencies + +- Bump OpenTelemetry to 1.41.0, OpenTelemetry Java Agent to 2.7.0 and Semantic Conventions to 1.25.0 ([#3668](https://github.com/getsentry/sentry-java/pull/3668)) + +## 8.0.0-alpha.4 + +### Fixes + +- Removed user segment ([#3512](https://github.com/getsentry/sentry-java/pull/3512)) +- Use span id of remote parent ([#3548](https://github.com/getsentry/sentry-java/pull/3548)) + - Traces were broken because on an incoming request, OtelSentrySpanProcessor did not set the parentSpanId on the span correctly. Traces were not referencing the actual parent span but some other (random) span ID which the server doesn't know. +- Attach active span to scope when using OpenTelemetry ([#3549](https://github.com/getsentry/sentry-java/pull/3549)) + - Errors weren't linked to traces correctly due to parts of the SDK not knowing the current span +- Record dropped spans in client report when sampling out OpenTelemetry spans ([#3552](https://github.com/getsentry/sentry-java/pull/3552)) +- Retrieve the correct current span from `Scope`/`Scopes` when using OpenTelemetry ([#3554](https://github.com/getsentry/sentry-java/pull/3554)) + +## 8.0.0-alpha.3 + +### Breaking Changes + +- `sentry-android-okhttp` has been removed in favor of `sentry-okhttp`, removing android dependency from the module ([#3510](https://github.com/getsentry/sentry-java/pull/3510)) + +### Fixes + +- Support spans that are split into multiple batches ([#3539](https://github.com/getsentry/sentry-java/pull/3539)) + - When spans belonging to a single transaction were split into multiple batches for SpanExporter, we did not add all spans because the isSpanTooOld check wasn't inverted. +- Parse and use `send-default-pii` and `max-request-body-size` from `sentry.properties` ([#3534](https://github.com/getsentry/sentry-java/pull/3534)) +- `span.startChild` now uses `.makeCurrent()` by default ([#3544](https://github.com/getsentry/sentry-java/pull/3544)) + - This caused an issue where the span tree wasn't correct because some spans were not added to their direct parent +- Partially fix bootstrap class loading ([#3543](https://github.com/getsentry/sentry-java/pull/3543)) + - There was a problem with two separate Sentry `Scopes` being active inside each OpenTelemetry `Context` due to using context keys from more than one class loader. + +## 8.0.0-alpha.2 + +### Behavioural Changes + +- (Android) The JNI layer for sentry-native has now been moved from sentry-java to sentry-native ([#3189](https://github.com/getsentry/sentry-java/pull/3189)) + - This now includes prefab support for sentry-native, allowing you to link and access the sentry-native API within your native app code + - Checkout the `sentry-samples/sentry-samples-android` example on how to configure CMake and consume `sentry.h` + +### Features + +- Our `sentry-opentelemetry-agent` has been completely reworked and now plays nicely with the rest of the Java SDK + - You may also want to give this new agent a try even if you haven't used OpenTelemetry (with Sentry) before. It offers support for [many more libraries and frameworks](https://github.com/open-telemetry/opentelemetry-java-instrumentation/blob/main/docs/supported-libraries.md), improving on our trace propagation, `Scopes` (used to be `Hub`) propagation as well as performance instrumentation (i.e. more spans). + - If you are using a framework we did not support before and currently resort to manual instrumentation, please give the agent a try. See [here for a list of supported libraries, frameworks and application servers](https://github.com/open-telemetry/opentelemetry-java-instrumentation/blob/main/docs/supported-libraries.md). + - NOTE: Not all features have been implemented yet for the OpenTelemetry agent. Features of note that are not working yet: + - Metrics + - Measurements + - `forceFinish` on transaction + - `scheduleFinish` on transaction + - see [#3436](https://github.com/getsentry/sentry-java/issues/3436) for a more up-to-date list of features we have (not) implemented + - Please see "Installing `sentry-opentelemetry-agent`" for more details on how to set up the agent. + - What's new about the Agent + - When the OpenTelemetry Agent is used, Sentry API creates OpenTelemetry spans under the hood, handing back a wrapper object which bridges the gap between traditional Sentry API and OpenTelemetry. We might be replacing some of the Sentry performance API in the future. + - This is achieved by configuring the SDK to use `OtelSpanFactory` instead of `DefaultSpanFactory` which is done automatically by the auto init of the Java Agent. + - OpenTelemetry spans are now only turned into Sentry spans when they are finished so they can be sent to the Sentry server. + - Now registers an OpenTelemetry `Sampler` which uses Sentry sampling configuration + - Other Performance integrations automatically stop creating spans to avoid duplicate spans + - The Sentry SDK now makes use of OpenTelemetry `Context` for storing Sentry `Scopes` (which is similar to what used to be called `Hub`) and thus relies on OpenTelemetry for `Context` propagation. + - Classes used for the previous version of our OpenTelemetry support have been deprecated but can still be used manually. We're not planning to keep the old agent around in favor of less complexity in the SDK. +- Add `ignoredSpanOrigins` option for ignoring spans coming from certain integrations + - We pre-configure this to ignore Performance instrumentation for Spring and other integrations when using our OpenTelemetry Agent to avoid duplicate spans +- Add data fetching environment hint to breadcrumb for GraphQL (#3413) ([#3431](https://github.com/getsentry/sentry-java/pull/3431)) + +### Fixes + +- `TracesSampler` is now only created once in `SentryOptions` instead of creating a new one for every `Hub` (which is now `Scopes`). This means we're now creating fewer `SecureRandom` instances. +- Move onFinishCallback before span or transaction is finished ([#3459](https://github.com/getsentry/sentry-java/pull/3459)) +- Add timestamp when a profile starts ([#3442](https://github.com/getsentry/sentry-java/pull/3442)) +- Move fragment auto span finish to onFragmentStarted ([#3424](https://github.com/getsentry/sentry-java/pull/3424)) +- Remove profiling timeout logic and disable profiling on API 21 ([#3478](https://github.com/getsentry/sentry-java/pull/3478)) +- Properly reset metric flush flag on metric emission ([#3493](https://github.com/getsentry/sentry-java/pull/3493)) + +### Migration Guide / Deprecations + +- Classes used for the previous version of the Sentry OpenTelemetry Java Agent have been deprecated (`SentrySpanProcessor`, `SentryPropagator`, `OpenTelemetryLinkErrorEventProcessor`) +- Sentry OpenTelemetry Java Agent has been reworked and now allows you to manually create spans using Sentry API as well. +- Please see "Installing `sentry-opentelemetry-agent`" for more details on how to set up the agent. + +### Installing `sentry-opentelemetry-agent` + +#### Upgrading from a previous agent +If you've been using the previous version of `sentry-opentelemetry-agent`, simply replace the agent JAR with the [latest release](https://central.sonatype.com/artifact/io.sentry/sentry-opentelemetry-agent?smo=true) and start your application. That should be it. + +#### New to the agent +If you've not been using OpenTelemetry before, you can add `sentry-opentelemetry-agent` to your setup by downloading the latest release and using it when starting up your application +- `SENTRY_PROPERTIES_FILE=sentry.properties java -javaagent:sentry-opentelemetry-agent-x.x.x.jar -jar your-application.jar` +- Please use `sentry.properties` or environment variables to configure the SDK as the agent is now in charge of initializing the SDK and options coming from things like logging integrations or our Spring Boot integration will not take effect. +- You may find the [docs page](https://docs.sentry.io/platforms/java/tracing/instrumentation/opentelemetry/#using-sentry-opentelemetry-agent-with-auto-initialization) useful. While we haven't updated it yet to reflect the changes described here, the section about using the agent with auto init should still be valid. + +If you want to skip auto initialization of the SDK performed by the agent, please follow the steps above and set the environment variable `SENTRY_AUTO_INIT` to `false` then add the following to your `Sentry.init`: + +``` +Sentry.init(options -> { + options.setDsn("https://3d2ac63d6e1a4c6e9214443678f119a3@o87286.ingest.us.sentry.io/1801383"); + OpenTelemetryUtil.applyOpenTelemetryOptions(options); + ... +}); +``` + +If you're using our Spring (Boot) integration with auto init, use the following: +``` +@Bean +Sentry.OptionsConfiguration optionsConfiguration() { + return (options) -> { + OpenTelemetryUtil.applyOpenTelemetryOptions(options); + }; +} +``` + +### Dependencies + +- Bump Native SDK from v0.7.0 to v0.7.5 ([#3441](https://github.com/getsentry/sentry-java/pull/3189)) + - [changelog](https://github.com/getsentry/sentry-native/blob/master/CHANGELOG.md#075) + - [diff](https://github.com/getsentry/sentry-native/compare/0.7.0...0.7.5) + +## 8.0.0-alpha.1 + +Version 8 of the Sentry Android/Java SDK brings a variety of features and fixes. The most notable changes are: + +- New `Scope` types have been introduced, see "Behavioural Changes" for more details. +- Lifecycle tokens have been introduced to manage `Scope` lifecycle, see "Behavioural Changes" for more details. +- `Hub` has been replaced by `Scopes` + +### Behavioural Changes + +- We're introducing some new `Scope` types in the SDK, allowing for better control over what data is attached where. Previously there was a stack of scopes that was pushed and popped. Instead we now fork scopes for a given lifecycle and then restore the previous scopes. Since `Hub` is gone, it is also never cloned anymore. Separation of data now happens through the different scope types while making it easier to manipulate exactly what you need without having to attach data at the right time to have it apply where wanted. + - Global scope is attached to all events created by the SDK. It can also be modified before `Sentry.init` has been called. It can be manipulated using `Sentry.configureScope(ScopeType.GLOBAL, (scope) -> { ... })`. + - Isolation scope can be used e.g. to attach data to all events that come up while handling an incoming request. It can also be used for other isolation purposes. It can be manipulated using `Sentry.configureScope(ScopeType.ISOLATION, (scope) -> { ... })`. The SDK automatically forks isolation scope in certain cases like incoming requests, CRON jobs, Spring `@Async` and more. + - Current scope is forked often and data added to it is only added to events that are created while this scope is active. Data is also passed on to newly forked child scopes but not to parents. +- `Sentry.popScope` has been deprecated, please call `.close()` on the token returned by `Sentry.pushScope` instead or use it in a way described in more detail in "Migration Guide". +- We have chosen a default scope that is used for `Sentry.configureScope()` as well as API like `Sentry.setTag()` + - For Android the type defaults to `CURRENT` scope + - For Backend and other JVM applicatons it defaults to `ISOLATION` scope +- Event processors on `Scope` can now be ordered by overriding the `getOrder` method on implementations of `EventProcessor`. NOTE: This order only applies to event processors on `Scope` but not `SentryOptions` at the moment. Feel free to request this if you need it. +- `Hub` is deprecated in favor of `Scopes`, alongside some `Hub` relevant APIs. More details can be found in the "Migration Guide" section. + +### Breaking Changes + +- `Contexts` no longer extends `ConcurrentHashMap`, instead we offer a selected set of methods. + +### Migration Guide / Deprecations + +- `Hub` has been deprecated, we're replacing the following: + - `IHub` has been replaced by `IScopes`, however you should be able to simply pass `IHub` instances to code expecting `IScopes`, allowing for an easier migration. + - `HubAdapter.getInstance()` has been replaced by `ScopesAdapter.getInstance()` + - The `.clone()` method on `IHub`/`IScopes` has been deprecated, please use `.pushScope()` or `.pushIsolationScope()` instead + - Some internal methods like `.getCurrentHub()` and `.setCurrentHub()` have also been replaced. +- `Sentry.popScope` has been replaced by calling `.close()` on the token returned by `Sentry.pushScope()` and `Sentry.pushIsolationScope()`. The token can also be used in a `try` block like this: + +``` +try (final @NotNull ISentryLifecycleToken ignored = Sentry.pushScope()) { + // this block has its separate current scope +} +``` + +as well as: + + +``` +try (final @NotNull ISentryLifecycleToken ignored = Sentry.pushIsolationScope()) { + // this block has its separate isolation scope +} +``` + +You may also use `LifecycleHelper.close(token)`, e.g. in case you need to pass the token around for closing later. + +### Features + +- Report exceptions returned by Throwable.getSuppressed() to Sentry as exception groups ([#3396] https://github.com/getsentry/sentry-java/pull/3396) + + +## 7.15.0 + +### Features + +- Add support for `feedback` envelope header item type ([#3687](https://github.com/getsentry/sentry-java/pull/3687)) +- Add breadcrumb.origin field ([#3727](https://github.com/getsentry/sentry-java/pull/3727)) +- Session Replay: Add options to selectively mask/unmask views captured in replay. The following options are available: ([#3689](https://github.com/getsentry/sentry-java/pull/3689)) + - `android:tag="sentry-mask|sentry-unmask"` in XML or `view.setTag("sentry-mask|sentry-unmask")` in code tags + - if you already have a tag set for a view, you can set a tag by id: `` in XML or `view.setTag(io.sentry.android.replay.R.id.sentry_privacy, "mask|unmask")` in code + - `view.sentryReplayMask()` or `view.sentryReplayUnmask()` extension functions + - mask/unmask `View`s of a certain type by adding fully-qualified classname to one of the lists `options.experimental.sessionReplay.addMaskViewClass()` or `options.experimental.sessionReplay.addUnmaskViewClass()`. Note, that all of the view subclasses/subtypes will be masked/unmasked as well + - For example, (this is already a default behavior) to mask all `TextView`s and their subclasses (`RadioButton`, `EditText`, etc.): `options.experimental.sessionReplay.addMaskViewClass("android.widget.TextView")` + - If you're using code obfuscation, adjust your proguard-rules accordingly, so your custom view class name is not minified +- Session Replay: Support Jetpack Compose masking ([#3739](https://github.com/getsentry/sentry-java/pull/3739)) + - To selectively mask/unmask @Composables, use `Modifier.sentryReplayMask()` and `Modifier.sentryReplayUnmask()` modifiers +- Session Replay: Mask `WebView`, `VideoView` and `androidx.media3.ui.PlayerView` by default ([#3775](https://github.com/getsentry/sentry-java/pull/3775)) + +### Fixes + +- Avoid stopping appStartProfiler after application creation ([#3630](https://github.com/getsentry/sentry-java/pull/3630)) +- Session Replay: Correctly detect dominant color for `TextView`s with Spans ([#3682](https://github.com/getsentry/sentry-java/pull/3682)) +- Fix ensure Application Context is used even when SDK is initialized via Activity Context ([#3669](https://github.com/getsentry/sentry-java/pull/3669)) +- Fix potential ANRs due to `Calendar.getInstance` usage in Breadcrumbs constructor ([#3736](https://github.com/getsentry/sentry-java/pull/3736)) +- Fix potential ANRs due to default integrations ([#3778](https://github.com/getsentry/sentry-java/pull/3778)) +- Lazily initialize heavy `SentryOptions` members to avoid ANRs on app start ([#3749](https://github.com/getsentry/sentry-java/pull/3749)) + +*Breaking changes*: + +- `options.experimental.sessionReplay.errorSampleRate` was renamed to `options.experimental.sessionReplay.onErrorSampleRate` ([#3637](https://github.com/getsentry/sentry-java/pull/3637)) +- Manifest option `io.sentry.session-replay.error-sample-rate` was renamed to `io.sentry.session-replay.on-error-sample-rate` ([#3637](https://github.com/getsentry/sentry-java/pull/3637)) +- Change `redactAllText` and `redactAllImages` to `maskAllText` and `maskAllImages` ([#3741](https://github.com/getsentry/sentry-java/pull/3741)) + +## 7.14.0 + +### Features + +- Session Replay: Gesture/touch support for Flutter ([#3623](https://github.com/getsentry/sentry-java/pull/3623)) + +### Fixes + +- Fix app start spans missing from Pixel devices ([#3634](https://github.com/getsentry/sentry-java/pull/3634)) +- Avoid ArrayIndexOutOfBoundsException on Android cpu data collection ([#3598](https://github.com/getsentry/sentry-java/pull/3598)) +- Fix lazy select queries instrumentation ([#3604](https://github.com/getsentry/sentry-java/pull/3604)) +- Session Replay: buffer mode improvements ([#3622](https://github.com/getsentry/sentry-java/pull/3622)) + - Align next segment timestamp with the end of the buffered segment when converting from buffer mode to session mode + - Persist `buffer` replay type for the entire replay when converting from buffer mode to session mode + - Properly store screen names for `buffer` mode +- Session Replay: fix various crashes and issues ([#3628](https://github.com/getsentry/sentry-java/pull/3628)) + - Fix video not being encoded on Pixel devices + - Fix SIGABRT native crashes on Xiaomi devices when encoding a video + - Fix `RejectedExecutionException` when redacting a screenshot + - Fix `FileNotFoundException` when persisting segment values + +### Chores + +- Introduce `ReplayShadowMediaCodec` and refactor tests using custom encoder ([#3612](https://github.com/getsentry/sentry-java/pull/3612)) + +## 7.13.0 + +### Features + +- Session Replay: ([#3565](https://github.com/getsentry/sentry-java/pull/3565)) ([#3609](https://github.com/getsentry/sentry-java/pull/3609)) + - Capture remaining replay segment for ANRs on next app launch + - Capture remaining replay segment for unhandled crashes on next app launch + +### Fixes + +- Session Replay: ([#3565](https://github.com/getsentry/sentry-java/pull/3565)) ([#3609](https://github.com/getsentry/sentry-java/pull/3609)) + - Fix stopping replay in `session` mode at 1 hour deadline + - Never encode full frames for a video segment, only do partial updates. This further reduces size of the replay segment + - Use propagation context when no active transaction for ANRs + +### Dependencies + +- Bump Spring Boot to 3.3.2 ([#3541](https://github.com/getsentry/sentry-java/pull/3541)) + +## 7.12.1 + +### 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 + +- 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/README.md b/README.md index 338a59eba5..90cdcc8588 100644 --- a/README.md +++ b/README.md @@ -21,11 +21,11 @@ Sentry SDK for Java and Android | sentry-android | [![Maven Central](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-android/badge.svg)](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-android) | 19 | | sentry-android-core | [![Maven Central](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-android-core/badge.svg)](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-android-core) | 19 | | sentry-android-ndk | [![Maven Central](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-android-ndk/badge.svg)](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-android-ndk) | 19 | -| sentry-android-okhttp | [![Maven Central](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-android-okhttp/badge.svg)](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-android-okhttp) | 21 | | sentry-android-timber | [![Maven Central](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-android-timber/badge.svg)](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-android-timber) | 19 | | 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) | @@ -48,6 +48,8 @@ Sentry SDK for Java and Android | sentry-log4j2 | [![Maven Central](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-log4j2/badge.svg)](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-log4j2) | | sentry-bom | [![Maven Central](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-bom/badge.svg)](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-bom) | | sentry-graphql | [![Maven Central](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-graphql/badge.svg)](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-graphql) | +| sentry-graphql-core | [![Maven Central](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-graphql-core/badge.svg)](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-graphql-core) | +| sentry-graphql-22 | [![Maven Central](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-graphql-22/badge.svg)](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-graphql-22) | | sentry-quartz | [![Maven Central](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-quartz/badge.svg)](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-quartz) | | sentry-openfeign | [![Maven Central](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-openfeign/badge.svg)](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-openfeign) | | sentry-opentelemetry-agent | [![Maven Central](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-opentelemetry-agent/badge.svg)](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-opentelemetry-agent) | @@ -55,7 +57,13 @@ Sentry SDK for Java and Android | sentry-opentelemetry-core | [![Maven Central](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-opentelemetry-core/badge.svg)](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-opentelemetry-core) | | sentry-okhttp | [![Maven Central](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-okhttp/badge.svg)](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-okhttp) | +# Releases +This repo uses the following ways to release SDK updates: + +- `Pre-release`: We create pre-releases (alpha, beta, RC,…) for larger and potentially more impactful changes, such as new features or major versions. +- `Latest`: We continuously release major/minor/hotfix versions from the `main` branch. These releases go through all our internal quality gates and are very safe to use and intended to be the default for most teams. +- `Stable`: We promote releases from `Latest` when they have been used in the field for some time and in scale, considering time since release, adoption, and other quality and stability metrics. These releases will be indicated on the releases page (https://github.com/getsentry/sentry-java/releases/) with the `Stable` suffix. # Useful links and docs diff --git a/build.gradle.kts b/build.gradle.kts index 998c547efb..b80ec8ac14 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -32,15 +32,12 @@ buildscript { classpath(Config.QualityPlugins.errorpronePlugin) classpath(Config.QualityPlugins.gradleVersionsPlugin) - // add classpath of androidNativeBundle - // com.ydq.android.gradle.build.tool:nativeBundle:{version}} - classpath(Config.NativePlugins.nativeBundlePlugin) - // add classpath of sentry android gradle plugin // classpath("io.sentry:sentry-android-gradle-plugin:{version}") classpath(Config.QualityPlugins.binaryCompatibilityValidatorPlugin) classpath(Config.BuildPlugins.composeGradlePlugin) + classpath(Config.BuildPlugins.commonsCompressOverride) } } @@ -63,6 +60,7 @@ apiValidation { "sentry-samples-spring-jakarta", "sentry-samples-spring-boot", "sentry-samples-spring-boot-jakarta", + "sentry-samples-spring-boot-jakarta-opentelemetry", "sentry-samples-spring-boot-webflux", "sentry-samples-spring-boot-webflux-jakarta", "sentry-uitest-android", @@ -78,6 +76,7 @@ allprojects { repositories { google() mavenCentral() + mavenLocal() } group = Config.Sentry.group version = properties[Config.Sentry.versionNameProp].toString() @@ -99,7 +98,7 @@ allprojects { dependsOn("cleanTest") } withType { - options.compilerArgs.addAll(arrayOf("-Xlint:all", "-Werror", "-Xlint:-classfile", "-Xlint:-processing")) + options.compilerArgs.addAll(arrayOf("-Xlint:all", "-Werror", "-Xlint:-classfile", "-Xlint:-processing", "-Xlint:-try")) } } } @@ -110,8 +109,8 @@ subprojects { "sentry-android-fragment", "sentry-android-navigation", "sentry-android-ndk", - "sentry-android-okhttp", "sentry-android-sqlite", + "sentry-android-replay", "sentry-android-timber" ) if (jacocoAndroidModules.contains(name)) { @@ -294,9 +293,10 @@ private val androidLibs = setOf( "sentry-android-ndk", "sentry-android-fragment", "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..b276156c0b 100644 --- a/buildSrc/src/main/java/Config.kt +++ b/buildSrc/src/main/java/Config.kt @@ -7,7 +7,7 @@ object Config { val kotlinStdLib = "stdlib-jdk8" val springBootVersion = "2.7.5" - val springBoot3Version = "3.2.0" + val springBoot3Version = "3.3.2" val kotlinCompatibleLanguageVersion = "1.4" val composeVersion = "1.5.3" @@ -27,6 +27,7 @@ object Config { val dokkaPlugin = "org.jetbrains.dokka:dokka-gradle-plugin:1.7.10" val dokkaPluginAlias = "org.jetbrains.dokka" val composeGradlePlugin = "org.jetbrains.compose:compose-gradle-plugin:$composeVersion" + val commonsCompressOverride = "org.apache.commons:commons-compress:1.25.0" } object Android { @@ -34,6 +35,7 @@ object Config { val minSdkVersion = 19 val minSdkVersionOkHttp = 21 + val minSdkVersionReplay = 19 val minSdkVersionNdk = 19 val minSdkVersionCompose = 21 val targetSdkVersion = sdkVersion @@ -51,7 +53,7 @@ object Config { val appCompat = "androidx.appcompat:appcompat:1.3.0" val timber = "com.jakewharton.timber:timber:4.7.1" val okhttp = "com.squareup.okhttp3:okhttp:$okHttpVersion" - val leakCanary = "com.squareup.leakcanary:leakcanary-android:2.8.1" + val leakCanary = "com.squareup.leakcanary:leakcanary-android:2.14" val constraintLayout = "androidx.constraintlayout:constraintlayout:2.1.3" private val lifecycleVersion = "2.2.0" @@ -96,6 +98,7 @@ object Config { val springBoot3StarterSecurity = "org.springframework.boot:spring-boot-starter-security:$springBoot3Version" val springBoot3StarterJdbc = "org.springframework.boot:spring-boot-starter-jdbc:$springBoot3Version" val springBoot3StarterActuator = "org.springframework.boot:spring-boot-starter-actuator:$springBoot3Version" + val springBoot3StarterOpenTelemetry = "io.opentelemetry.instrumentation:opentelemetry-spring-boot-starter:${OpenTelemetry.otelJavaagentVersion}" val springWeb = "org.springframework:spring-webmvc" val springWebflux = "org.springframework:spring-webflux" @@ -131,6 +134,7 @@ object Config { val p6spy = "p6spy:p6spy:3.9.1" val graphQlJava = "com.graphql-java:graphql-java:17.3" + val graphQlJava22 = "com.graphql-java:graphql-java:22.1" val quartz = "org.quartz-scheduler:quartz:2.3.0" @@ -145,20 +149,24 @@ object Config { val composeActivity = "androidx.activity:activity-compose:1.4.0" val composeFoundation = "androidx.compose.foundation:foundation:$composeVersion" val composeUi = "androidx.compose.ui:ui:$composeVersion" + + val composeUiReplay = "androidx.compose.ui:ui:1.5.0" // Note: don't change without testing forwards compatibility val composeFoundationLayout = "androidx.compose.foundation:foundation-layout:$composeVersion" val composeMaterial = "androidx.compose.material3:material3:1.0.0-alpha13" + val composeCoil = "io.coil-kt:coil-compose:2.0.0" val apolloKotlin = "com.apollographql.apollo3:apollo-runtime:3.8.2" object OpenTelemetry { - val otelVersion = "1.33.0" + val otelVersion = "1.41.0" val otelAlphaVersion = "$otelVersion-alpha" - val otelJavaagentVersion = "1.32.0" + val otelJavaagentVersion = "2.7.0" val otelJavaagentAlphaVersion = "$otelJavaagentVersion-alpha" - val otelSemanticConvetionsVersion = "1.23.1-alpha" + val otelSemanticConvetionsVersion = "1.25.0-alpha" // check https://github.com/open-telemetry/opentelemetry-java-instrumentation/blob/main/dependencyManagement/build.gradle.kts#L49 for release version above to find a compatible version val otelSdk = "io.opentelemetry:opentelemetry-sdk:$otelVersion" val otelSemconv = "io.opentelemetry.semconv:opentelemetry-semconv:$otelSemanticConvetionsVersion" + val otelSemconvIncubating = "io.opentelemetry.semconv:opentelemetry-semconv-incubating:$otelSemanticConvetionsVersion" val otelJavaAgent = "io.opentelemetry.javaagent:opentelemetry-javaagent:$otelJavaagentVersion" val otelJavaAgentExtensionApi = "io.opentelemetry.javaagent:opentelemetry-javaagent-extension-api:$otelJavaagentAlphaVersion" val otelJavaAgentTooling = "io.opentelemetry.javaagent:opentelemetry-javaagent-tooling:$otelJavaagentAlphaVersion" @@ -194,12 +202,16 @@ 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" + val leakCanaryInstrumentation = "com.squareup.leakcanary:leakcanary-android-instrumentation:2.14" } object QualityPlugins { object Jacoco { val version = "0.8.7" - val minimumCoverage = BigDecimal.valueOf(0.6) + + // TODO [POTEL] add tests and restore + val minimumCoverage = BigDecimal.valueOf(0.1) } val spotless = "com.diffplug.spotless" val spotlessVersion = "6.11.0" @@ -234,6 +246,7 @@ object Config { val SENTRY_APOLLO3_SDK_NAME = "$SENTRY_JAVA_SDK_NAME.apollo3" val SENTRY_APOLLO_SDK_NAME = "$SENTRY_JAVA_SDK_NAME.apollo" val SENTRY_GRAPHQL_SDK_NAME = "$SENTRY_JAVA_SDK_NAME.graphql" + val SENTRY_GRAPHQL22_SDK_NAME = "$SENTRY_JAVA_SDK_NAME.graphql22" val SENTRY_QUARTZ_SDK_NAME = "$SENTRY_JAVA_SDK_NAME.quartz" val SENTRY_JDBC_SDK_NAME = "$SENTRY_JAVA_SDK_NAME.jdbc" val SENTRY_SERVLET_SDK_NAME = "$SENTRY_JAVA_SDK_NAME.servlet" @@ -254,9 +267,4 @@ object Config { val errorprone = "com.google.errorprone:error_prone_core:2.11.0" val errorProneNullAway = "com.uber.nullaway:nullaway:0.9.5" } - - object NativePlugins { - val nativeBundlePlugin = "io.github.howardpang:androidNativeBundle:1.1.1" - val nativeBundleExport = "com.ydq.android.gradle.native-aar.export" - } } diff --git a/gradle.properties b/gradle.properties index 35ce98ed2d..58c3e92dd7 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=8.0.0-beta.1 # Override the SDK name on native crashes on Android sentryAndroidSdkName=sentry.native.android diff --git a/scripts/update-sentry-native-ndk.sh b/scripts/update-sentry-native-ndk.sh new file mode 100755 index 0000000000..544dc403ac --- /dev/null +++ b/scripts/update-sentry-native-ndk.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +set -euo pipefail + +cd $(dirname "$0")/../ +GRADLE_NDK_FILEPATH=sentry-android-ndk/build.gradle.kts +GRADLE_SAMPLE_FILEPATH=sentry-samples/sentry-samples-android/build.gradle.kts + +case $1 in +get-version) + version=$(perl -ne 'print "$1\n" if ( m/io\.sentry:sentry-native-ndk:([0-9.]+)+/ )' $GRADLE_NDK_FILEPATH) + + echo "v$version" + ;; +get-repo) + echo "https://github.com/getsentry/sentry-native.git" + ;; +set-version) + version=$2 + + # Remove leading "v" + if [[ "$version" == v* ]]; then + version="${version:1}" + fi + + echo "Setting sentry-native-ndk version to '$version'" + + PATTERN="io\.sentry:sentry-native-ndk:([0-9.]+)+" + perl -pi -e "s/$PATTERN/io.sentry:sentry-native-ndk:$version/g" $GRADLE_NDK_FILEPATH + perl -pi -e "s/$PATTERN/io.sentry:sentry-native-ndk:$version/g" $GRADLE_SAMPLE_FILEPATH + ;; +*) + echo "Unknown argument $1" + exit 1 + ;; +esac diff --git a/sentry-android-core/api/sentry-android-core.api b/sentry-android-core/api/sentry-android-core.api index adcc6ea87d..35c8408af2 100644 --- a/sentry-android-core/api/sentry-android-core.api +++ b/sentry-android-core/api/sentry-android-core.api @@ -8,12 +8,12 @@ public final class io/sentry/android/core/ActivityBreadcrumbsIntegration : andro public fun onActivitySaveInstanceState (Landroid/app/Activity;Landroid/os/Bundle;)V public fun onActivityStarted (Landroid/app/Activity;)V public fun onActivityStopped (Landroid/app/Activity;)V - public fun register (Lio/sentry/IHub;Lio/sentry/SentryOptions;)V + public fun register (Lio/sentry/IScopes;Lio/sentry/SentryOptions;)V } public final class io/sentry/android/core/ActivityFramesTracker { - public fun (Lio/sentry/android/core/LoadClass;Lio/sentry/android/core/SentryAndroidOptions;)V - public fun (Lio/sentry/android/core/LoadClass;Lio/sentry/android/core/SentryAndroidOptions;Lio/sentry/android/core/MainLooperHandler;)V + public fun (Lio/sentry/util/LoadClass;Lio/sentry/android/core/SentryAndroidOptions;)V + public fun (Lio/sentry/util/LoadClass;Lio/sentry/android/core/SentryAndroidOptions;Lio/sentry/android/core/MainLooperHandler;)V public fun addActivity (Landroid/app/Activity;)V public fun isFrameMetricsAggregatorAvailable ()Z public fun setMetrics (Landroid/app/Activity;Lio/sentry/protocol/SentryId;)V @@ -33,7 +33,7 @@ public final class io/sentry/android/core/ActivityLifecycleIntegration : android public fun onActivitySaveInstanceState (Landroid/app/Activity;Landroid/os/Bundle;)V public fun onActivityStarted (Landroid/app/Activity;)V public fun onActivityStopped (Landroid/app/Activity;)V - public fun register (Lio/sentry/IHub;Lio/sentry/SentryOptions;)V + public fun register (Lio/sentry/IScopes;Lio/sentry/SentryOptions;)V } public final class io/sentry/android/core/AndroidCpuCollector : io/sentry/IPerformanceSnapshotCollector { @@ -63,6 +63,7 @@ public class io/sentry/android/core/AndroidMemoryCollector : io/sentry/IPerforma } public class io/sentry/android/core/AndroidProfiler { + protected final field lock Lio/sentry/util/AutoClosableReentrantLock; public fun (Ljava/lang/String;ILio/sentry/android/core/internal/util/SentryFrameMetricsCollector;Lio/sentry/ISentryExecutorService;Lio/sentry/ILogger;Lio/sentry/android/core/BuildInfoProvider;)V public fun close ()V public fun endAndCollect (ZLjava/util/List;)Lio/sentry/android/core/AndroidProfiler$ProfileEndData; @@ -88,7 +89,7 @@ public class io/sentry/android/core/AndroidProfiler$ProfileStartData { public final class io/sentry/android/core/AnrIntegration : io/sentry/Integration, java/io/Closeable { public fun (Landroid/content/Context;)V public fun close ()V - public final fun register (Lio/sentry/IHub;Lio/sentry/SentryOptions;)V + public final fun register (Lio/sentry/IScopes;Lio/sentry/SentryOptions;)V } public final class io/sentry/android/core/AnrIntegrationFactory { @@ -98,6 +99,7 @@ public final class io/sentry/android/core/AnrIntegrationFactory { public final class io/sentry/android/core/AnrV2EventProcessor : io/sentry/BackfillingEventProcessor { public fun (Landroid/content/Context;Lio/sentry/android/core/SentryAndroidOptions;Lio/sentry/android/core/BuildInfoProvider;)V + public fun getOrder ()Ljava/lang/Long; public fun process (Lio/sentry/SentryEvent;Lio/sentry/Hint;)Lio/sentry/SentryEvent; public fun process (Lio/sentry/protocol/SentryTransaction;Lio/sentry/Hint;)Lio/sentry/protocol/SentryTransaction; } @@ -105,7 +107,7 @@ public final class io/sentry/android/core/AnrV2EventProcessor : io/sentry/Backfi public class io/sentry/android/core/AnrV2Integration : io/sentry/Integration, java/io/Closeable { public fun (Landroid/content/Context;)V public fun close ()V - public fun register (Lio/sentry/IHub;Lio/sentry/SentryOptions;)V + public fun register (Lio/sentry/IScopes;Lio/sentry/SentryOptions;)V } public final class io/sentry/android/core/AnrV2Integration$AnrV2Hint : io/sentry/hints/BlockingFlushHint, io/sentry/hints/AbnormalExit, io/sentry/hints/Backfillable { @@ -124,13 +126,13 @@ public final class io/sentry/android/core/AppComponentsBreadcrumbsIntegration : public fun onConfigurationChanged (Landroid/content/res/Configuration;)V public fun onLowMemory ()V public fun onTrimMemory (I)V - public fun register (Lio/sentry/IHub;Lio/sentry/SentryOptions;)V + public fun register (Lio/sentry/IScopes;Lio/sentry/SentryOptions;)V } public final class io/sentry/android/core/AppLifecycleIntegration : io/sentry/Integration, java/io/Closeable { public fun ()V public fun close ()V - public fun register (Lio/sentry/IHub;Lio/sentry/SentryOptions;)V + public fun register (Lio/sentry/IScopes;Lio/sentry/SentryOptions;)V } public final class io/sentry/android/core/AppState { @@ -158,6 +160,7 @@ public final class io/sentry/android/core/BuildInfoProvider { } public final class io/sentry/android/core/ContextUtils { + public static fun getApplicationContext (Landroid/content/Context;)Landroid/content/Context; public static fun isForegroundImportance ()Z } @@ -178,23 +181,26 @@ public final class io/sentry/android/core/CurrentActivityIntegration : android/a public fun onActivitySaveInstanceState (Landroid/app/Activity;Landroid/os/Bundle;)V public fun onActivityStarted (Landroid/app/Activity;)V public fun onActivityStopped (Landroid/app/Activity;)V - public fun register (Lio/sentry/IHub;Lio/sentry/SentryOptions;)V + public fun register (Lio/sentry/IScopes;Lio/sentry/SentryOptions;)V } 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 } public abstract class io/sentry/android/core/EnvelopeFileObserverIntegration : io/sentry/Integration, java/io/Closeable { + protected final field startLock Lio/sentry/util/AutoClosableReentrantLock; public fun ()V public fun close ()V public static fun getOutboxFileObserver ()Lio/sentry/android/core/EnvelopeFileObserverIntegration; - public final fun register (Lio/sentry/IHub;Lio/sentry/SentryOptions;)V + public final fun register (Lio/sentry/IScopes;Lio/sentry/SentryOptions;)V } public abstract interface class io/sentry/android/core/IDebugImagesLoader { @@ -210,7 +216,7 @@ public final class io/sentry/android/core/InternalSentrySdk { public static fun serializeScope (Landroid/content/Context;Lio/sentry/android/core/SentryAndroidOptions;Lio/sentry/IScope;)Ljava/util/Map; } -public final class io/sentry/android/core/LoadClass { +public final class io/sentry/android/core/LoadClass : io/sentry/util/LoadClass { public fun ()V public fun isClassAvailable (Ljava/lang/String;Lio/sentry/ILogger;)Z public fun isClassAvailable (Ljava/lang/String;Lio/sentry/SentryOptions;)Z @@ -221,23 +227,24 @@ public final class io/sentry/android/core/NdkIntegration : io/sentry/Integration public static final field SENTRY_NDK_CLASS_NAME Ljava/lang/String; public fun (Ljava/lang/Class;)V public fun close ()V - public final fun register (Lio/sentry/IHub;Lio/sentry/SentryOptions;)V + public final fun register (Lio/sentry/IScopes;Lio/sentry/SentryOptions;)V } public final class io/sentry/android/core/NetworkBreadcrumbsIntegration : io/sentry/Integration, java/io/Closeable { public fun (Landroid/content/Context;Lio/sentry/android/core/BuildInfoProvider;Lio/sentry/ILogger;)V public fun close ()V - public fun register (Lio/sentry/IHub;Lio/sentry/SentryOptions;)V + public fun register (Lio/sentry/IScopes;Lio/sentry/SentryOptions;)V } public final class io/sentry/android/core/PhoneStateBreadcrumbsIntegration : io/sentry/Integration, java/io/Closeable { public fun (Landroid/content/Context;)V public fun close ()V - public fun register (Lio/sentry/IHub;Lio/sentry/SentryOptions;)V + public fun register (Lio/sentry/IScopes;Lio/sentry/SentryOptions;)V } public final class io/sentry/android/core/ScreenshotEventProcessor : io/sentry/EventProcessor { public fun (Lio/sentry/android/core/SentryAndroidOptions;Lio/sentry/android/core/BuildInfoProvider;)V + public fun getOrder ()Ljava/lang/Long; public fun process (Lio/sentry/SentryEvent;Lio/sentry/Hint;)Lio/sentry/SentryEvent; public fun process (Lio/sentry/protocol/SentryTransaction;Lio/sentry/Hint;)Lio/sentry/protocol/SentryTransaction; } @@ -263,7 +270,6 @@ public final class io/sentry/android/core/SentryAndroidOptions : io/sentry/Sentr public fun getDebugImagesLoader ()Lio/sentry/android/core/IDebugImagesLoader; public fun getFrameMetricsCollector ()Lio/sentry/android/core/internal/util/SentryFrameMetricsCollector; public fun getNativeSdkName ()Ljava/lang/String; - public fun getProfilingTracesIntervalMillis ()I public fun getStartupCrashDurationThresholdMillis ()J public fun isAnrEnabled ()Z public fun isAnrReportInDebug ()Z @@ -308,7 +314,6 @@ public final class io/sentry/android/core/SentryAndroidOptions : io/sentry/Sentr public fun setEnableSystemEventBreadcrumbs (Z)V public fun setFrameMetricsCollector (Lio/sentry/android/core/internal/util/SentryFrameMetricsCollector;)V public fun setNativeSdkName (Ljava/lang/String;)V - public fun setProfilingTracesIntervalMillis (I)V public fun setReportHistoricalAnrs (Z)V } @@ -351,6 +356,7 @@ public final class io/sentry/android/core/SentryPerformanceProvider { } public class io/sentry/android/core/SpanFrameMetricsCollector : io/sentry/IPerformanceContinuousCollector, io/sentry/android/core/internal/util/SentryFrameMetricsCollector$FrameMetricsCollectorListener { + protected final field lock Lio/sentry/util/AutoClosableReentrantLock; public fun (Lio/sentry/android/core/SentryAndroidOptions;Lio/sentry/android/core/internal/util/SentryFrameMetricsCollector;)V public fun clear ()V public fun onFrameMetricCollected (JJJJZZF)V @@ -362,7 +368,7 @@ public final class io/sentry/android/core/SystemEventsBreadcrumbsIntegration : i public fun (Landroid/content/Context;)V public fun (Landroid/content/Context;Ljava/util/List;)V public fun close ()V - public fun register (Lio/sentry/IHub;Lio/sentry/SentryOptions;)V + public fun register (Lio/sentry/IScopes;Lio/sentry/SentryOptions;)V } public final class io/sentry/android/core/TempSensorBreadcrumbsIntegration : android/hardware/SensorEventListener, io/sentry/Integration, java/io/Closeable { @@ -370,11 +376,11 @@ public final class io/sentry/android/core/TempSensorBreadcrumbsIntegration : and public fun close ()V public fun onAccuracyChanged (Landroid/hardware/Sensor;I)V public fun onSensorChanged (Landroid/hardware/SensorEvent;)V - public fun register (Lio/sentry/IHub;Lio/sentry/SentryOptions;)V + public fun register (Lio/sentry/IScopes;Lio/sentry/SentryOptions;)V } public final class io/sentry/android/core/UserInteractionIntegration : android/app/Application$ActivityLifecycleCallbacks, io/sentry/Integration, java/io/Closeable { - public fun (Landroid/app/Application;Lio/sentry/android/core/LoadClass;)V + public fun (Landroid/app/Application;Lio/sentry/util/LoadClass;)V public fun close ()V public fun onActivityCreated (Landroid/app/Activity;Landroid/os/Bundle;)V public fun onActivityDestroyed (Landroid/app/Activity;)V @@ -383,18 +389,19 @@ public final class io/sentry/android/core/UserInteractionIntegration : android/a public fun onActivitySaveInstanceState (Landroid/app/Activity;Landroid/os/Bundle;)V public fun onActivityStarted (Landroid/app/Activity;)V public fun onActivityStopped (Landroid/app/Activity;)V - public fun register (Lio/sentry/IHub;Lio/sentry/SentryOptions;)V + public fun register (Lio/sentry/IScopes;Lio/sentry/SentryOptions;)V } public final class io/sentry/android/core/ViewHierarchyEventProcessor : io/sentry/EventProcessor { public fun (Lio/sentry/android/core/SentryAndroidOptions;)V + public fun getOrder ()Ljava/lang/Long; public fun process (Lio/sentry/SentryEvent;Lio/sentry/Hint;)Lio/sentry/SentryEvent; public fun process (Lio/sentry/protocol/SentryTransaction;Lio/sentry/Hint;)Lio/sentry/protocol/SentryTransaction; public static fun snapshotViewHierarchy (Landroid/app/Activity;Lio/sentry/ILogger;)Lio/sentry/protocol/ViewHierarchy; - public static fun snapshotViewHierarchy (Landroid/app/Activity;Ljava/util/List;Lio/sentry/util/thread/IMainThreadChecker;Lio/sentry/ILogger;)Lio/sentry/protocol/ViewHierarchy; + public static fun snapshotViewHierarchy (Landroid/app/Activity;Ljava/util/List;Lio/sentry/util/thread/IThreadChecker;Lio/sentry/ILogger;)Lio/sentry/protocol/ViewHierarchy; public static fun snapshotViewHierarchy (Landroid/view/View;)Lio/sentry/protocol/ViewHierarchy; public static fun snapshotViewHierarchy (Landroid/view/View;Ljava/util/List;)Lio/sentry/protocol/ViewHierarchy; - public static fun snapshotViewHierarchyAsData (Landroid/app/Activity;Lio/sentry/util/thread/IMainThreadChecker;Lio/sentry/ISerializer;Lio/sentry/ILogger;)[B + public static fun snapshotViewHierarchyAsData (Landroid/app/Activity;Lio/sentry/util/thread/IThreadChecker;Lio/sentry/ISerializer;Lio/sentry/ILogger;)[B } public final class io/sentry/android/core/cache/AndroidEnvelopeCache : io/sentry/cache/EnvelopeCache { @@ -425,7 +432,8 @@ 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 static final field staticLock Lio/sentry/util/AutoClosableReentrantLock; public fun ()V public fun addActivityLifecycleTimeSpans (Lio/sentry/android/core/performance/ActivityLifecycleTimeSpan;)V public fun clear ()V @@ -441,10 +449,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/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/ActivityBreadcrumbsIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/ActivityBreadcrumbsIntegration.java index dc03abe808..886ad40c92 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/ActivityBreadcrumbsIntegration.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ActivityBreadcrumbsIntegration.java @@ -8,10 +8,12 @@ import android.os.Bundle; import io.sentry.Breadcrumb; import io.sentry.Hint; -import io.sentry.IHub; +import io.sentry.IScopes; +import io.sentry.ISentryLifecycleToken; import io.sentry.Integration; import io.sentry.SentryLevel; import io.sentry.SentryOptions; +import io.sentry.util.AutoClosableReentrantLock; import io.sentry.util.Objects; import java.io.Closeable; import java.io.IOException; @@ -23,21 +25,23 @@ public final class ActivityBreadcrumbsIntegration implements Integration, Closeable, Application.ActivityLifecycleCallbacks { private final @NotNull Application application; - private @Nullable IHub hub; + private @Nullable IScopes scopes; private boolean enabled; + private final @NotNull AutoClosableReentrantLock lock = new AutoClosableReentrantLock(); + // TODO check if locking is even required at all for lifecycle methods public ActivityBreadcrumbsIntegration(final @NotNull Application application) { this.application = Objects.requireNonNull(application, "Application is required"); } @Override - public void register(final @NotNull IHub hub, final @NotNull SentryOptions options) { + public void register(final @NotNull IScopes scopes, final @NotNull SentryOptions options) { final SentryAndroidOptions androidOptions = Objects.requireNonNull( (options instanceof SentryAndroidOptions) ? (SentryAndroidOptions) options : null, "SentryAndroidOptions is required"); - this.hub = Objects.requireNonNull(hub, "Hub is required"); + this.scopes = Objects.requireNonNull(scopes, "Scopes are required"); this.enabled = androidOptions.isEnableActivityLifecycleBreadcrumbs(); options .getLogger() @@ -54,8 +58,9 @@ public void register(final @NotNull IHub hub, final @NotNull SentryOptions optio public void close() throws IOException { if (enabled) { application.unregisterActivityLifecycleCallbacks(this); - if (hub != null) { - hub.getOptions() + if (scopes != null) { + scopes + .getOptions() .getLogger() .log(SentryLevel.DEBUG, "ActivityBreadcrumbsIntegration removed."); } @@ -63,44 +68,58 @@ public void close() throws IOException { } @Override - public synchronized void onActivityCreated( + public void onActivityCreated( final @NotNull Activity activity, final @Nullable Bundle savedInstanceState) { - addBreadcrumb(activity, "created"); + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + addBreadcrumb(activity, "created"); + } } @Override - public synchronized void onActivityStarted(final @NotNull Activity activity) { - addBreadcrumb(activity, "started"); + public void onActivityStarted(final @NotNull Activity activity) { + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + addBreadcrumb(activity, "started"); + } } @Override - public synchronized void onActivityResumed(final @NotNull Activity activity) { - addBreadcrumb(activity, "resumed"); + public void onActivityResumed(final @NotNull Activity activity) { + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + addBreadcrumb(activity, "resumed"); + } } @Override - public synchronized void onActivityPaused(final @NotNull Activity activity) { - addBreadcrumb(activity, "paused"); + public void onActivityPaused(final @NotNull Activity activity) { + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + addBreadcrumb(activity, "paused"); + } } @Override - public synchronized void onActivityStopped(final @NotNull Activity activity) { - addBreadcrumb(activity, "stopped"); + public void onActivityStopped(final @NotNull Activity activity) { + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + addBreadcrumb(activity, "stopped"); + } } @Override - public synchronized void onActivitySaveInstanceState( + public void onActivitySaveInstanceState( final @NotNull Activity activity, final @NotNull Bundle outState) { - addBreadcrumb(activity, "saveInstanceState"); + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + addBreadcrumb(activity, "saveInstanceState"); + } } @Override - public synchronized void onActivityDestroyed(final @NotNull Activity activity) { - addBreadcrumb(activity, "destroyed"); + public void onActivityDestroyed(final @NotNull Activity activity) { + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + addBreadcrumb(activity, "destroyed"); + } } private void addBreadcrumb(final @NotNull Activity activity, final @NotNull String state) { - if (hub == null) { + if (scopes == null) { return; } @@ -114,7 +133,7 @@ private void addBreadcrumb(final @NotNull Activity activity, final @NotNull Stri final Hint hint = new Hint(); hint.set(ANDROID_ACTIVITY, activity); - hub.addBreadcrumb(breadcrumb, hint); + scopes.addBreadcrumb(breadcrumb, hint); } private @NotNull String getActivityName(final @NotNull Activity activity) { diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ActivityFramesTracker.java b/sentry-android-core/src/main/java/io/sentry/android/core/ActivityFramesTracker.java index 93f50b3ec1..ade8fdd37c 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/ActivityFramesTracker.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ActivityFramesTracker.java @@ -3,11 +3,13 @@ import android.app.Activity; import android.util.SparseIntArray; import androidx.core.app.FrameMetricsAggregator; +import io.sentry.ISentryLifecycleToken; import io.sentry.MeasurementUnit; import io.sentry.SentryLevel; -import io.sentry.android.core.internal.util.AndroidMainThreadChecker; +import io.sentry.android.core.internal.util.AndroidThreadChecker; import io.sentry.protocol.MeasurementValue; import io.sentry.protocol.SentryId; +import io.sentry.util.AutoClosableReentrantLock; import java.util.HashMap; import java.util.Map; import java.util.WeakHashMap; @@ -37,9 +39,10 @@ public final class ActivityFramesTracker { new WeakHashMap<>(); private final @NotNull MainLooperHandler handler; + protected @NotNull AutoClosableReentrantLock lock = new AutoClosableReentrantLock(); public ActivityFramesTracker( - final @NotNull LoadClass loadClass, + final @NotNull io.sentry.util.LoadClass loadClass, final @NotNull SentryAndroidOptions options, final @NotNull MainLooperHandler handler) { @@ -54,13 +57,14 @@ public ActivityFramesTracker( } public ActivityFramesTracker( - final @NotNull LoadClass loadClass, final @NotNull SentryAndroidOptions options) { + final @NotNull io.sentry.util.LoadClass loadClass, + final @NotNull SentryAndroidOptions options) { this(loadClass, options, new MainLooperHandler()); } @TestOnly ActivityFramesTracker( - final @NotNull LoadClass loadClass, + final @NotNull io.sentry.util.LoadClass loadClass, final @NotNull SentryAndroidOptions options, final @NotNull MainLooperHandler handler, final @Nullable FrameMetricsAggregator frameMetricsAggregator) { @@ -77,13 +81,15 @@ public boolean isFrameMetricsAggregatorAvailable() { } @SuppressWarnings("NullAway") - public synchronized void addActivity(final @NotNull Activity activity) { - if (!isFrameMetricsAggregatorAvailable()) { - return; - } + public void addActivity(final @NotNull Activity activity) { + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + if (!isFrameMetricsAggregatorAvailable()) { + return; + } - runSafelyOnUiThread(() -> frameMetricsAggregator.add(activity), "FrameMetricsAggregator.add"); - snapshotFrameCountsAtStart(activity); + runSafelyOnUiThread(() -> frameMetricsAggregator.add(activity), "FrameMetricsAggregator.add"); + snapshotFrameCountsAtStart(activity); + } } private void snapshotFrameCountsAtStart(final @NotNull Activity activity) { @@ -131,45 +137,46 @@ private void snapshotFrameCountsAtStart(final @NotNull Activity activity) { } @SuppressWarnings("NullAway") - public synchronized void setMetrics( - final @NotNull Activity activity, final @NotNull SentryId transactionId) { - if (!isFrameMetricsAggregatorAvailable()) { - return; - } + public void setMetrics(final @NotNull Activity activity, final @NotNull SentryId transactionId) { + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + if (!isFrameMetricsAggregatorAvailable()) { + return; + } - // NOTE: removing an activity does not reset the frame counts, only reset() does - // throws IllegalArgumentException when attempting to remove - // OnFrameMetricsAvailableListener - // that was never added. - // there's no contains method. - // throws NullPointerException when attempting to remove - // OnFrameMetricsAvailableListener and - // there was no - // Observers, See - // https://android.googlesource.com/platform/frameworks/base/+/140ff5ea8e2d99edc3fbe63a43239e459334c76b - runSafelyOnUiThread(() -> frameMetricsAggregator.remove(activity), null); - - final @Nullable FrameCounts frameCounts = diffFrameCountsAtEnd(activity); - - if (frameCounts == null - || (frameCounts.totalFrames == 0 - && frameCounts.slowFrames == 0 - && frameCounts.frozenFrames == 0)) { - return; - } + // NOTE: removing an activity does not reset the frame counts, only reset() does + // throws IllegalArgumentException when attempting to remove + // OnFrameMetricsAvailableListener + // that was never added. + // there's no contains method. + // throws NullPointerException when attempting to remove + // OnFrameMetricsAvailableListener and + // there was no + // Observers, See + // https://android.googlesource.com/platform/frameworks/base/+/140ff5ea8e2d99edc3fbe63a43239e459334c76b + runSafelyOnUiThread(() -> frameMetricsAggregator.remove(activity), null); + + final @Nullable FrameCounts frameCounts = diffFrameCountsAtEnd(activity); + + if (frameCounts == null + || (frameCounts.totalFrames == 0 + && frameCounts.slowFrames == 0 + && frameCounts.frozenFrames == 0)) { + return; + } - final MeasurementValue tfValues = - new MeasurementValue(frameCounts.totalFrames, MeasurementUnit.NONE); - final MeasurementValue sfValues = - new MeasurementValue(frameCounts.slowFrames, MeasurementUnit.NONE); - final MeasurementValue ffValues = - new MeasurementValue(frameCounts.frozenFrames, MeasurementUnit.NONE); - final Map measurements = new HashMap<>(); - measurements.put(MeasurementValue.KEY_FRAMES_TOTAL, tfValues); - measurements.put(MeasurementValue.KEY_FRAMES_SLOW, sfValues); - measurements.put(MeasurementValue.KEY_FRAMES_FROZEN, ffValues); - - activityMeasurements.put(transactionId, measurements); + final MeasurementValue tfValues = + new MeasurementValue(frameCounts.totalFrames, MeasurementUnit.NONE); + final MeasurementValue sfValues = + new MeasurementValue(frameCounts.slowFrames, MeasurementUnit.NONE); + final MeasurementValue ffValues = + new MeasurementValue(frameCounts.frozenFrames, MeasurementUnit.NONE); + final Map measurements = new HashMap<>(); + measurements.put(MeasurementValue.KEY_FRAMES_TOTAL, tfValues); + measurements.put(MeasurementValue.KEY_FRAMES_SLOW, sfValues); + measurements.put(MeasurementValue.KEY_FRAMES_FROZEN, ffValues); + + activityMeasurements.put(transactionId, measurements); + } } private @Nullable FrameCounts diffFrameCountsAtEnd(final @NotNull Activity activity) { @@ -191,30 +198,33 @@ public synchronized void setMetrics( } @Nullable - public synchronized Map takeMetrics( - final @NotNull SentryId transactionId) { - if (!isFrameMetricsAggregatorAvailable()) { - return null; - } + public Map takeMetrics(final @NotNull SentryId transactionId) { + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + if (!isFrameMetricsAggregatorAvailable()) { + return null; + } - final Map stringMeasurementValueMap = - activityMeasurements.get(transactionId); - activityMeasurements.remove(transactionId); - return stringMeasurementValueMap; + final Map stringMeasurementValueMap = + activityMeasurements.get(transactionId); + activityMeasurements.remove(transactionId); + return stringMeasurementValueMap; + } } @SuppressWarnings("NullAway") - public synchronized void stop() { - if (isFrameMetricsAggregatorAvailable()) { - runSafelyOnUiThread(() -> frameMetricsAggregator.stop(), "FrameMetricsAggregator.stop"); - frameMetricsAggregator.reset(); + public void stop() { + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + if (isFrameMetricsAggregatorAvailable()) { + runSafelyOnUiThread(() -> frameMetricsAggregator.stop(), "FrameMetricsAggregator.stop"); + frameMetricsAggregator.reset(); + } + activityMeasurements.clear(); } - activityMeasurements.clear(); } private void runSafelyOnUiThread(final Runnable runnable, final String tag) { try { - if (AndroidMainThreadChecker.getInstance().isMainThread()) { + if (AndroidThreadChecker.getInstance().isMainThread()) { runnable.run(); } else { handler.post( 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..b69f8d4ea0 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 @@ -12,8 +12,9 @@ import android.view.View; import androidx.annotation.NonNull; import io.sentry.FullyDisplayedReporter; -import io.sentry.IHub; import io.sentry.IScope; +import io.sentry.IScopes; +import io.sentry.ISentryLifecycleToken; import io.sentry.ISpan; import io.sentry.ITransaction; import io.sentry.Instrumenter; @@ -21,7 +22,9 @@ import io.sentry.NoOpTransaction; import io.sentry.SentryDate; import io.sentry.SentryLevel; +import io.sentry.SentryNanotimeDate; import io.sentry.SentryOptions; +import io.sentry.SpanOptions; import io.sentry.SpanStatus; import io.sentry.TracesSamplingDecision; import io.sentry.TransactionContext; @@ -32,11 +35,13 @@ import io.sentry.android.core.performance.TimeSpan; import io.sentry.protocol.MeasurementValue; import io.sentry.protocol.TransactionNameSource; +import io.sentry.util.AutoClosableReentrantLock; import io.sentry.util.Objects; import io.sentry.util.TracingUtils; 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; @@ -60,7 +65,7 @@ public final class ActivityLifecycleIntegration private final @NotNull Application application; private final @NotNull BuildInfoProvider buildInfoProvider; - private @Nullable IHub hub; + private @Nullable IScopes scopes; private @Nullable SentryAndroidOptions options; private boolean performanceEnabled = false; @@ -75,7 +80,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; @@ -85,6 +90,7 @@ public final class ActivityLifecycleIntegration new WeakHashMap<>(); private final @NotNull ActivityFramesTracker activityFramesTracker; + private final @NotNull AutoClosableReentrantLock lock = new AutoClosableReentrantLock(); public ActivityLifecycleIntegration( final @NotNull Application application, @@ -102,13 +108,13 @@ public ActivityLifecycleIntegration( } @Override - public void register(final @NotNull IHub hub, final @NotNull SentryOptions options) { + public void register(final @NotNull IScopes scopes, final @NotNull SentryOptions options) { this.options = Objects.requireNonNull( (options instanceof SentryAndroidOptions) ? (SentryAndroidOptions) options : null, "SentryAndroidOptions is required"); - this.hub = Objects.requireNonNull(hub, "Hub is required"); + this.scopes = Objects.requireNonNull(scopes, "Scopes are required"); performanceEnabled = isPerformanceEnabled(this.options); fullyDisplayedReporter = this.options.getFullyDisplayedReporter(); @@ -150,10 +156,10 @@ private void stopPreviousTransactions() { private void startTracing(final @NotNull Activity activity) { WeakReference weakActivity = new WeakReference<>(activity); - if (hub != null && !isRunningTransactionOrTrace(activity)) { + if (scopes != null && !isRunningTransactionOrTrace(activity)) { if (!performanceEnabled) { activitiesWithOngoingTransactions.put(activity, NoOpTransaction.getInstance()); - TracingUtils.startNewTrace(hub); + TracingUtils.startNewTrace(scopes); } else { // as we allow a single transaction running on the bound Scope, we finish the previous ones stopPreviousTransactions(); @@ -222,17 +228,20 @@ private void startTracing(final @NotNull Activity activity) { } transactionOptions.setStartTimestamp(ttidStartTime); transactionOptions.setAppStartTransaction(appStartSamplingDecision != null); + setSpanOrigin(transactionOptions); // we can only bind to the scope if there's no running transaction ITransaction transaction = - hub.startTransaction( + scopes.startTransaction( new TransactionContext( activityName, TransactionNameSource.COMPONENT, UI_LOAD_OP, appStartSamplingDecision), transactionOptions); - setSpanOrigin(transaction); + + final SpanOptions spanOptions = new SpanOptions(); + setSpanOrigin(spanOptions); // in case appStartTime isn't available, we don't create a span for it. if (!(firstActivityCreated || appStartTime == null || coldStart == null)) { @@ -242,8 +251,8 @@ private void startTracing(final @NotNull Activity activity) { getAppStartOp(coldStart), getAppStartDesc(coldStart), appStartTime, - Instrumenter.SENTRY); - setSpanOrigin(appStartSpan); + Instrumenter.SENTRY, + spanOptions); // in case there's already an end time (e.g. due to deferred SDK init) // we can finish the app-start span @@ -251,15 +260,21 @@ private void startTracing(final @NotNull Activity activity) { } final @NotNull ISpan ttidSpan = transaction.startChild( - TTID_OP, getTtidDesc(activityName), ttidStartTime, Instrumenter.SENTRY); + TTID_OP, + getTtidDesc(activityName), + ttidStartTime, + Instrumenter.SENTRY, + spanOptions); ttidSpanMap.put(activity, ttidSpan); - setSpanOrigin(ttidSpan); if (timeToFullDisplaySpanEnabled && fullyDisplayedReporter != null && options != null) { final @NotNull ISpan ttfdSpan = transaction.startChild( - TTFD_OP, getTtfdDesc(activityName), ttidStartTime, Instrumenter.SENTRY); - setSpanOrigin(ttfdSpan); + TTFD_OP, + getTtfdDesc(activityName), + ttidStartTime, + Instrumenter.SENTRY, + spanOptions); try { ttfdSpanMap.put(activity, ttfdSpan); ttfdAutoCloseFuture = @@ -278,7 +293,7 @@ private void startTracing(final @NotNull Activity activity) { } // lets bind to the scope so other integrations can pick it up - hub.configureScope( + scopes.configureScope( scope -> { applyScope(scope, transaction); }); @@ -288,10 +303,8 @@ private void startTracing(final @NotNull Activity activity) { } } - private void setSpanOrigin(ISpan span) { - if (span != null) { - span.getSpanContext().setOrigin(TRACE_ORIGIN); - } + private void setSpanOrigin(final @NotNull SpanOptions spanOptions) { + spanOptions.setOrigin(TRACE_ORIGIN); } @VisibleForTesting @@ -356,10 +369,10 @@ private void finishTransaction( status = SpanStatus.OK; } transaction.finish(status); - if (hub != null) { + if (scopes != null) { // make sure to remove the transaction from scope, as it may contain running children, // therefore `finish` method will not remove it from scope - hub.configureScope( + scopes.configureScope( scope -> { clearScope(scope, transaction); }); @@ -368,50 +381,56 @@ private void finishTransaction( } @Override - public synchronized void onActivityCreated( + public void onActivityCreated( final @NotNull Activity activity, final @Nullable Bundle savedInstanceState) { - setColdStart(savedInstanceState); - if (hub != null) { - final @Nullable String activityClassName = ClassUtil.getClassName(activity); - hub.configureScope(scope -> scope.setScreen(activityClassName)); - } - startTracing(activity); - final @Nullable ISpan ttfdSpan = ttfdSpanMap.get(activity); + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + setColdStart(savedInstanceState); + if (scopes != null && options != null && options.isEnableScreenTracking()) { + final @Nullable String activityClassName = ClassUtil.getClassName(activity); + scopes.configureScope(scope -> scope.setScreen(activityClassName)); + } + startTracing(activity); + final @Nullable ISpan ttfdSpan = ttfdSpanMap.get(activity); - firstActivityCreated = true; + firstActivityCreated = true; - if (fullyDisplayedReporter != null) { - fullyDisplayedReporter.registerFullyDrawnListener(() -> onFullFrameDrawn(ttfdSpan)); + if (performanceEnabled && ttfdSpan != null && fullyDisplayedReporter != null) { + fullyDisplayedReporter.registerFullyDrawnListener(() -> onFullFrameDrawn(ttfdSpan)); + } } } @Override - public synchronized void onActivityStarted(final @NotNull Activity activity) { - if (performanceEnabled) { - // The docs on the screen rendering performance tracing - // (https://firebase.google.com/docs/perf-mon/screen-traces?platform=android#definition), - // state that the tracing starts for every Activity class when the app calls - // .onActivityStarted. - // Adding an Activity in onActivityCreated leads to Window.FEATURE_NO_TITLE not - // working. Moving this to onActivityStarted fixes the problem. - activityFramesTracker.addActivity(activity); + public void onActivityStarted(final @NotNull Activity activity) { + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + if (performanceEnabled) { + // The docs on the screen rendering performance tracing + // (https://firebase.google.com/docs/perf-mon/screen-traces?platform=android#definition), + // state that the tracing starts for every Activity class when the app calls + // .onActivityStarted. + // Adding an Activity in onActivityCreated leads to Window.FEATURE_NO_TITLE not + // working. Moving this to onActivityStarted fixes the problem. + activityFramesTracker.addActivity(activity); + } } } @Override - public synchronized void onActivityResumed(final @NotNull Activity activity) { - if (performanceEnabled) { - - final @Nullable ISpan ttidSpan = ttidSpanMap.get(activity); - final @Nullable ISpan ttfdSpan = ttfdSpanMap.get(activity); - final View rootView = activity.findViewById(android.R.id.content); - if (rootView != null) { - FirstDrawDoneListener.registerForNextDraw( - rootView, () -> onFirstFrameDrawn(ttfdSpan, ttidSpan), buildInfoProvider); - } else { - // Posting a task to the main thread's handler will make it executed after it finished - // its current job. That is, right after the activity draws the layout. - mainHandler.post(() -> onFirstFrameDrawn(ttfdSpan, ttidSpan)); + public void onActivityResumed(final @NotNull Activity activity) { + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + if (performanceEnabled) { + + final @Nullable ISpan ttidSpan = ttidSpanMap.get(activity); + final @Nullable ISpan ttfdSpan = ttfdSpanMap.get(activity); + final View rootView = activity.findViewById(android.R.id.content); + if (rootView != null) { + FirstDrawDoneListener.registerForNextDraw( + rootView, () -> onFirstFrameDrawn(ttfdSpan, ttidSpan), buildInfoProvider); + } else { + // Posting a task to the main thread's handler will make it executed after it finished + // its current job. That is, right after the activity draws the layout. + mainHandler.post(() -> onFirstFrameDrawn(ttfdSpan, ttidSpan)); + } } } } @@ -429,73 +448,79 @@ public void onActivityPrePaused(@NonNull Activity activity) { // well // this ensures any newly launched activity will not use the app start timestamp as txn start firstActivityCreated = true; - if (hub == null) { + if (scopes == null) { lastPausedTime = AndroidDateUtils.getCurrentSentryDateTime(); } else { - lastPausedTime = hub.getOptions().getDateProvider().now(); + lastPausedTime = scopes.getOptions().getDateProvider().now(); } } } @Override - public synchronized void onActivityPaused(final @NotNull Activity activity) { - // only executed if API < 29 otherwise it happens on onActivityPrePaused - if (!isAllActivityCallbacksAvailable) { - // as the SDK may gets (re-)initialized mid activity lifecycle, ensure we set the flag here as - // well - // this ensures any newly launched activity will not use the app start timestamp as txn start - firstActivityCreated = true; - if (hub == null) { - lastPausedTime = AndroidDateUtils.getCurrentSentryDateTime(); - } else { - lastPausedTime = hub.getOptions().getDateProvider().now(); + public void onActivityPaused(final @NotNull Activity activity) { + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + // only executed if API < 29 otherwise it happens on onActivityPrePaused + if (!isAllActivityCallbacksAvailable) { + // as the SDK may gets (re-)initialized mid activity lifecycle, ensure we set the flag here + // as + // well + // this ensures any newly launched activity will not use the app start timestamp as txn + // start + firstActivityCreated = true; + if (scopes == null) { + lastPausedTime = AndroidDateUtils.getCurrentSentryDateTime(); + } else { + lastPausedTime = scopes.getOptions().getDateProvider().now(); + } } } } @Override - public synchronized void onActivityStopped(final @NotNull Activity activity) { - // no-op + public void onActivityStopped(final @NotNull Activity activity) { + // no-op (acquire lock if this no longer is no-op) } @Override - public synchronized void onActivitySaveInstanceState( + public void onActivitySaveInstanceState( final @NotNull Activity activity, final @NotNull Bundle outState) { - // no-op + // no-op (acquire lock if this no longer is no-op) } @Override - public synchronized void onActivityDestroyed(final @NotNull Activity activity) { - if (performanceEnabled) { - - // in case the appStartSpan isn't completed yet, we finish it as cancelled to avoid - // memory leak - finishSpan(appStartSpan, SpanStatus.CANCELLED); - - // we finish the ttidSpan as cancelled in case it isn't completed yet - final ISpan ttidSpan = ttidSpanMap.get(activity); - final ISpan ttfdSpan = ttfdSpanMap.get(activity); - finishSpan(ttidSpan, SpanStatus.DEADLINE_EXCEEDED); - - // we finish the ttfdSpan as deadline_exceeded in case it isn't completed yet - finishExceededTtfdSpan(ttfdSpan, ttidSpan); - cancelTtfdAutoClose(); - - // in case people opt-out enableActivityLifecycleTracingAutoFinish and forgot to finish it, - // we make sure to finish it when the activity gets destroyed. - stopTracing(activity, true); + public void onActivityDestroyed(final @NotNull Activity activity) { + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + if (performanceEnabled) { + + // in case the appStartSpan isn't completed yet, we finish it as cancelled to avoid + // memory leak + finishSpan(appStartSpan, SpanStatus.CANCELLED); + + // we finish the ttidSpan as cancelled in case it isn't completed yet + final ISpan ttidSpan = ttidSpanMap.get(activity); + final ISpan ttfdSpan = ttfdSpanMap.get(activity); + finishSpan(ttidSpan, SpanStatus.DEADLINE_EXCEEDED); + + // we finish the ttfdSpan as deadline_exceeded in case it isn't completed yet + finishExceededTtfdSpan(ttfdSpan, ttidSpan); + cancelTtfdAutoClose(); + + // in case people opt-out enableActivityLifecycleTracingAutoFinish and forgot to finish it, + // we make sure to finish it when the activity gets destroyed. + stopTracing(activity, true); + + // set it to null in case its been just finished as cancelled + appStartSpan = null; + ttidSpanMap.remove(activity); + ttfdSpanMap.remove(activity); + } - // set it to null in case its been just finished as cancelled - appStartSpan = null; - ttidSpanMap.remove(activity); - ttfdSpanMap.remove(activity); + // clear it up, so we don't start again for the same activity if the activity is in the + // activity + // stack still. + // if the activity is opened again and not in memory, transactions will be created normally. + activitiesWithOngoingTransactions.remove(activity); } - - // clear it up, so we don't start again for the same activity if the activity is in the - // activity - // stack still. - // if the activity is opened again and not in memory, transactions will be created normally. - activitiesWithOngoingTransactions.remove(activity); } private void finishSpan(final @Nullable ISpan span) { @@ -627,17 +652,26 @@ 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 (scopes != null && lastPausedTime.nanoTimestamp() == 0) { + lastPausedTime = scopes.getOptions().getDateProvider().now(); + } else if (lastPausedTime.nanoTimestamp() == 0) { + lastPausedTime = AndroidDateUtils.getCurrentSentryDateTime(); + } if (!firstActivityCreated) { + final @NotNull AppStartMetrics appStartMetrics = AppStartMetrics.getInstance(); // if Activity has savedInstanceState then its a warm start // https://developer.android.com/topic/performance/vitals/launch-time#warm // SentryPerformanceProvider sets this already // pre-performance-v2: back-fill with best guess - if (options != null && !options.isEnablePerformanceV2()) { - AppStartMetrics.getInstance() - .setAppStartType( - savedInstanceState == null - ? AppStartMetrics.AppStartType.COLD - : AppStartMetrics.AppStartType.WARM); + if ((options != null && !options.isEnablePerformanceV2()) + || appStartMetrics.getAppStartType() == AppStartMetrics.AppStartType.UNKNOWN) { + appStartMetrics.setAppStartType( + savedInstanceState == null + ? AppStartMetrics.AppStartType.COLD + : AppStartMetrics.AppStartType.WARM); } } } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidCpuCollector.java b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidCpuCollector.java index 9f23154aa4..8f54305e6f 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidCpuCollector.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidCpuCollector.java @@ -115,7 +115,7 @@ private long readTotalCpuNanos() { // Amount of clock ticks this process' waited-for children has been scheduled in kernel mode long csTime = Long.parseLong(stats[16]); return (long) ((uTime + sTime + cuTime + csTime) * nanosecondsPerClockTick); - } catch (NumberFormatException e) { + } catch (NumberFormatException | ArrayIndexOutOfBoundsException e) { logger.log(SentryLevel.ERROR, "Error parsing /proc/self/stat file.", e); return 0; } 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..8562f6ad2d 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 @@ -8,8 +8,10 @@ import io.sentry.DeduplicateMultithreadedEventProcessor; import io.sentry.DefaultTransactionPerformanceCollector; import io.sentry.ILogger; +import io.sentry.ISentryLifecycleToken; import io.sentry.ITransactionProfiler; import io.sentry.NoOpConnectionStatusProvider; +import io.sentry.ScopeType; import io.sentry.SendFireAndForgetEnvelopeSender; import io.sentry.SendFireAndForgetOutboxSender; import io.sentry.SentryLevel; @@ -18,10 +20,12 @@ import io.sentry.android.core.internal.gestures.AndroidViewGestureTargetLocator; import io.sentry.android.core.internal.modules.AssetsModulesLoader; import io.sentry.android.core.internal.util.AndroidConnectionStatusProvider; -import io.sentry.android.core.internal.util.AndroidMainThreadChecker; +import io.sentry.android.core.internal.util.AndroidThreadChecker; 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 +33,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; @@ -87,10 +92,7 @@ static void loadDefaultAndMetadataOptions( final @NotNull BuildInfoProvider buildInfoProvider) { Objects.requireNonNull(context, "The context is required."); - // it returns null if ContextImpl, so let's check for nullability - if (context.getApplicationContext() != null) { - context = context.getApplicationContext(); - } + context = ContextUtils.getApplicationContext(context); Objects.requireNonNull(options, "The options object is required."); Objects.requireNonNull(logger, "The ILogger object is required."); @@ -98,6 +100,8 @@ static void loadDefaultAndMetadataOptions( // Firstly set the logger, if `debug=true` configured, logging can start asap. options.setLogger(logger); + options.setDefaultScopeType(ScopeType.CURRENT); + options.setDateProvider(new SentryAndroidDateProvider()); // set a lower flush timeout on Android to avoid ANRs @@ -116,7 +120,7 @@ static void loadDefaultAndMetadataOptions( static void initializeIntegrationsAndProcessors( final @NotNull SentryAndroidOptions options, final @NotNull Context context, - final @NotNull LoadClass loadClass, + final @NotNull io.sentry.util.LoadClass loadClass, final @NotNull ActivityFramesTracker activityFramesTracker) { initializeIntegrationsAndProcessors( options, @@ -130,7 +134,7 @@ static void initializeIntegrationsAndProcessors( final @NotNull SentryAndroidOptions options, final @NotNull Context context, final @NotNull BuildInfoProvider buildInfoProvider, - final @NotNull LoadClass loadClass, + final @NotNull io.sentry.util.LoadClass loadClass, final @NotNull ActivityFramesTracker activityFramesTracker) { if (options.getCacheDirPath() != null @@ -155,7 +159,7 @@ static void initializeIntegrationsAndProcessors( // Check if the profiler was already instantiated in the app start. // We use the Android profiler, that uses a global start/stop api, so we need to preserve the // state of the profiler, and it's only possible retaining the instance. - synchronized (AppStartMetrics.getInstance()) { + try (final @NotNull ISentryLifecycleToken ignored = AppStartMetrics.staticLock.acquire()) { final @Nullable ITransactionProfiler appStartProfiler = AppStartMetrics.getInstance().getAppStartProfiler(); if (appStartProfiler != null) { @@ -205,7 +209,7 @@ static void initializeIntegrationsAndProcessors( options.setViewHierarchyExporters(viewHierarchyExporters); } - options.setMainThreadChecker(AndroidMainThreadChecker.getInstance()); + options.setThreadChecker(AndroidThreadChecker.getInstance()); if (options.getPerformanceCollectors().isEmpty()) { options.addPerformanceCollector(new AndroidMemoryCollector()); options.addPerformanceCollector( @@ -234,10 +238,11 @@ static void installDefaultIntegrations( final @NotNull Context context, final @NotNull SentryAndroidOptions options, final @NotNull BuildInfoProvider buildInfoProvider, - final @NotNull LoadClass loadClass, + final @NotNull io.sentry.util.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 +307,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/AndroidProfiler.java b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidProfiler.java index d24025c551..379123c2c8 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidProfiler.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidProfiler.java @@ -9,12 +9,14 @@ import io.sentry.DateUtils; import io.sentry.ILogger; import io.sentry.ISentryExecutorService; +import io.sentry.ISentryLifecycleToken; import io.sentry.MemoryCollectionData; import io.sentry.PerformanceCollectionData; import io.sentry.SentryLevel; import io.sentry.android.core.internal.util.SentryFrameMetricsCollector; import io.sentry.profilemeasurements.ProfileMeasurement; import io.sentry.profilemeasurements.ProfileMeasurementValue; +import io.sentry.util.AutoClosableReentrantLock; import io.sentry.util.Objects; import java.io.File; import java.util.ArrayDeque; @@ -96,6 +98,7 @@ public ProfileEndData( private final @NotNull ISentryExecutorService executorService; private final @NotNull ILogger logger; private boolean isRunning = false; + protected final @NotNull AutoClosableReentrantLock lock = new AutoClosableReentrantLock(); public AndroidProfiler( final @NotNull String tracesFilesDirPath, @@ -116,176 +119,184 @@ public AndroidProfiler( } @SuppressLint("NewApi") - public synchronized @Nullable ProfileStartData start() { - // intervalUs is 0 only if there was a problem in the init - if (intervalUs == 0) { - logger.log( - SentryLevel.WARNING, "Disabling profiling because intervaUs is set to %d", intervalUs); - return null; - } + public @Nullable ProfileStartData start() { + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + // intervalUs is 0 only if there was a problem in the init + if (intervalUs == 0) { + logger.log( + SentryLevel.WARNING, "Disabling profiling because intervaUs is set to %d", intervalUs); + return null; + } - if (isRunning) { - logger.log(SentryLevel.WARNING, "Profiling has already started..."); - return null; - } + if (isRunning) { + logger.log(SentryLevel.WARNING, "Profiling has already started..."); + return null; + } - // and SystemClock.elapsedRealtimeNanos() since Jelly Bean - if (buildInfoProvider.getSdkInfoVersion() < Build.VERSION_CODES.LOLLIPOP) return null; - - // We create a file with a uuid name, so no need to check if it already exists - traceFile = new File(traceFilesDir, UUID.randomUUID() + ".trace"); - - measurementsMap.clear(); - screenFrameRateMeasurements.clear(); - slowFrameRenderMeasurements.clear(); - frozenFrameRenderMeasurements.clear(); - - frameMetricsCollectorId = - frameMetricsCollector.startCollection( - new SentryFrameMetricsCollector.FrameMetricsCollectorListener() { - float lastRefreshRate = 0; - - @Override - public void onFrameMetricCollected( - final long frameStartNanos, - final long frameEndNanos, - final long durationNanos, - final long delayNanos, - final boolean isSlow, - final boolean isFrozen, - final float refreshRate) { - // profileStartNanos is calculated through SystemClock.elapsedRealtimeNanos(), - // but frameEndNanos uses System.nanotime(), so we convert it to get the timestamp - // relative to profileStartNanos - final long frameTimestampRelativeNanos = - frameEndNanos - - System.nanoTime() - + SystemClock.elapsedRealtimeNanos() - - profileStartNanos; - - // We don't allow negative relative timestamps. - // So we add a check, even if this should never happen. - if (frameTimestampRelativeNanos < 0) { - return; - } - if (isFrozen) { - frozenFrameRenderMeasurements.addLast( - new ProfileMeasurementValue(frameTimestampRelativeNanos, durationNanos)); - } else if (isSlow) { - slowFrameRenderMeasurements.addLast( - new ProfileMeasurementValue(frameTimestampRelativeNanos, durationNanos)); + // and SystemClock.elapsedRealtimeNanos() since Jelly Bean + if (buildInfoProvider.getSdkInfoVersion() < Build.VERSION_CODES.LOLLIPOP) return null; + + // We create a file with a uuid name, so no need to check if it already exists + traceFile = new File(traceFilesDir, UUID.randomUUID() + ".trace"); + + measurementsMap.clear(); + screenFrameRateMeasurements.clear(); + slowFrameRenderMeasurements.clear(); + frozenFrameRenderMeasurements.clear(); + + frameMetricsCollectorId = + frameMetricsCollector.startCollection( + new SentryFrameMetricsCollector.FrameMetricsCollectorListener() { + float lastRefreshRate = 0; + + @Override + public void onFrameMetricCollected( + final long frameStartNanos, + final long frameEndNanos, + final long durationNanos, + final long delayNanos, + final boolean isSlow, + final boolean isFrozen, + final float refreshRate) { + // profileStartNanos is calculated through SystemClock.elapsedRealtimeNanos(), + // but frameEndNanos uses System.nanotime(), so we convert it to get the timestamp + // relative to profileStartNanos + final long frameTimestampRelativeNanos = + frameEndNanos + - System.nanoTime() + + SystemClock.elapsedRealtimeNanos() + - profileStartNanos; + + // We don't allow negative relative timestamps. + // So we add a check, even if this should never happen. + if (frameTimestampRelativeNanos < 0) { + return; + } + if (isFrozen) { + frozenFrameRenderMeasurements.addLast( + new ProfileMeasurementValue(frameTimestampRelativeNanos, durationNanos)); + } else if (isSlow) { + slowFrameRenderMeasurements.addLast( + new ProfileMeasurementValue(frameTimestampRelativeNanos, durationNanos)); + } + if (refreshRate != lastRefreshRate) { + lastRefreshRate = refreshRate; + screenFrameRateMeasurements.addLast( + new ProfileMeasurementValue(frameTimestampRelativeNanos, refreshRate)); + } } - if (refreshRate != lastRefreshRate) { - lastRefreshRate = refreshRate; - screenFrameRateMeasurements.addLast( - new ProfileMeasurementValue(frameTimestampRelativeNanos, refreshRate)); - } - } - }); - - // We stop profiling after a timeout to avoid huge profiles to be sent - try { - scheduledFinish = - executorService.schedule(() -> endAndCollect(true, null), PROFILING_TIMEOUT_MILLIS); - } catch (RejectedExecutionException e) { - logger.log( - SentryLevel.ERROR, - "Failed to call the executor. Profiling will not be automatically finished. Did you call Sentry.close()?", - e); - } + }); + + // We stop profiling after a timeout to avoid huge profiles to be sent + try { + scheduledFinish = + executorService.schedule(() -> endAndCollect(true, null), PROFILING_TIMEOUT_MILLIS); + } catch (RejectedExecutionException e) { + logger.log( + SentryLevel.ERROR, + "Failed to call the executor. Profiling will not be automatically finished. Did you call Sentry.close()?", + e); + } - profileStartNanos = SystemClock.elapsedRealtimeNanos(); - final @NotNull Date profileStartTimestamp = DateUtils.getCurrentDateTime(); - long profileStartCpuMillis = Process.getElapsedCpuTime(); - - // We don't make any check on the file existence or writeable state, because we don't want to - // make file IO in the main thread. - // We cannot offload the work to the executorService, as if that's very busy, profiles could - // start/stop with a lot of delay and even cause ANRs. - try { - // If there is any problem with the file this method will throw (but it will not throw in - // tests) - Debug.startMethodTracingSampling(traceFile.getPath(), BUFFER_SIZE_BYTES, intervalUs); - isRunning = true; - return new ProfileStartData(profileStartNanos, profileStartCpuMillis, profileStartTimestamp); - } catch (Throwable e) { - endAndCollect(false, null); - logger.log(SentryLevel.ERROR, "Unable to start a profile: ", e); - isRunning = false; - return null; + profileStartNanos = SystemClock.elapsedRealtimeNanos(); + final @NotNull Date profileStartTimestamp = DateUtils.getCurrentDateTime(); + long profileStartCpuMillis = Process.getElapsedCpuTime(); + + // We don't make any check on the file existence or writeable state, because we don't want to + // make file IO in the main thread. + // We cannot offload the work to the executorService, as if that's very busy, profiles could + // start/stop with a lot of delay and even cause ANRs. + try { + // If there is any problem with the file this method will throw (but it will not throw in + // tests) + Debug.startMethodTracingSampling(traceFile.getPath(), BUFFER_SIZE_BYTES, intervalUs); + isRunning = true; + return new ProfileStartData( + profileStartNanos, profileStartCpuMillis, profileStartTimestamp); + } catch (Throwable e) { + endAndCollect(false, null); + logger.log(SentryLevel.ERROR, "Unable to start a profile: ", e); + isRunning = false; + return null; + } } } @SuppressLint("NewApi") - public synchronized @Nullable ProfileEndData endAndCollect( + public @Nullable ProfileEndData endAndCollect( final boolean isTimeout, final @Nullable List performanceCollectionData) { + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + if (!isRunning) { + logger.log(SentryLevel.WARNING, "Profiler not running"); + return null; + } - if (!isRunning) { - logger.log(SentryLevel.WARNING, "Profiler not running"); - return null; - } + // and SystemClock.elapsedRealtimeNanos() since Jelly Bean + if (buildInfoProvider.getSdkInfoVersion() < Build.VERSION_CODES.LOLLIPOP) return null; + + try { + // If there is any problem with the file this method could throw, but the start is also + // wrapped, so this should never happen (except for tests, where this is the only method + // that + // throws) + Debug.stopMethodTracing(); + } catch (Throwable e) { + logger.log(SentryLevel.ERROR, "Error while stopping profiling: ", e); + } finally { + isRunning = false; + } + frameMetricsCollector.stopCollection(frameMetricsCollectorId); - // and SystemClock.elapsedRealtimeNanos() since Jelly Bean - if (buildInfoProvider.getSdkInfoVersion() < Build.VERSION_CODES.LOLLIPOP) return null; - - try { - // If there is any problem with the file this method could throw, but the start is also - // wrapped, so this should never happen (except for tests, where this is the only method that - // throws) - Debug.stopMethodTracing(); - } catch (Throwable e) { - logger.log(SentryLevel.ERROR, "Error while stopping profiling: ", e); - } finally { - isRunning = false; - } - frameMetricsCollector.stopCollection(frameMetricsCollectorId); + long transactionEndNanos = SystemClock.elapsedRealtimeNanos(); + long transactionEndCpuMillis = Process.getElapsedCpuTime(); - long transactionEndNanos = SystemClock.elapsedRealtimeNanos(); - long transactionEndCpuMillis = Process.getElapsedCpuTime(); + if (traceFile == null) { + logger.log(SentryLevel.ERROR, "Trace file does not exists"); + return null; + } - if (traceFile == null) { - logger.log(SentryLevel.ERROR, "Trace file does not exists"); - return null; - } + if (!slowFrameRenderMeasurements.isEmpty()) { + measurementsMap.put( + ProfileMeasurement.ID_SLOW_FRAME_RENDERS, + new ProfileMeasurement( + ProfileMeasurement.UNIT_NANOSECONDS, slowFrameRenderMeasurements)); + } + if (!frozenFrameRenderMeasurements.isEmpty()) { + measurementsMap.put( + ProfileMeasurement.ID_FROZEN_FRAME_RENDERS, + new ProfileMeasurement( + ProfileMeasurement.UNIT_NANOSECONDS, frozenFrameRenderMeasurements)); + } + if (!screenFrameRateMeasurements.isEmpty()) { + measurementsMap.put( + ProfileMeasurement.ID_SCREEN_FRAME_RATES, + new ProfileMeasurement(ProfileMeasurement.UNIT_HZ, screenFrameRateMeasurements)); + } + putPerformanceCollectionDataInMeasurements(performanceCollectionData); - if (!slowFrameRenderMeasurements.isEmpty()) { - measurementsMap.put( - ProfileMeasurement.ID_SLOW_FRAME_RENDERS, - new ProfileMeasurement(ProfileMeasurement.UNIT_NANOSECONDS, slowFrameRenderMeasurements)); - } - if (!frozenFrameRenderMeasurements.isEmpty()) { - measurementsMap.put( - ProfileMeasurement.ID_FROZEN_FRAME_RENDERS, - new ProfileMeasurement( - ProfileMeasurement.UNIT_NANOSECONDS, frozenFrameRenderMeasurements)); - } - if (!screenFrameRateMeasurements.isEmpty()) { - measurementsMap.put( - ProfileMeasurement.ID_SCREEN_FRAME_RATES, - new ProfileMeasurement(ProfileMeasurement.UNIT_HZ, screenFrameRateMeasurements)); - } - putPerformanceCollectionDataInMeasurements(performanceCollectionData); + if (scheduledFinish != null) { + scheduledFinish.cancel(true); + scheduledFinish = null; + } - if (scheduledFinish != null) { - scheduledFinish.cancel(true); - scheduledFinish = null; + return new ProfileEndData( + transactionEndNanos, transactionEndCpuMillis, isTimeout, traceFile, measurementsMap); } - - return new ProfileEndData( - transactionEndNanos, transactionEndCpuMillis, isTimeout, traceFile, measurementsMap); } - public synchronized void close() { - // we cancel any scheduled work - if (scheduledFinish != null) { - scheduledFinish.cancel(true); - scheduledFinish = null; - } + public void close() { + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + // we cancel any scheduled work + if (scheduledFinish != null) { + scheduledFinish.cancel(true); + scheduledFinish = null; + } - // stop profiling if running - if (isRunning) { - endAndCollect(true, null); + // stop profiling if running + if (isRunning) { + endAndCollect(true, null); + } } } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidTransactionProfiler.java b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidTransactionProfiler.java index d9ece7fb46..a2d61e9921 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidTransactionProfiler.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidTransactionProfiler.java @@ -10,19 +10,20 @@ import android.os.Process; import android.os.SystemClock; import io.sentry.DateUtils; -import io.sentry.HubAdapter; -import io.sentry.IHub; import io.sentry.ILogger; import io.sentry.ISentryExecutorService; +import io.sentry.ISentryLifecycleToken; import io.sentry.ITransaction; import io.sentry.ITransactionProfiler; import io.sentry.PerformanceCollectionData; import io.sentry.ProfilingTraceData; import io.sentry.ProfilingTransactionData; +import io.sentry.ScopesAdapter; import io.sentry.SentryLevel; import io.sentry.SentryOptions; import io.sentry.android.core.internal.util.CpuInfoUtils; import io.sentry.android.core.internal.util.SentryFrameMetricsCollector; +import io.sentry.util.AutoClosableReentrantLock; import io.sentry.util.Objects; import java.util.ArrayList; import java.util.Date; @@ -47,20 +48,7 @@ final class AndroidTransactionProfiler implements ITransactionProfiler { private long profileStartNanos; private long profileStartCpuMillis; private @NotNull Date profileStartTimestamp; - - /** - * @deprecated please use a constructor that doesn't takes a {@link IHub} instead, as it would be - * ignored anyway. - */ - @Deprecated - public AndroidTransactionProfiler( - final @NotNull Context context, - final @NotNull SentryAndroidOptions sentryAndroidOptions, - final @NotNull BuildInfoProvider buildInfoProvider, - final @NotNull SentryFrameMetricsCollector frameMetricsCollector, - final @NotNull IHub hub) { - this(context, sentryAndroidOptions, buildInfoProvider, frameMetricsCollector); - } + private final @NotNull AutoClosableReentrantLock lock = new AutoClosableReentrantLock(); public AndroidTransactionProfiler( final @NotNull Context context, @@ -87,7 +75,9 @@ public AndroidTransactionProfiler( final boolean isProfilingEnabled, final int profilingTracesHz, final @NotNull ISentryExecutorService executorService) { - this.context = Objects.requireNonNull(context, "The application context is required"); + this.context = + Objects.requireNonNull( + ContextUtils.getApplicationContext(context), "The application context is required"); this.logger = Objects.requireNonNull(logger, "ILogger is required"); this.frameMetricsCollector = Objects.requireNonNull(frameMetricsCollector, "SentryFrameMetricsCollector is required"); @@ -136,22 +126,24 @@ private void init() { } @Override - public synchronized void start() { - // Debug.startMethodTracingSampling() is only available since Lollipop, but Android Profiler - // causes crashes on api 21 -> https://github.com/getsentry/sentry-java/issues/3392 - if (buildInfoProvider.getSdkInfoVersion() < Build.VERSION_CODES.LOLLIPOP_MR1) return; - - // Let's initialize trace folder and profiling interval - init(); - - transactionsCounter++; - // When the first transaction is starting, we can start profiling - if (transactionsCounter == 1 && onFirstStart()) { - logger.log(SentryLevel.DEBUG, "Profiler started."); - } else { - transactionsCounter--; - logger.log( - SentryLevel.WARNING, "A profile is already running. This profile will be ignored."); + public void start() { + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + // Debug.startMethodTracingSampling() is only available since Lollipop, but Android Profiler + // causes crashes on api 21 -> https://github.com/getsentry/sentry-java/issues/3392 + if (buildInfoProvider.getSdkInfoVersion() < Build.VERSION_CODES.LOLLIPOP_MR1) return; + + // Let's initialize trace folder and profiling interval + init(); + + transactionsCounter++; + // When the first transaction is starting, we can start profiling + if (transactionsCounter == 1 && onFirstStart()) { + logger.log(SentryLevel.DEBUG, "Profiler started."); + } else { + transactionsCounter--; + logger.log( + SentryLevel.WARNING, "A profile is already running. This profile will be ignored."); + } } } @@ -174,133 +166,138 @@ private boolean onFirstStart() { } @Override - public synchronized void bindTransaction(final @NotNull ITransaction transaction) { - // If the profiler is running, but no profilingTransactionData is set, we bind it here - if (transactionsCounter > 0 && currentProfilingTransactionData == null) { - currentProfilingTransactionData = - new ProfilingTransactionData(transaction, profileStartNanos, profileStartCpuMillis); + public void bindTransaction(final @NotNull ITransaction transaction) { + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + // If the profiler is running, but no profilingTransactionData is set, we bind it here + if (transactionsCounter > 0 && currentProfilingTransactionData == null) { + currentProfilingTransactionData = + new ProfilingTransactionData(transaction, profileStartNanos, profileStartCpuMillis); + } } } @Override - public @Nullable synchronized ProfilingTraceData onTransactionFinish( + public @Nullable ProfilingTraceData onTransactionFinish( final @NotNull ITransaction transaction, final @Nullable List performanceCollectionData, final @NotNull SentryOptions options) { - - return onTransactionFinish( - transaction.getName(), - transaction.getEventId().toString(), - transaction.getSpanContext().getTraceId().toString(), - false, - performanceCollectionData, - options); + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + return onTransactionFinish( + transaction.getName(), + transaction.getEventId().toString(), + transaction.getSpanContext().getTraceId().toString(), + false, + performanceCollectionData, + options); + } } @SuppressLint("NewApi") - private @Nullable synchronized ProfilingTraceData onTransactionFinish( + private @Nullable ProfilingTraceData onTransactionFinish( final @NotNull String transactionName, final @NotNull String transactionId, final @NotNull String traceId, final boolean isTimeout, final @Nullable List performanceCollectionData, final @NotNull SentryOptions options) { - // check if profiler was created - if (profiler == null) { - return null; - } - - // onTransactionStart() is only available since Lollipop_MR1 - // and SystemClock.elapsedRealtimeNanos() since Jelly Bean - if (buildInfoProvider.getSdkInfoVersion() < Build.VERSION_CODES.LOLLIPOP_MR1) return null; - - // Transaction finished, but it's not in the current profile - if (currentProfilingTransactionData == null - || !currentProfilingTransactionData.getId().equals(transactionId)) { - // A transaction is finishing, but it's not profiled. We can skip it - logger.log( - SentryLevel.INFO, - "Transaction %s (%s) finished, but was not currently being profiled. Skipping", - transactionName, - traceId); - return null; - } + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + // check if profiler was created + if (profiler == null) { + return null; + } - if (transactionsCounter > 0) { - transactionsCounter--; - } + // onTransactionStart() is only available since Lollipop_MR1 + // and SystemClock.elapsedRealtimeNanos() since Jelly Bean + if (buildInfoProvider.getSdkInfoVersion() < Build.VERSION_CODES.LOLLIPOP_MR1) return null; + + // Transaction finished, but it's not in the current profile + if (currentProfilingTransactionData == null + || !currentProfilingTransactionData.getId().equals(transactionId)) { + // A transaction is finishing, but it's not profiled. We can skip it + logger.log( + SentryLevel.INFO, + "Transaction %s (%s) finished, but was not currently being profiled. Skipping", + transactionName, + traceId); + return null; + } - logger.log(SentryLevel.DEBUG, "Transaction %s (%s) finished.", transactionName, traceId); + if (transactionsCounter > 0) { + transactionsCounter--; + } - if (transactionsCounter != 0) { - // We notify the data referring to this transaction that it finished - if (currentProfilingTransactionData != null) { - currentProfilingTransactionData.notifyFinish( - SystemClock.elapsedRealtimeNanos(), - profileStartNanos, - Process.getElapsedCpuTime(), - profileStartCpuMillis); + logger.log(SentryLevel.DEBUG, "Transaction %s (%s) finished.", transactionName, traceId); + + if (transactionsCounter != 0) { + // We notify the data referring to this transaction that it finished + if (currentProfilingTransactionData != null) { + currentProfilingTransactionData.notifyFinish( + SystemClock.elapsedRealtimeNanos(), + profileStartNanos, + Process.getElapsedCpuTime(), + profileStartCpuMillis); + } + return null; } - return null; - } - final AndroidProfiler.ProfileEndData endData = - profiler.endAndCollect(false, performanceCollectionData); - // check if profiler end successfully - if (endData == null) { - return null; - } + final AndroidProfiler.ProfileEndData endData = + profiler.endAndCollect(false, performanceCollectionData); + // check if profiler end successfully + if (endData == null) { + return null; + } - long transactionDurationNanos = endData.endNanos - profileStartNanos; + long transactionDurationNanos = endData.endNanos - profileStartNanos; - List transactionList = new ArrayList<>(1); - final ProfilingTransactionData txData = currentProfilingTransactionData; - if (txData != null) { - transactionList.add(txData); - } - currentProfilingTransactionData = null; - // We clear the counter in case of a timeout - transactionsCounter = 0; + List transactionList = new ArrayList<>(1); + final ProfilingTransactionData txData = currentProfilingTransactionData; + if (txData != null) { + transactionList.add(txData); + } + currentProfilingTransactionData = null; + // We clear the counter in case of a timeout + transactionsCounter = 0; + + String totalMem = "0"; + ActivityManager.MemoryInfo memInfo = getMemInfo(); + if (memInfo != null) { + totalMem = Long.toString(memInfo.totalMem); + } + String[] abis = Build.SUPPORTED_ABIS; - String totalMem = "0"; - ActivityManager.MemoryInfo memInfo = getMemInfo(); - if (memInfo != null) { - totalMem = Long.toString(memInfo.totalMem); - } - String[] abis = Build.SUPPORTED_ABIS; + // We notify all transactions data that all transactions finished. + // Some may not have been really finished, in case of a timeout + for (ProfilingTransactionData t : transactionList) { + t.notifyFinish( + endData.endNanos, profileStartNanos, endData.endCpuMillis, profileStartCpuMillis); + } - // We notify all transactions data that all transactions finished. - // Some may not have been really finished, in case of a timeout - for (ProfilingTransactionData t : transactionList) { - t.notifyFinish( - endData.endNanos, profileStartNanos, endData.endCpuMillis, profileStartCpuMillis); + // cpu max frequencies are read with a lambda because reading files is involved, so it will be + // done in the background when the trace file is read + return new ProfilingTraceData( + endData.traceFile, + profileStartTimestamp, + transactionList, + transactionName, + transactionId, + traceId, + Long.toString(transactionDurationNanos), + buildInfoProvider.getSdkInfoVersion(), + abis != null && abis.length > 0 ? abis[0] : "", + () -> CpuInfoUtils.getInstance().readMaxFrequencies(), + buildInfoProvider.getManufacturer(), + buildInfoProvider.getModel(), + buildInfoProvider.getVersionRelease(), + buildInfoProvider.isEmulator(), + totalMem, + options.getProguardUuid(), + options.getRelease(), + options.getEnvironment(), + (endData.didTimeout || isTimeout) + ? ProfilingTraceData.TRUNCATION_REASON_TIMEOUT + : ProfilingTraceData.TRUNCATION_REASON_NORMAL, + endData.measurementsMap); } - - // cpu max frequencies are read with a lambda because reading files is involved, so it will be - // done in the background when the trace file is read - return new ProfilingTraceData( - endData.traceFile, - profileStartTimestamp, - transactionList, - transactionName, - transactionId, - traceId, - Long.toString(transactionDurationNanos), - buildInfoProvider.getSdkInfoVersion(), - abis != null && abis.length > 0 ? abis[0] : "", - () -> CpuInfoUtils.getInstance().readMaxFrequencies(), - buildInfoProvider.getManufacturer(), - buildInfoProvider.getModel(), - buildInfoProvider.getVersionRelease(), - buildInfoProvider.isEmulator(), - totalMem, - options.getProguardUuid(), - options.getRelease(), - options.getEnvironment(), - (endData.didTimeout || isTimeout) - ? ProfilingTraceData.TRUNCATION_REASON_TIMEOUT - : ProfilingTraceData.TRUNCATION_REASON_NORMAL, - endData.measurementsMap); } @Override @@ -318,7 +315,7 @@ public void close() { currentProfilingTransactionData.getTraceId(), true, null, - HubAdapter.getInstance().getOptions()); + ScopesAdapter.getInstance().getOptions()); } else if (transactionsCounter != 0) { // in case the app start profiling is running, and it's not bound to a transaction, we still // stop profiling, but we also have to manually update the counter. diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AnrIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/AnrIntegration.java index 0ad2c242da..df8de1a7de 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/AnrIntegration.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AnrIntegration.java @@ -5,7 +5,8 @@ import android.annotation.SuppressLint; import android.content.Context; import io.sentry.Hint; -import io.sentry.IHub; +import io.sentry.IScopes; +import io.sentry.ISentryLifecycleToken; import io.sentry.Integration; import io.sentry.SentryEvent; import io.sentry.SentryLevel; @@ -14,6 +15,7 @@ import io.sentry.hints.AbnormalExit; import io.sentry.hints.TransactionEnd; import io.sentry.protocol.Mechanism; +import io.sentry.util.AutoClosableReentrantLock; import io.sentry.util.HintUtils; import io.sentry.util.Objects; import java.io.Closeable; @@ -30,10 +32,10 @@ public final class AnrIntegration implements Integration, Closeable { private final @NotNull Context context; private boolean isClosed = false; - private final @NotNull Object startLock = new Object(); + private final @NotNull AutoClosableReentrantLock startLock = new AutoClosableReentrantLock(); public AnrIntegration(final @NotNull Context context) { - this.context = context; + this.context = ContextUtils.getApplicationContext(context); } /** @@ -45,15 +47,17 @@ public AnrIntegration(final @NotNull Context context) { private @Nullable SentryOptions options; - private static final @NotNull Object watchDogLock = new Object(); + protected static final @NotNull AutoClosableReentrantLock watchDogLock = + new AutoClosableReentrantLock(); @Override - public final void register(final @NotNull IHub hub, final @NotNull SentryOptions options) { + public final void register(final @NotNull IScopes scopes, final @NotNull SentryOptions options) { this.options = Objects.requireNonNull(options, "SentryOptions is required"); - register(hub, (SentryAndroidOptions) options); + register(scopes, (SentryAndroidOptions) options); } - private void register(final @NotNull IHub hub, final @NotNull SentryAndroidOptions options) { + private void register( + final @NotNull IScopes scopes, final @NotNull SentryAndroidOptions options) { options .getLogger() .log(SentryLevel.DEBUG, "AnrIntegration enabled: %s", options.isAnrEnabled()); @@ -65,9 +69,9 @@ private void register(final @NotNull IHub hub, final @NotNull SentryAndroidOptio .getExecutorService() .submit( () -> { - synchronized (startLock) { + try (final @NotNull ISentryLifecycleToken ignored = startLock.acquire()) { if (!isClosed) { - startAnrWatchdog(hub, options); + startAnrWatchdog(scopes, options); } } }); @@ -80,8 +84,8 @@ private void register(final @NotNull IHub hub, final @NotNull SentryAndroidOptio } private void startAnrWatchdog( - final @NotNull IHub hub, final @NotNull SentryAndroidOptions options) { - synchronized (watchDogLock) { + final @NotNull IScopes scopes, final @NotNull SentryAndroidOptions options) { + try (final @NotNull ISentryLifecycleToken ignored = watchDogLock.acquire()) { if (anrWatchDog == null) { options .getLogger() @@ -94,7 +98,7 @@ private void startAnrWatchdog( new ANRWatchDog( options.getAnrTimeoutIntervalMillis(), options.isAnrReportInDebug(), - error -> reportANR(hub, options, error), + error -> reportANR(scopes, options, error), options.getLogger(), context); anrWatchDog.start(); @@ -106,7 +110,7 @@ private void startAnrWatchdog( @TestOnly void reportANR( - final @NotNull IHub hub, + final @NotNull IScopes scopes, final @NotNull SentryAndroidOptions options, final @NotNull ApplicationNotResponding error) { options.getLogger().log(SentryLevel.INFO, "ANR triggered with message: %s", error.getMessage()); @@ -122,7 +126,7 @@ void reportANR( final AnrHint anrHint = new AnrHint(isAppInBackground); final Hint hint = HintUtils.createWithTypeCheckHint(anrHint); - hub.captureEvent(event, hint); + scopes.captureEvent(event, hint); } private @NotNull Throwable buildAnrThrowable( @@ -150,10 +154,10 @@ ANRWatchDog getANRWatchDog() { @Override public void close() throws IOException { - synchronized (startLock) { + try (final @NotNull ISentryLifecycleToken ignored = startLock.acquire()) { isClosed = true; } - synchronized (watchDogLock) { + try (final @NotNull ISentryLifecycleToken ignored = watchDogLock.acquire()) { if (anrWatchDog != null) { anrWatchDog.interrupt(); anrWatchDog = null; diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2EventProcessor.java b/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2EventProcessor.java index 45f997542b..431e9dc4c0 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2EventProcessor.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2EventProcessor.java @@ -4,16 +4,19 @@ import static io.sentry.cache.PersistingOptionsObserver.ENVIRONMENT_FILENAME; import static io.sentry.cache.PersistingOptionsObserver.PROGUARD_UUID_FILENAME; import static io.sentry.cache.PersistingOptionsObserver.RELEASE_FILENAME; +import static io.sentry.cache.PersistingOptionsObserver.REPLAY_ERROR_SAMPLE_RATE_FILENAME; import static io.sentry.cache.PersistingOptionsObserver.SDK_VERSION_FILENAME; import static io.sentry.cache.PersistingScopeObserver.BREADCRUMBS_FILENAME; import static io.sentry.cache.PersistingScopeObserver.CONTEXTS_FILENAME; import static io.sentry.cache.PersistingScopeObserver.EXTRAS_FILENAME; import static io.sentry.cache.PersistingScopeObserver.FINGERPRINT_FILENAME; import static io.sentry.cache.PersistingScopeObserver.LEVEL_FILENAME; +import static io.sentry.cache.PersistingScopeObserver.REPLAY_FILENAME; import static io.sentry.cache.PersistingScopeObserver.REQUEST_FILENAME; import static io.sentry.cache.PersistingScopeObserver.TRACE_FILENAME; import static io.sentry.cache.PersistingScopeObserver.TRANSACTION_FILENAME; import static io.sentry.cache.PersistingScopeObserver.USER_FILENAME; +import static io.sentry.protocol.Contexts.REPLAY_ID; import android.annotation.SuppressLint; import android.app.ActivityManager; @@ -51,6 +54,8 @@ import io.sentry.protocol.SentryTransaction; import io.sentry.protocol.User; import io.sentry.util.HintUtils; +import java.io.File; +import java.security.SecureRandom; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; @@ -78,13 +83,24 @@ public final class AnrV2EventProcessor implements BackfillingEventProcessor { private final @NotNull SentryExceptionFactory sentryExceptionFactory; + private final @Nullable SecureRandom random; + public AnrV2EventProcessor( final @NotNull Context context, final @NotNull SentryAndroidOptions options, final @NotNull BuildInfoProvider buildInfoProvider) { - this.context = context; + this(context, options, buildInfoProvider, null); + } + + AnrV2EventProcessor( + final @NotNull Context context, + final @NotNull SentryAndroidOptions options, + final @NotNull BuildInfoProvider buildInfoProvider, + final @Nullable SecureRandom random) { + this.context = ContextUtils.getApplicationContext(context); this.options = options; this.buildInfoProvider = buildInfoProvider; + this.random = random; final SentryStackTraceFactory sentryStackTraceFactory = new SentryStackTraceFactory(this.options); @@ -151,6 +167,72 @@ private void backfillScope(final @NotNull SentryEvent event, final @NotNull Obje setFingerprints(event, hint); setLevel(event); setTrace(event); + setReplayId(event); + } + + private boolean sampleReplay(final @NotNull SentryEvent event) { + final @Nullable String replayErrorSampleRate = + PersistingOptionsObserver.read(options, REPLAY_ERROR_SAMPLE_RATE_FILENAME, String.class); + + if (replayErrorSampleRate == null) { + return false; + } + + try { + // we have to sample here with the old sample rate, because it may change between app launches + final @NotNull SecureRandom random = this.random != null ? this.random : new SecureRandom(); + final double replayErrorSampleRateDouble = Double.parseDouble(replayErrorSampleRate); + if (replayErrorSampleRateDouble < random.nextDouble()) { + options + .getLogger() + .log( + SentryLevel.DEBUG, + "Not capturing replay for ANR %s due to not being sampled.", + event.getEventId()); + return false; + } + } catch (Throwable e) { + options.getLogger().log(SentryLevel.ERROR, "Error parsing replay sample rate.", e); + return false; + } + + return true; + } + + private void setReplayId(final @NotNull SentryEvent event) { + @Nullable + String persistedReplayId = PersistingScopeObserver.read(options, REPLAY_FILENAME, String.class); + final @NotNull File replayFolder = + new File(options.getCacheDirPath(), "replay_" + persistedReplayId); + if (!replayFolder.exists()) { + if (!sampleReplay(event)) { + return; + } + // if the replay folder does not exist (e.g. running in buffer mode), we need to find the + // latest replay folder that was modified before the ANR event. + persistedReplayId = null; + long lastModified = Long.MIN_VALUE; + final File[] dirs = new File(options.getCacheDirPath()).listFiles(); + if (dirs != null) { + for (File dir : dirs) { + if (dir.isDirectory() && dir.getName().startsWith("replay_")) { + if (dir.lastModified() > lastModified + && dir.lastModified() <= event.getTimestamp().getTime()) { + lastModified = dir.lastModified(); + persistedReplayId = dir.getName().substring("replay_".length()); + } + } + } + } + } + + if (persistedReplayId == null) { + return; + } + + // store the relevant replayId so ReplayIntegration can pick it up and finalize that replay + PersistingScopeObserver.store(options, persistedReplayId, REPLAY_FILENAME); + event.getContexts().put(REPLAY_ID, persistedReplayId); } private void setTrace(final @NotNull SentryEvent event) { @@ -429,6 +511,11 @@ private void setOptionsTags(final @NotNull SentryBaseEvent event) { } // endregion + @Override + public @Nullable Long getOrder() { + return 12000L; + } + // region static values private void setStaticValues(final @NotNull SentryEvent event) { mergeUser(event); diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2Integration.java b/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2Integration.java index 669233bb09..d52c5328ca 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2Integration.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2Integration.java @@ -9,8 +9,8 @@ import io.sentry.Attachment; import io.sentry.DateUtils; import io.sentry.Hint; -import io.sentry.IHub; import io.sentry.ILogger; +import io.sentry.IScopes; import io.sentry.Integration; import io.sentry.SentryEvent; import io.sentry.SentryLevel; @@ -63,13 +63,13 @@ public AnrV2Integration(final @NotNull Context context) { AnrV2Integration( final @NotNull Context context, final @NotNull ICurrentDateProvider dateProvider) { - this.context = context; + this.context = ContextUtils.getApplicationContext(context); this.dateProvider = dateProvider; } @SuppressLint("NewApi") // we do the check in the AnrIntegrationFactory @Override - public void register(@NotNull IHub hub, @NotNull SentryOptions options) { + public void register(@NotNull IScopes scopes, @NotNull SentryOptions options) { this.options = Objects.requireNonNull( (options instanceof SentryAndroidOptions) ? (SentryAndroidOptions) options : null, @@ -90,7 +90,7 @@ public void register(@NotNull IHub hub, @NotNull SentryOptions options) { try { options .getExecutorService() - .submit(new AnrProcessor(context, hub, this.options, dateProvider)); + .submit(new AnrProcessor(context, scopes, this.options, dateProvider)); } catch (Throwable e) { options.getLogger().log(SentryLevel.DEBUG, "Failed to start AnrProcessor.", e); } @@ -109,17 +109,17 @@ public void close() throws IOException { static class AnrProcessor implements Runnable { private final @NotNull Context context; - private final @NotNull IHub hub; + private final @NotNull IScopes scopes; private final @NotNull SentryAndroidOptions options; private final long threshold; AnrProcessor( final @NotNull Context context, - final @NotNull IHub hub, + final @NotNull IScopes scopes, final @NotNull SentryAndroidOptions options, final @NotNull ICurrentDateProvider dateProvider) { this.context = context; - this.hub = hub; + this.scopes = scopes; this.options = options; this.threshold = dateProvider.getCurrentTimeMillis() - NINETY_DAYS_THRESHOLD; } @@ -277,7 +277,7 @@ private void reportAsSentryEvent( } } - final @NotNull SentryId sentryId = hub.captureEvent(event, hint); + final @NotNull SentryId sentryId = scopes.captureEvent(event, hint); final boolean isEventDropped = sentryId.equals(SentryId.EMPTY_ID); if (!isEventDropped) { // Block until the event is flushed to disk and the last_reported_anr marker is updated diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AppComponentsBreadcrumbsIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/AppComponentsBreadcrumbsIntegration.java index eef4707683..063334c021 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/AppComponentsBreadcrumbsIntegration.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AppComponentsBreadcrumbsIntegration.java @@ -8,7 +8,7 @@ import android.content.res.Configuration; import io.sentry.Breadcrumb; import io.sentry.Hint; -import io.sentry.IHub; +import io.sentry.IScopes; import io.sentry.Integration; import io.sentry.SentryLevel; import io.sentry.SentryOptions; @@ -25,16 +25,17 @@ public final class AppComponentsBreadcrumbsIntegration implements Integration, Closeable, ComponentCallbacks2 { private final @NotNull Context context; - private @Nullable IHub hub; + private @Nullable IScopes scopes; private @Nullable SentryAndroidOptions options; public AppComponentsBreadcrumbsIntegration(final @NotNull Context context) { - this.context = Objects.requireNonNull(context, "Context is required"); + this.context = + Objects.requireNonNull(ContextUtils.getApplicationContext(context), "Context is required"); } @Override - public void register(final @NotNull IHub hub, final @NotNull SentryOptions options) { - this.hub = Objects.requireNonNull(hub, "Hub is required"); + public void register(final @NotNull IScopes scopes, final @NotNull SentryOptions options) { + this.scopes = Objects.requireNonNull(scopes, "Scopes are required"); this.options = Objects.requireNonNull( (options instanceof SentryAndroidOptions) ? (SentryAndroidOptions) options : null, @@ -84,43 +85,25 @@ public void close() throws IOException { @SuppressWarnings("deprecation") @Override public void onConfigurationChanged(@NotNull Configuration newConfig) { - if (hub != null) { - final Device.DeviceOrientation deviceOrientation = - DeviceOrientations.getOrientation(context.getResources().getConfiguration().orientation); - - String orientation; - if (deviceOrientation != null) { - orientation = deviceOrientation.name().toLowerCase(Locale.ROOT); - } else { - orientation = "undefined"; - } - - final Breadcrumb breadcrumb = new Breadcrumb(); - breadcrumb.setType("navigation"); - breadcrumb.setCategory("device.orientation"); - breadcrumb.setData("position", orientation); - breadcrumb.setLevel(SentryLevel.INFO); - - final Hint hint = new Hint(); - hint.set(ANDROID_CONFIGURATION, newConfig); - - hub.addBreadcrumb(breadcrumb, hint); - } + final long now = System.currentTimeMillis(); + executeInBackground(() -> captureConfigurationChangedBreadcrumb(now, newConfig)); } @Override public void onLowMemory() { - createLowMemoryBreadcrumb(null); + final long now = System.currentTimeMillis(); + executeInBackground(() -> captureLowMemoryBreadcrumb(now, null)); } @Override public void onTrimMemory(final int level) { - createLowMemoryBreadcrumb(level); + final long now = System.currentTimeMillis(); + executeInBackground(() -> captureLowMemoryBreadcrumb(now, level)); } - private void createLowMemoryBreadcrumb(final @Nullable Integer level) { - if (hub != null) { - final Breadcrumb breadcrumb = new Breadcrumb(); + private void captureLowMemoryBreadcrumb(final long timeMs, final @Nullable Integer level) { + if (scopes != null) { + final Breadcrumb breadcrumb = new Breadcrumb(timeMs); if (level != null) { // only add breadcrumb if TRIM_MEMORY_BACKGROUND, TRIM_MEMORY_MODERATE or // TRIM_MEMORY_COMPLETE. @@ -143,7 +126,45 @@ private void createLowMemoryBreadcrumb(final @Nullable Integer level) { breadcrumb.setMessage("Low memory"); breadcrumb.setData("action", "LOW_MEMORY"); breadcrumb.setLevel(SentryLevel.WARNING); - hub.addBreadcrumb(breadcrumb); + scopes.addBreadcrumb(breadcrumb); + } + } + + private void captureConfigurationChangedBreadcrumb( + final long timeMs, final @NotNull Configuration newConfig) { + if (scopes != null) { + final Device.DeviceOrientation deviceOrientation = + DeviceOrientations.getOrientation(context.getResources().getConfiguration().orientation); + + String orientation; + if (deviceOrientation != null) { + orientation = deviceOrientation.name().toLowerCase(Locale.ROOT); + } else { + orientation = "undefined"; + } + + final Breadcrumb breadcrumb = new Breadcrumb(timeMs); + breadcrumb.setType("navigation"); + breadcrumb.setCategory("device.orientation"); + breadcrumb.setData("position", orientation); + breadcrumb.setLevel(SentryLevel.INFO); + + final Hint hint = new Hint(); + hint.set(ANDROID_CONFIGURATION, newConfig); + + scopes.addBreadcrumb(breadcrumb, hint); + } + } + + private void executeInBackground(final @NotNull Runnable runnable) { + if (options != null) { + try { + options.getExecutorService().submit(runnable); + } catch (Throwable t) { + options + .getLogger() + .log(SentryLevel.ERROR, t, "Failed to submit app components breadcrumb task"); + } } } } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AppLifecycleIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/AppLifecycleIntegration.java index 3e8fe6383f..d26832af87 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/AppLifecycleIntegration.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AppLifecycleIntegration.java @@ -3,11 +3,11 @@ import static io.sentry.util.IntegrationUtils.addIntegrationToSdkVersion; import androidx.lifecycle.ProcessLifecycleOwner; -import io.sentry.IHub; +import io.sentry.IScopes; import io.sentry.Integration; import io.sentry.SentryLevel; import io.sentry.SentryOptions; -import io.sentry.android.core.internal.util.AndroidMainThreadChecker; +import io.sentry.android.core.internal.util.AndroidThreadChecker; import io.sentry.util.Objects; import java.io.Closeable; import java.io.IOException; @@ -32,8 +32,8 @@ public AppLifecycleIntegration() { } @Override - public void register(final @NotNull IHub hub, final @NotNull SentryOptions options) { - Objects.requireNonNull(hub, "Hub is required"); + public void register(final @NotNull IScopes scopes, final @NotNull SentryOptions options) { + Objects.requireNonNull(scopes, "Scopes are required"); this.options = Objects.requireNonNull( (options instanceof SentryAndroidOptions) ? (SentryAndroidOptions) options : null, @@ -58,12 +58,12 @@ public void register(final @NotNull IHub hub, final @NotNull SentryOptions optio try { Class.forName("androidx.lifecycle.DefaultLifecycleObserver"); Class.forName("androidx.lifecycle.ProcessLifecycleOwner"); - if (AndroidMainThreadChecker.getInstance().isMainThread()) { - addObserver(hub); + if (AndroidThreadChecker.getInstance().isMainThread()) { + addObserver(scopes); } else { // some versions of the androidx lifecycle-process require this to be executed on the main // thread. - handler.post(() -> addObserver(hub)); + handler.post(() -> addObserver(scopes)); } } catch (ClassNotFoundException e) { options @@ -80,7 +80,7 @@ public void register(final @NotNull IHub hub, final @NotNull SentryOptions optio } } - private void addObserver(final @NotNull IHub hub) { + private void addObserver(final @NotNull IScopes scopes) { // this should never happen, check added to avoid warnings from NullAway if (this.options == null) { return; @@ -88,7 +88,7 @@ private void addObserver(final @NotNull IHub hub) { watcher = new LifecycleWatcher( - hub, + scopes, this.options.getSessionTrackingIntervalMillis(), this.options.isEnableAutoSessionTracking(), this.options.isEnableAppLifecycleBreadcrumbs()); @@ -127,7 +127,7 @@ public void close() throws IOException { if (watcher == null) { return; } - if (AndroidMainThreadChecker.getInstance().isMainThread()) { + if (AndroidThreadChecker.getInstance().isMainThread()) { removeObserver(); } else { // some versions of the androidx lifecycle-process require this to be executed on the main diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AppState.java b/sentry-android-core/src/main/java/io/sentry/android/core/AppState.java index d47372c0c8..d9633aed54 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/AppState.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AppState.java @@ -1,5 +1,7 @@ package io.sentry.android.core; +import io.sentry.ISentryLifecycleToken; +import io.sentry.util.AutoClosableReentrantLock; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -9,6 +11,7 @@ @ApiStatus.Internal public final class AppState { private static @NotNull AppState instance = new AppState(); + private final @NotNull AutoClosableReentrantLock lock = new AutoClosableReentrantLock(); private AppState() {} @@ -27,7 +30,9 @@ void resetInstance() { return inBackground; } - synchronized void setInBackground(final boolean inBackground) { - this.inBackground = inBackground; + void setInBackground(final boolean inBackground) { + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + this.inBackground = inBackground; + } } } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ContextUtils.java b/sentry-android-core/src/main/java/io/sentry/android/core/ContextUtils.java index 2e76de4d12..89fe856631 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/ContextUtils.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ContextUtils.java @@ -383,4 +383,19 @@ static void setAppPackageInfo( } app.setPermissions(permissions); } + + /** + * Get the app context + * + * @return the app context, or if not available, the provided context + */ + @NotNull + public static Context getApplicationContext(final @NotNull Context context) { + // it returns null if ContextImpl, so let's check for nullability + final @Nullable Context appContext = context.getApplicationContext(); + if (appContext != null) { + return appContext; + } + return context; + } } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/CurrentActivityIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/CurrentActivityIntegration.java index b4c5f1ed02..0b618636d3 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/CurrentActivityIntegration.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/CurrentActivityIntegration.java @@ -4,7 +4,7 @@ import android.app.Application; import android.os.Bundle; import androidx.annotation.NonNull; -import io.sentry.IHub; +import io.sentry.IScopes; import io.sentry.Integration; import io.sentry.SentryOptions; import io.sentry.util.Objects; @@ -25,7 +25,7 @@ public CurrentActivityIntegration(final @NotNull Application application) { } @Override - public void register(@NotNull IHub hub, @NotNull SentryOptions options) { + public void register(@NotNull IScopes scopes, @NotNull SentryOptions options) { application.registerActivityLifecycleCallbacks(this); } 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..ff85162a38 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,7 +10,8 @@ import io.sentry.SentryBaseEvent; import io.sentry.SentryEvent; import io.sentry.SentryLevel; -import io.sentry.android.core.internal.util.AndroidMainThreadChecker; +import io.sentry.SentryReplayEvent; +import io.sentry.android.core.internal.util.AndroidThreadChecker; import io.sentry.android.core.performance.AppStartMetrics; import io.sentry.android.core.performance.TimeSpan; import io.sentry.protocol.App; @@ -46,7 +47,9 @@ public DefaultAndroidEventProcessor( final @NotNull Context context, final @NotNull BuildInfoProvider buildInfoProvider, final @NotNull SentryAndroidOptions options) { - this.context = Objects.requireNonNull(context, "The application context is required."); + this.context = + Objects.requireNonNull( + ContextUtils.getApplicationContext(context), "The application context is required."); this.buildInfoProvider = Objects.requireNonNull(buildInfoProvider, "The BuildInfoProvider is required."); this.options = Objects.requireNonNull(options, "The options object is required."); @@ -56,7 +59,7 @@ public DefaultAndroidEventProcessor( // some device info performs disk I/O, but it's result is cached, let's pre-cache it final @NotNull ExecutorService executorService = Executors.newSingleThreadExecutor(); this.deviceInfoUtil = - executorService.submit(() -> DeviceInfoUtil.getInstance(context, options)); + executorService.submit(() -> DeviceInfoUtil.getInstance(this.context, options)); executorService.shutdown(); } @@ -213,7 +216,7 @@ private void setThreads(final @NotNull SentryEvent event, final @NotNull Hint hi final boolean isHybridSDK = HintUtils.isFromHybridSdk(hint); for (final SentryThread thread : event.getThreads()) { - final boolean isMainThread = AndroidMainThreadChecker.getInstance().isMainThread(thread); + final boolean isMainThread = AndroidThreadChecker.getInstance().isMainThread(thread); // TODO: Fix https://github.com/getsentry/team-mobile/issues/47 if (thread.isCurrent() == null) { @@ -303,4 +306,22 @@ 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; + } + + @Override + public @Nullable Long getOrder() { + return 8000L; + } } 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..9c7b748c03 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 @@ -15,12 +15,15 @@ import android.os.SystemClock; import android.util.DisplayMetrics; import io.sentry.DateUtils; +import io.sentry.ISentryLifecycleToken; 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; import io.sentry.protocol.Device; import io.sentry.protocol.OperatingSystem; +import io.sentry.util.AutoClosableReentrantLock; import java.io.File; import java.util.Calendar; import java.util.Collections; @@ -39,6 +42,9 @@ public final class DeviceInfoUtil { @SuppressLint("StaticFieldLeak") private static volatile DeviceInfoUtil instance; + private static final @NotNull AutoClosableReentrantLock staticLock = + new AutoClosableReentrantLock(); + private final @NotNull Context context; private final @NotNull SentryAndroidOptions options; private final @NotNull BuildInfoProvider buildInfoProvider; @@ -73,9 +79,9 @@ public DeviceInfoUtil( public static DeviceInfoUtil getInstance( final @NotNull Context context, final @NotNull SentryAndroidOptions options) { if (instance == null) { - synchronized (DeviceInfoUtil.class) { + try (final @NotNull ISentryLifecycleToken ignored = staticLock.acquire()) { if (instance == null) { - instance = new DeviceInfoUtil(context.getApplicationContext(), options); + instance = new DeviceInfoUtil(ContextUtils.getApplicationContext(context), options); } } } @@ -127,9 +133,6 @@ public Device collectDeviceInformation( } final @NotNull Locale locale = Locale.getDefault(); - if (device.getLanguage() == null) { - device.setLanguage(locale.getLanguage()); - } if (device.getLocale() == null) { device.setLocale(locale.toString()); // eg en_US } @@ -184,8 +187,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 +273,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 +298,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/EnvelopeFileObserverIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/EnvelopeFileObserverIntegration.java index f99294584b..a921f79458 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/EnvelopeFileObserverIntegration.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/EnvelopeFileObserverIntegration.java @@ -1,11 +1,13 @@ package io.sentry.android.core; -import io.sentry.IHub; import io.sentry.ILogger; +import io.sentry.IScopes; +import io.sentry.ISentryLifecycleToken; import io.sentry.Integration; import io.sentry.OutboxSender; import io.sentry.SentryLevel; import io.sentry.SentryOptions; +import io.sentry.util.AutoClosableReentrantLock; import io.sentry.util.Objects; import java.io.Closeable; import org.jetbrains.annotations.NotNull; @@ -17,15 +19,15 @@ public abstract class EnvelopeFileObserverIntegration implements Integration, Cl private @Nullable EnvelopeFileObserver observer; private @Nullable ILogger logger; private boolean isClosed = false; - private final @NotNull Object startLock = new Object(); + protected final @NotNull AutoClosableReentrantLock startLock = new AutoClosableReentrantLock(); public static @NotNull EnvelopeFileObserverIntegration getOutboxFileObserver() { return new OutboxEnvelopeFileObserverIntegration(); } @Override - public final void register(final @NotNull IHub hub, final @NotNull SentryOptions options) { - Objects.requireNonNull(hub, "Hub is required"); + public final void register(final @NotNull IScopes scopes, final @NotNull SentryOptions options) { + Objects.requireNonNull(scopes, "Scopes are required"); Objects.requireNonNull(options, "SentryOptions is required"); logger = options.getLogger(); @@ -44,9 +46,9 @@ public final void register(final @NotNull IHub hub, final @NotNull SentryOptions .getExecutorService() .submit( () -> { - synchronized (startLock) { + try (final @NotNull ISentryLifecycleToken ignored = startLock.acquire()) { if (!isClosed) { - startOutboxSender(hub, options, path); + startOutboxSender(scopes, options, path); } } }); @@ -60,10 +62,12 @@ public final void register(final @NotNull IHub hub, final @NotNull SentryOptions } private void startOutboxSender( - final @NotNull IHub hub, final @NotNull SentryOptions options, final @NotNull String path) { + final @NotNull IScopes scopes, + final @NotNull SentryOptions options, + final @NotNull String path) { final OutboxSender outboxSender = new OutboxSender( - hub, + scopes, options.getEnvelopeReader(), options.getSerializer(), options.getLogger(), @@ -86,7 +90,7 @@ private void startOutboxSender( @Override public void close() { - synchronized (startLock) { + try (final @NotNull ISentryLifecycleToken ignored = startLock.acquire()) { isClosed = true; } if (observer != null) { diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/Installation.java b/sentry-android-core/src/main/java/io/sentry/android/core/Installation.java index 007bb306cd..4c9b5ddbc2 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/Installation.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/Installation.java @@ -1,6 +1,8 @@ package io.sentry.android.core; import android.content.Context; +import io.sentry.ISentryLifecycleToken; +import io.sentry.util.AutoClosableReentrantLock; import java.io.File; import java.io.FileOutputStream; import java.io.IOException; @@ -19,6 +21,9 @@ final class Installation { private static final Charset UTF_8 = Charset.forName("UTF-8"); + protected static final @NotNull AutoClosableReentrantLock staticLock = + new AutoClosableReentrantLock(); + private Installation() {} /** @@ -29,20 +34,22 @@ private Installation() {} * @return the generated installationId * @throws RuntimeException if not possible to read nor to write to the file. */ - public static synchronized String id(final @NotNull Context context) throws RuntimeException { - if (deviceId == null) { - final File installation = new File(context.getFilesDir(), INSTALLATION); - try { - if (!installation.exists()) { - deviceId = writeInstallationFile(installation); - return deviceId; + public static String id(final @NotNull Context context) throws RuntimeException { + try (final @NotNull ISentryLifecycleToken ignored = staticLock.acquire()) { + if (deviceId == null) { + final File installation = new File(context.getFilesDir(), INSTALLATION); + try { + if (!installation.exists()) { + deviceId = writeInstallationFile(installation); + return deviceId; + } + deviceId = readInstallationFile(installation); + } catch (Throwable e) { + throw new RuntimeException(e); } - deviceId = readInstallationFile(installation); - } catch (Throwable e) { - throw new RuntimeException(e); } + return deviceId; } - return deviceId; } @TestOnly diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/InternalSentrySdk.java b/sentry-android-core/src/main/java/io/sentry/android/core/InternalSentrySdk.java index a3a15d7326..03547ef1fd 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/InternalSentrySdk.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/InternalSentrySdk.java @@ -8,12 +8,13 @@ import android.content.pm.PackageInfo; import android.content.pm.PackageManager; import io.sentry.DateUtils; -import io.sentry.HubAdapter; -import io.sentry.IHub; import io.sentry.ILogger; import io.sentry.IScope; +import io.sentry.IScopes; import io.sentry.ISerializer; import io.sentry.ObjectWriter; +import io.sentry.ScopeType; +import io.sentry.ScopesAdapter; import io.sentry.SentryEnvelope; import io.sentry.SentryEnvelopeItem; import io.sentry.SentryEvent; @@ -47,13 +48,14 @@ public final class InternalSentrySdk { /** - * @return a copy of the current hub's topmost scope, or null in case the hub is disabled + * @return a copy of the current scopes's topmost scope, or null in case the scopes is disabled */ @Nullable public static IScope getCurrentScope() { final @NotNull AtomicReference scopeRef = new AtomicReference<>(); - HubAdapter.getInstance() + ScopesAdapter.getInstance() .configureScope( + ScopeType.COMBINED, scope -> { scopeRef.set(scope.clone()); }); @@ -142,8 +144,8 @@ public static Map serializeScope( } /** - * Captures the provided envelope. Compared to {@link IHub#captureEvent(SentryEvent)} this method - *
+ * Captures the provided envelope. Compared to {@link IScopes#captureEvent(SentryEvent)} this + * method
* - will not enrich events with additional data (e.g. scope)
* - will not execute beforeSend: it's up to the caller to take care of this
* - will not perform any sampling: it's up to the caller to take care of this
@@ -156,8 +158,8 @@ public static Map serializeScope( @Nullable public static SentryId captureEnvelope( final @NotNull byte[] envelopeData, final boolean maybeStartNewSession) { - final @NotNull IHub hub = HubAdapter.getInstance(); - final @NotNull SentryOptions options = hub.getOptions(); + final @NotNull IScopes scopes = ScopesAdapter.getInstance(); + final @NotNull SentryOptions options = scopes.getOptions(); try (final InputStream envelopeInputStream = new ByteArrayInputStream(envelopeData)) { final @NotNull ISerializer serializer = options.getSerializer(); @@ -187,22 +189,22 @@ public static SentryId captureEnvelope( } // update session and add it to envelope if necessary - final @Nullable Session session = updateSession(hub, options, status, crashedOrErrored); + final @Nullable Session session = updateSession(scopes, options, status, crashedOrErrored); if (session != null) { final SentryEnvelopeItem sessionItem = SentryEnvelopeItem.fromSession(serializer, session); envelopeItems.add(sessionItem); deleteCurrentSessionFile( options, // should be sync if going to crash or already not a main thread - !maybeStartNewSession || !hub.getOptions().getMainThreadChecker().isMainThread()); + !maybeStartNewSession || !scopes.getOptions().getThreadChecker().isMainThread()); if (maybeStartNewSession) { - hub.startSession(); + scopes.startSession(); } } final SentryEnvelope repackagedEnvelope = new SentryEnvelope(envelope.getHeader(), envelopeItems); - return hub.captureEnvelope(repackagedEnvelope); + return scopes.captureEnvelope(repackagedEnvelope); } catch (Throwable t) { options.getLogger().log(SentryLevel.ERROR, "Failed to capture envelope", t); } @@ -244,7 +246,7 @@ public static Map getAppStartMeasurement() { private static void addTimeSpanToSerializedSpans(TimeSpan span, List> spans) { if (span.hasNotStarted()) { - HubAdapter.getInstance() + ScopesAdapter.getInstance() .getOptions() .getLogger() .log(WARNING, "Can not convert not-started TimeSpan to Map for Hybrid SDKs."); @@ -252,7 +254,7 @@ private static void addTimeSpanToSerializedSpans(TimeSpan span, List sessionRef = new AtomicReference<>(); - hub.configureScope( + scopes.configureScope( scope -> { final @Nullable Session session = scope.getSession(); if (session != null) { 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..73c37dfe97 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 @@ -3,14 +3,16 @@ import androidx.lifecycle.DefaultLifecycleObserver; import androidx.lifecycle.LifecycleOwner; import io.sentry.Breadcrumb; -import io.sentry.IHub; +import io.sentry.IScopes; +import io.sentry.ISentryLifecycleToken; import io.sentry.SentryLevel; import io.sentry.Session; -import io.sentry.android.core.internal.util.BreadcrumbFactory; import io.sentry.transport.CurrentDateProvider; import io.sentry.transport.ICurrentDateProvider; +import io.sentry.util.AutoClosableReentrantLock; 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,25 +21,26 @@ 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 Object timerLock = new Object(); - private final @NotNull IHub hub; + private final @NotNull Timer timer = new Timer(true); + private final @NotNull AutoClosableReentrantLock timerLock = new AutoClosableReentrantLock(); + private final @NotNull IScopes scopes; private final boolean enableSessionTracking; private final boolean enableAppLifecycleBreadcrumbs; private final @NotNull ICurrentDateProvider currentDateProvider; LifecycleWatcher( - final @NotNull IHub hub, + final @NotNull IScopes scopes, final long sessionIntervalMillis, final boolean enableSessionTracking, final boolean enableAppLifecycleBreadcrumbs) { this( - hub, + scopes, sessionIntervalMillis, enableSessionTracking, enableAppLifecycleBreadcrumbs, @@ -45,7 +48,7 @@ final class LifecycleWatcher implements DefaultLifecycleObserver { } LifecycleWatcher( - final @NotNull IHub hub, + final @NotNull IScopes scopes, final long sessionIntervalMillis, final boolean enableSessionTracking, final boolean enableAppLifecycleBreadcrumbs, @@ -53,13 +56,8 @@ final class LifecycleWatcher implements DefaultLifecycleObserver { this.sessionIntervalMillis = sessionIntervalMillis; this.enableSessionTracking = enableSessionTracking; this.enableAppLifecycleBreadcrumbs = enableAppLifecycleBreadcrumbs; - this.hub = hub; + this.scopes = scopes; this.currentDateProvider = currentDateProvider; - if (enableSessionTracking) { - timer = new Timer(true); - } else { - timer = null; - } } // App goes to foreground @@ -74,56 +72,62 @@ 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()); - } + scopes.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) { - addSessionBreadcrumb("start"); - hub.startSession(); + } + }); + + final long lastUpdatedSession = this.lastUpdatedSession.get(); + if (lastUpdatedSession == 0L + || (lastUpdatedSession + sessionIntervalMillis) <= currentTimeMillis) { + if (enableSessionTracking) { + scopes.startSession(); } - this.lastUpdatedSession.set(currentTimeMillis); + scopes.getOptions().getReplayController().start(); + } else if (!isFreshSession.get()) { + // only resume if it's not a fresh session, which has been started in SentryAndroid.init + scopes.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(); - } + scopes.getOptions().getReplayController().pause(); + scheduleEndSession(); AppState.getInstance().setInBackground(true); addAppBreadcrumb("background"); } private void scheduleEndSession() { - synchronized (timerLock) { + try (final @NotNull ISentryLifecycleToken ignored = timerLock.acquire()) { cancelTask(); if (timer != null) { timerTask = new TimerTask() { @Override public void run() { - addSessionBreadcrumb("end"); - hub.endSession(); + if (enableSessionTracking) { + scopes.endSession(); + } + scopes.getOptions().getReplayController().stop(); } }; @@ -133,7 +137,7 @@ public void run() { } private void cancelTask() { - synchronized (timerLock) { + try (final @NotNull ISentryLifecycleToken ignored = timerLock.acquire()) { if (timerTask != null) { timerTask.cancel(); timerTask = null; @@ -148,15 +152,10 @@ private void addAppBreadcrumb(final @NotNull String state) { breadcrumb.setData("state", state); breadcrumb.setCategory("app.lifecycle"); breadcrumb.setLevel(SentryLevel.INFO); - hub.addBreadcrumb(breadcrumb); + scopes.addBreadcrumb(breadcrumb); } } - private void addSessionBreadcrumb(final @NotNull String state) { - final Breadcrumb breadcrumb = BreadcrumbFactory.forSession(state); - hub.addBreadcrumb(breadcrumb); - } - @TestOnly @Nullable TimerTask getTimerTask() { @@ -164,7 +163,7 @@ TimerTask getTimerTask() { } @TestOnly - @Nullable + @NotNull Timer getTimer() { return timer; } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/LoadClass.java b/sentry-android-core/src/main/java/io/sentry/android/core/LoadClass.java index 6401945cab..34b8d1d5f1 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/LoadClass.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/LoadClass.java @@ -1,13 +1,23 @@ package io.sentry.android.core; import io.sentry.ILogger; -import io.sentry.SentryLevel; import io.sentry.SentryOptions; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; -/** An Adapter for making Class.forName testable */ -public final class LoadClass { +/** + * An Adapter for making Class.forName testable + * + * @deprecated please use {@link io.sentry.util.LoadClass} instead. + */ +@Deprecated +public final class LoadClass extends io.sentry.util.LoadClass { + + private final io.sentry.util.LoadClass delegate; + + public LoadClass() { + delegate = new io.sentry.util.LoadClass(); + } /** * Try to load a class via reflection @@ -17,30 +27,15 @@ public final class LoadClass { * @return a Class if it's available, or null */ public @Nullable Class loadClass(final @NotNull String clazz, final @Nullable ILogger logger) { - try { - return Class.forName(clazz); - } catch (ClassNotFoundException e) { - if (logger != null) { - logger.log(SentryLevel.DEBUG, "Class not available:" + clazz, e); - } - } catch (UnsatisfiedLinkError e) { - if (logger != null) { - logger.log(SentryLevel.ERROR, "Failed to load (UnsatisfiedLinkError) " + clazz, e); - } - } catch (Throwable e) { - if (logger != null) { - logger.log(SentryLevel.ERROR, "Failed to initialize " + clazz, e); - } - } - return null; + return delegate.loadClass(clazz, logger); } public boolean isClassAvailable(final @NotNull String clazz, final @Nullable ILogger logger) { - return loadClass(clazz, logger) != null; + return delegate.isClassAvailable(clazz, logger); } public boolean isClassAvailable( final @NotNull String clazz, final @Nullable SentryOptions options) { - return isClassAvailable(clazz, options != null ? options.getLogger() : null); + return delegate.isClassAvailable(clazz, options); } } 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..0edf0f5598 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 @@ -5,6 +5,7 @@ import android.content.pm.PackageManager; import android.os.Bundle; import io.sentry.ILogger; +import io.sentry.InitPriority; import io.sentry.SentryIntegrationPackageStorage; import io.sentry.SentryLevel; import io.sentry.protocol.SdkVersion; @@ -37,9 +38,6 @@ final class ManifestMetadataReader { static final String SDK_NAME = "io.sentry.sdk.name"; static final String SDK_VERSION = "io.sentry.sdk.version"; - // TODO: remove on 6.x in favor of SESSION_AUTO_TRACKING_ENABLE - static final String SESSION_TRACKING_ENABLE = "io.sentry.session-tracking.enable"; - static final String AUTO_SESSION_TRACKING_ENABLE = "io.sentry.auto-session-tracking.enable"; static final String SESSION_TRACKING_TIMEOUT_INTERVAL_MILLIS = "io.sentry.session-tracking.timeout-interval-millis"; @@ -56,7 +54,6 @@ final class ManifestMetadataReader { static final String UNCAUGHT_EXCEPTION_HANDLER_ENABLE = "io.sentry.uncaught-exception-handler.enable"; - static final String TRACING_ENABLE = "io.sentry.traces.enable"; static final String TRACES_SAMPLE_RATE = "io.sentry.traces.sample-rate"; static final String TRACES_ACTIVITY_ENABLE = "io.sentry.traces.activity.enable"; static final String TRACES_ACTIVITY_AUTO_FINISH_ENABLE = @@ -65,14 +62,9 @@ final class ManifestMetadataReader { static final String TTFD_ENABLE = "io.sentry.traces.time-to-full-display.enable"; - static final String TRACES_PROFILING_ENABLE = "io.sentry.traces.profiling.enable"; static final String PROFILES_SAMPLE_RATE = "io.sentry.traces.profiling.sample-rate"; @ApiStatus.Experimental static final String TRACE_SAMPLING = "io.sentry.traces.trace-sampling"; - - // TODO: remove in favor of TRACE_PROPAGATION_TARGETS - @Deprecated static final String TRACING_ORIGINS = "io.sentry.traces.tracing-origins"; - static final String TRACE_PROPAGATION_TARGETS = "io.sentry.traces.trace-propagation-targets"; static final String ATTACH_THREADS = "io.sentry.attach-threads"; @@ -102,7 +94,15 @@ final class ManifestMetadataReader { static final String ENABLE_SCOPE_PERSISTENCE = "io.sentry.enable-scope-persistence"; - 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.on-error-sample-rate"; + + static final String REPLAYS_MASK_ALL_TEXT = "io.sentry.session-replay.mask-all-text"; + + static final String REPLAYS_MASK_ALL_IMAGES = "io.sentry.session-replay.mask-all-images"; + + static final String FORCE_INIT = "io.sentry.force-init"; /** ManifestMetadataReader ctor */ private ManifestMetadataReader() {} @@ -142,14 +142,13 @@ static void applyMetadata( options.setAnrEnabled(readBool(metadata, logger, ANR_ENABLE, options.isAnrEnabled())); - // deprecated - final boolean enableSessionTracking = - readBool( - metadata, logger, SESSION_TRACKING_ENABLE, options.isEnableAutoSessionTracking()); - // use enableAutoSessionTracking as fallback options.setEnableAutoSessionTracking( - readBool(metadata, logger, AUTO_SESSION_TRACKING_ENABLE, enableSessionTracking)); + readBool( + metadata, + logger, + AUTO_SESSION_TRACKING_ENABLE, + options.isEnableAutoSessionTracking())); if (options.getSampleRate() == null) { final Double sampleRate = readDouble(metadata, logger, SAMPLE_RATE); @@ -263,6 +262,13 @@ static void applyMetadata( options.setSendClientReports( readBool(metadata, logger, CLIENT_REPORTS_ENABLE, options.isSendClientReports())); + final boolean isAutoInitEnabled = readBool(metadata, logger, AUTO_INIT, true); + if (isAutoInitEnabled) { + options.setInitPriority(InitPriority.LOW); + } + + options.setForceInit(readBool(metadata, logger, FORCE_INIT, options.isForceInit())); + options.setCollectAdditionalContext( readBool( metadata, @@ -270,10 +276,6 @@ static void applyMetadata( COLLECT_ADDITIONAL_CONTEXT, options.isCollectAdditionalContext())); - if (options.getEnableTracing() == null) { - options.setEnableTracing(readBoolNullable(metadata, logger, TRACING_ENABLE, null)); - } - if (options.getTracesSampleRate() == null) { final Double tracesSampleRate = readDouble(metadata, logger, TRACES_SAMPLE_RATE); if (tracesSampleRate != -1) { @@ -298,9 +300,6 @@ static void applyMetadata( TRACES_ACTIVITY_AUTO_FINISH_ENABLE, options.isEnableActivityLifecycleTracingAutoFinish())); - options.setProfilingEnabled( - readBool(metadata, logger, TRACES_PROFILING_ENABLE, options.isProfilingEnabled())); - if (options.getProfilesSampleRate() == null) { final Double profilesSampleRate = readDouble(metadata, logger, PROFILES_SAMPLE_RATE); if (profilesSampleRate != -1) { @@ -323,15 +322,7 @@ static void applyMetadata( List tracePropagationTargets = readList(metadata, logger, TRACE_PROPAGATION_TARGETS); - // TODO remove once TRACING_ORIGINS have been removed - if (!metadata.containsKey(TRACE_PROPAGATION_TARGETS) - && (tracePropagationTargets == null || tracePropagationTargets.isEmpty())) { - tracePropagationTargets = readList(metadata, logger, TRACING_ORIGINS); - } - - if ((metadata.containsKey(TRACE_PROPAGATION_TARGETS) - || metadata.containsKey(TRACING_ORIGINS)) - && tracePropagationTargets == null) { + if (metadata.containsKey(TRACE_PROPAGATION_TARGETS) && tracePropagationTargets == null) { options.setTracePropagationTargets(Collections.emptyList()); } else if (tracePropagationTargets != null) { options.setTracePropagationTargets(tracePropagationTargets); @@ -380,8 +371,30 @@ static void applyMetadata( readBool( metadata, logger, ENABLE_SCOPE_PERSISTENCE, options.isEnableScopePersistence())); - 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().getOnErrorSampleRate() == null) { + final Double onErrorSampleRate = readDouble(metadata, logger, REPLAYS_ERROR_SAMPLE_RATE); + if (onErrorSampleRate != -1) { + options.getExperimental().getSessionReplay().setOnErrorSampleRate(onErrorSampleRate); + } + } + + options + .getExperimental() + .getSessionReplay() + .setMaskAllText(readBool(metadata, logger, REPLAYS_MASK_ALL_TEXT, true)); + + options + .getExperimental() + .getSessionReplay() + .setMaskAllImages(readBool(metadata, logger, REPLAYS_MASK_ALL_IMAGES, true)); } options diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/NdkIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/NdkIntegration.java index 3a4a91498e..dc464303c6 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/NdkIntegration.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/NdkIntegration.java @@ -2,7 +2,7 @@ import static io.sentry.util.IntegrationUtils.addIntegrationToSdkVersion; -import io.sentry.IHub; +import io.sentry.IScopes; import io.sentry.Integration; import io.sentry.SentryLevel; import io.sentry.SentryOptions; @@ -28,8 +28,8 @@ public NdkIntegration(final @Nullable Class sentryNdkClass) { } @Override - public final void register(final @NotNull IHub hub, final @NotNull SentryOptions options) { - Objects.requireNonNull(hub, "Hub is required"); + public final void register(final @NotNull IScopes scopes, final @NotNull SentryOptions options) { + Objects.requireNonNull(scopes, "Scopes are required"); this.options = Objects.requireNonNull( (options instanceof SentryAndroidOptions) ? (SentryAndroidOptions) options : null, @@ -38,7 +38,8 @@ public final void register(final @NotNull IHub hub, final @NotNull SentryOptions final boolean enabled = this.options.isEnableNdk(); this.options.getLogger().log(SentryLevel.DEBUG, "NdkIntegration enabled: %s", enabled); - // Note: `hub` isn't used here because the NDK integration writes files to disk which are picked + // Note: `scopes` isn't used here because the NDK integration writes files to disk which are + // picked // up by another integration (EnvelopeFileObserverIntegration). if (enabled && sentryNdkClass != null) { final String cachedDir = this.options.getCacheDirPath(); diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/NetworkBreadcrumbsIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/NetworkBreadcrumbsIntegration.java index 1cd42e9dab..dc715119c1 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/NetworkBreadcrumbsIntegration.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/NetworkBreadcrumbsIntegration.java @@ -13,14 +13,16 @@ import io.sentry.Breadcrumb; import io.sentry.DateUtils; import io.sentry.Hint; -import io.sentry.IHub; import io.sentry.ILogger; +import io.sentry.IScopes; +import io.sentry.ISentryLifecycleToken; import io.sentry.Integration; import io.sentry.SentryDateProvider; import io.sentry.SentryLevel; import io.sentry.SentryOptions; import io.sentry.TypeCheckHint; import io.sentry.android.core.internal.util.AndroidConnectionStatusProvider; +import io.sentry.util.AutoClosableReentrantLock; import io.sentry.util.Objects; import java.io.Closeable; import java.io.IOException; @@ -31,18 +33,20 @@ public final class NetworkBreadcrumbsIntegration implements Integration, Closeable { private final @NotNull Context context; - private final @NotNull BuildInfoProvider buildInfoProvider; - private final @NotNull ILogger logger; + private final @NotNull AutoClosableReentrantLock lock = new AutoClosableReentrantLock(); + private volatile boolean isClosed; + private @Nullable SentryOptions options; - @TestOnly @Nullable NetworkBreadcrumbsNetworkCallback networkCallback; + @TestOnly @Nullable volatile NetworkBreadcrumbsNetworkCallback networkCallback; public NetworkBreadcrumbsIntegration( final @NotNull Context context, final @NotNull BuildInfoProvider buildInfoProvider, final @NotNull ILogger logger) { - this.context = Objects.requireNonNull(context, "Context is required"); + this.context = + Objects.requireNonNull(ContextUtils.getApplicationContext(context), "Context is required"); this.buildInfoProvider = Objects.requireNonNull(buildInfoProvider, "BuildInfoProvider is required"); this.logger = Objects.requireNonNull(logger, "ILogger is required"); @@ -50,8 +54,8 @@ public NetworkBreadcrumbsIntegration( @SuppressLint("NewApi") @Override - public void register(final @NotNull IHub hub, final @NotNull SentryOptions options) { - Objects.requireNonNull(hub, "Hub is required"); + public void register(final @NotNull IScopes scopes, final @NotNull SentryOptions options) { + Objects.requireNonNull(scopes, "Scopes are required"); SentryAndroidOptions androidOptions = Objects.requireNonNull( (options instanceof SentryAndroidOptions) ? (SentryAndroidOptions) options : null, @@ -62,46 +66,80 @@ public void register(final @NotNull IHub hub, final @NotNull SentryOptions optio "NetworkBreadcrumbsIntegration enabled: %s", androidOptions.isEnableNetworkEventBreadcrumbs()); + this.options = options; + if (androidOptions.isEnableNetworkEventBreadcrumbs()) { // The specific error is logged in the ConnectivityChecker method - if (buildInfoProvider.getSdkInfoVersion() < Build.VERSION_CODES.LOLLIPOP) { - networkCallback = null; - logger.log(SentryLevel.DEBUG, "NetworkBreadcrumbsIntegration requires Android 5+"); + if (buildInfoProvider.getSdkInfoVersion() < Build.VERSION_CODES.N) { + logger.log(SentryLevel.DEBUG, "NetworkCallbacks need Android N+."); return; } - networkCallback = - new NetworkBreadcrumbsNetworkCallback(hub, buildInfoProvider, options.getDateProvider()); - final boolean registered = - AndroidConnectionStatusProvider.registerNetworkCallback( - context, logger, buildInfoProvider, networkCallback); + try { + options + .getExecutorService() + .submit( + new Runnable() { + @Override + public void run() { + // in case integration is closed before the task is executed, simply return + if (isClosed) { + return; + } - // The specific error is logged in the ConnectivityChecker method - if (!registered) { - networkCallback = null; - logger.log(SentryLevel.DEBUG, "NetworkBreadcrumbsIntegration not installed."); - return; + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + networkCallback = + new NetworkBreadcrumbsNetworkCallback( + scopes, buildInfoProvider, options.getDateProvider()); + + final boolean registered = + AndroidConnectionStatusProvider.registerNetworkCallback( + context, logger, buildInfoProvider, networkCallback); + if (registered) { + logger.log(SentryLevel.DEBUG, "NetworkBreadcrumbsIntegration installed."); + addIntegrationToSdkVersion(getClass()); + } else { + logger.log( + SentryLevel.DEBUG, "NetworkBreadcrumbsIntegration not installed."); + // The specific error is logged by AndroidConnectionStatusProvider + } + } + } + }); + } catch (Throwable t) { + logger.log(SentryLevel.ERROR, "Error submitting NetworkBreadcrumbsIntegration task.", t); } - logger.log(SentryLevel.DEBUG, "NetworkBreadcrumbsIntegration installed."); - addIntegrationToSdkVersion(getClass()); } } @Override public void close() throws IOException { - if (networkCallback != null) { - AndroidConnectionStatusProvider.unregisterNetworkCallback( - context, logger, buildInfoProvider, networkCallback); - logger.log(SentryLevel.DEBUG, "NetworkBreadcrumbsIntegration remove."); + isClosed = true; + + try { + Objects.requireNonNull(options, "Options is required") + .getExecutorService() + .submit( + () -> { + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + if (networkCallback != null) { + AndroidConnectionStatusProvider.unregisterNetworkCallback( + context, logger, buildInfoProvider, networkCallback); + logger.log(SentryLevel.DEBUG, "NetworkBreadcrumbsIntegration removed."); + } + networkCallback = null; + } + }); + } catch (Throwable t) { + logger.log(SentryLevel.ERROR, "Error submitting NetworkBreadcrumbsIntegration task.", t); } - networkCallback = null; } @SuppressLint("ObsoleteSdkInt") @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP) static final class NetworkBreadcrumbsNetworkCallback extends ConnectivityManager.NetworkCallback { - final @NotNull IHub hub; + final @NotNull IScopes scopes; final @NotNull BuildInfoProvider buildInfoProvider; @Nullable Network currentNetwork = null; @@ -111,10 +149,10 @@ static final class NetworkBreadcrumbsNetworkCallback extends ConnectivityManager final @NotNull SentryDateProvider dateProvider; NetworkBreadcrumbsNetworkCallback( - final @NotNull IHub hub, + final @NotNull IScopes scopes, final @NotNull BuildInfoProvider buildInfoProvider, final @NotNull SentryDateProvider dateProvider) { - this.hub = Objects.requireNonNull(hub, "Hub is required"); + this.scopes = Objects.requireNonNull(scopes, "Scopes are required"); this.buildInfoProvider = Objects.requireNonNull(buildInfoProvider, "BuildInfoProvider is required"); this.dateProvider = Objects.requireNonNull(dateProvider, "SentryDateProvider is required"); @@ -126,7 +164,7 @@ public void onAvailable(final @NonNull Network network) { return; } final Breadcrumb breadcrumb = createBreadcrumb("NETWORK_AVAILABLE"); - hub.addBreadcrumb(breadcrumb); + scopes.addBreadcrumb(breadcrumb); currentNetwork = network; lastCapabilities = null; } @@ -156,7 +194,7 @@ public void onCapabilitiesChanged( } Hint hint = new Hint(); hint.set(TypeCheckHint.ANDROID_NETWORK_CAPABILITIES, connectionDetail); - hub.addBreadcrumb(breadcrumb, hint); + scopes.addBreadcrumb(breadcrumb, hint); } @Override @@ -165,7 +203,7 @@ public void onLost(final @NonNull Network network) { return; } final Breadcrumb breadcrumb = createBreadcrumb("NETWORK_LOST"); - hub.addBreadcrumb(breadcrumb); + scopes.addBreadcrumb(breadcrumb); currentNetwork = null; lastCapabilities = null; } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/PerformanceAndroidEventProcessor.java b/sentry-android-core/src/main/java/io/sentry/android/core/PerformanceAndroidEventProcessor.java index 00ba9122e7..887c63f5c8 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/PerformanceAndroidEventProcessor.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/PerformanceAndroidEventProcessor.java @@ -4,15 +4,16 @@ import static io.sentry.android.core.ActivityLifecycleIntegration.APP_START_WARM; import static io.sentry.android.core.ActivityLifecycleIntegration.UI_LOAD_OP; -import android.os.Looper; import io.sentry.EventProcessor; import io.sentry.Hint; +import io.sentry.ISentryLifecycleToken; import io.sentry.MeasurementUnit; import io.sentry.SentryEvent; import io.sentry.SpanContext; import io.sentry.SpanDataConvention; import io.sentry.SpanId; import io.sentry.SpanStatus; +import io.sentry.android.core.internal.util.AndroidThreadChecker; import io.sentry.android.core.performance.ActivityLifecycleTimeSpan; import io.sentry.android.core.performance.AppStartMetrics; import io.sentry.android.core.performance.TimeSpan; @@ -21,6 +22,7 @@ import io.sentry.protocol.SentryId; import io.sentry.protocol.SentrySpan; import io.sentry.protocol.SentryTransaction; +import io.sentry.util.AutoClosableReentrantLock; import io.sentry.util.Objects; import java.util.HashMap; import java.util.List; @@ -44,6 +46,7 @@ final class PerformanceAndroidEventProcessor implements EventProcessor { private final @NotNull ActivityFramesTracker activityFramesTracker; private final @NotNull SentryAndroidOptions options; + private final @NotNull AutoClosableReentrantLock lock = new AutoClosableReentrantLock(); PerformanceAndroidEventProcessor( final @NotNull SentryAndroidOptions options, @@ -71,70 +74,71 @@ public SentryEvent process(@NotNull SentryEvent event, @NotNull Hint hint) { @SuppressWarnings("NullAway") @Override - public synchronized @NotNull SentryTransaction process( + public @NotNull SentryTransaction process( @NotNull SentryTransaction transaction, @NotNull Hint hint) { + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + if (!options.isTracingEnabled()) { + return transaction; + } - if (!options.isTracingEnabled()) { - return transaction; - } + // the app start measurement is only sent once and only if the transaction has + // the app.start span, which is automatically created by the SDK. + if (hasAppStartSpan(transaction)) { + if (!sentStartMeasurement) { + final @NotNull TimeSpan appStartTimeSpan = + AppStartMetrics.getInstance().getAppStartTimeSpanWithFallback(options); + final long appStartUpDurationMs = appStartTimeSpan.getDurationMs(); + + // if appStartUpDurationMs is 0, metrics are not ready to be sent + if (appStartUpDurationMs != 0) { + final MeasurementValue value = + new MeasurementValue( + (float) appStartUpDurationMs, MeasurementUnit.Duration.MILLISECOND.apiName()); + + final String appStartKey = + AppStartMetrics.getInstance().getAppStartType() == AppStartMetrics.AppStartType.COLD + ? MeasurementValue.KEY_APP_START_COLD + : MeasurementValue.KEY_APP_START_WARM; + + transaction.getMeasurements().put(appStartKey, value); + + attachColdAppStartSpans(AppStartMetrics.getInstance(), transaction); + sentStartMeasurement = true; + } + } - // the app start measurement is only sent once and only if the transaction has - // the app.start span, which is automatically created by the SDK. - if (hasAppStartSpan(transaction)) { - if (!sentStartMeasurement) { - final @NotNull TimeSpan appStartTimeSpan = - AppStartMetrics.getInstance().getAppStartTimeSpanWithFallback(options); - final long appStartUpDurationMs = appStartTimeSpan.getDurationMs(); - - // if appStartUpDurationMs is 0, metrics are not ready to be sent - if (appStartUpDurationMs != 0) { - final MeasurementValue value = - new MeasurementValue( - (float) appStartUpDurationMs, MeasurementUnit.Duration.MILLISECOND.apiName()); - - final String appStartKey = - AppStartMetrics.getInstance().getAppStartType() == AppStartMetrics.AppStartType.COLD - ? MeasurementValue.KEY_APP_START_COLD - : MeasurementValue.KEY_APP_START_WARM; - - transaction.getMeasurements().put(appStartKey, value); - - attachColdAppStartSpans(AppStartMetrics.getInstance(), transaction); - sentStartMeasurement = true; + @Nullable App appContext = transaction.getContexts().getApp(); + if (appContext == null) { + appContext = new App(); + transaction.getContexts().setApp(appContext); } + final String appStartType = + AppStartMetrics.getInstance().getAppStartType() == AppStartMetrics.AppStartType.COLD + ? "cold" + : "warm"; + appContext.setStartType(appStartType); } - @Nullable App appContext = transaction.getContexts().getApp(); - if (appContext == null) { - appContext = new App(); - transaction.getContexts().setApp(appContext); + setContributingFlags(transaction); + + final SentryId eventId = transaction.getEventId(); + final SpanContext spanContext = transaction.getContexts().getTrace(); + + // only add slow/frozen frames to transactions created by ActivityLifecycleIntegration + // which have the operation UI_LOAD_OP. If a user-defined (or hybrid SDK) transaction + // users it, we'll also add the metrics if available + if (eventId != null + && spanContext != null + && spanContext.getOperation().contentEquals(UI_LOAD_OP)) { + final Map framesMetrics = + activityFramesTracker.takeMetrics(eventId); + if (framesMetrics != null) { + transaction.getMeasurements().putAll(framesMetrics); + } } - final String appStartType = - AppStartMetrics.getInstance().getAppStartType() == AppStartMetrics.AppStartType.COLD - ? "cold" - : "warm"; - appContext.setStartType(appStartType); - } - setContributingFlags(transaction); - - final SentryId eventId = transaction.getEventId(); - final SpanContext spanContext = transaction.getContexts().getTrace(); - - // only add slow/frozen frames to transactions created by ActivityLifecycleIntegration - // which have the operation UI_LOAD_OP. If a user-defined (or hybrid SDK) transaction - // users it, we'll also add the metrics if available - if (eventId != null - && spanContext != null - && spanContext.getOperation().contentEquals(UI_LOAD_OP)) { - final Map framesMetrics = - activityFramesTracker.takeMetrics(eventId); - if (framesMetrics != null) { - transaction.getMeasurements().putAll(framesMetrics); - } + return transaction; } - - return transaction; } private void setContributingFlags(SentryTransaction transaction) { @@ -317,7 +321,7 @@ private static SentrySpan timeSpanToSentrySpan( final @NotNull String operation) { final Map defaultSpanData = new HashMap<>(2); - defaultSpanData.put(SpanDataConvention.THREAD_ID, Looper.getMainLooper().getThread().getId()); + defaultSpanData.put(SpanDataConvention.THREAD_ID, AndroidThreadChecker.mainThreadSystemId); defaultSpanData.put(SpanDataConvention.THREAD_NAME, "main"); defaultSpanData.put(SpanDataConvention.CONTRIBUTES_TTID, true); @@ -335,7 +339,11 @@ private static SentrySpan timeSpanToSentrySpan( APP_METRICS_ORIGIN, new ConcurrentHashMap<>(), new ConcurrentHashMap<>(), - null, defaultSpanData); } + + @Override + public @Nullable Long getOrder() { + return 9000L; + } } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/PhoneStateBreadcrumbsIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/PhoneStateBreadcrumbsIntegration.java index c10d25b057..81f1a52262 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/PhoneStateBreadcrumbsIntegration.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/PhoneStateBreadcrumbsIntegration.java @@ -6,11 +6,13 @@ import android.content.Context; import android.telephony.TelephonyManager; import io.sentry.Breadcrumb; -import io.sentry.IHub; +import io.sentry.IScopes; +import io.sentry.ISentryLifecycleToken; import io.sentry.Integration; import io.sentry.SentryLevel; import io.sentry.SentryOptions; import io.sentry.android.core.internal.util.Permissions; +import io.sentry.util.AutoClosableReentrantLock; import io.sentry.util.Objects; import java.io.Closeable; import java.io.IOException; @@ -25,15 +27,16 @@ public final class PhoneStateBreadcrumbsIntegration implements Integration, Clos @TestOnly @Nullable PhoneStateChangeListener listener; private @Nullable TelephonyManager telephonyManager; private boolean isClosed = false; - private final @NotNull Object startLock = new Object(); + private final @NotNull AutoClosableReentrantLock startLock = new AutoClosableReentrantLock(); public PhoneStateBreadcrumbsIntegration(final @NotNull Context context) { - this.context = Objects.requireNonNull(context, "Context is required"); + this.context = + Objects.requireNonNull(ContextUtils.getApplicationContext(context), "Context is required"); } @Override - public void register(final @NotNull IHub hub, final @NotNull SentryOptions options) { - Objects.requireNonNull(hub, "Hub is required"); + public void register(final @NotNull IScopes scopes, final @NotNull SentryOptions options) { + Objects.requireNonNull(scopes, "Scopes are required"); this.options = Objects.requireNonNull( (options instanceof SentryAndroidOptions) ? (SentryAndroidOptions) options : null, @@ -53,9 +56,9 @@ public void register(final @NotNull IHub hub, final @NotNull SentryOptions optio .getExecutorService() .submit( () -> { - synchronized (startLock) { + try (final @NotNull ISentryLifecycleToken ignored = startLock.acquire()) { if (!isClosed) { - startTelephonyListener(hub, options); + startTelephonyListener(scopes, options); } } }); @@ -72,11 +75,11 @@ public void register(final @NotNull IHub hub, final @NotNull SentryOptions optio @SuppressWarnings("deprecation") private void startTelephonyListener( - final @NotNull IHub hub, final @NotNull SentryOptions options) { + final @NotNull IScopes scopes, final @NotNull SentryOptions options) { telephonyManager = (TelephonyManager) context.getSystemService(Context.TELEPHONY_SERVICE); if (telephonyManager != null) { try { - listener = new PhoneStateChangeListener(hub); + listener = new PhoneStateChangeListener(scopes); telephonyManager.listen(listener, android.telephony.PhoneStateListener.LISTEN_CALL_STATE); options.getLogger().log(SentryLevel.DEBUG, "PhoneStateBreadcrumbsIntegration installed."); @@ -94,7 +97,7 @@ private void startTelephonyListener( @SuppressWarnings("deprecation") @Override public void close() throws IOException { - synchronized (startLock) { + try (final @NotNull ISentryLifecycleToken ignored = startLock.acquire()) { isClosed = true; } if (telephonyManager != null && listener != null) { @@ -110,10 +113,10 @@ public void close() throws IOException { @SuppressWarnings("deprecation") static final class PhoneStateChangeListener extends android.telephony.PhoneStateListener { - private final @NotNull IHub hub; + private final @NotNull IScopes scopes; - PhoneStateChangeListener(final @NotNull IHub hub) { - this.hub = hub; + PhoneStateChangeListener(final @NotNull IScopes scopes) { + this.scopes = scopes; } @SuppressWarnings("deprecation") @@ -128,7 +131,7 @@ public void onCallStateChanged(int state, String incomingNumber) { breadcrumb.setData("action", "CALL_STATE_RINGING"); breadcrumb.setMessage("Device ringing"); breadcrumb.setLevel(SentryLevel.INFO); - hub.addBreadcrumb(breadcrumb); + scopes.addBreadcrumb(breadcrumb); } } } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ScreenshotEventProcessor.java b/sentry-android-core/src/main/java/io/sentry/android/core/ScreenshotEventProcessor.java index 5e07a44078..61ff9d290c 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/ScreenshotEventProcessor.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ScreenshotEventProcessor.java @@ -89,7 +89,7 @@ public ScreenshotEventProcessor( final byte[] screenshot = takeScreenshot( - activity, options.getMainThreadChecker(), options.getLogger(), buildInfoProvider); + activity, options.getThreadChecker(), options.getLogger(), buildInfoProvider); if (screenshot == null) { return event; } @@ -98,4 +98,9 @@ public ScreenshotEventProcessor( hint.set(ANDROID_ACTIVITY, activity); return event; } + + @Override + public @Nullable Long getOrder() { + return 10000L; + } } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/SendCachedEnvelopeIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/SendCachedEnvelopeIntegration.java index 66e534bb7d..9b50f3b6e5 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/SendCachedEnvelopeIntegration.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/SendCachedEnvelopeIntegration.java @@ -2,12 +2,14 @@ import io.sentry.DataCategory; import io.sentry.IConnectionStatusProvider; -import io.sentry.IHub; +import io.sentry.IScopes; +import io.sentry.ISentryLifecycleToken; import io.sentry.Integration; import io.sentry.SendCachedEnvelopeFireAndForgetIntegration; import io.sentry.SentryLevel; import io.sentry.SentryOptions; import io.sentry.transport.RateLimiter; +import io.sentry.util.AutoClosableReentrantLock; import io.sentry.util.LazyEvaluator; import io.sentry.util.Objects; import java.io.Closeable; @@ -28,11 +30,12 @@ final class SendCachedEnvelopeIntegration private final @NotNull LazyEvaluator startupCrashMarkerEvaluator; private final AtomicBoolean startupCrashHandled = new AtomicBoolean(false); private @Nullable IConnectionStatusProvider connectionStatusProvider; - private @Nullable IHub hub; + private @Nullable IScopes scopes; private @Nullable SentryAndroidOptions options; private @Nullable SendCachedEnvelopeFireAndForgetIntegration.SendFireAndForget sender; private final AtomicBoolean isInitialized = new AtomicBoolean(false); private final AtomicBoolean isClosed = new AtomicBoolean(false); + private final @NotNull AutoClosableReentrantLock lock = new AutoClosableReentrantLock(); public SendCachedEnvelopeIntegration( final @NotNull SendCachedEnvelopeFireAndForgetIntegration.SendFireAndForgetFactory factory, @@ -42,8 +45,8 @@ public SendCachedEnvelopeIntegration( } @Override - public void register(@NotNull IHub hub, @NotNull SentryOptions options) { - this.hub = Objects.requireNonNull(hub, "Hub is required"); + public void register(@NotNull IScopes scopes, @NotNull SentryOptions options) { + this.scopes = Objects.requireNonNull(scopes, "Scopes are required"); this.options = Objects.requireNonNull( (options instanceof SentryAndroidOptions) ? (SentryAndroidOptions) options : null, @@ -55,7 +58,7 @@ public void register(@NotNull IHub hub, @NotNull SentryOptions options) { return; } - sendCachedEnvelopes(hub, this.options); + sendCachedEnvelopes(scopes, this.options); } @Override @@ -69,15 +72,15 @@ public void close() throws IOException { @Override public void onConnectionStatusChanged( final @NotNull IConnectionStatusProvider.ConnectionStatus status) { - if (hub != null && options != null) { - sendCachedEnvelopes(hub, options); + if (scopes != null && options != null) { + sendCachedEnvelopes(scopes, options); } } @SuppressWarnings({"NullAway"}) - private synchronized void sendCachedEnvelopes( - final @NotNull IHub hub, final @NotNull SentryAndroidOptions options) { - try { + private void sendCachedEnvelopes( + final @NotNull IScopes scopes, final @NotNull SentryAndroidOptions options) { + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { final Future future = options .getExecutorService() @@ -97,7 +100,7 @@ private synchronized void sendCachedEnvelopes( connectionStatusProvider = options.getConnectionStatusProvider(); connectionStatusProvider.addConnectionStatusObserver(this); - sender = factory.create(hub, options); + sender = factory.create(scopes, options); } if (connectionStatusProvider != null @@ -110,7 +113,7 @@ private synchronized void sendCachedEnvelopes( } // in case there's rate limiting active, skip processing - final @Nullable RateLimiter rateLimiter = hub.getRateLimiter(); + final @Nullable RateLimiter rateLimiter = scopes.getRateLimiter(); if (rateLimiter != null && rateLimiter.isActiveForCategory(DataCategory.All)) { 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..6a7745104d 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,22 +1,24 @@ 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; -import io.sentry.IHub; import io.sentry.ILogger; +import io.sentry.IScopes; +import io.sentry.ISentryLifecycleToken; import io.sentry.Integration; import io.sentry.OptionsContainer; import io.sentry.Sentry; import io.sentry.SentryLevel; import io.sentry.SentryOptions; import io.sentry.Session; -import io.sentry.android.core.internal.util.BreadcrumbFactory; import io.sentry.android.core.performance.AppStartMetrics; import io.sentry.android.core.performance.TimeSpan; import io.sentry.android.fragment.FragmentLifecycleIntegration; import io.sentry.android.timber.SentryTimberIntegration; +import io.sentry.util.AutoClosableReentrantLock; import java.lang.reflect.InvocationTargetException; import java.util.ArrayList; import java.util.List; @@ -36,10 +38,16 @@ 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"; + protected static final @NotNull AutoClosableReentrantLock staticLock = + new AutoClosableReentrantLock(); + private SentryAndroid() {} /** @@ -81,16 +89,15 @@ public static void init( * @param configuration Sentry.OptionsConfiguration configuration handler */ @SuppressLint("NewApi") - public static synchronized void init( + public static void init( @NotNull final Context context, @NotNull ILogger logger, @NotNull Sentry.OptionsConfiguration configuration) { - - try { + try (final @NotNull ISentryLifecycleToken ignored = staticLock.acquire()) { Sentry.init( OptionsContainer.create(SentryAndroidOptions.class), options -> { - final LoadClass classLoader = new LoadClass(); + final io.sentry.util.LoadClass classLoader = new io.sentry.util.LoadClass(); final boolean isTimberUpstreamAvailable = classLoader.isClassAvailable(TIMBER_CLASS_NAME, options); final boolean isFragmentUpstreamAvailable = @@ -102,9 +109,11 @@ 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(); + final io.sentry.util.LoadClass loadClass = new io.sentry.util.LoadClass(); final ActivityFramesTracker activityFramesTracker = new ActivityFramesTracker(loadClass, options); @@ -121,7 +130,8 @@ public static synchronized void init( loadClass, activityFramesTracker, isFragmentAvailable, - isTimberAvailable); + isTimberAvailable, + isReplayAvailable); configuration.configure(options); @@ -135,6 +145,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); @@ -147,23 +161,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(); + final @NotNull IScopes scopes = Sentry.getCurrentScopes(); + if (ContextUtils.isForegroundImportance()) { + if (scopes.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); + scopes.configureScope( + scope -> { + final @Nullable Session currentSession = scope.getSession(); + if (currentSession != null && currentSession.getStarted() != null) { + sessionStarted.set(true); + } + }); + if (!sessionStarted.get()) { + scopes.startSession(); + } } + scopes.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/SentryAndroidOptions.java b/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroidOptions.java index 449834574a..013c93d909 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroidOptions.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroidOptions.java @@ -54,8 +54,8 @@ public final class SentryAndroidOptions extends SentryOptions { * Enables the Auto instrumentation for Activity lifecycle tracing. * *
    - *
  • It also requires setting any of {@link SentryOptions#getEnableTracing()}, {@link - * SentryOptions#getTracesSampleRate()} or {@link SentryOptions#getTracesSampler()}. + *
  • It also requires setting any of {@link SentryOptions#getTracesSampleRate()} or {@link + * SentryOptions#getTracesSampler()}. *
* *
    @@ -206,7 +206,7 @@ public interface BeforeCaptureCallback { */ private boolean attachAnrThreadDump = false; - private boolean enablePerformanceV2 = false; + private boolean enablePerformanceV2 = true; private @Nullable SentryFrameMetricsCollector frameMetricsCollector; @@ -337,27 +337,6 @@ public void enableAllAutoBreadcrumbs(boolean enable) { setEnableUserInteractionBreadcrumbs(enable); } - /** - * Returns the interval for profiling traces in milliseconds. - * - * @return the interval for profiling traces in milliseconds. - * @deprecated has no effect and will be removed in future versions. It now just returns 0. - */ - @Deprecated - @SuppressWarnings("InlineMeSuggester") - public int getProfilingTracesIntervalMillis() { - return 0; - } - - /** - * Sets the interval for profiling traces in milliseconds. - * - * @param profilingTracesIntervalMillis - the interval for profiling traces in milliseconds. - * @deprecated has no effect and will be removed in future versions. - */ - @Deprecated - public void setProfilingTracesIntervalMillis(final int profilingTracesIntervalMillis) {} - /** * Returns the Debug image loader * @@ -576,20 +555,18 @@ public void setAttachAnrThreadDump(final boolean attachAnrThreadDump) { * @return true if performance-v2 is enabled. See {@link #setEnablePerformanceV2(boolean)} for * more details. */ - @ApiStatus.Experimental public boolean isEnablePerformanceV2() { return enablePerformanceV2; } /** - * Experimental: Enables or disables the Performance V2 SDK features. + * Enables or disables the Performance V2 SDK features. * *

    With this change - Cold app start spans will provide more accurate timings - Cold app start * spans will be enriched with detailed ContentProvider, Application and Activity startup times * * @param enablePerformanceV2 true if enabled or false otherwise */ - @ApiStatus.Experimental public void setEnablePerformanceV2(final boolean enablePerformanceV2) { this.enablePerformanceV2 = enablePerformanceV2; } 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..7334b0bb9e 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 @@ -14,6 +14,7 @@ import android.os.SystemClock; import androidx.annotation.NonNull; import io.sentry.ILogger; +import io.sentry.ISentryLifecycleToken; import io.sentry.ITransactionProfiler; import io.sentry.JsonSerializer; import io.sentry.NoOpLogger; @@ -28,6 +29,7 @@ import io.sentry.android.core.performance.ActivityLifecycleTimeSpan; import io.sentry.android.core.performance.AppStartMetrics; import io.sentry.android.core.performance.TimeSpan; +import io.sentry.util.AutoClosableReentrantLock; import java.io.BufferedReader; import java.io.File; import java.io.FileInputStream; @@ -53,6 +55,7 @@ public final class SentryPerformanceProvider extends EmptySecureContentProvider private final @NotNull ILogger logger; private final @NotNull BuildInfoProvider buildInfoProvider; + private final @NotNull AutoClosableReentrantLock lock = new AutoClosableReentrantLock(); @TestOnly SentryPerformanceProvider( @@ -92,7 +95,7 @@ public String getType(@NotNull Uri uri) { @Override public void shutdown() { - synchronized (AppStartMetrics.getInstance()) { + try (final @NotNull ISentryLifecycleToken ignored = AppStartMetrics.staticLock.acquire()) { final @Nullable ITransactionProfiler appStartProfiler = AppStartMetrics.getInstance().getAppStartProfiler(); if (appStartProfiler != null) { @@ -159,10 +162,9 @@ private void launchAppStartProfiler(final @NotNull AppStartMetrics appStartMetri final @NotNull ITransactionProfiler appStartProfiler = new AndroidTransactionProfiler( - context.getApplicationContext(), + context, buildInfoProvider, - new SentryFrameMetricsCollector( - context.getApplicationContext(), logger, buildInfoProvider), + new SentryFrameMetricsCollector(context, logger, buildInfoProvider), logger, profilingOptions.getProfilingTracesDirPath(), profilingOptions.isProfilingEnabled(), @@ -201,6 +203,7 @@ private void onAppLaunched( final @NotNull TimeSpan appStartTimespan = appStartMetrics.getAppStartTimeSpan(); appStartTimespan.setStartedAt(Process.getStartUptimeMillis()); + appStartMetrics.registerApplicationForegroundCheck(app); final AtomicBoolean firstDrawDone = new AtomicBoolean(false); @@ -301,14 +304,16 @@ public void onActivityDestroyed(@NonNull Activity activity) { } @TestOnly - synchronized void onAppStartDone() { - final @NotNull AppStartMetrics appStartMetrics = AppStartMetrics.getInstance(); - appStartMetrics.getSdkInitTimeSpan().stop(); - appStartMetrics.getAppStartTimeSpan().stop(); - - if (app != null) { - if (activityCallback != null) { - app.unregisterActivityLifecycleCallbacks(activityCallback); + void onAppStartDone() { + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + final @NotNull AppStartMetrics appStartMetrics = AppStartMetrics.getInstance(); + appStartMetrics.getSdkInitTimeSpan().stop(); + appStartMetrics.getAppStartTimeSpan().stop(); + + if (app != null) { + if (activityCallback != null) { + app.unregisterActivityLifecycleCallbacks(activityCallback); + } } } } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/SpanFrameMetricsCollector.java b/sentry-android-core/src/main/java/io/sentry/android/core/SpanFrameMetricsCollector.java index 5535bccb91..d4e47ddc80 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/SpanFrameMetricsCollector.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/SpanFrameMetricsCollector.java @@ -2,6 +2,7 @@ import io.sentry.DateUtils; import io.sentry.IPerformanceContinuousCollector; +import io.sentry.ISentryLifecycleToken; import io.sentry.ISpan; import io.sentry.ITransaction; import io.sentry.NoOpSpan; @@ -11,6 +12,7 @@ import io.sentry.SpanDataConvention; import io.sentry.android.core.internal.util.SentryFrameMetricsCollector; import io.sentry.protocol.MeasurementValue; +import io.sentry.util.AutoClosableReentrantLock; import java.util.Date; import java.util.Iterator; import java.util.SortedSet; @@ -34,7 +36,7 @@ public class SpanFrameMetricsCollector private static final SentryNanotimeDate EMPTY_NANO_TIME = new SentryNanotimeDate(new Date(0), 0); private final boolean enabled; - private final @NotNull Object lock = new Object(); + protected final @NotNull AutoClosableReentrantLock lock = new AutoClosableReentrantLock(); private final @NotNull SentryFrameMetricsCollector frameMetricsCollector; private volatile @Nullable String listenerId; @@ -43,17 +45,19 @@ public class SpanFrameMetricsCollector private final @NotNull SortedSet runningSpans = new TreeSet<>( (o1, o2) -> { + if (o1 == o2) { + return 0; + } int timeDiff = o1.getStartDate().compareTo(o2.getStartDate()); if (timeDiff != 0) { return timeDiff; - } else { - // TreeSet uses compareTo to check for duplicates, so ensure that - // two non-equal spans with the same start date are not considered equal - return o1.getSpanContext() - .getSpanId() - .toString() - .compareTo(o2.getSpanContext().getSpanId().toString()); } + // TreeSet uses compareTo to check for duplicates, so ensure that + // two non-equal spans with the same start date are not considered equal + return o1.getSpanContext() + .getSpanId() + .toString() + .compareTo(o2.getSpanContext().getSpanId().toString()); }); // all collected frames, sorted by frame end time @@ -85,7 +89,7 @@ public void onSpanStarted(final @NotNull ISpan span) { return; } - synchronized (lock) { + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { runningSpans.add(span); if (listenerId == null) { @@ -109,7 +113,7 @@ public void onSpanFinished(final @NotNull ISpan span) { } // ignore span if onSpanStarted was never called for it - synchronized (lock) { + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { if (!runningSpans.contains(span)) { return; } @@ -117,7 +121,7 @@ public void onSpanFinished(final @NotNull ISpan span) { captureFrameMetrics(span); - synchronized (lock) { + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { if (runningSpans.isEmpty()) { clear(); } else { @@ -130,7 +134,7 @@ public void onSpanFinished(final @NotNull ISpan span) { private void captureFrameMetrics(@NotNull final ISpan span) { // TODO lock still required? - synchronized (lock) { + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { boolean removed = runningSpans.remove(span); if (!removed) { return; @@ -224,7 +228,7 @@ private void captureFrameMetrics(@NotNull final ISpan span) { @Override public void clear() { - synchronized (lock) { + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { if (listenerId != null) { frameMetricsCollector.stopCollection(listenerId); listenerId = null; 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..00e0dde646 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; @@ -40,11 +41,14 @@ import android.os.Bundle; import io.sentry.Breadcrumb; import io.sentry.Hint; -import io.sentry.IHub; -import io.sentry.ILogger; +import io.sentry.IScopes; +import io.sentry.ISentryLifecycleToken; 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.AutoClosableReentrantLock; import io.sentry.util.Objects; import io.sentry.util.StringUtils; import java.io.Closeable; @@ -67,7 +71,7 @@ public final class SystemEventsBreadcrumbsIntegration implements Integration, Cl private final @NotNull List actions; private boolean isClosed = false; - private final @NotNull Object startLock = new Object(); + private final @NotNull AutoClosableReentrantLock startLock = new AutoClosableReentrantLock(); public SystemEventsBreadcrumbsIntegration(final @NotNull Context context) { this(context, getDefaultActions()); @@ -75,13 +79,14 @@ public SystemEventsBreadcrumbsIntegration(final @NotNull Context context) { public SystemEventsBreadcrumbsIntegration( final @NotNull Context context, final @NotNull List actions) { - this.context = Objects.requireNonNull(context, "Context is required"); + this.context = + Objects.requireNonNull(ContextUtils.getApplicationContext(context), "Context is required"); this.actions = Objects.requireNonNull(actions, "Actions list is required"); } @Override - public void register(final @NotNull IHub hub, final @NotNull SentryOptions options) { - Objects.requireNonNull(hub, "Hub is required"); + public void register(final @NotNull IScopes scopes, final @NotNull SentryOptions options) { + Objects.requireNonNull(scopes, "Scopes are required"); this.options = Objects.requireNonNull( (options instanceof SentryAndroidOptions) ? (SentryAndroidOptions) options : null, @@ -101,9 +106,9 @@ public void register(final @NotNull IHub hub, final @NotNull SentryOptions optio .getExecutorService() .submit( () -> { - synchronized (startLock) { + try (final @NotNull ISentryLifecycleToken ignored = startLock.acquire()) { if (!isClosed) { - startSystemEventsReceiver(hub, (SentryAndroidOptions) options); + startSystemEventsReceiver(scopes, (SentryAndroidOptions) options); } } }); @@ -119,8 +124,8 @@ 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()); + final @NotNull IScopes scopes, final @NotNull SentryAndroidOptions options) { + receiver = new SystemEventsBroadcastReceiver(scopes, options); final IntentFilter filter = new IntentFilter(); for (String item : actions) { filter.addAction(item); @@ -154,6 +159,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); @@ -189,7 +195,7 @@ private void startSystemEventsReceiver( @Override public void close() throws IOException { - synchronized (startLock) { + try (final @NotNull ISentryLifecycleToken ignored = startLock.acquire()) { isClosed = true; } if (receiver != null) { @@ -204,52 +210,96 @@ public void close() throws IOException { static final class SystemEventsBroadcastReceiver extends BroadcastReceiver { - private final @NotNull IHub hub; - private final @NotNull ILogger logger; + private static final long DEBOUNCE_WAIT_TIME_MS = 60 * 1000; + private final @NotNull IScopes scopes; + private final @NotNull SentryAndroidOptions options; + private final @NotNull Debouncer batteryChangedDebouncer = + new Debouncer(AndroidCurrentDateProvider.getInstance(), DEBOUNCE_WAIT_TIME_MS, 0); - SystemEventsBroadcastReceiver(final @NotNull IHub hub, final @NotNull ILogger logger) { - this.hub = hub; - this.logger = logger; + SystemEventsBroadcastReceiver( + final @NotNull IScopes scopes, final @NotNull SentryAndroidOptions options) { + this.scopes = scopes; + this.options = options; } @Override - public void onReceive(Context context, Intent intent) { - final Breadcrumb breadcrumb = new Breadcrumb(); + public void onReceive(final Context context, final @NotNull Intent intent) { + final @Nullable String action = intent.getAction(); + final boolean isBatteryChanged = ACTION_BATTERY_CHANGED.equals(action); + + // aligning with iOS which only captures battery status changes every minute at maximum + if (isBatteryChanged && batteryChangedDebouncer.checkForDebounce()) { + return; + } + + final long now = System.currentTimeMillis(); + try { + options + .getExecutorService() + .submit( + () -> { + final Breadcrumb breadcrumb = + createBreadcrumb(now, intent, action, isBatteryChanged); + final Hint hint = new Hint(); + hint.set(ANDROID_INTENT, intent); + scopes.addBreadcrumb(breadcrumb, hint); + }); + } catch (Throwable t) { + options + .getLogger() + .log(SentryLevel.ERROR, t, "Failed to submit system event breadcrumb action."); + } + } + + private @NotNull Breadcrumb createBreadcrumb( + final long timeMs, + final @NotNull Intent intent, + final @Nullable String action, + boolean isBatteryChanged) { + final Breadcrumb breadcrumb = new Breadcrumb(timeMs); breadcrumb.setType("system"); breadcrumb.setCategory("device.event"); - final String action = intent.getAction(); - String shortAction = StringUtils.getStringAfterDot(action); + final 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); - - final Hint hint = new Hint(); - hint.set(ANDROID_INTENT, intent); - - hub.addBreadcrumb(breadcrumb, hint); + return breadcrumb; } } } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/TempSensorBreadcrumbsIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/TempSensorBreadcrumbsIntegration.java index eaf5c64991..f835b3670b 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/TempSensorBreadcrumbsIntegration.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/TempSensorBreadcrumbsIntegration.java @@ -11,10 +11,12 @@ import android.hardware.SensorManager; import io.sentry.Breadcrumb; import io.sentry.Hint; -import io.sentry.IHub; +import io.sentry.IScopes; +import io.sentry.ISentryLifecycleToken; import io.sentry.Integration; import io.sentry.SentryLevel; import io.sentry.SentryOptions; +import io.sentry.util.AutoClosableReentrantLock; import io.sentry.util.Objects; import java.io.Closeable; import java.io.IOException; @@ -26,20 +28,21 @@ public final class TempSensorBreadcrumbsIntegration implements Integration, Closeable, SensorEventListener { private final @NotNull Context context; - private @Nullable IHub hub; + private @Nullable IScopes scopes; private @Nullable SentryAndroidOptions options; @TestOnly @Nullable SensorManager sensorManager; private boolean isClosed = false; - private final @NotNull Object startLock = new Object(); + private final @NotNull AutoClosableReentrantLock startLock = new AutoClosableReentrantLock(); public TempSensorBreadcrumbsIntegration(final @NotNull Context context) { - this.context = Objects.requireNonNull(context, "Context is required"); + this.context = + Objects.requireNonNull(ContextUtils.getApplicationContext(context), "Context is required"); } @Override - public void register(final @NotNull IHub hub, final @NotNull SentryOptions options) { - this.hub = Objects.requireNonNull(hub, "Hub is required"); + public void register(final @NotNull IScopes scopes, final @NotNull SentryOptions options) { + this.scopes = Objects.requireNonNull(scopes, "Scopes are required"); this.options = Objects.requireNonNull( (options instanceof SentryAndroidOptions) ? (SentryAndroidOptions) options : null, @@ -59,7 +62,7 @@ public void register(final @NotNull IHub hub, final @NotNull SentryOptions optio .getExecutorService() .submit( () -> { - synchronized (startLock) { + try (final @NotNull ISentryLifecycleToken ignored = startLock.acquire()) { if (!isClosed) { startSensorListener(options); } @@ -100,7 +103,7 @@ private void startSensorListener(final @NotNull SentryOptions options) { @Override public void close() throws IOException { - synchronized (startLock) { + try (final @NotNull ISentryLifecycleToken ignored = startLock.acquire()) { isClosed = true; } if (sensorManager != null) { @@ -121,7 +124,7 @@ public void onSensorChanged(final @NotNull SensorEvent event) { return; } - if (hub != null) { + if (scopes != null) { final Breadcrumb breadcrumb = new Breadcrumb(); breadcrumb.setType("system"); breadcrumb.setCategory("device.event"); @@ -134,7 +137,7 @@ public void onSensorChanged(final @NotNull SensorEvent event) { final Hint hint = new Hint(); hint.set(ANDROID_SENSOR_EVENT, event); - hub.addBreadcrumb(breadcrumb, hint); + scopes.addBreadcrumb(breadcrumb, hint); } } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/UserInteractionIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/UserInteractionIntegration.java index c361529671..02a707173a 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/UserInteractionIntegration.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/UserInteractionIntegration.java @@ -6,7 +6,7 @@ import android.app.Application; import android.os.Bundle; import android.view.Window; -import io.sentry.IHub; +import io.sentry.IScopes; import io.sentry.Integration; import io.sentry.SentryLevel; import io.sentry.SentryOptions; @@ -23,13 +23,13 @@ public final class UserInteractionIntegration implements Integration, Closeable, Application.ActivityLifecycleCallbacks { private final @NotNull Application application; - private @Nullable IHub hub; + private @Nullable IScopes scopes; private @Nullable SentryAndroidOptions options; private final boolean isAndroidXAvailable; public UserInteractionIntegration( - final @NotNull Application application, final @NotNull LoadClass classLoader) { + final @NotNull Application application, final @NotNull io.sentry.util.LoadClass classLoader) { this.application = Objects.requireNonNull(application, "Application is required"); isAndroidXAvailable = classLoader.isClassAvailable("androidx.core.view.GestureDetectorCompat", options); @@ -44,14 +44,14 @@ private void startTracking(final @NotNull Activity activity) { return; } - if (hub != null && options != null) { + if (scopes != null && options != null) { Window.Callback delegate = window.getCallback(); if (delegate == null) { delegate = new NoOpWindowCallback(); } final SentryGestureListener gestureListener = - new SentryGestureListener(activity, hub, options); + new SentryGestureListener(activity, scopes, options); window.setCallback(new SentryWindowCallback(delegate, activity, gestureListener, options)); } } @@ -102,13 +102,13 @@ public void onActivitySaveInstanceState(@NotNull Activity activity, @NotNull Bun public void onActivityDestroyed(@NotNull Activity activity) {} @Override - public void register(@NotNull IHub hub, @NotNull SentryOptions options) { + public void register(@NotNull IScopes scopes, @NotNull SentryOptions options) { this.options = Objects.requireNonNull( (options instanceof SentryAndroidOptions) ? (SentryAndroidOptions) options : null, "SentryAndroidOptions is required"); - this.hub = Objects.requireNonNull(hub, "Hub is required"); + this.scopes = Objects.requireNonNull(scopes, "Scopes are required"); final boolean integrationEnabled = this.options.isEnableUserInteractionBreadcrumbs() diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ViewHierarchyEventProcessor.java b/sentry-android-core/src/main/java/io/sentry/android/core/ViewHierarchyEventProcessor.java index 30e9f8de11..8b6a1f87aa 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/ViewHierarchyEventProcessor.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ViewHierarchyEventProcessor.java @@ -15,7 +15,7 @@ import io.sentry.SentryLevel; import io.sentry.android.core.internal.gestures.ViewUtils; import io.sentry.android.core.internal.util.AndroidCurrentDateProvider; -import io.sentry.android.core.internal.util.AndroidMainThreadChecker; +import io.sentry.android.core.internal.util.AndroidThreadChecker; import io.sentry.android.core.internal.util.ClassUtil; import io.sentry.android.core.internal.util.Debouncer; import io.sentry.internal.viewhierarchy.ViewHierarchyExporter; @@ -25,7 +25,7 @@ import io.sentry.util.HintUtils; import io.sentry.util.JsonSerializationUtils; import io.sentry.util.Objects; -import io.sentry.util.thread.IMainThreadChecker; +import io.sentry.util.thread.IThreadChecker; import java.util.ArrayList; import java.util.List; import java.util.concurrent.CountDownLatch; @@ -101,7 +101,7 @@ public ViewHierarchyEventProcessor(final @NotNull SentryAndroidOptions options) snapshotViewHierarchy( activity, options.getViewHierarchyExporters(), - options.getMainThreadChecker(), + options.getThreadChecker(), options.getLogger()); if (viewHierarchy != null) { @@ -113,13 +113,13 @@ public ViewHierarchyEventProcessor(final @NotNull SentryAndroidOptions options) public static byte[] snapshotViewHierarchyAsData( @Nullable Activity activity, - @NotNull IMainThreadChecker mainThreadChecker, + @NotNull IThreadChecker threadChecker, @NotNull ISerializer serializer, @NotNull ILogger logger) { @Nullable ViewHierarchy viewHierarchy = - snapshotViewHierarchy(activity, new ArrayList<>(0), mainThreadChecker, logger); + snapshotViewHierarchy(activity, new ArrayList<>(0), threadChecker, logger); if (viewHierarchy == null) { logger.log(SentryLevel.ERROR, "Could not get ViewHierarchy."); @@ -144,14 +144,14 @@ public static byte[] snapshotViewHierarchyAsData( public static ViewHierarchy snapshotViewHierarchy( final @Nullable Activity activity, final @NotNull ILogger logger) { return snapshotViewHierarchy( - activity, new ArrayList<>(0), AndroidMainThreadChecker.getInstance(), logger); + activity, new ArrayList<>(0), AndroidThreadChecker.getInstance(), logger); } @Nullable public static ViewHierarchy snapshotViewHierarchy( final @Nullable Activity activity, final @NotNull List exporters, - final @NotNull IMainThreadChecker mainThreadChecker, + final @NotNull IThreadChecker threadChecker, final @NotNull ILogger logger) { if (activity == null) { @@ -172,7 +172,7 @@ public static ViewHierarchy snapshotViewHierarchy( } try { - if (mainThreadChecker.isMainThread()) { + if (threadChecker.isMainThread()) { return snapshotViewHierarchy(decorView, exporters); } else { final CountDownLatch latch = new CountDownLatch(1); @@ -284,4 +284,9 @@ private static ViewHierarchyNode viewToNode(@NotNull final View view) { return node; } + + @Override + public @Nullable Long getOrder() { + return 11000L; + } } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/internal/debugmeta/AssetsDebugMetaLoader.java b/sentry-android-core/src/main/java/io/sentry/android/core/internal/debugmeta/AssetsDebugMetaLoader.java index 6c5bed5ae2..568b67f0b0 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/internal/debugmeta/AssetsDebugMetaLoader.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/internal/debugmeta/AssetsDebugMetaLoader.java @@ -6,6 +6,7 @@ import android.content.res.AssetManager; import io.sentry.ILogger; import io.sentry.SentryLevel; +import io.sentry.android.core.ContextUtils; import io.sentry.internal.debugmeta.IDebugMetaLoader; import java.io.BufferedInputStream; import java.io.FileNotFoundException; @@ -24,7 +25,7 @@ public final class AssetsDebugMetaLoader implements IDebugMetaLoader { private final @NotNull ILogger logger; public AssetsDebugMetaLoader(final @NotNull Context context, final @NotNull ILogger logger) { - this.context = context; + this.context = ContextUtils.getApplicationContext(context); this.logger = logger; } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/SentryGestureListener.java b/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/SentryGestureListener.java index 0ec0d83258..cd80f5ced7 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/SentryGestureListener.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/SentryGestureListener.java @@ -10,8 +10,8 @@ import android.view.Window; import io.sentry.Breadcrumb; import io.sentry.Hint; -import io.sentry.IHub; import io.sentry.IScope; +import io.sentry.IScopes; import io.sentry.ITransaction; import io.sentry.SentryLevel; import io.sentry.SpanStatus; @@ -43,7 +43,7 @@ private enum GestureType { private static final String TRACE_ORIGIN = "auto.ui.gesture_listener"; private final @NotNull WeakReference activityRef; - private final @NotNull IHub hub; + private final @NotNull IScopes scopes; private final @NotNull SentryAndroidOptions options; private @Nullable UiElement activeUiElement = null; @@ -54,10 +54,10 @@ private enum GestureType { public SentryGestureListener( final @NotNull Activity currentActivity, - final @NotNull IHub hub, + final @NotNull IScopes scopes, final @NotNull SentryAndroidOptions options) { this.activityRef = new WeakReference<>(currentActivity); - this.hub = hub; + this.scopes = scopes; this.options = options; } @@ -185,7 +185,7 @@ private void addBreadcrumb( hint.set(ANDROID_MOTION_EVENT, motionEvent); hint.set(ANDROID_VIEW, target.getView()); - hub.addBreadcrumb( + scopes.addBreadcrumb( Breadcrumb.userInteraction( type, target.getResourceName(), target.getClassName(), target.getTag(), additionalData), hint); @@ -202,7 +202,7 @@ private void startTracing(final @NotNull UiElement target, final @NotNull Gestur if (!(options.isTracingEnabled() && options.isEnableUserInteractionTracing())) { if (isNewInteraction) { - TracingUtils.startNewTrace(hub); + TracingUtils.startNewTrace(scopes); activeUiElement = target; activeEventType = eventType; } @@ -251,14 +251,13 @@ private void startTracing(final @NotNull UiElement target, final @NotNull Gestur TransactionOptions.DEFAULT_DEADLINE_TIMEOUT_AUTO_TRANSACTION); transactionOptions.setIdleTimeout(options.getIdleTimeout()); transactionOptions.setTrimEnd(true); + transactionOptions.setOrigin(TRACE_ORIGIN + "." + target.getOrigin()); final ITransaction transaction = - hub.startTransaction( + scopes.startTransaction( new TransactionContext(name, TransactionNameSource.COMPONENT, op), transactionOptions); - transaction.getSpanContext().setOrigin(TRACE_ORIGIN + "." + target.getOrigin()); - - hub.configureScope( + scopes.configureScope( scope -> { applyScope(scope, transaction); }); @@ -278,7 +277,7 @@ void stopTracing(final @NotNull SpanStatus status) { activeTransaction.finish(); } } - hub.configureScope( + scopes.configureScope( scope -> { // avoid method refs on Android due to some issues with older AGP setups // noinspection Convert2MethodRef diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/internal/modules/AssetsModulesLoader.java b/sentry-android-core/src/main/java/io/sentry/android/core/internal/modules/AssetsModulesLoader.java index 6d6f3737cb..b6374a32e3 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/internal/modules/AssetsModulesLoader.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/internal/modules/AssetsModulesLoader.java @@ -3,6 +3,7 @@ import android.content.Context; import io.sentry.ILogger; import io.sentry.SentryLevel; +import io.sentry.android.core.ContextUtils; import io.sentry.internal.modules.ModulesLoader; import java.io.FileNotFoundException; import java.io.IOException; @@ -19,7 +20,7 @@ public final class AssetsModulesLoader extends ModulesLoader { public AssetsModulesLoader(final @NotNull Context context, final @NotNull ILogger logger) { super(logger); - this.context = context; + this.context = ContextUtils.getApplicationContext(context); } @Override diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/AndroidConnectionStatusProvider.java b/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/AndroidConnectionStatusProvider.java index b8279edcb1..0afd2bce97 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/AndroidConnectionStatusProvider.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/AndroidConnectionStatusProvider.java @@ -12,6 +12,7 @@ import io.sentry.ILogger; import io.sentry.SentryLevel; import io.sentry.android.core.BuildInfoProvider; +import io.sentry.android.core.ContextUtils; import java.util.HashMap; import java.util.Map; import org.jetbrains.annotations.ApiStatus; @@ -37,7 +38,7 @@ public AndroidConnectionStatusProvider( @NotNull Context context, @NotNull ILogger logger, @NotNull BuildInfoProvider buildInfoProvider) { - this.context = context; + this.context = ContextUtils.getApplicationContext(context); this.logger = logger; this.buildInfoProvider = buildInfoProvider; this.registeredCallbacks = new HashMap<>(); diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/AndroidMainThreadChecker.java b/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/AndroidThreadChecker.java similarity index 56% rename from sentry-android-core/src/main/java/io/sentry/android/core/internal/util/AndroidMainThreadChecker.java rename to sentry-android-core/src/main/java/io/sentry/android/core/internal/util/AndroidThreadChecker.java index aa54790c47..15781d711f 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/AndroidMainThreadChecker.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/AndroidThreadChecker.java @@ -1,22 +1,28 @@ package io.sentry.android.core.internal.util; +import android.os.Handler; import android.os.Looper; +import android.os.Process; import io.sentry.protocol.SentryThread; -import io.sentry.util.thread.IMainThreadChecker; +import io.sentry.util.thread.IThreadChecker; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; /** Class that checks if a given thread is the Android Main/UI thread */ @ApiStatus.Internal -public final class AndroidMainThreadChecker implements IMainThreadChecker { +public final class AndroidThreadChecker implements IThreadChecker { - private static final AndroidMainThreadChecker instance = new AndroidMainThreadChecker(); + private static final AndroidThreadChecker instance = new AndroidThreadChecker(); + public static volatile long mainThreadSystemId = Process.myTid(); - public static AndroidMainThreadChecker getInstance() { + public static AndroidThreadChecker getInstance() { return instance; } - private AndroidMainThreadChecker() {} + private AndroidThreadChecker() { + // The first time this class is loaded, we make sure to set the correct mainThreadId + new Handler(Looper.getMainLooper()).post(() -> mainThreadSystemId = Process.myTid()); + } @Override public boolean isMainThread(final long threadId) { @@ -38,4 +44,9 @@ public boolean isMainThread(final @NotNull SentryThread sentryThread) { final Long threadId = sentryThread.getId(); return threadId != null && isMainThread(threadId); } + + @Override + public long currentThreadSystemId() { + return Process.myTid(); + } } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/CpuInfoUtils.java b/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/CpuInfoUtils.java index 8dcb994fbc..019db99fc7 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/CpuInfoUtils.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/CpuInfoUtils.java @@ -1,5 +1,7 @@ package io.sentry.android.core.internal.util; +import io.sentry.ISentryLifecycleToken; +import io.sentry.util.AutoClosableReentrantLock; import io.sentry.util.FileUtils; import java.io.File; import java.io.IOException; @@ -14,6 +16,7 @@ public final class CpuInfoUtils { private static final CpuInfoUtils instance = new CpuInfoUtils(); + private final @NotNull AutoClosableReentrantLock lock = new AutoClosableReentrantLock(); public static CpuInfoUtils getInstance() { return instance; @@ -34,34 +37,36 @@ private CpuInfoUtils() {} * * @return A list with the frequency of each core of the cpu in Mhz */ - public synchronized @NotNull List readMaxFrequencies() { - if (!cpuMaxFrequenciesMhz.isEmpty()) { - return cpuMaxFrequenciesMhz; - } - File[] cpuDirs = new File(getSystemCpuPath()).listFiles(); - if (cpuDirs == null) { - return new ArrayList<>(); - } + public @NotNull List readMaxFrequencies() { + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + if (!cpuMaxFrequenciesMhz.isEmpty()) { + return cpuMaxFrequenciesMhz; + } + File[] cpuDirs = new File(getSystemCpuPath()).listFiles(); + if (cpuDirs == null) { + return new ArrayList<>(); + } - for (File cpuDir : cpuDirs) { - if (!cpuDir.getName().matches("cpu[0-9]+")) continue; - File cpuMaxFreqFile = new File(cpuDir, CPUINFO_MAX_FREQ_PATH); + for (File cpuDir : cpuDirs) { + if (!cpuDir.getName().matches("cpu[0-9]+")) continue; + File cpuMaxFreqFile = new File(cpuDir, CPUINFO_MAX_FREQ_PATH); - if (!cpuMaxFreqFile.exists() || !cpuMaxFreqFile.canRead()) continue; + if (!cpuMaxFreqFile.exists() || !cpuMaxFreqFile.canRead()) continue; - long khz; - try { - String content = FileUtils.readText(cpuMaxFreqFile); - if (content == null) continue; - khz = Long.parseLong(content.trim()); - } catch (NumberFormatException e) { - continue; - } catch (IOException e) { - continue; + long khz; + try { + String content = FileUtils.readText(cpuMaxFreqFile); + if (content == null) continue; + khz = Long.parseLong(content.trim()); + } catch (NumberFormatException e) { + continue; + } catch (IOException e) { + continue; + } + cpuMaxFrequenciesMhz.add((int) (khz / 1000)); } - cpuMaxFrequenciesMhz.add((int) (khz / 1000)); + return cpuMaxFrequenciesMhz; } - return cpuMaxFrequenciesMhz; } @VisibleForTesting diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/ScreenshotUtils.java b/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/ScreenshotUtils.java index 45e9d56877..d6cd7bc6af 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/ScreenshotUtils.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/ScreenshotUtils.java @@ -14,7 +14,7 @@ import io.sentry.ILogger; import io.sentry.SentryLevel; import io.sentry.android.core.BuildInfoProvider; -import io.sentry.util.thread.IMainThreadChecker; +import io.sentry.util.thread.IThreadChecker; import java.io.ByteArrayOutputStream; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; @@ -31,14 +31,13 @@ public class ScreenshotUtils { final @NotNull Activity activity, final @NotNull ILogger logger, final @NotNull BuildInfoProvider buildInfoProvider) { - return takeScreenshot( - activity, AndroidMainThreadChecker.getInstance(), logger, buildInfoProvider); + return takeScreenshot(activity, AndroidThreadChecker.getInstance(), logger, buildInfoProvider); } @SuppressLint("NewApi") public static @Nullable byte[] takeScreenshot( final @NotNull Activity activity, - final @NotNull IMainThreadChecker mainThreadChecker, + final @NotNull IThreadChecker threadChecker, final @NotNull ILogger logger, final @NotNull BuildInfoProvider buildInfoProvider) { // We are keeping BuildInfoProvider param for compatibility, as it's being used by @@ -113,7 +112,7 @@ public class ScreenshotUtils { } } else { final Canvas canvas = new Canvas(bitmap); - if (mainThreadChecker.isMainThread()) { + if (threadChecker.isMainThread()) { view.draw(canvas); latch.countDown(); } else { diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/SentryFrameMetricsCollector.java b/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/SentryFrameMetricsCollector.java index 27731e48cf..25ff5da2bd 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/SentryFrameMetricsCollector.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/SentryFrameMetricsCollector.java @@ -17,6 +17,7 @@ import io.sentry.SentryLevel; import io.sentry.SentryOptions; import io.sentry.android.core.BuildInfoProvider; +import io.sentry.android.core.ContextUtils; import io.sentry.util.Objects; import java.lang.ref.WeakReference; import java.lang.reflect.Field; @@ -84,7 +85,9 @@ public SentryFrameMetricsCollector( final @NotNull ILogger logger, final @NotNull BuildInfoProvider buildInfoProvider, final @NotNull WindowFrameMetricsManager windowFrameMetricsManager) { - Objects.requireNonNull(context, "The context is required"); + final @NotNull Context appContext = + Objects.requireNonNull( + ContextUtils.getApplicationContext(context), "The context is required"); this.logger = Objects.requireNonNull(logger, "Logger is required"); this.buildInfoProvider = Objects.requireNonNull(buildInfoProvider, "BuildInfoProvider is required"); @@ -92,7 +95,7 @@ public SentryFrameMetricsCollector( Objects.requireNonNull(windowFrameMetricsManager, "WindowFrameMetricsManager is required"); // registerActivityLifecycleCallbacks is only available if Context is an AppContext - if (!(context instanceof Application)) { + if (!(appContext instanceof Application)) { return; } // FrameMetrics api is only available since sdk version N @@ -110,7 +113,7 @@ public SentryFrameMetricsCollector( // We have to register the lifecycle callback, even if no profile is started, otherwise when we // start a profile, we wouldn't have the current activity and couldn't get the frameMetrics. - ((Application) context).registerActivityLifecycleCallbacks(this); + ((Application) appContext).registerActivityLifecycleCallbacks(this); // Most considerations regarding timestamps of frames are inspired from JankStats library: // https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:metrics/metrics-performance/src/main/java/androidx/metrics/performance/JankStatsApi24Impl.kt diff --git a/sentry-android-core/src/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..996c6ab171 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,18 +1,29 @@ 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.ISentryLifecycleToken; 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; +import io.sentry.util.AutoClosableReentrantLock; import java.util.ArrayList; import java.util.Collections; 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 +34,7 @@ * transformed into SDK specific txn/span data structures. */ @ApiStatus.Internal -public class AppStartMetrics { +public class AppStartMetrics extends ActivityLifecycleCallbacksAdapter { public enum AppStartType { UNKNOWN, @@ -34,6 +45,8 @@ public enum AppStartType { private static long CLASS_LOADED_UPTIME_MS = SystemClock.uptimeMillis(); private static volatile @Nullable AppStartMetrics instance; + public static final @NotNull AutoClosableReentrantLock staticLock = + new AutoClosableReentrantLock(); private @NotNull AppStartType appStartType = AppStartType.UNKNOWN; private boolean appLaunchedInForeground = false; @@ -45,11 +58,13 @@ 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() { - if (instance == null) { - synchronized (AppStartMetrics.class) { + try (final @NotNull ISentryLifecycleToken ignored = staticLock.acquire()) { if (instance == null) { instance = new AppStartMetrics(); } @@ -65,6 +80,7 @@ public AppStartMetrics() { applicationOnCreate = new TimeSpan(); contentProviderOnCreates = new HashMap<>(); activityLifecycles = new ArrayList<>(); + appLaunchedInForeground = ContextUtils.isForegroundImportance(); } /** @@ -102,6 +118,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 +158,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 +187,10 @@ public void clear() { } appStartProfiler = null; appStartSamplingDecision = null; + appLaunchTooLong = false; + appLaunchedInForeground = false; + onCreateTime = null; + isCallbackRegistered = false; } public @Nullable ITransactionProfiler getAppStartProfiler() { @@ -195,7 +228,64 @@ 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); + // We post on the main thread a task to post a check on the main thread. On Pixel devices + // (possibly others) the first task posted on the main thread is called before the + // Activity.onCreate callback. This is a workaround for that, so that the Activity.onCreate + // callback is called before the application one. + new Handler(Looper.getMainLooper()).post(() -> checkCreateTimeOnMain(application)); + } + + private void checkCreateTimeOnMain(final @NotNull Application application) { + new Handler(Looper.getMainLooper()) + .post( + () -> { + // if no activity has ever been created, app was launched in background + if (onCreateTime == null) { + appLaunchedInForeground = false; + + // we stop the app start profiler, as it's useless and likely to timeout + if (appStartProfiler != null && appStartProfiler.isRunning()) { + appStartProfiler.close(); + appStartProfiler = null; + } + } + application.unregisterActivityLifecycleCallbacks(instance); + }); + } + + @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/ActivityBreadcrumbsIntegrationTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/ActivityBreadcrumbsIntegrationTest.kt index 10dc60e74b..56dabd2fbc 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/ActivityBreadcrumbsIntegrationTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/ActivityBreadcrumbsIntegrationTest.kt @@ -4,7 +4,7 @@ import android.app.Activity import android.app.Application import android.os.Bundle import io.sentry.Breadcrumb -import io.sentry.Hub +import io.sentry.Scopes import io.sentry.SentryLevel import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull @@ -20,7 +20,7 @@ class ActivityBreadcrumbsIntegrationTest { private class Fixture { val application = mock() - val hub = mock() + val scopes = mock() val options = SentryAndroidOptions().apply { dsn = "https://key@sentry.io/proj" } @@ -28,7 +28,7 @@ class ActivityBreadcrumbsIntegrationTest { fun getSut(enabled: Boolean = true): ActivityBreadcrumbsIntegration { options.isEnableActivityLifecycleBreadcrumbs = enabled - whenever(hub.options).thenReturn(options) + whenever(scopes.options).thenReturn(options) return ActivityBreadcrumbsIntegration( application ) @@ -40,7 +40,7 @@ class ActivityBreadcrumbsIntegrationTest { @Test fun `When ActivityBreadcrumbsIntegration is disabled, it should not register the activity callback`() { val sut = fixture.getSut(false) - sut.register(fixture.hub, fixture.options) + sut.register(fixture.scopes, fixture.options) verify(fixture.application, never()).registerActivityLifecycleCallbacks(any()) } @@ -48,7 +48,7 @@ class ActivityBreadcrumbsIntegrationTest { @Test fun `When ActivityBreadcrumbsIntegration is enabled, it should register the activity callback`() { val sut = fixture.getSut() - sut.register(fixture.hub, fixture.options) + sut.register(fixture.scopes, fixture.options) verify(fixture.application).registerActivityLifecycleCallbacks(any()) @@ -59,12 +59,12 @@ class ActivityBreadcrumbsIntegrationTest { @Test fun `When breadcrumb is added, type and category should be set`() { val sut = fixture.getSut() - sut.register(fixture.hub, fixture.options) + sut.register(fixture.scopes, fixture.options) val activity = mock() sut.onActivityCreated(activity, fixture.bundle) - verify(fixture.hub).addBreadcrumb( + verify(fixture.scopes).addBreadcrumb( check { assertEquals("ui.lifecycle", it.category) assertEquals("navigation", it.type) @@ -78,77 +78,77 @@ class ActivityBreadcrumbsIntegrationTest { @Test fun `When activity is created, it should add a breadcrumb`() { val sut = fixture.getSut() - sut.register(fixture.hub, fixture.options) + sut.register(fixture.scopes, fixture.options) val activity = mock() sut.onActivityCreated(activity, fixture.bundle) - verify(fixture.hub).addBreadcrumb(any(), anyOrNull()) + verify(fixture.scopes).addBreadcrumb(any(), anyOrNull()) } @Test fun `When activity is started, it should add a breadcrumb`() { val sut = fixture.getSut() - sut.register(fixture.hub, fixture.options) + sut.register(fixture.scopes, fixture.options) val activity = mock() sut.onActivityStarted(activity) - verify(fixture.hub).addBreadcrumb(any(), anyOrNull()) + verify(fixture.scopes).addBreadcrumb(any(), anyOrNull()) } @Test fun `When activity is resumed, it should add a breadcrumb`() { val sut = fixture.getSut() - sut.register(fixture.hub, fixture.options) + sut.register(fixture.scopes, fixture.options) val activity = mock() sut.onActivityResumed(activity) - verify(fixture.hub).addBreadcrumb(any(), anyOrNull()) + verify(fixture.scopes).addBreadcrumb(any(), anyOrNull()) } @Test fun `When activity is paused, it should add a breadcrumb`() { val sut = fixture.getSut() - sut.register(fixture.hub, fixture.options) + sut.register(fixture.scopes, fixture.options) val activity = mock() sut.onActivityPaused(activity) - verify(fixture.hub).addBreadcrumb(any(), anyOrNull()) + verify(fixture.scopes).addBreadcrumb(any(), anyOrNull()) } @Test fun `When activity is stopped, it should add a breadcrumb`() { val sut = fixture.getSut() - sut.register(fixture.hub, fixture.options) + sut.register(fixture.scopes, fixture.options) val activity = mock() sut.onActivityStopped(activity) - verify(fixture.hub).addBreadcrumb(any(), anyOrNull()) + verify(fixture.scopes).addBreadcrumb(any(), anyOrNull()) } @Test fun `When activity is save instance, it should add a breadcrumb`() { val sut = fixture.getSut() - sut.register(fixture.hub, fixture.options) + sut.register(fixture.scopes, fixture.options) val activity = mock() sut.onActivitySaveInstanceState(activity, fixture.bundle) - verify(fixture.hub).addBreadcrumb(any(), anyOrNull()) + verify(fixture.scopes).addBreadcrumb(any(), anyOrNull()) } @Test fun `When activity is destroyed, it should add a breadcrumb`() { val sut = fixture.getSut() - sut.register(fixture.hub, fixture.options) + sut.register(fixture.scopes, fixture.options) val activity = mock() sut.onActivityDestroyed(activity) - verify(fixture.hub).addBreadcrumb(any(), anyOrNull()) + verify(fixture.scopes).addBreadcrumb(any(), anyOrNull()) } } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/ActivityFramesTrackerTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/ActivityFramesTrackerTest.kt index f42d903415..db9912c052 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/ActivityFramesTrackerTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/ActivityFramesTrackerTest.kt @@ -30,6 +30,11 @@ class ActivityFramesTrackerTest { val handler = mock() val options = SentryAndroidOptions() + init { + // ActivityFramesTracker is used only if performanceV2 is disabled + options.isEnablePerformanceV2 = false + } + fun getSut(mockAggregator: Boolean = true): ActivityFramesTracker { return if (mockAggregator) { ActivityFramesTracker(loadClass, options, handler, aggregator) 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..675626522c 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 @@ -14,10 +14,10 @@ import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 import io.sentry.DateUtils import io.sentry.FullyDisplayedReporter -import io.sentry.Hub import io.sentry.IScope import io.sentry.Scope import io.sentry.ScopeCallback +import io.sentry.Scopes import io.sentry.Sentry import io.sentry.SentryDate import io.sentry.SentryDateProvider @@ -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 @@ -71,13 +72,12 @@ class ActivityLifecycleIntegrationTest { private class Fixture { val application = mock() - val hub = mock() + val scopes = mock() val options = SentryAndroidOptions().apply { dsn = "https://key@sentry.io/proj" } val bundle = mock() val activityFramesTracker = mock() - val fullyDisplayedReporter = FullyDisplayedReporter.getInstance() val transactionFinishedCallback = mock() lateinit var shadowActivityManager: ShadowActivityManager @@ -92,13 +92,14 @@ class ActivityLifecycleIntegrationTest { ): ActivityLifecycleIntegration { initializer?.configure(options) - whenever(hub.options).thenReturn(options) + whenever(scopes.options).thenReturn(options) + AppStartMetrics.getInstance().isAppLaunchedInForeground = true // We let the ActivityLifecycleIntegration create the proper transaction here val optionCaptor = argumentCaptor() val contextCaptor = argumentCaptor() - whenever(hub.startTransaction(contextCaptor.capture(), optionCaptor.capture())).thenAnswer { - val t = SentryTracer(contextCaptor.lastValue, hub, optionCaptor.lastValue) + whenever(scopes.startTransaction(contextCaptor.capture(), optionCaptor.capture())).thenAnswer { + val t = SentryTracer(contextCaptor.lastValue, scopes, optionCaptor.lastValue) transaction = t return@thenAnswer t } @@ -145,7 +146,7 @@ class ActivityLifecycleIntegrationTest { @Test fun `When ActivityLifecycleIntegration is registered, it registers activity callback`() { val sut = fixture.getSut() - sut.register(fixture.hub, fixture.options) + sut.register(fixture.scopes, fixture.options) verify(fixture.application).registerActivityLifecycleCallbacks(any()) } @@ -153,7 +154,7 @@ class ActivityLifecycleIntegrationTest { @Test fun `When ActivityLifecycleIntegration is closed, it should unregister the callback`() { val sut = fixture.getSut() - sut.register(fixture.hub, fixture.options) + sut.register(fixture.scopes, fixture.options) sut.close() @@ -163,7 +164,7 @@ class ActivityLifecycleIntegrationTest { @Test fun `When ActivityLifecycleIntegration is closed, it should close the ActivityFramesTracker`() { val sut = fixture.getSut() - sut.register(fixture.hub, fixture.options) + sut.register(fixture.scopes, fixture.options) sut.close() @@ -173,39 +174,39 @@ class ActivityLifecycleIntegrationTest { @Test fun `When tracing is disabled, do not start tracing`() { val sut = fixture.getSut() - sut.register(fixture.hub, fixture.options) + sut.register(fixture.scopes, fixture.options) val activity = mock() sut.onActivityCreated(activity, fixture.bundle) - verify(fixture.hub, never()).startTransaction(any(), any()) + verify(fixture.scopes, never()).startTransaction(any(), any()) } @Test fun `When tracing is enabled but activity is running, do not start tracing again`() { val sut = fixture.getSut() fixture.options.tracesSampleRate = 1.0 - sut.register(fixture.hub, fixture.options) + sut.register(fixture.scopes, fixture.options) val activity = mock() sut.onActivityCreated(activity, fixture.bundle) sut.onActivityCreated(activity, fixture.bundle) - verify(fixture.hub).startTransaction(any(), any()) + verify(fixture.scopes).startTransaction(any(), any()) } @Test fun `Transaction op is ui_load and idle+deadline timeouts are set`() { val sut = fixture.getSut() fixture.options.tracesSampleRate = 1.0 - sut.register(fixture.hub, fixture.options) + sut.register(fixture.scopes, fixture.options) setAppStartTime() val activity = mock() sut.onActivityCreated(activity, fixture.bundle) - verify(fixture.hub).startTransaction( + verify(fixture.scopes).startTransaction( check { assertEquals("ui.load", it.operation) assertEquals(TransactionNameSource.COMPONENT, it.transactionNameSource) @@ -213,6 +214,7 @@ class ActivityLifecycleIntegrationTest { check { transactionOptions -> assertEquals(fixture.options.idleTimeout, transactionOptions.idleTimeout) assertEquals(TransactionOptions.DEFAULT_DEADLINE_TIMEOUT_AUTO_TRANSACTION, transactionOptions.deadlineTimeout) + assertEquals("auto.ui.activity", transactionOptions.origin) } ) } @@ -221,7 +223,7 @@ class ActivityLifecycleIntegrationTest { fun `Activity gets added to ActivityFramesTracker during transaction creation`() { val sut = fixture.getSut() fixture.options.tracesSampleRate = 1.0 - sut.register(fixture.hub, fixture.options) + sut.register(fixture.scopes, fixture.options) val activity = mock() sut.onActivityStarted(activity) @@ -233,14 +235,14 @@ class ActivityLifecycleIntegrationTest { fun `Transaction name is the Activity's name`() { val sut = fixture.getSut() fixture.options.tracesSampleRate = 1.0 - sut.register(fixture.hub, fixture.options) + sut.register(fixture.scopes, fixture.options) setAppStartTime() val activity = mock() sut.onActivityCreated(activity, fixture.bundle) - verify(fixture.hub).startTransaction( + verify(fixture.scopes).startTransaction( check { assertEquals("Activity", it.name) assertEquals(TransactionNameSource.COMPONENT, it.transactionNameSource) @@ -254,9 +256,9 @@ class ActivityLifecycleIntegrationTest { val sut = fixture.getSut() fixture.options.tracesSampleRate = 1.0 - sut.register(fixture.hub, fixture.options) + sut.register(fixture.scopes, fixture.options) - whenever(fixture.hub.configureScope(any())).thenAnswer { + whenever(fixture.scopes.configureScope(any())).thenAnswer { val scope = Scope(fixture.options) sut.applyScope(scope, fixture.transaction) @@ -273,11 +275,11 @@ class ActivityLifecycleIntegrationTest { val sut = fixture.getSut() fixture.options.tracesSampleRate = 1.0 - sut.register(fixture.hub, fixture.options) + sut.register(fixture.scopes, fixture.options) - whenever(fixture.hub.configureScope(any())).thenAnswer { + whenever(fixture.scopes.configureScope(any())).thenAnswer { val scope = Scope(fixture.options) - val previousTransaction = SentryTracer(TransactionContext("name", "op"), fixture.hub) + val previousTransaction = SentryTracer(TransactionContext("name", "op"), fixture.scopes) scope.transaction = previousTransaction sut.applyScope(scope, fixture.transaction) @@ -297,14 +299,14 @@ class ActivityLifecycleIntegrationTest { it.isEnableTimeToFullDisplayTracing = true it.idleTimeout = 200 }) - sut.register(fixture.hub, fixture.options) + sut.register(fixture.scopes, fixture.options) sut.onActivityCreated(activity, fixture.bundle) sut.ttidSpanMap.values.first().finish() sut.ttfdSpanMap.values.first().finish() // then transaction should not be immediately finished - verify(fixture.hub, never()) + verify(fixture.scopes, never()) .captureTransaction( anyOrNull(), anyOrNull(), @@ -316,7 +318,7 @@ class ActivityLifecycleIntegrationTest { Thread.sleep(400) // then the transaction should be finished - verify(fixture.hub).captureTransaction( + verify(fixture.scopes).captureTransaction( check { assertEquals(SpanStatus.OK, it.status) }, @@ -330,13 +332,13 @@ class ActivityLifecycleIntegrationTest { fun `When tracing auto finish is enabled, it doesn't stop the transaction on onActivityPostResumed`() { val sut = fixture.getSut() fixture.options.tracesSampleRate = 1.0 - sut.register(fixture.hub, fixture.options) + sut.register(fixture.scopes, fixture.options) val activity = mock() sut.onActivityCreated(activity, fixture.bundle) sut.onActivityPostResumed(activity) - verify(fixture.hub, never()).captureTransaction( + verify(fixture.scopes, never()).captureTransaction( check { assertEquals(SpanStatus.OK, it.status) }, @@ -350,7 +352,7 @@ class ActivityLifecycleIntegrationTest { fun `When tracing has status, do not overwrite it`() { val sut = fixture.getSut() fixture.options.tracesSampleRate = 1.0 - sut.register(fixture.hub, fixture.options) + sut.register(fixture.scopes, fixture.options) val activity = mock() sut.onActivityCreated(activity, fixture.bundle) @@ -360,7 +362,7 @@ class ActivityLifecycleIntegrationTest { sut.onActivityPostResumed(activity) sut.onActivityDestroyed(activity) - verify(fixture.hub).captureTransaction( + verify(fixture.scopes).captureTransaction( check { assertEquals(SpanStatus.UNKNOWN_ERROR, it.status) }, @@ -376,43 +378,43 @@ class ActivityLifecycleIntegrationTest { it.tracesSampleRate = 1.0 it.isEnableActivityLifecycleTracingAutoFinish = false }) - sut.register(fixture.hub, fixture.options) + sut.register(fixture.scopes, fixture.options) val activity = mock() sut.onActivityCreated(activity, fixture.bundle) sut.onActivityPostResumed(activity) - verify(fixture.hub, never()).captureTransaction(any(), anyOrNull(), anyOrNull(), anyOrNull()) + verify(fixture.scopes, never()).captureTransaction(any(), anyOrNull(), anyOrNull(), anyOrNull()) } @Test fun `When tracing is disabled, do not finish transaction`() { val sut = fixture.getSut() - sut.register(fixture.hub, fixture.options) + sut.register(fixture.scopes, fixture.options) val activity = mock() sut.onActivityPostResumed(activity) - verify(fixture.hub, never()).captureTransaction(any(), anyOrNull(), anyOrNull(), anyOrNull()) + verify(fixture.scopes, never()).captureTransaction(any(), anyOrNull(), anyOrNull(), anyOrNull()) } @Test fun `When Activity is destroyed but transaction is running, finish it`() { val sut = fixture.getSut() fixture.options.tracesSampleRate = 1.0 - sut.register(fixture.hub, fixture.options) + sut.register(fixture.scopes, fixture.options) val activity = mock() sut.onActivityCreated(activity, fixture.bundle) sut.onActivityDestroyed(activity) - verify(fixture.hub).captureTransaction(any(), anyOrNull(), anyOrNull(), anyOrNull()) + verify(fixture.scopes).captureTransaction(any(), anyOrNull(), anyOrNull(), anyOrNull()) } @Test fun `When transaction is started, adds to WeakWef`() { val sut = fixture.getSut() fixture.options.tracesSampleRate = 1.0 - sut.register(fixture.hub, fixture.options) + sut.register(fixture.scopes, fixture.options) val activity = mock() sut.onActivityCreated(activity, fixture.bundle) @@ -424,7 +426,7 @@ class ActivityLifecycleIntegrationTest { fun `When Activity is destroyed removes WeakRef`() { val sut = fixture.getSut() fixture.options.tracesSampleRate = 1.0 - sut.register(fixture.hub, fixture.options) + sut.register(fixture.scopes, fixture.options) val activity = mock() sut.onActivityCreated(activity, fixture.bundle) @@ -437,7 +439,7 @@ class ActivityLifecycleIntegrationTest { fun `When Activity is destroyed, sets appStartSpan status to cancelled and finish it`() { val sut = fixture.getSut() fixture.options.tracesSampleRate = 1.0 - sut.register(fixture.hub, fixture.options) + sut.register(fixture.scopes, fixture.options) setAppStartTime() @@ -454,7 +456,7 @@ class ActivityLifecycleIntegrationTest { fun `When Activity is destroyed, sets appStartSpan to null`() { val sut = fixture.getSut() fixture.options.tracesSampleRate = 1.0 - sut.register(fixture.hub, fixture.options) + sut.register(fixture.scopes, fixture.options) setAppStartTime() @@ -469,7 +471,7 @@ class ActivityLifecycleIntegrationTest { fun `When Activity is destroyed, sets ttidSpan status to deadline_exceeded and finish it`() { val sut = fixture.getSut() fixture.options.tracesSampleRate = 1.0 - sut.register(fixture.hub, fixture.options) + sut.register(fixture.scopes, fixture.options) setAppStartTime() @@ -486,7 +488,7 @@ class ActivityLifecycleIntegrationTest { fun `When Activity is destroyed, sets ttidSpan to null`() { val sut = fixture.getSut() fixture.options.tracesSampleRate = 1.0 - sut.register(fixture.hub, fixture.options) + sut.register(fixture.scopes, fixture.options) setAppStartTime() @@ -503,7 +505,7 @@ class ActivityLifecycleIntegrationTest { val sut = fixture.getSut() fixture.options.tracesSampleRate = 1.0 fixture.options.isEnableTimeToFullDisplayTracing = true - sut.register(fixture.hub, fixture.options) + sut.register(fixture.scopes, fixture.options) setAppStartTime() @@ -521,7 +523,7 @@ class ActivityLifecycleIntegrationTest { val sut = fixture.getSut() fixture.options.tracesSampleRate = 1.0 fixture.options.isEnableTimeToFullDisplayTracing = true - sut.register(fixture.hub, fixture.options) + sut.register(fixture.scopes, fixture.options) setAppStartTime() @@ -537,25 +539,25 @@ class ActivityLifecycleIntegrationTest { fun `When new Activity and transaction is created, finish previous ones`() { val sut = fixture.getSut() fixture.options.tracesSampleRate = 1.0 - sut.register(fixture.hub, fixture.options) + sut.register(fixture.scopes, fixture.options) sut.onActivityCreated(mock(), mock()) sut.onActivityCreated(mock(), fixture.bundle) - verify(fixture.hub).captureTransaction(any(), anyOrNull(), anyOrNull(), anyOrNull()) + verify(fixture.scopes).captureTransaction(any(), anyOrNull(), anyOrNull(), anyOrNull()) } @Test fun `do not stop transaction on resumed if API 29`() { val sut = fixture.getSut() fixture.options.tracesSampleRate = 1.0 - sut.register(fixture.hub, fixture.options) + sut.register(fixture.scopes, fixture.options) val activity = mock() sut.onActivityCreated(activity, mock()) sut.onActivityResumed(activity) - verify(fixture.hub, never()).captureTransaction(any(), any(), anyOrNull(), anyOrNull()) + verify(fixture.scopes, never()).captureTransaction(any(), any(), anyOrNull(), anyOrNull()) } @Test @@ -563,7 +565,7 @@ class ActivityLifecycleIntegrationTest { val sut = fixture.getSut(Build.VERSION_CODES.P) fixture.options.tracesSampleRate = 1.0 fixture.options.isEnableTimeToFullDisplayTracing = true - sut.register(fixture.hub, fixture.options) + sut.register(fixture.scopes, fixture.options) val activity = mock() sut.onActivityCreated(activity, mock()) @@ -571,21 +573,21 @@ class ActivityLifecycleIntegrationTest { sut.ttfdSpanMap.values.first().finish() sut.onActivityResumed(activity) - verify(fixture.hub, never()).captureTransaction(any(), any(), anyOrNull(), anyOrNull()) + verify(fixture.scopes, never()).captureTransaction(any(), any(), anyOrNull(), anyOrNull()) } @Test fun `start transaction on created if API less than 29`() { val sut = fixture.getSut(Build.VERSION_CODES.P) fixture.options.tracesSampleRate = 1.0 - sut.register(fixture.hub, fixture.options) + sut.register(fixture.scopes, fixture.options) setAppStartTime() val activity = mock() sut.onActivityCreated(activity, mock()) - verify(fixture.hub).startTransaction(any(), any()) + verify(fixture.scopes).startTransaction(any(), any()) } @Test @@ -593,7 +595,7 @@ class ActivityLifecycleIntegrationTest { val sut = fixture.getSut() fixture.options.tracesSampleRate = 1.0 fixture.options.isEnableTimeToFullDisplayTracing = true - sut.register(fixture.hub, fixture.options) + sut.register(fixture.scopes, fixture.options) val activity = mock() sut.onActivityCreated(activity, mock()) @@ -611,22 +613,41 @@ class ActivityLifecycleIntegrationTest { val sut = fixture.getSut() fixture.options.tracesSampleRate = 1.0 fixture.options.isEnableTimeToFullDisplayTracing = true - sut.register(fixture.hub, fixture.options) + sut.register(fixture.scopes, fixture.options) val activity = mock() sut.onActivityCreated(activity, mock()) val ttfdSpan = sut.ttfdSpanMap[activity] sut.ttidSpanMap.values.first().finish() - fixture.fullyDisplayedReporter.reportFullyDrawn() + fixture.options.fullyDisplayedReporter.reportFullyDrawn() assertTrue(ttfdSpan!!.isFinished) assertNotEquals(SpanStatus.CANCELLED, ttfdSpan.status) } + @Test + fun `if ttfd is disabled, no listener is registered for FullyDisplayedReporter`() { + val ttfdReporter = mock() + + val sut = fixture.getSut() + fixture.options.apply { + tracesSampleRate = 1.0 + isEnableTimeToFullDisplayTracing = false + fullyDisplayedReporter = ttfdReporter + } + + sut.register(fixture.scopes, fixture.options) + + val activity = mock() + sut.onActivityCreated(activity, mock()) + + verify(ttfdReporter, never()).registerFullyDrawnListener(any()) + } + @Test fun `App start is Cold when savedInstanceState is null`() { val sut = fixture.getSut() fixture.options.tracesSampleRate = 1.0 - sut.register(fixture.hub, fixture.options) + sut.register(fixture.scopes, fixture.options) val activity = mock() sut.onActivityCreated(activity, null) @@ -638,7 +659,7 @@ class ActivityLifecycleIntegrationTest { fun `App start is Warm when savedInstanceState is not null`() { val sut = fixture.getSut() fixture.options.tracesSampleRate = 1.0 - sut.register(fixture.hub, fixture.options) + sut.register(fixture.scopes, fixture.options) val activity = mock() val bundle = Bundle() @@ -651,7 +672,7 @@ class ActivityLifecycleIntegrationTest { fun `Do not overwrite App start type after set`() { val sut = fixture.getSut() fixture.options.tracesSampleRate = 1.0 - sut.register(fixture.hub, fixture.options) + sut.register(fixture.scopes, fixture.options) val activity = mock() val bundle = Bundle() @@ -665,7 +686,7 @@ class ActivityLifecycleIntegrationTest { fun `When firstActivityCreated is true, start transaction with given appStartTime`() { val sut = fixture.getSut() fixture.options.tracesSampleRate = 1.0 - sut.register(fixture.hub, fixture.options) + sut.register(fixture.scopes, fixture.options) val date = SentryNanotimeDate(Date(1), 0) setAppStartTime(date) @@ -674,7 +695,7 @@ class ActivityLifecycleIntegrationTest { sut.onActivityCreated(activity, fixture.bundle) // call only once - verify(fixture.hub).startTransaction( + verify(fixture.scopes).startTransaction( any(), check { assertEquals(date.nanoTimestamp(), it.startTimestamp!!.nanoTimestamp()) @@ -686,7 +707,7 @@ class ActivityLifecycleIntegrationTest { fun `When firstActivityCreated is true and app start sampling decision is set, start transaction with isAppStart true`() { AppStartMetrics.getInstance().appStartSamplingDecision = mock() val sut = fixture.getSut { it.tracesSampleRate = 1.0 } - sut.register(fixture.hub, fixture.options) + sut.register(fixture.scopes, fixture.options) val date = SentryNanotimeDate(Date(1), 0) setAppStartTime(date) @@ -694,7 +715,7 @@ class ActivityLifecycleIntegrationTest { val activity = mock() sut.onActivityCreated(activity, fixture.bundle) - verify(fixture.hub).startTransaction( + verify(fixture.scopes).startTransaction( any(), check { assertEquals(date.nanoTimestamp(), it.startTimestamp!!.nanoTimestamp()) @@ -706,18 +727,22 @@ class ActivityLifecycleIntegrationTest { @Test fun `When firstActivityCreated is true and app start sampling decision is not set, start transaction with isAppStart false`() { val sut = fixture.getSut { it.tracesSampleRate = 1.0 } - sut.register(fixture.hub, fixture.options) + sut.register(fixture.scopes, 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( + verify(fixture.scopes).startTransaction( any(), check { assertEquals(date.nanoTimestamp(), it.startTimestamp!!.nanoTimestamp()) + assertNotEquals(date2.nanoTimestamp(), it.startTimestamp!!.nanoTimestamp()) assertFalse(it.isAppStartTransaction) } ) @@ -727,19 +752,19 @@ class ActivityLifecycleIntegrationTest { fun `When firstActivityCreated is false and app start sampling decision is set, start transaction with isAppStart false`() { AppStartMetrics.getInstance().appStartSamplingDecision = mock() val sut = fixture.getSut { it.tracesSampleRate = 1.0 } - sut.register(fixture.hub, fixture.options) + sut.register(fixture.scopes, fixture.options) val activity = mock() sut.onActivityCreated(activity, fixture.bundle) - verify(fixture.hub).startTransaction(any(), check { assertFalse(it.isAppStartTransaction) }) + verify(fixture.scopes).startTransaction(any(), check { assertFalse(it.isAppStartTransaction) }) } @Test fun `When firstActivityCreated is true, do not create app start span if not foregroundImportance`() { val sut = fixture.getSut(importance = RunningAppProcessInfo.IMPORTANCE_BACKGROUND) fixture.options.tracesSampleRate = 1.0 - sut.register(fixture.hub, fixture.options) + sut.register(fixture.scopes, fixture.options) // usually set by SentryPerformanceProvider val date = SentryNanotimeDate(Date(1), 0) @@ -750,17 +775,41 @@ class ActivityLifecycleIntegrationTest { sut.onActivityCreated(activity, fixture.bundle) // call only once - verify(fixture.hub).startTransaction( + verify(fixture.scopes).startTransaction( any(), check { assertNotEquals(date, it.startTimestamp) } ) } + @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.scopes, 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.scopes).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) fixture.options.tracesSampleRate = 1.0 - sut.register(fixture.hub, fixture.options) + sut.register(fixture.scopes, fixture.options) // usually set by SentryPerformanceProvider val startDate = SentryNanotimeDate(Date(1), 0) @@ -768,6 +817,7 @@ class ActivityLifecycleIntegrationTest { val appStartMetrics = AppStartMetrics.getInstance() appStartMetrics.appStartType = AppStartType.WARM appStartMetrics.sdkInitTimeSpan.setStoppedAt(2) + appStartMetrics.appStartTimeSpan.setStoppedAt(2) val endDate = appStartMetrics.sdkInitTimeSpan.projectedStopTimestamp @@ -786,7 +836,7 @@ class ActivityLifecycleIntegrationTest { fun `When SentryPerformanceProvider is disabled, app start time span is still created`() { val sut = fixture.getSut(importance = RunningAppProcessInfo.IMPORTANCE_FOREGROUND) fixture.options.tracesSampleRate = 1.0 - sut.register(fixture.hub, fixture.options) + sut.register(fixture.scopes, fixture.options) // usually done by SentryPerformanceProvider, if disabled it's done by // SentryAndroid.init @@ -814,7 +864,7 @@ class ActivityLifecycleIntegrationTest { fun `When app-start end time is already set, it should not be overwritten`() { val sut = fixture.getSut(importance = RunningAppProcessInfo.IMPORTANCE_FOREGROUND) fixture.options.tracesSampleRate = 1.0 - sut.register(fixture.hub, fixture.options) + sut.register(fixture.scopes, fixture.options) // usually done by SentryPerformanceProvider val startDate = SentryNanotimeDate(Date(1), 0) @@ -838,7 +888,7 @@ class ActivityLifecycleIntegrationTest { fun `When activity lifecycle happens multiple times, app-start end time should not be overwritten`() { val sut = fixture.getSut(importance = RunningAppProcessInfo.IMPORTANCE_FOREGROUND) fixture.options.tracesSampleRate = 1.0 - sut.register(fixture.hub, fixture.options) + sut.register(fixture.scopes, fixture.options) // usually done by SentryPerformanceProvider val startDate = SentryNanotimeDate(Date(1), 0) @@ -876,7 +926,7 @@ class ActivityLifecycleIntegrationTest { fun `When firstActivityCreated is true, start app start warm span with given appStartTime`() { val sut = fixture.getSut() fixture.options.tracesSampleRate = 1.0 - sut.register(fixture.hub, fixture.options) + sut.register(fixture.scopes, fixture.options) val date = SentryNanotimeDate(Date(1), 0) setAppStartTime(date) @@ -893,7 +943,7 @@ class ActivityLifecycleIntegrationTest { fun `When firstActivityCreated is true, start app start cold span with given appStartTime`() { val sut = fixture.getSut() fixture.options.tracesSampleRate = 1.0 - sut.register(fixture.hub, fixture.options) + sut.register(fixture.scopes, fixture.options) val date = SentryNanotimeDate(Date(1), 0) setAppStartTime(date) @@ -910,7 +960,7 @@ class ActivityLifecycleIntegrationTest { fun `When firstActivityCreated is true, start app start span with Warm description`() { val sut = fixture.getSut() fixture.options.tracesSampleRate = 1.0 - sut.register(fixture.hub, fixture.options) + sut.register(fixture.scopes, fixture.options) val date = SentryNanotimeDate(Date(1), 0) setAppStartTime(date) @@ -927,7 +977,7 @@ class ActivityLifecycleIntegrationTest { fun `When firstActivityCreated is true, start app start span with Cold description`() { val sut = fixture.getSut() fixture.options.tracesSampleRate = 1.0 - sut.register(fixture.hub, fixture.options) + sut.register(fixture.scopes, fixture.options) val date = SentryNanotimeDate(Date(1), 0) setAppStartTime(date) @@ -940,11 +990,51 @@ 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.scopes, 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.scopes, 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() fixture.options.tracesSampleRate = 1.0 - sut.register(fixture.hub, fixture.options) + sut.register(fixture.scopes, fixture.options) val date = SentryNanotimeDate(Date(1), 0) setAppStartTime() @@ -965,12 +1055,12 @@ class ActivityLifecycleIntegrationTest { fun `When transaction is finished, it gets removed from scope`() { val sut = fixture.getSut() fixture.options.tracesSampleRate = 1.0 - sut.register(fixture.hub, fixture.options) + sut.register(fixture.scopes, fixture.options) val activity = mock() sut.onActivityCreated(activity, fixture.bundle) - whenever(fixture.hub.configureScope(any())).thenAnswer { + whenever(fixture.scopes.configureScope(any())).thenAnswer { val scope = Scope(fixture.options) scope.transaction = fixture.transaction @@ -988,7 +1078,7 @@ class ActivityLifecycleIntegrationTest { val sut = fixture.getSut() fixture.options.tracesSampleRate = 1.0 fixture.options.isEnableTimeToFullDisplayTracing = false - sut.register(fixture.hub, fixture.options) + sut.register(fixture.scopes, fixture.options) val activity = mock() sut.onActivityCreated(activity, fixture.bundle) @@ -1001,7 +1091,7 @@ class ActivityLifecycleIntegrationTest { val sut = fixture.getSut() fixture.options.tracesSampleRate = 1.0 fixture.options.isEnableTimeToFullDisplayTracing = true - sut.register(fixture.hub, fixture.options) + sut.register(fixture.scopes, fixture.options) val activity = mock() sut.onActivityCreated(activity, fixture.bundle) @@ -1016,7 +1106,7 @@ class ActivityLifecycleIntegrationTest { fixture.options.tracesSampleRate = 1.0 fixture.options.isEnableTimeToFullDisplayTracing = true fixture.options.executorService = deferredExecutorService - sut.register(fixture.hub, fixture.options) + sut.register(fixture.scopes, fixture.options) val activity = mock() sut.onActivityCreated(activity, fixture.bundle) val ttfdSpan = sut.ttfdSpanMap[activity] @@ -1032,7 +1122,7 @@ class ActivityLifecycleIntegrationTest { assertEquals(SpanStatus.DEADLINE_EXCEEDED, ttfdSpan.status) sut.onActivityDestroyed(activity) - verify(fixture.hub).captureTransaction( + verify(fixture.scopes).captureTransaction( check { // ttfd timed out, so its measurement should not be set val ttfdMeasurement = it.measurements[MeasurementValue.KEY_TIME_TO_FULL_DISPLAY] @@ -1049,7 +1139,7 @@ class ActivityLifecycleIntegrationTest { val sut = fixture.getSut() fixture.options.tracesSampleRate = 1.0 fixture.options.isEnableTimeToFullDisplayTracing = true - sut.register(fixture.hub, fixture.options) + sut.register(fixture.scopes, fixture.options) val activity = mock() sut.onActivityCreated(activity, fixture.bundle) val ttfdSpan = sut.ttfdSpanMap[activity] @@ -1072,7 +1162,7 @@ class ActivityLifecycleIntegrationTest { assertNull(autoCloseFuture) sut.onActivityDestroyed(activity) - verify(fixture.hub).captureTransaction( + verify(fixture.scopes).captureTransaction( check { // ttfd was finished successfully, so its measurement should be set val ttfdMeasurement = it.measurements[MeasurementValue.KEY_TIME_TO_FULL_DISPLAY] @@ -1090,7 +1180,7 @@ class ActivityLifecycleIntegrationTest { val sut = fixture.getSut() fixture.options.tracesSampleRate = 1.0 fixture.options.isEnableTimeToFullDisplayTracing = true - sut.register(fixture.hub, fixture.options) + sut.register(fixture.scopes, fixture.options) val activity = mock() val activity2 = mock() sut.onActivityCreated(activity, fixture.bundle) @@ -1128,7 +1218,7 @@ class ActivityLifecycleIntegrationTest { whenever(activity.findViewById(any())).thenReturn(view) // Make the integration create the spans and register to the FirstDrawDoneListener - sut.register(fixture.hub, fixture.options) + sut.register(fixture.scopes, fixture.options) sut.onActivityCreated(activity, fixture.bundle) sut.onActivityResumed(activity) @@ -1143,7 +1233,7 @@ class ActivityLifecycleIntegrationTest { assertTrue(ttidSpan.isFinished) sut.onActivityDestroyed(activity) - verify(fixture.hub).captureTransaction( + verify(fixture.scopes).captureTransaction( check { // ttid measurement should be set val ttidMeasurement = it.measurements[MeasurementValue.KEY_TIME_TO_INITIAL_DISPLAY] @@ -1166,7 +1256,7 @@ class ActivityLifecycleIntegrationTest { whenever(activity.findViewById(any())).thenReturn(view) // Make the integration create the spans and register to the FirstDrawDoneListener - sut.register(fixture.hub, fixture.options) + sut.register(fixture.scopes, fixture.options) sut.onActivityCreated(activity, fixture.bundle) sut.onActivityResumed(activity) @@ -1189,7 +1279,7 @@ class ActivityLifecycleIntegrationTest { assertEquals(newEndDate, ttidSpan.finishDate) sut.onActivityDestroyed(activity) - verify(fixture.hub).captureTransaction( + verify(fixture.scopes).captureTransaction( check { // ttid and ttfd measurements should be the same val ttidMeasurement = it.measurements[MeasurementValue.KEY_TIME_TO_INITIAL_DISPLAY] @@ -1211,7 +1301,7 @@ class ActivityLifecycleIntegrationTest { fixture.options.tracesSampleRate = 1.0 fixture.options.isEnableTimeToFullDisplayTracing = true - sut.register(fixture.hub, fixture.options) + sut.register(fixture.scopes, fixture.options) sut.onActivityCreated(activity, fixture.bundle) // The ttid span should be running @@ -1233,7 +1323,7 @@ class ActivityLifecycleIntegrationTest { fixture.options.tracesSampleRate = 1.0 fixture.options.isEnableTimeToFullDisplayTracing = true fixture.options.executorService = deferredExecutorService - sut.register(fixture.hub, fixture.options) + sut.register(fixture.scopes, fixture.options) sut.onActivityCreated(activity, fixture.bundle) sut.onActivityResumed(activity) @@ -1268,7 +1358,7 @@ class ActivityLifecycleIntegrationTest { fixture.options.tracesSampleRate = 1.0 fixture.options.isEnableTimeToFullDisplayTracing = true - sut.register(fixture.hub, fixture.options) + sut.register(fixture.scopes, fixture.options) sut.onActivityCreated(activity, fixture.bundle) val ttfdSpan = sut.ttfdSpanMap[activity] assertNotNull(ttfdSpan) @@ -1289,20 +1379,20 @@ class ActivityLifecycleIntegrationTest { fun `starts new trace if performance is disabled`() { val sut = fixture.getSut() val activity = mock() - fixture.options.enableTracing = false + fixture.options.tracesSampleRate = null val argumentCaptor: ArgumentCaptor = ArgumentCaptor.forClass(ScopeCallback::class.java) val scope = Scope(fixture.options) val propagationContextAtStart = scope.propagationContext - whenever(fixture.hub.configureScope(argumentCaptor.capture())).thenAnswer { + whenever(fixture.scopes.configureScope(argumentCaptor.capture())).thenAnswer { argumentCaptor.value.run(scope) } - sut.register(fixture.hub, fixture.options) + sut.register(fixture.scopes, fixture.options) sut.onActivityCreated(activity, fixture.bundle) // once for the screen, and once for the tracing propagation context - verify(fixture.hub, times(2)).configureScope(any()) + verify(fixture.scopes, times(2)).configureScope(any()) assertNotSame(propagationContextAtStart, scope.propagationContext) } @@ -1310,19 +1400,19 @@ class ActivityLifecycleIntegrationTest { fun `sets the activity as the current screen`() { val sut = fixture.getSut() val activity = mock() - fixture.options.enableTracing = false + fixture.options.tracesSampleRate = null val argumentCaptor: ArgumentCaptor = ArgumentCaptor.forClass(ScopeCallback::class.java) val scope = mock() - whenever(fixture.hub.configureScope(argumentCaptor.capture())).thenAnswer { + whenever(fixture.scopes.configureScope(argumentCaptor.capture())).thenAnswer { argumentCaptor.value.run(scope) } - sut.register(fixture.hub, fixture.options) + sut.register(fixture.scopes, fixture.options) sut.onActivityCreated(activity, fixture.bundle) // once for the screen, and once for the tracing propagation context - verify(fixture.hub, times(2)).configureScope(any()) + verify(fixture.scopes, times(2)).configureScope(any()) verify(scope).setScreen(any()) } @@ -1330,37 +1420,37 @@ class ActivityLifecycleIntegrationTest { fun `does not start another new trace if one has already been started but does after activity was destroyed`() { val sut = fixture.getSut() val activity = mock() - fixture.options.enableTracing = false + fixture.options.tracesSampleRate = null val argumentCaptor: ArgumentCaptor = ArgumentCaptor.forClass(ScopeCallback::class.java) val scope = Scope(fixture.options) val propagationContextAtStart = scope.propagationContext - whenever(fixture.hub.configureScope(argumentCaptor.capture())).thenAnswer { + whenever(fixture.scopes.configureScope(argumentCaptor.capture())).thenAnswer { argumentCaptor.value.run(scope) } - sut.register(fixture.hub, fixture.options) + sut.register(fixture.scopes, fixture.options) sut.onActivityCreated(activity, fixture.bundle) // once for the screen, and once for the tracing propagation context - verify(fixture.hub, times(2)).configureScope(any()) + verify(fixture.scopes, times(2)).configureScope(any()) val propagationContextAfterNewTrace = scope.propagationContext assertNotSame(propagationContextAtStart, propagationContextAfterNewTrace) - clearInvocations(fixture.hub) + clearInvocations(fixture.scopes) sut.onActivityCreated(activity, fixture.bundle) // once for the screen, but not for the tracing propagation context - verify(fixture.hub).configureScope(any()) + verify(fixture.scopes).configureScope(any()) assertSame(propagationContextAfterNewTrace, scope.propagationContext) sut.onActivityDestroyed(activity) - clearInvocations(fixture.hub) + clearInvocations(fixture.scopes) sut.onActivityCreated(activity, fixture.bundle) // once for the screen, and once for the tracing propagation context - verify(fixture.hub, times(2)).configureScope(any()) + verify(fixture.scopes, times(2)).configureScope(any()) assertNotSame(propagationContextAfterNewTrace, scope.propagationContext) } @@ -1368,7 +1458,7 @@ class ActivityLifecycleIntegrationTest { fun `when transaction is finished, sets frame metrics`() { val sut = fixture.getSut() fixture.options.tracesSampleRate = 1.0 - sut.register(fixture.hub, fixture.options) + sut.register(fixture.scopes, fixture.options) val activity = mock() sut.onActivityCreated(activity, fixture.bundle) @@ -1384,7 +1474,7 @@ class ActivityLifecycleIntegrationTest { fixture.options.tracesSampleRate = 1.0 fixture.options.dateProvider = SentryDateProvider { now } - sut.register(fixture.hub, fixture.options) + sut.register(fixture.scopes, fixture.options) // usually done by SentryPerformanceProvider val startDate = SentryNanotimeDate(Date(5678), 910) @@ -1412,18 +1502,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/AndroidOptionsInitializerTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/AndroidOptionsInitializerTest.kt index 7800063b35..fce84bc855 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 @@ -13,8 +13,9 @@ import io.sentry.SentryOptions import io.sentry.android.core.cache.AndroidEnvelopeCache 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.core.internal.util.AndroidThreadChecker 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() @@ -495,7 +524,7 @@ class AndroidOptionsInitializerTest { } @Test - fun `When Activity Frames Tracking is enabled, the Activity Frames Tracker should be available`() { + fun `When Activity Frames Tracking is enabled, the Activity Frames Tracker should be unavailable`() { fixture.initSut( hasAppContext = true, useRealContext = true, @@ -504,6 +533,25 @@ class AndroidOptionsInitializerTest { } ) + val activityLifeCycleIntegration = fixture.sentryOptions.integrations + .first { it is ActivityLifecycleIntegration } + + assertFalse( + (activityLifeCycleIntegration as ActivityLifecycleIntegration).activityFramesTracker.isFrameMetricsAggregatorAvailable + ) + } + + @Test + fun `When Activity Frames Tracking is enabled, the Activity Frames Tracker should be available if perfv2 is false`() { + fixture.initSut( + hasAppContext = true, + useRealContext = true, + configureOptions = { + isEnablePerformanceV2 = false + isEnableFramesTracking = true + } + ) + val activityLifeCycleIntegration = fixture.sentryOptions.integrations .first { it is ActivityLifecycleIntegration } @@ -527,12 +575,32 @@ class AndroidOptionsInitializerTest { } @Test - fun `When Frames Tracking is initially disabled, but enabled via configureOptions it should be available`() { + fun `When Frames Tracking is initially disabled, but enabled via configureOptions it should be unavailable`() { + fixture.sentryOptions.isEnableFramesTracking = false + fixture.initSut( + hasAppContext = true, + useRealContext = true, + configureOptions = { + isEnableFramesTracking = true + } + ) + + val activityLifeCycleIntegration = fixture.sentryOptions.integrations + .first { it is ActivityLifecycleIntegration } + + assertFalse( + (activityLifeCycleIntegration as ActivityLifecycleIntegration).activityFramesTracker.isFrameMetricsAggregatorAvailable + ) + } + + @Test + fun `When Frames Tracking is initially disabled, but enabled via configureOptions it should be available if perfv2 is false`() { fixture.sentryOptions.isEnableFramesTracking = false fixture.initSut( hasAppContext = true, useRealContext = true, configureOptions = { + isEnablePerformanceV2 = false isEnableFramesTracking = true } ) @@ -553,10 +621,10 @@ class AndroidOptionsInitializerTest { } @Test - fun `AndroidMainThreadChecker is set to options`() { + fun `AndroidThreadChecker is set to options`() { fixture.initSut() - assertTrue { fixture.sentryOptions.mainThreadChecker is AndroidMainThreadChecker } + assertTrue { fixture.sentryOptions.threadChecker is AndroidThreadChecker } } @Test @@ -634,6 +702,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..86f95b4430 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 @@ -5,8 +5,8 @@ import android.os.Build import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 import io.sentry.CpuCollectionData -import io.sentry.IHub import io.sentry.ILogger +import io.sentry.IScopes import io.sentry.ISentryExecutorService import io.sentry.MemoryCollectionData import io.sentry.PerformanceCollectionData @@ -89,7 +89,7 @@ class AndroidTransactionProfilerTest { executorService = mockExecutorService } - val hub: IHub = mock() + val scopes: IScopes = mock() val frameMetricsCollector: SentryFrameMetricsCollector = mock() lateinit var transaction1: SentryTracer @@ -97,10 +97,10 @@ class AndroidTransactionProfilerTest { lateinit var transaction3: SentryTracer fun getSut(context: Context, buildInfoProvider: BuildInfoProvider = buildInfo): AndroidTransactionProfiler { - whenever(hub.options).thenReturn(options) - transaction1 = SentryTracer(TransactionContext("", ""), hub) - transaction2 = SentryTracer(TransactionContext("", ""), hub) - transaction3 = SentryTracer(TransactionContext("", ""), hub) + whenever(scopes.options).thenReturn(options) + transaction1 = SentryTracer(TransactionContext("", ""), scopes) + transaction2 = SentryTracer(TransactionContext("", ""), scopes) + transaction3 = SentryTracer(TransactionContext("", ""), scopes) return AndroidTransactionProfiler(context, options, buildInfoProvider, frameMetricsCollector) } } @@ -125,6 +125,7 @@ class AndroidTransactionProfilerTest { loadClass, activityFramesTracker, false, + false, false ) @@ -335,16 +336,6 @@ class AndroidTransactionProfilerTest { assertEquals(0, profiler.transactionsCounter) } - @Test - fun `profiler ignores profilingTracesIntervalMillis`() { - fixture.options.apply { - profilingTracesIntervalMillis = 0 - } - val profiler = fixture.getSut(context) - profiler.start() - assertEquals(1, profiler.transactionsCounter) - } - @Test fun `profiler never use background threads`() { val profiler = fixture.getSut(context) diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/AnrIntegrationTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/AnrIntegrationTest.kt index cceabc9774..1a74a47ae1 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/AnrIntegrationTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/AnrIntegrationTest.kt @@ -2,7 +2,7 @@ package io.sentry.android.core import android.content.Context import io.sentry.Hint -import io.sentry.IHub +import io.sentry.IScopes import io.sentry.SentryLevel import io.sentry.android.core.AnrIntegration.AnrHint import io.sentry.exception.ExceptionMechanismException @@ -24,7 +24,7 @@ class AnrIntegrationTest { private class Fixture { val context = mock() - val hub = mock() + val scopes = mock() var options: SentryAndroidOptions = SentryAndroidOptions().apply { setLogger(mock()) } @@ -49,7 +49,7 @@ class AnrIntegrationTest { fixture.options.executorService = ImmediateExecutorService() val sut = fixture.getSut() - sut.register(fixture.hub, fixture.options) + sut.register(fixture.scopes, fixture.options) assertNotNull(sut.anrWatchDog) assertTrue((sut.anrWatchDog as ANRWatchDog).isAlive) @@ -60,7 +60,7 @@ class AnrIntegrationTest { fixture.options.executorService = mock() val sut = fixture.getSut() - sut.register(fixture.hub, fixture.options) + sut.register(fixture.scopes, fixture.options) assertNull(sut.anrWatchDog) } @@ -70,7 +70,7 @@ class AnrIntegrationTest { val sut = fixture.getSut() fixture.options.isAnrEnabled = false - sut.register(fixture.hub, fixture.options) + sut.register(fixture.scopes, fixture.options) assertNull(sut.anrWatchDog) } @@ -79,9 +79,9 @@ class AnrIntegrationTest { fun `When ANR watch dog is triggered, it should capture an error event with AnrHint`() { val sut = fixture.getSut() - sut.reportANR(fixture.hub, fixture.options, getApplicationNotResponding()) + sut.reportANR(fixture.scopes, fixture.options, getApplicationNotResponding()) - verify(fixture.hub).captureEvent( + verify(fixture.scopes).captureEvent( check { assertEquals(SentryLevel.ERROR, it.level) }, @@ -97,7 +97,7 @@ class AnrIntegrationTest { val sut = fixture.getSut() fixture.options.executorService = ImmediateExecutorService() - sut.register(fixture.hub, fixture.options) + sut.register(fixture.scopes, fixture.options) assertNotNull(sut.anrWatchDog) @@ -107,11 +107,11 @@ class AnrIntegrationTest { } @Test - fun `when hub is closed right after start, integration is not registered`() { + fun `when scopes is closed right after start, integration is not registered`() { val deferredExecutorService = DeferredExecutorService() val sut = fixture.getSut() fixture.options.executorService = deferredExecutorService - sut.register(fixture.hub, fixture.options) + sut.register(fixture.scopes, fixture.options) assertNull(sut.anrWatchDog) sut.close() deferredExecutorService.runAll() @@ -122,9 +122,9 @@ class AnrIntegrationTest { fun `When ANR watch dog is triggered, constructs exception with proper mechanism and snapshot flag`() { val sut = fixture.getSut() - sut.reportANR(fixture.hub, fixture.options, getApplicationNotResponding()) + sut.reportANR(fixture.scopes, fixture.options, getApplicationNotResponding()) - verify(fixture.hub).captureEvent( + verify(fixture.scopes).captureEvent( check { val ex = it.throwableMechanism as ExceptionMechanismException assertTrue(ex.isSnapshot) @@ -139,9 +139,9 @@ class AnrIntegrationTest { val sut = fixture.getSut() AppState.getInstance().setInBackground(true) - sut.reportANR(fixture.hub, fixture.options, getApplicationNotResponding()) + sut.reportANR(fixture.scopes, fixture.options, getApplicationNotResponding()) - verify(fixture.hub).captureEvent( + verify(fixture.scopes).captureEvent( check { val message = it.throwable?.message assertTrue(message?.startsWith("Background") == true) diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/AnrV2EventProcessorTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/AnrV2EventProcessorTest.kt index b581856fe0..d930333f4c 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/AnrV2EventProcessorTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/AnrV2EventProcessorTest.kt @@ -15,18 +15,20 @@ import io.sentry.SentryEvent import io.sentry.SentryLevel import io.sentry.SentryLevel.DEBUG import io.sentry.SpanContext -import io.sentry.cache.PersistingOptionsObserver import io.sentry.cache.PersistingOptionsObserver.DIST_FILENAME import io.sentry.cache.PersistingOptionsObserver.ENVIRONMENT_FILENAME import io.sentry.cache.PersistingOptionsObserver.OPTIONS_CACHE import io.sentry.cache.PersistingOptionsObserver.PROGUARD_UUID_FILENAME import io.sentry.cache.PersistingOptionsObserver.RELEASE_FILENAME +import io.sentry.cache.PersistingOptionsObserver.REPLAY_ERROR_SAMPLE_RATE_FILENAME import io.sentry.cache.PersistingOptionsObserver.SDK_VERSION_FILENAME +import io.sentry.cache.PersistingScopeObserver import io.sentry.cache.PersistingScopeObserver.BREADCRUMBS_FILENAME import io.sentry.cache.PersistingScopeObserver.CONTEXTS_FILENAME import io.sentry.cache.PersistingScopeObserver.EXTRAS_FILENAME import io.sentry.cache.PersistingScopeObserver.FINGERPRINT_FILENAME import io.sentry.cache.PersistingScopeObserver.LEVEL_FILENAME +import io.sentry.cache.PersistingScopeObserver.REPLAY_FILENAME import io.sentry.cache.PersistingScopeObserver.REQUEST_FILENAME import io.sentry.cache.PersistingScopeObserver.SCOPE_CACHE import io.sentry.cache.PersistingScopeObserver.TAGS_FILENAME @@ -44,6 +46,7 @@ import io.sentry.protocol.OperatingSystem import io.sentry.protocol.Request import io.sentry.protocol.Response import io.sentry.protocol.SdkVersion +import io.sentry.protocol.SentryId import io.sentry.protocol.SentryStackFrame import io.sentry.protocol.SentryStackTrace import io.sentry.protocol.SentryThread @@ -75,7 +78,9 @@ class AnrV2EventProcessorTest { val tmpDir = TemporaryFolder() class Fixture { - + companion object { + const val REPLAY_ID = "64cf554cc8d74c6eafa3e08b7c984f6d" + } val buildInfo = mock() lateinit var context: Context val options = SentryAndroidOptions().apply { @@ -87,7 +92,8 @@ class AnrV2EventProcessorTest { dir: TemporaryFolder, currentSdk: Int = Build.VERSION_CODES.LOLLIPOP, populateScopeCache: Boolean = false, - populateOptionsCache: Boolean = false + populateOptionsCache: Boolean = false, + replayErrorSampleRate: Double? = null ): AnrV2EventProcessor { options.cacheDirPath = dir.newFolder().absolutePath options.environment = "release" @@ -109,7 +115,7 @@ class AnrV2EventProcessorTest { persistScope( CONTEXTS_FILENAME, Contexts().apply { - trace = SpanContext("test") + setTrace(SpanContext("test")) setResponse(Response().apply { bodySize = 1024 }) setBrowser(Browser().apply { name = "Google Chrome" }) } @@ -118,6 +124,7 @@ class AnrV2EventProcessorTest { REQUEST_FILENAME, Request().apply { url = "google.com"; method = "GET" } ) + persistScope(REPLAY_FILENAME, SentryId(REPLAY_ID)) } if (populateOptionsCache) { @@ -126,7 +133,10 @@ class AnrV2EventProcessorTest { persistOptions(SDK_VERSION_FILENAME, SdkVersion("sentry.java.android", "6.15.0")) persistOptions(DIST_FILENAME, "232") persistOptions(ENVIRONMENT_FILENAME, "debug") - persistOptions(PersistingOptionsObserver.TAGS_FILENAME, mapOf("option" to "tag")) + persistOptions(TAGS_FILENAME, mapOf("option" to "tag")) + replayErrorSampleRate?.let { + persistOptions(REPLAY_ERROR_SAMPLE_RATE_FILENAME, it.toString()) + } } return AnrV2EventProcessor(context, options, buildInfo) @@ -169,7 +179,7 @@ class AnrV2EventProcessorTest { assertNull(processed.platform) assertNull(processed.exceptions) - assertEquals(emptyMap(), processed.contexts) + assertTrue(processed.contexts.isEmpty) } @Test @@ -544,6 +554,65 @@ class AnrV2EventProcessorTest { assertEquals(listOf("{{ default }}", "foreground-anr"), processedForeground.fingerprints) } + @Test + fun `sets replayId when replay folder exists`() { + val hint = HintUtils.createWithTypeCheckHint(BackfillableHint()) + val processor = fixture.getSut(tmpDir, populateScopeCache = true) + val replayFolder = File(fixture.options.cacheDirPath, "replay_${Fixture.REPLAY_ID}").also { it.mkdirs() } + + val processed = processor.process(SentryEvent(), hint)!! + + assertEquals(Fixture.REPLAY_ID, processed.contexts[Contexts.REPLAY_ID].toString()) + } + + @Test + fun `does not set replayId when replay folder does not exist and no sample rate persisted`() { + val hint = HintUtils.createWithTypeCheckHint(BackfillableHint()) + val processor = fixture.getSut(tmpDir, populateScopeCache = true) + val replayId1 = SentryId() + val replayId2 = SentryId() + + val replayFolder1 = File(fixture.options.cacheDirPath, "replay_$replayId1").also { it.mkdirs() } + val replayFolder2 = File(fixture.options.cacheDirPath, "replay_$replayId2").also { it.mkdirs() } + + val processed = processor.process(SentryEvent(), hint)!! + + assertNull(processed.contexts[Contexts.REPLAY_ID]) + } + + @Test + fun `does not set replayId when replay folder does not exist and not sampled`() { + val hint = HintUtils.createWithTypeCheckHint(BackfillableHint()) + val processor = fixture.getSut(tmpDir, populateScopeCache = true, populateOptionsCache = true, replayErrorSampleRate = 0.0) + val replayId1 = SentryId() + val replayId2 = SentryId() + + val replayFolder1 = File(fixture.options.cacheDirPath, "replay_$replayId1").also { it.mkdirs() } + val replayFolder2 = File(fixture.options.cacheDirPath, "replay_$replayId2").also { it.mkdirs() } + + val processed = processor.process(SentryEvent(), hint)!! + + assertNull(processed.contexts[Contexts.REPLAY_ID]) + } + + @Test + fun `set replayId of the last modified folder`() { + val hint = HintUtils.createWithTypeCheckHint(BackfillableHint()) + val processor = fixture.getSut(tmpDir, populateScopeCache = true, populateOptionsCache = true, replayErrorSampleRate = 1.0) + val replayId1 = SentryId() + val replayId2 = SentryId() + + val replayFolder1 = File(fixture.options.cacheDirPath, "replay_$replayId1").also { it.mkdirs() } + val replayFolder2 = File(fixture.options.cacheDirPath, "replay_$replayId2").also { it.mkdirs() } + replayFolder1.setLastModified(1000) + replayFolder2.setLastModified(500) + + val processed = processor.process(SentryEvent(), hint)!! + + assertEquals(replayId1.toString(), processed.contexts[Contexts.REPLAY_ID].toString()) + assertEquals(replayId1.toString(), PersistingScopeObserver.read(fixture.options, REPLAY_FILENAME, String::class.java)) + } + private fun processEvent( hint: Hint, populateScopeCache: Boolean = false, diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/AnrV2IntegrationTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/AnrV2IntegrationTest.kt index 885ad22c8f..1abcd43719 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/AnrV2IntegrationTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/AnrV2IntegrationTest.kt @@ -6,8 +6,8 @@ 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.ILogger +import io.sentry.IScopes import io.sentry.SentryEnvelope import io.sentry.SentryLevel import io.sentry.android.core.AnrV2Integration.AnrV2Hint @@ -59,7 +59,7 @@ class AnrV2IntegrationTest { lateinit var lastReportedAnrFile: File val options = SentryAndroidOptions() - val hub = mock() + val scopes = mock() val logger = mock() fun getSut( @@ -93,7 +93,7 @@ class AnrV2IntegrationTest { lastReportedAnrFile = File(cacheDir, AndroidEnvelopeCache.LAST_ANR_REPORT) lastReportedAnrFile.writeText(lastReportedAnrTimestamp.toString()) } - whenever(hub.captureEvent(any(), anyOrNull())).thenReturn(lastEventId) + whenever(scopes.captureEvent(any(), anyOrNull())).thenReturn(lastEventId) return AnrV2Integration(context) } @@ -170,7 +170,7 @@ class AnrV2IntegrationTest { fun `when cacheDir is not set, does not process historical exits`() { val integration = fixture.getSut(null, useImmediateExecutorService = false) - integration.register(fixture.hub, fixture.options) + integration.register(fixture.scopes, fixture.options) verify(fixture.options.executorService, never()).submit(any()) } @@ -180,7 +180,7 @@ class AnrV2IntegrationTest { val integration = fixture.getSut(tmpDir, isAnrEnabled = false, useImmediateExecutorService = false) - integration.register(fixture.hub, fixture.options) + integration.register(fixture.scopes, fixture.options) verify(fixture.options.executorService, never()).submit(any()) } @@ -189,9 +189,9 @@ class AnrV2IntegrationTest { fun `when historical exit list is empty, does not process historical exits`() { val integration = fixture.getSut(tmpDir) - integration.register(fixture.hub, fixture.options) + integration.register(fixture.scopes, fixture.options) - verify(fixture.hub, never()).captureEvent(any(), anyOrNull()) + verify(fixture.scopes, never()).captureEvent(any(), anyOrNull()) } @Test @@ -199,9 +199,9 @@ class AnrV2IntegrationTest { val integration = fixture.getSut(tmpDir) fixture.addAppExitInfo(reason = null) - integration.register(fixture.hub, fixture.options) + integration.register(fixture.scopes, fixture.options) - verify(fixture.hub, never()).captureEvent(any(), anyOrNull()) + verify(fixture.scopes, never()).captureEvent(any(), anyOrNull()) } @Test @@ -212,9 +212,9 @@ class AnrV2IntegrationTest { val integration = fixture.getSut(tmpDir) fixture.addAppExitInfo(timestamp = oldTimestamp) - integration.register(fixture.hub, fixture.options) + integration.register(fixture.scopes, fixture.options) - verify(fixture.hub, never()).captureEvent(any(), anyOrNull()) + verify(fixture.scopes, never()).captureEvent(any(), anyOrNull()) } @Test @@ -222,9 +222,9 @@ class AnrV2IntegrationTest { val integration = fixture.getSut(tmpDir, lastReportedAnrTimestamp = oldTimestamp) fixture.addAppExitInfo(timestamp = oldTimestamp) - integration.register(fixture.hub, fixture.options) + integration.register(fixture.scopes, fixture.options) - verify(fixture.hub, never()).captureEvent(any(), anyOrNull()) + verify(fixture.scopes, never()).captureEvent(any(), anyOrNull()) } @Test @@ -232,9 +232,9 @@ class AnrV2IntegrationTest { val integration = fixture.getSut(tmpDir, lastReportedAnrTimestamp = null) fixture.addAppExitInfo(timestamp = oldTimestamp) - integration.register(fixture.hub, fixture.options) + integration.register(fixture.scopes, fixture.options) - verify(fixture.hub).captureEvent(any(), anyOrNull()) + verify(fixture.scopes).captureEvent(any(), anyOrNull()) } @Test @@ -242,9 +242,9 @@ class AnrV2IntegrationTest { val integration = fixture.getSut(tmpDir, lastReportedAnrTimestamp = oldTimestamp) fixture.addAppExitInfo(timestamp = newTimestamp) - integration.register(fixture.hub, fixture.options) + integration.register(fixture.scopes, fixture.options) - verify(fixture.hub).captureEvent( + verify(fixture.scopes).captureEvent( check { assertEquals(newTimestamp, it.timestamp.time) assertEquals(SentryLevel.FATAL, it.level) @@ -291,9 +291,9 @@ class AnrV2IntegrationTest { importance = ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND ) - integration.register(fixture.hub, fixture.options) + integration.register(fixture.scopes, fixture.options) - verify(fixture.hub).captureEvent( + verify(fixture.scopes).captureEvent( any(), argThat { val hint = HintUtils.getSentrySdkHint(this) @@ -311,7 +311,7 @@ class AnrV2IntegrationTest { ) fixture.addAppExitInfo(timestamp = newTimestamp) - whenever(fixture.hub.captureEvent(any(), any())).thenAnswer { invocation -> + whenever(fixture.scopes.captureEvent(any(), any())).thenAnswer { invocation -> val hint = HintUtils.getSentrySdkHint(invocation.getArgument(1)) as DiskFlushNotification thread { @@ -321,9 +321,9 @@ class AnrV2IntegrationTest { SentryId() } - integration.register(fixture.hub, fixture.options) + integration.register(fixture.scopes, fixture.options) - verify(fixture.hub).captureEvent(any(), anyOrNull()) + verify(fixture.scopes).captureEvent(any(), anyOrNull()) // shouldn't fall into timed out state, because we marked event as flushed on another thread verify(fixture.logger, never()).log( any(), @@ -341,9 +341,9 @@ class AnrV2IntegrationTest { ) fixture.addAppExitInfo(timestamp = newTimestamp) - integration.register(fixture.hub, fixture.options) + integration.register(fixture.scopes, fixture.options) - verify(fixture.hub).captureEvent(any(), anyOrNull()) + verify(fixture.scopes).captureEvent(any(), anyOrNull()) // we do not call markFlushed, hence it should time out waiting for flush, but because // we drop the event, it should not even come to this if-check verify(fixture.logger, never()).log( @@ -360,9 +360,9 @@ class AnrV2IntegrationTest { fixture.addAppExitInfo(timestamp = newTimestamp - 1 * 60 * 1000) fixture.addAppExitInfo(timestamp = newTimestamp) - integration.register(fixture.hub, fixture.options) + integration.register(fixture.scopes, fixture.options) - verify(fixture.hub, times(2)).captureEvent( + verify(fixture.scopes, times(2)).captureEvent( any(), argThat { val hint = HintUtils.getSentrySdkHint(this) @@ -382,10 +382,10 @@ class AnrV2IntegrationTest { fixture.addAppExitInfo(timestamp = newTimestamp - 1 * 60 * 1000) fixture.addAppExitInfo(timestamp = newTimestamp) - integration.register(fixture.hub, fixture.options) + integration.register(fixture.scopes, fixture.options) // only the latest anr is reported which should be enrichable - verify(fixture.hub, atMost(1)).captureEvent( + verify(fixture.scopes, atMost(1)).captureEvent( any(), argThat { val hint = HintUtils.getSentrySdkHint(this) @@ -402,20 +402,20 @@ class AnrV2IntegrationTest { fixture.addAppExitInfo(timestamp = newTimestamp - TimeUnit.DAYS.toMillis(1)) fixture.addAppExitInfo(timestamp = newTimestamp) - integration.register(fixture.hub, fixture.options) + integration.register(fixture.scopes, fixture.options) // the order is reverse here, so the oldest ANR will be reported first to keep track of // last reported ANR in a marker file - inOrder(fixture.hub) { - verify(fixture.hub).captureEvent( + inOrder(fixture.scopes) { + verify(fixture.scopes).captureEvent( argThat { timestamp.time == newTimestamp - TimeUnit.DAYS.toMillis(2) }, anyOrNull() ) - verify(fixture.hub).captureEvent( + verify(fixture.scopes).captureEvent( argThat { timestamp.time == newTimestamp - TimeUnit.DAYS.toMillis(1) }, anyOrNull() ) - verify(fixture.hub).captureEvent( + verify(fixture.scopes).captureEvent( argThat { timestamp.time == newTimestamp }, anyOrNull() ) @@ -427,9 +427,9 @@ class AnrV2IntegrationTest { val integration = fixture.getSut(tmpDir, lastReportedAnrTimestamp = oldTimestamp) fixture.addAppExitInfo(timestamp = newTimestamp) - integration.register(fixture.hub, fixture.options) + integration.register(fixture.scopes, fixture.options) - verify(fixture.hub).captureEvent( + verify(fixture.scopes).captureEvent( any(), argThat { val hint = HintUtils.getSentrySdkHint(this) @@ -443,9 +443,9 @@ class AnrV2IntegrationTest { val integration = fixture.getSut(tmpDir, lastReportedAnrTimestamp = oldTimestamp) fixture.addAppExitInfo(timestamp = newTimestamp) - integration.register(fixture.hub, fixture.options) + integration.register(fixture.scopes, fixture.options) - verify(fixture.hub).captureEvent( + verify(fixture.scopes).captureEvent( any(), argThat { val hint = HintUtils.getSentrySdkHint(this) @@ -472,7 +472,7 @@ class AnrV2IntegrationTest { ) } - integration.register(fixture.hub, fixture.options) + integration.register(fixture.scopes, fixture.options) // we store envelope with StartSessionHint on different thread after some delay, which // triggers the previous session flush, so no timeout @@ -493,14 +493,14 @@ class AnrV2IntegrationTest { ) fixture.addAppExitInfo(timestamp = newTimestamp) - integration.register(fixture.hub, fixture.options) + integration.register(fixture.scopes, fixture.options) verify(fixture.logger, never()).log( any(), argThat { startsWith("Timed out waiting to flush previous session to its own file.") }, any() ) - verify(fixture.hub).captureEvent(any(), any()) + verify(fixture.scopes).captureEvent(any(), any()) } @Test @@ -512,7 +512,7 @@ class AnrV2IntegrationTest { ) fixture.addAppExitInfo(timestamp = newTimestamp) - integration.register(fixture.hub, fixture.options) + integration.register(fixture.scopes, fixture.options) verify(fixture.logger).log( any(), @@ -532,9 +532,9 @@ class AnrV2IntegrationTest { ) fixture.addAppExitInfo(timestamp = newTimestamp) - integration.register(fixture.hub, fixture.options) + integration.register(fixture.scopes, fixture.options) - verify(fixture.hub).captureEvent( + verify(fixture.scopes).captureEvent( any(), check { assertNotNull(it.threadDump) @@ -547,8 +547,8 @@ class AnrV2IntegrationTest { val integration = fixture.getSut(tmpDir, lastReportedAnrTimestamp = oldTimestamp) fixture.addAppExitInfo(timestamp = newTimestamp, addTrace = false) - integration.register(fixture.hub, fixture.options) + integration.register(fixture.scopes, fixture.options) - verify(fixture.hub, never()).captureEvent(any(), anyOrNull()) + verify(fixture.scopes, never()).captureEvent(any(), anyOrNull()) } } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/AppComponentsBreadcrumbsIntegrationTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/AppComponentsBreadcrumbsIntegrationTest.kt index 15a6d690e5..9ae0c1c1c0 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/AppComponentsBreadcrumbsIntegrationTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/AppComponentsBreadcrumbsIntegrationTest.kt @@ -5,8 +5,9 @@ import android.content.Context import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 import io.sentry.Breadcrumb -import io.sentry.IHub +import io.sentry.IScopes import io.sentry.SentryLevel +import io.sentry.test.ImmediateExecutorService import org.junit.runner.RunWith import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull @@ -36,20 +37,24 @@ class AppComponentsBreadcrumbsIntegrationTest { @Test fun `When app components breadcrumb is enabled, it registers callback`() { val sut = fixture.getSut() - val options = SentryAndroidOptions() - val hub = mock() - sut.register(hub, options) + val options = SentryAndroidOptions().apply { + executorService = ImmediateExecutorService() + } + val scopes = mock() + sut.register(scopes, options) verify(fixture.context).registerComponentCallbacks(any()) } @Test fun `When app components breadcrumb is enabled, but ComponentCallbacks is not ready, do not throw`() { val sut = fixture.getSut() - val options = SentryAndroidOptions() - val hub = mock() - sut.register(hub, options) + val options = SentryAndroidOptions().apply { + executorService = ImmediateExecutorService() + } + val scopes = mock() + sut.register(scopes, options) whenever(fixture.context.registerComponentCallbacks(any())).thenThrow(NullPointerException()) - sut.register(hub, options) + sut.register(scopes, options) assertFalse(options.isEnableAppComponentBreadcrumbs) } @@ -58,18 +63,21 @@ class AppComponentsBreadcrumbsIntegrationTest { val sut = fixture.getSut() val options = SentryAndroidOptions().apply { isEnableAppComponentBreadcrumbs = false + executorService = ImmediateExecutorService() } - val hub = mock() - sut.register(hub, options) + val scopes = mock() + sut.register(scopes, options) verify(fixture.context, never()).registerComponentCallbacks(any()) } @Test fun `When AppComponentsBreadcrumbsIntegrationTest is closed, it should unregister the callback`() { val sut = fixture.getSut() - val options = SentryAndroidOptions() - val hub = mock() - sut.register(hub, options) + val options = SentryAndroidOptions().apply { + executorService = ImmediateExecutorService() + } + val scopes = mock() + sut.register(scopes, options) sut.close() verify(fixture.context).unregisterComponentCallbacks(any()) } @@ -77,22 +85,26 @@ class AppComponentsBreadcrumbsIntegrationTest { @Test fun `When app components breadcrumb is closed, but ComponentCallbacks is not ready, do not throw`() { val sut = fixture.getSut() - val options = SentryAndroidOptions() - val hub = mock() + val options = SentryAndroidOptions().apply { + executorService = ImmediateExecutorService() + } + val scopes = mock() whenever(fixture.context.registerComponentCallbacks(any())).thenThrow(NullPointerException()) whenever(fixture.context.unregisterComponentCallbacks(any())).thenThrow(NullPointerException()) - sut.register(hub, options) + sut.register(scopes, options) sut.close() } @Test fun `When low memory event, a breadcrumb with type, category and level should be set`() { val sut = fixture.getSut() - val options = SentryAndroidOptions() - val hub = mock() - sut.register(hub, options) + val options = SentryAndroidOptions().apply { + executorService = ImmediateExecutorService() + } + val scopes = mock() + sut.register(scopes, options) sut.onLowMemory() - verify(hub).addBreadcrumb( + verify(scopes).addBreadcrumb( check { assertEquals("device.event", it.category) assertEquals("system", it.type) @@ -104,11 +116,13 @@ class AppComponentsBreadcrumbsIntegrationTest { @Test fun `When trim memory event with level, a breadcrumb with type, category and level should be set`() { val sut = fixture.getSut() - val options = SentryAndroidOptions() - val hub = mock() - sut.register(hub, options) + val options = SentryAndroidOptions().apply { + executorService = ImmediateExecutorService() + } + val scopes = mock() + sut.register(scopes, options) sut.onTrimMemory(ComponentCallbacks2.TRIM_MEMORY_BACKGROUND) - verify(hub).addBreadcrumb( + verify(scopes).addBreadcrumb( check { assertEquals("device.event", it.category) assertEquals("system", it.type) @@ -120,21 +134,25 @@ class AppComponentsBreadcrumbsIntegrationTest { @Test fun `When trim memory event with level not so high, do not add a breadcrumb`() { val sut = fixture.getSut() - val options = SentryAndroidOptions() - val hub = mock() - sut.register(hub, options) + val options = SentryAndroidOptions().apply { + executorService = ImmediateExecutorService() + } + val scopes = mock() + sut.register(scopes, options) sut.onTrimMemory(ComponentCallbacks2.TRIM_MEMORY_RUNNING_CRITICAL) - verify(hub, never()).addBreadcrumb(any()) + verify(scopes, never()).addBreadcrumb(any()) } @Test fun `When device orientation event, a breadcrumb with type, category and level should be set`() { val sut = AppComponentsBreadcrumbsIntegration(ApplicationProvider.getApplicationContext()) - val options = SentryAndroidOptions() - val hub = mock() - sut.register(hub, options) + val options = SentryAndroidOptions().apply { + executorService = ImmediateExecutorService() + } + val scopes = mock() + sut.register(scopes, options) sut.onConfigurationChanged(mock()) - verify(hub).addBreadcrumb( + verify(scopes).addBreadcrumb( check { assertEquals("device.orientation", it.category) assertEquals("navigation", it.type) diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/AppLifecycleIntegrationTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/AppLifecycleIntegrationTest.kt index ed8d53227c..733aefa8d6 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/AppLifecycleIntegrationTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/AppLifecycleIntegrationTest.kt @@ -2,7 +2,7 @@ package io.sentry.android.core import android.os.Looper import androidx.test.ext.junit.runners.AndroidJUnit4 -import io.sentry.IHub +import io.sentry.IScopes import org.junit.runner.RunWith import org.mockito.kotlin.any import org.mockito.kotlin.mock @@ -17,7 +17,7 @@ import kotlin.test.assertNull class AppLifecycleIntegrationTest { private class Fixture { - val hub = mock() + val scopes = mock() lateinit var handler: MainLooperHandler val options = SentryAndroidOptions() @@ -33,7 +33,7 @@ class AppLifecycleIntegrationTest { fun `When AppLifecycleIntegration is added, lifecycle watcher should be started`() { val sut = fixture.getSut() - sut.register(fixture.hub, fixture.options) + sut.register(fixture.scopes, fixture.options) assertNotNull(sut.watcher) } @@ -46,7 +46,7 @@ class AppLifecycleIntegrationTest { isEnableAutoSessionTracking = false } - sut.register(fixture.hub, fixture.options) + sut.register(fixture.scopes, fixture.options) assertNull(sut.watcher) } @@ -55,7 +55,7 @@ class AppLifecycleIntegrationTest { fun `When AppLifecycleIntegration is closed, lifecycle watcher should be closed`() { val sut = fixture.getSut() - sut.register(fixture.hub, fixture.options) + sut.register(fixture.scopes, fixture.options) assertNotNull(sut.watcher) @@ -70,7 +70,7 @@ class AppLifecycleIntegrationTest { val latch = CountDownLatch(1) Thread { - sut.register(fixture.hub, fixture.options) + sut.register(fixture.scopes, fixture.options) latch.countDown() }.start() @@ -84,7 +84,7 @@ class AppLifecycleIntegrationTest { val sut = fixture.getSut() val latch = CountDownLatch(1) - sut.register(fixture.hub, fixture.options) + sut.register(fixture.scopes, fixture.options) assertNotNull(sut.watcher) @@ -103,7 +103,7 @@ class AppLifecycleIntegrationTest { val sut = fixture.getSut(mockHandler = false) val latch = CountDownLatch(1) - sut.register(fixture.hub, fixture.options) + sut.register(fixture.scopes, fixture.options) assertNotNull(sut.watcher) diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/ContextUtilsTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/ContextUtilsTest.kt index b758fae1f8..588a32a656 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/ContextUtilsTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/ContextUtilsTest.kt @@ -29,6 +29,7 @@ import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertNotNull import kotlin.test.assertNull +import kotlin.test.assertSame import kotlin.test.assertTrue @Config(sdk = [33]) @@ -213,4 +214,21 @@ class ContextUtilsTest { ) assertFalse(ContextUtils.isForegroundImportance()) } + + @Test + fun `getApplicationContext returns context if app context is null`() { + val contextMock = mock() + val appContext = ContextUtils.getApplicationContext(contextMock) + assertSame(contextMock, appContext) + } + + @Test + fun `getApplicationContext returns app context`() { + val contextMock = mock() + val appContextMock = mock() + whenever(contextMock.applicationContext).thenReturn(appContextMock) + + val appContext = ContextUtils.getApplicationContext(contextMock) + assertSame(appContextMock, appContext) + } } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/CurrentActivityIntegrationTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/CurrentActivityIntegrationTest.kt index 6330623121..ecdbff5104 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/CurrentActivityIntegrationTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/CurrentActivityIntegrationTest.kt @@ -3,7 +3,7 @@ package io.sentry.android.core import android.app.Activity import android.app.Application import androidx.test.ext.junit.runners.AndroidJUnit4 -import io.sentry.IHub +import io.sentry.IScopes import org.junit.runner.RunWith import org.mockito.kotlin.any import org.mockito.kotlin.mock @@ -19,7 +19,7 @@ class CurrentActivityIntegrationTest { private class Fixture { val application = mock() val activity = mock() - val hub = mock() + val scopes = mock() val options = SentryAndroidOptions().apply { dsn = "https://key@sentry.io/proj" @@ -27,7 +27,7 @@ class CurrentActivityIntegrationTest { fun getSut(): CurrentActivityIntegration { val integration = CurrentActivityIntegration(application) - integration.register(hub, options) + integration.register(scopes, options) return integration } } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/DefaultAndroidEventProcessorTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/DefaultAndroidEventProcessorTest.kt index 80954f67a5..9aac83ebad 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/DefaultAndroidEventProcessorTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/DefaultAndroidEventProcessorTest.kt @@ -7,7 +7,7 @@ import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 import io.sentry.DiagnosticLogger import io.sentry.Hint -import io.sentry.IHub +import io.sentry.IScopes import io.sentry.SentryEvent import io.sentry.SentryLevel import io.sentry.SentryTracer @@ -62,13 +62,13 @@ class DefaultAndroidEventProcessorTest { sdkVersion = SdkVersion("test", "1.2.3") } - val hub: IHub = mock() + val scopes: IScopes = mock() lateinit var sentryTracer: SentryTracer fun getSut(context: Context): DefaultAndroidEventProcessor { - whenever(hub.options).thenReturn(options) - sentryTracer = SentryTracer(TransactionContext("", ""), hub) + whenever(scopes.options).thenReturn(options) + sentryTracer = SentryTracer(TransactionContext("", ""), scopes) return DefaultAndroidEventProcessor(context, buildInfo, options) } } @@ -493,12 +493,11 @@ class DefaultAndroidEventProcessorTest { } @Test - fun `Event sets language and locale`() { + fun `Event sets locale`() { val sut = fixture.getSut(context) assertNotNull(sut.process(SentryEvent(), Hint())) { val device = it.contexts.device!! - assertEquals("en", device.language) assertEquals("en_US", device.locale) } } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/EnvelopeFileObserverIntegrationTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/EnvelopeFileObserverIntegrationTest.kt index 699fa2d2f2..69b2eee2ec 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/EnvelopeFileObserverIntegrationTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/EnvelopeFileObserverIntegrationTest.kt @@ -1,13 +1,13 @@ package io.sentry.android.core import androidx.test.ext.junit.runners.AndroidJUnit4 -import io.sentry.Hub -import io.sentry.IHub import io.sentry.ILogger +import io.sentry.IScopes import io.sentry.SentryLevel import io.sentry.SentryOptions import io.sentry.test.DeferredExecutorService import io.sentry.test.ImmediateExecutorService +import io.sentry.test.createTestScopes import org.junit.runner.RunWith import org.mockito.kotlin.eq import org.mockito.kotlin.mock @@ -24,7 +24,7 @@ import kotlin.test.assertEquals @RunWith(AndroidJUnit4::class) class EnvelopeFileObserverIntegrationTest { inner class Fixture { - val hub: IHub = mock() + val scopes: IScopes = mock() private lateinit var options: SentryAndroidOptions val logger = mock() @@ -33,7 +33,7 @@ class EnvelopeFileObserverIntegrationTest { options.setLogger(logger) options.isDebug = true optionConfiguration(options) - whenever(hub.options).thenReturn(options) + whenever(scopes.options).thenReturn(options) return object : EnvelopeFileObserverIntegration() { override fun getPath(options: SentryOptions): String? = file.absolutePath @@ -65,27 +65,25 @@ class EnvelopeFileObserverIntegrationTest { } @Test - fun `when hub is closed, integrations should be closed`() { + fun `when scopes is closed, integrations should be closed`() { val integrationMock = mock() val options = SentryOptions() options.dsn = "https://key@sentry.io/proj" options.cacheDirPath = file.absolutePath options.addIntegration(integrationMock) options.setSerializer(mock()) -// val expected = HubAdapter.getInstance() - val hub = Hub(options) -// verify(integrationMock).register(expected, options) - hub.close() + val scopes = createTestScopes(options) + scopes.close() verify(integrationMock).close() } @Test - fun `when hub is closed right after start, integration is not registered`() { + fun `when scopes is closed right after start, integration is not registered`() { val deferredExecutorService = DeferredExecutorService() val integration = fixture.getSut { it.executorService = deferredExecutorService } - integration.register(fixture.hub, fixture.hub.options) + integration.register(fixture.scopes, fixture.scopes.options) integration.close() deferredExecutorService.runAll() verify(fixture.logger, never()).log(eq(SentryLevel.DEBUG), eq("EnvelopeFileObserverIntegration installed.")) @@ -96,7 +94,7 @@ class EnvelopeFileObserverIntegrationTest { val integration = fixture.getSut { it.executorService = mock() } - integration.register(fixture.hub, fixture.hub.options) + integration.register(fixture.scopes, fixture.scopes.options) verify(fixture.logger).log( eq(SentryLevel.DEBUG), eq("Registering EnvelopeFileObserverIntegration for path: %s"), @@ -110,7 +108,7 @@ class EnvelopeFileObserverIntegrationTest { val integration = fixture.getSut { it.executorService = ImmediateExecutorService() } - integration.register(fixture.hub, fixture.hub.options) + integration.register(fixture.scopes, fixture.scopes.options) verify(fixture.logger).log( eq(SentryLevel.DEBUG), eq("Registering EnvelopeFileObserverIntegration for path: %s"), diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/InternalSentrySdkTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/InternalSentrySdkTest.kt index 3aa8cb575e..5f63f39cae 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/InternalSentrySdkTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/InternalSentrySdkTest.kt @@ -7,9 +7,9 @@ import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 import io.sentry.Breadcrumb import io.sentry.Hint -import io.sentry.Hub import io.sentry.IScope import io.sentry.Scope +import io.sentry.ScopeType import io.sentry.Sentry import io.sentry.SentryEnvelope import io.sentry.SentryEnvelopeHeader @@ -27,6 +27,7 @@ import io.sentry.protocol.Contexts import io.sentry.protocol.Mechanism import io.sentry.protocol.SentryId import io.sentry.protocol.User +import io.sentry.test.createTestScopes import io.sentry.transport.ITransport import io.sentry.transport.RateLimiter import org.junit.runner.RunWith @@ -87,7 +88,7 @@ class InternalSentrySdkTest { fun captureEnvelopeWithEvent(event: SentryEvent = SentryEvent(), maybeStartNewSession: Boolean = false) { // create an envelope with session data - val options = Sentry.getCurrentHub().options + val options = Sentry.getCurrentScopes().options val eventId = SentryId() val header = SentryEnvelopeHeader(eventId) val eventItem = SentryEnvelopeItem.fromEvent(options.serializer, event) @@ -202,40 +203,34 @@ class InternalSentrySdkTest { @BeforeTest fun `set up`() { + Sentry.close() context = ApplicationProvider.getApplicationContext() DeviceInfoUtil.resetInstance() } @Test - fun `current scope returns null when hub is no-op`() { - Sentry.getCurrentHub().close() + fun `current scope returns null when scopes is no-op`() { + Sentry.setCurrentScopes(createTestScopes(enabled = false)) val scope = InternalSentrySdk.getCurrentScope() assertNull(scope) } @Test - fun `current scope returns obj when hub is active`() { - Sentry.setCurrentHub( - Hub( - SentryOptions().apply { - dsn = "https://key@uri/1234567" - } - ) - ) + fun `current scope returns obj when scopes is active`() { + val fixture = Fixture() + fixture.init(context) val scope = InternalSentrySdk.getCurrentScope() assertNotNull(scope) } @Test fun `current scope returns a copy of the scope`() { - Sentry.setCurrentHub( - Hub( - SentryOptions().apply { - dsn = "https://key@uri/1234567" - } - ) - ) + val fixture = Fixture() + fixture.init(context) Sentry.addBreadcrumb("test") + Sentry.configureScope(ScopeType.CURRENT) { scope -> scope.addBreadcrumb(Breadcrumb("currentBreadcrumb")) } + Sentry.configureScope(ScopeType.ISOLATION) { scope -> scope.addBreadcrumb(Breadcrumb("isolationBreadcrumb")) } + Sentry.configureScope(ScopeType.GLOBAL) { scope -> scope.addBreadcrumb(Breadcrumb("globalBreadcrumb")) } // when the clone is modified val clonedScope = InternalSentrySdk.getCurrentScope()!! @@ -243,7 +238,7 @@ class InternalSentrySdkTest { // then modifications should not be reflected Sentry.configureScope { scope -> - assertEquals(1, scope.breadcrumbs.size) + assertEquals(3, scope.breadcrumbs.size) } } 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..93a731481b 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 @@ -3,10 +3,12 @@ package io.sentry.android.core import androidx.lifecycle.LifecycleOwner import io.sentry.Breadcrumb import io.sentry.DateUtils -import io.sentry.IHub import io.sentry.IScope +import io.sentry.IScopes +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 @@ -32,8 +34,10 @@ class LifecycleWatcherTest { private class Fixture { val ownerMock = mock() - val hub = mock() + val scopes = mock() val dateProvider = mock() + val options = SentryOptions() + val replayController = mock() fun getSUT( sessionIntervalMillis: Long = 0L, @@ -44,12 +48,14 @@ class LifecycleWatcherTest { val argumentCaptor: ArgumentCaptor = ArgumentCaptor.forClass(ScopeCallback::class.java) val scope = mock() whenever(scope.session).thenReturn(session) - whenever(hub.configureScope(argumentCaptor.capture())).thenAnswer { + whenever(scopes.configureScope(argumentCaptor.capture())).thenAnswer { argumentCaptor.value.run(scope) } + options.setReplayController(replayController) + whenever(scopes.options).thenReturn(options) return LifecycleWatcher( - hub, + scopes, sessionIntervalMillis, enableAutoSessionTracking, enableAppLifecycleBreadcrumbs, @@ -69,7 +75,8 @@ class LifecycleWatcherTest { fun `if last started session is 0, start new session`() { val watcher = fixture.getSUT(enableAppLifecycleBreadcrumbs = false) watcher.onStart(fixture.ownerMock) - verify(fixture.hub).startSession() + verify(fixture.scopes).startSession() + verify(fixture.replayController).start() } @Test @@ -78,7 +85,8 @@ class LifecycleWatcherTest { whenever(fixture.dateProvider.currentTimeMillis).thenReturn(1L, 2L) watcher.onStart(fixture.ownerMock) watcher.onStart(fixture.ownerMock) - verify(fixture.hub, times(2)).startSession() + verify(fixture.scopes, times(2)).startSession() + verify(fixture.replayController, times(2)).start() } @Test @@ -87,7 +95,8 @@ class LifecycleWatcherTest { whenever(fixture.dateProvider.currentTimeMillis).thenReturn(2L, 1L) watcher.onStart(fixture.ownerMock) watcher.onStart(fixture.ownerMock) - verify(fixture.hub).startSession() + verify(fixture.scopes).startSession() + verify(fixture.replayController).start() } @Test @@ -95,7 +104,8 @@ class LifecycleWatcherTest { val watcher = fixture.getSUT(enableAppLifecycleBreadcrumbs = false) watcher.onStart(fixture.ownerMock) watcher.onStop(fixture.ownerMock) - verify(fixture.hub, timeout(10000)).endSession() + verify(fixture.scopes, timeout(10000)).endSession() + verify(fixture.replayController, timeout(10000)).stop() } @Test @@ -109,73 +119,29 @@ class LifecycleWatcherTest { watcher.onStart(fixture.ownerMock) assertNull(watcher.timerTask) - verify(fixture.hub, never()).endSession() + verify(fixture.scopes, never()).endSession() + verify(fixture.replayController, never()).stop() } @Test fun `When session tracking is disabled, do not start session`() { val watcher = fixture.getSUT(enableAutoSessionTracking = false, enableAppLifecycleBreadcrumbs = false) watcher.onStart(fixture.ownerMock) - verify(fixture.hub, never()).startSession() + verify(fixture.scopes, never()).startSession() } @Test 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() - } - - @Test - fun `When session tracking is enabled, add breadcrumb on start`() { - val watcher = fixture.getSUT(enableAppLifecycleBreadcrumbs = false) - watcher.onStart(fixture.ownerMock) - verify(fixture.hub).addBreadcrumb( - check { - assertEquals("app.lifecycle", it.category) - assertEquals("session", it.type) - assertEquals(SentryLevel.INFO, it.level) - // cant assert data, its not a public API - } - ) - } - - @Test - fun `When session tracking is enabled, add breadcrumb on stop`() { - val watcher = fixture.getSUT(enableAppLifecycleBreadcrumbs = false) - watcher.onStop(fixture.ownerMock) - verify(fixture.hub, timeout(10000)).endSession() - verify(fixture.hub).addBreadcrumb( - check { - assertEquals("app.lifecycle", it.category) - assertEquals("session", it.type) - assertEquals(SentryLevel.INFO, it.level) - // cant assert data, its not a public API - } - ) - } - - @Test - fun `When session tracking is disabled, do not add breadcrumb on start`() { - val watcher = fixture.getSUT(enableAutoSessionTracking = false, enableAppLifecycleBreadcrumbs = false) - watcher.onStart(fixture.ownerMock) - verify(fixture.hub, never()).addBreadcrumb(any()) - } - - @Test - fun `When session tracking is disabled, do not add breadcrumb on stop`() { - val watcher = fixture.getSUT(enableAutoSessionTracking = false, enableAppLifecycleBreadcrumbs = false) - watcher.onStop(fixture.ownerMock) - assertNull(watcher.timerTask) - verify(fixture.hub, never()).addBreadcrumb(any()) + verify(fixture.scopes, never()).endSession() } @Test fun `When app lifecycle breadcrumbs is enabled, add breadcrumb on start`() { val watcher = fixture.getSUT(enableAutoSessionTracking = false) watcher.onStart(fixture.ownerMock) - verify(fixture.hub).addBreadcrumb( + verify(fixture.scopes).addBreadcrumb( check { assertEquals("app.lifecycle", it.category) assertEquals("navigation", it.type) @@ -189,14 +155,14 @@ class LifecycleWatcherTest { fun `When app lifecycle breadcrumbs is disabled, do not add breadcrumb on start`() { val watcher = fixture.getSUT(enableAutoSessionTracking = false, enableAppLifecycleBreadcrumbs = false) watcher.onStart(fixture.ownerMock) - verify(fixture.hub, never()).addBreadcrumb(any()) + verify(fixture.scopes, never()).addBreadcrumb(any()) } @Test fun `When app lifecycle breadcrumbs is enabled, add breadcrumb on stop`() { val watcher = fixture.getSUT(enableAutoSessionTracking = false) watcher.onStop(fixture.ownerMock) - verify(fixture.hub).addBreadcrumb( + verify(fixture.scopes).addBreadcrumb( check { assertEquals("app.lifecycle", it.category) assertEquals("navigation", it.type) @@ -210,7 +176,7 @@ class LifecycleWatcherTest { fun `When app lifecycle breadcrumbs is disabled, do not add breadcrumb on stop`() { val watcher = fixture.getSUT(enableAutoSessionTracking = false, enableAppLifecycleBreadcrumbs = false) watcher.onStop(fixture.ownerMock) - verify(fixture.hub, never()).addBreadcrumb(any()) + verify(fixture.scopes, never()).addBreadcrumb(any()) } @Test @@ -220,13 +186,7 @@ class LifecycleWatcherTest { } @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`() { + fun `if the scopes has already a fresh session running, don't start new one`() { val watcher = fixture.getSUT( enableAppLifecycleBreadcrumbs = false, session = Session( @@ -248,11 +208,12 @@ class LifecycleWatcherTest { ) watcher.onStart(fixture.ownerMock) - verify(fixture.hub, never()).startSession() + verify(fixture.scopes, never()).startSession() + verify(fixture.replayController, never()).start() } @Test - fun `if the hub has a long running session, start new one`() { + fun `if the scopes has a long running session, start new one`() { val watcher = fixture.getSUT( enableAppLifecycleBreadcrumbs = false, session = Session( @@ -274,7 +235,8 @@ class LifecycleWatcherTest { ) watcher.onStart(fixture.ownerMock) - verify(fixture.hub).startSession() + verify(fixture.scopes).startSession() + verify(fixture.replayController).start() } @Test @@ -290,4 +252,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..4b0d19977d 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt @@ -6,6 +6,7 @@ import androidx.core.os.bundleOf import androidx.test.ext.junit.runners.AndroidJUnit4 import io.sentry.ILogger import io.sentry.SentryLevel +import io.sentry.SentryReplayOptions import org.junit.runner.RunWith import org.mockito.kotlin.any import org.mockito.kotlin.eq @@ -105,19 +106,6 @@ class ManifestMetadataReaderTest { assertNull(fixture.options.sampleRate) } - @Test - fun `applyMetadata reads session tracking to options`() { - // Arrange - val bundle = bundleOf(ManifestMetadataReader.SESSION_TRACKING_ENABLE to false) - val context = fixture.getContext(metaData = bundle) - - // Act - ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) - - // Assert - assertFalse(fixture.options.isEnableAutoSessionTracking) - } - @Test fun `applyMetadata reads session tracking and keep default value if not found`() { // Arrange @@ -671,45 +659,6 @@ class ManifestMetadataReaderTest { assertNull(fixture.options.tracesSampleRate) } - @Test - fun `applyMetadata reads enableTracing from metadata`() { - // Arrange - val bundle = bundleOf(ManifestMetadataReader.TRACING_ENABLE to true) - val context = fixture.getContext(metaData = bundle) - - // Act - ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) - - // Assert - assertEquals(true, fixture.options.enableTracing) - } - - @Test - fun `applyMetadata does not override enableTracing from options`() { - // Arrange - fixture.options.enableTracing = true - val bundle = bundleOf(ManifestMetadataReader.TRACING_ENABLE to false) - val context = fixture.getContext(metaData = bundle) - - // Act - ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) - - // Assert - assertEquals(true, fixture.options.enableTracing) - } - - @Test - fun `applyMetadata without specifying enableTracing, stays null`() { - // Arrange - val context = fixture.getContext() - - // Act - ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) - - // Assert - assertNull(fixture.options.enableTracing) - } - @Test fun `applyMetadata reads enableAutoActivityLifecycleTracing to options`() { // Arrange @@ -785,31 +734,6 @@ class ManifestMetadataReaderTest { assertTrue(fixture.options.isTraceSampling) } - @Test - fun `applyMetadata reads enableTracesProfiling to options`() { - // Arrange - val bundle = bundleOf(ManifestMetadataReader.TRACES_PROFILING_ENABLE to true) - val context = fixture.getContext(metaData = bundle) - - // Act - ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) - - // Assert - assertTrue(fixture.options.isProfilingEnabled) - } - - @Test - fun `applyMetadata reads enableTracesProfiling to options and keeps default`() { - // Arrange - val context = fixture.getContext() - - // Act - ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) - - // Assert - assertFalse(fixture.options.isProfilingEnabled) - } - @Test fun `applyMetadata reads profilesSampleRate from metadata`() { // Arrange @@ -864,67 +788,6 @@ class ManifestMetadataReaderTest { assertEquals(listOf("localhost", """^(http|https)://api\..*$"""), fixture.options.tracePropagationTargets) } - @Test - fun `applyMetadata ignores tracingOrigins if tracePropagationTargets is present`() { - // Arrange - val bundle = bundleOf( - ManifestMetadataReader.TRACE_PROPAGATION_TARGETS to """localhost,^(http|https)://api\..*$""", - ManifestMetadataReader.TRACING_ORIGINS to """otherhost""" - ) - val context = fixture.getContext(metaData = bundle) - - // Act - ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) - - // Assert - assertEquals(listOf("localhost", """^(http|https)://api\..*$"""), fixture.options.tracePropagationTargets) - } - - @Test - fun `applyMetadata ignores tracingOrigins if tracePropagationTargets is present even if null`() { - // Arrange - val bundle = bundleOf( - ManifestMetadataReader.TRACE_PROPAGATION_TARGETS to null, - ManifestMetadataReader.TRACING_ORIGINS to """otherhost""" - ) - val context = fixture.getContext(metaData = bundle) - - // Act - ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) - - // Assert - assertTrue(fixture.options.tracePropagationTargets.isEmpty()) - } - - @Test - fun `applyMetadata ignores tracingOrigins if tracePropagationTargets is present even if empty string`() { - // Arrange - val bundle = bundleOf( - ManifestMetadataReader.TRACE_PROPAGATION_TARGETS to "", - ManifestMetadataReader.TRACING_ORIGINS to """otherhost""" - ) - val context = fixture.getContext(metaData = bundle) - - // Act - ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) - - // Assert - assertTrue(fixture.options.tracePropagationTargets.isEmpty()) - } - - @Test - fun `applyMetadata uses tracingOrigins if tracePropagationTargets is not present`() { - // Arrange - val bundle = bundleOf(ManifestMetadataReader.TRACING_ORIGINS to """otherhost""") - val context = fixture.getContext(metaData = bundle) - - // Act - ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) - - // Assert - assertEquals(listOf("otherhost"), fixture.options.tracePropagationTargets) - } - @Test fun `applyMetadata reads null tracePropagationTargets and sets empty list`() { // Arrange @@ -1322,14 +1185,14 @@ class ManifestMetadataReaderTest { @Test fun `applyMetadata reads performance-v2 flag to options`() { // Arrange - val bundle = bundleOf(ManifestMetadataReader.ENABLE_PERFORMANCE_V2 to true) + val bundle = bundleOf(ManifestMetadataReader.ENABLE_PERFORMANCE_V2 to false) val context = fixture.getContext(metaData = bundle) // Act ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) // Assert - assertTrue(fixture.options.isEnablePerformanceV2) + assertFalse(fixture.options.isEnablePerformanceV2) } @Test @@ -1341,7 +1204,7 @@ class ManifestMetadataReaderTest { ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) // Assert - assertFalse(fixture.options.isEnablePerformanceV2) + assertTrue(fixture.options.isEnablePerformanceV2) } @Test @@ -1397,20 +1260,88 @@ class ManifestMetadataReaderTest { } @Test - fun `applyMetadata reads enableMetrics flag to options`() { + fun `applyMetadata does not override replays onErrorSampleRate from options`() { + // Arrange + val expectedSampleRate = 0.99f + fixture.options.experimental.sessionReplay.onErrorSampleRate = 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.onErrorSampleRate) + } + + @Test + fun `applyMetadata reads forceInit flag to options`() { + // Arrange + val bundle = bundleOf(ManifestMetadataReader.FORCE_INIT to true) + val context = fixture.getContext(metaData = bundle) + + // Act + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + // Assert + assertTrue(fixture.options.isForceInit) + } + + @Test + fun `applyMetadata reads forceInit flag to options and keeps default if not found`() { + // Arrange + val context = fixture.getContext() + + // Act + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + // Assert + assertFalse(fixture.options.isForceInit) + } + + @Test + fun `applyMetadata reads replays onErrorSampleRate 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.onErrorSampleRate) + } + + @Test + fun `applyMetadata without specifying replays onErrorSampleRate, stays null`() { + // Arrange + val context = fixture.getContext() + + // Act + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + // Assert + assertNull(fixture.options.experimental.sessionReplay.onErrorSampleRate) + } + + @Test + fun `applyMetadata reads session replay mask flags to options`() { // Arrange - val bundle = bundleOf(ManifestMetadataReader.ENABLE_METRICS to true) + val bundle = bundleOf(ManifestMetadataReader.REPLAYS_MASK_ALL_TEXT to false, ManifestMetadataReader.REPLAYS_MASK_ALL_IMAGES to false) val context = fixture.getContext(metaData = bundle) // Act ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) // Assert - assertTrue(fixture.options.isEnableMetrics) + assertTrue(fixture.options.experimental.sessionReplay.unmaskViewClasses.contains(SentryReplayOptions.IMAGE_VIEW_CLASS_NAME)) + assertTrue(fixture.options.experimental.sessionReplay.unmaskViewClasses.contains(SentryReplayOptions.TEXT_VIEW_CLASS_NAME)) } @Test - fun `applyMetadata reads enableMetrics flag to options and keeps default if not found`() { + fun `applyMetadata reads session replay mask flags to options and keeps default if not found`() { // Arrange val context = fixture.getContext() @@ -1418,6 +1349,7 @@ class ManifestMetadataReaderTest { ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) // Assert - assertFalse(fixture.options.isEnableMetrics) + assertTrue(fixture.options.experimental.sessionReplay.maskViewClasses.contains(SentryReplayOptions.IMAGE_VIEW_CLASS_NAME)) + assertTrue(fixture.options.experimental.sessionReplay.maskViewClasses.contains(SentryReplayOptions.TEXT_VIEW_CLASS_NAME)) } } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/NdkIntegrationTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/NdkIntegrationTest.kt index e86de06814..e282ad7141 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/NdkIntegrationTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/NdkIntegrationTest.kt @@ -1,7 +1,7 @@ package io.sentry.android.core -import io.sentry.IHub import io.sentry.ILogger +import io.sentry.IScopes import io.sentry.SentryLevel import org.mockito.kotlin.any import org.mockito.kotlin.eq @@ -15,7 +15,7 @@ import kotlin.test.assertTrue class NdkIntegrationTest { private class Fixture { - val hub = mock() + val scopes = mock() val logger = mock() fun getSut(clazz: Class<*>? = SentryNdk::class.java): NdkIntegration { @@ -31,7 +31,7 @@ class NdkIntegrationTest { val options = getOptions() - integration.register(fixture.hub, options) + integration.register(fixture.scopes, options) verify(fixture.logger, never()).log(eq(SentryLevel.ERROR), any(), any()) assertTrue(options.isEnableNdk) @@ -44,7 +44,7 @@ class NdkIntegrationTest { val options = getOptions() - integration.register(fixture.hub, options) + integration.register(fixture.scopes, options) assertTrue(options.isEnableNdk) assertTrue(options.isEnableScopeSync) @@ -62,7 +62,7 @@ class NdkIntegrationTest { val options = getOptions(enableNdk = false) - integration.register(fixture.hub, options) + integration.register(fixture.scopes, options) verify(fixture.logger, never()).log(eq(SentryLevel.ERROR), any(), any()) @@ -76,7 +76,7 @@ class NdkIntegrationTest { val options = getOptions() - integration.register(fixture.hub, options) + integration.register(fixture.scopes, options) verify(fixture.logger, never()).log(eq(SentryLevel.ERROR), any(), any()) @@ -90,7 +90,7 @@ class NdkIntegrationTest { val options = getOptions() - integration.register(fixture.hub, options) + integration.register(fixture.scopes, options) verify(fixture.logger).log(eq(SentryLevel.ERROR), any(), any()) @@ -104,7 +104,7 @@ class NdkIntegrationTest { val options = getOptions() - integration.register(fixture.hub, options) + integration.register(fixture.scopes, options) assertTrue(options.isEnableNdk) assertTrue(options.isEnableScopeSync) @@ -122,7 +122,7 @@ class NdkIntegrationTest { val options = getOptions() - integration.register(fixture.hub, options) + integration.register(fixture.scopes, options) verify(fixture.logger).log(eq(SentryLevel.ERROR), any(), any()) @@ -136,7 +136,7 @@ class NdkIntegrationTest { val options = getOptions(cacheDir = null) - integration.register(fixture.hub, options) + integration.register(fixture.scopes, options) verify(fixture.logger).log(eq(SentryLevel.ERROR), any()) @@ -150,7 +150,7 @@ class NdkIntegrationTest { val options = getOptions(cacheDir = "") - integration.register(fixture.hub, options) + integration.register(fixture.scopes, options) verify(fixture.logger).log(eq(SentryLevel.ERROR), any()) diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/NetworkBreadcrumbsIntegrationTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/NetworkBreadcrumbsIntegrationTest.kt index 146f229fdf..fd3c5a4ddc 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/NetworkBreadcrumbsIntegrationTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/NetworkBreadcrumbsIntegrationTest.kt @@ -8,13 +8,16 @@ import android.net.NetworkCapabilities import android.os.Build import io.sentry.Breadcrumb import io.sentry.DateUtils -import io.sentry.IHub +import io.sentry.IScopes +import io.sentry.ISentryExecutorService import io.sentry.SentryDateProvider import io.sentry.SentryLevel import io.sentry.SentryNanotimeDate import io.sentry.TypeCheckHint import io.sentry.android.core.NetworkBreadcrumbsIntegration.NetworkBreadcrumbConnectionDetail import io.sentry.android.core.NetworkBreadcrumbsIntegration.NetworkBreadcrumbsNetworkCallback +import io.sentry.test.DeferredExecutorService +import io.sentry.test.ImmediateExecutorService import org.mockito.kotlin.KInOrder import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull @@ -39,7 +42,7 @@ class NetworkBreadcrumbsIntegrationTest { private class Fixture { val context = mock() var options = SentryAndroidOptions() - val hub = mock() + val scopes = mock() val mockBuildInfoProvider = mock() val connectivityManager = mock() var nowMs: Long = 0 @@ -47,14 +50,22 @@ class NetworkBreadcrumbsIntegrationTest { init { whenever(mockBuildInfoProvider.sdkInfoVersion).thenReturn(Build.VERSION_CODES.N) - whenever(context.getSystemService(eq(Context.CONNECTIVITY_SERVICE))).thenReturn(connectivityManager) + whenever(context.getSystemService(eq(Context.CONNECTIVITY_SERVICE))).thenReturn( + connectivityManager + ) } - fun getSut(enableNetworkEventBreadcrumbs: Boolean = true, buildInfo: BuildInfoProvider = mockBuildInfoProvider): NetworkBreadcrumbsIntegration { + fun getSut( + enableNetworkEventBreadcrumbs: Boolean = true, + buildInfo: BuildInfoProvider = mockBuildInfoProvider, + executor: ISentryExecutorService = ImmediateExecutorService() + ): NetworkBreadcrumbsIntegration { options = SentryAndroidOptions().apply { + executorService = executor isEnableNetworkEventBreadcrumbs = enableNetworkEventBreadcrumbs dateProvider = SentryDateProvider { - val nowNanos = TimeUnit.MILLISECONDS.toNanos(nowMs ?: System.currentTimeMillis()) + val nowNanos = + TimeUnit.MILLISECONDS.toNanos(nowMs ?: System.currentTimeMillis()) SentryNanotimeDate(DateUtils.nanosToDate(nowNanos), nowNanos) } } @@ -68,7 +79,7 @@ class NetworkBreadcrumbsIntegrationTest { fun `When network events breadcrumb is enabled, it registers callback`() { val sut = fixture.getSut() - sut.register(fixture.hub, fixture.options) + sut.register(fixture.scopes, fixture.options) verify(fixture.connectivityManager).registerDefaultNetworkCallback(any()) assertNotNull(sut.networkCallback) @@ -78,7 +89,7 @@ class NetworkBreadcrumbsIntegrationTest { fun `When system events breadcrumb is disabled, it doesn't register callback`() { val sut = fixture.getSut(enableNetworkEventBreadcrumbs = false) - sut.register(fixture.hub, fixture.options) + sut.register(fixture.scopes, fixture.options) verify(fixture.connectivityManager, never()).registerDefaultNetworkCallback(any()) assertNull(sut.networkCallback) @@ -90,7 +101,7 @@ class NetworkBreadcrumbsIntegrationTest { whenever(buildInfo.sdkInfoVersion).thenReturn(Build.VERSION_CODES.M) val sut = fixture.getSut(buildInfo = buildInfo) - sut.register(fixture.hub, fixture.options) + sut.register(fixture.scopes, fixture.options) verify(fixture.connectivityManager, never()).registerDefaultNetworkCallback(any()) assertNull(sut.networkCallback) @@ -100,7 +111,7 @@ class NetworkBreadcrumbsIntegrationTest { fun `When NetworkBreadcrumbsIntegration is closed, it should unregister the callback`() { val sut = fixture.getSut() - sut.register(fixture.hub, fixture.options) + sut.register(fixture.scopes, fixture.options) sut.close() verify(fixture.connectivityManager).unregisterNetworkCallback(any()) @@ -114,22 +125,25 @@ class NetworkBreadcrumbsIntegrationTest { val sut = fixture.getSut(buildInfo = buildInfo) assertNull(sut.networkCallback) - sut.register(fixture.hub, fixture.options) + sut.register(fixture.scopes, fixture.options) sut.close() - verify(fixture.connectivityManager, never()).unregisterNetworkCallback(any()) + verify( + fixture.connectivityManager, + never() + ).unregisterNetworkCallback(any()) assertNull(sut.networkCallback) } @Test fun `When connected to a new network, a breadcrumb is captured`() { val sut = fixture.getSut() - sut.register(fixture.hub, fixture.options) + sut.register(fixture.scopes, fixture.options) val callback = sut.networkCallback assertNotNull(callback) callback.onAvailable(mock()) - verify(fixture.hub).addBreadcrumb( + verify(fixture.scopes).addBreadcrumb( check { assertEquals("system", it.type) assertEquals("network.event", it.category) @@ -142,27 +156,27 @@ class NetworkBreadcrumbsIntegrationTest { @Test fun `When connected to the same network without disconnecting from the previous one, only one breadcrumb is captured`() { val sut = fixture.getSut() - sut.register(fixture.hub, fixture.options) + sut.register(fixture.scopes, fixture.options) val callback = sut.networkCallback assertNotNull(callback) callback.onAvailable(fixture.network) callback.onAvailable(fixture.network) - verify(fixture.hub, times(1)).addBreadcrumb(any()) + verify(fixture.scopes, times(1)).addBreadcrumb(any()) } @Test fun `When disconnected from a network, a breadcrumb is captured`() { val sut = fixture.getSut() - sut.register(fixture.hub, fixture.options) + sut.register(fixture.scopes, fixture.options) val callback = sut.networkCallback assertNotNull(callback) callback.onAvailable(fixture.network) - verify(fixture.hub).addBreadcrumb(any()) + verify(fixture.scopes).addBreadcrumb(any()) callback.onLost(fixture.network) - verify(fixture.hub).addBreadcrumb( + verify(fixture.scopes).addBreadcrumb( check { assertEquals("system", it.type) assertEquals("network.event", it.category) @@ -175,12 +189,12 @@ class NetworkBreadcrumbsIntegrationTest { @Test fun `When disconnected from a network, a breadcrumb is captured only if previously connected to that network`() { val sut = fixture.getSut() - sut.register(fixture.hub, fixture.options) + sut.register(fixture.scopes, fixture.options) val callback = sut.networkCallback assertNotNull(callback) // callback.onAvailable(network) was not called, so no breadcrumb should be captured callback.onLost(mock()) - verify(fixture.hub, never()).addBreadcrumb(any()) + verify(fixture.scopes, never()).addBreadcrumb(any()) } @Test @@ -188,7 +202,7 @@ class NetworkBreadcrumbsIntegrationTest { val buildInfo = mock() whenever(buildInfo.sdkInfoVersion).thenReturn(Build.VERSION_CODES.Q) val sut = fixture.getSut(buildInfo = buildInfo) - sut.register(fixture.hub, fixture.options) + sut.register(fixture.scopes, fixture.options) val callback = sut.networkCallback assertNotNull(callback) callback.onAvailable(fixture.network) @@ -204,7 +218,7 @@ class NetworkBreadcrumbsIntegrationTest { isCellular = false ) ) - verify(fixture.hub).addBreadcrumb( + verify(fixture.scopes).addBreadcrumb( check { assertEquals("system", it.type) assertEquals("network.event", it.category) @@ -223,18 +237,18 @@ class NetworkBreadcrumbsIntegrationTest { @Test fun `When a network connection detail changes, a breadcrumb is captured only if previously connected to that network`() { val sut = fixture.getSut() - sut.register(fixture.hub, fixture.options) + sut.register(fixture.scopes, fixture.options) val callback = sut.networkCallback assertNotNull(callback) // callback.onAvailable(network) was not called, so no breadcrumb should be captured onCapabilitiesChanged(callback, mock()) - verify(fixture.hub, never()).addBreadcrumb(any(), anyOrNull()) + verify(fixture.scopes, never()).addBreadcrumb(any(), anyOrNull()) } @Test fun `When a network connection detail changes, a new breadcrumb is captured if vpn flag changes`() { val sut = fixture.getSut() - sut.register(fixture.hub, fixture.options) + sut.register(fixture.scopes, fixture.options) val callback = sut.networkCallback assertNotNull(callback) callback.onAvailable(fixture.network) @@ -245,17 +259,17 @@ class NetworkBreadcrumbsIntegrationTest { onCapabilitiesChanged(callback, details1) onCapabilitiesChanged(callback, details2) onCapabilitiesChanged(callback, details3) - inOrder(fixture.hub) { + inOrder(fixture.scopes) { verifyBreadcrumbInOrder { assertFalse(it.isVpn) } verifyBreadcrumbInOrder { assertTrue(it.isVpn) } - verify(fixture.hub, never()).addBreadcrumb(any(), any()) + verify(fixture.scopes, never()).addBreadcrumb(any(), any()) } } @Test fun `When a network connection detail changes, a new breadcrumb is captured if type changes`() { val sut = fixture.getSut() - sut.register(fixture.hub, fixture.options) + sut.register(fixture.scopes, fixture.options) val callback = sut.networkCallback assertNotNull(callback) callback.onAvailable(fixture.network) @@ -266,10 +280,10 @@ class NetworkBreadcrumbsIntegrationTest { onCapabilitiesChanged(callback, details1) onCapabilitiesChanged(callback, details2) onCapabilitiesChanged(callback, details3) - inOrder(fixture.hub) { + inOrder(fixture.scopes) { verifyBreadcrumbInOrder { assertEquals("wifi", it.type) } verifyBreadcrumbInOrder { assertEquals("cellular", it.type) } - verify(fixture.hub, never()).addBreadcrumb(any(), any()) + verify(fixture.scopes, never()).addBreadcrumb(any(), any()) } } @@ -278,7 +292,7 @@ class NetworkBreadcrumbsIntegrationTest { val buildInfo = mock() whenever(buildInfo.sdkInfoVersion).thenReturn(Build.VERSION_CODES.Q) val sut = fixture.getSut(buildInfo = buildInfo) - sut.register(fixture.hub, fixture.options) + sut.register(fixture.scopes, fixture.options) val callback = sut.networkCallback assertNotNull(callback) callback.onAvailable(fixture.network) @@ -289,10 +303,10 @@ class NetworkBreadcrumbsIntegrationTest { // A change of signal strength of 5 doesn't trigger a new breadcrumb onCapabilitiesChanged(callback, details2) onCapabilitiesChanged(callback, details3) - inOrder(fixture.hub) { + inOrder(fixture.scopes) { verifyBreadcrumbInOrder { assertEquals(50, it.signalStrength) } verifyBreadcrumbInOrder { assertEquals(56, it.signalStrength) } - verify(fixture.hub, never()).addBreadcrumb(any(), any()) + verify(fixture.scopes, never()).addBreadcrumb(any(), any()) } } @@ -301,7 +315,7 @@ class NetworkBreadcrumbsIntegrationTest { val buildInfo = mock() whenever(buildInfo.sdkInfoVersion).thenReturn(Build.VERSION_CODES.Q) val sut = fixture.getSut(buildInfo = buildInfo) - sut.register(fixture.hub, fixture.options) + sut.register(fixture.scopes, fixture.options) val callback = sut.networkCallback assertNotNull(callback) callback.onAvailable(fixture.network) @@ -317,11 +331,11 @@ class NetworkBreadcrumbsIntegrationTest { // A change of download bandwidth of 10% (more than 1000) doesn't trigger a new breadcrumb onCapabilitiesChanged(callback, details4) onCapabilitiesChanged(callback, details5) - inOrder(fixture.hub) { + inOrder(fixture.scopes) { verifyBreadcrumbInOrder { assertEquals(1000, it.downBandwidth) } verifyBreadcrumbInOrder { assertEquals(20000, it.downBandwidth) } verifyBreadcrumbInOrder { assertEquals(22001, it.downBandwidth) } - verify(fixture.hub, never()).addBreadcrumb(any(), any()) + verify(fixture.scopes, never()).addBreadcrumb(any(), any()) } } @@ -330,7 +344,7 @@ class NetworkBreadcrumbsIntegrationTest { val buildInfo = mock() whenever(buildInfo.sdkInfoVersion).thenReturn(Build.VERSION_CODES.Q) val sut = fixture.getSut(buildInfo = buildInfo) - sut.register(fixture.hub, fixture.options) + sut.register(fixture.scopes, fixture.options) val callback = sut.networkCallback assertNotNull(callback) callback.onAvailable(fixture.network) @@ -346,18 +360,18 @@ class NetworkBreadcrumbsIntegrationTest { // A change of upload bandwidth of 10% (more than 1000) doesn't trigger a new breadcrumb onCapabilitiesChanged(callback, details4) onCapabilitiesChanged(callback, details5) - inOrder(fixture.hub) { + inOrder(fixture.scopes) { verifyBreadcrumbInOrder { assertEquals(1000, it.upBandwidth) } verifyBreadcrumbInOrder { assertEquals(20000, it.upBandwidth) } verifyBreadcrumbInOrder { assertEquals(22001, it.upBandwidth) } - verify(fixture.hub, never()).addBreadcrumb(any(), any()) + verify(fixture.scopes, never()).addBreadcrumb(any(), any()) } } @Test fun `signal strength is 0 if not on Android Q+`() { val sut = fixture.getSut() - sut.register(fixture.hub, fixture.options) + sut.register(fixture.scopes, fixture.options) val callback = sut.networkCallback assertNotNull(callback) callback.onAvailable(fixture.network) @@ -371,7 +385,7 @@ class NetworkBreadcrumbsIntegrationTest { val buildInfo = mock() whenever(buildInfo.sdkInfoVersion).thenReturn(Build.VERSION_CODES.Q) val sut = fixture.getSut(buildInfo = buildInfo) - sut.register(fixture.hub, fixture.options) + sut.register(fixture.scopes, fixture.options) val callback = sut.networkCallback assertNotNull(callback) callback.onAvailable(fixture.network) @@ -384,7 +398,7 @@ class NetworkBreadcrumbsIntegrationTest { @Test fun `A breadcrumb is captured when vpn status changes, regardless of the timestamp`() { val sut = fixture.getSut() - sut.register(fixture.hub, fixture.options) + sut.register(fixture.scopes, fixture.options) val callback = sut.networkCallback assertNotNull(callback) callback.onAvailable(fixture.network) @@ -392,17 +406,17 @@ class NetworkBreadcrumbsIntegrationTest { val details2 = createConnectionDetail(isVpn = true) onCapabilitiesChanged(callback, details1, 0) onCapabilitiesChanged(callback, details2, 0) - inOrder(fixture.hub) { + inOrder(fixture.scopes) { verifyBreadcrumbInOrder { assertFalse(it.isVpn) } verifyBreadcrumbInOrder { assertTrue(it.isVpn) } - verify(fixture.hub, never()).addBreadcrumb(any(), any()) + verify(fixture.scopes, never()).addBreadcrumb(any(), any()) } } @Test fun `A breadcrumb is captured when connection type changes, regardless of the timestamp`() { val sut = fixture.getSut() - sut.register(fixture.hub, fixture.options) + sut.register(fixture.scopes, fixture.options) val callback = sut.networkCallback assertNotNull(callback) callback.onAvailable(fixture.network) @@ -412,11 +426,11 @@ class NetworkBreadcrumbsIntegrationTest { onCapabilitiesChanged(callback, details1, 0) onCapabilitiesChanged(callback, details2, 0) onCapabilitiesChanged(callback, details3, 0) - inOrder(fixture.hub) { + inOrder(fixture.scopes) { verifyBreadcrumbInOrder { assertEquals("wifi", it.type) } verifyBreadcrumbInOrder { assertEquals("cellular", it.type) } verifyBreadcrumbInOrder { assertEquals("ethernet", it.type) } - verify(fixture.hub, never()).addBreadcrumb(any(), any()) + verify(fixture.scopes, never()).addBreadcrumb(any(), any()) } } @@ -425,7 +439,7 @@ class NetworkBreadcrumbsIntegrationTest { val buildInfo = mock() whenever(buildInfo.sdkInfoVersion).thenReturn(Build.VERSION_CODES.Q) val sut = fixture.getSut(buildInfo = buildInfo) - sut.register(fixture.hub, fixture.options) + sut.register(fixture.scopes, fixture.options) val callback = sut.networkCallback assertNotNull(callback) callback.onAvailable(fixture.network) @@ -435,17 +449,17 @@ class NetworkBreadcrumbsIntegrationTest { onCapabilitiesChanged(callback, details1, 0) onCapabilitiesChanged(callback, details2, 0) onCapabilitiesChanged(callback, details3, 5000) - inOrder(fixture.hub) { + inOrder(fixture.scopes) { verifyBreadcrumbInOrder { assertEquals(1, it.signalStrength) } verifyBreadcrumbInOrder { assertEquals(51, it.signalStrength) } - verify(fixture.hub, never()).addBreadcrumb(any(), any()) + verify(fixture.scopes, never()).addBreadcrumb(any(), any()) } } @Test fun `A breadcrumb is captured when downBandwidth changes at most once every 5 seconds`() { val sut = fixture.getSut() - sut.register(fixture.hub, fixture.options) + sut.register(fixture.scopes, fixture.options) val callback = sut.networkCallback assertNotNull(callback) callback.onAvailable(fixture.network) @@ -455,17 +469,17 @@ class NetworkBreadcrumbsIntegrationTest { onCapabilitiesChanged(callback, details1, 0) onCapabilitiesChanged(callback, details2, 0) onCapabilitiesChanged(callback, details3, 5000) - inOrder(fixture.hub) { + inOrder(fixture.scopes) { verifyBreadcrumbInOrder { assertEquals(1, it.downBandwidth) } verifyBreadcrumbInOrder { assertEquals(2001, it.downBandwidth) } - verify(fixture.hub, never()).addBreadcrumb(any(), any()) + verify(fixture.scopes, never()).addBreadcrumb(any(), any()) } } @Test fun `A breadcrumb is captured when upBandwidth changes at most once every 5 seconds`() { val sut = fixture.getSut() - sut.register(fixture.hub, fixture.options) + sut.register(fixture.scopes, fixture.options) val callback = sut.networkCallback assertNotNull(callback) callback.onAvailable(fixture.network) @@ -475,28 +489,45 @@ class NetworkBreadcrumbsIntegrationTest { onCapabilitiesChanged(callback, details1, 0) onCapabilitiesChanged(callback, details2, 0) onCapabilitiesChanged(callback, details3, 5000) - inOrder(fixture.hub) { + inOrder(fixture.scopes) { verifyBreadcrumbInOrder { assertEquals(1, it.upBandwidth) } verifyBreadcrumbInOrder { assertEquals(2001, it.upBandwidth) } - verify(fixture.hub, never()).addBreadcrumb(any(), any()) + verify(fixture.scopes, never()).addBreadcrumb(any(), any()) } } + @Test + fun `If integration is opened and closed immediately it still properly unregisters`() { + val executor = DeferredExecutorService() + val sut = fixture.getSut(executor = executor) + + sut.register(fixture.scopes, fixture.options) + sut.close() + + executor.runAll() + + assertNull(sut.networkCallback) + verify(fixture.connectivityManager, never()).registerDefaultNetworkCallback(any()) + verify(fixture.connectivityManager, never()).unregisterNetworkCallback(any()) + } + private fun KInOrder.verifyBreadcrumbInOrder(check: (detail: NetworkBreadcrumbConnectionDetail) -> Unit) { - verify(fixture.hub, times(1)).addBreadcrumb( + verify(fixture.scopes, times(1)).addBreadcrumb( any(), check { - val connectionDetail = it[TypeCheckHint.ANDROID_NETWORK_CAPABILITIES] as NetworkBreadcrumbConnectionDetail + val connectionDetail = + it[TypeCheckHint.ANDROID_NETWORK_CAPABILITIES] as NetworkBreadcrumbConnectionDetail check(connectionDetail) } ) } private fun verifyBreadcrumb(check: (detail: NetworkBreadcrumbConnectionDetail) -> Unit) { - verify(fixture.hub).addBreadcrumb( + verify(fixture.scopes).addBreadcrumb( any(), check { - val connectionDetail = it[TypeCheckHint.ANDROID_NETWORK_CAPABILITIES] as NetworkBreadcrumbConnectionDetail + val connectionDetail = + it[TypeCheckHint.ANDROID_NETWORK_CAPABILITIES] as NetworkBreadcrumbConnectionDetail check(connectionDetail) } ) @@ -516,9 +547,13 @@ class NetworkBreadcrumbsIntegrationTest { whenever(capabilities.linkUpstreamBandwidthKbps).thenReturn(upstreamBandwidthKbps) whenever(capabilities.signalStrength).thenReturn(signalStrength) whenever(capabilities.hasTransport(NetworkCapabilities.TRANSPORT_VPN)).thenReturn(isVpn) - whenever(capabilities.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET)).thenReturn(isEthernet) + whenever(capabilities.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET)).thenReturn( + isEthernet + ) whenever(capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)).thenReturn(isWifi) - whenever(capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)).thenReturn(isCellular) + whenever(capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)).thenReturn( + isCellular + ) return capabilities } diff --git a/sentry-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..529731c634 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 @@ -3,7 +3,7 @@ package io.sentry.android.core import android.content.ContentProvider import androidx.test.ext.junit.runners.AndroidJUnit4 import io.sentry.Hint -import io.sentry.IHub +import io.sentry.IScopes import io.sentry.MeasurementUnit import io.sentry.SentryTracer import io.sentry.SpanContext @@ -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 @@ -37,7 +39,7 @@ class PerformanceAndroidEventProcessorTest { private class Fixture { val options = SentryAndroidOptions() - val hub = mock() + val scopes = mock() val context = TransactionContext("name", "op", TracesSamplingDecision(true)) lateinit var tracer: SentryTracer val activityFramesTracker = mock() @@ -46,16 +48,34 @@ 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) - tracer = SentryTracer(context, hub) + whenever(scopes.options).thenReturn(options) + tracer = SentryTracer(context, scopes) return PerformanceAndroidEventProcessor(options, activityFramesTracker) } } 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 + ).also { + AppStartMetrics.getInstance().onActivityCreated(mock(), mock()) + } + @BeforeTest fun `reset instance`() { AppStartMetrics.getInstance().clear() @@ -183,7 +203,7 @@ class PerformanceAndroidEventProcessorTest { fun `add slow and frozen frames if auto transaction`() { val sut = fixture.getSut() val context = TransactionContext("Activity", UI_LOAD_OP) - val tracer = SentryTracer(context, fixture.hub) + val tracer = SentryTracer(context, fixture.scopes) var tr = SentryTransaction(tracer) val metrics = mapOf( @@ -229,25 +249,11 @@ class PerformanceAndroidEventProcessorTest { // 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) + val tracer = SentryTracer(context, fixture.scopes) 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 +291,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.scopes) + 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.scopes) + 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 @@ -299,7 +409,7 @@ class PerformanceAndroidEventProcessorTest { // 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) + val tracer = SentryTracer(context, fixture.scopes) var tr = SentryTransaction(tracer) // then the app start metrics should not be attached @@ -328,23 +438,9 @@ class PerformanceAndroidEventProcessorTest { // when the first activity transaction is created val sut = fixture.getSut(enablePerformanceV2 = true) val context = TransactionContext("Activity", UI_LOAD_OP) - val tracer = SentryTracer(context, fixture.hub) + val tracer = SentryTracer(context, fixture.scopes) 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 @@ -379,23 +475,9 @@ class PerformanceAndroidEventProcessorTest { val sut = fixture.getSut(enablePerformanceV2 = true) val context = TransactionContext("Activity", UI_LOAD_OP) - val tracer = SentryTracer(context, fixture.hub) + val tracer = SentryTracer(context, fixture.scopes) 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 @@ -426,23 +508,9 @@ class PerformanceAndroidEventProcessorTest { val sut = fixture.getSut(enablePerformanceV2 = true) val context = TransactionContext("Activity", UI_LOAD_OP) - val tracer = SentryTracer(context, fixture.hub) + val tracer = SentryTracer(context, fixture.scopes) 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 @@ -473,7 +541,7 @@ class PerformanceAndroidEventProcessorTest { val sut = fixture.getSut(enablePerformanceV2 = true) val context = TransactionContext("Activity", UI_LOAD_OP) - val tracer = SentryTracer(context, fixture.hub) + val tracer = SentryTracer(context, fixture.scopes) var tr = SentryTransaction(tracer) // when it contains no app start span and is processed @@ -490,24 +558,10 @@ class PerformanceAndroidEventProcessorTest { val sut = fixture.getSut(enablePerformanceV2 = true) val context = TransactionContext("Activity", UI_LOAD_OP) - val tracer = SentryTracer(context, fixture.hub) + val tracer = SentryTracer(context, fixture.scopes) 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 @@ -525,7 +579,7 @@ class PerformanceAndroidEventProcessorTest { val sut = fixture.getSut() val context = TransactionContext("Activity", UI_LOAD_OP) - val tracer = SentryTracer(context, fixture.hub) + val tracer = SentryTracer(context, fixture.scopes) val tr = SentryTransaction(tracer) // given a ttid from 0.0 -> 1.0 @@ -542,7 +596,6 @@ class PerformanceAndroidEventProcessorTest { null, emptyMap(), emptyMap(), - null, null ) @@ -558,7 +611,6 @@ class PerformanceAndroidEventProcessorTest { null, emptyMap(), emptyMap(), - null, null ) tr.spans.add(ttid) @@ -578,7 +630,6 @@ class PerformanceAndroidEventProcessorTest { null, emptyMap(), emptyMap(), - null, null ) @@ -595,7 +646,6 @@ class PerformanceAndroidEventProcessorTest { null, emptyMap(), emptyMap(), - null, null ) @@ -612,7 +662,6 @@ class PerformanceAndroidEventProcessorTest { null, emptyMap(), emptyMap(), - null, mutableMapOf( "tag" to "value" ) @@ -649,7 +698,7 @@ class PerformanceAndroidEventProcessorTest { val sut = fixture.getSut() val context = TransactionContext("Activity", UI_LOAD_OP) - val tracer = SentryTracer(context, fixture.hub) + val tracer = SentryTracer(context, fixture.scopes) val tr = SentryTransaction(tracer) val span = SentrySpan( @@ -664,7 +713,6 @@ class PerformanceAndroidEventProcessorTest { null, emptyMap(), emptyMap(), - null, null ) @@ -683,7 +731,7 @@ class PerformanceAndroidEventProcessorTest { val sut = fixture.getSut() val context = TransactionContext("Activity", UI_LOAD_OP) - val tracer = SentryTracer(context, fixture.hub) + val tracer = SentryTracer(context, fixture.scopes) val tr = SentryTransaction(tracer) // given a ttid from 0.0 -> 1.0 @@ -700,7 +748,6 @@ class PerformanceAndroidEventProcessorTest { null, emptyMap(), emptyMap(), - null, null ) @@ -716,7 +763,6 @@ class PerformanceAndroidEventProcessorTest { null, emptyMap(), emptyMap(), - null, null ) tr.spans.add(ttid) @@ -735,7 +781,6 @@ class PerformanceAndroidEventProcessorTest { null, emptyMap(), emptyMap(), - null, null ) @@ -752,7 +797,6 @@ class PerformanceAndroidEventProcessorTest { null, emptyMap(), emptyMap(), - null, mutableMapOf( "thread.name" to "main" ) @@ -771,7 +815,6 @@ class PerformanceAndroidEventProcessorTest { null, emptyMap(), emptyMap(), - null, mutableMapOf( "thread.name" to "background" ) @@ -818,7 +861,7 @@ class PerformanceAndroidEventProcessorTest { AppStartType.UNKNOWN -> "ui.load" } val txn = SentryTransaction(fixture.tracer) - txn.contexts.trace = SpanContext(op, TracesSamplingDecision(false)) + txn.contexts.setTrace(SpanContext(op, TracesSamplingDecision(false))) return txn } } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/PhoneStateBreadcrumbsIntegrationTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/PhoneStateBreadcrumbsIntegrationTest.kt index 2b6ca801da..c764d11c2d 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/PhoneStateBreadcrumbsIntegrationTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/PhoneStateBreadcrumbsIntegrationTest.kt @@ -4,7 +4,7 @@ import android.content.Context import android.telephony.PhoneStateListener import android.telephony.TelephonyManager import io.sentry.Breadcrumb -import io.sentry.IHub +import io.sentry.IScopes import io.sentry.ISentryExecutorService import io.sentry.SentryLevel import io.sentry.test.DeferredExecutorService @@ -41,8 +41,8 @@ class PhoneStateBreadcrumbsIntegrationTest { @Test fun `When system events breadcrumb is enabled, it registers callback`() { val sut = fixture.getSut() - val hub = mock() - sut.register(hub, fixture.options) + val scopes = mock() + sut.register(scopes, fixture.options) verify(fixture.manager).listen(any(), eq(PhoneStateListener.LISTEN_CALL_STATE)) assertNotNull(sut.listener) } @@ -50,8 +50,8 @@ class PhoneStateBreadcrumbsIntegrationTest { @Test fun `Phone state callback is registered in the executorService`() { val sut = fixture.getSut(mock()) - val hub = mock() - sut.register(hub, fixture.options) + val scopes = mock() + sut.register(scopes, fixture.options) assertNull(sut.listener) } @@ -59,9 +59,9 @@ class PhoneStateBreadcrumbsIntegrationTest { @Test fun `When system events breadcrumb is disabled, it doesn't register callback`() { val sut = fixture.getSut() - val hub = mock() + val scopes = mock() sut.register( - hub, + scopes, fixture.options.apply { isEnableSystemEventBreadcrumbs = false } @@ -73,15 +73,15 @@ class PhoneStateBreadcrumbsIntegrationTest { @Test fun `When ActivityBreadcrumbsIntegration is closed, it should unregister the callback`() { val sut = fixture.getSut() - val hub = mock() - sut.register(hub, fixture.options) + val scopes = mock() + sut.register(scopes, fixture.options) sut.close() verify(fixture.manager).listen(any(), eq(PhoneStateListener.LISTEN_NONE)) assertNull(sut.listener) } @Test - fun `when hub is closed right after start, integration is not registered`() { + fun `when scopes is closed right after start, integration is not registered`() { val deferredExecutorService = DeferredExecutorService() val sut = fixture.getSut(executorService = deferredExecutorService) sut.register(mock(), fixture.options) @@ -94,11 +94,11 @@ class PhoneStateBreadcrumbsIntegrationTest { @Test fun `When on call state received, added breadcrumb with type and category`() { val sut = fixture.getSut() - val hub = mock() - sut.register(hub, fixture.options) + val scopes = mock() + sut.register(scopes, fixture.options) sut.listener!!.onCallStateChanged(TelephonyManager.CALL_STATE_RINGING, null) - verify(hub).addBreadcrumb( + verify(scopes).addBreadcrumb( check { assertEquals("device.event", it.category) assertEquals("system", it.type) @@ -111,18 +111,18 @@ class PhoneStateBreadcrumbsIntegrationTest { @Test fun `When on idle state received, added breadcrumb with type and category`() { val sut = fixture.getSut() - val hub = mock() - sut.register(hub, fixture.options) + val scopes = mock() + sut.register(scopes, fixture.options) sut.listener!!.onCallStateChanged(TelephonyManager.CALL_STATE_IDLE, null) - verify(hub, never()).addBreadcrumb(any()) + verify(scopes, never()).addBreadcrumb(any()) } @Test fun `When on offhook state received, added breadcrumb with type and category`() { val sut = fixture.getSut() - val hub = mock() - sut.register(hub, fixture.options) + val scopes = mock() + sut.register(scopes, fixture.options) sut.listener!!.onCallStateChanged(TelephonyManager.CALL_STATE_OFFHOOK, null) - verify(hub, never()).addBreadcrumb(any()) + verify(scopes, never()).addBreadcrumb(any()) } } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/ScreenshotEventProcessorTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/ScreenshotEventProcessorTest.kt index f75ddbd901..143b954f74 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/ScreenshotEventProcessorTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/ScreenshotEventProcessorTest.kt @@ -11,7 +11,7 @@ import io.sentry.SentryEvent import io.sentry.SentryIntegrationPackageStorage import io.sentry.TypeCheckHint.ANDROID_ACTIVITY import io.sentry.protocol.SentryException -import io.sentry.util.thread.IMainThreadChecker +import io.sentry.util.thread.IThreadChecker import org.junit.runner.RunWith import org.mockito.kotlin.any import org.mockito.kotlin.mock @@ -35,7 +35,7 @@ class ScreenshotEventProcessorTest { val window = mock() val view = mock() val rootView = mock() - val mainThreadChecker = mock() + val threadChecker = mock() val options = SentryAndroidOptions().apply { dsn = "https://key@sentry.io/proj" } @@ -52,12 +52,12 @@ class ScreenshotEventProcessorTest { it.getArgument(0).run() } - whenever(mainThreadChecker.isMainThread).thenReturn(true) + whenever(threadChecker.isMainThread).thenReturn(true) } fun getSut(attachScreenshot: Boolean = false): ScreenshotEventProcessor { options.isAttachScreenshot = attachScreenshot - options.mainThreadChecker = mainThreadChecker + options.threadChecker = threadChecker return ScreenshotEventProcessor(options, buildInfo) } @@ -172,7 +172,7 @@ class ScreenshotEventProcessorTest { @Test fun `when screenshot event processor is called from background thread it executes on main thread`() { val sut = fixture.getSut(true) - whenever(fixture.mainThreadChecker.isMainThread).thenReturn(false) + whenever(fixture.threadChecker.isMainThread).thenReturn(false) CurrentActivityHolder.getInstance().setActivity(fixture.activity) diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/SendCachedEnvelopeIntegrationTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/SendCachedEnvelopeIntegrationTest.kt index 403f40ee70..f1e345eefc 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/SendCachedEnvelopeIntegrationTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/SendCachedEnvelopeIntegrationTest.kt @@ -2,8 +2,8 @@ package io.sentry.android.core import io.sentry.IConnectionStatusProvider import io.sentry.IConnectionStatusProvider.ConnectionStatus -import io.sentry.IHub import io.sentry.ILogger +import io.sentry.IScopes import io.sentry.ISentryExecutorService import io.sentry.SendCachedEnvelopeFireAndForgetIntegration.SendFireAndForget import io.sentry.SendCachedEnvelopeFireAndForgetIntegration.SendFireAndForgetFactory @@ -28,7 +28,7 @@ import kotlin.test.Test class SendCachedEnvelopeIntegrationTest { private class Fixture { - val hub: IHub = mock() + val scopes: IScopes = mock() val options = SentryAndroidOptions() val logger = mock() val factory = mock() @@ -74,7 +74,7 @@ class SendCachedEnvelopeIntegrationTest { fun `when cacheDirPath is not set, does nothing`() { val sut = fixture.getSut(cacheDirPath = null) - sut.register(fixture.hub, fixture.options) + sut.register(fixture.scopes, fixture.options) verify(fixture.factory, never()).create(any(), any()) } @@ -83,7 +83,7 @@ class SendCachedEnvelopeIntegrationTest { fun `when factory returns null, does nothing`() { val sut = fixture.getSut(hasSender = false, mockExecutorService = ImmediateExecutorService()) - sut.register(fixture.hub, fixture.options) + sut.register(fixture.scopes, fixture.options) verify(fixture.factory).create(any(), any()) verify(fixture.sender, never()).send() @@ -93,7 +93,7 @@ class SendCachedEnvelopeIntegrationTest { fun `when has factory and cacheDirPath set, submits task into queue`() { val sut = fixture.getSut(mockExecutorService = ImmediateExecutorService()) - sut.register(fixture.hub, fixture.options) + sut.register(fixture.scopes, fixture.options) await.untilFalse(fixture.flag) verify(fixture.sender).send() @@ -102,7 +102,7 @@ class SendCachedEnvelopeIntegrationTest { @Test fun `when executorService is fake, does nothing`() { val sut = fixture.getSut(mockExecutorService = mock()) - sut.register(fixture.hub, fixture.options) + sut.register(fixture.scopes, fixture.options) verify(fixture.factory, never()).create(any(), any()) verify(fixture.sender, never()).send() @@ -112,7 +112,7 @@ class SendCachedEnvelopeIntegrationTest { fun `when has startup crash marker, awaits the task on the calling thread`() { val sut = fixture.getSut(hasStartupCrashMarker = true) - sut.register(fixture.hub, fixture.options) + sut.register(fixture.scopes, fixture.options) // we do not need to await here, because it's executed synchronously verify(fixture.sender).send() @@ -123,7 +123,7 @@ class SendCachedEnvelopeIntegrationTest { val sut = fixture.getSut(hasStartupCrashMarker = true, delaySend = 1000) fixture.options.startupCrashFlushTimeoutMillis = 100 - sut.register(fixture.hub, fixture.options) + sut.register(fixture.scopes, fixture.options) // first wait until synchronous send times out and check that the logger was hit in the catch block await.atLeast(500, MILLISECONDS) @@ -144,7 +144,7 @@ class SendCachedEnvelopeIntegrationTest { val connectionStatusProvider = mock() fixture.options.connectionStatusProvider = connectionStatusProvider - sut.register(fixture.hub, fixture.options) + sut.register(fixture.scopes, fixture.options) verify(connectionStatusProvider).addConnectionStatusObserver(any()) } @@ -159,7 +159,7 @@ class SendCachedEnvelopeIntegrationTest { ConnectionStatus.DISCONNECTED ) - sut.register(fixture.hub, fixture.options) + sut.register(fixture.scopes, fixture.options) verify(fixture.sender, never()).send() } @@ -174,7 +174,7 @@ class SendCachedEnvelopeIntegrationTest { ConnectionStatus.UNKNOWN ) - sut.register(fixture.hub, fixture.options) + sut.register(fixture.scopes, fixture.options) verify(fixture.factory).create(any(), any()) } @@ -187,7 +187,7 @@ class SendCachedEnvelopeIntegrationTest { whenever(connectionStatusProvider.connectionStatus).thenReturn( ConnectionStatus.DISCONNECTED ) - sut.register(fixture.hub, fixture.options) + sut.register(fixture.scopes, fixture.options) // when there's no connection no factory create call should be done verify(fixture.sender, never()).send() @@ -215,9 +215,9 @@ class SendCachedEnvelopeIntegrationTest { val rateLimiter = mock { whenever(mock.isActiveForCategory(any())).thenReturn(true) } - whenever(fixture.hub.rateLimiter).thenReturn(rateLimiter) + whenever(fixture.scopes.rateLimiter).thenReturn(rateLimiter) - sut.register(fixture.hub, fixture.options) + sut.register(fixture.scopes, fixture.options) // no factory call should be done if there's rate limiting active verify(fixture.sender, never()).send() @@ -228,7 +228,7 @@ class SendCachedEnvelopeIntegrationTest { val deferredExecutorService = DeferredExecutorService() val sut = fixture.getSut(mockExecutorService = deferredExecutorService) - sut.register(fixture.hub, fixture.options) + sut.register(fixture.scopes, fixture.options) verify(fixture.sender, never()).send() sut.close() diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidOptionsTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidOptionsTest.kt index 5ed5de1c04..71667394a2 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidOptionsTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidOptionsTest.kt @@ -151,16 +151,16 @@ class SentryAndroidOptionsTest { } @Test - fun `performance v2 is disabled by default`() { + fun `performance v2 is enabled by default`() { val sentryOptions = SentryAndroidOptions() - assertFalse(sentryOptions.isEnablePerformanceV2) + assertTrue(sentryOptions.isEnablePerformanceV2) } @Test - fun `performance v2 can be enabled`() { + fun `performance v2 can be disabled`() { val sentryOptions = SentryAndroidOptions() - sentryOptions.isEnablePerformanceV2 = true - assertTrue(sentryOptions.isEnablePerformanceV2) + sentryOptions.isEnablePerformanceV2 = false + assertFalse(sentryOptions.isEnablePerformanceV2) } fun `when options is initialized, enableScopeSync is enabled by default`() { 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..cf2369d559 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 @@ -319,6 +320,7 @@ class SentryAndroidTest { @Test fun `init does not start a session if one is already running`() { val client = mock() + whenever(client.isEnabled).thenReturn(true) initSentryWithForegroundImportance(true, { options -> options.addIntegration { hub, _ -> @@ -331,25 +333,49 @@ 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()) + } + } + + @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 = {}, callback: (session: Session?) -> Unit ) { - val context = ContextUtilsTestHelper.createMockContext() - - Mockito.mockStatic(ContextUtils::class.java).use { mockedContextUtils -> + Mockito.mockStatic(ContextUtils::class.java, Mockito.CALLS_REAL_METHODS).use { mockedContextUtils -> mockedContextUtils.`when` { ContextUtils.isForegroundImportance() } .thenReturn(inForeground) SentryAndroid.init(context) { options -> options.release = "prod" options.dsn = "https://key@sentry.io/123" options.isEnableAutoSessionTracking = true + options.experimental.sessionReplay.onErrorSampleRate = 1.0 optionsConfig(options) } var session: Session? = null - Sentry.getCurrentHub().configureScope { scope -> + Sentry.getCurrentScopes().configureScope { scope -> session = scope.session } callback(session) @@ -361,7 +387,7 @@ class SentryAndroidTest { fixture.initSut { options -> options.isEnableAutoSessionTracking = false } - Sentry.getCurrentHub().withScope { scope -> + Sentry.getCurrentScopes().withScope { scope -> assertNull(scope.session) } } @@ -397,7 +423,7 @@ class SentryAndroidTest { it.release = "io.sentry.sample@1.1.0+220" it.environment = "debug" // this is necessary to delay the AnrV2Integration processing to execute the configure - // scope block below (otherwise it won't be possible as hub is no-op before .init) + // scope block below (otherwise it won't be possible as scopes is no-op before .init) it.executorService.submit { Sentry.configureScope { scope -> // make sure the scope values changed to test that we're still using previously @@ -415,7 +441,7 @@ class SentryAndroidTest { .untilTrue(asserted) // assert that persisted values have changed - options.executorService.close(5000L) // finalizes all enqueued persisting tasks + options.executorService.close(10000L) // finalizes all enqueued persisting tasks assertEquals( "TestActivity", PersistingScopeObserver.read(options, TRANSACTION_FILENAME, String::class.java) @@ -432,7 +458,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 +478,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/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/SessionTrackingIntegrationTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/SessionTrackingIntegrationTest.kt index 1a441cd832..e392d238ab 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 @@ -8,7 +8,6 @@ import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 import io.sentry.CheckIn import io.sentry.Hint -import io.sentry.IMetricsAggregator import io.sentry.IScope import io.sentry.ISentryClient import io.sentry.ProfilingTraceData @@ -16,6 +15,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 +146,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") } @@ -175,9 +183,5 @@ class SessionTrackingIntegrationTest { override fun getRateLimiter(): RateLimiter? { TODO("Not yet implemented") } - - override fun getMetricsAggregator(): IMetricsAggregator { - 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..45e247a5cb 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.IScopes 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,12 +25,13 @@ import kotlin.test.assertFalse import kotlin.test.assertNotNull import kotlin.test.assertNull +@RunWith(AndroidJUnit4::class) class SystemEventsBreadcrumbsIntegrationTest { private class Fixture { val context = mock() var options = SentryAndroidOptions() - val hub = mock() + val scopes = mock() fun getSut(enableSystemEventBreadcrumbs: Boolean = true, executorService: ISentryExecutorService = ImmediateExecutorService()): SystemEventsBreadcrumbsIntegration { options = SentryAndroidOptions().apply { @@ -43,7 +48,7 @@ class SystemEventsBreadcrumbsIntegrationTest { fun `When system events breadcrumb is enabled, it registers callback`() { val sut = fixture.getSut() - sut.register(fixture.hub, fixture.options) + sut.register(fixture.scopes, fixture.options) verify(fixture.context).registerReceiver(any(), any()) assertNotNull(sut.receiver) @@ -52,8 +57,8 @@ class SystemEventsBreadcrumbsIntegrationTest { @Test fun `system events callback is registered in the executorService`() { val sut = fixture.getSut(executorService = mock()) - val hub = mock() - sut.register(hub, fixture.options) + val scopes = mock() + sut.register(scopes, fixture.options) assertNull(sut.receiver) } @@ -62,7 +67,7 @@ class SystemEventsBreadcrumbsIntegrationTest { fun `When system events breadcrumb is disabled, it doesn't register callback`() { val sut = fixture.getSut(enableSystemEventBreadcrumbs = false) - sut.register(fixture.hub, fixture.options) + sut.register(fixture.scopes, fixture.options) verify(fixture.context, never()).registerReceiver(any(), any()) assertNull(sut.receiver) @@ -72,7 +77,7 @@ class SystemEventsBreadcrumbsIntegrationTest { fun `When ActivityBreadcrumbsIntegration is closed, it should unregister the callback`() { val sut = fixture.getSut() - sut.register(fixture.hub, fixture.options) + sut.register(fixture.scopes, fixture.options) sut.close() verify(fixture.context).unregisterReceiver(any()) @@ -80,10 +85,10 @@ class SystemEventsBreadcrumbsIntegrationTest { } @Test - fun `when hub is closed right after start, integration is not registered`() { + fun `when scopes is closed right after start, integration is not registered`() { val deferredExecutorService = DeferredExecutorService() val sut = fixture.getSut(executorService = deferredExecutorService) - sut.register(fixture.hub, fixture.options) + sut.register(fixture.scopes, fixture.options) assertNull(sut.receiver) sut.close() deferredExecutorService.runAll() @@ -94,13 +99,13 @@ class SystemEventsBreadcrumbsIntegrationTest { fun `When broadcast received, added breadcrumb with type and category`() { val sut = fixture.getSut() - sut.register(fixture.hub, fixture.options) + sut.register(fixture.scopes, fixture.options) val intent = Intent().apply { action = Intent.ACTION_TIME_CHANGED } sut.receiver!!.onReceive(fixture.context, intent) - verify(fixture.hub).addBreadcrumb( + verify(fixture.scopes).addBreadcrumb( check { assertEquals("device.event", it.category) assertEquals("system", it.type) @@ -111,12 +116,67 @@ class SystemEventsBreadcrumbsIntegrationTest { ) } + @Test + fun `handles battery changes`() { + val sut = fixture.getSut() + + sut.register(fixture.scopes, 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.scopes).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.scopes, 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.scopes).addBreadcrumb( + check { + assertEquals(it.data["level"], 80f) + assertEquals(it.data["charging"], false) + }, + anyOrNull() + ) + verifyNoMoreInteractions(fixture.scopes) + } + @Test fun `Do not crash if registerReceiver throws exception`() { val sut = fixture.getSut() whenever(fixture.context.registerReceiver(any(), any())).thenThrow(SecurityException()) - sut.register(fixture.hub, fixture.options) + sut.register(fixture.scopes, fixture.options) assertFalse(fixture.options.isEnableSystemEventBreadcrumbs) } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/TempSensorBreadcrumbsIntegrationTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/TempSensorBreadcrumbsIntegrationTest.kt index d443b1e345..5d049e3dad 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/TempSensorBreadcrumbsIntegrationTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/TempSensorBreadcrumbsIntegrationTest.kt @@ -7,7 +7,7 @@ import android.hardware.SensorEventListener import android.hardware.SensorManager import io.sentry.Breadcrumb import io.sentry.Hint -import io.sentry.IHub +import io.sentry.IScopes import io.sentry.ISentryExecutorService import io.sentry.SentryLevel import io.sentry.TypeCheckHint @@ -47,8 +47,8 @@ class TempSensorBreadcrumbsIntegrationTest { @Test fun `When system events breadcrumb is enabled, it registers callback`() { val sut = fixture.getSut() - val hub = mock() - sut.register(hub, fixture.options) + val scopes = mock() + sut.register(scopes, fixture.options) verify(fixture.manager).registerListener(any(), any(), eq(SensorManager.SENSOR_DELAY_NORMAL)) assertNotNull(sut.sensorManager) } @@ -56,8 +56,8 @@ class TempSensorBreadcrumbsIntegrationTest { @Test fun `temp sensor listener is registered in the executorService`() { val sut = fixture.getSut(executorService = mock()) - val hub = mock() - sut.register(hub, fixture.options) + val scopes = mock() + sut.register(scopes, fixture.options) assertNull(sut.sensorManager) } @@ -65,9 +65,9 @@ class TempSensorBreadcrumbsIntegrationTest { @Test fun `When system events breadcrumb is disabled, it should not register a callback`() { val sut = fixture.getSut() - val hub = mock() + val scopes = mock() sut.register( - hub, + scopes, fixture.options.apply { isEnableSystemEventBreadcrumbs = false } @@ -79,15 +79,15 @@ class TempSensorBreadcrumbsIntegrationTest { @Test fun `When TempSensorBreadcrumbsIntegration is closed, it should unregister the callback`() { val sut = fixture.getSut() - val hub = mock() - sut.register(hub, fixture.options) + val scopes = mock() + sut.register(scopes, fixture.options) sut.close() verify(fixture.manager).unregisterListener(any()) assertNull(sut.sensorManager) } @Test - fun `when hub is closed right after start, integration is not registered`() { + fun `when scopes is closed right after start, integration is not registered`() { val deferredExecutorService = DeferredExecutorService() val sut = fixture.getSut(executorService = deferredExecutorService) sut.register(mock(), fixture.options) @@ -100,14 +100,14 @@ class TempSensorBreadcrumbsIntegrationTest { @Test fun `When onSensorChanged received, add a breadcrumb with type and category`() { val sut = fixture.getSut() - val hub = mock() - sut.register(hub, fixture.options) + val scopes = mock() + sut.register(scopes, fixture.options) val sensorCtor = "android.hardware.SensorEvent".getDeclaredCtor(emptyArray()) val sensorEvent: SensorEvent = sensorCtor.newInstance() as SensorEvent sensorEvent.injectForField("values", FloatArray(2) { 1F }) sut.onSensorChanged(sensorEvent) - verify(hub).addBreadcrumb( + verify(scopes).addBreadcrumb( check { assertEquals("device.event", it.category) assertEquals("system", it.type) @@ -122,12 +122,12 @@ class TempSensorBreadcrumbsIntegrationTest { @Test fun `When onSensorChanged received and null values, do not add a breadcrumb`() { val sut = fixture.getSut() - val hub = mock() - sut.register(hub, fixture.options) + val scopes = mock() + sut.register(scopes, fixture.options) val event = mock() assertNull(event.values) sut.onSensorChanged(event) - verify(hub, never()).addBreadcrumb(any()) + verify(scopes, never()).addBreadcrumb(any()) } } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/UserInteractionIntegrationTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/UserInteractionIntegrationTest.kt index d43dfe1419..379e3db353 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/UserInteractionIntegrationTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/UserInteractionIntegrationTest.kt @@ -7,7 +7,7 @@ import android.content.res.Resources import android.util.DisplayMetrics import android.view.Window import androidx.test.ext.junit.runners.AndroidJUnit4 -import io.sentry.Hub +import io.sentry.Scopes import io.sentry.android.core.internal.gestures.NoOpWindowCallback import io.sentry.android.core.internal.gestures.SentryWindowCallback import org.junit.runner.RunWith @@ -26,7 +26,7 @@ class UserInteractionIntegrationTest { private class Fixture { val application = mock() - val hub = mock() + val scopes = mock() val options = SentryAndroidOptions().apply { dsn = "https://key@sentry.io/proj" } @@ -39,7 +39,7 @@ class UserInteractionIntegrationTest { isAndroidXAvailable: Boolean = true ): UserInteractionIntegration { whenever(loadClass.isClassAvailable(any(), anyOrNull())).thenReturn(isAndroidXAvailable) - whenever(hub.options).thenReturn(options) + whenever(scopes.options).thenReturn(options) whenever(window.callback).thenReturn(callback) whenever(activity.window).thenReturn(window) @@ -65,7 +65,7 @@ class UserInteractionIntegrationTest { @Test fun `when user interaction breadcrumb is enabled registers a callback`() { val sut = fixture.getSut() - sut.register(fixture.hub, fixture.options) + sut.register(fixture.scopes, fixture.options) verify(fixture.application).registerActivityLifecycleCallbacks(any()) } @@ -75,7 +75,7 @@ class UserInteractionIntegrationTest { val sut = fixture.getSut() fixture.options.isEnableUserInteractionBreadcrumbs = false - sut.register(fixture.hub, fixture.options) + sut.register(fixture.scopes, fixture.options) verify(fixture.application, never()).registerActivityLifecycleCallbacks(any()) } @@ -83,7 +83,7 @@ class UserInteractionIntegrationTest { @Test fun `when UserInteractionIntegration is closed unregisters the callback`() { val sut = fixture.getSut() - sut.register(fixture.hub, fixture.options) + sut.register(fixture.scopes, fixture.options) sut.close() @@ -94,7 +94,7 @@ class UserInteractionIntegrationTest { fun `when androidx is unavailable doesn't register a callback`() { val sut = fixture.getSut(isAndroidXAvailable = false) - sut.register(fixture.hub, fixture.options) + sut.register(fixture.scopes, fixture.options) verify(fixture.application, never()).registerActivityLifecycleCallbacks(any()) } @@ -102,7 +102,7 @@ class UserInteractionIntegrationTest { @Test fun `registers window callback on activity resumed`() { val sut = fixture.getSut() - sut.register(fixture.hub, fixture.options) + sut.register(fixture.scopes, fixture.options) sut.onActivityResumed(fixture.activity) @@ -114,7 +114,7 @@ class UserInteractionIntegrationTest { @Test fun `when no original callback delegates to NoOpWindowCallback`() { val sut = fixture.getSut() - sut.register(fixture.hub, fixture.options) + sut.register(fixture.scopes, fixture.options) sut.onActivityResumed(fixture.activity) diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/ViewHierarchyEventProcessorTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/ViewHierarchyEventProcessorTest.kt index 3f17d8bff2..3c72af480e 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/ViewHierarchyEventProcessorTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/ViewHierarchyEventProcessorTest.kt @@ -12,7 +12,7 @@ import io.sentry.SentryEvent import io.sentry.SentryIntegrationPackageStorage import io.sentry.TypeCheckHint import io.sentry.protocol.SentryException -import io.sentry.util.thread.IMainThreadChecker +import io.sentry.util.thread.IThreadChecker import org.junit.runner.RunWith import org.mockito.invocation.InvocationOnMock import org.mockito.kotlin.any @@ -46,7 +46,7 @@ class ViewHierarchyEventProcessorTest { } } val activity = mock() - val mainThreadChecker = mock() + val threadChecker = mock() val window = mock() val view = mock() val options = SentryAndroidOptions().apply { @@ -62,14 +62,14 @@ class ViewHierarchyEventProcessorTest { whenever(activity.runOnUiThread(any())).then { it.getArgument(0).run() } - whenever(mainThreadChecker.isMainThread).thenReturn(true) + whenever(threadChecker.isMainThread).thenReturn(true) CurrentActivityHolder.getInstance().setActivity(activity) } fun getSut(attachViewHierarchy: Boolean = false): ViewHierarchyEventProcessor { options.isAttachViewHierarchy = attachViewHierarchy - options.mainThreadChecker = mainThreadChecker + options.threadChecker = threadChecker return ViewHierarchyEventProcessor(options) } @@ -96,7 +96,7 @@ class ViewHierarchyEventProcessorTest { fun `should return a view hierarchy as byte array`() { val viewHierarchy = ViewHierarchyEventProcessor.snapshotViewHierarchyAsData( fixture.activity, - fixture.mainThreadChecker, + fixture.threadChecker, fixture.serializer, fixture.logger ) @@ -109,7 +109,7 @@ class ViewHierarchyEventProcessorTest { fun `should return null as bytes are empty array`() { val viewHierarchy = ViewHierarchyEventProcessor.snapshotViewHierarchyAsData( fixture.activity, - fixture.mainThreadChecker, + fixture.threadChecker, fixture.emptySerializer, fixture.logger ) @@ -161,7 +161,7 @@ class ViewHierarchyEventProcessorTest { @Test fun `when an event errored in the background, the view hierarchy should captured on the main thread`() { - whenever(fixture.mainThreadChecker.isMainThread).thenReturn(false) + whenever(fixture.threadChecker.isMainThread).thenReturn(false) val (event, hint) = fixture.process( true, diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/cache/AndroidEnvelopeCacheTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/cache/AndroidEnvelopeCacheTest.kt index ba70e5a879..fc294d2481 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/cache/AndroidEnvelopeCacheTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/cache/AndroidEnvelopeCacheTest.kt @@ -51,6 +51,7 @@ class AndroidEnvelopeCacheTest { AppStartMetrics.getInstance().apply { if (options.isEnablePerformanceV2) { appStartTimeSpan.setStartedAt(appStartMillis) + sdkInitTimeSpan.setStartedAt(appStartMillis) } else { sdkInitTimeSpan.setStartedAt(appStartMillis) } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/internal/gestures/SentryGestureListenerClickTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/internal/gestures/SentryGestureListenerClickTest.kt index 1e6652276a..74edfb4302 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/internal/gestures/SentryGestureListenerClickTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/internal/gestures/SentryGestureListenerClickTest.kt @@ -10,8 +10,8 @@ import android.view.Window import android.widget.CheckBox import android.widget.RadioButton import io.sentry.Breadcrumb -import io.sentry.IHub import io.sentry.IScope +import io.sentry.IScopes import io.sentry.PropagationContext import io.sentry.Scope.IWithPropagationContext import io.sentry.ScopeCallback @@ -40,7 +40,7 @@ class SentryGestureListenerClickTest { gestureTargetLocators = listOf(AndroidViewGestureTargetLocator(true)) dsn = "https://key@sentry.io/proj" } - val hub = mock() + val scopes = mock() val scope = mock() val propagationContext = PropagationContext() lateinit var target: View @@ -86,11 +86,11 @@ class SentryGestureListenerClickTest { whenever(context.resources).thenReturn(resources) whenever(this.target.context).thenReturn(context) whenever(activity.window).thenReturn(window) - doAnswer { (it.arguments[0] as ScopeCallback).run(scope) }.whenever(hub).configureScope(any()) + doAnswer { (it.arguments[0] as ScopeCallback).run(scope) }.whenever(scopes).configureScope(any()) doAnswer { (it.arguments[0] as IWithPropagationContext).accept(propagationContext); propagationContext; }.whenever(scope).withPropagationContext(any()) return SentryGestureListener( activity, - hub, + scopes, options ) } @@ -123,7 +123,7 @@ class SentryGestureListenerClickTest { sut.onSingleTapUp(event) - verify(fixture.hub).addBreadcrumb( + verify(fixture.scopes).addBreadcrumb( check { assertEquals("ui.click", it.category) assertEquals("user", it.type) @@ -146,7 +146,7 @@ class SentryGestureListenerClickTest { sut.onSingleTapUp(event) - verify(fixture.hub).addBreadcrumb( + verify(fixture.scopes).addBreadcrumb( check { assertEquals("radio_button", it.data["view.id"]) assertEquals("android.widget.RadioButton", it.data["view.class"]) @@ -166,7 +166,7 @@ class SentryGestureListenerClickTest { sut.onSingleTapUp(event) - verify(fixture.hub).addBreadcrumb( + verify(fixture.scopes).addBreadcrumb( check { assertEquals("check_box", it.data["view.id"]) assertEquals("android.widget.CheckBox", it.data["view.class"]) @@ -185,7 +185,7 @@ class SentryGestureListenerClickTest { sut.onSingleTapUp(event) - verify(fixture.hub, never()).addBreadcrumb(any()) + verify(fixture.scopes, never()).addBreadcrumb(any()) } @Test @@ -198,7 +198,7 @@ class SentryGestureListenerClickTest { val sut = fixture.getSut(event, "decor_view", targetOverride = decorView) sut.onSingleTapUp(event) - verify(fixture.hub).addBreadcrumb( + verify(fixture.scopes).addBreadcrumb( check { assertEquals(decorView.javaClass.canonicalName, it.data["view.class"]) assertEquals("decor_view", it.data["view.id"]) @@ -214,7 +214,7 @@ class SentryGestureListenerClickTest { sut.onSingleTapUp(event) - verify(fixture.hub, never()).addBreadcrumb(any()) + verify(fixture.scopes, never()).addBreadcrumb(any()) } @Test @@ -230,7 +230,7 @@ class SentryGestureListenerClickTest { sut.onSingleTapUp(event) - verify(fixture.hub).addBreadcrumb( + verify(fixture.scopes).addBreadcrumb( check { assertEquals(fixture.target.javaClass.simpleName, it.data["view.class"]) }, diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/internal/gestures/SentryGestureListenerScrollTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/internal/gestures/SentryGestureListenerScrollTest.kt index 5d39b64753..e5a9623c4d 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/internal/gestures/SentryGestureListenerScrollTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/internal/gestures/SentryGestureListenerScrollTest.kt @@ -11,8 +11,8 @@ import android.widget.AbsListView import android.widget.ListAdapter import androidx.core.view.ScrollingView import io.sentry.Breadcrumb -import io.sentry.IHub import io.sentry.IScope +import io.sentry.IScopes import io.sentry.PropagationContext import io.sentry.Scope import io.sentry.ScopeCallback @@ -44,7 +44,7 @@ class SentryGestureListenerScrollTest { isEnableUserInteractionTracing = true gestureTargetLocators = listOf(AndroidViewGestureTargetLocator(true)) } - val hub = mock() + val scopes = mock() val scope = mock() val propagationContext = PropagationContext() @@ -77,11 +77,11 @@ class SentryGestureListenerScrollTest { endEvent.mockDirection(firstEvent, direction) } whenever(activity.window).thenReturn(window) - doAnswer { (it.arguments[0] as ScopeCallback).run(scope) }.whenever(hub).configureScope(any()) + doAnswer { (it.arguments[0] as ScopeCallback).run(scope) }.whenever(scopes).configureScope(any()) doAnswer { (it.arguments[0] as Scope.IWithPropagationContext).accept(propagationContext); propagationContext }.whenever(scope).withPropagationContext(any()) return SentryGestureListener( activity, - hub, + scopes, options ) } @@ -99,7 +99,7 @@ class SentryGestureListenerScrollTest { } sut.onUp(fixture.endEvent) - verify(fixture.hub).addBreadcrumb( + verify(fixture.scopes).addBreadcrumb( check { assertEquals("ui.scroll", it.category) assertEquals("user", it.type) @@ -122,7 +122,7 @@ class SentryGestureListenerScrollTest { } sut.onUp(fixture.endEvent) - verify(fixture.hub, never()).addBreadcrumb(any()) + verify(fixture.scopes, never()).addBreadcrumb(any()) } @Test @@ -143,8 +143,8 @@ class SentryGestureListenerScrollTest { sut.onFling(fixture.firstEvent, fixture.endEvent, 1.0f, 1.0f) sut.onUp(fixture.endEvent) - inOrder(fixture.hub) { - verify(fixture.hub).addBreadcrumb( + inOrder(fixture.scopes) { + verify(fixture.scopes).addBreadcrumb( check { assertEquals("ui.swipe", it.category) assertEquals("user", it.type) @@ -155,8 +155,8 @@ class SentryGestureListenerScrollTest { }, anyOrNull() ) - verify(fixture.hub).configureScope(anyOrNull()) - verify(fixture.hub).addBreadcrumb( + verify(fixture.scopes).configureScope(anyOrNull()) + verify(fixture.scopes).addBreadcrumb( check { assertEquals("ui.swipe", it.category) assertEquals("user", it.type) @@ -168,7 +168,7 @@ class SentryGestureListenerScrollTest { anyOrNull() ) } - verifyNoMoreInteractions(fixture.hub) + verifyNoMoreInteractions(fixture.scopes) } @Test @@ -177,7 +177,7 @@ class SentryGestureListenerScrollTest { sut.onUp(fixture.firstEvent) sut.onDown(fixture.endEvent) - verify(fixture.hub, never()).addBreadcrumb(any()) + verify(fixture.scopes, never()).addBreadcrumb(any()) } @Test @@ -190,7 +190,7 @@ class SentryGestureListenerScrollTest { } sut.onUp(fixture.endEvent) - verify(fixture.hub, never()).addBreadcrumb(any()) + verify(fixture.scopes, never()).addBreadcrumb(any()) } @Test diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/internal/gestures/SentryGestureListenerTracingTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/internal/gestures/SentryGestureListenerTracingTest.kt index c7ada69c88..07dde15e8f 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/internal/gestures/SentryGestureListenerTracingTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/internal/gestures/SentryGestureListenerTracingTest.kt @@ -9,8 +9,8 @@ import android.view.ViewGroup import android.view.Window import android.widget.AbsListView import android.widget.ListAdapter -import io.sentry.IHub import io.sentry.IScope +import io.sentry.IScopes import io.sentry.Scope import io.sentry.ScopeCallback import io.sentry.SentryTracer @@ -23,6 +23,7 @@ import io.sentry.TransactionOptions import io.sentry.android.core.SentryAndroidOptions import io.sentry.protocol.SentryId import io.sentry.protocol.TransactionNameSource +import org.mockito.ArgumentCaptor import org.mockito.kotlin.any import org.mockito.kotlin.check import org.mockito.kotlin.clearInvocations @@ -46,9 +47,10 @@ class SentryGestureListenerTracingTest { val options = SentryAndroidOptions().apply { dsn = "https://key@sentry.io/proj" } - val hub = mock() + val scopes = mock() val event = mock() val scope = mock() + val transactionOptionsArgumentCaptor: ArgumentCaptor = ArgumentCaptor.forClass(TransactionOptions::class.java) lateinit var target: View lateinit var transaction: SentryTracer @@ -64,9 +66,9 @@ class SentryGestureListenerTracingTest { options.isEnableUserInteractionBreadcrumbs = true options.gestureTargetLocators = listOf(AndroidViewGestureTargetLocator(true)) - whenever(hub.options).thenReturn(options) + whenever(scopes.options).thenReturn(options) - this.transaction = transaction ?: SentryTracer(TransactionContext("name", "op"), hub) + this.transaction = transaction ?: SentryTracer(TransactionContext("name", "op"), scopes) target = mockView(event = event, clickable = true, context = context) window.mockDecorView(event = event, context = context) { @@ -85,14 +87,13 @@ class SentryGestureListenerTracingTest { whenever(target.context).thenReturn(context) whenever(activity.window).thenReturn(window) - - whenever(hub.startTransaction(any(), any())) + whenever(scopes.startTransaction(any(), transactionOptionsArgumentCaptor.capture())) .thenReturn(this.transaction) - doAnswer { (it.arguments[0] as ScopeCallback).run(scope) }.whenever(hub).configureScope(any()) + doAnswer { (it.arguments[0] as ScopeCallback).run(scope) }.whenever(scopes).configureScope(any()) return SentryGestureListener( activity, - hub, + scopes, options ) } @@ -106,7 +107,7 @@ class SentryGestureListenerTracingTest { sut.onSingleTapUp(fixture.event) - verify(fixture.hub, never()).startTransaction( + verify(fixture.scopes, never()).startTransaction( any(), any() ) @@ -118,7 +119,7 @@ class SentryGestureListenerTracingTest { sut.onSingleTapUp(fixture.event) - verify(fixture.hub, never()).startTransaction( + verify(fixture.scopes, never()).startTransaction( any(), any() ) @@ -130,7 +131,7 @@ class SentryGestureListenerTracingTest { sut.onSingleTapUp(fixture.event) - verify(fixture.hub, never()).startTransaction( + verify(fixture.scopes, never()).startTransaction( any(), any() ) @@ -140,7 +141,7 @@ class SentryGestureListenerTracingTest { fun `when transaction is created, set transaction to the bound Scope`() { val sut = fixture.getSut() - whenever(fixture.hub.configureScope(any())).thenAnswer { + whenever(fixture.scopes.configureScope(any())).thenAnswer { val scope = Scope(fixture.options) sut.applyScope(scope, fixture.transaction) @@ -155,9 +156,9 @@ class SentryGestureListenerTracingTest { fun `when transaction is created, do not overwrite transaction already bound to the Scope`() { val sut = fixture.getSut() - whenever(fixture.hub.configureScope(any())).thenAnswer { + whenever(fixture.scopes.configureScope(any())).thenAnswer { val scope = Scope(fixture.options) - val previousTransaction = SentryTracer(TransactionContext("name", "op"), fixture.hub) + val previousTransaction = SentryTracer(TransactionContext("name", "op"), fixture.scopes) scope.transaction = previousTransaction sut.applyScope(scope, fixture.transaction) @@ -173,14 +174,14 @@ class SentryGestureListenerTracingTest { val sut = fixture.getSut() val expectedStatus = SpanStatus.CANCELLED - whenever(fixture.hub.configureScope(any())).thenAnswer { + whenever(fixture.scopes.configureScope(any())).thenAnswer { val scope = Scope(fixture.options) sut.applyScope(scope, fixture.transaction) } sut.onSingleTapUp(fixture.event) - whenever(fixture.hub.configureScope(any())).thenAnswer { + whenever(fixture.scopes.configureScope(any())).thenAnswer { val scope = Scope(fixture.options) scope.transaction = fixture.transaction @@ -199,7 +200,7 @@ class SentryGestureListenerTracingTest { sut.onSingleTapUp(fixture.event) - verify(fixture.hub).startTransaction( + verify(fixture.scopes).startTransaction( check { assertEquals("Activity.test_button", it.name) assertEquals(TransactionNameSource.COMPONENT, it.transactionNameSource) @@ -214,7 +215,7 @@ class SentryGestureListenerTracingTest { sut.onSingleTapUp(fixture.event) - verify(fixture.hub).startTransaction( + verify(fixture.scopes).startTransaction( any(), check { transactionOptions -> assertEquals(fixture.options.idleTimeout, transactionOptions.idleTimeout) @@ -232,7 +233,7 @@ class SentryGestureListenerTracingTest { sut.onSingleTapUp(fixture.event) - verify(fixture.hub).startTransaction( + verify(fixture.scopes).startTransaction( check { assertEquals("ui.action.click", it.operation) assertEquals(TransactionNameSource.COMPONENT, it.transactionNameSource) @@ -248,7 +249,7 @@ class SentryGestureListenerTracingTest { sut.onSingleTapUp(fixture.event) - verify(fixture.hub).startTransaction( + verify(fixture.scopes).startTransaction( check { assertEquals("Activity.test_button", it.name) assertEquals(TransactionNameSource.COMPONENT, it.transactionNameSource) @@ -256,7 +257,7 @@ class SentryGestureListenerTracingTest { any() ) - clearInvocations(fixture.hub) + clearInvocations(fixture.scopes) // second view interaction with another view val newTarget = mockView(event = fixture.event, clickable = true, context = fixture.context) val newContext = mock() @@ -269,16 +270,16 @@ class SentryGestureListenerTracingTest { whenever(it.getChildAt(0)).thenReturn(newTarget) } - whenever(fixture.hub.startTransaction(any(), any())) + whenever(fixture.scopes.startTransaction(any(), any())) .thenAnswer { // verify that the active transaction gets finished when a new one appears assertEquals(true, fixture.transaction.isFinished) - SentryTracer(TransactionContext("name", "op"), fixture.hub) + SentryTracer(TransactionContext("name", "op"), fixture.scopes) } sut.onSingleTapUp(fixture.event) - verify(fixture.hub).startTransaction( + verify(fixture.scopes).startTransaction( check { assertEquals("Activity.test_checkbox", it.name) assertEquals(TransactionNameSource.COMPONENT, it.transactionNameSource) @@ -293,7 +294,7 @@ class SentryGestureListenerTracingTest { sut.onSingleTapUp(fixture.event) - verify(fixture.hub).startTransaction( + verify(fixture.scopes).startTransaction( check { assertEquals("Activity.test_scroll_view", it.name) assertEquals("ui.action.click", it.operation) @@ -302,20 +303,20 @@ class SentryGestureListenerTracingTest { any() ) - clearInvocations(fixture.hub) + clearInvocations(fixture.scopes) // second view interaction with a different interaction type (scroll) - whenever(fixture.hub.startTransaction(any(), any())) + whenever(fixture.scopes.startTransaction(any(), any())) .thenAnswer { // verify that the active transaction gets finished when a new one appears assertEquals(true, fixture.transaction.isFinished) - SentryTracer(TransactionContext("name", "op"), fixture.hub) + SentryTracer(TransactionContext("name", "op"), fixture.scopes) } sut.onScroll(fixture.event, mock(), 10.0f, 0f) sut.onUp(mock()) - verify(fixture.hub).startTransaction( + verify(fixture.scopes).startTransaction( check { assertEquals("Activity.test_scroll_view", it.name) assertEquals("ui.action.scroll", it.operation) @@ -340,7 +341,7 @@ class SentryGestureListenerTracingTest { sut.onSingleTapUp(fixture.event) // then two transaction should be captured - verify(fixture.hub, times(2)).startTransaction( + verify(fixture.scopes, times(2)).startTransaction( check { assertEquals("Activity.test_button", it.name) assertEquals(TransactionNameSource.COMPONENT, it.transactionNameSource) @@ -355,7 +356,7 @@ class SentryGestureListenerTracingTest { sut.onSingleTapUp(fixture.event) - assertEquals("auto.ui.gesture_listener.old_view_system", fixture.transaction.spanContext.origin) + assertEquals("auto.ui.gesture_listener.old_view_system", fixture.transactionOptionsArgumentCaptor.value.origin) } @Test diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/internal/util/AndroidMainThreadCheckerTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/internal/util/AndroidThreadCheckerTest.kt similarity index 72% rename from sentry-android-core/src/test/java/io/sentry/android/core/internal/util/AndroidMainThreadCheckerTest.kt rename to sentry-android-core/src/test/java/io/sentry/android/core/internal/util/AndroidThreadCheckerTest.kt index c759bdf79e..eb59f0732e 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/internal/util/AndroidMainThreadCheckerTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/internal/util/AndroidThreadCheckerTest.kt @@ -8,23 +8,23 @@ import kotlin.test.assertFalse import kotlin.test.assertTrue @RunWith(AndroidJUnit4::class) -class AndroidMainThreadCheckerTest { +class AndroidThreadCheckerTest { @Test fun `When calling isMainThread from the same thread, it should return true`() { - assertTrue(AndroidMainThreadChecker.getInstance().isMainThread) + assertTrue(AndroidThreadChecker.getInstance().isMainThread) } @Test fun `When calling isMainThread with the current thread, it should return true`() { val thread = Thread.currentThread() - assertTrue(AndroidMainThreadChecker.getInstance().isMainThread(thread)) + assertTrue(AndroidThreadChecker.getInstance().isMainThread(thread)) } @Test fun `When calling isMainThread from a different thread, it should return false`() { val thread = Thread() - assertFalse(AndroidMainThreadChecker.getInstance().isMainThread(thread)) + assertFalse(AndroidThreadChecker.getInstance().isMainThread(thread)) } @Test @@ -33,7 +33,7 @@ class AndroidMainThreadCheckerTest { val sentryThread = SentryThread().apply { id = thread.id } - assertTrue(AndroidMainThreadChecker.getInstance().isMainThread(sentryThread)) + assertTrue(AndroidThreadChecker.getInstance().isMainThread(sentryThread)) } @Test @@ -42,6 +42,6 @@ class AndroidMainThreadCheckerTest { val sentryThread = SentryThread().apply { id = thread.id } - assertFalse(AndroidMainThreadChecker.getInstance().isMainThread(sentryThread)) + assertFalse(AndroidThreadChecker.getInstance().isMainThread(sentryThread)) } } 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..eb0e85dc28 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/performance/AppStartMetricsTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/performance/AppStartMetricsTest.kt @@ -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,163 @@ 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 activity is started, does not stop app start profiler if running`() { + val profiler = mock() + whenever(profiler.isRunning).thenReturn(true) + AppStartMetrics.getInstance().appStartProfiler = profiler + AppStartMetrics.getInstance().onActivityCreated(mock(), mock()) + + AppStartMetrics.getInstance().registerApplicationForegroundCheck(mock()) + // Job on main thread checks if activity was launched + Shadows.shadowOf(Looper.getMainLooper()).idle() + + verify(profiler, never()).close() + } + + @Test + fun `if app start span is longer than 1 minute, appStartTimeSpanWithFallback returns an empty span`() { + val appStartTimeSpan = AppStartMetrics.getInstance().appStartTimeSpan + 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) + } } diff --git a/sentry-android-fragment/api/sentry-android-fragment.api b/sentry-android-fragment/api/sentry-android-fragment.api index 4b3487c36e..f2f3334280 100644 --- a/sentry-android-fragment/api/sentry-android-fragment.api +++ b/sentry-android-fragment/api/sentry-android-fragment.api @@ -18,7 +18,7 @@ public final class io/sentry/android/fragment/FragmentLifecycleIntegration : and public fun onActivitySaveInstanceState (Landroid/app/Activity;Landroid/os/Bundle;)V public fun onActivityStarted (Landroid/app/Activity;)V public fun onActivityStopped (Landroid/app/Activity;)V - public fun register (Lio/sentry/IHub;Lio/sentry/SentryOptions;)V + public fun register (Lio/sentry/IScopes;Lio/sentry/SentryOptions;)V } public final class io/sentry/android/fragment/FragmentLifecycleState : java/lang/Enum { @@ -40,9 +40,9 @@ public final class io/sentry/android/fragment/FragmentLifecycleState : java/lang public final class io/sentry/android/fragment/SentryFragmentLifecycleCallbacks : androidx/fragment/app/FragmentManager$FragmentLifecycleCallbacks { public static final field Companion Lio/sentry/android/fragment/SentryFragmentLifecycleCallbacks$Companion; public static final field FRAGMENT_LOAD_OP Ljava/lang/String; - public fun (Lio/sentry/IHub;Ljava/util/Set;Z)V - public synthetic fun (Lio/sentry/IHub;Ljava/util/Set;ZILkotlin/jvm/internal/DefaultConstructorMarker;)V - public fun (Lio/sentry/IHub;ZZ)V + public fun (Lio/sentry/IScopes;Ljava/util/Set;Z)V + public synthetic fun (Lio/sentry/IScopes;Ljava/util/Set;ZILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Lio/sentry/IScopes;ZZ)V public fun (ZZ)V public synthetic fun (ZZILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun getEnableAutoFragmentLifecycleTracing ()Z diff --git a/sentry-android-fragment/src/main/java/io/sentry/android/fragment/FragmentLifecycleIntegration.kt b/sentry-android-fragment/src/main/java/io/sentry/android/fragment/FragmentLifecycleIntegration.kt index 4129ea4356..d2fd393200 100644 --- a/sentry-android-fragment/src/main/java/io/sentry/android/fragment/FragmentLifecycleIntegration.kt +++ b/sentry-android-fragment/src/main/java/io/sentry/android/fragment/FragmentLifecycleIntegration.kt @@ -5,7 +5,7 @@ import android.app.Application import android.app.Application.ActivityLifecycleCallbacks import android.os.Bundle import androidx.fragment.app.FragmentActivity -import io.sentry.IHub +import io.sentry.IScopes import io.sentry.Integration import io.sentry.SentryIntegrationPackageStorage import io.sentry.SentryLevel.DEBUG @@ -40,11 +40,11 @@ class FragmentLifecycleIntegration( enableAutoFragmentLifecycleTracing = enableAutoFragmentLifecycleTracing ) - private lateinit var hub: IHub + private lateinit var scopes: IScopes private lateinit var options: SentryOptions - override fun register(hub: IHub, options: SentryOptions) { - this.hub = hub + override fun register(scopes: IScopes, options: SentryOptions) { + this.scopes = scopes this.options = options application.registerActivityLifecycleCallbacks(this) @@ -66,7 +66,7 @@ class FragmentLifecycleIntegration( ?.supportFragmentManager ?.registerFragmentLifecycleCallbacks( SentryFragmentLifecycleCallbacks( - hub = hub, + scopes = scopes, filterFragmentLifecycleBreadcrumbs = filterFragmentLifecycleBreadcrumbs, enableAutoFragmentLifecycleTracing = enableAutoFragmentLifecycleTracing ), 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..ec6a50c692 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 @@ -8,9 +8,9 @@ import androidx.fragment.app.FragmentManager import androidx.fragment.app.FragmentManager.FragmentLifecycleCallbacks import io.sentry.Breadcrumb import io.sentry.Hint -import io.sentry.HubAdapter -import io.sentry.IHub +import io.sentry.IScopes import io.sentry.ISpan +import io.sentry.ScopesAdapter import io.sentry.SentryLevel.INFO import io.sentry.SpanStatus import io.sentry.TypeCheckHint.ANDROID_FRAGMENT @@ -20,17 +20,17 @@ private const val TRACE_ORIGIN = "auto.ui.fragment" @Suppress("TooManyFunctions") class SentryFragmentLifecycleCallbacks( - private val hub: IHub = HubAdapter.getInstance(), + private val scopes: IScopes = ScopesAdapter.getInstance(), val filterFragmentLifecycleBreadcrumbs: Set, val enableAutoFragmentLifecycleTracing: Boolean ) : FragmentLifecycleCallbacks() { constructor( - hub: IHub, + scopes: IScopes, enableFragmentLifecycleBreadcrumbs: Boolean, enableAutoFragmentLifecycleTracing: Boolean ) : this( - hub = hub, + scopes = scopes, filterFragmentLifecycleBreadcrumbs = FragmentLifecycleState.values().toSet() .takeIf { enableFragmentLifecycleBreadcrumbs } .orEmpty(), @@ -41,14 +41,14 @@ class SentryFragmentLifecycleCallbacks( enableFragmentLifecycleBreadcrumbs: Boolean = true, enableAutoFragmentLifecycleTracing: Boolean = false ) : this( - hub = HubAdapter.getInstance(), + scopes = ScopesAdapter.getInstance(), filterFragmentLifecycleBreadcrumbs = FragmentLifecycleState.values().toSet() .takeIf { enableFragmentLifecycleBreadcrumbs } .orEmpty(), enableAutoFragmentLifecycleTracing = enableAutoFragmentLifecycleTracing ) - private val isPerformanceEnabled get() = hub.options.isTracingEnabled && enableAutoFragmentLifecycleTracing + private val isPerformanceEnabled get() = scopes.options.isTracingEnabled && enableAutoFragmentLifecycleTracing private val fragmentsWithOngoingTransactions = WeakHashMap() @@ -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 (scopes.options.isEnableScreenTracking) { + scopes.configureScope { it.screen = getFragmentName(fragment) } + } startTracing(fragment) } } @@ -142,7 +145,7 @@ class SentryFragmentLifecycleCallbacks( val hint = Hint() .also { it.set(ANDROID_FRAGMENT, fragment) } - hub.addBreadcrumb(breadcrumb, hint) + scopes.addBreadcrumb(breadcrumb, hint) } private fun getFragmentName(fragment: Fragment): String { @@ -158,7 +161,7 @@ class SentryFragmentLifecycleCallbacks( } var transaction: ISpan? = null - hub.configureScope { + scopes.configureScope { transaction = it.transaction } diff --git a/sentry-android-fragment/src/test/java/io/sentry/android/fragment/FragmentLifecycleIntegrationTest.kt b/sentry-android-fragment/src/test/java/io/sentry/android/fragment/FragmentLifecycleIntegrationTest.kt index 032aef58e1..84286503b9 100644 --- a/sentry-android-fragment/src/test/java/io/sentry/android/fragment/FragmentLifecycleIntegrationTest.kt +++ b/sentry-android-fragment/src/test/java/io/sentry/android/fragment/FragmentLifecycleIntegrationTest.kt @@ -4,7 +4,7 @@ import android.app.Activity import android.app.Application import androidx.fragment.app.FragmentActivity import androidx.fragment.app.FragmentManager -import io.sentry.IHub +import io.sentry.IScopes import io.sentry.SentryOptions import org.mockito.kotlin.check import org.mockito.kotlin.doReturn @@ -24,14 +24,14 @@ class FragmentLifecycleIntegrationTest { val fragmentActivity = mock { on { supportFragmentManager } doReturn fragmentManager } - val hub = mock() + val scopes = mock() val options = SentryOptions() fun getSut( enableFragmentLifecycleBreadcrumbs: Boolean = true, enableAutoFragmentLifecycleTracing: Boolean = false ): FragmentLifecycleIntegration { - whenever(hub.options).thenReturn(options) + whenever(scopes.options).thenReturn(options) return FragmentLifecycleIntegration( application = application, enableFragmentLifecycleBreadcrumbs = enableFragmentLifecycleBreadcrumbs, @@ -46,7 +46,7 @@ class FragmentLifecycleIntegrationTest { fun `When register, it should register activity lifecycle callbacks`() { val sut = fixture.getSut() - sut.register(fixture.hub, fixture.options) + sut.register(fixture.scopes, fixture.options) verify(fixture.application).registerActivityLifecycleCallbacks(sut) } @@ -55,7 +55,7 @@ class FragmentLifecycleIntegrationTest { fun `When close, it should unregister lifecycle callbacks`() { val sut = fixture.getSut() - sut.register(fixture.hub, fixture.options) + sut.register(fixture.scopes, fixture.options) sut.close() verify(fixture.application).unregisterActivityLifecycleCallbacks(sut) @@ -69,7 +69,7 @@ class FragmentLifecycleIntegrationTest { on { supportFragmentManager } doReturn fragmentManager } - sut.register(fixture.hub, fixture.options) + sut.register(fixture.scopes, fixture.options) sut.onActivityCreated(fragmentActivity, savedInstanceState = null) verify(fragmentManager).registerFragmentLifecycleCallbacks( @@ -84,7 +84,7 @@ class FragmentLifecycleIntegrationTest { fun `When FragmentActivity is created, it should register fragment lifecycle callbacks with passed config`() { val sut = fixture.getSut(enableFragmentLifecycleBreadcrumbs = false, enableAutoFragmentLifecycleTracing = true) - sut.register(fixture.hub, fixture.options) + sut.register(fixture.scopes, fixture.options) sut.onActivityCreated(fixture.fragmentActivity, savedInstanceState = null) verify(fixture.fragmentManager).registerFragmentLifecycleCallbacks( @@ -102,7 +102,7 @@ class FragmentLifecycleIntegrationTest { val sut = fixture.getSut() val activity = mock() - sut.register(fixture.hub, fixture.options) + sut.register(fixture.scopes, fixture.options) sut.onActivityCreated(activity, savedInstanceState = null) } diff --git a/sentry-android-fragment/src/test/java/io/sentry/android/fragment/SentryFragmentLifecycleCallbacksTest.kt b/sentry-android-fragment/src/test/java/io/sentry/android/fragment/SentryFragmentLifecycleCallbacksTest.kt index 812d78de30..394d4e0aaf 100644 --- a/sentry-android-fragment/src/test/java/io/sentry/android/fragment/SentryFragmentLifecycleCallbacksTest.kt +++ b/sentry-android-fragment/src/test/java/io/sentry/android/fragment/SentryFragmentLifecycleCallbacksTest.kt @@ -5,8 +5,8 @@ import android.os.Bundle import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentManager import io.sentry.Breadcrumb -import io.sentry.Hub import io.sentry.IScope +import io.sentry.IScopes import io.sentry.ISpan import io.sentry.ITransaction import io.sentry.ScopeCallback @@ -32,7 +32,7 @@ class SentryFragmentLifecycleCallbacksTest { private class Fixture { val fragmentManager = mock() - val hub = mock() + val scopes = mock() val fragment = mock() val context = mock() val scope = mock() @@ -45,7 +45,7 @@ class SentryFragmentLifecycleCallbacksTest { tracesSampleRate: Double? = 1.0, isAdded: Boolean = true ): SentryFragmentLifecycleCallbacks { - whenever(hub.options).thenReturn( + whenever(scopes.options).thenReturn( SentryOptions().apply { setTracesSampleRate(tracesSampleRate) } @@ -53,14 +53,14 @@ class SentryFragmentLifecycleCallbacksTest { whenever(span.spanContext).thenReturn( SpanContext(SentryId.EMPTY_ID, SpanId.EMPTY_ID, "op", null, null) ) - whenever(transaction.startChild(any(), any())).thenReturn(span) + whenever(transaction.startChild(any(), any())).thenReturn(span) whenever(scope.transaction).thenReturn(transaction) - whenever(hub.configureScope(any())).thenAnswer { + whenever(scopes.configureScope(any())).thenAnswer { (it.arguments[0] as ScopeCallback).run(scope) } whenever(fragment.isAdded).thenReturn(isAdded) return SentryFragmentLifecycleCallbacks( - hub = hub, + scopes = scopes, filterFragmentLifecycleBreadcrumbs = loggedFragmentLifecycleStates, enableAutoFragmentLifecycleTracing = enableAutoFragmentLifecycleTracing ) @@ -190,7 +190,7 @@ class SentryFragmentLifecycleCallbacksTest { sut.onFragmentCreated(fixture.fragmentManager, fixture.fragment, savedInstanceState = null) - verify(fixture.transaction, never()).startChild(any(), any()) + verify(fixture.transaction, never()).startChild(any(), any()) } @Test @@ -200,10 +200,10 @@ class SentryFragmentLifecycleCallbacksTest { sut.onFragmentCreated(fixture.fragmentManager, fixture.fragment, savedInstanceState = null) verify(fixture.transaction).startChild( - check { + check { assertEquals(SentryFragmentLifecycleCallbacks.FRAGMENT_LOAD_OP, it) }, - check { + check { assertEquals("androidx.fragment.app.Fragment", it) } ) @@ -215,7 +215,7 @@ class SentryFragmentLifecycleCallbacksTest { sut.onFragmentCreated(fixture.fragmentManager, fixture.fragment, savedInstanceState = null) - verify(fixture.transaction, never()).startChild(any(), any()) + verify(fixture.transaction, never()).startChild(any(), any()) } @Test @@ -225,7 +225,7 @@ class SentryFragmentLifecycleCallbacksTest { sut.onFragmentCreated(fixture.fragmentManager, fixture.fragment, savedInstanceState = null) sut.onFragmentCreated(fixture.fragmentManager, fixture.fragment, savedInstanceState = null) - verify(fixture.transaction).startChild(any(), any()) + verify(fixture.transaction).startChild(any(), any()) } @Test @@ -272,7 +272,7 @@ class SentryFragmentLifecycleCallbacksTest { } private fun verifyBreadcrumbAdded(expectedState: String) { - verify(fixture.hub).addBreadcrumb( + verify(fixture.scopes).addBreadcrumb( check { breadcrumb: Breadcrumb -> assertEquals("ui.fragment.lifecycle", breadcrumb.category) assertEquals("navigation", breadcrumb.type) @@ -285,6 +285,6 @@ class SentryFragmentLifecycleCallbacksTest { } private fun verifyBreadcrumbAddedCount(count: Int) { - verify(fixture.hub, times(count)).addBreadcrumb(any(), anyOrNull()) + verify(fixture.scopes, times(count)).addBreadcrumb(any(), anyOrNull()) } } diff --git a/sentry-android-integration-tests/sentry-uitest-android/build.gradle.kts b/sentry-android-integration-tests/sentry-uitest-android/build.gradle.kts index 98b00eb4f8..7755166a4e 100644 --- a/sentry-android-integration-tests/sentry-uitest-android/build.gradle.kts +++ b/sentry-android-integration-tests/sentry-uitest-android/build.gradle.kts @@ -109,6 +109,7 @@ dependencies { implementation(Config.Libs.androidxRecylerView) implementation(Config.Libs.constraintLayout) implementation(Config.TestLibs.espressoIdlingResource) + implementation(Config.Libs.leakCanary) compileOnly(Config.CompileOnly.nopen) errorprone(Config.CompileOnly.nopenChecker) @@ -123,6 +124,7 @@ dependencies { androidTestImplementation(Config.TestLibs.androidxTestCoreKtx) androidTestImplementation(Config.TestLibs.mockWebserver) androidTestImplementation(Config.TestLibs.androidxJunit) + androidTestImplementation(Config.TestLibs.leakCanaryInstrumentation) androidTestUtil(Config.TestLibs.androidxTestOrchestrator) } diff --git a/sentry-android-integration-tests/sentry-uitest-android/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-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/BaseUiTest.kt b/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/BaseUiTest.kt index d6d3d1b666..bfac0c3b5f 100644 --- a/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/BaseUiTest.kt +++ b/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/BaseUiTest.kt @@ -77,6 +77,7 @@ abstract class BaseUiTest { */ protected fun initSentry( relayWaitForRequests: Boolean = false, + context: Context = this.context, optionsConfiguration: ((options: SentryAndroidOptions) -> Unit)? = null ) { relay.waitForRequests = relayWaitForRequests diff --git a/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/SdkInitTests.kt b/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/SdkInitTests.kt index c942783548..6a5707f70e 100644 --- a/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/SdkInitTests.kt +++ b/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/SdkInitTests.kt @@ -9,7 +9,12 @@ import io.sentry.android.core.AndroidLogger import io.sentry.android.core.SentryAndroidOptions import io.sentry.assertEnvelopeTransaction import io.sentry.protocol.SentryTransaction +import leakcanary.LeakAssertions +import leakcanary.LeakCanary import org.junit.runner.RunWith +import shark.AndroidReferenceMatchers +import shark.IgnoredReferenceMatcher +import shark.ReferencePattern import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertTrue @@ -56,6 +61,7 @@ class SdkInitTests : BaseUiTest() { it.isDebug = true } relayIdlingResource.increment() + relayIdlingResource.increment() transaction.finish() sampleScenario.moveToState(Lifecycle.State.DESTROYED) val transaction2 = Sentry.startTransaction("e2etests2", "testInit") @@ -63,7 +69,23 @@ class SdkInitTests : BaseUiTest() { relay.assert { findEnvelope { - assertEnvelopeTransaction(it.items.toList(), AndroidLogger()).transaction == "e2etests2" + assertEnvelopeTransaction( + it.items.toList(), + AndroidLogger() + ).transaction == "e2etests" + }.assert { + val transactionItem: SentryTransaction = it.assertTransaction() + it.assertNoOtherItems() + assertEquals("e2etests", transactionItem.transaction) + } + } + + relay.assert { + findEnvelope { + assertEnvelopeTransaction( + it.items.toList(), + AndroidLogger() + ).transaction == "e2etests2" }.assert { val transactionItem: SentryTransaction = it.assertTransaction() // Profiling uses executorService, so if the executorService is shutdown it would fail @@ -105,7 +127,8 @@ class SdkInitTests : BaseUiTest() { Sentry.startTransaction("afterRestart", "emptyTransaction").finish() // We assert for less than 1 second just to account for slow devices in saucelabs or headless emulator - assertTrue(restartMs < 1000, "Expected less than 1000 ms for SDK restart. Got $restartMs ms") + // TODO: Revert back to 1000ms after making scope.close() faster again + assertTrue(restartMs < 2500, "Expected less than 2500 ms for SDK restart. Got $restartMs ms") relay.assert { findEnvelope { @@ -152,6 +175,42 @@ class SdkInitTests : BaseUiTest() { } val afterRestart = System.currentTimeMillis() val restartMs = afterRestart - beforeRestart - assertTrue(restartMs > 3000, "Expected more than 3000 ms for SDK close and restart. Got $restartMs ms") + assertTrue( + restartMs > 3000, + "Expected more than 3000 ms for SDK close and restart. Got $restartMs ms" + ) + } + + @Test + fun initViaActivityDoesNotLeak() { + LeakCanary.config = LeakCanary.config.copy( + referenceMatchers = AndroidReferenceMatchers.appDefaults + + listOf( + IgnoredReferenceMatcher( + ReferencePattern.InstanceFieldPattern( + "com.saucelabs.rdcinjector.testfairy.TestFairyEventQueue", + "context" + ) + ) + ) + ('a'..'z').map { char -> + IgnoredReferenceMatcher( + ReferencePattern.StaticFieldPattern( + "com.testfairy.modules.capture.TouchListener", + "$char" + ) + ) + } + ) + + val activityScenario = launchActivity() + activityScenario.moveToState(Lifecycle.State.RESUMED) + + activityScenario.onActivity { activity -> + initSentry(context = activity) + } + + activityScenario.moveToState(Lifecycle.State.DESTROYED) + + LeakAssertions.assertNoLeaks() } } diff --git a/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/mockservers/RelayAsserter.kt b/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/mockservers/RelayAsserter.kt index e956978086..f685e84874 100644 --- a/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/mockservers/RelayAsserter.kt +++ b/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/mockservers/RelayAsserter.kt @@ -93,7 +93,7 @@ class RelayAsserter( /** Request parsed as envelope. */ val envelope: SentryEnvelope? by lazy { try { - EnvelopeReader(Sentry.getCurrentHub().options.serializer) + EnvelopeReader(Sentry.getCurrentScopes().options.serializer) .read(GZIPInputStream(request.body.inputStream())) } catch (e: IOException) { null diff --git a/sentry-android-integration-tests/sentry-uitest-android/src/main/res/values/values.xml b/sentry-android-integration-tests/sentry-uitest-android/src/main/res/values/values.xml new file mode 100644 index 0000000000..7763c15cd1 --- /dev/null +++ b/sentry-android-integration-tests/sentry-uitest-android/src/main/res/values/values.xml @@ -0,0 +1,4 @@ + + + true + diff --git a/sentry-android-navigation/api/sentry-android-navigation.api b/sentry-android-navigation/api/sentry-android-navigation.api index 79151bb3fb..03a46d8b87 100644 --- a/sentry-android-navigation/api/sentry-android-navigation.api +++ b/sentry-android-navigation/api/sentry-android-navigation.api @@ -10,11 +10,11 @@ public final class io/sentry/android/navigation/SentryNavigationListener : andro public static final field Companion Lio/sentry/android/navigation/SentryNavigationListener$Companion; public static final field NAVIGATION_OP Ljava/lang/String; public fun ()V - public fun (Lio/sentry/IHub;)V - public fun (Lio/sentry/IHub;Z)V - public fun (Lio/sentry/IHub;ZZ)V - public fun (Lio/sentry/IHub;ZZLjava/lang/String;)V - public synthetic fun (Lio/sentry/IHub;ZZLjava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Lio/sentry/IScopes;)V + public fun (Lio/sentry/IScopes;Z)V + public fun (Lio/sentry/IScopes;ZZ)V + public fun (Lio/sentry/IScopes;ZZLjava/lang/String;)V + public synthetic fun (Lio/sentry/IScopes;ZZLjava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public fun onDestinationChanged (Landroidx/navigation/NavController;Landroidx/navigation/NavDestination;Landroid/os/Bundle;)V } 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..bc008fa678 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,14 +1,15 @@ package io.sentry.android.navigation +import android.content.Context import android.content.res.Resources.NotFoundException import android.os.Bundle import androidx.navigation.NavController import androidx.navigation.NavDestination import io.sentry.Breadcrumb import io.sentry.Hint -import io.sentry.HubAdapter -import io.sentry.IHub +import io.sentry.IScopes import io.sentry.ITransaction +import io.sentry.ScopesAdapter import io.sentry.SentryIntegrationPackageStorage import io.sentry.SentryLevel.DEBUG import io.sentry.SentryLevel.INFO @@ -34,7 +35,7 @@ private const val TRACE_ORIGIN = "auto.navigation" * with [SentryOptions.idleTimeout] for navigation events. */ class SentryNavigationListener @JvmOverloads constructor( - private val hub: IHub = HubAdapter.getInstance(), + private val scopes: IScopes = ScopesAdapter.getInstance(), private val enableNavigationBreadcrumbs: Boolean = true, private val enableNavigationTracing: Boolean = true, private val traceOriginAppendix: String? = null @@ -43,7 +44,7 @@ class SentryNavigationListener @JvmOverloads constructor( private var previousDestinationRef: WeakReference? = null private var previousArgs: Bundle? = null - private val isPerformanceEnabled get() = hub.options.isTracingEnabled && enableNavigationTracing + private val isPerformanceEnabled get() = scopes.options.isTracingEnabled && enableNavigationTracing private var activeTransaction: ITransaction? = null @@ -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 (scopes.options.isEnableScreenTracking) { + scopes.configureScope { it.screen = routeName } + } + startTracing(routeName, destination, toArguments) + } previousDestinationRef = WeakReference(destination) previousArgs = arguments } @@ -91,16 +98,16 @@ class SentryNavigationListener @JvmOverloads constructor( } val hint = Hint() hint.set(TypeCheckHint.ANDROID_NAV_DESTINATION, destination) - hub.addBreadcrumb(breadcrumb, hint) + scopes.addBreadcrumb(breadcrumb, hint) } private fun startTracing( - controller: NavController, + routeName: String, destination: NavDestination, arguments: Map ) { if (!isPerformanceEnabled) { - TracingUtils.startNewTrace(hub) + TracingUtils.startNewTrace(scopes) return } @@ -111,36 +118,22 @@ class SentryNavigationListener @JvmOverloads constructor( if (destination.navigatorName == "activity") { // we do not trace navigation between activities to avoid clashing with activity lifecycle tracing - hub.options.logger.log( + scopes.options.logger.log( DEBUG, "Navigating to activity destination, no transaction captured." ) 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 + it.idleTimeout = scopes.options.idleTimeout it.deadlineTimeout = TransactionOptions.DEFAULT_DEADLINE_TIMEOUT_AUTO_TRANSACTION it.isTrimEnd = true } - val transaction = hub.startTransaction( - TransactionContext(name, TransactionNameSource.ROUTE, NAVIGATION_OP), + val transaction = scopes.startTransaction( + TransactionContext(routeName, TransactionNameSource.ROUTE, NAVIGATION_OP), transactionOptions ) @@ -151,7 +144,7 @@ class SentryNavigationListener @JvmOverloads constructor( if (arguments.isNotEmpty()) { transaction.setData("arguments", arguments) } - hub.configureScope { scope -> + scopes.configureScope { scope -> scope.withTransaction { tx -> if (tx == null) { scope.transaction = transaction @@ -166,7 +159,7 @@ class SentryNavigationListener @JvmOverloads constructor( activeTransaction?.finish(status) // clear transaction from scope so others can bind to it - hub.configureScope { scope -> + scopes.configureScope { scope -> scope.withTransaction { tx -> if (tx == activeTransaction) { scope.clearTransaction() @@ -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) { + scopes.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..e80cc299bd 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 @@ -7,8 +7,8 @@ import androidx.navigation.NavController import androidx.navigation.NavDestination import androidx.test.ext.junit.runners.AndroidJUnit4 import io.sentry.Breadcrumb -import io.sentry.IHub import io.sentry.IScope +import io.sentry.IScopes import io.sentry.Scope import io.sentry.Scope.IWithTransaction import io.sentry.ScopeCallback @@ -39,7 +39,7 @@ import kotlin.test.assertNull class SentryNavigationListenerTest { class Fixture { - val hub = mock() + val scopes = mock() val destination = mock() val navController = mock() @@ -55,7 +55,8 @@ class SentryNavigationListenerTest { toRoute: String? = "route", toId: String? = "destination-id-1", enableBreadcrumbs: Boolean = true, - enableTracing: Boolean = true, + enableNavigationTracing: Boolean = true, + enableScreenTracking: Boolean = true, tracesSampleRate: Double? = 1.0, hasViewIdInRes: Boolean = true, transaction: SentryTracer? = null, @@ -66,21 +67,22 @@ class SentryNavigationListenerTest { setTracesSampleRate( tracesSampleRate ) + isEnableScreenTracking = enableScreenTracking } - whenever(hub.options).thenReturn(options) + whenever(scopes.options).thenReturn(options) this.transaction = transaction ?: SentryTracer( TransactionContext( "/$toRoute", SentryNavigationListener.NAVIGATION_OP ), - hub + scopes ) - whenever(hub.startTransaction(any(), any())) + whenever(scopes.startTransaction(any(), any())) .thenReturn(this.transaction) - whenever(hub.configureScope(any())).thenAnswer { + whenever(scopes.configureScope(any())).thenAnswer { (it.arguments[0] as ScopeCallback).run(scope) } @@ -96,9 +98,9 @@ class SentryNavigationListenerTest { whenever(navController.context).thenReturn(context) whenever(destination.route).thenReturn(toRoute) return SentryNavigationListener( - hub, + scopes, enableBreadcrumbs, - enableTracing, + enableNavigationTracing, traceOriginAppendix ) } @@ -112,7 +114,7 @@ class SentryNavigationListenerTest { sut.onDestinationChanged(fixture.navController, fixture.destination, null) - verify(fixture.hub).addBreadcrumb( + verify(fixture.scopes).addBreadcrumb( check { assertEquals("navigation", it.type) assertEquals("navigation", it.category) @@ -133,7 +135,7 @@ class SentryNavigationListenerTest { bundleOf("arg1" to "foo", "arg2" to "bar") ) - verify(fixture.hub).addBreadcrumb( + verify(fixture.scopes).addBreadcrumb( check { assertEquals("/route", it.data["to"]) assertEquals(mapOf("arg1" to "foo", "arg2" to "bar"), it.data["to_arguments"]) @@ -152,7 +154,7 @@ class SentryNavigationListenerTest { bundleOf() ) - verify(fixture.hub).addBreadcrumb( + verify(fixture.scopes).addBreadcrumb( check { assertEquals("/route", it.data["to"]) assertNull(it.data["to_arguments"]) @@ -180,7 +182,7 @@ class SentryNavigationListenerTest { bundleOf("to_arg1" to "to_foo") ) val captor = argumentCaptor() - verify(fixture.hub, times(2)).addBreadcrumb(captor.capture(), any()) + verify(fixture.scopes, times(2)).addBreadcrumb(captor.capture(), any()) captor.secondValue.let { assertEquals("/route_from", it.data["from"]) assertEquals(mapOf("from_arg1" to "from_foo"), it.data["from_arguments"]) @@ -196,16 +198,16 @@ class SentryNavigationListenerTest { sut.onDestinationChanged(fixture.navController, fixture.destination, null) - verify(fixture.hub, never()).addBreadcrumb(any()) + verify(fixture.scopes, never()).addBreadcrumb(any()) } @Test fun `onDestinationChanged does not start tracing when tracing is disabled`() { - val sut = fixture.getSut(enableTracing = false) + val sut = fixture.getSut(enableNavigationTracing = false) sut.onDestinationChanged(fixture.navController, fixture.destination, null) - verify(fixture.hub, never()).startTransaction( + verify(fixture.scopes, never()).startTransaction( any(), any() ) @@ -213,11 +215,11 @@ class SentryNavigationListenerTest { @Test fun `onDestinationChanged does not start tracing when tracesSampleRate is not set`() { - val sut = fixture.getSut(enableTracing = true, tracesSampleRate = null) + val sut = fixture.getSut(enableNavigationTracing = true, tracesSampleRate = null) sut.onDestinationChanged(fixture.navController, fixture.destination, null) - verify(fixture.hub, never()).startTransaction( + verify(fixture.scopes, never()).startTransaction( any(), any() ) @@ -230,7 +232,7 @@ class SentryNavigationListenerTest { sut.onDestinationChanged(fixture.navController, fixture.destination, null) - verify(fixture.hub, never()).startTransaction( + verify(fixture.scopes, never()).startTransaction( any(), any() ) @@ -242,7 +244,7 @@ class SentryNavigationListenerTest { sut.onDestinationChanged(fixture.navController, fixture.destination, null) - verify(fixture.hub, never()).startTransaction( + verify(fixture.scopes, never()).startTransaction( any(), any() ) @@ -254,7 +256,7 @@ class SentryNavigationListenerTest { sut.onDestinationChanged(fixture.navController, fixture.destination, null) - verify(fixture.hub).startTransaction( + verify(fixture.scopes).startTransaction( check { assertEquals("/route", it.name) assertEquals(SentryNavigationListener.NAVIGATION_OP, it.operation) @@ -270,7 +272,7 @@ class SentryNavigationListenerTest { sut.onDestinationChanged(fixture.navController, fixture.destination, null) - verify(fixture.hub).startTransaction( + verify(fixture.scopes).startTransaction( check { assertEquals("/github", it.name) assertEquals(TransactionNameSource.ROUTE, it.transactionNameSource) @@ -285,7 +287,7 @@ class SentryNavigationListenerTest { sut.onDestinationChanged(fixture.navController, fixture.destination, null) - verify(fixture.hub).startTransaction( + verify(fixture.scopes).startTransaction( check { assertEquals("/destination-id-1", it.name) assertEquals(TransactionNameSource.ROUTE, it.transactionNameSource) @@ -304,7 +306,7 @@ class SentryNavigationListenerTest { bundleOf("user_id" to 123, "per_page" to 10) ) - verify(fixture.hub).startTransaction( + verify(fixture.scopes).startTransaction( check { assertEquals("/github", it.name) assertEquals(TransactionNameSource.ROUTE, it.transactionNameSource) @@ -359,19 +361,19 @@ class SentryNavigationListenerTest { @Test fun `starts new trace if performance is disabled`() { - val sut = fixture.getSut(enableTracing = false) + val sut = fixture.getSut(enableNavigationTracing = false) val argumentCaptor: ArgumentCaptor = ArgumentCaptor.forClass(ScopeCallback::class.java) val scope = Scope(fixture.options) val propagationContextAtStart = scope.propagationContext - whenever(fixture.hub.configureScope(argumentCaptor.capture())).thenAnswer { + whenever(fixture.scopes.configureScope(argumentCaptor.capture())).thenAnswer { argumentCaptor.value.run(scope) } sut.onDestinationChanged(fixture.navController, fixture.destination, null) - verify(fixture.hub).configureScope(any()) + verify(fixture.scopes, times(2)).configureScope(any()) assertNotSame(propagationContextAtStart, scope.propagationContext) } @@ -399,11 +401,29 @@ class SentryNavigationListenerTest { sut.onDestinationChanged(fixture.navController, fixture.destination, null) - verify(fixture.hub).startTransaction( + verify(fixture.scopes).startTransaction( any(), check { options -> assertEquals(TransactionOptions.DEFAULT_DEADLINE_TIMEOUT_AUTO_TRANSACTION, options.deadlineTimeout) } ) } + + @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-ndk/CMakeLists.txt b/sentry-android-ndk/CMakeLists.txt deleted file mode 100644 index c9a0181935..0000000000 --- a/sentry-android-ndk/CMakeLists.txt +++ /dev/null @@ -1,17 +0,0 @@ -cmake_minimum_required(VERSION 3.10) -project(Sentry-Android LANGUAGES C CXX) - -# Add sentry-android shared library -add_library(sentry-android SHARED src/main/jni/sentry.c) - -# make sure that we build it as a shared lib instead of a static lib -set(BUILD_SHARED_LIBS ON) -set(SENTRY_BUILD_SHARED_LIBS ON) - -# Adding sentry-native submodule subdirectory -add_subdirectory(${SENTRY_NATIVE_SRC} sentry_build) - -# Link to sentry-native -target_link_libraries(sentry-android PRIVATE - $ -) diff --git a/sentry-android-ndk/api/sentry-android-ndk.api b/sentry-android-ndk/api/sentry-android-ndk.api index e8f838ce8b..155a368b11 100644 --- a/sentry-android-ndk/api/sentry-android-ndk.api +++ b/sentry-android-ndk/api/sentry-android-ndk.api @@ -7,7 +7,7 @@ public final class io/sentry/android/ndk/BuildConfig { } public final class io/sentry/android/ndk/DebugImagesLoader : io/sentry/android/core/IDebugImagesLoader { - public fun (Lio/sentry/android/core/SentryAndroidOptions;Lio/sentry/android/ndk/NativeModuleListLoader;)V + public fun (Lio/sentry/android/core/SentryAndroidOptions;Lio/sentry/ndk/NativeModuleListLoader;)V public fun clearDebugImages ()V public fun loadDebugImages ()Ljava/util/List; } diff --git a/sentry-android-ndk/build.gradle.kts b/sentry-android-ndk/build.gradle.kts index f6564cd97f..fe67063139 100644 --- a/sentry-android-ndk/build.gradle.kts +++ b/sentry-android-ndk/build.gradle.kts @@ -5,38 +5,21 @@ plugins { kotlin("android") jacoco id(Config.QualityPlugins.jacocoAndroid) - id(Config.NativePlugins.nativeBundleExport) id(Config.QualityPlugins.gradleVersions) } -var sentryNativeSrc: String = "sentry-native" val sentryAndroidSdkName: String by project android { compileSdk = Config.Android.compileSdkVersion namespace = "io.sentry.android.ndk" - sentryNativeSrc = if (File("${project.projectDir}/sentry-native-local").exists()) { - "sentry-native-local" - } else { - "sentry-native" - } - println("sentry-android-ndk: $sentryNativeSrc") - defaultConfig { targetSdk = Config.Android.targetSdkVersion minSdk = Config.Android.minSdkVersionNdk // NDK requires a higher API level than core. testInstrumentationRunner = Config.TestLibs.androidJUnitRunner - externalNativeBuild { - cmake { - arguments.add(0, "-DANDROID_STL=c++_static") - arguments.add(0, "-DSENTRY_NATIVE_SRC=$sentryNativeSrc") - arguments.add(0, "-DSENTRY_SDK_NAME=$sentryAndroidSdkName") - } - } - ndk { abiFilters.addAll(Config.Android.abiFilters) } @@ -45,15 +28,6 @@ android { buildConfigField("String", "VERSION_NAME", "\"${project.version}\"") } - // we use the default NDK and CMake versions based on the AGP's version - // https://developer.android.com/studio/projects/install-ndk#apply-specific-version - - externalNativeBuild { - cmake { - path("CMakeLists.txt") - } - } - buildTypes { getByName("debug") getByName("release") { @@ -81,10 +55,6 @@ android { checkReleaseBuilds = false } - nativeBundleExport { - headerDir = "${project.projectDir}/$sentryNativeSrc/include" - } - // needed because of Kotlin 1.4.x configurations.all { resolutionStrategy.force(Config.CompileOnly.jetbrainsAnnotations) @@ -101,6 +71,8 @@ dependencies { api(projects.sentry) api(projects.sentryAndroidCore) + implementation("io.sentry:sentry-native-ndk:0.7.5") + compileOnly(Config.CompileOnly.jetbrainsAnnotations) testImplementation(kotlin(Config.kotlinStdLib, KotlinCompilerVersion.VERSION)) diff --git a/sentry-android-ndk/sentry-native b/sentry-android-ndk/sentry-native deleted file mode 160000 index 0f1d664759..0000000000 --- a/sentry-android-ndk/sentry-native +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 0f1d664759cba187a846a562f9d55f3c62dffaa3 diff --git a/sentry-android-ndk/src/main/java/io/sentry/android/ndk/DebugImagesLoader.java b/sentry-android-ndk/src/main/java/io/sentry/android/ndk/DebugImagesLoader.java index cb38db498a..1257325091 100644 --- a/sentry-android-ndk/src/main/java/io/sentry/android/ndk/DebugImagesLoader.java +++ b/sentry-android-ndk/src/main/java/io/sentry/android/ndk/DebugImagesLoader.java @@ -1,12 +1,15 @@ package io.sentry.android.ndk; +import io.sentry.ISentryLifecycleToken; import io.sentry.SentryLevel; import io.sentry.SentryOptions; import io.sentry.android.core.IDebugImagesLoader; import io.sentry.android.core.SentryAndroidOptions; +import io.sentry.ndk.NativeModuleListLoader; import io.sentry.protocol.DebugImage; +import io.sentry.util.AutoClosableReentrantLock; import io.sentry.util.Objects; -import java.util.Arrays; +import java.util.ArrayList; import java.util.List; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -25,7 +28,8 @@ public final class DebugImagesLoader implements IDebugImagesLoader { private static @Nullable List debugImages; /** we need to lock it because it could be called from different threads */ - private static final @NotNull Object debugImagesLock = new Object(); + protected static final @NotNull AutoClosableReentrantLock debugImagesLock = + new AutoClosableReentrantLock(); public DebugImagesLoader( final @NotNull SentryAndroidOptions options, @@ -42,12 +46,23 @@ public DebugImagesLoader( */ @Override public @Nullable List loadDebugImages() { - synchronized (debugImagesLock) { + try (final @NotNull ISentryLifecycleToken ignored = debugImagesLock.acquire()) { if (debugImages == null) { try { - final DebugImage[] debugImagesArr = moduleListLoader.loadModuleList(); + final io.sentry.ndk.DebugImage[] debugImagesArr = moduleListLoader.loadModuleList(); if (debugImagesArr != null) { - debugImages = Arrays.asList(debugImagesArr); + debugImages = new ArrayList<>(debugImagesArr.length); + for (io.sentry.ndk.DebugImage d : debugImagesArr) { + final DebugImage debugImage = new DebugImage(); + debugImage.setUuid(d.getUuid()); + debugImage.setType(d.getType()); + debugImage.setDebugId(d.getDebugId()); + debugImage.setCodeId(d.getCodeId()); + debugImage.setImageAddr(d.getImageAddr()); + debugImage.setImageSize(d.getImageSize()); + debugImage.setArch(d.getArch()); + debugImages.add(debugImage); + } options .getLogger() .log(SentryLevel.DEBUG, "Debug images loaded: %d", debugImages.size()); @@ -63,7 +78,7 @@ public DebugImagesLoader( /** Clears the caching of debug images on sentry-native and here. */ @Override public void clearDebugImages() { - synchronized (debugImagesLock) { + try (final @NotNull ISentryLifecycleToken ignored = debugImagesLock.acquire()) { try { moduleListLoader.clearModuleList(); diff --git a/sentry-android-ndk/src/main/java/io/sentry/android/ndk/INativeScope.java b/sentry-android-ndk/src/main/java/io/sentry/android/ndk/INativeScope.java deleted file mode 100644 index a8d50e40fe..0000000000 --- a/sentry-android-ndk/src/main/java/io/sentry/android/ndk/INativeScope.java +++ /dev/null @@ -1,18 +0,0 @@ -package io.sentry.android.ndk; - -interface INativeScope { - void setTag(String key, String value); - - void removeTag(String key); - - void setExtra(String key, String value); - - void removeExtra(String key); - - void setUser(String id, String email, String ipAddress, String username); - - void removeUser(); - - void addBreadcrumb( - String level, String message, String category, String type, String timestamp, String data); -} diff --git a/sentry-android-ndk/src/main/java/io/sentry/android/ndk/NativeModuleListLoader.java b/sentry-android-ndk/src/main/java/io/sentry/android/ndk/NativeModuleListLoader.java deleted file mode 100644 index 464fcd3992..0000000000 --- a/sentry-android-ndk/src/main/java/io/sentry/android/ndk/NativeModuleListLoader.java +++ /dev/null @@ -1,19 +0,0 @@ -package io.sentry.android.ndk; - -import io.sentry.protocol.DebugImage; -import org.jetbrains.annotations.Nullable; - -final class NativeModuleListLoader { - - public @Nullable DebugImage[] loadModuleList() { - return nativeLoadModuleList(); - } - - public void clearModuleList() { - nativeClearModuleList(); - } - - public static native DebugImage[] nativeLoadModuleList(); - - public static native void nativeClearModuleList(); -} diff --git a/sentry-android-ndk/src/main/java/io/sentry/android/ndk/NativeScope.java b/sentry-android-ndk/src/main/java/io/sentry/android/ndk/NativeScope.java deleted file mode 100644 index 9d82f9d5c8..0000000000 --- a/sentry-android-ndk/src/main/java/io/sentry/android/ndk/NativeScope.java +++ /dev/null @@ -1,55 +0,0 @@ -package io.sentry.android.ndk; - -final class NativeScope implements INativeScope { - @Override - public void setTag(String key, String value) { - nativeSetTag(key, value); - } - - @Override - public void removeTag(String key) { - nativeRemoveTag(key); - } - - @Override - public void setExtra(String key, String value) { - nativeSetExtra(key, value); - } - - @Override - public void removeExtra(String key) { - nativeRemoveExtra(key); - } - - @Override - public void setUser(String id, String email, String ipAddress, String username) { - nativeSetUser(id, email, ipAddress, username); - } - - @Override - public void removeUser() { - nativeRemoveUser(); - } - - @Override - public void addBreadcrumb( - String level, String message, String category, String type, String timestamp, String data) { - nativeAddBreadcrumb(level, message, category, type, timestamp, data); - } - - public static native void nativeSetTag(String key, String value); - - public static native void nativeRemoveTag(String key); - - public static native void nativeSetExtra(String key, String value); - - public static native void nativeRemoveExtra(String key); - - public static native void nativeSetUser( - String id, String email, String ipAddress, String username); - - public static native void nativeRemoveUser(); - - public static native void nativeAddBreadcrumb( - String level, String message, String category, String type, String timestamp, String data); -} diff --git a/sentry-android-ndk/src/main/java/io/sentry/android/ndk/NdkScopeObserver.java b/sentry-android-ndk/src/main/java/io/sentry/android/ndk/NdkScopeObserver.java index 009bba9b81..4a4237ba08 100644 --- a/sentry-android-ndk/src/main/java/io/sentry/android/ndk/NdkScopeObserver.java +++ b/sentry-android-ndk/src/main/java/io/sentry/android/ndk/NdkScopeObserver.java @@ -5,6 +5,8 @@ import io.sentry.ScopeObserverAdapter; import io.sentry.SentryLevel; import io.sentry.SentryOptions; +import io.sentry.ndk.INativeScope; +import io.sentry.ndk.NativeScope; import io.sentry.protocol.User; import io.sentry.util.Objects; import java.util.Locale; diff --git a/sentry-android-ndk/src/main/java/io/sentry/android/ndk/SentryNdk.java b/sentry-android-ndk/src/main/java/io/sentry/android/ndk/SentryNdk.java index 1ddc04c524..ebce1a12fd 100644 --- a/sentry-android-ndk/src/main/java/io/sentry/android/ndk/SentryNdk.java +++ b/sentry-android-ndk/src/main/java/io/sentry/android/ndk/SentryNdk.java @@ -1,6 +1,8 @@ package io.sentry.android.ndk; import io.sentry.android.core.SentryAndroidOptions; +import io.sentry.ndk.NativeModuleListLoader; +import io.sentry.ndk.NdkOptions; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; @@ -9,21 +11,6 @@ public final class SentryNdk { private SentryNdk() {} - static { - // On older Android versions, it was necessary to manually call "`System.loadLibrary` on all - // transitive dependencies before loading [the] main library." - // The dependencies of `libsentry.so` are currently `lib{c,m,dl,log}.so`. - // See - // https://android.googlesource.com/platform/bionic/+/master/android-changes-for-ndk-developers.md#changes-to-library-dependency-resolution - System.loadLibrary("log"); - System.loadLibrary("sentry"); - System.loadLibrary("sentry-android"); - } - - private static native void initSentryNative(@NotNull final SentryAndroidOptions options); - - private static native void shutdown(); - /** * Init the NDK integration * @@ -31,7 +18,18 @@ private SentryNdk() {} */ public static void init(@NotNull final SentryAndroidOptions options) { SentryNdkUtil.addPackage(options.getSdkVersion()); - initSentryNative(options); + + final @NotNull NdkOptions ndkOptions = + new NdkOptions( + options.getDsn(), + options.isDebug(), + options.getOutboxPath(), + options.getRelease(), + options.getEnvironment(), + options.getDist(), + options.getMaxBreadcrumbs(), + options.getNativeSdkName()); + io.sentry.ndk.SentryNdk.init(ndkOptions); // only add scope sync observer if the scope sync is enabled. if (options.isEnableScopeSync()) { @@ -43,6 +41,6 @@ public static void init(@NotNull final SentryAndroidOptions options) { /** Closes the NDK integration */ public static void close() { - shutdown(); + io.sentry.ndk.SentryNdk.close(); } } diff --git a/sentry-android-ndk/src/main/jni/sentry.c b/sentry-android-ndk/src/main/jni/sentry.c deleted file mode 100644 index d62ef56123..0000000000 --- a/sentry-android-ndk/src/main/jni/sentry.c +++ /dev/null @@ -1,494 +0,0 @@ -#include -#include -#include -#include -#include - -#define ENSURE(Expr) \ - if (!(Expr)) \ - return - -#define ENSURE_OR_FAIL(Expr) \ - if (!(Expr)) \ - goto fail - -static bool get_string_into(JNIEnv *env, jstring jstr, char* buf, size_t buf_len) -{ - jsize utf_len = (*env)->GetStringUTFLength(env, jstr); - if ((size_t)utf_len >= buf_len) { - return false; - } - - jsize j_len = (*env)->GetStringLength(env, jstr); - - (*env)->GetStringUTFRegion(env, jstr, 0, j_len, buf); - if ((*env)->ExceptionCheck(env) == JNI_TRUE) { - return false; - } - - buf[utf_len] = '\0'; - return true; -} - -static char* get_string(JNIEnv *env, jstring jstr) { - char *buf = NULL; - - jsize utf_len = (*env)->GetStringUTFLength(env, jstr); - size_t buf_len = (size_t)utf_len + 1; - buf = sentry_malloc(buf_len); - ENSURE_OR_FAIL(buf); - - ENSURE_OR_FAIL(get_string_into(env, jstr, buf, buf_len)); - - return buf; - -fail: - sentry_free(buf); - - return NULL; -} - -static char *call_get_string(JNIEnv *env, jobject obj, jmethodID mid) -{ - jstring j_str = (jstring)(*env)->CallObjectMethod(env, obj, mid); - ENSURE_OR_FAIL(j_str); - char* str = get_string(env, j_str); - (*env)->DeleteLocalRef(env, j_str); - - return str; - -fail: - return NULL; -} - -JNIEXPORT void JNICALL -Java_io_sentry_android_ndk_NativeScope_nativeSetTag( - JNIEnv *env, - jclass cls, - jstring key, - jstring value) { - const char *charKey = (*env)->GetStringUTFChars(env, key, 0); - const char *charValue = (*env)->GetStringUTFChars(env, value, 0); - - sentry_set_tag(charKey, charValue); - - (*env)->ReleaseStringUTFChars(env, key, charKey); - (*env)->ReleaseStringUTFChars(env, value, charValue); -} - -JNIEXPORT void JNICALL -Java_io_sentry_android_ndk_NativeScope_nativeRemoveTag(JNIEnv *env, jclass cls, jstring key) { - const char *charKey = (*env)->GetStringUTFChars(env, key, 0); - - sentry_remove_tag(charKey); - - (*env)->ReleaseStringUTFChars(env, key, charKey); -} - -JNIEXPORT void JNICALL -Java_io_sentry_android_ndk_NativeScope_nativeSetExtra( - JNIEnv *env, - jclass cls, - jstring key, - jstring value) { - const char *charKey = (*env)->GetStringUTFChars(env, key, 0); - const char *charValue = (*env)->GetStringUTFChars(env, value, 0); - - sentry_value_t sentryValue = sentry_value_new_string(charValue); - sentry_set_extra(charKey, sentryValue); - - (*env)->ReleaseStringUTFChars(env, key, charKey); - (*env)->ReleaseStringUTFChars(env, value, charValue); -} - -JNIEXPORT void JNICALL -Java_io_sentry_android_ndk_NativeScope_nativeRemoveExtra(JNIEnv *env, jclass cls, jstring key) { - const char *charKey = (*env)->GetStringUTFChars(env, key, 0); - - sentry_remove_extra(charKey); - - (*env)->ReleaseStringUTFChars(env, key, charKey); -} - -JNIEXPORT void JNICALL -Java_io_sentry_android_ndk_NativeScope_nativeSetUser( - JNIEnv *env, - jclass cls, - jstring id, - jstring email, - jstring ipAddress, - jstring username) { - sentry_value_t user = sentry_value_new_object(); - if (id) { - const char *charId = (*env)->GetStringUTFChars(env, id, 0); - sentry_value_set_by_key(user, "id", sentry_value_new_string(charId)); - (*env)->ReleaseStringUTFChars(env, id, charId); - } - if (email) { - const char *charEmail = (*env)->GetStringUTFChars(env, email, 0); - sentry_value_set_by_key( - user, "email", sentry_value_new_string(charEmail)); - (*env)->ReleaseStringUTFChars(env, email, charEmail); - } - if (ipAddress) { - const char *charIpAddress = (*env)->GetStringUTFChars(env, ipAddress, 0); - sentry_value_set_by_key( - user, "ip_address", sentry_value_new_string(charIpAddress)); - (*env)->ReleaseStringUTFChars(env, ipAddress, charIpAddress); - } - if (username) { - const char *charUsername = (*env)->GetStringUTFChars(env, username, 0); - sentry_value_set_by_key( - user, "username", sentry_value_new_string(charUsername)); - (*env)->ReleaseStringUTFChars(env, username, charUsername); - } - sentry_set_user(user); -} - -JNIEXPORT void JNICALL -Java_io_sentry_android_ndk_NativeScope_nativeRemoveUser(JNIEnv *env, jclass cls) { - sentry_remove_user(); -} - -JNIEXPORT void JNICALL -Java_io_sentry_android_ndk_NativeScope_nativeAddBreadcrumb( - JNIEnv *env, - jclass cls, - jstring level, - jstring message, - jstring category, - jstring type, - jstring timestamp, - jstring data) { - if (!level && !message && !category && !type) { - return; - } - const char *charMessage = NULL; - if (message) { - charMessage = (*env)->GetStringUTFChars(env, message, 0); - } - const char *charType = NULL; - if (type) { - charType = (*env)->GetStringUTFChars(env, type, 0); - } - sentry_value_t crumb = sentry_value_new_breadcrumb(charType, charMessage); - - if (charMessage) { - (*env)->ReleaseStringUTFChars(env, message, charMessage); - } - if (charType) { - (*env)->ReleaseStringUTFChars(env, type, charType); - } - - if (category) { - const char *charCategory = (*env)->GetStringUTFChars(env, category, 0); - sentry_value_set_by_key( - crumb, "category", sentry_value_new_string(charCategory)); - (*env)->ReleaseStringUTFChars(env, category, charCategory); - } - if (level) { - const char *charLevel = (*env)->GetStringUTFChars(env, level, 0); - sentry_value_set_by_key( - crumb, "level", sentry_value_new_string(charLevel)); - (*env)->ReleaseStringUTFChars(env, level, charLevel); - } - - if (timestamp) { - // overwrite timestamp that is already created on sentry_value_new_breadcrumb - const char *charTimestamp = (*env)->GetStringUTFChars(env, timestamp, 0); - sentry_value_set_by_key( - crumb, "timestamp", sentry_value_new_string(charTimestamp)); - (*env)->ReleaseStringUTFChars(env, timestamp, charTimestamp); - } - - if (data) { - const char *charData = (*env)->GetStringUTFChars(env, data, 0); - - // we create an object because the Java layer parses it as a Map - sentry_value_t dataObject = sentry_value_new_object(); - sentry_value_set_by_key(dataObject, "data", sentry_value_new_string(charData)); - - sentry_value_set_by_key(crumb, "data", dataObject); - - (*env)->ReleaseStringUTFChars(env, data, charData); - } - - sentry_add_breadcrumb(crumb); -} - -static void send_envelope(sentry_envelope_t *envelope, void *data) { - const char *outbox_path = (const char *) data; - char envelope_id_str[40]; - - sentry_uuid_t envelope_id = sentry_uuid_new_v4(); - sentry_uuid_as_string(&envelope_id, envelope_id_str); - - size_t outbox_len = strlen(outbox_path); - size_t final_len = outbox_len + 42; // "/" + envelope_id_str + "\0" = 42 - char* envelope_path = sentry_malloc(final_len); - ENSURE(envelope_path); - int written = snprintf(envelope_path, final_len, "%s/%s", outbox_path, envelope_id_str); - if (written > outbox_len && written < final_len) { - sentry_envelope_write_to_file(envelope, envelope_path); - } - - sentry_free(envelope_path); - sentry_envelope_free(envelope); -} - -JNIEXPORT void JNICALL -Java_io_sentry_android_ndk_SentryNdk_initSentryNative( - JNIEnv *env, - jclass cls, - jobject sentry_sdk_options) { - jclass options_cls = (*env)->GetObjectClass(env, sentry_sdk_options); - jmethodID outbox_path_mid = (*env)->GetMethodID(env, options_cls, "getOutboxPath", - "()Ljava/lang/String;"); - jmethodID dsn_mid = (*env)->GetMethodID(env, options_cls, "getDsn", "()Ljava/lang/String;"); - jmethodID is_debug_mid = (*env)->GetMethodID(env, options_cls, "isDebug", "()Z"); - jmethodID release_mid = (*env)->GetMethodID(env, options_cls, "getRelease", - "()Ljava/lang/String;"); - jmethodID environment_mid = (*env)->GetMethodID(env, options_cls, "getEnvironment", - "()Ljava/lang/String;"); - jmethodID dist_mid = (*env)->GetMethodID(env, options_cls, "getDist", "()Ljava/lang/String;"); - jmethodID max_crumbs_mid = (*env)->GetMethodID(env, options_cls, "getMaxBreadcrumbs", "()I"); - jmethodID native_sdk_name_mid = (*env)->GetMethodID(env, options_cls, "getNativeSdkName", - "()Ljava/lang/String;"); - - (*env)->DeleteLocalRef(env, options_cls); - - char *outbox_path = NULL; - sentry_transport_t *transport = NULL; - bool transport_owns_path = false; - sentry_options_t *options = NULL; - bool options_owns_transport = false; - char *dsn_str = NULL; - char *release_str = NULL; - char *environment_str = NULL; - char *dist_str = NULL; - char *native_sdk_name_str = NULL; - - options = sentry_options_new(); - ENSURE_OR_FAIL(options); - - // session tracking is enabled by default, but the Android SDK already handles it - sentry_options_set_auto_session_tracking(options, 0); - - jboolean debug = (jboolean)(*env)->CallBooleanMethod(env, sentry_sdk_options, is_debug_mid); - sentry_options_set_debug(options, debug); - - jint max_crumbs = (jint) (*env)->CallIntMethod(env, sentry_sdk_options, max_crumbs_mid); - sentry_options_set_max_breadcrumbs(options, max_crumbs); - - outbox_path = call_get_string(env, sentry_sdk_options, outbox_path_mid); - ENSURE_OR_FAIL(outbox_path); - - transport = sentry_transport_new(send_envelope); - ENSURE_OR_FAIL(transport); - sentry_transport_set_state(transport, outbox_path); - sentry_transport_set_free_func(transport, sentry_free); - transport_owns_path = true; - - sentry_options_set_transport(options, transport); - options_owns_transport = true; - - // give sentry-native its own database path it can work with, next to the outbox - size_t outbox_len = strlen(outbox_path); - size_t final_len = outbox_len + 15; // len(".sentry-native\0") = 15 - char* database_path = sentry_malloc(final_len); - ENSURE_OR_FAIL(database_path); - strncpy(database_path, outbox_path, final_len); - char *dir = strrchr(database_path, '/'); - if (dir) - { - strncpy(dir + 1, ".sentry-native", final_len - (dir + 1 - database_path)); - } - sentry_options_set_database_path(options, database_path); - sentry_free(database_path); - - dsn_str = call_get_string(env, sentry_sdk_options, dsn_mid); - ENSURE_OR_FAIL(dsn_str); - sentry_options_set_dsn(options, dsn_str); - sentry_free(dsn_str); - - release_str = call_get_string(env, sentry_sdk_options, release_mid); - if (release_str) { - sentry_options_set_release(options, release_str); - sentry_free(release_str); - } - - environment_str = call_get_string(env, sentry_sdk_options, environment_mid); - if (environment_str) - { - sentry_options_set_environment(options, environment_str); - sentry_free(environment_str); - } - - dist_str = call_get_string(env, sentry_sdk_options, dist_mid); - if (dist_str) - { - sentry_options_set_dist(options, dist_str); - sentry_free(dist_str); - } - - native_sdk_name_str = call_get_string(env, sentry_sdk_options, native_sdk_name_mid); - if (native_sdk_name_str) { - sentry_options_set_sdk_name(options, native_sdk_name_str); - sentry_free(native_sdk_name_str); - } - - sentry_init(options); - return; - -fail: - if (!transport_owns_path) { - sentry_free(outbox_path); - } - if (!options_owns_transport) { - sentry_transport_free(transport); - } - sentry_options_free(options); -} - -JNIEXPORT void JNICALL -Java_io_sentry_android_ndk_NativeModuleListLoader_nativeClearModuleList(JNIEnv *env, jclass cls) { - sentry_clear_modulecache(); -} - -JNIEXPORT jobjectArray JNICALL -Java_io_sentry_android_ndk_NativeModuleListLoader_nativeLoadModuleList(JNIEnv *env, jclass cls) { - sentry_value_t image_list_t = sentry_get_modules_list(); - jobjectArray image_list = NULL; - - if (sentry_value_get_type(image_list_t) == SENTRY_VALUE_TYPE_LIST) { - size_t len_t = sentry_value_get_length(image_list_t); - - jclass image_class = (*env)->FindClass(env, "io/sentry/protocol/DebugImage"); - image_list = (*env)->NewObjectArray(env, len_t, image_class, NULL); - - jmethodID image_addr_method = (*env)->GetMethodID(env, image_class, "setImageAddr", - "(Ljava/lang/String;)V"); - - jmethodID image_size_method = (*env)->GetMethodID(env, image_class, "setImageSize", - "(J)V"); - - jmethodID code_file_method = (*env)->GetMethodID(env, image_class, "setCodeFile", - "(Ljava/lang/String;)V"); - - jmethodID image_addr_ctor = (*env)->GetMethodID(env, image_class, "", - "()V"); - - jmethodID type_method = (*env)->GetMethodID(env, image_class, "setType", - "(Ljava/lang/String;)V"); - - jmethodID debug_id_method = (*env)->GetMethodID(env, image_class, "setDebugId", - "(Ljava/lang/String;)V"); - - jmethodID code_id_method = (*env)->GetMethodID(env, image_class, "setCodeId", - "(Ljava/lang/String;)V"); - - jmethodID debug_file_method = (*env)->GetMethodID(env, image_class, "setDebugFile", - "(Ljava/lang/String;)V"); - - for (size_t i = 0; i < len_t; i++) { - sentry_value_t image_t = sentry_value_get_by_index(image_list_t, i); - - if (!sentry_value_is_null(image_t)) { - jobject image = (*env)->NewObject(env, image_class, image_addr_ctor); - - sentry_value_t image_addr_t = sentry_value_get_by_key(image_t, "image_addr"); - if (!sentry_value_is_null(image_addr_t)) { - - const char *value_v = sentry_value_as_string(image_addr_t); - jstring value = (*env)->NewStringUTF(env, value_v); - - (*env)->CallVoidMethod(env, image, image_addr_method, value); - - // Local refs (eg NewStringUTF) are freed automatically when the native method - // returns, but if you're iterating a large array, it's recommended to release - // manually due to allocation limits (512) on Android < 8 or OOM. - // https://developer.android.com/training/articles/perf-jni.html#local-and-global-references - (*env)->DeleteLocalRef(env, value); - } - - sentry_value_t image_size_t = sentry_value_get_by_key(image_t, "image_size"); - if (!sentry_value_is_null(image_size_t)) { - - int32_t value_v = sentry_value_as_int32(image_size_t); - jlong value = (jlong) value_v; - - (*env)->CallVoidMethod(env, image, image_size_method, value); - } - - sentry_value_t code_file_t = sentry_value_get_by_key(image_t, "code_file"); - if (!sentry_value_is_null(code_file_t)) { - - const char *value_v = sentry_value_as_string(code_file_t); - jstring value = (*env)->NewStringUTF(env, value_v); - - (*env)->CallVoidMethod(env, image, code_file_method, value); - - (*env)->DeleteLocalRef(env, value); - } - - sentry_value_t code_type_t = sentry_value_get_by_key(image_t, "type"); - if (!sentry_value_is_null(code_type_t)) { - - const char *value_v = sentry_value_as_string(code_type_t); - jstring value = (*env)->NewStringUTF(env, value_v); - - (*env)->CallVoidMethod(env, image, type_method, value); - - (*env)->DeleteLocalRef(env, value); - } - - sentry_value_t debug_id_t = sentry_value_get_by_key(image_t, "debug_id"); - if (!sentry_value_is_null(code_type_t)) { - - const char *value_v = sentry_value_as_string(debug_id_t); - jstring value = (*env)->NewStringUTF(env, value_v); - - (*env)->CallVoidMethod(env, image, debug_id_method, value); - - (*env)->DeleteLocalRef(env, value); - } - - sentry_value_t code_id_t = sentry_value_get_by_key(image_t, "code_id"); - if (!sentry_value_is_null(code_id_t)) { - - const char *value_v = sentry_value_as_string(code_id_t); - jstring value = (*env)->NewStringUTF(env, value_v); - - (*env)->CallVoidMethod(env, image, code_id_method, value); - - (*env)->DeleteLocalRef(env, value); - } - - // not needed on Android, but keeping for forward compatibility - sentry_value_t debug_file_t = sentry_value_get_by_key(image_t, "debug_file"); - if (!sentry_value_is_null(debug_file_t)) { - - const char *value_v = sentry_value_as_string(debug_file_t); - jstring value = (*env)->NewStringUTF(env, value_v); - - (*env)->CallVoidMethod(env, image, debug_file_method, value); - - (*env)->DeleteLocalRef(env, value); - } - - (*env)->SetObjectArrayElement(env, image_list, i, image); - - (*env)->DeleteLocalRef(env, image); - } - } - - sentry_value_decref(image_list_t); - } - - return image_list; -} - -JNIEXPORT void JNICALL -Java_io_sentry_android_ndk_SentryNdk_shutdown(JNIEnv *env, jclass cls) { - sentry_close(); -} diff --git a/sentry-android-ndk/src/test/java/io/sentry/android/ndk/DebugImagesLoaderTest.kt b/sentry-android-ndk/src/test/java/io/sentry/android/ndk/DebugImagesLoaderTest.kt index db584c814f..927ce98c3b 100644 --- a/sentry-android-ndk/src/test/java/io/sentry/android/ndk/DebugImagesLoaderTest.kt +++ b/sentry-android-ndk/src/test/java/io/sentry/android/ndk/DebugImagesLoaderTest.kt @@ -1,11 +1,10 @@ package io.sentry.android.ndk import io.sentry.android.core.SentryAndroidOptions -import io.sentry.protocol.DebugImage +import io.sentry.ndk.NativeModuleListLoader import org.mockito.kotlin.mock import org.mockito.kotlin.verify import org.mockito.kotlin.whenever -import java.lang.RuntimeException import kotlin.test.Test import kotlin.test.assertNotNull import kotlin.test.assertNull @@ -38,7 +37,7 @@ class DebugImagesLoaderTest { whenever(fixture.nativeLoader.loadModuleList()).thenReturn(arrayOf()) assertNotNull(sut.loadDebugImages()) - whenever(fixture.nativeLoader.loadModuleList()).thenReturn(arrayOf(DebugImage())) + whenever(fixture.nativeLoader.loadModuleList()).thenReturn(arrayOf(io.sentry.ndk.DebugImage())) assertTrue(sut.loadDebugImages()!!.isEmpty()) } diff --git a/sentry-android-ndk/src/test/java/io/sentry/android/ndk/NdkScopeObserverTest.kt b/sentry-android-ndk/src/test/java/io/sentry/android/ndk/NdkScopeObserverTest.kt index 335a7679e1..ad523a883e 100644 --- a/sentry-android-ndk/src/test/java/io/sentry/android/ndk/NdkScopeObserverTest.kt +++ b/sentry-android-ndk/src/test/java/io/sentry/android/ndk/NdkScopeObserverTest.kt @@ -5,6 +5,7 @@ import io.sentry.DateUtils import io.sentry.JsonSerializer import io.sentry.SentryLevel import io.sentry.SentryOptions +import io.sentry.ndk.INativeScope import io.sentry.protocol.User import org.mockito.kotlin.eq import org.mockito.kotlin.mock diff --git a/sentry-android-okhttp/api/sentry-android-okhttp.api b/sentry-android-okhttp/api/sentry-android-okhttp.api deleted file mode 100644 index a1ad9114a2..0000000000 --- a/sentry-android-okhttp/api/sentry-android-okhttp.api +++ /dev/null @@ -1,62 +0,0 @@ -public final class io/sentry/android/okhttp/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 final class io/sentry/android/okhttp/SentryOkHttpEventListener : okhttp3/EventListener { - public fun ()V - public fun (Lio/sentry/IHub;Lkotlin/jvm/functions/Function1;)V - public synthetic fun (Lio/sentry/IHub;Lkotlin/jvm/functions/Function1;ILkotlin/jvm/internal/DefaultConstructorMarker;)V - public fun (Lio/sentry/IHub;Lokhttp3/EventListener$Factory;)V - public synthetic fun (Lio/sentry/IHub;Lokhttp3/EventListener$Factory;ILkotlin/jvm/internal/DefaultConstructorMarker;)V - public fun (Lio/sentry/IHub;Lokhttp3/EventListener;)V - public synthetic fun (Lio/sentry/IHub;Lokhttp3/EventListener;ILkotlin/jvm/internal/DefaultConstructorMarker;)V - public fun (Lokhttp3/EventListener$Factory;)V - public fun (Lokhttp3/EventListener;)V - public fun cacheConditionalHit (Lokhttp3/Call;Lokhttp3/Response;)V - public fun cacheHit (Lokhttp3/Call;Lokhttp3/Response;)V - public fun cacheMiss (Lokhttp3/Call;)V - public fun callEnd (Lokhttp3/Call;)V - public fun callFailed (Lokhttp3/Call;Ljava/io/IOException;)V - public fun callStart (Lokhttp3/Call;)V - public fun canceled (Lokhttp3/Call;)V - public fun connectEnd (Lokhttp3/Call;Ljava/net/InetSocketAddress;Ljava/net/Proxy;Lokhttp3/Protocol;)V - public fun connectFailed (Lokhttp3/Call;Ljava/net/InetSocketAddress;Ljava/net/Proxy;Lokhttp3/Protocol;Ljava/io/IOException;)V - public fun connectStart (Lokhttp3/Call;Ljava/net/InetSocketAddress;Ljava/net/Proxy;)V - public fun connectionAcquired (Lokhttp3/Call;Lokhttp3/Connection;)V - public fun connectionReleased (Lokhttp3/Call;Lokhttp3/Connection;)V - public fun dnsEnd (Lokhttp3/Call;Ljava/lang/String;Ljava/util/List;)V - public fun dnsStart (Lokhttp3/Call;Ljava/lang/String;)V - public fun proxySelectEnd (Lokhttp3/Call;Lokhttp3/HttpUrl;Ljava/util/List;)V - public fun proxySelectStart (Lokhttp3/Call;Lokhttp3/HttpUrl;)V - public fun requestBodyEnd (Lokhttp3/Call;J)V - public fun requestBodyStart (Lokhttp3/Call;)V - public fun requestFailed (Lokhttp3/Call;Ljava/io/IOException;)V - public fun requestHeadersEnd (Lokhttp3/Call;Lokhttp3/Request;)V - public fun requestHeadersStart (Lokhttp3/Call;)V - public fun responseBodyEnd (Lokhttp3/Call;J)V - public fun responseBodyStart (Lokhttp3/Call;)V - public fun responseFailed (Lokhttp3/Call;Ljava/io/IOException;)V - public fun responseHeadersEnd (Lokhttp3/Call;Lokhttp3/Response;)V - public fun responseHeadersStart (Lokhttp3/Call;)V - public fun satisfactionFailure (Lokhttp3/Call;Lokhttp3/Response;)V - public fun secureConnectEnd (Lokhttp3/Call;Lokhttp3/Handshake;)V - public fun secureConnectStart (Lokhttp3/Call;)V -} - -public final class io/sentry/android/okhttp/SentryOkHttpInterceptor : okhttp3/Interceptor { - public fun ()V - public fun (Lio/sentry/IHub;)V - public fun (Lio/sentry/IHub;Lio/sentry/android/okhttp/SentryOkHttpInterceptor$BeforeSpanCallback;ZLjava/util/List;Ljava/util/List;)V - public synthetic fun (Lio/sentry/IHub;Lio/sentry/android/okhttp/SentryOkHttpInterceptor$BeforeSpanCallback;ZLjava/util/List;Ljava/util/List;ILkotlin/jvm/internal/DefaultConstructorMarker;)V - public fun (Lio/sentry/android/okhttp/SentryOkHttpInterceptor$BeforeSpanCallback;)V - public fun intercept (Lokhttp3/Interceptor$Chain;)Lokhttp3/Response; -} - -public abstract interface class io/sentry/android/okhttp/SentryOkHttpInterceptor$BeforeSpanCallback { - public abstract fun execute (Lio/sentry/ISpan;Lokhttp3/Request;Lokhttp3/Response;)Lio/sentry/ISpan; -} - diff --git a/sentry-android-okhttp/proguard-rules.pro b/sentry-android-okhttp/proguard-rules.pro deleted file mode 100644 index 9407448f6d..0000000000 --- a/sentry-android-okhttp/proguard-rules.pro +++ /dev/null @@ -1,15 +0,0 @@ -##---------------Begin: proguard configuration for OkHttp ---------- - -# To ensure that stack traces is unambiguous -# https://developer.android.com/studio/build/shrink-code#decode-stack-trace --keepattributes LineNumberTable,SourceFile - -# https://square.github.io/okhttp/features/r8_proguard/ -# If you use OkHttp as a dependency in an Android project which uses R8 as a default compiler you -# don’t have to do anything. The specific rules are already bundled into the JAR which can -# be interpreted by R8 automatically. -# https://raw.githubusercontent.com/square/okhttp/master/okhttp/src/jvmMain/resources/META-INF/proguard/okhttp3.pro - -##---------------End: proguard configuration for OkHttp ---------- -# We keep this name to avoid the sentry-okttp module to call the old listener multiple times --keepnames class io.sentry.android.okhttp.SentryOkHttpEventListener diff --git a/sentry-android-okhttp/src/main/java/io/sentry/android/okhttp/SentryOkHttpEventListener.kt b/sentry-android-okhttp/src/main/java/io/sentry/android/okhttp/SentryOkHttpEventListener.kt deleted file mode 100644 index 7ca5313d8f..0000000000 --- a/sentry-android-okhttp/src/main/java/io/sentry/android/okhttp/SentryOkHttpEventListener.kt +++ /dev/null @@ -1,201 +0,0 @@ -package io.sentry.android.okhttp - -import io.sentry.HubAdapter -import io.sentry.IHub -import okhttp3.Call -import okhttp3.Connection -import okhttp3.EventListener -import okhttp3.Handshake -import okhttp3.HttpUrl -import okhttp3.Protocol -import okhttp3.Request -import okhttp3.Response -import java.io.IOException -import java.net.InetAddress -import java.net.InetSocketAddress -import java.net.Proxy - -/** - * Logs network performance event metrics to Sentry - * - * Usage - add instance of [SentryOkHttpEventListener] in [okhttp3.OkHttpClient.Builder.eventListener] - * - * ``` - * val client = OkHttpClient.Builder() - * .eventListener(SentryOkHttpEventListener()) - * .addInterceptor(SentryOkHttpInterceptor()) - * .build() - * ``` - * - * If you already use a [okhttp3.EventListener], you can pass it in the constructor. - * - * ``` - * val client = OkHttpClient.Builder() - * .eventListener(SentryOkHttpEventListener(myEventListener)) - * .addInterceptor(SentryOkHttpInterceptor()) - * .build() - * ``` - */ -@Deprecated( - "Use SentryOkHttpEventListener from sentry-okhttp instead", - ReplaceWith("SentryOkHttpEventListener", "io.sentry.okhttp.SentryOkHttpEventListener") -) -@Suppress("TooManyFunctions") -class SentryOkHttpEventListener( - hub: IHub = HubAdapter.getInstance(), - originalEventListenerCreator: ((call: Call) -> EventListener)? = null -) : EventListener() { - constructor() : this( - HubAdapter.getInstance(), - originalEventListenerCreator = null - ) - - constructor(originalEventListener: EventListener) : this( - HubAdapter.getInstance(), - originalEventListenerCreator = { originalEventListener } - ) - - constructor(originalEventListenerFactory: Factory) : this( - HubAdapter.getInstance(), - originalEventListenerCreator = { originalEventListenerFactory.create(it) } - ) - - constructor(hub: IHub = HubAdapter.getInstance(), originalEventListener: EventListener) : this( - hub, - originalEventListenerCreator = { originalEventListener } - ) - - constructor(hub: IHub = HubAdapter.getInstance(), originalEventListenerFactory: Factory) : this( - hub, - originalEventListenerCreator = { originalEventListenerFactory.create(it) } - ) - - private val delegate = io.sentry.okhttp.SentryOkHttpEventListener(hub, originalEventListenerCreator) - - override fun cacheConditionalHit(call: Call, cachedResponse: Response) { - delegate.cacheConditionalHit(call, cachedResponse) - } - - override fun cacheHit(call: Call, response: Response) { - delegate.cacheHit(call, response) - } - - override fun cacheMiss(call: Call) { - delegate.cacheMiss(call) - } - - override fun callEnd(call: Call) { - delegate.callEnd(call) - } - - override fun callFailed(call: Call, ioe: IOException) { - delegate.callFailed(call, ioe) - } - - override fun callStart(call: Call) { - delegate.callStart(call) - } - - override fun canceled(call: Call) { - delegate.canceled(call) - } - - override fun connectEnd( - call: Call, - inetSocketAddress: InetSocketAddress, - proxy: Proxy, - protocol: Protocol? - ) { - delegate.connectEnd(call, inetSocketAddress, proxy, protocol) - } - - override fun connectFailed( - call: Call, - inetSocketAddress: InetSocketAddress, - proxy: Proxy, - protocol: Protocol?, - ioe: IOException - ) { - delegate.connectFailed(call, inetSocketAddress, proxy, protocol, ioe) - } - - override fun connectStart(call: Call, inetSocketAddress: InetSocketAddress, proxy: Proxy) { - delegate.connectStart(call, inetSocketAddress, proxy) - } - - override fun connectionAcquired(call: Call, connection: Connection) { - delegate.connectionAcquired(call, connection) - } - - override fun connectionReleased(call: Call, connection: Connection) { - delegate.connectionReleased(call, connection) - } - - override fun dnsEnd(call: Call, domainName: String, inetAddressList: List) { - delegate.dnsEnd(call, domainName, inetAddressList) - } - - override fun dnsStart(call: Call, domainName: String) { - delegate.dnsStart(call, domainName) - } - - override fun proxySelectEnd(call: Call, url: HttpUrl, proxies: List) { - delegate.proxySelectEnd(call, url, proxies) - } - - override fun proxySelectStart(call: Call, url: HttpUrl) { - delegate.proxySelectStart(call, url) - } - - override fun requestBodyEnd(call: Call, byteCount: Long) { - delegate.requestBodyEnd(call, byteCount) - } - - override fun requestBodyStart(call: Call) { - delegate.requestBodyStart(call) - } - - override fun requestFailed(call: Call, ioe: IOException) { - delegate.requestFailed(call, ioe) - } - - override fun requestHeadersEnd(call: Call, request: Request) { - delegate.requestHeadersEnd(call, request) - } - - override fun requestHeadersStart(call: Call) { - delegate.requestHeadersStart(call) - } - - override fun responseBodyEnd(call: Call, byteCount: Long) { - delegate.responseBodyEnd(call, byteCount) - } - - override fun responseBodyStart(call: Call) { - delegate.responseBodyStart(call) - } - - override fun responseFailed(call: Call, ioe: IOException) { - delegate.responseFailed(call, ioe) - } - - override fun responseHeadersEnd(call: Call, response: Response) { - delegate.responseHeadersEnd(call, response) - } - - override fun responseHeadersStart(call: Call) { - delegate.responseHeadersStart(call) - } - - override fun satisfactionFailure(call: Call, response: Response) { - delegate.satisfactionFailure(call, response) - } - - override fun secureConnectEnd(call: Call, handshake: Handshake?) { - delegate.secureConnectEnd(call, handshake) - } - - override fun secureConnectStart(call: Call) { - delegate.secureConnectStart(call) - } -} diff --git a/sentry-android-okhttp/src/main/java/io/sentry/android/okhttp/SentryOkHttpInterceptor.kt b/sentry-android-okhttp/src/main/java/io/sentry/android/okhttp/SentryOkHttpInterceptor.kt deleted file mode 100644 index 28c242c82c..0000000000 --- a/sentry-android-okhttp/src/main/java/io/sentry/android/okhttp/SentryOkHttpInterceptor.kt +++ /dev/null @@ -1,79 +0,0 @@ -package io.sentry.android.okhttp - -import io.sentry.HttpStatusCodeRange -import io.sentry.HubAdapter -import io.sentry.IHub -import io.sentry.ISpan -import io.sentry.SentryIntegrationPackageStorage -import io.sentry.SentryOptions.DEFAULT_PROPAGATION_TARGETS -import io.sentry.android.okhttp.SentryOkHttpInterceptor.BeforeSpanCallback -import io.sentry.util.IntegrationUtils.addIntegrationToSdkVersion -import okhttp3.Interceptor -import okhttp3.Request -import okhttp3.Response - -/** - * The Sentry's [SentryOkHttpInterceptor], it will automatically add a breadcrumb and start a span - * out of the active span bound to the scope for each HTTP Request. - * If [captureFailedRequests] is enabled, the SDK will capture HTTP Client errors as well. - * - * @param hub The [IHub], internal and only used for testing. - * @param beforeSpan The [ISpan] can be customized or dropped with the [BeforeSpanCallback]. - * @param captureFailedRequests The SDK will only capture HTTP Client errors if it is enabled, - * Defaults to true. - * @param failedRequestStatusCodes The SDK will only capture HTTP Client errors if the HTTP Response - * status code is within the defined ranges. - * @param failedRequestTargets The SDK will only capture HTTP Client errors if the HTTP Request URL - * is a match for any of the defined targets. - */ -@Deprecated( - "Use SentryOkHttpInterceptor from sentry-okhttp instead", - ReplaceWith("SentryOkHttpInterceptor", "io.sentry.okhttp.SentryOkHttpInterceptor") -) -class SentryOkHttpInterceptor( - private val hub: IHub = HubAdapter.getInstance(), - private val beforeSpan: BeforeSpanCallback? = null, - private val captureFailedRequests: Boolean = true, - private val failedRequestStatusCodes: List = listOf( - HttpStatusCodeRange(HttpStatusCodeRange.DEFAULT_MIN, HttpStatusCodeRange.DEFAULT_MAX) - ), - private val failedRequestTargets: List = listOf(DEFAULT_PROPAGATION_TARGETS) -) : Interceptor by io.sentry.okhttp.SentryOkHttpInterceptor( - hub, - { span, request, response -> - beforeSpan ?: return@SentryOkHttpInterceptor span - beforeSpan.execute(span, request, response) - }, - captureFailedRequests, - failedRequestStatusCodes, - failedRequestTargets -) { - - constructor() : this(HubAdapter.getInstance()) - constructor(hub: IHub) : this(hub, null) - constructor(beforeSpan: BeforeSpanCallback) : this(HubAdapter.getInstance(), beforeSpan) - - init { - addIntegrationToSdkVersion(javaClass) - SentryIntegrationPackageStorage.getInstance() - .addPackage("maven:io.sentry:sentry-android-okhttp", BuildConfig.VERSION_NAME) - } - - /** - * The BeforeSpan callback - */ - @Deprecated( - "Use BeforeSpanCallback from sentry-okhttp instead", - ReplaceWith("BeforeSpanCallback", "io.sentry.okhttp.SentryOkHttpInterceptor.BeforeSpanCallback") - ) - fun interface BeforeSpanCallback { - /** - * Mutates or drops span before being added - * - * @param span the span to mutate or drop - * @param request the HTTP request executed by okHttp - * @param response the HTTP response received by okHttp - */ - fun execute(span: ISpan, request: Request, response: Response?): ISpan? - } -} diff --git a/sentry-android-okhttp/src/main/res/values/public.xml b/sentry-android-okhttp/src/main/res/values/public.xml deleted file mode 100644 index 379be515be..0000000000 --- a/sentry-android-okhttp/src/main/res/values/public.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/sentry-android-okhttp/src/test/java/io/sentry/android/okhttp/SentryOkHttpEventListenerTest.kt b/sentry-android-okhttp/src/test/java/io/sentry/android/okhttp/SentryOkHttpEventListenerTest.kt deleted file mode 100644 index 9ed110ef7e..0000000000 --- a/sentry-android-okhttp/src/test/java/io/sentry/android/okhttp/SentryOkHttpEventListenerTest.kt +++ /dev/null @@ -1,70 +0,0 @@ -package io.sentry.android.okhttp - -import io.sentry.IHub -import io.sentry.SentryOptions -import io.sentry.SentryTracer -import io.sentry.TransactionContext -import okhttp3.EventListener -import okhttp3.OkHttpClient -import okhttp3.Request -import okhttp3.mockwebserver.MockResponse -import okhttp3.mockwebserver.MockWebServer -import okhttp3.mockwebserver.SocketPolicy -import org.mockito.kotlin.mock -import org.mockito.kotlin.whenever -import kotlin.test.Test -import kotlin.test.assertEquals - -@SuppressWarnings("Deprecated") -class SentryOkHttpEventListenerTest { - - class Fixture { - val hub = mock() - val server = MockWebServer() - lateinit var sentryTracer: SentryTracer - - @SuppressWarnings("LongParameterList") - fun getSut( - eventListener: EventListener? = null - ): OkHttpClient { - val options = SentryOptions().apply { - dsn = "https://key@sentry.io/proj" - } - whenever(hub.options).thenReturn(options) - - sentryTracer = SentryTracer(TransactionContext("name", "op"), hub) - whenever(hub.span).thenReturn(sentryTracer) - server.enqueue( - MockResponse() - .setBody("responseBody") - .setSocketPolicy(SocketPolicy.KEEP_OPEN) - .setResponseCode(200) - ) - - val builder = OkHttpClient.Builder().addInterceptor(SentryOkHttpInterceptor(hub)) - val sentryOkHttpEventListener = when { - eventListener != null -> SentryOkHttpEventListener(hub, eventListener) - else -> SentryOkHttpEventListener(hub) - } - return builder.eventListener(sentryOkHttpEventListener).build() - } - } - - private val fixture = Fixture() - - private fun getRequest(url: String = "/hello"): Request { - return Request.Builder() - .addHeader("myHeader", "myValue") - .get() - .url(fixture.server.url(url)) - .build() - } - - @Test - fun `when there are multiple SentryOkHttpEventListeners, they don't duplicate spans`() { - val sut = fixture.getSut(eventListener = SentryOkHttpEventListener(fixture.hub)) - val call = sut.newCall(getRequest()) - call.execute().close() - assertEquals(8, fixture.sentryTracer.children.size) - } -} 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..888cf6a172 --- /dev/null +++ b/sentry-android-replay/api/sentry-android-replay.api @@ -0,0 +1,272 @@ +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 static final field $stable I + public fun ()V + public fun convert (Lio/sentry/Breadcrumb;)Lio/sentry/rrweb/RRWebEvent; +} + +public final class io/sentry/android/replay/GeneratedVideo { + public static final field $stable I + public fun (Ljava/io/File;IJ)V + public final fun component1 ()Ljava/io/File; + public final fun component2 ()I + 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 final class io/sentry/android/replay/ModifierExtensionsKt { + public static final fun sentryReplayMask (Landroidx/compose/ui/Modifier;)Landroidx/compose/ui/Modifier; + public static final fun sentryReplayUnmask (Landroidx/compose/ui/Modifier;)Landroidx/compose/ui/Modifier; +} + +public abstract interface class io/sentry/android/replay/Recorder : java/io/Closeable { + 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 static final field $stable I + public static final field Companion Lio/sentry/android/replay/ReplayCache$Companion; + public fun (Lio/sentry/SentryOptions;Lio/sentry/protocol/SentryId;Lio/sentry/android/replay/ScreenshotRecorderConfig;)V + public final fun addFrame (Ljava/io/File;JLjava/lang/String;)V + public static synthetic fun addFrame$default (Lio/sentry/android/replay/ReplayCache;Ljava/io/File;JLjava/lang/String;ILjava/lang/Object;)V + public fun close ()V + public final fun createVideoOf (JJIIILjava/io/File;)Lio/sentry/android/replay/GeneratedVideo; + public static synthetic fun createVideoOf$default (Lio/sentry/android/replay/ReplayCache;JJIIILjava/io/File;ILjava/lang/Object;)Lio/sentry/android/replay/GeneratedVideo; + public final fun persistSegmentValues (Ljava/lang/String;Ljava/lang/String;)V + public final fun rotate (J)Ljava/lang/String; +} + +public final class io/sentry/android/replay/ReplayCache$Companion { + public final fun makeReplayCacheDir (Lio/sentry/SentryOptions;Lio/sentry/protocol/SentryId;)Ljava/io/File; +} + +public final class io/sentry/android/replay/ReplayIntegration : android/content/ComponentCallbacks, io/sentry/Integration, io/sentry/ReplayController, io/sentry/android/replay/ScreenshotRecorderCallback, io/sentry/android/replay/gestures/TouchRecorderCallback, java/io/Closeable { + public static final field $stable I + public fun (Landroid/content/Context;Lio/sentry/transport/ICurrentDateProvider;)V + public fun (Landroid/content/Context;Lio/sentry/transport/ICurrentDateProvider;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;)V + public synthetic fun (Landroid/content/Context;Lio/sentry/transport/ICurrentDateProvider;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun captureReplay (Ljava/lang/Boolean;)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/IScopes;Lio/sentry/SentryOptions;)V + public fun resume ()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 $stable I + 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 final class io/sentry/android/replay/SentryReplayModifiers { + public static final field $stable I + public static final field INSTANCE Lio/sentry/android/replay/SentryReplayModifiers; + public final fun getSentryPrivacy ()Landroidx/compose/ui/semantics/SemanticsPropertyKey; +} + +public final class io/sentry/android/replay/SessionReplayOptionsKt { + public static final fun getMaskAllImages (Lio/sentry/SentryReplayOptions;)Z + public static final fun getMaskAllText (Lio/sentry/SentryReplayOptions;)Z + public static final fun setMaskAllImages (Lio/sentry/SentryReplayOptions;Z)V + public static final fun setMaskAllText (Lio/sentry/SentryReplayOptions;Z)V +} + +public final class io/sentry/android/replay/ViewExtensionsKt { + public static final fun sentryReplayMask (Landroid/view/View;)V + public static final fun sentryReplayUnmask (Landroid/view/View;)V +} + +public final class io/sentry/android/replay/gestures/GestureRecorder : io/sentry/android/replay/OnRootViewsChangedListener { + public static final field $stable I + public fun (Lio/sentry/SentryOptions;Lio/sentry/android/replay/gestures/TouchRecorderCallback;)V + public fun onRootViewsChanged (Landroid/view/View;Z)V + public final fun stop ()V +} + +public final class io/sentry/android/replay/gestures/ReplayGestureConverter { + public static final field $stable I + public fun (Lio/sentry/transport/ICurrentDateProvider;)V + public final fun convert (Landroid/view/MotionEvent;Lio/sentry/android/replay/ScreenshotRecorderConfig;)Ljava/util/List; +} + +public abstract interface class io/sentry/android/replay/gestures/TouchRecorderCallback { + public abstract fun onTouchEvent (Landroid/view/MotionEvent;)V +} + +public final class io/sentry/android/replay/util/AndroidTextLayout : io/sentry/android/replay/util/TextLayout { + public static final field $stable I + public fun (Landroid/text/Layout;)V + public fun getDominantTextColor ()Ljava/lang/Integer; + public fun getEllipsisCount (I)I + public fun getLineBottom (I)I + public fun getLineCount ()I + public fun getLineStart (I)I + public fun getLineTop (I)I + public fun getLineVisibleEnd (I)I + public fun getPrimaryHorizontal (II)F +} + +public class io/sentry/android/replay/util/FixedWindowCallback : android/view/Window$Callback { + public final field delegate Landroid/view/Window$Callback; + public fun (Landroid/view/Window$Callback;)V + 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/util/TextLayout { + public abstract fun getDominantTextColor ()Ljava/lang/Integer; + public abstract fun getEllipsisCount (I)I + public abstract fun getLineBottom (I)I + public abstract fun getLineCount ()I + public abstract fun getLineStart (I)I + public abstract fun getLineTop (I)I + public abstract fun getLineVisibleEnd (I)I + public abstract fun getPrimaryHorizontal (II)F +} + +public abstract interface class io/sentry/android/replay/video/SimpleFrameMuxer { + public abstract fun getVideoTime ()J + public abstract fun isStarted ()Z + 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 static final field $stable I + 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 $stable I + public static final field Companion Lio/sentry/android/replay/viewhierarchy/ViewHierarchyNode$Companion; + public synthetic fun (FFIIFILio/sentry/android/replay/viewhierarchy/ViewHierarchyNode;ZZZLandroid/graphics/Rect;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public synthetic fun (FFIIFILio/sentry/android/replay/viewhierarchy/ViewHierarchyNode;ZZZLandroid/graphics/Rect;Lkotlin/jvm/internal/DefaultConstructorMarker;)V + 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 getShouldMask ()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 setImportantForCaptureToAncestors (Z)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 static final field $stable I + public fun (FFIIFILio/sentry/android/replay/viewhierarchy/ViewHierarchyNode;ZZZLandroid/graphics/Rect;)V + public synthetic fun (FFIIFILio/sentry/android/replay/viewhierarchy/ViewHierarchyNode;ZZZLandroid/graphics/Rect;ILkotlin/jvm/internal/DefaultConstructorMarker;)V +} + +public final class io/sentry/android/replay/viewhierarchy/ViewHierarchyNode$ImageViewHierarchyNode : io/sentry/android/replay/viewhierarchy/ViewHierarchyNode { + public static final field $stable I + public fun (FFIIFILio/sentry/android/replay/viewhierarchy/ViewHierarchyNode;ZZZLandroid/graphics/Rect;)V + public synthetic fun (FFIIFILio/sentry/android/replay/viewhierarchy/ViewHierarchyNode;ZZZLandroid/graphics/Rect;ILkotlin/jvm/internal/DefaultConstructorMarker;)V +} + +public final class io/sentry/android/replay/viewhierarchy/ViewHierarchyNode$TextViewHierarchyNode : io/sentry/android/replay/viewhierarchy/ViewHierarchyNode { + public static final field $stable I + public fun (Lio/sentry/android/replay/util/TextLayout;Ljava/lang/Integer;IIFFIIFILio/sentry/android/replay/viewhierarchy/ViewHierarchyNode;ZZZLandroid/graphics/Rect;)V + public synthetic fun (Lio/sentry/android/replay/util/TextLayout;Ljava/lang/Integer;IIFFIIFILio/sentry/android/replay/viewhierarchy/ViewHierarchyNode;ZZZLandroid/graphics/Rect;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun getDominantColor ()Ljava/lang/Integer; + public final fun getLayout ()Lio/sentry/android/replay/util/TextLayout; + public final fun getPaddingLeft ()I + public final fun getPaddingTop ()I +} + diff --git a/sentry-android-okhttp/build.gradle.kts b/sentry-android-replay/build.gradle.kts similarity index 61% rename from sentry-android-okhttp/build.gradle.kts rename to sentry-android-replay/build.gradle.kts index 67a4729e56..15713bb6f4 100644 --- a/sentry-android-okhttp/build.gradle.kts +++ b/sentry-android-replay/build.gradle.kts @@ -1,5 +1,6 @@ import io.gitlab.arturbosch.detekt.Detekt import org.jetbrains.kotlin.config.KotlinCompilerVersion +import org.jetbrains.kotlin.gradle.tasks.KotlinCompilationTask plugins { id("com.android.library") @@ -7,21 +8,33 @@ plugins { jacoco id(Config.QualityPlugins.jacocoAndroid) id(Config.QualityPlugins.gradleVersions) - id(Config.QualityPlugins.detektPlugin) + // TODO: enable it later +// id(Config.QualityPlugins.detektPlugin) } android { compileSdk = Config.Android.compileSdkVersion - namespace = "io.sentry.android.okhttp" + namespace = "io.sentry.android.replay" defaultConfig { targetSdk = Config.Android.targetSdkVersion - minSdk = Config.Android.minSdkVersionOkHttp + minSdk = Config.Android.minSdkVersionReplay + + testInstrumentationRunner = Config.TestLibs.androidJUnitRunner // for AGP 4.1 buildConfigField("String", "VERSION_NAME", "\"${project.version}\"") } + buildFeatures { + compose = true + } + + composeOptions { + kotlinCompilerExtensionVersion = Config.androidComposeCompilerVersion + useLiveLiterals = false + } + buildTypes { getByName("debug") getByName("release") { @@ -63,23 +76,33 @@ kotlin { dependencies { api(projects.sentry) - api(projects.sentryOkhttp) - - compileOnly(Config.Libs.okhttp) + compileOnly(Config.Libs.composeUiReplay) implementation(kotlin(Config.kotlinStdLib, KotlinCompilerVersion.VERSION)) // tests testImplementation(projects.sentryTestSupport) - testImplementation(Config.Libs.okhttp) + testImplementation(projects.sentryAndroidCore) + 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.mockWebserver) + testImplementation(Config.TestLibs.awaitility) + testImplementation(Config.Libs.composeActivity) + testImplementation(Config.Libs.composeUi) + testImplementation(Config.Libs.composeCoil) + testImplementation(Config.Libs.composeFoundation) + testImplementation(Config.Libs.composeFoundationLayout) + testImplementation(Config.Libs.composeMaterial) } tasks.withType { // Target version of the generated JVM bytecode. It is used for type resolution. jvmTarget = JavaVersion.VERSION_1_8.toString() } + +tasks.withType>().configureEach { + compilerOptions.freeCompilerArgs.add("-opt-in=androidx.compose.ui.ExperimentalComposeUiApi") +} diff --git a/sentry-android-replay/proguard-rules.pro b/sentry-android-replay/proguard-rules.pro new file mode 100644 index 0000000000..378c0964f8 --- /dev/null +++ b/sentry-android-replay/proguard-rules.pro @@ -0,0 +1,28 @@ +# Uncomment this to preserve the line number information for +# debugging stack traces. +-keepattributes SourceFile,LineNumberTable + +# Rules to detect Images/Icons and mask them +-dontwarn androidx.compose.ui.graphics.painter.Painter +-keepnames class * extends androidx.compose.ui.graphics.painter.Painter +-keepclasseswithmembernames class * { + androidx.compose.ui.graphics.painter.Painter painter; +} +# Rules to detect Text colors and if they have Modifier.fillMaxWidth to later mask them +-dontwarn androidx.compose.ui.graphics.ColorProducer +-dontwarn androidx.compose.foundation.layout.FillElement +-keepnames class androidx.compose.foundation.layout.FillElement +-keepclasseswithmembernames class * { + androidx.compose.ui.graphics.ColorProducer color; +} +# Rules to detect a compose view to parse its hierarchy +-dontwarn androidx.compose.ui.platform.AndroidComposeView +-keepnames class androidx.compose.ui.platform.AndroidComposeView +# Rules to detect a media player view to later mask it +-dontwarn androidx.media3.ui.PlayerView +-keepnames class androidx.media3.ui.PlayerView +# Rules to detect a ExoPlayer view to later mask it +-dontwarn com.google.android.exoplayer2.ui.PlayerView +-keepnames class com.google.android.exoplayer2.ui.PlayerView +-dontwarn com.google.android.exoplayer2.ui.StyledPlayerView +-keepnames class com.google.android.exoplayer2.ui.StyledPlayerView diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/DefaultReplayBreadcrumbConverter.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/DefaultReplayBreadcrumbConverter.kt new file mode 100644 index 0000000000..c95b72088a --- /dev/null +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/DefaultReplayBreadcrumbConverter.kt @@ -0,0 +1,166 @@ +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 + val httpStartTimestamp = breadcrumb.data[SpanDataConvention.HTTP_START_TIMESTAMP] + val httpEndTimestamp = breadcrumb.data[SpanDataConvention.HTTP_END_TIMESTAMP] + return RRWebSpanEvent().apply { + timestamp = breadcrumb.timestamp.time + op = "resource.http" + description = breadcrumb.data["url"] as String + // can be double if it was serialized to disk + startTimestamp = if (httpStartTimestamp is Double) { + httpStartTimestamp / 1000.0 + } else { + (httpStartTimestamp as Long) / 1000.0 + } + endTimestamp = if (httpEndTimestamp is Double) { + httpEndTimestamp / 1000.0 + } else { + (httpEndTimestamp as Long) / 1000.0 + } + + val breadcrumbData = mutableMapOf() + for ((key, value) in breadcrumb.data) { + 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/ModifierExtensions.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ModifierExtensions.kt new file mode 100644 index 0000000000..b5d5222388 --- /dev/null +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ModifierExtensions.kt @@ -0,0 +1,29 @@ +package io.sentry.android.replay + +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.SemanticsPropertyKey +import androidx.compose.ui.semantics.semantics +import io.sentry.android.replay.SentryReplayModifiers.SentryPrivacy + +public object SentryReplayModifiers { + val SentryPrivacy = SemanticsPropertyKey( + name = "SentryPrivacy", + mergePolicy = { parentValue, _ -> parentValue } + ) +} + +public fun Modifier.sentryReplayMask(): Modifier { + return semantics( + properties = { + this[SentryPrivacy] = "mask" + } + ) +} + +public fun Modifier.sentryReplayUnmask(): Modifier { + return semantics( + properties = { + this[SentryPrivacy] = "unmask" + } + ) +} 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..dcbdd84360 --- /dev/null +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt @@ -0,0 +1,446 @@ +package io.sentry.android.replay + +import android.graphics.Bitmap +import android.graphics.Bitmap.CompressFormat.JPEG +import android.graphics.BitmapFactory +import io.sentry.DateUtils +import io.sentry.ReplayRecording +import io.sentry.SentryLevel.DEBUG +import io.sentry.SentryLevel.ERROR +import io.sentry.SentryLevel.WARNING +import io.sentry.SentryOptions +import io.sentry.SentryReplayEvent.ReplayType +import io.sentry.SentryReplayEvent.ReplayType.SESSION +import io.sentry.android.replay.video.MuxerConfig +import io.sentry.android.replay.video.SimpleVideoEncoder +import io.sentry.protocol.SentryId +import io.sentry.rrweb.RRWebEvent +import io.sentry.util.AutoClosableReentrantLock +import io.sentry.util.FileUtils +import java.io.Closeable +import java.io.File +import java.io.StringReader +import java.util.Date +import java.util.LinkedList +import java.util.concurrent.atomic.AtomicBoolean + +/** + * A basic in-memory and disk cache for Session Replay frames. Frames are stored in order under the + * [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( + private val options: SentryOptions, + private val replayId: SentryId, + private val recorderConfig: ScreenshotRecorderConfig +) : Closeable { + + private val isClosed = AtomicBoolean(false) + private val encoderLock = AutoClosableReentrantLock() + private val lock = AutoClosableReentrantLock() + private var encoder: SimpleVideoEncoder? = null + + internal val replayCacheDir: File? by lazy { + makeReplayCacheDir(options, replayId) + } + + // TODO: maybe account for multi-threaded access + internal val frames = mutableListOf() + + private val ongoingSegment = LinkedHashMap() + private val ongoingSegmentFile: File? by lazy { + if (replayCacheDir == null) { + return@lazy null + } + + val file = File(replayCacheDir, ONGOING_SEGMENT) + if (!file.exists()) { + file.createNewFile() + } + file + } + + /** + * Stores the current frame screenshot to in-memory cache as well as disk with [frameTimestamp] + * as filename. Uses [Bitmap.CompressFormat.JPEG] format with quality 80. The frames are stored + * 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, screen: String? = null) { + if (replayCacheDir == null || bitmap.isRecycled) { + return + } + replayCacheDir?.mkdirs() + + val screenshot = File(replayCacheDir, "$frameTimestamp.jpg").also { + it.createNewFile() + } + screenshot.outputStream().use { + bitmap.compress(JPEG, 80, it) + it.flush() + } + + addFrame(screenshot, frameTimestamp, screen) + } + + /** + * 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, screen: String? = null) { + val frame = ReplayFrame(screenshot, frameTimestamp, screen) + 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 (videoFile.exists() && videoFile.length() > 0) { + videoFile.delete() + } + 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 = encoderLock.acquire().use { + SimpleVideoEncoder( + options, + MuxerConfig( + file = videoFile, + recordingHeight = height, + recordingWidth = width, + frameRate = recorderConfig.frameRate, + bitRate = recorderConfig.bitRate + ) + ).also { it.start() } + } + + val step = 1000 / recorderConfig.frameRate.toLong() + var frameCount = 0 + 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 + encoderLock.acquire().use { + 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) + encoderLock.acquire().use { + 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 + * @return the first screen in the rotated buffer, if any + */ + fun rotate(until: Long): String? { + var screen: String? = null + frames.removeAll { + if (it.timestamp < until) { + deleteFile(it.screenshot) + return@removeAll true + } else if (screen == null) { + screen = it.screen + } + return@removeAll false + } + return screen + } + + override fun close() { + encoderLock.acquire().use { + encoder?.release() + encoder = null + } + isClosed.set(true) + } + + // TODO: it's awful, choose a better serialization format + fun persistSegmentValues(key: String, value: String?) { + lock.acquire().use { + if (isClosed.get()) { + return + } + if (ongoingSegment.isEmpty()) { + ongoingSegmentFile?.useLines { lines -> + lines.associateTo(ongoingSegment) { + val (k, v) = it.split("=", limit = 2) + k to v + } + } + } + if (value == null) { + ongoingSegment.remove(key) + } else { + ongoingSegment[key] = value + } + ongoingSegmentFile?.writeText(ongoingSegment.entries.joinToString("\n") { (k, v) -> "$k=$v" }) + } + } + + companion object { + internal const val ONGOING_SEGMENT = ".ongoing_segment" + + internal const val SEGMENT_KEY_HEIGHT = "config.height" + internal const val SEGMENT_KEY_WIDTH = "config.width" + internal const val SEGMENT_KEY_FRAME_RATE = "config.frame-rate" + internal const val SEGMENT_KEY_BIT_RATE = "config.bit-rate" + internal const val SEGMENT_KEY_TIMESTAMP = "segment.timestamp" + internal const val SEGMENT_KEY_REPLAY_ID = "replay.id" + internal const val SEGMENT_KEY_REPLAY_TYPE = "replay.type" + internal const val SEGMENT_KEY_REPLAY_SCREEN_AT_START = "replay.screen-at-start" + internal const val SEGMENT_KEY_REPLAY_RECORDING = "replay.recording" + internal const val SEGMENT_KEY_ID = "segment.id" + + fun makeReplayCacheDir(options: SentryOptions, replayId: SentryId): File? { + return if (options.cacheDirPath.isNullOrEmpty()) { + options.logger.log( + WARNING, + "SentryOptions.cacheDirPath is not set, session replay is no-op" + ) + null + } else { + File(options.cacheDirPath!!, "replay_$replayId").also { it.mkdirs() } + } + } + + internal fun fromDisk(options: SentryOptions, replayId: SentryId, replayCacheProvider: ((replayId: SentryId, recorderConfig: ScreenshotRecorderConfig) -> ReplayCache)? = null): LastSegmentData? { + val replayCacheDir = makeReplayCacheDir(options, replayId) + val lastSegmentFile = File(replayCacheDir, ONGOING_SEGMENT) + if (!lastSegmentFile.exists()) { + options.logger.log(DEBUG, "No ongoing segment found for replay: %s", replayId) + FileUtils.deleteRecursively(replayCacheDir) + return null + } + + val lastSegment = LinkedHashMap() + lastSegmentFile.useLines { lines -> + lines.associateTo(lastSegment) { + val (k, v) = it.split("=", limit = 2) + k to v + } + } + + val height = lastSegment[SEGMENT_KEY_HEIGHT]?.toIntOrNull() + val width = lastSegment[SEGMENT_KEY_WIDTH]?.toIntOrNull() + val frameRate = lastSegment[SEGMENT_KEY_FRAME_RATE]?.toIntOrNull() + val bitRate = lastSegment[SEGMENT_KEY_BIT_RATE]?.toIntOrNull() + val segmentId = lastSegment[SEGMENT_KEY_ID]?.toIntOrNull() + val segmentTimestamp = try { + DateUtils.getDateTime(lastSegment[SEGMENT_KEY_TIMESTAMP].orEmpty()) + } catch (e: Throwable) { + null + } + val replayType = try { + ReplayType.valueOf(lastSegment[SEGMENT_KEY_REPLAY_TYPE].orEmpty()) + } catch (e: Throwable) { + null + } + if (height == null || width == null || frameRate == null || bitRate == null || + (segmentId == null || segmentId == -1) || segmentTimestamp == null || replayType == null + ) { + options.logger.log( + DEBUG, + "Incorrect segment values found for replay: %s, deleting the replay", + replayId + ) + FileUtils.deleteRecursively(replayCacheDir) + return null + } + + val recorderConfig = ScreenshotRecorderConfig( + recordingHeight = height, + recordingWidth = width, + frameRate = frameRate, + bitRate = bitRate, + // these are not used for already captured frames, so we just hardcode them + scaleFactorX = 1.0f, + scaleFactorY = 1.0f + ) + + val cache = replayCacheProvider?.invoke(replayId, recorderConfig) ?: ReplayCache(options, replayId, recorderConfig) + cache.replayCacheDir?.listFiles { dir, name -> + if (name.endsWith(".jpg")) { + val file = File(dir, name) + val timestamp = file.nameWithoutExtension.toLongOrNull() + if (timestamp != null) { + cache.addFrame(file, timestamp) + } + } + false + } + + if (cache.frames.isEmpty()) { + options.logger.log( + DEBUG, + "No frames found for replay: %s, deleting the replay", + replayId + ) + FileUtils.deleteRecursively(replayCacheDir) + return null + } + + cache.frames.sortBy { it.timestamp } + // TODO: this should be removed when we start sending buffered segments on next launch + val normalizedSegmentId = if (replayType == SESSION) segmentId else 0 + val normalizedTimestamp = if (replayType == SESSION) { + segmentTimestamp + } else { + // in buffer mode we have to set the timestamp of the first frame as the actual start + DateUtils.getDateTime(cache.frames.first().timestamp) + } + + // add one frame to include breadcrumbs/events happened after the frame was captured + val duration = cache.frames.last().timestamp - normalizedTimestamp.time + (1000 / frameRate) + + val events = lastSegment[SEGMENT_KEY_REPLAY_RECORDING]?.let { + val reader = StringReader(it) + val recording = options.serializer.deserialize(reader, ReplayRecording::class.java) + if (recording?.payload != null) { + LinkedList(recording.payload!!) + } else { + null + } + } ?: emptyList() + + return LastSegmentData( + recorderConfig = recorderConfig, + cache = cache, + timestamp = normalizedTimestamp, + id = normalizedSegmentId, + duration = duration, + replayType = replayType, + screenAtStart = lastSegment[SEGMENT_KEY_REPLAY_SCREEN_AT_START], + events = events.sortedBy { it.timestamp } + ) + } + } +} + +internal data class LastSegmentData( + val recorderConfig: ScreenshotRecorderConfig, + val cache: ReplayCache, + val timestamp: Date, + val id: Int, + val duration: Long, + val replayType: ReplayType, + val screenAtStart: String?, + val events: List +) + +internal data class ReplayFrame( + val screenshot: File, + val timestamp: Long, + val screen: String? = null +) + +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..a449d3843a --- /dev/null +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt @@ -0,0 +1,344 @@ +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.Breadcrumb +import io.sentry.IScopes +import io.sentry.Integration +import io.sentry.NoOpReplayBreadcrumbConverter +import io.sentry.ReplayBreadcrumbConverter +import io.sentry.ReplayController +import io.sentry.SentryIntegrationPackageStorage +import io.sentry.SentryLevel.DEBUG +import io.sentry.SentryLevel.INFO +import io.sentry.SentryOptions +import io.sentry.android.replay.capture.BufferCaptureStrategy +import io.sentry.android.replay.capture.CaptureStrategy +import io.sentry.android.replay.capture.CaptureStrategy.ReplaySegment +import io.sentry.android.replay.capture.SessionCaptureStrategy +import io.sentry.android.replay.gestures.GestureRecorder +import io.sentry.android.replay.gestures.TouchRecorderCallback +import io.sentry.android.replay.util.MainLooperHandler +import io.sentry.android.replay.util.appContext +import io.sentry.android.replay.util.sample +import io.sentry.android.replay.util.submitSafely +import io.sentry.cache.PersistingScopeObserver +import io.sentry.cache.PersistingScopeObserver.BREADCRUMBS_FILENAME +import io.sentry.cache.PersistingScopeObserver.REPLAY_FILENAME +import io.sentry.hints.Backfillable +import io.sentry.protocol.SentryId +import io.sentry.transport.ICurrentDateProvider +import io.sentry.util.FileUtils +import io.sentry.util.HintUtils +import io.sentry.util.IntegrationUtils.addIntegrationToSdkVersion +import java.io.Closeable +import java.io.File +import java.security.SecureRandom +import java.util.LinkedList +import java.util.concurrent.atomic.AtomicBoolean +import kotlin.LazyThreadSafetyMode.NONE + +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.appContext(), + 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, + gestureRecorderProvider: (() -> GestureRecorder)? = null + ) : this(context.appContext(), dateProvider, recorderProvider, recorderConfigProvider, replayCacheProvider) { + this.replayCaptureStrategyProvider = replayCaptureStrategyProvider + this.mainLooperHandler = mainLooperHandler ?: MainLooperHandler() + this.gestureRecorderProvider = gestureRecorderProvider + } + + private lateinit var options: SentryOptions + private var scopes: IScopes? = null + private var recorder: Recorder? = null + private var gestureRecorder: GestureRecorder? = null + private val random by lazy { SecureRandom() } + private val rootViewsSpy by lazy(NONE) { RootViewsSpy.install() } + + // TODO: probably not everything has to be thread-safe here + internal val isEnabled = AtomicBoolean(false) + 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 var gestureRecorderProvider: (() -> GestureRecorder)? = null + + private lateinit var recorderConfig: ScreenshotRecorderConfig + + override fun register(scopes: IScopes, 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.scopes = scopes + recorder = recorderProvider?.invoke() ?: WindowRecorder(options, this, mainLooperHandler) + gestureRecorder = gestureRecorderProvider?.invoke() ?: GestureRecorder(options, this) + 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) + + finalizePreviousReplay() + } + + 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 onErrorSampleRate is not specified") + return + } + + recorderConfig = recorderConfigProvider?.invoke(false) ?: ScreenshotRecorderConfig.from(context, options.experimental.sessionReplay) + captureStrategy = replayCaptureStrategyProvider?.invoke(isFullSession) ?: if (isFullSession) { + SessionCaptureStrategy(options, scopes, dateProvider, replayCacheProvider = replayCacheProvider) + } else { + BufferCaptureStrategy(options, scopes, dateProvider, random, replayCacheProvider = replayCacheProvider) + } + + captureStrategy?.start(recorderConfig) + recorder?.start(recorderConfig) + registerRootViewListeners() + } + + override fun resume() { + if (!isEnabled.get() || !isRecording.get()) { + return + } + + captureStrategy?.resume() + recorder?.resume() + } + + override fun captureReplay(isTerminating: Boolean?) { + if (!isEnabled.get() || !isRecording.get()) { + return + } + + if (SentryId.EMPTY_ID.equals(captureStrategy?.currentReplayId)) { + options.logger.log(DEBUG, "Replay id is not set, not capturing for event") + return + } + + captureStrategy?.captureReplay(isTerminating == true, onSegmentSent = { newTimestamp -> + captureStrategy?.currentSegment = captureStrategy?.currentSegment!! + 1 + captureStrategy?.segmentTimestamp = newTimestamp + }) + captureStrategy = captureStrategy?.convert() + } + + override fun getReplayId(): SentryId = captureStrategy?.currentReplayId ?: 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 + } + + unregisterRootViewListeners() + recorder?.stop() + gestureRecorder?.stop() + captureStrategy?.stop() + isRecording.set(false) + captureStrategy?.close() + captureStrategy = null + } + + override fun onScreenshotRecorded(bitmap: Bitmap) { + var screen: String? = null + scopes?.configureScope { screen = it.screen?.substringAfterLast('.') } + captureStrategy?.onScreenshotRecorded(bitmap) { frameTimeStamp -> + addFrame(bitmap, frameTimeStamp, screen) + } + } + + 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) + } + + private fun registerRootViewListeners() { + if (recorder is OnRootViewsChangedListener) { + rootViewsSpy.listeners += (recorder as OnRootViewsChangedListener) + } + rootViewsSpy.listeners += gestureRecorder + } + + private fun unregisterRootViewListeners() { + if (recorder is OnRootViewsChangedListener) { + rootViewsSpy.listeners -= (recorder as OnRootViewsChangedListener) + } + rootViewsSpy.listeners -= gestureRecorder + } + + private fun cleanupReplays(unfinishedReplayId: String = "") { + // clean up old replays + options.cacheDirPath?.let { cacheDir -> + File(cacheDir).listFiles()?.forEach { file -> + val name = file.name + if (name.startsWith("replay_") && + !name.contains(replayId.toString()) && + !(unfinishedReplayId.isNotBlank() && name.contains(unfinishedReplayId)) + ) { + FileUtils.deleteRecursively(file) + } + } + } + } + + private fun finalizePreviousReplay() { + // TODO: read persisted options/scope values form the + // TODO: previous run and set them directly to the ReplayEvent so they don't get overwritten in MainEventProcessor + + options.executorService.submitSafely(options, "ReplayIntegration.finalize_previous_replay") { + val previousReplayIdString = PersistingScopeObserver.read(options, REPLAY_FILENAME, String::class.java) ?: run { + cleanupReplays() + return@submitSafely + } + val previousReplayId = SentryId(previousReplayIdString) + if (previousReplayId == SentryId.EMPTY_ID) { + cleanupReplays() + return@submitSafely + } + val lastSegment = ReplayCache.fromDisk(options, previousReplayId, replayCacheProvider) ?: run { + cleanupReplays() + return@submitSafely + } + val breadcrumbs = PersistingScopeObserver.read(options, BREADCRUMBS_FILENAME, List::class.java, Breadcrumb.Deserializer()) as? List + val segment = CaptureStrategy.createSegment( + scopes = scopes, + options = options, + duration = lastSegment.duration, + currentSegmentTimestamp = lastSegment.timestamp, + replayId = previousReplayId, + segmentId = lastSegment.id, + height = lastSegment.recorderConfig.recordingHeight, + width = lastSegment.recorderConfig.recordingWidth, + frameRate = lastSegment.recorderConfig.frameRate, + cache = lastSegment.cache, + replayType = lastSegment.replayType, + screenAtStart = lastSegment.screenAtStart, + breadcrumbs = breadcrumbs, + events = LinkedList(lastSegment.events) + ) + + if (segment is ReplaySegment.Created) { + val hint = HintUtils.createWithTypeCheckHint(PreviousReplayHint()) + segment.capture(scopes, hint) + } + cleanupReplays(unfinishedReplayId = previousReplayIdString) // will be cleaned up after the envelope is assembled + } + } + + private class PreviousReplayHint : Backfillable { + override fun shouldEnrich(): Boolean = false + } +} diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt new file mode 100644 index 0000000000..4a229f85df --- /dev/null +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt @@ -0,0 +1,379 @@ +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.util.submitSafely +import io.sentry.android.replay.util.traverse +import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode +import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode.ImageViewHierarchyNode +import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode.TextViewHierarchyNode +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 + } + + // TODO: handle animations with heuristics (e.g. if we fall under this condition 2 times in a row, we should capture) + if (contentChanged.get()) { + options.logger.log(INFO, "Failed to determine view hierarchy, not capturing") + bitmap.recycle() + return@request + } + + val viewHierarchy = ViewHierarchyNode.fromView(root, null, 0, options) + root.traverse(viewHierarchy, options) + + recorder.submitSafely(options, "screenshot_recorder.mask") { + val canvas = Canvas(bitmap) + canvas.setMatrix(prescaledMatrix) + viewHierarchy.traverse { node -> + if (node.shouldMask && (node.width > 0 && node.height > 0)) { + node.visibleRect ?: return@traverse false + + // TODO: investigate why it returns true on RN when it shouldn't +// if (viewHierarchy.isObscured(node)) { +// return@traverse true +// } + + val (visibleRects, color) = when (node) { + is ImageViewHierarchyNode -> { + listOf(node.visibleRect) to + bitmap.dominantColorForRect(node.visibleRect) + } + + is TextViewHierarchyNode -> { + val textColor = node.layout?.dominantTextColor + ?: node.dominantColor + ?: Color.BLACK + node.layout.getVisibleRects( + node.visibleRect, + node.paddingLeft, + node.paddingTop + ) to textColor + } + + 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) + // invalidate the flag to capture the first frame after new window is attached + contentChanged.set(true) + } + + 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 +) { + internal constructor( + scaleFactorX: Float, + scaleFactorY: Float + ) : this( + recordingWidth = 0, + recordingHeight = 0, + scaleFactorX = scaleFactorX, + scaleFactorY = scaleFactorY, + frameRate = 0, + bitRate = 0 + ) + + companion object { + /** + * Since codec block size is 16, so we have to adjust the width and height to it, otherwise + * 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/SessionReplayOptions.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/SessionReplayOptions.kt new file mode 100644 index 0000000000..fb5105565b --- /dev/null +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/SessionReplayOptions.kt @@ -0,0 +1,31 @@ +package io.sentry.android.replay + +import io.sentry.SentryReplayOptions + +// since we don't have getters for maskAllText and maskAllimages, they won't be accessible as +// properties in Kotlin, therefore we create these extensions where a getter is dummy, but a setter +// delegates to the corresponding method in SentryReplayOptions + +/** + * Mask all text content. Draws a rectangle of text bounds with text color on top. By default + * only views extending TextView are masked. + * + *

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

    Default is enabled. + */ +var SentryReplayOptions.maskAllImages: Boolean + @Deprecated("Getter is unsupported.", level = DeprecationLevel.ERROR) + get() = error("Getter not supported") + set(value) = setMaskAllImages(value) diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ViewExtensions.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ViewExtensions.kt new file mode 100644 index 0000000000..2625399c99 --- /dev/null +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ViewExtensions.kt @@ -0,0 +1,18 @@ +package io.sentry.android.replay + +import android.view.View + +/** + * Marks this view to be masked in session replay. + */ +fun View.sentryReplayMask() { + setTag(R.id.sentry_privacy, "mask") +} + +/** + * Marks this view to be unmasked in session replay. + * All its content will be visible in the replay, use with caution. + */ +fun View.sentryReplayUnmask() { + setTag(R.id.sentry_privacy, "unmask") +} 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..9e846dfcf0 --- /dev/null +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/WindowRecorder.kt @@ -0,0 +1,97 @@ +package io.sentry.android.replay + +import android.annotation.TargetApi +import android.view.View +import io.sentry.SentryOptions +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 + +@TargetApi(26) +internal class WindowRecorder( + private val options: SentryOptions, + private val screenshotRecorderCallback: ScreenshotRecorderCallback? = null, + private val mainLooperHandler: MainLooperHandler +) : Recorder, OnRootViewsChangedListener { + + internal companion object { + private const val TAG = "WindowRecorder" + } + + 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()) + } + + override fun onRootViewsChanged(root: View, added: Boolean) { + if (added) { + rootViews.add(WeakReference(root)) + recorder?.bind(root) + } else { + 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) + capturingTask = capturer.scheduleAtFixedRateSafely( + options, + "$TAG.capture", + 100L, // delay the first run by a bit, to allow root view listener to register + 1000L / recorderConfig.frameRate, + MILLISECONDS + ) { + recorder?.capture() + } + } + + override fun resume() { + recorder?.resume() + } + override fun pause() { + recorder?.pause() + } + + override fun stop() { + 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 class RecorderExecutorServiceThreadFactory : ThreadFactory { + private var cnt = 0 + override fun newThread(r: Runnable): Thread { + val ret = Thread(r, "SentryWindowRecorder-" + cnt++) + ret.setDaemon(true) + return ret + } + } +} 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..48c7eb5813 --- /dev/null +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/Windows.kt @@ -0,0 +1,224 @@ +/** + * 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 object RootViewsSpy { + + 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 + } + } + + fun install(): RootViewsSpy { + return apply { + // had to do this as a first message of the main thread queue, otherwise if this is + // called from ContentProvider, it might be too early and the listener won't be installed + Handler(Looper.getMainLooper()).postAtFrontOfQueue { + WindowManagerSpy.swapWindowManagerGlobalMViews { mViews -> + delegatingViewList.apply { addAll(mViews) } + } + } + } + } +} + +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..c4ace6afd0 --- /dev/null +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt @@ -0,0 +1,239 @@ +package io.sentry.android.replay.capture + +import android.view.MotionEvent +import io.sentry.Breadcrumb +import io.sentry.DateUtils +import io.sentry.IScopes +import io.sentry.SentryOptions +import io.sentry.SentryReplayEvent.ReplayType +import io.sentry.SentryReplayEvent.ReplayType.BUFFER +import io.sentry.SentryReplayEvent.ReplayType.SESSION +import io.sentry.android.replay.ReplayCache +import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_BIT_RATE +import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_FRAME_RATE +import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_HEIGHT +import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_ID +import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_REPLAY_ID +import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_REPLAY_RECORDING +import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_REPLAY_SCREEN_AT_START +import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_REPLAY_TYPE +import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_TIMESTAMP +import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_WIDTH +import io.sentry.android.replay.ScreenshotRecorderConfig +import io.sentry.android.replay.capture.CaptureStrategy.Companion.createSegment +import io.sentry.android.replay.capture.CaptureStrategy.Companion.currentEventsLock +import io.sentry.android.replay.capture.CaptureStrategy.ReplaySegment +import io.sentry.android.replay.gestures.ReplayGestureConverter +import io.sentry.android.replay.util.PersistableLinkedList +import io.sentry.android.replay.util.gracefullyShutdown +import io.sentry.android.replay.util.submitSafely +import io.sentry.protocol.SentryId +import io.sentry.rrweb.RRWebEvent +import io.sentry.transport.ICurrentDateProvider +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.AtomicBoolean +import java.util.concurrent.atomic.AtomicLong +import java.util.concurrent.atomic.AtomicReference +import kotlin.properties.ReadWriteProperty +import kotlin.reflect.KProperty + +internal abstract class BaseCaptureStrategy( + private val options: SentryOptions, + private val scopes: IScopes?, + private val dateProvider: ICurrentDateProvider, + executor: ScheduledExecutorService? = null, + private val replayCacheProvider: ((replayId: SentryId, recorderConfig: ScreenshotRecorderConfig) -> ReplayCache)? = null +) : CaptureStrategy { + + internal companion object { + private const val TAG = "CaptureStrategy" + } + + private val persistingExecutor: ScheduledExecutorService by lazy { + Executors.newSingleThreadScheduledExecutor(ReplayPersistingExecutorServiceThreadFactory()) + } + private val gestureConverter = ReplayGestureConverter(dateProvider) + + protected val isTerminating = AtomicBoolean(false) + protected var cache: ReplayCache? = null + protected var recorderConfig: ScreenshotRecorderConfig by persistableAtomic { _, _, newValue -> + if (newValue == null) { + // recorderConfig is only nullable on init, but never after + return@persistableAtomic + } + cache?.persistSegmentValues(SEGMENT_KEY_HEIGHT, newValue.recordingHeight.toString()) + cache?.persistSegmentValues(SEGMENT_KEY_WIDTH, newValue.recordingWidth.toString()) + cache?.persistSegmentValues(SEGMENT_KEY_FRAME_RATE, newValue.frameRate.toString()) + cache?.persistSegmentValues(SEGMENT_KEY_BIT_RATE, newValue.bitRate.toString()) + } + override var segmentTimestamp by persistableAtomicNullable(propertyName = SEGMENT_KEY_TIMESTAMP) { _, _, newValue -> + cache?.persistSegmentValues(SEGMENT_KEY_TIMESTAMP, if (newValue == null) null else DateUtils.getTimestamp(newValue)) + } + protected val replayStartTimestamp = AtomicLong() + protected var screenAtStart by persistableAtomicNullable(propertyName = SEGMENT_KEY_REPLAY_SCREEN_AT_START) + override var currentReplayId: SentryId by persistableAtomic(initialValue = SentryId.EMPTY_ID, propertyName = SEGMENT_KEY_REPLAY_ID) + override var currentSegment: Int by persistableAtomic(initialValue = -1, propertyName = SEGMENT_KEY_ID) + override val replayCacheDir: File? get() = cache?.replayCacheDir + + override var replayType by persistableAtomic(propertyName = SEGMENT_KEY_REPLAY_TYPE) + protected val currentEvents: LinkedList = PersistableLinkedList( + propertyName = SEGMENT_KEY_REPLAY_RECORDING, + options, + persistingExecutor, + cacheProvider = { cache } + ) + + protected val replayExecutor: ScheduledExecutorService by lazy { + executor ?: Executors.newSingleThreadScheduledExecutor(ReplayExecutorServiceThreadFactory()) + } + + override fun start( + recorderConfig: ScreenshotRecorderConfig, + segmentId: Int, + replayId: SentryId, + replayType: ReplayType? + ) { + cache = replayCacheProvider?.invoke(replayId, recorderConfig) ?: ReplayCache(options, replayId, recorderConfig) + + this.currentReplayId = replayId + this.currentSegment = segmentId + this.replayType = replayType ?: (if (this is SessionCaptureStrategy) SESSION else BUFFER) + this.recorderConfig = recorderConfig + + segmentTimestamp = DateUtils.getCurrentDateTime() + replayStartTimestamp.set(dateProvider.currentTimeMillis) + } + + override fun resume() { + segmentTimestamp = DateUtils.getCurrentDateTime() + } + + override fun pause() = Unit + + override fun stop() { + cache?.close() + currentSegment = -1 + replayStartTimestamp.set(0) + segmentTimestamp = null + currentReplayId = SentryId.EMPTY_ID + } + + protected fun createSegmentInternal( + duration: Long, + currentSegmentTimestamp: Date, + replayId: SentryId, + segmentId: Int, + height: Int, + width: Int, + replayType: ReplayType = this.replayType, + cache: ReplayCache? = this.cache, + frameRate: Int = recorderConfig.frameRate, + screenAtStart: String? = this.screenAtStart, + breadcrumbs: List? = null, + events: LinkedList = this.currentEvents + ): ReplaySegment = + createSegment( + scopes, + options, + duration, + currentSegmentTimestamp, + replayId, + segmentId, + height, + width, + replayType, + cache, + frameRate, + screenAtStart, + breadcrumbs, + events + ) + + override fun onConfigurationChanged(recorderConfig: ScreenshotRecorderConfig) { + this.recorderConfig = recorderConfig + } + + override fun onTouchEvent(event: MotionEvent) { + val rrwebEvents = gestureConverter.convert(event, recorderConfig) + if (rrwebEvents != null) { + currentEventsLock.acquire().use { + currentEvents += rrwebEvents + } + } + } + + override fun close() { + replayExecutor.gracefullyShutdown(options) + } + + 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 + } + } + + private class ReplayPersistingExecutorServiceThreadFactory : ThreadFactory { + private var cnt = 0 + override fun newThread(r: Runnable): Thread { + val ret = Thread(r, "SentryReplayPersister-" + cnt++) + ret.setDaemon(true) + return ret + } + } + + private inline fun persistableAtomicNullable( + initialValue: T? = null, + propertyName: String, + crossinline onChange: (propertyName: String?, oldValue: T?, newValue: T?) -> Unit = { _, _, newValue -> + cache?.persistSegmentValues(propertyName, newValue.toString()) + } + ): ReadWriteProperty = + object : ReadWriteProperty { + private val value = AtomicReference(initialValue) + + private fun runInBackground(task: () -> Unit) { + if (options.threadChecker.isMainThread) { + persistingExecutor.submitSafely(options, "$TAG.runInBackground") { + task() + } + } else { + task() + } + } + + init { + runInBackground { onChange(propertyName, initialValue, initialValue) } + } + + override fun getValue(thisRef: Any?, property: KProperty<*>): T? = value.get() + + override fun setValue(thisRef: Any?, property: KProperty<*>, value: T?) { + val oldValue = this.value.getAndSet(value) + if (oldValue != value) { + runInBackground { onChange(propertyName, oldValue, value) } + } + } + } + + private inline fun persistableAtomic( + initialValue: T? = null, + propertyName: String, + crossinline onChange: (propertyName: String?, oldValue: T?, newValue: T?) -> Unit = { _, _, newValue -> + cache?.persistSegmentValues(propertyName, newValue.toString()) + } + ): ReadWriteProperty = + persistableAtomicNullable(initialValue, propertyName, onChange) as ReadWriteProperty + + private inline fun persistableAtomic( + crossinline onChange: (propertyName: String?, oldValue: T?, newValue: T?) -> Unit + ): ReadWriteProperty = + persistableAtomicNullable(null, "", onChange) as ReadWriteProperty +} diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BufferCaptureStrategy.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BufferCaptureStrategy.kt new file mode 100644 index 0000000000..e0c728fd08 --- /dev/null +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BufferCaptureStrategy.kt @@ -0,0 +1,210 @@ +package io.sentry.android.replay.capture + +import android.graphics.Bitmap +import android.view.MotionEvent +import io.sentry.DateUtils +import io.sentry.IScopes +import io.sentry.SentryLevel.DEBUG +import io.sentry.SentryLevel.ERROR +import io.sentry.SentryLevel.INFO +import io.sentry.SentryOptions +import io.sentry.SentryReplayEvent.ReplayType.BUFFER +import io.sentry.android.replay.ReplayCache +import io.sentry.android.replay.ScreenshotRecorderConfig +import io.sentry.android.replay.capture.CaptureStrategy.Companion.rotateEvents +import io.sentry.android.replay.capture.CaptureStrategy.ReplaySegment +import io.sentry.android.replay.util.sample +import io.sentry.android.replay.util.submitSafely +import io.sentry.protocol.SentryId +import io.sentry.transport.ICurrentDateProvider +import io.sentry.util.FileUtils +import java.io.File +import java.security.SecureRandom +import java.util.Date +import java.util.concurrent.ScheduledExecutorService + +internal class BufferCaptureStrategy( + private val options: SentryOptions, + private val scopes: IScopes?, + private val dateProvider: ICurrentDateProvider, + private val random: SecureRandom, + executor: ScheduledExecutorService? = null, + replayCacheProvider: ((replayId: SentryId, recorderConfig: ScreenshotRecorderConfig) -> ReplayCache)? = null +) : BaseCaptureStrategy(options, scopes, dateProvider, executor = executor, replayCacheProvider = replayCacheProvider) { + + // TODO: capture envelopes for buffered segments instead, but don't send them until buffer is triggered + private val bufferedSegments = mutableListOf() + + internal companion object { + private const val TAG = "BufferCaptureStrategy" + private const val ENVELOPE_PROCESSING_DELAY: Long = 100L + } + + override fun pause() { + createCurrentSegment("pause") { segment -> + if (segment is ReplaySegment.Created) { + bufferedSegments += segment + + currentSegment++ + } + } + super.pause() + } + + override fun stop() { + val replayCacheDir = cache?.replayCacheDir + replayExecutor.submitSafely(options, "$TAG.stop") { + FileUtils.deleteRecursively(replayCacheDir) + } + super.stop() + } + + override fun captureReplay( + isTerminating: Boolean, + onSegmentSent: (Date) -> Unit + ) { + val sampled = random.sample(options.experimental.sessionReplay.onErrorSampleRate) + + if (!sampled) { + options.logger.log(INFO, "Replay wasn't sampled by onErrorSampleRate, not capturing for event") + return + } + + // write replayId to scope right away, so it gets picked up by the event that caused buffer + // to flush + scopes?.configureScope { + it.replayId = currentReplayId + } + + if (isTerminating) { + this.isTerminating.set(true) + // avoid capturing replay, because the video will be malformed + options.logger.log(DEBUG, "Not capturing replay for crashed event, will be captured on next launch") + return + } + + createCurrentSegment("capture_replay") { segment -> + bufferedSegments.capture() + + if (segment is ReplaySegment.Created) { + segment.capture(scopes) + + // 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(segment.replay.timestamp) + } + } + } + + 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 + screenAtStart = cache?.rotate(bufferLimit) + bufferedSegments.rotate(bufferLimit) + } + } + + override fun onConfigurationChanged(recorderConfig: ScreenshotRecorderConfig) { + createCurrentSegment("configuration_changed") { segment -> + if (segment is ReplaySegment.Created) { + bufferedSegments += segment + + currentSegment++ + } + } + super.onConfigurationChanged(recorderConfig) + } + + override fun convert(): CaptureStrategy { + if (isTerminating.get()) { + options.logger.log(DEBUG, "Not converting to session mode, because the process is about to terminate") + return this + } + // we hand over replayExecutor to the new strategy to preserve order of execution + val captureStrategy = SessionCaptureStrategy(options, scopes, dateProvider, replayExecutor) + captureStrategy.start(recorderConfig, segmentId = currentSegment, replayId = currentReplayId, replayType = BUFFER) + return captureStrategy + } + + override fun onTouchEvent(event: MotionEvent) { + super.onTouchEvent(event) + val bufferLimit = dateProvider.currentTimeMillis - options.experimental.sessionReplay.errorReplayDuration + rotateEvents(currentEvents, bufferLimit) + } + + private fun deleteFile(file: File?) { + if (file == null) { + return + } + try { + if (!file.delete()) { + options.logger.log(ERROR, "Failed to delete replay segment: %s", file.absolutePath) + } + } catch (e: Throwable) { + options.logger.log(ERROR, e, "Failed to delete replay segment: %s", file.absolutePath) + } + } + + private fun MutableList.capture() { + var bufferedSegment = removeFirstOrNull() + while (bufferedSegment != null) { + bufferedSegment.capture(scopes) + bufferedSegment = removeFirstOrNull() + // a short delay between processing envelopes to avoid bursting our server and hitting + // another rate limit https://develop.sentry.dev/sdk/features/#additional-capabilities + // InterruptedException will be handled by the outer try-catch + Thread.sleep(ENVELOPE_PROCESSING_DELAY) + } + } + + private fun MutableList.rotate(bufferLimit: Long) { + // TODO: can be a single while-loop + var removed = false + removeAll { + // it can be that the buffered segment is half-way older than the buffer limit, but + // we only drop it if its end timestamp is older + if (it.replay.timestamp.time < bufferLimit) { + currentSegment-- + deleteFile(it.replay.videoFile) + removed = true + return@removeAll true + } + return@removeAll false + } + if (removed) { + // shift segmentIds after rotating buffered segments + forEachIndexed { index, segment -> + segment.setSegmentId(index) + } + } + } + + private fun createCurrentSegment(taskName: String, onSegmentCreated: (ReplaySegment) -> Unit) { + val errorReplayDuration = options.experimental.sessionReplay.errorReplayDuration + val now = dateProvider.currentTimeMillis + val currentSegmentTimestamp = if (cache?.frames?.isNotEmpty() == true) { + // in buffer mode we have to set the timestamp of the first frame as the actual start + DateUtils.getDateTime(cache!!.frames.first().timestamp) + } else { + DateUtils.getDateTime(now - errorReplayDuration) + } + val segmentId = currentSegment + val duration = now - currentSegmentTimestamp.time + val replayId = currentReplayId + val height = this.recorderConfig.recordingHeight + val width = this.recorderConfig.recordingWidth + + replayExecutor.submitSafely(options, "$TAG.$taskName") { + val segment = + createSegmentInternal(duration, currentSegmentTimestamp, replayId, segmentId, height, width) + onSegmentCreated(segment) + } + } +} diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/CaptureStrategy.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/CaptureStrategy.kt new file mode 100644 index 0000000000..4ad9f03386 --- /dev/null +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/CaptureStrategy.kt @@ -0,0 +1,242 @@ +package io.sentry.android.replay.capture + +import android.graphics.Bitmap +import android.view.MotionEvent +import io.sentry.Breadcrumb +import io.sentry.DateUtils +import io.sentry.Hint +import io.sentry.IScopes +import io.sentry.ReplayRecording +import io.sentry.SentryOptions +import io.sentry.SentryReplayEvent +import io.sentry.SentryReplayEvent.ReplayType +import io.sentry.android.replay.ReplayCache +import io.sentry.android.replay.ScreenshotRecorderConfig +import io.sentry.protocol.SentryId +import io.sentry.rrweb.RRWebBreadcrumbEvent +import io.sentry.rrweb.RRWebEvent +import io.sentry.rrweb.RRWebMetaEvent +import io.sentry.rrweb.RRWebVideoEvent +import io.sentry.util.AutoClosableReentrantLock +import java.io.File +import java.util.Date +import java.util.LinkedList + +internal interface CaptureStrategy { + var currentSegment: Int + var currentReplayId: SentryId + val replayCacheDir: File? + var replayType: ReplayType + var segmentTimestamp: Date? + + fun start( + recorderConfig: ScreenshotRecorderConfig, + segmentId: Int = 0, + replayId: SentryId = SentryId(), + replayType: ReplayType? = null + ) + + fun stop() + + fun pause() + + fun resume() + + fun captureReplay(isTerminating: Boolean, onSegmentSent: (Date) -> 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() + + companion object { + internal val currentEventsLock = AutoClosableReentrantLock() + + fun createSegment( + scopes: IScopes?, + options: SentryOptions, + duration: Long, + currentSegmentTimestamp: Date, + replayId: SentryId, + segmentId: Int, + height: Int, + width: Int, + replayType: ReplayType, + cache: ReplayCache?, + frameRate: Int, + screenAtStart: String?, + breadcrumbs: List?, + events: LinkedList + ): ReplaySegment { + val generatedVideo = cache?.createVideoOf( + duration, + currentSegmentTimestamp.time, + segmentId, + height, + width + ) ?: return ReplaySegment.Failed + + val (video, frameCount, videoDuration) = generatedVideo + + val replayBreadcrumbs: List = if (breadcrumbs == null) { + var crumbs = emptyList() + scopes?.configureScope { scope -> + crumbs = ArrayList(scope.breadcrumbs) + } + crumbs + } else { + breadcrumbs + } + + return buildReplay( + options, + video, + replayId, + currentSegmentTimestamp, + segmentId, + height, + width, + frameCount, + frameRate, + videoDuration, + replayType, + screenAtStart, + replayBreadcrumbs, + events + ) + } + + private fun buildReplay( + options: SentryOptions, + video: File, + currentReplayId: SentryId, + segmentTimestamp: Date, + segmentId: Int, + height: Int, + width: Int, + frameCount: Int, + frameRate: Int, + videoDuration: Long, + replayType: ReplayType, + screenAtStart: String?, + breadcrumbs: List, + events: LinkedList + ): ReplaySegment { + val endTimestamp = DateUtils.getDateTime(segmentTimestamp.time + videoDuration) + val replay = SentryReplayEvent().apply { + this.eventId = currentReplayId + this.replayId = currentReplayId + this.segmentId = segmentId + this.timestamp = endTimestamp + this.replayStartTimestamp = segmentTimestamp + this.replayType = replayType + this.videoFile = video + } + + val recordingPayload = mutableListOf() + recordingPayload += RRWebMetaEvent().apply { + this.timestamp = segmentTimestamp.time + this.height = height + this.width = width + } + recordingPayload += RRWebVideoEvent().apply { + this.timestamp = segmentTimestamp.time + this.segmentId = segmentId + this.durationMs = videoDuration + this.frameCount = frameCount + this.size = video.length() + this.frameRate = frameRate + this.height = height + this.width = width + // TODO: support non-fullscreen windows later + this.left = 0 + this.top = 0 + } + + val urls = LinkedList() + breadcrumbs.forEach { breadcrumb -> + if (breadcrumb.timestamp.time >= segmentTimestamp.time && + breadcrumb.timestamp.time < endTimestamp.time + ) { + val rrwebEvent = options + .replayController + .breadcrumbConverter + .convert(breadcrumb) + + if (rrwebEvent != null) { + recordingPayload += rrwebEvent + + // fill in the urls array from navigation breadcrumbs + if ((rrwebEvent as? RRWebBreadcrumbEvent)?.category == "navigation") { + urls.add(rrwebEvent.data!!["to"] as String) + } + } + } + } + + if (screenAtStart != null && urls.firstOrNull() != screenAtStart) { + urls.addFirst(screenAtStart) + } + + rotateEvents(events, endTimestamp.time) { event -> + if (event.timestamp >= segmentTimestamp.time) { + recordingPayload += event + } + } + + val recording = ReplayRecording().apply { + this.segmentId = segmentId + this.payload = recordingPayload.sortedBy { it.timestamp } + } + + replay.urls = urls + return ReplaySegment.Created( + replay = replay, + recording = recording + ) + } + + internal fun rotateEvents( + events: LinkedList, + until: Long, + callback: ((RRWebEvent) -> Unit)? = null + ) { + currentEventsLock.acquire().use { + var event = events.peek() + while (event != null && event.timestamp < until) { + callback?.invoke(event) + events.remove() + event = events.peek() + } + } + } + } + + sealed class ReplaySegment { + object Failed : ReplaySegment() + data class Created( + val replay: SentryReplayEvent, + val recording: ReplayRecording + ) : ReplaySegment() { + fun capture(scopes: IScopes?, hint: Hint = Hint()) { + scopes?.captureReplay(replay, hint.apply { replayRecording = recording }) + } + + fun setSegmentId(segmentId: Int) { + replay.segmentId = segmentId + recording.payload?.forEach { + when (it) { + is RRWebVideoEvent -> it.segmentId = segmentId + } + } + } + } + } +} diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/SessionCaptureStrategy.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/SessionCaptureStrategy.kt new file mode 100644 index 0000000000..3109c55c5a --- /dev/null +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/SessionCaptureStrategy.kt @@ -0,0 +1,157 @@ +package io.sentry.android.replay.capture + +import android.graphics.Bitmap +import io.sentry.IConnectionStatusProvider.ConnectionStatus.DISCONNECTED +import io.sentry.IScopes +import io.sentry.SentryLevel.DEBUG +import io.sentry.SentryLevel.INFO +import io.sentry.SentryOptions +import io.sentry.SentryReplayEvent.ReplayType +import io.sentry.android.replay.ReplayCache +import io.sentry.android.replay.ScreenshotRecorderConfig +import io.sentry.android.replay.capture.CaptureStrategy.ReplaySegment +import io.sentry.android.replay.util.submitSafely +import io.sentry.protocol.SentryId +import io.sentry.transport.ICurrentDateProvider +import io.sentry.util.FileUtils +import java.util.Date +import java.util.concurrent.ScheduledExecutorService + +internal class SessionCaptureStrategy( + private val options: SentryOptions, + private val scopes: IScopes?, + private val dateProvider: ICurrentDateProvider, + executor: ScheduledExecutorService? = null, + replayCacheProvider: ((replayId: SentryId, recorderConfig: ScreenshotRecorderConfig) -> ReplayCache)? = null +) : BaseCaptureStrategy(options, scopes, dateProvider, executor, replayCacheProvider) { + + internal companion object { + private const val TAG = "SessionCaptureStrategy" + } + + override fun start( + recorderConfig: ScreenshotRecorderConfig, + segmentId: Int, + replayId: SentryId, + replayType: ReplayType? + ) { + super.start(recorderConfig, segmentId, replayId, replayType) + // only set replayId on the scope if it's a full session, otherwise all events will be + // tagged with the replay that might never be sent when we're recording in buffer mode + scopes?.configureScope { + it.replayId = currentReplayId + screenAtStart = it.screen?.substringAfterLast('.') + } + } + + override fun pause() { + createCurrentSegment("pause") { segment -> + if (segment is ReplaySegment.Created) { + segment.capture(scopes) + + currentSegment++ + } + } + super.pause() + } + + override fun stop() { + val replayCacheDir = cache?.replayCacheDir + createCurrentSegment("stop") { segment -> + if (segment is ReplaySegment.Created) { + segment.capture(scopes) + } + FileUtils.deleteRecursively(replayCacheDir) + } + scopes?.configureScope { it.replayId = SentryId.EMPTY_ID } + super.stop() + } + + override fun captureReplay(isTerminating: Boolean, onSegmentSent: (Date) -> Unit) { + options.logger.log(DEBUG, "Replay is already running in 'session' mode, not capturing for event") + this.isTerminating.set(isTerminating) + } + + 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 currentSegmentTimestamp = segmentTimestamp + currentSegmentTimestamp ?: run { + options.logger.log(DEBUG, "Segment timestamp is not set, not recording frame") + return@submitSafely + } + + if (isTerminating.get()) { + options.logger.log(DEBUG, "Not capturing segment, because the app is terminating, will be captured on next launch") + return@submitSafely + } + + val now = dateProvider.currentTimeMillis + if ((now - currentSegmentTimestamp.time >= options.experimental.sessionReplay.sessionSegmentDuration)) { + val segment = + createSegmentInternal( + options.experimental.sessionReplay.sessionSegmentDuration, + currentSegmentTimestamp, + currentReplayId, + currentSegment, + height, + width + ) + if (segment is ReplaySegment.Created) { + segment.capture(scopes) + currentSegment++ + // set next segment timestamp as close to the previous one as possible to avoid gaps + segmentTimestamp = segment.replay.timestamp + } + } + + if ((now - replayStartTimestamp.get() >= options.experimental.sessionReplay.sessionDuration)) { + options.replayController.stop() + options.logger.log(INFO, "Session replay deadline exceeded (1h), stopping recording") + } + } + } + + override fun onConfigurationChanged(recorderConfig: ScreenshotRecorderConfig) { + createCurrentSegment("onConfigurationChanged") { segment -> + if (segment is ReplaySegment.Created) { + segment.capture(scopes) + + currentSegment++ + // set next segment timestamp as close to the previous one as possible to avoid gaps + segmentTimestamp = segment.replay.timestamp + } + } + + // 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 ?: return + val segmentId = currentSegment + val duration = now - currentSegmentTimestamp.time + val replayId = currentReplayId + val height = recorderConfig.recordingHeight + val width = recorderConfig.recordingWidth + replayExecutor.submitSafely(options, "$TAG.$taskName") { + val segment = + createSegmentInternal(duration, currentSegmentTimestamp, replayId, segmentId, height, width) + onSegmentCreated(segment) + } + } +} diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/gestures/GestureRecorder.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/gestures/GestureRecorder.kt new file mode 100644 index 0000000000..57302aaac1 --- /dev/null +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/gestures/GestureRecorder.kt @@ -0,0 +1,85 @@ +package io.sentry.android.replay.gestures + +import android.view.MotionEvent +import android.view.View +import android.view.Window +import io.sentry.SentryLevel.DEBUG +import io.sentry.SentryLevel.ERROR +import io.sentry.SentryOptions +import io.sentry.android.replay.OnRootViewsChangedListener +import io.sentry.android.replay.phoneWindow +import io.sentry.android.replay.util.FixedWindowCallback +import java.lang.ref.WeakReference + +class GestureRecorder( + private val options: SentryOptions, + private val touchRecorderCallback: TouchRecorderCallback +) : OnRootViewsChangedListener { + + private val rootViews = ArrayList>() + + override fun onRootViewsChanged(root: View, added: Boolean) { + if (added) { + rootViews.add(WeakReference(root)) + root.startGestureTracking() + } else { + root.stopGestureTracking() + rootViews.removeAll { it.get() == root } + } + } + + fun stop() { + rootViews.forEach { it.get()?.stopGestureTracking() } + rootViews.clear() + } + + private fun View.startGestureTracking() { + val window = phoneWindow + if (window == null) { + options.logger.log(DEBUG, "Window is invalid, not tracking gestures") + return + } + + val delegate = window.callback + if (delegate !is SentryReplayGestureRecorder) { + window.callback = SentryReplayGestureRecorder(options, touchRecorderCallback, delegate) + } + } + + private fun View.stopGestureTracking() { + val window = phoneWindow + if (window == null) { + options.logger.log(DEBUG, "Window was null in stopGestureTracking") + return + } + + if (window.callback is SentryReplayGestureRecorder) { + val delegate = (window.callback as SentryReplayGestureRecorder).delegate + window.callback = delegate + } + } + + internal class SentryReplayGestureRecorder( + private val options: SentryOptions, + private val touchRecorderCallback: TouchRecorderCallback?, + delegate: Window.Callback? + ) : FixedWindowCallback(delegate) { + override fun dispatchTouchEvent(event: MotionEvent?): Boolean { + if (event != null) { + val copy: MotionEvent = MotionEvent.obtainNoHistory(event) + try { + touchRecorderCallback?.onTouchEvent(copy) + } catch (e: Throwable) { + options.logger.log(ERROR, "Error dispatching touch event", e) + } finally { + copy.recycle() + } + } + return super.dispatchTouchEvent(event) + } + } +} + +public interface TouchRecorderCallback { + fun onTouchEvent(event: MotionEvent) +} diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/gestures/ReplayGestureConverter.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/gestures/ReplayGestureConverter.kt new file mode 100644 index 0000000000..59d6b30bce --- /dev/null +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/gestures/ReplayGestureConverter.kt @@ -0,0 +1,144 @@ +package io.sentry.android.replay.gestures + +import android.view.MotionEvent +import io.sentry.android.replay.ScreenshotRecorderConfig +import io.sentry.rrweb.RRWebIncrementalSnapshotEvent +import io.sentry.rrweb.RRWebInteractionEvent +import io.sentry.rrweb.RRWebInteractionEvent.InteractionType +import io.sentry.rrweb.RRWebInteractionMoveEvent +import io.sentry.rrweb.RRWebInteractionMoveEvent.Position +import io.sentry.transport.ICurrentDateProvider + +class ReplayGestureConverter( + private val dateProvider: ICurrentDateProvider +) { + + internal companion object { + // rrweb values + private const val TOUCH_MOVE_DEBOUNCE_THRESHOLD = 50 + private const val CAPTURE_MOVE_EVENT_THRESHOLD = 500 + } + + private val currentPositions = LinkedHashMap>(10) + private var touchMoveBaseline = 0L + private var lastCapturedMoveEvent = 0L + + fun convert(event: MotionEvent, recorderConfig: ScreenshotRecorderConfig): List? { + return when (event.actionMasked) { + MotionEvent.ACTION_MOVE -> { + // we only throttle move events as those can be overwhelming + val now = dateProvider.currentTimeMillis + if (lastCapturedMoveEvent != 0L && lastCapturedMoveEvent + TOUCH_MOVE_DEBOUNCE_THRESHOLD > now) { + return null + } + lastCapturedMoveEvent = now + + currentPositions.keys.forEach { pId -> + val pIndex = event.findPointerIndex(pId) + + if (pIndex == -1) { + // no data for this pointer + return@forEach + } + + // idk why but rrweb does it like dis + if (touchMoveBaseline == 0L) { + touchMoveBaseline = now + } + + currentPositions[pId]!! += Position().apply { + x = event.getX(pIndex) * recorderConfig.scaleFactorX + y = event.getY(pIndex) * recorderConfig.scaleFactorY + id = 0 // html node id, but we don't have it, so hardcode to 0 to align with FE + timeOffset = now - touchMoveBaseline + } + } + + val totalOffset = now - touchMoveBaseline + return if (totalOffset > CAPTURE_MOVE_EVENT_THRESHOLD) { + val moveEvents = mutableListOf() + for ((pointerId, positions) in currentPositions) { + if (positions.isNotEmpty()) { + moveEvents += RRWebInteractionMoveEvent().apply { + this.timestamp = now + this.positions = positions.map { pos -> + pos.timeOffset -= totalOffset + pos + } + this.pointerId = pointerId + } + currentPositions[pointerId]!!.clear() + } + } + touchMoveBaseline = 0L + moveEvents + } else { + null + } + } + + MotionEvent.ACTION_DOWN, + MotionEvent.ACTION_POINTER_DOWN -> { + val pId = event.getPointerId(event.actionIndex) + val pIndex = event.findPointerIndex(pId) + + if (pIndex == -1) { + // no data for this pointer + return null + } + + // new finger down - add a new pointer for tracking movement + currentPositions[pId] = ArrayList() + listOf( + RRWebInteractionEvent().apply { + timestamp = dateProvider.currentTimeMillis + x = event.getX(pIndex) * recorderConfig.scaleFactorX + y = event.getY(pIndex) * recorderConfig.scaleFactorY + id = 0 // html node id, but we don't have it, so hardcode to 0 to align with FE + pointerId = pId + interactionType = InteractionType.TouchStart + } + ) + } + MotionEvent.ACTION_UP, + MotionEvent.ACTION_POINTER_UP -> { + val pId = event.getPointerId(event.actionIndex) + val pIndex = event.findPointerIndex(pId) + + if (pIndex == -1) { + // no data for this pointer + return null + } + + // finger lift up - remove the pointer from tracking + currentPositions.remove(pId) + listOf( + RRWebInteractionEvent().apply { + timestamp = dateProvider.currentTimeMillis + x = event.getX(pIndex) * recorderConfig.scaleFactorX + y = event.getY(pIndex) * recorderConfig.scaleFactorY + id = 0 // html node id, but we don't have it, so hardcode to 0 to align with FE + pointerId = pId + interactionType = InteractionType.TouchEnd + } + ) + } + MotionEvent.ACTION_CANCEL -> { + // gesture cancelled - remove all pointers from tracking + currentPositions.clear() + listOf( + RRWebInteractionEvent().apply { + timestamp = dateProvider.currentTimeMillis + x = event.x * recorderConfig.scaleFactorX + y = event.y * recorderConfig.scaleFactorY + id = 0 // html node id, but we don't have it, so hardcode to 0 to align with FE + pointerId = 0 // the pointerId is not used for TouchCancel, so just set it to 0 + interactionType = InteractionType.TouchCancel + } + ) + } + + else -> null + } + } +} diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Context.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Context.kt new file mode 100644 index 0000000000..3c5be33115 --- /dev/null +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Context.kt @@ -0,0 +1,5 @@ +package io.sentry.android.replay.util + +import android.content.Context + +internal fun Context.appContext() = this.applicationContext ?: this 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..453ff49df2 --- /dev/null +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Executors.kt @@ -0,0 +1,87 @@ +package io.sentry.android.replay.util + +import io.sentry.ISentryExecutorService +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 ISentryExecutorService.submitSafely( + options: SentryOptions, + taskName: String, + task: Runnable +): Future<*>? { + return try { + submit { + try { + task.run() + } catch (e: Throwable) { + options.logger.log(ERROR, "Failed to execute task $taskName", e) + } + } + } catch (e: Throwable) { + options.logger.log(ERROR, "Failed to submit task $taskName to executor", e) + null + } +} + +internal fun ExecutorService.submitSafely( + options: SentryOptions, + taskName: String, + 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/Nodes.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Nodes.kt new file mode 100644 index 0000000000..5608371722 --- /dev/null +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Nodes.kt @@ -0,0 +1,206 @@ +@file:Suppress("INVISIBLE_MEMBER", "INVISIBLE_REFERENCE") // to access internal vals and classes + +package io.sentry.android.replay.util + +import android.graphics.Rect +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorProducer +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.layout.LayoutCoordinates +import androidx.compose.ui.node.LayoutNode +import androidx.compose.ui.text.TextLayoutResult +import kotlin.math.roundToInt + +internal class ComposeTextLayout(internal val layout: TextLayoutResult, private val hasFillModifier: Boolean) : TextLayout { + override val lineCount: Int get() = layout.lineCount + override val dominantTextColor: Int? get() = null + override fun getPrimaryHorizontal(line: Int, offset: Int): Float { + val horizontalPos = layout.getHorizontalPosition(offset, usePrimaryDirection = true) + // when there's no `fill` modifier on a Text composable, compose still thinks that there's + // one and wrongly calculates horizontal position relative to node's start, not text's start + // for some reason. This is only the case for single-line text (multiline works fien). + // So we subtract line's left to get the correct position + return if (!hasFillModifier && lineCount == 1) { + horizontalPos - layout.getLineLeft(line) + } else { + horizontalPos + } + } + override fun getEllipsisCount(line: Int): Int = if (layout.isLineEllipsized(line)) 1 else 0 + override fun getLineVisibleEnd(line: Int): Int = layout.getLineEnd(line, visibleEnd = true) + override fun getLineTop(line: Int): Int = layout.getLineTop(line).roundToInt() + override fun getLineBottom(line: Int): Int = layout.getLineBottom(line).roundToInt() + override fun getLineStart(line: Int): Int = layout.getLineStart(line) +} + +// TODO: probably most of the below we can do via bytecode instrumentation and speed up at runtime + +/** + * This method is necessary to mask images in Compose. + * + * We heuristically look up for classes that have a [Painter] modifier, usually they all have a + * `Painter` string in their name, e.g. PainterElement, PainterModifierNodeElement or + * ContentPainterModifier for Coil. + * + * That's not going to cover all cases, but probably 90%. + * + * We also add special proguard rules to keep the `Painter` class names and their `painter` member. + */ +internal fun LayoutNode.findPainter(): Painter? { + val modifierInfos = getModifierInfo() + for (index in modifierInfos.indices) { + val modifier = modifierInfos[index].modifier + if (modifier::class.java.name.contains("Painter")) { + return try { + modifier::class.java.getDeclaredField("painter") + .apply { isAccessible = true } + .get(modifier) as? Painter + } catch (e: Throwable) { + null + } + } + } + return null +} + +/** + * We heuristically check the known classes that are coming from local assets usually: + * [androidx.compose.ui.graphics.vector.VectorPainter] + * [androidx.compose.ui.graphics.painter.ColorPainter] + * [androidx.compose.ui.graphics.painter.BrushPainter] + * + * In theory, [androidx.compose.ui.graphics.painter.BitmapPainter] can also come from local assets, + * but it can as well come from a network resource, so we preemptively mask it. + */ +internal fun Painter.isMaskable(): Boolean { + val className = this::class.java.name + return !className.contains("Vector") && + !className.contains("Color") && + !className.contains("Brush") +} + +internal data class TextAttributes(val color: Color?, val hasFillModifier: Boolean) + +/** + * This method is necessary to mask text in Compose. + * + * We heuristically look up for classes that have a [Text] modifier, usually they all have a + * `Text` string in their name, e.g. TextStringSimpleElement or TextAnnotatedStringElement. We then + * get the color from the modifier, to be able to mask it with the correct color. + * + * We also look up for classes that have a [Fill] modifier, usually they all have a `Fill` string in + * their name, e.g. FillElement. This is necessary to workaround a Compose bug where single-line + * text composable without a `fill` modifier still thinks that there's one and wrongly calculates + * horizontal position. + * + * We also add special proguard rules to keep the `Text` class names and their `color` member. + */ +internal fun LayoutNode.findTextAttributes(): TextAttributes { + val modifierInfos = getModifierInfo() + var color: Color? = null + var hasFillModifier = false + for (index in modifierInfos.indices) { + val modifier = modifierInfos[index].modifier + val modifierClassName = modifier::class.java.name + if (modifierClassName.contains("Text")) { + color = try { + ( + modifier::class.java.getDeclaredField("color") + .apply { isAccessible = true } + .get(modifier) as? ColorProducer + ) + ?.invoke() + } catch (e: Throwable) { + null + } + } else if (modifierClassName.contains("Fill")) { + hasFillModifier = true + } + } + return TextAttributes(color, hasFillModifier) +} + +/** + * Returns the smaller of the given values. If any value is NaN, returns NaN. Preferred over + * `kotlin.comparisons.minOf()` for 4 arguments as it avoids allocating an array because of the + * varargs. + */ +private inline fun fastMinOf(a: Float, b: Float, c: Float, d: Float): Float { + return minOf(a, minOf(b, minOf(c, d))) +} + +/** + * Returns the largest of the given values. If any value is NaN, returns NaN. Preferred over + * `kotlin.comparisons.maxOf()` for 4 arguments as it avoids allocating an array because of the + * varargs. + */ +private inline fun fastMaxOf(a: Float, b: Float, c: Float, d: Float): Float { + return maxOf(a, maxOf(b, maxOf(c, d))) +} + +/** + * Returns this float value clamped in the inclusive range defined by [minimumValue] and + * [maximumValue]. Unlike [Float.coerceIn], the range is not validated: the caller must ensure that + * [minimumValue] is less than [maximumValue]. + */ +private inline fun Float.fastCoerceIn(minimumValue: Float, maximumValue: Float) = + this.fastCoerceAtLeast(minimumValue).fastCoerceAtMost(maximumValue) + +/** Ensures that this value is not less than the specified [minimumValue]. */ +private inline fun Float.fastCoerceAtLeast(minimumValue: Float): Float { + return if (this < minimumValue) minimumValue else this +} + +/** Ensures that this value is not greater than the specified [maximumValue]. */ +private inline fun Float.fastCoerceAtMost(maximumValue: Float): Float { + return if (this > maximumValue) maximumValue else this +} + +/** + * A faster copy of https://github.com/androidx/androidx/blob/fc7df0dd68466ac3bb16b1c79b7a73dd0bfdd4c1/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/layout/LayoutCoordinates.kt#L187 + * + * Since we traverse the tree from the root, we don't need to find it again from the leaf node and + * just pass it as an argument. + * + * @return boundaries of this layout relative to the window's origin. + */ +internal fun LayoutCoordinates.boundsInWindow(root: LayoutCoordinates?): Rect { + root ?: return Rect() + + val rootWidth = root.size.width.toFloat() + val rootHeight = root.size.height.toFloat() + + val bounds = root.localBoundingBoxOf(this) + val boundsLeft = bounds.left.fastCoerceIn(0f, rootWidth) + val boundsTop = bounds.top.fastCoerceIn(0f, rootHeight) + val boundsRight = bounds.right.fastCoerceIn(0f, rootWidth) + val boundsBottom = bounds.bottom.fastCoerceIn(0f, rootHeight) + + if (boundsLeft == boundsRight || boundsTop == boundsBottom) { + return Rect() + } + + val topLeft = root.localToWindow(Offset(boundsLeft, boundsTop)) + val topRight = root.localToWindow(Offset(boundsRight, boundsTop)) + val bottomRight = root.localToWindow(Offset(boundsRight, boundsBottom)) + val bottomLeft = root.localToWindow(Offset(boundsLeft, boundsBottom)) + + val topLeftX = topLeft.x + val topRightX = topRight.x + val bottomLeftX = bottomLeft.x + val bottomRightX = bottomRight.x + + val left = fastMinOf(topLeftX, topRightX, bottomLeftX, bottomRightX) + val right = fastMaxOf(topLeftX, topRightX, bottomLeftX, bottomRightX) + + val topLeftY = topLeft.y + val topRightY = topRight.y + val bottomLeftY = bottomLeft.y + val bottomRightY = bottomRight.y + + val top = fastMinOf(topLeftY, topRightY, bottomLeftY, bottomRightY) + val bottom = fastMaxOf(topLeftY, topRightY, bottomLeftY, bottomRightY) + + return Rect(left.toInt(), top.toInt(), right.toInt(), bottom.toInt()) +} diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Persistable.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Persistable.kt new file mode 100644 index 0000000000..a5d3c3e9ec --- /dev/null +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Persistable.kt @@ -0,0 +1,53 @@ +// ktlint-disable filename +package io.sentry.android.replay.util + +import io.sentry.ReplayRecording +import io.sentry.SentryOptions +import io.sentry.android.replay.ReplayCache +import io.sentry.rrweb.RRWebEvent +import java.io.BufferedWriter +import java.io.StringWriter +import java.util.LinkedList +import java.util.concurrent.ScheduledExecutorService + +internal class PersistableLinkedList( + private val propertyName: String, + private val options: SentryOptions, + private val persistingExecutor: ScheduledExecutorService, + private val cacheProvider: () -> ReplayCache? +) : LinkedList() { + // only overriding methods that we use, to observe the collection + override fun addAll(elements: Collection): Boolean { + val result = super.addAll(elements) + persistRecording() + return result + } + + override fun add(element: RRWebEvent): Boolean { + val result = super.add(element) + persistRecording() + return result + } + + override fun remove(): RRWebEvent { + val result = super.remove() + persistRecording() + return result + } + + private fun persistRecording() { + val cache = cacheProvider() ?: return + val recording = ReplayRecording().apply { payload = ArrayList(this@PersistableLinkedList) } + if (options.threadChecker.isMainThread) { + persistingExecutor.submit { + val stringWriter = StringWriter() + options.serializer.serialize(recording, BufferedWriter(stringWriter)) + cache.persistSegmentValues(propertyName, stringWriter.toString()) + } + } else { + val stringWriter = StringWriter() + options.serializer.serialize(recording, BufferedWriter(stringWriter)) + cache.persistSegmentValues(propertyName, stringWriter.toString()) + } + } +} diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/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/TextLayout.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/util/TextLayout.kt new file mode 100644 index 0000000000..cd07c6d170 --- /dev/null +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/util/TextLayout.kt @@ -0,0 +1,21 @@ +package io.sentry.android.replay.util + +/** + * An abstraction over [android.text.Layout] with different implementations for Views and Compose. + */ +interface TextLayout { + val lineCount: Int + + /** + * Returns the dominant text color of the layout by looking at the [ForegroundColorSpan] spans if + * this text is a [Spanned] text. If the text is not a [Spanned] text or there are no spans, it + * returns null. + */ + val dominantTextColor: Int? + fun getPrimaryHorizontal(line: Int, offset: Int): Float + fun getEllipsisCount(line: Int): Int + fun getLineVisibleEnd(line: Int): Int + fun getLineTop(line: Int): Int + fun getLineBottom(line: Int): Int + fun getLineStart(line: Int): Int +} diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Views.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Views.kt new file mode 100644 index 0000000000..0a0656de52 --- /dev/null +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Views.kt @@ -0,0 +1,180 @@ +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.text.Spanned +import android.text.style.ForegroundColorSpan +import android.view.View +import android.view.ViewGroup +import android.widget.TextView +import io.sentry.SentryOptions +import io.sentry.android.replay.viewhierarchy.ComposeViewHierarchyNode +import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode +import java.lang.NullPointerException + +/** + * Recursively traverses the view hierarchy and creates a [ViewHierarchyNode] for each view. + * Supports Compose view hierarchy as well. + */ +internal fun View.traverse(parentNode: ViewHierarchyNode, options: SentryOptions) { + if (this !is ViewGroup) { + return + } + + if (ComposeViewHierarchyNode.fromView(this, parentNode, options)) { + // if it's a compose view, we can skip the children as they are already traversed in + // the ComposeViewHierarchyNode.fromView method + return + } + + if (this.childCount == 0) { + return + } + + val childNodes = ArrayList(this.childCount) + for (i in 0 until childCount) { + val child = getChildAt(i) + if (child != null) { + val childNode = + ViewHierarchyNode.fromView(child, parentNode, indexOfChild(child), options) + childNodes.add(childNode) + child.traverse(childNode, options) + } + } + parentNode.children = childNodes +} + +/** + * Adapted copy of AccessibilityNodeInfo from https://cs.android.com/android/platform/superproject/+/master:frameworks/base/core/java/android/view/View.java;l=10718 + */ +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?.isMaskable(): Boolean { + // TODO: maybe find a way how to check if the drawable is coming from the apk or loaded from network + // TODO: otherwise maybe check for the bitmap size and don't mask those that take a lot of height (e.g. a background of a whatsapp chat) + return when (this) { + is InsetDrawable, is ColorDrawable, is VectorDrawable, is GradientDrawable -> false + is BitmapDrawable -> { + val bmp = bitmap ?: return false + return !bmp.isRecycled && bmp.height > 10 && bmp.width > 10 + } + else -> true + } +} + +internal fun TextLayout?.getVisibleRects(globalRect: Rect, paddingLeft: Int, paddingTop: Int): List { + if (this == null) { + return listOf(globalRect) + } + + val rects = mutableListOf() + for (i in 0 until lineCount) { + val lineStart = getPrimaryHorizontal(i, getLineStart(i)).toInt() + val ellipsisCount = getEllipsisCount(i) + val lineVisibleEnd = getLineVisibleEnd(i) + var lineEnd = getPrimaryHorizontal(i, lineVisibleEnd - ellipsisCount + if (ellipsisCount > 0) 1 else 0).toInt() + if (lineEnd == 0 && lineVisibleEnd > 0) { + // looks like the case for when emojis are present in text + lineEnd = getPrimaryHorizontal(i, lineVisibleEnd - 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 +} + +/** + * [TextView.getVerticalOffset] which is used by [TextView.getTotalPaddingTop] may throw an NPE on + * some devices (Redmi), so we try-catch it specifically for an NPE and then fallback to + * [TextView.getExtendedPaddingTop] + */ +internal val TextView.totalPaddingTopSafe: Int + get() = try { + totalPaddingTop + } catch (e: NullPointerException) { + extendedPaddingTop + } + +/** + * Converts an [Int] ARGB color to an opaque color by setting the alpha channel to 255. + */ +internal fun Int.toOpaque() = this or 0xFF000000.toInt() + +class AndroidTextLayout(private val layout: Layout) : TextLayout { + override val lineCount: Int get() = layout.lineCount + override val dominantTextColor: Int? get() { + if (layout.text !is Spanned) return null + + val spans = (layout.text as Spanned).getSpans(0, layout.text.length, ForegroundColorSpan::class.java) + + // determine the dominant color by the span with the longest range + var longestSpan = Int.MIN_VALUE + var dominantColor: Int? = null + for (span in spans) { + val spanStart = (layout.text as Spanned).getSpanStart(span) + val spanEnd = (layout.text as Spanned).getSpanEnd(span) + if (spanStart == -1 || spanEnd == -1) { + // the span is not attached + continue + } + val spanLength = spanEnd - spanStart + if (spanLength > longestSpan) { + longestSpan = spanLength + dominantColor = span.foregroundColor + } + } + return dominantColor?.toOpaque() + } + override fun getPrimaryHorizontal(line: Int, offset: Int): Float = layout.getPrimaryHorizontal(offset) + override fun getEllipsisCount(line: Int): Int = layout.getEllipsisCount(line) + override fun getLineVisibleEnd(line: Int): Int = layout.getLineVisibleEnd(line) + override fun getLineTop(line: Int): Int = layout.getLineTop(line) + override fun getLineBottom(line: Int): Int = layout.getLineBottom(line) + override fun getLineStart(line: Int): Int = layout.getLineStart(line) +} diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/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..211decc098 --- /dev/null +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleVideoEncoder.kt @@ -0,0 +1,264 @@ +/** + * 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.MediaCodecList +import android.media.MediaFormat +import android.os.Build +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 +) { + + private val hasExynosCodec: Boolean by lazy(NONE) { + // MediaCodecList ctor will initialize an internal in-memory static cache of codecs, so this + // call is only expensive the first time + MediaCodecList(MediaCodecList.REGULAR_CODECS) + .codecInfos + .any { it.name.contains("c2.exynos") } + } + + internal val mediaCodec: MediaCodec = run { + // c2.exynos.h264.encoder seems to have problems encoding the video (Pixel and Samsung devices) + // so we use the default encoder instead + val codec = if (hasExynosCodec) { + MediaCodec.createByCodecName("c2.android.avc.encoder") + } else { + MediaCodec.createEncoderByType(muxerConfig.mimeType) + } + + codec + } + + 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, 6) // use 6 to force non-key frames, meaning only partial updates to save the video size. Every 6th second is a key frame, which is useful for buffer mode + + format + } + + 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) { + // it seems that Xiaomi devices have problems with hardware canvas, so we have to use + // lockCanvas instead https://stackoverflow.com/a/73520742 + val canvas = if (Build.MANUFACTURER.contains("xiaomi", ignoreCase = true)) { + surface?.lockCanvas(null) + } else { + surface?.lockHardwareCanvas() + } + canvas?.drawBitmap(image, 0f, 0f, null) + surface?.unlockCanvasAndPost(canvas) + drainCodec(false) + } + + /** + * 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/ComposeViewHierarchyNode.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ComposeViewHierarchyNode.kt new file mode 100644 index 0000000000..888528f769 --- /dev/null +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ComposeViewHierarchyNode.kt @@ -0,0 +1,213 @@ +@file:Suppress("INVISIBLE_MEMBER", "INVISIBLE_REFERENCE") // to access internal vals + +package io.sentry.android.replay.viewhierarchy + +import android.annotation.TargetApi +import android.view.View +import androidx.compose.ui.graphics.isUnspecified +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.layout.LayoutCoordinates +import androidx.compose.ui.layout.findRootCoordinates +import androidx.compose.ui.layout.positionInWindow +import androidx.compose.ui.node.LayoutNode +import androidx.compose.ui.node.Owner +import androidx.compose.ui.semantics.SemanticsActions +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.semantics.getOrNull +import androidx.compose.ui.text.TextLayoutResult +import io.sentry.SentryLevel +import io.sentry.SentryOptions +import io.sentry.SentryReplayOptions +import io.sentry.android.replay.SentryReplayModifiers +import io.sentry.android.replay.util.ComposeTextLayout +import io.sentry.android.replay.util.boundsInWindow +import io.sentry.android.replay.util.findPainter +import io.sentry.android.replay.util.findTextAttributes +import io.sentry.android.replay.util.isMaskable +import io.sentry.android.replay.util.toOpaque +import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode.GenericViewHierarchyNode +import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode.ImageViewHierarchyNode +import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode.TextViewHierarchyNode + +@TargetApi(26) +internal object ComposeViewHierarchyNode { + + /** + * Since Compose doesn't have a concept of a View class (they are all composable functions), + * we need to map the semantics node to a corresponding old view system class. + */ + private fun LayoutNode.getProxyClassName(isImage: Boolean): String { + return when { + isImage -> SentryReplayOptions.IMAGE_VIEW_CLASS_NAME + collapsedSemantics?.contains(SemanticsProperties.Text) == true || + collapsedSemantics?.contains(SemanticsActions.SetText) == true -> SentryReplayOptions.TEXT_VIEW_CLASS_NAME + else -> "android.view.View" + } + } + + private fun LayoutNode.shouldMask(isImage: Boolean, options: SentryOptions): Boolean { + val sentryPrivacyModifier = collapsedSemantics?.getOrNull(SentryReplayModifiers.SentryPrivacy) + if (sentryPrivacyModifier == "unmask") { + return false + } + + if (sentryPrivacyModifier == "mask") { + return true + } + + val className = getProxyClassName(isImage) + if (options.experimental.sessionReplay.unmaskViewClasses.contains(className)) { + return false + } + + return options.experimental.sessionReplay.maskViewClasses.contains(className) + } + + private var _rootCoordinates: LayoutCoordinates? = null + + private fun fromComposeNode( + node: LayoutNode, + parent: ViewHierarchyNode?, + distance: Int, + isComposeRoot: Boolean, + options: SentryOptions + ): ViewHierarchyNode? { + val isInTree = node.isPlaced && node.isAttached + if (!isInTree) { + return null + } + + if (isComposeRoot) { + _rootCoordinates = node.coordinates.findRootCoordinates() + } + + val semantics = node.collapsedSemantics + val visibleRect = node.coordinates.boundsInWindow(_rootCoordinates) + val isVisible = !node.outerCoordinator.isTransparent() && + (semantics == null || !semantics.contains(SemanticsProperties.InvisibleToUser)) && + visibleRect.height() > 0 && visibleRect.width() > 0 + val isEditable = semantics?.contains(SemanticsActions.SetText) == true + val positionInWindow = node.coordinates.positionInWindow() + return when { + semantics?.contains(SemanticsProperties.Text) == true || isEditable -> { + val shouldMask = isVisible && node.shouldMask(isImage = false, options) + + parent?.setImportantForCaptureToAncestors(true) + val textLayoutResults = mutableListOf() + semantics?.getOrNull(SemanticsActions.GetTextLayoutResult) + ?.action + ?.invoke(textLayoutResults) + + val (color, hasFillModifier) = node.findTextAttributes() + var textColor = textLayoutResults.firstOrNull()?.layoutInput?.style?.color + if (textColor?.isUnspecified == true) { + textColor = color + } + // TODO: support multiple text layouts + // TODO: support editable text (currently there's a way to get @Composable's padding only via reflection, and we can't reliably mask input fields based on TextLayout, so we mask the whole view instead) + TextViewHierarchyNode( + layout = if (textLayoutResults.isNotEmpty() && !isEditable) ComposeTextLayout(textLayoutResults.first(), hasFillModifier) else null, + dominantColor = textColor?.toArgb()?.toOpaque(), + x = positionInWindow.x, + y = positionInWindow.y, + width = node.width, + height = node.height, + elevation = (parent?.elevation ?: 0f), + distance = distance, + parent = parent, + shouldMask = shouldMask, + isImportantForContentCapture = true, + isVisible = isVisible, + visibleRect = visibleRect + ) + } + else -> { + val painter = node.findPainter() + if (painter != null) { + val shouldMask = isVisible && node.shouldMask(isImage = true, options) + + parent?.setImportantForCaptureToAncestors(true) + ImageViewHierarchyNode( + x = positionInWindow.x, + y = positionInWindow.y, + width = node.width, + height = node.height, + elevation = (parent?.elevation ?: 0f), + distance = distance, + parent = parent, + isVisible = isVisible, + isImportantForContentCapture = true, + shouldMask = shouldMask && painter.isMaskable(), + visibleRect = visibleRect + ) + } else { + val shouldMask = isVisible && node.shouldMask(isImage = false, options) + + // TODO: this currently does not support embedded AndroidViews, we'd have to + // TODO: traverse the ViewHierarchyNode here again. For now we can recommend + // TODO: using custom modifiers to obscure the entire node if it's sensitive + GenericViewHierarchyNode( + x = positionInWindow.x, + y = positionInWindow.y, + width = node.width, + height = node.height, + elevation = (parent?.elevation ?: 0f), + distance = distance, + parent = parent, + shouldMask = shouldMask, + isImportantForContentCapture = false, /* will be set by children */ + isVisible = isVisible, + visibleRect = visibleRect + ) + } + } + } + } + + fun fromView(view: View, parent: ViewHierarchyNode?, options: SentryOptions): Boolean { + if (!view::class.java.name.contains("AndroidComposeView")) { + return false + } + + if (parent == null) { + return false + } + + try { + val rootNode = (view as? Owner)?.root ?: return false + rootNode.traverse(parent, isComposeRoot = true, options) + } catch (e: Throwable) { + options.logger.log( + SentryLevel.ERROR, + e, + """ + Error traversing Compose tree. Most likely you're using an unsupported version of + androidx.compose.ui:ui. The minimum supported version is 1.5.0. If it's a newer + version, please open a github issue with the version you're using, so we can add + support for it. + """.trimIndent() + ) + return false + } + + return true + } + + private fun LayoutNode.traverse(parentNode: ViewHierarchyNode, isComposeRoot: Boolean, options: SentryOptions) { + val children = this.children + if (children.isEmpty()) { + return + } + + val childNodes = ArrayList(children.size) + for (index in children.indices) { + val child = children[index] + val childNode = fromComposeNode(child, parentNode, index, isComposeRoot, options) + if (childNode != null) { + childNodes.add(childNode) + child.traverse(childNode, isComposeRoot = false, options) + } + } + parentNode.children = childNodes + } +} diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ViewHierarchyNode.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ViewHierarchyNode.kt new file mode 100644 index 0000000000..ef05ecb029 --- /dev/null +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ViewHierarchyNode.kt @@ -0,0 +1,329 @@ +package io.sentry.android.replay.viewhierarchy + +import android.annotation.TargetApi +import android.graphics.Rect +import android.view.View +import android.widget.ImageView +import android.widget.TextView +import io.sentry.SentryOptions +import io.sentry.android.replay.R +import io.sentry.android.replay.util.AndroidTextLayout +import io.sentry.android.replay.util.TextLayout +import io.sentry.android.replay.util.isMaskable +import io.sentry.android.replay.util.isVisibleToUser +import io.sentry.android.replay.util.toOpaque +import io.sentry.android.replay.util.totalPaddingTopSafe + +@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 shouldMask: 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, + shouldMask: Boolean = false, + isImportantForContentCapture: Boolean = false, + isVisible: Boolean = false, + visibleRect: Rect? = null + ) : ViewHierarchyNode(x, y, width, height, elevation, distance, parent, shouldMask, isImportantForContentCapture, isVisible, visibleRect) + + class TextViewHierarchyNode( + val layout: TextLayout? = 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, + shouldMask: Boolean = false, + isImportantForContentCapture: Boolean = false, + isVisible: Boolean = false, + visibleRect: Rect? = null + ) : ViewHierarchyNode(x, y, width, height, elevation, distance, parent, shouldMask, isImportantForContentCapture, isVisible, visibleRect) + + class ImageViewHierarchyNode( + x: Float, + y: Float, + width: Int, + height: Int, + elevation: Float, + distance: Int, + parent: ViewHierarchyNode? = null, + shouldMask: Boolean = false, + isImportantForContentCapture: Boolean = false, + isVisible: Boolean = false, + visibleRect: Rect? = null + ) : ViewHierarchyNode(x, y, width, height, elevation, distance, parent, shouldMask, isImportantForContentCapture, isVisible, visibleRect) + + /** + * Basically replicating this: https://developer.android.com/reference/android/view/View#isImportantForContentCapture() + * but for lower APIs and with less overhead. If we take a look at how it's set in Android: + * https://cs.android.com/search?q=IMPORTANT_FOR_CONTENT_CAPTURE_YES&ss=android%2Fplatform%2Fsuperproject%2Fmain + * we see that they just set it as important for views containing TextViews, ImageViews and WebViews. + */ + fun setImportantForCaptureToAncestors(isImportant: Boolean) { + var parent = this.parent + while (parent != null) { + parent.isImportantForContentCapture = isImportant + parent = parent.parent + } + } + + /** + * Traverses the view hierarchy starting from this node. The traversal is done in a depth-first + * manner. + * + * @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 const val SENTRY_UNMASK_TAG = "sentry-unmask" + private const val SENTRY_MASK_TAG = "sentry-mask" + + private fun Class<*>.isAssignableFrom(set: Set): Boolean { + var cls: Class<*>? = this + while (cls != null) { + val canonicalName = cls.canonicalName + if (canonicalName != null && set.contains(canonicalName)) { + return true + } + cls = cls.superclass + } + return false + } + + private fun View.shouldMask(options: SentryOptions): Boolean { + if ((tag as? String)?.lowercase()?.contains(SENTRY_UNMASK_TAG) == true || + getTag(R.id.sentry_privacy) == "unmask" + ) { + return false + } + + if ((tag as? String)?.lowercase()?.contains(SENTRY_MASK_TAG) == true || + getTag(R.id.sentry_privacy) == "mask" + ) { + return true + } + + if (this.javaClass.isAssignableFrom(options.experimental.sessionReplay.unmaskViewClasses)) { + return false + } + + return this.javaClass.isAssignableFrom(options.experimental.sessionReplay.maskViewClasses) + } + + fun fromView(view: View, parent: ViewHierarchyNode?, distance: Int, options: SentryOptions): ViewHierarchyNode { + val (isVisible, visibleRect) = view.isVisibleToUser() + val shouldMask = isVisible && view.shouldMask(options) + when (view) { + is TextView -> { + parent?.setImportantForCaptureToAncestors(true) + return TextViewHierarchyNode( + layout = view.layout?.let { AndroidTextLayout(it) }, + dominantColor = view.currentTextColor.toOpaque(), + paddingLeft = view.totalPaddingLeft, + paddingTop = view.totalPaddingTopSafe, + x = view.x, + y = view.y, + width = view.width, + height = view.height, + elevation = (parent?.elevation ?: 0f) + view.elevation, + shouldMask = shouldMask, + distance = distance, + parent = parent, + isImportantForContentCapture = true, + isVisible = isVisible, + visibleRect = visibleRect + ) + } + + is ImageView -> { + 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, + shouldMask = shouldMask && view.drawable?.isMaskable() == true, + visibleRect = visibleRect + ) + } + } + + return GenericViewHierarchyNode( + view.x, + view.y, + view.width, + view.height, + (parent?.elevation ?: 0f) + view.elevation, + distance = distance, + parent = parent, + shouldMask = shouldMask, + isImportantForContentCapture = false, /* will be set by children */ + isVisible = isVisible, + visibleRect = visibleRect + ) + } + } +} diff --git a/sentry-android-replay/src/main/res/values/public.xml b/sentry-android-replay/src/main/res/values/public.xml new file mode 100644 index 0000000000..cc60000bcd --- /dev/null +++ b/sentry-android-replay/src/main/res/values/public.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/sentry-android-replay/src/main/resources/META-INF/io/sentry/sentry-android-replay/verification.properties b/sentry-android-replay/src/main/resources/META-INF/io/sentry/sentry-android-replay/verification.properties new file mode 100644 index 0000000000..5e20f67b37 --- /dev/null +++ b/sentry-android-replay/src/main/resources/META-INF/io/sentry/sentry-android-replay/verification.properties @@ -0,0 +1,3 @@ +#This is the verification token for the io.sentry:sentry-android-replay SDK. +#Tue Aug 20 03:48:30 PDT 2024 +token=MNMM3TDLWFC5DOCIOFYQJO7JWI diff --git a/sentry-android-replay/src/test/AndroidManifest.xml b/sentry-android-replay/src/test/AndroidManifest.xml new file mode 100644 index 0000000000..c8f45a53bb --- /dev/null +++ b/sentry-android-replay/src/test/AndroidManifest.xml @@ -0,0 +1,24 @@ + + + + + diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/AnrWithReplayIntegrationTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/AnrWithReplayIntegrationTest.kt new file mode 100644 index 0000000000..a050bd885f --- /dev/null +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/AnrWithReplayIntegrationTest.kt @@ -0,0 +1,218 @@ +package io.sentry.android.replay + +import android.app.ActivityManager +import android.app.ApplicationExitInfo +import android.content.Context +import android.graphics.Bitmap +import android.graphics.Bitmap.CompressFormat.JPEG +import android.graphics.Bitmap.Config.ARGB_8888 +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.sentry.EventProcessor +import io.sentry.Hint +import io.sentry.Sentry +import io.sentry.SentryOptions +import io.sentry.SentryReplayEvent +import io.sentry.SentryReplayEvent.ReplayType +import io.sentry.SystemOutLogger +import io.sentry.android.core.SentryAndroid +import io.sentry.android.core.performance.AppStartMetrics +import io.sentry.android.replay.ReplayCache.Companion.ONGOING_SEGMENT +import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_BIT_RATE +import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_FRAME_RATE +import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_HEIGHT +import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_ID +import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_REPLAY_TYPE +import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_TIMESTAMP +import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_WIDTH +import io.sentry.android.replay.util.ReplayShadowMediaCodec +import io.sentry.cache.PersistingOptionsObserver.OPTIONS_CACHE +import io.sentry.cache.PersistingOptionsObserver.REPLAY_ERROR_SAMPLE_RATE_FILENAME +import io.sentry.protocol.Contexts +import io.sentry.protocol.SentryId +import io.sentry.rrweb.RRWebMetaEvent +import io.sentry.rrweb.RRWebVideoEvent +import org.awaitility.kotlin.await +import org.awaitility.kotlin.withAlias +import org.junit.Rule +import org.junit.rules.TemporaryFolder +import org.junit.runner.RunWith +import org.mockito.kotlin.spy +import org.mockito.kotlin.whenever +import org.robolectric.annotation.Config +import org.robolectric.shadow.api.Shadow +import org.robolectric.shadows.ShadowActivityManager +import org.robolectric.shadows.ShadowActivityManager.ApplicationExitInfoBuilder +import java.io.File +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals + +@RunWith(AndroidJUnit4::class) +@Config( + sdk = [30], + shadows = [ReplayShadowMediaCodec::class] +) +class AnrWithReplayIntegrationTest { + + @get:Rule + val tmpDir = TemporaryFolder() + + private class Fixture { + lateinit var shadowActivityManager: ShadowActivityManager + + fun addAppExitInfo( + reason: Int? = ApplicationExitInfo.REASON_ANR, + timestamp: Long? = null, + importance: Int? = null + ) { + val builder = ApplicationExitInfoBuilder.newBuilder() + if (reason != null) { + builder.setReason(reason) + } + if (timestamp != null) { + builder.setTimestamp(timestamp) + } + if (importance != null) { + builder.setImportance(importance) + } + val exitInfo = spy(builder.build()) { + whenever(mock.traceInputStream).thenReturn( + """ +"main" prio=5 tid=1 Blocked + | group="main" sCount=1 ucsCount=0 flags=1 obj=0x72a985e0 self=0xb400007cabc57380 + | sysTid=28941 nice=-10 cgrp=top-app sched=0/0 handle=0x7deceb74f8 + | state=S schedstat=( 324804784 183300334 997 ) utm=23 stm=8 core=3 HZ=100 + | stack=0x7ff93a9000-0x7ff93ab000 stackSize=8188KB + | held mutexes= + at io.sentry.samples.android.MainActivity${'$'}2.run(MainActivity.java:177) + - waiting to lock <0x0d3a2f0a> (a java.lang.Object) held by thread 5 + at android.os.Handler.handleCallback(Handler.java:942) + at android.os.Handler.dispatchMessage(Handler.java:99) + at android.os.Looper.loopOnce(Looper.java:201) + at android.os.Looper.loop(Looper.java:288) + at android.app.ActivityThread.main(ActivityThread.java:7872) + at java.lang.reflect.Method.invoke(Native method) + at com.android.internal.os.RuntimeInit${'$'}MethodAndArgsCaller.run(RuntimeInit.java:548) + at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:936) + +"perfetto_hprof_listener" prio=10 tid=7 Native (still starting up) + | group="" sCount=1 ucsCount=0 flags=1 obj=0x0 self=0xb400007cabc5ab20 + | sysTid=28959 nice=-20 cgrp=top-app sched=0/0 handle=0x7b2021bcb0 + | state=S schedstat=( 72750 1679167 1 ) utm=0 stm=0 core=3 HZ=100 + | stack=0x7b20124000-0x7b20126000 stackSize=991KB + | held mutexes= + native: #00 pc 00000000000a20f4 /apex/com.android.runtime/lib64/bionic/libc.so (read+4) (BuildId: 01331f74b0bb2cb958bdc15282b8ec7b) + native: #01 pc 000000000001d840 /apex/com.android.art/lib64/libperfetto_hprof.so (void* std::__1::__thread_proxy >, ArtPlugin_Initialize::${'$'}_34> >(void*)+260) (BuildId: 525cc92a7dc49130157aeb74f6870364) + native: #02 pc 00000000000b63b0 /apex/com.android.runtime/lib64/bionic/libc.so (__pthread_start(void*)+208) (BuildId: 01331f74b0bb2cb958bdc15282b8ec7b) + native: #03 pc 00000000000530b8 /apex/com.android.runtime/lib64/bionic/libc.so (__start_thread+64) (BuildId: 01331f74b0bb2cb958bdc15282b8ec7b) + (no managed stack frames) + """.trimIndent().byteInputStream() + ) + } + shadowActivityManager.addApplicationExitInfo(exitInfo) + } + + fun prefillOptionsCache(cacheDir: String) { + val optionsDir = File(cacheDir, OPTIONS_CACHE).also { it.mkdirs() } + File(optionsDir, REPLAY_ERROR_SAMPLE_RATE_FILENAME).writeText("\"1.0\"") + } + } + + private val fixture = Fixture() + private lateinit var context: Context + + @BeforeTest + fun `set up`() { + ReplayShadowMediaCodec.framesToEncode = 5 + Sentry.close() + AppStartMetrics.getInstance().clear() + context = ApplicationProvider.getApplicationContext() + val activityManager = context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager? + fixture.shadowActivityManager = Shadow.extract(activityManager) + } + + @Test + fun `replay is being captured for ANRs in buffer mode`() { + ReplayShadowMediaCodec.framesToEncode = 1 + + val cacheDir = tmpDir.newFolder().absolutePath + val oneDayAgo = System.currentTimeMillis() - TimeUnit.DAYS.toMillis(1) + fixture.addAppExitInfo(timestamp = oneDayAgo) + val asserted = AtomicBoolean(false) + + val replayId1 = SentryId() + val replayId2 = SentryId() + + SentryAndroid.init(context) { + it.dsn = "https://key@sentry.io/123" + it.cacheDirPath = cacheDir + it.isDebug = true + it.setLogger(SystemOutLogger()) + it.experimental.sessionReplay.onErrorSampleRate = 1.0 + // beforeSend is called after event processors are applied, so we can assert here + // against the enriched ANR event + it.beforeSend = SentryOptions.BeforeSendCallback { event, _ -> + assertEquals(replayId2.toString(), event.contexts[Contexts.REPLAY_ID]) + event + } + it.addEventProcessor(object : EventProcessor { + override fun process(event: SentryReplayEvent, hint: Hint): SentryReplayEvent { + assertEquals(replayId2, event.replayId) + assertEquals(ReplayType.BUFFER, event.replayType) + assertEquals("0.mp4", event.videoFile?.name) + + val metaEvents = + hint.replayRecording?.payload?.filterIsInstance() + assertEquals(912, metaEvents?.first()?.height) + assertEquals(416, metaEvents?.first()?.width) // clamped to power of 16 + + val videoEvents = + hint.replayRecording?.payload?.filterIsInstance() + assertEquals(912, videoEvents?.first()?.height) + assertEquals(416, videoEvents?.first()?.width) // clamped to power of 16 + assertEquals(1000, videoEvents?.first()?.durationMs) + assertEquals(1, videoEvents?.first()?.frameCount) + assertEquals(1, videoEvents?.first()?.frameRate) + assertEquals(0, videoEvents?.first()?.segmentId) + asserted.set(true) + return event + } + }) + + // have to do it after the cacheDir is set to options, because it adds a dsn hash after + fixture.prefillOptionsCache(it.cacheDirPath!!) + + val replayFolder1 = File(it.cacheDirPath!!, "replay_$replayId1").also { it.mkdirs() } + val replayFolder2 = File(it.cacheDirPath!!, "replay_$replayId2").also { it.mkdirs() } + + File(replayFolder2, ONGOING_SEGMENT).also { file -> + file.writeText( + """ + $SEGMENT_KEY_HEIGHT=912 + $SEGMENT_KEY_WIDTH=416 + $SEGMENT_KEY_FRAME_RATE=1 + $SEGMENT_KEY_BIT_RATE=75000 + $SEGMENT_KEY_ID=0 + $SEGMENT_KEY_TIMESTAMP=2024-07-11T10:25:21.454Z + $SEGMENT_KEY_REPLAY_TYPE=BUFFER + """.trimIndent() + ) + } + + val screenshot = File(replayFolder2, "1720693523997.jpg").also { it.createNewFile() } + screenshot.outputStream().use { os -> + Bitmap.createBitmap(1, 1, ARGB_8888).compress(JPEG, 80, os) + os.flush() + } + + replayFolder1.setLastModified(oneDayAgo - 1000) + replayFolder2.setLastModified(oneDayAgo - 500) + } + + await.withAlias("Failed because of BeforeSend callback above, but we swallow BeforeSend exceptions, hence the timeout") + .untilTrue(asserted) + } +} diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/DefaultReplayBreadcrumbConverterTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/DefaultReplayBreadcrumbConverterTest.kt new file mode 100644 index 0000000000..a659f7f596 --- /dev/null +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/DefaultReplayBreadcrumbConverterTest.kt @@ -0,0 +1,310 @@ +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 `convert RRWebSpanEvent works with floating timestamps`() { + val converter = fixture.getSut() + + val breadcrumb = Breadcrumb(Date(123L)).apply { + category = "http" + data["url"] = "http://example.com" + data["status_code"] = 404 + data["method"] = "GET" + data[SpanDataConvention.HTTP_START_TIMESTAMP] = 1234.0 + data[SpanDataConvention.HTTP_END_TIMESTAMP] = 2234.0 + data["http.response_content_length"] = 300 + data["http.request_content_length"] = 400 + } + + val rrwebEvent = converter.convert(breadcrumb) + + check(rrwebEvent is RRWebSpanEvent) + assertEquals(1.234, rrwebEvent.startTimestamp) + assertEquals(2.234, rrwebEvent.endTimestamp) + } + + @Test + fun `returns null if not eligible for RRWebSpanEvent`() { + val converter = fixture.getSut() + + 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..91a17f5192 --- /dev/null +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayCacheTest.kt @@ -0,0 +1,521 @@ +package io.sentry.android.replay + +import android.graphics.Bitmap +import android.graphics.Bitmap.CompressFormat.JPEG +import android.graphics.Bitmap.Config.ARGB_8888 +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.sentry.DateUtils +import io.sentry.SentryOptions +import io.sentry.SentryReplayEvent.ReplayType +import io.sentry.android.replay.ReplayCache.Companion.ONGOING_SEGMENT +import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_BIT_RATE +import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_FRAME_RATE +import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_HEIGHT +import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_ID +import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_REPLAY_RECORDING +import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_REPLAY_TYPE +import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_TIMESTAMP +import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_WIDTH +import io.sentry.android.replay.util.ReplayShadowMediaCodec +import io.sentry.protocol.SentryId +import io.sentry.rrweb.RRWebInteractionEvent +import io.sentry.rrweb.RRWebInteractionEvent.InteractionType.TouchEnd +import io.sentry.rrweb.RRWebInteractionEvent.InteractionType.TouchStart +import org.junit.Rule +import org.junit.rules.TemporaryFolder +import org.junit.runner.RunWith +import org.robolectric.annotation.Config +import java.io.File +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +@RunWith(AndroidJUnit4::class) +@Config( + sdk = [26], + shadows = [ReplayShadowMediaCodec::class] +) +class ReplayCacheTest { + + @get:Rule + val tmpDir = TemporaryFolder() + + internal class Fixture { + val options = SentryOptions() + fun getSut( + dir: TemporaryFolder?, + replayId: SentryId = SentryId(), + frameRate: Int + ): ReplayCache { + val recorderConfig = ScreenshotRecorderConfig(100, 200, 1f, 1f, frameRate = frameRate, bitRate = 20_000) + options.run { + cacheDirPath = dir?.newFolder()?.absolutePath + } + return ReplayCache(options, replayId, recorderConfig) + } + } + + private val fixture = Fixture() + + @BeforeTest + fun `set up`() { + ReplayShadowMediaCodec.framesToEncode = 5 + } + + @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`() { + ReplayShadowMediaCodec.framesToEncode = 3 + val replayCache = fixture.getSut( + tmpDir, + frameRate = 1 + ) + + 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 + ) + + 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 + ) + + 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 + ) + + 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`() { + ReplayShadowMediaCodec.framesToEncode = 6 + + val replayCache = fixture.getSut( + tmpDir, + frameRate = 2 + ) + + 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 `does not add frame when bitmap is recycled`() { + val replayCache = fixture.getSut( + tmpDir, + frameRate = 1 + ) + + val bitmap = Bitmap.createBitmap(1, 1, ARGB_8888).also { it.recycle() } + replayCache.addFrame(bitmap, 1) + + assertTrue(replayCache.frames.isEmpty()) + } + + @Test + fun `addFrame with File path works`() { + val replayCache = fixture.getSut( + tmpDir, + frameRate = 1 + ) + + 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") + + val bitmap = Bitmap.createBitmap(1, 1, ARGB_8888).also { it.recycle() } + replayCache.addFrame(screenshot, frameTimestamp = 1) + + val segment0 = replayCache.createVideoOf(5000L, 0, 0, 100, 200, videoFile = video) + 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 + ) + + 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" }) + } + + @Test + fun `rotate returns first screen in buffer`() { + val replayCache = fixture.getSut( + tmpDir, + frameRate = 1 + ) + + val bitmap = Bitmap.createBitmap(1, 1, ARGB_8888) + replayCache.addFrame(bitmap, 1, "MainActivity") + replayCache.addFrame(bitmap, 1001, "SecondActivity") + replayCache.addFrame(bitmap, 2001, "ThirdActivity") + replayCache.addFrame(bitmap, 3001, "FourthActivity") + + val screen = replayCache.rotate(2000) + assertEquals("ThirdActivity", screen) + } + + @Test + fun `does not persist segment if already closed`() { + val replayId = SentryId() + val replayCache = fixture.getSut( + tmpDir, + replayId, + frameRate = 1 + ) + + replayCache.close() + + replayCache.persistSegmentValues("key", "value") + assertFalse(File(replayCache.replayCacheDir, ONGOING_SEGMENT).exists()) + } + + @Test + fun `stores segment key value pairs`() { + val replayId = SentryId() + val replayCache = fixture.getSut( + tmpDir, + replayId, + frameRate = 1 + ) + + replayCache.persistSegmentValues("key1", "value1") + replayCache.persistSegmentValues("key2", "value2") + + val segmentValues = File(replayCache.replayCacheDir, ONGOING_SEGMENT).readLines() + assertEquals("key1=value1", segmentValues[0]) + assertEquals("key2=value2", segmentValues[1]) + } + + @Test + fun `removes segment key value pair, if the value is null`() { + val replayId = SentryId() + val replayCache = fixture.getSut( + tmpDir, + replayId, + frameRate = 1 + ) + + replayCache.persistSegmentValues("key1", "value1") + replayCache.persistSegmentValues("key2", "value2") + + replayCache.persistSegmentValues("key1", null) + + val segmentValues = File(replayCache.replayCacheDir, ONGOING_SEGMENT).readLines() + assertEquals(1, segmentValues.size) + assertEquals("key2=value2", segmentValues[0]) + } + + @Test + fun `if no ongoing_segment file exists, deletes replay folder`() { + fixture.options.run { + cacheDirPath = tmpDir.newFolder()?.absolutePath + } + val replayId = SentryId() + val replayCacheFolder = File(fixture.options.cacheDirPath!!, "replay_$replayId") + val lastSegment = ReplayCache.fromDisk(fixture.options, replayId) + + assertNull(lastSegment) + assertFalse(replayCacheFolder.exists()) + } + + @Test + fun `if one of the required segment values is not present, deletes replay folder`() { + fixture.options.run { + cacheDirPath = tmpDir.newFolder()?.absolutePath + } + val replayId = SentryId() + val replayCacheFolder = File(fixture.options.cacheDirPath!!, "replay_$replayId").also { it.mkdirs() } + File(replayCacheFolder, ONGOING_SEGMENT).also { + it.writeText( + """ + $SEGMENT_KEY_HEIGHT=912 + $SEGMENT_KEY_WIDTH=416 + $SEGMENT_KEY_FRAME_RATE=1 + $SEGMENT_KEY_BIT_RATE=75000 + $SEGMENT_KEY_ID=0 + $SEGMENT_KEY_TIMESTAMP=2024-07-11T10:25:21.454Z + """.trimIndent() + ) + // omitting replay type, which is required, for the test + } + + val lastSegment = ReplayCache.fromDisk(fixture.options, replayId) + + assertNull(lastSegment) + assertFalse(replayCacheFolder.exists()) + } + + @Test + fun `returns last segment data when all values are present`() { + fixture.options.run { + cacheDirPath = tmpDir.newFolder()?.absolutePath + } + val replayId = SentryId() + val replayCacheFolder = File(fixture.options.cacheDirPath!!, "replay_$replayId").also { it.mkdirs() } + File(replayCacheFolder, ONGOING_SEGMENT).also { + it.writeText( + """ + $SEGMENT_KEY_HEIGHT=912 + $SEGMENT_KEY_WIDTH=416 + $SEGMENT_KEY_FRAME_RATE=1 + $SEGMENT_KEY_BIT_RATE=75000 + $SEGMENT_KEY_ID=0 + $SEGMENT_KEY_TIMESTAMP=2024-07-11T10:25:21.454Z + $SEGMENT_KEY_REPLAY_TYPE=SESSION + $SEGMENT_KEY_REPLAY_RECORDING={}[{"type":3,"timestamp":1720693523997,"data":{"source":2,"type":7,"id":0,"x":314.2979431152344,"y":625.44140625,"pointerType":2,"pointerId":0}},{"type":3,"timestamp":1720693524774,"data":{"source":2,"type":9,"id":0,"x":322.00390625,"y":424.4384765625,"pointerType":2,"pointerId":0}}] + """.trimIndent() + ) + } + + val screenshot = File(replayCacheFolder, "1720693523997.jpg").also { it.createNewFile() } + screenshot.outputStream().use { + Bitmap.createBitmap(1, 1, ARGB_8888).compress(JPEG, 80, it) + it.flush() + } + + val lastSegment = ReplayCache.fromDisk(fixture.options, replayId)!! + + assertEquals(912, lastSegment.recorderConfig.recordingHeight) + assertEquals(416, lastSegment.recorderConfig.recordingWidth) + assertEquals(1, lastSegment.recorderConfig.frameRate) + assertEquals(75000, lastSegment.recorderConfig.bitRate) + assertEquals(0, lastSegment.id) + assertEquals("2024-07-11T10:25:21.454Z", DateUtils.getTimestamp(lastSegment.timestamp)) + assertEquals(ReplayType.SESSION, lastSegment.replayType) + assertEquals(3543, lastSegment.duration) // duration + 1 frame duration + assertTrue { + val firstEvent = lastSegment.events.first() as RRWebInteractionEvent + firstEvent.timestamp == 1720693523997 && + firstEvent.interactionType == TouchStart && + firstEvent.x.toDouble() == 314.2979431152344 && + firstEvent.y.toDouble() == 625.44140625 + } + assertTrue { + val lastEvent = lastSegment.events.last() as RRWebInteractionEvent + lastEvent.timestamp == 1720693524774 && + lastEvent.interactionType == TouchEnd && + lastEvent.x.toDouble() == 322.00390625 && + lastEvent.y.toDouble() == 424.4384765625 + } + } + + @Test + fun `fills in cache with frames from disk`() { + fixture.options.run { + cacheDirPath = tmpDir.newFolder()?.absolutePath + } + val replayId = SentryId() + val replayCacheFolder = File(fixture.options.cacheDirPath!!, "replay_$replayId").also { it.mkdirs() } + File(replayCacheFolder, ONGOING_SEGMENT).also { + it.writeText( + """ + $SEGMENT_KEY_HEIGHT=912 + $SEGMENT_KEY_WIDTH=416 + $SEGMENT_KEY_FRAME_RATE=1 + $SEGMENT_KEY_BIT_RATE=75000 + $SEGMENT_KEY_ID=0 + $SEGMENT_KEY_TIMESTAMP=2024-07-11T10:25:21.454Z + $SEGMENT_KEY_REPLAY_TYPE=SESSION + """.trimIndent() + ) + } + + val screenshot = File(replayCacheFolder, "1.jpg").also { it.createNewFile() } + screenshot.outputStream().use { + Bitmap.createBitmap(1, 1, ARGB_8888).compress(JPEG, 80, it) + it.flush() + } + + val lastSegment = ReplayCache.fromDisk(fixture.options, replayId)!! + + assertEquals(1, lastSegment.cache.frames.size) + assertEquals(1, lastSegment.cache.frames.first().timestamp) + assertEquals("1.jpg", lastSegment.cache.frames.first().screenshot.name) + } + + @Test + fun `when videoFile exists and is not empty, deletes it before writing`() { + ReplayShadowMediaCodec.framesToEncode = 3 + + val replayCache = fixture.getSut( + tmpDir, + frameRate = 1 + ) + + val oldVideoFile = File(replayCache.replayCacheDir, "0.mp4").also { + it.createNewFile() + it.writeBytes(byteArrayOf(1, 2, 3)) + } + val bitmap = Bitmap.createBitmap(1, 1, ARGB_8888) + replayCache.addFrame(bitmap, 1) + replayCache.addFrame(bitmap, 1001) + replayCache.addFrame(bitmap, 2001) + + val segment0 = replayCache.createVideoOf(3000L, 0, 0, 100, 200, oldVideoFile) + assertEquals(3, segment0!!.frameCount) + assertEquals(3000, segment0.duration) + assertTrue { segment0.video.exists() && segment0.video.length() > 0 } + assertEquals(File(replayCache.replayCacheDir, "0.mp4"), segment0.video) + } + + @Test + fun `sets segmentId to 0 for buffer mode`() { + fixture.options.run { + cacheDirPath = tmpDir.newFolder()?.absolutePath + } + val replayId = SentryId() + val replayCacheFolder = File(fixture.options.cacheDirPath!!, "replay_$replayId").also { it.mkdirs() } + File(replayCacheFolder, ONGOING_SEGMENT).also { + it.writeText( + """ + $SEGMENT_KEY_HEIGHT=912 + $SEGMENT_KEY_WIDTH=416 + $SEGMENT_KEY_FRAME_RATE=1 + $SEGMENT_KEY_BIT_RATE=75000 + $SEGMENT_KEY_ID=2 + $SEGMENT_KEY_TIMESTAMP=2024-07-11T10:25:21.454Z + $SEGMENT_KEY_REPLAY_TYPE=BUFFER + """.trimIndent() + ) + } + + val screenshot = File(replayCacheFolder, "1720693523997.jpg").also { it.createNewFile() } + screenshot.outputStream().use { + Bitmap.createBitmap(1, 1, ARGB_8888).compress(JPEG, 80, it) + it.flush() + } + + val lastSegment = ReplayCache.fromDisk(fixture.options, replayId)!! + + assertEquals(0, lastSegment.id) + } +} diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationTest.kt new file mode 100644 index 0000000000..4b10043e72 --- /dev/null +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationTest.kt @@ -0,0 +1,570 @@ +package io.sentry.android.replay + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.Bitmap.CompressFormat.JPEG +import android.graphics.Bitmap.Config.ARGB_8888 +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.sentry.Breadcrumb +import io.sentry.DateUtils +import io.sentry.Hint +import io.sentry.IScopes +import io.sentry.Scope +import io.sentry.ScopeCallback +import io.sentry.SentryEvent +import io.sentry.SentryIntegrationPackageStorage +import io.sentry.SentryOptions +import io.sentry.SentryReplayEvent.ReplayType +import io.sentry.android.replay.ReplayCache.Companion.ONGOING_SEGMENT +import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_BIT_RATE +import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_FRAME_RATE +import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_HEIGHT +import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_ID +import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_REPLAY_RECORDING +import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_REPLAY_TYPE +import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_TIMESTAMP +import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_WIDTH +import io.sentry.android.replay.capture.CaptureStrategy +import io.sentry.android.replay.capture.SessionCaptureStrategyTest.Fixture.Companion.VIDEO_DURATION +import io.sentry.android.replay.gestures.GestureRecorder +import io.sentry.cache.PersistingScopeObserver +import io.sentry.protocol.SentryException +import io.sentry.protocol.SentryId +import io.sentry.rrweb.RRWebBreadcrumbEvent +import io.sentry.rrweb.RRWebInteractionEvent +import io.sentry.rrweb.RRWebInteractionEvent.InteractionType +import io.sentry.rrweb.RRWebMetaEvent +import io.sentry.rrweb.RRWebVideoEvent +import io.sentry.transport.CurrentDateProvider +import io.sentry.transport.ICurrentDateProvider +import org.junit.Rule +import org.junit.rules.TemporaryFolder +import org.junit.runner.RunWith +import org.mockito.ArgumentMatchers.anyInt +import org.mockito.ArgumentMatchers.anyLong +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.argThat +import org.mockito.kotlin.check +import org.mockito.kotlin.doAnswer +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.robolectric.annotation.Config +import java.io.File +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 { + @get:Rule + val tmpDir = TemporaryFolder() + + internal class Fixture { + val options = SentryOptions().apply { + setReplayController( + mock { + on { breadcrumbConverter }.thenReturn(DefaultReplayBreadcrumbConverter()) + } + ) + executorService = mock { + doAnswer { + (it.arguments[0] as Runnable).run() + }.whenever(mock).submit(any()) + } + } + val scope = Scope(options) + val scopes = mock { + doAnswer { + ((it.arguments[0]) as ScopeCallback).run(scope) + }.whenever(mock).configureScope(any()) + } + + val replayCache = mock { + on { frames }.thenReturn(mutableListOf(ReplayFrame(File("1720693523997.jpg"), 1720693523997))) + on { createVideoOf(anyLong(), anyLong(), anyInt(), anyInt(), anyInt(), any()) } + .thenReturn(GeneratedVideo(File("0.mp4"), 5, VIDEO_DURATION)) + } + + fun getSut( + context: Context, + sessionSampleRate: Double = 1.0, + onErrorSampleRate: Double = 1.0, + recorderProvider: (() -> Recorder)? = null, + replayCaptureStrategyProvider: ((isFullSession: Boolean) -> CaptureStrategy)? = null, + recorderConfigProvider: ((configChanged: Boolean) -> ScreenshotRecorderConfig)? = null, + gestureRecorderProvider: (() -> GestureRecorder)? = null, + dateProvider: ICurrentDateProvider = CurrentDateProvider.getInstance() + ): ReplayIntegration { + options.run { + experimental.sessionReplay.onErrorSampleRate = onErrorSampleRate + experimental.sessionReplay.sessionSampleRate = sessionSampleRate + } + return ReplayIntegration( + context, + dateProvider, + recorderProvider, + recorderConfigProvider = recorderConfigProvider, + replayCacheProvider = { _, _ -> replayCache }, + replayCaptureStrategyProvider = replayCaptureStrategyProvider, + gestureRecorderProvider = gestureRecorderProvider + ) + } + } + + 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.scopes, 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.scopes, 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.scopes, fixture.options) + + assertTrue(replay.isEnabled.get()) + 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(any(), any(), any(), anyOrNull()) + } + + @Test + fun `start sets isRecording to true`() { + val captureStrategy = mock() + val replay = fixture.getSut(context, replayCaptureStrategyProvider = { captureStrategy }) + + replay.register(fixture.scopes, 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.scopes, fixture.options) + replay.start() + replay.start() + + verify(captureStrategy, times(1)).start( + any(), + eq(0), + argThat { this != SentryId.EMPTY_ID }, + anyOrNull() + ) + } + + @Test + fun `does not start replay when session is not sampled`() { + val captureStrategy = mock() + val replay = fixture.getSut(context, onErrorSampleRate = 0.0, sessionSampleRate = 0.0, replayCaptureStrategyProvider = { captureStrategy }) + + replay.register(fixture.scopes, fixture.options) + replay.start() + + verify(captureStrategy, never()).start( + any(), + eq(0), + argThat { this != SentryId.EMPTY_ID }, + anyOrNull() + ) + } + + @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.scopes, fixture.options) + replay.start() + + verify(captureStrategy, times(1)).start( + any(), + eq(0), + argThat { this != SentryId.EMPTY_ID }, + anyOrNull() + ) + } + + @Test + fun `calls recorder start`() { + val recorder = mock() + val replay = fixture.getSut(context, recorderProvider = { recorder }) + + replay.register(fixture.scopes, 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.scopes, 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.scopes, fixture.options) + replay.start() + replay.resume() + + verify(captureStrategy).resume() + verify(recorder).resume() + } + + @Test + fun `captureReplay does nothing when not recording`() { + val captureStrategy = mock() + val replay = fixture.getSut(context, replayCaptureStrategyProvider = { captureStrategy }) + + replay.register(fixture.scopes, fixture.options) + + val event = SentryEvent().apply { + exceptions = listOf(SentryException()) + } + replay.captureReplay(event.isCrashed) + + verify(captureStrategy, never()).captureReplay(any(), any()) + } + + @Test + fun `captureReplay does nothing when currentReplayId is not set`() { + val captureStrategy = mock { + whenever(mock.currentReplayId).thenReturn(SentryId.EMPTY_ID) + } + val replay = fixture.getSut(context, replayCaptureStrategyProvider = { captureStrategy }) + + replay.register(fixture.scopes, fixture.options) + replay.start() + + val event = SentryEvent().apply { + exceptions = listOf(SentryException()) + } + replay.captureReplay(event.isCrashed) + + verify(captureStrategy, never()).captureReplay(any(), any()) + } + + @Test + fun `captureReplay calls and converts strategy`() { + val captureStrategy = mock { + whenever(mock.currentReplayId).thenReturn(SentryId()) + } + val replay = fixture.getSut(context, replayCaptureStrategyProvider = { captureStrategy }) + + replay.register(fixture.scopes, fixture.options) + replay.start() + + val id = SentryId() + val event = SentryEvent().apply { + exceptions = listOf(SentryException()) + } + event.eventId = id + val hint = Hint() + replay.captureReplay(event.isCrashed) + + verify(captureStrategy).captureReplay(eq(false), 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.scopes, 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.scopes, 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.scopes, fixture.options) + replay.stop() + + verify(captureStrategy, never()).stop() + verify(recorder, never()).stop() + } + + @Test + fun `stop calls stop for recorders and strategy and sets recording to false`() { + val captureStrategy = mock() + val recorder = mock() + val gestureRecorder = mock() + val replay = fixture.getSut( + context, + recorderProvider = { recorder }, + replayCaptureStrategyProvider = { captureStrategy }, + gestureRecorderProvider = { gestureRecorder } + ) + + replay.register(fixture.scopes, fixture.options) + replay.start() + replay.stop() + + verify(captureStrategy).stop() + verify(recorder).stop() + verify(gestureRecorder).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.scopes, 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.scopes, 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.scopes, fixture.options) + replay.start() + replay.onConfigurationChanged(mock()) + + verify(recorder).stop() + verify(captureStrategy).onConfigurationChanged(eq(recorderConfig)) + verify(recorder, times(2)).start(eq(recorderConfig)) + assertTrue(configChanged) + } + + @Test + fun `register finalizes previous replay`() { + val oldReplayId = SentryId() + + fixture.options.cacheDirPath = tmpDir.newFolder().absolutePath + val oldReplay = + File(fixture.options.cacheDirPath, "replay_$oldReplayId").also { it.mkdirs() } + val screenshot = File(oldReplay, "1720693523997.jpg").also { it.createNewFile() } + screenshot.outputStream().use { + Bitmap.createBitmap(1, 1, ARGB_8888).compress(JPEG, 80, it) + it.flush() + } + val scopeCache = File( + fixture.options.cacheDirPath, + PersistingScopeObserver.SCOPE_CACHE + ).also { it.mkdirs() } + File(scopeCache, PersistingScopeObserver.REPLAY_FILENAME).also { + it.createNewFile() + it.writeText("\"$oldReplayId\"") + } + val breadcrumbsFile = File(scopeCache, PersistingScopeObserver.BREADCRUMBS_FILENAME) + fixture.options.serializer.serialize( + listOf( + Breadcrumb(DateUtils.getDateTime("2024-07-11T10:25:23.454Z")).apply { + category = "navigation" + type = "navigation" + setData("from", "from") + setData("to", "to") + } + ), + breadcrumbsFile.writer() + ) + File(oldReplay, ONGOING_SEGMENT).also { + it.writeText( + """ + $SEGMENT_KEY_HEIGHT=912 + $SEGMENT_KEY_WIDTH=416 + $SEGMENT_KEY_FRAME_RATE=1 + $SEGMENT_KEY_BIT_RATE=75000 + $SEGMENT_KEY_ID=1 + $SEGMENT_KEY_TIMESTAMP=2024-07-11T10:25:21.454Z + $SEGMENT_KEY_REPLAY_TYPE=SESSION + $SEGMENT_KEY_REPLAY_RECORDING={}[{"type":3,"timestamp":1720693523997,"data":{"source":2,"type":7,"id":0,"x":314.2979431152344,"y":625.44140625,"pointerType":2,"pointerId":0}},{"type":3,"timestamp":1720693524774,"data":{"source":2,"type":9,"id":0,"x":322.00390625,"y":424.4384765625,"pointerType":2,"pointerId":0}}] + """.trimIndent() + ) + } + + val replay = fixture.getSut(context) + replay.register(fixture.scopes, fixture.options) + + assertTrue(oldReplay.exists()) // should not be deleted until the video is packed into envelope + verify(fixture.scopes).captureReplay( + check { + assertEquals(oldReplayId, it.replayId) + assertEquals(ReplayType.SESSION, it.replayType) + assertEquals("0.mp4", it.videoFile?.name) + }, + check { + val metaEvents = it.replayRecording?.payload?.filterIsInstance() + assertEquals(912, metaEvents?.first()?.height) + assertEquals(416, metaEvents?.first()?.width) // clamped to power of 16 + + val videoEvents = it.replayRecording?.payload?.filterIsInstance() + assertEquals(912, videoEvents?.first()?.height) + assertEquals(416, videoEvents?.first()?.width) // clamped to power of 16 + assertEquals(5000, videoEvents?.first()?.durationMs) + assertEquals(5, videoEvents?.first()?.frameCount) + assertEquals(1, videoEvents?.first()?.frameRate) + assertEquals(1, videoEvents?.first()?.segmentId) + + val breadcrumbEvents = + it.replayRecording?.payload?.filterIsInstance() + assertEquals("navigation", breadcrumbEvents?.first()?.category) + assertEquals("to", breadcrumbEvents?.first()?.data?.get("to")) + + val interactionEvents = + it.replayRecording?.payload?.filterIsInstance() + assertEquals( + InteractionType.TouchStart, + interactionEvents?.first()?.interactionType + ) + assertEquals(314.29794f, interactionEvents?.first()?.x) + assertEquals(625.4414f, interactionEvents?.first()?.y) + + assertEquals(InteractionType.TouchEnd, interactionEvents?.last()?.interactionType) + assertEquals(322.0039f, interactionEvents?.last()?.x) + assertEquals(424.43848f, interactionEvents?.last()?.y) + } + ) + } + + @Test + fun `register cleans up old replays`() { + val replayId = SentryId() + + fixture.options.cacheDirPath = tmpDir.newFolder().absolutePath + val evenOlderReplay = + File(fixture.options.cacheDirPath, "replay_${SentryId()}").also { it.mkdirs() } + val scopeCache = File( + fixture.options.cacheDirPath, + PersistingScopeObserver.SCOPE_CACHE + ).also { it.mkdirs() } + + val captureStrategy = mock { + on { currentReplayId }.thenReturn(replayId) + } + val replay = fixture.getSut(context, replayCaptureStrategyProvider = { captureStrategy }) + replay.register(fixture.scopes, fixture.options) + + assertTrue(scopeCache.exists()) + assertFalse(evenOlderReplay.exists()) + } + + @Test + fun `onScreenshotRecorded supplies screen from scope to replay cache`() { + val captureStrategy = mock { + doAnswer { + ((it.arguments[1] as ReplayCache.(frameTimestamp: Long) -> Unit)).invoke(fixture.replayCache, 1720693523997) + }.whenever(mock).onScreenshotRecorded(anyOrNull(), any()) + } + val replay = fixture.getSut(context, replayCaptureStrategyProvider = { captureStrategy }) + + fixture.scopes.configureScope { it.screen = "MainActivity" } + replay.register(fixture.scopes, fixture.options) + replay.start() + + replay.onScreenshotRecorded(mock()) + + verify(fixture.replayCache).addFrame(any(), any(), eq("MainActivity")) + } +} diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationWithRecorderTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationWithRecorderTest.kt new file mode 100644 index 0000000000..03fdc43b86 --- /dev/null +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationWithRecorderTest.kt @@ -0,0 +1,189 @@ +package io.sentry.android.replay + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.Bitmap.CompressFormat.JPEG +import android.graphics.Bitmap.Config.ARGB_8888 +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.sentry.IScopes +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.util.ReplayShadowMediaCodec +import io.sentry.rrweb.RRWebMetaEvent +import io.sentry.rrweb.RRWebVideoEvent +import io.sentry.transport.CurrentDateProvider +import io.sentry.transport.ICurrentDateProvider +import io.sentry.util.thread.NoOpThreadChecker +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.atomic.AtomicBoolean +import kotlin.test.BeforeTest +import kotlin.test.assertEquals + +@RunWith(AndroidJUnit4::class) +@Config( + sdk = [26], + shadows = [ReplayShadowMediaCodec::class] +) +class ReplayIntegrationWithRecorderTest { + + @get:Rule + val tmpDir = TemporaryFolder() + + internal class Fixture { + val options = SentryOptions().apply { + threadChecker = NoOpThreadChecker.getInstance() + } + val scopes = mock() + + fun getSut( + context: Context, + recorder: Recorder, + recorderConfig: ScreenshotRecorderConfig, + dateProvider: ICurrentDateProvider = CurrentDateProvider.getInstance() + ): ReplayIntegration { + return ReplayIntegration( + context, + dateProvider, + recorderProvider = { recorder }, + recorderConfigProvider = { recorderConfig } + ) + } + } + + private val fixture = Fixture() + private lateinit var context: Context + + @BeforeTest + fun `set up`() { + ReplayShadowMediaCodec.framesToEncode = 5 + context = ApplicationProvider.getApplicationContext() + } + + @Test + fun `works with different recorder`() { + val captured = AtomicBoolean(false) + whenever(fixture.scopes.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) + replay.register(fixture.scopes, 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.scopes).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..831f11428e --- /dev/null +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplaySmokeTest.kt @@ -0,0 +1,231 @@ +package io.sentry.android.replay + +import android.app.Activity +import android.content.Context +import android.graphics.drawable.Drawable +import android.os.Bundle +import android.os.Handler +import android.os.Handler.Callback +import android.os.Looper +import android.widget.ImageView +import android.widget.LinearLayout +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.IScopes +import io.sentry.Scope +import io.sentry.ScopeCallback +import io.sentry.SentryOptions +import io.sentry.SentryReplayEvent.ReplayType +import io.sentry.android.replay.util.ReplayShadowMediaCodec +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.atomic.AtomicBoolean +import kotlin.test.BeforeTest +import kotlin.test.assertEquals + +@RunWith(AndroidJUnit4::class) +@Config( + shadows = [ShadowPixelCopy::class, ReplayShadowMediaCodec::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 scopes = mock { + doAnswer { + (it.arguments[0] as ScopeCallback).run(scope) + }.whenever(it).configureScope(any()) + } + var count: Int = 0 + + private class ImmediateHandler : Handler(Callback { it.callback?.run(); true }) + + fun getSut( + context: Context, + dateProvider: ICurrentDateProvider = CurrentDateProvider.getInstance() + ): ReplayIntegration { + return ReplayIntegration( + context, + dateProvider, + recorderProvider = null, + recorderConfigProvider = null, + replayCaptureStrategyProvider = null, + replayCacheProvider = null, + mainLooperHandler = mock { + whenever(mock.handler).thenReturn(ImmediateHandler()) + whenever(mock.post(any())).then { + (it.arguments[0] as Runnable).run() + count++ + } + } + ) + } + } + + private val fixture = Fixture() + private lateinit var context: Context + + @BeforeTest + fun `set up`() { + ReplayShadowMediaCodec.framesToEncode = 5 + context = ApplicationProvider.getApplicationContext() + } + + @Test + fun `works in session mode`() { + val captured = AtomicBoolean(false) + whenever(fixture.scopes.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) + replay.register(fixture.scopes, 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.scopes).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`() { + ReplayShadowMediaCodec.framesToEncode = 10 + + val captured = AtomicBoolean(false) + whenever(fixture.scopes.captureReplay(any(), anyOrNull())).then { + captured.set(true) + } + + fixture.options.experimental.sessionReplay.onErrorSampleRate = 1.0 + fixture.options.cacheDirPath = tmpDir.newFolder().absolutePath + + val replay: ReplayIntegration = fixture.getSut(context) + replay.register(fixture.scopes, 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) { + } + + replay.captureReplay(isTerminating = false) + + await.timeout(Duration.ofSeconds(5)).untilTrue(captured) + + verify(fixture.scopes).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/java/io/sentry/android/replay/capture/BufferCaptureStrategyTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/BufferCaptureStrategyTest.kt new file mode 100644 index 0000000000..840035989f --- /dev/null +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/BufferCaptureStrategyTest.kt @@ -0,0 +1,311 @@ +package io.sentry.android.replay.capture + +import android.graphics.Bitmap +import android.view.MotionEvent +import io.sentry.IScopes +import io.sentry.Scope +import io.sentry.ScopeCallback +import io.sentry.SentryOptions +import io.sentry.SentryReplayEvent.ReplayType +import io.sentry.android.replay.DefaultReplayBreadcrumbConverter +import io.sentry.android.replay.GeneratedVideo +import io.sentry.android.replay.ReplayCache +import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_ID +import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_REPLAY_ID +import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_REPLAY_TYPE +import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_TIMESTAMP +import io.sentry.android.replay.ReplayFrame +import io.sentry.android.replay.ScreenshotRecorderConfig +import io.sentry.android.replay.capture.BufferCaptureStrategyTest.Fixture.Companion.VIDEO_DURATION +import io.sentry.protocol.SentryId +import io.sentry.transport.CurrentDateProvider +import io.sentry.transport.ICurrentDateProvider +import org.awaitility.kotlin.await +import org.junit.Rule +import org.junit.rules.TemporaryFolder +import org.mockito.ArgumentMatchers.anyInt +import org.mockito.ArgumentMatchers.anyLong +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.doAnswer +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import java.io.File +import java.security.SecureRandom +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class BufferCaptureStrategyTest { + + @get:Rule + val tmpDir = TemporaryFolder() + + internal class Fixture { + companion object { + const val VIDEO_DURATION = 5000L + } + + val options = SentryOptions().apply { + setReplayController( + mock { + on { breadcrumbConverter }.thenReturn(DefaultReplayBreadcrumbConverter()) + } + ) + } + val scope = Scope(options) + val scopes = mock { + doAnswer { + (it.arguments[0] as ScopeCallback).run(scope) + }.whenever(it).configureScope(any()) + } + var persistedSegment = LinkedHashMap() + val replayCache = mock { + on { frames }.thenReturn(mutableListOf(ReplayFrame(File("1720693523997.jpg"), 1720693523997))) + on { persistSegmentValues(any(), anyOrNull()) }.then { + persistedSegment.put(it.arguments[0].toString(), it.arguments[1]?.toString()) + } + on { createVideoOf(anyLong(), anyLong(), anyInt(), anyInt(), anyInt(), any()) } + .thenReturn(GeneratedVideo(File("0.mp4"), 5, VIDEO_DURATION)) + } + val recorderConfig = ScreenshotRecorderConfig( + recordingWidth = 1080, + recordingHeight = 1920, + scaleFactorX = 1f, + scaleFactorY = 1f, + frameRate = 1, + bitRate = 20_000 + ) + + fun getSut( + onErrorSampleRate: Double = 1.0, + dateProvider: ICurrentDateProvider = CurrentDateProvider.getInstance(), + replayCacheDir: File? = null + ): BufferCaptureStrategy { + replayCacheDir?.let { + whenever(replayCache.replayCacheDir).thenReturn(it) + } + options.run { + experimental.sessionReplay.onErrorSampleRate = onErrorSampleRate + } + return BufferCaptureStrategy( + options, + scopes, + dateProvider, + SecureRandom(), + mock { + doAnswer { invocation -> + (invocation.arguments[0] as Runnable).run() + null + }.whenever(it).submit(any()) + } + ) { _, _ -> replayCache } + } + + fun mockedMotionEvent(action: Int): MotionEvent = mock { + on { actionMasked }.thenReturn(action) + on { getPointerId(anyInt()) }.thenReturn(0) + on { findPointerIndex(anyInt()) }.thenReturn(0) + on { getX(anyInt()) }.thenReturn(1f) + on { getY(anyInt()) }.thenReturn(1f) + } + } + + private val fixture = Fixture() + + @Test + fun `start does not set replayId on scope for buffered session`() { + val strategy = fixture.getSut() + val replayId = SentryId() + + strategy.start(fixture.recorderConfig, 0, replayId) + + assertEquals(SentryId.EMPTY_ID, fixture.scope.replayId) + assertEquals(replayId, strategy.currentReplayId) + assertEquals(0, strategy.currentSegment) + } + + @Test + fun `start persists segment values`() { + val strategy = fixture.getSut() + val replayId = SentryId() + + strategy.start(fixture.recorderConfig, 0, replayId) + + assertEquals("0", fixture.persistedSegment[SEGMENT_KEY_ID]) + assertEquals(replayId.toString(), fixture.persistedSegment[SEGMENT_KEY_REPLAY_ID]) + assertEquals( + ReplayType.BUFFER.toString(), + fixture.persistedSegment[SEGMENT_KEY_REPLAY_TYPE] + ) + assertTrue(fixture.persistedSegment[SEGMENT_KEY_TIMESTAMP]?.isNotEmpty() == true) + } + + @Test + fun `pause creates but does not capture current segment`() { + val strategy = fixture.getSut() + strategy.start(fixture.recorderConfig, 0, SentryId()) + + strategy.pause() + + await.until { strategy.currentSegment == 1 } + + verify(fixture.scopes, never()).captureReplay(any(), any()) + assertEquals(1, strategy.currentSegment) + } + + @Test + fun `stop clears replay cache dir`() { + val replayId = SentryId() + val currentReplay = + File(fixture.options.cacheDirPath, "replay_$replayId").also { it.mkdirs() } + + val strategy = fixture.getSut(replayCacheDir = currentReplay) + strategy.start(fixture.recorderConfig, 0, replayId) + + strategy.stop() + + verify(fixture.scopes, never()).captureReplay(any(), any()) + + assertEquals(SentryId.EMPTY_ID, strategy.currentReplayId) + assertEquals(-1, strategy.currentSegment) + assertFalse(currentReplay.exists()) + verify(fixture.replayCache).close() + } + + @Test + fun `onScreenshotRecorded adds screenshot to cache`() { + val now = + System.currentTimeMillis() + (fixture.options.experimental.sessionReplay.errorReplayDuration * 5) + val strategy = fixture.getSut( + dateProvider = { now } + ) + strategy.start(fixture.recorderConfig) + + strategy.onScreenshotRecorded(mock()) { frameTimestamp -> + assertEquals(now, frameTimestamp) + } + } + + @Test + fun `onScreenshotRecorded rotates screenshots when out of buffer bounds`() { + val now = + System.currentTimeMillis() + (fixture.options.experimental.sessionReplay.errorReplayDuration * 5) + val strategy = fixture.getSut( + dateProvider = { now } + ) + strategy.start(fixture.recorderConfig) + + strategy.onScreenshotRecorded(mock()) { frameTimestamp -> + assertEquals(now, frameTimestamp) + } + verify(fixture.replayCache).rotate(eq(now - fixture.options.experimental.sessionReplay.errorReplayDuration)) + } + + @Test + fun `onConfigurationChanged creates new segment and updates config`() { + val strategy = fixture.getSut() + strategy.start(fixture.recorderConfig) + + val newConfig = fixture.recorderConfig.copy(recordingHeight = 1080, recordingWidth = 1920) + strategy.onConfigurationChanged(newConfig) + + await.until { strategy.currentSegment == 1 } + + verify(fixture.scopes, never()).captureReplay(any(), any()) + assertEquals(1, strategy.currentSegment) + } + + @Test + fun `convert does nothing when process is terminating`() { + val strategy = fixture.getSut() + strategy.start(fixture.recorderConfig) + + strategy.captureReplay(true) {} + + val converted = strategy.convert() + assertTrue(converted is BufferCaptureStrategy) + } + + @Test + fun `convert converts to session strategy and sets replayId to scope`() { + val strategy = fixture.getSut() + strategy.start(fixture.recorderConfig) + + val converted = strategy.convert() + assertTrue(converted is SessionCaptureStrategy) + assertEquals(strategy.currentReplayId, fixture.scope.replayId) + } + + @Test + fun `convert persists buffer replayType when converting to session strategy`() { + val strategy = fixture.getSut() + strategy.start(fixture.recorderConfig) + + val converted = strategy.convert() + assertEquals( + ReplayType.BUFFER, + converted.replayType + ) + } + + @Test + fun `captureReplay does not replayId to scope when not sampled`() { + val strategy = fixture.getSut(onErrorSampleRate = 0.0) + strategy.start(fixture.recorderConfig) + + strategy.captureReplay(false) {} + + assertEquals(SentryId.EMPTY_ID, fixture.scope.replayId) + } + + @Test + fun `captureReplay sets replayId to scope and captures buffered segments`() { + var called = false + val strategy = fixture.getSut() + strategy.start(fixture.recorderConfig) + strategy.pause() + + strategy.captureReplay(false) { + called = true + } + + // buffered + current = 2 + verify(fixture.scopes, times(2)).captureReplay(any(), any()) + assertEquals(strategy.currentReplayId, fixture.scope.replayId) + assertTrue(called) + } + + @Test + fun `captureReplay sets new segment timestamp to new strategy after successful creation`() { + val strategy = fixture.getSut() + strategy.start(fixture.recorderConfig) + val oldTimestamp = strategy.segmentTimestamp + + strategy.captureReplay(false) { newTimestamp -> + assertEquals(oldTimestamp!!.time + VIDEO_DURATION, newTimestamp.time) + } + + verify(fixture.scopes).captureReplay(any(), any()) + } + + @Test + fun `replayId should be set and serialized first`() { + val strategy = fixture.getSut() + val replayId = SentryId() + + strategy.start(fixture.recorderConfig, 0, replayId) + + assertEquals( + replayId.toString(), + fixture.persistedSegment.values.first(), + "The replayId must be set first, so when we clean up stale replays" + + "the current replay cache folder is not being deleted." + ) + } +} diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/SessionCaptureStrategyTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/SessionCaptureStrategyTest.kt new file mode 100644 index 0000000000..6a90251c74 --- /dev/null +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/SessionCaptureStrategyTest.kt @@ -0,0 +1,370 @@ +package io.sentry.android.replay.capture + +import android.graphics.Bitmap +import io.sentry.Breadcrumb +import io.sentry.DateUtils +import io.sentry.IScopes +import io.sentry.Scope +import io.sentry.ScopeCallback +import io.sentry.SentryOptions +import io.sentry.SentryReplayEvent +import io.sentry.SentryReplayEvent.ReplayType +import io.sentry.android.replay.DefaultReplayBreadcrumbConverter +import io.sentry.android.replay.GeneratedVideo +import io.sentry.android.replay.ReplayCache +import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_BIT_RATE +import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_FRAME_RATE +import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_HEIGHT +import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_ID +import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_REPLAY_ID +import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_REPLAY_TYPE +import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_TIMESTAMP +import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_WIDTH +import io.sentry.android.replay.ReplayFrame +import io.sentry.android.replay.ScreenshotRecorderConfig +import io.sentry.protocol.SentryId +import io.sentry.rrweb.RRWebBreadcrumbEvent +import io.sentry.rrweb.RRWebMetaEvent +import io.sentry.transport.CurrentDateProvider +import io.sentry.transport.ICurrentDateProvider +import org.junit.Rule +import org.junit.rules.TemporaryFolder +import org.mockito.ArgumentMatchers.anyInt +import org.mockito.ArgumentMatchers.anyLong +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.argThat +import org.mockito.kotlin.check +import org.mockito.kotlin.doAnswer +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import java.io.File +import java.util.Date +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class SessionCaptureStrategyTest { + + @get:Rule + val tmpDir = TemporaryFolder() + + internal class Fixture { + companion object { + const val VIDEO_DURATION = 5000L + } + + val options = SentryOptions().apply { + setReplayController( + mock { + on { breadcrumbConverter }.thenReturn(DefaultReplayBreadcrumbConverter()) + } + ) + } + val scope = Scope(options) + val scopes = mock { + doAnswer { + (it.arguments[0] as ScopeCallback).run(scope) + }.whenever(it).configureScope(any()) + } + var persistedSegment = LinkedHashMap() + val replayCache = mock { + on { frames }.thenReturn(mutableListOf(ReplayFrame(File("1720693523997.jpg"), 1720693523997))) + on { persistSegmentValues(any(), anyOrNull()) }.then { + persistedSegment.put(it.arguments[0].toString(), it.arguments[1]?.toString()) + } + on { createVideoOf(anyLong(), anyLong(), anyInt(), anyInt(), anyInt(), any()) } + .thenReturn(GeneratedVideo(File("0.mp4"), 5, VIDEO_DURATION)) + } + val recorderConfig = ScreenshotRecorderConfig( + recordingWidth = 1080, + recordingHeight = 1920, + scaleFactorX = 1f, + scaleFactorY = 1f, + frameRate = 1, + bitRate = 20_000 + ) + + fun getSut( + dateProvider: ICurrentDateProvider = CurrentDateProvider.getInstance(), + replayCacheDir: File? = null + ): SessionCaptureStrategy { + replayCacheDir?.let { + whenever(replayCache.replayCacheDir).thenReturn(it) + } + return SessionCaptureStrategy( + options, + scopes, + dateProvider, + mock { + doAnswer { invocation -> + (invocation.arguments[0] as Runnable).run() + null + }.whenever(it).submit(any()) + } + ) { _, _ -> replayCache } + } + } + + private val fixture = Fixture() + + @Test + fun `start sets replayId on scope for full session`() { + val strategy = fixture.getSut() + val replayId = SentryId() + + strategy.start(fixture.recorderConfig, 0, replayId) + + assertEquals(replayId, fixture.scope.replayId) + assertEquals(replayId, strategy.currentReplayId) + assertEquals(0, strategy.currentSegment) + } + + @Test + fun `start persists segment values`() { + val strategy = fixture.getSut() + val replayId = SentryId() + + strategy.start(fixture.recorderConfig, 0, replayId) + + assertEquals("0", fixture.persistedSegment[SEGMENT_KEY_ID]) + assertEquals(replayId.toString(), fixture.persistedSegment[SEGMENT_KEY_REPLAY_ID]) + assertEquals( + ReplayType.SESSION.toString(), + fixture.persistedSegment[SEGMENT_KEY_REPLAY_TYPE] + ) + assertEquals( + fixture.recorderConfig.recordingWidth.toString(), + fixture.persistedSegment[SEGMENT_KEY_WIDTH] + ) + assertEquals( + fixture.recorderConfig.recordingHeight.toString(), + fixture.persistedSegment[SEGMENT_KEY_HEIGHT] + ) + assertEquals( + fixture.recorderConfig.frameRate.toString(), + fixture.persistedSegment[SEGMENT_KEY_FRAME_RATE] + ) + assertEquals( + fixture.recorderConfig.bitRate.toString(), + fixture.persistedSegment[SEGMENT_KEY_BIT_RATE] + ) + assertTrue(fixture.persistedSegment[SEGMENT_KEY_TIMESTAMP]?.isNotEmpty() == true) + } + + @Test + fun `pause creates and captures current segment`() { + val strategy = fixture.getSut() + strategy.start(fixture.recorderConfig, 0, SentryId()) + + strategy.pause() + + verify(fixture.scopes).captureReplay( + argThat { event -> + event is SentryReplayEvent && event.segmentId == 0 + }, + any() + ) + assertEquals(1, strategy.currentSegment) + } + + @Test + fun `stop creates and captures current segment and clears replayId from scope`() { + val replayId = SentryId() + val currentReplay = + File(fixture.options.cacheDirPath, "replay_$replayId").also { it.mkdirs() } + + val strategy = fixture.getSut(replayCacheDir = currentReplay) + strategy.start(fixture.recorderConfig, 0, replayId) + + strategy.stop() + + verify(fixture.scopes).captureReplay( + argThat { event -> + event is SentryReplayEvent && event.segmentId == 0 + }, + any() + ) + assertEquals(SentryId.EMPTY_ID, fixture.scope.replayId) + assertEquals(SentryId.EMPTY_ID, strategy.currentReplayId) + assertEquals(-1, strategy.currentSegment) + assertFalse(currentReplay.exists()) + verify(fixture.replayCache).close() + } + + @Test + fun `captureReplay does nothing for non-crashed event`() { + val strategy = fixture.getSut() + strategy.start(fixture.recorderConfig) + + strategy.captureReplay(false) {} + + verify(fixture.scopes, never()).captureReplay(any(), any()) + } + + @Test + fun `when process is crashing, onScreenshotRecorded does not create new segment`() { + val now = + System.currentTimeMillis() + (fixture.options.experimental.sessionReplay.sessionSegmentDuration * 5) + val strategy = fixture.getSut( + dateProvider = { now } + ) + strategy.start(fixture.recorderConfig) + + strategy.captureReplay(true) {} + strategy.onScreenshotRecorded(mock()) {} + + verify(fixture.scopes, never()).captureReplay(any(), any()) + } + + @Test + fun `onScreenshotRecorded creates new segment when segment duration exceeded`() { + val now = + System.currentTimeMillis() + (fixture.options.experimental.sessionReplay.sessionSegmentDuration * 5) + val strategy = fixture.getSut( + dateProvider = { now } + ) + strategy.start(fixture.recorderConfig) + + strategy.onScreenshotRecorded(mock()) { frameTimestamp -> + assertEquals(now, frameTimestamp) + } + + var segmentTimestamp: Date? = null + verify(fixture.scopes).captureReplay( + argThat { event -> + segmentTimestamp = event.replayStartTimestamp + event is SentryReplayEvent && event.segmentId == 0 + }, + any() + ) + assertEquals(1, strategy.currentSegment) + + segmentTimestamp!!.time = segmentTimestamp!!.time.plus(Fixture.VIDEO_DURATION) + // timestamp should be updated with video duration + assertEquals( + DateUtils.getTimestamp(segmentTimestamp!!), + fixture.persistedSegment[SEGMENT_KEY_TIMESTAMP] + ) + } + + @Test + fun `onScreenshotRecorded stops replay when replay duration exceeded`() { + val now = + System.currentTimeMillis() + (fixture.options.experimental.sessionReplay.sessionDuration * 2) + var count = 0 + val strategy = fixture.getSut( + dateProvider = { + // we only need to fake value for the 3rd call (first two is for replayStartTimestamp and frameTimestamp) + if (count++ == 2) { + now + } else { + System.currentTimeMillis() + } + } + ) + strategy.start(fixture.recorderConfig) + + strategy.onScreenshotRecorded(mock()) {} + + verify(fixture.options.replayController).stop() + } + + @Test + fun `onConfigurationChanged creates new segment and updates config`() { + val strategy = fixture.getSut() + strategy.start(fixture.recorderConfig) + + val newConfig = fixture.recorderConfig.copy(recordingHeight = 1080, recordingWidth = 1920) + strategy.onConfigurationChanged(newConfig) + + var segmentTimestamp: Date? = null + verify(fixture.scopes).captureReplay( + argThat { event -> + segmentTimestamp = event.replayStartTimestamp + event is SentryReplayEvent && event.segmentId == 0 + }, + check { + val metaEvents = it.replayRecording?.payload?.filterIsInstance() + // should still capture with the old values + assertEquals(1920, metaEvents?.first()?.height) + assertEquals(1080, metaEvents?.first()?.width) + } + ) + assertEquals(1, strategy.currentSegment) + + segmentTimestamp!!.time = segmentTimestamp!!.time.plus(Fixture.VIDEO_DURATION) + assertEquals("1080", fixture.persistedSegment[SEGMENT_KEY_HEIGHT]) + assertEquals("1920", fixture.persistedSegment[SEGMENT_KEY_WIDTH]) + // timestamp should be updated with video duration + assertEquals( + DateUtils.getTimestamp(segmentTimestamp!!), + fixture.persistedSegment[SEGMENT_KEY_TIMESTAMP] + ) + } + + @Test + fun `fills replay urls from navigation breadcrumbs`() { + val now = + System.currentTimeMillis() + (fixture.options.experimental.sessionReplay.sessionSegmentDuration * 5) + val strategy = fixture.getSut(dateProvider = { now }) + strategy.start(fixture.recorderConfig) + + fixture.scope.addBreadcrumb(Breadcrumb.navigation("from", "to")) + + strategy.onScreenshotRecorded(mock()) {} + + verify(fixture.scopes).captureReplay( + check { + assertEquals("to", it.urls!!.first()) + }, + check { + val breadcrumbEvents = + it.replayRecording?.payload?.filterIsInstance() + assertEquals("navigation", breadcrumbEvents?.first()?.category) + assertEquals("to", breadcrumbEvents?.first()?.data?.get("to")) + } + ) + } + + @Test + fun `sets screen from scope as replay url`() { + fixture.scope.screen = "MainActivity" + + val now = + System.currentTimeMillis() + (fixture.options.experimental.sessionReplay.sessionSegmentDuration * 5) + val strategy = fixture.getSut(dateProvider = { now }) + strategy.start(fixture.recorderConfig) + + strategy.onScreenshotRecorded(mock()) {} + + verify(fixture.scopes).captureReplay( + check { + assertEquals("MainActivity", it.urls!!.first()) + }, + check { + val breadcrumbEvents = + it.replayRecording?.payload?.filterIsInstance() + assertTrue(breadcrumbEvents?.isEmpty() == true) + } + ) + } + + @Test + fun `replayId should be set and serialized first`() { + val strategy = fixture.getSut() + val replayId = SentryId() + + strategy.start(fixture.recorderConfig, 0, replayId) + + assertEquals( + replayId.toString(), + fixture.persistedSegment.values.first(), + "The replayId must be set first, so when we clean up stale replays" + + "the current replay cache folder is not being deleted." + ) + } +} diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/gestures/GestureRecorderTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/gestures/GestureRecorderTest.kt new file mode 100644 index 0000000000..bb2de2b7c8 --- /dev/null +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/gestures/GestureRecorderTest.kt @@ -0,0 +1,131 @@ +package io.sentry.android.replay.gestures + +import android.R +import android.app.Activity +import android.os.Bundle +import android.view.MotionEvent +import android.view.View +import android.widget.LinearLayout +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.sentry.SentryOptions +import io.sentry.android.core.internal.gestures.NoOpWindowCallback +import io.sentry.android.replay.gestures.GestureRecorder.SentryReplayGestureRecorder +import io.sentry.android.replay.phoneWindow +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.annotation.Config +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +@RunWith(AndroidJUnit4::class) +@Config(sdk = [30]) +class GestureRecorderTest { + internal class Fixture { + + val options = SentryOptions() + + fun getSut( + touchRecorderCallback: TouchRecorderCallback = NoOpTouchRecorderCallback() + ): GestureRecorder { + return GestureRecorder(options, touchRecorderCallback) + } + } + + private val fixture = Fixture() + private class NoOpTouchRecorderCallback : TouchRecorderCallback { + override fun onTouchEvent(event: MotionEvent) = Unit + } + + @Test + fun `when new window added and window callback is already wrapped, does not wrap it again`() { + val activity = Robolectric.buildActivity(TestActivity::class.java).setup().get() + val gestureRecorder = fixture.getSut() + + activity.root.phoneWindow?.callback = SentryReplayGestureRecorder(fixture.options, null, null) + gestureRecorder.onRootViewsChanged(activity.root, true) + + assertFalse((activity.root.phoneWindow?.callback as SentryReplayGestureRecorder).delegate is SentryReplayGestureRecorder) + } + + @Test + fun `when new window added tracks touch events`() { + var called = false + val activity = Robolectric.buildActivity(TestActivity::class.java).setup().get() + val motionEvent = MotionEvent.obtain(0, 0, MotionEvent.ACTION_DOWN, 0f, 0f, 0) + val gestureRecorder = fixture.getSut( + touchRecorderCallback = object : TouchRecorderCallback { + override fun onTouchEvent(event: MotionEvent) { + assertEquals(MotionEvent.ACTION_DOWN, event.action) + called = true + } + } + ) + + gestureRecorder.onRootViewsChanged(activity.root, true) + + activity.root.phoneWindow?.callback?.dispatchTouchEvent(motionEvent) + assertTrue(called) + } + + @Test + fun `when window removed and window is not sentry recorder does nothing`() { + val activity = Robolectric.buildActivity(TestActivity::class.java).setup().get() + val gestureRecorder = fixture.getSut() + + activity.root.phoneWindow?.callback = NoOpWindowCallback() + gestureRecorder.onRootViewsChanged(activity.root, false) + + assertTrue(activity.root.phoneWindow?.callback is NoOpWindowCallback) + } + + @Test + fun `when window removed stops tracking touch events`() { + val activity = Robolectric.buildActivity(TestActivity::class.java).setup().get() + val gestureRecorder = fixture.getSut() + + gestureRecorder.onRootViewsChanged(activity.root, true) + gestureRecorder.onRootViewsChanged(activity.root, false) + + assertFalse(activity.root.phoneWindow?.callback is SentryReplayGestureRecorder) + } + + @Test + fun `when stopped stops tracking all windows`() { + val activity1 = Robolectric.buildActivity(TestActivity::class.java).setup().get() + val activity2 = Robolectric.buildActivity(TestActivity2::class.java).setup().get() + val gestureRecorder = fixture.getSut() + + gestureRecorder.onRootViewsChanged(activity1.root, true) + gestureRecorder.onRootViewsChanged(activity2.root, true) + gestureRecorder.stop() + + assertFalse(activity1.root.phoneWindow?.callback is SentryReplayGestureRecorder) + assertFalse(activity2.root.phoneWindow?.callback is SentryReplayGestureRecorder) + } +} + +private class TestActivity : Activity() { + lateinit var root: View + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setTheme(R.style.Theme_Holo_Light) + root = LinearLayout(this) + setContentView(root) + actionBar!!.setIcon(R.drawable.ic_lock_power_off) + } +} + +private class TestActivity2 : Activity() { + lateinit var root: View + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setTheme(R.style.Theme_Holo_Light) + root = LinearLayout(this) + setContentView(root) + actionBar!!.setIcon(R.drawable.ic_lock_power_off) + } +} diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/gestures/ReplayGestureConverterTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/gestures/ReplayGestureConverterTest.kt new file mode 100644 index 0000000000..00ae93af4a --- /dev/null +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/gestures/ReplayGestureConverterTest.kt @@ -0,0 +1,240 @@ +package io.sentry.android.replay.gestures + +import android.view.MotionEvent +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.sentry.android.replay.ScreenshotRecorderConfig +import io.sentry.rrweb.RRWebInteractionEvent +import io.sentry.rrweb.RRWebInteractionMoveEvent +import io.sentry.transport.ICurrentDateProvider +import org.junit.runner.RunWith +import org.robolectric.annotation.Config +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +@RunWith(AndroidJUnit4::class) +@Config(sdk = [30]) +class ReplayGestureConverterTest { + internal class Fixture { + var now: Long = 1000L + + fun getSut( + dateProvider: ICurrentDateProvider = ICurrentDateProvider { now } + ): ReplayGestureConverter { + return ReplayGestureConverter(dateProvider) + } + } + + private val fixture = Fixture() + + @Test + fun `convert ACTION_DOWN event`() { + val sut = fixture.getSut() + val recorderConfig = ScreenshotRecorderConfig(scaleFactorX = 1f, scaleFactorY = 1f) + val event = MotionEvent.obtain(0, 0, MotionEvent.ACTION_DOWN, 100f, 200f, 0) + + val result = sut.convert(event, recorderConfig) + + assertNotNull(result) + assertEquals(1, result.size) + assertTrue(result[0] is RRWebInteractionEvent) + with(result[0] as RRWebInteractionEvent) { + assertEquals(1000L, timestamp) + assertEquals(100f, x) + assertEquals(200f, y) + assertEquals(0, id) + assertEquals(0, pointerId) + assertEquals(RRWebInteractionEvent.InteractionType.TouchStart, interactionType) + } + + event.recycle() + } + + @Test + fun `convert ACTION_MOVE event with debounce`() { + val sut = fixture.getSut() + val recorderConfig = ScreenshotRecorderConfig(scaleFactorX = 1f, scaleFactorY = 1f) + val event = MotionEvent.obtain(0, 0, MotionEvent.ACTION_MOVE, 100f, 200f, 0) + + // First call should pass + var result = sut.convert(event, recorderConfig) + assertNotNull(result) + + // Second call within debounce threshold should be null + fixture.now += 40 // Increase time by 40ms + result = sut.convert(event, recorderConfig) + assertNull(result) + + event.recycle() + } + + @Test + fun `convert ACTION_MOVE event with capture threshold`() { + val sut = fixture.getSut() + val recorderConfig = ScreenshotRecorderConfig(scaleFactorX = 1f, scaleFactorY = 1f) + val downEvent = MotionEvent.obtain(0, 0, MotionEvent.ACTION_DOWN, 100f, 200f, 0) + val moveEvent = MotionEvent.obtain(0, 0, MotionEvent.ACTION_MOVE, 110f, 210f, 0) + + // Add a pointer to currentPositions + sut.convert(downEvent, recorderConfig) + + // First call should not trigger capture + var result = sut.convert(moveEvent, recorderConfig) + assertNull(result) + + // Second call should trigger capture + fixture.now += 600 // Increase time by 600ms + result = sut.convert(moveEvent, recorderConfig) + assertNotNull(result) + with(result[0] as RRWebInteractionMoveEvent) { + assertEquals(1600L, timestamp) + assertEquals(2, positions!!.size) + assertEquals(110f, positions!![0].x) + assertEquals(210f, positions!![0].y) + assertEquals(0, positions!![0].id) + assertEquals(0, pointerId) + } + + downEvent.recycle() + moveEvent.recycle() + } + + @Test + fun `convert ACTION_UP event`() { + val sut = fixture.getSut() + val recorderConfig = ScreenshotRecorderConfig(scaleFactorX = 1f, scaleFactorY = 1f) + val event = MotionEvent.obtain(0, 0, MotionEvent.ACTION_UP, 100f, 200f, 0) + + val result = sut.convert(event, recorderConfig) + + assertNotNull(result) + assertEquals(1, result.size) + assertTrue(result[0] is RRWebInteractionEvent) + with(result[0] as RRWebInteractionEvent) { + assertEquals(1000L, timestamp) + assertEquals(100f, x) + assertEquals(200f, y) + assertEquals(0, id) + assertEquals(0, pointerId) + assertEquals(RRWebInteractionEvent.InteractionType.TouchEnd, interactionType) + } + + event.recycle() + } + + @Test + fun `convert ACTION_CANCEL event`() { + val sut = fixture.getSut() + val recorderConfig = ScreenshotRecorderConfig(scaleFactorX = 1f, scaleFactorY = 1f) + val event = MotionEvent.obtain(0, 0, MotionEvent.ACTION_CANCEL, 100f, 200f, 0) + + val result = sut.convert(event, recorderConfig) + + assertNotNull(result) + assertEquals(1, result.size) + assertTrue(result[0] is RRWebInteractionEvent) + with(result[0] as RRWebInteractionEvent) { + assertEquals(1000L, timestamp) + assertEquals(100f, x) + assertEquals(200f, y) + assertEquals(0, id) + assertEquals(0, pointerId) + assertEquals(RRWebInteractionEvent.InteractionType.TouchCancel, interactionType) + } + + event.recycle() + } + + @Test + fun `convert event with different scale factors`() { + val sut = fixture.getSut() + val customRecorderConfig = ScreenshotRecorderConfig(scaleFactorX = 0.5f, scaleFactorY = 1.5f) + val event = MotionEvent.obtain(0, 0, MotionEvent.ACTION_DOWN, 100f, 200f, 0) + + val result = sut.convert(event, customRecorderConfig) + + assertNotNull(result) + assertEquals(1, result.size) + assertTrue(result[0] is RRWebInteractionEvent) + with(result[0] as RRWebInteractionEvent) { + assertEquals(1000L, timestamp) + assertEquals(50f, x) // 100 * 0.5 + assertEquals(300f, y) // 200 * 1.5 + assertEquals(0, id) + assertEquals(0, pointerId) + assertEquals(RRWebInteractionEvent.InteractionType.TouchStart, interactionType) + } + + event.recycle() + } + + @Test + fun `convert multi-pointer events`() { + val sut = fixture.getSut() + val recorderConfig = ScreenshotRecorderConfig(scaleFactorX = 1f, scaleFactorY = 1f) + + // Simulate first finger down + var event = MotionEvent.obtain(0, 0, MotionEvent.ACTION_DOWN, 100f, 100f, 0) + var result = sut.convert(event, recorderConfig) + assertNotNull(result) + assertTrue(result[0] is RRWebInteractionEvent) + assertEquals(RRWebInteractionEvent.InteractionType.TouchStart, (result[0] as RRWebInteractionEvent).interactionType) + event.recycle() + + // Simulate second finger down + val properties = MotionEvent.PointerProperties() + properties.id = 1 + properties.toolType = MotionEvent.TOOL_TYPE_FINGER + val pointerProperties = arrayOf(MotionEvent.PointerProperties(), properties) + val pointerCoords = arrayOf( + MotionEvent.PointerCoords().apply { x = 100f; y = 100f }, + MotionEvent.PointerCoords().apply { x = 200f; y = 200f } + ) + event = MotionEvent.obtain(0, 1, MotionEvent.ACTION_POINTER_DOWN or (1 shl MotionEvent.ACTION_POINTER_INDEX_SHIFT), 2, pointerProperties, pointerCoords, 0, 0, 1f, 1f, 0, 0, 0, 0) + fixture.now += 100 // Increase time by 100ms + result = sut.convert(event, recorderConfig) + assertNotNull(result) + assertTrue(result[0] is RRWebInteractionEvent) + assertEquals(RRWebInteractionEvent.InteractionType.TouchStart, (result[0] as RRWebInteractionEvent).interactionType) + assertEquals(1, (result[0] as RRWebInteractionEvent).pointerId) + event.recycle() + + // Simulate move event + pointerCoords[0].x = 90f + pointerCoords[0].y = 90f + pointerCoords[1].x = 210f + pointerCoords[1].y = 210f + event = MotionEvent.obtain(0, 2, MotionEvent.ACTION_MOVE, 2, pointerProperties, pointerCoords, 0, 0, 1f, 1f, 0, 0, 0, 0) + // First call should not trigger capture + result = sut.convert(event, recorderConfig) + assertNull(result) + + fixture.now += 600 // Increase time by 600ms to trigger move capture + result = sut.convert(event, recorderConfig) + assertNotNull(result) + assertTrue((result[0] as RRWebInteractionMoveEvent).positions!!.size == 2) + event.recycle() + + // Simulate second finger up + event = MotionEvent.obtain(0, 3, MotionEvent.ACTION_POINTER_UP or (1 shl MotionEvent.ACTION_POINTER_INDEX_SHIFT), 2, pointerProperties, pointerCoords, 0, 0, 1f, 1f, 0, 0, 0, 0) + fixture.now += 100 // Increase time by 100ms + result = sut.convert(event, recorderConfig) + assertNotNull(result) + assertTrue(result[0] is RRWebInteractionEvent) + assertEquals(RRWebInteractionEvent.InteractionType.TouchEnd, (result[0] as RRWebInteractionEvent).interactionType) + assertEquals(1, (result[0] as RRWebInteractionEvent).pointerId) + event.recycle() + + // Simulate first finger up + event = MotionEvent.obtain(0, 4, MotionEvent.ACTION_UP, 90f, 90f, 0) + fixture.now += 100 // Increase time by 100ms + result = sut.convert(event, recorderConfig) + assertNotNull(result) + assertTrue(result[0] is RRWebInteractionEvent) + assertEquals(RRWebInteractionEvent.InteractionType.TouchEnd, (result[0] as RRWebInteractionEvent).interactionType) + assertEquals(0, (result[0] as RRWebInteractionEvent).pointerId) + event.recycle() + } +} diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/util/ReplayShadowMediaCodec.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/util/ReplayShadowMediaCodec.kt new file mode 100644 index 0000000000..c46c49ded0 --- /dev/null +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/util/ReplayShadowMediaCodec.kt @@ -0,0 +1,60 @@ +package io.sentry.android.replay.util + +import android.media.MediaCodec +import android.media.MediaCodec.BufferInfo +import org.robolectric.annotation.Implementation +import org.robolectric.annotation.Implements +import org.robolectric.shadows.ShadowMediaCodec +import java.nio.ByteBuffer +import java.util.concurrent.TimeUnit.MICROSECONDS +import java.util.concurrent.TimeUnit.MILLISECONDS +import java.util.concurrent.atomic.AtomicBoolean + +@Implements(MediaCodec::class) +class ReplayShadowMediaCodec : ShadowMediaCodec() { + + companion object { + var frameRate = 1 + var framesToEncode = 5 + } + + private val encoded = AtomicBoolean(false) + + @Implementation + fun start() { + super.native_start() + } + + @Implementation + fun signalEndOfInputStream() { + encodeFrame(framesToEncode, frameRate, 0, MediaCodec.BUFFER_FLAG_END_OF_STREAM) + } + + @Implementation + fun getOutputBuffers(): Array { + return super.getBuffers(false) + } + + @Implementation + fun dequeueOutputBuffer(info: BufferInfo, timeoutUs: Long): Int { + val encoderStatus = super.native_dequeueOutputBuffer(info, timeoutUs) + super.validateOutputByteBuffer(getOutputBuffers(), encoderStatus, info) + if (encoderStatus == MediaCodec.INFO_TRY_AGAIN_LATER && !encoded.getAndSet(true)) { + // MediaMuxer is initialized now, so we can start encoding frames + repeat(framesToEncode) { encodeFrame(it, frameRate) } + } + return encoderStatus + } + + private fun encodeFrame(index: Int, frameRate: Int, size: Int = 10, flags: Int = 0) { + val presentationTime = MICROSECONDS.convert(index * (1000L / frameRate), MILLISECONDS) + super.native_dequeueInputBuffer(0) + super.native_queueInputBuffer( + index, + index * size, + size, + presentationTime, + flags + ) + } +} diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/util/TextViewDominantColorTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/util/TextViewDominantColorTest.kt new file mode 100644 index 0000000000..9a5b805ad7 --- /dev/null +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/util/TextViewDominantColorTest.kt @@ -0,0 +1,104 @@ +package io.sentry.android.replay.util + +import android.app.Activity +import android.graphics.Color +import android.os.Bundle +import android.os.Looper +import android.text.SpannableString +import android.text.Spanned +import android.text.style.ForegroundColorSpan +import android.widget.LinearLayout +import android.widget.LinearLayout.LayoutParams +import android.widget.TextView +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.sentry.SentryOptions +import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode +import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode.TextViewHierarchyNode +import org.junit.runner.RunWith +import org.robolectric.Robolectric.buildActivity +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +@RunWith(AndroidJUnit4::class) +@Config(sdk = [30]) +class TextViewDominantColorTest { + + @Test + fun `when no spans, returns currentTextColor`() { + val controller = buildActivity(TextViewActivity::class.java, null).setup() + controller.create().start().resume() + + TextViewActivity.textView?.setTextColor(Color.WHITE) + + val node = ViewHierarchyNode.fromView(TextViewActivity.textView!!, null, 0, SentryOptions()) + assertTrue(node is TextViewHierarchyNode) + assertNull(node.layout?.dominantTextColor) + } + + @Test + fun `when has a foreground color span, returns its color`() { + val controller = buildActivity(TextViewActivity::class.java, null).setup() + controller.create().start().resume() + + val text = "Hello, World!" + TextViewActivity.textView?.text = SpannableString(text).apply { + setSpan(ForegroundColorSpan(Color.RED), 0, text.length, Spanned.SPAN_INCLUSIVE_INCLUSIVE) + } + TextViewActivity.textView?.setTextColor(Color.WHITE) + TextViewActivity.textView?.requestLayout() + + shadowOf(Looper.getMainLooper()).idle() + + val node = ViewHierarchyNode.fromView(TextViewActivity.textView!!, null, 0, SentryOptions()) + assertTrue(node is TextViewHierarchyNode) + assertEquals(Color.RED, node.layout?.dominantTextColor) + } + + @Test + fun `when has multiple foreground color spans, returns color of the longest span`() { + val controller = buildActivity(TextViewActivity::class.java, null).setup() + controller.create().start().resume() + + val text = "Hello, World!" + TextViewActivity.textView?.text = SpannableString(text).apply { + setSpan(ForegroundColorSpan(Color.RED), 0, 5, Spanned.SPAN_INCLUSIVE_INCLUSIVE) + setSpan(ForegroundColorSpan(Color.BLACK), 6, text.length, Spanned.SPAN_INCLUSIVE_INCLUSIVE) + } + TextViewActivity.textView?.setTextColor(Color.WHITE) + TextViewActivity.textView?.requestLayout() + + shadowOf(Looper.getMainLooper()).idle() + + val node = ViewHierarchyNode.fromView(TextViewActivity.textView!!, null, 0, SentryOptions()) + assertTrue(node is TextViewHierarchyNode) + assertEquals(Color.BLACK, node.layout?.dominantTextColor) + } +} + +private class TextViewActivity : Activity() { + + companion object { + var textView: TextView? = null + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val linearLayout = LinearLayout(this).apply { + setBackgroundColor(android.R.color.white) + orientation = LinearLayout.VERTICAL + layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT) + } + + textView = TextView(this).apply { + text = "Hello, World!" + layoutParams = LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT) + } + linearLayout.addView(textView) + + setContentView(linearLayout) + } +} diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/viewhierarchy/ComposeMaskingOptionsTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/viewhierarchy/ComposeMaskingOptionsTest.kt new file mode 100644 index 0000000000..e5330fa827 --- /dev/null +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/viewhierarchy/ComposeMaskingOptionsTest.kt @@ -0,0 +1,240 @@ +package io.sentry.android.replay.viewhierarchy + +import android.app.Activity +import android.net.Uri +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.invisibleToUser +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.unit.dp +import androidx.test.ext.junit.runners.AndroidJUnit4 +import coil.compose.AsyncImage +import io.sentry.SentryOptions +import io.sentry.android.replay.maskAllImages +import io.sentry.android.replay.maskAllText +import io.sentry.android.replay.sentryReplayMask +import io.sentry.android.replay.sentryReplayUnmask +import io.sentry.android.replay.util.ComposeTextLayout +import io.sentry.android.replay.util.traverse +import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode.GenericViewHierarchyNode +import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode.ImageViewHierarchyNode +import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode.TextViewHierarchyNode +import org.junit.Before +import org.junit.runner.RunWith +import org.robolectric.Robolectric.buildActivity +import org.robolectric.annotation.Config +import java.io.File +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +@RunWith(AndroidJUnit4::class) +@Config(sdk = [30]) +class ComposeMaskingOptionsTest { + + @Before + fun setup() { + System.setProperty("robolectric.areWindowsMarkedVisible", "true") + ComposeMaskingOptionsActivity.textModifierApplier = null + ComposeMaskingOptionsActivity.containerModifierApplier = null + } + + @Test + fun `when maskAllText is set all Text nodes are masked`() { + val activity = buildActivity(ComposeMaskingOptionsActivity::class.java).setup() + + val options = SentryOptions().apply { + experimental.sessionReplay.maskAllText = true + } + + val textNodes = activity.get().collectNodesOfType(options) + assertEquals(4, textNodes.size) // [TextField, Text, Button, Activity Title] + assertTrue(textNodes.all { it.shouldMask }) + // just a sanity check for parsing the tree + assertEquals("Random repo", (textNodes[1].layout as ComposeTextLayout).layout.layoutInput.text.text) + } + + @Test + fun `when maskAllText is set to false all Text nodes are unmasked`() { + val activity = buildActivity(ComposeMaskingOptionsActivity::class.java).setup() + + val options = SentryOptions().apply { + experimental.sessionReplay.maskAllText = false + } + + val textNodes = activity.get().collectNodesOfType(options) + assertEquals(4, textNodes.size) // [TextField, Text, Button, Activity Title] + assertTrue(textNodes.none { it.shouldMask }) + } + + @Test + fun `when maskAllImages is set all Image nodes are masked`() { + val activity = buildActivity(ComposeMaskingOptionsActivity::class.java).setup() + + val options = SentryOptions().apply { + experimental.sessionReplay.maskAllImages = true + } + + val imageNodes = activity.get().collectNodesOfType(options) + assertEquals(1, imageNodes.size) // [AsyncImage] + assertTrue(imageNodes.all { it.shouldMask }) + } + + @Test + fun `when maskAllImages is set to false all Image nodes are unmasked`() { + val activity = buildActivity(ComposeMaskingOptionsActivity::class.java).setup() + + val options = SentryOptions().apply { + experimental.sessionReplay.maskAllImages = false + } + + val imageNodes = activity.get().collectNodesOfType(options) + assertEquals(1, imageNodes.size) // [AsyncImage] + assertTrue(imageNodes.none { it.shouldMask }) + } + + @Test + fun `when sentry-mask modifier is set masks the node`() { + ComposeMaskingOptionsActivity.textModifierApplier = { Modifier.sentryReplayMask() } + val activity = buildActivity(ComposeMaskingOptionsActivity::class.java).setup() + + val options = SentryOptions().apply { + experimental.sessionReplay.maskAllText = false + } + + val textNodes = activity.get().collectNodesOfType(options) + assertEquals(4, textNodes.size) // [TextField, Text, Button, Activity Title] + textNodes.forEach { + if ((it.layout as? ComposeTextLayout)?.layout?.layoutInput?.text?.text == "Make Request") { + assertTrue(it.shouldMask) + } else { + assertFalse(it.shouldMask) + } + } + } + + @Test + fun `when sentry-unmask modifier is set unmasks the node`() { + ComposeMaskingOptionsActivity.textModifierApplier = { Modifier.sentryReplayUnmask() } + val activity = buildActivity(ComposeMaskingOptionsActivity::class.java).setup() + + val options = SentryOptions().apply { + experimental.sessionReplay.maskAllText = true + } + + val textNodes = activity.get().collectNodesOfType(options) + assertEquals(4, textNodes.size) // [TextField, Text, Button, Activity Title] + textNodes.forEach { + if ((it.layout as? ComposeTextLayout)?.layout?.layoutInput?.text?.text == "Make Request") { + assertFalse(it.shouldMask) + } else { + assertTrue(it.shouldMask) + } + } + } + + @Test + fun `when view is not visible, does not mask the view`() { + ComposeMaskingOptionsActivity.textModifierApplier = { Modifier.semantics { invisibleToUser() } } + val activity = buildActivity(ComposeMaskingOptionsActivity::class.java).setup() + + val options = SentryOptions().apply { + experimental.sessionReplay.maskAllText = true + } + + val textNodes = activity.get().collectNodesOfType(options) + textNodes.forEach { + if ((it.layout as? ComposeTextLayout)?.layout?.layoutInput?.text?.text == "Make Request") { + assertFalse(it.shouldMask) + } else { + assertTrue(it.shouldMask) + } + } + } + + @Test + fun `when a container view is unmasked its children are not unmasked`() { + ComposeMaskingOptionsActivity.containerModifierApplier = { Modifier.sentryReplayUnmask() } + val activity = buildActivity(ComposeMaskingOptionsActivity::class.java).setup() + + val options = SentryOptions() + + val allNodes = activity.get().collectNodesOfType(options) + val imageNodes = allNodes.filterIsInstance() + val textNodes = allNodes.filterIsInstance() + val genericNodes = allNodes.filterIsInstance() + assertTrue(imageNodes.all { it.shouldMask }) + assertTrue(textNodes.all { it.shouldMask }) + assertTrue(genericNodes.none { it.shouldMask }) + } + + private inline fun Activity.collectNodesOfType(options: SentryOptions): List { + val root = window.decorView + val viewHierarchy = ViewHierarchyNode.fromView(root, null, 0, options) + root.traverse(viewHierarchy, options) + + val nodes = mutableListOf() + viewHierarchy.traverse { + if (it is T) { + nodes += it + } + return@traverse true + } + return nodes + } +} + +private class ComposeMaskingOptionsActivity : ComponentActivity() { + + companion object { + var textModifierApplier: (() -> Modifier)? = null + var containerModifierApplier: (() -> Modifier)? = null + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val image = this::class.java.classLoader.getResource("Tongariro.jpg")!! + + setContent { + Column( + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .fillMaxSize() + .then(containerModifierApplier?.invoke() ?: Modifier) + ) { + AsyncImage( + model = Uri.fromFile(File(image.toURI())), + contentDescription = null, + modifier = Modifier.padding(vertical = 16.dp) + ) + TextField( + value = TextFieldValue("Placeholder"), + onValueChange = { _ -> } + ) + Text("Random repo") + Button( + onClick = {}, + modifier = Modifier + .testTag("button_list_repos_async") + .padding(top = 32.dp) + ) { + Text("Make Request", modifier = Modifier.then(textModifierApplier?.invoke() ?: Modifier)) + } + } + } + } +} diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/viewhierarchy/MaskingOptionsTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/viewhierarchy/MaskingOptionsTest.kt new file mode 100644 index 0000000000..4a40e0a915 --- /dev/null +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/viewhierarchy/MaskingOptionsTest.kt @@ -0,0 +1,278 @@ +package io.sentry.android.replay.viewhierarchy + +import android.app.Activity +import android.content.Context +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.drawable.Drawable +import android.os.Bundle +import android.view.View +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.LinearLayout.LayoutParams +import android.widget.RadioButton +import android.widget.TextView +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.sentry.SentryOptions +import io.sentry.android.replay.maskAllImages +import io.sentry.android.replay.maskAllText +import io.sentry.android.replay.sentryReplayMask +import io.sentry.android.replay.sentryReplayUnmask +import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode.ImageViewHierarchyNode +import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode.TextViewHierarchyNode +import org.junit.runner.RunWith +import org.robolectric.Robolectric.buildActivity +import org.robolectric.annotation.Config +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +@RunWith(AndroidJUnit4::class) +@Config(sdk = [30]) +class MaskingOptionsTest { + + @BeforeTest + fun setup() { + System.setProperty("robolectric.areWindowsMarkedVisible", "true") + } + + @Test + fun `when maskAllText is set all TextView nodes are masked`() { + buildActivity(MaskingOptionsActivity::class.java).setup() + + val options = SentryOptions().apply { + experimental.sessionReplay.maskAllText = true + } + + val textNode = ViewHierarchyNode.fromView(MaskingOptionsActivity.textView!!, null, 0, options) + val radioButtonNode = ViewHierarchyNode.fromView(MaskingOptionsActivity.radioButton!!, null, 0, options) + + assertTrue(textNode is TextViewHierarchyNode) + assertTrue(textNode.shouldMask) + + assertTrue(radioButtonNode is TextViewHierarchyNode) + assertTrue(radioButtonNode.shouldMask) + } + + @Test + fun `when maskAllText is set to false all TextView nodes are unmasked`() { + buildActivity(MaskingOptionsActivity::class.java).setup() + + val options = SentryOptions().apply { + experimental.sessionReplay.maskAllText = false + } + + val textNode = ViewHierarchyNode.fromView(MaskingOptionsActivity.textView!!, null, 0, options) + val radioButtonNode = ViewHierarchyNode.fromView(MaskingOptionsActivity.radioButton!!, null, 0, options) + + assertTrue(textNode is TextViewHierarchyNode) + assertFalse(textNode.shouldMask) + + assertTrue(radioButtonNode is TextViewHierarchyNode) + assertFalse(radioButtonNode.shouldMask) + } + + @Test + fun `when maskAllImages is set all ImageView nodes are masked`() { + buildActivity(MaskingOptionsActivity::class.java).setup() + + val options = SentryOptions().apply { + experimental.sessionReplay.maskAllImages = true + } + + val imageNode = ViewHierarchyNode.fromView(MaskingOptionsActivity.imageView!!, null, 0, options) + + assertTrue(imageNode is ImageViewHierarchyNode) + assertTrue(imageNode.shouldMask) + } + + @Test + fun `when maskAllImages is set to false all ImageView nodes are unmasked`() { + buildActivity(MaskingOptionsActivity::class.java).setup() + + val options = SentryOptions().apply { + experimental.sessionReplay.maskAllImages = false + } + + val imageNode = ViewHierarchyNode.fromView(MaskingOptionsActivity.imageView!!, null, 0, options) + + assertTrue(imageNode is ImageViewHierarchyNode) + assertFalse(imageNode.shouldMask) + } + + @Test + fun `when sentry-mask tag is set mask the view`() { + buildActivity(MaskingOptionsActivity::class.java).setup() + + val options = SentryOptions().apply { + experimental.sessionReplay.maskAllText = false + } + + MaskingOptionsActivity.textView!!.tag = "sentry-mask" + val textNode = ViewHierarchyNode.fromView(MaskingOptionsActivity.textView!!, null, 0, options) + + assertTrue(textNode.shouldMask) + } + + @Test + fun `when sentry-unmask tag is set unmasks the view`() { + buildActivity(MaskingOptionsActivity::class.java).setup() + + val options = SentryOptions().apply { + experimental.sessionReplay.maskAllText = true + } + + MaskingOptionsActivity.textView!!.tag = "sentry-unmask" + val textNode = ViewHierarchyNode.fromView(MaskingOptionsActivity.textView!!, null, 0, options) + + assertFalse(textNode.shouldMask) + } + + @Test + fun `when sentry-privacy tag is set to mask masks the view`() { + buildActivity(MaskingOptionsActivity::class.java).setup() + + val options = SentryOptions().apply { + experimental.sessionReplay.maskAllText = false + } + + MaskingOptionsActivity.textView!!.sentryReplayMask() + val textNode = ViewHierarchyNode.fromView(MaskingOptionsActivity.textView!!, null, 0, options) + + assertTrue(textNode.shouldMask) + } + + @Test + fun `when sentry-privacy tag is set to unmask unmasks the view`() { + buildActivity(MaskingOptionsActivity::class.java).setup() + + val options = SentryOptions().apply { + experimental.sessionReplay.maskAllText = true + } + + MaskingOptionsActivity.textView!!.sentryReplayUnmask() + val textNode = ViewHierarchyNode.fromView(MaskingOptionsActivity.textView!!, null, 0, options) + + assertFalse(textNode.shouldMask) + } + + @Test + fun `when view is not visible, does not mask the view`() { + buildActivity(MaskingOptionsActivity::class.java).setup() + + val options = SentryOptions().apply { + experimental.sessionReplay.maskAllText = true + } + + MaskingOptionsActivity.textView!!.visibility = View.GONE + val textNode = ViewHierarchyNode.fromView(MaskingOptionsActivity.textView!!, null, 0, options) + + assertFalse(textNode.shouldMask) + } + + @Test + fun `when added to mask list masks custom view`() { + buildActivity(MaskingOptionsActivity::class.java).setup() + + val options = SentryOptions().apply { + experimental.sessionReplay.maskViewClasses.add(CustomView::class.java.canonicalName) + } + + val customViewNode = ViewHierarchyNode.fromView(MaskingOptionsActivity.customView!!, null, 0, options) + + assertTrue(customViewNode.shouldMask) + } + + @Test + fun `when subclass is added to ignored classes ignores all instances of that class`() { + buildActivity(MaskingOptionsActivity::class.java).setup() + + val options = SentryOptions().apply { + experimental.sessionReplay.maskAllText = true // all TextView subclasses + experimental.sessionReplay.unmaskViewClasses.add(RadioButton::class.java.canonicalName) + } + + val textNode = ViewHierarchyNode.fromView(MaskingOptionsActivity.textView!!, null, 0, options) + val radioButtonNode = ViewHierarchyNode.fromView(MaskingOptionsActivity.radioButton!!, null, 0, options) + + assertTrue(textNode.shouldMask) + assertFalse(radioButtonNode.shouldMask) + } + + @Test + fun `when a container view is ignored its children are not ignored`() { + buildActivity(MaskingOptionsActivity::class.java).setup() + + val options = SentryOptions().apply { + experimental.sessionReplay.unmaskViewClasses.add(LinearLayout::class.java.canonicalName) + } + + val linearLayoutNode = ViewHierarchyNode.fromView(MaskingOptionsActivity.textView!!.parent as LinearLayout, null, 0, options) + val textNode = ViewHierarchyNode.fromView(MaskingOptionsActivity.textView!!, null, 0, options) + val imageNode = ViewHierarchyNode.fromView(MaskingOptionsActivity.imageView!!, null, 0, options) + + assertFalse(linearLayoutNode.shouldMask) + assertTrue(textNode.shouldMask) + assertTrue(imageNode.shouldMask) + } +} + +private class CustomView(context: Context) : View(context) { + + override fun onDraw(canvas: Canvas) { + super.onDraw(canvas) + canvas.drawColor(Color.BLACK) + } +} + +private class MaskingOptionsActivity : Activity() { + + companion object { + var textView: TextView? = null + var radioButton: RadioButton? = null + var imageView: ImageView? = null + var customView: CustomView? = null + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val linearLayout = LinearLayout(this).apply { + setBackgroundColor(android.R.color.white) + orientation = LinearLayout.VERTICAL + layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT) + } + + textView = TextView(this).apply { + text = "Hello, World!" + layoutParams = LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT) + } + linearLayout.addView(textView) + + val image = this::class.java.classLoader.getResource("Tongariro.jpg")!! + imageView = ImageView(this).apply { + setImageDrawable(Drawable.createFromPath(image.path)) + layoutParams = LayoutParams(50, 50).apply { + setMargins(0, 16, 0, 0) + } + } + linearLayout.addView(imageView) + + radioButton = RadioButton(this).apply { + text = "Radio Button" + layoutParams = LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT).apply { + setMargins(0, 16, 0, 0) + } + } + linearLayout.addView(radioButton) + + customView = CustomView(this).apply { + layoutParams = LayoutParams(50, 50).apply { + setMargins(0, 16, 0, 0) + } + } + linearLayout.addView(customView) + + setContentView(linearLayout) + } +} diff --git a/sentry-android-replay/src/test/resources/Tongariro.jpg b/sentry-android-replay/src/test/resources/Tongariro.jpg new file mode 100644 index 0000000000..96e2f074f0 Binary files /dev/null and b/sentry-android-replay/src/test/resources/Tongariro.jpg differ diff --git a/sentry-android-sqlite/src/main/java/io/sentry/android/sqlite/SQLiteSpanManager.kt b/sentry-android-sqlite/src/main/java/io/sentry/android/sqlite/SQLiteSpanManager.kt index 3bfa855d53..61c7a771dc 100644 --- a/sentry-android-sqlite/src/main/java/io/sentry/android/sqlite/SQLiteSpanManager.kt +++ b/sentry-android-sqlite/src/main/java/io/sentry/android/sqlite/SQLiteSpanManager.kt @@ -1,8 +1,11 @@ package io.sentry.android.sqlite +import android.database.CrossProcessCursor import android.database.SQLException -import io.sentry.HubAdapter -import io.sentry.IHub +import io.sentry.IScopes +import io.sentry.ISpan +import io.sentry.Instrumenter +import io.sentry.ScopesAdapter import io.sentry.SentryIntegrationPackageStorage import io.sentry.SentryStackTraceFactory import io.sentry.SpanDataConvention @@ -11,10 +14,10 @@ import io.sentry.SpanStatus private const val TRACE_ORIGIN = "auto.db.sqlite" internal class SQLiteSpanManager( - private val hub: IHub = HubAdapter.getInstance(), + private val scopes: IScopes = ScopesAdapter.getInstance(), private val databaseName: String? = null ) { - private val stackTraceFactory = SentryStackTraceFactory(hub.options) + private val stackTraceFactory = SentryStackTraceFactory(scopes.options) init { SentryIntegrationPackageStorage.getInstance().addIntegration("SQLite") @@ -27,22 +30,34 @@ internal class SQLiteSpanManager( * @param operation The sql operation to execute. * In case of an error the surrounding span will have its status set to INTERNAL_ERROR */ - @Suppress("TooGenericExceptionCaught") + @Suppress("TooGenericExceptionCaught", "UNCHECKED_CAST") @Throws(SQLException::class) fun performSql(sql: String, operation: () -> T): T { - val span = hub.span?.startChild("db.sql.query", sql) - span?.spanContext?.origin = TRACE_ORIGIN + val startTimestamp = scopes.getOptions().dateProvider.now() + var span: ISpan? = null return try { val result = operation() + /* + * SQLiteCursor - that extends CrossProcessCursor - executes the query lazily, when one of + * getCount() or onMove() is called. In this case we don't have to start the span here. + * Otherwise we start the span with the timestamp taken before the operation started. + */ + if (result is CrossProcessCursor) { + return SentryCrossProcessCursor(result, this, sql) as T + } + span = scopes.span?.startChild("db.sql.query", sql, startTimestamp, Instrumenter.SENTRY) + span?.spanContext?.origin = TRACE_ORIGIN span?.status = SpanStatus.OK result } catch (e: Throwable) { + span = scopes.span?.startChild("db.sql.query", sql, startTimestamp, Instrumenter.SENTRY) + span?.spanContext?.origin = TRACE_ORIGIN span?.status = SpanStatus.INTERNAL_ERROR span?.throwable = e throw e } finally { span?.apply { - val isMainThread: Boolean = hub.options.mainThreadChecker.isMainThread + val isMainThread: Boolean = scopes.options.threadChecker.isMainThread setData(SpanDataConvention.BLOCKED_MAIN_THREAD_KEY, isMainThread) if (isMainThread) { setData(SpanDataConvention.CALL_STACK_KEY, stackTraceFactory.inAppCallStack) diff --git a/sentry-android-sqlite/src/main/java/io/sentry/android/sqlite/SentryCrossProcessCursor.kt b/sentry-android-sqlite/src/main/java/io/sentry/android/sqlite/SentryCrossProcessCursor.kt new file mode 100644 index 0000000000..962e8bbb71 --- /dev/null +++ b/sentry-android-sqlite/src/main/java/io/sentry/android/sqlite/SentryCrossProcessCursor.kt @@ -0,0 +1,51 @@ +package io.sentry.android.sqlite + +import android.database.CrossProcessCursor +import android.database.CursorWindow + +/* + * SQLiteCursor executes the query lazily, when one of getCount() and onMove() is called. + * Also, by docs, fillWindow() can be used to fill the cursor with data. + * So we wrap these methods to create a span. + * SQLiteCursor is never used directly in the code, but only the Cursor interface. + * This means we can use CrossProcessCursor - that extends Cursor - as wrapper, since + * CrossProcessCursor is an interface and we can use Kotlin delegation. + */ +internal class SentryCrossProcessCursor( + private val delegate: CrossProcessCursor, + private val spanManager: SQLiteSpanManager, + private val sql: String +) : CrossProcessCursor by delegate { + // We have to start the span only the first time, regardless of how many times its methods get called. + private var isSpanStarted = false + + override fun getCount(): Int { + if (isSpanStarted) { + return delegate.count + } + isSpanStarted = true + return spanManager.performSql(sql) { + delegate.count + } + } + + override fun onMove(oldPosition: Int, newPosition: Int): Boolean { + if (isSpanStarted) { + return delegate.onMove(oldPosition, newPosition) + } + isSpanStarted = true + return spanManager.performSql(sql) { + delegate.onMove(oldPosition, newPosition) + } + } + + override fun fillWindow(position: Int, window: CursorWindow?) { + if (isSpanStarted) { + return delegate.fillWindow(position, window) + } + isSpanStarted = true + return spanManager.performSql(sql) { + delegate.fillWindow(position, window) + } + } +} diff --git a/sentry-android-sqlite/src/test/java/io/sentry/android/sqlite/SQLiteSpanManagerTest.kt b/sentry-android-sqlite/src/test/java/io/sentry/android/sqlite/SQLiteSpanManagerTest.kt index e2fa0c2e4d..17c37d69bf 100644 --- a/sentry-android-sqlite/src/test/java/io/sentry/android/sqlite/SQLiteSpanManagerTest.kt +++ b/sentry-android-sqlite/src/test/java/io/sentry/android/sqlite/SQLiteSpanManagerTest.kt @@ -1,20 +1,22 @@ package io.sentry.android.sqlite +import android.database.CrossProcessCursor import android.database.SQLException -import io.sentry.IHub +import io.sentry.IScopes import io.sentry.SentryIntegrationPackageStorage import io.sentry.SentryOptions import io.sentry.SentryTracer import io.sentry.SpanDataConvention import io.sentry.SpanStatus import io.sentry.TransactionContext -import io.sentry.util.thread.IMainThreadChecker +import io.sentry.util.thread.IThreadChecker import org.junit.Before import org.mockito.kotlin.mock import org.mockito.kotlin.whenever import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse +import kotlin.test.assertIs import kotlin.test.assertNotNull import kotlin.test.assertNull import kotlin.test.assertTrue @@ -22,7 +24,7 @@ import kotlin.test.assertTrue class SQLiteSpanManagerTest { private class Fixture { - private val hub = mock() + private val scopes = mock() lateinit var sentryTracer: SentryTracer lateinit var options: SentryOptions @@ -30,13 +32,13 @@ class SQLiteSpanManagerTest { options = SentryOptions().apply { dsn = "https://key@sentry.io/proj" } - whenever(hub.options).thenReturn(options) - sentryTracer = SentryTracer(TransactionContext("name", "op"), hub) + whenever(scopes.options).thenReturn(options) + sentryTracer = SentryTracer(TransactionContext("name", "op"), scopes) if (isSpanActive) { - whenever(hub.span).thenReturn(sentryTracer) + whenever(scopes.span).thenReturn(sentryTracer) } - return SQLiteSpanManager(hub, databaseName) + return SQLiteSpanManager(scopes, databaseName) } } @@ -96,8 +98,8 @@ class SQLiteSpanManagerTest { fun `when performSql runs in background blocked_main_thread is false and no stack trace is attached`() { val sut = fixture.getSut() - fixture.options.mainThreadChecker = mock() - whenever(fixture.options.mainThreadChecker.isMainThread).thenReturn(false) + fixture.options.threadChecker = mock() + whenever(fixture.options.threadChecker.isMainThread).thenReturn(false) sut.performSql("sql") {} val span = fixture.sentryTracer.children.first() @@ -110,8 +112,8 @@ class SQLiteSpanManagerTest { fun `when performSql runs in foreground blocked_main_thread is true and a stack trace is attached`() { val sut = fixture.getSut() - fixture.options.mainThreadChecker = mock() - whenever(fixture.options.mainThreadChecker.isMainThread).thenReturn(true) + fixture.options.threadChecker = mock() + whenever(fixture.options.threadChecker.isMainThread).thenReturn(true) sut.performSql("sql") {} val span = fixture.sentryTracer.children.first() @@ -140,4 +142,17 @@ class SQLiteSpanManagerTest { assertEquals(span.data[SpanDataConvention.DB_SYSTEM_KEY], "in-memory") } + + @Test + fun `when performSql returns a CrossProcessCursor, does not start a span and returns a SentryCrossProcessCursor`() { + val sut = fixture.getSut() + + // When performSql returns a CrossProcessCursor + val result = sut.performSql("sql") { mock() } + + // Returns a SentryCrossProcessCursor + assertIs(result) + // And no span is started + assertNull(fixture.sentryTracer.children.firstOrNull()) + } } diff --git a/sentry-android-sqlite/src/test/java/io/sentry/android/sqlite/SentryCrossProcessCursorTest.kt b/sentry-android-sqlite/src/test/java/io/sentry/android/sqlite/SentryCrossProcessCursorTest.kt new file mode 100644 index 0000000000..409e3a5b07 --- /dev/null +++ b/sentry-android-sqlite/src/test/java/io/sentry/android/sqlite/SentryCrossProcessCursorTest.kt @@ -0,0 +1,124 @@ +package io.sentry.android.sqlite + +import android.database.CrossProcessCursor +import io.sentry.IScopes +import io.sentry.ISpan +import io.sentry.SentryOptions +import io.sentry.SentryTracer +import io.sentry.SpanStatus +import io.sentry.TransactionContext +import org.mockito.kotlin.any +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class SentryCrossProcessCursorTest { + private class Fixture { + private val scopes = mock() + private val spanManager = SQLiteSpanManager(scopes) + val mockCursor = mock() + lateinit var options: SentryOptions + lateinit var sentryTracer: SentryTracer + + fun getSut(sql: String, isSpanActive: Boolean = true): SentryCrossProcessCursor { + options = SentryOptions().apply { + dsn = "https://key@sentry.io/proj" + } + whenever(scopes.options).thenReturn(options) + sentryTracer = SentryTracer(TransactionContext("name", "op"), scopes) + + if (isSpanActive) { + whenever(scopes.span).thenReturn(sentryTracer) + } + return SentryCrossProcessCursor(mockCursor, spanManager, sql) + } + } + + private val fixture = Fixture() + + @Test + fun `all calls are propagated to the delegate`() { + val sql = "sql" + val cursor = fixture.getSut(sql) + + cursor.onMove(0, 1) + verify(fixture.mockCursor).onMove(eq(0), eq(1)) + + cursor.count + verify(fixture.mockCursor).count + + cursor.fillWindow(0, mock()) + verify(fixture.mockCursor).fillWindow(eq(0), any()) + + // Let's verify other methods are delegated, even if not explicitly + cursor.close() + verify(fixture.mockCursor).close() + + cursor.getString(1) + verify(fixture.mockCursor).getString(eq(1)) + } + + @Test + fun `getCount creates a span if a span is running`() { + val sql = "execute" + val sut = fixture.getSut(sql) + assertEquals(0, fixture.sentryTracer.children.size) + sut.count + val span = fixture.sentryTracer.children.firstOrNull() + assertSqlSpanCreated(sql, span) + } + + @Test + fun `getCount does not create a span if no span is running`() { + val sut = fixture.getSut("execute", isSpanActive = false) + sut.count + assertEquals(0, fixture.sentryTracer.children.size) + } + + @Test + fun `onMove creates a span if a span is running`() { + val sql = "execute" + val sut = fixture.getSut(sql) + assertEquals(0, fixture.sentryTracer.children.size) + sut.onMove(0, 5) + val span = fixture.sentryTracer.children.firstOrNull() + assertSqlSpanCreated(sql, span) + } + + @Test + fun `onMove does not create a span if no span is running`() { + val sut = fixture.getSut("execute", isSpanActive = false) + sut.onMove(0, 5) + assertEquals(0, fixture.sentryTracer.children.size) + } + + @Test + fun `fillWindow creates a span if a span is running`() { + val sql = "execute" + val sut = fixture.getSut(sql) + assertEquals(0, fixture.sentryTracer.children.size) + sut.fillWindow(0, mock()) + val span = fixture.sentryTracer.children.firstOrNull() + assertSqlSpanCreated(sql, span) + } + + @Test + fun `fillWindow does not create a span if no span is running`() { + val sut = fixture.getSut("execute", isSpanActive = false) + sut.fillWindow(0, mock()) + assertEquals(0, fixture.sentryTracer.children.size) + } + + private fun assertSqlSpanCreated(sql: String, span: ISpan?) { + assertNotNull(span) + assertEquals("db.sql.query", span.operation) + assertEquals(sql, span.description) + assertEquals(SpanStatus.OK, span.status) + assertTrue(span.isFinished) + } +} diff --git a/sentry-android-sqlite/src/test/java/io/sentry/android/sqlite/SentrySupportSQLiteDatabaseTest.kt b/sentry-android-sqlite/src/test/java/io/sentry/android/sqlite/SentrySupportSQLiteDatabaseTest.kt index cf22c3b0ec..99e1d5f4a0 100644 --- a/sentry-android-sqlite/src/test/java/io/sentry/android/sqlite/SentrySupportSQLiteDatabaseTest.kt +++ b/sentry-android-sqlite/src/test/java/io/sentry/android/sqlite/SentrySupportSQLiteDatabaseTest.kt @@ -3,7 +3,7 @@ package io.sentry.android.sqlite import android.database.Cursor import androidx.sqlite.db.SupportSQLiteDatabase import androidx.sqlite.db.SupportSQLiteQuery -import io.sentry.IHub +import io.sentry.IScopes import io.sentry.ISpan import io.sentry.SentryOptions import io.sentry.SentryTracer @@ -23,8 +23,8 @@ import kotlin.test.assertTrue class SentrySupportSQLiteDatabaseTest { private class Fixture { - private val hub = mock() - private val spanManager = SQLiteSpanManager(hub) + private val scopes = mock() + private val spanManager = SQLiteSpanManager(scopes) val mockDatabase = mock() lateinit var sentryTracer: SentryTracer lateinit var options: SentryOptions @@ -37,11 +37,11 @@ class SentrySupportSQLiteDatabaseTest { options = SentryOptions().apply { dsn = "https://key@sentry.io/proj" } - whenever(hub.options).thenReturn(options) - sentryTracer = SentryTracer(TransactionContext("name", "op"), hub) + whenever(scopes.options).thenReturn(options) + sentryTracer = SentryTracer(TransactionContext("name", "op"), scopes) if (isSpanActive) { - whenever(hub.span).thenReturn(sentryTracer) + whenever(scopes.span).thenReturn(sentryTracer) } return SentrySupportSQLiteDatabase(mockDatabase, spanManager) diff --git a/sentry-android-sqlite/src/test/java/io/sentry/android/sqlite/SentrySupportSQLiteStatementTest.kt b/sentry-android-sqlite/src/test/java/io/sentry/android/sqlite/SentrySupportSQLiteStatementTest.kt index 9078ba8b08..4b6292bd27 100644 --- a/sentry-android-sqlite/src/test/java/io/sentry/android/sqlite/SentrySupportSQLiteStatementTest.kt +++ b/sentry-android-sqlite/src/test/java/io/sentry/android/sqlite/SentrySupportSQLiteStatementTest.kt @@ -1,7 +1,7 @@ package io.sentry.android.sqlite import androidx.sqlite.db.SupportSQLiteStatement -import io.sentry.IHub +import io.sentry.IScopes import io.sentry.ISpan import io.sentry.SentryOptions import io.sentry.SentryTracer @@ -18,8 +18,8 @@ import kotlin.test.assertTrue class SentrySupportSQLiteStatementTest { private class Fixture { - private val hub = mock() - private val spanManager = SQLiteSpanManager(hub) + private val scopes = mock() + private val spanManager = SQLiteSpanManager(scopes) val mockStatement = mock() lateinit var sentryTracer: SentryTracer lateinit var options: SentryOptions @@ -28,11 +28,11 @@ class SentrySupportSQLiteStatementTest { options = SentryOptions().apply { dsn = "https://key@sentry.io/proj" } - whenever(hub.options).thenReturn(options) - sentryTracer = SentryTracer(TransactionContext("name", "op"), hub) + whenever(scopes.options).thenReturn(options) + sentryTracer = SentryTracer(TransactionContext("name", "op"), scopes) if (isSpanActive) { - whenever(hub.span).thenReturn(sentryTracer) + whenever(scopes.span).thenReturn(sentryTracer) } return SentrySupportSQLiteStatement(mockStatement, spanManager, sql) } diff --git a/sentry-android-timber/api/sentry-android-timber.api b/sentry-android-timber/api/sentry-android-timber.api index 808e91bf10..2d71f67570 100644 --- a/sentry-android-timber/api/sentry-android-timber.api +++ b/sentry-android-timber/api/sentry-android-timber.api @@ -14,11 +14,11 @@ public final class io/sentry/android/timber/SentryTimberIntegration : io/sentry/ public fun close ()V public final fun getMinBreadcrumbLevel ()Lio/sentry/SentryLevel; public final fun getMinEventLevel ()Lio/sentry/SentryLevel; - public fun register (Lio/sentry/IHub;Lio/sentry/SentryOptions;)V + public fun register (Lio/sentry/IScopes;Lio/sentry/SentryOptions;)V } public final class io/sentry/android/timber/SentryTimberTree : timber/log/Timber$Tree { - public fun (Lio/sentry/IHub;Lio/sentry/SentryLevel;Lio/sentry/SentryLevel;)V + public fun (Lio/sentry/IScopes;Lio/sentry/SentryLevel;Lio/sentry/SentryLevel;)V public fun d (Ljava/lang/String;[Ljava/lang/Object;)V public fun d (Ljava/lang/Throwable;)V public fun d (Ljava/lang/Throwable;Ljava/lang/String;[Ljava/lang/Object;)V diff --git a/sentry-android-timber/src/main/java/io/sentry/android/timber/SentryTimberIntegration.kt b/sentry-android-timber/src/main/java/io/sentry/android/timber/SentryTimberIntegration.kt index d043faa5f6..334146a218 100644 --- a/sentry-android-timber/src/main/java/io/sentry/android/timber/SentryTimberIntegration.kt +++ b/sentry-android-timber/src/main/java/io/sentry/android/timber/SentryTimberIntegration.kt @@ -1,7 +1,7 @@ package io.sentry.android.timber -import io.sentry.IHub import io.sentry.ILogger +import io.sentry.IScopes import io.sentry.Integration import io.sentry.SentryIntegrationPackageStorage import io.sentry.SentryLevel @@ -21,10 +21,10 @@ class SentryTimberIntegration( private lateinit var tree: SentryTimberTree private lateinit var logger: ILogger - override fun register(hub: IHub, options: SentryOptions) { + override fun register(scopes: IScopes, options: SentryOptions) { logger = options.logger - tree = SentryTimberTree(hub, minEventLevel, minBreadcrumbLevel) + tree = SentryTimberTree(scopes, minEventLevel, minBreadcrumbLevel) Timber.plant(tree) logger.log(SentryLevel.DEBUG, "SentryTimberIntegration installed.") diff --git a/sentry-android-timber/src/main/java/io/sentry/android/timber/SentryTimberTree.kt b/sentry-android-timber/src/main/java/io/sentry/android/timber/SentryTimberTree.kt index f3a0f599a9..dddab75133 100644 --- a/sentry-android-timber/src/main/java/io/sentry/android/timber/SentryTimberTree.kt +++ b/sentry-android-timber/src/main/java/io/sentry/android/timber/SentryTimberTree.kt @@ -2,7 +2,7 @@ package io.sentry.android.timber import android.util.Log import io.sentry.Breadcrumb -import io.sentry.IHub +import io.sentry.IScopes import io.sentry.SentryEvent import io.sentry.SentryLevel import io.sentry.protocol.Message @@ -13,7 +13,7 @@ import timber.log.Timber */ @Suppress("TooManyFunctions") // we have to override all methods to be able to tweak logging class SentryTimberTree( - private val hub: IHub, + private val scopes: IScopes, private val minEventLevel: SentryLevel, private val minBreadcrumbLevel: SentryLevel ) : Timber.Tree() { @@ -269,7 +269,7 @@ class SentryTimberTree( logger = "Timber" } - hub.captureEvent(sentryEvent) + scopes.captureEvent(sentryEvent) } } @@ -296,7 +296,7 @@ class SentryTimberTree( else -> null } - breadCrumb?.let { hub.addBreadcrumb(it) } + breadCrumb?.let { scopes.addBreadcrumb(it) } } } diff --git a/sentry-android-timber/src/test/java/io/sentry/android/timber/SentryTimberIntegrationTest.kt b/sentry-android-timber/src/test/java/io/sentry/android/timber/SentryTimberIntegrationTest.kt index a57853e059..8bb85aa085 100644 --- a/sentry-android-timber/src/test/java/io/sentry/android/timber/SentryTimberIntegrationTest.kt +++ b/sentry-android-timber/src/test/java/io/sentry/android/timber/SentryTimberIntegrationTest.kt @@ -1,6 +1,6 @@ package io.sentry.android.timber -import io.sentry.IHub +import io.sentry.IScopes import io.sentry.SentryLevel import io.sentry.SentryOptions import io.sentry.protocol.SdkVersion @@ -16,7 +16,7 @@ import kotlin.test.assertTrue class SentryTimberIntegrationTest { private class Fixture { - val hub = mock() + val scopes = mock() val options = SentryOptions().apply { sdkVersion = SdkVersion("test", "1.2.3") } @@ -41,7 +41,7 @@ class SentryTimberIntegrationTest { @Test fun `Integrations plants a tree into Timber on register`() { val sut = fixture.getSut() - sut.register(fixture.hub, fixture.options) + sut.register(fixture.scopes, fixture.options) assertEquals(1, Timber.treeCount()) @@ -53,16 +53,16 @@ class SentryTimberIntegrationTest { @Test fun `Integrations plants the SentryTimberTree tree`() { val sut = fixture.getSut() - sut.register(fixture.hub, fixture.options) + sut.register(fixture.scopes, fixture.options) Timber.e(Throwable()) - verify(fixture.hub).captureEvent(any()) + verify(fixture.scopes).captureEvent(any()) } @Test fun `Integrations removes a tree from Timber on close integration`() { val sut = fixture.getSut() - sut.register(fixture.hub, fixture.options) + sut.register(fixture.scopes, fixture.options) assertEquals(1, Timber.treeCount()) @@ -84,7 +84,7 @@ class SentryTimberIntegrationTest { minEventLevel = SentryLevel.INFO, minBreadcrumbLevel = SentryLevel.DEBUG ) - sut.register(fixture.hub, fixture.options) + sut.register(fixture.scopes, fixture.options) assertEquals(sut.minEventLevel, SentryLevel.INFO) assertEquals(sut.minBreadcrumbLevel, SentryLevel.DEBUG) @@ -93,7 +93,7 @@ class SentryTimberIntegrationTest { @Test fun `Integration adds itself to the package list`() { val sut = fixture.getSut() - sut.register(fixture.hub, fixture.options) + sut.register(fixture.scopes, fixture.options) assertTrue( fixture.options.sdkVersion!!.packageSet.any { @@ -106,7 +106,7 @@ class SentryTimberIntegrationTest { @Test fun `Integration adds itself to the integration list`() { val sut = fixture.getSut() - sut.register(fixture.hub, fixture.options) + sut.register(fixture.scopes, fixture.options) assertTrue( fixture.options.sdkVersion!!.integrationSet.contains("Timber") diff --git a/sentry-android-timber/src/test/java/io/sentry/android/timber/SentryTimberTreeTest.kt b/sentry-android-timber/src/test/java/io/sentry/android/timber/SentryTimberTreeTest.kt index 3d82b139ec..2ab7ff64db 100644 --- a/sentry-android-timber/src/test/java/io/sentry/android/timber/SentryTimberTreeTest.kt +++ b/sentry-android-timber/src/test/java/io/sentry/android/timber/SentryTimberTreeTest.kt @@ -1,7 +1,7 @@ package io.sentry.android.timber import io.sentry.Breadcrumb -import io.sentry.IHub +import io.sentry.IScopes import io.sentry.SentryLevel import io.sentry.getExc import org.mockito.kotlin.any @@ -19,13 +19,13 @@ import kotlin.test.assertNull class SentryTimberTreeTest { private class Fixture { - val hub = mock() + val scopes = mock() fun getSut( minEventLevel: SentryLevel = SentryLevel.ERROR, minBreadcrumbLevel: SentryLevel = SentryLevel.INFO ): SentryTimberTree { - return SentryTimberTree(hub, minEventLevel, minBreadcrumbLevel) + return SentryTimberTree(scopes, minEventLevel, minBreadcrumbLevel) } } @@ -40,28 +40,28 @@ class SentryTimberTreeTest { fun `Tree captures an event if min level is equal`() { val sut = fixture.getSut() sut.e(Throwable()) - verify(fixture.hub).captureEvent(any()) + verify(fixture.scopes).captureEvent(any()) } @Test fun `Tree captures an event if min level is higher`() { val sut = fixture.getSut() sut.wtf(Throwable()) - verify(fixture.hub).captureEvent(any()) + verify(fixture.scopes).captureEvent(any()) } @Test fun `Tree won't capture an event if min level is lower`() { val sut = fixture.getSut() sut.d(Throwable()) - verify(fixture.hub, never()).captureEvent(any()) + verify(fixture.scopes, never()).captureEvent(any()) } @Test fun `Tree captures debug level event`() { val sut = fixture.getSut(SentryLevel.DEBUG) sut.d(Throwable()) - verify(fixture.hub).captureEvent( + verify(fixture.scopes).captureEvent( check { assertEquals(SentryLevel.DEBUG, it.level) } @@ -72,7 +72,7 @@ class SentryTimberTreeTest { fun `Tree captures info level event`() { val sut = fixture.getSut(SentryLevel.DEBUG) sut.i(Throwable()) - verify(fixture.hub).captureEvent( + verify(fixture.scopes).captureEvent( check { assertEquals(SentryLevel.INFO, it.level) } @@ -83,7 +83,7 @@ class SentryTimberTreeTest { fun `Tree captures warning level event`() { val sut = fixture.getSut(SentryLevel.DEBUG) sut.w(Throwable()) - verify(fixture.hub).captureEvent( + verify(fixture.scopes).captureEvent( check { assertEquals(SentryLevel.WARNING, it.level) } @@ -94,7 +94,7 @@ class SentryTimberTreeTest { fun `Tree captures error level event`() { val sut = fixture.getSut(SentryLevel.DEBUG) sut.e(Throwable()) - verify(fixture.hub).captureEvent( + verify(fixture.scopes).captureEvent( check { assertEquals(SentryLevel.ERROR, it.level) } @@ -105,7 +105,7 @@ class SentryTimberTreeTest { fun `Tree captures fatal level event`() { val sut = fixture.getSut(SentryLevel.DEBUG) sut.wtf(Throwable()) - verify(fixture.hub).captureEvent( + verify(fixture.scopes).captureEvent( check { assertEquals(SentryLevel.FATAL, it.level) } @@ -116,7 +116,7 @@ class SentryTimberTreeTest { fun `Tree captures unknown as debug level event`() { val sut = fixture.getSut(SentryLevel.DEBUG) sut.log(15, Throwable()) - verify(fixture.hub).captureEvent( + verify(fixture.scopes).captureEvent( check { assertEquals(SentryLevel.DEBUG, it.level) } @@ -128,7 +128,7 @@ class SentryTimberTreeTest { val sut = fixture.getSut() val throwable = Throwable() sut.e(throwable) - verify(fixture.hub).captureEvent( + verify(fixture.scopes).captureEvent( check { assertEquals(throwable, it.getExc()) } @@ -139,7 +139,7 @@ class SentryTimberTreeTest { fun `Tree captures an event without an exception`() { val sut = fixture.getSut() sut.e("message") - verify(fixture.hub).captureEvent( + verify(fixture.scopes).captureEvent( check { assertNull(it.getExc()) } @@ -150,7 +150,7 @@ class SentryTimberTreeTest { fun `Tree captures an event and sets Timber as a logger`() { val sut = fixture.getSut() sut.e("message") - verify(fixture.hub).captureEvent( + verify(fixture.scopes).captureEvent( check { assertEquals("Timber", it.logger) } @@ -164,7 +164,7 @@ class SentryTimberTreeTest { // only available thru static class Timber.tag("tag") Timber.e("message") - verify(fixture.hub).captureEvent( + verify(fixture.scopes).captureEvent( check { assertEquals("tag", it.getTag("TimberTag")) } @@ -176,7 +176,7 @@ class SentryTimberTreeTest { val sut = fixture.getSut() Timber.plant(sut) Timber.e("message") - verify(fixture.hub).captureEvent( + verify(fixture.scopes).captureEvent( check { assertNull(it.getTag("TimberTag")) } @@ -187,7 +187,7 @@ class SentryTimberTreeTest { fun `Tree captures an event with given message`() { val sut = fixture.getSut() sut.e("message") - verify(fixture.hub).captureEvent( + verify(fixture.scopes).captureEvent( check { assertNotNull(it.message) { message -> assertEquals("message", message.message) @@ -200,7 +200,7 @@ class SentryTimberTreeTest { fun `Tree captures an event with formatted message and arguments, when provided`() { val sut = fixture.getSut() sut.e("test count: %d", 32) - verify(fixture.hub).captureEvent( + verify(fixture.scopes).captureEvent( check { assertNotNull(it.message) { message -> assertEquals("test count: %d", message.message) @@ -216,7 +216,7 @@ class SentryTimberTreeTest { val sut = fixture.getSut() sut.e("test count: %d", 32) - verify(fixture.hub).addBreadcrumb( + verify(fixture.scopes).addBreadcrumb( check { assertEquals("test count: 32", it.message) } @@ -227,28 +227,28 @@ class SentryTimberTreeTest { fun `Tree adds a breadcrumb if min level is equal`() { val sut = fixture.getSut() sut.i(Throwable("test")) - verify(fixture.hub).addBreadcrumb(any()) + verify(fixture.scopes).addBreadcrumb(any()) } @Test fun `Tree adds a breadcrumb if min level is higher`() { val sut = fixture.getSut() sut.e(Throwable("test")) - verify(fixture.hub).addBreadcrumb(any()) + verify(fixture.scopes).addBreadcrumb(any()) } @Test fun `Tree won't add a breadcrumb if min level is lower`() { val sut = fixture.getSut(minBreadcrumbLevel = SentryLevel.ERROR) sut.i(Throwable("test")) - verify(fixture.hub, never()).addBreadcrumb(any()) + verify(fixture.scopes, never()).addBreadcrumb(any()) } @Test fun `Tree adds an info breadcrumb`() { val sut = fixture.getSut() sut.i("message") - verify(fixture.hub).addBreadcrumb( + verify(fixture.scopes).addBreadcrumb( check { assertEquals("Timber", it.category) assertEquals(SentryLevel.INFO, it.level) @@ -261,7 +261,7 @@ class SentryTimberTreeTest { fun `Tree adds an error breadcrumb`() { val sut = fixture.getSut() sut.e(Throwable("test")) - verify(fixture.hub).addBreadcrumb( + verify(fixture.scopes).addBreadcrumb( check { assertEquals("exception", it.category) assertEquals(SentryLevel.ERROR, it.level) @@ -274,7 +274,7 @@ class SentryTimberTreeTest { fun `Tree does not add a breadcrumb, if no message provided`() { val sut = fixture.getSut() sut.e(Throwable()) - verify(fixture.hub, never()).addBreadcrumb(any()) + verify(fixture.scopes, never()).addBreadcrumb(any()) } @Test 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-apollo-3/api/sentry-apollo-3.api b/sentry-apollo-3/api/sentry-apollo-3.api index 1c80e1950b..e106585156 100644 --- a/sentry-apollo-3/api/sentry-apollo-3.api +++ b/sentry-apollo-3/api/sentry-apollo-3.api @@ -17,11 +17,11 @@ public final class io/sentry/apollo3/SentryApollo3HttpInterceptor : com/apollogr public static final field SENTRY_APOLLO_3_OPERATION_TYPE Ljava/lang/String; public static final field SENTRY_APOLLO_3_VARIABLES Ljava/lang/String; public fun ()V - public fun (Lio/sentry/IHub;)V - public fun (Lio/sentry/IHub;Lio/sentry/apollo3/SentryApollo3HttpInterceptor$BeforeSpanCallback;)V - public fun (Lio/sentry/IHub;Lio/sentry/apollo3/SentryApollo3HttpInterceptor$BeforeSpanCallback;Z)V - public fun (Lio/sentry/IHub;Lio/sentry/apollo3/SentryApollo3HttpInterceptor$BeforeSpanCallback;ZLjava/util/List;)V - public synthetic fun (Lio/sentry/IHub;Lio/sentry/apollo3/SentryApollo3HttpInterceptor$BeforeSpanCallback;ZLjava/util/List;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Lio/sentry/IScopes;)V + public fun (Lio/sentry/IScopes;Lio/sentry/apollo3/SentryApollo3HttpInterceptor$BeforeSpanCallback;)V + public fun (Lio/sentry/IScopes;Lio/sentry/apollo3/SentryApollo3HttpInterceptor$BeforeSpanCallback;Z)V + public fun (Lio/sentry/IScopes;Lio/sentry/apollo3/SentryApollo3HttpInterceptor$BeforeSpanCallback;ZLjava/util/List;)V + public synthetic fun (Lio/sentry/IScopes;Lio/sentry/apollo3/SentryApollo3HttpInterceptor$BeforeSpanCallback;ZLjava/util/List;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public fun dispose ()V public fun intercept (Lcom/apollographql/apollo3/api/http/HttpRequest;Lcom/apollographql/apollo3/network/http/HttpInterceptorChain;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; } @@ -40,12 +40,12 @@ public final class io/sentry/apollo3/SentryApollo3Interceptor : com/apollographq public final class io/sentry/apollo3/SentryApolloBuilderExtensionsKt { public static final fun sentryTracing (Lcom/apollographql/apollo3/ApolloClient$Builder;)Lcom/apollographql/apollo3/ApolloClient$Builder; - public static final fun sentryTracing (Lcom/apollographql/apollo3/ApolloClient$Builder;Lio/sentry/IHub;)Lcom/apollographql/apollo3/ApolloClient$Builder; - public static final fun sentryTracing (Lcom/apollographql/apollo3/ApolloClient$Builder;Lio/sentry/IHub;Z)Lcom/apollographql/apollo3/ApolloClient$Builder; - public static final fun sentryTracing (Lcom/apollographql/apollo3/ApolloClient$Builder;Lio/sentry/IHub;ZLjava/util/List;)Lcom/apollographql/apollo3/ApolloClient$Builder; - public static final fun sentryTracing (Lcom/apollographql/apollo3/ApolloClient$Builder;Lio/sentry/IHub;ZLjava/util/List;Lio/sentry/apollo3/SentryApollo3HttpInterceptor$BeforeSpanCallback;)Lcom/apollographql/apollo3/ApolloClient$Builder; + public static final fun sentryTracing (Lcom/apollographql/apollo3/ApolloClient$Builder;Lio/sentry/IScopes;)Lcom/apollographql/apollo3/ApolloClient$Builder; + public static final fun sentryTracing (Lcom/apollographql/apollo3/ApolloClient$Builder;Lio/sentry/IScopes;Z)Lcom/apollographql/apollo3/ApolloClient$Builder; + public static final fun sentryTracing (Lcom/apollographql/apollo3/ApolloClient$Builder;Lio/sentry/IScopes;ZLjava/util/List;)Lcom/apollographql/apollo3/ApolloClient$Builder; + public static final fun sentryTracing (Lcom/apollographql/apollo3/ApolloClient$Builder;Lio/sentry/IScopes;ZLjava/util/List;Lio/sentry/apollo3/SentryApollo3HttpInterceptor$BeforeSpanCallback;)Lcom/apollographql/apollo3/ApolloClient$Builder; public static final fun sentryTracing (Lcom/apollographql/apollo3/ApolloClient$Builder;ZLjava/util/List;Lio/sentry/apollo3/SentryApollo3HttpInterceptor$BeforeSpanCallback;)Lcom/apollographql/apollo3/ApolloClient$Builder; - public static synthetic fun sentryTracing$default (Lcom/apollographql/apollo3/ApolloClient$Builder;Lio/sentry/IHub;ZLjava/util/List;Lio/sentry/apollo3/SentryApollo3HttpInterceptor$BeforeSpanCallback;ILjava/lang/Object;)Lcom/apollographql/apollo3/ApolloClient$Builder; + public static synthetic fun sentryTracing$default (Lcom/apollographql/apollo3/ApolloClient$Builder;Lio/sentry/IScopes;ZLjava/util/List;Lio/sentry/apollo3/SentryApollo3HttpInterceptor$BeforeSpanCallback;ILjava/lang/Object;)Lcom/apollographql/apollo3/ApolloClient$Builder; public static synthetic fun sentryTracing$default (Lcom/apollographql/apollo3/ApolloClient$Builder;ZLjava/util/List;Lio/sentry/apollo3/SentryApollo3HttpInterceptor$BeforeSpanCallback;ILjava/lang/Object;)Lcom/apollographql/apollo3/ApolloClient$Builder; } diff --git a/sentry-apollo-3/src/main/java/io/sentry/apollo3/SentryApollo3HttpInterceptor.kt b/sentry-apollo-3/src/main/java/io/sentry/apollo3/SentryApollo3HttpInterceptor.kt index 08cab179a5..52219cb8e1 100644 --- a/sentry-apollo-3/src/main/java/io/sentry/apollo3/SentryApollo3HttpInterceptor.kt +++ b/sentry-apollo-3/src/main/java/io/sentry/apollo3/SentryApollo3HttpInterceptor.kt @@ -11,9 +11,9 @@ import com.apollographql.apollo3.network.http.HttpInterceptorChain import io.sentry.BaggageHeader import io.sentry.Breadcrumb import io.sentry.Hint -import io.sentry.HubAdapter -import io.sentry.IHub +import io.sentry.IScopes import io.sentry.ISpan +import io.sentry.ScopesAdapter import io.sentry.SentryEvent import io.sentry.SentryIntegrationPackageStorage import io.sentry.SentryLevel @@ -41,7 +41,7 @@ import java.util.Locale private const val TRACE_ORIGIN = "auto.graphql.apollo3" class SentryApollo3HttpInterceptor @JvmOverloads constructor( - @ApiStatus.Internal private val hub: IHub = HubAdapter.getInstance(), + @ApiStatus.Internal private val scopes: IScopes = ScopesAdapter.getInstance(), private val beforeSpan: BeforeSpanCallback? = null, private val captureFailedRequests: Boolean = DEFAULT_CAPTURE_FAILED_REQUESTS, private val failedRequestTargets: List = listOf(DEFAULT_PROPAGATION_TARGETS) @@ -65,7 +65,7 @@ class SentryApollo3HttpInterceptor @JvmOverloads constructor( request: HttpRequest, chain: HttpInterceptorChain ): HttpResponse { - val activeSpan = if (Platform.isAndroid()) hub.transaction else hub.span + val activeSpan = if (Platform.isAndroid()) scopes.transaction else scopes.span val operationName = getHeader(HEADER_APOLLO_OPERATION_NAME, request.headers) val operationType = decodeHeaderValue(request, SENTRY_APOLLO_3_OPERATION_TYPE) @@ -77,7 +77,7 @@ class SentryApollo3HttpInterceptor @JvmOverloads constructor( span = startChild(request, activeSpan, operationName, operationType, operationId) } - val modifiedRequest = maybeAddTracingHeaders(hub, request, span) + val modifiedRequest = maybeAddTracingHeaders(scopes, request, span) var httpResponse: HttpResponse? = null var statusCode: Int? = null @@ -117,10 +117,10 @@ class SentryApollo3HttpInterceptor @JvmOverloads constructor( } } - private fun maybeAddTracingHeaders(hub: IHub, request: HttpRequest, span: ISpan?): HttpRequest { + private fun maybeAddTracingHeaders(scopes: IScopes, request: HttpRequest, span: ISpan?): HttpRequest { var cleanedHeaders = removeSentryInternalHeaders(request.headers).toMutableList() - TracingUtils.traceIfAllowed(hub, request.url, request.headers.filter { it.name == BaggageHeader.BAGGAGE_HEADER }.map { it.value }, span)?.let { + TracingUtils.traceIfAllowed(scopes, request.url, request.headers.filter { it.name == BaggageHeader.BAGGAGE_HEADER }.map { it.value }, span)?.let { cleanedHeaders.add(HttpHeader(it.sentryTraceHeader.name, it.sentryTraceHeader.value)) it.baggageHeader?.let { baggageHeader -> cleanedHeaders = cleanedHeaders.filterNot { it.name == BaggageHeader.BAGGAGE_HEADER }.toMutableList().apply { @@ -179,7 +179,7 @@ class SentryApollo3HttpInterceptor @JvmOverloads constructor( try { String(Base64.decode(it, Base64.NO_WRAP)) } catch (e: Throwable) { - hub.options.logger.log( + scopes.options.logger.log( SentryLevel.ERROR, "Error decoding internal apolloHeader $headerName", e @@ -218,7 +218,7 @@ class SentryApollo3HttpInterceptor @JvmOverloads constructor( span.spanContext.sampled = false } } catch (e: Throwable) { - hub.options.logger.log( + scopes.options.logger.log( SentryLevel.ERROR, "An error occurred while executing beforeSpan on ApolloInterceptor", e @@ -256,7 +256,7 @@ class SentryApollo3HttpInterceptor @JvmOverloads constructor( hint.set(APOLLO_RESPONSE, httpResponse) } - hub.addBreadcrumb(breadcrumb, hint) + scopes.addBreadcrumb(breadcrumb, hint) } // Extensions @@ -273,7 +273,7 @@ class SentryApollo3HttpInterceptor @JvmOverloads constructor( private fun getHeaders(headers: List): MutableMap? { // Headers are only sent if isSendDefaultPii is enabled due to PII - if (!hub.options.isSendDefaultPii) { + if (!scopes.options.isSendDefaultPii) { return null } @@ -311,7 +311,7 @@ class SentryApollo3HttpInterceptor @JvmOverloads constructor( val body = try { response.body?.peek()?.readUtf8() ?: "" } catch (e: Throwable) { - hub.options.logger.log( + scopes.options.logger.log( SentryLevel.ERROR, "Error reading the response body.", e @@ -368,7 +368,7 @@ class SentryApollo3HttpInterceptor @JvmOverloads constructor( urlDetails.applyToRequest(this) // Cookie is only sent if isSendDefaultPii is enabled cookies = - if (hub.options.isSendDefaultPii) getHeader("Cookie", request.headers) else null + if (scopes.options.isSendDefaultPii) getHeader("Cookie", request.headers) else null method = request.method.name headers = getHeaders(request.headers) apiTarget = "graphql" @@ -382,7 +382,7 @@ class SentryApollo3HttpInterceptor @JvmOverloads constructor( it.writeTo(buffer) data = buffer.readUtf8() } catch (e: Throwable) { - hub.options.logger.log( + scopes.options.logger.log( SentryLevel.ERROR, "Error reading the request body.", e @@ -396,7 +396,7 @@ class SentryApollo3HttpInterceptor @JvmOverloads constructor( val sentryResponse = Response().apply { // Set-Cookie is only sent if isSendDefaultPii is enabled due to PII - cookies = if (hub.options.isSendDefaultPii) { + cookies = if (scopes.options.isSendDefaultPii) { getHeader( "Set-Cookie", response.headers @@ -419,9 +419,9 @@ class SentryApollo3HttpInterceptor @JvmOverloads constructor( event.contexts.setResponse(sentryResponse) event.fingerprints = fingerprints - hub.captureEvent(event, hint) + scopes.captureEvent(event, hint) } catch (e: Throwable) { - hub.options.logger.log( + scopes.options.logger.log( SentryLevel.ERROR, "Error capturing the GraphQL error.", e diff --git a/sentry-apollo-3/src/main/java/io/sentry/apollo3/SentryApolloBuilderExtensions.kt b/sentry-apollo-3/src/main/java/io/sentry/apollo3/SentryApolloBuilderExtensions.kt index 2cdbc148fb..b40b1c183d 100644 --- a/sentry-apollo-3/src/main/java/io/sentry/apollo3/SentryApolloBuilderExtensions.kt +++ b/sentry-apollo-3/src/main/java/io/sentry/apollo3/SentryApolloBuilderExtensions.kt @@ -1,14 +1,14 @@ package io.sentry.apollo3 import com.apollographql.apollo3.ApolloClient -import io.sentry.HubAdapter -import io.sentry.IHub +import io.sentry.IScopes +import io.sentry.ScopesAdapter import io.sentry.SentryOptions.DEFAULT_PROPAGATION_TARGETS import io.sentry.apollo3.SentryApollo3HttpInterceptor.Companion.DEFAULT_CAPTURE_FAILED_REQUESTS @JvmOverloads fun ApolloClient.Builder.sentryTracing( - hub: IHub = HubAdapter.getInstance(), + scopes: IScopes = ScopesAdapter.getInstance(), captureFailedRequests: Boolean = DEFAULT_CAPTURE_FAILED_REQUESTS, failedRequestTargets: List = listOf(DEFAULT_PROPAGATION_TARGETS), beforeSpan: SentryApollo3HttpInterceptor.BeforeSpanCallback? = null @@ -16,7 +16,7 @@ fun ApolloClient.Builder.sentryTracing( addInterceptor(SentryApollo3Interceptor()) addHttpInterceptor( SentryApollo3HttpInterceptor( - hub = hub, + scopes = scopes, captureFailedRequests = captureFailedRequests, failedRequestTargets = failedRequestTargets, beforeSpan = beforeSpan @@ -31,7 +31,7 @@ fun ApolloClient.Builder.sentryTracing( beforeSpan: SentryApollo3HttpInterceptor.BeforeSpanCallback? = null ): ApolloClient.Builder { return sentryTracing( - hub = HubAdapter.getInstance(), + scopes = ScopesAdapter.getInstance(), captureFailedRequests = captureFailedRequests, failedRequestTargets = failedRequestTargets, beforeSpan = beforeSpan diff --git a/sentry-apollo-3/src/test/java/io/sentry/apollo3/SentryApollo3InterceptorClientErrors.kt b/sentry-apollo-3/src/test/java/io/sentry/apollo3/SentryApollo3InterceptorClientErrors.kt index 40406b77b5..b3f8b6d57e 100644 --- a/sentry-apollo-3/src/test/java/io/sentry/apollo3/SentryApollo3InterceptorClientErrors.kt +++ b/sentry-apollo-3/src/test/java/io/sentry/apollo3/SentryApollo3InterceptorClientErrors.kt @@ -5,7 +5,7 @@ import com.apollographql.apollo3.api.http.HttpRequest import com.apollographql.apollo3.api.http.HttpResponse import com.apollographql.apollo3.exception.ApolloException import io.sentry.Hint -import io.sentry.IHub +import io.sentry.IScopes import io.sentry.SentryIntegrationPackageStorage import io.sentry.SentryOptions import io.sentry.SentryOptions.DEFAULT_PROPAGATION_TARGETS @@ -35,7 +35,7 @@ import kotlin.test.assertTrue class SentryApollo3InterceptorClientErrors { class Fixture { val server = MockWebServer() - lateinit var hub: IHub + lateinit var scopes: IScopes private val responseBodyOk = """{ @@ -75,7 +75,7 @@ class SentryApollo3InterceptorClientErrors { ): ApolloClient { SentryIntegrationPackageStorage.getInstance().clearStorage() - hub = mock().apply { + scopes = mock().apply { whenever(options).thenReturn( SentryOptions().apply { dsn = "https://key@sentry.io/proj" @@ -84,7 +84,7 @@ class SentryApollo3InterceptorClientErrors { } ) } - whenever(hub.captureEvent(any(), any())).thenReturn(SentryId.EMPTY_ID) + whenever(scopes.captureEvent(any(), any())).thenReturn(SentryId.EMPTY_ID) val response = MockResponse() .setBody(responseBody) @@ -102,7 +102,7 @@ class SentryApollo3InterceptorClientErrors { val builder = ApolloClient.Builder() .serverUrl(server.url("?myQuery=query#myFragment").toString()) .sentryTracing( - hub = hub, + scopes = scopes, captureFailedRequests = captureFailedRequests, failedRequestTargets = failedRequestTargets ) @@ -123,7 +123,7 @@ class SentryApollo3InterceptorClientErrors { val sut = fixture.getSut(captureFailedRequests = false, responseBody = fixture.responseBodyNotOk) executeQuery(sut) - verify(fixture.hub, never()).captureEvent(any(), any()) + verify(fixture.scopes, never()).captureEvent(any(), any()) } @Test @@ -132,7 +132,7 @@ class SentryApollo3InterceptorClientErrors { fixture.getSut(responseBody = fixture.responseBodyNotOk) executeQuery(sut) - verify(fixture.hub).captureEvent(any(), any()) + verify(fixture.scopes).captureEvent(any(), any()) } // endregion @@ -165,7 +165,7 @@ class SentryApollo3InterceptorClientErrors { ) executeQuery(sut) - verify(fixture.hub, never()).captureEvent(any(), any()) + verify(fixture.scopes, never()).captureEvent(any(), any()) } @Test @@ -174,7 +174,7 @@ class SentryApollo3InterceptorClientErrors { fixture.getSut(responseBody = fixture.responseBodyNotOk) executeQuery(sut) - verify(fixture.hub).captureEvent(any(), any()) + verify(fixture.scopes).captureEvent(any(), any()) } // endregion @@ -187,7 +187,7 @@ class SentryApollo3InterceptorClientErrors { fixture.getSut(responseBody = fixture.responseBodyNotOk) executeQuery(sut) - verify(fixture.hub).captureEvent( + verify(fixture.scopes).captureEvent( check { val throwable = (it.throwableMechanism as ExceptionMechanismException) assertEquals("SentryApollo3Interceptor", throwable.exceptionMechanism.type) @@ -202,7 +202,7 @@ class SentryApollo3InterceptorClientErrors { fixture.getSut(responseBody = fixture.responseBodyNotOk) executeQuery(sut) - verify(fixture.hub).captureEvent( + verify(fixture.scopes).captureEvent( check { val throwable = (it.throwableMechanism as ExceptionMechanismException) assertEquals("GraphQL Request failed, name: LaunchDetails, type: query", throwable.throwable.message) @@ -217,7 +217,7 @@ class SentryApollo3InterceptorClientErrors { fixture.getSut(responseBody = fixture.responseBodyNotOk) executeQuery(sut) - verify(fixture.hub).captureEvent( + verify(fixture.scopes).captureEvent( check { val throwable = (it.throwableMechanism as ExceptionMechanismException) assertTrue(throwable.isSnapshot) @@ -238,7 +238,7 @@ class SentryApollo3InterceptorClientErrors { {"operationName":"LaunchDetails","variables":{"id":"83"},"query":"query LaunchDetails($escapeDolar: ID!) { launch(id: $escapeDolar) { id site mission { name missionPatch(size: LARGE) } rocket { name type } } }"} """.trimIndent() - verify(fixture.hub).captureEvent( + verify(fixture.scopes).captureEvent( check { val request = it.request!! @@ -262,7 +262,7 @@ class SentryApollo3InterceptorClientErrors { fixture.getSut(responseBody = fixture.responseBodyNotOk, sendDefaultPii = true) executeQuery(sut) - verify(fixture.hub).captureEvent( + verify(fixture.scopes).captureEvent( check { val request = it.request!! @@ -280,7 +280,7 @@ class SentryApollo3InterceptorClientErrors { fixture.getSut(responseBody = fixture.responseBodyNotOk) executeQuery(sut) - verify(fixture.hub).captureEvent( + verify(fixture.scopes).captureEvent( check { val response = it.contexts.response!! @@ -300,7 +300,7 @@ class SentryApollo3InterceptorClientErrors { fixture.getSut(responseBody = fixture.responseBodyNotOk, sendDefaultPii = true) executeQuery(sut) - verify(fixture.hub).captureEvent( + verify(fixture.scopes).captureEvent( check { val response = it.contexts.response!! @@ -318,7 +318,7 @@ class SentryApollo3InterceptorClientErrors { fixture.getSut(responseBody = fixture.responseBodyNotOk) executeQuery(sut) - verify(fixture.hub).captureEvent( + verify(fixture.scopes).captureEvent( check { assertEquals(listOf("LaunchDetails", "query", "200"), it.fingerprints) }, @@ -337,7 +337,7 @@ class SentryApollo3InterceptorClientErrors { executeQuery(sut) // HttpInterceptor does not throw for >= 400 - verify(fixture.hub).captureEvent(any(), any()) + verify(fixture.scopes).captureEvent(any(), any()) } @Test @@ -345,7 +345,7 @@ class SentryApollo3InterceptorClientErrors { val sut = fixture.getSut(responseBody = fixture.responseBodyNotOk) - whenever(fixture.hub.captureEvent(any(), any())).thenThrow(RuntimeException()) + whenever(fixture.scopes.captureEvent(any(), any())).thenThrow(RuntimeException()) executeQuery(sut) } @@ -360,7 +360,7 @@ class SentryApollo3InterceptorClientErrors { fixture.getSut(responseBody = fixture.responseBodyNotOk) executeQuery(sut) - verify(fixture.hub).captureEvent( + verify(fixture.scopes).captureEvent( any(), check { val request = it.get(TypeCheckHint.APOLLO_REQUEST) diff --git a/sentry-apollo-3/src/test/java/io/sentry/apollo3/SentryApollo3InterceptorTest.kt b/sentry-apollo-3/src/test/java/io/sentry/apollo3/SentryApollo3InterceptorTest.kt index 44d8bfd624..3b836f45b9 100644 --- a/sentry-apollo-3/src/test/java/io/sentry/apollo3/SentryApollo3InterceptorTest.kt +++ b/sentry-apollo-3/src/test/java/io/sentry/apollo3/SentryApollo3InterceptorTest.kt @@ -9,7 +9,7 @@ import com.apollographql.apollo3.network.http.HttpInterceptor import com.apollographql.apollo3.network.http.HttpInterceptorChain import io.sentry.BaggageHeader import io.sentry.Breadcrumb -import io.sentry.IHub +import io.sentry.IScopes import io.sentry.ITransaction import io.sentry.Scope import io.sentry.ScopeCallback @@ -57,11 +57,11 @@ class SentryApollo3InterceptorTest { sdkVersion = SdkVersion("test", "1.2.3") } val scope = Scope(options) - val hub = mock().also { + val scopes = mock().also { whenever(it.options).thenReturn(options) doAnswer { (it.arguments[0] as ScopeCallback).run(scope) }.whenever(it).configureScope(any()) } - private var httpInterceptor = SentryApollo3HttpInterceptor(hub, captureFailedRequests = false) + private var httpInterceptor = SentryApollo3HttpInterceptor(scopes, captureFailedRequests = false) @SuppressWarnings("LongParameterList") fun getSut( @@ -93,7 +93,7 @@ class SentryApollo3InterceptorTest { ) if (beforeSpan != null) { - httpInterceptor = SentryApollo3HttpInterceptor(hub, beforeSpan, captureFailedRequests = false) + httpInterceptor = SentryApollo3HttpInterceptor(scopes, beforeSpan, captureFailedRequests = false) } val builder = ApolloClient.Builder() @@ -124,7 +124,7 @@ class SentryApollo3InterceptorTest { fun `creates a span around the successful request`() { executeQuery() - verify(fixture.hub).captureTransaction( + verify(fixture.scopes).captureTransaction( check { assertTransactionDetails(it, httpStatusCode = 200) assertEquals(SpanStatus.OK, it.spans.first().status) @@ -139,7 +139,7 @@ class SentryApollo3InterceptorTest { fun `creates a span around the failed request`() { executeQuery(fixture.getSut(httpStatusCode = 403)) - verify(fixture.hub).captureTransaction( + verify(fixture.scopes).captureTransaction( check { assertTransactionDetails(it, httpStatusCode = 403) assertEquals(SpanStatus.PERMISSION_DENIED, it.spans.first().status) @@ -159,7 +159,7 @@ class SentryApollo3InterceptorTest { } executeQuery(fixture.getSut(interceptor = failingInterceptor)) - verify(fixture.hub).captureTransaction( + verify(fixture.scopes).captureTransaction( check { assertTransactionDetails(it, httpStatusCode = 404, contentLength = null) assertEquals("POST", it.spans.first().data?.get(SpanDataConvention.HTTP_METHOD_KEY)) @@ -176,7 +176,7 @@ class SentryApollo3InterceptorTest { fun `creates a span around the request failing with network error`() { executeQuery(fixture.getSut(socketPolicy = SocketPolicy.DISCONNECT_DURING_REQUEST_BODY)) - verify(fixture.hub).captureTransaction( + verify(fixture.scopes).captureTransaction( check { assertTransactionDetails(it, httpStatusCode = null, contentLength = null) assertEquals(SpanStatus.INTERNAL_ERROR, it.spans.first().status) @@ -241,7 +241,7 @@ class SentryApollo3InterceptorTest { ) ) - verify(fixture.hub).captureTransaction( + verify(fixture.scopes).captureTransaction( check { assertEquals(1, it.spans.size) val httpClientSpan = it.spans.first() @@ -261,7 +261,7 @@ class SentryApollo3InterceptorTest { ) ) - verify(fixture.hub).captureTransaction( + verify(fixture.scopes).captureTransaction( check { assertEquals(0, it.spans.size) }, @@ -281,7 +281,7 @@ class SentryApollo3InterceptorTest { ) ) - verify(fixture.hub).captureTransaction( + verify(fixture.scopes).captureTransaction( check { assertEquals(1, it.spans.size) }, @@ -294,7 +294,7 @@ class SentryApollo3InterceptorTest { @Test fun `adds breadcrumb when http calls succeeds`() { executeQuery(fixture.getSut()) - verify(fixture.hub).addBreadcrumb( + verify(fixture.scopes).addBreadcrumb( check { assertEquals("http", it.type) // response_body_size is added but mock webserver returns 0 always @@ -309,9 +309,9 @@ class SentryApollo3InterceptorTest { @Test fun `sets SDKVersion Info`() { - assertNotNull(fixture.hub.options.sdkVersion) - assert(fixture.hub.options.sdkVersion!!.integrationSet.contains("Apollo3")) - val packageInfo = fixture.hub.options.sdkVersion!!.packageSet.firstOrNull { pkg -> pkg.name == "maven:io.sentry:sentry-apollo-3" } + assertNotNull(fixture.scopes.options.sdkVersion) + assert(fixture.scopes.options.sdkVersion!!.integrationSet.contains("Apollo3")) + val packageInfo = fixture.scopes.options.sdkVersion!!.packageSet.firstOrNull { pkg -> pkg.name == "maven:io.sentry:sentry-apollo-3" } assertNotNull(packageInfo) assert(packageInfo.version == BuildConfig.VERSION_NAME) } @@ -320,14 +320,14 @@ class SentryApollo3InterceptorTest { fun `attaches to root transaction on Android`() { Apollo3PlatformTestManipulator.pretendIsAndroid(true) executeQuery(fixture.getSut()) - verify(fixture.hub).transaction + verify(fixture.scopes).transaction } @Test fun `attaches to child span on non-Android`() { Apollo3PlatformTestManipulator.pretendIsAndroid(false) executeQuery(fixture.getSut()) - verify(fixture.hub).span + verify(fixture.scopes).span } private fun assertTransactionDetails(it: SentryTransaction, httpStatusCode: Int? = 200, contentLength: Long? = 0L) { @@ -350,9 +350,9 @@ class SentryApollo3InterceptorTest { private fun executeQuery(sut: ApolloClient = fixture.getSut(), isSpanActive: Boolean = true, id: String = "83") = runBlocking { var tx: ITransaction? = null if (isSpanActive) { - tx = SentryTracer(TransactionContext("op", "desc", TracesSamplingDecision(true)), fixture.hub) - whenever(fixture.hub.transaction).thenReturn(tx) - whenever(fixture.hub.span).thenReturn(tx) + tx = SentryTracer(TransactionContext("op", "desc", TracesSamplingDecision(true)), fixture.scopes) + whenever(fixture.scopes.transaction).thenReturn(tx) + whenever(fixture.scopes.span).thenReturn(tx) } val coroutine = launch { diff --git a/sentry-apollo-3/src/test/java/io/sentry/apollo3/SentryApollo3InterceptorWithVariablesTest.kt b/sentry-apollo-3/src/test/java/io/sentry/apollo3/SentryApollo3InterceptorWithVariablesTest.kt index 81775efc18..3ac3d80d7d 100644 --- a/sentry-apollo-3/src/test/java/io/sentry/apollo3/SentryApollo3InterceptorWithVariablesTest.kt +++ b/sentry-apollo-3/src/test/java/io/sentry/apollo3/SentryApollo3InterceptorWithVariablesTest.kt @@ -3,7 +3,7 @@ package io.sentry.apollo3 import com.apollographql.apollo3.ApolloClient import com.apollographql.apollo3.exception.ApolloException import io.sentry.Breadcrumb -import io.sentry.IHub +import io.sentry.IScopes import io.sentry.ITransaction import io.sentry.SentryOptions import io.sentry.SentryTracer @@ -32,7 +32,7 @@ class SentryApollo3InterceptorWithVariablesTest { class Fixture { val server = MockWebServer() - val hub = mock() + val scopes = mock() @SuppressWarnings("LongParameterList") fun getSut( @@ -54,7 +54,7 @@ class SentryApollo3InterceptorWithVariablesTest { socketPolicy: SocketPolicy = SocketPolicy.KEEP_OPEN, beforeSpan: BeforeSpanCallback? = null ): ApolloClient { - whenever(hub.options).thenReturn( + whenever(scopes.options).thenReturn( SentryOptions().apply { dsn = "http://key@localhost/proj" } @@ -68,7 +68,7 @@ class SentryApollo3InterceptorWithVariablesTest { ) return ApolloClient.Builder().serverUrl(server.url("/").toString()) - .sentryTracing(hub = hub, beforeSpan = beforeSpan, captureFailedRequests = false) + .sentryTracing(scopes = scopes, beforeSpan = beforeSpan, captureFailedRequests = false) .build() } } @@ -79,7 +79,7 @@ class SentryApollo3InterceptorWithVariablesTest { fun `creates a span around the successful request`() { executeQuery() - verify(fixture.hub).captureTransaction( + verify(fixture.scopes).captureTransaction( check { assertTransactionDetails(it) assertEquals(SpanStatus.OK, it.spans.first().status) @@ -94,7 +94,7 @@ class SentryApollo3InterceptorWithVariablesTest { fun `creates a span around the failed request`() { executeQuery(fixture.getSut(httpStatusCode = 403)) - verify(fixture.hub).captureTransaction( + verify(fixture.scopes).captureTransaction( check { assertTransactionDetails(it) assertEquals(SpanStatus.PERMISSION_DENIED, it.spans.first().status) @@ -109,7 +109,7 @@ class SentryApollo3InterceptorWithVariablesTest { fun `creates a span around the request failing with network error`() { executeQuery(fixture.getSut(socketPolicy = SocketPolicy.DISCONNECT_DURING_REQUEST_BODY)) - verify(fixture.hub).captureTransaction( + verify(fixture.scopes).captureTransaction( check { assertTransactionDetails(it) assertEquals(SpanStatus.INTERNAL_ERROR, it.spans.first().status) @@ -124,7 +124,7 @@ class SentryApollo3InterceptorWithVariablesTest { fun `handles non-ascii header values correctly`() { executeQuery(id = "á") - verify(fixture.hub).captureTransaction( + verify(fixture.scopes).captureTransaction( check { assertTransactionDetails(it) assertEquals(SpanStatus.OK, it.spans.first().status) @@ -138,7 +138,7 @@ class SentryApollo3InterceptorWithVariablesTest { @Test fun `adds breadcrumb when http calls succeeds`() { executeQuery(fixture.getSut()) - verify(fixture.hub).addBreadcrumb( + verify(fixture.scopes).addBreadcrumb( check { assertEquals("http", it.type) // response_body_size is added but mock webserver returns 0 always @@ -173,8 +173,8 @@ class SentryApollo3InterceptorWithVariablesTest { private fun executeQuery(sut: ApolloClient = fixture.getSut(), isSpanActive: Boolean = true, id: String = "83") = runBlocking { var tx: ITransaction? = null if (isSpanActive) { - tx = SentryTracer(TransactionContext("op", "desc", TracesSamplingDecision(true)), fixture.hub) - whenever(fixture.hub.span).thenReturn(tx) + tx = SentryTracer(TransactionContext("op", "desc", TracesSamplingDecision(true)), fixture.scopes) + whenever(fixture.scopes.span).thenReturn(tx) } val coroutine = launch { diff --git a/sentry-apollo/api/sentry-apollo.api b/sentry-apollo/api/sentry-apollo.api index 8c18bce06e..63eac6a193 100644 --- a/sentry-apollo/api/sentry-apollo.api +++ b/sentry-apollo/api/sentry-apollo.api @@ -5,9 +5,9 @@ public final class io/sentry/apollo/BuildConfig { public final class io/sentry/apollo/SentryApolloInterceptor : com/apollographql/apollo/interceptor/ApolloInterceptor { public fun ()V - public fun (Lio/sentry/IHub;)V - public fun (Lio/sentry/IHub;Lio/sentry/apollo/SentryApolloInterceptor$BeforeSpanCallback;)V - public synthetic fun (Lio/sentry/IHub;Lio/sentry/apollo/SentryApolloInterceptor$BeforeSpanCallback;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Lio/sentry/IScopes;)V + public fun (Lio/sentry/IScopes;Lio/sentry/apollo/SentryApolloInterceptor$BeforeSpanCallback;)V + public synthetic fun (Lio/sentry/IScopes;Lio/sentry/apollo/SentryApolloInterceptor$BeforeSpanCallback;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public fun (Lio/sentry/apollo/SentryApolloInterceptor$BeforeSpanCallback;)V public fun dispose ()V public fun interceptAsync (Lcom/apollographql/apollo/interceptor/ApolloInterceptor$InterceptorRequest;Lcom/apollographql/apollo/interceptor/ApolloInterceptorChain;Ljava/util/concurrent/Executor;Lcom/apollographql/apollo/interceptor/ApolloInterceptor$CallBack;)V diff --git a/sentry-apollo/src/main/java/io/sentry/apollo/SentryApolloInterceptor.kt b/sentry-apollo/src/main/java/io/sentry/apollo/SentryApolloInterceptor.kt index faa8a549a9..fe5a6a4762 100644 --- a/sentry-apollo/src/main/java/io/sentry/apollo/SentryApolloInterceptor.kt +++ b/sentry-apollo/src/main/java/io/sentry/apollo/SentryApolloInterceptor.kt @@ -15,9 +15,9 @@ import com.apollographql.apollo.request.RequestHeaders import io.sentry.BaggageHeader import io.sentry.Breadcrumb import io.sentry.Hint -import io.sentry.HubAdapter -import io.sentry.IHub +import io.sentry.IScopes import io.sentry.ISpan +import io.sentry.ScopesAdapter import io.sentry.SentryIntegrationPackageStorage import io.sentry.SentryLevel import io.sentry.SpanDataConvention @@ -32,12 +32,12 @@ import java.util.concurrent.Executor private const val TRACE_ORIGIN = "auto.graphql.apollo" class SentryApolloInterceptor( - private val hub: IHub = HubAdapter.getInstance(), + private val scopes: IScopes = ScopesAdapter.getInstance(), private val beforeSpan: BeforeSpanCallback? = null ) : ApolloInterceptor { - constructor(hub: IHub) : this(hub, null) - constructor(beforeSpan: BeforeSpanCallback) : this(HubAdapter.getInstance(), beforeSpan) + constructor(scopes: IScopes) : this(scopes, null) + constructor(beforeSpan: BeforeSpanCallback) : this(ScopesAdapter.getInstance(), beforeSpan) init { addIntegrationToSdkVersion(javaClass) @@ -45,7 +45,7 @@ class SentryApolloInterceptor( } override fun interceptAsync(request: InterceptorRequest, chain: ApolloInterceptorChain, dispatcher: Executor, callBack: CallBack) { - val activeSpan = if (io.sentry.util.Platform.isAndroid()) hub.transaction else hub.span + val activeSpan = if (io.sentry.util.Platform.isAndroid()) scopes.transaction else scopes.span if (activeSpan == null) { val headers = addTracingHeaders(request, null) val modifiedRequest = request.toBuilder().requestHeaders(headers).build() @@ -115,10 +115,10 @@ class SentryApolloInterceptor( private fun addTracingHeaders(request: InterceptorRequest, span: ISpan?): RequestHeaders { val requestHeaderBuilder = request.requestHeaders.toBuilder() - if (hub.options.isTraceSampling) { + if (scopes.options.isTraceSampling) { // we have no access to URI, no way to verify tracing origins TracingUtils.trace( - hub, + scopes, listOf(request.requestHeaders.headerValue(BaggageHeader.BAGGAGE_HEADER)), span )?.let { tracingHeaders -> @@ -154,7 +154,7 @@ class SentryApolloInterceptor( try { newSpan = beforeSpan.execute(span, request, response) } catch (e: Exception) { - hub.options.logger.log(SentryLevel.ERROR, "An error occurred while executing beforeSpan on ApolloInterceptor", e) + scopes.options.logger.log(SentryLevel.ERROR, "An error occurred while executing beforeSpan on ApolloInterceptor", e) } } if (newSpan == null) { @@ -182,7 +182,7 @@ class SentryApolloInterceptor( set(APOLLO_REQUEST, httpRequest) set(APOLLO_RESPONSE, httpResponse) } - hub.addBreadcrumb(breadcrumb, hint) + scopes.addBreadcrumb(breadcrumb, hint) } } } diff --git a/sentry-apollo/src/test/java/io/sentry/apollo/SentryApolloInterceptorTest.kt b/sentry-apollo/src/test/java/io/sentry/apollo/SentryApolloInterceptorTest.kt index d22c2fd3e5..b1b118c334 100644 --- a/sentry-apollo/src/test/java/io/sentry/apollo/SentryApolloInterceptorTest.kt +++ b/sentry-apollo/src/test/java/io/sentry/apollo/SentryApolloInterceptorTest.kt @@ -5,7 +5,7 @@ import com.apollographql.apollo.coroutines.await import com.apollographql.apollo.exception.ApolloException import io.sentry.BaggageHeader import io.sentry.Breadcrumb -import io.sentry.IHub +import io.sentry.IScopes import io.sentry.ITransaction import io.sentry.Scope import io.sentry.ScopeCallback @@ -48,13 +48,13 @@ class SentryApolloInterceptorTest { sdkVersion = SdkVersion("test", "1.2.3") } val scope = Scope(options) - val hub = mock().also { + val scopes = mock().also { whenever(it.options).thenReturn(options) doAnswer { (it.arguments[0] as ScopeCallback).run(scope) }.whenever(it).configureScope( any() ) } - private var interceptor = SentryApolloInterceptor(hub) + private var interceptor = SentryApolloInterceptor(scopes) @SuppressWarnings("LongParameterList") fun getSut( @@ -84,7 +84,7 @@ class SentryApolloInterceptorTest { ) if (beforeSpan != null) { - interceptor = SentryApolloInterceptor(hub, beforeSpan) + interceptor = SentryApolloInterceptor(scopes, beforeSpan) } return ApolloClient.builder() .serverUrl(server.url("/")) @@ -104,7 +104,7 @@ class SentryApolloInterceptorTest { fun `creates a span around the successful request`() { executeQuery() - verify(fixture.hub).captureTransaction( + verify(fixture.scopes).captureTransaction( check { assertTransactionDetails(it) assertEquals(SpanStatus.OK, it.spans.first().status) @@ -120,7 +120,7 @@ class SentryApolloInterceptorTest { fun `creates a span around the failed request`() { executeQuery(fixture.getSut(httpStatusCode = 403)) - verify(fixture.hub).captureTransaction( + verify(fixture.scopes).captureTransaction( check { assertTransactionDetails(it) assertEquals(SpanStatus.PERMISSION_DENIED, it.spans.first().status) @@ -138,7 +138,7 @@ class SentryApolloInterceptorTest { fun `creates a span around the request failing with network error`() { executeQuery(fixture.getSut(socketPolicy = SocketPolicy.DISCONNECT_DURING_REQUEST_BODY)) - verify(fixture.hub).captureTransaction( + verify(fixture.scopes).captureTransaction( check { assertTransactionDetails(it) assertEquals(SpanStatus.INTERNAL_ERROR, it.spans.first().status) @@ -176,7 +176,7 @@ class SentryApolloInterceptorTest { } ) - verify(fixture.hub).captureTransaction( + verify(fixture.scopes).captureTransaction( check { assertEquals(1, it.spans.size) val httpClientSpan = it.spans.first() @@ -196,7 +196,7 @@ class SentryApolloInterceptorTest { } ) - verify(fixture.hub).captureTransaction( + verify(fixture.scopes).captureTransaction( check { assertTrue(it.spans.isEmpty()) }, @@ -212,7 +212,7 @@ class SentryApolloInterceptorTest { fixture.getSut { _, _, _ -> throw RuntimeException() } ) - verify(fixture.hub).captureTransaction( + verify(fixture.scopes).captureTransaction( check { assertEquals(1, it.spans.size) }, @@ -225,7 +225,7 @@ class SentryApolloInterceptorTest { @Test fun `adds breadcrumb when http calls succeeds`() { executeQuery() - verify(fixture.hub).addBreadcrumb( + verify(fixture.scopes).addBreadcrumb( check { assertEquals("http", it.type) assertEquals(280L, it.data["response_body_size"]) @@ -237,9 +237,9 @@ class SentryApolloInterceptorTest { @Test fun `sets SDKVersion Info`() { - assertNotNull(fixture.hub.options.sdkVersion) - assert(fixture.hub.options.sdkVersion!!.integrationSet.contains("Apollo")) - val packageInfo = fixture.hub.options.sdkVersion!!.packageSet.firstOrNull { pkg -> pkg.name == "maven:io.sentry:sentry-apollo" } + assertNotNull(fixture.scopes.options.sdkVersion) + assert(fixture.scopes.options.sdkVersion!!.integrationSet.contains("Apollo")) + val packageInfo = fixture.scopes.options.sdkVersion!!.packageSet.firstOrNull { pkg -> pkg.name == "maven:io.sentry:sentry-apollo" } assertNotNull(packageInfo) assert(packageInfo.version == BuildConfig.VERSION_NAME) } @@ -248,14 +248,14 @@ class SentryApolloInterceptorTest { fun `attaches to root transaction on Android`() { ApolloPlatformTestManipulator.pretendIsAndroid(true) executeQuery(fixture.getSut()) - verify(fixture.hub).transaction + verify(fixture.scopes).transaction } @Test fun `attaches to child span on non-Android`() { ApolloPlatformTestManipulator.pretendIsAndroid(false) executeQuery(fixture.getSut()) - verify(fixture.hub).span + verify(fixture.scopes).span } private fun assertTransactionDetails(it: SentryTransaction) { @@ -273,9 +273,9 @@ class SentryApolloInterceptorTest { private fun executeQuery(sut: ApolloClient = fixture.getSut(), isSpanActive: Boolean = true) = runBlocking { var tx: ITransaction? = null if (isSpanActive) { - tx = SentryTracer(TransactionContext("op", "desc", TracesSamplingDecision(true)), fixture.hub) - whenever(fixture.hub.transaction).thenReturn(tx) - whenever(fixture.hub.span).thenReturn(tx) + tx = SentryTracer(TransactionContext("op", "desc", TracesSamplingDecision(true)), fixture.scopes) + whenever(fixture.scopes.transaction).thenReturn(tx) + whenever(fixture.scopes.span).thenReturn(tx) } val coroutine = launch { diff --git a/sentry-compose-helper/src/jvmMain/java/io/sentry/compose/gestures/ComposeGestureTargetLocator.java b/sentry-compose-helper/src/jvmMain/java/io/sentry/compose/gestures/ComposeGestureTargetLocator.java index 8f8ab283e9..2d9e5a6bc9 100644 --- a/sentry-compose-helper/src/jvmMain/java/io/sentry/compose/gestures/ComposeGestureTargetLocator.java +++ b/sentry-compose-helper/src/jvmMain/java/io/sentry/compose/gestures/ComposeGestureTargetLocator.java @@ -8,11 +8,13 @@ import androidx.compose.ui.semantics.SemanticsModifier; import androidx.compose.ui.semantics.SemanticsPropertyKey; import io.sentry.ILogger; +import io.sentry.ISentryLifecycleToken; import io.sentry.SentryIntegrationPackageStorage; import io.sentry.compose.SentryComposeHelper; import io.sentry.compose.helper.BuildConfig; import io.sentry.internal.gestures.GestureTargetLocator; import io.sentry.internal.gestures.UiElement; +import io.sentry.util.AutoClosableReentrantLock; import java.util.LinkedList; import java.util.List; import java.util.Map; @@ -27,6 +29,7 @@ public final class ComposeGestureTargetLocator implements GestureTargetLocator { private final @NotNull ILogger logger; private volatile @Nullable SentryComposeHelper composeHelper; + private final @NotNull AutoClosableReentrantLock lock = new AutoClosableReentrantLock(); public ComposeGestureTargetLocator(final @NotNull ILogger logger) { this.logger = logger; @@ -41,7 +44,7 @@ public ComposeGestureTargetLocator(final @NotNull ILogger logger) { // lazy init composeHelper as it's using some reflection under the hood if (composeHelper == null) { - synchronized (this) { + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { if (composeHelper == null) { composeHelper = new SentryComposeHelper(logger); } diff --git a/sentry-compose-helper/src/jvmMain/java/io/sentry/compose/viewhierarchy/ComposeViewHierarchyExporter.java b/sentry-compose-helper/src/jvmMain/java/io/sentry/compose/viewhierarchy/ComposeViewHierarchyExporter.java index 81b843d256..6568b495c3 100644 --- a/sentry-compose-helper/src/jvmMain/java/io/sentry/compose/viewhierarchy/ComposeViewHierarchyExporter.java +++ b/sentry-compose-helper/src/jvmMain/java/io/sentry/compose/viewhierarchy/ComposeViewHierarchyExporter.java @@ -9,9 +9,11 @@ import androidx.compose.ui.semantics.SemanticsModifier; import androidx.compose.ui.semantics.SemanticsPropertyKey; import io.sentry.ILogger; +import io.sentry.ISentryLifecycleToken; import io.sentry.compose.SentryComposeHelper; import io.sentry.internal.viewhierarchy.ViewHierarchyExporter; import io.sentry.protocol.ViewHierarchyNode; +import io.sentry.util.AutoClosableReentrantLock; import java.util.ArrayList; import java.util.List; import java.util.Map; @@ -23,6 +25,7 @@ public final class ComposeViewHierarchyExporter implements ViewHierarchyExporter @NotNull private final ILogger logger; @Nullable private volatile SentryComposeHelper composeHelper; + private final @NotNull AutoClosableReentrantLock lock = new AutoClosableReentrantLock(); public ComposeViewHierarchyExporter(@NotNull final ILogger logger) { this.logger = logger; @@ -37,7 +40,7 @@ public boolean export(@NotNull final ViewHierarchyNode parent, @NotNull final Ob // lazy init composeHelper as it's using some reflection under the hood if (composeHelper == null) { - synchronized (this) { + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { if (composeHelper == null) { composeHelper = new SentryComposeHelper(logger); } diff --git a/sentry-graphql-22/api/sentry-graphql-22.api b/sentry-graphql-22/api/sentry-graphql-22.api new file mode 100644 index 0000000000..b456fd98bf --- /dev/null +++ b/sentry-graphql-22/api/sentry-graphql-22.api @@ -0,0 +1,22 @@ +public final class io/sentry/graphql22/BuildConfig { + public static final field SENTRY_GRAPHQL22_SDK_NAME Ljava/lang/String; + public static final field VERSION_NAME Ljava/lang/String; +} + +public final class io/sentry/graphql22/SentryInstrumentation : graphql/execution/instrumentation/SimpleInstrumentation { + public static final field SENTRY_EXCEPTIONS_CONTEXT_KEY Ljava/lang/String; + public static final field SENTRY_SCOPES_CONTEXT_KEY Ljava/lang/String; + public fun (Lio/sentry/graphql/SentryGraphqlInstrumentation$BeforeSpanCallback;Lio/sentry/graphql/SentrySubscriptionHandler;Lio/sentry/graphql/ExceptionReporter;Ljava/util/List;)V + public fun (Lio/sentry/graphql/SentryGraphqlInstrumentation$BeforeSpanCallback;Lio/sentry/graphql/SentrySubscriptionHandler;Z)V + public fun (Lio/sentry/graphql/SentryGraphqlInstrumentation$BeforeSpanCallback;Lio/sentry/graphql/SentrySubscriptionHandler;ZLjava/util/List;)V + public fun (Lio/sentry/graphql/SentrySubscriptionHandler;Z)V + public fun beginExecuteOperation (Lgraphql/execution/instrumentation/parameters/InstrumentationExecuteOperationParameters;Lgraphql/execution/instrumentation/InstrumentationState;)Lgraphql/execution/instrumentation/InstrumentationContext; + public fun beginExecution (Lgraphql/execution/instrumentation/parameters/InstrumentationExecutionParameters;Lgraphql/execution/instrumentation/InstrumentationState;)Lgraphql/execution/instrumentation/InstrumentationContext; + public fun createState (Lgraphql/execution/instrumentation/parameters/InstrumentationCreateStateParameters;)Lgraphql/execution/instrumentation/InstrumentationState; + public fun instrumentDataFetcher (Lgraphql/schema/DataFetcher;Lgraphql/execution/instrumentation/parameters/InstrumentationFieldFetchParameters;Lgraphql/execution/instrumentation/InstrumentationState;)Lgraphql/schema/DataFetcher; + public fun instrumentExecutionResult (Lgraphql/ExecutionResult;Lgraphql/execution/instrumentation/parameters/InstrumentationExecutionParameters;Lgraphql/execution/instrumentation/InstrumentationState;)Ljava/util/concurrent/CompletableFuture; +} + +public abstract interface class io/sentry/graphql22/SentryInstrumentation$BeforeSpanCallback : io/sentry/graphql/SentryGraphqlInstrumentation$BeforeSpanCallback { +} + diff --git a/sentry-graphql-22/build.gradle.kts b/sentry-graphql-22/build.gradle.kts new file mode 100644 index 0000000000..5463456f8c --- /dev/null +++ b/sentry-graphql-22/build.gradle.kts @@ -0,0 +1,89 @@ +import net.ltgt.gradle.errorprone.errorprone +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile + +plugins { + `java-library` + kotlin("jvm") + jacoco + id(Config.QualityPlugins.errorProne) + id(Config.QualityPlugins.gradleVersions) + id(Config.BuildPlugins.buildConfig) version Config.BuildPlugins.buildConfigVersion +} + +configure { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 +} + +tasks.withType().configureEach { + kotlinOptions.jvmTarget = JavaVersion.VERSION_1_8.toString() + kotlinOptions.languageVersion = Config.kotlinCompatibleLanguageVersion +} + +dependencies { + api(projects.sentry) + api(projects.sentryGraphqlCore) + compileOnly(Config.Libs.graphQlJava22) + + compileOnly(Config.CompileOnly.nopen) + errorprone(Config.CompileOnly.nopenChecker) + errorprone(Config.CompileOnly.errorprone) + errorprone(Config.CompileOnly.errorProneNullAway) + compileOnly(Config.CompileOnly.jetbrainsAnnotations) + + // tests + testImplementation(projects.sentry) + testImplementation(projects.sentryTestSupport) + testImplementation(kotlin(Config.kotlinStdLib)) + testImplementation(Config.TestLibs.kotlinTestJunit) + testImplementation(Config.TestLibs.mockitoKotlin) + testImplementation(Config.TestLibs.mockitoInline) + testImplementation(Config.TestLibs.mockWebserver) + testImplementation(Config.Libs.okhttp) + testImplementation(Config.Libs.springBootStarterGraphql) + testImplementation("com.netflix.graphql.dgs:graphql-error-types:4.9.2") + testImplementation(Config.Libs.graphQlJava22) +} + +configure { + test { + java.srcDir("src/test/java") + } +} + +jacoco { + toolVersion = Config.QualityPlugins.Jacoco.version +} + +tasks.jacocoTestReport { + reports { + xml.required.set(true) + html.required.set(false) + } +} + +tasks { + jacocoTestCoverageVerification { + violationRules { + rule { limit { minimum = Config.QualityPlugins.Jacoco.minimumCoverage } } + } + } + check { + dependsOn(jacocoTestCoverageVerification) + dependsOn(jacocoTestReport) + } +} + +tasks.withType().configureEach { + options.errorprone { + check("NullAway", net.ltgt.gradle.errorprone.CheckSeverity.ERROR) + option("NullAway:AnnotatedPackages", "io.sentry") + } +} + +buildConfig { + useJavaOutput() + packageName("io.sentry.graphql22") + buildConfigField("String", "SENTRY_GRAPHQL22_SDK_NAME", "\"${Config.Sentry.SENTRY_GRAPHQL22_SDK_NAME}\"") + buildConfigField("String", "VERSION_NAME", "\"${project.version}\"") +} diff --git a/sentry-graphql-22/src/main/java/io/sentry/graphql22/SentryInstrumentation.java b/sentry-graphql-22/src/main/java/io/sentry/graphql22/SentryInstrumentation.java new file mode 100644 index 0000000000..47881ceac8 --- /dev/null +++ b/sentry-graphql-22/src/main/java/io/sentry/graphql22/SentryInstrumentation.java @@ -0,0 +1,163 @@ +package io.sentry.graphql22; + +import graphql.ExecutionResult; +import graphql.execution.instrumentation.InstrumentationContext; +import graphql.execution.instrumentation.InstrumentationState; +import graphql.execution.instrumentation.parameters.InstrumentationCreateStateParameters; +import graphql.execution.instrumentation.parameters.InstrumentationExecuteOperationParameters; +import graphql.execution.instrumentation.parameters.InstrumentationExecutionParameters; +import graphql.execution.instrumentation.parameters.InstrumentationFieldFetchParameters; +import graphql.schema.DataFetcher; +import io.sentry.SentryIntegrationPackageStorage; +import io.sentry.graphql.ExceptionReporter; +import io.sentry.graphql.SentryGraphqlInstrumentation; +import io.sentry.graphql.SentrySubscriptionHandler; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.jetbrains.annotations.TestOnly; + +@SuppressWarnings("deprecation") +public final class SentryInstrumentation + extends graphql.execution.instrumentation.SimpleInstrumentation { + + /** + * @deprecated please use {@link SentryGraphqlInstrumentation#SENTRY_SCOPES_CONTEXT_KEY} + */ + @Deprecated + public static final @NotNull String SENTRY_SCOPES_CONTEXT_KEY = + SentryGraphqlInstrumentation.SENTRY_SCOPES_CONTEXT_KEY; + + /** + * @deprecated please use {@link SentryGraphqlInstrumentation#SENTRY_EXCEPTIONS_CONTEXT_KEY} + */ + @Deprecated + public static final @NotNull String SENTRY_EXCEPTIONS_CONTEXT_KEY = + SentryGraphqlInstrumentation.SENTRY_EXCEPTIONS_CONTEXT_KEY; + + private static final String TRACE_ORIGIN = "auto.graphql.graphql22"; + private final @NotNull SentryGraphqlInstrumentation instrumentation; + + /** + * @param beforeSpan callback when a span is created + * @param subscriptionHandler can report subscription errors + * @param captureRequestBodyForNonSubscriptions false if request bodies should not be captured by + * this integration for query and mutation operations. This can be used to prevent unnecessary + * work by not adding the request body when another integration will add it anyways, as is the + * case with our spring integration for WebMVC. + */ + public SentryInstrumentation( + final @Nullable SentryGraphqlInstrumentation.BeforeSpanCallback beforeSpan, + final @NotNull SentrySubscriptionHandler subscriptionHandler, + final boolean captureRequestBodyForNonSubscriptions) { + this( + beforeSpan, + subscriptionHandler, + new ExceptionReporter(captureRequestBodyForNonSubscriptions), + new ArrayList<>()); + } + + /** + * @param beforeSpan callback when a span is created + * @param subscriptionHandler can report subscription errors + * @param captureRequestBodyForNonSubscriptions false if request bodies should not be captured by + * this integration for query and mutation operations. This can be used to prevent unnecessary + * work by not adding the request body when another integration will add it anyways, as is the + * case with our spring integration for WebMVC. + * @param ignoredErrorTypes list of error types that should not be captured and sent to Sentry + */ + public SentryInstrumentation( + final @Nullable SentryGraphqlInstrumentation.BeforeSpanCallback beforeSpan, + final @NotNull SentrySubscriptionHandler subscriptionHandler, + final boolean captureRequestBodyForNonSubscriptions, + final @NotNull List ignoredErrorTypes) { + this( + beforeSpan, + subscriptionHandler, + new ExceptionReporter(captureRequestBodyForNonSubscriptions), + ignoredErrorTypes); + } + + @TestOnly + public SentryInstrumentation( + final @Nullable SentryGraphqlInstrumentation.BeforeSpanCallback beforeSpan, + final @NotNull SentrySubscriptionHandler subscriptionHandler, + final @NotNull ExceptionReporter exceptionReporter, + final @NotNull List ignoredErrorTypes) { + this.instrumentation = + new SentryGraphqlInstrumentation( + beforeSpan, subscriptionHandler, exceptionReporter, ignoredErrorTypes, TRACE_ORIGIN); + SentryIntegrationPackageStorage.getInstance().addIntegration("GraphQL-v22"); + SentryIntegrationPackageStorage.getInstance() + .addPackage("maven:io.sentry:sentry-graphql-22", BuildConfig.VERSION_NAME); + } + + /** + * @param subscriptionHandler can report subscription errors + * @param captureRequestBodyForNonSubscriptions false if request bodies should not be captured by + * this integration for query and mutation operations. This can be used to prevent unnecessary + * work by not adding the request body when another integration will add it anyways, as is the + * case with our spring integration for WebMVC. + */ + public SentryInstrumentation( + final @NotNull SentrySubscriptionHandler subscriptionHandler, + final boolean captureRequestBodyForNonSubscriptions) { + this(null, subscriptionHandler, captureRequestBodyForNonSubscriptions); + } + + @Override + public @NotNull InstrumentationState createState( + final @NotNull InstrumentationCreateStateParameters parameters) { + return instrumentation.createState(); + } + + @Override + public @Nullable InstrumentationContext beginExecution( + final @NotNull InstrumentationExecutionParameters parameters, + final @NotNull InstrumentationState state) { + final SentryGraphqlInstrumentation.TracingState tracingState = + InstrumentationState.ofState(state); + instrumentation.beginExecution(parameters, tracingState); + return super.beginExecution(parameters, state); + } + + @Override + public @NotNull CompletableFuture instrumentExecutionResult( + final @NotNull ExecutionResult executionResult, + final @NotNull InstrumentationExecutionParameters parameters, + final @NotNull InstrumentationState state) { + return super.instrumentExecutionResult(executionResult, parameters, state) + .whenComplete( + (result, exception) -> { + instrumentation.instrumentExecutionResultComplete(parameters, result, exception); + }); + } + + @Override + public @Nullable InstrumentationContext beginExecuteOperation( + final @NotNull InstrumentationExecuteOperationParameters parameters, + final @NotNull InstrumentationState state) { + instrumentation.beginExecuteOperation(parameters); + return super.beginExecuteOperation(parameters, state); + } + + @Override + @SuppressWarnings({"FutureReturnValueIgnored", "deprecation"}) + public @NotNull DataFetcher instrumentDataFetcher( + final @NotNull DataFetcher dataFetcher, + final @NotNull InstrumentationFieldFetchParameters parameters, + final @NotNull InstrumentationState state) { + final SentryGraphqlInstrumentation.TracingState tracingState = + InstrumentationState.ofState(state); + return instrumentation.instrumentDataFetcher(dataFetcher, parameters, tracingState); + } + + /** + * @deprecated please use {@link SentryGraphqlInstrumentation.BeforeSpanCallback} + */ + @Deprecated + @FunctionalInterface + public interface BeforeSpanCallback extends SentryGraphqlInstrumentation.BeforeSpanCallback {} +} diff --git a/sentry-graphql-22/src/test/kotlin/io/sentry/graphql22/SentryInstrumentationAnotherTest.kt b/sentry-graphql-22/src/test/kotlin/io/sentry/graphql22/SentryInstrumentationAnotherTest.kt new file mode 100644 index 0000000000..628001bfeb --- /dev/null +++ b/sentry-graphql-22/src/test/kotlin/io/sentry/graphql22/SentryInstrumentationAnotherTest.kt @@ -0,0 +1,379 @@ +package io.sentry.graphql22 + +import graphql.ErrorClassification +import graphql.ErrorType +import graphql.ExecutionInput +import graphql.ExecutionResult +import graphql.GraphQLContext +import graphql.GraphqlErrorException +import graphql.execution.ExecutionContext +import graphql.execution.ExecutionContextBuilder +import graphql.execution.ExecutionId +import graphql.execution.ExecutionStepInfo +import graphql.execution.ExecutionStrategyParameters +import graphql.execution.MergedField +import graphql.execution.MergedSelectionSet +import graphql.execution.ResultPath +import graphql.execution.instrumentation.parameters.InstrumentationExecuteOperationParameters +import graphql.execution.instrumentation.parameters.InstrumentationExecutionParameters +import graphql.execution.instrumentation.parameters.InstrumentationFieldFetchParameters +import graphql.language.Field +import graphql.language.OperationDefinition +import graphql.scalar.GraphqlStringCoercing +import graphql.schema.DataFetcher +import graphql.schema.DataFetchingEnvironment +import graphql.schema.DataFetchingEnvironmentImpl +import graphql.schema.GraphQLFieldDefinition +import graphql.schema.GraphQLObjectType +import graphql.schema.GraphQLScalarType +import graphql.schema.GraphQLSchema +import io.sentry.Breadcrumb +import io.sentry.Hint +import io.sentry.IScopes +import io.sentry.Sentry +import io.sentry.SentryOptions +import io.sentry.SentryTracer +import io.sentry.TransactionContext +import io.sentry.TypeCheckHint +import io.sentry.graphql.ExceptionReporter +import io.sentry.graphql.ExceptionReporter.ExceptionDetails +import io.sentry.graphql.SentryGraphqlInstrumentation +import io.sentry.graphql.SentrySubscriptionHandler +import org.mockito.Mockito +import org.mockito.kotlin.any +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.same +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertSame + +class SentryInstrumentationAnotherTest { + + class Fixture { + val scopes = mock() + lateinit var activeSpan: SentryTracer + lateinit var dataFetcher: DataFetcher + lateinit var fieldFetchParameters: InstrumentationFieldFetchParameters + lateinit var instrumentationExecutionParameters: InstrumentationExecutionParameters + lateinit var environment: DataFetchingEnvironment + lateinit var executionContext: ExecutionContext + lateinit var executionStrategyParameters: ExecutionStrategyParameters + lateinit var executionStepInfo: ExecutionStepInfo + lateinit var graphQLContext: GraphQLContext + lateinit var subscriptionHandler: SentrySubscriptionHandler + lateinit var exceptionReporter: ExceptionReporter + internal lateinit var instrumentationState: SentryGraphqlInstrumentation.TracingState + lateinit var instrumentationExecuteOperationParameters: InstrumentationExecuteOperationParameters + val query = """query greeting(name: "somename")""" + val variables = mapOf("variableA" to "value a") + + fun getSut(isTransactionActive: Boolean = true, operation: OperationDefinition.Operation = OperationDefinition.Operation.QUERY, graphQLContextParam: Map? = null, addTransactionToTracingState: Boolean = true, ignoredErrors: List = emptyList()): SentryInstrumentation { + whenever(scopes.options).thenReturn(SentryOptions()) + activeSpan = SentryTracer(TransactionContext("name", "op"), scopes) + + if (isTransactionActive) { + whenever(scopes.span).thenReturn(activeSpan) + } else { + whenever(scopes.span).thenReturn(null) + } + + val defaultGraphQLContext = mapOf( + SentryGraphqlInstrumentation.SENTRY_SCOPES_CONTEXT_KEY to scopes + ) + val mergedField = + MergedField.newMergedField().addField(Field.newField("myFieldName").build()).build() + exceptionReporter = mock() + subscriptionHandler = mock() + whenever(subscriptionHandler.onSubscriptionResult(any(), any(), any(), any())).thenReturn("result modified by subscription handler") + val instrumentation = SentryInstrumentation( + null, + subscriptionHandler, + exceptionReporter, + ignoredErrors + ) + dataFetcher = mock>() + whenever(dataFetcher.get(any())).thenReturn("raw result") + graphQLContext = GraphQLContext.newContext() + .of(graphQLContextParam ?: defaultGraphQLContext).build() + val scalarType = GraphQLScalarType.newScalar().name("MyResponseType").coercing( + GraphqlStringCoercing() + ).build() + val field = GraphQLFieldDefinition.newFieldDefinition() + .name("myQueryFieldName") + .type(scalarType) + .build() + val objectType = GraphQLObjectType.newObject().name("QUERY").field(field).build() + executionStepInfo = ExecutionStepInfo.newExecutionStepInfo() + .type(scalarType) + .fieldContainer(objectType) + .parentInfo(ExecutionStepInfo.newExecutionStepInfo().type(objectType).build()) + .path(ResultPath.rootPath().segment("child")) + .field(mergedField) + .build() + val operationDefinition = OperationDefinition.newOperationDefinition() + .operation(operation) + .name("operation name") + .build() + environment = DataFetchingEnvironmentImpl.newDataFetchingEnvironment() + .graphQLContext(graphQLContext) + .executionStepInfo(executionStepInfo) + .operationDefinition(operationDefinition) + .build() + executionContext = ExecutionContextBuilder.newExecutionContextBuilder() + .executionId(ExecutionId.generate()) + .graphQLContext(graphQLContext) + .operationDefinition(operationDefinition) + .build() + executionStrategyParameters = ExecutionStrategyParameters.newParameters() + .executionStepInfo(executionStepInfo) + .fields(MergedSelectionSet.newMergedSelectionSet().build()) + .field(mergedField) + .build() + instrumentationState = SentryGraphqlInstrumentation.TracingState().also { + if (isTransactionActive && addTransactionToTracingState) { + it.transaction = activeSpan + } + } + fieldFetchParameters = InstrumentationFieldFetchParameters( + executionContext, + { environment }, + executionStrategyParameters, + false + ) + val executionInput = ExecutionInput.newExecutionInput() + .query(query) + .graphQLContext(graphQLContextParam ?: defaultGraphQLContext) + .variables(variables) + .build() + val schema = GraphQLSchema.newSchema().query( + GraphQLObjectType.newObject().name("QueryType").field( + field + ).build() + ).build() + instrumentationExecutionParameters = InstrumentationExecutionParameters(executionInput, schema) + instrumentationExecuteOperationParameters = InstrumentationExecuteOperationParameters(executionContext) + + return instrumentation + } + } + + private val fixture = Fixture() + + @Test + fun `invokes subscription handler for subscription`() { + val instrumentation = fixture.getSut(isTransactionActive = false, operation = OperationDefinition.Operation.SUBSCRIPTION) + val instrumentedDataFetcher = instrumentation.instrumentDataFetcher(fixture.dataFetcher, fixture.fieldFetchParameters, fixture.instrumentationState) + val result = instrumentedDataFetcher.get(fixture.environment) + + assertEquals("result modified by subscription handler", result) + verify(fixture.subscriptionHandler).onSubscriptionResult(eq("raw result"), same(fixture.scopes), same(fixture.exceptionReporter), same(fixture.fieldFetchParameters)) + } + + @Test + fun `invokes subscription handler for subscription if transaction is active`() { + val instrumentation = fixture.getSut(isTransactionActive = true, operation = OperationDefinition.Operation.SUBSCRIPTION) + val instrumentedDataFetcher = instrumentation.instrumentDataFetcher(fixture.dataFetcher, fixture.fieldFetchParameters, fixture.instrumentationState) + val result = instrumentedDataFetcher.get(fixture.environment) + + assertEquals("result modified by subscription handler", result) + verify(fixture.subscriptionHandler).onSubscriptionResult(eq("raw result"), same(fixture.scopes), same(fixture.exceptionReporter), same(fixture.fieldFetchParameters)) + } + + @Test + fun `does not invoke subscription handler for query`() { + val instrumentation = fixture.getSut(isTransactionActive = false, operation = OperationDefinition.Operation.QUERY) + val instrumentedDataFetcher = instrumentation.instrumentDataFetcher(fixture.dataFetcher, fixture.fieldFetchParameters, fixture.instrumentationState) + val result = instrumentedDataFetcher.get(fixture.environment) + + assertEquals("raw result", result) + verify(fixture.subscriptionHandler, never()).onSubscriptionResult(any(), any(), any(), any()) + } + + @Test + fun `does not invoke subscription handler for query if transaction is active`() { + val instrumentation = fixture.getSut(isTransactionActive = false, operation = OperationDefinition.Operation.QUERY) + val instrumentedDataFetcher = instrumentation.instrumentDataFetcher(fixture.dataFetcher, fixture.fieldFetchParameters, fixture.instrumentationState) + val result = instrumentedDataFetcher.get(fixture.environment) + + assertEquals("raw result", result) + verify(fixture.subscriptionHandler, never()).onSubscriptionResult(any(), any(), any(), any()) + } + + @Test + fun `does not invoke subscription handler for mutation`() { + val instrumentation = fixture.getSut(isTransactionActive = false, operation = OperationDefinition.Operation.MUTATION) + val instrumentedDataFetcher = instrumentation.instrumentDataFetcher(fixture.dataFetcher, fixture.fieldFetchParameters, fixture.instrumentationState) + val result = instrumentedDataFetcher.get(fixture.environment) + + assertEquals("raw result", result) + verify(fixture.subscriptionHandler, never()).onSubscriptionResult(any(), any(), any(), any()) + } + + @Test + fun `does not invoke subscription handler for mutation if transaction is active`() { + val instrumentation = fixture.getSut(isTransactionActive = false, operation = OperationDefinition.Operation.MUTATION) + val instrumentedDataFetcher = instrumentation.instrumentDataFetcher(fixture.dataFetcher, fixture.fieldFetchParameters, fixture.instrumentationState) + val result = instrumentedDataFetcher.get(fixture.environment) + + assertEquals("raw result", result) + verify(fixture.subscriptionHandler, never()).onSubscriptionResult(any(), any(), any(), any()) + } + + @Test + fun `adds a breadcrumb for operation`() { + val instrumentation = fixture.getSut() + instrumentation.beginExecuteOperation(fixture.instrumentationExecuteOperationParameters, fixture.instrumentationState) + verify(fixture.scopes).addBreadcrumb( + org.mockito.kotlin.check { breadcrumb -> + assertEquals("graphql", breadcrumb.type) + assertEquals("query", breadcrumb.category) + assertEquals("operation name", breadcrumb.data["operation_name"]) + assertEquals("query", breadcrumb.data["operation_type"]) + assertEquals(fixture.executionContext.executionId.toString(), breadcrumb.data["operation_id"]) + } + ) + } + + @Test + fun `adds a breadcrumb for data fetcher`() { + val instrumentation = fixture.getSut() + instrumentation.instrumentDataFetcher(fixture.dataFetcher, fixture.fieldFetchParameters, fixture.instrumentationState).get(fixture.environment) + verify(fixture.scopes).addBreadcrumb( + org.mockito.kotlin.check { breadcrumb -> + assertEquals("graphql", breadcrumb.type) + assertEquals("graphql.fetcher", breadcrumb.category) + assertEquals("/child", breadcrumb.data["path"]) + assertEquals("myFieldName", breadcrumb.data["field"]) + assertEquals("MyResponseType", breadcrumb.data["type"]) + assertEquals("QUERY", breadcrumb.data["object_type"]) + }, + org.mockito.kotlin.check { hint -> + val environment = hint.getAs(TypeCheckHint.GRAPHQL_DATA_FETCHING_ENVIRONMENT, DataFetchingEnvironment::class.java) + assertNotNull(environment) + } + ) + } + + @Test + fun `stores scopes in context and adds transaction to state`() { + val instrumentation = fixture.getSut(isTransactionActive = true, operation = OperationDefinition.Operation.MUTATION, graphQLContextParam = emptyMap(), addTransactionToTracingState = false) + withMockScopes { + instrumentation.beginExecution(fixture.instrumentationExecutionParameters, fixture.instrumentationState) + assertSame(fixture.scopes, fixture.instrumentationExecutionParameters.graphQLContext.get(SentryGraphqlInstrumentation.SENTRY_SCOPES_CONTEXT_KEY)) + assertNotNull(fixture.instrumentationState.transaction) + } + } + + @Test + fun `invokes exceptionReporter for error`() { + val instrumentation = fixture.getSut() + val executionResult = ExecutionResult.newExecutionResult() + .data("raw result") + .addError( + GraphqlErrorException.newErrorException().message("exception message").errorClassification( + ErrorType.ValidationError + ).build() + ) + .build() + val resultFuture = instrumentation.instrumentExecutionResult(executionResult, fixture.instrumentationExecutionParameters, fixture.instrumentationState) + verify(fixture.exceptionReporter).captureThrowable( + org.mockito.kotlin.check { + assertEquals("exception message", it.message) + }, + org.mockito.kotlin.check { + assertSame(fixture.scopes, it.scopes) + assertSame(fixture.query, it.query) + assertEquals(false, it.isSubscription) + assertEquals(fixture.variables, it.variables) + }, + same(executionResult) + ) + val result = resultFuture.get() + assertSame(executionResult, result) + } + + @Test + fun `invokes exceptionReporter for exceptions in GraphQLContext`() { + val exception = IllegalStateException("some exception") + val instrumentation = fixture.getSut( + graphQLContextParam = mapOf( + SentryGraphqlInstrumentation.SENTRY_EXCEPTIONS_CONTEXT_KEY to listOf(exception), + SentryGraphqlInstrumentation.SENTRY_SCOPES_CONTEXT_KEY to fixture.scopes + ) + ) + val executionResult = ExecutionResult.newExecutionResult() + .data("raw result") + .build() + val resultFuture = instrumentation.instrumentExecutionResult(executionResult, fixture.instrumentationExecutionParameters, fixture.instrumentationState) + verify(fixture.exceptionReporter).captureThrowable( + org.mockito.kotlin.check { + assertSame(exception, it) + }, + org.mockito.kotlin.check { + assertSame(fixture.scopes, it.scopes) + assertSame(fixture.query, it.query) + assertEquals(false, it.isSubscription) + assertEquals(fixture.variables, it.variables) + }, + same(executionResult) + ) + val result = resultFuture.get() + assertSame(executionResult, result) + } + + @Test + fun `does not invoke exceptionReporter for certain errors that should be handled by SentryDataFetcherExceptionHandler`() { + val instrumentation = fixture.getSut() + val executionResult = ExecutionResult.newExecutionResult() + .data("raw result") + .addError(GraphqlErrorException.newErrorException().message("exception message").errorClassification(ErrorType.DataFetchingException).build()) + .addError(GraphqlErrorException.newErrorException().message("exception message").errorClassification(org.springframework.graphql.execution.ErrorType.INTERNAL_ERROR).build()) + .addError(GraphqlErrorException.newErrorException().message("exception message").errorClassification(com.netflix.graphql.types.errors.ErrorType.INTERNAL).build()) + .build() + val resultFuture = instrumentation.instrumentExecutionResult(executionResult, fixture.instrumentationExecutionParameters, fixture.instrumentationState) + verify(fixture.exceptionReporter, never()).captureThrowable(any(), any(), any()) + val result = resultFuture.get() + assertSame(executionResult, result) + } + + @Test + fun `does not invoke exceptionReporter for ignored errors`() { + val instrumentation = fixture.getSut(ignoredErrors = listOf("SOME_ERROR")) + val executionResult = ExecutionResult.newExecutionResult() + .data("raw result") + .addError(GraphqlErrorException.newErrorException().message("exception message").errorClassification(SomeErrorClassification.SOME_ERROR).build()) + .build() + val resultFuture = instrumentation.instrumentExecutionResult(executionResult, fixture.instrumentationExecutionParameters, fixture.instrumentationState) + verify(fixture.exceptionReporter, never()).captureThrowable(any(), any(), any()) + val result = resultFuture.get() + assertSame(executionResult, result) + } + + @Test + fun `never invokes exceptionReporter if no errors`() { + val instrumentation = fixture.getSut() + val executionResult = ExecutionResult.newExecutionResult() + .data("raw result") + .build() + val resultFuture = instrumentation.instrumentExecutionResult(executionResult, fixture.instrumentationExecutionParameters, fixture.instrumentationState) + verify(fixture.exceptionReporter, never()).captureThrowable(any(), any(), any()) + val result = resultFuture.get() + assertSame(executionResult, result) + } + + fun withMockScopes(closure: () -> Unit) = Mockito.mockStatic(Sentry::class.java).use { + it.`when` { Sentry.getCurrentScopes() }.thenReturn(fixture.scopes) + closure.invoke() + } + + data class Show(val id: Int) + + enum class SomeErrorClassification : ErrorClassification { + SOME_ERROR; + } +} diff --git a/sentry-graphql-22/src/test/kotlin/io/sentry/graphql22/SentryInstrumentationTest.kt b/sentry-graphql-22/src/test/kotlin/io/sentry/graphql22/SentryInstrumentationTest.kt new file mode 100644 index 0000000000..bec8c209b1 --- /dev/null +++ b/sentry-graphql-22/src/test/kotlin/io/sentry/graphql22/SentryInstrumentationTest.kt @@ -0,0 +1,243 @@ +package io.sentry.graphql22 + +import graphql.GraphQL +import graphql.GraphQLContext +import graphql.execution.ExecutionContextBuilder +import graphql.execution.ExecutionId +import graphql.execution.ExecutionStepInfo +import graphql.execution.ExecutionStrategyParameters +import graphql.execution.MergedField +import graphql.execution.MergedSelectionSet +import graphql.execution.instrumentation.parameters.InstrumentationFieldFetchParameters +import graphql.language.Field +import graphql.language.OperationDefinition +import graphql.scalar.GraphqlStringCoercing +import graphql.schema.DataFetcher +import graphql.schema.DataFetchingEnvironmentImpl +import graphql.schema.GraphQLScalarType +import graphql.schema.idl.RuntimeWiring +import graphql.schema.idl.SchemaGenerator +import graphql.schema.idl.SchemaParser +import io.sentry.IScopes +import io.sentry.Sentry +import io.sentry.SentryOptions +import io.sentry.SentryTracer +import io.sentry.SpanStatus +import io.sentry.TransactionContext +import io.sentry.graphql.ExceptionReporter +import io.sentry.graphql.NoOpSubscriptionHandler +import io.sentry.graphql.SentryGraphqlInstrumentation +import io.sentry.graphql.SentrySubscriptionHandler +import org.mockito.Mockito +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import java.lang.RuntimeException +import kotlin.random.Random +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class SentryInstrumentationTest { + + class Fixture { + val scopes = mock() + lateinit var activeSpan: SentryTracer + + fun getSut(isTransactionActive: Boolean = true, dataFetcherThrows: Boolean = false, beforeSpan: SentryGraphqlInstrumentation.BeforeSpanCallback? = null): GraphQL { + whenever(scopes.options).thenReturn(SentryOptions()) + activeSpan = SentryTracer(TransactionContext("name", "op"), scopes) + val schema = """ + type Query { + shows: [Show] + } + + type Show { + id: Int + } + """.trimIndent() + + val graphQLSchema = SchemaGenerator().makeExecutableSchema(SchemaParser().parse(schema), buildRuntimeWiring(dataFetcherThrows)) + val graphQL = GraphQL.newGraphQL(graphQLSchema) + .instrumentation( + SentryInstrumentation( + beforeSpan, + NoOpSubscriptionHandler.getInstance(), + true + ) + ) + .build() + + if (isTransactionActive) { + whenever(scopes.span).thenReturn(activeSpan) + } else { + whenever(scopes.span).thenReturn(null) + } + + return graphQL + } + + private fun buildRuntimeWiring(dataFetcherThrows: Boolean) = RuntimeWiring.newRuntimeWiring() + .type("Query") { + it.dataFetcher("shows") { + if (dataFetcherThrows) { + throw RuntimeException("error") + } else { + listOf(Show(Random.nextInt()), Show(Random.nextInt())) + } + } + }.build() + } + + private val fixture = Fixture() + + @Test + fun `when transaction is active, creates inner spans`() { + val sut = fixture.getSut() + + withMockScopes { + val result = sut.execute("{ shows { id } }") + + assertTrue(result.errors.isEmpty()) + assertEquals(1, fixture.activeSpan.children.size) + val span = fixture.activeSpan.children.first() + assertEquals("graphql", span.operation) + assertEquals("Query.shows", span.description) + assertEquals("auto.graphql.graphql22", span.spanContext.origin) + assertTrue(span.isFinished) + assertEquals(SpanStatus.OK, span.status) + } + } + + @Test + fun `when transaction is active, and data fetcher throws, creates inner spans`() { + val sut = fixture.getSut(dataFetcherThrows = true) + + withMockScopes { + val result = sut.execute("{ shows { id } }") + + assertTrue(result.errors.isNotEmpty()) + assertEquals(1, fixture.activeSpan.children.size) + val span = fixture.activeSpan.children.first() + assertEquals("graphql", span.operation) + assertEquals("Query.shows", span.description) + assertTrue(span.isFinished) + assertEquals(SpanStatus.INTERNAL_ERROR, span.status) + } + } + + @Test + fun `when transaction is not active, does not create spans`() { + val sut = fixture.getSut(isTransactionActive = false) + + withMockScopes { + val result = sut.execute("{ shows { id } }") + + assertTrue(result.errors.isEmpty()) + assertTrue(fixture.activeSpan.children.isEmpty()) + } + } + + @Test + fun `beforeSpan can drop spans`() { + val sut = fixture.getSut(beforeSpan = SentryGraphqlInstrumentation.BeforeSpanCallback { _, _, _ -> null }) + + withMockScopes { + val result = sut.execute("{ shows { id } }") + + assertTrue(result.errors.isEmpty()) + assertEquals(1, fixture.activeSpan.children.size) + val span = fixture.activeSpan.children.first() + assertEquals("graphql", span.operation) + assertEquals("Query.shows", span.description) + assertNotNull(span.isSampled) { + assertFalse(it) + } + } + } + + @Test + fun `beforeSpan can modify spans`() { + val sut = fixture.getSut(beforeSpan = SentryGraphqlInstrumentation.BeforeSpanCallback { span, _, _ -> span.apply { description = "changed" } }) + + withMockScopes { + val result = sut.execute("{ shows { id } }") + + assertTrue(result.errors.isEmpty()) + assertEquals(1, fixture.activeSpan.children.size) + val span = fixture.activeSpan.children.first() + assertEquals("graphql", span.operation) + assertEquals("changed", span.description) + assertTrue(span.isFinished) + } + } + + @Test + fun `invokes subscription handler for subscription`() { + val exceptionReporter = mock() + val subscriptionHandler = mock() + whenever(subscriptionHandler.onSubscriptionResult(any(), any(), any(), any())).thenReturn("result modified by subscription handler") + val operation = OperationDefinition.Operation.SUBSCRIPTION + val instrumentation = SentryInstrumentation( + null, + subscriptionHandler, + exceptionReporter, + emptyList() + ) + val dataFetcher = mock>() + whenever(dataFetcher.get(any())).thenReturn("raw result") + val graphQLContext = GraphQLContext.newContext().build() + val executionStepInfo = ExecutionStepInfo.newExecutionStepInfo().type( + GraphQLScalarType.newScalar().name("MyResponseType").coercing( + GraphqlStringCoercing() + ).build() + ).build() + val environment = DataFetchingEnvironmentImpl.newDataFetchingEnvironment() + .graphQLContext(graphQLContext) + .executionStepInfo(executionStepInfo) + .operationDefinition(OperationDefinition.newOperationDefinition().operation(operation).build()) + .build() + val executionContext = ExecutionContextBuilder.newExecutionContextBuilder() + .executionId(ExecutionId.generate()) + .graphQLContext(graphQLContext) + .build() + val executionStrategyParameters = ExecutionStrategyParameters.newParameters() + .executionStepInfo(executionStepInfo) + .fields(MergedSelectionSet.newMergedSelectionSet().build()) + .field(MergedField.newMergedField().addField(Field.newField("myFieldName").build()).build()) + .build() + val parameters = InstrumentationFieldFetchParameters( + executionContext, + { environment }, + executionStrategyParameters, + false + ) + val instrumentedDataFetcher = instrumentation.instrumentDataFetcher(dataFetcher, parameters, SentryGraphqlInstrumentation.TracingState()) + val result = instrumentedDataFetcher.get(environment) + + assertNotNull(result) + assertEquals("result modified by subscription handler", result) + } + + @Test + fun `Integration adds itself to integration and package list`() { + withMockScopes { + val sut = fixture.getSut() + assertNotNull(fixture.scopes.options.sdkVersion) + assert(fixture.scopes.options.sdkVersion!!.integrationSet.contains("GraphQL-v22")) + val packageInfo = + fixture.scopes.options.sdkVersion!!.packageSet.firstOrNull { pkg -> pkg.name == "maven:io.sentry:sentry-graphql-22" } + assertNotNull(packageInfo) + assert(packageInfo.version == BuildConfig.VERSION_NAME) + } + } + + fun withMockScopes(closure: () -> Unit) = Mockito.mockStatic(Sentry::class.java).use { + it.`when` { Sentry.getCurrentScopes() }.thenReturn(fixture.scopes) + closure.invoke() + } + + data class Show(val id: Int) +} diff --git a/sentry-graphql-core/api/sentry-graphql-core.api b/sentry-graphql-core/api/sentry-graphql-core.api new file mode 100644 index 0000000000..7b63e2270d --- /dev/null +++ b/sentry-graphql-core/api/sentry-graphql-core.api @@ -0,0 +1,70 @@ +public final class io/sentry/graphql/BuildConfig { + public static final field SENTRY_GRAPHQL_SDK_NAME Ljava/lang/String; + public static final field VERSION_NAME Ljava/lang/String; +} + +public final class io/sentry/graphql/ExceptionReporter { + public fun (Z)V + public fun captureThrowable (Ljava/lang/Throwable;Lio/sentry/graphql/ExceptionReporter$ExceptionDetails;Lgraphql/ExecutionResult;)V +} + +public final class io/sentry/graphql/ExceptionReporter$ExceptionDetails { + public fun (Lio/sentry/IScopes;Lgraphql/execution/instrumentation/parameters/InstrumentationExecutionParameters;Z)V + public fun (Lio/sentry/IScopes;Lgraphql/schema/DataFetchingEnvironment;Z)V + public fun getHub ()Lio/sentry/IScopes; + public fun getQuery ()Ljava/lang/String; + public fun getScopes ()Lio/sentry/IScopes; + public fun getVariables ()Ljava/util/Map; + public fun isSubscription ()Z +} + +public final class io/sentry/graphql/GraphqlStringUtils { + public fun ()V + public static fun fieldToString (Lgraphql/execution/MergedField;)Ljava/lang/String; + public static fun objectTypeToString (Lgraphql/schema/GraphQLObjectType;)Ljava/lang/String; + public static fun typeToString (Lgraphql/schema/GraphQLOutputType;)Ljava/lang/String; +} + +public final class io/sentry/graphql/NoOpSubscriptionHandler : io/sentry/graphql/SentrySubscriptionHandler { + public static fun getInstance ()Lio/sentry/graphql/NoOpSubscriptionHandler; + public fun onSubscriptionResult (Ljava/lang/Object;Lio/sentry/IScopes;Lio/sentry/graphql/ExceptionReporter;Lgraphql/execution/instrumentation/parameters/InstrumentationFieldFetchParameters;)Ljava/lang/Object; +} + +public final class io/sentry/graphql/SentryGenericDataFetcherExceptionHandler : graphql/execution/DataFetcherExceptionHandler { + public fun (Lgraphql/execution/DataFetcherExceptionHandler;)V + public fun (Lio/sentry/IScopes;Lgraphql/execution/DataFetcherExceptionHandler;)V + public fun handleException (Lgraphql/execution/DataFetcherExceptionHandlerParameters;)Ljava/util/concurrent/CompletableFuture; + public fun onException (Lgraphql/execution/DataFetcherExceptionHandlerParameters;)Lgraphql/execution/DataFetcherExceptionHandlerResult; +} + +public final class io/sentry/graphql/SentryGraphqlExceptionHandler { + public fun (Lgraphql/execution/DataFetcherExceptionHandler;)V + public fun handleException (Ljava/lang/Throwable;Lgraphql/schema/DataFetchingEnvironment;Lgraphql/execution/DataFetcherExceptionHandlerParameters;)Ljava/util/concurrent/CompletableFuture; +} + +public final class io/sentry/graphql/SentryGraphqlInstrumentation { + public static final field SENTRY_EXCEPTIONS_CONTEXT_KEY Ljava/lang/String; + public static final field SENTRY_SCOPES_CONTEXT_KEY Ljava/lang/String; + public fun (Lio/sentry/graphql/SentryGraphqlInstrumentation$BeforeSpanCallback;Lio/sentry/graphql/SentrySubscriptionHandler;Lio/sentry/graphql/ExceptionReporter;Ljava/util/List;Ljava/lang/String;)V + public fun (Lio/sentry/graphql/SentryGraphqlInstrumentation$BeforeSpanCallback;Lio/sentry/graphql/SentrySubscriptionHandler;ZLjava/util/List;Ljava/lang/String;)V + public fun beginExecuteOperation (Lgraphql/execution/instrumentation/parameters/InstrumentationExecuteOperationParameters;)V + public fun beginExecution (Lgraphql/execution/instrumentation/parameters/InstrumentationExecutionParameters;Lio/sentry/graphql/SentryGraphqlInstrumentation$TracingState;)V + public fun createState ()Lgraphql/execution/instrumentation/InstrumentationState; + public fun instrumentDataFetcher (Lgraphql/schema/DataFetcher;Lgraphql/execution/instrumentation/parameters/InstrumentationFieldFetchParameters;Lio/sentry/graphql/SentryGraphqlInstrumentation$TracingState;)Lgraphql/schema/DataFetcher; + public fun instrumentExecutionResultComplete (Lgraphql/execution/instrumentation/parameters/InstrumentationExecutionParameters;Lgraphql/ExecutionResult;Ljava/lang/Throwable;)V +} + +public abstract interface class io/sentry/graphql/SentryGraphqlInstrumentation$BeforeSpanCallback { + public abstract fun execute (Lio/sentry/ISpan;Lgraphql/schema/DataFetchingEnvironment;Ljava/lang/Object;)Lio/sentry/ISpan; +} + +public final class io/sentry/graphql/SentryGraphqlInstrumentation$TracingState : graphql/execution/instrumentation/InstrumentationState { + public fun ()V + public fun getTransaction ()Lio/sentry/ISpan; + public fun setTransaction (Lio/sentry/ISpan;)V +} + +public abstract interface class io/sentry/graphql/SentrySubscriptionHandler { + public abstract fun onSubscriptionResult (Ljava/lang/Object;Lio/sentry/IScopes;Lio/sentry/graphql/ExceptionReporter;Lgraphql/execution/instrumentation/parameters/InstrumentationFieldFetchParameters;)Ljava/lang/Object; +} + diff --git a/sentry-graphql-core/build.gradle.kts b/sentry-graphql-core/build.gradle.kts new file mode 100644 index 0000000000..ed1c197acd --- /dev/null +++ b/sentry-graphql-core/build.gradle.kts @@ -0,0 +1,88 @@ +import net.ltgt.gradle.errorprone.errorprone +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile + +plugins { + `java-library` + kotlin("jvm") + jacoco + id(Config.QualityPlugins.errorProne) + id(Config.QualityPlugins.gradleVersions) + id(Config.BuildPlugins.buildConfig) version Config.BuildPlugins.buildConfigVersion +} + +configure { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 +} + +tasks.withType().configureEach { + kotlinOptions.jvmTarget = JavaVersion.VERSION_1_8.toString() + kotlinOptions.languageVersion = Config.kotlinCompatibleLanguageVersion +} + +dependencies { + api(projects.sentry) + compileOnly(Config.Libs.graphQlJava) + + compileOnly(Config.CompileOnly.nopen) + errorprone(Config.CompileOnly.nopenChecker) + errorprone(Config.CompileOnly.errorprone) + errorprone(Config.CompileOnly.errorProneNullAway) + compileOnly(Config.CompileOnly.jetbrainsAnnotations) + + // tests + testImplementation(projects.sentry) + testImplementation(projects.sentryTestSupport) + testImplementation(kotlin(Config.kotlinStdLib)) + testImplementation(Config.TestLibs.kotlinTestJunit) + testImplementation(Config.TestLibs.mockitoKotlin) + testImplementation(Config.TestLibs.mockitoInline) + testImplementation(Config.TestLibs.mockWebserver) + testImplementation(Config.Libs.okhttp) + testImplementation(Config.Libs.springBootStarterGraphql) + testImplementation("com.netflix.graphql.dgs:graphql-error-types:4.9.2") + testImplementation(Config.Libs.graphQlJava) +} + +configure { + test { + java.srcDir("src/test/java") + } +} + +jacoco { + toolVersion = Config.QualityPlugins.Jacoco.version +} + +tasks.jacocoTestReport { + reports { + xml.required.set(true) + html.required.set(false) + } +} + +tasks { + jacocoTestCoverageVerification { + violationRules { + rule { limit { minimum = Config.QualityPlugins.Jacoco.minimumCoverage } } + } + } + check { + dependsOn(jacocoTestCoverageVerification) + dependsOn(jacocoTestReport) + } +} + +tasks.withType().configureEach { + options.errorprone { + check("NullAway", net.ltgt.gradle.errorprone.CheckSeverity.ERROR) + option("NullAway:AnnotatedPackages", "io.sentry") + } +} + +buildConfig { + useJavaOutput() + packageName("io.sentry.graphql") + buildConfigField("String", "SENTRY_GRAPHQL_SDK_NAME", "\"${Config.Sentry.SENTRY_GRAPHQL_SDK_NAME}\"") + buildConfigField("String", "VERSION_NAME", "\"${project.version}\"") +} diff --git a/sentry-graphql/src/main/java/io/sentry/graphql/ExceptionReporter.java b/sentry-graphql-core/src/main/java/io/sentry/graphql/ExceptionReporter.java similarity index 82% rename from sentry-graphql/src/main/java/io/sentry/graphql/ExceptionReporter.java rename to sentry-graphql-core/src/main/java/io/sentry/graphql/ExceptionReporter.java index 30ccb21425..9bca0955e4 100644 --- a/sentry-graphql/src/main/java/io/sentry/graphql/ExceptionReporter.java +++ b/sentry-graphql-core/src/main/java/io/sentry/graphql/ExceptionReporter.java @@ -5,7 +5,7 @@ import graphql.language.AstPrinter; import graphql.schema.DataFetchingEnvironment; import io.sentry.Hint; -import io.sentry.IHub; +import io.sentry.IScopes; import io.sentry.SentryEvent; import io.sentry.SentryLevel; import io.sentry.SentryOptions; @@ -33,7 +33,7 @@ public void captureThrowable( final @NotNull Throwable throwable, final @NotNull ExceptionDetails exceptionDetails, final @Nullable ExecutionResult result) { - final @NotNull IHub hub = exceptionDetails.getHub(); + final @NotNull IScopes scopes = exceptionDetails.getScopes(); final @NotNull Mechanism mechanism = new Mechanism(); mechanism.setType(MECHANISM_TYPE); mechanism.setHandled(false); @@ -43,44 +43,44 @@ public void captureThrowable( event.setLevel(SentryLevel.FATAL); final @NotNull Hint hint = new Hint(); - setRequestDetailsOnEvent(hub, exceptionDetails, event); + setRequestDetailsOnEvent(scopes, exceptionDetails, event); - if (result != null && isAllowedToAttachBody(hub)) { + if (result != null && isAllowedToAttachBody(scopes)) { final @NotNull Response response = new Response(); final @NotNull Map responseBody = result.toSpecification(); response.setData(responseBody); event.getContexts().setResponse(response); } - hub.captureEvent(event, hint); + scopes.captureEvent(event, hint); } - private boolean isAllowedToAttachBody(final @NotNull IHub hub) { - final @NotNull SentryOptions options = hub.getOptions(); + private boolean isAllowedToAttachBody(final @NotNull IScopes scopes) { + final @NotNull SentryOptions options = scopes.getOptions(); return options.isSendDefaultPii() && !SentryOptions.RequestSize.NONE.equals(options.getMaxRequestBodySize()); } private void setRequestDetailsOnEvent( - final @NotNull IHub hub, + final @NotNull IScopes scopes, final @NotNull ExceptionDetails exceptionDetails, final @NotNull SentryEvent event) { - hub.configureScope( + scopes.configureScope( (scope) -> { final @Nullable Request scopeRequest = scope.getRequest(); final @NotNull Request request = scopeRequest == null ? new Request() : scopeRequest; - setDetailsOnRequest(hub, exceptionDetails, request); + setDetailsOnRequest(scopes, exceptionDetails, request); event.setRequest(request); }); } private void setDetailsOnRequest( - final @NotNull IHub hub, + final @NotNull IScopes scopes, final @NotNull ExceptionDetails exceptionDetails, final @NotNull Request request) { request.setApiTarget("graphql"); - if (isAllowedToAttachBody(hub) + if (isAllowedToAttachBody(scopes) && (exceptionDetails.isSubscription() || captureRequestBodyForNonSubscriptions)) { final @NotNull Map data = new HashMap<>(); @@ -99,27 +99,27 @@ private void setDetailsOnRequest( public static final class ExceptionDetails { - private final @NotNull IHub hub; + private final @NotNull IScopes scopes; private final @Nullable InstrumentationExecutionParameters instrumentationExecutionParameters; private final @Nullable DataFetchingEnvironment dataFetchingEnvironment; private final boolean isSubscription; public ExceptionDetails( - final @NotNull IHub hub, + final @NotNull IScopes scopes, final @Nullable InstrumentationExecutionParameters instrumentationExecutionParameters, final boolean isSubscription) { - this.hub = hub; + this.scopes = scopes; this.instrumentationExecutionParameters = instrumentationExecutionParameters; dataFetchingEnvironment = null; this.isSubscription = isSubscription; } public ExceptionDetails( - final @NotNull IHub hub, + final @NotNull IScopes scopes, final @Nullable DataFetchingEnvironment dataFetchingEnvironment, final boolean isSubscription) { - this.hub = hub; + this.scopes = scopes; this.dataFetchingEnvironment = dataFetchingEnvironment; instrumentationExecutionParameters = null; this.isSubscription = isSubscription; @@ -149,8 +149,16 @@ public boolean isSubscription() { return isSubscription; } - public @NotNull IHub getHub() { - return hub; + /** + * @deprecated please use {@link ExceptionDetails#getScopes()} instead. + */ + @Deprecated + public @NotNull IScopes getHub() { + return scopes; + } + + public @NotNull IScopes getScopes() { + return scopes; } } } diff --git a/sentry-graphql/src/main/java/io/sentry/graphql/GraphqlStringUtils.java b/sentry-graphql-core/src/main/java/io/sentry/graphql/GraphqlStringUtils.java similarity index 100% rename from sentry-graphql/src/main/java/io/sentry/graphql/GraphqlStringUtils.java rename to sentry-graphql-core/src/main/java/io/sentry/graphql/GraphqlStringUtils.java diff --git a/sentry-graphql/src/main/java/io/sentry/graphql/NoOpSubscriptionHandler.java b/sentry-graphql-core/src/main/java/io/sentry/graphql/NoOpSubscriptionHandler.java similarity index 92% rename from sentry-graphql/src/main/java/io/sentry/graphql/NoOpSubscriptionHandler.java rename to sentry-graphql-core/src/main/java/io/sentry/graphql/NoOpSubscriptionHandler.java index df241ce35b..839f413719 100644 --- a/sentry-graphql/src/main/java/io/sentry/graphql/NoOpSubscriptionHandler.java +++ b/sentry-graphql-core/src/main/java/io/sentry/graphql/NoOpSubscriptionHandler.java @@ -1,7 +1,7 @@ package io.sentry.graphql; import graphql.execution.instrumentation.parameters.InstrumentationFieldFetchParameters; -import io.sentry.IHub; +import io.sentry.IScopes; import org.jetbrains.annotations.NotNull; public final class NoOpSubscriptionHandler implements SentrySubscriptionHandler { @@ -17,7 +17,7 @@ private NoOpSubscriptionHandler() {} @Override public @NotNull Object onSubscriptionResult( @NotNull Object result, - @NotNull IHub hub, + @NotNull IScopes scopes, @NotNull ExceptionReporter exceptionReporter, @NotNull InstrumentationFieldFetchParameters parameters) { return result; diff --git a/sentry-graphql/src/main/java/io/sentry/graphql/SentryGenericDataFetcherExceptionHandler.java b/sentry-graphql-core/src/main/java/io/sentry/graphql/SentryGenericDataFetcherExceptionHandler.java similarity index 93% rename from sentry-graphql/src/main/java/io/sentry/graphql/SentryGenericDataFetcherExceptionHandler.java rename to sentry-graphql-core/src/main/java/io/sentry/graphql/SentryGenericDataFetcherExceptionHandler.java index 6251d00779..1287d38caa 100644 --- a/sentry-graphql/src/main/java/io/sentry/graphql/SentryGenericDataFetcherExceptionHandler.java +++ b/sentry-graphql-core/src/main/java/io/sentry/graphql/SentryGenericDataFetcherExceptionHandler.java @@ -3,7 +3,7 @@ import graphql.execution.DataFetcherExceptionHandler; import graphql.execution.DataFetcherExceptionHandlerParameters; import graphql.execution.DataFetcherExceptionHandlerResult; -import io.sentry.IHub; +import io.sentry.IScopes; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutionException; import org.jetbrains.annotations.NotNull; @@ -17,7 +17,7 @@ public final class SentryGenericDataFetcherExceptionHandler implements DataFetch private final @NotNull SentryGraphqlExceptionHandler handler; public SentryGenericDataFetcherExceptionHandler( - final @Nullable IHub hub, final @NotNull DataFetcherExceptionHandler delegate) { + final @Nullable IScopes scopes, final @NotNull DataFetcherExceptionHandler delegate) { this.handler = new SentryGraphqlExceptionHandler(delegate); } diff --git a/sentry-graphql/src/main/java/io/sentry/graphql/SentryGraphqlExceptionHandler.java b/sentry-graphql-core/src/main/java/io/sentry/graphql/SentryGraphqlExceptionHandler.java similarity index 81% rename from sentry-graphql/src/main/java/io/sentry/graphql/SentryGraphqlExceptionHandler.java rename to sentry-graphql-core/src/main/java/io/sentry/graphql/SentryGraphqlExceptionHandler.java index a1f94cacce..4b178c498f 100644 --- a/sentry-graphql/src/main/java/io/sentry/graphql/SentryGraphqlExceptionHandler.java +++ b/sentry-graphql-core/src/main/java/io/sentry/graphql/SentryGraphqlExceptionHandler.java @@ -1,12 +1,14 @@ package io.sentry.graphql; -import static io.sentry.graphql.SentryInstrumentation.SENTRY_EXCEPTIONS_CONTEXT_KEY; +import static io.sentry.graphql.SentryGraphqlInstrumentation.SENTRY_EXCEPTIONS_CONTEXT_KEY; import graphql.GraphQLContext; import graphql.execution.DataFetcherExceptionHandler; import graphql.execution.DataFetcherExceptionHandlerParameters; import graphql.execution.DataFetcherExceptionHandlerResult; import graphql.schema.DataFetchingEnvironment; +import io.sentry.ISentryLifecycleToken; +import io.sentry.util.AutoClosableReentrantLock; import java.util.List; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CopyOnWriteArrayList; @@ -17,7 +19,8 @@ @ApiStatus.Internal public final class SentryGraphqlExceptionHandler { private final @Nullable DataFetcherExceptionHandler delegate; - private final @NotNull Object exceptionContextLock = new Object(); + private final @NotNull AutoClosableReentrantLock exceptionContextLock = + new AutoClosableReentrantLock(); public SentryGraphqlExceptionHandler(final @Nullable DataFetcherExceptionHandler delegate) { this.delegate = delegate; @@ -30,7 +33,7 @@ public SentryGraphqlExceptionHandler(final @Nullable DataFetcherExceptionHandler if (environment != null) { final @Nullable GraphQLContext graphQlContext = environment.getGraphQlContext(); if (graphQlContext != null) { - synchronized (exceptionContextLock) { + try (final @NotNull ISentryLifecycleToken ignored = exceptionContextLock.acquire()) { final @NotNull List exceptions = graphQlContext.getOrDefault( SENTRY_EXCEPTIONS_CONTEXT_KEY, new CopyOnWriteArrayList()); diff --git a/sentry-graphql-core/src/main/java/io/sentry/graphql/SentryGraphqlInstrumentation.java b/sentry-graphql-core/src/main/java/io/sentry/graphql/SentryGraphqlInstrumentation.java new file mode 100644 index 0000000000..c316774c04 --- /dev/null +++ b/sentry-graphql-core/src/main/java/io/sentry/graphql/SentryGraphqlInstrumentation.java @@ -0,0 +1,341 @@ +package io.sentry.graphql; + +import graphql.ErrorClassification; +import graphql.ExecutionResult; +import graphql.GraphQLContext; +import graphql.GraphQLError; +import graphql.execution.ExecutionContext; +import graphql.execution.ExecutionStepInfo; +import graphql.execution.instrumentation.InstrumentationState; +import graphql.execution.instrumentation.parameters.InstrumentationExecuteOperationParameters; +import graphql.execution.instrumentation.parameters.InstrumentationExecutionParameters; +import graphql.execution.instrumentation.parameters.InstrumentationFieldFetchParameters; +import graphql.language.OperationDefinition; +import graphql.schema.DataFetcher; +import graphql.schema.DataFetchingEnvironment; +import graphql.schema.GraphQLNonNull; +import graphql.schema.GraphQLObjectType; +import graphql.schema.GraphQLOutputType; +import io.sentry.Breadcrumb; +import io.sentry.Hint; +import io.sentry.IScopes; +import io.sentry.ISpan; +import io.sentry.NoOpScopes; +import io.sentry.Sentry; +import io.sentry.SpanOptions; +import io.sentry.SpanStatus; +import io.sentry.TypeCheckHint; +import io.sentry.util.StringUtils; +import java.util.Arrays; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CopyOnWriteArrayList; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.jetbrains.annotations.TestOnly; + +public final class SentryGraphqlInstrumentation { + + public static final @NotNull String SENTRY_SCOPES_CONTEXT_KEY = "sentry.scopes"; + public static final @NotNull String SENTRY_EXCEPTIONS_CONTEXT_KEY = "sentry.exceptions"; + + private static final @NotNull List ERROR_TYPES_HANDLED_BY_DATA_FETCHERS = + Arrays.asList( + "INTERNAL_ERROR", // spring-graphql + "INTERNAL", // Netflix DGS + "DataFetchingException" // raw graphql-java + ); + private final @Nullable BeforeSpanCallback beforeSpan; + private final @NotNull SentrySubscriptionHandler subscriptionHandler; + private final @NotNull ExceptionReporter exceptionReporter; + private final @NotNull List ignoredErrorTypes; + private final @NotNull String traceOrigin; + + /** + * @param beforeSpan callback when a span is created + * @param subscriptionHandler can report subscription errors + * @param captureRequestBodyForNonSubscriptions false if request bodies should not be captured by + * this integration for query and mutation operations. This can be used to prevent unnecessary + * work by not adding the request body when another integration will add it anyways, as is the + * case with our spring integration for WebMVC. + * @param ignoredErrorTypes list of error types that should not be captured and sent to Sentry + */ + public SentryGraphqlInstrumentation( + final @Nullable BeforeSpanCallback beforeSpan, + final @NotNull SentrySubscriptionHandler subscriptionHandler, + final boolean captureRequestBodyForNonSubscriptions, + final @NotNull List ignoredErrorTypes, + final @NotNull String traceOrigin) { + this( + beforeSpan, + subscriptionHandler, + new ExceptionReporter(captureRequestBodyForNonSubscriptions), + ignoredErrorTypes, + traceOrigin); + } + + @TestOnly + public SentryGraphqlInstrumentation( + final @Nullable BeforeSpanCallback beforeSpan, + final @NotNull SentrySubscriptionHandler subscriptionHandler, + final @NotNull ExceptionReporter exceptionReporter, + final @NotNull List ignoredErrorTypes, + final @NotNull String traceOrigin) { + this.beforeSpan = beforeSpan; + this.subscriptionHandler = subscriptionHandler; + this.exceptionReporter = exceptionReporter; + this.ignoredErrorTypes = ignoredErrorTypes; + this.traceOrigin = traceOrigin; + } + + public @NotNull InstrumentationState createState() { + return new TracingState(); + } + + public void beginExecution( + final @NotNull InstrumentationExecutionParameters parameters, + final @NotNull TracingState tracingState) { + final @NotNull IScopes currentScopes = Sentry.getCurrentScopes(); + tracingState.setTransaction(currentScopes.getSpan()); + parameters.getGraphQLContext().put(SENTRY_SCOPES_CONTEXT_KEY, currentScopes); + } + + public void instrumentExecutionResultComplete( + final @NotNull InstrumentationExecutionParameters parameters, + final @Nullable ExecutionResult result, + final @Nullable Throwable exception) { + if (result != null) { + final @Nullable GraphQLContext graphQLContext = parameters.getGraphQLContext(); + if (graphQLContext != null) { + final @NotNull List exceptions = + graphQLContext.getOrDefault( + SENTRY_EXCEPTIONS_CONTEXT_KEY, new CopyOnWriteArrayList()); + for (Throwable throwable : exceptions) { + exceptionReporter.captureThrowable( + throwable, + new ExceptionReporter.ExceptionDetails( + scopesFromContext(graphQLContext), parameters, false), + result); + } + } + final @NotNull List errors = result.getErrors(); + if (errors != null) { + for (GraphQLError error : errors) { + String errorType = getErrorType(error); + if (!isIgnored(errorType)) { + exceptionReporter.captureThrowable( + new RuntimeException(error.getMessage()), + new ExceptionReporter.ExceptionDetails( + scopesFromContext(graphQLContext), parameters, false), + result); + } + } + } + } + if (exception != null) { + exceptionReporter.captureThrowable( + exception, + new ExceptionReporter.ExceptionDetails( + scopesFromContext(parameters.getGraphQLContext()), parameters, false), + null); + } + } + + private boolean isIgnored(final @Nullable String errorType) { + if (errorType == null) { + return false; + } + + // not capturing INTERNAL_ERRORS as they should be reported via graphQlContext above + // also not capturing error types explicitly ignored by users + return ERROR_TYPES_HANDLED_BY_DATA_FETCHERS.contains(errorType) + || ignoredErrorTypes.contains(errorType); + } + + private @Nullable String getErrorType(final @Nullable GraphQLError error) { + if (error == null) { + return null; + } + final @Nullable ErrorClassification errorType = error.getErrorType(); + if (errorType != null) { + return errorType.toString(); + } + final @Nullable Map extensions = error.getExtensions(); + if (extensions != null) { + return StringUtils.toString(extensions.get("errorType")); + } + return null; + } + + public void beginExecuteOperation( + final @NotNull InstrumentationExecuteOperationParameters parameters) { + final @Nullable ExecutionContext executionContext = parameters.getExecutionContext(); + if (executionContext != null) { + final @Nullable OperationDefinition operationDefinition = + executionContext.getOperationDefinition(); + if (operationDefinition != null) { + final @Nullable OperationDefinition.Operation operation = + operationDefinition.getOperation(); + final @Nullable String operationType = + operation == null ? null : operation.name().toLowerCase(Locale.ROOT); + scopesFromContext(parameters.getExecutionContext().getGraphQLContext()) + .addBreadcrumb( + Breadcrumb.graphqlOperation( + operationDefinition.getName(), + operationType, + StringUtils.toString(executionContext.getExecutionId()))); + } + } + } + + private @NotNull IScopes scopesFromContext(final @Nullable GraphQLContext context) { + if (context == null) { + return NoOpScopes.getInstance(); + } + return context.getOrDefault(SENTRY_SCOPES_CONTEXT_KEY, NoOpScopes.getInstance()); + } + + @SuppressWarnings({"FutureReturnValueIgnored", "deprecation"}) + public @NotNull DataFetcher instrumentDataFetcher( + final @NotNull DataFetcher dataFetcher, + final @NotNull InstrumentationFieldFetchParameters parameters, + final @NotNull TracingState tracingState) { + // We only care about user code + if (parameters.isTrivialDataFetcher()) { + return dataFetcher; + } + + return environment -> { + final @Nullable ExecutionStepInfo executionStepInfo = environment.getExecutionStepInfo(); + if (executionStepInfo != null) { + Hint hint = new Hint(); + hint.set(TypeCheckHint.GRAPHQL_DATA_FETCHING_ENVIRONMENT, environment); + scopesFromContext(parameters.getExecutionContext().getGraphQLContext()) + .addBreadcrumb( + Breadcrumb.graphqlDataFetcher( + StringUtils.toString(executionStepInfo.getPath()), + GraphqlStringUtils.fieldToString(executionStepInfo.getField()), + GraphqlStringUtils.typeToString(executionStepInfo.getType()), + GraphqlStringUtils.objectTypeToString(executionStepInfo.getObjectType())), + hint); + } + final ISpan transaction = tracingState.getTransaction(); + if (transaction != null) { + final ISpan span = createSpan(transaction, parameters); + try { + final @Nullable Object tmpResult = dataFetcher.get(environment); + final @Nullable Object result = + maybeCallSubscriptionHandler(parameters, environment, tmpResult); + if (result instanceof CompletableFuture) { + ((CompletableFuture) result) + .whenComplete( + (r, ex) -> { + if (ex != null) { + span.setThrowable(ex); + span.setStatus(SpanStatus.INTERNAL_ERROR); + } else { + span.setStatus(SpanStatus.OK); + } + finish(span, environment, r); + }); + } else { + span.setStatus(SpanStatus.OK); + finish(span, environment, result); + } + return result; + } catch (Throwable e) { + span.setThrowable(e); + span.setStatus(SpanStatus.INTERNAL_ERROR); + finish(span, environment); + throw e; + } + } else { + final Object result = dataFetcher.get(environment); + return maybeCallSubscriptionHandler(parameters, environment, result); + } + }; + } + + private @Nullable Object maybeCallSubscriptionHandler( + final @NotNull InstrumentationFieldFetchParameters parameters, + final @NotNull DataFetchingEnvironment environment, + final @Nullable Object tmpResult) { + if (tmpResult == null) { + return null; + } + + if (OperationDefinition.Operation.SUBSCRIPTION.equals( + environment.getOperationDefinition().getOperation())) { + return subscriptionHandler.onSubscriptionResult( + tmpResult, + scopesFromContext(environment.getGraphQlContext()), + exceptionReporter, + parameters); + } + + return tmpResult; + } + + private void finish( + final @NotNull ISpan span, + final @NotNull DataFetchingEnvironment environment, + final @Nullable Object result) { + if (beforeSpan != null) { + final ISpan newSpan = beforeSpan.execute(span, environment, result); + if (newSpan == null) { + // span is dropped + span.getSpanContext().setSampled(false); + } else { + newSpan.finish(); + } + } else { + span.finish(); + } + } + + private void finish( + final @NotNull ISpan span, final @NotNull DataFetchingEnvironment environment) { + finish(span, environment, null); + } + + private @NotNull ISpan createSpan( + @NotNull ISpan transaction, @NotNull InstrumentationFieldFetchParameters parameters) { + final GraphQLOutputType type = parameters.getExecutionStepInfo().getParent().getType(); + GraphQLObjectType parent; + if (type instanceof GraphQLNonNull) { + parent = (GraphQLObjectType) ((GraphQLNonNull) type).getWrappedType(); + } else { + parent = (GraphQLObjectType) type; + } + final @NotNull SpanOptions spanOptions = new SpanOptions(); + spanOptions.setOrigin(traceOrigin); + final @NotNull ISpan span = + transaction.startChild( + "graphql", + parent.getName() + "." + parameters.getExecutionStepInfo().getPath().getSegmentName(), + spanOptions); + + return span; + } + + public static final class TracingState implements InstrumentationState { + private @Nullable ISpan transaction; + + public @Nullable ISpan getTransaction() { + return transaction; + } + + public void setTransaction(final @Nullable ISpan transaction) { + this.transaction = transaction; + } + } + + @FunctionalInterface + public interface BeforeSpanCallback { + @Nullable + ISpan execute( + @NotNull ISpan span, @NotNull DataFetchingEnvironment environment, @Nullable Object result); + } +} diff --git a/sentry-graphql/src/main/java/io/sentry/graphql/SentrySubscriptionHandler.java b/sentry-graphql-core/src/main/java/io/sentry/graphql/SentrySubscriptionHandler.java similarity index 87% rename from sentry-graphql/src/main/java/io/sentry/graphql/SentrySubscriptionHandler.java rename to sentry-graphql-core/src/main/java/io/sentry/graphql/SentrySubscriptionHandler.java index bfc962b501..0a5538ce22 100644 --- a/sentry-graphql/src/main/java/io/sentry/graphql/SentrySubscriptionHandler.java +++ b/sentry-graphql-core/src/main/java/io/sentry/graphql/SentrySubscriptionHandler.java @@ -1,14 +1,14 @@ package io.sentry.graphql; import graphql.execution.instrumentation.parameters.InstrumentationFieldFetchParameters; -import io.sentry.IHub; +import io.sentry.IScopes; import org.jetbrains.annotations.NotNull; public interface SentrySubscriptionHandler { @NotNull Object onSubscriptionResult( @NotNull Object result, - @NotNull IHub hub, + @NotNull IScopes scopes, @NotNull ExceptionReporter exceptionReporter, @NotNull InstrumentationFieldFetchParameters parameters); } diff --git a/sentry-graphql/src/test/kotlin/io/sentry/graphql/ExceptionReporterTest.kt b/sentry-graphql-core/src/test/kotlin/io/sentry/graphql/ExceptionReporterTest.kt similarity index 87% rename from sentry-graphql/src/test/kotlin/io/sentry/graphql/ExceptionReporterTest.kt rename to sentry-graphql-core/src/test/kotlin/io/sentry/graphql/ExceptionReporterTest.kt index a2b2b0f101..df561f7169 100644 --- a/sentry-graphql/src/test/kotlin/io/sentry/graphql/ExceptionReporterTest.kt +++ b/sentry-graphql-core/src/test/kotlin/io/sentry/graphql/ExceptionReporterTest.kt @@ -12,8 +12,8 @@ import graphql.schema.GraphQLObjectType import graphql.schema.GraphQLScalarType import graphql.schema.GraphQLSchema import io.sentry.Hint -import io.sentry.IHub import io.sentry.IScope +import io.sentry.IScopes import io.sentry.Scope import io.sentry.ScopeCallback import io.sentry.SentryOptions @@ -39,7 +39,7 @@ class ExceptionReporterTest { it.maxRequestBodySize = SentryOptions.RequestSize.ALWAYS } val exception = IllegalStateException("some exception") - val hub = mock() + val scopes = mock() lateinit var instrumentationExecutionParameters: InstrumentationExecutionParameters lateinit var executionResult: ExecutionResult lateinit var scope: IScope @@ -47,7 +47,7 @@ class ExceptionReporterTest { val variables = mapOf("variableA" to "value a") fun getSut(options: SentryOptions = defaultOptions, captureRequestBodyForNonSubscriptions: Boolean = true): ExceptionReporter { - whenever(hub.options).thenReturn(options) + whenever(scopes.options).thenReturn(options) scope = Scope(options) val exceptionReporter = ExceptionReporter(captureRequestBodyForNonSubscriptions) executionResult = ExecutionResultImpl.newExecutionResult() @@ -75,9 +75,9 @@ class ExceptionReporterTest { field ).build() ).build() - val instrumentationState = SentryInstrumentation.TracingState() + val instrumentationState = SentryGraphqlInstrumentation.TracingState() instrumentationExecutionParameters = InstrumentationExecutionParameters(executionInput, schema, instrumentationState) - doAnswer { (it.arguments[0] as ScopeCallback).run(scope) }.whenever(hub).configureScope(any()) + doAnswer { (it.arguments[0] as ScopeCallback).run(scope) }.whenever(scopes).configureScope(any()) return exceptionReporter } @@ -88,9 +88,9 @@ class ExceptionReporterTest { @Test fun `captures throwable`() { val exceptionReporter = fixture.getSut() - exceptionReporter.captureThrowable(fixture.exception, ExceptionReporter.ExceptionDetails(fixture.hub, fixture.instrumentationExecutionParameters, false), fixture.executionResult) + exceptionReporter.captureThrowable(fixture.exception, ExceptionReporter.ExceptionDetails(fixture.scopes, fixture.instrumentationExecutionParameters, false), fixture.executionResult) - verify(fixture.hub).captureEvent( + verify(fixture.scopes).captureEvent( org.mockito.kotlin.check { val ex = it.throwableMechanism as ExceptionMechanismException assertFalse(ex.exceptionMechanism.isHandled!!) @@ -112,9 +112,9 @@ class ExceptionReporterTest { val exceptionReporter = fixture.getSut() val headers = mapOf("some-header" to "some-header-value") fixture.scope.request = Request().also { it.headers = headers } - exceptionReporter.captureThrowable(fixture.exception, ExceptionReporter.ExceptionDetails(fixture.hub, fixture.instrumentationExecutionParameters, false), fixture.executionResult) + exceptionReporter.captureThrowable(fixture.exception, ExceptionReporter.ExceptionDetails(fixture.scopes, fixture.instrumentationExecutionParameters, false), fixture.executionResult) - verify(fixture.hub).captureEvent( + verify(fixture.scopes).captureEvent( org.mockito.kotlin.check { val ex = it.throwableMechanism as ExceptionMechanismException assertFalse(ex.exceptionMechanism.isHandled!!) @@ -136,9 +136,9 @@ class ExceptionReporterTest { @Test fun `does not attach query or variables if spring`() { val exceptionReporter = fixture.getSut(captureRequestBodyForNonSubscriptions = false) - exceptionReporter.captureThrowable(fixture.exception, ExceptionReporter.ExceptionDetails(fixture.hub, fixture.instrumentationExecutionParameters, false), fixture.executionResult) + exceptionReporter.captureThrowable(fixture.exception, ExceptionReporter.ExceptionDetails(fixture.scopes, fixture.instrumentationExecutionParameters, false), fixture.executionResult) - verify(fixture.hub).captureEvent( + verify(fixture.scopes).captureEvent( org.mockito.kotlin.check { val ex = it.throwableMechanism as ExceptionMechanismException assertFalse(ex.exceptionMechanism.isHandled!!) @@ -156,9 +156,9 @@ class ExceptionReporterTest { @Test fun `does not attach query or variables if no max body size is set`() { val exceptionReporter = fixture.getSut(SentryOptions().also { it.isSendDefaultPii = true }, false) - exceptionReporter.captureThrowable(fixture.exception, ExceptionReporter.ExceptionDetails(fixture.hub, fixture.instrumentationExecutionParameters, false), fixture.executionResult) + exceptionReporter.captureThrowable(fixture.exception, ExceptionReporter.ExceptionDetails(fixture.scopes, fixture.instrumentationExecutionParameters, false), fixture.executionResult) - verify(fixture.hub).captureEvent( + verify(fixture.scopes).captureEvent( org.mockito.kotlin.check { val ex = it.throwableMechanism as ExceptionMechanismException assertFalse(ex.exceptionMechanism.isHandled!!) @@ -176,9 +176,9 @@ class ExceptionReporterTest { @Test fun `does not attach query or variables if sendDefaultPii is false`() { val exceptionReporter = fixture.getSut(SentryOptions().also { it.maxRequestBodySize = SentryOptions.RequestSize.ALWAYS }, false) - exceptionReporter.captureThrowable(fixture.exception, ExceptionReporter.ExceptionDetails(fixture.hub, fixture.instrumentationExecutionParameters, false), fixture.executionResult) + exceptionReporter.captureThrowable(fixture.exception, ExceptionReporter.ExceptionDetails(fixture.scopes, fixture.instrumentationExecutionParameters, false), fixture.executionResult) - verify(fixture.hub).captureEvent( + verify(fixture.scopes).captureEvent( org.mockito.kotlin.check { val ex = it.throwableMechanism as ExceptionMechanismException assertFalse(ex.exceptionMechanism.isHandled!!) @@ -196,9 +196,9 @@ class ExceptionReporterTest { @Test fun `attaches query and variables if spring and subscription`() { val exceptionReporter = fixture.getSut(captureRequestBodyForNonSubscriptions = false) - exceptionReporter.captureThrowable(fixture.exception, ExceptionReporter.ExceptionDetails(fixture.hub, fixture.instrumentationExecutionParameters, true), fixture.executionResult) + exceptionReporter.captureThrowable(fixture.exception, ExceptionReporter.ExceptionDetails(fixture.scopes, fixture.instrumentationExecutionParameters, true), fixture.executionResult) - verify(fixture.hub).captureEvent( + verify(fixture.scopes).captureEvent( org.mockito.kotlin.check { val ex = it.throwableMechanism as ExceptionMechanismException assertFalse(ex.exceptionMechanism.isHandled!!) diff --git a/sentry-graphql/src/test/kotlin/io/sentry/graphql/GraphqlStringUtilsTest.kt b/sentry-graphql-core/src/test/kotlin/io/sentry/graphql/GraphqlStringUtilsTest.kt similarity index 100% rename from sentry-graphql/src/test/kotlin/io/sentry/graphql/GraphqlStringUtilsTest.kt rename to sentry-graphql-core/src/test/kotlin/io/sentry/graphql/GraphqlStringUtilsTest.kt diff --git a/sentry-graphql/src/test/kotlin/io/sentry/graphql/SentryGenericDataFetcherExceptionHandlerTest.kt b/sentry-graphql-core/src/test/kotlin/io/sentry/graphql/SentryGenericDataFetcherExceptionHandlerTest.kt similarity index 88% rename from sentry-graphql/src/test/kotlin/io/sentry/graphql/SentryGenericDataFetcherExceptionHandlerTest.kt rename to sentry-graphql-core/src/test/kotlin/io/sentry/graphql/SentryGenericDataFetcherExceptionHandlerTest.kt index 6d643baf01..ee8cf36d77 100644 --- a/sentry-graphql/src/test/kotlin/io/sentry/graphql/SentryGenericDataFetcherExceptionHandlerTest.kt +++ b/sentry-graphql-core/src/test/kotlin/io/sentry/graphql/SentryGenericDataFetcherExceptionHandlerTest.kt @@ -4,7 +4,7 @@ import graphql.GraphQLContext import graphql.execution.DataFetcherExceptionHandler import graphql.execution.DataFetcherExceptionHandlerParameters import graphql.schema.DataFetchingEnvironmentImpl -import io.sentry.IHub +import io.sentry.IScopes import org.mockito.kotlin.mock import org.mockito.kotlin.verify import kotlin.test.Test @@ -15,10 +15,10 @@ class SentryGenericDataFetcherExceptionHandlerTest { @Test fun `collects exception into GraphQLContext and invokes delegate`() { - val hub = mock() + val scopes = mock() val delegate = mock() val handler = SentryGenericDataFetcherExceptionHandler( - hub, + scopes, delegate ) @@ -32,7 +32,7 @@ class SentryGenericDataFetcherExceptionHandlerTest { ).build() handler.onException(parameters) - val exceptions: List = parameters.dataFetchingEnvironment.graphQlContext[SentryInstrumentation.SENTRY_EXCEPTIONS_CONTEXT_KEY] + val exceptions: List = parameters.dataFetchingEnvironment.graphQlContext[SentryGraphqlInstrumentation.SENTRY_EXCEPTIONS_CONTEXT_KEY] assertNotNull(exceptions) assertEquals(1, exceptions.size) assertEquals(exception, exceptions.first()) diff --git a/sentry-graphql/api/sentry-graphql.api b/sentry-graphql/api/sentry-graphql.api index 57c253e23c..2e5f81ae76 100644 --- a/sentry-graphql/api/sentry-graphql.api +++ b/sentry-graphql/api/sentry-graphql.api @@ -3,61 +3,12 @@ public final class io/sentry/graphql/BuildConfig { public static final field VERSION_NAME Ljava/lang/String; } -public final class io/sentry/graphql/ExceptionReporter { - public fun (Z)V - public fun captureThrowable (Ljava/lang/Throwable;Lio/sentry/graphql/ExceptionReporter$ExceptionDetails;Lgraphql/ExecutionResult;)V -} - -public final class io/sentry/graphql/ExceptionReporter$ExceptionDetails { - public fun (Lio/sentry/IHub;Lgraphql/execution/instrumentation/parameters/InstrumentationExecutionParameters;Z)V - public fun (Lio/sentry/IHub;Lgraphql/schema/DataFetchingEnvironment;Z)V - public fun getHub ()Lio/sentry/IHub; - public fun getQuery ()Ljava/lang/String; - public fun getVariables ()Ljava/util/Map; - public fun isSubscription ()Z -} - -public final class io/sentry/graphql/GraphqlStringUtils { - public fun ()V - public static fun fieldToString (Lgraphql/execution/MergedField;)Ljava/lang/String; - public static fun objectTypeToString (Lgraphql/schema/GraphQLObjectType;)Ljava/lang/String; - public static fun typeToString (Lgraphql/schema/GraphQLOutputType;)Ljava/lang/String; -} - -public final class io/sentry/graphql/NoOpSubscriptionHandler : io/sentry/graphql/SentrySubscriptionHandler { - public static fun getInstance ()Lio/sentry/graphql/NoOpSubscriptionHandler; - public fun onSubscriptionResult (Ljava/lang/Object;Lio/sentry/IHub;Lio/sentry/graphql/ExceptionReporter;Lgraphql/execution/instrumentation/parameters/InstrumentationFieldFetchParameters;)Ljava/lang/Object; -} - -public final class io/sentry/graphql/SentryDataFetcherExceptionHandler : graphql/execution/DataFetcherExceptionHandler { - public fun (Lgraphql/execution/DataFetcherExceptionHandler;)V - public fun (Lio/sentry/IHub;Lgraphql/execution/DataFetcherExceptionHandler;)V - public fun handleException (Lgraphql/execution/DataFetcherExceptionHandlerParameters;)Ljava/util/concurrent/CompletableFuture; - public fun onException (Lgraphql/execution/DataFetcherExceptionHandlerParameters;)Lgraphql/execution/DataFetcherExceptionHandlerResult; -} - -public final class io/sentry/graphql/SentryGenericDataFetcherExceptionHandler : graphql/execution/DataFetcherExceptionHandler { - public fun (Lgraphql/execution/DataFetcherExceptionHandler;)V - public fun (Lio/sentry/IHub;Lgraphql/execution/DataFetcherExceptionHandler;)V - public fun handleException (Lgraphql/execution/DataFetcherExceptionHandlerParameters;)Ljava/util/concurrent/CompletableFuture; - public fun onException (Lgraphql/execution/DataFetcherExceptionHandlerParameters;)Lgraphql/execution/DataFetcherExceptionHandlerResult; -} - -public final class io/sentry/graphql/SentryGraphqlExceptionHandler { - public fun (Lgraphql/execution/DataFetcherExceptionHandler;)V - public fun handleException (Ljava/lang/Throwable;Lgraphql/schema/DataFetchingEnvironment;Lgraphql/execution/DataFetcherExceptionHandlerParameters;)Ljava/util/concurrent/CompletableFuture; -} - public final class io/sentry/graphql/SentryInstrumentation : graphql/execution/instrumentation/SimpleInstrumentation { public static final field SENTRY_EXCEPTIONS_CONTEXT_KEY Ljava/lang/String; - public static final field SENTRY_HUB_CONTEXT_KEY Ljava/lang/String; - public fun ()V - public fun (Lio/sentry/IHub;)V - public fun (Lio/sentry/IHub;Lio/sentry/graphql/SentryInstrumentation$BeforeSpanCallback;)V - public fun (Lio/sentry/graphql/SentryInstrumentation$BeforeSpanCallback;)V - public fun (Lio/sentry/graphql/SentryInstrumentation$BeforeSpanCallback;Lio/sentry/graphql/SentrySubscriptionHandler;Lio/sentry/graphql/ExceptionReporter;Ljava/util/List;)V - public fun (Lio/sentry/graphql/SentryInstrumentation$BeforeSpanCallback;Lio/sentry/graphql/SentrySubscriptionHandler;Z)V - public fun (Lio/sentry/graphql/SentryInstrumentation$BeforeSpanCallback;Lio/sentry/graphql/SentrySubscriptionHandler;ZLjava/util/List;)V + public static final field SENTRY_SCOPES_CONTEXT_KEY Ljava/lang/String; + public fun (Lio/sentry/graphql/SentryGraphqlInstrumentation$BeforeSpanCallback;Lio/sentry/graphql/SentrySubscriptionHandler;Lio/sentry/graphql/ExceptionReporter;Ljava/util/List;)V + public fun (Lio/sentry/graphql/SentryGraphqlInstrumentation$BeforeSpanCallback;Lio/sentry/graphql/SentrySubscriptionHandler;Z)V + public fun (Lio/sentry/graphql/SentryGraphqlInstrumentation$BeforeSpanCallback;Lio/sentry/graphql/SentrySubscriptionHandler;ZLjava/util/List;)V public fun (Lio/sentry/graphql/SentrySubscriptionHandler;Z)V public fun beginExecuteOperation (Lgraphql/execution/instrumentation/parameters/InstrumentationExecuteOperationParameters;)Lgraphql/execution/instrumentation/InstrumentationContext; public fun beginExecution (Lgraphql/execution/instrumentation/parameters/InstrumentationExecutionParameters;)Lgraphql/execution/instrumentation/InstrumentationContext; @@ -66,11 +17,6 @@ public final class io/sentry/graphql/SentryInstrumentation : graphql/execution/i public fun instrumentExecutionResult (Lgraphql/ExecutionResult;Lgraphql/execution/instrumentation/parameters/InstrumentationExecutionParameters;)Ljava/util/concurrent/CompletableFuture; } -public abstract interface class io/sentry/graphql/SentryInstrumentation$BeforeSpanCallback { - public abstract fun execute (Lio/sentry/ISpan;Lgraphql/schema/DataFetchingEnvironment;Ljava/lang/Object;)Lio/sentry/ISpan; -} - -public abstract interface class io/sentry/graphql/SentrySubscriptionHandler { - public abstract fun onSubscriptionResult (Ljava/lang/Object;Lio/sentry/IHub;Lio/sentry/graphql/ExceptionReporter;Lgraphql/execution/instrumentation/parameters/InstrumentationFieldFetchParameters;)Ljava/lang/Object; +public abstract interface class io/sentry/graphql/SentryInstrumentation$BeforeSpanCallback : io/sentry/graphql/SentryGraphqlInstrumentation$BeforeSpanCallback { } diff --git a/sentry-graphql/build.gradle.kts b/sentry-graphql/build.gradle.kts index ed1c197acd..f0de17f288 100644 --- a/sentry-graphql/build.gradle.kts +++ b/sentry-graphql/build.gradle.kts @@ -22,6 +22,7 @@ tasks.withType().configureEach { dependencies { api(projects.sentry) + api(projects.sentryGraphqlCore) compileOnly(Config.Libs.graphQlJava) compileOnly(Config.CompileOnly.nopen) diff --git a/sentry-graphql/src/main/java/io/sentry/graphql/SentryDataFetcherExceptionHandler.java b/sentry-graphql/src/main/java/io/sentry/graphql/SentryDataFetcherExceptionHandler.java deleted file mode 100644 index c0467c0089..0000000000 --- a/sentry-graphql/src/main/java/io/sentry/graphql/SentryDataFetcherExceptionHandler.java +++ /dev/null @@ -1,67 +0,0 @@ -package io.sentry.graphql; - -import static io.sentry.TypeCheckHint.GRAPHQL_HANDLER_PARAMETERS; - -import graphql.execution.DataFetcherExceptionHandler; -import graphql.execution.DataFetcherExceptionHandlerParameters; -import graphql.execution.DataFetcherExceptionHandlerResult; -import io.sentry.Hint; -import io.sentry.HubAdapter; -import io.sentry.IHub; -import io.sentry.SentryIntegrationPackageStorage; -import io.sentry.util.Objects; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.ExecutionException; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; - -/** - * Captures exceptions that occur during data fetching, passes them to Sentry and invokes a delegate - * exception handler. - * - * @deprecated please use {@link SentryGenericDataFetcherExceptionHandler} in combination with - * {@link SentryInstrumentation} instead for better error reporting. - */ -@Deprecated -public final class SentryDataFetcherExceptionHandler implements DataFetcherExceptionHandler { - private final @NotNull IHub hub; - private final @NotNull DataFetcherExceptionHandler delegate; - - public SentryDataFetcherExceptionHandler( - final @NotNull IHub hub, final @NotNull DataFetcherExceptionHandler delegate) { - this.hub = Objects.requireNonNull(hub, "hub is required"); - this.delegate = Objects.requireNonNull(delegate, "delegate is required"); - SentryIntegrationPackageStorage.getInstance().addIntegration("GrahQLLegacyExceptionHandler"); - } - - public SentryDataFetcherExceptionHandler(final @NotNull DataFetcherExceptionHandler delegate) { - this(HubAdapter.getInstance(), delegate); - } - - @Override - public CompletableFuture handleException( - DataFetcherExceptionHandlerParameters handlerParameters) { - final Hint hint = new Hint(); - hint.set(GRAPHQL_HANDLER_PARAMETERS, handlerParameters); - - hub.captureException(handlerParameters.getException(), hint); - return delegate.handleException(handlerParameters); - } - - @SuppressWarnings("deprecation") - public DataFetcherExceptionHandlerResult onException( - final @NotNull DataFetcherExceptionHandlerParameters handlerParameters) { - final @Nullable CompletableFuture futureResult = - handleException(handlerParameters); - - if (futureResult != null) { - try { - return futureResult.get(); - } catch (InterruptedException | ExecutionException e) { - return DataFetcherExceptionHandlerResult.newResult().build(); - } - } else { - return DataFetcherExceptionHandlerResult.newResult().build(); - } - } -} diff --git a/sentry-graphql/src/main/java/io/sentry/graphql/SentryInstrumentation.java b/sentry-graphql/src/main/java/io/sentry/graphql/SentryInstrumentation.java index d2d62c99d8..db2982e080 100644 --- a/sentry-graphql/src/main/java/io/sentry/graphql/SentryInstrumentation.java +++ b/sentry-graphql/src/main/java/io/sentry/graphql/SentryInstrumentation.java @@ -1,37 +1,16 @@ package io.sentry.graphql; -import graphql.ErrorClassification; import graphql.ExecutionResult; -import graphql.GraphQLContext; -import graphql.GraphQLError; -import graphql.execution.ExecutionContext; -import graphql.execution.ExecutionStepInfo; import graphql.execution.instrumentation.InstrumentationContext; import graphql.execution.instrumentation.InstrumentationState; import graphql.execution.instrumentation.parameters.InstrumentationExecuteOperationParameters; import graphql.execution.instrumentation.parameters.InstrumentationExecutionParameters; import graphql.execution.instrumentation.parameters.InstrumentationFieldFetchParameters; -import graphql.language.OperationDefinition; import graphql.schema.DataFetcher; -import graphql.schema.DataFetchingEnvironment; -import graphql.schema.GraphQLNonNull; -import graphql.schema.GraphQLObjectType; -import graphql.schema.GraphQLOutputType; -import io.sentry.Breadcrumb; -import io.sentry.IHub; -import io.sentry.ISpan; -import io.sentry.NoOpHub; -import io.sentry.Sentry; import io.sentry.SentryIntegrationPackageStorage; -import io.sentry.SpanStatus; -import io.sentry.util.StringUtils; import java.util.ArrayList; -import java.util.Arrays; import java.util.List; -import java.util.Locale; -import java.util.Map; import java.util.concurrent.CompletableFuture; -import java.util.concurrent.CopyOnWriteArrayList; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import org.jetbrains.annotations.TestOnly; @@ -40,58 +19,22 @@ public final class SentryInstrumentation extends graphql.execution.instrumentation.SimpleInstrumentation { - private static final @NotNull List ERROR_TYPES_HANDLED_BY_DATA_FETCHERS = - Arrays.asList( - "INTERNAL_ERROR", // spring-graphql - "INTERNAL", // Netflix DGS - "DataFetchingException" // raw graphql-java - ); - public static final @NotNull String SENTRY_HUB_CONTEXT_KEY = "sentry.hub"; - public static final @NotNull String SENTRY_EXCEPTIONS_CONTEXT_KEY = "sentry.exceptions"; - private static final String TRACE_ORIGIN = "auto.graphql.graphql"; - private final @Nullable BeforeSpanCallback beforeSpan; - private final @NotNull SentrySubscriptionHandler subscriptionHandler; - - private final @NotNull ExceptionReporter exceptionReporter; - - private final @NotNull List ignoredErrorTypes; - - /** - * @deprecated please use a constructor that takes a {@link SentrySubscriptionHandler} instead. - */ - @Deprecated - @SuppressWarnings("InlineMeSuggester") - public SentryInstrumentation() { - this(null, NoOpSubscriptionHandler.getInstance(), true); - } - /** - * @deprecated please use a constructor that takes a {@link SentrySubscriptionHandler} instead. + * @deprecated please use {@link SentryGraphqlInstrumentation#SENTRY_SCOPES_CONTEXT_KEY} */ @Deprecated - @SuppressWarnings("InlineMeSuggester") - public SentryInstrumentation(final @Nullable IHub hub) { - this(null, NoOpSubscriptionHandler.getInstance(), true); - } + public static final @NotNull String SENTRY_SCOPES_CONTEXT_KEY = + SentryGraphqlInstrumentation.SENTRY_SCOPES_CONTEXT_KEY; /** - * @deprecated please use a constructor that takes a {@link SentrySubscriptionHandler} instead. + * @deprecated please use {@link SentryGraphqlInstrumentation#SENTRY_EXCEPTIONS_CONTEXT_KEY} */ @Deprecated - @SuppressWarnings("InlineMeSuggester") - public SentryInstrumentation(final @Nullable BeforeSpanCallback beforeSpan) { - this(beforeSpan, NoOpSubscriptionHandler.getInstance(), true); - } + public static final @NotNull String SENTRY_EXCEPTIONS_CONTEXT_KEY = + SentryGraphqlInstrumentation.SENTRY_EXCEPTIONS_CONTEXT_KEY; - /** - * @deprecated please use a constructor that takes a {@link SentrySubscriptionHandler} instead. - */ - @Deprecated - @SuppressWarnings("InlineMeSuggester") - public SentryInstrumentation( - final @Nullable IHub hub, final @Nullable BeforeSpanCallback beforeSpan) { - this(beforeSpan, NoOpSubscriptionHandler.getInstance(), true); - } + private static final String TRACE_ORIGIN = "auto.graphql.graphql"; + private final @NotNull SentryGraphqlInstrumentation instrumentation; /** * @param beforeSpan callback when a span is created @@ -102,7 +45,7 @@ public SentryInstrumentation( * case with our spring integration for WebMVC. */ public SentryInstrumentation( - final @Nullable BeforeSpanCallback beforeSpan, + final @Nullable SentryGraphqlInstrumentation.BeforeSpanCallback beforeSpan, final @NotNull SentrySubscriptionHandler subscriptionHandler, final boolean captureRequestBodyForNonSubscriptions) { this( @@ -122,7 +65,7 @@ public SentryInstrumentation( * @param ignoredErrorTypes list of error types that should not be captured and sent to Sentry */ public SentryInstrumentation( - final @Nullable BeforeSpanCallback beforeSpan, + final @Nullable SentryGraphqlInstrumentation.BeforeSpanCallback beforeSpan, final @NotNull SentrySubscriptionHandler subscriptionHandler, final boolean captureRequestBodyForNonSubscriptions, final @NotNull List ignoredErrorTypes) { @@ -135,14 +78,13 @@ public SentryInstrumentation( @TestOnly public SentryInstrumentation( - final @Nullable BeforeSpanCallback beforeSpan, + final @Nullable SentryGraphqlInstrumentation.BeforeSpanCallback beforeSpan, final @NotNull SentrySubscriptionHandler subscriptionHandler, final @NotNull ExceptionReporter exceptionReporter, final @NotNull List ignoredErrorTypes) { - this.beforeSpan = beforeSpan; - this.subscriptionHandler = subscriptionHandler; - this.exceptionReporter = exceptionReporter; - this.ignoredErrorTypes = ignoredErrorTypes; + this.instrumentation = + new SentryGraphqlInstrumentation( + beforeSpan, subscriptionHandler, exceptionReporter, ignoredErrorTypes, TRACE_ORIGIN); SentryIntegrationPackageStorage.getInstance().addIntegration("GraphQL"); SentryIntegrationPackageStorage.getInstance() .addPackage("maven:io.sentry:sentry-graphql", BuildConfig.VERSION_NAME); @@ -162,261 +104,50 @@ public SentryInstrumentation( } @Override - @SuppressWarnings("deprecation") public @NotNull InstrumentationState createState() { - return new TracingState(); + return instrumentation.createState(); } @Override - @SuppressWarnings("deprecation") public @NotNull InstrumentationContext beginExecution( final @NotNull InstrumentationExecutionParameters parameters) { - final TracingState tracingState = parameters.getInstrumentationState(); - final @NotNull IHub currentHub = Sentry.getCurrentHub(); - tracingState.setTransaction(currentHub.getSpan()); - parameters.getGraphQLContext().put(SENTRY_HUB_CONTEXT_KEY, currentHub); + final SentryGraphqlInstrumentation.TracingState tracingState = + parameters.getInstrumentationState(); + instrumentation.beginExecution(parameters, tracingState); return super.beginExecution(parameters); } @Override - @SuppressWarnings("deprecation") public CompletableFuture instrumentExecutionResult( ExecutionResult executionResult, InstrumentationExecutionParameters parameters) { return super.instrumentExecutionResult(executionResult, parameters) .whenComplete( (result, exception) -> { - if (result != null) { - final @Nullable GraphQLContext graphQLContext = parameters.getGraphQLContext(); - if (graphQLContext != null) { - final @NotNull List exceptions = - graphQLContext.getOrDefault( - SENTRY_EXCEPTIONS_CONTEXT_KEY, new CopyOnWriteArrayList()); - for (Throwable throwable : exceptions) { - exceptionReporter.captureThrowable( - throwable, - new ExceptionReporter.ExceptionDetails( - hubFromContext(graphQLContext), parameters, false), - result); - } - } - final @NotNull List errors = result.getErrors(); - if (errors != null) { - for (GraphQLError error : errors) { - String errorType = getErrorType(error); - if (!isIgnored(errorType)) { - exceptionReporter.captureThrowable( - new RuntimeException(error.getMessage()), - new ExceptionReporter.ExceptionDetails( - hubFromContext(graphQLContext), parameters, false), - result); - } - } - } - } - if (exception != null) { - exceptionReporter.captureThrowable( - exception, - new ExceptionReporter.ExceptionDetails( - hubFromContext(parameters.getGraphQLContext()), parameters, false), - null); - } + instrumentation.instrumentExecutionResultComplete(parameters, result, exception); }); } - private boolean isIgnored(final @Nullable String errorType) { - if (errorType == null) { - return false; - } - - // not capturing INTERNAL_ERRORS as they should be reported via graphQlContext above - // also not capturing error types explicitly ignored by users - return ERROR_TYPES_HANDLED_BY_DATA_FETCHERS.contains(errorType) - || ignoredErrorTypes.contains(errorType); - } - - private @Nullable String getErrorType(final @Nullable GraphQLError error) { - if (error == null) { - return null; - } - final @Nullable ErrorClassification errorType = error.getErrorType(); - if (errorType != null) { - return errorType.toString(); - } - final @Nullable Map extensions = error.getExtensions(); - if (extensions != null) { - return StringUtils.toString(extensions.get("errorType")); - } - return null; - } - @Override - @SuppressWarnings("deprecation") public @NotNull InstrumentationContext beginExecuteOperation( final @NotNull InstrumentationExecuteOperationParameters parameters) { - final @Nullable ExecutionContext executionContext = parameters.getExecutionContext(); - if (executionContext != null) { - final @Nullable OperationDefinition operationDefinition = - executionContext.getOperationDefinition(); - if (operationDefinition != null) { - final @Nullable OperationDefinition.Operation operation = - operationDefinition.getOperation(); - final @Nullable String operationType = - operation == null ? null : operation.name().toLowerCase(Locale.ROOT); - hubFromContext(parameters.getExecutionContext().getGraphQLContext()) - .addBreadcrumb( - Breadcrumb.graphqlOperation( - operationDefinition.getName(), - operationType, - StringUtils.toString(executionContext.getExecutionId()))); - } - } + instrumentation.beginExecuteOperation(parameters); return super.beginExecuteOperation(parameters); } - private @NotNull IHub hubFromContext(final @Nullable GraphQLContext context) { - if (context == null) { - return NoOpHub.getInstance(); - } - return context.getOrDefault(SENTRY_HUB_CONTEXT_KEY, NoOpHub.getInstance()); - } - @Override @SuppressWarnings({"FutureReturnValueIgnored", "deprecation"}) public @NotNull DataFetcher instrumentDataFetcher( final @NotNull DataFetcher dataFetcher, final @NotNull InstrumentationFieldFetchParameters parameters) { - // We only care about user code - if (parameters.isTrivialDataFetcher()) { - return dataFetcher; - } - - return environment -> { - final @Nullable ExecutionStepInfo executionStepInfo = environment.getExecutionStepInfo(); - if (executionStepInfo != null) { - hubFromContext(parameters.getExecutionContext().getGraphQLContext()) - .addBreadcrumb( - Breadcrumb.graphqlDataFetcher( - StringUtils.toString(executionStepInfo.getPath()), - GraphqlStringUtils.fieldToString(executionStepInfo.getField()), - GraphqlStringUtils.typeToString(executionStepInfo.getType()), - GraphqlStringUtils.objectTypeToString(executionStepInfo.getObjectType()))); - } - final TracingState tracingState = parameters.getInstrumentationState(); - final ISpan transaction = tracingState.getTransaction(); - if (transaction != null) { - final ISpan span = createSpan(transaction, parameters); - try { - final @Nullable Object tmpResult = dataFetcher.get(environment); - final @Nullable Object result = - maybeCallSubscriptionHandler(parameters, environment, tmpResult); - if (result instanceof CompletableFuture) { - ((CompletableFuture) result) - .whenComplete( - (r, ex) -> { - if (ex != null) { - span.setThrowable(ex); - span.setStatus(SpanStatus.INTERNAL_ERROR); - } else { - span.setStatus(SpanStatus.OK); - } - finish(span, environment, r); - }); - } else { - span.setStatus(SpanStatus.OK); - finish(span, environment, result); - } - return result; - } catch (Throwable e) { - span.setThrowable(e); - span.setStatus(SpanStatus.INTERNAL_ERROR); - finish(span, environment); - throw e; - } - } else { - final Object result = dataFetcher.get(environment); - return maybeCallSubscriptionHandler(parameters, environment, result); - } - }; - } - - private @Nullable Object maybeCallSubscriptionHandler( - final @NotNull InstrumentationFieldFetchParameters parameters, - final @NotNull DataFetchingEnvironment environment, - final @Nullable Object tmpResult) { - if (tmpResult == null) { - return null; - } - - if (OperationDefinition.Operation.SUBSCRIPTION.equals( - environment.getOperationDefinition().getOperation())) { - return subscriptionHandler.onSubscriptionResult( - tmpResult, - hubFromContext(environment.getGraphQlContext()), - exceptionReporter, - parameters); - } - - return tmpResult; - } - - private void finish( - final @NotNull ISpan span, - final @NotNull DataFetchingEnvironment environment, - final @Nullable Object result) { - if (beforeSpan != null) { - final ISpan newSpan = beforeSpan.execute(span, environment, result); - if (newSpan == null) { - // span is dropped - span.getSpanContext().setSampled(false); - } else { - newSpan.finish(); - } - } else { - span.finish(); - } - } - - private void finish( - final @NotNull ISpan span, final @NotNull DataFetchingEnvironment environment) { - finish(span, environment, null); - } - - private @NotNull ISpan createSpan( - @NotNull ISpan transaction, @NotNull InstrumentationFieldFetchParameters parameters) { - final GraphQLOutputType type = parameters.getExecutionStepInfo().getParent().getType(); - GraphQLObjectType parent; - if (type instanceof GraphQLNonNull) { - parent = (GraphQLObjectType) ((GraphQLNonNull) type).getWrappedType(); - } else { - parent = (GraphQLObjectType) type; - } - - final @NotNull ISpan span = - transaction.startChild( - "graphql", - parent.getName() + "." + parameters.getExecutionStepInfo().getPath().getSegmentName()); - - span.getSpanContext().setOrigin(TRACE_ORIGIN); - - return span; - } - - static final class TracingState implements InstrumentationState { - private @Nullable ISpan transaction; - - public @Nullable ISpan getTransaction() { - return transaction; - } - - public void setTransaction(final @Nullable ISpan transaction) { - this.transaction = transaction; - } + final SentryGraphqlInstrumentation.TracingState tracingState = + parameters.getInstrumentationState(); + return instrumentation.instrumentDataFetcher(dataFetcher, parameters, tracingState); } + /** + * @deprecated please use {@link SentryGraphqlInstrumentation.BeforeSpanCallback} + */ + @Deprecated @FunctionalInterface - public interface BeforeSpanCallback { - @Nullable - ISpan execute( - @NotNull ISpan span, @NotNull DataFetchingEnvironment environment, @Nullable Object result); - } + public interface BeforeSpanCallback extends SentryGraphqlInstrumentation.BeforeSpanCallback {} } diff --git a/sentry-graphql/src/test/kotlin/io/sentry/graphql/SentryDataFetcherExceptionHandlerTest.kt b/sentry-graphql/src/test/kotlin/io/sentry/graphql/SentryDataFetcherExceptionHandlerTest.kt deleted file mode 100644 index b571fa8218..0000000000 --- a/sentry-graphql/src/test/kotlin/io/sentry/graphql/SentryDataFetcherExceptionHandlerTest.kt +++ /dev/null @@ -1,28 +0,0 @@ -package io.sentry.graphql - -import graphql.execution.DataFetcherExceptionHandler -import graphql.execution.DataFetcherExceptionHandlerParameters -import io.sentry.Hint -import io.sentry.IHub -import org.mockito.kotlin.anyOrNull -import org.mockito.kotlin.eq -import org.mockito.kotlin.mock -import org.mockito.kotlin.verify -import kotlin.test.Test - -class SentryDataFetcherExceptionHandlerTest { - - @Test - fun `passes exception to Sentry and invokes delegate`() { - val hub = mock() - val delegate = mock() - val handler = SentryDataFetcherExceptionHandler(hub, delegate) - - val exception = RuntimeException() - val parameters = DataFetcherExceptionHandlerParameters.newExceptionParameters().exception(exception).build() - handler.onException(parameters) - - verify(hub).captureException(eq(exception), anyOrNull()) - verify(delegate).handleException(parameters) - } -} diff --git a/sentry-graphql/src/test/kotlin/io/sentry/graphql/SentryInstrumentationAnotherTest.kt b/sentry-graphql/src/test/kotlin/io/sentry/graphql/SentryInstrumentationAnotherTest.kt index e30bbc9415..7324c59a79 100644 --- a/sentry-graphql/src/test/kotlin/io/sentry/graphql/SentryInstrumentationAnotherTest.kt +++ b/sentry-graphql/src/test/kotlin/io/sentry/graphql/SentryInstrumentationAnotherTest.kt @@ -28,14 +28,14 @@ import graphql.schema.GraphQLObjectType import graphql.schema.GraphQLScalarType import graphql.schema.GraphQLSchema import io.sentry.Breadcrumb -import io.sentry.IHub +import io.sentry.Hint +import io.sentry.IScopes import io.sentry.Sentry import io.sentry.SentryOptions import io.sentry.SentryTracer import io.sentry.TransactionContext +import io.sentry.TypeCheckHint import io.sentry.graphql.ExceptionReporter.ExceptionDetails -import io.sentry.graphql.SentryInstrumentation.SENTRY_EXCEPTIONS_CONTEXT_KEY -import io.sentry.graphql.SentryInstrumentation.TracingState import org.mockito.Mockito import org.mockito.kotlin.any import org.mockito.kotlin.eq @@ -52,7 +52,7 @@ import kotlin.test.assertSame class SentryInstrumentationAnotherTest { class Fixture { - val hub = mock() + val scopes = mock() lateinit var activeSpan: SentryTracer lateinit var dataFetcher: DataFetcher lateinit var fieldFetchParameters: InstrumentationFieldFetchParameters @@ -64,23 +64,23 @@ class SentryInstrumentationAnotherTest { lateinit var graphQLContext: GraphQLContext lateinit var subscriptionHandler: SentrySubscriptionHandler lateinit var exceptionReporter: ExceptionReporter - internal lateinit var instrumentationState: TracingState + internal lateinit var instrumentationState: SentryGraphqlInstrumentation.TracingState lateinit var instrumentationExecuteOperationParameters: InstrumentationExecuteOperationParameters val query = """query greeting(name: "somename")""" val variables = mapOf("variableA" to "value a") fun getSut(isTransactionActive: Boolean = true, operation: OperationDefinition.Operation = OperationDefinition.Operation.QUERY, graphQLContextParam: Map? = null, addTransactionToTracingState: Boolean = true, ignoredErrors: List = emptyList()): SentryInstrumentation { - whenever(hub.options).thenReturn(SentryOptions()) - activeSpan = SentryTracer(TransactionContext("name", "op"), hub) + whenever(scopes.options).thenReturn(SentryOptions()) + activeSpan = SentryTracer(TransactionContext("name", "op"), scopes) if (isTransactionActive) { - whenever(hub.span).thenReturn(activeSpan) + whenever(scopes.span).thenReturn(activeSpan) } else { - whenever(hub.span).thenReturn(null) + whenever(scopes.span).thenReturn(null) } val defaultGraphQLContext = mapOf( - SentryInstrumentation.SENTRY_HUB_CONTEXT_KEY to hub + SentryGraphqlInstrumentation.SENTRY_SCOPES_CONTEXT_KEY to scopes ) val mergedField = MergedField.newMergedField().addField(Field.newField("myFieldName").build()).build() @@ -126,7 +126,7 @@ class SentryInstrumentationAnotherTest { .fields(MergedSelectionSet.newMergedSelectionSet().build()) .field(mergedField) .build() - instrumentationState = SentryInstrumentation.TracingState().also { + instrumentationState = SentryGraphqlInstrumentation.TracingState().also { if (isTransactionActive && addTransactionToTracingState) { it.transaction = activeSpan } @@ -165,7 +165,7 @@ class SentryInstrumentationAnotherTest { val result = instrumentedDataFetcher.get(fixture.environment) assertEquals("result modified by subscription handler", result) - verify(fixture.subscriptionHandler).onSubscriptionResult(eq("raw result"), same(fixture.hub), same(fixture.exceptionReporter), same(fixture.fieldFetchParameters)) + verify(fixture.subscriptionHandler).onSubscriptionResult(eq("raw result"), same(fixture.scopes), same(fixture.exceptionReporter), same(fixture.fieldFetchParameters)) } @Test @@ -175,7 +175,7 @@ class SentryInstrumentationAnotherTest { val result = instrumentedDataFetcher.get(fixture.environment) assertEquals("result modified by subscription handler", result) - verify(fixture.subscriptionHandler).onSubscriptionResult(eq("raw result"), same(fixture.hub), same(fixture.exceptionReporter), same(fixture.fieldFetchParameters)) + verify(fixture.subscriptionHandler).onSubscriptionResult(eq("raw result"), same(fixture.scopes), same(fixture.exceptionReporter), same(fixture.fieldFetchParameters)) } @Test @@ -222,7 +222,7 @@ class SentryInstrumentationAnotherTest { fun `adds a breadcrumb for operation`() { val instrumentation = fixture.getSut() instrumentation.beginExecuteOperation(fixture.instrumentationExecuteOperationParameters) - verify(fixture.hub).addBreadcrumb( + verify(fixture.scopes).addBreadcrumb( org.mockito.kotlin.check { breadcrumb -> assertEquals("graphql", breadcrumb.type) assertEquals("query", breadcrumb.category) @@ -237,7 +237,7 @@ class SentryInstrumentationAnotherTest { fun `adds a breadcrumb for data fetcher`() { val instrumentation = fixture.getSut() instrumentation.instrumentDataFetcher(fixture.dataFetcher, fixture.fieldFetchParameters).get(fixture.environment) - verify(fixture.hub).addBreadcrumb( + verify(fixture.scopes).addBreadcrumb( org.mockito.kotlin.check { breadcrumb -> assertEquals("graphql", breadcrumb.type) assertEquals("graphql.fetcher", breadcrumb.category) @@ -245,16 +245,20 @@ class SentryInstrumentationAnotherTest { assertEquals("myFieldName", breadcrumb.data["field"]) assertEquals("MyResponseType", breadcrumb.data["type"]) assertEquals("QUERY", breadcrumb.data["object_type"]) + }, + org.mockito.kotlin.check { hint -> + val environment = hint.getAs(TypeCheckHint.GRAPHQL_DATA_FETCHING_ENVIRONMENT, DataFetchingEnvironment::class.java) + assertNotNull(environment) } ) } @Test - fun `stores hub in context and adds transaction to state`() { + fun `stores scopes in context and adds transaction to state`() { val instrumentation = fixture.getSut(isTransactionActive = true, operation = OperationDefinition.Operation.MUTATION, graphQLContextParam = emptyMap(), addTransactionToTracingState = false) - withMockHub { + withMockScopes { instrumentation.beginExecution(fixture.instrumentationExecutionParameters) - assertSame(fixture.hub, fixture.instrumentationExecutionParameters.graphQLContext.get(SentryInstrumentation.SENTRY_HUB_CONTEXT_KEY)) + assertSame(fixture.scopes, fixture.instrumentationExecutionParameters.graphQLContext.get(SentryGraphqlInstrumentation.SENTRY_SCOPES_CONTEXT_KEY)) assertNotNull(fixture.instrumentationState.transaction) } } @@ -276,7 +280,7 @@ class SentryInstrumentationAnotherTest { assertEquals("exception message", it.message) }, org.mockito.kotlin.check { - assertSame(fixture.hub, it.hub) + assertSame(fixture.scopes, it.scopes) assertSame(fixture.query, it.query) assertEquals(false, it.isSubscription) assertEquals(fixture.variables, it.variables) @@ -292,8 +296,8 @@ class SentryInstrumentationAnotherTest { val exception = IllegalStateException("some exception") val instrumentation = fixture.getSut( graphQLContextParam = mapOf( - SENTRY_EXCEPTIONS_CONTEXT_KEY to listOf(exception), - SentryInstrumentation.SENTRY_HUB_CONTEXT_KEY to fixture.hub + SentryGraphqlInstrumentation.SENTRY_EXCEPTIONS_CONTEXT_KEY to listOf(exception), + SentryGraphqlInstrumentation.SENTRY_SCOPES_CONTEXT_KEY to fixture.scopes ) ) val executionResult = ExecutionResultImpl.newExecutionResult() @@ -305,7 +309,7 @@ class SentryInstrumentationAnotherTest { assertSame(exception, it) }, org.mockito.kotlin.check { - assertSame(fixture.hub, it.hub) + assertSame(fixture.scopes, it.scopes) assertSame(fixture.query, it.query) assertEquals(false, it.isSubscription) assertEquals(fixture.variables, it.variables) @@ -356,8 +360,8 @@ class SentryInstrumentationAnotherTest { assertSame(executionResult, result) } - fun withMockHub(closure: () -> Unit) = Mockito.mockStatic(Sentry::class.java).use { - it.`when` { Sentry.getCurrentHub() }.thenReturn(fixture.hub) + fun withMockScopes(closure: () -> Unit) = Mockito.mockStatic(Sentry::class.java).use { + it.`when` { Sentry.getCurrentScopes() }.thenReturn(fixture.scopes) closure.invoke() } diff --git a/sentry-graphql/src/test/kotlin/io/sentry/graphql/SentryInstrumentationTest.kt b/sentry-graphql/src/test/kotlin/io/sentry/graphql/SentryInstrumentationTest.kt index 8a579e2687..c8d63a1e98 100644 --- a/sentry-graphql/src/test/kotlin/io/sentry/graphql/SentryInstrumentationTest.kt +++ b/sentry-graphql/src/test/kotlin/io/sentry/graphql/SentryInstrumentationTest.kt @@ -18,7 +18,7 @@ import graphql.schema.GraphQLScalarType import graphql.schema.idl.RuntimeWiring import graphql.schema.idl.SchemaGenerator import graphql.schema.idl.SchemaParser -import io.sentry.IHub +import io.sentry.IScopes import io.sentry.Sentry import io.sentry.SentryOptions import io.sentry.SentryTracer @@ -39,12 +39,12 @@ import kotlin.test.assertTrue class SentryInstrumentationTest { class Fixture { - val hub = mock() + val scopes = mock() lateinit var activeSpan: SentryTracer - fun getSut(isTransactionActive: Boolean = true, dataFetcherThrows: Boolean = false, beforeSpan: SentryInstrumentation.BeforeSpanCallback? = null): GraphQL { - whenever(hub.options).thenReturn(SentryOptions()) - activeSpan = SentryTracer(TransactionContext("name", "op"), hub) + fun getSut(isTransactionActive: Boolean = true, dataFetcherThrows: Boolean = false, beforeSpan: SentryGraphqlInstrumentation.BeforeSpanCallback? = null): GraphQL { + whenever(scopes.options).thenReturn(SentryOptions()) + activeSpan = SentryTracer(TransactionContext("name", "op"), scopes) val schema = """ type Query { shows: [Show] @@ -61,9 +61,9 @@ class SentryInstrumentationTest { .build() if (isTransactionActive) { - whenever(hub.span).thenReturn(activeSpan) + whenever(scopes.span).thenReturn(activeSpan) } else { - whenever(hub.span).thenReturn(null) + whenever(scopes.span).thenReturn(null) } return graphQL @@ -87,7 +87,7 @@ class SentryInstrumentationTest { fun `when transaction is active, creates inner spans`() { val sut = fixture.getSut() - withMockHub { + withMockScopes { val result = sut.execute("{ shows { id } }") assertTrue(result.errors.isEmpty()) @@ -105,7 +105,7 @@ class SentryInstrumentationTest { fun `when transaction is active, and data fetcher throws, creates inner spans`() { val sut = fixture.getSut(dataFetcherThrows = true) - withMockHub { + withMockScopes { val result = sut.execute("{ shows { id } }") assertTrue(result.errors.isNotEmpty()) @@ -122,7 +122,7 @@ class SentryInstrumentationTest { fun `when transaction is not active, does not create spans`() { val sut = fixture.getSut(isTransactionActive = false) - withMockHub { + withMockScopes { val result = sut.execute("{ shows { id } }") assertTrue(result.errors.isEmpty()) @@ -132,9 +132,9 @@ class SentryInstrumentationTest { @Test fun `beforeSpan can drop spans`() { - val sut = fixture.getSut(beforeSpan = SentryInstrumentation.BeforeSpanCallback { _, _, _ -> null }) + val sut = fixture.getSut(beforeSpan = SentryGraphqlInstrumentation.BeforeSpanCallback { _, _, _ -> null }) - withMockHub { + withMockScopes { val result = sut.execute("{ shows { id } }") assertTrue(result.errors.isEmpty()) @@ -150,9 +150,9 @@ class SentryInstrumentationTest { @Test fun `beforeSpan can modify spans`() { - val sut = fixture.getSut(beforeSpan = SentryInstrumentation.BeforeSpanCallback { span, _, _ -> span.apply { description = "changed" } }) + val sut = fixture.getSut(beforeSpan = SentryGraphqlInstrumentation.BeforeSpanCallback { span, _, _ -> span.apply { description = "changed" } }) - withMockHub { + withMockScopes { val result = sut.execute("{ shows { id } }") assertTrue(result.errors.isEmpty()) @@ -198,7 +198,7 @@ class SentryInstrumentationTest { environment, executionStrategyParameters, false - ).withNewState(SentryInstrumentation.TracingState()) + ).withNewState(SentryGraphqlInstrumentation.TracingState()) val instrumentedDataFetcher = instrumentation.instrumentDataFetcher(dataFetcher, parameters) val result = instrumentedDataFetcher.get(environment) @@ -208,19 +208,19 @@ class SentryInstrumentationTest { @Test fun `Integration adds itself to integration and package list`() { - withMockHub { + withMockScopes { val sut = fixture.getSut() - assertNotNull(fixture.hub.options.sdkVersion) - assert(fixture.hub.options.sdkVersion!!.integrationSet.contains("GraphQL")) + assertNotNull(fixture.scopes.options.sdkVersion) + assert(fixture.scopes.options.sdkVersion!!.integrationSet.contains("GraphQL")) val packageInfo = - fixture.hub.options.sdkVersion!!.packageSet.firstOrNull { pkg -> pkg.name == "maven:io.sentry:sentry-graphql" } + fixture.scopes.options.sdkVersion!!.packageSet.firstOrNull { pkg -> pkg.name == "maven:io.sentry:sentry-graphql" } assertNotNull(packageInfo) assert(packageInfo.version == BuildConfig.VERSION_NAME) } } - fun withMockHub(closure: () -> Unit) = Mockito.mockStatic(Sentry::class.java).use { - it.`when` { Sentry.getCurrentHub() }.thenReturn(fixture.hub) + fun withMockScopes(closure: () -> Unit) = Mockito.mockStatic(Sentry::class.java).use { + it.`when` { Sentry.getCurrentScopes() }.thenReturn(fixture.scopes) closure.invoke() } diff --git a/sentry-jdbc/api/sentry-jdbc.api b/sentry-jdbc/api/sentry-jdbc.api index cff0f37fd2..dba5791f80 100644 --- a/sentry-jdbc/api/sentry-jdbc.api +++ b/sentry-jdbc/api/sentry-jdbc.api @@ -15,8 +15,9 @@ public final class io/sentry/jdbc/DatabaseUtils$DatabaseDetails { } public class io/sentry/jdbc/SentryJdbcEventListener : com/p6spy/engine/event/SimpleJdbcEventListener { + protected final field databaseDetailsLock Lio/sentry/util/AutoClosableReentrantLock; public fun ()V - public fun (Lio/sentry/IHub;)V + public fun (Lio/sentry/IScopes;)V public fun onAfterAnyExecute (Lcom/p6spy/engine/common/StatementInformation;JLjava/sql/SQLException;)V public fun onBeforeAnyExecute (Lcom/p6spy/engine/common/StatementInformation;)V } diff --git a/sentry-jdbc/src/main/java/io/sentry/jdbc/SentryJdbcEventListener.java b/sentry-jdbc/src/main/java/io/sentry/jdbc/SentryJdbcEventListener.java index 0346d2d0b9..8a57e1eeca 100644 --- a/sentry-jdbc/src/main/java/io/sentry/jdbc/SentryJdbcEventListener.java +++ b/sentry-jdbc/src/main/java/io/sentry/jdbc/SentryJdbcEventListener.java @@ -6,12 +6,15 @@ import com.jakewharton.nopen.annotation.Open; import com.p6spy.engine.common.StatementInformation; import com.p6spy.engine.event.SimpleJdbcEventListener; -import io.sentry.HubAdapter; -import io.sentry.IHub; +import io.sentry.IScopes; +import io.sentry.ISentryLifecycleToken; import io.sentry.ISpan; +import io.sentry.ScopesAdapter; import io.sentry.SentryIntegrationPackageStorage; import io.sentry.Span; +import io.sentry.SpanOptions; import io.sentry.SpanStatus; +import io.sentry.util.AutoClosableReentrantLock; import io.sentry.util.Objects; import java.sql.SQLException; import org.jetbrains.annotations.NotNull; @@ -21,28 +24,30 @@ @Open public class SentryJdbcEventListener extends SimpleJdbcEventListener { private static final String TRACE_ORIGIN = "auto.db.jdbc"; - private final @NotNull IHub hub; + private final @NotNull IScopes scopes; private static final @NotNull ThreadLocal CURRENT_SPAN = new ThreadLocal<>(); private volatile @Nullable DatabaseUtils.DatabaseDetails cachedDatabaseDetails = null; - private final @NotNull Object databaseDetailsLock = new Object(); + protected final @NotNull AutoClosableReentrantLock databaseDetailsLock = + new AutoClosableReentrantLock(); - public SentryJdbcEventListener(final @NotNull IHub hub) { - this.hub = Objects.requireNonNull(hub, "hub is required"); + public SentryJdbcEventListener(final @NotNull IScopes scopes) { + this.scopes = Objects.requireNonNull(scopes, "scopes are required"); addPackageAndIntegrationInfo(); } public SentryJdbcEventListener() { - this(HubAdapter.getInstance()); + this(ScopesAdapter.getInstance()); } @Override public void onBeforeAnyExecute(final @NotNull StatementInformation statementInformation) { - final ISpan parent = hub.getSpan(); + final ISpan parent = scopes.getSpan(); if (parent != null && !parent.isNoOp()) { - final ISpan span = parent.startChild("db.query", statementInformation.getSql()); + final @NotNull SpanOptions spanOptions = new SpanOptions(); + spanOptions.setOrigin(TRACE_ORIGIN); + final ISpan span = parent.startChild("db.query", statementInformation.getSql(), spanOptions); CURRENT_SPAN.set(span); - span.getSpanContext().setOrigin(TRACE_ORIGIN); } } @@ -90,7 +95,7 @@ private void applyDatabaseDetailsToSpan( private @NotNull DatabaseUtils.DatabaseDetails getOrComputeDatabaseDetails( final @NotNull StatementInformation statementInformation) { if (cachedDatabaseDetails == null) { - synchronized (databaseDetailsLock) { + try (final @NotNull ISentryLifecycleToken ignored = databaseDetailsLock.acquire()) { if (cachedDatabaseDetails == null) { cachedDatabaseDetails = DatabaseUtils.readFrom(statementInformation); } diff --git a/sentry-jdbc/src/test/kotlin/io/sentry/jdbc/SentryJdbcEventListenerTest.kt b/sentry-jdbc/src/test/kotlin/io/sentry/jdbc/SentryJdbcEventListenerTest.kt index 78c5d4cf12..00ce03de41 100644 --- a/sentry-jdbc/src/test/kotlin/io/sentry/jdbc/SentryJdbcEventListenerTest.kt +++ b/sentry-jdbc/src/test/kotlin/io/sentry/jdbc/SentryJdbcEventListenerTest.kt @@ -2,7 +2,7 @@ package io.sentry.jdbc import com.p6spy.engine.common.StatementInformation import com.p6spy.engine.spy.P6DataSource -import io.sentry.IHub +import io.sentry.IScopes import io.sentry.SentryOptions import io.sentry.SentryTracer import io.sentry.SpanDataConvention.DB_NAME_KEY @@ -26,7 +26,7 @@ import kotlin.test.assertTrue class SentryJdbcEventListenerTest { class Fixture { - val hub = mock().apply { + val scopes = mock().apply { whenever(options).thenReturn( SentryOptions().apply { sdkVersion = SdkVersion("test", "1.2.3") @@ -37,9 +37,9 @@ class SentryJdbcEventListenerTest { val actualDataSource = JDBCDataSource() fun getSut(withRunningTransaction: Boolean = true, existingRow: Int? = null): DataSource { - tx = SentryTracer(TransactionContext("name", "op"), hub) + tx = SentryTracer(TransactionContext("name", "op"), scopes) if (withRunningTransaction) { - whenever(hub.span).thenReturn(tx) + whenever(scopes.span).thenReturn(tx) } actualDataSource.setURL("jdbc:hsqldb:mem:testdb") @@ -54,7 +54,7 @@ class SentryJdbcEventListenerTest { } } - val sentryQueryExecutionListener = SentryJdbcEventListener(hub) + val sentryQueryExecutionListener = SentryJdbcEventListener(scopes) val p6spyDataSource = P6DataSource(actualDataSource) p6spyDataSource.setJdbcEventListenerFactory { sentryQueryExecutionListener } return p6spyDataSource @@ -131,9 +131,9 @@ class SentryJdbcEventListenerTest { @Test fun `sets SDKVersion Info`() { val sut = fixture.getSut() - assertNotNull(fixture.hub.options.sdkVersion) - assert(fixture.hub.options.sdkVersion!!.integrationSet.contains("JDBC")) - val packageInfo = fixture.hub.options.sdkVersion!!.packageSet.firstOrNull { pkg -> pkg.name == "maven:io.sentry:sentry-jdbc" } + assertNotNull(fixture.scopes.options.sdkVersion) + assert(fixture.scopes.options.sdkVersion!!.integrationSet.contains("JDBC")) + val packageInfo = fixture.scopes.options.sdkVersion!!.packageSet.firstOrNull { pkg -> pkg.name == "maven:io.sentry:sentry-jdbc" } assertNotNull(packageInfo) assert(packageInfo.version == BuildConfig.VERSION_NAME) } diff --git a/sentry-jul/src/main/java/io/sentry/jul/SentryHandler.java b/sentry-jul/src/main/java/io/sentry/jul/SentryHandler.java index 2ca775572b..d6afd514e6 100644 --- a/sentry-jul/src/main/java/io/sentry/jul/SentryHandler.java +++ b/sentry-jul/src/main/java/io/sentry/jul/SentryHandler.java @@ -6,7 +6,8 @@ import com.jakewharton.nopen.annotation.Open; import io.sentry.Breadcrumb; import io.sentry.Hint; -import io.sentry.HubAdapter; +import io.sentry.InitPriority; +import io.sentry.ScopesAdapter; import io.sentry.Sentry; import io.sentry.SentryEvent; import io.sentry.SentryIntegrationPackageStorage; @@ -69,11 +70,10 @@ public SentryHandler(final @NotNull SentryOptions options) { if (configureFromLogManager) { retrieveProperties(); } - if (!Sentry.isEnabled()) { - options.setEnableExternalConfiguration(true); - options.setSdkVersion(createSdkVersion(options)); - Sentry.init(options); - } + options.setEnableExternalConfiguration(true); + options.setInitPriority(InitPriority.LOWEST); + options.setSdkVersion(createSdkVersion(options)); + Sentry.init(options); addPackageAndIntegrationInfo(); } @@ -210,9 +210,9 @@ SentryEvent createEvent(final @NotNull LogRecord record) { mdcProperties = CollectionUtils.filterMapEntries(mdcProperties, entry -> entry.getValue() != null); if (!mdcProperties.isEmpty()) { - // get tags from HubAdapter options to allow getting the correct tags if Sentry has been + // get tags from ScopesAdapter options to allow getting the correct tags if Sentry has been // initialized somewhere else - final List contextTags = HubAdapter.getInstance().getOptions().getContextTags(); + final List contextTags = ScopesAdapter.getInstance().getOptions().getContextTags(); if (!contextTags.isEmpty()) { for (final String contextTag : contextTags) { // if mdc tag is listed in SentryOptions, apply as event tag diff --git a/sentry-jul/src/test/kotlin/io/sentry/jul/SentryHandlerTest.kt b/sentry-jul/src/test/kotlin/io/sentry/jul/SentryHandlerTest.kt index eaef25f070..ad53182b80 100644 --- a/sentry-jul/src/test/kotlin/io/sentry/jul/SentryHandlerTest.kt +++ b/sentry-jul/src/test/kotlin/io/sentry/jul/SentryHandlerTest.kt @@ -1,5 +1,6 @@ package io.sentry.jul +import io.sentry.InitPriority import io.sentry.Sentry import io.sentry.SentryLevel import io.sentry.SentryOptions @@ -57,13 +58,14 @@ class SentryHandlerTest { } @Test - fun `does not initialize Sentry if Sentry is already enabled`() { + fun `does not initialize Sentry if Sentry is already enabled with higher prio`() { val transport = mock() Sentry.init { it.dsn = "http://key@localhost/proj" it.environment = "manual-environment" it.setTransportFactory { _, _ -> transport } it.isEnableBackpressureHandling = false + it.initPriority = InitPriority.LOW } fixture = Fixture(transport = transport) fixture.logger.severe("testing environment field") diff --git a/sentry-kotlin-extensions/api/sentry-kotlin-extensions.api b/sentry-kotlin-extensions/api/sentry-kotlin-extensions.api index d501240a3a..7e3be67279 100644 --- a/sentry-kotlin-extensions/api/sentry-kotlin-extensions.api +++ b/sentry-kotlin-extensions/api/sentry-kotlin-extensions.api @@ -1,16 +1,16 @@ public final class io/sentry/kotlin/SentryContext : kotlin/coroutines/AbstractCoroutineContextElement, kotlinx/coroutines/CopyableThreadContextElement { public fun ()V - public fun (Lio/sentry/IHub;)V - public synthetic fun (Lio/sentry/IHub;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Lio/sentry/IScopes;)V + public synthetic fun (Lio/sentry/IScopes;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public fun copyForChild ()Lkotlinx/coroutines/CopyableThreadContextElement; public fun fold (Ljava/lang/Object;Lkotlin/jvm/functions/Function2;)Ljava/lang/Object; public fun get (Lkotlin/coroutines/CoroutineContext$Key;)Lkotlin/coroutines/CoroutineContext$Element; public fun mergeForChild (Lkotlin/coroutines/CoroutineContext$Element;)Lkotlin/coroutines/CoroutineContext; public fun minusKey (Lkotlin/coroutines/CoroutineContext$Key;)Lkotlin/coroutines/CoroutineContext; public fun plus (Lkotlin/coroutines/CoroutineContext;)Lkotlin/coroutines/CoroutineContext; - public fun restoreThreadContext (Lkotlin/coroutines/CoroutineContext;Lio/sentry/IHub;)V + public fun restoreThreadContext (Lkotlin/coroutines/CoroutineContext;Lio/sentry/IScopes;)V public synthetic fun restoreThreadContext (Lkotlin/coroutines/CoroutineContext;Ljava/lang/Object;)V - public fun updateThreadContext (Lkotlin/coroutines/CoroutineContext;)Lio/sentry/IHub; + public fun updateThreadContext (Lkotlin/coroutines/CoroutineContext;)Lio/sentry/IScopes; public synthetic fun updateThreadContext (Lkotlin/coroutines/CoroutineContext;)Ljava/lang/Object; } diff --git a/sentry-kotlin-extensions/src/main/java/io/sentry/kotlin/SentryContext.kt b/sentry-kotlin-extensions/src/main/java/io/sentry/kotlin/SentryContext.kt index 3cf22a20da..a77281a033 100644 --- a/sentry-kotlin-extensions/src/main/java/io/sentry/kotlin/SentryContext.kt +++ b/sentry-kotlin-extensions/src/main/java/io/sentry/kotlin/SentryContext.kt @@ -1,6 +1,6 @@ package io.sentry.kotlin -import io.sentry.IHub +import io.sentry.IScopes import io.sentry.Sentry import kotlinx.coroutines.CopyableThreadContextElement import kotlin.coroutines.AbstractCoroutineContextElement @@ -9,26 +9,28 @@ import kotlin.coroutines.CoroutineContext /** * Sentry context element for [CoroutineContext]. */ -public class SentryContext(private val hub: IHub = Sentry.getCurrentHub().clone()) : - CopyableThreadContextElement, AbstractCoroutineContextElement(Key) { +public class SentryContext(private val scopes: IScopes = Sentry.forkedCurrentScope("coroutine")) : + CopyableThreadContextElement, AbstractCoroutineContextElement(Key) { private companion object Key : CoroutineContext.Key - override fun copyForChild(): CopyableThreadContextElement { - return SentryContext(hub.clone()) + @SuppressWarnings("deprecation") + override fun copyForChild(): CopyableThreadContextElement { + return SentryContext(scopes.forkedCurrentScope("coroutine.child")) } + @SuppressWarnings("deprecation") override fun mergeForChild(overwritingElement: CoroutineContext.Element): CoroutineContext { - return overwritingElement[Key] ?: SentryContext(hub.clone()) + return overwritingElement[Key] ?: SentryContext(scopes.forkedCurrentScope("coroutine.child")) } - override fun updateThreadContext(context: CoroutineContext): IHub { - val oldState = Sentry.getCurrentHub() - Sentry.setCurrentHub(hub) + override fun updateThreadContext(context: CoroutineContext): IScopes { + val oldState = Sentry.getCurrentScopes() + Sentry.setCurrentScopes(scopes) return oldState } - override fun restoreThreadContext(context: CoroutineContext, oldState: IHub) { - Sentry.setCurrentHub(oldState) + override fun restoreThreadContext(context: CoroutineContext, oldState: IScopes) { + Sentry.setCurrentScopes(oldState) } } diff --git a/sentry-kotlin-extensions/src/test/java/io/sentry/kotlin/SentryContextTest.kt b/sentry-kotlin-extensions/src/test/java/io/sentry/kotlin/SentryContextTest.kt index b54ceabc51..9cffd744d8 100644 --- a/sentry-kotlin-extensions/src/test/java/io/sentry/kotlin/SentryContextTest.kt +++ b/sentry-kotlin-extensions/src/test/java/io/sentry/kotlin/SentryContextTest.kt @@ -1,5 +1,6 @@ package io.sentry.kotlin +import io.sentry.ScopeType import io.sentry.Sentry import kotlinx.coroutines.CoroutineName import kotlinx.coroutines.joinAll @@ -15,6 +16,10 @@ import kotlin.test.assertNull class SentryContextTest { + // TODO [HSM] In global hub mode SentryContext behaves differently + // because Sentry.getCurrentScopes always returns rootScopes + // What's the desired behaviour? + @BeforeTest fun init() { Sentry.init("https://key@sentry.io/123") @@ -38,11 +43,40 @@ class SentryContextTest { Sentry.setTag("c2", "c2value") assertEquals("c2value", getTag("c2")) assertEquals("parentValue", getTag("parent")) - assertNull(getTag("c1")) + assertNotNull(getTag("c1")) } listOf(c1, c2).joinAll() - assertNull(getTag("c1")) - assertNull(getTag("c2")) + assertNotNull(getTag("parent")) + assertNotNull(getTag("c1")) + assertNotNull(getTag("c2")) + return@runBlocking + } + + @Test + fun testContextIsNotPassedByDefaultBetweenCoroutinesCurrentScope() = runBlocking { + Sentry.configureScope(ScopeType.CURRENT) { scope -> + scope.setTag("parent", "parentValue") + } + val c1 = launch(SentryContext()) { + Sentry.configureScope(ScopeType.CURRENT) { scope -> + scope.setTag("c1", "c1value") + } + assertEquals("c1value", getTag("c1", ScopeType.CURRENT)) + assertEquals("parentValue", getTag("parent", ScopeType.CURRENT)) + assertNull(getTag("c2", ScopeType.CURRENT)) + } + val c2 = launch(SentryContext()) { + Sentry.configureScope(ScopeType.CURRENT) { scope -> + scope.setTag("c2", "c2value") + } + assertEquals("c2value", getTag("c2", ScopeType.CURRENT)) + assertEquals("parentValue", getTag("parent", ScopeType.CURRENT)) + assertNull(getTag("c1", ScopeType.CURRENT)) + } + listOf(c1, c2).joinAll() + assertNotNull(getTag("parent", ScopeType.CURRENT)) + assertNull(getTag("c1", ScopeType.CURRENT)) + assertNull(getTag("c2", ScopeType.CURRENT)) } @Test @@ -84,7 +118,7 @@ class SentryContextTest { } @Test - fun testContextIsClonedWhenPassedToChild() = runBlocking { + fun testContextIsClonedWhenPassedToChildCurrentScope() = runBlocking { Sentry.setTag("parent", "parentValue") launch(SentryContext()) { Sentry.setTag("c1", "c1value") @@ -102,10 +136,44 @@ class SentryContextTest { c2.join() assertNotNull(getTag("c1")) - assertNull(getTag("c2")) + assertNotNull(getTag("c2")) + }.join() + assertNotNull(getTag("parent")) + assertNotNull(getTag("c1")) + assertNotNull(getTag("c2")) + return@runBlocking + } + + @Test + fun testContextIsClonedWhenPassedToChild() = runBlocking { + Sentry.configureScope(ScopeType.CURRENT) { scope -> + scope.setTag("parent", "parentValue") } - assertNull(getTag("c1")) - assertNull(getTag("c2")) + launch(SentryContext()) { + Sentry.configureScope(ScopeType.CURRENT) { scope -> + scope.setTag("c1", "c1value") + } + assertEquals("c1value", getTag("c1", ScopeType.CURRENT)) + assertEquals("parentValue", getTag("parent", ScopeType.CURRENT)) + assertNull(getTag("c2", ScopeType.CURRENT)) + + val c2 = launch() { + Sentry.configureScope(ScopeType.CURRENT) { scope -> + scope.setTag("c2", "c2value") + } + assertEquals("c2value", getTag("c2", ScopeType.CURRENT)) + assertEquals("parentValue", getTag("parent", ScopeType.CURRENT)) + assertNotNull(getTag("c1", ScopeType.CURRENT)) + } + + c2.join() + + assertNotNull(getTag("c1", ScopeType.CURRENT)) + assertNull(getTag("c2", ScopeType.CURRENT)) + }.join() + assertNotNull(getTag("parent", ScopeType.CURRENT)) + assertNull(getTag("c1", ScopeType.CURRENT)) + assertNull(getTag("c2", ScopeType.CURRENT)) } @Test @@ -119,7 +187,7 @@ class SentryContextTest { val c2 = launch( SentryContext( - Sentry.getCurrentHub().clone().also { + Sentry.getCurrentScopes().forkedScopes("test").also { it.setTag("cloned", "clonedValue") } ) @@ -136,16 +204,60 @@ class SentryContextTest { assertNotNull(getTag("c1")) assertNull(getTag("c2")) assertNull(getTag("cloned")) - } - assertNull(getTag("c1")) + }.join() + + assertNotNull(getTag("c1")) assertNull(getTag("c2")) assertNull(getTag("cloned")) + return@runBlocking + } + + @Test + fun testExplicitlyPassedContextOverridesPropagatedContextCurrentScope() = runBlocking { + Sentry.configureScope(ScopeType.CURRENT) { scope -> + scope.setTag("parent", "parentValue") + } + launch(SentryContext()) { + Sentry.configureScope(ScopeType.CURRENT) { scope -> + scope.setTag("c1", "c1value") + } + assertEquals("c1value", getTag("c1", ScopeType.CURRENT)) + assertEquals("parentValue", getTag("parent", ScopeType.CURRENT)) + assertNull(getTag("c2", ScopeType.CURRENT)) + + val c2 = launch( + SentryContext( + Sentry.getCurrentScopes().forkedCurrentScope("test").also { + it.configureScope(ScopeType.CURRENT) { scope -> + scope.setTag("cloned", "clonedValue") + } + } + ) + ) { + Sentry.configureScope(ScopeType.CURRENT) { scope -> + scope.setTag("c2", "c2value") + } + assertEquals("c2value", getTag("c2", ScopeType.CURRENT)) + assertEquals("parentValue", getTag("parent", ScopeType.CURRENT)) + assertNotNull(getTag("c1", ScopeType.CURRENT)) + assertNotNull(getTag("cloned", ScopeType.CURRENT)) + } + + c2.join() + + assertNotNull(getTag("c1", ScopeType.CURRENT)) + assertNull(getTag("c2", ScopeType.CURRENT)) + assertNull(getTag("cloned", ScopeType.CURRENT)) + } + assertNull(getTag("c1", ScopeType.CURRENT)) + assertNull(getTag("c2", ScopeType.CURRENT)) + assertNull(getTag("cloned", ScopeType.CURRENT)) } @Test fun `mergeForChild returns copy of initial context if Key not present`() { val initialContextElement = SentryContext( - Sentry.getCurrentHub().clone().also { + Sentry.getCurrentScopes().forkedScopes("test").also { it.setTag("cloned", "clonedValue") } ) @@ -158,7 +270,7 @@ class SentryContextTest { @Test fun `mergeForChild returns passed context`() { val initialContextElement = SentryContext( - Sentry.getCurrentHub().clone().also { + Sentry.getCurrentScopes().forkedScopes("test").also { it.setTag("cloned", "clonedValue") } ) @@ -167,9 +279,9 @@ class SentryContextTest { assertEquals(initialContextElement, mergedContextElement) } - private fun getTag(tag: String): String? { + private fun getTag(tag: String, scopeType: ScopeType = ScopeType.ISOLATION): String? { var value: String? = null - Sentry.configureScope { + Sentry.configureScope(scopeType) { value = it.tags[tag] } return value diff --git a/sentry-log4j2/api/sentry-log4j2.api b/sentry-log4j2/api/sentry-log4j2.api index 76aa3e823e..b7fe8b3273 100644 --- a/sentry-log4j2/api/sentry-log4j2.api +++ b/sentry-log4j2/api/sentry-log4j2.api @@ -5,7 +5,7 @@ public final class io/sentry/log4j2/BuildConfig { public class io/sentry/log4j2/SentryAppender : org/apache/logging/log4j/core/appender/AbstractAppender { public static final field MECHANISM_TYPE Ljava/lang/String; - public fun (Ljava/lang/String;Lorg/apache/logging/log4j/core/Filter;Ljava/lang/String;Lorg/apache/logging/log4j/Level;Lorg/apache/logging/log4j/Level;Ljava/lang/Boolean;Lio/sentry/ITransportFactory;Lio/sentry/IHub;[Ljava/lang/String;)V + public fun (Ljava/lang/String;Lorg/apache/logging/log4j/core/Filter;Ljava/lang/String;Lorg/apache/logging/log4j/Level;Lorg/apache/logging/log4j/Level;Ljava/lang/Boolean;Lio/sentry/ITransportFactory;Lio/sentry/IScopes;[Ljava/lang/String;)V public fun append (Lorg/apache/logging/log4j/core/LogEvent;)V public static fun createAppender (Ljava/lang/String;Lorg/apache/logging/log4j/Level;Lorg/apache/logging/log4j/Level;Ljava/lang/String;Ljava/lang/Boolean;Lorg/apache/logging/log4j/core/Filter;Ljava/lang/String;)Lio/sentry/log4j2/SentryAppender; protected fun createBreadcrumb (Lorg/apache/logging/log4j/core/LogEvent;)Lio/sentry/Breadcrumb; diff --git a/sentry-log4j2/src/main/java/io/sentry/log4j2/SentryAppender.java b/sentry-log4j2/src/main/java/io/sentry/log4j2/SentryAppender.java index 4cf4ad4a86..35b9d69419 100644 --- a/sentry-log4j2/src/main/java/io/sentry/log4j2/SentryAppender.java +++ b/sentry-log4j2/src/main/java/io/sentry/log4j2/SentryAppender.java @@ -7,9 +7,10 @@ import io.sentry.Breadcrumb; import io.sentry.DateUtils; import io.sentry.Hint; -import io.sentry.HubAdapter; -import io.sentry.IHub; +import io.sentry.IScopes; import io.sentry.ITransportFactory; +import io.sentry.InitPriority; +import io.sentry.ScopesAdapter; import io.sentry.Sentry; import io.sentry.SentryEvent; import io.sentry.SentryIntegrationPackageStorage; @@ -50,7 +51,7 @@ public class SentryAppender extends AbstractAppender { private @NotNull Level minimumBreadcrumbLevel = Level.INFO; private @NotNull Level minimumEventLevel = Level.ERROR; private final @Nullable Boolean debug; - private final @NotNull IHub hub; + private final @NotNull IScopes scopes; private final @Nullable List contextTags; public SentryAppender( @@ -61,7 +62,7 @@ public SentryAppender( final @Nullable Level minimumEventLevel, final @Nullable Boolean debug, final @Nullable ITransportFactory transportFactory, - final @NotNull IHub hub, + final @NotNull IScopes scopes, final @Nullable String[] contextTags) { super(name, filter, null, true, null); this.dsn = dsn; @@ -73,7 +74,7 @@ public SentryAppender( } this.debug = debug; this.transportFactory = transportFactory; - this.hub = hub; + this.scopes = scopes; this.contextTags = contextTags != null ? Arrays.asList(contextTags) : null; } @@ -110,34 +111,33 @@ public SentryAppender( minimumEventLevel, debug, null, - HubAdapter.getInstance(), + ScopesAdapter.getInstance(), contextTags != null ? contextTags.split(",") : null); } @Override public void start() { - if (!Sentry.isEnabled()) { - try { - Sentry.init( - options -> { - options.setEnableExternalConfiguration(true); - options.setDsn(dsn); - if (debug != null) { - options.setDebug(debug); + try { + Sentry.init( + options -> { + options.setEnableExternalConfiguration(true); + options.setInitPriority(InitPriority.LOWEST); + options.setDsn(dsn); + if (debug != null) { + options.setDebug(debug); + } + options.setSentryClientName( + BuildConfig.SENTRY_LOG4J2_SDK_NAME + "/" + BuildConfig.VERSION_NAME); + options.setSdkVersion(createSdkVersion(options)); + if (contextTags != null) { + for (final String contextTag : contextTags) { + options.addContextTag(contextTag); } - options.setSentryClientName( - BuildConfig.SENTRY_LOG4J2_SDK_NAME + "/" + BuildConfig.VERSION_NAME); - options.setSdkVersion(createSdkVersion(options)); - if (contextTags != null) { - for (final String contextTag : contextTags) { - options.addContextTag(contextTag); - } - } - Optional.ofNullable(transportFactory).ifPresent(options::setTransportFactory); - }); - } catch (IllegalArgumentException e) { - LOGGER.warn("Failed to init Sentry during appender initialization: " + e.getMessage()); - } + } + Optional.ofNullable(transportFactory).ifPresent(options::setTransportFactory); + }); + } catch (IllegalArgumentException e) { + LOGGER.warn("Failed to init Sentry during appender initialization: " + e.getMessage()); } addPackageAndIntegrationInfo(); super.start(); @@ -149,13 +149,13 @@ public void append(final @NotNull LogEvent eventObject) { final Hint hint = new Hint(); hint.set(SENTRY_SYNTHETIC_EXCEPTION, eventObject); - hub.captureEvent(createEvent(eventObject), hint); + scopes.captureEvent(createEvent(eventObject), hint); } if (eventObject.getLevel().isMoreSpecificThan(minimumBreadcrumbLevel)) { final Hint hint = new Hint(); hint.set(LOG4J_LOG_EVENT, eventObject); - hub.addBreadcrumb(createBreadcrumb(eventObject), hint); + scopes.addBreadcrumb(createBreadcrumb(eventObject), hint); } } @@ -199,9 +199,9 @@ public void append(final @NotNull LogEvent eventObject) { CollectionUtils.filterMapEntries( loggingEvent.getContextData().toMap(), entry -> entry.getValue() != null); if (!contextData.isEmpty()) { - // get tags from HubAdapter options to allow getting the correct tags if Sentry has been + // get tags from ScopesAdapter options to allow getting the correct tags if Sentry has been // initialized somewhere else - final List contextTags = hub.getOptions().getContextTags(); + final List contextTags = scopes.getOptions().getContextTags(); if (contextTags != null && !contextTags.isEmpty()) { for (final String contextTag : contextTags) { // if mdc tag is listed in SentryOptions, apply as event tag diff --git a/sentry-log4j2/src/test/kotlin/io/sentry/log4j2/SentryAppenderTest.kt b/sentry-log4j2/src/test/kotlin/io/sentry/log4j2/SentryAppenderTest.kt index a99096d315..80405f1b4c 100644 --- a/sentry-log4j2/src/test/kotlin/io/sentry/log4j2/SentryAppenderTest.kt +++ b/sentry-log4j2/src/test/kotlin/io/sentry/log4j2/SentryAppenderTest.kt @@ -1,7 +1,8 @@ package io.sentry.log4j2 -import io.sentry.HubAdapter import io.sentry.ITransportFactory +import io.sentry.InitPriority +import io.sentry.ScopesAdapter import io.sentry.Sentry import io.sentry.SentryLevel import io.sentry.checkEvent @@ -49,7 +50,7 @@ class SentryAppenderTest { } loggerContext.start() val config: Configuration = loggerContext.configuration - val appender = SentryAppender("sentry", null, "http://key@localhost/proj", minimumBreadcrumbLevel, minimumEventLevel, debug, this.transportFactory, HubAdapter.getInstance(), contextTags?.toTypedArray()) + val appender = SentryAppender("sentry", null, "http://key@localhost/proj", minimumBreadcrumbLevel, minimumEventLevel, debug, this.transportFactory, ScopesAdapter.getInstance(), contextTags?.toTypedArray()) config.addAppender(appender) val ref = AppenderRef.createAppenderRef("sentry", null, null) @@ -78,15 +79,17 @@ class SentryAppenderTest { @BeforeTest fun `clear MDC`() { ThreadContext.clearAll() + Sentry.close() } @Test - fun `does not initialize Sentry if Sentry is already enabled`() { + fun `does not initialize Sentry if Sentry is already enabled with higher prio`() { Sentry.init { it.dsn = "http://key@localhost/proj" it.environment = "manual-environment" it.setTransportFactory(fixture.transportFactory) it.isEnableBackpressureHandling = false + it.initPriority = InitPriority.LOW } val logger = fixture.getSut() logger.error("testing environment field") @@ -446,6 +449,6 @@ class SentryAppenderTest { @Test fun `sets the debug mode`() { fixture.getSut(debug = true) - assertTrue(HubAdapter.getInstance().options.isDebug) + assertTrue(ScopesAdapter.getInstance().options.isDebug) } } diff --git a/sentry-logback/src/main/java/io/sentry/logback/SentryAppender.java b/sentry-logback/src/main/java/io/sentry/logback/SentryAppender.java index d0be108149..77ce05f47f 100644 --- a/sentry-logback/src/main/java/io/sentry/logback/SentryAppender.java +++ b/sentry-logback/src/main/java/io/sentry/logback/SentryAppender.java @@ -12,8 +12,9 @@ import io.sentry.Breadcrumb; import io.sentry.DateUtils; import io.sentry.Hint; -import io.sentry.HubAdapter; import io.sentry.ITransportFactory; +import io.sentry.InitPriority; +import io.sentry.ScopesAdapter; import io.sentry.Sentry; import io.sentry.SentryEvent; import io.sentry.SentryIntegrationPackageStorage; @@ -49,24 +50,22 @@ public class SentryAppender extends UnsynchronizedAppenderBase { @Override public void start() { - // NOTE: logback.xml properties will only be applied if the SDK has not yet been initialized - if (!Sentry.isEnabled()) { - if (options.getDsn() == null || !options.getDsn().endsWith("_IS_UNDEFINED")) { - options.setEnableExternalConfiguration(true); - options.setSentryClientName( - BuildConfig.SENTRY_LOGBACK_SDK_NAME + "/" + BuildConfig.VERSION_NAME); - options.setSdkVersion(createSdkVersion(options)); - Optional.ofNullable(transportFactory).ifPresent(options::setTransportFactory); - try { - Sentry.init(options); - } catch (IllegalArgumentException e) { - addWarn("Failed to init Sentry during appender initialization: " + e.getMessage()); - } - } else { - options - .getLogger() - .log(SentryLevel.WARNING, "DSN is null. SentryAppender is not being initialized"); + if (options.getDsn() == null || !options.getDsn().endsWith("_IS_UNDEFINED")) { + options.setEnableExternalConfiguration(true); + options.setInitPriority(InitPriority.LOWEST); + options.setSentryClientName( + BuildConfig.SENTRY_LOGBACK_SDK_NAME + "/" + BuildConfig.VERSION_NAME); + options.setSdkVersion(createSdkVersion(options)); + Optional.ofNullable(transportFactory).ifPresent(options::setTransportFactory); + try { + Sentry.init(options); + } catch (IllegalArgumentException e) { + addWarn("Failed to init Sentry during appender initialization: " + e.getMessage()); } + } else if (!Sentry.isEnabled()) { + options + .getLogger() + .log(SentryLevel.WARNING, "DSN is null. SentryAppender is not being initialized"); } addPackageAndIntegrationInfo(); super.start(); @@ -134,9 +133,9 @@ protected void append(@NotNull ILoggingEvent eventObject) { CollectionUtils.filterMapEntries( loggingEvent.getMDCPropertyMap(), entry -> entry.getValue() != null); if (!mdcProperties.isEmpty()) { - // get tags from HubAdapter options to allow getting the correct tags if Sentry has been + // get tags from ScopesAdapter options to allow getting the correct tags if Sentry has been // initialized somewhere else - final List contextTags = HubAdapter.getInstance().getOptions().getContextTags(); + final List contextTags = ScopesAdapter.getInstance().getOptions().getContextTags(); if (!contextTags.isEmpty()) { for (final String contextTag : contextTags) { // if mdc tag is listed in SentryOptions, apply as event tag diff --git a/sentry-logback/src/test/kotlin/io/sentry/logback/SentryAppenderTest.kt b/sentry-logback/src/test/kotlin/io/sentry/logback/SentryAppenderTest.kt index b7797cadc7..abb4a5bd61 100644 --- a/sentry-logback/src/test/kotlin/io/sentry/logback/SentryAppenderTest.kt +++ b/sentry-logback/src/test/kotlin/io/sentry/logback/SentryAppenderTest.kt @@ -8,6 +8,7 @@ import ch.qos.logback.core.encoder.Encoder import ch.qos.logback.core.encoder.EncoderBase import ch.qos.logback.core.status.Status import io.sentry.ITransportFactory +import io.sentry.InitPriority import io.sentry.Sentry import io.sentry.SentryLevel import io.sentry.SentryOptions @@ -35,17 +36,18 @@ import kotlin.test.assertNull import kotlin.test.assertTrue class SentryAppenderTest { - private class Fixture(dsn: String? = "http://key@localhost/proj", minimumBreadcrumbLevel: Level? = null, minimumEventLevel: Level? = null, contextTags: List? = null, encoder: Encoder? = null, sendDefaultPii: Boolean = false) { + private class Fixture(dsn: String? = "http://key@localhost/proj", minimumBreadcrumbLevel: Level? = null, minimumEventLevel: Level? = null, contextTags: List? = null, encoder: Encoder? = null, sendDefaultPii: Boolean = false, options: SentryOptions = SentryOptions(), startLater: Boolean = false) { val logger: Logger = LoggerFactory.getLogger(SentryAppenderTest::class.java) val loggerContext = LoggerFactory.getILoggerFactory() as LoggerContext val transportFactory = mock() val transport = mock() val utcTimeZone: ZoneId = ZoneId.of("UTC") + val appender = SentryAppender() + var encoder: Encoder? = null init { whenever(this.transportFactory.create(any(), any())).thenReturn(transport) - val appender = SentryAppender() - val options = SentryOptions() + this.encoder = encoder options.dsn = dsn options.isSendDefaultPii = sendDefaultPii contextTags?.forEach { options.addContextTag(it) } @@ -59,6 +61,12 @@ class SentryAppenderTest { val rootLogger = loggerContext.getLogger(Logger.ROOT_LOGGER_NAME) rootLogger.level = Level.TRACE rootLogger.addAppender(appender) + if (!startLater) { + start() + } + } + + fun start() { appender.start() encoder?.start() loggerContext.start() @@ -77,22 +85,32 @@ class SentryAppenderTest { @BeforeTest fun `clear MDC`() { MDC.clear() + Sentry.close() } @Test - fun `does not initialize Sentry if Sentry is already enabled`() { - fixture = Fixture() + fun `does not initialize Sentry if Sentry is already enabled with higher prio`() { + fixture = Fixture( + startLater = true, + options = SentryOptions().also { + it.setTag("only-present-if-logger-init-was-run", "another-value") + } + ) Sentry.init { it.dsn = "http://key@localhost/proj" it.environment = "manual-environment" it.setTransportFactory(fixture.transportFactory) + it.setTag("tag-from-first-init", "some-value") it.isEnableBackpressureHandling = false + it.initPriority = InitPriority.LOW } + fixture.start() + fixture.logger.error("testing environment field") verify(fixture.transport).send( checkEvent { event -> - assertEquals("manual-environment", event.environment) + assertNull(event.tags?.get("only-present-if-logger-init-was-run")) }, anyOrNull() ) diff --git a/sentry-okhttp/api/sentry-okhttp.api b/sentry-okhttp/api/sentry-okhttp.api index 3095659c88..9cb875ff34 100644 --- a/sentry-okhttp/api/sentry-okhttp.api +++ b/sentry-okhttp/api/sentry-okhttp.api @@ -6,12 +6,12 @@ public final class io/sentry/okhttp/BuildConfig { public class io/sentry/okhttp/SentryOkHttpEventListener : okhttp3/EventListener { public static final field Companion Lio/sentry/okhttp/SentryOkHttpEventListener$Companion; public fun ()V - public fun (Lio/sentry/IHub;Lkotlin/jvm/functions/Function1;)V - public synthetic fun (Lio/sentry/IHub;Lkotlin/jvm/functions/Function1;ILkotlin/jvm/internal/DefaultConstructorMarker;)V - public fun (Lio/sentry/IHub;Lokhttp3/EventListener$Factory;)V - public synthetic fun (Lio/sentry/IHub;Lokhttp3/EventListener$Factory;ILkotlin/jvm/internal/DefaultConstructorMarker;)V - public fun (Lio/sentry/IHub;Lokhttp3/EventListener;)V - public synthetic fun (Lio/sentry/IHub;Lokhttp3/EventListener;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Lio/sentry/IScopes;Lkotlin/jvm/functions/Function1;)V + public synthetic fun (Lio/sentry/IScopes;Lkotlin/jvm/functions/Function1;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Lio/sentry/IScopes;Lokhttp3/EventListener$Factory;)V + public synthetic fun (Lio/sentry/IScopes;Lokhttp3/EventListener$Factory;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Lio/sentry/IScopes;Lokhttp3/EventListener;)V + public synthetic fun (Lio/sentry/IScopes;Lokhttp3/EventListener;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public fun (Lokhttp3/EventListener$Factory;)V public fun (Lokhttp3/EventListener;)V public fun cacheConditionalHit (Lokhttp3/Call;Lokhttp3/Response;)V @@ -50,9 +50,9 @@ public final class io/sentry/okhttp/SentryOkHttpEventListener$Companion { public class io/sentry/okhttp/SentryOkHttpInterceptor : okhttp3/Interceptor { public fun ()V - public fun (Lio/sentry/IHub;)V - public fun (Lio/sentry/IHub;Lio/sentry/okhttp/SentryOkHttpInterceptor$BeforeSpanCallback;ZLjava/util/List;Ljava/util/List;)V - public synthetic fun (Lio/sentry/IHub;Lio/sentry/okhttp/SentryOkHttpInterceptor$BeforeSpanCallback;ZLjava/util/List;Ljava/util/List;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Lio/sentry/IScopes;)V + public fun (Lio/sentry/IScopes;Lio/sentry/okhttp/SentryOkHttpInterceptor$BeforeSpanCallback;ZLjava/util/List;Ljava/util/List;)V + public synthetic fun (Lio/sentry/IScopes;Lio/sentry/okhttp/SentryOkHttpInterceptor$BeforeSpanCallback;ZLjava/util/List;Ljava/util/List;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public fun (Lio/sentry/okhttp/SentryOkHttpInterceptor$BeforeSpanCallback;)V public fun intercept (Lokhttp3/Interceptor$Chain;)Lokhttp3/Response; } 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..137af27913 100644 --- a/sentry-okhttp/src/main/java/io/sentry/okhttp/SentryOkHttpEvent.kt +++ b/sentry-okhttp/src/main/java/io/sentry/okhttp/SentryOkHttpEvent.kt @@ -2,41 +2,32 @@ package io.sentry.okhttp import io.sentry.Breadcrumb import io.sentry.Hint -import io.sentry.IHub +import io.sentry.IScopes import io.sentry.ISpan import io.sentry.SentryDate -import io.sentry.SentryLevel import io.sentry.SpanDataConvention import io.sentry.TypeCheckHint -import io.sentry.okhttp.SentryOkHttpEventListener.Companion.CONNECTION_EVENT -import io.sentry.okhttp.SentryOkHttpEventListener.Companion.CONNECT_EVENT -import io.sentry.okhttp.SentryOkHttpEventListener.Companion.REQUEST_BODY_EVENT -import io.sentry.okhttp.SentryOkHttpEventListener.Companion.REQUEST_HEADERS_EVENT -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 import okhttp3.Response import java.util.Locale import java.util.concurrent.ConcurrentHashMap -import java.util.concurrent.RejectedExecutionException +import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicBoolean private const val PROTOCOL_KEY = "protocol" private const val ERROR_MESSAGE_KEY = "error_message" -private const val RESPONSE_BODY_TIMEOUT_MILLIS = 800L internal const val TRACE_ORIGIN = "auto.http.okhttp" @Suppress("TooManyFunctions") -internal class SentryOkHttpEvent(private val hub: IHub, private val request: Request) { - private val eventSpans: MutableMap = ConcurrentHashMap() +internal class SentryOkHttpEvent(private val scopes: IScopes, private val request: Request) { + private val eventDates: MutableMap = ConcurrentHashMap() private val breadcrumb: Breadcrumb - internal val callRootSpan: ISpan? + internal val callSpan: ISpan? private var response: Response? = null private var clientErrorResponse: Response? = null - private val isReadingResponseBody = AtomicBoolean(false) private val isEventFinished = AtomicBoolean(false) private val url: String private val method: String @@ -49,53 +40,55 @@ internal class SentryOkHttpEvent(private val hub: IHub, private val request: Req method = request.method // We start the call span that will contain all the others - val parentSpan = if (Platform.isAndroid()) hub.transaction else hub.span - callRootSpan = parentSpan?.startChild("http.client", "$method $url") - callRootSpan?.spanContext?.origin = TRACE_ORIGIN - urlDetails.applyToSpan(callRootSpan) + val parentSpan = if (Platform.isAndroid()) scopes.transaction else scopes.span + callSpan = parentSpan?.startChild("http.client", "$method $url") + callSpan?.spanContext?.origin = TRACE_ORIGIN + urlDetails.applyToSpan(callSpan) // We setup a breadcrumb with all meaningful data breadcrumb = Breadcrumb.http(url, method) breadcrumb.setData("host", host) breadcrumb.setData("path", encodedPath) - - // We add the same data to the root call span - callRootSpan?.setData("url", url) - callRootSpan?.setData("host", host) - callRootSpan?.setData("path", encodedPath) - callRootSpan?.setData(SpanDataConvention.HTTP_METHOD_KEY, method.toUpperCase(Locale.ROOT)) + // needs this as unix timestamp for rrweb + breadcrumb.setData(SpanDataConvention.HTTP_START_TIMESTAMP, CurrentDateProvider.getInstance().currentTimeMillis) + + // We add the same data to the call span + callSpan?.setData("url", url) + callSpan?.setData("host", host) + callSpan?.setData("path", encodedPath) + callSpan?.setData(SpanDataConvention.HTTP_METHOD_KEY, method.uppercase(Locale.ROOT)) } /** * Sets the [Response] that will be sent in the breadcrumb [Hint]. - * Also, it sets the protocol and status code in the breadcrumb and the call root span. + * Also, it sets the protocol and status code in the breadcrumb and the call span. */ fun setResponse(response: Response) { this.response = response breadcrumb.setData(PROTOCOL_KEY, response.protocol.name) breadcrumb.setData("status_code", response.code) - callRootSpan?.setData(PROTOCOL_KEY, response.protocol.name) - callRootSpan?.setData(SpanDataConvention.HTTP_STATUS_CODE_KEY, response.code) + callSpan?.setData(PROTOCOL_KEY, response.protocol.name) + callSpan?.setData(SpanDataConvention.HTTP_STATUS_CODE_KEY, response.code) } fun setProtocol(protocolName: String?) { if (protocolName != null) { breadcrumb.setData(PROTOCOL_KEY, protocolName) - callRootSpan?.setData(PROTOCOL_KEY, protocolName) + callSpan?.setData(PROTOCOL_KEY, protocolName) } } fun setRequestBodySize(byteCount: Long) { if (byteCount > -1) { breadcrumb.setData("request_content_length", byteCount) - callRootSpan?.setData("http.request_content_length", byteCount) + callSpan?.setData("http.request_content_length", byteCount) } } fun setResponseBodySize(byteCount: Long) { if (byteCount > -1) { breadcrumb.setData("response_content_length", byteCount) - callRootSpan?.setData(SpanDataConvention.HTTP_RESPONSE_CONTENT_LENGTH_KEY, byteCount) + callSpan?.setData(SpanDataConvention.HTTP_RESPONSE_CONTENT_LENGTH_KEY, byteCount) } } @@ -107,121 +100,49 @@ internal class SentryOkHttpEvent(private val hub: IHub, private val request: Req fun setError(errorMessage: String?) { if (errorMessage != null) { breadcrumb.setData(ERROR_MESSAGE_KEY, errorMessage) - callRootSpan?.setData(ERROR_MESSAGE_KEY, errorMessage) + callSpan?.setData(ERROR_MESSAGE_KEY, errorMessage) } } - /** Starts a span, if the callRootSpan is not null. */ - fun startSpan(event: String) { - // Find the parent of the span being created. E.g. secureConnect is child of connect - val parentSpan = findParentSpan(event) - val span = parentSpan?.startChild("http.client.$event", "$method $url") ?: return - if (event == RESPONSE_BODY_EVENT) { - // We save this event is reading the response body, so that it will not be auto-finished - isReadingResponseBody.set(true) - } - span.spanContext.origin = TRACE_ORIGIN - eventSpans[event] = span + /** Record event start if the callRootSpan is not null. */ + fun onEventStart(event: String) { + callSpan ?: return + eventDates[event] = scopes.options.dateProvider.now() } - /** Finishes a previously started span, and runs [beforeFinish] on it, on its parent and on the call root span. */ - fun finishSpan(event: String, beforeFinish: ((span: ISpan) -> Unit)? = null): ISpan? { - val span = eventSpans[event] ?: return null - val parentSpan = findParentSpan(event) - beforeFinish?.invoke(span) - moveThrowableToRootSpan(span) - if (parentSpan != null && parentSpan != callRootSpan) { - beforeFinish?.invoke(parentSpan) - moveThrowableToRootSpan(parentSpan) - } - callRootSpan?.let { beforeFinish?.invoke(it) } - span.finish() - return span + /** Record event finish and runs [beforeFinish] on the call span. */ + fun onEventFinish(event: String, beforeFinish: ((span: ISpan) -> Unit)? = null) { + val eventDate = eventDates.remove(event) ?: return + callSpan ?: return + beforeFinish?.invoke(callSpan) + val eventDurationNanos = scopes.options.dateProvider.now().diff(eventDate) + callSpan.setData(event, TimeUnit.NANOSECONDS.toMillis(eventDurationNanos)) } - /** Finishes the call root span, and runs [beforeFinish] on it. Then a breadcrumb is sent. */ - fun finishEvent(finishDate: SentryDate? = null, beforeFinish: ((span: ISpan) -> Unit)? = null) { + /** Finishes the call span, and runs [beforeFinish] on it. Then a breadcrumb is sent. */ + fun finish(beforeFinish: ((span: ISpan) -> Unit)? = null) { // If the event already finished, we don't do anything if (isEventFinished.getAndSet(true)) { return } + // We clear any date left, in case an event started, but never finished. Shouldn't happen. + eventDates.clear() // We put data in the hint and send a breadcrumb val hint = Hint() 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) - - // No span is created (e.g. no transaction is running) - if (callRootSpan == null) { - // We report the client error even without spans. - clientErrorResponse?.let { - SentryOkHttpUtils.captureClientError(hub, it.request, it) - } - return - } + scopes.addBreadcrumb(breadcrumb, hint) - // We forcefully finish all spans, even if they should already have been finished through finishSpan() - eventSpans.values.filter { !it.isFinished }.forEach { - moveThrowableToRootSpan(it) - if (finishDate != null) { - it.finish(it.status, finishDate) - } else { - it.finish() - } - } - beforeFinish?.invoke(callRootSpan) - // We report the client error here, after all sub-spans finished, so that it will be bound - // to the root call span. + callSpan?.let { beforeFinish?.invoke(it) } + // We report the client error here so that it will be bound to the call span. We send it even if there is no running span. clientErrorResponse?.let { - SentryOkHttpUtils.captureClientError(hub, it.request, it) - } - if (finishDate != null) { - callRootSpan.finish(callRootSpan.status, finishDate) - } else { - callRootSpan.finish() + SentryOkHttpUtils.captureClientError(scopes, it.request, it) } + callSpan?.finish() return } - - /** Move any throwable from an inner span to the call root span. */ - private fun moveThrowableToRootSpan(span: ISpan) { - if (span != callRootSpan && span.throwable != null && span.status != null) { - callRootSpan?.throwable = span.throwable - callRootSpan?.status = span.status - span.throwable = null - } - } - - private fun findParentSpan(event: String): ISpan? = when (event) { - // PROXY_SELECT, DNS, CONNECT and CONNECTION are not children of one another - SECURE_CONNECT_EVENT -> eventSpans[CONNECT_EVENT] - REQUEST_HEADERS_EVENT -> eventSpans[CONNECTION_EVENT] - REQUEST_BODY_EVENT -> eventSpans[CONNECTION_EVENT] - RESPONSE_HEADERS_EVENT -> eventSpans[CONNECTION_EVENT] - RESPONSE_BODY_EVENT -> eventSpans[CONNECTION_EVENT] - else -> callRootSpan - } ?: callRootSpan - - fun scheduleFinish(timestamp: SentryDate) { - try { - hub.options.executorService.schedule({ - if (!isReadingResponseBody.get() && - (eventSpans.values.all { it.isFinished } || callRootSpan?.isFinished != true) - ) { - finishEvent(timestamp) - } - }, RESPONSE_BODY_TIMEOUT_MILLIS) - } catch (e: RejectedExecutionException) { - hub.options - .logger - .log( - SentryLevel.ERROR, - "Failed to call the executor. OkHttp span will not be finished " + - "automatically. Did you call Sentry.close()?", - e - ) - } - } } diff --git a/sentry-okhttp/src/main/java/io/sentry/okhttp/SentryOkHttpEventListener.kt b/sentry-okhttp/src/main/java/io/sentry/okhttp/SentryOkHttpEventListener.kt index 853d9cd02a..9e6ed3019f 100644 --- a/sentry-okhttp/src/main/java/io/sentry/okhttp/SentryOkHttpEventListener.kt +++ b/sentry-okhttp/src/main/java/io/sentry/okhttp/SentryOkHttpEventListener.kt @@ -1,7 +1,7 @@ package io.sentry.okhttp -import io.sentry.HubAdapter -import io.sentry.IHub +import io.sentry.IScopes +import io.sentry.ScopesAdapter import io.sentry.SpanDataConvention import io.sentry.SpanStatus import okhttp3.Call @@ -41,48 +41,48 @@ import java.util.concurrent.ConcurrentHashMap */ @Suppress("TooManyFunctions") public open class SentryOkHttpEventListener( - private val hub: IHub = HubAdapter.getInstance(), + private val scopes: IScopes = ScopesAdapter.getInstance(), private val originalEventListenerCreator: ((call: Call) -> EventListener)? = null ) : EventListener() { private var originalEventListener: EventListener? = null public companion object { - internal const val PROXY_SELECT_EVENT = "proxy_select" - internal const val DNS_EVENT = "dns" - internal const val SECURE_CONNECT_EVENT = "secure_connect" - internal const val CONNECT_EVENT = "connect" - internal const val CONNECTION_EVENT = "connection" - internal const val REQUEST_HEADERS_EVENT = "request_headers" - internal const val REQUEST_BODY_EVENT = "request_body" - internal const val RESPONSE_HEADERS_EVENT = "response_headers" - internal const val RESPONSE_BODY_EVENT = "response_body" + internal const val PROXY_SELECT_EVENT = "http.client.proxy_select_ms" + internal const val DNS_EVENT = "http.client.resolve_dns_ms" + internal const val CONNECT_EVENT = "http.connect_ms" + internal const val SECURE_CONNECT_EVENT = "http.connect.secure_connect_ms" + internal const val CONNECTION_EVENT = "http.connection_ms" + internal const val REQUEST_HEADERS_EVENT = "http.connection.request_headers_ms" + internal const val REQUEST_BODY_EVENT = "http.connection.request_body_ms" + internal const val RESPONSE_HEADERS_EVENT = "http.connection.response_headers_ms" + internal const val RESPONSE_BODY_EVENT = "http.connection.response_body_ms" internal val eventMap: MutableMap = ConcurrentHashMap() } public constructor() : this( - HubAdapter.getInstance(), + ScopesAdapter.getInstance(), originalEventListenerCreator = null ) public constructor(originalEventListener: EventListener) : this( - HubAdapter.getInstance(), + ScopesAdapter.getInstance(), originalEventListenerCreator = { originalEventListener } ) public constructor(originalEventListenerFactory: Factory) : this( - HubAdapter.getInstance(), + ScopesAdapter.getInstance(), originalEventListenerCreator = { originalEventListenerFactory.create(it) } ) - public constructor(hub: IHub = HubAdapter.getInstance(), originalEventListener: EventListener) : this( - hub, + public constructor(scopes: IScopes = ScopesAdapter.getInstance(), originalEventListener: EventListener) : this( + scopes, originalEventListenerCreator = { originalEventListener } ) - public constructor(hub: IHub = HubAdapter.getInstance(), originalEventListenerFactory: Factory) : this( - hub, + public constructor(scopes: IScopes = ScopesAdapter.getInstance(), originalEventListenerFactory: Factory) : this( + scopes, originalEventListenerCreator = { originalEventListenerFactory.create(it) } ) @@ -92,7 +92,7 @@ public open class SentryOkHttpEventListener( // If the wrapped EventListener is ours, we can just delegate the calls, // without creating other events that would create duplicates if (canCreateEventSpan()) { - eventMap[call] = SentryOkHttpEvent(hub, call.request()) + eventMap[call] = SentryOkHttpEvent(scopes, call.request()) } } @@ -102,7 +102,7 @@ public open class SentryOkHttpEventListener( return } val okHttpEvent: SentryOkHttpEvent = eventMap[call] ?: return - okHttpEvent.startSpan(PROXY_SELECT_EVENT) + okHttpEvent.onEventStart(PROXY_SELECT_EVENT) } override fun proxySelectEnd( @@ -115,7 +115,7 @@ public open class SentryOkHttpEventListener( return } val okHttpEvent: SentryOkHttpEvent = eventMap[call] ?: return - okHttpEvent.finishSpan(PROXY_SELECT_EVENT) { + okHttpEvent.onEventFinish(PROXY_SELECT_EVENT) { if (proxies.isNotEmpty()) { it.setData("proxies", proxies.joinToString { proxy -> proxy.toString() }) } @@ -128,7 +128,7 @@ public open class SentryOkHttpEventListener( return } val okHttpEvent: SentryOkHttpEvent = eventMap[call] ?: return - okHttpEvent.startSpan(DNS_EVENT) + okHttpEvent.onEventStart(DNS_EVENT) } override fun dnsEnd( @@ -141,7 +141,7 @@ public open class SentryOkHttpEventListener( return } val okHttpEvent: SentryOkHttpEvent = eventMap[call] ?: return - okHttpEvent.finishSpan(DNS_EVENT) { + okHttpEvent.onEventFinish(DNS_EVENT) { it.setData("domain_name", domainName) if (inetAddressList.isNotEmpty()) { it.setData("dns_addresses", inetAddressList.joinToString { address -> address.toString() }) @@ -159,7 +159,7 @@ public open class SentryOkHttpEventListener( return } val okHttpEvent: SentryOkHttpEvent = eventMap[call] ?: return - okHttpEvent.startSpan(CONNECT_EVENT) + okHttpEvent.onEventStart(CONNECT_EVENT) } override fun secureConnectStart(call: Call) { @@ -168,7 +168,7 @@ public open class SentryOkHttpEventListener( return } val okHttpEvent: SentryOkHttpEvent = eventMap[call] ?: return - okHttpEvent.startSpan(SECURE_CONNECT_EVENT) + okHttpEvent.onEventStart(SECURE_CONNECT_EVENT) } override fun secureConnectEnd(call: Call, handshake: Handshake?) { @@ -177,7 +177,7 @@ public open class SentryOkHttpEventListener( return } val okHttpEvent: SentryOkHttpEvent = eventMap[call] ?: return - okHttpEvent.finishSpan(SECURE_CONNECT_EVENT) + okHttpEvent.onEventFinish(SECURE_CONNECT_EVENT) } override fun connectEnd( @@ -192,7 +192,7 @@ public open class SentryOkHttpEventListener( } val okHttpEvent: SentryOkHttpEvent = eventMap[call] ?: return okHttpEvent.setProtocol(protocol?.name) - okHttpEvent.finishSpan(CONNECT_EVENT) + okHttpEvent.onEventFinish(CONNECT_EVENT) } override fun connectFailed( @@ -209,7 +209,7 @@ public open class SentryOkHttpEventListener( val okHttpEvent: SentryOkHttpEvent = eventMap[call] ?: return okHttpEvent.setProtocol(protocol?.name) okHttpEvent.setError(ioe.message) - okHttpEvent.finishSpan(CONNECT_EVENT) { + okHttpEvent.onEventFinish(CONNECT_EVENT) { it.throwable = ioe it.status = SpanStatus.INTERNAL_ERROR } @@ -221,7 +221,7 @@ public open class SentryOkHttpEventListener( return } val okHttpEvent: SentryOkHttpEvent = eventMap[call] ?: return - okHttpEvent.startSpan(CONNECTION_EVENT) + okHttpEvent.onEventStart(CONNECTION_EVENT) } override fun connectionReleased(call: Call, connection: Connection) { @@ -230,7 +230,7 @@ public open class SentryOkHttpEventListener( return } val okHttpEvent: SentryOkHttpEvent = eventMap[call] ?: return - okHttpEvent.finishSpan(CONNECTION_EVENT) + okHttpEvent.onEventFinish(CONNECTION_EVENT) } override fun requestHeadersStart(call: Call) { @@ -239,7 +239,7 @@ public open class SentryOkHttpEventListener( return } val okHttpEvent: SentryOkHttpEvent = eventMap[call] ?: return - okHttpEvent.startSpan(REQUEST_HEADERS_EVENT) + okHttpEvent.onEventStart(REQUEST_HEADERS_EVENT) } override fun requestHeadersEnd(call: Call, request: Request) { @@ -248,7 +248,7 @@ public open class SentryOkHttpEventListener( return } val okHttpEvent: SentryOkHttpEvent = eventMap[call] ?: return - okHttpEvent.finishSpan(REQUEST_HEADERS_EVENT) + okHttpEvent.onEventFinish(REQUEST_HEADERS_EVENT) } override fun requestBodyStart(call: Call) { @@ -257,7 +257,7 @@ public open class SentryOkHttpEventListener( return } val okHttpEvent: SentryOkHttpEvent = eventMap[call] ?: return - okHttpEvent.startSpan(REQUEST_BODY_EVENT) + okHttpEvent.onEventStart(REQUEST_BODY_EVENT) } override fun requestBodyEnd(call: Call, byteCount: Long) { @@ -266,7 +266,7 @@ public open class SentryOkHttpEventListener( return } val okHttpEvent: SentryOkHttpEvent = eventMap[call] ?: return - okHttpEvent.finishSpan(REQUEST_BODY_EVENT) { + okHttpEvent.onEventFinish(REQUEST_BODY_EVENT) { if (byteCount > 0) { it.setData("http.request_content_length", byteCount) } @@ -283,13 +283,13 @@ public open class SentryOkHttpEventListener( okHttpEvent.setError(ioe.message) // requestFailed can happen after requestHeaders or requestBody. // If requestHeaders already finished, we don't change its status. - okHttpEvent.finishSpan(REQUEST_HEADERS_EVENT) { + okHttpEvent.onEventFinish(REQUEST_HEADERS_EVENT) { if (!it.isFinished) { it.status = SpanStatus.INTERNAL_ERROR it.throwable = ioe } } - okHttpEvent.finishSpan(REQUEST_BODY_EVENT) { + okHttpEvent.onEventFinish(REQUEST_BODY_EVENT) { it.status = SpanStatus.INTERNAL_ERROR it.throwable = ioe } @@ -301,7 +301,7 @@ public open class SentryOkHttpEventListener( return } val okHttpEvent: SentryOkHttpEvent = eventMap[call] ?: return - okHttpEvent.startSpan(RESPONSE_HEADERS_EVENT) + okHttpEvent.onEventStart(RESPONSE_HEADERS_EVENT) } override fun responseHeadersEnd(call: Call, response: Response) { @@ -311,14 +311,13 @@ public open class SentryOkHttpEventListener( } val okHttpEvent: SentryOkHttpEvent = eventMap[call] ?: return okHttpEvent.setResponse(response) - val responseHeadersSpan = okHttpEvent.finishSpan(RESPONSE_HEADERS_EVENT) { + okHttpEvent.onEventFinish(RESPONSE_HEADERS_EVENT) { it.setData(SpanDataConvention.HTTP_STATUS_CODE_KEY, response.code) // Let's not override the status of a span that was set if (it.status == null) { it.status = SpanStatus.fromHttpStatusCode(response.code) } } - okHttpEvent.scheduleFinish(responseHeadersSpan?.finishDate ?: hub.options.dateProvider.now()) } override fun responseBodyStart(call: Call) { @@ -327,7 +326,7 @@ public open class SentryOkHttpEventListener( return } val okHttpEvent: SentryOkHttpEvent = eventMap[call] ?: return - okHttpEvent.startSpan(RESPONSE_BODY_EVENT) + okHttpEvent.onEventStart(RESPONSE_BODY_EVENT) } override fun responseBodyEnd(call: Call, byteCount: Long) { @@ -337,7 +336,7 @@ public open class SentryOkHttpEventListener( } val okHttpEvent: SentryOkHttpEvent = eventMap[call] ?: return okHttpEvent.setResponseBodySize(byteCount) - okHttpEvent.finishSpan(RESPONSE_BODY_EVENT) { + okHttpEvent.onEventFinish(RESPONSE_BODY_EVENT) { if (byteCount > 0) { it.setData(SpanDataConvention.HTTP_RESPONSE_CONTENT_LENGTH_KEY, byteCount) } @@ -353,13 +352,13 @@ public open class SentryOkHttpEventListener( okHttpEvent.setError(ioe.message) // responseFailed can happen after responseHeaders or responseBody. // If responseHeaders already finished, we don't change its status. - okHttpEvent.finishSpan(RESPONSE_HEADERS_EVENT) { + okHttpEvent.onEventFinish(RESPONSE_HEADERS_EVENT) { if (!it.isFinished) { it.status = SpanStatus.INTERNAL_ERROR it.throwable = ioe } } - okHttpEvent.finishSpan(RESPONSE_BODY_EVENT) { + okHttpEvent.onEventFinish(RESPONSE_BODY_EVENT) { it.status = SpanStatus.INTERNAL_ERROR it.throwable = ioe } @@ -368,7 +367,7 @@ public open class SentryOkHttpEventListener( override fun callEnd(call: Call) { originalEventListener?.callEnd(call) val okHttpEvent: SentryOkHttpEvent = eventMap.remove(call) ?: return - okHttpEvent.finishEvent() + okHttpEvent.finish() } override fun callFailed(call: Call, ioe: IOException) { @@ -378,7 +377,7 @@ public open class SentryOkHttpEventListener( } val okHttpEvent: SentryOkHttpEvent = eventMap.remove(call) ?: return okHttpEvent.setError(ioe.message) - okHttpEvent.finishEvent { + okHttpEvent.finish { it.status = SpanStatus.INTERNAL_ERROR it.throwable = ioe } 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..bd6061da5f 100644 --- a/sentry-okhttp/src/main/java/io/sentry/okhttp/SentryOkHttpInterceptor.kt +++ b/sentry-okhttp/src/main/java/io/sentry/okhttp/SentryOkHttpInterceptor.kt @@ -4,9 +4,9 @@ import io.sentry.BaggageHeader import io.sentry.Breadcrumb import io.sentry.Hint import io.sentry.HttpStatusCodeRange -import io.sentry.HubAdapter -import io.sentry.IHub +import io.sentry.IScopes import io.sentry.ISpan +import io.sentry.ScopesAdapter import io.sentry.SentryIntegrationPackageStorage import io.sentry.SentryOptions.DEFAULT_PROPAGATION_TARGETS import io.sentry.SpanDataConvention @@ -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 @@ -29,7 +30,7 @@ import java.io.IOException * out of the active span bound to the scope for each HTTP Request. * If [captureFailedRequests] is enabled, the SDK will capture HTTP Client errors as well. * - * @param hub The [IHub], internal and only used for testing. + * @param scopes The [IScopes], internal and only used for testing. * @param beforeSpan The [ISpan] can be customized or dropped with the [BeforeSpanCallback]. * @param captureFailedRequests The SDK will only capture HTTP Client errors if it is enabled, * Defaults to false. @@ -39,7 +40,7 @@ import java.io.IOException * is a match for any of the defined targets. */ public open class SentryOkHttpInterceptor( - private val hub: IHub = HubAdapter.getInstance(), + private val scopes: IScopes = ScopesAdapter.getInstance(), private val beforeSpan: BeforeSpanCallback? = null, private val captureFailedRequests: Boolean = true, private val failedRequestStatusCodes: List = listOf( @@ -48,9 +49,9 @@ public open class SentryOkHttpInterceptor( private val failedRequestTargets: List = listOf(DEFAULT_PROPAGATION_TARGETS) ) : Interceptor { - public constructor() : this(HubAdapter.getInstance()) - public constructor(hub: IHub) : this(hub, null) - public constructor(beforeSpan: BeforeSpanCallback) : this(HubAdapter.getInstance(), beforeSpan) + public constructor() : this(ScopesAdapter.getInstance()) + public constructor(scopes: IScopes) : this(scopes, null) + public constructor(beforeSpan: BeforeSpanCallback) : this(ScopesAdapter.getInstance(), beforeSpan) init { addIntegrationToSdkVersion(javaClass) @@ -72,13 +73,14 @@ public open class SentryOkHttpInterceptor( if (SentryOkHttpEventListener.eventMap.containsKey(chain.call())) { // read the span from the event listener okHttpEvent = SentryOkHttpEventListener.eventMap[chain.call()] - span = okHttpEvent?.callRootSpan + span = okHttpEvent?.callSpan } else { // read the span from the bound scope okHttpEvent = null - val parentSpan = if (Platform.isAndroid()) hub.transaction else hub.span + val parentSpan = if (Platform.isAndroid()) scopes.transaction else scopes.span span = parentSpan?.startChild("http.client", "$method $url") } + val startTimestamp = CurrentDateProvider.getInstance().currentTimeMillis span?.spanContext?.origin = TRACE_ORIGIN @@ -92,7 +94,7 @@ public open class SentryOkHttpInterceptor( val requestBuilder = request.newBuilder() TracingUtils.traceIfAllowed( - hub, + scopes, request.url.toString(), request.headers(BaggageHeader.BAGGAGE_HEADER), span @@ -121,7 +123,7 @@ public open class SentryOkHttpInterceptor( if (isFromEventListener && okHttpEvent != null) { okHttpEvent.setClientErrorResponse(response) } else { - SentryOkHttpUtils.captureClientError(hub, request, response) + SentryOkHttpUtils.captureClientError(scopes, request, response) } } @@ -133,16 +135,21 @@ public open class SentryOkHttpInterceptor( } throw e } finally { - finishSpan(span, request, response, isFromEventListener) + finishSpan(span, request, response, isFromEventListener, okHttpEvent) // 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,11 +163,14 @@ 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) + scopes.addBreadcrumb(breadcrumb, hint) } - private fun finishSpan(span: ISpan?, request: Request, response: Response?, isFromEventListener: Boolean) { + private fun finishSpan(span: ISpan?, request: Request, response: Response?, isFromEventListener: Boolean, okHttpEvent: SentryOkHttpEvent?) { if (span == null) { return } @@ -170,16 +180,12 @@ public open class SentryOkHttpInterceptor( // span is dropped span.spanContext.sampled = false } - // The SentryOkHttpEventListener will finish the span itself if used for this call - if (!isFromEventListener) { - span.finish() - } - } else { - // The SentryOkHttpEventListener will finish the span itself if used for this call - if (!isFromEventListener) { - span.finish() - } } + if (!isFromEventListener) { + span.finish() + } + // The SentryOkHttpEventListener waits until the response is closed (which may never happen), so we close it here + okHttpEvent?.finish() } private fun Long?.ifHasValidLength(fn: (Long) -> Unit) { diff --git a/sentry-okhttp/src/main/java/io/sentry/okhttp/SentryOkHttpUtils.kt b/sentry-okhttp/src/main/java/io/sentry/okhttp/SentryOkHttpUtils.kt index 0cfc1c5a75..eea35ca22e 100644 --- a/sentry-okhttp/src/main/java/io/sentry/okhttp/SentryOkHttpUtils.kt +++ b/sentry-okhttp/src/main/java/io/sentry/okhttp/SentryOkHttpUtils.kt @@ -1,7 +1,7 @@ package io.sentry.okhttp import io.sentry.Hint -import io.sentry.IHub +import io.sentry.IScopes import io.sentry.SentryEvent import io.sentry.TypeCheckHint import io.sentry.exception.ExceptionMechanismException @@ -15,7 +15,7 @@ import okhttp3.Response internal object SentryOkHttpUtils { - internal fun captureClientError(hub: IHub, request: Request, response: Response) { + internal fun captureClientError(scopes: IScopes, request: Request, response: Response) { // not possible to get a parameterized url, but we remove at least the // query string and the fragment. // url example: https://api.github.com/users/getsentry/repos/#fragment?query=query @@ -40,9 +40,9 @@ internal object SentryOkHttpUtils { val sentryRequest = io.sentry.protocol.Request().apply { urlDetails.applyToRequest(this) // Cookie is only sent if isSendDefaultPii is enabled - cookies = if (hub.options.isSendDefaultPii) request.headers["Cookie"] else null + cookies = if (scopes.options.isSendDefaultPii) request.headers["Cookie"] else null method = request.method - headers = getHeaders(hub, request.headers) + headers = getHeaders(scopes, request.headers) request.body?.contentLength().ifHasValidLength { bodySize = it @@ -51,8 +51,8 @@ internal object SentryOkHttpUtils { val sentryResponse = io.sentry.protocol.Response().apply { // Set-Cookie is only sent if isSendDefaultPii is enabled due to PII - cookies = if (hub.options.isSendDefaultPii) response.headers["Set-Cookie"] else null - headers = getHeaders(hub, response.headers) + cookies = if (scopes.options.isSendDefaultPii) response.headers["Set-Cookie"] else null + headers = getHeaders(scopes, response.headers) statusCode = response.code response.body?.contentLength().ifHasValidLength { @@ -63,7 +63,7 @@ internal object SentryOkHttpUtils { event.request = sentryRequest event.contexts.setResponse(sentryResponse) - hub.captureEvent(event, hint) + scopes.captureEvent(event, hint) } private fun Long?.ifHasValidLength(fn: (Long) -> Unit) { @@ -72,9 +72,9 @@ internal object SentryOkHttpUtils { } } - private fun getHeaders(hub: IHub, requestHeaders: Headers): MutableMap? { + private fun getHeaders(scopes: IScopes, requestHeaders: Headers): MutableMap? { // Headers are only sent if isSendDefaultPii is enabled due to PII - if (!hub.options.isSendDefaultPii) { + if (!scopes.options.isSendDefaultPii) { return null } diff --git a/sentry-okhttp/src/test/java/io/sentry/okhttp/SentryOkHttpEventListenerTest.kt b/sentry-okhttp/src/test/java/io/sentry/okhttp/SentryOkHttpEventListenerTest.kt index 88727c4c0d..ab179189b4 100644 --- a/sentry-okhttp/src/test/java/io/sentry/okhttp/SentryOkHttpEventListenerTest.kt +++ b/sentry-okhttp/src/test/java/io/sentry/okhttp/SentryOkHttpEventListenerTest.kt @@ -1,14 +1,13 @@ package io.sentry.okhttp import io.sentry.BaggageHeader -import io.sentry.IHub +import io.sentry.IScopes import io.sentry.SentryOptions import io.sentry.SentryTraceHeader import io.sentry.SentryTracer import io.sentry.SpanDataConvention import io.sentry.SpanStatus import io.sentry.TransactionContext -import io.sentry.test.ImmediateExecutorService import okhttp3.Call import okhttp3.EventListener import okhttp3.OkHttpClient @@ -23,7 +22,6 @@ import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.eq import org.mockito.kotlin.mock -import org.mockito.kotlin.never import org.mockito.kotlin.spy import org.mockito.kotlin.verify import org.mockito.kotlin.whenever @@ -36,7 +34,7 @@ import kotlin.test.assertTrue class SentryOkHttpEventListenerTest { class Fixture { - val hub = mock() + val scopes = mock() val server = MockWebServer() val mockEventListener = mock() val mockEventListenerFactory = mock() @@ -63,12 +61,12 @@ class SentryOkHttpEventListenerTest { isSendDefaultPii = sendDefaultPii configureOptions(this) } - whenever(hub.options).thenReturn(options) + whenever(scopes.options).thenReturn(options) - sentryTracer = SentryTracer(TransactionContext("name", "op"), hub) + sentryTracer = SentryTracer(TransactionContext("name", "op"), scopes) if (isSpanActive) { - whenever(hub.span).thenReturn(sentryTracer) + whenever(scopes.span).thenReturn(sentryTracer) } server.enqueue( MockResponse() @@ -80,12 +78,12 @@ class SentryOkHttpEventListenerTest { val builder = OkHttpClient.Builder() if (useInterceptor) { - builder.addInterceptor(SentryOkHttpInterceptor(hub)) + builder.addInterceptor(SentryOkHttpInterceptor(scopes)) } sentryOkHttpEventListener = when { - eventListenerFactory != null -> SentryOkHttpEventListener(hub, eventListenerFactory) - eventListener != null -> SentryOkHttpEventListener(hub, eventListener) - else -> SentryOkHttpEventListener(hub) + eventListenerFactory != null -> SentryOkHttpEventListener(scopes, eventListenerFactory) + eventListener != null -> SentryOkHttpEventListener(scopes, eventListener) + else -> SentryOkHttpEventListener(scopes) } return builder.eventListener(sentryOkHttpEventListener).build() } @@ -135,7 +133,7 @@ class SentryOkHttpEventListenerTest { val call = sut.newCall(request) val response = call.execute() val okHttpEvent = SentryOkHttpEventListener.eventMap[call] - val callSpan = okHttpEvent?.callRootSpan + val callSpan = okHttpEvent?.callSpan response.close() assertNotNull(callSpan) assertEquals(callSpan, fixture.sentryTracer.children.first()) @@ -146,76 +144,40 @@ class SentryOkHttpEventListenerTest { } @Test - fun `creates a span for each event`() { + fun `adds a data for each event`() { val sut = fixture.getSut() val request = getRequest() val call = sut.newCall(request) val response = call.execute() val okHttpEvent = SentryOkHttpEventListener.eventMap[call] - val callSpan = okHttpEvent?.callRootSpan + val callSpan = okHttpEvent?.callSpan response.close() - assertEquals(8, fixture.sentryTracer.children.size) - fixture.sentryTracer.children.forEachIndexed { index, span -> - assertTrue(span.isFinished) - when (index) { - 0 -> { - assertEquals(callSpan, span) - assertEquals("GET ${request.url}", span.description) - assertNotNull(span.data["proxies"]) - assertNotNull(span.data["domain_name"]) - assertNotNull(span.data["dns_addresses"]) - assertEquals(201, span.data[SpanDataConvention.HTTP_STATUS_CODE_KEY]) - } - 1 -> { - assertEquals("http.client.proxy_select", span.operation) - assertEquals("GET ${request.url}", span.description) - assertNotNull(span.data["proxies"]) - } - 2 -> { - assertEquals("http.client.dns", span.operation) - assertEquals("GET ${request.url}", span.description) - assertNotNull(span.data["domain_name"]) - assertNotNull(span.data["dns_addresses"]) - } - 3 -> { - assertEquals("http.client.connect", span.operation) - assertEquals("GET ${request.url}", span.description) - } - 4 -> { - assertEquals("http.client.connection", span.operation) - assertEquals("GET ${request.url}", span.description) - } - 5 -> { - assertEquals("http.client.request_headers", span.operation) - assertEquals("GET ${request.url}", span.description) - } - 6 -> { - assertEquals("http.client.response_headers", span.operation) - assertEquals("GET ${request.url}", span.description) - assertEquals(201, span.data[SpanDataConvention.HTTP_STATUS_CODE_KEY]) - } - 7 -> { - assertEquals("http.client.response_body", span.operation) - assertEquals("GET ${request.url}", span.description) - } - } - } + assertEquals(1, fixture.sentryTracer.children.size) + assertNotNull(callSpan) + assertNotNull(callSpan.getData("proxies")) + assertNotNull(callSpan.getData("domain_name")) + assertNotNull(callSpan.getData("dns_addresses")) + assertEquals(201, callSpan.getData(SpanDataConvention.HTTP_STATUS_CODE_KEY)) + assertNotNull(callSpan.getData("http.client.proxy_select_ms")) + assertNotNull(callSpan.getData("http.client.resolve_dns_ms")) + assertNotNull(callSpan.getData("http.connect_ms")) + assertNotNull(callSpan.getData("http.connection_ms")) + assertNotNull(callSpan.getData("http.connection.request_headers_ms")) + assertNotNull(callSpan.getData("http.connection.response_headers_ms")) + assertNotNull(callSpan.getData("http.connection.response_body_ms")) } @Test - fun `has requestBody span for requests with body`() { + fun `has requestBody data for requests with body`() { val sut = fixture.getSut() val requestBody = "request body sent in the request" val request = postRequest(body = requestBody) val call = sut.newCall(request) val response = call.execute() val okHttpEvent = SentryOkHttpEventListener.eventMap[call] - val callSpan = okHttpEvent?.callRootSpan + val callSpan = okHttpEvent?.callSpan response.close() - assertEquals(9, fixture.sentryTracer.children.size) - val requestBodySpan = fixture.sentryTracer.children.firstOrNull { it.operation == "http.client.request_body" } - assertNotNull(requestBodySpan) - assertEquals(requestBody.toByteArray().size.toLong(), requestBodySpan.data["http.request_content_length"]) + assertNotNull(callSpan?.getData("http.connection.request_body_ms")) assertEquals(requestBody.toByteArray().size.toLong(), callSpan?.getData("http.request_content_length")) } @@ -227,19 +189,15 @@ class SentryOkHttpEventListenerTest { val call = sut.newCall(request) val response = call.execute() val okHttpEvent = SentryOkHttpEventListener.eventMap[call] - val callSpan = okHttpEvent?.callRootSpan + val callSpan = okHttpEvent?.callSpan + assertNull(callSpan?.getData("http.connection.response_body_ms")) // Consume the response val responseBytes = response.body?.byteStream()?.readBytes() assertNotNull(responseBytes) + assertNotNull(callSpan?.getData("http.connection.response_body_ms")) response.close() - val requestBodySpan = fixture.sentryTracer.children.firstOrNull { it.operation == "http.client.response_body" } - assertNotNull(requestBodySpan) - assertEquals( - responseBytes.size.toLong(), - requestBodySpan.data[SpanDataConvention.HTTP_RESPONSE_CONTENT_LENGTH_KEY] - ) assertEquals( responseBytes.size.toLong(), callSpan?.getData(SpanDataConvention.HTTP_RESPONSE_CONTENT_LENGTH_KEY) @@ -247,13 +205,13 @@ class SentryOkHttpEventListenerTest { } @Test - fun `root call span status depends on http status code`() { + fun `call span status depends on http status code`() { val sut = fixture.getSut(httpStatusCode = 404) val request = getRequest() val call = sut.newCall(request) val response = call.execute() val okHttpEvent = SentryOkHttpEventListener.eventMap[call] - val callSpan = okHttpEvent?.callRootSpan + val callSpan = okHttpEvent?.callSpan response.close() assertNotNull(callSpan) assertEquals(SpanStatus.fromHttpStatusCode(404), callSpan.status) @@ -283,7 +241,7 @@ class SentryOkHttpEventListenerTest { @Test fun `propagate all calls to the SentryOkHttpEventListener passed in the ctor`() { - val originalListener = spy(SentryOkHttpEventListener(fixture.hub, fixture.mockEventListener)) + val originalListener = spy(SentryOkHttpEventListener(fixture.scopes, fixture.mockEventListener)) val sut = fixture.getSut(eventListener = originalListener) val listener = fixture.sentryOkHttpEventListener val request = postRequest(body = "requestBody") @@ -295,7 +253,7 @@ class SentryOkHttpEventListenerTest { @Test fun `propagate all calls to the SentryOkHttpEventListener factory passed in the ctor`() { - val originalListener = spy(SentryOkHttpEventListener(fixture.hub, fixture.mockEventListener)) + val originalListener = spy(SentryOkHttpEventListener(fixture.scopes, fixture.mockEventListener)) val sut = fixture.getSut(eventListenerFactory = { originalListener }) val listener = fixture.sentryOkHttpEventListener val request = postRequest(body = "requestBody") @@ -307,86 +265,23 @@ class SentryOkHttpEventListenerTest { @Test fun `does not duplicated spans if an SentryOkHttpEventListener is passed in the ctor`() { - val originalListener = spy(SentryOkHttpEventListener(fixture.hub, fixture.mockEventListener)) + val originalListener = spy(SentryOkHttpEventListener(fixture.scopes, fixture.mockEventListener)) val sut = fixture.getSut(eventListener = originalListener) val request = postRequest(body = "requestBody") val call = sut.newCall(request) val response = call.execute() response.close() // Spans are created by the originalListener, so the listener doesn't create duplicates - assertEquals(9, fixture.sentryTracer.children.size) - } - - @Test - fun `status propagates to parent span and call root span`() { - val sut = fixture.getSut(httpStatusCode = 500) - val request = getRequest() - val call = sut.newCall(request) - val response = call.execute() - val okHttpEvent = SentryOkHttpEventListener.eventMap[call] - val callSpan = okHttpEvent?.callRootSpan - val responseHeaderSpan = - fixture.sentryTracer.children.firstOrNull { it.operation == "http.client.response_headers" } - val connectionSpan = fixture.sentryTracer.children.firstOrNull { it.operation == "http.client.connection" } - response.close() - assertNotNull(callSpan) - assertNotNull(responseHeaderSpan) - assertNotNull(connectionSpan) - assertEquals(SpanStatus.fromHttpStatusCode(500), callSpan.status) - assertEquals(SpanStatus.fromHttpStatusCode(500), responseHeaderSpan.status) - assertEquals(SpanStatus.fromHttpStatusCode(500), connectionSpan.status) - } - - @Test - fun `when response is not closed, root call is trimmed to responseHeadersEnd`() { - val sut = fixture.getSut( - httpStatusCode = 500, - configureOptions = { it.executorService = ImmediateExecutorService() } - ) - val request = getRequest() - val call = sut.newCall(request) - val response = spy(call.execute()) - val okHttpEvent = SentryOkHttpEventListener.eventMap[call] - val callSpan = okHttpEvent?.callRootSpan - val responseHeaderSpan = - fixture.sentryTracer.children.firstOrNull { it.operation == "http.client.response_headers" } - val responseBodySpan = fixture.sentryTracer.children.firstOrNull { it.operation == "http.client.response_body" } - - // Response is not finished - verify(response, never()).close() - - // response body span is never started - assertNull(responseBodySpan) - - assertNotNull(callSpan) - assertNotNull(responseHeaderSpan) - - // Call span is trimmed to responseHeader finishTimestamp - assertEquals(callSpan.finishDate?.nanoTimestamp(), responseHeaderSpan.finishDate?.nanoTimestamp()) - - // All children spans of the root call are finished - assertTrue(fixture.sentryTracer.children.all { it.isFinished }) - } - - @Test - fun `responseHeadersEnd schedules event finish`() { - val listener = SentryOkHttpEventListener(fixture.hub, fixture.mockEventListener) - whenever(fixture.hub.options).thenReturn(SentryOptions()) - val call = mock() - whenever(call.request()).thenReturn(getRequest()) - val okHttpEvent = mock() - SentryOkHttpEventListener.eventMap[call] = okHttpEvent - listener.responseHeadersEnd(call, mock()) - verify(okHttpEvent).scheduleFinish(any()) + assertEquals(1, fixture.sentryTracer.children.size) } @Test - fun `call root span status is not overridden if not null`() { + fun `call span status is not overridden if not null`() { val mockListener = mock() lateinit var call: Call whenever(mockListener.connectStart(any(), anyOrNull(), anyOrNull())).then { val okHttpEvent = SentryOkHttpEventListener.eventMap[call] - val callSpan = okHttpEvent?.callRootSpan + val callSpan = okHttpEvent?.callSpan assertNotNull(callSpan) assertNull(callSpan.status) callSpan.status = SpanStatus.UNKNOWN @@ -397,7 +292,7 @@ class SentryOkHttpEventListenerTest { call = sut.newCall(request) val response = call.execute() val okHttpEvent = SentryOkHttpEventListener.eventMap[call] - val callSpan = okHttpEvent?.callRootSpan + val callSpan = okHttpEvent?.callSpan assertNotNull(callSpan) response.close() assertEquals(SpanStatus.UNKNOWN, callSpan.status) diff --git a/sentry-okhttp/src/test/java/io/sentry/okhttp/SentryOkHttpEventTest.kt b/sentry-okhttp/src/test/java/io/sentry/okhttp/SentryOkHttpEventTest.kt index 4b41b75c1e..33f9b04d85 100644 --- a/sentry-okhttp/src/test/java/io/sentry/okhttp/SentryOkHttpEventTest.kt +++ b/sentry-okhttp/src/test/java/io/sentry/okhttp/SentryOkHttpEventTest.kt @@ -2,8 +2,7 @@ package io.sentry.okhttp import io.sentry.Breadcrumb import io.sentry.Hint -import io.sentry.IHub -import io.sentry.ISentryExecutorService +import io.sentry.IScopes import io.sentry.ISpan import io.sentry.SentryDate import io.sentry.SentryOptions @@ -11,19 +10,10 @@ import io.sentry.SentryTracer import io.sentry.Span import io.sentry.SpanDataConvention import io.sentry.SpanOptions -import io.sentry.SpanStatus import io.sentry.TracesSamplingDecision import io.sentry.TransactionContext import io.sentry.TypeCheckHint import io.sentry.exception.SentryHttpClientException -import io.sentry.okhttp.SentryOkHttpEventListener.Companion.CONNECTION_EVENT -import io.sentry.okhttp.SentryOkHttpEventListener.Companion.CONNECT_EVENT -import io.sentry.okhttp.SentryOkHttpEventListener.Companion.REQUEST_BODY_EVENT -import io.sentry.okhttp.SentryOkHttpEventListener.Companion.REQUEST_HEADERS_EVENT -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.test.ImmediateExecutorService import io.sentry.test.getProperty import okhttp3.Protocol import okhttp3.Request @@ -33,14 +23,11 @@ import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.argThat import org.mockito.kotlin.check -import org.mockito.kotlin.eq import org.mockito.kotlin.mock import org.mockito.kotlin.never -import org.mockito.kotlin.spy import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.whenever -import java.util.concurrent.RejectedExecutionException import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -50,14 +37,14 @@ import kotlin.test.assertTrue class SentryOkHttpEventTest { private class Fixture { - val hub = mock() + val scopes = mock() val server = MockWebServer() val span: ISpan val mockRequest: Request val response: Response init { - whenever(hub.options).thenReturn( + whenever(scopes.options).thenReturn( SentryOptions().apply { dsn = "https://key@sentry.io/proj" } @@ -65,9 +52,8 @@ class SentryOkHttpEventTest { span = Span( TransactionContext("name", "op", TracesSamplingDecision(true)), - SentryTracer(TransactionContext("name", "op", TracesSamplingDecision(true)), hub), - hub, - null, + SentryTracer(TransactionContext("name", "op", TracesSamplingDecision(true)), scopes), + scopes, SpanOptions() ) @@ -86,7 +72,7 @@ class SentryOkHttpEventTest { } fun getSut(currentSpan: ISpan? = span, requestUrl: String ? = null): SentryOkHttpEvent { - whenever(hub.span).thenReturn(currentSpan) + whenever(scopes.span).thenReturn(currentSpan) val request = if (requestUrl == null) { mockRequest } else { @@ -96,22 +82,22 @@ class SentryOkHttpEventTest { .url(server.url(requestUrl)) .build() } - return SentryOkHttpEvent(hub, request) + return SentryOkHttpEvent(scopes, request) } } private val fixture = Fixture() @Test - fun `when there is no active span, root span is null`() { + fun `when there is no active span, call span is null`() { val sut = fixture.getSut(currentSpan = null) - assertNull(sut.callRootSpan) + assertNull(sut.callSpan) } @Test - fun `when there is an active span, a root span is created`() { + fun `when there is an active span, a call span is created`() { val sut = fixture.getSut() - val callSpan = sut.callRootSpan + val callSpan = sut.callSpan assertNotNull(callSpan) assertEquals("http.client", callSpan.operation) assertEquals("${fixture.mockRequest.method} ${fixture.mockRequest.url}", callSpan.description) @@ -122,127 +108,120 @@ class SentryOkHttpEventTest { } @Test - fun `when root span is null, breadcrumb is created anyway`() { + fun `when call span is null, breadcrumb is created anyway`() { val sut = fixture.getSut(currentSpan = null) - assertNull(sut.callRootSpan) - sut.finishEvent() - verify(fixture.hub).addBreadcrumb(any(), anyOrNull()) + assertNull(sut.callSpan) + sut.finish() + verify(fixture.scopes).addBreadcrumb(any(), anyOrNull()) } @Test - fun `when root span is null, no span is created`() { + fun `when call span is null, no event is recorded`() { val sut = fixture.getSut(currentSpan = null) - assertNull(sut.callRootSpan) - sut.startSpan("span") - assertTrue(sut.getEventSpans().isEmpty()) + assertNull(sut.callSpan) + sut.onEventStart("span") + assertTrue(sut.getEventDates().isEmpty()) } @Test - fun `when event is finished, root span is finished`() { + fun `when event is finished, call span is finished`() { val sut = fixture.getSut() - val rootSpan = sut.callRootSpan + val rootSpan = sut.callSpan assertNotNull(rootSpan) assertFalse(rootSpan.isFinished) - sut.finishEvent() + sut.finish() assertTrue(rootSpan.isFinished) } @Test - fun `when startSpan, a new span is started`() { + fun `when onEventStart, a new event is recorded`() { val sut = fixture.getSut() - assertTrue(sut.getEventSpans().isEmpty()) - sut.startSpan("span") - val spans = sut.getEventSpans() - assertEquals(1, spans.size) - val span = spans["span"] - assertNotNull(span) - assertTrue(spans.containsKey("span")) - assertEquals("http.client.span", span.operation) - assertEquals("${fixture.mockRequest.method} ${fixture.mockRequest.url}", span.description) - assertFalse(span.isFinished) + val callSpan = sut.callSpan + assertTrue(sut.getEventDates().isEmpty()) + sut.onEventStart("span") + val dates = sut.getEventDates() + assertEquals(1, dates.size) + assertNull(callSpan!!.getData("span")) } @Test - fun `when finishSpan, a span is finished if previously started`() { + fun `when onEventFinish, an event is added to call span`() { val sut = fixture.getSut() - assertTrue(sut.getEventSpans().isEmpty()) - sut.startSpan("span") - val spans = sut.getEventSpans() - assertFalse(spans["span"]!!.isFinished) - sut.finishSpan("span") - assertTrue(spans["span"]!!.isFinished) + val callSpan = sut.callSpan + assertTrue(sut.getEventDates().isEmpty()) + sut.onEventStart("span") + val dates = sut.getEventDates() + assertEquals(1, dates.size) + assertNull(callSpan!!.getData("span")) + sut.onEventFinish("span") + assertEquals(0, dates.size) + assertNotNull(callSpan.getData("span")) } @Test - fun `when finishSpan, a callback is called before the span is finished`() { + fun `when onEventFinish, a callback is called before the event is set`() { val sut = fixture.getSut() + val callSpan = sut.callSpan var called = false - assertTrue(sut.getEventSpans().isEmpty()) - sut.startSpan("span") - val spans = sut.getEventSpans() - assertFalse(spans["span"]!!.isFinished) - sut.finishSpan("span") { + assertTrue(sut.getEventDates().isEmpty()) + sut.onEventStart("span") + assertNull(callSpan!!.getData("span")) + sut.onEventFinish("span") { called = true - assertFalse(it.isFinished) + assertNull(callSpan.getData("span")) } - assertTrue(spans["span"]!!.isFinished) + assertNotNull(callSpan.getData("span")) assertTrue(called) } @Test - fun `when finishSpan, a callback is called with the current span and the root call span is finished`() { + fun `when onEventFinish, a callback is called only once with the call span`() { val sut = fixture.getSut() var called = 0 - sut.startSpan("span") - sut.finishSpan("span") { - if (called == 0) { - assertEquals("http.client.span", it.operation) - assertEquals("${fixture.mockRequest.method} ${fixture.mockRequest.url}", it.description) - } else { - assertEquals(sut.callRootSpan, it) - } + sut.onEventStart("span") + sut.onEventFinish("span") { called++ - assertFalse(it.isFinished) } - assertEquals(2, called) + assertEquals(1, called) } @Test - fun `finishSpan is ignored if the span was not previously started`() { + fun `onEventFinish is ignored if the span was not previously started`() { val sut = fixture.getSut() var called = false - assertTrue(sut.getEventSpans().isEmpty()) - sut.finishSpan("span") { called = true } - assertTrue(sut.getEventSpans().isEmpty()) + assertTrue(sut.getEventDates().isEmpty()) + sut.onEventFinish("span") { called = true } + assertTrue(sut.getEventDates().isEmpty()) assertFalse(called) } @Test - fun `when finishEvent, a callback is called with the call root span before it is finished`() { + fun `when finish, a callback is called with the call span before it is finished`() { val sut = fixture.getSut() var called = false - sut.finishEvent { + sut.finish { called = true - assertEquals(sut.callRootSpan, it) + assertEquals(sut.callSpan, it) + assertFalse(it.isFinished) } assertTrue(called) } @Test - fun `when finishEvent, all running spans are finished`() { + fun `when finish, all event dates are cleared`() { val sut = fixture.getSut() - sut.startSpan("span") - val spans = sut.getEventSpans() - assertFalse(spans["span"]!!.isFinished) - sut.finishEvent() - assertTrue(spans["span"]!!.isFinished) + sut.onEventStart("span") + val dates = sut.getEventDates() + assertFalse(dates.isEmpty()) + sut.finish() + assertTrue(dates.isEmpty()) } @Test - fun `when finishEvent, a breadcrumb is captured with request in the hint`() { + fun `when finish, a breadcrumb is captured with request in the hint`() { val sut = fixture.getSut() - sut.finishEvent() - verify(fixture.hub).addBreadcrumb( + sut.finish() + verify(fixture.scopes).addBreadcrumb( check { assertEquals(fixture.mockRequest.url.toString(), it.data["url"]) assertEquals(fixture.mockRequest.url.host, it.data["host"]) @@ -256,36 +235,23 @@ class SentryOkHttpEventTest { } @Test - fun `when finishEvent multiple times, only one breadcrumb is captured`() { - val sut = fixture.getSut() - sut.finishEvent() - sut.finishEvent() - verify(fixture.hub, times(1)).addBreadcrumb(any(), any()) - } - - @Test - fun `when finishEvent, does not override running spans status if set`() { + fun `when finish multiple times, only one breadcrumb is captured`() { val sut = fixture.getSut() - sut.startSpan("span") - val spans = sut.getEventSpans() - assertNull(spans["span"]!!.status) - spans["span"]!!.status = SpanStatus.OK - assertEquals(SpanStatus.OK, spans["span"]!!.status) - sut.finishEvent() - assertTrue(spans["span"]!!.isFinished) - assertEquals(SpanStatus.OK, spans["span"]!!.status) + sut.finish() + sut.finish() + verify(fixture.scopes, times(1)).addBreadcrumb(any(), any()) } @Test - fun `setResponse set protocol and code in the breadcrumb and root span, and response in the hint`() { + fun `setResponse set protocol and code in the breadcrumb and call span, and response in the hint`() { val sut = fixture.getSut() sut.setResponse(fixture.response) - assertEquals(fixture.response.protocol.name, sut.callRootSpan?.getData("protocol")) - assertEquals(fixture.response.code, sut.callRootSpan?.getData(SpanDataConvention.HTTP_STATUS_CODE_KEY)) - sut.finishEvent() + assertEquals(fixture.response.protocol.name, sut.callSpan?.getData("protocol")) + assertEquals(fixture.response.code, sut.callSpan?.getData(SpanDataConvention.HTTP_STATUS_CODE_KEY)) + sut.finish() - verify(fixture.hub).addBreadcrumb( + verify(fixture.scopes).addBreadcrumb( check { assertEquals(fixture.response.protocol.name, it.data["protocol"]) assertEquals(fixture.response.code, it.data["status_code"]) @@ -297,12 +263,12 @@ class SentryOkHttpEventTest { } @Test - fun `setProtocol set protocol in the breadcrumb and in the root span`() { + fun `setProtocol set protocol in the breadcrumb and in the call span`() { val sut = fixture.getSut() sut.setProtocol("protocol") - assertEquals("protocol", sut.callRootSpan?.getData("protocol")) - sut.finishEvent() - verify(fixture.hub).addBreadcrumb( + assertEquals("protocol", sut.callSpan?.getData("protocol")) + sut.finish() + verify(fixture.scopes).addBreadcrumb( check { assertEquals("protocol", it.data["protocol"]) }, @@ -314,9 +280,9 @@ class SentryOkHttpEventTest { fun `setProtocol is ignored if protocol is null`() { val sut = fixture.getSut() sut.setProtocol(null) - assertNull(sut.callRootSpan?.getData("protocol")) - sut.finishEvent() - verify(fixture.hub).addBreadcrumb( + assertNull(sut.callSpan?.getData("protocol")) + sut.finish() + verify(fixture.scopes).addBreadcrumb( check { assertNull(it.data["protocol"]) }, @@ -325,12 +291,12 @@ class SentryOkHttpEventTest { } @Test - fun `setRequestBodySize set RequestBodySize in the breadcrumb and in the root span`() { + fun `setRequestBodySize set RequestBodySize in the breadcrumb and in the call span`() { val sut = fixture.getSut() sut.setRequestBodySize(10) - assertEquals(10L, sut.callRootSpan?.getData("http.request_content_length")) - sut.finishEvent() - verify(fixture.hub).addBreadcrumb( + assertEquals(10L, sut.callSpan?.getData("http.request_content_length")) + sut.finish() + verify(fixture.scopes).addBreadcrumb( check { assertEquals(10L, it.data["request_content_length"]) }, @@ -342,9 +308,9 @@ class SentryOkHttpEventTest { fun `setRequestBodySize is ignored if RequestBodySize is negative`() { val sut = fixture.getSut() sut.setRequestBodySize(-1) - assertNull(sut.callRootSpan?.getData("http.request_content_length")) - sut.finishEvent() - verify(fixture.hub).addBreadcrumb( + assertNull(sut.callSpan?.getData("http.request_content_length")) + sut.finish() + verify(fixture.scopes).addBreadcrumb( check { assertNull(it.data["request_content_length"]) }, @@ -353,12 +319,12 @@ class SentryOkHttpEventTest { } @Test - fun `setResponseBodySize set ResponseBodySize in the breadcrumb and in the root span`() { + fun `setResponseBodySize set ResponseBodySize in the breadcrumb and in the call span`() { val sut = fixture.getSut() sut.setResponseBodySize(10) - assertEquals(10L, sut.callRootSpan?.getData(SpanDataConvention.HTTP_RESPONSE_CONTENT_LENGTH_KEY)) - sut.finishEvent() - verify(fixture.hub).addBreadcrumb( + assertEquals(10L, sut.callSpan?.getData(SpanDataConvention.HTTP_RESPONSE_CONTENT_LENGTH_KEY)) + sut.finish() + verify(fixture.scopes).addBreadcrumb( check { assertEquals(10L, it.data["response_content_length"]) }, @@ -370,9 +336,9 @@ class SentryOkHttpEventTest { fun `setResponseBodySize is ignored if ResponseBodySize is negative`() { val sut = fixture.getSut() sut.setResponseBodySize(-1) - assertNull(sut.callRootSpan?.getData(SpanDataConvention.HTTP_RESPONSE_CONTENT_LENGTH_KEY)) - sut.finishEvent() - verify(fixture.hub).addBreadcrumb( + assertNull(sut.callSpan?.getData(SpanDataConvention.HTTP_RESPONSE_CONTENT_LENGTH_KEY)) + sut.finish() + verify(fixture.scopes).addBreadcrumb( check { assertNull(it.data["response_content_length"]) }, @@ -381,12 +347,12 @@ class SentryOkHttpEventTest { } @Test - fun `setError set success to false and errorMessage in the breadcrumb and in the root span`() { + fun `setError set success to false and errorMessage in the breadcrumb and in the call span`() { val sut = fixture.getSut() sut.setError("errorMessage") - assertEquals("errorMessage", sut.callRootSpan?.getData("error_message")) - sut.finishEvent() - verify(fixture.hub).addBreadcrumb( + assertEquals("errorMessage", sut.callSpan?.getData("error_message")) + sut.finish() + verify(fixture.scopes).addBreadcrumb( check { assertEquals("errorMessage", it.data["error_message"]) }, @@ -395,13 +361,13 @@ class SentryOkHttpEventTest { } @Test - fun `setError sets success to false in the breadcrumb and in the root span even if errorMessage is null`() { + fun `setError sets success to false in the breadcrumb and in the call span even if errorMessage is null`() { val sut = fixture.getSut() sut.setError(null) - assertNotNull(sut.callRootSpan) - assertNull(sut.callRootSpan.getData("error_message")) - sut.finishEvent() - verify(fixture.hub).addBreadcrumb( + assertNotNull(sut.callSpan) + assertNull(sut.callSpan.getData("error_message")) + sut.finish() + verify(fixture.scopes).addBreadcrumb( check { assertNull(it.data["error_message"]) }, @@ -410,167 +376,15 @@ class SentryOkHttpEventTest { } @Test - fun `secureConnect span is child of connect span`() { - val sut = fixture.getSut() - sut.startSpan(CONNECT_EVENT) - sut.startSpan(SECURE_CONNECT_EVENT) - val spans = sut.getEventSpans() - val secureConnectSpan = spans[SECURE_CONNECT_EVENT] as Span? - val connectSpan = spans[CONNECT_EVENT] as Span? - assertNotNull(secureConnectSpan) - assertNotNull(connectSpan) - assertEquals(connectSpan.spanId, secureConnectSpan.parentSpanId) - } - - @Test - fun `secureConnect span is child of root span if connect span is not available`() { - val sut = fixture.getSut() - sut.startSpan(SECURE_CONNECT_EVENT) - val spans = sut.getEventSpans() - val rootSpan = sut.callRootSpan as Span? - val secureConnectSpan = spans[SECURE_CONNECT_EVENT] as Span? - assertNotNull(secureConnectSpan) - assertNotNull(rootSpan) - assertEquals(rootSpan.spanId, secureConnectSpan.parentSpanId) - } - - @Test - fun `request and response spans are children of connection span`() { - val sut = fixture.getSut() - sut.startSpan(CONNECTION_EVENT) - sut.startSpan(REQUEST_HEADERS_EVENT) - sut.startSpan(REQUEST_BODY_EVENT) - sut.startSpan(RESPONSE_HEADERS_EVENT) - sut.startSpan(RESPONSE_BODY_EVENT) - val spans = sut.getEventSpans() - val connectionSpan = spans[CONNECTION_EVENT] as Span? - val requestHeadersSpan = spans[REQUEST_HEADERS_EVENT] as Span? - val requestBodySpan = spans[REQUEST_BODY_EVENT] as Span? - val responseHeadersSpan = spans[RESPONSE_HEADERS_EVENT] as Span? - val responseBodySpan = spans[RESPONSE_BODY_EVENT] as Span? - assertNotNull(connectionSpan) - assertEquals(connectionSpan.spanId, requestHeadersSpan?.parentSpanId) - assertEquals(connectionSpan.spanId, requestBodySpan?.parentSpanId) - assertEquals(connectionSpan.spanId, responseHeadersSpan?.parentSpanId) - assertEquals(connectionSpan.spanId, responseBodySpan?.parentSpanId) - } - - @Test - fun `request and response spans are children of root span if connection span is not available`() { - val sut = fixture.getSut() - sut.startSpan(REQUEST_HEADERS_EVENT) - sut.startSpan(REQUEST_BODY_EVENT) - sut.startSpan(RESPONSE_HEADERS_EVENT) - sut.startSpan(RESPONSE_BODY_EVENT) - val spans = sut.getEventSpans() - val connectionSpan = spans[CONNECTION_EVENT] as Span? - val requestHeadersSpan = spans[REQUEST_HEADERS_EVENT] as Span? - val requestBodySpan = spans[REQUEST_BODY_EVENT] as Span? - val responseHeadersSpan = spans[RESPONSE_HEADERS_EVENT] as Span? - val responseBodySpan = spans[RESPONSE_BODY_EVENT] as Span? - val rootSpan = sut.callRootSpan as Span? - assertNotNull(rootSpan) - assertNull(connectionSpan) - assertEquals(rootSpan.spanId, requestHeadersSpan?.parentSpanId) - assertEquals(rootSpan.spanId, requestBodySpan?.parentSpanId) - assertEquals(rootSpan.spanId, responseHeadersSpan?.parentSpanId) - assertEquals(rootSpan.spanId, responseBodySpan?.parentSpanId) - } - - @Test - fun `finishSpan beforeFinish is called on span, parent and call root span`() { - val sut = fixture.getSut() - sut.startSpan(CONNECTION_EVENT) - sut.startSpan(REQUEST_HEADERS_EVENT) - sut.startSpan("random event") - sut.finishSpan(REQUEST_HEADERS_EVENT) { it.status = SpanStatus.INTERNAL_ERROR } - sut.finishSpan("random event") { it.status = SpanStatus.DEADLINE_EXCEEDED } - sut.finishSpan(CONNECTION_EVENT) - sut.finishEvent() - val spans = sut.getEventSpans() - val connectionSpan = spans[CONNECTION_EVENT] as Span? - val requestHeadersSpan = spans[REQUEST_HEADERS_EVENT] as Span? - val randomEventSpan = spans["random event"] as Span? - assertNotNull(connectionSpan) - assertNotNull(requestHeadersSpan) - assertNotNull(randomEventSpan) - // requestHeadersSpan was finished with INTERNAL_ERROR - assertEquals(SpanStatus.INTERNAL_ERROR, requestHeadersSpan.status) - // randomEventSpan was finished with DEADLINE_EXCEEDED - assertEquals(SpanStatus.DEADLINE_EXCEEDED, randomEventSpan.status) - // requestHeadersSpan was finished with INTERNAL_ERROR, and it propagates to its parent - assertEquals(SpanStatus.INTERNAL_ERROR, connectionSpan.status) - // random event was finished last with DEADLINE_EXCEEDED, and it propagates to root call - assertEquals(SpanStatus.DEADLINE_EXCEEDED, sut.callRootSpan!!.status) - } - - @Test - fun `finishEvent moves throwables from inner span to call root span`() { - val sut = fixture.getSut() - val throwable = RuntimeException() - sut.startSpan(CONNECTION_EVENT) - sut.startSpan("random event") - sut.finishSpan("random event") { it.status = SpanStatus.DEADLINE_EXCEEDED } - sut.finishSpan(CONNECTION_EVENT) { - it.status = SpanStatus.INTERNAL_ERROR - it.throwable = throwable - } - sut.finishEvent() - val spans = sut.getEventSpans() - val connectionSpan = spans[CONNECTION_EVENT] as Span? - val randomEventSpan = spans["random event"] as Span? - assertNotNull(connectionSpan) - assertNotNull(randomEventSpan) - // randomEventSpan was finished with DEADLINE_EXCEEDED - assertEquals(SpanStatus.DEADLINE_EXCEEDED, randomEventSpan.status) - // connectionSpan was finished with INTERNAL_ERROR - assertEquals(SpanStatus.INTERNAL_ERROR, connectionSpan.status) - - // connectionSpan was finished last with INTERNAL_ERROR and a throwable, and it's moved to the root call - assertEquals(SpanStatus.INTERNAL_ERROR, sut.callRootSpan!!.status) - assertEquals(throwable, sut.callRootSpan.throwable) - assertNull(connectionSpan.throwable) - } - - @Test - fun `scheduleFinish schedules finishEvent and finish running spans to specific timestamp`() { - fixture.hub.options.executorService = ImmediateExecutorService() - val sut = spy(fixture.getSut()) - val timestamp = mock() - sut.startSpan(CONNECTION_EVENT) - sut.scheduleFinish(timestamp) - verify(sut).finishEvent(eq(timestamp), anyOrNull()) - val spans = sut.getEventSpans() - assertEquals(timestamp, spans[CONNECTION_EVENT]?.finishDate) - } - - @Test - fun `finishEvent with timestamp trims call root span`() { - val sut = fixture.getSut() - val timestamp = mock() - sut.finishEvent(finishDate = timestamp) - assertEquals(timestamp, sut.callRootSpan!!.finishDate) - } - - @Test - fun `scheduleFinish does not throw if executor is shut down`() { - val executorService = mock() - whenever(executorService.schedule(any(), any())).thenThrow(RejectedExecutionException()) - whenever(fixture.hub.options).thenReturn(SentryOptions().apply { this.executorService = executorService }) - val sut = fixture.getSut() - sut.scheduleFinish(mock()) - } - - @Test - fun `setClientErrorResponse will capture the client error on finishEvent`() { + fun `setClientErrorResponse will capture the client error on finish`() { val sut = fixture.getSut() val clientErrorResponse = mock() whenever(clientErrorResponse.request).thenReturn(fixture.mockRequest) sut.setClientErrorResponse(clientErrorResponse) - verify(fixture.hub, never()).captureEvent(any(), any()) - sut.finishEvent() - assertNotNull(sut.callRootSpan) - verify(fixture.hub).captureEvent( + verify(fixture.scopes, never()).captureEvent(any(), any()) + sut.finish() + assertNotNull(sut.callSpan) + verify(fixture.scopes).captureEvent( argThat { throwable is SentryHttpClientException && throwable!!.message!!.startsWith("HTTP Client Error with status code: ") @@ -583,15 +397,15 @@ class SentryOkHttpEventTest { } @Test - fun `setClientErrorResponse will capture the client error on finishEvent even when no span is running`() { + fun `setClientErrorResponse will capture the client error on finish even when no span is running`() { val sut = fixture.getSut(currentSpan = null) val clientErrorResponse = mock() whenever(clientErrorResponse.request).thenReturn(fixture.mockRequest) sut.setClientErrorResponse(clientErrorResponse) - verify(fixture.hub, never()).captureEvent(any(), any()) - sut.finishEvent() - assertNull(sut.callRootSpan) - verify(fixture.hub).captureEvent( + verify(fixture.scopes, never()).captureEvent(any(), any()) + sut.finish() + assertNull(sut.callSpan) + verify(fixture.scopes).captureEvent( argThat { throwable is SentryHttpClientException && throwable!!.message!!.startsWith("HTTP Client Error with status code: ") @@ -606,10 +420,10 @@ class SentryOkHttpEventTest { @Test fun `when setClientErrorResponse is not called, no client error is captured`() { val sut = fixture.getSut() - sut.finishEvent() - verify(fixture.hub, never()).captureEvent(any(), any()) + sut.finish() + verify(fixture.scopes, never()).captureEvent(any(), any()) } /** Retrieve all the spans started in the event using reflection. */ - private fun SentryOkHttpEvent.getEventSpans() = getProperty>("eventSpans") + private fun SentryOkHttpEvent.getEventDates() = getProperty>("eventDates") } diff --git a/sentry-okhttp/src/test/java/io/sentry/okhttp/SentryOkHttpInterceptorTest.kt b/sentry-okhttp/src/test/java/io/sentry/okhttp/SentryOkHttpInterceptorTest.kt index fce16d9220..8d2feff06e 100644 --- a/sentry-okhttp/src/test/java/io/sentry/okhttp/SentryOkHttpInterceptorTest.kt +++ b/sentry-okhttp/src/test/java/io/sentry/okhttp/SentryOkHttpInterceptorTest.kt @@ -6,13 +6,14 @@ import io.sentry.BaggageHeader import io.sentry.Breadcrumb import io.sentry.Hint import io.sentry.HttpStatusCodeRange -import io.sentry.IHub import io.sentry.IScope +import io.sentry.IScopes import io.sentry.Scope import io.sentry.ScopeCallback import io.sentry.SentryOptions import io.sentry.SentryTraceHeader import io.sentry.SentryTracer +import io.sentry.Span import io.sentry.SpanDataConvention import io.sentry.SpanStatus import io.sentry.TransactionContext @@ -47,7 +48,7 @@ import kotlin.test.fail class SentryOkHttpInterceptorTest { class Fixture { - val hub = mock() + val scopes = mock() val server = MockWebServer() lateinit var sentryTracer: SentryTracer lateinit var options: SentryOptions @@ -82,13 +83,13 @@ class SentryOkHttpInterceptorTest { isSendDefaultPii = sendDefaultPii } scope = Scope(options) - whenever(hub.options).thenReturn(options) - doAnswer { (it.arguments[0] as ScopeCallback).run(scope) }.whenever(hub).configureScope(any()) + whenever(scopes.options).thenReturn(options) + doAnswer { (it.arguments[0] as ScopeCallback).run(scope) }.whenever(scopes).configureScope(any()) - sentryTracer = SentryTracer(TransactionContext("name", "op"), hub) + sentryTracer = SentryTracer(TransactionContext("name", "op"), scopes) if (isSpanActive) { - whenever(hub.span).thenReturn(sentryTracer) + whenever(scopes.span).thenReturn(sentryTracer) } server.enqueue( MockResponse() @@ -100,14 +101,14 @@ class SentryOkHttpInterceptorTest { val interceptor = when (captureFailedRequests) { null -> SentryOkHttpInterceptor( - hub, + scopes, beforeSpan, failedRequestTargets = failedRequestTargets, failedRequestStatusCodes = failedRequestStatusCodes ) else -> SentryOkHttpInterceptor( - hub, + scopes, beforeSpan, captureFailedRequests = captureFailedRequests, failedRequestTargets = failedRequestTargets, @@ -281,7 +282,7 @@ class SentryOkHttpInterceptorTest { fun `adds breadcrumb when http calls succeeds`() { val sut = fixture.getSut(responseBody = "response body") sut.newCall(postRequest()).execute() - verify(fixture.hub).addBreadcrumb( + verify(fixture.scopes).addBreadcrumb( check { assertEquals("http", it.type) assertEquals(13L, it.data[SpanDataConvention.HTTP_RESPONSE_CONTENT_LENGTH_KEY]) @@ -296,7 +297,7 @@ class SentryOkHttpInterceptorTest { fun `adds breadcrumb when http calls results in exception`() { // to setup mocks fixture.getSut() - val interceptor = SentryOkHttpInterceptor(fixture.hub) + val interceptor = SentryOkHttpInterceptor(fixture.scopes) val chain = mock() whenever(chain.call()).thenReturn(mock()) whenever(chain.proceed(any())).thenThrow(IOException()) @@ -308,7 +309,7 @@ class SentryOkHttpInterceptorTest { } catch (e: IOException) { // ignore me } - verify(fixture.hub).addBreadcrumb( + verify(fixture.scopes).addBreadcrumb( check { assertEquals("http", it.type) }, @@ -385,7 +386,7 @@ class SentryOkHttpInterceptorTest { ) sut.newCall(getRequest()).execute() - verify(fixture.hub).captureEvent(any(), any()) + verify(fixture.scopes).captureEvent(any(), any()) } @Test @@ -396,7 +397,7 @@ class SentryOkHttpInterceptorTest { ) sut.newCall(getRequest()).execute() - verify(fixture.hub).captureEvent(any(), any()) + verify(fixture.scopes).captureEvent(any(), any()) } @Test @@ -406,7 +407,7 @@ class SentryOkHttpInterceptorTest { ) sut.newCall(getRequest()).execute() - verify(fixture.hub, never()).captureEvent(any(), any()) + verify(fixture.scopes, never()).captureEvent(any(), any()) } @Test @@ -417,7 +418,7 @@ class SentryOkHttpInterceptorTest { ) sut.newCall(getRequest()).execute() - verify(fixture.hub, never()).captureEvent(any(), any()) + verify(fixture.scopes, never()).captureEvent(any(), any()) } @Test @@ -429,7 +430,7 @@ class SentryOkHttpInterceptorTest { ) sut.newCall(getRequest()).execute() - verify(fixture.hub, never()).captureEvent(any(), any()) + verify(fixture.scopes, never()).captureEvent(any(), any()) } @Test @@ -440,7 +441,7 @@ class SentryOkHttpInterceptorTest { ) sut.newCall(getRequest()).execute() - verify(fixture.hub).captureEvent( + verify(fixture.scopes).captureEvent( any(), check { assertNotNull(it.get(TypeCheckHint.OKHTTP_REQUEST)) @@ -462,7 +463,7 @@ class SentryOkHttpInterceptorTest { val request = getRequest(url = "/hello?myQuery=myValue#myFragment") val response = sut.newCall(request).execute() - verify(fixture.hub).captureEvent( + verify(fixture.scopes).captureEvent( check { val sentryRequest = it.request!! assertEquals("http://localhost:${fixture.server.port}/hello", sentryRequest.url) @@ -503,7 +504,7 @@ class SentryOkHttpInterceptorTest { sut.newCall(postRequest(body = body)).execute() - verify(fixture.hub).captureEvent( + verify(fixture.scopes).captureEvent( check { val sentryRequest = it.request!! assertEquals(body.contentLength(), sentryRequest.bodySize) @@ -522,7 +523,7 @@ class SentryOkHttpInterceptorTest { sut.newCall(getRequest()).execute() - verify(fixture.hub).captureEvent( + verify(fixture.scopes).captureEvent( check { val sentryRequest = it.request!! assertEquals("myValue", sentryRequest.headers!!["myHeader"]) @@ -540,7 +541,7 @@ class SentryOkHttpInterceptorTest { // to setup mocks fixture.getSut() val interceptor = SentryOkHttpInterceptor( - fixture.hub, + fixture.scopes, captureFailedRequests = true ) val chain = mock() @@ -554,7 +555,7 @@ class SentryOkHttpInterceptorTest { } catch (e: IOException) { // ignore me } - verify(fixture.hub, never()).captureEvent(any(), any()) + verify(fixture.scopes, never()).captureEvent(any(), any()) } @Test @@ -565,7 +566,7 @@ class SentryOkHttpInterceptorTest { call.execute() val httpClientSpan = fixture.sentryTracer.children.firstOrNull() assertNull(httpClientSpan) - verify(fixture.hub, never()).addBreadcrumb(any(), anyOrNull()) + verify(fixture.scopes, never()).addBreadcrumb(any(), anyOrNull()) } @Test @@ -573,7 +574,7 @@ class SentryOkHttpInterceptorTest { val sut = fixture.getSut(captureFailedRequests = true, httpStatusCode = 500) val call = sut.newCall(getRequest()) call.execute() - verify(fixture.hub).captureEvent(any(), any()) + verify(fixture.scopes).captureEvent(any(), any()) } @Test @@ -582,6 +583,18 @@ class SentryOkHttpInterceptorTest { val call = sut.newCall(getRequest()) SentryOkHttpEventListener.eventMap[call] = mock() call.execute() - verify(fixture.hub, never()).captureEvent(any(), any()) + verify(fixture.scopes, never()).captureEvent(any(), any()) + } + + @Test + fun `when a call is captured by SentryOkHttpEventListener, interceptor finishes event`() { + val sut = fixture.getSut() + val call = sut.newCall(getRequest()) + val event = mock() + val span = Span(mock(), fixture.sentryTracer, fixture.scopes, mock()) + whenever(event.callSpan).thenReturn(span) + SentryOkHttpEventListener.eventMap[call] = event + call.execute() + verify(event).finish() } } diff --git a/sentry-okhttp/src/test/java/io/sentry/okhttp/SentryOkHttpUtilsTest.kt b/sentry-okhttp/src/test/java/io/sentry/okhttp/SentryOkHttpUtilsTest.kt index ec19454327..c7194e5994 100644 --- a/sentry-okhttp/src/test/java/io/sentry/okhttp/SentryOkHttpUtilsTest.kt +++ b/sentry-okhttp/src/test/java/io/sentry/okhttp/SentryOkHttpUtilsTest.kt @@ -1,7 +1,7 @@ package io.sentry.okhttp import io.sentry.Hint -import io.sentry.IHub +import io.sentry.IScopes import io.sentry.SentryOptions import io.sentry.SentryTracer import io.sentry.TransactionContext @@ -29,7 +29,7 @@ import kotlin.test.assertTrue class SentryOkHttpUtilsTest { class Fixture { - val hub = mock() + val scopes = mock() val server = MockWebServer() fun getSut( @@ -43,11 +43,11 @@ class SentryOkHttpUtilsTest { setTracePropagationTargets(listOf(server.hostName)) isSendDefaultPii = sendDefaultPii } - whenever(hub.options).thenReturn(options) + whenever(scopes.options).thenReturn(options) - val sentryTracer = SentryTracer(TransactionContext("name", "op"), hub) + val sentryTracer = SentryTracer(TransactionContext("name", "op"), scopes) - whenever(hub.span).thenReturn(sentryTracer) + whenever(scopes.span).thenReturn(sentryTracer) server.enqueue( MockResponse() @@ -78,8 +78,8 @@ class SentryOkHttpUtilsTest { val request = getRequest() val response = sut.newCall(request).execute() - SentryOkHttpUtils.captureClientError(fixture.hub, request, response) - verify(fixture.hub).captureEvent( + SentryOkHttpUtils.captureClientError(fixture.scopes, request, response) + verify(fixture.scopes).captureEvent( check { val req = it.request val resp = it.contexts.response @@ -103,8 +103,8 @@ class SentryOkHttpUtilsTest { val request = getRequest() val response = sut.newCall(request).execute() - SentryOkHttpUtils.captureClientError(fixture.hub, request, response) - verify(fixture.hub).captureEvent( + SentryOkHttpUtils.captureClientError(fixture.scopes, request, response) + verify(fixture.scopes).captureEvent( check { val req = it.request val resp = it.contexts.response @@ -127,8 +127,8 @@ class SentryOkHttpUtilsTest { val request = getRequest() val response = sut.newCall(request).execute() - SentryOkHttpUtils.captureClientError(fixture.hub, request, response) - verify(fixture.hub).captureEvent( + SentryOkHttpUtils.captureClientError(fixture.scopes, request, response) + verify(fixture.scopes).captureEvent( check { val req = it.request val resp = it.contexts.response diff --git a/sentry-openfeign/api/sentry-openfeign.api b/sentry-openfeign/api/sentry-openfeign.api index beb15c9e02..4ab65a5ca4 100644 --- a/sentry-openfeign/api/sentry-openfeign.api +++ b/sentry-openfeign/api/sentry-openfeign.api @@ -1,12 +1,12 @@ public final class io/sentry/openfeign/SentryCapability : feign/Capability { public fun ()V - public fun (Lio/sentry/IHub;Lio/sentry/openfeign/SentryFeignClient$BeforeSpanCallback;)V + public fun (Lio/sentry/IScopes;Lio/sentry/openfeign/SentryFeignClient$BeforeSpanCallback;)V public fun (Lio/sentry/openfeign/SentryFeignClient$BeforeSpanCallback;)V public fun enrich (Lfeign/Client;)Lfeign/Client; } public final class io/sentry/openfeign/SentryFeignClient : feign/Client { - public fun (Lfeign/Client;Lio/sentry/IHub;Lio/sentry/openfeign/SentryFeignClient$BeforeSpanCallback;)V + public fun (Lfeign/Client;Lio/sentry/IScopes;Lio/sentry/openfeign/SentryFeignClient$BeforeSpanCallback;)V public fun execute (Lfeign/Request;Lfeign/Request$Options;)Lfeign/Response; } diff --git a/sentry-openfeign/src/main/java/io/sentry/openfeign/SentryCapability.java b/sentry-openfeign/src/main/java/io/sentry/openfeign/SentryCapability.java index b65685c3fd..1ad6b1f274 100644 --- a/sentry-openfeign/src/main/java/io/sentry/openfeign/SentryCapability.java +++ b/sentry-openfeign/src/main/java/io/sentry/openfeign/SentryCapability.java @@ -2,33 +2,34 @@ import feign.Capability; import feign.Client; -import io.sentry.HubAdapter; -import io.sentry.IHub; +import io.sentry.IScopes; +import io.sentry.ScopesAdapter; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; /** Adds Sentry tracing capability to Feign clients. */ public final class SentryCapability implements Capability { - private final @NotNull IHub hub; + private final @NotNull IScopes scopes; private final @Nullable SentryFeignClient.BeforeSpanCallback beforeSpan; public SentryCapability( - final @NotNull IHub hub, final @Nullable SentryFeignClient.BeforeSpanCallback beforeSpan) { - this.hub = hub; + final @NotNull IScopes scopes, + final @Nullable SentryFeignClient.BeforeSpanCallback beforeSpan) { + this.scopes = scopes; this.beforeSpan = beforeSpan; } public SentryCapability(final @Nullable SentryFeignClient.BeforeSpanCallback beforeSpan) { - this(HubAdapter.getInstance(), beforeSpan); + this(ScopesAdapter.getInstance(), beforeSpan); } public SentryCapability() { - this(HubAdapter.getInstance(), null); + this(ScopesAdapter.getInstance(), null); } @Override public @NotNull Client enrich(final @NotNull Client client) { - return new SentryFeignClient(client, hub, beforeSpan); + return new SentryFeignClient(client, scopes, beforeSpan); } } diff --git a/sentry-openfeign/src/main/java/io/sentry/openfeign/SentryFeignClient.java b/sentry-openfeign/src/main/java/io/sentry/openfeign/SentryFeignClient.java index cb8aa3d9e0..a57380b29a 100644 --- a/sentry-openfeign/src/main/java/io/sentry/openfeign/SentryFeignClient.java +++ b/sentry-openfeign/src/main/java/io/sentry/openfeign/SentryFeignClient.java @@ -9,9 +9,10 @@ import io.sentry.BaggageHeader; import io.sentry.Breadcrumb; import io.sentry.Hint; -import io.sentry.IHub; +import io.sentry.IScopes; import io.sentry.ISpan; import io.sentry.SpanDataConvention; +import io.sentry.SpanOptions; import io.sentry.SpanStatus; import io.sentry.util.Objects; import io.sentry.util.TracingUtils; @@ -30,15 +31,15 @@ public final class SentryFeignClient implements Client { private static final String TRACE_ORIGIN = "auto.http.openfeign"; private final @NotNull Client delegate; - private final @NotNull IHub hub; + private final @NotNull IScopes scopes; private final @Nullable BeforeSpanCallback beforeSpan; public SentryFeignClient( final @NotNull Client delegate, - final @NotNull IHub hub, + final @NotNull IScopes scopes, final @Nullable BeforeSpanCallback beforeSpan) { this.delegate = Objects.requireNonNull(delegate, "delegate is required"); - this.hub = Objects.requireNonNull(hub, "hub is required"); + this.scopes = Objects.requireNonNull(scopes, "scopes are required"); this.beforeSpan = beforeSpan; } @@ -47,15 +48,16 @@ public Response execute(final @NotNull Request request, final @NotNull Request.O throws IOException { Response response = null; try { - final ISpan activeSpan = hub.getSpan(); + final ISpan activeSpan = scopes.getSpan(); if (activeSpan == null) { final @NotNull Request modifiedRequest = maybeAddTracingHeaders(request, null); return delegate.execute(modifiedRequest, options); } - ISpan span = activeSpan.startChild("http.client"); - span.getSpanContext().setOrigin(TRACE_ORIGIN); + final @NotNull SpanOptions spanOptions = new SpanOptions(); + spanOptions.setOrigin(TRACE_ORIGIN); + ISpan span = activeSpan.startChild("http.client", null, spanOptions); final @NotNull UrlUtils.UrlDetails urlDetails = UrlUtils.parse(request.url()); final @NotNull String method = request.httpMethod().name(); span.setDescription(method + " " + urlDetails.getUrlOrFallback()); @@ -102,7 +104,7 @@ public Response execute(final @NotNull Request request, final @NotNull Request.O final @Nullable TracingUtils.TracingHeaders tracingHeaders = TracingUtils.traceIfAllowed( - hub, + scopes, request.url(), (requestBaggageHeaders != null ? new ArrayList<>(requestBaggageHeaders) : null), span); @@ -139,7 +141,7 @@ private void addBreadcrumb(final @NotNull Request request, final @Nullable Respo hint.set(OPEN_FEIGN_RESPONSE, response); } - hub.addBreadcrumb(breadcrumb, hint); + scopes.addBreadcrumb(breadcrumb, hint); } static final class RequestWrapper { diff --git a/sentry-openfeign/src/test/kotlin/io/sentry/openfeign/SentryFeignClientTest.kt b/sentry-openfeign/src/test/kotlin/io/sentry/openfeign/SentryFeignClientTest.kt index 65e56ab02b..959b890d46 100644 --- a/sentry-openfeign/src/test/kotlin/io/sentry/openfeign/SentryFeignClientTest.kt +++ b/sentry-openfeign/src/test/kotlin/io/sentry/openfeign/SentryFeignClientTest.kt @@ -7,7 +7,7 @@ import feign.HeaderMap import feign.RequestLine import io.sentry.BaggageHeader import io.sentry.Breadcrumb -import io.sentry.IHub +import io.sentry.IScopes import io.sentry.Scope import io.sentry.ScopeCallback import io.sentry.SentryOptions @@ -37,7 +37,7 @@ import kotlin.test.fail class SentryFeignClientTest { class Fixture { - val hub = mock() + val scopes = mock() val server = MockWebServer() val sentryTracer: SentryTracer val sentryOptions = SentryOptions().apply { @@ -46,9 +46,9 @@ class SentryFeignClientTest { val scope = Scope(sentryOptions) init { - whenever(hub.options).thenReturn(sentryOptions) - doAnswer { (it.arguments[0] as ScopeCallback).run(scope) }.whenever(hub).configureScope(any()) - sentryTracer = SentryTracer(TransactionContext("name", "op"), hub) + whenever(scopes.options).thenReturn(sentryOptions) + doAnswer { (it.arguments[0] as ScopeCallback).run(scope) }.whenever(scopes).configureScope(any()) + sentryTracer = SentryTracer(TransactionContext("name", "op"), scopes) } fun getSut( @@ -59,7 +59,7 @@ class SentryFeignClientTest { beforeSpan: SentryFeignClient.BeforeSpanCallback? = null ): MockApi { if (isSpanActive) { - whenever(hub.span).thenReturn(sentryTracer) + whenever(scopes.span).thenReturn(sentryTracer) } server.enqueue( MockResponse() @@ -70,12 +70,12 @@ class SentryFeignClientTest { return if (!networkError) { Feign.builder() - .addCapability(SentryCapability(hub, beforeSpan)) + .addCapability(SentryCapability(scopes, beforeSpan)) } else { val mockClient = mock() whenever(mockClient.execute(any(), any())).thenThrow(RuntimeException::class.java) Feign.builder() - .client(SentryFeignClient(mockClient, hub, beforeSpan)) + .client(SentryFeignClient(mockClient, scopes, beforeSpan)) }.target(MockApi::class.java, server.url("/").toUrl().toString()) } } @@ -201,7 +201,7 @@ class SentryFeignClientTest { fun `adds breadcrumb when http calls succeeds`() { val sut = fixture.getSut(responseBody = "response body") sut.postWithBody("request-body") - verify(fixture.hub).addBreadcrumb( + verify(fixture.scopes).addBreadcrumb( check { assertEquals("http", it.type) assertEquals(13, it.data["response_body_size"]) @@ -215,7 +215,7 @@ class SentryFeignClientTest { fun `adds breadcrumb when http calls succeeds even though response body is null`() { val sut = fixture.getSut(responseBody = "") sut.postWithBody("request-body") - verify(fixture.hub).addBreadcrumb( + verify(fixture.scopes).addBreadcrumb( check { assertEquals("http", it.type) assertEquals(0, it.data["response_body_size"]) @@ -236,7 +236,7 @@ class SentryFeignClientTest { } catch (e: Exception) { // ignore me } - verify(fixture.hub).addBreadcrumb( + verify(fixture.scopes).addBreadcrumb( check { assertEquals("http", it.type) }, diff --git a/sentry-opentelemetry/sentry-opentelemetry-agent/build.gradle.kts b/sentry-opentelemetry/sentry-opentelemetry-agent/build.gradle.kts index 80b68430db..2d5ccf85bc 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-agent/build.gradle.kts +++ b/sentry-opentelemetry/sentry-opentelemetry-agent/build.gradle.kts @@ -53,6 +53,8 @@ val upstreamAgent = configurations.create("upstreamAgent") { dependencies { bootstrapLibs(projects.sentry) + bootstrapLibs(projects.sentryOpentelemetry.sentryOpentelemetryBootstrap) + bootstrapLibs(projects.sentryOpentelemetry.sentryOpentelemetryExtra) javaagentLibs(projects.sentryOpentelemetry.sentryOpentelemetryAgentcustomization) upstreamAgent(Config.Libs.OpenTelemetry.otelJavaAgent) } diff --git a/sentry-opentelemetry/sentry-opentelemetry-agentcustomization/api/sentry-opentelemetry-agentcustomization.api b/sentry-opentelemetry/sentry-opentelemetry-agentcustomization/api/sentry-opentelemetry-agentcustomization.api index 342f71b5bb..c35b221aa0 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-agentcustomization/api/sentry-opentelemetry-agentcustomization.api +++ b/sentry-opentelemetry/sentry-opentelemetry-agentcustomization/api/sentry-opentelemetry-agentcustomization.api @@ -1,4 +1,5 @@ public final class io/sentry/opentelemetry/SentryAutoConfigurationCustomizerProvider : io/opentelemetry/sdk/autoconfigure/spi/AutoConfigurationCustomizerProvider { + public static field skipInit Z public fun ()V public fun customize (Lio/opentelemetry/sdk/autoconfigure/spi/AutoConfigurationCustomizer;)V } diff --git a/sentry-opentelemetry/sentry-opentelemetry-agentcustomization/build.gradle.kts b/sentry-opentelemetry/sentry-opentelemetry-agentcustomization/build.gradle.kts index 79e3599cc8..82fce13c56 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-agentcustomization/build.gradle.kts +++ b/sentry-opentelemetry/sentry-opentelemetry-agentcustomization/build.gradle.kts @@ -24,6 +24,8 @@ dependencies { exclude(group = "io.opentelemetry") exclude(group = "io.opentelemetry.javaagent") } + compileOnly(projects.sentryOpentelemetry.sentryOpentelemetryBootstrap) + implementation(projects.sentryOpentelemetry.sentryOpentelemetryExtra) compileOnly(Config.Libs.OpenTelemetry.otelSdk) compileOnly(Config.Libs.OpenTelemetry.otelExtensionAutoconfigureSpi) diff --git a/sentry-opentelemetry/sentry-opentelemetry-agentcustomization/src/main/java/io/sentry/opentelemetry/SentryAutoConfigurationCustomizerProvider.java b/sentry-opentelemetry/sentry-opentelemetry-agentcustomization/src/main/java/io/sentry/opentelemetry/SentryAutoConfigurationCustomizerProvider.java index e808db8fcf..2e71afcd08 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-agentcustomization/src/main/java/io/sentry/opentelemetry/SentryAutoConfigurationCustomizerProvider.java +++ b/sentry-opentelemetry/sentry-opentelemetry-agentcustomization/src/main/java/io/sentry/opentelemetry/SentryAutoConfigurationCustomizerProvider.java @@ -4,10 +4,12 @@ import io.opentelemetry.sdk.autoconfigure.spi.AutoConfigurationCustomizerProvider; import io.opentelemetry.sdk.autoconfigure.spi.ConfigProperties; import io.opentelemetry.sdk.trace.SdkTracerProviderBuilder; -import io.sentry.Instrumenter; +import io.opentelemetry.sdk.trace.export.BatchSpanProcessor; +import io.sentry.InitPriority; import io.sentry.Sentry; import io.sentry.SentryIntegrationPackageStorage; import io.sentry.SentryOptions; +import io.sentry.SentrySpanFactoryHolder; import io.sentry.protocol.SdkVersion; import io.sentry.protocol.SentryPackage; import java.io.IOException; @@ -25,15 +27,27 @@ public final class SentryAutoConfigurationCustomizerProvider implements AutoConfigurationCustomizerProvider { + public static volatile boolean skipInit = false; + @Override public void customize(AutoConfigurationCustomizer autoConfiguration) { + System.out.println("hello from agent"); final @Nullable VersionInfoHolder versionInfoHolder = createVersionInfo(); + + final @NotNull OtelSpanFactory spanFactory = new OtelSpanFactory(); + SentrySpanFactoryHolder.setSpanFactory(spanFactory); + if (isSentryAutoInitEnabled()) { + System.out.println("hello from before agent init"); Sentry.init( options -> { + System.out.println("hello from agent init options config block"); options.setEnableExternalConfiguration(true); - options.setInstrumenter(Instrumenter.OTEL); - options.addEventProcessor(new OpenTelemetryLinkErrorEventProcessor()); + options.setInitPriority(InitPriority.HIGH); + OpenTelemetryUtil.applyOpenTelemetryOptions(options); + // + // options.setIgnoredSpanOrigins(SpanUtils.ignoredSpanOriginsForOpenTelemetry()); + // options.setSpanFactory(spanFactory); final @Nullable SdkVersion sdkVersion = createSdkVersion(options, versionInfoHolder); if (sdkVersion != null) { options.setSdkVersion(sdkVersion); @@ -56,6 +70,9 @@ public void customize(AutoConfigurationCustomizer autoConfiguration) { } private boolean isSentryAutoInitEnabled() { + if (skipInit) { + return false; + } final @Nullable String sentryAutoInit = System.getenv("SENTRY_AUTO_INIT"); if (sentryAutoInit != null) { @@ -140,7 +157,10 @@ private static class VersionInfoHolder { private SdkTracerProviderBuilder configureSdkTracerProvider( SdkTracerProviderBuilder tracerProvider, ConfigProperties config) { - return tracerProvider.addSpanProcessor(new SentrySpanProcessor()); + return tracerProvider + .setSampler(new SentrySampler()) + .addSpanProcessor(new OtelSentrySpanProcessor()) + .addSpanProcessor(BatchSpanProcessor.builder(new SentrySpanExporter()).build()); } private Map getDefaultProperties() { diff --git a/sentry-opentelemetry/sentry-opentelemetry-agentcustomization/src/main/java/io/sentry/opentelemetry/SentryPropagatorProvider.java b/sentry-opentelemetry/sentry-opentelemetry-agentcustomization/src/main/java/io/sentry/opentelemetry/SentryPropagatorProvider.java index 49acd725fb..6aa04f31e9 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-agentcustomization/src/main/java/io/sentry/opentelemetry/SentryPropagatorProvider.java +++ b/sentry-opentelemetry/sentry-opentelemetry-agentcustomization/src/main/java/io/sentry/opentelemetry/SentryPropagatorProvider.java @@ -7,7 +7,7 @@ public final class SentryPropagatorProvider implements ConfigurablePropagatorProvider { @Override public TextMapPropagator getPropagator(ConfigProperties config) { - return new SentryPropagator(); + return new OtelSentryPropagator(); } @Override diff --git a/sentry-opentelemetry/sentry-opentelemetry-bootstrap/api/sentry-opentelemetry-bootstrap.api b/sentry-opentelemetry/sentry-opentelemetry-bootstrap/api/sentry-opentelemetry-bootstrap.api new file mode 100644 index 0000000000..e47bc10b30 --- /dev/null +++ b/sentry-opentelemetry/sentry-opentelemetry-bootstrap/api/sentry-opentelemetry-bootstrap.api @@ -0,0 +1,19 @@ +public final class io/sentry/opentelemetry/InternalSemanticAttributes { + public static final field BAGGAGE Lio/opentelemetry/api/common/AttributeKey; + public static final field BAGGAGE_MUTABLE Lio/opentelemetry/api/common/AttributeKey; + public static final field IS_REMOTE_PARENT Lio/opentelemetry/api/common/AttributeKey; + public static final field PARENT_SAMPLED Lio/opentelemetry/api/common/AttributeKey; + public static final field PROFILE_SAMPLED Lio/opentelemetry/api/common/AttributeKey; + public static final field PROFILE_SAMPLE_RATE Lio/opentelemetry/api/common/AttributeKey; + public static final field SAMPLED Lio/opentelemetry/api/common/AttributeKey; + public static final field SAMPLE_RATE Lio/opentelemetry/api/common/AttributeKey; + public fun ()V +} + +public final class io/sentry/opentelemetry/SentryOtelKeys { + public static final field SENTRY_BAGGAGE_KEY Lio/opentelemetry/context/ContextKey; + public static final field SENTRY_SCOPES_KEY Lio/opentelemetry/context/ContextKey; + public static final field SENTRY_TRACE_KEY Lio/opentelemetry/context/ContextKey; + public fun ()V +} + diff --git a/sentry-opentelemetry/sentry-opentelemetry-bootstrap/build.gradle.kts b/sentry-opentelemetry/sentry-opentelemetry-bootstrap/build.gradle.kts new file mode 100644 index 0000000000..6eb8e6d6f1 --- /dev/null +++ b/sentry-opentelemetry/sentry-opentelemetry-bootstrap/build.gradle.kts @@ -0,0 +1,78 @@ +import net.ltgt.gradle.errorprone.errorprone +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile + +plugins { + `java-library` + kotlin("jvm") + jacoco + id(Config.QualityPlugins.errorProne) + id(Config.QualityPlugins.gradleVersions) +} + +configure { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 +} + +tasks.withType().configureEach { + kotlinOptions.jvmTarget = JavaVersion.VERSION_1_8.toString() +} + +dependencies { + compileOnly(projects.sentry) + + compileOnly(Config.Libs.OpenTelemetry.otelSdk) + + compileOnly(Config.CompileOnly.nopen) + errorprone(Config.CompileOnly.nopenChecker) + errorprone(Config.CompileOnly.errorprone) + compileOnly(Config.CompileOnly.jetbrainsAnnotations) + errorprone(Config.CompileOnly.errorProneNullAway) + + // tests + testImplementation(projects.sentryTestSupport) + testImplementation(kotlin(Config.kotlinStdLib)) + testImplementation(Config.TestLibs.kotlinTestJunit) + testImplementation(Config.TestLibs.mockitoKotlin) + testImplementation(Config.TestLibs.awaitility) + + testImplementation(Config.Libs.OpenTelemetry.otelSdk) + testImplementation(Config.Libs.OpenTelemetry.otelSemconv) + testImplementation(Config.Libs.OpenTelemetry.otelSemconvIncubating) +} + +configure { + test { + java.srcDir("src/test/java") + } +} + +jacoco { + toolVersion = Config.QualityPlugins.Jacoco.version +} + +tasks.jacocoTestReport { + reports { + xml.required.set(true) + html.required.set(false) + } +} + +tasks { + jacocoTestCoverageVerification { + violationRules { + rule { limit { minimum = Config.QualityPlugins.Jacoco.minimumCoverage } } + } + } + check { + dependsOn(jacocoTestCoverageVerification) + dependsOn(jacocoTestReport) + } +} + +tasks.withType().configureEach { + options.errorprone { + check("NullAway", net.ltgt.gradle.errorprone.CheckSeverity.ERROR) + option("NullAway:AnnotatedPackages", "io.sentry") + } +} diff --git a/sentry-opentelemetry/sentry-opentelemetry-bootstrap/src/main/java/io/sentry/opentelemetry/InternalSemanticAttributes.java b/sentry-opentelemetry/sentry-opentelemetry-bootstrap/src/main/java/io/sentry/opentelemetry/InternalSemanticAttributes.java new file mode 100644 index 0000000000..cb64d7bfff --- /dev/null +++ b/sentry-opentelemetry/sentry-opentelemetry-bootstrap/src/main/java/io/sentry/opentelemetry/InternalSemanticAttributes.java @@ -0,0 +1,22 @@ +package io.sentry.opentelemetry; + +import io.opentelemetry.api.common.AttributeKey; +import org.jetbrains.annotations.ApiStatus; + +@ApiStatus.Internal +public final class InternalSemanticAttributes { + public static final AttributeKey SAMPLED = AttributeKey.booleanKey("sentry.sampled"); + public static final AttributeKey SAMPLE_RATE = + AttributeKey.doubleKey("sentry.sample_rate"); + public static final AttributeKey PARENT_SAMPLED = + AttributeKey.booleanKey("sentry.parent_sampled"); + public static final AttributeKey PROFILE_SAMPLED = + AttributeKey.booleanKey("sentry.profile_sampled"); + public static final AttributeKey PROFILE_SAMPLE_RATE = + AttributeKey.doubleKey("sentry.profile_sample_rate"); + public static final AttributeKey IS_REMOTE_PARENT = + AttributeKey.booleanKey("sentry.is_remote_parent"); + public static final AttributeKey BAGGAGE = AttributeKey.stringKey("sentry.baggage"); + public static final AttributeKey BAGGAGE_MUTABLE = + AttributeKey.booleanKey("sentry.baggage_mutable"); +} diff --git a/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/SentryOtelKeys.java b/sentry-opentelemetry/sentry-opentelemetry-bootstrap/src/main/java/io/sentry/opentelemetry/SentryOtelKeys.java similarity index 79% rename from sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/SentryOtelKeys.java rename to sentry-opentelemetry/sentry-opentelemetry-bootstrap/src/main/java/io/sentry/opentelemetry/SentryOtelKeys.java index 51ead00c6f..54889d1e73 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/SentryOtelKeys.java +++ b/sentry-opentelemetry/sentry-opentelemetry-bootstrap/src/main/java/io/sentry/opentelemetry/SentryOtelKeys.java @@ -2,6 +2,7 @@ import io.opentelemetry.context.ContextKey; import io.sentry.Baggage; +import io.sentry.IScopes; import io.sentry.SentryTraceHeader; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; @@ -13,4 +14,6 @@ public final class SentryOtelKeys { ContextKey.named("sentry.trace"); public static final @NotNull ContextKey SENTRY_BAGGAGE_KEY = ContextKey.named("sentry.baggage"); + public static final @NotNull ContextKey SENTRY_SCOPES_KEY = + ContextKey.named("sentry.scopes"); } diff --git a/sentry-opentelemetry/sentry-opentelemetry-core/api/sentry-opentelemetry-core.api b/sentry-opentelemetry/sentry-opentelemetry-core/api/sentry-opentelemetry-core.api index 18c73a9b68..7a4fd853c7 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-core/api/sentry-opentelemetry-core.api +++ b/sentry-opentelemetry/sentry-opentelemetry-core/api/sentry-opentelemetry-core.api @@ -1,8 +1,35 @@ public final class io/sentry/opentelemetry/OpenTelemetryLinkErrorEventProcessor : io/sentry/EventProcessor { public fun ()V + public fun getOrder ()Ljava/lang/Long; public fun process (Lio/sentry/SentryEvent;Lio/sentry/Hint;)Lio/sentry/SentryEvent; } +public final class io/sentry/opentelemetry/OtelInternalSpanDetectionUtil { + public fun ()V + public static fun isSentryRequest (Lio/sentry/IScopes;Lio/opentelemetry/api/trace/SpanKind;Lio/opentelemetry/api/common/Attributes;)Z +} + +public final class io/sentry/opentelemetry/OtelSamplingUtil { + public fun ()V + public static fun extractSamplingDecision (Lio/opentelemetry/api/common/Attributes;)Lio/sentry/TracesSamplingDecision; + public static fun extractSamplingDecisionOrDefault (Lio/opentelemetry/api/common/Attributes;)Lio/sentry/TracesSamplingDecision; +} + +public final class io/sentry/opentelemetry/OtelSentryPropagator : io/opentelemetry/context/propagation/TextMapPropagator { + public fun ()V + public fun extract (Lio/opentelemetry/context/Context;Ljava/lang/Object;Lio/opentelemetry/context/propagation/TextMapGetter;)Lio/opentelemetry/context/Context; + public fun fields ()Ljava/util/Collection; + public fun inject (Lio/opentelemetry/context/Context;Ljava/lang/Object;Lio/opentelemetry/context/propagation/TextMapSetter;)V +} + +public final class io/sentry/opentelemetry/OtelSentrySpanProcessor : io/opentelemetry/sdk/trace/SpanProcessor { + public fun ()V + public fun isEndRequired ()Z + public fun isStartRequired ()Z + public fun onEnd (Lio/opentelemetry/sdk/trace/ReadableSpan;)V + public fun onStart (Lio/opentelemetry/context/Context;Lio/opentelemetry/sdk/trace/ReadWriteSpan;)V +} + public final class io/sentry/opentelemetry/OtelSpanInfo { public fun (Ljava/lang/String;Ljava/lang/String;Lio/sentry/protocol/TransactionNameSource;)V public fun getDescription ()Ljava/lang/String; @@ -10,12 +37,6 @@ public final class io/sentry/opentelemetry/OtelSpanInfo { public fun getTransactionNameSource ()Lio/sentry/protocol/TransactionNameSource; } -public final class io/sentry/opentelemetry/SentryOtelKeys { - public static final field SENTRY_BAGGAGE_KEY Lio/opentelemetry/context/ContextKey; - public static final field SENTRY_TRACE_KEY Lio/opentelemetry/context/ContextKey; - public fun ()V -} - public final class io/sentry/opentelemetry/SentryPropagator : io/opentelemetry/context/propagation/TextMapPropagator { public fun ()V public fun extract (Lio/opentelemetry/context/Context;Ljava/lang/Object;Lio/opentelemetry/context/propagation/TextMapGetter;)Lio/opentelemetry/context/Context; @@ -23,6 +44,29 @@ public final class io/sentry/opentelemetry/SentryPropagator : io/opentelemetry/c public fun inject (Lio/opentelemetry/context/Context;Ljava/lang/Object;Lio/opentelemetry/context/propagation/TextMapSetter;)V } +public final class io/sentry/opentelemetry/SentrySampler : io/opentelemetry/sdk/trace/samplers/Sampler { + public fun ()V + public fun (Lio/sentry/IScopes;)V + public fun getDescription ()Ljava/lang/String; + public fun shouldSample (Lio/opentelemetry/context/Context;Ljava/lang/String;Ljava/lang/String;Lio/opentelemetry/api/trace/SpanKind;Lio/opentelemetry/api/common/Attributes;Ljava/util/List;)Lio/opentelemetry/sdk/trace/samplers/SamplingResult; +} + +public final class io/sentry/opentelemetry/SentrySamplingResult : io/opentelemetry/sdk/trace/samplers/SamplingResult { + public fun (Lio/sentry/TracesSamplingDecision;)V + public fun getAttributes ()Lio/opentelemetry/api/common/Attributes; + public fun getDecision ()Lio/opentelemetry/sdk/trace/samplers/SamplingDecision; + public fun getSentryDecision ()Lio/sentry/TracesSamplingDecision; +} + +public final class io/sentry/opentelemetry/SentrySpanExporter : io/opentelemetry/sdk/trace/export/SpanExporter { + public static final field TRACE_ORIGIN Ljava/lang/String; + public fun ()V + public fun (Lio/sentry/IScopes;)V + public fun export (Ljava/util/Collection;)Lio/opentelemetry/sdk/common/CompletableResultCode; + public fun flush ()Lio/opentelemetry/sdk/common/CompletableResultCode; + public fun shutdown ()Lio/opentelemetry/sdk/common/CompletableResultCode; +} + public final class io/sentry/opentelemetry/SentrySpanProcessor : io/opentelemetry/sdk/trace/SpanProcessor { public fun ()V public fun isEndRequired ()Z @@ -33,7 +77,19 @@ public final class io/sentry/opentelemetry/SentrySpanProcessor : io/opentelemetr public final class io/sentry/opentelemetry/SpanDescriptionExtractor { public fun ()V - public fun extractSpanDescription (Lio/opentelemetry/sdk/trace/ReadableSpan;)Lio/sentry/opentelemetry/OtelSpanInfo; + public fun extractSpanInfo (Lio/opentelemetry/sdk/trace/data/SpanData;Lio/sentry/opentelemetry/OtelSpanWrapper;)Lio/sentry/opentelemetry/OtelSpanInfo; +} + +public final class io/sentry/opentelemetry/SpanNode { + public fun (Ljava/lang/String;)V + public fun addChild (Lio/sentry/opentelemetry/SpanNode;)V + public fun addChildren (Ljava/util/List;)V + public fun getChildren ()Ljava/util/List; + public fun getId ()Ljava/lang/String; + public fun getParentNode ()Lio/sentry/opentelemetry/SpanNode; + public fun getSpan ()Lio/opentelemetry/sdk/trace/data/SpanData; + public fun setParentNode (Lio/sentry/opentelemetry/SpanNode;)V + public fun setSpan (Lio/opentelemetry/sdk/trace/data/SpanData;)V } public final class io/sentry/opentelemetry/TraceData { diff --git a/sentry-opentelemetry/sentry-opentelemetry-core/build.gradle.kts b/sentry-opentelemetry/sentry-opentelemetry-core/build.gradle.kts index 1dad433555..0ff181c868 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-core/build.gradle.kts +++ b/sentry-opentelemetry/sentry-opentelemetry-core/build.gradle.kts @@ -20,9 +20,13 @@ tasks.withType().configureEach { dependencies { compileOnly(projects.sentry) + // TODO implementation? + compileOnly(projects.sentryOpentelemetry.sentryOpentelemetryBootstrap) + compileOnly(projects.sentryOpentelemetry.sentryOpentelemetryExtra) implementation(Config.Libs.OpenTelemetry.otelSdk) compileOnly(Config.Libs.OpenTelemetry.otelSemconv) + compileOnly(Config.Libs.OpenTelemetry.otelSemconvIncubating) compileOnly(Config.CompileOnly.nopen) errorprone(Config.CompileOnly.nopenChecker) @@ -31,6 +35,8 @@ dependencies { errorprone(Config.CompileOnly.errorProneNullAway) // tests + testImplementation(projects.sentryOpentelemetry.sentryOpentelemetryBootstrap) + testImplementation(projects.sentryOpentelemetry.sentryOpentelemetryExtra) testImplementation(projects.sentryTestSupport) testImplementation(kotlin(Config.kotlinStdLib)) testImplementation(Config.TestLibs.kotlinTestJunit) @@ -39,6 +45,7 @@ dependencies { testImplementation(Config.Libs.OpenTelemetry.otelSdk) testImplementation(Config.Libs.OpenTelemetry.otelSemconv) + testImplementation(Config.Libs.OpenTelemetry.otelSemconvIncubating) } configure { diff --git a/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/OpenTelemetryLinkErrorEventProcessor.java b/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/OpenTelemetryLinkErrorEventProcessor.java index 1e373ece9c..cf1e530cd9 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/OpenTelemetryLinkErrorEventProcessor.java +++ b/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/OpenTelemetryLinkErrorEventProcessor.java @@ -5,36 +5,42 @@ import io.opentelemetry.api.trace.TraceId; import io.sentry.EventProcessor; import io.sentry.Hint; -import io.sentry.HubAdapter; -import io.sentry.IHub; +import io.sentry.IScopes; import io.sentry.ISpan; import io.sentry.Instrumenter; +import io.sentry.ScopesAdapter; import io.sentry.SentryEvent; import io.sentry.SentryLevel; -import io.sentry.SentrySpanStorage; import io.sentry.SpanContext; import io.sentry.protocol.SentryId; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import org.jetbrains.annotations.TestOnly; +/** + * @deprecated this is no longer needed for the latest version of our OpenTelemetry integration. + */ +@Deprecated public final class OpenTelemetryLinkErrorEventProcessor implements EventProcessor { - private final @NotNull IHub hub; - private final @NotNull SentrySpanStorage spanStorage = SentrySpanStorage.getInstance(); + private final @NotNull IScopes scopes; + + @SuppressWarnings("deprecation") + private final @NotNull io.sentry.SentrySpanStorage spanStorage = + io.sentry.SentrySpanStorage.getInstance(); public OpenTelemetryLinkErrorEventProcessor() { - this(HubAdapter.getInstance()); + this(ScopesAdapter.getInstance()); } @TestOnly - OpenTelemetryLinkErrorEventProcessor(final @NotNull IHub hub) { - this.hub = hub; + OpenTelemetryLinkErrorEventProcessor(final @NotNull IScopes scopes) { + this.scopes = scopes; } @Override public @Nullable SentryEvent process(final @NotNull SentryEvent event, final @NotNull Hint hint) { - final @NotNull Instrumenter instrumenter = hub.getOptions().getInstrumenter(); + final @NotNull Instrumenter instrumenter = scopes.getOptions().getInstrumenter(); if (Instrumenter.OTEL.equals(instrumenter)) { @NotNull final Span otelSpan = Span.current(); @NotNull final String traceId = otelSpan.getSpanContext().getTraceId(); @@ -55,7 +61,8 @@ public OpenTelemetryLinkErrorEventProcessor() { null); event.getContexts().setTrace(spanContext); - hub.getOptions() + scopes + .getOptions() .getLogger() .log( SentryLevel.DEBUG, @@ -64,7 +71,8 @@ public OpenTelemetryLinkErrorEventProcessor() { spanId, traceId); } else { - hub.getOptions() + scopes + .getOptions() .getLogger() .log( SentryLevel.DEBUG, @@ -74,7 +82,8 @@ public OpenTelemetryLinkErrorEventProcessor() { traceId); } } else { - hub.getOptions() + scopes + .getOptions() .getLogger() .log( SentryLevel.DEBUG, @@ -84,7 +93,8 @@ public OpenTelemetryLinkErrorEventProcessor() { spanId); } } else { - hub.getOptions() + scopes + .getOptions() .getLogger() .log( SentryLevel.DEBUG, @@ -95,4 +105,9 @@ public OpenTelemetryLinkErrorEventProcessor() { return event; } + + @Override + public @Nullable Long getOrder() { + return 6000L; + } } diff --git a/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/OtelInternalSpanDetectionUtil.java b/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/OtelInternalSpanDetectionUtil.java new file mode 100644 index 0000000000..e4bd5a7e7c --- /dev/null +++ b/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/OtelInternalSpanDetectionUtil.java @@ -0,0 +1,65 @@ +package io.sentry.opentelemetry; + +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.api.trace.SpanKind; +import io.opentelemetry.semconv.UrlAttributes; +import io.sentry.DsnUtil; +import io.sentry.IScopes; +import java.util.Arrays; +import java.util.List; +import java.util.Locale; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +@ApiStatus.Internal +public final class OtelInternalSpanDetectionUtil { + + private static final @NotNull List spanKindsConsideredForSentryRequests = + Arrays.asList(SpanKind.CLIENT, SpanKind.INTERNAL); + + @SuppressWarnings("deprecation") + public static boolean isSentryRequest( + final @NotNull IScopes scopes, + final @NotNull SpanKind spanKind, + final @NotNull Attributes attributes) { + if (!spanKindsConsideredForSentryRequests.contains(spanKind)) { + return false; + } + + final @Nullable String httpUrl = + attributes.get(io.opentelemetry.semconv.SemanticAttributes.HTTP_URL); + if (DsnUtil.urlContainsDsnHost(scopes.getOptions(), httpUrl)) { + return true; + } + + final @Nullable String fullUrl = attributes.get(UrlAttributes.URL_FULL); + if (DsnUtil.urlContainsDsnHost(scopes.getOptions(), fullUrl)) { + return true; + } + + if (scopes.getOptions().isEnableSpotlight()) { + final @Nullable String optionsSpotlightUrl = scopes.getOptions().getSpotlightConnectionUrl(); + final @NotNull String spotlightUrl = + optionsSpotlightUrl != null ? optionsSpotlightUrl : "http://localhost:8969/stream"; + + if (containsSpotlightUrl(fullUrl, spotlightUrl)) { + return true; + } + if (containsSpotlightUrl(httpUrl, spotlightUrl)) { + return true; + } + } + + return false; + } + + private static boolean containsSpotlightUrl( + final @Nullable String requestUrl, final @NotNull String spotlightUrl) { + if (requestUrl == null) { + return false; + } + + return requestUrl.toLowerCase(Locale.ROOT).contains(spotlightUrl.toLowerCase(Locale.ROOT)); + } +} diff --git a/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/OtelSamplingUtil.java b/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/OtelSamplingUtil.java new file mode 100644 index 0000000000..45a8922741 --- /dev/null +++ b/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/OtelSamplingUtil.java @@ -0,0 +1,38 @@ +package io.sentry.opentelemetry; + +import io.opentelemetry.api.common.Attributes; +import io.sentry.TracesSamplingDecision; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +@ApiStatus.Internal +public final class OtelSamplingUtil { + + public static @NotNull TracesSamplingDecision extractSamplingDecisionOrDefault( + final @NotNull Attributes attributes) { + final @Nullable TracesSamplingDecision decision = extractSamplingDecision(attributes); + if (decision != null) { + return decision; + } else { + return new TracesSamplingDecision(false); + } + } + + public static @Nullable TracesSamplingDecision extractSamplingDecision( + final @NotNull Attributes attributes) { + final @Nullable Boolean sampled = attributes.get(InternalSemanticAttributes.SAMPLED); + if (sampled != null) { + final @Nullable Double sampleRate = attributes.get(InternalSemanticAttributes.SAMPLE_RATE); + final @Nullable Boolean profileSampled = + attributes.get(InternalSemanticAttributes.PROFILE_SAMPLED); + final @Nullable Double profileSampleRate = + attributes.get(InternalSemanticAttributes.PROFILE_SAMPLE_RATE); + + return new TracesSamplingDecision( + sampled, sampleRate, profileSampled == null ? false : profileSampled, profileSampleRate); + } else { + return null; + } + } +} diff --git a/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/OtelSentryPropagator.java b/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/OtelSentryPropagator.java new file mode 100644 index 0000000000..0b9ee88e08 --- /dev/null +++ b/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/OtelSentryPropagator.java @@ -0,0 +1,145 @@ +package io.sentry.opentelemetry; + +import static io.sentry.opentelemetry.SentryOtelKeys.SENTRY_SCOPES_KEY; + +import io.opentelemetry.api.trace.Span; +import io.opentelemetry.api.trace.SpanContext; +import io.opentelemetry.api.trace.TraceFlags; +import io.opentelemetry.api.trace.TraceState; +import io.opentelemetry.context.Context; +import io.opentelemetry.context.propagation.TextMapGetter; +import io.opentelemetry.context.propagation.TextMapPropagator; +import io.opentelemetry.context.propagation.TextMapSetter; +import io.sentry.Baggage; +import io.sentry.BaggageHeader; +import io.sentry.IScopes; +import io.sentry.PropagationContext; +import io.sentry.ScopesAdapter; +import io.sentry.Sentry; +import io.sentry.SentryLevel; +import io.sentry.SentryTraceHeader; +import io.sentry.exception.InvalidSentryTraceHeaderException; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +public final class OtelSentryPropagator implements TextMapPropagator { + + private static final @NotNull List FIELDS = + Arrays.asList(SentryTraceHeader.SENTRY_TRACE_HEADER, BaggageHeader.BAGGAGE_HEADER); + private final @NotNull SentryWeakSpanStorage spanStorage = SentryWeakSpanStorage.getInstance(); + private final @NotNull IScopes scopes; + + public OtelSentryPropagator() { + this(ScopesAdapter.getInstance()); + } + + OtelSentryPropagator(final @NotNull IScopes scopes) { + this.scopes = scopes; + } + + @Override + public Collection fields() { + return FIELDS; + } + + @Override + public void inject(final Context context, final C carrier, final TextMapSetter setter) { + final @NotNull Span otelSpan = Span.fromContext(context); + final @NotNull SpanContext otelSpanContext = otelSpan.getSpanContext(); + if (!otelSpanContext.isValid()) { + scopes + .getOptions() + .getLogger() + .log( + SentryLevel.DEBUG, + "Not injecting Sentry tracing information for invalid OpenTelemetry span."); + return; + } + + final @Nullable OtelSpanWrapper sentrySpan = spanStorage.getSentrySpan(otelSpanContext); + if (sentrySpan == null || sentrySpan.isNoOp()) { + scopes + .getOptions() + .getLogger() + .log( + SentryLevel.DEBUG, + "Not injecting Sentry tracing information for span %s as no Sentry span has been found or it is a NoOp (trace %s). This might simply mean this is a request to Sentry.", + otelSpanContext.getSpanId(), + otelSpanContext.getTraceId()); + return; + } + + final @NotNull SentryTraceHeader sentryTraceHeader = sentrySpan.toSentryTrace(); + setter.set(carrier, sentryTraceHeader.getName(), sentryTraceHeader.getValue()); + final @Nullable BaggageHeader baggageHeader = + sentrySpan.toBaggageHeader(Collections.emptyList()); + if (baggageHeader != null) { + setter.set(carrier, baggageHeader.getName(), baggageHeader.getValue()); + } + } + + @Override + public Context extract( + final Context context, final C carrier, final TextMapGetter getter) { + final @Nullable IScopes scopesFromParentContext = context.get(SENTRY_SCOPES_KEY); + final @NotNull IScopes scopesToUse = + scopesFromParentContext != null + ? scopesFromParentContext.forkedScopes("propagator") + : Sentry.forkedRootScopes("propagator"); + + final @Nullable String sentryTraceString = + getter.get(carrier, SentryTraceHeader.SENTRY_TRACE_HEADER); + if (sentryTraceString == null) { + return context.with(SENTRY_SCOPES_KEY, scopesToUse); + } + + try { + SentryTraceHeader sentryTraceHeader = new SentryTraceHeader(sentryTraceString); + + final @Nullable String baggageString = getter.get(carrier, BaggageHeader.BAGGAGE_HEADER); + final Baggage baggage = Baggage.fromHeader(baggageString); + final @NotNull TraceState traceState = TraceState.getDefault(); + + SpanContext otelSpanContext = + SpanContext.createFromRemoteParent( + sentryTraceHeader.getTraceId().toString(), + sentryTraceHeader.getSpanId().toString(), + TraceFlags.getSampled(), + traceState); + + Span wrappedSpan = Span.wrap(otelSpanContext); + + final @NotNull Context modifiedContext = + context + .with(wrappedSpan) + .with(SENTRY_SCOPES_KEY, scopesToUse) + .with(SentryOtelKeys.SENTRY_TRACE_KEY, sentryTraceHeader) + .with(SentryOtelKeys.SENTRY_BAGGAGE_KEY, baggage); + + scopes + .getOptions() + .getLogger() + .log(SentryLevel.DEBUG, "Continuing Sentry trace %s", sentryTraceHeader.getTraceId()); + + final @NotNull PropagationContext propagationContext = + PropagationContext.fromHeaders( + scopes.getOptions().getLogger(), sentryTraceString, baggageString); + scopesToUse.getIsolationScope().setPropagationContext(propagationContext); + + return modifiedContext; + } catch (InvalidSentryTraceHeaderException e) { + scopes + .getOptions() + .getLogger() + .log( + SentryLevel.ERROR, + "Unable to extract Sentry tracing information from invalid header.", + e); + return context; + } + } +} diff --git a/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/OtelSentrySpanProcessor.java b/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/OtelSentrySpanProcessor.java new file mode 100644 index 0000000000..7439906868 --- /dev/null +++ b/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/OtelSentrySpanProcessor.java @@ -0,0 +1,174 @@ +package io.sentry.opentelemetry; + +import static io.sentry.opentelemetry.InternalSemanticAttributes.IS_REMOTE_PARENT; +import static io.sentry.opentelemetry.SentryOtelKeys.SENTRY_SCOPES_KEY; + +import io.opentelemetry.api.trace.SpanContext; +import io.opentelemetry.context.Context; +import io.opentelemetry.sdk.trace.ReadWriteSpan; +import io.opentelemetry.sdk.trace.ReadableSpan; +import io.opentelemetry.sdk.trace.SpanProcessor; +import io.sentry.Baggage; +import io.sentry.IScopes; +import io.sentry.PropagationContext; +import io.sentry.ScopesAdapter; +import io.sentry.Sentry; +import io.sentry.SentryDate; +import io.sentry.SentryLevel; +import io.sentry.SentryLongDate; +import io.sentry.SentryTraceHeader; +import io.sentry.SpanId; +import io.sentry.TracesSamplingDecision; +import io.sentry.protocol.SentryId; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +public final class OtelSentrySpanProcessor implements SpanProcessor { + private final @NotNull SentryWeakSpanStorage spanStorage = SentryWeakSpanStorage.getInstance(); + private final @NotNull IScopes scopes; + + public OtelSentrySpanProcessor() { + this(ScopesAdapter.getInstance()); + } + + OtelSentrySpanProcessor(final @NotNull IScopes scopes) { + this.scopes = scopes; + } + + @Override + public void onStart(final @NotNull Context parentContext, final @NotNull ReadWriteSpan otelSpan) { + if (!ensurePrerequisites(otelSpan)) { + return; + } + + final @Nullable IScopes scopesFromContext = parentContext.get(SENTRY_SCOPES_KEY); + final @NotNull IScopes scopes = + scopesFromContext != null + ? scopesFromContext.forkedCurrentScope("spanprocessor") + : Sentry.forkedRootScopes("spanprocessor"); + + final @Nullable OtelSpanWrapper sentryParentSpan = + spanStorage.getSentrySpan(otelSpan.getParentSpanContext()); + @NotNull + TracesSamplingDecision samplingDecision = + OtelSamplingUtil.extractSamplingDecisionOrDefault(otelSpan.toSpanData().getAttributes()); + @Nullable Baggage baggage = null; + @Nullable SpanId sentryParentSpanId = null; + otelSpan.setAttribute(IS_REMOTE_PARENT, otelSpan.getParentSpanContext().isRemote()); + if (sentryParentSpan == null) { + final @NotNull String traceId = otelSpan.getSpanContext().getTraceId(); + final @NotNull String spanId = otelSpan.getSpanContext().getSpanId(); + final @NotNull SpanId sentrySpanId = new SpanId(spanId); + final @NotNull String parentSpanId = otelSpan.getParentSpanContext().getSpanId(); + sentryParentSpanId = + io.opentelemetry.api.trace.SpanId.isValid(parentSpanId) ? new SpanId(parentSpanId) : null; + + @Nullable + SentryTraceHeader sentryTraceHeader = parentContext.get(SentryOtelKeys.SENTRY_TRACE_KEY); + @Nullable Baggage baggageFromContext = parentContext.get(SentryOtelKeys.SENTRY_BAGGAGE_KEY); + if (sentryTraceHeader != null) { + baggage = baggageFromContext; + } + + final @Nullable Boolean baggageMutable = + otelSpan.getAttribute(InternalSemanticAttributes.BAGGAGE_MUTABLE); + final @Nullable String baggageString = + otelSpan.getAttribute(InternalSemanticAttributes.BAGGAGE); + if (baggageString != null) { + baggage = Baggage.fromHeader(baggageString); + if (baggageMutable == true) { + baggage.freeze(); + } + } + + final boolean sampled = + samplingDecision != null + ? samplingDecision.getSampled() + : otelSpan.getSpanContext().isSampled(); + + final @NotNull PropagationContext propagationContext = + sentryTraceHeader == null + ? new PropagationContext( + new SentryId(traceId), sentrySpanId, sentryParentSpanId, baggage, sampled) + : PropagationContext.fromHeaders(sentryTraceHeader, baggage, sentrySpanId); + + updatePropagationContext(scopes, propagationContext); + } + + final @NotNull SpanContext spanContext = otelSpan.getSpanContext(); + final @NotNull SentryDate startTimestamp = + new SentryLongDate(otelSpan.toSpanData().getStartEpochNanos()); + final @NotNull OtelSpanWrapper sentrySpan = + new OtelSpanWrapper( + otelSpan, + scopes, + startTimestamp, + samplingDecision, + sentryParentSpan, + sentryParentSpanId, + baggage); + sentrySpan.getSpanContext().setOrigin(SentrySpanExporter.TRACE_ORIGIN); + spanStorage.storeSentrySpan(spanContext, sentrySpan); + } + + private static void updatePropagationContext( + IScopes scopes, PropagationContext propagationContext) { + scopes.configureScope( + scope -> { + scope.withPropagationContext( + oldPropagationContext -> { + scope.setPropagationContext(propagationContext); + }); + }); + } + + @Override + public boolean isStartRequired() { + return true; + } + + @Override + public void onEnd(final @NotNull ReadableSpan spanBeingEnded) { + final @Nullable OtelSpanWrapper sentrySpan = + spanStorage.getSentrySpan(spanBeingEnded.getSpanContext()); + if (sentrySpan != null) { + final @NotNull SentryDate finishDate = + new SentryLongDate(spanBeingEnded.toSpanData().getEndEpochNanos()); + sentrySpan.updateEndDate(finishDate); + } + } + + @Override + public boolean isEndRequired() { + return true; + } + + private boolean ensurePrerequisites(final @NotNull ReadableSpan otelSpan) { + if (!hasSentryBeenInitialized()) { + scopes + .getOptions() + .getLogger() + .log( + SentryLevel.DEBUG, + "Not forwarding OpenTelemetry span to Sentry as Sentry has not yet been initialized."); + return false; + } + + final @NotNull SpanContext otelSpanContext = otelSpan.getSpanContext(); + if (!otelSpanContext.isValid()) { + scopes + .getOptions() + .getLogger() + .log( + SentryLevel.DEBUG, + "Not forwarding OpenTelemetry span to Sentry as the span is invalid."); + return false; + } + + return true; + } + + private boolean hasSentryBeenInitialized() { + return scopes.isEnabled(); + } +} diff --git a/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/OtelSpanInfo.java b/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/OtelSpanInfo.java index 6ad39d3793..d6a9aab202 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/OtelSpanInfo.java +++ b/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/OtelSpanInfo.java @@ -3,17 +3,18 @@ import io.sentry.protocol.TransactionNameSource; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; @ApiStatus.Internal public final class OtelSpanInfo { private final @NotNull String op; - private final @NotNull String description; + private final @Nullable String description; private final @NotNull TransactionNameSource transactionNameSource; public OtelSpanInfo( final @NotNull String op, - final @NotNull String description, + final @Nullable String description, final @NotNull TransactionNameSource transactionNameSource) { this.op = op; this.description = description; @@ -24,7 +25,7 @@ public OtelSpanInfo( return op; } - public @NotNull String getDescription() { + public @Nullable String getDescription() { return description; } diff --git a/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/SentryPropagator.java b/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/SentryPropagator.java index 14ac12323b..ffcab9ed54 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/SentryPropagator.java +++ b/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/SentryPropagator.java @@ -10,11 +10,10 @@ import io.opentelemetry.context.propagation.TextMapSetter; import io.sentry.Baggage; import io.sentry.BaggageHeader; -import io.sentry.HubAdapter; -import io.sentry.IHub; +import io.sentry.IScopes; import io.sentry.ISpan; +import io.sentry.ScopesAdapter; import io.sentry.SentryLevel; -import io.sentry.SentrySpanStorage; import io.sentry.SentryTraceHeader; import io.sentry.exception.InvalidSentryTraceHeaderException; import java.util.Arrays; @@ -24,19 +23,27 @@ import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; +/** + * @deprecated please use {@link OtelSentryPropagator} instead + */ +@Deprecated public final class SentryPropagator implements TextMapPropagator { private static final @NotNull List FIELDS = Arrays.asList(SentryTraceHeader.SENTRY_TRACE_HEADER, BaggageHeader.BAGGAGE_HEADER); - private final @NotNull SentrySpanStorage spanStorage = SentrySpanStorage.getInstance(); - private final @NotNull IHub hub; + + @SuppressWarnings("deprecation") + private final @NotNull io.sentry.SentrySpanStorage spanStorage = + io.sentry.SentrySpanStorage.getInstance(); + + private final @NotNull IScopes scopes; public SentryPropagator() { - this(HubAdapter.getInstance()); + this(ScopesAdapter.getInstance()); } - SentryPropagator(final @NotNull IHub hub) { - this.hub = hub; + SentryPropagator(final @NotNull IScopes scopes) { + this.scopes = scopes; } @Override @@ -49,7 +56,8 @@ public void inject(final Context context, final C carrier, final TextMapSett final @NotNull Span otelSpan = Span.fromContext(context); final @NotNull SpanContext otelSpanContext = otelSpan.getSpanContext(); if (!otelSpanContext.isValid()) { - hub.getOptions() + scopes + .getOptions() .getLogger() .log( SentryLevel.DEBUG, @@ -58,7 +66,8 @@ public void inject(final Context context, final C carrier, final TextMapSett } final @Nullable ISpan sentrySpan = spanStorage.get(otelSpanContext.getSpanId()); if (sentrySpan == null || sentrySpan.isNoOp()) { - hub.getOptions() + scopes + .getOptions() .getLogger() .log( SentryLevel.DEBUG, @@ -106,13 +115,15 @@ public Context extract( Span wrappedSpan = Span.wrap(otelSpanContext); modifiedContext = modifiedContext.with(wrappedSpan); - hub.getOptions() + scopes + .getOptions() .getLogger() .log(SentryLevel.DEBUG, "Continuing Sentry trace %s", sentryTraceHeader.getTraceId()); return modifiedContext; } catch (InvalidSentryTraceHeaderException e) { - hub.getOptions() + scopes + .getOptions() .getLogger() .log( SentryLevel.ERROR, diff --git a/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/SentrySampler.java b/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/SentrySampler.java new file mode 100644 index 0000000000..c52abb14fa --- /dev/null +++ b/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/SentrySampler.java @@ -0,0 +1,141 @@ +package io.sentry.opentelemetry; + +import static io.sentry.opentelemetry.OtelInternalSpanDetectionUtil.isSentryRequest; + +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.api.trace.Span; +import io.opentelemetry.api.trace.SpanKind; +import io.opentelemetry.context.Context; +import io.opentelemetry.sdk.trace.data.LinkData; +import io.opentelemetry.sdk.trace.samplers.Sampler; +import io.opentelemetry.sdk.trace.samplers.SamplingDecision; +import io.opentelemetry.sdk.trace.samplers.SamplingResult; +import io.sentry.Baggage; +import io.sentry.DataCategory; +import io.sentry.IScopes; +import io.sentry.PropagationContext; +import io.sentry.SamplingContext; +import io.sentry.ScopesAdapter; +import io.sentry.SentryLevel; +import io.sentry.SentryTraceHeader; +import io.sentry.SpanId; +import io.sentry.TracesSamplingDecision; +import io.sentry.TransactionContext; +import io.sentry.clientreport.DiscardReason; +import io.sentry.protocol.SentryId; +import java.util.List; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +public final class SentrySampler implements Sampler { + + private final @NotNull SentryWeakSpanStorage spanStorage = SentryWeakSpanStorage.getInstance(); + private final @NotNull IScopes scopes; + + public SentrySampler(final @NotNull IScopes scopes) { + this.scopes = scopes; + } + + public SentrySampler() { + this(ScopesAdapter.getInstance()); + } + + @Override + public SamplingResult shouldSample( + final @NotNull Context parentContext, + final @NotNull String traceId, + final @NotNull String name, + final @NotNull SpanKind spanKind, + final @NotNull Attributes attributes, + final @NotNull List parentLinks) { + if (isSentryRequest(scopes, spanKind, attributes)) { + return SamplingResult.drop(); + } + // note: parentLinks seems to usually be empty + final @Nullable Span parentOtelSpan = Span.fromContextOrNull(parentContext); + final @Nullable OtelSpanWrapper parentSentrySpan = + parentOtelSpan != null ? spanStorage.getSentrySpan(parentOtelSpan.getSpanContext()) : null; + + if (parentSentrySpan != null) { + return copyParentSentryDecision(parentSentrySpan); + } else { + final @Nullable TracesSamplingDecision samplingDecision = + OtelSamplingUtil.extractSamplingDecision(attributes); + if (samplingDecision != null) { + return new SentrySamplingResult(samplingDecision); + } else { + return handleRootOtelSpan(traceId, parentContext); + } + } + } + + private @NotNull SamplingResult handleRootOtelSpan( + final @NotNull String traceId, final @NotNull Context parentContext) { + if (!scopes.getOptions().isTracingEnabled()) { + return SamplingResult.create(SamplingDecision.RECORD_ONLY); + } + @Nullable Baggage baggage = null; + @Nullable + SentryTraceHeader sentryTraceHeader = parentContext.get(SentryOtelKeys.SENTRY_TRACE_KEY); + @Nullable Baggage baggageFromContext = parentContext.get(SentryOtelKeys.SENTRY_BAGGAGE_KEY); + if (sentryTraceHeader != null) { + baggage = baggageFromContext; + } + + // there's no way to get the span id here, so we just use a random id for sampling + SpanId randomSpanId = new SpanId(); + final @NotNull PropagationContext propagationContext = + sentryTraceHeader == null + ? new PropagationContext(new SentryId(traceId), randomSpanId, null, baggage, null) + : PropagationContext.fromHeaders(sentryTraceHeader, baggage, randomSpanId); + + final @NotNull TransactionContext transactionContext = + TransactionContext.fromPropagationContext(propagationContext); + final @NotNull TracesSamplingDecision sentryDecision = + scopes + .getOptions() + .getInternalTracesSampler() + .sample(new SamplingContext(transactionContext, null)); + + if (!sentryDecision.getSampled()) { + scopes + .getOptions() + .getClientReportRecorder() + .recordLostEvent(DiscardReason.SAMPLE_RATE, DataCategory.Transaction); + scopes + .getOptions() + .getClientReportRecorder() + .recordLostEvent(DiscardReason.SAMPLE_RATE, DataCategory.Span); + } + + return new SentrySamplingResult(sentryDecision); + } + + private @NotNull SentrySamplingResult copyParentSentryDecision( + final @NotNull OtelSpanWrapper parentSentrySpan) { + final @Nullable TracesSamplingDecision parentSamplingDecision = + parentSentrySpan.getSamplingDecision(); + if (parentSamplingDecision != null) { + if (!parentSamplingDecision.getSampled()) { + scopes + .getOptions() + .getClientReportRecorder() + .recordLostEvent(DiscardReason.SAMPLE_RATE, DataCategory.Span); + } + return new SentrySamplingResult(parentSamplingDecision); + } else { + scopes + .getOptions() + .getLogger() + .log( + SentryLevel.WARNING, + "Encountered a missing parent sampling decision where one was expected."); + return new SentrySamplingResult(new TracesSamplingDecision(true)); + } + } + + @Override + public String getDescription() { + return "SentrySampler"; + } +} diff --git a/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/SentrySamplingResult.java b/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/SentrySamplingResult.java new file mode 100644 index 0000000000..69acf52134 --- /dev/null +++ b/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/SentrySamplingResult.java @@ -0,0 +1,40 @@ +package io.sentry.opentelemetry; + +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.sdk.trace.samplers.SamplingDecision; +import io.opentelemetry.sdk.trace.samplers.SamplingResult; +import io.sentry.TracesSamplingDecision; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; + +@ApiStatus.Internal +public final class SentrySamplingResult implements SamplingResult { + private final TracesSamplingDecision sentryDecision; + + public SentrySamplingResult(final @NotNull TracesSamplingDecision sentryDecision) { + this.sentryDecision = sentryDecision; + } + + @Override + public SamplingDecision getDecision() { + if (sentryDecision.getSampled()) { + return SamplingDecision.RECORD_AND_SAMPLE; + } else { + return SamplingDecision.RECORD_ONLY; + } + } + + @Override + public Attributes getAttributes() { + return Attributes.builder() + .put(InternalSemanticAttributes.SAMPLED, sentryDecision.getSampled()) + .put(InternalSemanticAttributes.SAMPLE_RATE, sentryDecision.getSampleRate()) + .put(InternalSemanticAttributes.PROFILE_SAMPLED, sentryDecision.getProfileSampled()) + .put(InternalSemanticAttributes.PROFILE_SAMPLE_RATE, sentryDecision.getProfileSampleRate()) + .build(); + } + + public TracesSamplingDecision getSentryDecision() { + return sentryDecision; + } +} diff --git a/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/SentrySpanExporter.java b/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/SentrySpanExporter.java new file mode 100644 index 0000000000..dbb0c8d32f --- /dev/null +++ b/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/SentrySpanExporter.java @@ -0,0 +1,510 @@ +package io.sentry.opentelemetry; + +import static io.sentry.TransactionContext.DEFAULT_TRANSACTION_NAME; +import static io.sentry.opentelemetry.InternalSemanticAttributes.IS_REMOTE_PARENT; +import static io.sentry.opentelemetry.OtelInternalSpanDetectionUtil.isSentryRequest; + +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.api.trace.StatusCode; +import io.opentelemetry.sdk.common.CompletableResultCode; +import io.opentelemetry.sdk.trace.data.SpanData; +import io.opentelemetry.sdk.trace.data.StatusData; +import io.opentelemetry.sdk.trace.export.SpanExporter; +import io.opentelemetry.semconv.HttpAttributes; +import io.opentelemetry.semconv.incubating.ProcessIncubatingAttributes; +import io.sentry.Baggage; +import io.sentry.DateUtils; +import io.sentry.DefaultSpanFactory; +import io.sentry.IScopes; +import io.sentry.ISpan; +import io.sentry.ITransaction; +import io.sentry.Instrumenter; +import io.sentry.ScopesAdapter; +import io.sentry.SentryDate; +import io.sentry.SentryInstantDate; +import io.sentry.SentryLevel; +import io.sentry.SentryLongDate; +import io.sentry.SpanContext; +import io.sentry.SpanId; +import io.sentry.SpanOptions; +import io.sentry.SpanStatus; +import io.sentry.TransactionContext; +import io.sentry.TransactionOptions; +import io.sentry.protocol.Contexts; +import io.sentry.protocol.SentryId; +import io.sentry.protocol.TransactionNameSource; +import java.util.Arrays; +import java.util.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.function.Predicate; +import java.util.stream.Collectors; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +public final class SentrySpanExporter implements SpanExporter { + private volatile boolean stopped = false; + private final List finishedSpans = new CopyOnWriteArrayList<>(); + private final @NotNull SentryWeakSpanStorage spanStorage = SentryWeakSpanStorage.getInstance(); + private final @NotNull SpanDescriptionExtractor spanDescriptionExtractor = + new SpanDescriptionExtractor(); + private final @NotNull IScopes scopes; + + private final @NotNull List attributeKeysToRemove = + Arrays.asList( + InternalSemanticAttributes.IS_REMOTE_PARENT.getKey(), + InternalSemanticAttributes.BAGGAGE.getKey(), + InternalSemanticAttributes.BAGGAGE_MUTABLE.getKey(), + InternalSemanticAttributes.SAMPLED.getKey(), + InternalSemanticAttributes.SAMPLE_RATE.getKey(), + InternalSemanticAttributes.PROFILE_SAMPLED.getKey(), + InternalSemanticAttributes.PROFILE_SAMPLE_RATE.getKey(), + InternalSemanticAttributes.PARENT_SAMPLED.getKey(), + ProcessIncubatingAttributes.PROCESS_COMMAND_ARGS.getKey() // can be very long + ); + private static final @NotNull Long SPAN_TIMEOUT = DateUtils.secondsToNanos(5 * 60); + + public static final String TRACE_ORIGIN = "auto.opentelemetry"; + + public SentrySpanExporter() { + this(ScopesAdapter.getInstance()); + } + + public SentrySpanExporter(final @NotNull IScopes scopes) { + this.scopes = scopes; + } + + @Override + public CompletableResultCode export(Collection spans) { + if (stopped) { + // TODO unsure if there's a way to attach a message + return CompletableResultCode.ofFailure(); + } + + final int openSpanCount = finishedSpans.size(); + final int newSpanCount = spans.size(); + + final @NotNull List nonSentryRequestSpans = filterOutSentrySpans(spans); + + finishedSpans.addAll(nonSentryRequestSpans); + final @NotNull List remaining = maybeSend(finishedSpans); + final int remainingSpanCount = remaining.size(); + final int sentSpanCount = openSpanCount + newSpanCount - remainingSpanCount; + + scopes + .getOptions() + .getLogger() + .log( + SentryLevel.DEBUG, + "SpanExporter exported %s spans, %s unset spans remaining.", + sentSpanCount, + remainingSpanCount); + + this.finishedSpans.clear(); + + final @NotNull SentryInstantDate now = new SentryInstantDate(); + + final @NotNull List nonExpired = + remaining.stream().filter((span) -> !isSpanTooOld(span, now)).collect(Collectors.toList()); + + this.finishedSpans.addAll(nonExpired); + + // TODO + + return CompletableResultCode.ofSuccess(); + } + + private boolean isSpanTooOld(final @NotNull SpanData span, final @NotNull SentryInstantDate now) { + final @NotNull SentryDate startDate = new SentryLongDate(span.getStartEpochNanos()); + boolean isTimedOut = now.diff(startDate) > SPAN_TIMEOUT; + if (isTimedOut) { + scopes + .getOptions() + .getLogger() + .log( + SentryLevel.DEBUG, + "Dropping span %s as it was pending for too long.", + span.getSpanId()); + } + return isTimedOut; + } + + private @NotNull List filterOutSentrySpans(final @NotNull Collection spans) { + return spans.stream() + .filter((span) -> !isSentryRequest(scopes, span.getKind(), span.getAttributes())) + .collect(Collectors.toList()); + } + + private List maybeSend(final @NotNull List spans) { + final @NotNull List grouped = groupSpansWithParents(spans); + final @NotNull List remaining = new CopyOnWriteArrayList<>(grouped); + final @NotNull List rootNodes = findCompletedRootNodes(grouped); + + for (final @NotNull SpanNode rootNode : rootNodes) { + remaining.remove(rootNode); + final @Nullable SpanData span = rootNode.getSpan(); + if (span == null) { + // TODO log + continue; + } + final @Nullable ITransaction transaction = createTransactionForOtelSpan(span); + if (transaction == null) { + // TODO log + continue; + } + + for (final @NotNull SpanNode childNode : rootNode.getChildren()) { + createAndFinishSpanForOtelSpan(childNode, transaction, remaining); + } + + transaction.finish( + mapOtelStatus(span, transaction), new SentryLongDate(span.getEndEpochNanos())); + } + + return remaining.stream() + .map((node) -> node.getSpan()) + .filter((it) -> it != null) + .collect(Collectors.toList()); + } + + private void createAndFinishSpanForOtelSpan( + final @NotNull SpanNode spanNode, + final @NotNull ISpan parentSentrySpan, + final @NotNull List remaining) { + remaining.remove(spanNode); + final @Nullable SpanData spanData = spanNode.getSpan(); + + // If this span should be dropped, we still want to create spans for the children of this + if (spanData == null) { + for (SpanNode childNode : spanNode.getChildren()) { + createAndFinishSpanForOtelSpan(childNode, parentSentrySpan, remaining); + } + return; + } + + final @NotNull String spanId = spanData.getSpanId(); + final @Nullable OtelSpanWrapper sentrySpanMaybe = + spanStorage.getSentrySpan(spanData.getSpanContext()); + final @NotNull OtelSpanInfo spanInfo = + spanDescriptionExtractor.extractSpanInfo(spanData, sentrySpanMaybe); + + scopes + .getOptions() + .getLogger() + .log( + SentryLevel.DEBUG, + "Creating Sentry child span for OpenTelemetry span %s (trace %s). Parent span is %s.", + spanId, + spanData.getTraceId(), + spanData.getParentSpanId()); + final @NotNull SentryDate startDate = new SentryLongDate(spanData.getStartEpochNanos()); + final @NotNull SpanOptions spanOptions = new SpanOptions(); + final @NotNull io.sentry.SpanContext spanContext = + parentSentrySpan + .getSpanContext() + .copyForChild( + spanInfo.getOp(), + parentSentrySpan.getSpanContext().getSpanId(), + new SpanId(spanId)); + spanContext.setDescription(spanInfo.getDescription()); + spanContext.setInstrumenter(Instrumenter.SENTRY); + if (sentrySpanMaybe != null) { + spanContext.setSamplingDecision(sentrySpanMaybe.getSamplingDecision()); + spanOptions.setOrigin(sentrySpanMaybe.getSpanContext().getOrigin()); + } else { + spanOptions.setOrigin(TRACE_ORIGIN); + } + + spanOptions.setStartTimestamp(startDate); + + final @NotNull ISpan sentryChildSpan = parentSentrySpan.startChild(spanContext, spanOptions); + + for (Map.Entry dataField : + toMapWithStringKeys(spanData.getAttributes()).entrySet()) { + sentryChildSpan.setData(dataField.getKey(), dataField.getValue()); + } + + setOtelInstrumentationInfo(spanData, sentryChildSpan); + + transferSpanDetails(sentrySpanMaybe, sentryChildSpan); + + for (SpanNode childNode : spanNode.getChildren()) { + createAndFinishSpanForOtelSpan(childNode, sentryChildSpan, remaining); + } + + sentryChildSpan.finish( + mapOtelStatus(spanData, sentryChildSpan), new SentryLongDate(spanData.getEndEpochNanos())); + } + + private void transferSpanDetails( + final @Nullable OtelSpanWrapper sourceSpanMaybe, final @NotNull ISpan targetSpan) { + if (sourceSpanMaybe != null) { + final @NotNull OtelSpanWrapper sourceSpan = sourceSpanMaybe; + + final @NotNull Contexts contexts = sourceSpan.getContexts(); + targetSpan.getContexts().putAll(contexts); + + final @NotNull Map data = sourceSpan.getData(); + for (Map.Entry entry : data.entrySet()) { + targetSpan.setData(entry.getKey(), entry.getValue()); + } + + final @NotNull Map tags = sourceSpan.getTags(); + for (Map.Entry entry : tags.entrySet()) { + targetSpan.setTag(entry.getKey(), entry.getValue()); + } + + targetSpan.setStatus(sourceSpan.getStatus()); + } + } + + private @Nullable ITransaction createTransactionForOtelSpan(final @NotNull SpanData span) { + final @NotNull String spanId = span.getSpanId(); + final @NotNull String traceId = span.getTraceId(); + final @Nullable OtelSpanWrapper sentrySpanMaybe = + spanStorage.getSentrySpan(span.getSpanContext()); + final @Nullable IScopes scopesMaybe = + sentrySpanMaybe != null ? sentrySpanMaybe.getScopes() : null; + final @NotNull IScopes scopesToUse = + scopesMaybe == null ? ScopesAdapter.getInstance() : scopesMaybe; + final @NotNull OtelSpanInfo spanInfo = + spanDescriptionExtractor.extractSpanInfo(span, sentrySpanMaybe); + + scopesToUse + .getOptions() + .getLogger() + .log( + SentryLevel.DEBUG, + "Creating Sentry transaction for OpenTelemetry span %s (trace %s).", + spanId, + traceId); + final SpanId sentrySpanId = new SpanId(spanId); + + @Nullable String transactionName = spanInfo.getDescription(); + @NotNull TransactionNameSource transactionNameSource = spanInfo.getTransactionNameSource(); + @Nullable SpanId parentSpanId = null; + @Nullable Baggage baggage = null; + + if (sentrySpanMaybe != null) { + final @NotNull OtelSpanWrapper sentrySpan = sentrySpanMaybe; + final @Nullable String transactionNameMaybe = sentrySpan.getTransactionName(); + if (transactionNameMaybe != null) { + transactionName = transactionNameMaybe; + } + final @Nullable TransactionNameSource transactionNameSourceMaybe = + sentrySpan.getTransactionNameSource(); + if (transactionNameSourceMaybe != null) { + transactionNameSource = transactionNameSourceMaybe; + } + final @NotNull SpanContext spanContext = sentrySpan.getSpanContext(); + parentSpanId = spanContext.getParentSpanId(); + baggage = spanContext.getBaggage(); + } + + final @NotNull TransactionContext transactionContext = + new TransactionContext(new SentryId(traceId), sentrySpanId, parentSpanId, null, baggage); + + TransactionOptions transactionOptions = new TransactionOptions(); + + transactionContext.setName( + transactionName == null ? DEFAULT_TRANSACTION_NAME : transactionName); + transactionContext.setTransactionNameSource(transactionNameSource); + transactionContext.setOperation(spanInfo.getOp()); + transactionContext.setInstrumenter(Instrumenter.SENTRY); + if (sentrySpanMaybe != null) { + transactionContext.setSamplingDecision(sentrySpanMaybe.getSamplingDecision()); + transactionOptions.setOrigin(sentrySpanMaybe.getSpanContext().getOrigin()); + } + + transactionOptions.setStartTimestamp(new SentryLongDate(span.getStartEpochNanos())); + transactionOptions.setSpanFactory(new DefaultSpanFactory()); + + ITransaction sentryTransaction = + scopesToUse.startTransaction(transactionContext, transactionOptions); + + final @NotNull Map otelContext = toOtelContext(span); + sentryTransaction.setContext("otel", otelContext); + + setOtelInstrumentationInfo(span, sentryTransaction); + + transferSpanDetails(sentrySpanMaybe, sentryTransaction); + + return sentryTransaction; + } + + private List findCompletedRootNodes(final @NotNull List grouped) { + final @NotNull Predicate isRootPredicate = + (node) -> { + return node.getParentNode() == null && node.getSpan() != null; + }; + return grouped.stream().filter(isRootPredicate).collect(Collectors.toList()); + } + + private List groupSpansWithParents(final @NotNull List spans) { + final @NotNull Map nodeMap = new HashMap<>(); + + for (final @NotNull SpanData spanData : spans) { + createOrUpdateSpanNodeAndRefs(nodeMap, spanData); + } + + return nodeMap.values().stream().collect(Collectors.toList()); + } + + private void createOrUpdateSpanNodeAndRefs( + final @NotNull Map nodeMap, final @NotNull SpanData spanData) { + final @NotNull String spanId = spanData.getSpanId(); + final String parentId = getParentId(spanData); + if (parentId == null) { + createOrUpdateNode(nodeMap, spanId, spanData, null, null); + return; + } + + final @NotNull SpanNode parentNode = createOrGetParentNode(nodeMap, parentId); + final @NotNull SpanNode spanNode = + createOrUpdateNode(nodeMap, spanId, spanData, null, parentNode); + parentNode.addChild(spanNode); + } + + private @Nullable String getParentId(final @NotNull SpanData spanData) { + final @NotNull String parentSpanId = spanData.getParentSpanId(); + final @Nullable Boolean isRemoteParent = spanData.getAttributes().get(IS_REMOTE_PARENT); + if (isRemoteParent != null && isRemoteParent) { + return null; + } + if (io.opentelemetry.api.trace.SpanId.isValid(parentSpanId)) { + return parentSpanId; + } + return null; + } + + private @NotNull SpanNode createOrGetParentNode( + final @NotNull Map nodeMap, final @NotNull String spanId) { + final @Nullable SpanNode existingNode = nodeMap.get(spanId); + + if (existingNode == null) { + return createOrUpdateNode(nodeMap, spanId, null, null, null); + } + + return existingNode; + } + + // TODO do we ever pass children? + private @NotNull SpanNode createOrUpdateNode( + final @NotNull Map nodeMap, + final @NotNull String spanId, + final @Nullable SpanData spanData, + final @Nullable List children, + final @Nullable SpanNode parentNode) { + final @Nullable SpanNode existingNode = nodeMap.get(spanId); + + if (existingNode != null) { + final @Nullable SpanData existingNodeSpan = existingNode.getSpan(); + + if (existingNodeSpan != null) { + // If span is already set, nothing to do here + return existingNode; + } + + // If span is not set yet, we update it + existingNode.setSpan(spanData); + existingNode.setParentNode(parentNode); + + return existingNode; + } + + final @NotNull SpanNode spanNode = new SpanNode(spanId); + spanNode.setSpan(spanData); + spanNode.setParentNode(parentNode); + spanNode.addChildren(children); + + nodeMap.put(spanId, spanNode); + + return spanNode; + } + + @SuppressWarnings("deprecation") + private SpanStatus mapOtelStatus( + final @NotNull SpanData otelSpanData, final @NotNull ISpan sentrySpan) { + final @Nullable SpanStatus existingStatus = sentrySpan.getStatus(); + if (existingStatus != null && existingStatus != SpanStatus.UNKNOWN_ERROR) { + return existingStatus; + } + + final @NotNull StatusData otelStatus = otelSpanData.getStatus(); + final @NotNull StatusCode otelStatusCode = otelStatus.getStatusCode(); + + if (StatusCode.OK.equals(otelStatusCode) || StatusCode.UNSET.equals(otelStatusCode)) { + return SpanStatus.OK; + } + + final @Nullable Long httpStatus = + otelSpanData.getAttributes().get(HttpAttributes.HTTP_RESPONSE_STATUS_CODE); + if (httpStatus != null) { + final @Nullable SpanStatus spanStatus = SpanStatus.fromHttpStatusCode(httpStatus.intValue()); + if (spanStatus != null) { + return spanStatus; + } + } + + return SpanStatus.UNKNOWN_ERROR; + } + + private @NotNull Map toOtelContext(final @NotNull SpanData spanData) { + final @NotNull Map context = new HashMap<>(); + + context.put("attributes", toMapWithStringKeys(spanData.getAttributes())); + context.put("resource", toMapWithStringKeys(spanData.getResource().getAttributes())); + + return context; + } + + private @NotNull Map toMapWithStringKeys(final @Nullable Attributes attributes) { + final @NotNull Map mapWithStringKeys = new HashMap<>(); + + if (attributes != null) { + attributes.forEach( + (key, value) -> { + if (key != null) { + final @NotNull String stringKey = key.getKey(); + if (!shouldRemoveAttribute(stringKey)) { + mapWithStringKeys.put(stringKey, value); + } + } + }); + } + + return mapWithStringKeys; + } + + private boolean shouldRemoveAttribute(final @NotNull String key) { + return attributeKeysToRemove.contains(key); + } + + private void setOtelInstrumentationInfo(SpanData span, ISpan sentryTransaction) { + final @Nullable String otelInstrumentationName = span.getInstrumentationScopeInfo().getName(); + if (otelInstrumentationName != null) { + sentryTransaction.setData("otel.instrumentation.name", otelInstrumentationName); + } + + final @Nullable String otelInstrumentationVersion = + span.getInstrumentationScopeInfo().getVersion(); + if (otelInstrumentationVersion != null) { + sentryTransaction.setData("otel.instrumentation.version", otelInstrumentationVersion); + } + } + + @Override + public CompletableResultCode flush() { + scopes.flush(10000); + return CompletableResultCode.ofSuccess(); + } + + @Override + public CompletableResultCode shutdown() { + stopped = true; + scopes.close(); + return CompletableResultCode.ofSuccess(); + } +} diff --git a/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/SentrySpanProcessor.java b/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/SentrySpanProcessor.java index a9e70f66a0..2b650ef9dd 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/SentrySpanProcessor.java +++ b/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/SentrySpanProcessor.java @@ -1,5 +1,7 @@ package io.sentry.opentelemetry; +import static io.sentry.TransactionContext.DEFAULT_TRANSACTION_NAME; + import io.opentelemetry.api.common.Attributes; import io.opentelemetry.api.trace.SpanContext; import io.opentelemetry.api.trace.SpanKind; @@ -10,21 +12,22 @@ import io.opentelemetry.sdk.trace.SpanProcessor; import io.opentelemetry.sdk.trace.data.SpanData; import io.opentelemetry.sdk.trace.data.StatusData; -import io.opentelemetry.semconv.SemanticAttributes; +import io.opentelemetry.semconv.HttpAttributes; +import io.opentelemetry.semconv.UrlAttributes; import io.sentry.Baggage; import io.sentry.DsnUtil; -import io.sentry.HubAdapter; -import io.sentry.IHub; +import io.sentry.IScopes; import io.sentry.ISpan; import io.sentry.ITransaction; import io.sentry.Instrumenter; import io.sentry.PropagationContext; +import io.sentry.ScopesAdapter; import io.sentry.SentryDate; import io.sentry.SentryLevel; import io.sentry.SentryLongDate; -import io.sentry.SentrySpanStorage; import io.sentry.SentryTraceHeader; import io.sentry.SpanId; +import io.sentry.SpanOptions; import io.sentry.SpanStatus; import io.sentry.TransactionContext; import io.sentry.TransactionOptions; @@ -37,6 +40,10 @@ import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; +/** + * @deprecated please use {@link OtelSentrySpanProcessor} instead. + */ +@Deprecated public final class SentrySpanProcessor implements SpanProcessor { private static final String TRACE_ORIGN = "auto.otel"; @@ -45,15 +52,19 @@ public final class SentrySpanProcessor implements SpanProcessor { Arrays.asList(SpanKind.CLIENT, SpanKind.INTERNAL); private final @NotNull SpanDescriptionExtractor spanDescriptionExtractor = new SpanDescriptionExtractor(); - private final @NotNull SentrySpanStorage spanStorage = SentrySpanStorage.getInstance(); - private final @NotNull IHub hub; + + @SuppressWarnings("deprecation") + private final @NotNull io.sentry.SentrySpanStorage spanStorage = + io.sentry.SentrySpanStorage.getInstance(); + + private final @NotNull IScopes scopes; public SentrySpanProcessor() { - this(HubAdapter.getInstance()); + this(ScopesAdapter.getInstance()); } - SentrySpanProcessor(final @NotNull IHub hub) { - this.hub = hub; + SentrySpanProcessor(final @NotNull IScopes scopes) { + this.scopes = scopes; } @Override @@ -65,7 +76,8 @@ public void onStart(final @NotNull Context parentContext, final @NotNull ReadWri final @NotNull TraceData traceData = getTraceData(otelSpan, parentContext); if (isSentryRequest(otelSpan)) { - hub.getOptions() + scopes + .getOptions() .getLogger() .log( SentryLevel.DEBUG, @@ -78,7 +90,8 @@ public void onStart(final @NotNull Context parentContext, final @NotNull ReadWri traceData.getParentSpanId() == null ? null : spanStorage.get(traceData.getParentSpanId()); if (sentryParentSpan != null) { - hub.getOptions() + scopes + .getOptions() .getLogger() .log( SentryLevel.DEBUG, @@ -88,13 +101,15 @@ public void onStart(final @NotNull Context parentContext, final @NotNull ReadWri traceData.getParentSpanId()); final @NotNull SentryDate startDate = new SentryLongDate(otelSpan.toSpanData().getStartEpochNanos()); + final @NotNull SpanOptions spanOptions = new SpanOptions(); + spanOptions.setOrigin(TRACE_ORIGN); final @NotNull ISpan sentryChildSpan = sentryParentSpan.startChild( - otelSpan.getName(), otelSpan.getName(), startDate, Instrumenter.OTEL); - sentryChildSpan.getSpanContext().setOrigin(TRACE_ORIGN); + otelSpan.getName(), otelSpan.getName(), startDate, Instrumenter.OTEL, spanOptions); spanStorage.store(traceData.getSpanId(), sentryChildSpan); } else { - hub.getOptions() + scopes + .getOptions() .getLogger() .log( SentryLevel.DEBUG, @@ -122,9 +137,9 @@ public void onStart(final @NotNull Context parentContext, final @NotNull ReadWri TransactionOptions transactionOptions = new TransactionOptions(); transactionOptions.setStartTimestamp( new SentryLongDate(otelSpan.toSpanData().getStartEpochNanos())); + transactionOptions.setOrigin(TRACE_ORIGN); - ISpan sentryTransaction = hub.startTransaction(transactionContext, transactionOptions); - sentryTransaction.getSpanContext().setOrigin(TRACE_ORIGN); + ISpan sentryTransaction = scopes.startTransaction(transactionContext, transactionOptions); spanStorage.store(traceData.getSpanId(), sentryTransaction); } } @@ -144,7 +159,8 @@ public void onEnd(final @NotNull ReadableSpan otelSpan) { final @Nullable ISpan sentrySpan = spanStorage.removeAndGet(traceData.getSpanId()); if (sentrySpan == null) { - hub.getOptions() + scopes + .getOptions() .getLogger() .log( SentryLevel.DEBUG, @@ -155,7 +171,8 @@ public void onEnd(final @NotNull ReadableSpan otelSpan) { } if (isSentryRequest(otelSpan)) { - hub.getOptions() + scopes + .getOptions() .getLogger() .log( SentryLevel.DEBUG, @@ -168,7 +185,8 @@ public void onEnd(final @NotNull ReadableSpan otelSpan) { if (sentrySpan instanceof ITransaction) { final @NotNull ITransaction sentryTransaction = (ITransaction) sentrySpan; updateTransactionWithOtelData(sentryTransaction, otelSpan); - hub.getOptions() + scopes + .getOptions() .getLogger() .log( SentryLevel.DEBUG, @@ -178,7 +196,8 @@ public void onEnd(final @NotNull ReadableSpan otelSpan) { traceData.getTraceId()); } else { updateSpanWithOtelData(sentrySpan, otelSpan); - hub.getOptions() + scopes + .getOptions() .getLogger() .log( SentryLevel.DEBUG, @@ -201,7 +220,8 @@ public boolean isEndRequired() { private boolean ensurePrerequisites(final @NotNull ReadableSpan otelSpan) { if (!hasSentryBeenInitialized()) { - hub.getOptions() + scopes + .getOptions() .getLogger() .log( SentryLevel.DEBUG, @@ -209,9 +229,10 @@ private boolean ensurePrerequisites(final @NotNull ReadableSpan otelSpan) { return false; } - final @NotNull Instrumenter instrumenter = hub.getOptions().getInstrumenter(); + final @NotNull Instrumenter instrumenter = scopes.getOptions().getInstrumenter(); if (!Instrumenter.OTEL.equals(instrumenter)) { - hub.getOptions() + scopes + .getOptions() .getLogger() .log( SentryLevel.DEBUG, @@ -222,7 +243,8 @@ private boolean ensurePrerequisites(final @NotNull ReadableSpan otelSpan) { final @NotNull SpanContext otelSpanContext = otelSpan.getSpanContext(); if (!otelSpanContext.isValid()) { - hub.getOptions() + scopes + .getOptions() .getLogger() .log( SentryLevel.DEBUG, @@ -240,8 +262,8 @@ private boolean isSentryRequest(final @NotNull ReadableSpan otelSpan) { return false; } - final @Nullable String httpUrl = otelSpan.getAttribute(SemanticAttributes.HTTP_URL); - return DsnUtil.urlContainsDsnHost(hub.getOptions(), httpUrl); + final @Nullable String httpUrl = otelSpan.getAttribute(UrlAttributes.URL_FULL); + return DsnUtil.urlContainsDsnHost(scopes.getOptions(), httpUrl); } private @NotNull TraceData getTraceData( @@ -272,10 +294,12 @@ private boolean isSentryRequest(final @NotNull ReadableSpan otelSpan) { private void updateTransactionWithOtelData( final @NotNull ITransaction sentryTransaction, final @NotNull ReadableSpan otelSpan) { final @NotNull OtelSpanInfo otelSpanInfo = - spanDescriptionExtractor.extractSpanDescription(otelSpan); + spanDescriptionExtractor.extractSpanInfo(otelSpan.toSpanData(), null); sentryTransaction.setOperation(otelSpanInfo.getOp()); + String transactionName = otelSpanInfo.getDescription(); sentryTransaction.setName( - otelSpanInfo.getDescription(), otelSpanInfo.getTransactionNameSource()); + transactionName == null ? DEFAULT_TRANSACTION_NAME : transactionName, + otelSpanInfo.getTransactionNameSource()); final @NotNull Map otelContext = toOtelContext(otelSpan); sentryTransaction.setContext("otel", otelContext); @@ -307,7 +331,7 @@ private void updateSpanWithOtelData( }); final @NotNull OtelSpanInfo otelSpanInfo = - spanDescriptionExtractor.extractSpanDescription(otelSpan); + spanDescriptionExtractor.extractSpanInfo(otelSpan.toSpanData(), null); sentrySpan.setOperation(otelSpanInfo.getOp()); sentrySpan.setDescription(otelSpanInfo.getDescription()); } @@ -322,7 +346,8 @@ private SpanStatus mapOtelStatus(final @NotNull ReadableSpan otelSpan) { return SpanStatus.OK; } - final @Nullable Long httpStatus = otelSpan.getAttribute(SemanticAttributes.HTTP_STATUS_CODE); + final @Nullable Long httpStatus = + otelSpan.getAttribute(HttpAttributes.HTTP_RESPONSE_STATUS_CODE); if (httpStatus != null) { final @Nullable SpanStatus spanStatus = SpanStatus.fromHttpStatusCode(httpStatus.intValue()); if (spanStatus != null) { @@ -334,7 +359,7 @@ private SpanStatus mapOtelStatus(final @NotNull ReadableSpan otelSpan) { } private boolean hasSentryBeenInitialized() { - return hub.isEnabled(); + return scopes.isEnabled(); } private @NotNull Map toMapWithStringKeys(final @Nullable Attributes attributes) { diff --git a/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/SpanDescriptionExtractor.java b/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/SpanDescriptionExtractor.java index 57db007b0a..2a67ca46ad 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/SpanDescriptionExtractor.java +++ b/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/SpanDescriptionExtractor.java @@ -1,8 +1,12 @@ package io.sentry.opentelemetry; +import io.opentelemetry.api.common.Attributes; import io.opentelemetry.api.trace.SpanKind; -import io.opentelemetry.sdk.trace.ReadableSpan; -import io.opentelemetry.semconv.SemanticAttributes; +import io.opentelemetry.sdk.trace.data.SpanData; +import io.opentelemetry.semconv.HttpAttributes; +import io.opentelemetry.semconv.UrlAttributes; +import io.opentelemetry.semconv.incubating.DbIncubatingAttributes; +import io.opentelemetry.semconv.incubating.HttpIncubatingAttributes; import io.sentry.protocol.TransactionNameSource; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; @@ -12,38 +16,58 @@ public final class SpanDescriptionExtractor { @SuppressWarnings("deprecation") - public @NotNull OtelSpanInfo extractSpanDescription(final @NotNull ReadableSpan otelSpan) { - final @NotNull String name = otelSpan.getName(); + public @NotNull OtelSpanInfo extractSpanInfo( + final @NotNull SpanData otelSpan, final @Nullable OtelSpanWrapper sentrySpan) { + final @NotNull Attributes attributes = otelSpan.getAttributes(); - final @Nullable String httpMethod = otelSpan.getAttribute(SemanticAttributes.HTTP_METHOD); + final @Nullable String httpMethod = attributes.get(HttpAttributes.HTTP_REQUEST_METHOD); if (httpMethod != null) { return descriptionForHttpMethod(otelSpan, httpMethod); } - final @Nullable String dbSystem = otelSpan.getAttribute(SemanticAttributes.DB_SYSTEM); + final @Nullable String httpRequestMethod = attributes.get(HttpAttributes.HTTP_REQUEST_METHOD); + if (httpRequestMethod != null) { + return descriptionForHttpMethod(otelSpan, httpRequestMethod); + } + + final @Nullable String dbSystem = attributes.get(DbIncubatingAttributes.DB_SYSTEM); if (dbSystem != null) { return descriptionForDbSystem(otelSpan); } - return new OtelSpanInfo(name, name, TransactionNameSource.CUSTOM); + final @NotNull String name = otelSpan.getName(); + final @Nullable String maybeDescription = + sentrySpan != null ? sentrySpan.getDescription() : name; + final @NotNull String description = maybeDescription != null ? maybeDescription : name; + return new OtelSpanInfo(name, description, TransactionNameSource.CUSTOM); } @SuppressWarnings("deprecation") private OtelSpanInfo descriptionForHttpMethod( - final @NotNull ReadableSpan otelSpan, final @NotNull String httpMethod) { + final @NotNull SpanData otelSpan, final @NotNull String httpMethod) { final @NotNull String name = otelSpan.getName(); final @NotNull SpanKind kind = otelSpan.getKind(); final @NotNull StringBuilder opBuilder = new StringBuilder("http"); + final @NotNull Attributes attributes = otelSpan.getAttributes(); if (SpanKind.CLIENT.equals(kind)) { opBuilder.append(".client"); } else if (SpanKind.SERVER.equals(kind)) { opBuilder.append(".server"); } - final @Nullable String httpTarget = otelSpan.getAttribute(SemanticAttributes.HTTP_TARGET); - final @Nullable String httpRoute = otelSpan.getAttribute(SemanticAttributes.HTTP_ROUTE); - final @Nullable String httpPath = httpRoute != null ? httpRoute : httpTarget; + final @Nullable String httpTarget = attributes.get(HttpIncubatingAttributes.HTTP_TARGET); + final @Nullable String httpRoute = attributes.get(HttpAttributes.HTTP_ROUTE); + @Nullable String httpPath = httpRoute; + if (httpPath == null) { + httpPath = httpTarget; + } final @NotNull String op = opBuilder.toString(); + final @Nullable String urlFull = attributes.get(UrlAttributes.URL_FULL); + if (urlFull != null) { + if (httpPath == null) { + httpPath = urlFull; + } + } if (httpPath == null) { return new OtelSpanInfo(op, name, TransactionNameSource.CUSTOM); @@ -56,8 +80,10 @@ private OtelSpanInfo descriptionForHttpMethod( return new OtelSpanInfo(op, description, transactionNameSource); } - private OtelSpanInfo descriptionForDbSystem(final @NotNull ReadableSpan otelSpan) { - @Nullable String dbStatement = otelSpan.getAttribute(SemanticAttributes.DB_STATEMENT); + @SuppressWarnings("deprecation") + private OtelSpanInfo descriptionForDbSystem(final @NotNull SpanData otelSpan) { + final @NotNull Attributes attributes = otelSpan.getAttributes(); + @Nullable String dbStatement = attributes.get(DbIncubatingAttributes.DB_STATEMENT); @NotNull String description = dbStatement != null ? dbStatement : otelSpan.getName(); return new OtelSpanInfo("db", description, TransactionNameSource.TASK); } diff --git a/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/SpanNode.java b/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/SpanNode.java new file mode 100644 index 0000000000..e74747d8a1 --- /dev/null +++ b/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/SpanNode.java @@ -0,0 +1,56 @@ +package io.sentry.opentelemetry; + +import io.opentelemetry.sdk.trace.data.SpanData; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +@ApiStatus.Internal +public final class SpanNode { + private final @NotNull String id; + private @Nullable SpanData span; + private @Nullable SpanNode parentNode; + private @NotNull List children = new CopyOnWriteArrayList<>(); + + public SpanNode(final @NotNull String spanId) { + this.id = spanId; + } + + public @NotNull String getId() { + return id; + } + + public @Nullable SpanData getSpan() { + return span; + } + + public void setSpan(final @Nullable SpanData span) { + this.span = span; + } + + public @Nullable SpanNode getParentNode() { + return parentNode; + } + + public void setParentNode(final @Nullable SpanNode parentNode) { + this.parentNode = parentNode; + } + + public @NotNull List getChildren() { + return children; + } + + public void addChildren(final @Nullable List children) { + if (children != null) { + this.children.addAll(children); + } + } + + public void addChild(final @Nullable SpanNode child) { + if (child != null) { + this.children.add(child); + } + } +} diff --git a/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/TraceData.java b/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/TraceData.java index 08751b5609..5904db39e7 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/TraceData.java +++ b/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/TraceData.java @@ -6,6 +6,7 @@ import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; +@Deprecated @ApiStatus.Internal public final class TraceData { diff --git a/sentry-opentelemetry/sentry-opentelemetry-core/src/test/kotlin/SentrySpanProcessorTest.kt b/sentry-opentelemetry/sentry-opentelemetry-core/src/test/kotlin/SentrySpanProcessorTest.kt index 5ed757ba16..053f80e537 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-core/src/test/kotlin/SentrySpanProcessorTest.kt +++ b/sentry-opentelemetry/sentry-opentelemetry-core/src/test/kotlin/SentrySpanProcessorTest.kt @@ -17,11 +17,12 @@ import io.opentelemetry.sdk.OpenTelemetrySdk import io.opentelemetry.sdk.trace.ReadWriteSpan import io.opentelemetry.sdk.trace.ReadableSpan import io.opentelemetry.sdk.trace.SdkTracerProvider -import io.opentelemetry.semconv.SemanticAttributes +import io.opentelemetry.semconv.HttpAttributes +import io.opentelemetry.semconv.UrlAttributes import io.sentry.Baggage import io.sentry.BaggageHeader import io.sentry.Hint -import io.sentry.IHub +import io.sentry.IScopes import io.sentry.ISpan import io.sentry.ITransaction import io.sentry.Instrumenter @@ -29,6 +30,7 @@ import io.sentry.SentryDate import io.sentry.SentryEvent import io.sentry.SentryOptions import io.sentry.SentryTraceHeader +import io.sentry.SpanOptions import io.sentry.SpanStatus import io.sentry.TransactionContext import io.sentry.TransactionOptions @@ -65,7 +67,7 @@ class SentrySpanProcessorTest { it.dsn = "https://key@sentry.io/proj" it.instrumenter = Instrumenter.OTEL } - val hub = mock() + val scopes = mock() val transaction = mock() val span = mock() val spanContext = mock() @@ -75,9 +77,9 @@ class SentrySpanProcessorTest { val baggage = Baggage.fromHeader(BAGGAGE_HEADER_STRING) fun setup() { - whenever(hub.isEnabled).thenReturn(true) - whenever(hub.options).thenReturn(options) - whenever(hub.startTransaction(any(), any())).thenReturn(transaction) + whenever(scopes.isEnabled).thenReturn(true) + whenever(scopes.options).thenReturn(options) + whenever(scopes.startTransaction(any(), any())).thenReturn(transaction) whenever(spanContext.operation).thenReturn("spanContextOp") whenever(spanContext.parentSpanId).thenReturn(io.sentry.SpanId("cedf5b7571cb4972")) @@ -91,10 +93,10 @@ class SentrySpanProcessorTest { whenever(span.toBaggageHeader(any())).thenReturn(baggageHeader) whenever(transaction.toBaggageHeader(any())).thenReturn(baggageHeader) - whenever(transaction.startChild(any(), anyOrNull(), anyOrNull(), eq(Instrumenter.OTEL))).thenReturn(span) + whenever(transaction.startChild(any(), anyOrNull(), anyOrNull(), eq(Instrumenter.OTEL), any())).thenReturn(span) val sdkTracerProvider = SdkTracerProvider.builder() - .addSpanProcessor(SentrySpanProcessor(hub)) + .addSpanProcessor(SentrySpanProcessor(scopes)) .build() openTelemetry = OpenTelemetrySdk.builder() @@ -124,7 +126,7 @@ class SentrySpanProcessorTest { fun `ignores sentry client request`() { fixture.setup() givenSpanBuilder(SpanKind.CLIENT) - .setAttribute(SemanticAttributes.HTTP_URL, "https://key@sentry.io/proj/some-api") + .setAttribute(UrlAttributes.URL_FULL, "https://key@sentry.io/proj/some-api") .startSpan() thenNoTransactionIsStarted() @@ -134,7 +136,7 @@ class SentrySpanProcessorTest { fun `ignores sentry internal request`() { fixture.setup() givenSpanBuilder(SpanKind.CLIENT) - .setAttribute(SemanticAttributes.HTTP_URL, "https://key@sentry.io/proj/some-api") + .setAttribute(UrlAttributes.URL_FULL, "https://key@sentry.io/proj/some-api") .startSpan() thenNoTransactionIsStarted() @@ -146,13 +148,13 @@ class SentrySpanProcessorTest { val context = mock() val span = mock() - whenever(fixture.hub.isEnabled).thenReturn(false) + whenever(fixture.scopes.isEnabled).thenReturn(false) - SentrySpanProcessor(fixture.hub).onStart(context, span) + SentrySpanProcessor(fixture.scopes).onStart(context, span) - verify(fixture.hub).isEnabled - verify(fixture.hub).options - verifyNoMoreInteractions(fixture.hub) + verify(fixture.scopes).isEnabled + verify(fixture.scopes).options + verifyNoMoreInteractions(fixture.scopes) verifyNoInteractions(context, span) } @@ -161,13 +163,13 @@ class SentrySpanProcessorTest { fixture.setup() val span = mock() - whenever(fixture.hub.isEnabled).thenReturn(false) + whenever(fixture.scopes.isEnabled).thenReturn(false) - SentrySpanProcessor(fixture.hub).onEnd(span) + SentrySpanProcessor(fixture.scopes).onEnd(span) - verify(fixture.hub).isEnabled - verify(fixture.hub).options - verifyNoMoreInteractions(fixture.hub) + verify(fixture.scopes).isEnabled + verify(fixture.scopes).options + verifyNoMoreInteractions(fixture.scopes) verifyNoInteractions(span) } @@ -178,7 +180,7 @@ class SentrySpanProcessorTest { val mockSpanContext = mock() whenever(mockSpanContext.spanId).thenReturn(SpanId.getInvalid()) whenever(mockSpan.spanContext).thenReturn(mockSpanContext) - SentrySpanProcessor(fixture.hub).onStart(Context.current(), mockSpan) + SentrySpanProcessor(fixture.scopes).onStart(Context.current(), mockSpan) thenNoTransactionIsStarted() } @@ -190,7 +192,7 @@ class SentrySpanProcessorTest { whenever(mockSpanContext.spanId).thenReturn(SpanId.fromBytes("seed".toByteArray())) whenever(mockSpanContext.traceId).thenReturn(TraceId.getInvalid()) whenever(mockSpan.spanContext).thenReturn(mockSpanContext) - SentrySpanProcessor(fixture.hub).onStart(Context.current(), mockSpan) + SentrySpanProcessor(fixture.scopes).onStart(Context.current(), mockSpan) thenNoTransactionIsStarted() } @@ -303,8 +305,8 @@ class SentrySpanProcessorTest { thenChildSpanIsStarted() otelChildSpan.setStatus(StatusCode.ERROR) - otelChildSpan.setAttribute(SemanticAttributes.HTTP_URL, "http://github.com/getsentry/sentry-java") - otelChildSpan.setAttribute(SemanticAttributes.HTTP_STATUS_CODE, 404L) + otelChildSpan.setAttribute(UrlAttributes.URL_FULL, "http://github.com/getsentry/sentry-java") + otelChildSpan.setAttribute(HttpAttributes.HTTP_RESPONSE_STATUS_CODE, 404L) otelChildSpan.end() thenChildSpanIsFinished(SpanStatus.NOT_FOUND) @@ -342,7 +344,7 @@ class SentrySpanProcessorTest { thenTransactionIsStarted(otelSpan, isContinued = true) otelSpan.makeCurrent().use { _ -> - val processedEvent = OpenTelemetryLinkErrorEventProcessor(fixture.hub).process(SentryEvent(), Hint()) + val processedEvent = OpenTelemetryLinkErrorEventProcessor(fixture.scopes).process(SentryEvent(), Hint()) val traceContext = processedEvent!!.contexts.trace!! assertEquals("2722d9f6ec019ade60c776169d9a8904", traceContext.traceId.toString()) @@ -361,7 +363,7 @@ class SentrySpanProcessorTest { fixture.options.instrumenter = Instrumenter.SENTRY fixture.setup() - val processedEvent = OpenTelemetryLinkErrorEventProcessor(fixture.hub).process(SentryEvent(), Hint()) + val processedEvent = OpenTelemetryLinkErrorEventProcessor(fixture.scopes).process(SentryEvent(), Hint()) thenNoTraceContextHasBeenAddedToEvent(processedEvent) } @@ -393,7 +395,7 @@ class SentrySpanProcessorTest { private fun thenTransactionIsStarted(otelSpan: Span, isContinued: Boolean = false, continuesWithFilledBaggage: Boolean = true) { if (isContinued) { - verify(fixture.hub).startTransaction( + verify(fixture.scopes).startTransaction( check { assertEquals("testspan", it.name) assertEquals(TransactionNameSource.CUSTOM, it.transactionNameSource) @@ -423,7 +425,7 @@ class SentrySpanProcessorTest { } ) } else { - verify(fixture.hub).startTransaction( + verify(fixture.scopes).startTransaction( check { assertEquals("testspan", it.name) assertEquals(TransactionNameSource.CUSTOM, it.transactionNameSource) @@ -451,7 +453,7 @@ class SentrySpanProcessorTest { } private fun thenNoTransactionIsStarted() { - verify(fixture.hub, never()).startTransaction( + verify(fixture.scopes, never()).startTransaction( any(), any() ) @@ -462,7 +464,8 @@ class SentrySpanProcessorTest { eq("childspan"), eq("childspan"), any(), - eq(Instrumenter.OTEL) + eq(Instrumenter.OTEL), + any() ) } diff --git a/sentry-opentelemetry/sentry-opentelemetry-extra/api/sentry-opentelemetry-extra.api b/sentry-opentelemetry/sentry-opentelemetry-extra/api/sentry-opentelemetry-extra.api new file mode 100644 index 0000000000..4533891a18 --- /dev/null +++ b/sentry-opentelemetry/sentry-opentelemetry-extra/api/sentry-opentelemetry-extra.api @@ -0,0 +1,157 @@ +public final class io/sentry/opentelemetry/OpenTelemetryUtil { + public fun ()V + public static fun applyOpenTelemetryOptions (Lio/sentry/SentryOptions;)V +} + +public final class io/sentry/opentelemetry/OtelContextScopesStorage : io/sentry/IScopesStorage { + public fun ()V + public fun close ()V + public fun get ()Lio/sentry/IScopes; + public fun init ()V + public fun set (Lio/sentry/IScopes;)Lio/sentry/ISentryLifecycleToken; +} + +public final class io/sentry/opentelemetry/OtelSpanContext : io/sentry/SpanContext { + public fun (Lio/opentelemetry/sdk/trace/ReadWriteSpan;Lio/sentry/TracesSamplingDecision;Lio/sentry/opentelemetry/OtelSpanWrapper;Lio/sentry/SpanId;Lio/sentry/Baggage;)V + public fun getOperation ()Ljava/lang/String; + public fun getStatus ()Lio/sentry/SpanStatus; + public fun setOperation (Ljava/lang/String;)V + public fun setStatus (Lio/sentry/SpanStatus;)V +} + +public final class io/sentry/opentelemetry/OtelSpanFactory : io/sentry/ISpanFactory { + public fun ()V + public fun (Lio/opentelemetry/api/OpenTelemetry;)V + public fun createSpan (Lio/sentry/IScopes;Lio/sentry/SpanOptions;Lio/sentry/SpanContext;Lio/sentry/ISpan;)Lio/sentry/ISpan; + public fun createTransaction (Lio/sentry/TransactionContext;Lio/sentry/IScopes;Lio/sentry/TransactionOptions;Lio/sentry/TransactionPerformanceCollector;)Lio/sentry/ITransaction; +} + +public final class io/sentry/opentelemetry/OtelSpanWrapper : io/sentry/ISpan { + public fun (Lio/opentelemetry/sdk/trace/ReadWriteSpan;Lio/sentry/IScopes;Lio/sentry/SentryDate;Lio/sentry/TracesSamplingDecision;Lio/sentry/opentelemetry/OtelSpanWrapper;Lio/sentry/SpanId;Lio/sentry/Baggage;)V + public fun finish ()V + public fun finish (Lio/sentry/SpanStatus;)V + public fun finish (Lio/sentry/SpanStatus;Lio/sentry/SentryDate;)V + public fun getContexts ()Lio/sentry/protocol/Contexts; + public fun getData ()Ljava/util/Map; + public fun getData (Ljava/lang/String;)Ljava/lang/Object; + public fun getDescription ()Ljava/lang/String; + public fun getFinishDate ()Lio/sentry/SentryDate; + public fun getMeasurements ()Ljava/util/Map; + public fun getOperation ()Ljava/lang/String; + public fun getSamplingDecision ()Lio/sentry/TracesSamplingDecision; + public fun getScopes ()Lio/sentry/IScopes; + public fun getSpanContext ()Lio/sentry/SpanContext; + public fun getStartDate ()Lio/sentry/SentryDate; + public fun getStatus ()Lio/sentry/SpanStatus; + public fun getTag (Ljava/lang/String;)Ljava/lang/String; + public fun getTags ()Ljava/util/Map; + public fun getThrowable ()Ljava/lang/Throwable; + public fun getTraceId ()Lio/sentry/protocol/SentryId; + public fun getTransactionName ()Ljava/lang/String; + public fun getTransactionNameSource ()Lio/sentry/protocol/TransactionNameSource; + public fun isFinished ()Z + public fun isNoOp ()Z + public fun isProfileSampled ()Ljava/lang/Boolean; + public fun isSampled ()Ljava/lang/Boolean; + public fun makeCurrent ()Lio/sentry/ISentryLifecycleToken; + public fun setContext (Ljava/lang/String;Ljava/lang/Object;)V + public fun setData (Ljava/lang/String;Ljava/lang/Object;)V + public fun setDescription (Ljava/lang/String;)V + public fun setMeasurement (Ljava/lang/String;Ljava/lang/Number;)V + public fun setMeasurement (Ljava/lang/String;Ljava/lang/Number;Lio/sentry/MeasurementUnit;)V + public fun setOperation (Ljava/lang/String;)V + public fun setStatus (Lio/sentry/SpanStatus;)V + public fun setTag (Ljava/lang/String;Ljava/lang/String;)V + public fun setThrowable (Ljava/lang/Throwable;)V + public fun setTransactionName (Ljava/lang/String;)V + public fun setTransactionName (Ljava/lang/String;Lio/sentry/protocol/TransactionNameSource;)V + public fun startChild (Lio/sentry/SpanContext;Lio/sentry/SpanOptions;)Lio/sentry/ISpan; + public fun startChild (Ljava/lang/String;)Lio/sentry/ISpan; + public fun startChild (Ljava/lang/String;Ljava/lang/String;)Lio/sentry/ISpan; + public fun startChild (Ljava/lang/String;Ljava/lang/String;Lio/sentry/SentryDate;Lio/sentry/Instrumenter;)Lio/sentry/ISpan; + public fun startChild (Ljava/lang/String;Ljava/lang/String;Lio/sentry/SentryDate;Lio/sentry/Instrumenter;Lio/sentry/SpanOptions;)Lio/sentry/ISpan; + public fun startChild (Ljava/lang/String;Ljava/lang/String;Lio/sentry/SpanOptions;)Lio/sentry/ISpan; + public fun toBaggageHeader (Ljava/util/List;)Lio/sentry/BaggageHeader; + public fun toSentryTrace ()Lio/sentry/SentryTraceHeader; + public fun traceContext ()Lio/sentry/TraceContext; + public fun updateEndDate (Lio/sentry/SentryDate;)Z +} + +public final class io/sentry/opentelemetry/OtelTransactionSpanForwarder : io/sentry/ITransaction { + public fun (Lio/sentry/opentelemetry/OtelSpanWrapper;)V + public fun finish ()V + public fun finish (Lio/sentry/SpanStatus;)V + public fun finish (Lio/sentry/SpanStatus;Lio/sentry/SentryDate;)V + public fun finish (Lio/sentry/SpanStatus;Lio/sentry/SentryDate;ZLio/sentry/Hint;)V + public fun forceFinish (Lio/sentry/SpanStatus;ZLio/sentry/Hint;)V + public fun getContexts ()Lio/sentry/protocol/Contexts; + public fun getData (Ljava/lang/String;)Ljava/lang/Object; + public fun getDescription ()Ljava/lang/String; + public fun getEventId ()Lio/sentry/protocol/SentryId; + public fun getFinishDate ()Lio/sentry/SentryDate; + public fun getLatestActiveSpan ()Lio/sentry/ISpan; + public fun getName ()Ljava/lang/String; + public fun getOperation ()Ljava/lang/String; + public fun getSamplingDecision ()Lio/sentry/TracesSamplingDecision; + public fun getSpanContext ()Lio/sentry/SpanContext; + public fun getSpans ()Ljava/util/List; + public fun getStartDate ()Lio/sentry/SentryDate; + public fun getStatus ()Lio/sentry/SpanStatus; + public fun getTag (Ljava/lang/String;)Ljava/lang/String; + public fun getThrowable ()Ljava/lang/Throwable; + public fun getTransactionNameSource ()Lio/sentry/protocol/TransactionNameSource; + public fun isFinished ()Z + public fun isNoOp ()Z + public fun isProfileSampled ()Ljava/lang/Boolean; + public fun isSampled ()Ljava/lang/Boolean; + public fun makeCurrent ()Lio/sentry/ISentryLifecycleToken; + public fun scheduleFinish ()V + public fun setContext (Ljava/lang/String;Ljava/lang/Object;)V + public fun setData (Ljava/lang/String;Ljava/lang/Object;)V + public fun setDescription (Ljava/lang/String;)V + public fun setMeasurement (Ljava/lang/String;Ljava/lang/Number;)V + public fun setMeasurement (Ljava/lang/String;Ljava/lang/Number;Lio/sentry/MeasurementUnit;)V + public fun setName (Ljava/lang/String;)V + public fun setName (Ljava/lang/String;Lio/sentry/protocol/TransactionNameSource;)V + public fun setOperation (Ljava/lang/String;)V + public fun setStatus (Lio/sentry/SpanStatus;)V + public fun setTag (Ljava/lang/String;Ljava/lang/String;)V + public fun setThrowable (Ljava/lang/Throwable;)V + public fun startChild (Lio/sentry/SpanContext;Lio/sentry/SpanOptions;)Lio/sentry/ISpan; + public fun startChild (Ljava/lang/String;)Lio/sentry/ISpan; + public fun startChild (Ljava/lang/String;Ljava/lang/String;)Lio/sentry/ISpan; + public fun startChild (Ljava/lang/String;Ljava/lang/String;Lio/sentry/SentryDate;)Lio/sentry/ISpan; + public fun startChild (Ljava/lang/String;Ljava/lang/String;Lio/sentry/SentryDate;Lio/sentry/Instrumenter;)Lio/sentry/ISpan; + public fun startChild (Ljava/lang/String;Ljava/lang/String;Lio/sentry/SentryDate;Lio/sentry/Instrumenter;Lio/sentry/SpanOptions;)Lio/sentry/ISpan; + public fun startChild (Ljava/lang/String;Ljava/lang/String;Lio/sentry/SpanOptions;)Lio/sentry/ISpan; + public fun toBaggageHeader (Ljava/util/List;)Lio/sentry/BaggageHeader; + public fun toSentryTrace ()Lio/sentry/SentryTraceHeader; + public fun traceContext ()Lio/sentry/TraceContext; + public fun updateEndDate (Lio/sentry/SentryDate;)Z +} + +public final class io/sentry/opentelemetry/SentryContextStorage : io/opentelemetry/context/ContextStorage { + public fun (Lio/opentelemetry/context/ContextStorage;)V + public fun attach (Lio/opentelemetry/context/Context;)Lio/opentelemetry/context/Scope; + public fun current ()Lio/opentelemetry/context/Context; +} + +public final class io/sentry/opentelemetry/SentryContextWrapper : io/opentelemetry/context/Context { + public fun get (Lio/opentelemetry/context/ContextKey;)Ljava/lang/Object; + public fun toString ()Ljava/lang/String; + public fun with (Lio/opentelemetry/context/ContextKey;Ljava/lang/Object;)Lio/opentelemetry/context/Context; + public static fun wrap (Lio/opentelemetry/context/Context;)Lio/sentry/opentelemetry/SentryContextWrapper; +} + +public final class io/sentry/opentelemetry/SentryOtelThreadLocalStorage : io/opentelemetry/context/ContextStorage { + public fun ()V + public fun attach (Lio/opentelemetry/context/Context;)Lio/opentelemetry/context/Scope; + public fun current ()Lio/opentelemetry/context/Context; +} + +public final class io/sentry/opentelemetry/SentryWeakSpanStorage { + public static fun getInstance ()Lio/sentry/opentelemetry/SentryWeakSpanStorage; + public fun getSentrySpan (Lio/opentelemetry/api/trace/SpanContext;)Lio/sentry/opentelemetry/OtelSpanWrapper; + public fun storeSentrySpan (Lio/opentelemetry/api/trace/SpanContext;Lio/sentry/opentelemetry/OtelSpanWrapper;)V +} + diff --git a/sentry-opentelemetry/sentry-opentelemetry-extra/build.gradle.kts b/sentry-opentelemetry/sentry-opentelemetry-extra/build.gradle.kts new file mode 100644 index 0000000000..19bad8485f --- /dev/null +++ b/sentry-opentelemetry/sentry-opentelemetry-extra/build.gradle.kts @@ -0,0 +1,80 @@ +import net.ltgt.gradle.errorprone.errorprone +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile + +plugins { + `java-library` + kotlin("jvm") + jacoco + id(Config.QualityPlugins.errorProne) + id(Config.QualityPlugins.gradleVersions) +} + +configure { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 +} + +tasks.withType().configureEach { + kotlinOptions.jvmTarget = JavaVersion.VERSION_1_8.toString() +} + +dependencies { + compileOnly(projects.sentry) + compileOnly(projects.sentryOpentelemetry.sentryOpentelemetryBootstrap) + + compileOnly(Config.Libs.OpenTelemetry.otelSdk) + compileOnly(Config.Libs.OpenTelemetry.otelSdk) + + compileOnly(Config.CompileOnly.nopen) + errorprone(Config.CompileOnly.nopenChecker) + errorprone(Config.CompileOnly.errorprone) + compileOnly(Config.CompileOnly.jetbrainsAnnotations) + errorprone(Config.CompileOnly.errorProneNullAway) + + // tests + testImplementation(projects.sentryTestSupport) + testImplementation(kotlin(Config.kotlinStdLib)) + testImplementation(Config.TestLibs.kotlinTestJunit) + testImplementation(Config.TestLibs.mockitoKotlin) + testImplementation(Config.TestLibs.awaitility) + + testImplementation(Config.Libs.OpenTelemetry.otelSdk) + testImplementation(Config.Libs.OpenTelemetry.otelSemconv) + testImplementation(Config.Libs.OpenTelemetry.otelSemconvIncubating) +} + +configure { + test { + java.srcDir("src/test/java") + } +} + +jacoco { + toolVersion = Config.QualityPlugins.Jacoco.version +} + +tasks.jacocoTestReport { + reports { + xml.required.set(true) + html.required.set(false) + } +} + +tasks { + jacocoTestCoverageVerification { + violationRules { + rule { limit { minimum = Config.QualityPlugins.Jacoco.minimumCoverage } } + } + } + check { + dependsOn(jacocoTestCoverageVerification) + dependsOn(jacocoTestReport) + } +} + +tasks.withType().configureEach { + options.errorprone { + check("NullAway", net.ltgt.gradle.errorprone.CheckSeverity.ERROR) + option("NullAway:AnnotatedPackages", "io.sentry") + } +} diff --git a/sentry-opentelemetry/sentry-opentelemetry-extra/src/main/java/io/sentry/opentelemetry/OpenTelemetryUtil.java b/sentry-opentelemetry/sentry-opentelemetry-extra/src/main/java/io/sentry/opentelemetry/OpenTelemetryUtil.java new file mode 100644 index 0000000000..02fc706e61 --- /dev/null +++ b/sentry-opentelemetry/sentry-opentelemetry-extra/src/main/java/io/sentry/opentelemetry/OpenTelemetryUtil.java @@ -0,0 +1,18 @@ +package io.sentry.opentelemetry; + +import io.sentry.SentryOptions; +import io.sentry.SentrySpanFactoryHolder; +import io.sentry.util.SpanUtils; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.Nullable; + +@ApiStatus.Experimental +public final class OpenTelemetryUtil { + + public static void applyOpenTelemetryOptions(final @Nullable SentryOptions options) { + if (options != null) { + options.setSpanFactory(SentrySpanFactoryHolder.getSpanFactory()); + options.setIgnoredSpanOrigins(SpanUtils.ignoredSpanOriginsForOpenTelemetry()); + } + } +} diff --git a/sentry-opentelemetry/sentry-opentelemetry-extra/src/main/java/io/sentry/opentelemetry/OtelContextScopesStorage.java b/sentry-opentelemetry/sentry-opentelemetry-extra/src/main/java/io/sentry/opentelemetry/OtelContextScopesStorage.java new file mode 100644 index 0000000000..0be68b6305 --- /dev/null +++ b/sentry-opentelemetry/sentry-opentelemetry-extra/src/main/java/io/sentry/opentelemetry/OtelContextScopesStorage.java @@ -0,0 +1,50 @@ +package io.sentry.opentelemetry; + +import static io.sentry.opentelemetry.SentryOtelKeys.SENTRY_SCOPES_KEY; + +import io.opentelemetry.context.Context; +import io.opentelemetry.context.ContextStorage; +import io.opentelemetry.context.Scope; +import io.sentry.IScopes; +import io.sentry.IScopesStorage; +import io.sentry.ISentryLifecycleToken; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +@ApiStatus.Internal +@SuppressWarnings("MustBeClosedChecker") +public final class OtelContextScopesStorage implements IScopesStorage { + + @Override + public void init() { + System.out.println("hello from OtelContextScopesStorage init"); + /** + * We're currently overriding the storage mechanism to allow for cleanup of non closed OTel + * scopes. These happen when using e.g. Sentry static API due to getCurrentScopes() invoking + * Context.makeCurrent and then ignoring the returned lifecycle token (OTel Scope). After fixing + * the classloader problem (sentry bootstrap dependency is currently in agent classloader) we + * can revisit and try again to set the storage instead of overriding it in the wrapper. We + * should try to use OTels StorageProvider mechanism instead. + */ + // ContextStorage.addWrapper((storage) -> new SentryContextStorage(storage)); + ContextStorage.addWrapper( + (storage) -> new SentryContextStorage(new SentryOtelThreadLocalStorage())); + } + + @Override + public @NotNull ISentryLifecycleToken set(@Nullable IScopes scopes) { + final Context context = Context.current(); + final @NotNull Scope otelScope = context.with(SENTRY_SCOPES_KEY, scopes).makeCurrent(); + return new OtelStorageToken(otelScope); + } + + @Override + public @Nullable IScopes get() { + final Context context = Context.current(); + return context.get(SENTRY_SCOPES_KEY); + } + + @Override + public void close() {} +} diff --git a/sentry-opentelemetry/sentry-opentelemetry-extra/src/main/java/io/sentry/opentelemetry/OtelSpanContext.java b/sentry-opentelemetry/sentry-opentelemetry-extra/src/main/java/io/sentry/opentelemetry/OtelSpanContext.java new file mode 100644 index 0000000000..626b837c97 --- /dev/null +++ b/sentry-opentelemetry/sentry-opentelemetry-extra/src/main/java/io/sentry/opentelemetry/OtelSpanContext.java @@ -0,0 +1,116 @@ +package io.sentry.opentelemetry; + +import io.opentelemetry.api.trace.Span; +import io.opentelemetry.api.trace.StatusCode; +import io.opentelemetry.sdk.trace.ReadWriteSpan; +import io.opentelemetry.sdk.trace.data.StatusData; +import io.sentry.Baggage; +import io.sentry.SpanContext; +import io.sentry.SpanId; +import io.sentry.SpanStatus; +import io.sentry.TracesSamplingDecision; +import io.sentry.protocol.SentryId; +import java.lang.ref.WeakReference; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +@ApiStatus.Internal +public final class OtelSpanContext extends SpanContext { + + /** + * OpenTelemetry span which this wrapper wraps. Needs to be referenced weakly as otherwise we'd + * create a circular reference from {@link io.opentelemetry.sdk.trace.data.SpanData} to {@link + * OtelSpanWrapper} and indirectly back to {@link io.opentelemetry.sdk.trace.data.SpanData} via + * {@link Span}. Also see {@link SentryWeakSpanStorage}. + */ + private final @NotNull WeakReference span; + + public OtelSpanContext( + final @NotNull ReadWriteSpan span, + final @Nullable TracesSamplingDecision samplingDecision, + final @Nullable OtelSpanWrapper parentSpan, + final @Nullable SpanId parentSpanId, + final @Nullable Baggage baggage) { + super( + new SentryId(span.getSpanContext().getTraceId()), + new SpanId(span.getSpanContext().getSpanId()), + parentSpan == null ? parentSpanId : parentSpan.getSpanContext().getSpanId(), + span.getName(), + null, + samplingDecision != null + ? samplingDecision + : (parentSpan == null ? null : parentSpan.getSamplingDecision()), + null, + null); + this.span = new WeakReference<>(span); + this.baggage = baggage; + } + + @Override + public @Nullable SpanStatus getStatus() { + final @Nullable ReadWriteSpan otelSpan = span.get(); + + if (otelSpan != null) { + final @NotNull StatusData otelStatus = otelSpan.toSpanData().getStatus(); + final @NotNull String otelStatusDescription = otelStatus.getDescription(); + if (otelStatusDescription.isEmpty()) { + return otelStatusCodeFallback(otelStatus); + } + final @Nullable SpanStatus spanStatus = SpanStatus.fromApiNameSafely(otelStatusDescription); + if (spanStatus == null) { + return otelStatusCodeFallback(otelStatus); + } + return spanStatus; + } + + return null; + } + + @Override + public void setStatus(@Nullable SpanStatus status) { + if (status != null) { + final @Nullable ReadWriteSpan otelSpan = span.get(); + if (otelSpan != null) { + final @NotNull StatusCode statusCode = translateStatusCode(status); + otelSpan.setStatus(statusCode, status.apiName()); + } + } + } + + @Override + public @NotNull String getOperation() { + final @Nullable ReadWriteSpan otelSpan = span.get(); + if (otelSpan != null) { + return otelSpan.getName(); + } + return ""; + } + + @Override + public void setOperation(@NotNull String operation) { + final @Nullable ReadWriteSpan otelSpan = span.get(); + if (otelSpan != null) { + otelSpan.updateName(operation); + } + } + + private @Nullable SpanStatus otelStatusCodeFallback(final @NotNull StatusData otelStatus) { + if (otelStatus.getStatusCode() == StatusCode.ERROR) { + return SpanStatus.UNKNOWN_ERROR; + } else if (otelStatus.getStatusCode() == StatusCode.OK) { + return SpanStatus.OK; + } + return null; + } + + private @NotNull StatusCode translateStatusCode(final @Nullable SpanStatus status) { + if (status == null) { + return StatusCode.UNSET; + } else if (status == SpanStatus.OK) { + return StatusCode.OK; + } else { + return StatusCode.ERROR; + } + } +} diff --git a/sentry-opentelemetry/sentry-opentelemetry-extra/src/main/java/io/sentry/opentelemetry/OtelSpanFactory.java b/sentry-opentelemetry/sentry-opentelemetry-extra/src/main/java/io/sentry/opentelemetry/OtelSpanFactory.java new file mode 100644 index 0000000000..731db6a930 --- /dev/null +++ b/sentry-opentelemetry/sentry-opentelemetry-extra/src/main/java/io/sentry/opentelemetry/OtelSpanFactory.java @@ -0,0 +1,170 @@ +package io.sentry.opentelemetry; + +import io.opentelemetry.api.GlobalOpenTelemetry; +import io.opentelemetry.api.OpenTelemetry; +import io.opentelemetry.api.trace.Span; +import io.opentelemetry.api.trace.SpanBuilder; +import io.opentelemetry.api.trace.TraceFlags; +import io.opentelemetry.api.trace.TraceState; +import io.opentelemetry.api.trace.Tracer; +import io.opentelemetry.api.trace.TracerProvider; +import io.opentelemetry.context.Context; +import io.sentry.Baggage; +import io.sentry.BuildConfig; +import io.sentry.IScopes; +import io.sentry.ISpan; +import io.sentry.ISpanFactory; +import io.sentry.ITransaction; +import io.sentry.NoOpSpan; +import io.sentry.NoOpTransaction; +import io.sentry.SentryDate; +import io.sentry.SpanContext; +import io.sentry.SpanId; +import io.sentry.SpanOptions; +import io.sentry.TracesSamplingDecision; +import io.sentry.TransactionContext; +import io.sentry.TransactionOptions; +import io.sentry.TransactionPerformanceCollector; +import io.sentry.protocol.SentryId; +import io.sentry.util.SpanUtils; +import java.util.concurrent.TimeUnit; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +@ApiStatus.Internal +public final class OtelSpanFactory implements ISpanFactory { + + private final @NotNull SentryWeakSpanStorage storage = SentryWeakSpanStorage.getInstance(); + private final @Nullable OpenTelemetry openTelemetry; + + public OtelSpanFactory(final @Nullable OpenTelemetry openTelemetry) { + this.openTelemetry = openTelemetry; + } + + public OtelSpanFactory() { + this(null); + } + + @Override + public @NotNull ITransaction createTransaction( + @NotNull TransactionContext context, + @NotNull IScopes scopes, + @NotNull TransactionOptions transactionOptions, + @Nullable TransactionPerformanceCollector transactionPerformanceCollector) { + final @Nullable OtelSpanWrapper span = + createSpanInternal( + scopes, transactionOptions, null, context.getSamplingDecision(), context); + if (span == null) { + return NoOpTransaction.getInstance(); + } + return new OtelTransactionSpanForwarder(span); + } + + @Override + public @NotNull ISpan createSpan( + final @NotNull IScopes scopes, + final @NotNull SpanOptions spanOptions, + final @NotNull SpanContext spanContext, + final @Nullable ISpan parentSpan) { + if (SpanUtils.isIgnored(scopes.getOptions().getIgnoredSpanOrigins(), spanOptions.getOrigin())) { + return NoOpSpan.getInstance(); + } + + final @Nullable TracesSamplingDecision samplingDecision = + parentSpan == null ? null : parentSpan.getSamplingDecision(); + final @Nullable OtelSpanWrapper span = + createSpanInternal(scopes, spanOptions, parentSpan, samplingDecision, spanContext); + if (span == null) { + return NoOpSpan.getInstance(); + } + return span; + } + + private @Nullable OtelSpanWrapper createSpanInternal( + final @NotNull IScopes scopes, + final @NotNull SpanOptions spanOptions, + final @Nullable ISpan parentSpan, + final @Nullable TracesSamplingDecision samplingDecision, + final @NotNull SpanContext spanContext) { + final @NotNull String name = spanContext.getOperation(); + final @NotNull SpanBuilder spanBuilder = getTracer().spanBuilder(name); + if (parentSpan == null) { + final @NotNull SentryId traceId = spanContext.getTraceId(); + final @Nullable SpanId parentSpanId = spanContext.getParentSpanId(); + if (parentSpanId == null) { + final @NotNull io.opentelemetry.api.trace.SpanContext otelSpanContext = + io.opentelemetry.api.trace.SpanContext.create( + traceId.toString(), + io.opentelemetry.api.trace.SpanId.getInvalid(), + TraceFlags.getSampled(), + TraceState.getDefault()); + final @NotNull Span wrappedSpan = Span.wrap(otelSpanContext); + spanBuilder.setParent(Context.root().with(wrappedSpan)); + } else { + final @NotNull io.opentelemetry.api.trace.SpanContext otelSpanContext = + io.opentelemetry.api.trace.SpanContext.createFromRemoteParent( + traceId.toString(), + parentSpanId.toString(), + TraceFlags.getSampled(), + TraceState.getDefault()); + final @NotNull Span wrappedSpan = Span.wrap(otelSpanContext); + spanBuilder.setParent(Context.root().with(wrappedSpan)); + } + } + + // note: won't go through propagators + final @Nullable Baggage baggage = spanContext.getBaggage(); + if (baggage != null) { + spanBuilder.setAttribute(InternalSemanticAttributes.BAGGAGE_MUTABLE, baggage.isMutable()); + spanBuilder.setAttribute(InternalSemanticAttributes.BAGGAGE, baggage.toHeaderString(null)); + } + + final @Nullable SentryDate startTimestampFromOptions = spanOptions.getStartTimestamp(); + final @NotNull SentryDate startTimestamp = + startTimestampFromOptions == null + ? scopes.getOptions().getDateProvider().now() + : startTimestampFromOptions; + spanBuilder.setStartTimestamp(startTimestamp.nanoTimestamp(), TimeUnit.NANOSECONDS); + + if (samplingDecision != null) { + spanBuilder.setAttribute(InternalSemanticAttributes.SAMPLED, samplingDecision.getSampled()); + spanBuilder.setAttribute( + InternalSemanticAttributes.SAMPLE_RATE, samplingDecision.getSampleRate()); + spanBuilder.setAttribute( + InternalSemanticAttributes.PROFILE_SAMPLED, samplingDecision.getProfileSampled()); + spanBuilder.setAttribute( + InternalSemanticAttributes.PROFILE_SAMPLE_RATE, samplingDecision.getProfileSampleRate()); + } + + final @NotNull Span otelSpan = spanBuilder.startSpan(); + + final @Nullable OtelSpanWrapper sentrySpan = storage.getSentrySpan(otelSpan.getSpanContext()); + if (sentrySpan != null) { + final @Nullable String description = spanContext.getDescription(); + if (description != null) { + sentrySpan.setDescription(description); + } + if (spanContext instanceof TransactionContext) { + final @NotNull TransactionContext transactionContext = (TransactionContext) spanContext; + sentrySpan.setTransactionName( + transactionContext.getName(), transactionContext.getTransactionNameSource()); + } + sentrySpan.getSpanContext().setOrigin(spanOptions.getOrigin()); + } + + return sentrySpan; + } + + private @NotNull Tracer getTracer() { + return getTracerProvider().get("sentry-opentelemetry", BuildConfig.VERSION_NAME); + } + + private @NotNull TracerProvider getTracerProvider() { + System.out.println("hello from " + toString()); + if (openTelemetry != null) { + return openTelemetry.getTracerProvider(); + } + return GlobalOpenTelemetry.getTracerProvider(); + } +} diff --git a/sentry-opentelemetry/sentry-opentelemetry-extra/src/main/java/io/sentry/opentelemetry/OtelSpanWrapper.java b/sentry-opentelemetry/sentry-opentelemetry-extra/src/main/java/io/sentry/opentelemetry/OtelSpanWrapper.java new file mode 100644 index 0000000000..fc876840f6 --- /dev/null +++ b/sentry-opentelemetry/sentry-opentelemetry-extra/src/main/java/io/sentry/opentelemetry/OtelSpanWrapper.java @@ -0,0 +1,475 @@ +package io.sentry.opentelemetry; + +import io.opentelemetry.api.trace.Span; +import io.opentelemetry.context.Scope; +import io.opentelemetry.sdk.trace.ReadWriteSpan; +import io.sentry.Baggage; +import io.sentry.BaggageHeader; +import io.sentry.IScopes; +import io.sentry.ISentryLifecycleToken; +import io.sentry.ISpan; +import io.sentry.Instrumenter; +import io.sentry.MeasurementUnit; +import io.sentry.NoOpScopesLifecycleToken; +import io.sentry.NoOpSpan; +import io.sentry.SentryDate; +import io.sentry.SentryLevel; +import io.sentry.SentryTraceHeader; +import io.sentry.SpanContext; +import io.sentry.SpanId; +import io.sentry.SpanOptions; +import io.sentry.SpanStatus; +import io.sentry.TraceContext; +import io.sentry.TracesSamplingDecision; +import io.sentry.protocol.Contexts; +import io.sentry.protocol.MeasurementValue; +import io.sentry.protocol.SentryId; +import io.sentry.protocol.TransactionNameSource; +import io.sentry.util.AutoClosableReentrantLock; +import io.sentry.util.Objects; +import java.lang.ref.WeakReference; +import java.util.ArrayDeque; +import java.util.Deque; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** NOTE: This wrapper is not used when using OpenTelemetry API, only when using Sentry API. */ +@ApiStatus.Internal +public final class OtelSpanWrapper implements ISpan { + + private final @NotNull IScopes scopes; + + /** The moment in time when span was started. */ + private @NotNull SentryDate startTimestamp; + + private @Nullable SentryDate finishedTimestamp = null; + + /** + * OpenTelemetry span which this wrapper wraps. Needs to be referenced weakly as otherwise we'd + * create a circular reference from {@link io.opentelemetry.sdk.trace.data.SpanData} to {@link + * OtelSpanWrapper} and indirectly back to {@link io.opentelemetry.sdk.trace.data.SpanData} via + * {@link Span}. Also see {@link SentryWeakSpanStorage}. + */ + private final @NotNull WeakReference span; + + private final @NotNull SpanContext context; + private final @NotNull Contexts contexts = new Contexts(); + private @Nullable String transactionName; + private @Nullable TransactionNameSource transactionNameSource; + private final @Nullable Baggage baggage; + private final @NotNull AutoClosableReentrantLock lock = new AutoClosableReentrantLock(); + + private final @NotNull Map data = new ConcurrentHashMap<>(); + private final @NotNull Map measurements = new ConcurrentHashMap<>(); + + /** A throwable thrown during the execution of the span. */ + private @Nullable Throwable throwable; + + private @NotNull Deque tokensToCleanup = new ArrayDeque<>(1); + + public OtelSpanWrapper( + final @NotNull ReadWriteSpan span, + final @NotNull IScopes scopes, + final @NotNull SentryDate startTimestamp, + final @Nullable TracesSamplingDecision samplingDecision, + final @Nullable OtelSpanWrapper parentSpan, + final @Nullable SpanId parentSpanId, + final @Nullable Baggage baggage) { + this.scopes = Objects.requireNonNull(scopes, "scopes are required"); + this.span = new WeakReference<>(span); + this.startTimestamp = startTimestamp; + + if (parentSpan != null) { + this.baggage = parentSpan.getSpanContext().getBaggage(); + } else if (baggage != null) { + this.baggage = baggage; + } else { + this.baggage = null; + } + + this.context = + new OtelSpanContext(span, samplingDecision, parentSpan, parentSpanId, this.baggage); + } + + @Override + public @NotNull ISpan startChild(@NotNull String operation) { + return startChild(operation, (String) null); + } + + @Override + public @NotNull ISpan startChild( + @NotNull String operation, @Nullable String description, @NotNull SpanOptions spanOptions) { + if (isFinished()) { + return NoOpSpan.getInstance(); + } + final @NotNull SpanContext spanContext = + context.copyForChild(operation, getSpanContext().getSpanId(), null); + spanContext.setDescription(description); + + return startChild(spanContext, spanOptions); + } + + @Override + public @NotNull ISpan startChild( + @NotNull SpanContext spanContext, @NotNull SpanOptions spanOptions) { + if (isFinished()) { + return NoOpSpan.getInstance(); + } + + final @NotNull ISpan childSpan = + scopes.getOptions().getSpanFactory().createSpan(scopes, spanOptions, spanContext, this); + // TODO [POTEL] spanOptions.isBindToScope with default true? + childSpan.makeCurrent(); + return childSpan; + } + + @Override + public @NotNull ISpan startChild( + @NotNull String operation, + @Nullable String description, + @Nullable SentryDate timestamp, + @NotNull Instrumenter instrumenter) { + final @NotNull SpanContext spanContext = + context.copyForChild(operation, getSpanContext().getSpanId(), null); + spanContext.setDescription(description); + spanContext.setInstrumenter(instrumenter); + + final @NotNull SpanOptions spanOptions = new SpanOptions(); + spanOptions.setStartTimestamp(timestamp); + + return startChild(spanContext, spanOptions); + } + + @Override + public @NotNull ISpan startChild( + @NotNull String operation, + @Nullable String description, + @Nullable SentryDate timestamp, + @NotNull Instrumenter instrumenter, + @NotNull SpanOptions spanOptions) { + if (timestamp != null) { + spanOptions.setStartTimestamp(timestamp); + } + + final @NotNull SpanContext spanContext = + context.copyForChild(operation, getSpanContext().getSpanId(), null); + spanContext.setDescription(description); + spanContext.setInstrumenter(instrumenter); + + return startChild(spanContext, spanOptions); + } + + @Override + public @NotNull ISpan startChild(@NotNull String operation, @Nullable String description) { + final @NotNull SpanContext spanContext = + context.copyForChild(operation, getSpanContext().getSpanId(), null); + spanContext.setDescription(description); + + return startChild(spanContext, new SpanOptions()); + } + + @Override + public @NotNull SentryTraceHeader toSentryTrace() { + return new SentryTraceHeader(getTraceId(), getOtelSpanId(), isSampled()); + } + + private @NotNull SpanId getOtelSpanId() { + return context.getSpanId(); + } + + private @Nullable ReadWriteSpan getSpan() { + return span.get(); + } + + @Override + public @Nullable TraceContext traceContext() { + if (scopes.getOptions().isTraceSampling()) { + if (baggage != null) { + updateBaggageValues(); + return baggage.toTraceContext(); + } + } + return null; + } + + private void updateBaggageValues() { + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + if (baggage != null && baggage.isMutable()) { + final AtomicReference replayIdAtomicReference = new AtomicReference<>(); + scopes.configureScope( + scope -> { + replayIdAtomicReference.set(scope.getReplayId()); + }); + baggage.setValuesFromTransaction( + getSpanContext().getTraceId(), + replayIdAtomicReference.get(), + scopes.getOptions(), + this.getSamplingDecision(), + getTransactionName(), + getTransactionNameSource()); + baggage.freeze(); + } + } + } + + @Override + public @Nullable BaggageHeader toBaggageHeader(@Nullable List thirdPartyBaggageHeaders) { + if (scopes.getOptions().isTraceSampling()) { + if (baggage != null) { + updateBaggageValues(); + return BaggageHeader.fromBaggageAndOutgoingHeader(baggage, thirdPartyBaggageHeaders); + } + } + return null; + } + + @Override + public void finish() { + finish(getStatus()); + } + + @Override + public void finish(@Nullable SpanStatus status) { + setStatus(status); + final @Nullable Span otelSpan = getSpan(); + if (otelSpan != null) { + otelSpan.end(); + } + + for (ISentryLifecycleToken token : tokensToCleanup) { + token.close(); + } + } + + @Override + public void finish(@Nullable SpanStatus status, @Nullable SentryDate timestamp) { + setStatus(status); + final @Nullable Span otelSpan = getSpan(); + if (otelSpan != null) { + if (timestamp != null) { + otelSpan.end(timestamp.nanoTimestamp(), TimeUnit.NANOSECONDS); + } else { + otelSpan.end(); + } + } + } + + @Override + public void setOperation(@NotNull String operation) { + this.context.setOperation(operation); + } + + @Override + public @NotNull String getOperation() { + return context.getOperation(); + } + + @Override + public void setDescription(@Nullable String description) { + this.context.setDescription(description); + } + + @Override + public @Nullable String getDescription() { + return this.context.getDescription(); + } + + @Override + public void setStatus(final @Nullable SpanStatus status) { + context.setStatus(status); + } + + @Override + public @Nullable SpanStatus getStatus() { + return context.getStatus(); + } + + @Override + public void setThrowable(@Nullable Throwable throwable) { + this.throwable = throwable; + } + + @Override + public @Nullable Throwable getThrowable() { + return throwable; + } + + @Override + public @NotNull SpanContext getSpanContext() { + return context; + } + + @Override + public void setTag(@NotNull String key, @NotNull String value) { + context.setTag(key, value); + } + + @Override + public @Nullable String getTag(@NotNull String key) { + return context.getTags().get(key); + } + + @ApiStatus.Internal + public @NotNull Map getTags() { + return context.getTags(); + } + + @Override + public boolean isFinished() { + final @Nullable ReadWriteSpan otelSpan = getSpan(); + if (otelSpan != null) { + return otelSpan.hasEnded(); + } + + // if span is no longer available we consider it ended/finished + return true; + } + + @Override + public void setData(@NotNull String key, @NotNull Object value) { + data.put(key, value); + } + + @Override + public @Nullable Object getData(@NotNull String key) { + return data.get(key); + } + + @Override + public void setMeasurement(@NotNull String name, @NotNull Number value) { + if (isFinished()) { + scopes + .getOptions() + .getLogger() + .log( + SentryLevel.DEBUG, + "The span is already finished. Measurement %s cannot be set", + name); + return; + } + this.measurements.put(name, new MeasurementValue(value, null)); + } + + @Override + public void setMeasurement( + @NotNull String name, @NotNull Number value, @NotNull MeasurementUnit unit) { + if (isFinished()) { + scopes + .getOptions() + .getLogger() + .log( + SentryLevel.DEBUG, + "The span is already finished. Measurement %s cannot be set", + name); + return; + } + this.measurements.put(name, new MeasurementValue(value, unit.apiName())); + } + + @Override + public boolean updateEndDate(@NotNull SentryDate date) { + if (this.finishedTimestamp != null) { + this.finishedTimestamp = date; + return true; + } + return false; + } + + @Override + public @NotNull SentryDate getStartDate() { + return startTimestamp; + } + + @Override + public @Nullable SentryDate getFinishDate() { + return finishedTimestamp; + } + + @Override + public boolean isNoOp() { + return false; + } + + @Override + public void setContext(@NotNull String key, @NotNull Object context) { + contexts.put(key, context); + } + + @Override + public @NotNull Contexts getContexts() { + return contexts; + } + + public void setTransactionName(@NotNull String name) { + setTransactionName(name, TransactionNameSource.CUSTOM); + } + + public void setTransactionName(@NotNull String name, @NotNull TransactionNameSource nameSource) { + this.transactionName = name; + this.transactionNameSource = nameSource; + } + + @ApiStatus.Internal + public @Nullable TransactionNameSource getTransactionNameSource() { + return transactionNameSource; + } + + @ApiStatus.Internal + public @Nullable String getTransactionName() { + return this.transactionName; + } + + @NotNull + public SentryId getTraceId() { + return context.getTraceId(); + } + + public @NotNull Map getData() { + return data; + } + + @NotNull + public Map getMeasurements() { + return measurements; + } + + @Override + public @Nullable Boolean isSampled() { + return context.getSampled(); + } + + public @Nullable Boolean isProfileSampled() { + return context.getProfileSampled(); + } + + @Override + public @Nullable TracesSamplingDecision getSamplingDecision() { + return context.getSamplingDecision(); + } + + @ApiStatus.Internal + public @NotNull IScopes getScopes() { + return scopes; + } + + @SuppressWarnings("MustBeClosedChecker") + @ApiStatus.Internal + @Override + public @NotNull ISentryLifecycleToken makeCurrent() { + final @Nullable Span otelSpan = getSpan(); + if (otelSpan != null) { + final @NotNull Scope otelScope = otelSpan.makeCurrent(); + final @NotNull OtelStorageToken token = new OtelStorageToken(otelScope); + // to iterate LIFO when closing + tokensToCleanup.addFirst(token); + return token; + } + return NoOpScopesLifecycleToken.getInstance(); + } +} diff --git a/sentry-opentelemetry/sentry-opentelemetry-extra/src/main/java/io/sentry/opentelemetry/OtelStorageToken.java b/sentry-opentelemetry/sentry-opentelemetry-extra/src/main/java/io/sentry/opentelemetry/OtelStorageToken.java new file mode 100644 index 0000000000..f9c7ccafad --- /dev/null +++ b/sentry-opentelemetry/sentry-opentelemetry-extra/src/main/java/io/sentry/opentelemetry/OtelStorageToken.java @@ -0,0 +1,21 @@ +package io.sentry.opentelemetry; + +import io.opentelemetry.context.Scope; +import io.sentry.ISentryLifecycleToken; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; + +@ApiStatus.Internal +final class OtelStorageToken implements ISentryLifecycleToken { + + private final @NotNull Scope otelScope; + + OtelStorageToken(final @NotNull Scope otelScope) { + this.otelScope = otelScope; + } + + @Override + public void close() { + otelScope.close(); + } +} diff --git a/sentry-opentelemetry/sentry-opentelemetry-extra/src/main/java/io/sentry/opentelemetry/OtelTransactionSpanForwarder.java b/sentry-opentelemetry/sentry-opentelemetry-extra/src/main/java/io/sentry/opentelemetry/OtelTransactionSpanForwarder.java new file mode 100644 index 0000000000..c0106b25ad --- /dev/null +++ b/sentry-opentelemetry/sentry-opentelemetry-extra/src/main/java/io/sentry/opentelemetry/OtelTransactionSpanForwarder.java @@ -0,0 +1,312 @@ +package io.sentry.opentelemetry; + +import static io.sentry.TransactionContext.DEFAULT_TRANSACTION_NAME; + +import io.sentry.BaggageHeader; +import io.sentry.Hint; +import io.sentry.ISentryLifecycleToken; +import io.sentry.ISpan; +import io.sentry.ITransaction; +import io.sentry.Instrumenter; +import io.sentry.MeasurementUnit; +import io.sentry.SentryDate; +import io.sentry.SentryTraceHeader; +import io.sentry.SpanContext; +import io.sentry.SpanOptions; +import io.sentry.SpanStatus; +import io.sentry.TraceContext; +import io.sentry.TracesSamplingDecision; +import io.sentry.protocol.Contexts; +import io.sentry.protocol.SentryId; +import io.sentry.protocol.TransactionNameSource; +import io.sentry.util.Objects; +import java.util.ArrayList; +import java.util.List; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +@ApiStatus.Internal +public final class OtelTransactionSpanForwarder implements ITransaction { + + private final @NotNull OtelSpanWrapper rootSpan; + + public OtelTransactionSpanForwarder(final @NotNull OtelSpanWrapper rootSpan) { + this.rootSpan = Objects.requireNonNull(rootSpan, "root span is required"); + } + + @Override + public @NotNull ISpan startChild(@NotNull String operation) { + return rootSpan.startChild(operation); + } + + @Override + public @NotNull ISpan startChild( + @NotNull String operation, @Nullable String description, @NotNull SpanOptions spanOptions) { + return rootSpan.startChild(operation, description, spanOptions); + } + + @Override + public @NotNull ISpan startChild( + @NotNull SpanContext spanContext, @NotNull SpanOptions spanOptions) { + return rootSpan.startChild(spanContext, spanOptions); + } + + @Override + public @NotNull ISpan startChild( + @NotNull String operation, + @Nullable String description, + @Nullable SentryDate timestamp, + @NotNull Instrumenter instrumenter) { + return rootSpan.startChild(operation, description, timestamp, instrumenter); + } + + @Override + public @NotNull ISpan startChild( + @NotNull String operation, + @Nullable String description, + @Nullable SentryDate timestamp, + @NotNull Instrumenter instrumenter, + @NotNull SpanOptions spanOptions) { + return rootSpan.startChild(operation, description, timestamp, instrumenter, spanOptions); + } + + @Override + public @NotNull ISpan startChild(@NotNull String operation, @Nullable String description) { + return rootSpan.startChild(operation, description); + } + + @Override + public @NotNull SentryTraceHeader toSentryTrace() { + return rootSpan.toSentryTrace(); + } + + @Override + public @Nullable TraceContext traceContext() { + return rootSpan.traceContext(); + } + + @Override + public @Nullable BaggageHeader toBaggageHeader(@Nullable List thirdPartyBaggageHeaders) { + return rootSpan.toBaggageHeader(thirdPartyBaggageHeaders); + } + + @Override + public void finish() { + rootSpan.finish(); + } + + @Override + public void finish(@Nullable SpanStatus status) { + rootSpan.finish(status); + } + + @Override + public void finish(@Nullable SpanStatus status, @Nullable SentryDate timestamp) { + rootSpan.finish(status, timestamp); + } + + @Override + public void setOperation(@NotNull String operation) { + rootSpan.startChild(operation); + } + + @Override + public @NotNull String getOperation() { + return rootSpan.getOperation(); + } + + @Override + public void setDescription(@Nullable String description) { + rootSpan.setDescription(description); + } + + @Override + public @Nullable String getDescription() { + return rootSpan.getDescription(); + } + + @Override + public void setStatus(@Nullable SpanStatus status) { + rootSpan.setStatus(status); + } + + @Override + public @Nullable SpanStatus getStatus() { + return rootSpan.getStatus(); + } + + @Override + public void setThrowable(@Nullable Throwable throwable) { + rootSpan.setThrowable(throwable); + } + + @Override + public @Nullable Throwable getThrowable() { + return rootSpan.getThrowable(); + } + + @Override + public @NotNull SpanContext getSpanContext() { + return rootSpan.getSpanContext(); + } + + @Override + public void setTag(@NotNull String key, @NotNull String value) { + rootSpan.setTag(key, value); + } + + @Override + public @Nullable String getTag(@NotNull String key) { + return rootSpan.getTag(key); + } + + @Override + public boolean isFinished() { + return rootSpan.isFinished(); + } + + @Override + public void setData(@NotNull String key, @NotNull Object value) { + rootSpan.setData(key, value); + } + + @Override + public @Nullable Object getData(@NotNull String key) { + return rootSpan.getData(key); + } + + @Override + public void setMeasurement(@NotNull String name, @NotNull Number value) { + rootSpan.setMeasurement(name, value); + } + + @Override + public void setMeasurement( + @NotNull String name, @NotNull Number value, @NotNull MeasurementUnit unit) { + rootSpan.setMeasurement(name, value, unit); + } + + @Override + public boolean updateEndDate(@NotNull SentryDate date) { + return rootSpan.updateEndDate(date); + } + + @Override + public @NotNull SentryDate getStartDate() { + return rootSpan.getStartDate(); + } + + @Override + public @Nullable SentryDate getFinishDate() { + return rootSpan.getFinishDate(); + } + + @Override + public boolean isNoOp() { + return rootSpan.isNoOp(); + } + + @Override + public @NotNull TransactionNameSource getTransactionNameSource() { + final @Nullable TransactionNameSource nameSource = rootSpan.getTransactionNameSource(); + if (nameSource == null) { + return TransactionNameSource.CUSTOM; + } + return nameSource; + } + + @Override + public @NotNull List getSpans() { + return new ArrayList<>(); + } + + @Override + public @NotNull ISpan startChild( + @NotNull String operation, @Nullable String description, @Nullable SentryDate timestamp) { + return rootSpan.startChild(operation, description, timestamp, Instrumenter.SENTRY); + } + + @Override + public @Nullable Boolean isSampled() { + return rootSpan.isSampled(); + } + + @Override + public @Nullable Boolean isProfileSampled() { + return rootSpan.isProfileSampled(); + } + + @Override + public @Nullable TracesSamplingDecision getSamplingDecision() { + return rootSpan.getSamplingDecision(); + } + + @Override + public @Nullable ISpan getLatestActiveSpan() { + return rootSpan; + } + + @Override + public @NotNull SentryId getEventId() { + return new SentryId(); + } + + @ApiStatus.Internal + @Override + public @NotNull ISentryLifecycleToken makeCurrent() { + return rootSpan.makeCurrent(); + } + + @Override + public void scheduleFinish() {} + + @Override + public void forceFinish( + @NotNull SpanStatus status, boolean dropIfNoChildren, @Nullable Hint hint) { + rootSpan.finish(status); + } + + @Override + public void finish( + @Nullable SpanStatus status, + @Nullable SentryDate timestamp, + boolean dropIfNoChildren, + @Nullable Hint hint) { + rootSpan.finish(status, timestamp); + } + + @Override + public void setContext(@NotNull String key, @NotNull Object context) { + // thoughts: + // - span would have to save it on global storage too since we can't add complex data to otel + // span + // - with span ingestion there isn't a transaction anymore, so if we still need Contexts it + // should go on the (root) span + rootSpan.setContext(key, context); + } + + @Override + public @NotNull Contexts getContexts() { + return rootSpan.getContexts(); + } + + @Override + public void setName(@NotNull String name) { + rootSpan.setTransactionName(name); + } + + @Override + public void setName(@NotNull String name, @NotNull TransactionNameSource nameSource) { + rootSpan.setTransactionName(name, nameSource); + } + + @Override + public @NotNull String getName() { + final @Nullable String name = rootSpan.getTransactionName(); + if (name == null) { + return DEFAULT_TRANSACTION_NAME; + } + return name; + } +} diff --git a/sentry-opentelemetry/sentry-opentelemetry-extra/src/main/java/io/sentry/opentelemetry/SentryContextStorage.java b/sentry-opentelemetry/sentry-opentelemetry-extra/src/main/java/io/sentry/opentelemetry/SentryContextStorage.java new file mode 100644 index 0000000000..4f3efa40c2 --- /dev/null +++ b/sentry-opentelemetry/sentry-opentelemetry-extra/src/main/java/io/sentry/opentelemetry/SentryContextStorage.java @@ -0,0 +1,41 @@ +package io.sentry.opentelemetry; + +import io.opentelemetry.context.Context; +import io.opentelemetry.context.ContextStorage; +import io.opentelemetry.context.Scope; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; + +@ApiStatus.Internal +public final class SentryContextStorage implements ContextStorage { + private final @NotNull ContextStorage contextStorage; + + public SentryContextStorage(final @NotNull ContextStorage contextStorage) { + this.contextStorage = contextStorage; + } + + @Override + public Scope attach(Context toAttach) { + // TODO [POTEL] do we need to fork here as well? + // scenario: Context is propagated from thread A to thread B without changes + // OTEL likely also dosn't fork in that case so we probably also don't have to + // or maybe shouldn't even to better align with OTEL + // but since OTEL Context is immutable it doesn't have the same consequence for OTEL as for us + + // TODO [POTEL] sometimes context has already gone through forking but is still an + // ArrayBaseContext + // most likely due to OTEL bridging between agent and app + + // incoming non sentry wrapped context that already has scopes in it + if (toAttach instanceof SentryContextWrapper) { + return contextStorage.attach(toAttach); + } else { + return contextStorage.attach(SentryContextWrapper.wrap(toAttach)); + } + } + + @Override + public Context current() { + return contextStorage.current(); + } +} diff --git a/sentry-opentelemetry/sentry-opentelemetry-extra/src/main/java/io/sentry/opentelemetry/SentryContextWrapper.java b/sentry-opentelemetry/sentry-opentelemetry-extra/src/main/java/io/sentry/opentelemetry/SentryContextWrapper.java new file mode 100644 index 0000000000..20600fb243 --- /dev/null +++ b/sentry-opentelemetry/sentry-opentelemetry-extra/src/main/java/io/sentry/opentelemetry/SentryContextWrapper.java @@ -0,0 +1,97 @@ +package io.sentry.opentelemetry; + +import static io.sentry.opentelemetry.SentryOtelKeys.SENTRY_SCOPES_KEY; + +import io.opentelemetry.api.trace.Span; +import io.opentelemetry.context.Context; +import io.opentelemetry.context.ContextKey; +import io.sentry.IScopes; +import io.sentry.Sentry; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +@ApiStatus.Internal +public final class SentryContextWrapper implements Context { + + private final @NotNull Context delegate; + + private SentryContextWrapper(final @NotNull Context delegate) { + this.delegate = delegate; + } + + @Override + public V get(final @NotNull ContextKey contextKey) { + return delegate.get(contextKey); + } + + @Override + public Context with(final @NotNull ContextKey contextKey, V v) { + final @NotNull Context modifiedContext = delegate.with(contextKey, v); + + if (isOpentelemetrySpan(contextKey)) { + return forkCurrentScope(modifiedContext); + } else { + return modifiedContext; + } + } + + private boolean isOpentelemetrySpan(final @NotNull ContextKey contextKey) { + return "opentelemetry-trace-span-key".equals(contextKey.toString()); + } + + private static @NotNull Context forkCurrentScope(final @NotNull Context context) { + final @Nullable OtelSpanWrapper sentrySpan = getCurrentSpanFromGlobalStorage(context); + final @Nullable IScopes spanScopes = sentrySpan == null ? null : sentrySpan.getScopes(); + final @NotNull IScopes forkedScopes = forkCurrentScopeInternal(context, spanScopes); + if (sentrySpan != null) { + forkedScopes.setActiveSpan(sentrySpan); + } + return context.with(SENTRY_SCOPES_KEY, forkedScopes); + } + + private static @NotNull IScopes forkCurrentScopeInternal( + final @NotNull Context context, final @Nullable IScopes spanScopes) { + final @Nullable IScopes scopesInContext = context.get(SENTRY_SCOPES_KEY); + + if (scopesInContext != null && spanScopes != null) { + if (scopesInContext.isAncestorOf(spanScopes)) { + return spanScopes.forkedCurrentScope("contextwrapper.spanancestor"); + } + } + + if (scopesInContext != null) { + return scopesInContext.forkedCurrentScope("contextwrapper.scopeincontext"); + } + + if (spanScopes != null) { + return spanScopes.forkedCurrentScope("contextwrapper.spanscope"); + } + + return Sentry.forkedRootScopes("contextwrapper.fallback"); + } + + private static @Nullable OtelSpanWrapper getCurrentSpanFromGlobalStorage( + final @NotNull Context context) { + @Nullable final Span span = Span.fromContextOrNull(context); + + if (span != null) { + final @Nullable OtelSpanWrapper sentrySpan = + SentryWeakSpanStorage.getInstance().getSentrySpan(span.getSpanContext()); + return sentrySpan; + } + + return null; + } + + public static @NotNull SentryContextWrapper wrap(final @NotNull Context context) { + // we have to fork here because the first time we get to wrap a context it may already have a + // span and a scope + return new SentryContextWrapper(forkCurrentScope(context)); + } + + @Override + public String toString() { + return delegate.toString(); + } +} diff --git a/sentry-opentelemetry/sentry-opentelemetry-extra/src/main/java/io/sentry/opentelemetry/SentryOtelThreadLocalStorage.java b/sentry-opentelemetry/sentry-opentelemetry-extra/src/main/java/io/sentry/opentelemetry/SentryOtelThreadLocalStorage.java new file mode 100644 index 0000000000..e44afc5a2d --- /dev/null +++ b/sentry-opentelemetry/sentry-opentelemetry-extra/src/main/java/io/sentry/opentelemetry/SentryOtelThreadLocalStorage.java @@ -0,0 +1,85 @@ +/* + * Adapted from https://github.com/open-telemetry/opentelemetry-java/blob/0aacc55d1e3f5cc6dbb4f8fa26bcb657b01a7bc9/context/src/main/java/io/opentelemetry/context/ThreadLocalContextStorage.java + * + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.sentry.opentelemetry; + +import io.opentelemetry.context.Context; +import io.opentelemetry.context.ContextStorage; +import io.opentelemetry.context.Scope; +import java.util.logging.Level; +import java.util.logging.Logger; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.Nullable; + +/** + * Workaround to make OpenTelemetry context storage work for Sentry since Sentry sometimes forks + * Context without cleaning up. We are not yet sure if this is something we can easliy fix, since + * Sentry static API makes heavy use of getCurrentScopes and there is no easy way of knowing when to + * restore previous Context. + */ +@ApiStatus.Experimental +@ApiStatus.Internal +public final class SentryOtelThreadLocalStorage implements ContextStorage { + private static final Logger logger = + Logger.getLogger(SentryOtelThreadLocalStorage.class.getName()); + + private static final ThreadLocal THREAD_LOCAL_STORAGE = new ThreadLocal<>(); + + @Override + public Scope attach(Context toAttach) { + if (toAttach == null) { + // Null context not allowed so ignore it. + return NoopScope.INSTANCE; + } + + Context beforeAttach = current(); + if (toAttach == beforeAttach) { + return NoopScope.INSTANCE; + } + + THREAD_LOCAL_STORAGE.set(toAttach); + + return new SentryScopeImpl(beforeAttach); + } + + private static class SentryScopeImpl implements Scope { + @Nullable private final Context beforeAttach; + private boolean closed; + + private SentryScopeImpl(@Nullable Context beforeAttach) { + this.beforeAttach = beforeAttach; + } + + @Override + public void close() { + // if (!closed && current() == toAttach) { + // Used to make OTel thread local storage compatible with Sentry where cleanup isn't always + // performed correctly + if (!closed) { + closed = true; + THREAD_LOCAL_STORAGE.set(beforeAttach); + } else { + logger.log( + Level.FINE, + " Trying to close scope which does not represent current context. Ignoring the call."); + } + } + } + + @Override + @Nullable + public Context current() { + return THREAD_LOCAL_STORAGE.get(); + } + + enum NoopScope implements Scope { + INSTANCE; + + @Override + public void close() {} + } +} diff --git a/sentry-opentelemetry/sentry-opentelemetry-extra/src/main/java/io/sentry/opentelemetry/SentryWeakSpanStorage.java b/sentry-opentelemetry/sentry-opentelemetry-extra/src/main/java/io/sentry/opentelemetry/SentryWeakSpanStorage.java new file mode 100644 index 0000000000..7c8e2d3459 --- /dev/null +++ b/sentry-opentelemetry/sentry-opentelemetry-extra/src/main/java/io/sentry/opentelemetry/SentryWeakSpanStorage.java @@ -0,0 +1,47 @@ +package io.sentry.opentelemetry; + +import io.opentelemetry.api.trace.SpanContext; +import io.opentelemetry.context.internal.shaded.WeakConcurrentMap; +import io.sentry.ISentryLifecycleToken; +import io.sentry.util.AutoClosableReentrantLock; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * Weakly references wrappers for OpenTelemetry spans meaning they'll be cleaned up when the + * OpenTelemetry span is garbage collected. + */ +@ApiStatus.Internal +public final class SentryWeakSpanStorage { + private static volatile @Nullable SentryWeakSpanStorage INSTANCE; + private static final @NotNull AutoClosableReentrantLock staticLock = + new AutoClosableReentrantLock(); + + public static @NotNull SentryWeakSpanStorage getInstance() { + if (INSTANCE == null) { + try (final @NotNull ISentryLifecycleToken ignored = staticLock.acquire()) { + if (INSTANCE == null) { + INSTANCE = new SentryWeakSpanStorage(); + } + } + } + + return INSTANCE; + } + + // weak keys, spawns a thread to clean up values that have been garbage collected + private final @NotNull WeakConcurrentMap sentrySpans = + new WeakConcurrentMap<>(true); + + private SentryWeakSpanStorage() {} + + public @Nullable OtelSpanWrapper getSentrySpan(final @NotNull SpanContext spanContext) { + return sentrySpans.get(spanContext); + } + + public void storeSentrySpan( + final @NotNull SpanContext otelSpan, final @NotNull OtelSpanWrapper sentrySpan) { + this.sentrySpans.put(otelSpan, sentrySpan); + } +} diff --git a/sentry-quartz/api/sentry-quartz.api b/sentry-quartz/api/sentry-quartz.api index ff32280dc5..bb8b142a91 100644 --- a/sentry-quartz/api/sentry-quartz.api +++ b/sentry-quartz/api/sentry-quartz.api @@ -5,9 +5,10 @@ public final class io/sentry/quartz/BuildConfig { public final class io/sentry/quartz/SentryJobListener : org/quartz/JobListener { public static final field SENTRY_CHECK_IN_ID_KEY Ljava/lang/String; + public static final field SENTRY_SCOPE_LIFECYCLE_TOKEN_KEY Ljava/lang/String; public static final field SENTRY_SLUG_KEY Ljava/lang/String; public fun ()V - public fun (Lio/sentry/IHub;)V + public fun (Lio/sentry/IScopes;)V public fun getName ()Ljava/lang/String; public fun jobExecutionVetoed (Lorg/quartz/JobExecutionContext;)V public fun jobToBeExecuted (Lorg/quartz/JobExecutionContext;)V diff --git a/sentry-quartz/src/main/java/io/sentry/quartz/SentryJobListener.java b/sentry-quartz/src/main/java/io/sentry/quartz/SentryJobListener.java index 28a0e51200..38dbffdc8e 100644 --- a/sentry-quartz/src/main/java/io/sentry/quartz/SentryJobListener.java +++ b/sentry-quartz/src/main/java/io/sentry/quartz/SentryJobListener.java @@ -3,11 +3,13 @@ import io.sentry.BuildConfig; import io.sentry.CheckIn; import io.sentry.CheckInStatus; -import io.sentry.HubAdapter; -import io.sentry.IHub; +import io.sentry.IScopes; +import io.sentry.ISentryLifecycleToken; +import io.sentry.ScopesAdapter; import io.sentry.SentryIntegrationPackageStorage; import io.sentry.SentryLevel; import io.sentry.protocol.SentryId; +import io.sentry.util.LifecycleHelper; import io.sentry.util.Objects; import io.sentry.util.TracingUtils; import org.jetbrains.annotations.ApiStatus; @@ -23,15 +25,16 @@ public final class SentryJobListener implements JobListener { public static final String SENTRY_CHECK_IN_ID_KEY = "sentry-checkin-id"; public static final String SENTRY_SLUG_KEY = "sentry-slug"; + public static final String SENTRY_SCOPE_LIFECYCLE_TOKEN_KEY = "sentry-scope-lifecycle"; - private final @NotNull IHub hub; + private final @NotNull IScopes scopes; public SentryJobListener() { - this(HubAdapter.getInstance()); + this(ScopesAdapter.getInstance()); } - public SentryJobListener(final @NotNull IHub hub) { - this.hub = Objects.requireNonNull(hub, "hub is required"); + public SentryJobListener(final @NotNull IScopes scopes) { + this.scopes = Objects.requireNonNull(scopes, "scopes are required"); SentryIntegrationPackageStorage.getInstance().addIntegration("Quartz"); SentryIntegrationPackageStorage.getInstance() .addPackage("maven:io.sentry:sentry-quartz", BuildConfig.VERSION_NAME); @@ -49,15 +52,18 @@ public void jobToBeExecuted(final @NotNull JobExecutionContext context) { if (maybeSlug == null) { return; } - hub.pushScope(); - TracingUtils.startNewTrace(hub); + final @NotNull ISentryLifecycleToken lifecycleToken = + scopes.forkedScopes("SentryJobListener").makeCurrent(); + TracingUtils.startNewTrace(scopes); final @NotNull String slug = maybeSlug; final @NotNull CheckIn checkIn = new CheckIn(slug, CheckInStatus.IN_PROGRESS); - final @NotNull SentryId checkInId = hub.captureCheckIn(checkIn); + final @NotNull SentryId checkInId = scopes.captureCheckIn(checkIn); context.put(SENTRY_CHECK_IN_ID_KEY, checkInId); context.put(SENTRY_SLUG_KEY, slug); + context.put(SENTRY_SCOPE_LIFECYCLE_TOKEN_KEY, lifecycleToken); } catch (Throwable t) { - hub.getOptions() + scopes + .getOptions() .getLogger() .log(SentryLevel.ERROR, "Unable to capture check-in in jobToBeExecuted.", t); } @@ -94,14 +100,15 @@ public void jobWasExecuted(JobExecutionContext context, JobExecutionException jo if (slug != null) { final boolean isFailed = jobException != null; final @NotNull CheckInStatus status = isFailed ? CheckInStatus.ERROR : CheckInStatus.OK; - hub.captureCheckIn(new CheckIn(checkInId, slug, status)); + scopes.captureCheckIn(new CheckIn(checkInId, slug, status)); } } catch (Throwable t) { - hub.getOptions() + scopes + .getOptions() .getLogger() .log(SentryLevel.ERROR, "Unable to capture check-in in jobWasExecuted.", t); } finally { - hub.popScope(); + LifecycleHelper.close(context.get(SENTRY_SCOPE_LIFECYCLE_TOKEN_KEY)); } } } diff --git a/sentry-samples/sentry-samples-android/CMakeLists.txt b/sentry-samples/sentry-samples-android/CMakeLists.txt index ad170fe404..19dca2b80d 100644 --- a/sentry-samples/sentry-samples-android/CMakeLists.txt +++ b/sentry-samples/sentry-samples-android/CMakeLists.txt @@ -3,15 +3,12 @@ project(Sentry-Sample LANGUAGES C CXX) add_library(native-sample SHARED src/main/cpp/native-sample.cpp) -# make sure that we build it as a shared lib instead of a static lib -set(BUILD_SHARED_LIBS ON) -set(SENTRY_BUILD_SHARED_LIBS ON) - -add_subdirectory(../../sentry-android-ndk/${SENTRY_NATIVE_SRC} sentry_build) +find_package(sentry-native-ndk REQUIRED CONFIG) find_library(LOG_LIB log) target_link_libraries(native-sample PRIVATE ${LOG_LIB} - $ + sentry-native-ndk::sentry-android + sentry-native-ndk::sentry ) diff --git a/sentry-samples/sentry-samples-android/build.gradle.kts b/sentry-samples/sentry-samples-android/build.gradle.kts index a8d8897519..0f3cffecc2 100644 --- a/sentry-samples/sentry-samples-android/build.gradle.kts +++ b/sentry-samples/sentry-samples-android/build.gradle.kts @@ -15,16 +15,8 @@ android { versionName = project.version.toString() externalNativeBuild { - val sentryNativeSrc = if (File("${project.projectDir}/../../sentry-android-ndk/sentry-native-local").exists()) { - "sentry-native-local" - } else { - "sentry-native" - } - println("sentry-samples-android: $sentryNativeSrc") - cmake { - arguments.add(0, "-DANDROID_STL=c++_static") - arguments.add(0, "-DSENTRY_NATIVE_SRC=$sentryNativeSrc") + arguments.add(0, "-DANDROID_STL=c++_shared") } } @@ -38,6 +30,7 @@ android { // Note that the viewBinding.enabled property is now deprecated. viewBinding = true compose = true + prefab = true } composeOptions { @@ -107,11 +100,11 @@ dependencies { implementation(kotlin(Config.kotlinStdLib, org.jetbrains.kotlin.config.KotlinCompilerVersion.VERSION)) implementation(projects.sentryAndroid) - implementation(projects.sentryAndroidOkhttp) implementation(projects.sentryAndroidFragment) implementation(projects.sentryAndroidTimber) implementation(projects.sentryCompose) implementation(projects.sentryComposeHelper) + implementation(projects.sentryOkhttp) implementation(Config.Libs.fragment) implementation(Config.Libs.timber) @@ -132,6 +125,9 @@ dependencies { implementation(Config.Libs.composeFoundationLayout) implementation(Config.Libs.composeNavigation) implementation(Config.Libs.composeMaterial) + implementation(Config.Libs.composeCoil) debugImplementation(Config.Libs.leakCanary) + + implementation("io.sentry:sentry-native-ndk:0.7.5") } diff --git a/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml b/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml index 6d4b96bdca..84474b889c 100644 --- a/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml +++ b/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml @@ -66,9 +66,6 @@ - - @@ -108,15 +105,14 @@ - - - - + + + - + @@ -163,7 +159,7 @@ - - + + diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MainActivity.java b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MainActivity.java index 33dd35f986..da52c72a68 100644 --- a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MainActivity.java +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MainActivity.java @@ -254,9 +254,6 @@ public void run() { binding.openFrameDataForSpans.setOnClickListener( view -> startActivity(new Intent(this, FrameDataForSpansActivity.class))); - binding.openMetrics.setOnClickListener( - view -> startActivity(new Intent(this, MetricsActivity.class))); - setContentView(binding.getRoot()); } diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MetricsActivity.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MetricsActivity.kt deleted file mode 100644 index ebc535488a..0000000000 --- a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MetricsActivity.kt +++ /dev/null @@ -1,52 +0,0 @@ -package io.sentry.samples.android - -import android.os.Bundle -import androidx.activity.ComponentActivity -import androidx.activity.compose.setContent -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Button -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp -import io.sentry.Sentry -import kotlin.random.Random - -class MetricsActivity : ComponentActivity() { - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setContent { - MaterialTheme { - Surface { - Column( - modifier = Modifier.padding(20.dp) - ) { - Button(onClick = { - Sentry.metrics().increment("example.increment") - }) { - Text(text = "Increment") - } - Button(onClick = { - Sentry.metrics().distribution("example.distribution", Random.nextDouble()) - }) { - Text(text = "Distribution") - } - Button(onClick = { - Sentry.metrics().gauge("example.gauge", Random.nextDouble()) - }) { - Text(text = "Gauge") - } - Button(onClick = { - Sentry.metrics().set("example.set", Random.nextInt()) - }) { - Text(text = "Set") - } - } - } - } - } - } -} diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MyApplication.java b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MyApplication.java index 9a3169fef0..a4a1c5397a 100644 --- a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MyApplication.java +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MyApplication.java @@ -2,7 +2,6 @@ import android.app.Application; import android.os.StrictMode; -import io.sentry.Sentry; /** Apps. main Application. */ public class MyApplication extends Application { @@ -25,8 +24,6 @@ public void onCreate() { // }); // */ // }); - - Sentry.metrics().increment("app.start.cold"); } private void strictMode() { diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/ProfilingActivity.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/ProfilingActivity.kt index 610fc1534d..64e3f48441 100644 --- a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/ProfilingActivity.kt +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/ProfilingActivity.kt @@ -75,7 +75,7 @@ class ProfilingActivity : AppCompatActivity() { private fun finishTransactionAndPrintResults(t: ITransaction) { t.finish() profileFinished = true - val profilesDirPath = Sentry.getCurrentHub().options.profilingTracesDirPath + val profilesDirPath = Sentry.getCurrentScopes().options.profilingTracesDirPath if (profilesDirPath == null) { Toast.makeText(this, R.string.profiling_no_dir_set, Toast.LENGTH_SHORT).show() return @@ -84,7 +84,7 @@ class ProfilingActivity : AppCompatActivity() { // We have concurrent profiling now. We have to wait for all transactions to finish (e.g. button click) // before reading the profile, otherwise it's empty and a crash occurs if (Sentry.getSpan() != null) { - val timeout = Sentry.getCurrentHub().options.idleTimeout ?: 0 + val timeout = Sentry.getCurrentScopes().options.idleTimeout ?: 0 val duration = (getProfileDuration() * 1000).toLong() Thread.sleep((timeout - duration).coerceAtLeast(0)) } @@ -100,7 +100,7 @@ class ProfilingActivity : AppCompatActivity() { val traceData = ProfilingTraceData(profile, t) // Create envelope item from copied profile val item = - SentryEnvelopeItem.fromProfilingTrace(traceData, Long.MAX_VALUE, Sentry.getCurrentHub().options.serializer) + SentryEnvelopeItem.fromProfilingTrace(traceData, Long.MAX_VALUE, Sentry.getCurrentScopes().options.serializer) val itemData = item.data // Compress the envelope item using Gzip diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/compose/ComposeActivity.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/compose/ComposeActivity.kt index 1a4929b0b7..3d2e670495 100644 --- a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/compose/ComposeActivity.kt +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/compose/ComposeActivity.kt @@ -5,6 +5,7 @@ package io.sentry.samples.android.compose import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent +import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize @@ -22,7 +23,10 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.unit.dp import androidx.navigation.NavHostController @@ -31,10 +35,13 @@ import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController import androidx.navigation.navArgument +import coil.compose.AsyncImage +import io.sentry.android.replay.sentryReplayUnmask import io.sentry.compose.SentryTraced import io.sentry.compose.withSentryObservableEffect import io.sentry.samples.android.GithubAPI import kotlinx.coroutines.launch +import io.sentry.samples.android.R as IR class ComposeActivity : ComponentActivity() { @@ -109,6 +116,17 @@ fun Github( modifier = Modifier .fillMaxSize() ) { + Image( + painter = painterResource(IR.drawable.sentry_glyph), + contentDescription = "LOGO", + colorFilter = ColorFilter.tint(Color.Black), + modifier = Modifier.padding(vertical = 16.dp) + ) + AsyncImage( + model = "https://i.imgur.com/tie6A3J.jpeg", + contentDescription = null, + modifier = Modifier.padding(vertical = 16.dp) + ) TextField( value = user, onValueChange = { newText -> @@ -127,7 +145,7 @@ fun Github( .testTag("button_list_repos_async") .padding(top = 32.dp) ) { - Text("Make Request") + Text("Make Request", modifier = Modifier.sentryReplayUnmask()) } } } diff --git a/sentry-samples/sentry-samples-android/src/main/res/drawable/sentry_glyph.xml b/sentry-samples/sentry-samples-android/src/main/res/drawable/sentry_glyph.xml new file mode 100644 index 0000000000..28a3442987 --- /dev/null +++ b/sentry-samples/sentry-samples-android/src/main/res/drawable/sentry_glyph.xml @@ -0,0 +1,9 @@ + + + diff --git a/sentry-samples/sentry-samples-android/src/main/res/layout/activity_main.xml b/sentry-samples/sentry-samples-android/src/main/res/layout/activity_main.xml index 620acaa04c..6fb8d02863 100644 --- a/sentry-samples/sentry-samples-android/src/main/res/layout/activity_main.xml +++ b/sentry-samples/sentry-samples-android/src/main/res/layout/activity_main.xml @@ -142,11 +142,6 @@ android:layout_height="wrap_content" android:text="@string/open_frame_data_for_spans"/> -