diff --git a/.claude/skills/test/SKILL.md b/.claude/skills/test/SKILL.md index d7b28f6d260..0a066c76de0 100644 --- a/.claude/skills/test/SKILL.md +++ b/.claude/skills/test/SKILL.md @@ -1,13 +1,14 @@ --- name: test -description: Run tests for a specific SDK module. Use when asked to "run tests", "test module", "run unit tests", "run system tests", "run e2e tests", or test a specific class. Auto-detects unit vs system tests. Supports interactive mode. +description: Run tests for a specific SDK module. Use when asked to "run tests", "test module", "run unit tests", "run integration tests", "run e2e tests", or test a specific class. Auto-detects unit tests vs integration test suites. Supports interactive mode. allowed-tools: Bash, Read, Glob, AskUserQuestion argument-hint: [interactive] [test-class-filter] --- # Run Tests -Run tests for a specific module. Auto-detects whether to run unit tests or system tests. +Run tests for a specific module. Auto-detects whether to run unit tests or an integration test +suite. ## Step 0: Check for Interactive Mode @@ -19,7 +20,7 @@ In interactive mode, use AskUserQuestion at decision points as described in the The argument can be either: - A **file path** (e.g., `@sentry/src/test/java/io/sentry/ScopesTest.kt`) -- A **module name** (e.g., `sentry-android-core`, `sentry-samples-spring-boot-4`) +- A **module name** (e.g., `sentry-android-core`, `sentry-spring-boot-4`) - A **module name + test filter** (e.g., `sentry ScopesTest`) Extract the module name and optional test class filter from the argument. @@ -30,10 +31,13 @@ Extract the module name and optional test class filter from the argument. | Signal | Test Type | |--------|-----------| -| Path contains `sentry-samples/` | System test | -| Module name starts with `sentry-samples-` | System test | +| Path contains `/src/integrationTest*/` or `/src/agentFixture/` | Integration test suite | +| Path contains `sentry-samples/`, or module name starts with `sentry-samples-` | None: samples have no tests | | Everything else | Unit test | +For a sample, tell the user that samples have no tests, and that the end-to-end tests for what +the sample shows live in the module it uses (see the `e2e_tests` rule). + ## Step 3a: Run Unit Tests Determine the Gradle test task: @@ -60,30 +64,20 @@ Without a filter: ./gradlew '::' --info ``` -## Step 3b: Run System Tests - -System tests require the Python-based test runner which manages a mock Sentry server and sample app lifecycle. - -1. Ensure the Python venv exists: -```bash -test -d .venv || make setupPython -``` +## Step 3b: Run an Integration Test Suite -2. Extract the sample module name. For file paths like `sentry-samples//src/...`, the sample module is the directory name (e.g., `sentry-samples-spring`). +The suite's task is named after its source set directory: `src/integrationTestWebflux/...` runs +with `integrationTestWebflux`. The OpenTelemetry agent suite runs in two tasks, one per init mode, +so for `src/integrationTestOtelAgent/` or `src/agentFixture/` run both: -3. Run the system test: ```bash -.venv/bin/python test/system-test-runner.py test --module +./gradlew '::integrationTestOtelAgent' '::integrationTestOtelAgentWithoutAutoInit' ``` -This starts the mock Sentry server, starts the sample app (Spring Boot/Tomcat/CLI), runs tests via `./gradlew :sentry-samples::systemTest`, and cleans up afterwards. - -To run **every** system test instead of one module, use the Makefile targets — they also create the -venv for you: +Otherwise, with or without a test class filter: ```bash -make systemTest # all system tests (--all) -make systemTestInteractive # pick the setups to run (--interactive) +./gradlew '::' --tests="**" ``` ## Step 4: Report Results diff --git a/.cursor/rules/e2e_tests.mdc b/.cursor/rules/e2e_tests.mdc index 17e088774b5..1785e61b5c1 100644 --- a/.cursor/rules/e2e_tests.mdc +++ b/.cursor/rules/e2e_tests.mdc @@ -3,26 +3,82 @@ alwaysApply: false description: Java SDK End to End Tests --- -# Java SDK End to End Tests (System Tests) +# Java SDK End to End Tests -The samples in the `sentry-samples` directory are used to run end to end tests against them. +End-to-end tests live in the module whose code they test, not in `sentry-samples`. Samples have no +tests. A test runs the code the way an app would and asserts what reaches Sentry over the real HTTP +transport. -There is a python script (`system-test-runner.py`) that can be used to run one (using `--module SAMPLE_NAME`) or all (using `--all`) system tests. +## MockSentryServer -The script has an interactive mode (`-i`) which allows selection of test setups to execute, whether to run the tests or just prepare infrastructure for testing from IDE. +`MockSentryServer` (`sentry-test-support`) is an in-process HTTP server on a free port. Point the +SDK at `server.dsn`, then: -The tests run a mock Sentry server via `system-test-sentry-server.py`. Any system under test will then have a DSN set that reflects this local mock server like `http://502f25099c204a2fbf4cb16edc5975d1@localhost:8000/0`. -By using this local DSN, the system under test sends events to the local mock server. -The tests can then use `TestHelper` to assert envelopes that were received by the mock server. -`TestHelper` uses HTTP requests to retrieve the JSON payload of the received events and deserialize them back to objects for easier assertion. -Tests can then assert events, transactions, logs etc. similar to how they would appear in `beforeSend` and similar callbacks. +- `awaitEnvelope(timeoutMillis) { predicate }` polls for a matching envelope and fails with what it + received on timeout. +- `assertNoEnvelope(millis) { predicate }` checks that nothing matching arrives. +- `MockSentryServer { ... }` takes a hook that runs before each check while waiting, for example to + flush an OpenTelemetry tracer provider. -`TestHelper` has a lot of helper methods for asserting, e.g. by span name, log body etc. +`EnvelopeMatchers.kt` has the accessors and matchers: `event()`, `transaction()`, `logs()`, +`metrics()`, `profileChunk()`, `hasSpanWithOp`, `hasFlag`, `hasLogWithAttribute`, `hasMetric` and +so on. -The end to end tests either expect the system under test to either be running on a server or call `java -jar` to execute a CLI system under test. +Call `Sentry.flush()` before awaiting when Sentry runs in the test's JVM: logs and metrics are +batched. Envelopes that arrive late can outlive a server's `reset()`, so tests that share a server +match on values only they produce. -For Spring Boot, we spin up the Spring Boot server. The tests then send requests to that server and assert what is sent to Sentry. +## Where the tests are -End to end tests are also executed on CI using a matrix build, as defined in `.github/workflows/system-tests-backend.yml`. +- **Core and plain integrations** use their module's `src/test`. Examples: `SentryEndToEndTest` + (`sentry`), `SentryAppenderIntegrationTest` (`sentry-logback`, `sentry-log4j2`, loading the + appender from XML), `SentryHandlerIntegrationTest` (`sentry-jul`), + `ContinuousProfilerEndToEndTest` (`sentry-async-profiler`), `SentryKafkaEndToEndTest` + (`sentry-kafka`), `AgentlessEndToEndTest` and `OtlpEndToEndTest` (`sentry-opentelemetry-*`). +- **Spring Boot** (`sentry-spring-boot`, `sentry-spring-boot-jakarta`, `sentry-spring-boot-4`) has + one JVM test suite per flavor, because a flavor is a classpath: Sentry's auto-configuration and + OpenTelemetry mode switch on which classes are present. + - `integrationTest`: servlet app. + - `integrationTestWebflux`: reactive app. + - `integrationTestOtelNoAgent`: `sentry-opentelemetry-agentless-spring`. + - `integrationTestOtelAgent` and `integrationTestOtelAgentWithoutAutoInit`: the built agent. + - `integrationTestOtlp` (Boot 4): `sentry-opentelemetry-otlp-spring`. + - `integrationTestLog4j2` (Boot 3, 4): Log4j2 instead of Logback, for the auto-configured + Log4j2 appender. -Some of the samples are tested in multiple ways, e.g. with OpenTelemetry Agent auto init turned on and off. + Each suite has a base class with `@SpringBootTest(RANDOM_PORT)` and a `@DynamicPropertySource` + that sets `sentry.dsn`. Subclasses share one context and one server. +- **Plain Spring** (`sentry-spring`, `sentry-spring-jakarta`, `sentry-spring-7`) has an + `integrationTest` suite that starts the app in an embedded Tomcat, the way a WAR starts. Spring + Boot stays off its classpath, because `SentrySpringServletContainerInitializer` behaves + differently with it. + +## The OpenTelemetry agent + +The agent must not run in the test's JVM, because it would instrument the test's own HTTP client +and server. The Spring Boot modules have an `agentFixture` source set with the app. `AgentApp` +starts it once per test JVM in a child process, with `ChildJvmApp` and the agent jar passed through +`ChildJvmTestArguments`. The suite's task runs with the agent initializing Sentry, and +`integrationTestOtelAgentWithoutAutoInit` runs the same tests with the Spring Boot starter doing +it. Gradle runs the two in parallel. + +## Running them + +- The `spring-boot-{2,3,4}-matrix.yml` workflows run the suites against each supported Spring Boot + version, by setting the version in `gradle/libs.versions.toml`. The suites aren't part of + `check`, so a new suite only runs on CI once it's added to its matrix. + +## Gotchas + +- Don't use the context's `TestRestTemplate`. It comes from the `RestTemplateBuilder` that Sentry + customizes, and that the OpenTelemetry starter instruments, so it adds or replaces trace headers + on the requests a test sends. Build one outside the context. +- Sentry exports OpenTelemetry spans in batches every 5 seconds. In-process, flush the tracer + provider from the server's hook. With the agent this isn't possible, so each test waits for a + batch and negative checks wait at least 6 seconds. +- Sentry is global, so a second live Spring context in an OpenTelemetry suite takes it over. Keep + one live context per JVM (`@DirtiesContext` when a test needs its own). +- The async profiler rejects a traces path with a `.` anywhere in its absolute form. Put the path + in `java.io.tmpdir`. +- Kafka uses an in-JVM broker (`@EmbeddedKafka` or spring-kafka-test's broker classes), so no + Docker is needed. diff --git a/.cursor/rules/new_module.mdc b/.cursor/rules/new_module.mdc index d499acf03a0..b9fdde3df6a 100644 --- a/.cursor/rules/new_module.mdc +++ b/.cursor/rules/new_module.mdc @@ -69,8 +69,8 @@ static { addIntegrationToSdkVersion("{IntegrationName}"); ``` -7. If adding a JVM sample, add E2E (system) tests, following the structure we have in the existing JVM examples. - The test should then be added to `test/system-test-runner.py` and `.github/workflows/system-tests-backend.yml`. +7. Samples have no tests. Add end-to-end coverage of what the sample shows to the module it uses, + against `MockSentryServer`, as described in the `e2e_tests` rule. `sentry-bom` and the root `build.gradle.kts` need no change — they iterate over subprojects. diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 00fe6dc8a7e..25c6b9fbf2b 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -36,7 +36,7 @@ jobs: cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} - name: Initialize CodeQL - uses: github/codeql-action/init@6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee # pin@v2 + uses: github/codeql-action/init@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # pin@v2 with: languages: 'java' @@ -45,4 +45,4 @@ jobs: ./gradlew buildForCodeQL --no-build-cache - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee # pin@v2 + uses: github/codeql-action/analyze@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # pin@v2 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ce434f67c40..8a415025fae 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -34,7 +34,7 @@ jobs: fetch-depth: 0 submodules: 'recursive' - name: Prepare release - uses: getsentry/craft@7a7857552e2cdc08b502de96e6076849c01fba32 # v2.35.0 + uses: getsentry/craft@924b450ede4af30e30de4821a4b357d676c8df92 # v3 env: GITHUB_TOKEN: ${{ steps.token.outputs.token }} with: diff --git a/.github/workflows/spring-boot-2-matrix.yml b/.github/workflows/spring-boot-2-matrix.yml index 8a723dd994d..edfe9e74fc0 100644 --- a/.github/workflows/spring-boot-2-matrix.yml +++ b/.github/workflows/spring-boot-2-matrix.yml @@ -25,7 +25,6 @@ jobs: name: Spring Boot ${{ matrix.springboot-version }} env: - SENTRY_URL: http://127.0.0.1:8000 GRADLE_ENCRYPTION_KEY: ${{ secrets.GRADLE_ENCRYPTION_KEY }} steps: @@ -34,16 +33,6 @@ jobs: with: submodules: 'recursive' - - name: Set up Python - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 - with: - python-version: '3.10' - - - name: Install Python dependencies - run: | - python3 -m pip install --upgrade pip - python3 -m pip install -r requirements.txt - - name: Set up Java uses: actions/setup-java@b843ebf9b913146b38da2eaa7aeddf76074ae1ad # v6.1.0 with: @@ -65,69 +54,18 @@ jobs: - name: Update Spring Boot 2.x version run: | springboot_version="${{ matrix.springboot-version }}" - if [[ ! "$springboot_version" =~ ^2\.7\. ]]; then - echo "ORG_GRADLE_PROJECT_excludeGraphql=true" >> "$GITHUB_ENV" - echo "ORG_GRADLE_PROJECT_excludeKafka=true" >> "$GITHUB_ENV" - fi perl -0pi -e 'BEGIN { $v = shift } s/^springboot2[[:space:]]*=[[:space:]]*"\K[^"]*/$v/m or die "::error::springboot2 version entry not found in gradle/libs.versions.toml\n"' "$springboot_version" gradle/libs.versions.toml echo "Updated Spring Boot 2.x version to $springboot_version" - - name: Build sample artifacts + - name: Test integration suites run: | ./gradlew \ - :sentry-samples:sentry-samples-spring-boot:shadowJar \ - :sentry-samples:sentry-samples-spring-boot:testClasses \ - :sentry-samples:sentry-samples-spring-boot-webflux:shadowJar \ - :sentry-samples:sentry-samples-spring-boot-webflux:testClasses \ - :sentry-samples:sentry-samples-spring-boot-opentelemetry:shadowJar \ - :sentry-samples:sentry-samples-spring-boot-opentelemetry:testClasses \ - :sentry-samples:sentry-samples-spring-boot-opentelemetry-noagent:shadowJar \ - :sentry-samples:sentry-samples-spring-boot-opentelemetry-noagent:testClasses \ - :sentry-samples:sentry-samples-spring:war \ - :sentry-samples:sentry-samples-spring:testClasses \ - :sentry-opentelemetry:sentry-opentelemetry-agent:assemble - - - name: Test sentry-samples-spring-boot - run: | - python3 test/system-test-runner.py test \ - --module "sentry-samples-spring-boot" \ - --agent false \ - --auto-init "true" - - - name: Test sentry-samples-spring-boot-webflux - run: | - python3 test/system-test-runner.py test \ - --module "sentry-samples-spring-boot-webflux" \ - --agent false \ - --auto-init "true" - - - name: Test sentry-samples-spring-boot-opentelemetry agent init true - run: | - python3 test/system-test-runner.py test \ - --module "sentry-samples-spring-boot-opentelemetry" \ - --agent true \ - --auto-init "true" - - - name: Test sentry-samples-spring-boot-opentelemetry agent init false - run: | - python3 test/system-test-runner.py test \ - --module "sentry-samples-spring-boot-opentelemetry" \ - --agent true \ - --auto-init "false" - - - name: Test sentry-samples-spring-boot-opentelemetry-noagent - run: | - python3 test/system-test-runner.py test \ - --module "sentry-samples-spring-boot-opentelemetry-noagent" \ - --agent false \ - --auto-init "true" - - - name: Test sentry-samples-spring - run: | - python3 test/system-test-runner.py test \ - --module "sentry-samples-spring" \ - --agent false \ - --auto-init "true" + :sentry-spring:integrationTest \ + :sentry-spring-boot:integrationTest \ + :sentry-spring-boot:integrationTestWebflux \ + :sentry-spring-boot:integrationTestOtelNoAgent \ + :sentry-spring-boot:integrationTestOtelAgent \ + :sentry-spring-boot:integrationTestOtelAgentWithoutAutoInit - name: Upload test results if: always() @@ -137,8 +75,6 @@ jobs: path: | **/build/reports/* **/build/test-results/**/*.xml - sentry-mock-server.txt - spring-server.txt - name: Test Report uses: phoenix-actions/test-reporting@7317eea6e13c47348dd0bb318669485157c518d6 # pin@v16 diff --git a/.github/workflows/spring-boot-3-matrix.yml b/.github/workflows/spring-boot-3-matrix.yml index 33e06c49b50..f698683cdc6 100644 --- a/.github/workflows/spring-boot-3-matrix.yml +++ b/.github/workflows/spring-boot-3-matrix.yml @@ -25,7 +25,6 @@ jobs: name: Spring Boot ${{ matrix.springboot-version }} env: - SENTRY_URL: http://127.0.0.1:8000 GRADLE_ENCRYPTION_KEY: ${{ secrets.GRADLE_ENCRYPTION_KEY }} steps: @@ -34,16 +33,6 @@ jobs: with: submodules: 'recursive' - - name: Set up Python - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 - with: - python-version: '3.10' - - - name: Install Python dependencies - run: | - python3 -m pip install --upgrade pip - python3 -m pip install -r requirements.txt - - name: Set up Java uses: actions/setup-java@b843ebf9b913146b38da2eaa7aeddf76074ae1ad # v6.1.0 with: @@ -68,62 +57,16 @@ jobs: perl -0pi -e 'BEGIN { $v = shift } s/^springboot3[[:space:]]*=[[:space:]]*"\K[^"]*/$v/m or die "::error::springboot3 version entry not found in gradle/libs.versions.toml\n"' "$springboot_version" gradle/libs.versions.toml echo "Updated Spring Boot 3.x version to $springboot_version" - - name: Build sample artifacts + - name: Test integration suites run: | ./gradlew \ - :sentry-samples:sentry-samples-spring-boot-jakarta:bootJar \ - :sentry-samples:sentry-samples-spring-boot-jakarta:testClasses \ - :sentry-samples:sentry-samples-spring-boot-webflux-jakarta:bootJar \ - :sentry-samples:sentry-samples-spring-boot-webflux-jakarta:testClasses \ - :sentry-samples:sentry-samples-spring-boot-jakarta-opentelemetry:bootJar \ - :sentry-samples:sentry-samples-spring-boot-jakarta-opentelemetry:testClasses \ - :sentry-samples:sentry-samples-spring-boot-jakarta-opentelemetry-noagent:bootJar \ - :sentry-samples:sentry-samples-spring-boot-jakarta-opentelemetry-noagent:testClasses \ - :sentry-samples:sentry-samples-spring-jakarta:war \ - :sentry-samples:sentry-samples-spring-jakarta:testClasses \ - :sentry-opentelemetry:sentry-opentelemetry-agent:assemble - - - name: Test sentry-samples-spring-boot-jakarta - run: | - python3 test/system-test-runner.py test \ - --module "sentry-samples-spring-boot-jakarta" \ - --agent false \ - --auto-init "true" - - - name: Test sentry-samples-spring-boot-webflux-jakarta - run: | - python3 test/system-test-runner.py test \ - --module "sentry-samples-spring-boot-webflux-jakarta" \ - --agent false \ - --auto-init "true" - - - name: Test sentry-samples-spring-boot-jakarta-opentelemetry agent init true - run: | - python3 test/system-test-runner.py test \ - --module "sentry-samples-spring-boot-jakarta-opentelemetry" \ - --agent true \ - --auto-init "true" - - - name: Test sentry-samples-spring-boot-jakarta-opentelemetry agent init false - run: | - python3 test/system-test-runner.py test \ - --module "sentry-samples-spring-boot-jakarta-opentelemetry" \ - --agent true \ - --auto-init "false" - - - name: Test sentry-samples-spring-boot-jakarta-opentelemetry-noagent - run: | - python3 test/system-test-runner.py test \ - --module "sentry-samples-spring-boot-jakarta-opentelemetry-noagent" \ - --agent false \ - --auto-init "true" - - - name: Test sentry-samples-spring-jakarta - run: | - python3 test/system-test-runner.py test \ - --module "sentry-samples-spring-jakarta" \ - --agent false \ - --auto-init "true" + :sentry-spring-jakarta:integrationTest \ + :sentry-spring-boot-jakarta:integrationTest \ + :sentry-spring-boot-jakarta:integrationTestWebflux \ + :sentry-spring-boot-jakarta:integrationTestOtelNoAgent \ + :sentry-spring-boot-jakarta:integrationTestLog4j2 \ + :sentry-spring-boot-jakarta:integrationTestOtelAgent \ + :sentry-spring-boot-jakarta:integrationTestOtelAgentWithoutAutoInit - name: Upload test results if: always() @@ -133,8 +76,6 @@ jobs: path: | **/build/reports/* **/build/test-results/**/*.xml - sentry-mock-server.txt - spring-server.txt - name: Test Report uses: phoenix-actions/test-reporting@7317eea6e13c47348dd0bb318669485157c518d6 # pin@v16 diff --git a/.github/workflows/spring-boot-4-matrix.yml b/.github/workflows/spring-boot-4-matrix.yml index a960e00082e..8f91289db98 100644 --- a/.github/workflows/spring-boot-4-matrix.yml +++ b/.github/workflows/spring-boot-4-matrix.yml @@ -25,7 +25,6 @@ jobs: name: Spring Boot ${{ matrix.springboot-version }} env: - SENTRY_URL: http://127.0.0.1:8000 GRADLE_ENCRYPTION_KEY: ${{ secrets.GRADLE_ENCRYPTION_KEY }} steps: @@ -34,16 +33,6 @@ jobs: with: submodules: 'recursive' - - name: Set up Python - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 - with: - python-version: '3.10' - - - name: Install Python dependencies - run: | - python3 -m pip install --upgrade pip - python3 -m pip install -r requirements.txt - - name: Set up Java uses: actions/setup-java@b843ebf9b913146b38da2eaa7aeddf76074ae1ad # v6.1.0 with: @@ -68,62 +57,17 @@ jobs: perl -0pi -e 'BEGIN { $v = shift } s/^springboot4[[:space:]]*=[[:space:]]*"\K[^"]*/$v/m or die "::error::springboot4 version entry not found in gradle/libs.versions.toml\n"' "$springboot_version" gradle/libs.versions.toml echo "Updated Spring Boot 4.x version to $springboot_version" - - name: Build sample artifacts + - name: Test integration suites run: | ./gradlew \ - :sentry-samples:sentry-samples-spring-boot-4:bootJar \ - :sentry-samples:sentry-samples-spring-boot-4:testClasses \ - :sentry-samples:sentry-samples-spring-boot-4-webflux:bootJar \ - :sentry-samples:sentry-samples-spring-boot-4-webflux:testClasses \ - :sentry-samples:sentry-samples-spring-boot-4-opentelemetry:bootJar \ - :sentry-samples:sentry-samples-spring-boot-4-opentelemetry:testClasses \ - :sentry-samples:sentry-samples-spring-boot-4-opentelemetry-noagent:bootJar \ - :sentry-samples:sentry-samples-spring-boot-4-opentelemetry-noagent:testClasses \ - :sentry-samples:sentry-samples-spring-7:war \ - :sentry-samples:sentry-samples-spring-7:testClasses \ - :sentry-opentelemetry:sentry-opentelemetry-agent:assemble - - - name: Run sentry-samples-spring-boot-4 - run: | - python3 test/system-test-runner.py test \ - --module "sentry-samples-spring-boot-4" \ - --agent false \ - --auto-init "true" - - - name: Run sentry-samples-spring-boot-4-webflux - run: | - python3 test/system-test-runner.py test \ - --module "sentry-samples-spring-boot-4-webflux" \ - --agent false \ - --auto-init "true" - - - name: Run sentry-samples-spring-boot-4-opentelemetry agent init true - run: | - python3 test/system-test-runner.py test \ - --module "sentry-samples-spring-boot-4-opentelemetry" \ - --agent true \ - --auto-init "true" - - - name: Run sentry-samples-spring-boot-4-opentelemetry agent init false - run: | - python3 test/system-test-runner.py test \ - --module "sentry-samples-spring-boot-4-opentelemetry" \ - --agent true \ - --auto-init "false" - - - name: Run sentry-samples-spring-boot-4-opentelemetry-noagent - run: | - python3 test/system-test-runner.py test \ - --module "sentry-samples-spring-boot-4-opentelemetry-noagent" \ - --agent false \ - --auto-init "true" - - - name: Run sentry-samples-spring-7 - run: | - python3 test/system-test-runner.py test \ - --module "sentry-samples-spring-7" \ - --agent false \ - --auto-init "true" + :sentry-spring-7:integrationTest \ + :sentry-spring-boot-4:integrationTest \ + :sentry-spring-boot-4:integrationTestWebflux \ + :sentry-spring-boot-4:integrationTestOtelNoAgent \ + :sentry-spring-boot-4:integrationTestLog4j2 \ + :sentry-spring-boot-4:integrationTestOtelAgent \ + :sentry-spring-boot-4:integrationTestOtelAgentWithoutAutoInit \ + :sentry-spring-boot-4:integrationTestOtlp - name: Upload test results if: always() @@ -133,8 +77,6 @@ jobs: path: | **/build/reports/* **/build/test-results/**/*.xml - sentry-mock-server.txt - spring-server.txt - name: Test Report uses: phoenix-actions/test-reporting@7317eea6e13c47348dd0bb318669485157c518d6 # pin@v16 diff --git a/.github/workflows/system-tests-backend.yml b/.github/workflows/system-tests-backend.yml deleted file mode 100644 index 2165a6c6a67..00000000000 --- a/.github/workflows/system-tests-backend.yml +++ /dev/null @@ -1,154 +0,0 @@ -name: 'System Tests Backend' - -on: - push: - branches: - - main - pull_request: - paths-ignore: - - '*android*/**' - - 'sentry-compose/**' - - 'sentry-samples/sentry-samples-android/**' - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -jobs: - system-test: - runs-on: ubuntu-latest - continue-on-error: true - env: - SENTRY_URL: http://127.0.0.1:8000 - GRADLE_ENCRYPTION_KEY: ${{ secrets.GRADLE_ENCRYPTION_KEY }} - strategy: - fail-fast: false - matrix: - sample: [ "sentry-samples-spring-boot-jakarta" ] - agent: [ "false" ] - agent-auto-init: [ "true" ] - include: - - sample: "sentry-samples-spring-boot" - agent: "false" - agent-auto-init: "true" - - sample: "sentry-samples-spring-boot-opentelemetry-noagent" - agent: "false" - agent-auto-init: "true" - - sample: "sentry-samples-spring-boot-opentelemetry" - agent: "true" - agent-auto-init: "true" - - sample: "sentry-samples-spring-boot-opentelemetry" - agent: "true" - agent-auto-init: "false" - - sample: "sentry-samples-spring-boot-webflux-jakarta" - agent: "false" - agent-auto-init: "true" - - sample: "sentry-samples-spring-boot-webflux" - agent: "false" - agent-auto-init: "true" - - sample: "sentry-samples-spring-boot-jakarta-log4j2" - agent: "false" - agent-auto-init: "true" - - sample: "sentry-samples-spring-boot-jakarta-opentelemetry-noagent" - agent: "false" - agent-auto-init: "true" - - sample: "sentry-samples-spring-boot-jakarta-opentelemetry" - agent: "true" - agent-auto-init: "true" - - sample: "sentry-samples-spring-boot-jakarta-opentelemetry" - agent: "true" - agent-auto-init: "false" - - sample: "sentry-samples-console" - agent: "false" - agent-auto-init: "true" - - sample: "sentry-samples-console-otlp" - agent: "false" - agent-auto-init: "true" - - sample: "sentry-samples-logback" - agent: "false" - agent-auto-init: "true" - - sample: "sentry-samples-log4j2" - agent: "false" - agent-auto-init: "true" - - sample: "sentry-samples-jul" - agent: "false" - agent-auto-init: "true" - - sample: "sentry-samples-spring-boot-4" - agent: "false" - agent-auto-init: "true" - - sample: "sentry-samples-spring-boot-4-log4j2" - agent: "false" - agent-auto-init: "true" - - sample: "sentry-samples-spring-boot-4-webflux" - agent: "false" - agent-auto-init: "true" - - sample: "sentry-samples-spring-boot-4-opentelemetry-noagent" - agent: "false" - agent-auto-init: "true" - - sample: "sentry-samples-spring-boot-4-opentelemetry" - agent: "true" - agent-auto-init: "true" - - sample: "sentry-samples-spring-boot-4-opentelemetry" - agent: "true" - agent-auto-init: "false" - - sample: "sentry-samples-spring-boot-4-otlp" - agent: "false" - agent-auto-init: "true" - - sample: "sentry-samples-spring-7" - agent: "false" - agent-auto-init: "true" - - sample: "sentry-samples-spring-jakarta" - agent: "false" - agent-auto-init: "true" - - sample: "sentry-samples-spring" - agent: "false" - agent-auto-init: "true" - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - submodules: 'recursive' - - - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 - with: - python-version: '3.10' - - - name: Install Python dependencies - run: | - python3 -m pip install --upgrade pip - python3 -m pip install -r requirements.txt - - - name: Set up Java - uses: actions/setup-java@b843ebf9b913146b38da2eaa7aeddf76074ae1ad # v6.1.0 - with: - distribution: 'temurin' - java-version: '21' - - - name: Setup Gradle - uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0 - with: - cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} - - - name: Build and run system tests - run: | - python3 test/system-test-runner.py test --module "${{ matrix.sample }}" --agent "${{ matrix.agent }}" --auto-init "${{ matrix.agent-auto-init }}" --build "true" - - - name: Upload test results - if: always() - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 - with: - name: test-results-${{ matrix.sample }}-${{ matrix.agent }}-${{ matrix.agent-auto-init }}-system-test - path: | - **/build/reports/* - sentry-mock-server.txt - spring-server.txt - - - name: Test Report - uses: phoenix-actions/test-reporting@7317eea6e13c47348dd0bb318669485157c518d6 # pin@v16 - if: always() - with: - name: JUnit System Tests ${{ matrix.sample }} - path: | - **/build/test-results/**/*.xml - reporter: java-junit - output-to: step-summary - fail-on-error: false diff --git a/AGENTS.md b/AGENTS.md index 627033d499d..46668617274 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,7 +20,7 @@ rule file in `.cursor/rules/`: | `continuous_profiling_jvm` | `sentry-async-profiler`, `IContinuousProfiler`, `ProfileChunk`, JFR files, `ProfileLifecycle` | | `opentelemetry` | `sentry-opentelemetry-*`, agent vs agentless, span processing, sampling, context propagation | | `new_module` | Adding a new integration or sample module | -| `e2e_tests` | System tests, sample applications, `system-test-runner.py`, mock Sentry server | +| `e2e_tests` | End-to-end tests, `MockSentryServer`, integration test suites, the OpenTelemetry agent fixture | Rules can be combined — a tracing scope issue may need both `scopes` and `opentelemetry`. There is no rule for Android profiling yet; read the `sentry-android-core` profiling code @@ -55,7 +55,7 @@ The project uses **Gradle** with Kotlin DSL. Key build files: ``` To run tests, use the `test` skill rather than composing the Gradle invocation by hand — it -resolves the per-module test task and the unit-test vs system-test split for you. +resolves the per-module test task, including integration test suites, for you. ### Android-Specific Commands ```bash @@ -109,8 +109,7 @@ The repository is organized into multiple modules: - **Other**: `sentry-spotlight`, `sentry-kotlin-extensions`, `sentry-android-distribution` ### Utility Modules -- **`sentry-test-support`** - Shared test utilities -- **`sentry-system-test-support`** - System testing infrastructure +- **`sentry-test-support`** - Shared test utilities, including `MockSentryServer` and envelope matchers - **`sentry-samples`** - Example applications - **`sentry-bom`** - Bill of Materials for dependency management @@ -158,7 +157,7 @@ PR description why the broad catch is necessary. ### Testing Requirements - Write comprehensive unit tests for new features - Android modules require both unit tests and instrumented tests where applicable -- System tests validate end-to-end functionality with sample applications +- End-to-end tests run in the module they test, against `MockSentryServer`. Samples have no tests - **Assertions**: For new unit tests, prefer [Google Truth](https://truth.dev/) (`com.google.common.truth.Truth.assertThat`) over `kotlin.test`/JUnit assertions for its readable, fluent API. Keep using `kotlin.test` for test structure (`@Test`, `assertFailsWith`). See `sentry/src/test/java/io/sentry/DsnTest.kt` for the style. Don't rewrite existing `kotlin.test` assertions solely to switch libraries. - Truth is wired into the `sentry` module. When adding Truth-based tests to another module, add `testImplementation(libs.google.truth)` to that module's `build.gradle.kts`. diff --git a/CHANGELOG.md b/CHANGELOG.md index 4db4ef4c060..bed15fd55c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,15 +10,67 @@ - `Sentry.showUserFeedbackDialog(...)`: use `Sentry.feedback().show(...)` instead - Android: `SentryUserFeedbackDialog`: use `SentryUserFeedbackForm` instead - Android: the `SentryUserFeedbackButton` view and composable: call `Sentry.feedback().show()` from your own button instead +- Remove the aggregate Sentry Logs and Metrics enable options from all configuration surfaces: + - Java API: `options.getLogs().setEnabled(...)` and `options.getMetrics().setEnabled(...)` + - Properties files: `logs.enabled` and `metrics.enabled` + - Environment variables: `SENTRY_LOGS_ENABLED` and `SENTRY_METRICS_ENABLED` + - JVM system properties and Spring Boot: `sentry.logs.enabled` and `sentry.metrics.enabled` + - Android manifest metadata: `io.sentry.logs.enabled` and `io.sentry.metrics.enabled` + + The Java accessors are removed. Values supplied through the other configuration surfaces no + longer affect capture. + ([#5939](https://github.com/getsentry/sentry-java/pull/5939), + [#5947](https://github.com/getsentry/sentry-java/pull/5947), + [#5953](https://github.com/getsentry/sentry-java/pull/5953)) + - **Automatic Logs capture:** Disabled by default. Enable it separately for each integration: + - Spring Boot logging auto-configuration: `sentry.logging.logs-enabled` + ([#5946](https://github.com/getsentry/sentry-java/pull/5946)) + - Logback appender: `logsEnabled` ([#5940](https://github.com/getsentry/sentry-java/pull/5940)) + - Log4j2 appender: `logsEnabled` ([#5941](https://github.com/getsentry/sentry-java/pull/5941)) + - JUL handler: `logsEnabled` or `io.sentry.jul.SentryHandler.logsEnabled` + ([#5942](https://github.com/getsentry/sentry-java/pull/5942)) + - Auto-installed Timber integration: `timberLogCaptureEnabled` or + `io.sentry.timber.log-capture.enabled` manifest metadata + ([#5943](https://github.com/getsentry/sentry-java/pull/5943)) + - Manually installed Timber integration or tree: `logsEnabled` + ([#5943](https://github.com/getsentry/sentry-java/pull/5943)) + - Logcat: `logcatLogCaptureEnabled` or `io.sentry.logcat.log-capture.enabled` manifest metadata + ([#5945](https://github.com/getsentry/sentry-java/pull/5945)) + - **Constructor migrations:** + - The remaining Log4j2 `SentryAppender` constructor and `createAppender` factory require a + `logsEnabled` argument. The deprecated constructor without `minimumLevel` is removed. + - Manually constructed `SentryTimberIntegration` and `SentryTimberTree` instances require a + `logsEnabled` argument. Pass `false` to keep Sentry Logs disabled. +- Remove the Apache HttpClient transport and stop publishing `sentry-apache-http-client-5` ([#6214](https://github.com/getsentry/sentry-java/pull/6214)) +- Decide whether foregrounding the app starts a new session on a monotonic clock instead of the wall clock, so that a device time change no longer starts a session that should have been resumed, or resumes one that should have ended. This affects session counts ([#6096](https://github.com/getsentry/sentry-java/pull/6096)) + +## 8.62.0 + +### Improvements + +- Support Navigation 3 integration for multi-Activity and multi-Fragment applications, where each Activity or Fragment has its own `SentryNavEffect` ([#6305](https://github.com/getsentry/sentry-java/pull/6305)) + - See the [Navigation for Android docs](https://docs.sentry.io/platforms/android/integrations/navigation/#supported-setups) for a detailed list of supported vs unsupported app setups. ### Fixes +- Fix a D8 crash (`NullPointerException: Cannot invoke "String.length()"`) when dexing the SDK with AGP 8.0 and older, a regression from 8.61.1 ([#6356](https://github.com/getsentry/sentry-java/pull/6356)) +- Always remove the first-draw listener after the first frame, even if no layout pass follows it ([#6152](https://github.com/getsentry/sentry-java/pull/6152)) +- Prevent Reactor pool maintenance tasks from retaining unbounded Sentry scope chains ([#6283](https://github.com/getsentry/sentry-java/pull/6283)) + +## 8.61.1 + +### Fixes + +- Session Replay: Fix a native crash (`SIGABRT` on the RenderThread) when stopping a replay that uses the canvas screenshot strategy ([#6342](https://github.com/getsentry/sentry-java/pull/6342)) +- Session Replay: Keep the segment videos of a replay that stopped before the startup cleanup of previous replays ran ([#6343](https://github.com/getsentry/sentry-java/pull/6343)) - Report the number of dropped spans in client reports when a batched span envelope item is discarded, instead of a quantity of 1 (e.g. for Flutter span streaming) ([#6265](https://github.com/getsentry/sentry-java/pull/6265)) +- Session Replay: Keep the flushed video and do not reuse its segment id when `stop()` is called right after `flush()` in buffer mode ([#6340](https://github.com/getsentry/sentry-java/pull/6340)) - Keep sending events from Android 7.1.0 and older devices after Sentry changes its TLS certificate authority ([#6227](https://github.com/getsentry/sentry-java/pull/6227)) - In February 2027, Sentry moves its TLS certificates from DigiCert to Let's Encrypt and Google Trust Services ([announcement](https://sentry.io/changelog/were-changing-our-tls-certificate-authority-in-february-2027)). - Android 7.1.0 (API 25) and older don't trust the Let's Encrypt root certificate (ISRG Root X1), so without this fix, the SDK on those devices can no longer send events to Sentry after the switch. - The SDK now bundles ISRG Root X1 and trusts it in addition to the device's root certificates on API 25 and lower. This only applies to the SDK's own uploads to Sentry SaaS (DSNs on `sentry.io`), not to self-hosted Sentry, not to any other connection your app makes, and not if you set your own `SentryOptions.setSslSocketFactory`. - Apps need to update to an SDK version with this fix to keep sending events from these devices. +- Session Replay: Use a software canvas for video encoding on the Android Emulator to avoid a native crash in `eglDestroySurface` ([#6282](https://github.com/getsentry/sentry-java/pull/6282)) ### Internal @@ -64,42 +116,6 @@ ## 8.60.0 -### Breaking Changes - -- Remove the aggregate Sentry Logs and Metrics enable options from all configuration surfaces: - - Java API: `options.getLogs().setEnabled(...)` and `options.getMetrics().setEnabled(...)` - - Properties files: `logs.enabled` and `metrics.enabled` - - Environment variables: `SENTRY_LOGS_ENABLED` and `SENTRY_METRICS_ENABLED` - - JVM system properties and Spring Boot: `sentry.logs.enabled` and `sentry.metrics.enabled` - - Android manifest metadata: `io.sentry.logs.enabled` and `io.sentry.metrics.enabled` - - The Java accessors are removed. Values supplied through the other configuration surfaces no - longer affect capture. - ([#5939](https://github.com/getsentry/sentry-java/pull/5939), - [#5947](https://github.com/getsentry/sentry-java/pull/5947), - [#5953](https://github.com/getsentry/sentry-java/pull/5953)) - - **Automatic Logs capture:** Disabled by default. Enable it separately for each integration: - - Spring Boot logging auto-configuration: `sentry.logging.logs-enabled` - ([#5946](https://github.com/getsentry/sentry-java/pull/5946)) - - Logback appender: `logsEnabled` ([#5940](https://github.com/getsentry/sentry-java/pull/5940)) - - Log4j2 appender: `logsEnabled` ([#5941](https://github.com/getsentry/sentry-java/pull/5941)) - - JUL handler: `logsEnabled` or `io.sentry.jul.SentryHandler.logsEnabled` - ([#5942](https://github.com/getsentry/sentry-java/pull/5942)) - - Auto-installed Timber integration: `timberLogCaptureEnabled` or - `io.sentry.timber.log-capture.enabled` manifest metadata - ([#5943](https://github.com/getsentry/sentry-java/pull/5943)) - - Manually installed Timber integration or tree: `logsEnabled` - ([#5943](https://github.com/getsentry/sentry-java/pull/5943)) - - Logcat: `logcatLogCaptureEnabled` or `io.sentry.logcat.log-capture.enabled` manifest metadata - ([#5945](https://github.com/getsentry/sentry-java/pull/5945)) - - **Constructor migrations:** - - The remaining Log4j2 `SentryAppender` constructor and `createAppender` factory require a - `logsEnabled` argument. The deprecated constructor without `minimumLevel` is removed. - - Manually constructed `SentryTimberIntegration` and `SentryTimberTree` instances require a - `logsEnabled` argument. Pass `false` to keep Sentry Logs disabled. -- Remove the Apache HttpClient transport and stop publishing `sentry-apache-http-client-5` ([#6214](https://github.com/getsentry/sentry-java/pull/6214)) -- Decide whether foregrounding the app starts a new session on a monotonic clock instead of the wall clock, so that a device time change no longer starts a session that should have been resumed, or resumes one that should have ended. This affects session counts ([#6096](https://github.com/getsentry/sentry-java/pull/6096)) - ### Features - Add support for Android Navigation 3 through the new `sentry-android-navigation3` library ([#6233](https://github.com/getsentry/sentry-java/pull/6233)) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8c49840c0b2..79800a6ff2b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -36,9 +36,10 @@ You are welcome to use whatever tools you prefer for making a contribution. Howe # Requirements -The project requires you to run JDK 21 or newer. Compilation and tests always happen on JDK 21 -through a [toolchain](https://docs.gradle.org/current/userguide/toolchains.html), so a newer JDK -runs the build but never changes its output. +The project requires JDK 21. Gradle finds it through +[toolchain auto-detection](https://docs.gradle.org/current/userguide/toolchains.html#sec:auto_detection) +(SDKMAN!, asdf, IntelliJ, ...) and uses it for the build, compilation and tests, whatever JDK +`JAVA_HOME` points to. ## Android diff --git a/Makefile b/Makefile index 3941eb93ec3..7a57ed54e09 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: all clean compile javadocs dryRelease update checkFormat api assembleUiTestRelease assembleUiTestCriticalRelease runUiTestCritical setupPython systemTest systemTestInteractive check preMerge publish +.PHONY: all clean compile javadocs dryRelease update checkFormat api assembleUiTestRelease assembleUiTestCriticalRelease runUiTestCritical check preMerge publish all: stop clean javadocs compile assembleUiTests: assembleUiTestRelease @@ -46,20 +46,6 @@ assembleUiTestCriticalRelease: runUiTestCritical: ./scripts/test-ui-critical.sh -# Create the Python virtual environment for system tests, and install the necessary dependencies -setupPython: - @test -d .venv || python3 -m venv .venv - .venv/bin/pip install --upgrade pip - .venv/bin/pip install -r requirements.txt - -# Run system tests for sample applications -systemTest: setupPython - .venv/bin/python test/system-test-runner.py test --all - -# Run system tests with interactive module selection -systemTestInteractive: setupPython - .venv/bin/python test/system-test-runner.py test --interactive - # Run tests and lint check: ./gradlew check diff --git a/build-logic/build.gradle.kts b/build-logic/build.gradle.kts index d02ab584885..60a340c9b85 100644 --- a/build-logic/build.gradle.kts +++ b/build-logic/build.gradle.kts @@ -5,6 +5,7 @@ plugins { dependencies { implementation(libs.animalsniffer.gradle.plugin) implementation(libs.spotlessLib) + implementation(libs.asm) compileOnly(libs.kotlin.gradle.plugin) compileOnly(libs.android.gradle.plugin) } diff --git a/build-logic/src/main/kotlin/io.sentry.systemtest.gradle.kts b/build-logic/src/main/kotlin/io.sentry.systemtest.gradle.kts deleted file mode 100644 index a21079e1336..00000000000 --- a/build-logic/src/main/kotlin/io.sentry.systemtest.gradle.kts +++ /dev/null @@ -1,38 +0,0 @@ -import io.sentry.gradle.SystemTestExtension -import org.gradle.api.tasks.ClasspathNormalizer - -val systemTest = extensions.create("sentrySystemTest") - -// The sample system tests launch the packaged app (war/shadowJar/bootJar) from build/libs as a -// separate process, so the archive is a real input even though it is not on the test classpath. -// Agent-based samples are additionally launched with -javaagent:, another runtime -// input not on the classpath. See test/system-test-runner.py. -tasks.matching { it.name == "systemTest" }.configureEach { - val archiveTask = - listOf("war", "shadowJar", "bootJar").firstOrNull { it in tasks.names } - ?: throw GradleException( - "io.sentry.systemtest is applied to $path but none of war/shadowJar/bootJar " + - "exist to provide the launched app archive for the systemTest task" - ) - // Declaring the archive as an input also wires the dependency on its producing task. - inputs - .files(tasks.named(archiveTask)) - .withPropertyName("appArchive") - .withNormalizer(ClasspathNormalizer::class.java) - - if (systemTest.usesOpenTelemetryAgent.get()) { - // The runner builds the agent and launches the app with -javaagent before invoking this task, - // so the agent jar is tracked for content only (by path, no cross-project task dependency): a - // change to it makes systemTest out of date even though it runs outside the test JVM. - val version = providers.gradleProperty("versionName").get() - inputs - .files( - rootProject.layout.projectDirectory.file( - "sentry-opentelemetry/sentry-opentelemetry-agent/build/libs/" + - "sentry-opentelemetry-agent-$version.jar" - ) - ) - .withPropertyName("openTelemetryAgent") - .withNormalizer(ClasspathNormalizer::class.java) - } -} diff --git a/build-logic/src/main/kotlin/io/sentry/gradle/ChildJvmTestArguments.kt b/build-logic/src/main/kotlin/io/sentry/gradle/ChildJvmTestArguments.kt new file mode 100644 index 00000000000..84335b938f5 --- /dev/null +++ b/build-logic/src/main/kotlin/io/sentry/gradle/ChildJvmTestArguments.kt @@ -0,0 +1,30 @@ +package io.sentry.gradle + +import org.gradle.api.file.ConfigurableFileCollection +import org.gradle.api.tasks.Classpath +import org.gradle.api.tasks.InputFiles +import org.gradle.api.tasks.PathSensitive +import org.gradle.api.tasks.PathSensitivity +import org.gradle.process.CommandLineArgumentProvider + +/** + * Tells a test task's JVM where to find the built OpenTelemetry agent and the app to run with it. + * + * The test launches the app in a child JVM, so neither file is on the test classpath. Declaring + * them here makes them task inputs: the test reruns when either changes and depends on the tasks + * that build them. + */ +abstract class ChildJvmTestArguments : CommandLineArgumentProvider { + @get:InputFiles + @get:PathSensitive(PathSensitivity.NONE) + abstract val agentJar: ConfigurableFileCollection + + @get:Classpath abstract val appClasspath: ConfigurableFileCollection + + override fun asArguments(): List = buildList { + add("-Dsentry.test.agentJar=${agentJar.singleFile.absolutePath}") + if (!appClasspath.isEmpty) { + add("-Dsentry.test.appClasspath=${appClasspath.asPath}") + } + } +} diff --git a/build-logic/src/main/kotlin/io/sentry/gradle/JdkTargetConvention.kt b/build-logic/src/main/kotlin/io/sentry/gradle/JdkTargetConvention.kt index c92c83bbac2..ca2999f878a 100644 --- a/build-logic/src/main/kotlin/io/sentry/gradle/JdkTargetConvention.kt +++ b/build-logic/src/main/kotlin/io/sentry/gradle/JdkTargetConvention.kt @@ -1,5 +1,7 @@ package io.sentry.gradle +import com.android.build.api.artifact.SingleArtifact +import com.android.build.api.variant.LibraryAndroidComponentsExtension import com.android.build.gradle.BaseExtension import org.gradle.api.JavaVersion import org.gradle.api.Plugin @@ -27,6 +29,8 @@ private val PUBLISHED_JAVA_VERSION = JavaVersion.VERSION_1_8 * - sample apps target the build JDK (21) * - unit test code will target the build JDK (21) (required for Mockito 5) * - published JVM modules main code pins the Java 1.8 release, for Java and for Kotlin + * - published modules Java code drops the MethodParameters attribute javac 21 adds, and their + * archives are checked for it * - published Android modules main code targets Java 1.8 * - minSdk is set for Android libraries */ @@ -39,6 +43,30 @@ class JdkTargetConvention : Plugin { } project.plugins.withId("com.vanniktech.maven.publish") { + // Published Java classes must stay dexable by older D8 versions, see StripMethodParameters + project.tasks.withType(JavaCompile::class.java).configureEach { + doLast(StripMethodParameters()) + } + val verifyMethodParameters = + project.tasks.register("verifyMethodParameters", VerifyMethodParameters::class.java) { + description = "Checks the published archives for MethodParameters entries without a name" + group = "verification" + report.set(project.layout.buildDirectory.file("reports/method-parameters.txt")) + } + project.tasks.matching { it.name == "check" }.configureEach { + dependsOn(verifyMethodParameters) + } + project.plugins.withId("java-library") { + verifyMethodParameters.configure { archives.from(project.tasks.named("jar")) } + } + project.plugins.withId("com.android.library") { + project.extensions.getByType(LibraryAndroidComponentsExtension::class.java).onVariants { + variant -> + verifyMethodParameters.configure { + archives.from(variant.artifacts.get(SingleArtifact.AAR)) + } + } + } // Only works for non-android modules // See https://issuetracker.google.com/issues/278800528 project.plugins.withId("java-library") { diff --git a/build-logic/src/main/kotlin/io/sentry/gradle/MethodParameters.kt b/build-logic/src/main/kotlin/io/sentry/gradle/MethodParameters.kt new file mode 100644 index 00000000000..49662c59c71 --- /dev/null +++ b/build-logic/src/main/kotlin/io/sentry/gradle/MethodParameters.kt @@ -0,0 +1,35 @@ +package io.sentry.gradle + +import org.objectweb.asm.ClassReader +import org.objectweb.asm.ClassVisitor +import org.objectweb.asm.MethodVisitor +import org.objectweb.asm.Opcodes + +/** + * Returns `name + descriptor` of every method of the class that has a MethodParameters entry + * without a name. Only those entries make the D8 of AGP 8.0 and older fail, see + * [StripMethodParameters]. + */ +internal fun ClassReader.methodsWithUnnamedParameters(): Set { + val methods = mutableSetOf() + val scanner = + object : ClassVisitor(Opcodes.ASM9) { + override fun visitMethod( + access: Int, + name: String?, + descriptor: String?, + signature: String?, + exceptions: Array?, + ): MethodVisitor = + object : MethodVisitor(Opcodes.ASM9) { + override fun visitParameter(parameterName: String?, access: Int) { + if (parameterName == null) { + methods.add(name + descriptor) + } + } + } + } + // no SKIP_DEBUG here, ASM counts MethodParameters as debug info and then skips it + accept(scanner, ClassReader.SKIP_CODE) + return methods +} diff --git a/build-logic/src/main/kotlin/io/sentry/gradle/StripMethodParameters.kt b/build-logic/src/main/kotlin/io/sentry/gradle/StripMethodParameters.kt new file mode 100644 index 00000000000..94460086a97 --- /dev/null +++ b/build-logic/src/main/kotlin/io/sentry/gradle/StripMethodParameters.kt @@ -0,0 +1,66 @@ +package io.sentry.gradle + +import org.gradle.api.Action +import org.gradle.api.Task +import org.gradle.api.tasks.compile.JavaCompile +import org.objectweb.asm.ClassReader +import org.objectweb.asm.ClassVisitor +import org.objectweb.asm.ClassWriter +import org.objectweb.asm.MethodVisitor +import org.objectweb.asm.Opcodes + +/** + * Removes the + * [MethodParameters](https://docs.oracle.com/javase/specs/jvms/se8/html/jvms-4.html#jvms-4.7.24) + * attribute from the methods of a JavaCompile task that have a parameter without a name. + * + * Since JDK 21 (JDK-8292275) javac writes MethodParameters for every method with synthetic or + * mandated parameters, e.g. bridge methods, even without `-parameters`. Those entries carry flags + * but no name, and the D8 of AGP 8.0 and older fails the build while dexing. See the + * [R8 fix](https://r8.googlesource.com/r8/+/9da58a26e22f195d8187492fac350a90e3315e92) for more + * details. An alternative would be adding the `-parameters` compile flag, but this could lead to + * other binary incompatibility issues along the road. + * + * `parameters_count` must match the parameters of the method descriptor, so a single entry cannot + * be removed. The attribute goes away for the whole method instead, as soon as one of its entries + * has no name. Methods whose entries all have a name, e.g. the canonical constructor of a record + * or anything compiled with `-parameters`, keep the attribute and their names. + */ +internal class StripMethodParameters : Action { + override fun execute(task: Task) { + val classesDir = (task as JavaCompile).destinationDirectory.get().asFile + classesDir + .walkTopDown() + .filter { it.isFile && it.extension == "class" } + .forEach { file -> + val reader = ClassReader(file.readBytes()) + val methods = reader.methodsWithUnnamedParameters() + if (methods.isNotEmpty()) { + val writer = ClassWriter(reader, 0) + reader.accept(MethodParametersStripper(writer, methods), 0) + file.writeBytes(writer.toByteArray()) + } + } + } + + private class MethodParametersStripper(next: ClassVisitor, private val methods: Set) : + ClassVisitor(Opcodes.ASM9, next) { + + override fun visitMethod( + access: Int, + name: String?, + descriptor: String?, + signature: String?, + exceptions: Array?, + ): MethodVisitor? { + val next = super.visitMethod(access, name, descriptor, signature, exceptions) + // every other method keeps the byte for byte copy ASM does for an untouched method + if (name + descriptor !in methods) { + return next + } + return object : MethodVisitor(Opcodes.ASM9, next) { + override fun visitParameter(name: String?, access: Int) = Unit + } + } + } +} diff --git a/build-logic/src/main/kotlin/io/sentry/gradle/SystemTestExtension.kt b/build-logic/src/main/kotlin/io/sentry/gradle/SystemTestExtension.kt deleted file mode 100644 index 9111ce17b1f..00000000000 --- a/build-logic/src/main/kotlin/io/sentry/gradle/SystemTestExtension.kt +++ /dev/null @@ -1,17 +0,0 @@ -package io.sentry.gradle - -import org.gradle.api.provider.Property - -/** Configuration for the `io.sentry.systemtest` convention plugin. */ -abstract class SystemTestExtension { - /** - * Set to `true` for samples that the system-test runner launches with the Sentry OpenTelemetry - * Java agent (`-javaagent`). The agent jar is then tracked as a `systemTest` input so the task - * re-runs when the agent changes, even though it is started outside the test JVM. - */ - abstract val usesOpenTelemetryAgent: Property - - init { - usesOpenTelemetryAgent.convention(false) - } -} diff --git a/build-logic/src/main/kotlin/io/sentry/gradle/VerifyMethodParameters.kt b/build-logic/src/main/kotlin/io/sentry/gradle/VerifyMethodParameters.kt new file mode 100644 index 00000000000..48ff0664e8b --- /dev/null +++ b/build-logic/src/main/kotlin/io/sentry/gradle/VerifyMethodParameters.kt @@ -0,0 +1,80 @@ +package io.sentry.gradle + +import java.io.ByteArrayInputStream +import java.util.zip.ZipInputStream +import org.gradle.api.DefaultTask +import org.gradle.api.GradleException +import org.gradle.api.file.ConfigurableFileCollection +import org.gradle.api.file.RegularFileProperty +import org.gradle.api.tasks.CacheableTask +import org.gradle.api.tasks.InputFiles +import org.gradle.api.tasks.OutputFile +import org.gradle.api.tasks.PathSensitive +import org.gradle.api.tasks.PathSensitivity +import org.gradle.api.tasks.TaskAction +import org.objectweb.asm.ClassReader + +/** + * Fails the build if an archive still holds a MethodParameters entry without a name, the entries + * [StripMethodParameters] removes. It reads the final jar and aar of a module, so it also covers + * classes that no JavaCompile task of this build produced, e.g. repackaged ones. + */ +@CacheableTask +abstract class VerifyMethodParameters : DefaultTask() { + + /** The jar and aar files to read. An aar is opened down to its nested jars. */ + @get:InputFiles + @get:PathSensitive(PathSensitivity.RELATIVE) + abstract val archives: ConfigurableFileCollection + + @get:OutputFile abstract val report: RegularFileProperty + + @TaskAction + fun verify() { + val offenders = mutableListOf() + var classes = 0 + archives.files + .filter { it.isFile } + .forEach { archive -> + archive.inputStream().use { stream -> + classes += scan(ZipInputStream(stream), archive.name, offenders) + } + } + + val summary = + if (offenders.isEmpty()) "$classes classes checked, no unnamed MethodParameters entry" + else offenders.joinToString("\n") + val reportFile = report.get().asFile + reportFile.parentFile.mkdirs() + reportFile.writeText(summary + "\n") + + if (offenders.isNotEmpty()) { + throw GradleException( + "${offenders.size} method(s) keep a MethodParameters entry without a name, which makes " + + "the D8 of AGP 8.0 and older fail. See StripMethodParameters.\n$summary" + ) + } + logger.info("verifyMethodParameters: $summary") + } + + /** Adds `archive!/entry method` for every offending method and returns the classes read. */ + private fun scan(zip: ZipInputStream, path: String, offenders: MutableList): Int { + var classes = 0 + while (true) { + val entry = zip.nextEntry ?: break + val name = entry.name + when { + name.endsWith(".class") -> { + val reader = ClassReader(zip.readBytes()) + reader.methodsWithUnnamedParameters().forEach { offenders.add("$path!/$name $it") } + classes++ + } + name.endsWith(".jar") -> { + val nested = ZipInputStream(ByteArrayInputStream(zip.readBytes())) + classes += scan(nested, "$path!/$name", offenders) + } + } + } + return classes + } +} diff --git a/build.gradle.kts b/build.gradle.kts index 898b429cb5d..3ed35d2da82 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -29,10 +29,6 @@ plugins { alias(libs.plugins.sentry) apply false } -check(JavaVersion.current().isCompatibleWith(JavaVersion.VERSION_21)) { - "This build requires JDK 21 or newer, but runs on JDK ${JavaVersion.current()}." -} - buildscript { repositories { google() @@ -87,8 +83,7 @@ apiValidation { "test-app-size", "sentry-samples-netflix-dgs", "sentry-samples-console-otlp", - "sentry-test-support", - "sentry-system-test-support" + "sentry-test-support" ) ) } @@ -126,7 +121,7 @@ subprojects { } } - if (!this.name.contains("sample") && !this.name.contains("integration-tests") && this.name != "sentry-system-test-support" && this.name != "sentry-test-support") { + if (!this.name.contains("sample") && !this.name.contains("integration-tests") && this.name != "sentry-test-support") { apply() apply() diff --git a/devenv/config.ini b/devenv/config.ini index b546b762c0c..904a4143fbb 100644 --- a/devenv/config.ini +++ b/devenv/config.ini @@ -2,15 +2,15 @@ minimum_version = 1.22.1 [uv] -darwin_arm64 = https://github.com/astral-sh/uv/releases/download/0.8.2/uv-aarch64-apple-darwin.tar.gz -darwin_arm64_sha256 = 954d24634d5f37fa26c7af75eb79893d11623fc81b4de4b82d60d1ade4bfca22 -darwin_x86_64 = https://github.com/astral-sh/uv/releases/download/0.8.2/uv-x86_64-apple-darwin.tar.gz -darwin_x86_64_sha256 = ae755df53c8c2c1f3dfbee6e3d2e00be0dfbc9c9b4bdffdb040b96f43678b7ce -linux_arm64 = https://github.com/astral-sh/uv/releases/download/0.8.2/uv-aarch64-unknown-linux-gnu.tar.gz -linux_arm64_sha256 = 27da35ef54e9131c2e305de67dd59a07c19257882c6b1f3cf4d8d5fbb8eaf4ca -linux_x86_64 = https://github.com/astral-sh/uv/releases/download/0.8.2/uv-x86_64-unknown-linux-gnu.tar.gz -linux_x86_64_sha256 = 6dcb28a541868a455aefb2e8d4a1283dd6bf888605a2db710f0530cec888b0ad +darwin_arm64 = https://github.com/astral-sh/uv/releases/download/0.13.5/uv-aarch64-apple-darwin.tar.gz +darwin_arm64_sha256 = 743f85035a1996ecb350659e01cd9faea9709829be09b1b4b03c73eaf3c9cd73 +darwin_x86_64 = https://github.com/astral-sh/uv/releases/download/0.13.5/uv-x86_64-apple-darwin.tar.gz +darwin_x86_64_sha256 = b0f52b0e3945b4107d89024ea38e625683011fbfc05a0d98f9fa2d6dcee28c66 +linux_arm64 = https://github.com/astral-sh/uv/releases/download/0.13.5/uv-aarch64-unknown-linux-gnu.tar.gz +linux_arm64_sha256 = 4bb69cf95ed13f23713bb7bc09f241896ea0c75b7c557e615a20604b82f9c403 +linux_x86_64 = https://github.com/astral-sh/uv/releases/download/0.13.5/uv-x86_64-unknown-linux-gnu.tar.gz +linux_x86_64_sha256 = f68d291c75783122dad58ab77def0e13f3233f358731db5d6d7e8715603b928f # used for autoupdate # NOTE: if using uv-build as a build backend, you'll have to make sure the versions match -version = 0.8.2 +version = 0.13.5 diff --git a/gradle.properties b/gradle.properties index 0bf31eb0f9b..a7df990ebf3 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,10 +1,11 @@ # Daemons heap size -org.gradle.jvmargs=-Xmx8g -XX:MaxMetaspaceSize=2g -XX:+CrashOnOutOfMemoryError -Dfile.encoding=UTF-8 -XX:+UseParallelGC +org.gradle.jvmargs=-Xmx8g -XX:MaxMetaspaceSize=2g -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8 -XX:+UseParallelGC org.gradle.caching=true org.gradle.parallel=true org.gradle.configureondemand=true org.gradle.configuration-cache=true org.gradle.configuration-cache.parallel=true +org.gradle.welcome=never org.jetbrains.dokka.experimental.gradle.pluginMode=V2Enabled @@ -15,8 +16,10 @@ android.builtInKotlin=false android.newDsl=false android.experimental.lint.version=9.4.0 +android.disableAgpUpgradePrompt=true + # Release information -versionName=8.61.0 +versionName=8.62.0 # Override the SDK name on native crashes on Android sentryAndroidSdkName=sentry.native.android diff --git a/gradle/gradle-daemon-jvm.properties b/gradle/gradle-daemon-jvm.properties new file mode 100644 index 00000000000..52234b5ce85 --- /dev/null +++ b/gradle/gradle-daemon-jvm.properties @@ -0,0 +1 @@ +toolchainVersion=21 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e5c2f9e2171..7cbe8e626c8 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -21,7 +21,7 @@ kotlin-compatible-version = "1.9" ksp = "2.3.9" ktorClient = "3.0.0" logback = "1.2.9" -log4j2 = "2.20.0" +log4j2 = "2.26.1" nopen = "1.0.1" # see https://www.jetbrains.com/help/kotlin-multiplatform-dev/compose-compatibility-and-versioning.html#kotlin-compatibility # see https://developer.android.com/jetpack/androidx/releases/compose-kotlin @@ -48,6 +48,8 @@ spotless = "8.8.0" springboot2 = "2.7.18" springboot3 = "3.5.0" springboot4 = "4.1.0" +springKafka2 = "2.8.11" +springKafka3 = "3.3.5" sqldelight = "2.3.2" # Android @@ -110,11 +112,13 @@ androidx-core-ktx = { module = "androidx.core:core-ktx", version = "1.7.0" } androidx-fragment-ktx = { module = "androidx.fragment:fragment-ktx", version = "1.3.5" } androidx-lifecycle-common-java8 = { module = "androidx.lifecycle:lifecycle-common-java8", version.ref = "androidxLifecycle" } androidx-lifecycle-process = { module = "androidx.lifecycle:lifecycle-process", version.ref = "androidxLifecycle" } +androidx-lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version = "2.8.3" } androidx-navigation-runtime = { module = "androidx.navigation:navigation-runtime", version.ref = "androidxNavigation" } androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "androidxNavigation" } androidx-navigation-fragment = { module = "androidx.navigation:navigation-fragment-ktx", version.ref = "androidxNavigation" } androidx-navigation3-runtime = { module = "androidx.navigation3:navigation3-runtime", version.ref = "androidxNavigation3" } androidx-navigation3-ui = { module = "androidx.navigation3:navigation3-ui", version.ref = "androidxNavigation3" } +androidx-viewpager2 = { module = "androidx.viewpager2:viewpager2", version = "1.1.0" } androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "room2" } androidx-room-ktx = { module = "androidx.room:room-ktx", version.ref = "room2" } androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "room2" } @@ -157,6 +161,7 @@ lottie-compose = { module = "com.airbnb.android:lottie-compose", version = "6.7. logback-classic = { module = "ch.qos.logback:logback-classic", version.ref = "logback" } kotlin-gradle-plugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" } android-gradle-plugin = { module = "com.android.tools.build:gradle", version.ref = "agp" } +asm = { module = "org.ow2.asm:asm", version = "9.9" } nopen-annotations = { module = "com.jakewharton.nopen:nopen-annotations", version.ref = "nopen" } nopen-checker = { module = "com.jakewharton.nopen:nopen-checker", version.ref = "nopen" } nullaway = { module = "com.uber.nullaway:nullaway", version = "0.14.2" } @@ -218,9 +223,12 @@ springboot3-starter-security = { module = "org.springframework.boot:spring-boot- springboot3-starter-jdbc = { module = "org.springframework.boot:spring-boot-starter-jdbc", version.ref = "springboot3" } springboot3-starter-actuator = { module = "org.springframework.boot:spring-boot-starter-actuator", version.ref = "springboot3" } springboot3-starter-cache = { module = "org.springframework.boot:spring-boot-starter-cache", version.ref = "springboot3" } -spring-kafka2 = { module = "org.springframework.kafka:spring-kafka", version = "2.8.11" } -spring-kafka3 = { module = "org.springframework.kafka:spring-kafka", version = "3.3.5" } +spring-kafka2 = { module = "org.springframework.kafka:spring-kafka", version.ref = "springKafka2" } +spring-kafka2-test = { module = "org.springframework.kafka:spring-kafka-test", version.ref = "springKafka2" } +spring-kafka3 = { module = "org.springframework.kafka:spring-kafka", version.ref = "springKafka3" } +spring-kafka3-test = { module = "org.springframework.kafka:spring-kafka-test", version.ref = "springKafka3" } spring-kafka4 = { module = "org.springframework.kafka:spring-kafka" } +spring-kafka4-test = { module = "org.springframework.kafka:spring-kafka-test" } kafka-clients = { module = "org.apache.kafka:kafka-clients", version = "3.8.1" } springboot4-otel = { module = "io.opentelemetry.instrumentation:opentelemetry-spring-boot-starter", version.ref = "otelInstrumentation" } springboot4-resttestclient = { module = "org.springframework.boot:spring-boot-resttestclient", version.ref = "springboot4" } @@ -251,6 +259,8 @@ tomcat-catalina = { module = "org.apache.tomcat:tomcat-catalina", version = "9.0 tomcat-embed-jasper = { module = "org.apache.tomcat.embed:tomcat-embed-jasper", version = "9.0.108" } tomcat-catalina-jakarta = { module = "org.apache.tomcat:tomcat-catalina", version = "11.0.22" } tomcat-embed-jasper-jakarta = { module = "org.apache.tomcat.embed:tomcat-embed-jasper", version = "11.0.22" } +# The version comes from the Spring Boot BOM of whichever Spring line uses it. +tomcat-embed-core = { module = "org.apache.tomcat.embed:tomcat-embed-core" } # test libraries androidx-benchmark-macro-junit4 = { module = "androidx.benchmark:benchmark-macro-junit4", version = "1.4.1" } diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index eddabd2eef8..9fa7f288283 100644 Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index ad7845be306..91c4fc64aa0 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.7.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.8.2-bin.zip networkTimeout=10000 retries=0 retryBackOffMs=500 diff --git a/gradlew.bat b/gradlew.bat index a51ec4f5886..3185a43f78f 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -26,6 +26,33 @@ @rem Set local scope for the variables, and ensure extensions are enabled setlocal EnableExtensions +@rem Catch executions from older scripts and ensure they exit cleanly. +@rem This can be removed once we can be reasonably confident that few people +@rem will be migrating directly to this new wrapper. +goto afterSafetyNet +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +goto exitWithErrorLevel +:afterSafetyNet + set DIRNAME=%~dp0 if "%DIRNAME%"=="" set DIRNAME=. @rem This is normally unused @@ -45,13 +72,14 @@ set JAVA_EXE=java.exe %JAVA_EXE% -version >NUL 2>&1 if %ERRORLEVEL% equ 0 goto execute -echo. 1>&2 -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 -echo. 1>&2 -echo Please set the JAVA_HOME variable in your environment to match the 1>&2 -echo location of your Java installation. 1>&2 +1>&2 echo. +1>&2 echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +1>&2 echo. +1>&2 echo Please set the JAVA_HOME variable in your environment to match the +1>&2 echo location of your Java installation. "%COMSPEC%" /c exit 1 +goto exitWithErrorLevel :findJavaFromJavaHome set JAVA_HOME=%JAVA_HOME:"=% @@ -59,13 +87,14 @@ set JAVA_EXE=%JAVA_HOME%/bin/java.exe if exist "%JAVA_EXE%" goto execute -echo. 1>&2 -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 -echo. 1>&2 -echo Please set the JAVA_HOME variable in your environment to match the 1>&2 -echo location of your Java installation. 1>&2 +1>&2 echo. +1>&2 echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +1>&2 echo. +1>&2 echo Please set the JAVA_HOME variable in your environment to match the +1>&2 echo location of your Java installation. "%COMSPEC%" /c exit 1 +goto exitWithErrorLevel :execute @rem Setup the command line @@ -75,8 +104,9 @@ echo location of your Java installation. 1>&2 @rem Execute gradlew @rem endlocal doesn't take effect until after the line is parsed and variables are expanded @rem which allows us to clear the local environment before executing the java command -endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel +endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel & goto exitWithErrorLevel +@rem This label must not be changed. We rely on old scripts being able to jump to this point. :exitWithErrorLevel @rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts "%COMSPEC%" /c exit %ERRORLEVEL% diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 62c248385cd..00000000000 --- a/requirements.txt +++ /dev/null @@ -1,5 +0,0 @@ -certifi==2026.10.19 -charset-normalizer==3.4.2 -idna==3.15 -requests==2.33.0 -urllib3==2.8.0 diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/FirstDrawDoneListener.java b/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/FirstDrawDoneListener.java index 0629b7a4908..533739d51d8 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/FirstDrawDoneListener.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/FirstDrawDoneListener.java @@ -109,17 +109,15 @@ public void onDraw() { if (view == null) { return; } - // OnDrawListeners cannot be removed within onDraw, so we remove it with a - // GlobalLayoutListener - view.getViewTreeObserver() - .addOnGlobalLayoutListener( - new ViewTreeObserver.OnGlobalLayoutListener() { - @Override - public void onGlobalLayout() { - view.getViewTreeObserver().removeOnGlobalLayoutListener(this); - view.getViewTreeObserver().removeOnDrawListener(FirstDrawDoneListener.this); - } - }); + // OnDrawListeners cannot be removed within onDraw. Posting the removal instead of waiting for + // the next global layout guarantees it happens, even if no layout pass follows the draw. + mainThreadHandler.post( + () -> { + final ViewTreeObserver observer = view.getViewTreeObserver(); + if (observer.isAlive()) { + observer.removeOnDrawListener(this); + } + }); mainThreadHandler.postAtFrontOfQueue(callback); } 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 ba0322c7a1e..031df4c523c 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 @@ -38,13 +38,13 @@ import java.io.ByteArrayOutputStream import java.io.InputStreamReader import java.util.concurrent.atomic.AtomicReference import kotlin.test.BeforeTest -import kotlin.test.Ignore import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNotEquals import kotlin.test.assertNotNull import kotlin.test.assertNull import kotlin.test.assertTrue +import org.awaitility.kotlin.await import org.junit.runner.RunWith import org.mockito.kotlin.mock import org.mockito.kotlin.whenever @@ -87,6 +87,12 @@ class InternalSentrySdkTest { } } + // Sentry.init moves any leftover session.json to previous_session.json on the executor. Wait + // for that to finish, otherwise it can move the session a test has just persisted. Test + // options set sessionFlushTimeoutMillis to 0, so waitPreviousSessionFlush() only polls. + val cache = options.envelopeDiskCache as EnvelopeCache + await.until { cache.waitPreviousSessionFlush() } + capturedEnvelopes.clear() } @@ -634,14 +640,6 @@ class InternalSentrySdkTest { assertThat(activeSession.get().sessionId).isNotEqualTo(oldSid) } - // Flaky: intermittently fails with FileNotFoundException reading session.json at the - // `sessionFile.reader()` call below, i.e. persistCurrentSession() did not leave a session file - // on disk by the time this test reads it back. Seen across unrelated PRs, e.g.: - // https://scans.gradle.com/s/55fnn6xhtfyfq/tests/task/:sentry-android-core:testReleaseUnitTest/details/io.sentry.android.core.InternalSentrySdkTest/updateSessionForDroppedEventNonTerminating%20flags%20an%20unhandled%20error%20without%20sending%20an%20envelope?top-execution=1 - // https://scans.gradle.com/s/ojzzz4yxag7rw/tests/task/:sentry-android-core:testReleaseUnitTest/details/io.sentry.android.core.InternalSentrySdkTest/updateSessionForDroppedEventNonTerminating%20flags%20an%20unhandled%20error%20without%20sending%20an%20envelope?top-execution=1 - // Disabling until root-caused; see https://github.com/getsentry/sentry-java/pull/5990 for the - // code under test. - @Ignore("Flaky: intermittently fails to find the persisted session.json, needs root-causing") @Test fun `updateSessionForDroppedEventNonTerminating flags an unhandled error without sending an envelope`() { val fixture = Fixture() diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/internal/util/FirstDrawDoneListenerTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/internal/util/FirstDrawDoneListenerTest.kt index 44d6d9fd03a..57e72ac4d72 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/internal/util/FirstDrawDoneListenerTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/internal/util/FirstDrawDoneListenerTest.kt @@ -113,49 +113,14 @@ class FirstDrawDoneListenerTest { } @Test - fun `registerForNextDraw unregister itself after onDraw`() { + fun `registerForNextDraw unregisters itself after onDraw without a layout pass`() { val view = fixture.getSut() FirstDrawDoneListener.registerForNextDraw(view, {}, fixture.buildInfo) assertFalse(fixture.onDrawListeners.isEmpty()) - // Does not remove OnDrawListener before onDraw, even if OnGlobalLayout is triggered - view.viewTreeObserver.dispatchOnGlobalLayout() - assertFalse(fixture.onDrawListeners.isEmpty()) - - // Removes OnDrawListener in the next OnGlobalLayout after onDraw - view.viewTreeObserver.dispatchOnDraw() - view.viewTreeObserver.dispatchOnGlobalLayout() - assertTrue(fixture.onDrawListeners.isEmpty()) - } - - @Test - fun `OnGlobalLayoutListener is removed after cleanup`() { - val view = fixture.getSut() - - // Initialize mOnGlobalLayoutListeners via a dummy add/remove - val dummyGlobalListener = ViewTreeObserver.OnGlobalLayoutListener {} - view.viewTreeObserver.addOnGlobalLayoutListener(dummyGlobalListener) - view.viewTreeObserver.removeOnGlobalLayoutListener(dummyGlobalListener) - - // CopyOnWriteArray wraps an internal ArrayList called mData - val copyOnWriteArray: Any = view.viewTreeObserver.getProperty("mOnGlobalLayoutListeners") - val mDataField = copyOnWriteArray.javaClass.getDeclaredField("mData") - mDataField.isAccessible = true - - @Suppress("UNCHECKED_CAST") - fun globalLayoutListeners(): ArrayList<*> = mDataField.get(copyOnWriteArray) as ArrayList<*> - - assertTrue(globalLayoutListeners().isEmpty()) - - FirstDrawDoneListener.registerForNextDraw(view, {}, fixture.buildInfo) - - // onDraw registers a cleanup OnGlobalLayoutListener view.viewTreeObserver.dispatchOnDraw() - assertFalse(globalLayoutListeners().isEmpty()) + Shadows.shadowOf(Looper.getMainLooper()).idle() - // onGlobalLayout fires the cleanup, which removes both the draw and layout listeners - view.viewTreeObserver.dispatchOnGlobalLayout() - assertTrue(globalLayoutListeners().isEmpty()) assertTrue(fixture.onDrawListeners.isEmpty()) } diff --git a/sentry-android-navigation3/README.md b/sentry-android-navigation3/README.md index bdfec781fc4..95bbbeb006b 100644 --- a/sentry-android-navigation3/README.md +++ b/sentry-android-navigation3/README.md @@ -9,4 +9,4 @@ Because Nav3 back stacks can have arbitrary key types, host apps need to provide keys and the data Sentry should display for each (see `BackStackEntryMapper`). See the [Navigation for Android docs](https://docs.sentry.io/platforms/android/integrations/navigation/) for additional details, including -installation, configuration, and limitations. +configuration, supported setups, and limitations. diff --git a/sentry-android-navigation3/build.gradle.kts b/sentry-android-navigation3/build.gradle.kts index b9d02bb49d4..9612fded9d6 100644 --- a/sentry-android-navigation3/build.gradle.kts +++ b/sentry-android-navigation3/build.gradle.kts @@ -60,12 +60,15 @@ kotlin { explicitApi() } dependencies { implementation(projects.sentry) + implementation(libs.androidx.lifecycle.runtime.compose) compileOnly(libs.androidx.compose.runtime) testImplementation(libs.androidx.compose.ui.test.junit4) + testImplementation(libs.androidx.fragment.ktx) testImplementation(libs.androidx.test.core) testImplementation(libs.androidx.test.ext.junit) + testImplementation(libs.androidx.viewpager2) testImplementation(libs.google.truth) testImplementation(libs.mockito.core) testImplementation(libs.mockito.kotlin) diff --git a/sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/BackStackConverter.kt b/sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/BackStackConverter.kt index f567f7c8c5e..6100c706202 100644 --- a/sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/BackStackConverter.kt +++ b/sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/BackStackConverter.kt @@ -395,7 +395,9 @@ internal data class NormalizedSentryBackStackEntry( val name: String, /** Sanitized [SentryBackStackEntry.arguments] (i.e., bounded in size and depth). */ val arguments: Map = emptyMap(), - /** The reason why the SDK dropped host-provided arguments. */ + /** + * The reason why the SDK dropped host-provided arguments, or null if no arguments were dropped. + */ val argumentDropReason: ArgumentDropReason? = null, ) { diff --git a/sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/BackStackObserver.kt b/sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/BackStackObserver.kt index 37f694350fa..8e2806686e5 100644 --- a/sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/BackStackObserver.kt +++ b/sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/BackStackObserver.kt @@ -2,13 +2,11 @@ package io.sentry.compose.navigation3 import io.sentry.Breadcrumb import io.sentry.Hint -import io.sentry.IScope -import io.sentry.IScopes import io.sentry.ITransaction -import io.sentry.PropagationContext import io.sentry.SentryIntegrationPackageStorage import io.sentry.SentryLevel.DEBUG import io.sentry.SentryLevel.INFO +import io.sentry.SentryOptions import io.sentry.SpanStatus import io.sentry.TransactionContext import io.sentry.TransactionOptions @@ -16,6 +14,9 @@ import io.sentry.compose.navigation3.BackStackConverter.RetentionPolicy import io.sentry.compose.navigation3.PreparedChange.BackStackHasNewTop import io.sentry.compose.navigation3.PreparedChange.BackStackHasSameTop import io.sentry.compose.navigation3.PreparedChange.BackStackIsEmpty +import io.sentry.compose.navigation3.lifecycle.INavScope +import io.sentry.compose.navigation3.lifecycle.INavScopes +import io.sentry.compose.navigation3.lifecycle.NAVIGATION_CONTEXT_KEY import io.sentry.protocol.App import io.sentry.protocol.TransactionNameSource import io.sentry.util.IntegrationUtils.addIntegrationToSdkVersion @@ -47,10 +48,11 @@ private const val NAVIGATION_OP: String = "navigation" * **Thread safety** * * This class is ***not*** thread-safe. Clients should serialize calls to [onBackStackChanged] and - * [cleanup] (e.g., via invocation from an `*Effect` or another form of thread confinement). + * [dispose] (e.g., via invocation from an `*Effect` or another form of thread confinement). */ +@Suppress("TooManyFunctions") internal class BackStackObserver( - private val scopes: IScopes, + private val scopes: INavScopes, private val options: SentryNavOptions, entryMapper: ForwardingBackStackEntryMapper, ) { @@ -110,23 +112,14 @@ internal class BackStackObserver( scopes.configureScope { scope -> applyChange(scope, change) } } - internal fun cleanup() { - previousTop = null - previousTopNormalized = null - - scopes.configureScope { scope -> - navTransaction.stop(scope) - navScreen.clear(scope) - - if (options.captureBackStack) { - // This observer owns the nav context while it's in the composition, and cleanup removes - // it to avoid leaking stale back stack data after observation stops. If the host app - // replaces one observer with another, there may be a brief gap where events lack nav - // context. Apps should keep the observer at the nav root so cleanup only runs when the - // navigation session is ending, not during normal destination changes. - navContext.clear(scope) - } - } + /** + * Clears the nav data this observer recorded. + * + * Call it when observation ends so that no stale screen, nav context, or nav transaction outlives + * it. + */ + internal fun dispose() { + scopes.configureScope { scope -> tearDown(scope) } } private fun prepareChange(backStack: List): PreparedChange { @@ -140,7 +133,7 @@ internal class BackStackObserver( } } - private fun applyChange(scope: IScope, change: PreparedChange) { + private fun applyChange(scope: INavScope, change: PreparedChange) { when (change) { is BackStackIsEmpty -> handleEmptyBackStack(scope) is BackStackHasNewTop -> handleNewTop(scope, change.previousTop, change.backStack) @@ -185,7 +178,7 @@ internal class BackStackObserver( } private fun handleNewTop( - scope: IScope, + scope: INavScope, previousTop: NormalizedSentryBackStackEntry?, currentBackStack: BackStackData, ) { @@ -212,23 +205,19 @@ internal class BackStackObserver( ) ?.let { transaction -> navContext.updateTransaction(transaction, scope, currentBackStack) } } else { - // Rotate the propagation context. - scope.withPropagationContext { scope.setPropagationContext(PropagationContext()) } + scope.rotatePropagationContext() } storeAsPreviousTop(currentBackStack.topEntry, currentBackStack.topEntryNormalized) } - private fun handleSameTop(scope: IScope, backStack: BackStackData) { + private fun handleSameTop(scope: INavScope, backStack: BackStackData) { navContext.update(scope, backStack.capturedEntriesNormalized) storeAsPreviousTop(backStack.topEntry, backStack.topEntryNormalized) } - private fun handleEmptyBackStack(scope: IScope) { - navTransaction.stop(scope) - navContext.clear(scope) - navScreen.clear(scope) - clearPreviousTop() + private fun handleEmptyBackStack(scope: INavScope) { + tearDown(scope) } private fun storeAsPreviousTop(topEntry: T, topEntryNormalized: NormalizedSentryBackStackEntry) { @@ -240,12 +229,19 @@ internal class BackStackObserver( previousTop = null previousTopNormalized = null } + + private fun tearDown(scope: INavScope) { + navTransaction.stop(scope) + navContext.clear(scope) + navScreen.clear(scope) + clearPreviousTop() + } } /** * A model for applying one back stack update. * - * Lets us separate change preparation from its application so that the [IScopes.configureScope] + * Lets us separate change preparation from its application so that the [INavScopes.configureScope] * callback in charge of application can use already-computed navigation state. Otherwise, any * exceptions thrown during state computation would be swallowed by `configureScope`'s over-broad * `catch` clause. @@ -281,7 +277,7 @@ private data class BackStackData( ) /** A helper class for managing nav transactions. */ -private class NavTransaction(private val scopes: IScopes) { +private class NavTransaction(private val scopes: INavScopes) { private companion object { private const val TRANSACTION_ORIGIN = "auto.navigation.nav3" @@ -291,11 +287,11 @@ private class NavTransaction(private val scopes: IScopes) { /** Starts an idle navigation transaction, or no-ops if another transaction is already active. */ fun start( - scope: IScope, + scope: INavScope, name: String, arguments: Map, ): ITransaction? { - clearTransactionIfFinished(scope) + scope.clearFinishedTransaction() if (scope.transaction != null) { scopes.options.logger.log( @@ -323,6 +319,7 @@ private class NavTransaction(private val scopes: IScopes) { transactionOptions, ) + // Not an error: INavScopes.startTransaction may decline to start a transaction by design. if (transaction.isNoOp) { return null } @@ -335,61 +332,39 @@ private class NavTransaction(private val scopes: IScopes) { } } - scope.withTransaction { tx -> - if (tx == null) { - scope.transaction = transaction - } - } + scope.bindTransaction(transaction) return transaction } /** Finishes and unsets the active navigation transaction, if one exists. */ - fun stop(scope: IScope) { + fun stop(scope: INavScope) { val transaction = activeNavTransaction ?: return val status = transaction.status ?: SpanStatus.OK transaction.finish(status) - - scope.withTransaction { tx -> - if (tx == transaction) { - scope.clearTransaction() - } - } - + scope.unbindTransaction(transaction) activeNavTransaction = null } - - /** Clears a stale finished transaction that's still bound to the default scope. */ - private fun clearTransactionIfFinished(scope: IScope) { - scope.withTransaction { tx -> - if (tx?.isFinished == true) { - scope.clearTransaction() - } - } - } } /** A helper class for updating [nav context][NAVIGATION_CONTEXT_KEY]. */ -private class NavContext(private val scopes: IScopes, private val options: SentryNavOptions) { +private class NavContext(private val scopes: INavScopes, private val options: SentryNavOptions) { private companion object { private const val BACKSTACK_KEY = "Back Stack" - private const val NAVIGATION_CONTEXT_KEY = "Navigation" } - fun update(scope: IScope, backStackEntries: List) { + fun update(scope: INavScope, backStackEntries: List) { if (backStackEntries.isEmpty()) { clear(scope) return } - scope.setContexts(NAVIGATION_CONTEXT_KEY, backStackEntries.toBackStackMap()) + scope.setNavigationContext(backStackEntries.toBackStackMap()) } - fun clear(scope: IScope) { - // We purposefully don't call IScope.removeContexts(), as it doesn't notify IScopeObserver and - // therefore doesn't write its updates to disk ¯\_ (ツ)_/¯. - scope.setContexts(NAVIGATION_CONTEXT_KEY, null as Any?) + fun clear(scope: INavScope) { + scope.setNavigationContext(null) } /** @@ -403,12 +378,11 @@ private class NavContext(private val scopes: IScopes, private val options: Sentr */ fun updateTransaction( transaction: ITransaction, - scope: IScope, + scope: INavScope, backStack: BackStackData, ) { if (scopes.options.isEnableScreenTracking) { - val appContext = - transaction.contexts.app ?: io.sentry.protocol.Contexts(scope.contexts).app ?: App() + val appContext = transaction.contexts.app ?: scope.app ?: App() appContext.viewNames = listOf(backStack.topEntryNormalized.name) transaction.contexts.setApp(appContext) @@ -432,12 +406,12 @@ private class NavScreen { private var lastScreenName: String? = null - fun update(scope: IScope, currentEntry: NormalizedSentryBackStackEntry) { + fun update(scope: INavScope, currentEntry: NormalizedSentryBackStackEntry) { scope.screen = currentEntry.name lastScreenName = currentEntry.name } - fun clear(scope: IScope) { + fun clear(scope: INavScope) { val screenName = lastScreenName ?: return if (scope.screen == screenName) { scope.screen = null @@ -447,7 +421,7 @@ private class NavScreen { } /** A helper class for generating nav breadcrumbs. */ -private class NavBreadcrumbs(private val scopes: IScopes) { +private class NavBreadcrumbs(private val scopes: INavScopes) { fun emit(fromEntry: NormalizedSentryBackStackEntry?, toEntry: NormalizedSentryBackStackEntry) { val breadcrumb = diff --git a/sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/CoordinatedBackStackObserver.kt b/sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/CoordinatedBackStackObserver.kt new file mode 100644 index 00000000000..8cf9c6e1be3 --- /dev/null +++ b/sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/CoordinatedBackStackObserver.kt @@ -0,0 +1,55 @@ +package io.sentry.compose.navigation3 + +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import io.sentry.IScope +import io.sentry.IScopes +import io.sentry.compose.navigation3.lifecycle.NavLeaseCoordinator +import io.sentry.compose.navigation3.lifecycle.NavScopes + +/** + * A [BackStackObserver] whose updates to a shared mutable [IScope] are coordinated with other + * `BackStackObserver`s. + * + * Coordination is achieved via [NavScopes]. + * + * **Usage** + * + * Call [attach] before the first [onBackStackChanged], and call [dispose] when observation ends. + * + * Instances targeting the same [IScope] must be created with the same [NavLeaseCoordinator]. + * + * **Thread safety** + * + * This class is ***not*** thread-safe. Invocations should be confined to the same thread as the + * [LifecycleEventObserver] callbacks implemented by [NavScopes]. + */ +internal class CoordinatedBackStackObserver( + scopes: IScopes, + options: SentryNavOptions, + entryMapper: ForwardingBackStackEntryMapper, + coordinator: NavLeaseCoordinator, +) { + + private val navScopes = NavScopes(scopes, coordinator) + private val observer = BackStackObserver(navScopes, options, entryMapper) + + fun attach(lifecycle: Lifecycle) { + navScopes.attach(lifecycle) + } + + fun onBackStackChanged(backStack: List) { + navScopes.onBackStackUpdate() + observer.onBackStackChanged(backStack) + } + + fun dispose() { + // Call disposal on both the observer and navScopes in case their (currently overlapping) + // implementations drift over time. + observer.dispose() + navScopes.dispose() + } +} + +/** The [NavLeaseCoordinator] shared by default by all [SentryNavEffect]s. */ +internal val defaultNavLeaseCoordinator: NavLeaseCoordinator = NavLeaseCoordinator() diff --git a/sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/SentryNavEffect.kt b/sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/SentryNavEffect.kt index 8b6269752b6..c666122efc7 100644 --- a/sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/SentryNavEffect.kt +++ b/sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/SentryNavEffect.kt @@ -4,9 +4,11 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState +import androidx.lifecycle.compose.LocalLifecycleOwner import io.sentry.IScopes import io.sentry.ScopesAdapter import io.sentry.SentryOptions +import io.sentry.compose.navigation3.lifecycle.NavLeaseCoordinator import org.jetbrains.annotations.ApiStatus /** @@ -41,8 +43,7 @@ import org.jetbrains.annotations.ApiStatus * * **Data generated** * - * By default, the following data is produced every time the top of the provided [backStack] - * changes: + * By default, the following are produced every time the top of the provided [backStack] changes: * * - a new navigation transaction (unless another transaction is already active on the current * scope) @@ -55,20 +56,23 @@ import org.jetbrains.annotations.ApiStatus * * **Limitations** * - * `SentryNavEffect` generates all Sentry data based solely on the top entry of your back stack. In - * particular, it has no awareness of + * *Tracks the back stack, not scenes*: `SentryNavEffect` generates all Sentry data based solely on + * the top entry of your back stack. In particular, it has no awareness of * [Scenes](https://developer.android.com/guide/navigation/navigation-3/scenes). Transaction routes, * breadcrumbs, and screen names are all derived from the top entry of the back stack and are * updated as it changes. * - * `SentryNavEffect` doesn't make any special accommodations for + * *No accommodation for predictive back*: `SentryNavEffect` doesn't make any special accommodations + * for * [predictive back](https://developer.android.com/guide/navigation/custom-back/predictive-back-gesture) * gestures. That means that spans produced by predictively rendered composables can show up under * the current destination's transaction. * - * Multiple simultaneously active `SentryNavEffect` instances writing to the same Sentry scope are - * not supported. Violating this restriction can result in interleaved breadcrumbs, clobbered screen - * names and back stacks, and transactions that interfere with one another. + * *Limited support for simultaneously active `SentryNavEffect`s*: Multiple `SentryNavEffect`s are + * supported only in certain setups: roughly, one effect per Activity or Fragment, placed next to + * (and not inside) its `NavDisplay`. See the + * [Navigation for Android docs](https://docs.sentry.io/platforms/android/integrations/navigation/#supported-setups) + * for a detailed list of supported vs unsupported configurations. * * @param backStack The navigation backstack to observe. * @param backStackEntryMapper Maps each entry of the [backStack] to a name and optional arguments @@ -87,6 +91,7 @@ public fun SentryNavEffect( backStackEntryMapper = backStackEntryMapper, options = options, scopes = ScopesAdapter.getInstance(), + coordinator = defaultNavLeaseCoordinator, ) } @@ -94,20 +99,29 @@ public fun SentryNavEffect( internal fun SentryNavEffect( backStack: List, backStackEntryMapper: BackStackEntryMapper, - options: SentryNavOptions = SentryNavOptions(), + options: SentryNavOptions, scopes: IScopes, + coordinator: NavLeaseCoordinator, ) { val currentBackStackEntryMapper = rememberUpdatedState(backStackEntryMapper) + val lifecycle = LocalLifecycleOwner.current.lifecycle val observer = - remember(scopes, options) { - BackStackObserver( + remember(scopes, options, lifecycle, coordinator) { + CoordinatedBackStackObserver( scopes = scopes, options = options, entryMapper = ForwardingBackStackEntryMapper { currentBackStackEntryMapper.value }, + coordinator = coordinator, ) } + // Register first so the initial prepared back stack update can acquire navigation ownership. + DisposableEffect(observer) { + observer.attach(lifecycle) + onDispose { observer.dispose() } + } + // Intentionally don't remember this copy. Snapshot-backed lists mutate in place, so // remember(backStack) { backStack.toList() } would cache a stale copy. (The key reference // retained by remember() and the backStack reference passed to this effect would point to the @@ -120,10 +134,6 @@ internal fun SentryNavEffect( observer.onBackStackChanged(backStack = copy) onDispose {} } - - DisposableEffect(observer) { - onDispose { observer.cleanup() } - } } /** diff --git a/sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/SentryNavOptions.kt b/sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/SentryNavOptions.kt index 9a571d47dee..4b45d7c5b21 100644 --- a/sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/SentryNavOptions.kt +++ b/sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/SentryNavOptions.kt @@ -3,7 +3,7 @@ package io.sentry.compose.navigation3 import androidx.compose.runtime.Immutable import org.jetbrains.annotations.ApiStatus -// Keep the default low: every captured entry may require argument extraction and recursive +// Keep the default low, as every captured entry may require argument extraction and recursive // sanitization when navigation changes are observed. private const val DEFAULT_MAX_CAPTURED_BACK_STACK_ENTRIES = 5 diff --git a/sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/lifecycle/NavLeaseCoordinator.kt b/sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/lifecycle/NavLeaseCoordinator.kt new file mode 100644 index 00000000000..87b0f8f75ba --- /dev/null +++ b/sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/lifecycle/NavLeaseCoordinator.kt @@ -0,0 +1,461 @@ +package io.sentry.compose.navigation3.lifecycle + +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import io.sentry.IScope +import io.sentry.ITransaction +import io.sentry.SpanStatus + +/** + * Manages a lease in order to determine which [NavScopes] instance can bind nav transactions and + * publish nav data to a shared [IScope]. + * + * `NavScopes` instances register as [candidates][Candidate] for lease ownership via + * [registerCandidate]. + * + * Note: "Owner" below refers to the `LifecycleOwner` whose lifecycle events the candidate's + * `NavScopes` relays. + * + * **Exception semantics** + * + * Methods in this class with an `IScope` parameter can throw arbitrary exceptions†, which propagate + * to the caller. Each method updates its own bookkeeping before calling into the scope, so the + * coordinator remains usable if the exception is caught. + * + * † E.g., `IScope` can throw arbitrary exceptions when scope mutators notify registered observers – + * including the host app – in an unguarded loop, or when `finish()` is called on a retained nav + * transaction, which can trigger the host app's event processors. + * + * **Thread safety** + * + * This class is ***not*** thread-safe. All `NavScopes` instances using this coordinator must + * confine their access to the same thread. + */ +@Suppress("TooManyFunctions") +internal class NavLeaseCoordinator { + + private val candidates = mutableListOf() + + /** Candidate responsible for the most recently published nav data in the shared scope. */ + private var publishedDataCandidate: Candidate? = null + + private var leaseHolder: Candidate? = null + + /** + * Counter that orders candidates by when this coordinator most recently observed them in resumed + * state. + * + * Each resume, including registration of an already-resumed owner, assigns the next value to that + * candidate's [Candidate.resumeOrder]. This lets fallback selection prefer the most recently + * resumed eligible candidate without relying on clock timestamps. Zero is reserved for candidates + * that have not been observed resumed and therefore cannot be chosen as fallbacks. + */ + private var resumeOrder: Long = 0 + + /** + * Registers [owner] as a candidate for the nav lease. No-ops if `owner` instance is already + * registered or [lifecycleState] is [destroyed][Lifecycle.State.DESTROYED]. + * + * [onPublishData] publishes `owner`'s staged nav data to the given scope when `owner` acquires + * the lease. [onClearPublishedData] clears `owner`'s published nav data from the given scope when + * its data is the most recently published and either another owner acquires the lease or `owner` + * is unregistered. + */ + fun registerCandidate( + owner: Any, + lifecycleState: Lifecycle.State, + onPublishData: (IScope) -> Unit, + onClearPublishedData: (IScope) -> Unit, + ) { + if (findCandidate(owner) != null) { + return + } + + if (lifecycleState == Lifecycle.State.DESTROYED) { + return + } + + val initialResumeOrder = if (lifecycleState == Lifecycle.State.RESUMED) ++resumeOrder else 0 + candidates += + Candidate(owner, onPublishData, onClearPublishedData, lifecycleState, initialResumeOrder) + } + + /** Records that [owner] started. */ + fun startCandidate(owner: Any) { + findCandidate(owner)?.start() + } + + /** + * Records that [owner] resumed. + * + * Restores the lease to `owner` if it already applied a back stack update and [scope] is + * non-null. + * + * A `null` scope (e.g., the SDK is disabled) still records the lifecycle transition so the + * candidate remains eligible for fallback once a scope becomes available. + * + * Can throw arbitrary exceptions. See "Exception semantics" in the class KDoc. + */ + fun resumeCandidate(owner: Any, scope: IScope?) { + findCandidate(owner)?.let { + it.resume(++resumeOrder) + + if (scope != null && it.hasAppliedInitialBackStackUpdate) { + transferLeaseTo(it, scope) + } + } + } + + /** + * Records that [owner] paused. + * + * Hands the lease to another owner only if that owner is resumed (e.g., a Fragment whose dialog + * is being dismissed); otherwise keeps it. A paused leaseholder may still be the topmost visible + * owner (e.g., beneath a translucent window or in picture-in-picture mode), and any other paused + * owner is beneath it. A displaced owner gets the lease back when it resumes. + * + * A `null` scope (e.g., the SDK is disabled) still records the lifecycle transition so the + * candidate remains eligible for fallback once a scope becomes available. + * + * Can throw arbitrary exceptions. See "Exception semantics" in the class KDoc. + */ + fun pauseCandidate(owner: Any, scope: IScope?) { + findCandidate(owner)?.let { + it.pause() + + if (leaseHolder === it) { + mostRecentlyResumedCandidate(excluding = it, resumedOnly = true)?.let { next -> + handOffLease(next, scope) + } + } + } + } + + /** + * Records that [owner] stopped. + * + * As with pausing, hands the lease to another owner only if that owner is resumed; otherwise the + * stopped owner keeps it. Keeping it lets a lone owner go on recording navigation that happens + * while it isn't resumed (e.g., while the app is in the background, or after it restarts but + * before it resumes), including starting nav transactions. + * + * A paused owner doesn't get the lease: when the leaseholder stops before it, the app is + * typically going to the background (e.g., a translucent leaseholder stops before the owner + * beneath it), and that owner isn't what the user last saw. A paused owner that comes back to the + * foreground gets the lease when it resumes. + * + * A `null` scope (e.g., the SDK is disabled) still records the lifecycle transition so the + * candidate remains eligible for fallback once a scope becomes available. + * + * Can throw arbitrary exceptions. See "Exception semantics" in the class KDoc. + */ + fun stopCandidate(owner: Any, scope: IScope?) { + findCandidate(owner)?.let { + it.stop() + + if (leaseHolder === it) { + mostRecentlyResumedCandidate(resumedOnly = true)?.let { next -> handOffLease(next, scope) } + } + } + } + + /** + * Stops tracking [owner] (e.g., because it was destroyed or its effect left the composition). + * No-ops if `owner` instance isn't registered. + * + * Finishes and unbinds `owner`'s nav transaction, and clears its published nav data if that data + * is the most recently published. If `owner` holds the lease, hands it to the eligible candidate + * most recently observed resumed, even if that candidate is now only started or paused, or + * releases it if there's none. + * + * A `null` scope (e.g., the SDK is disabled) still removes the candidate, but its transaction is + * left to time out, its published data is left in place, and the lease is released rather than + * handed off. + * + * Can throw arbitrary exceptions. See "Exception semantics" in the class KDoc. Coordinator + * references to `owner` are released before any scope call, so they're released even if scope + * cleanup or lease handoff throws. The remaining scope cleanup is then skipped. + */ + fun unregisterCandidate(owner: Any, scope: IScope?) { + val candidate = findCandidate(owner) ?: return + val transaction = candidate.transaction + val wasPublisher = publishedDataCandidate === candidate + val wasLeaseHolder = leaseHolder === candidate + + candidates.remove(candidate) + candidate.transaction = null + if (wasPublisher) { + publishedDataCandidate = null + } + if (wasLeaseHolder) { + leaseHolder = null + } + + // No need to finish the transaction if we don't have a scope (e.g., the SDK is disabled), as + // the transaction can't be sent and it will time out anyway. Not finishing it has the added + // advantage of keeping SDK work inside `configureScope`'s error handling. + if (scope == null) { + return + } + + finishTransaction(transaction, scope) + if (wasPublisher) { + candidate.onClearPublishedData(scope) + } + if (wasLeaseHolder) { + transferLeaseTo(mostRecentlyResumedCandidate(), scope) + } + } + + fun hasLease(owner: Any): Boolean = leaseHolder?.owner === owner + + /** + * Records that the registered [owner] is about to apply a back-stack update and acquires the nav + * lease if the owner's lifecycle permits it. + * + * An owner that isn't resumed doesn't take the lease from a resumed leaseholder, as it isn't the + * one the user is interacting with (e.g., a preloaded ViewPager2 page next to the current one). + * Instead, it acquires the lease when it resumes. + * + * If the lease transfers, ownership and transaction binding are updated before the candidate's + * staged nav data is published. Publication completes before this method returns, so the caller + * can then apply the back-stack update. Later calls for the same [owner] do nothing. + * + * Can throw arbitrary exceptions. See "Exception semantics" in the class KDoc. + */ + fun onBackStackUpdate(owner: Any, scope: IScope) { + val candidate = findCandidate(owner) ?: return + if (candidate.hasAppliedInitialBackStackUpdate) { + return + } + + candidate.hasAppliedInitialBackStackUpdate = true + + val holder = leaseHolder + val wouldDisplaceResumedHolder = + holder != null && + holder.state == Lifecycle.State.RESUMED && + candidate.state != Lifecycle.State.RESUMED + + if (candidate.canAcquireLeaseOnFirstUpdate && !wouldDisplaceResumedHolder) { + transferLeaseTo(candidate, scope) + } + } + + /** Whether [transaction] is a nav transaction retained for any registered owner. */ + fun isNavTransaction(transaction: ITransaction): Boolean = candidates.any { + it.transaction === transaction + } + + /** + * Records [transaction] as the nav transaction of [owner], which must hold the lease. Ignored + * otherwise, as only the leaseholder starts nav transactions. + * + * If [owner] loses the lease, the transaction is unbound but retained: it's rebound if [owner] + * regains the lease while it's still unfinished and the scope is free, and finished when [owner] + * is [unregistered][unregisterCandidate]. It's forgotten once [owner] retires it; see + * [releaseTransaction]. + */ + fun retainTransaction(owner: Any, transaction: ITransaction) { + val candidate = leaseHolder ?: return + if (candidate.owner !== owner) { + return + } + candidate.transaction = transaction + } + + /** + * Forgets [transaction] as the nav transaction of [owner], if it's the one retained. Called when + * [owner] retires the transaction (e.g., it navigated away), so it's never rebound later. + * + * Retiring is distinct from losing the lease: a transaction that's still waiting for child spans + * isn't [finished][ITransaction.isFinished] yet, so without this, regaining the lease would + * rebind a transaction whose destination [owner] has already left. + */ + fun releaseTransaction(owner: Any, transaction: ITransaction) { + val candidate = findCandidate(owner) ?: return + if (candidate.transaction === transaction) { + candidate.transaction = null + } + } + + private fun findCandidate(owner: Any): Candidate? = candidates.firstOrNull { it.owner === owner } + + /** + * The eligible candidate most recently observed resumed, other than [excluding]. With + * [resumedOnly], only candidates that are resumed right now are considered; otherwise, started + * and paused ones are too. + */ + private fun mostRecentlyResumedCandidate( + excluding: Candidate? = null, + resumedOnly: Boolean = false, + ): Candidate? = + candidates + .filter { + it !== excluding && + it.hasAppliedInitialBackStackUpdate && + it.state.isAtLeast(Lifecycle.State.STARTED) && + it.resumeOrder > 0 && + (!resumedOnly || it.state == Lifecycle.State.RESUMED) + } + .maxByOrNull { it.resumeOrder } + + /** + * Hands the current lease to [next]. Without a [scope], the lease is released instead, as [next] + * could not publish its staged data. + */ + private fun handOffLease(next: Candidate?, scope: IScope?) { + if (scope != null) { + transferLeaseTo(next, scope) + } else { + leaseHolder = null + } + } + + /** + * Hands the lease to [candidate], or releases it given `null`. + * + * Maintains the invariant that, while a lease is held, the leaseholder is also the candidate + * recorded as having published the shared scope's nav data. The invariant holds even when a + * publish or clear callback throws part way through the handoff. See "Exception semantics" in the + * class KDoc. + */ + private fun transferLeaseTo(candidate: Candidate?, scope: IScope) { + if (leaseHolder === candidate) { + return + } + + if (candidate != null) { + // Unbind another owner's transaction without finishing it, so it can be restored if that + // owner returns. If this owner's retained transaction has since finished, discard it + // instead of binding a finished transaction to the shared scope. + scope.withTransaction { current -> + if (current != null && candidates.any { it !== candidate && it.transaction === current }) { + scope.clearTransaction() + } + } + + val transaction = candidate.transaction + if (transaction?.isFinished == true) { + candidate.transaction = null + clearTransactionFromScope(transaction, scope) + } else if (transaction != null) { + // The navigation lease doesn't give this owner control over an unrelated transaction + // already bound to the shared scope. Restore the owner's retained nav transaction only if + // the scope is free or already holds that same transaction. + scope.withTransaction { current -> + if (current == null || current === transaction) { + scope.transaction = transaction + } + } + } + } + + val previousPublisher = publishedDataCandidate + leaseHolder = candidate + if (candidate != null) { + // Record the candidate as the publisher before calling into the scope: it owns the scope's + // nav data from the moment it takes the lease. If clearing the previous owner's data then + // throws, the next handoff still clears the candidate's data rather than never clearing it. + // + // Clear the previous publisher's nav data before publishing the candidate's, so the + // previous owner's values don't linger where the candidate has nothing staged to replace + // them. + publishedDataCandidate = candidate + if (previousPublisher != null && previousPublisher !== candidate) { + previousPublisher.onClearPublishedData(scope) + } + candidate.onPublishData(scope) + } + } + + /** + * Finishes [transaction] if it's unfinished, and unbinds it from [scope]. + * + * Callers must forget the transaction first. + */ + private fun finishTransaction(transaction: ITransaction?, scope: IScope) { + if (transaction == null) { + return + } + if (!transaction.isFinished) { + transaction.finish(transaction.status ?: SpanStatus.OK) + } + clearTransactionFromScope(transaction, scope) + } + + private fun clearTransactionFromScope(transaction: ITransaction, scope: IScope) { + scope.withTransaction { current -> + if (current === transaction) { + scope.clearTransaction() + } + } + } + + /** A [LifecycleOwner] that has registered itself as a candidate for lease ownership. */ + private class Candidate( + val owner: Any, + val onPublishData: (IScope) -> Unit, + val onClearPublishedData: (IScope) -> Unit, + initialState: Lifecycle.State, + initialResumeOrder: Long, + ) { + + /** The candidate's lifecycle state, as last observed. */ + var state: Lifecycle.State = initialState + private set + + /** + * Whether the candidate has been observed STOPPED since + * [registration][NavLeaseCoordinator.registerCandidate]. + */ + private var wasStopped: Boolean = false + + var transaction: ITransaction? = null + + /** + * See [NavLeaseCoordinator.resumeOrder]. Non-zero once the candidate has been observed RESUMED + * since [registration][NavLeaseCoordinator.registerCandidate]. + */ + var resumeOrder: Long = initialResumeOrder + private set + + var hasAppliedInitialBackStackUpdate: Boolean = false + + /** + * Whether the candidate's lifecycle permits its first back stack update since + * [registration][NavLeaseCoordinator.registerCandidate] to acquire the nav lease, including + * taking it from another owner that isn't resumed: it's resumed, or it has neither resumed nor + * stopped since registration (i.e., it's still coming up for the first time). A paused or + * restarted candidate is returning to the screen rather than arriving on it. + * + * This checks the candidate's own lifecycle only; it does not indicate whether the candidate + * has already applied its first update, nor account for a resumed leaseholder (see + * [NavLeaseCoordinator.onBackStackUpdate]). + */ + val canAcquireLeaseOnFirstUpdate: Boolean + get() = state == Lifecycle.State.RESUMED || (resumeOrder == 0L && !wasStopped) + + fun start() { + // Attaching to an already-resumed lifecycle replays ON_START before ON_RESUME, so only move + // up from CREATED. + if (!state.isAtLeast(Lifecycle.State.STARTED)) { + state = Lifecycle.State.STARTED + } + } + + fun resume(order: Long) { + resumeOrder = order + state = Lifecycle.State.RESUMED + } + + fun pause() { + state = Lifecycle.State.STARTED + } + + fun stop() { + state = Lifecycle.State.CREATED + wasStopped = true + } + } +} diff --git a/sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/lifecycle/NavScope.kt b/sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/lifecycle/NavScope.kt new file mode 100644 index 00000000000..f67c0b9b32d --- /dev/null +++ b/sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/lifecycle/NavScope.kt @@ -0,0 +1,156 @@ +package io.sentry.compose.navigation3.lifecycle + +import io.sentry.IScope +import io.sentry.ITransaction +import io.sentry.PropagationContext +import io.sentry.protocol.App +import io.sentry.protocol.Contexts + +/** + * An [INavScope] implementation that writes to and reads from [delegate] only when [owner] holds + * the nav lease; otherwise it falls back to [stagedNavData]. + */ +internal class NavScope( + private val delegate: IScope, + private val stagedNavData: StagedNavData, + private val coordinator: NavLeaseCoordinator, + private val owner: Any, +) : INavScope { + + private val hasLease: Boolean + get() = coordinator.hasLease(owner) + + override var screen: String? + get() = if (hasLease) delegate.screen else stagedNavData.screen + set(value) { + stagedNavData.stageScreen(value) + if (hasLease) { + stagedNavData.publishScreen(delegate) + } + } + + override fun setNavigationContext(value: Any?) { + stagedNavData.stageNavigationContext(value) + if (hasLease) { + stagedNavData.publishNavigationContext(delegate) + } + } + + /** + * Copies the whole of [IScope.getContexts] in order to return a copy of its [App]. + * + * [App]'s copy constructor isn't public, so an `App` can't be copied on its own. Going through + * [Contexts] deep-copies every context on the scope and discards all of it but the `App`. + * + * Called once per navigation that changes the top of the back stack, so the cost is bounded. + * Don't add call sites without reading [INavScope.app] first. + */ + override val app: App? + get() = Contexts(delegate.contexts).app + + /** + * The transaction bound to the scope. Without the lease, another owner's nav transaction isn't + * reported, so [owner]'s attempt to start a nav transaction reaches [NavScopes.startTransaction], + * which declines it and defers the propagation context rotation it implies. Non-nav transactions + * (e.g., the host's) are always reported, so they still block nav transactions as usual. + */ + override val transaction: ITransaction? + get() = + delegate.transaction?.takeUnless { current -> + !hasLease && coordinator.isNavTransaction(current) + } + + override fun bindTransaction(transaction: ITransaction) { + if (!hasLease) { + return + } + delegate.withTransaction { current -> + if (current == null) { + delegate.transaction = transaction + } + } + } + + override fun unbindTransaction(transaction: ITransaction) { + // Coordinator is called before clearTransaction() because the latter notifies the host app's + // scope observer(s), which could throw. Clearing the transaction first would cause the + // coordinator to retain a stale transaction. + coordinator.releaseTransaction(owner, transaction) + delegate.withTransaction { current -> + if (current === transaction) { + delegate.clearTransaction() + } + } + } + + override fun clearFinishedTransaction() { + if (!hasLease) { + return + } + delegate.withTransaction { current -> + if (current?.isFinished == true) { + delegate.clearTransaction() + } + } + } + + override fun rotatePropagationContext() { + if (hasLease) { + delegate.withPropagationContext { delegate.propagationContext = PropagationContext() } + } else { + stagedNavData.deferPropagationContextRotation() + } + } +} + +/** + * The subset of the [IScope] interface used to record navigation data. + * + * Protects against accidental misuse of what would otherwise be a partially-implemented `IScope` + * interface. New operations against the underlying `IScope` instance must be added here. Operations + * not directly delegable to `IScope` should ***not*** be added. + */ +internal interface INavScope { + + var screen: String? + + /** + * A copy of the scope's app context, if any. + * + * A copy rather than the live instance, because callers mutate the result before binding it to a + * transaction, and mutating the scope's own [App] would corrupt shared scope state. + * + * Implementations may have to copy more than the [App] to produce it, so treat this as expensive: + * read it once where it's needed rather than per frame or in a loop. See [NavScope.app]. + */ + val app: App? + + /** + * The transaction bound to the scope, if any, as far as starting a nav transaction is concerned: + * see [NavScope.transaction]. + */ + val transaction: ITransaction? + + /** Binds [transaction] to the scope if no transaction is bound to it. */ + fun bindTransaction(transaction: ITransaction) + + /** + * Unbinds [transaction] from the scope if it's the bound transaction, and ensures it won't be + * bound to the scope again. + */ + fun unbindTransaction(transaction: ITransaction) + + /** Unbinds the scope's transaction if it has finished. */ + fun clearFinishedTransaction() + + /** + * Sets or, given `null`, clears the value stored under [NAVIGATION_CONTEXT_KEY]. + * + * Implementations should clear via [IScope.setContexts] rather than [IScope.removeContexts], as + * the latter doesn't notify scope observers and therefore doesn't persist the removal to disk. + */ + fun setNavigationContext(value: Any?) + + /** Replaces the scope's propagation context with a new one. */ + fun rotatePropagationContext() +} diff --git a/sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/lifecycle/NavScopes.kt b/sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/lifecycle/NavScopes.kt new file mode 100644 index 00000000000..92fdb80b52f --- /dev/null +++ b/sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/lifecycle/NavScopes.kt @@ -0,0 +1,375 @@ +package io.sentry.compose.navigation3.lifecycle + +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.Lifecycle.Event.ON_DESTROY +import androidx.lifecycle.Lifecycle.Event.ON_PAUSE +import androidx.lifecycle.Lifecycle.Event.ON_RESUME +import androidx.lifecycle.Lifecycle.Event.ON_START +import androidx.lifecycle.Lifecycle.Event.ON_STOP +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.LifecycleOwner +import io.sentry.Breadcrumb +import io.sentry.Hint +import io.sentry.IScope +import io.sentry.IScopes +import io.sentry.ITransaction +import io.sentry.NoOpTransaction +import io.sentry.SentryLevel +import io.sentry.SentryOptions +import io.sentry.TransactionContext +import io.sentry.TransactionOptions + +/** The key under which nav context (i.e., the captured back stack) is stored in a scope. */ +internal const val NAVIGATION_CONTEXT_KEY: String = "Navigation" + +/** + * An [INavScopes] implementation that lets us coordinate updates to navigation state across + * multiple `SentryNavEffect`s. (Also supports the trivial scenario where only a single + * `SentryNavEffect` exists.) + * + * Needed because all `SentryNavEffect`s mutate a single [IScope] instance by default and will + * clobber or interleave one another's updates unless managed properly: + * ``` + * LifecycleOwner (e.g., Activity or Fragment) + * ↓ owns + * SentryNavEffect + * ↓ + * CoordinatedBackStackObserver (wrapper) + * ├── BackStackObserver — computes nav data and nav transactions + * └── NavScopes — controls if nav data + txns reach the real scope + * ↓ + * NavLeaseCoordinator — arbitrates ownership across all observers + * ↓ + * shared IScope + * ``` + * + * **Limitations** + * + * Multi-effect support is restricted to the scenarios documented in the + * [Navigation for Android docs](https://docs.sentry.io/platforms/android/integrations/navigation/#supported-setups). + * + * This class implements the policy that underlies those scenarios. It assumes that, at any moment, + * the user is interacting with at most one registered [LifecycleOwner] in the host app, and that + * the `LifecycleOwner` being interacted with is the one that was most recently resumed. + * + * Concretely, a host app can safely use more than one `SentryNavEffect` only if: + * + * 1. each `SentryNavEffect` is called in the same composable as the `NavDisplay` it observes; + * 2. no `SentryNavEffect` is called from inside a `NavDisplay`, e.g., from a nav entry, scene, or + * entry decorator†; + * 3. each Activity or Fragment calls at most one `SentryNavEffect` from its own content (sibling + * Fragments may each have their own effect, but not alongside one in the Activity hosting them); + * and + * 4. the user can interact with only one of those Activities or Fragments at a time. + * + * † Meant to ensure each effect's `LocalLifecycleOwner` is an Activity or a Fragment and not a + * `NavDisplay`-provided owner. `NavDisplay` provides its own `LocalLifecycleOwner` to the entries + * and scenes it renders. That owner drops to STARTED state during transitions and while overlays + * are shown. An effect called inside a `NavDisplay` would therefore (wrongly) gain and lose the nav + * lease this class manages as navigation animates, rather than as its Activity or Fragment comes + * and goes. + * + * **A fundamental tradeoff** + * + * During `LifecycleOwner` overlap, the nav transaction and scoped nav data can reflect + * **_either_**: + * + * 1. the outgoing owner that's still visible, or + * 2. the incoming owner that may crash before it becomes visible, + * + * but not both. This class opts for (2) so that the incoming destination populates crash context + * and any work performed is associated with it† – the idea being that start-up logic is more likely + * to cause a crash or produce interesting spans than an outgoing destination's steady state. + * + * † Associating work with the incoming transaction means spans produced by the outgoing owner may + * appear as if they were produced by the incoming owner (or vice versa, were our policy reversed). + * A possible solution would be to implement a parent span delivery system similar to + * `LocalSentrySpan`, but that's a task for another day. + * + * **Only leaseholders publish nav state or start nav transactions** + * + * This class works by only allowing `NavScopes` instances holding a lease to start nav transactions + * and publish their navigation data to the shared `IScope`. + * + * `NavScopes` instances without a lease write to their own copy of [stagedNavData], which lets them + * preserve their latest updates without overwriting another instance's published data. When an + * instance acquires the lease, it publishes its staged data to the shared `IScope`. + * + * **Lease handoff policy** + * + * The lease is acquired preemptively by an incoming owner's `NavScopes` instance when it applies + * its first [back stack update][onBackStackUpdate].† + * + * A displaced owner remains eligible for lease restoration until it [stops][ON_STOP]. It gets the + * lease back when it [resumes][ON_RESUME], or sooner if the new owner leaves or pauses while the + * displaced owner is still visible. If restored, the displaced owner's nav data and unfinished + * transaction are reinstated. + * + * † There are some exceptions, e.g., the leaseholder is resumed and the incoming owner isn't (e.g., + * a preloaded ViewPager2 Fragment page next to the current one). Such an owner acquires the lease + * when it resumes instead. + * + * The following diagram illustrates how handoff works: + * ``` + * Time + * │ + * ▼ + * + * SentryNavEffect A Shared state SentryNavEffect B + * ───────────────── ──────────── ───────────────── + * + * attach() Navigation lease: none + * │ + * Initial back stack update + * │ + * ├── acquire lease ────────► Navigation lease: A + * ├── create & bind txA ────► Scope transaction: txA + * ├── publish nav data ─────► Published data: A + * │ + * ON_RESUME A is eligible for restoration + * │ + * ON_PAUSE + * │ + * ├────────────────────────► Navigation lease: A (no other resumed owner) + * │ A remains eligible for restoration + * │ + * │ attach() + * │ │ + * │ Navigation lease: A ◄────────────────────┤ + * │ Scope transaction: txA │ + * │ Published data: A │ + * │ B registered, awaiting initial back │ + * │ stack update │ + * │ │ + * │ Initial back stack update + * │ │ + * │ Suspend & unbind txA ◄───────────────────┤ + * │ Navigation lease: B │ + * │ Published data: B │ + * │ Scope transaction: txB │ + * │ (rollback point) │ + * │ │ + * │ ON_RESUME + * │ │ + * │ B eligible for restoration ◄─────────────┤ + * │ │ + * ON_STOP │ + * │ │ + * ├── retain txA; timers continue │ + * ├────────────────────────► A no longer eligible for restoration │ + * │ │ + * ON_DESTROY │ + * │ │ + * ├── finish retained txA if unfinished │ + * └────────────────────────► A no longer tracked │ + * ON_PAUSE + * │ + * Navigation lease: B (no other resumed ◄─┤ + * owner) │ + * │ + * ON_STOP + * │ + * Navigation lease: B (no other resumed ◄─┤ + * owner); B keeps recording navigation │ + * B no longer eligible for restoration │ + * │ + * ON_DESTROY + * │ + * Finish txB if unfinished; ◄──────────────┤ + * unbind if still bound │ + * Clear B's remaining screen/back stack │ + * Retain recorded breadcrumbs │ + * Navigation lease: none │ + * ▼ + * + * Alternative at rollback point: B disposes before A stops + * Finish & unbind txB ◄────── dispose B ───┤ + * Navigation lease: A │ + * Restore txA if unfinished and scope free │ + * Republish A's staged data │ + * ``` + * + * **Usage** + * + * 1. Create a `NavScopes` instance per `SentryNavEffect`. + * + * 2. Pass the same [NavLeaseCoordinator] instance to all `NavScopes` whose effects must be + * coordinated (i.e., that target the same `IScope`). + * + * 3. Call [attach] with the lifecycle that manages the effect before the first back stack update. + * + * 4. Call [onBackStackUpdate] before applying each back stack update. + * + * 5. Call [dispose] when the effect leaves the composition. + * + * **Thread safety** + * + * This class is ***not*** thread-safe. Lifecycle callbacks and method invocations should be + * confined to the same thread. + */ +internal class NavScopes( + private val delegate: IScopes, + private val coordinator: NavLeaseCoordinator, +) : INavScopes, LifecycleEventObserver { + + override val options: SentryOptions + get() = delegate.options + + private val owner: Any = Any() + private var attachedLifecycle: Lifecycle? = null + private val stagedNavData = StagedNavData() + + /** + * Registers this [NavScopes] instance with [lifecycle]. No-ops if `lifecycle` has already been + * attached or is [destroyed][Lifecycle.Event.ON_DESTROY]. + * + * Attaching to a second, different lifecycle is unsupported; it's logged and ignored. + */ + fun attach(lifecycle: Lifecycle) { + val currentLifecycle = attachedLifecycle + if (currentLifecycle != null && currentLifecycle !== lifecycle) { + delegate.options.logger.log( + SentryLevel.WARNING, + "NavScopes is already attached to a different lifecycle; ignoring the new lifecycle.", + ) + return + } + + if (currentLifecycle === lifecycle) { + return + } + if (lifecycle.currentState == Lifecycle.State.DESTROYED) { + return + } + + attachedLifecycle = lifecycle + + coordinator.registerCandidate( + owner = owner, + lifecycleState = lifecycle.currentState, + onPublishData = stagedNavData::publishTo, + onClearPublishedData = stagedNavData::clearPublishedStateFrom, + ) + + lifecycle.addObserver(this) + } + + override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) { + when (event) { + ON_START -> coordinator.startCandidate(owner) + ON_RESUME -> withScopeOrNull { coordinator.resumeCandidate(owner, it) } + ON_PAUSE -> withScopeOrNull { coordinator.pauseCandidate(owner, it) } + ON_STOP -> withScopeOrNull { coordinator.stopCandidate(owner, it) } + ON_DESTROY -> dispose() + else -> Unit + } + } + + /** + * Announces that a back stack update is about to be applied, so this instance can acquire the nav + * lease if it's eligible to. + * + * Must be called before each update. If [configureScope][IScopes.configureScope] skips its + * callback (e.g., the SDK is disabled), the update itself can't be applied either, so nothing is + * recorded and the next update applied is treated as this instance's first. + */ + fun onBackStackUpdate() { + delegate.configureScope { scope -> coordinator.onBackStackUpdate(owner, scope) } + } + + override fun configureScope(callback: (INavScope) -> Unit) { + delegate.configureScope { scope -> + callback(NavScope(scope, stagedNavData, coordinator, owner)) + } + } + + /** + * Starts a nav transaction, provided this instance holds the nav lease. + * + * Without the lease, returns a no-op transaction and defers the propagation context rotation a + * new transaction would have implied until the lease is acquired. A transaction started without + * the lease couldn't be bound to the scope, so its destination's spans would attach to the + * leaseholder's bound transaction instead, and the empty transaction would be dropped. + * + * The [coordinator] retains the leaseholder's transaction, so it can be restored if this instance + * loses the lease and regains it while the transaction is still unfinished. + */ + override fun startTransaction( + transactionContext: TransactionContext, + transactionOptions: TransactionOptions, + ): ITransaction { + if (!coordinator.hasLease(owner)) { + stagedNavData.deferPropagationContextRotation() + return NoOpTransaction.getInstance() + } + + return delegate.startTransaction(transactionContext, transactionOptions).also { transaction -> + if (!transaction.isNoOp) { + coordinator.retainTransaction(owner, transaction) + } + } + } + + /** Records [breadcrumb] immediately; breadcrumbs only append, so they aren't lease-protected. */ + override fun addBreadcrumb(breadcrumb: Breadcrumb, hint: Hint) { + delegate.addBreadcrumb(breadcrumb, hint) + } + + /** + * Releases the lease, clears remaining published state, and finishes this instance's transaction. + * + * Also stops observing the attached [LifecycleOwner] and cleans up memory references. + */ + fun dispose() { + attachedLifecycle?.removeObserver(this) + attachedLifecycle = null + + // Clear nav state, unbind the nav transaction, and avoid leaking the LifecycleOwner. + withScopeOrNull { coordinator.unregisterCandidate(owner, it) } + } + + /** + * Runs [block] with the real scope, or with `null` if [configureScope][IScopes.configureScope] + * skips its callback (e.g., the SDK is disabled), so coordinator bookkeeping still happens. + * + * A callback that ran but threw is not retried, as retrying could let its exception escape into + * the host app. + */ + private fun withScopeOrNull(block: (IScope?) -> Unit) { + var callbackInvoked = false + delegate.configureScope { scope -> + callbackInvoked = true + block(scope) + } + + if (!callbackInvoked) { + block(null) + } + } +} + +/** + * The subset of the [IScopes] interface used to record navigation data. + * + * Protects against accidental misuse of what would otherwise be a partially-implemented `IScopes` + * interface. New operations against the underlying `IScopes` instance must be added here. + * Operations not directly delegable to `IScopes` should ***not*** be added. + */ +internal interface INavScopes { + + val options: SentryOptions + + /** Runs [callback] with the current [INavScope], if the SDK provides a scope. */ + fun configureScope(callback: (INavScope) -> Unit) + + /** + * Starts a transaction, or returns a no-op transaction if none can be started (e.g., tracing is + * disabled, or the implementation declines to start one; see [NavScopes.startTransaction]). + */ + fun startTransaction( + transactionContext: TransactionContext, + transactionOptions: TransactionOptions, + ): ITransaction + + fun addBreadcrumb(breadcrumb: Breadcrumb, hint: Hint) +} diff --git a/sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/lifecycle/README.md b/sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/lifecycle/README.md new file mode 100644 index 00000000000..7d697812ecf --- /dev/null +++ b/sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/lifecycle/README.md @@ -0,0 +1,12 @@ +# `io.sentry.compose.navigation3.lifecycle` + +Package contains code for coordinating multiple `SentryNavEffect`s and their access to a shared `IScope`, so that overlapping effects (e.g., +during Activity or Fragment transitions) don't clobber one another's screen names, nav context, or nav transactions. + +See the `NavScopes` KDoc for the lease policy that decides which effect may write to the scope. + +## Encapsulation policy + +This package is meant to encapsulate knowledge about the possibility of multiple `SentryNavEffect`s and the shared mutable state issues it +raises. Addressing those concerns is complicated. Confining them here makes them easier to reason about and easier to swap for alternative +logic should we someday need to. diff --git a/sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/lifecycle/StagedNavData.kt b/sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/lifecycle/StagedNavData.kt new file mode 100644 index 00000000000..a32cc74d506 --- /dev/null +++ b/sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/lifecycle/StagedNavData.kt @@ -0,0 +1,106 @@ +package io.sentry.compose.navigation3.lifecycle + +import io.sentry.IScope +import io.sentry.PropagationContext + +/** + * Holds navigation state staged for publication. + * + * Only state that occupies a single slot in the shared [IScope] is staged. Nav events (i.e., + * breadcrumbs) only append to the scope, so they can't overwrite another instance's data and are + * recorded immediately, irrespective of lease ownership. + * + * Propagation context isn't staged either, since an earlier propagation context mustn't overwrite + * the scope's current one. Instead, a rotation requested without the lease, or implied by a nav + * transaction that couldn't be started without it, is [deferred][deferPropagationContextRotation] + * and performed once when the lease is acquired. As a result, any destination reached in the + * meantime still gets a fresh trace. + */ +internal class StagedNavData { + + /** The staged screen name, published or not. */ + var screen: String? = null + private set + + private var navigationContext: Any? = null + + private var hasNavigationContextUpdate = false + private var hasScreenUpdate = false + + /** Whether a propagation context rotation was requested while this instance lacked the lease. */ + private var hasDeferredPropagationContextRotation = false + + // These may differ from the staged values after background updates. We keep the exact published + // references so cleanup preserves host replacements, including structurally equal replacements. + private var publishedNavigationContext: Any? = null + private var publishedScreen: String? = null + + /** Stages [screen] for publication, replacing any previously staged screen. */ + fun stageScreen(screen: String?) { + this.screen = screen + hasScreenUpdate = true + } + + /** Stages [value] as the nav context for publication, replacing any previously staged value. */ + fun stageNavigationContext(value: Any?) { + navigationContext = value + hasNavigationContextUpdate = true + } + + /** Requests a propagation context rotation for when this instance next [publishes][publishTo]. */ + fun deferPropagationContextRotation() { + hasDeferredPropagationContextRotation = true + } + + /** + * Publishes the staged screen and nav context, and performs any deferred propagation context + * rotation. + * + * Only staged values are published, so values the host app or other integrations set (e.g., a + * screen name while screen tracking is off) survive. Values published by the previous leaseholder + * are cleared separately, via its [clearPublishedStateFrom]. + */ + fun publishTo(realScope: IScope) { + if (hasScreenUpdate) { + publishScreen(realScope) + } + if (hasNavigationContextUpdate) { + publishNavigationContext(realScope) + } + if (hasDeferredPropagationContextRotation) { + realScope.withPropagationContext { realScope.propagationContext = PropagationContext() } + hasDeferredPropagationContextRotation = false + } + } + + /** Publishes the staged screen to [realScope]. */ + fun publishScreen(realScope: IScope) { + publishedScreen = screen + realScope.screen = screen + } + + /** Publishes the staged nav context to [realScope]. */ + fun publishNavigationContext(realScope: IScope) { + publishedNavigationContext = navigationContext + realScope.setContexts(NAVIGATION_CONTEXT_KEY, navigationContext) + } + + /** + * Clears the screen and nav context this instance published, unless something else has replaced + * them in [realScope] since. + */ + fun clearPublishedStateFrom(realScope: IScope) { + if (publishedScreen != null && realScope.screen === publishedScreen) { + realScope.screen = null + } + if ( + publishedNavigationContext != null && + realScope.contexts[NAVIGATION_CONTEXT_KEY] === publishedNavigationContext + ) { + // Use setContexts so scope observers also receive the removal. + realScope.setContexts(NAVIGATION_CONTEXT_KEY, null as Any?) + } + publishedScreen = null + publishedNavigationContext = null + } +} diff --git a/sentry-android-navigation3/src/test/kotlin/io/sentry/compose/navigation3/BackStackObserverTest.kt b/sentry-android-navigation3/src/test/kotlin/io/sentry/compose/navigation3/BackStackObserverTest.kt index 0c7cdc9f534..d331afabe69 100644 --- a/sentry-android-navigation3/src/test/kotlin/io/sentry/compose/navigation3/BackStackObserverTest.kt +++ b/sentry-android-navigation3/src/test/kotlin/io/sentry/compose/navigation3/BackStackObserverTest.kt @@ -1,5 +1,8 @@ package io.sentry.compose.navigation3 +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.LifecycleRegistry import com.google.common.truth.Truth.assertThat import io.sentry.Breadcrumb import io.sentry.Hint @@ -16,6 +19,9 @@ import io.sentry.SentryTracer import io.sentry.TransactionContext import io.sentry.TransactionOptions import io.sentry.compose.navigation3.ArgumentDropReason.Companion.ARGUMENT_DROP_REASON_KEY +import io.sentry.compose.navigation3.lifecycle.NAVIGATION_CONTEXT_KEY +import io.sentry.compose.navigation3.lifecycle.NavLeaseCoordinator +import io.sentry.compose.navigation3.lifecycle.NavScopes import io.sentry.protocol.App import io.sentry.protocol.TransactionNameSource import org.junit.Test @@ -90,7 +96,7 @@ class BackStackObserverTest { scope.options.isEnableScreenTracking = config.enableScreenTracking return BackStackObserver( - scopes = scopes, + scopes = leaseholderNavScopes(), options = SentryNavOptions { enableNavigationBreadcrumbs = config.enableNavigationBreadcrumbs @@ -102,6 +108,23 @@ class BackStackObserverTest { ) } + /** + * Real [NavScopes] that hold the nav lease for the whole test, so [BackStackObserver] writes + * straight through to [scope] without lease coordination getting in the way. + */ + private fun leaseholderNavScopes(): NavScopes { + val owner = + object : LifecycleOwner { + override val lifecycle = + LifecycleRegistry.createUnsafe(this).apply { currentState = Lifecycle.State.RESUMED } + } + return NavScopes(scopes, NavLeaseCoordinator()).apply { + attach(owner.lifecycle) + // The sole resumed owner's first update acquires the lease, which it then keeps. + onBackStackUpdate() + } + } + private companion object { fun createOptions(logger: ILogger): SentryOptions = SentryOptions().apply { @@ -741,22 +764,60 @@ class BackStackObserverTest { } @Test - fun `cleanup clears observer owned tracked state`() { + fun `dispose finishes the active nav transaction and unbinds it from the scope`() { val fixture = Fixture() val sut = fixture.getSut() - sut.onBackStackChanged(listOf(HomeScreen())) val transaction = fixture.startedTransactions.single() - sut.cleanup() + sut.dispose() assertThat(transaction.isFinished).isTrue() assertThat(fixture.scope.transaction).isNull() + } + + @Test + fun `dispose clears the screen name and back stack context it recorded`() { + val fixture = Fixture() + val sut = fixture.getSut() + sut.onBackStackChanged(listOf(HomeScreen(), ProfileScreen("123"))) + + sut.dispose() + assertThat(fixture.scope.screen).isNull() assertThat(fixture.scope.contexts.app?.viewNames).isNull() assertThat(fixture.scope.contexts.containsKey(NAVIGATION_CONTEXT_KEY)).isFalse() } + @Test + fun `dispose keeps a screen name that was set after the observer recorded its own`() { + val fixture = Fixture() + val sut = fixture.getSut() + sut.onBackStackChanged(listOf(HomeScreen())) + fixture.scope.screen = "/HostScreen" + + sut.dispose() + + assertThat(fixture.scope.screen).isEqualTo("/HostScreen") + } + + @Test + fun `dispose makes the next back stack update a first navigation with no from entry`() { + val fixture = Fixture() + val sut = fixture.getSut() + val home = HomeScreen() + sut.onBackStackChanged(listOf(home)) + + sut.dispose() + sut.onBackStackChanged(listOf(home)) + + assertThat(fixture.breadcrumbs).hasSize(2) + assertThat(fixture.breadcrumbs.last().data).containsExactly("to", "/HomeScreen") + assertThat(fixture.startedTransactions).hasSize(2) + assertThat(fixture.scope.transaction).isSameInstanceAs(fixture.startedTransactions.last()) + assertThat(fixture.scope.screen).isEqualTo("/HomeScreen") + } + private fun IScope.navigationBackStack(): List>? { val navigationContext = contexts[NAVIGATION_CONTEXT_KEY] as? Map<*, *> ?: return null diff --git a/sentry-android-navigation3/src/test/kotlin/io/sentry/compose/navigation3/CoordinatedBackStackObserverTest.kt b/sentry-android-navigation3/src/test/kotlin/io/sentry/compose/navigation3/CoordinatedBackStackObserverTest.kt new file mode 100644 index 00000000000..08c2003fbbf --- /dev/null +++ b/sentry-android-navigation3/src/test/kotlin/io/sentry/compose/navigation3/CoordinatedBackStackObserverTest.kt @@ -0,0 +1,174 @@ +package io.sentry.compose.navigation3 + +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.LifecycleRegistry +import com.google.common.truth.Truth.assertThat +import io.sentry.IScopes +import io.sentry.Scope +import io.sentry.ScopeCallback +import io.sentry.SentryOptions +import io.sentry.SentryTracer +import io.sentry.TransactionContext +import io.sentry.TransactionOptions +import io.sentry.compose.navigation3.lifecycle.NAVIGATION_CONTEXT_KEY +import io.sentry.compose.navigation3.lifecycle.NavLeaseCoordinator +import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.doAnswer +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever + +class CoordinatedBackStackObserverTest { + + @Test + fun `onBackStackChanged() - acquires the nav lease before applying the first update`() { + val fixture = Fixture() + fixture.givenPausedLeaseholder() + val sut = fixture.getSut() + + sut.onBackStackChanged(listOf("Home")) + + // An update applied before the lease is acquired is staged and gets no nav transaction. + assertThat(fixture.scope.transaction).isSameInstanceAs(fixture.startedTransactions.single()) + } + + @Test + fun `a resumed owner doesn't rebind a nav transaction it retired by navigating without the nav lease`() { + val fixture = Fixture() + val homeLifecycle = fixture.resumedLifecycle() + val homeObserver = fixture.getSut(homeLifecycle) + val pickerObserver = fixture.getSut() + homeObserver.onBackStackChanged(listOf("Home")) + val homeTransaction = fixture.startedTransaction("/Home") + // A pending child span keeps the nav transaction, which waits for its children, unfinished. + homeTransaction.startChild("http.client") + homeLifecycle.currentState = Lifecycle.State.STARTED + pickerObserver.onBackStackChanged(listOf("Picker")) + homeObserver.onBackStackChanged(listOf("Home", "Result")) + check(!homeTransaction.isFinished) + + homeLifecycle.currentState = Lifecycle.State.RESUMED + + assertThat(fixture.scope.screen).isEqualTo("/Result") + // "/Result" was reached without the nav lease, so it has no nav transaction to bind. + assertThat(fixture.scope.transaction).isNull() + } + + @Test + fun `dispose() - clears the nav data it published`() { + val fixture = Fixture() + val sut = fixture.getSut() + sut.onBackStackChanged(listOf("Home")) + + sut.dispose() + + assertThat(fixture.scope.screen).isNull() + } + + @Test + fun `dispose() - without the nav lease, leaves the leaseholder's nav data and nav transaction alone`() { + val fixture = Fixture() + val homeObserver = fixture.getSut() + val settingsObserver = fixture.getSut() + homeObserver.onBackStackChanged(listOf("Home")) + // The settings observer's owner is also resumed, so it takes the nav lease from the home one. + settingsObserver.onBackStackChanged(listOf("Settings")) + val settingsTransaction = fixture.startedTransaction("/Settings") + + homeObserver.dispose() + + assertThat(fixture.scope.screen).isEqualTo("/Settings") + assertThat(fixture.scope.contexts[NAVIGATION_CONTEXT_KEY]) + .isEqualTo(mapOf("Back Stack" to listOf(mapOf("entry" to "/Settings")))) + assertThat(fixture.scope.transaction).isSameInstanceAs(settingsTransaction) + assertThat(settingsTransaction.isFinished).isFalse() + } + + @Test + fun `dispose() - after its lifecycle is destroyed, leaves the leaseholder's nav data and nav transaction alone`() { + val fixture = Fixture() + val homeLifecycle = fixture.resumedLifecycle() + val homeObserver = fixture.getSut(homeLifecycle) + val settingsObserver = fixture.getSut() + homeObserver.onBackStackChanged(listOf("Home")) + homeLifecycle.currentState = Lifecycle.State.STARTED + settingsObserver.onBackStackChanged(listOf("Settings")) + val settingsTransaction = fixture.startedTransaction("/Settings") + // Destroying the lifecycle before the composition is disposed unregisters the home observer's + // owner from the coordinator ahead of its dispose() call. + homeLifecycle.currentState = Lifecycle.State.DESTROYED + + homeObserver.dispose() + + assertThat(fixture.scope.screen).isEqualTo("/Settings") + assertThat(fixture.scope.contexts[NAVIGATION_CONTEXT_KEY]) + .isEqualTo(mapOf("Back Stack" to listOf(mapOf("entry" to "/Settings")))) + assertThat(fixture.scope.transaction).isSameInstanceAs(settingsTransaction) + assertThat(settingsTransaction.isFinished).isFalse() + } + + private class Fixture { + val scope = + Scope( + SentryOptions.empty().apply { + dsn = "http://key@localhost/proj" + tracesSampleRate = 1.0 + idleTimeout = null + deadlineTimeout = 0 + } + ) + val coordinator = NavLeaseCoordinator() + val startedTransactions = mutableListOf() + private val scopes = + mock().also { scopes -> + whenever(scopes.options).thenReturn(scope.options) + doAnswer { + (it.arguments[0] as ScopeCallback).run(scope) + null + } + .whenever(scopes) + .configureScope(any()) + doAnswer { + val transactionContext = it.arguments[0] as TransactionContext + val transactionOptions = it.arguments[1] as TransactionOptions + SentryTracer(transactionContext, scopes, transactionOptions) + .also(startedTransactions::add) + } + .whenever(scopes) + .startTransaction(any(), any()) + } + + fun startedTransaction(name: String): SentryTracer = startedTransactions.single { + it.name == name + } + + /** Registers another owner that takes the nav lease and then pauses, keeping the lease. */ + fun givenPausedLeaseholder() { + val other = Any() + coordinator.registerCandidate(other, Lifecycle.State.RESUMED, {}, {}) + coordinator.onBackStackUpdate(other, scope) + coordinator.pauseCandidate(other, scope) + check(coordinator.hasLease(other)) + } + + fun getSut(lifecycle: Lifecycle = resumedLifecycle()): CoordinatedBackStackObserver = + CoordinatedBackStackObserver( + scopes = scopes, + options = SentryNavOptions(), + entryMapper = + ForwardingBackStackEntryMapper { + BackStackEntryMapper { SentryBackStackEntry(it) } + }, + coordinator = coordinator, + ) + .apply { attach(lifecycle) } + + fun resumedLifecycle(): LifecycleRegistry = + object : LifecycleOwner { + override val lifecycle = + LifecycleRegistry.createUnsafe(this).apply { currentState = Lifecycle.State.RESUMED } + } + .lifecycle + } +} diff --git a/sentry-android-navigation3/src/test/kotlin/io/sentry/compose/navigation3/SentryNavEffectLifecycleIntegrationTest.kt b/sentry-android-navigation3/src/test/kotlin/io/sentry/compose/navigation3/SentryNavEffectLifecycleIntegrationTest.kt new file mode 100644 index 00000000000..7034ba29f68 --- /dev/null +++ b/sentry-android-navigation3/src/test/kotlin/io/sentry/compose/navigation3/SentryNavEffectLifecycleIntegrationTest.kt @@ -0,0 +1,817 @@ +package io.sentry.compose.navigation3 + +import android.app.Activity +import android.app.Application +import android.content.ComponentName +import android.content.Context +import android.os.Bundle +import android.os.Looper +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.FrameLayout +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.snapshots.SnapshotStateList +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.fragment.app.DialogFragment +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentActivity +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.viewpager2.adapter.FragmentStateAdapter +import androidx.viewpager2.widget.ViewPager2 +import com.google.common.truth.Truth.assertThat +import io.sentry.IScope +import io.sentry.ISentryClient +import io.sentry.Scope +import io.sentry.ScopeType +import io.sentry.Scopes +import io.sentry.SentryOptions +import io.sentry.compose.navigation3.lifecycle.NAVIGATION_CONTEXT_KEY +import io.sentry.compose.navigation3.lifecycle.NavLeaseCoordinator +import org.junit.After +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.robolectric.Robolectric +import org.robolectric.Shadows.shadowOf +import org.robolectric.android.controller.ActivityController +import org.robolectric.annotation.Config + +/** + * Drives real Activities and Fragments through Robolectric so that each `SentryNavEffect` observes + * the lifecycle events, and the composition timing, that Android produces when screens hand off to + * one another. Lets us test the multi-[SentryNavEffect] policy implemented by our + * `io.sentry.compose.navigation3.lifecycle` package. + * + * Activity transitions are driven through [ActivityController]s in the order the framework uses + * (e.g., the outgoing Activity pauses before the incoming one is created, and stops after the + * incoming one resumes and becomes visible). Fragment transitions are left entirely to the real + * FragmentManager (and, for ViewPager2 pages, to FragmentStateAdapter). Compositions run when a + * ComposeView attaches to its window, as on a device. + */ +@RunWith(AndroidJUnit4::class) +@Config(sdk = [30]) +class SentryNavEffectLifecycleIntegrationTest { + + @After + fun tearDown() { + Fixture.current = null + } + + // Activity handoffs + @Test + fun `an incoming activity publishes its own nav data and transaction over the outgoing activity's`() { + val fixture = Fixture.create() + val home = Robolectric.buildActivity(HomeActivity::class.java).setup() + fixture.idle() + val homeTransaction = requireNotNull(fixture.scope.transaction) + + home.pause() + Robolectric.buildActivity(CheckoutActivity::class.java).setup() + fixture.idle() + + assertThat(fixture.events) + .containsAtLeast("Home:ON_PAUSE", "Checkout:ON_RESUME", "Checkout:composed(RESUMED)") + .inOrder() + assertThat(fixture.scope.screen).isEqualTo("/Checkout") + assertThat(fixture.scope.transaction?.name).isEqualTo("/Checkout") + assertThat(fixture.scope.transaction).isNotSameInstanceAs(homeTransaction) + assertThat(fixture.scope.navigationEntries()).containsExactly("/Checkout") + assertThat(fixture.scope.breadcrumbs.map { it.data["to"] }) + .containsExactly("/Home", "/Checkout") + .inOrder() + } + + @Test + fun `an incoming activity's nav data and transaction survive the outgoing activity stopping`() { + val fixture = Fixture.create() + val home = Robolectric.buildActivity(HomeActivity::class.java).setup() + fixture.idle() + home.pause() + Robolectric.buildActivity(CheckoutActivity::class.java).setup() + fixture.idle() + val checkoutTransaction = requireNotNull(fixture.scope.transaction) + + home.stop() + fixture.idle() + + assertThat(fixture.scope.screen).isEqualTo("/Checkout") + assertThat(fixture.scope.transaction).isSameInstanceAs(checkoutTransaction) + assertThat(checkoutTransaction.isFinished).isFalse() + assertThat(fixture.scope.navigationEntries()).containsExactly("/Checkout") + } + + @Test + fun `an incoming activity's nav data and transaction survive the outgoing activity being destroyed`() { + val fixture = Fixture.create() + val home = Robolectric.buildActivity(HomeActivity::class.java).setup() + fixture.idle() + val homeTransaction = requireNotNull(fixture.scope.transaction) + home.pause() + Robolectric.buildActivity(CheckoutActivity::class.java).setup() + fixture.idle() + val checkoutTransaction = requireNotNull(fixture.scope.transaction) + val propagationContext = fixture.scope.propagationContext + + home.stop() + home.destroy() + fixture.idle() + + assertThat(homeTransaction.isFinished).isTrue() + assertThat(fixture.scope.screen).isEqualTo("/Checkout") + assertThat(fixture.scope.transaction).isSameInstanceAs(checkoutTransaction) + assertThat(checkoutTransaction.isFinished).isFalse() + assertThat(fixture.scope.navigationEntries()).containsExactly("/Checkout") + assertThat(fixture.scope.propagationContext).isSameInstanceAs(propagationContext) + } + + @Test + fun `going back to a stopped activity restores its nav data and transaction`() { + val fixture = Fixture.create() + val home = Robolectric.buildActivity(HomeActivity::class.java).setup() + fixture.idle() + val homeTransaction = requireNotNull(fixture.scope.transaction) + home.pause() + val checkout = Robolectric.buildActivity(CheckoutActivity::class.java).setup() + fixture.idle() + val checkoutTransaction = requireNotNull(fixture.scope.transaction) + home.stop() + fixture.idle() + + checkout.pause() + home.restart() + home.resume() + fixture.idle() + checkout.stop() + checkout.destroy() + fixture.idle() + + assertThat(fixture.scope.screen).isEqualTo("/Home") + assertThat(fixture.scope.navigationEntries()).containsExactly("/Home") + assertThat(checkoutTransaction.isFinished).isTrue() + assertThat(fixture.scope.transaction).isSameInstanceAs(homeTransaction) + assertThat(homeTransaction.isFinished).isFalse() + } + + @Test + fun `closing an activity that only paused the one beneath it restores the one beneath's nav data and transaction`() { + val fixture = Fixture.create() + val home = Robolectric.buildActivity(HomeActivity::class.java).setup() + fixture.idle() + val homeTransaction = requireNotNull(fixture.scope.transaction) + home.pause() + val checkout = Robolectric.buildActivity(CheckoutActivity::class.java).setup() + fixture.idle() + val checkoutTransaction = requireNotNull(fixture.scope.transaction) + + checkout.pause() + home.resume() + fixture.idle() + checkout.stop() + checkout.destroy() + fixture.idle() + + assertThat(fixture.events).doesNotContain("Home:ON_STOP") + assertThat(fixture.scope.screen).isEqualTo("/Home") + assertThat(fixture.scope.navigationEntries()).containsExactly("/Home") + assertThat(checkoutTransaction.isFinished).isTrue() + assertThat(fixture.scope.transaction).isSameInstanceAs(homeTransaction) + } + + // Activity recreation + @Test + fun `a recreated activity publishes its own nav data and transaction over the destroyed instance's`() { + val fixture = Fixture.create() + val home = Robolectric.buildActivity(HomeActivity::class.java).setup() + fixture.idle() + val destroyedTransaction = requireNotNull(fixture.scope.transaction) + fixture.events.clear() + + home.recreate() + fixture.idle() + + assertThat(fixture.events) + .containsAtLeast("Home:ON_DESTROY", "Home:ON_CREATE", "Home:composed(RESUMED)") + .inOrder() + assertThat(fixture.scope.screen).isEqualTo("/Home") + assertThat(fixture.scope.transaction?.name).isEqualTo("/Home") + assertThat(fixture.scope.transaction).isNotSameInstanceAs(destroyedTransaction) + assertThat(fixture.scope.navigationEntries()).containsExactly("/Home") + } + + @Test + fun `recreating an activity finishes the destroyed instance's transaction`() { + val fixture = Fixture.create() + val home = Robolectric.buildActivity(HomeActivity::class.java).setup() + fixture.idle() + val destroyedTransaction = requireNotNull(fixture.scope.transaction) + + home.recreate() + fixture.idle() + + assertThat(destroyedTransaction.isFinished).isTrue() + } + + // A single Activity going to the background and back + @Test + fun `a backgrounded activity's nav data and transaction stay on the scope while the activity is stopped`() { + val fixture = Fixture.create() + val home = Robolectric.buildActivity(HomeActivity::class.java).setup() + fixture.idle() + val homeTransaction = requireNotNull(fixture.scope.transaction) + + home.pause() + home.stop() + fixture.idle() + + assertThat(fixture.scope.screen).isEqualTo("/Home") + assertThat(fixture.scope.navigationEntries()).containsExactly("/Home") + assertThat(fixture.scope.transaction).isSameInstanceAs(homeTransaction) + } + + @Test + fun `navigating after an activity returns to the foreground publishes the destination's nav data and transaction`() { + val fixture = Fixture.create() + val home = Robolectric.buildActivity(HomeActivity::class.java).setup() + fixture.idle() + val homeTransaction = requireNotNull(fixture.scope.transaction) + home.pause() + home.stop() + fixture.idle() + home.restart() + home.resume() + fixture.idle() + + fixture.backStack("Home") += "Profile" + fixture.idle() + + assertThat(homeTransaction.isFinished).isTrue() + assertThat(fixture.scope.screen).isEqualTo("/Profile") + assertThat(fixture.scope.transaction?.name).isEqualTo("/Profile") + assertThat(fixture.scope.navigationEntries()).containsExactly("/Profile", "/Home").inOrder() + } + + @Test + fun `navigating before a restarted activity resumes publishes the destination's nav data and transaction`() { + val fixture = Fixture.create() + val home = Robolectric.buildActivity(HomeActivity::class.java).setup() + fixture.idle() + val homeTransaction = requireNotNull(fixture.scope.transaction) + home.pause() + home.stop() + fixture.idle() + home.restart() + fixture.idle() + + fixture.backStack("Home") += "Profile" + fixture.idle() + + assertThat(fixture.events) + .containsAtLeast("Home:ON_PAUSE", "Home:ON_STOP", "Home:ON_START") + .inOrder() + assertThat(fixture.events.last()).isEqualTo("Home:ON_START") + assertThat(homeTransaction.isFinished).isTrue() + assertThat(fixture.scope.screen).isEqualTo("/Profile") + assertThat(fixture.scope.transaction?.name).isEqualTo("/Profile") + assertThat(fixture.scope.navigationEntries()).containsExactly("/Profile", "/Home").inOrder() + } + + @Test + fun `destroying an activity that navigated before resuming finishes its transaction and clears its nav data`() { + val fixture = Fixture.create() + val home = Robolectric.buildActivity(HomeActivity::class.java).setup() + fixture.idle() + home.pause() + home.stop() + fixture.idle() + home.restart() + fixture.idle() + fixture.backStack("Home") += "Profile" + fixture.idle() + val profileTransaction = requireNotNull(fixture.scope.transaction) + + home.stop() + home.destroy() + fixture.idle() + + assertThat(fixture.events.takeLast(2)) + .containsExactly("Home:ON_STOP", "Home:ON_DESTROY") + .inOrder() + assertThat(profileTransaction.name).isEqualTo("/Profile") + assertThat(profileTransaction.isFinished).isTrue() + assertThat(fixture.scope.transaction).isNull() + assertThat(fixture.scope.screen).isNull() + assertThat(fixture.scope.navigationEntries()).isNull() + } + + // Fragment replacement + @Test + fun `replacing a fragment publishes the incoming fragment's nav data after the outgoing view is destroyed`() { + val fixture = Fixture.create() + val host = Robolectric.buildActivity(FragmentHostActivity::class.java).setup() + fixture.idle() + val homeTransaction = requireNotNull(fixture.scope.transaction) + + host + .get() + .supportFragmentManager + .beginTransaction() + .replace(FragmentHostActivity.CONTAINER_ID, CheckoutFragment()) + .addToBackStack(null) + .commit() + fixture.idle() + + assertThat(fixture.events) + .containsAtLeast("Home:view:ON_STOP", "Checkout:composed(CREATED)", "Home:view:ON_DESTROY") + .inOrder() + assertThat(homeTransaction.isFinished).isTrue() + assertThat(fixture.scope.screen).isEqualTo("/Checkout") + assertThat(fixture.scope.transaction?.name).isEqualTo("/Checkout") + assertThat(fixture.scope.navigationEntries()).containsExactly("/Checkout") + } + + @Test + fun `popping a replaced fragment restores the first fragment's nav data`() { + val fixture = Fixture.create() + val host = Robolectric.buildActivity(FragmentHostActivity::class.java).setup() + fixture.idle() + val fragmentManager = host.get().supportFragmentManager + fragmentManager + .beginTransaction() + .replace(FragmentHostActivity.CONTAINER_ID, CheckoutFragment()) + .addToBackStack(null) + .commit() + fixture.idle() + val checkoutTransaction = requireNotNull(fixture.scope.transaction) + fixture.events.clear() + + fragmentManager.popBackStack() + fixture.idle() + + assertThat(fixture.events) + .containsAtLeast( + "Checkout:view:ON_STOP", + "Home:composed(CREATED)", + "Checkout:view:ON_DESTROY", + ) + .inOrder() + assertThat(checkoutTransaction.isFinished).isTrue() + assertThat(fixture.scope.screen).isEqualTo("/Home") + assertThat(fixture.scope.transaction?.name).isEqualTo("/Home") + assertThat(fixture.scope.navigationEntries()).containsExactly("/Home") + } + + // ViewPager2 pages + @Test + fun `a preloaded view pager page does not displace the current page's nav data and transaction`() { + val fixture = Fixture.create() + + Robolectric.buildActivity(ViewPagerActivity::class.java).setup() + fixture.idle() + + assertThat(fixture.events) + .containsAtLeast( + "Home:view:ON_RESUME", + "Checkout:composed(CREATED)", + "Checkout:view:ON_START", + ) + .inOrder() + assertThat(fixture.events).doesNotContain("Checkout:view:ON_RESUME") + assertThat(fixture.scope.screen).isEqualTo("/Home") + assertThat(fixture.scope.transaction?.name).isEqualTo("/Home") + assertThat(fixture.scope.navigationEntries()).containsExactly("/Home") + } + + @Test + fun `switching to a preloaded view pager page publishes the incoming page's nav data`() { + val fixture = Fixture.create() + val activity = Robolectric.buildActivity(ViewPagerActivity::class.java).setup() + fixture.idle() + fixture.events.clear() + + activity.get().viewPager.setCurrentItem(1, false) + fixture.idle() + + assertThat(fixture.events) + .containsExactly("Home:view:ON_PAUSE", "Checkout:view:ON_RESUME") + .inOrder() + assertThat(fixture.scope.screen).isEqualTo("/Checkout") + assertThat(fixture.scope.navigationEntries()).containsExactly("/Checkout") + } + + @Test + fun `switching to a preloaded view pager page publishes a new propagation context instead of a nav transaction`() { + val fixture = Fixture.create() + val activity = Robolectric.buildActivity(ViewPagerActivity::class.java).setup() + fixture.idle() + val propagationContext = fixture.scope.propagationContext + + activity.get().viewPager.setCurrentItem(1, false) + fixture.idle() + + assertThat(fixture.scope.transaction).isNull() + assertThat(fixture.scope.propagationContext).isNotSameInstanceAs(propagationContext) + } + + // Dialogs + @Test + fun `a dialog fragment shown over a fragment publishes its own nav data while the fragment stays resumed`() { + val fixture = Fixture.create() + val host = Robolectric.buildActivity(FragmentHostActivity::class.java).setup() + fixture.idle() + val homeTransaction = requireNotNull(fixture.scope.transaction) + + PickerDialogFragment().show(host.get().supportFragmentManager, "picker") + fixture.idle() + + assertThat(fixture.events).contains("Picker:composed(RESUMED)") + assertThat(fixture.events).doesNotContain("Home:view:ON_PAUSE") + assertThat(fixture.scope.screen).isEqualTo("/Picker") + assertThat(fixture.scope.transaction?.name).isEqualTo("/Picker") + assertThat(fixture.scope.transaction).isNotSameInstanceAs(homeTransaction) + assertThat(fixture.scope.navigationEntries()).containsExactly("/Picker") + } + + @Test + fun `dismissing a dialog fragment restores the still-resumed fragment's nav data and transaction`() { + val fixture = Fixture.create() + val host = Robolectric.buildActivity(FragmentHostActivity::class.java).setup() + fixture.idle() + val homeTransaction = requireNotNull(fixture.scope.transaction) + val dialog = PickerDialogFragment() + dialog.show(host.get().supportFragmentManager, "picker") + fixture.idle() + val pickerTransaction = requireNotNull(fixture.scope.transaction) + + dialog.dismiss() + fixture.idle() + + assertThat(fixture.events).contains("Picker:view:ON_DESTROY") + assertThat(fixture.events.filter { it == "Home:view:ON_RESUME" }).hasSize(1) + assertThat(fixture.scope.screen).isEqualTo("/Home") + assertThat(fixture.scope.navigationEntries()).containsExactly("/Home") + assertThat(pickerTransaction.isFinished).isTrue() + assertThat(fixture.scope.transaction).isSameInstanceAs(homeTransaction) + assertThat(homeTransaction.isFinished).isFalse() + } + + @Test + fun `a dialog fragment shown over an activity publishes its own nav data while the activity stays resumed`() { + val fixture = Fixture.create() + val home = Robolectric.buildActivity(HomeActivity::class.java).setup() + fixture.idle() + val homeTransaction = requireNotNull(fixture.scope.transaction) + + PickerDialogFragment().show(home.get().supportFragmentManager, "picker") + fixture.idle() + + assertThat(fixture.events).contains("Picker:composed(RESUMED)") + assertThat(fixture.events).doesNotContain("Home:ON_PAUSE") + assertThat(fixture.scope.screen).isEqualTo("/Picker") + assertThat(fixture.scope.transaction?.name).isEqualTo("/Picker") + assertThat(fixture.scope.transaction).isNotSameInstanceAs(homeTransaction) + assertThat(fixture.scope.navigationEntries()).containsExactly("/Picker") + } + + @Test + fun `dismissing a dialog fragment restores the still-resumed activity's nav data and transaction`() { + val fixture = Fixture.create() + val home = Robolectric.buildActivity(HomeActivity::class.java).setup() + fixture.idle() + val homeTransaction = requireNotNull(fixture.scope.transaction) + val dialog = PickerDialogFragment() + dialog.show(home.get().supportFragmentManager, "picker") + fixture.idle() + val pickerTransaction = requireNotNull(fixture.scope.transaction) + + dialog.dismiss() + fixture.idle() + + assertThat(fixture.events).contains("Picker:view:ON_DESTROY") + assertThat(fixture.events.filter { it == "Home:ON_RESUME" }).hasSize(1) + assertThat(fixture.scope.screen).isEqualTo("/Home") + assertThat(fixture.scope.navigationEntries()).containsExactly("/Home") + assertThat(pickerTransaction.isFinished).isTrue() + assertThat(fixture.scope.transaction).isSameInstanceAs(homeTransaction) + assertThat(homeTransaction.isFinished).isFalse() + } + + // Screens without an effect + + @Test + fun `an activity without an effect keeps the outgoing activity's nav data and transaction on the scope`() { + val fixture = Fixture.create() + val home = Robolectric.buildActivity(HomeActivity::class.java).setup() + fixture.idle() + val homeTransaction = requireNotNull(fixture.scope.transaction) + + home.pause() + Robolectric.buildActivity(PlainActivity::class.java).setup() + fixture.idle() + home.stop() + fixture.idle() + + assertThat(fixture.events) + .containsAtLeast("Home:ON_PAUSE", "Plain:ON_RESUME", "Home:ON_STOP") + .inOrder() + assertThat(fixture.scope.screen).isEqualTo("/Home") + assertThat(fixture.scope.navigationEntries()).containsExactly("/Home") + assertThat(fixture.scope.transaction).isSameInstanceAs(homeTransaction) + assertThat(homeTransaction.isFinished).isFalse() + } + + @Test + fun `navigating after closing an activity without an effect publishes the destination's nav data and transaction`() { + val fixture = Fixture.create() + val home = Robolectric.buildActivity(HomeActivity::class.java).setup() + fixture.idle() + val homeTransaction = requireNotNull(fixture.scope.transaction) + home.pause() + val plain = Robolectric.buildActivity(PlainActivity::class.java).setup() + fixture.idle() + home.stop() + fixture.idle() + plain.pause() + home.restart() + home.resume() + fixture.idle() + plain.stop() + plain.destroy() + fixture.idle() + + fixture.backStack("Home") += "Profile" + fixture.idle() + + assertThat(homeTransaction.isFinished).isTrue() + assertThat(fixture.scope.screen).isEqualTo("/Profile") + assertThat(fixture.scope.transaction?.name).isEqualTo("/Profile") + assertThat(fixture.scope.transaction?.isFinished).isFalse() + assertThat(fixture.scope.navigationEntries()).containsExactly("/Profile", "/Home").inOrder() + } + + @Test + fun `replacing a fragment with one without an effect clears the outgoing fragment's nav data and transaction`() { + val fixture = Fixture.create() + val host = Robolectric.buildActivity(FragmentHostActivity::class.java).setup() + fixture.idle() + val homeTransaction = requireNotNull(fixture.scope.transaction) + + host + .get() + .supportFragmentManager + .beginTransaction() + .replace(FragmentHostActivity.CONTAINER_ID, PlainFragment()) + .addToBackStack(null) + .commit() + fixture.idle() + + assertThat(fixture.events) + .containsAtLeast("Home:view:ON_STOP", "Plain:view:ON_CREATE", "Home:view:ON_DESTROY") + .inOrder() + assertThat(homeTransaction.isFinished).isTrue() + assertThat(fixture.scope.transaction).isNull() + assertThat(fixture.scope.screen).isNull() + assertThat(fixture.scope.navigationEntries()).isNull() + } + + @Test + fun `popping a fragment without an effect republishes the replaced fragment's nav data with a new transaction`() { + val fixture = Fixture.create() + val host = Robolectric.buildActivity(FragmentHostActivity::class.java).setup() + fixture.idle() + val destroyedTransaction = requireNotNull(fixture.scope.transaction) + val fragmentManager = host.get().supportFragmentManager + fragmentManager + .beginTransaction() + .replace(FragmentHostActivity.CONTAINER_ID, PlainFragment()) + .addToBackStack(null) + .commit() + fixture.idle() + fixture.events.clear() + + fragmentManager.popBackStack() + fixture.idle() + + assertThat(fixture.events) + .containsAtLeast("Plain:view:ON_STOP", "Home:composed(CREATED)", "Plain:view:ON_DESTROY") + .inOrder() + assertThat(fixture.scope.screen).isEqualTo("/Home") + assertThat(fixture.scope.navigationEntries()).containsExactly("/Home") + assertThat(fixture.scope.transaction?.name).isEqualTo("/Home") + assertThat(fixture.scope.transaction).isNotSameInstanceAs(destroyedTransaction) + assertThat(fixture.scope.transaction?.isFinished).isFalse() + } + + private fun IScope.navigationEntries(): List? { + val navigationContext = contexts[NAVIGATION_CONTEXT_KEY] as? Map<*, *> ?: return null + val backStack = navigationContext["Back Stack"] as? List<*> ?: return null + return backStack.map { (it as? Map<*, *>)?.get("entry") } + } + + internal open class NavEffectActivity(private val route: String) : FragmentActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val fixture = Fixture.require() + lifecycle.addObserver(fixture.eventRecorder(route)) + setContentView(navEffectView(this, route)) + } + } + + internal class HomeActivity : NavEffectActivity("Home") + + internal class CheckoutActivity : NavEffectActivity("Checkout") + + internal class PlainActivity : FragmentActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + lifecycle.addObserver(Fixture.require().eventRecorder("Plain")) + setContentView(FrameLayout(this)) + } + } + + internal class FragmentHostActivity : FragmentActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(FrameLayout(this).apply { id = CONTAINER_ID }) + if (savedInstanceState == null) { + supportFragmentManager.beginTransaction().add(CONTAINER_ID, HomeFragment()).commit() + } + } + + companion object { + val CONTAINER_ID: Int = View.generateViewId() + } + } + + internal class ViewPagerActivity : FragmentActivity() { + + lateinit var viewPager: ViewPager2 + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + lifecycle.addObserver(Fixture.require().eventRecorder("ViewPager")) + viewPager = + ViewPager2(this).apply { + // Preload the page next to the current one. FragmentStateAdapter caps a preloaded page + // at STARTED and resumes only the current page. + offscreenPageLimit = 1 + adapter = PagesAdapter(this@ViewPagerActivity) + } + setContentView(viewPager) + } + + private class PagesAdapter(activity: FragmentActivity) : FragmentStateAdapter(activity) { + + override fun getItemCount(): Int = 2 + + override fun createFragment(position: Int): Fragment = + if (position == 0) HomeFragment() else CheckoutFragment() + } + } + + internal open class NavEffectFragment(private val route: String) : Fragment() { + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View = navEffectView(requireContext(), route) + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + viewLifecycleOwner.lifecycle.addObserver(Fixture.require().eventRecorder("$route:view")) + } + } + + internal class HomeFragment : NavEffectFragment("Home") + + internal class CheckoutFragment : NavEffectFragment("Checkout") + + internal class PlainFragment : Fragment() { + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View = FrameLayout(requireContext()) + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + viewLifecycleOwner.lifecycle.addObserver(Fixture.require().eventRecorder("Plain:view")) + } + } + + internal class PickerDialogFragment : DialogFragment() { + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View = navEffectView(requireContext(), "Picker") + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + viewLifecycleOwner.lifecycle.addObserver(Fixture.require().eventRecorder("Picker:view")) + } + } + + private class Fixture private constructor() { + + val options = + SentryOptions().apply { + dsn = "http://key@localhost/1" + defaultScopeType = ScopeType.CURRENT + setTracesSampleRate(1.0) + isEnableScreenTracking = true + idleTimeout = null + deadlineTimeout = 0 + } + + val scope = Scope(options) + val scopes = Scopes(scope, Scope(options), Scope(options), "navigation lifecycle test") + val coordinator = NavLeaseCoordinator() + val navOptions = SentryNavOptions() + val mapper = BackStackEntryMapper { SentryBackStackEntry(it) } + + /** Lifecycle events and first compositions, in the order they were observed. */ + val events = mutableListOf() + + private val backStacks = mutableMapOf>() + + init { + val client = mock() + whenever(client.isEnabled).thenReturn(true) + scopes.bindClient(client) + } + + fun idle() { + shadowOf(Looper.getMainLooper()).idle() + } + + /** + * The back stack observed by the effect of each Activity or Fragment showing [route], which + * starts out holding just `route`. + */ + fun backStack(route: String): SnapshotStateList = + backStacks.getOrPut(route) { mutableStateListOf(route) } + + fun eventRecorder(name: String) = LifecycleEventObserver { _, event -> + events += "$name:$event" + } + + /** + * The host-app setup under test: one `SentryNavEffect` in an Activity's or Fragment's content. + */ + @Composable + fun NavEffect(route: String) { + val lifecycle = LocalLifecycleOwner.current.lifecycle + DisposableEffect(Unit) { + events += "$route:composed(${lifecycle.currentState})" + onDispose {} + } + SentryNavEffect(backStack(route), mapper, navOptions, scopes, coordinator) + } + + companion object { + var current: Fixture? = null + + fun require(): Fixture = checkNotNull(current) { "Fixture.create() was not called" } + + fun create(): Fixture { + val application: Application = ApplicationProvider.getApplicationContext() + val packageManager = shadowOf(application.packageManager) + listOf( + HomeActivity::class.java, + CheckoutActivity::class.java, + PlainActivity::class.java, + FragmentHostActivity::class.java, + ViewPagerActivity::class.java, + ) + .forEach { activityClass: Class -> + packageManager.addActivityIfNotPresent( + ComponentName(application.packageName, activityClass.name) + ) + } + return Fixture().also { current = it } + } + } + } + + private companion object { + + fun navEffectView(context: Context, route: String): View { + val fixture = Fixture.require() + return ComposeView(context).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { fixture.NavEffect(route) } + } + } + } +} diff --git a/sentry-android-navigation3/src/test/kotlin/io/sentry/compose/navigation3/SentryNavEffectTest.kt b/sentry-android-navigation3/src/test/kotlin/io/sentry/compose/navigation3/SentryNavEffectTest.kt index 7b8a4b99467..32536d28ccf 100644 --- a/sentry-android-navigation3/src/test/kotlin/io/sentry/compose/navigation3/SentryNavEffectTest.kt +++ b/sentry-android-navigation3/src/test/kotlin/io/sentry/compose/navigation3/SentryNavEffectTest.kt @@ -3,11 +3,16 @@ package io.sentry.compose.navigation3 import android.app.Application import android.content.ComponentName import androidx.activity.ComponentActivity +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.LifecycleRegistry +import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 import com.google.common.truth.Truth.assertThat @@ -15,13 +20,18 @@ import io.sentry.Breadcrumb import io.sentry.Hint import io.sentry.IScope import io.sentry.IScopes +import io.sentry.ISentryClient import io.sentry.ITransaction +import io.sentry.PropagationContext import io.sentry.Scope import io.sentry.ScopeCallback +import io.sentry.ScopeType +import io.sentry.Scopes import io.sentry.SentryOptions import io.sentry.SentryTracer import io.sentry.TransactionContext import io.sentry.TransactionOptions +import io.sentry.compose.navigation3.lifecycle.NavLeaseCoordinator import org.junit.Rule import org.junit.Test import org.junit.rules.TestWatcher @@ -36,6 +46,7 @@ import org.robolectric.annotation.Config @RunWith(AndroidJUnit4::class) @Config(sdk = [30]) +@Suppress("LargeClass") class SentryNavEffectTest { private val defaultEntryMapper = @@ -73,6 +84,7 @@ class SentryNavEffectTest { } val scope = Scope(options) val scopes = mock() + val coordinator = NavLeaseCoordinator() val breadcrumbs = mutableListOf() val transactions = mutableListOf() @@ -112,6 +124,7 @@ class SentryNavEffectTest { backStackEntryMapper = defaultEntryMapper, options = SentryNavOptions(), scopes = fixture.scopes, + coordinator = fixture.coordinator, ) } @@ -135,6 +148,7 @@ class SentryNavEffectTest { backStackEntryMapper = defaultEntryMapper, options = SentryNavOptions(), scopes = fixture.scopes, + coordinator = fixture.coordinator, ) } composeRule.waitForIdle() @@ -162,6 +176,7 @@ class SentryNavEffectTest { backStackEntryMapper = defaultEntryMapper, options = SentryNavOptions(), scopes = fixture.scopes, + coordinator = fixture.coordinator, ) } composeRule.waitForIdle() @@ -189,6 +204,7 @@ class SentryNavEffectTest { backStackEntryMapper = defaultEntryMapper, options = SentryNavOptions(), scopes = fixture.scopes, + coordinator = fixture.coordinator, ) } composeRule.waitForIdle() @@ -222,6 +238,7 @@ class SentryNavEffectTest { backStackEntryMapper = defaultEntryMapper, options = SentryNavOptions(), scopes = fixture.scopes, + coordinator = fixture.coordinator, ) } composeRule.waitForIdle() @@ -255,6 +272,7 @@ class SentryNavEffectTest { backStackEntryMapper = defaultEntryMapper, options = SentryNavOptions { maxCapturedBackStackEntries = 0 }, scopes = fixture.scopes, + coordinator = fixture.coordinator, ) } composeRule.waitForIdle() @@ -278,6 +296,7 @@ class SentryNavEffectTest { backStackEntryMapper = defaultEntryMapper, options = SentryNavOptions(), scopes = fixture.scopes, + coordinator = fixture.coordinator, ) } composeRule.waitForIdle() @@ -314,6 +333,7 @@ class SentryNavEffectTest { backStackEntryMapper = defaultEntryMapper, options = SentryNavOptions(), scopes = fixture.scopes, + coordinator = fixture.coordinator, ) val currentTop = backStack.last() @@ -348,6 +368,7 @@ class SentryNavEffectTest { backStackEntryMapper = entryMapper.value, options = SentryNavOptions(), scopes = fixture.scopes, + coordinator = fixture.coordinator, ) } composeRule.waitForIdle() @@ -385,6 +406,7 @@ class SentryNavEffectTest { backStackEntryMapper = entryMapper.value, options = SentryNavOptions(), scopes = fixture.scopes, + coordinator = fixture.coordinator, ) } composeRule.waitForIdle() @@ -417,6 +439,7 @@ class SentryNavEffectTest { backStackEntryMapper = entryMapper.value, options = SentryNavOptions(), scopes = fixture.scopes, + coordinator = fixture.coordinator, ) } composeRule.waitForIdle() @@ -459,6 +482,7 @@ class SentryNavEffectTest { backStackEntryMapper = entryMapper.value, options = SentryNavOptions(), scopes = fixture.scopes, + coordinator = fixture.coordinator, ) } composeRule.waitForIdle() @@ -496,6 +520,7 @@ class SentryNavEffectTest { options = options.value, backStackEntryMapper = defaultEntryMapper, scopes = fixture.scopes, + coordinator = fixture.coordinator, ) } composeRule.waitForIdle() @@ -525,7 +550,9 @@ class SentryNavEffectTest { SentryNavEffect( backStack = backStack, backStackEntryMapper = defaultEntryMapper, + options = SentryNavOptions(), scopes = fixture.scopes, + coordinator = fixture.coordinator, ) } } @@ -543,14 +570,231 @@ class SentryNavEffectTest { assertThat(fixture.scope.contexts.containsKey(NAVIGATION_CONTEXT_KEY)).isFalse() } - private fun IScope.navigationBackStack(): List>? { - val navigationContext = contexts[NAVIGATION_CONTEXT_KEY] as? Map<*, *> ?: return null + @Test + fun `effect publishes initial navigation before its lifecycle owner resumes`() { + val fixture = NavTestFixture() + val owner = NavTestLifecycle() + val options = SentryNavOptions() + composeRule.setContent { + CompositionLocalProvider(LocalLifecycleOwner provides owner) { + SentryNavEffect( + backStack = listOf("Home"), + backStackEntryMapper = BackStackEntryMapper { SentryBackStackEntry(it) }, + options = options, + scopes = fixture.scopes, + coordinator = fixture.coordinator, + ) + } + } + composeRule.runOnIdle { + assertThat(fixture.scope.transaction?.name).isEqualTo("/Home") + assertThat(fixture.scope.screen).isEqualTo("/Home") + assertThat(fixture.scope.breadcrumbs.map { it.data["to"] }).containsExactly("/Home") + owner.resume() + } + composeRule.runOnIdle { + assertThat(fixture.scope.screen).isEqualTo("/Home") + assertThat(fixture.scope.breadcrumbs.map { it.data["to"] }).containsExactly("/Home") + } + } - @Suppress("UNCHECKED_CAST") - return navigationContext[BACKSTACK_KEY] as? List> + @Test + fun `effect records navigation while its lifecycle owner is paused`() { + val fixture = NavTestFixture() + val owner = NavTestLifecycle(Lifecycle.State.RESUMED) + val backStack = mutableStateListOf("Home") + val options = SentryNavOptions() + composeRule.setContent { + CompositionLocalProvider(LocalLifecycleOwner provides owner) { + SentryNavEffect( + backStack = backStack, + backStackEntryMapper = BackStackEntryMapper { SentryBackStackEntry(it) }, + options = options, + scopes = fixture.scopes, + coordinator = fixture.coordinator, + ) + } + } + composeRule.runOnIdle { + owner.pause() + backStack += "Profile" + } + composeRule.runOnIdle { + assertThat(fixture.scope.transaction?.name).isEqualTo("/Profile") + assertThat(fixture.scope.screen).isEqualTo("/Profile") + assertThat(fixture.scope.breadcrumbs.map { it.data["to"] }) + .containsExactly("/Home", "/Profile") + .inOrder() + } } - private fun ITransaction.navigationBackStack(): List>? { + @Test + fun `navigating without the lease records breadcrumbs, then publishes with a fresh trace on resume`() { + val fixture = NavTestFixture() + val ownerA = NavTestLifecycle(Lifecycle.State.RESUMED) + val ownerB = NavTestLifecycle() + val backStackA = mutableStateListOf("Home") + val showB = mutableStateOf(false) + val options = SentryNavOptions() + val mapper = BackStackEntryMapper { SentryBackStackEntry(it) } + composeRule.setContent { + CompositionLocalProvider(LocalLifecycleOwner provides ownerA) { + SentryNavEffect(backStackA, mapper, options, fixture.scopes, fixture.coordinator) + } + if (showB.value) { + CompositionLocalProvider(LocalLifecycleOwner provides ownerB) { + SentryNavEffect(listOf("Picker"), mapper, options, fixture.scopes, fixture.coordinator) + } + } + } + + // A starts B: A pauses, B is created and takes over, B resumes, then A stops. + composeRule.runOnIdle { + ownerA.pause() + showB.value = true + } + composeRule.runOnIdle { + ownerB.resume() + ownerA.stop() + } + // B finishes with a result: B pauses, then A restarts and navigates before it resumes. + composeRule.runOnIdle { + ownerB.pause() + ownerA.start() + backStackA += "Result" + } + lateinit var contextBeforeResume: PropagationContext + composeRule.runOnIdle { + assertThat(fixture.scope.transaction?.name).isEqualTo("/Picker") + assertThat(fixture.scope.screen).isEqualTo("/Picker") + assertThat(fixture.scope.breadcrumbs.map { it.data["to"] }) + .containsExactly("/Home", "/Picker", "/Result") + .inOrder() + contextBeforeResume = fixture.scope.propagationContext + ownerA.resume() + } + + // A gets no nav transaction for /Result: one started without the lease would have collected no + // spans of its own. It publishes its screen and gets the fresh trace a transaction would have. + composeRule.runOnIdle { + assertThat(fixture.scope.transaction).isNull() + assertThat(fixture.scope.screen).isEqualTo("/Result") + assertThat(fixture.scope.propagationContext).isNotSameInstanceAs(contextBeforeResume) + } + } + + @Test + fun `backgrounding with a translucent owner on top keeps the top owner's data published`() { + val fixture = NavTestFixture() + val beneath = NavTestLifecycle(Lifecycle.State.RESUMED) + val translucent = NavTestLifecycle() + val showTranslucent = mutableStateOf(false) + val options = SentryNavOptions() + val mapper = BackStackEntryMapper { SentryBackStackEntry(it) } + composeRule.setContent { + CompositionLocalProvider(LocalLifecycleOwner provides beneath) { + SentryNavEffect(listOf("Products"), mapper, options, fixture.scopes, fixture.coordinator) + } + if (showTranslucent.value) { + CompositionLocalProvider(LocalLifecycleOwner provides translucent) { + SentryNavEffect(listOf("Checkout"), mapper, options, fixture.scopes, fixture.coordinator) + } + } + } + + // A translucent owner opens on top: the owner beneath pauses but stays visible. + composeRule.runOnIdle { + beneath.pause() + showTranslucent.value = true + } + lateinit var checkoutTransaction: ITransaction + composeRule.runOnIdle { + translucent.resume() + checkoutTransaction = requireNotNull(fixture.scope.transaction) + assertThat(checkoutTransaction.name).isEqualTo("/Checkout") + } + + // The app goes to the background: the top owner stops before the one beneath it. + composeRule.runOnIdle { + translucent.pause() + translucent.stop() + beneath.stop() + + assertThat(fixture.scope.screen).isEqualTo("/Checkout") + assertThat(fixture.scope.navigationBackStack()?.map { it["entry"] }) + .containsExactly("/Checkout") + assertThat(fixture.scope.transaction).isSameInstanceAs(checkoutTransaction) + } + } + + @Test + fun `regaining the lease keeps a host screen name when screen tracking is off`() { + val fixture = NavTestFixture() + fixture.options.isEnableScreenTracking = false + val owner = NavTestLifecycle(Lifecycle.State.RESUMED) + val options = SentryNavOptions() + composeRule.setContent { + CompositionLocalProvider(LocalLifecycleOwner provides owner) { + SentryNavEffect( + backStack = listOf("Home"), + backStackEntryMapper = BackStackEntryMapper { SentryBackStackEntry(it) }, + options = options, + scopes = fixture.scopes, + coordinator = fixture.coordinator, + ) + } + } + composeRule.runOnIdle { + fixture.scope.screen = "HostScreen" + + // E.g., the app goes to the background and comes back. + owner.pause() + owner.stop() + owner.start() + owner.resume() + + assertThat(fixture.scope.screen).isEqualTo("HostScreen") + assertThat(fixture.scope.navigationBackStack()?.map { it["entry"] }).containsExactly("/Home") + } + } + + @Test + fun `replacing lifecycle owner disposes the old session and observes the new owner`() { + val fixture = NavTestFixture() + val originalOwner = NavTestLifecycle(Lifecycle.State.RESUMED) + val replacementOwner = NavTestLifecycle() + val owner = mutableStateOf(originalOwner) + val options = SentryNavOptions() + composeRule.setContent { + CompositionLocalProvider(LocalLifecycleOwner provides owner.value) { + SentryNavEffect( + backStack = listOf("Home"), + backStackEntryMapper = BackStackEntryMapper { SentryBackStackEntry(it) }, + options = options, + scopes = fixture.scopes, + coordinator = fixture.coordinator, + ) + } + } + lateinit var originalTransaction: ITransaction + composeRule.runOnIdle { + originalTransaction = requireNotNull(fixture.scope.transaction) + owner.value = replacementOwner + } + composeRule.runOnIdle { + assertThat(originalTransaction.isFinished).isTrue() + assertThat(fixture.scope.transaction).isNotSameInstanceAs(originalTransaction) + assertThat(fixture.scope.transaction?.name).isEqualTo("/Home") + assertThat(fixture.scope.screen).isEqualTo("/Home") + originalOwner.pause() + originalOwner.resume() + assertThat(fixture.scope.screen).isEqualTo("/Home") + replacementOwner.resume() + assertThat(fixture.scope.screen).isEqualTo("/Home") + } + } + + private fun IScope.navigationBackStack(): List>? { val navigationContext = contexts[NAVIGATION_CONTEXT_KEY] as? Map<*, *> ?: return null @Suppress("UNCHECKED_CAST") @@ -562,3 +806,52 @@ class SentryNavEffectTest { const val BACKSTACK_KEY = "Back Stack" } } + +private class NavTestLifecycle(state: Lifecycle.State = Lifecycle.State.CREATED) : LifecycleOwner { + + override val lifecycle = + LifecycleRegistry.createUnsafe(this).apply { + if (state == Lifecycle.State.DESTROYED) currentState = Lifecycle.State.CREATED + currentState = state + } + + fun resume() { + lifecycle.currentState = Lifecycle.State.RESUMED + } + + fun pause() { + lifecycle.currentState = Lifecycle.State.STARTED + } + + fun start() { + lifecycle.currentState = Lifecycle.State.STARTED + } + + fun stop() { + lifecycle.currentState = Lifecycle.State.CREATED + } +} + +/** Real SDK state with only the event delivery boundary stubbed. No timers or global SDK state. */ +private class NavTestFixture { + + val options = + SentryOptions().apply { + dsn = "http://key@localhost/1" + defaultScopeType = ScopeType.CURRENT + setTracesSampleRate(1.0) + idleTimeout = null + deadlineTimeout = 0 + } + + val scope = Scope(options) + val scopes = Scopes(scope, Scope(options), Scope(options), "navigation test") + + val coordinator = NavLeaseCoordinator() + + init { + val client = mock() + whenever(client.isEnabled).thenReturn(true) + scopes.bindClient(client) + } +} diff --git a/sentry-android-navigation3/src/test/kotlin/io/sentry/compose/navigation3/lifecycle/NavLeaseCoordinatorTest.kt b/sentry-android-navigation3/src/test/kotlin/io/sentry/compose/navigation3/lifecycle/NavLeaseCoordinatorTest.kt new file mode 100644 index 00000000000..44986848b5b --- /dev/null +++ b/sentry-android-navigation3/src/test/kotlin/io/sentry/compose/navigation3/lifecycle/NavLeaseCoordinatorTest.kt @@ -0,0 +1,873 @@ +package io.sentry.compose.navigation3.lifecycle + +import androidx.lifecycle.Lifecycle +import com.google.common.truth.Truth.assertThat +import io.sentry.IScope +import io.sentry.ITransaction +import io.sentry.Scope +import io.sentry.SentryOptions +import io.sentry.SpanStatus +import org.junit.Assert.assertThrows +import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.doThrow +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyNoInteractions +import org.mockito.kotlin.whenever + +@Suppress("LargeClass") +class NavLeaseCoordinatorTest { + + private val owner = Any() + private val incoming = Any() + private val incumbent = Any() + private val previous = Any() + + private val transaction = mock() + private val unrelatedTransaction = mock() + + @Test + fun `a registered created owner acquires the nav lease on its first back stack update`() { + val fixture = Fixture.create() + val navLeaseCoordinator = fixture.navLeaseCoordinator + fixture.register(owner, Lifecycle.State.CREATED) + + navLeaseCoordinator.onBackStackUpdate(owner, fixture.scope) + + assertThat(navLeaseCoordinator.hasLease(owner)).isTrue() + assertThat(fixture.published).containsExactly(owner) + } + + @Test + fun `registering the same owner twice preserves its first publication callback`() { + val fixture = Fixture.create() + val navLeaseCoordinator = fixture.navLeaseCoordinator + fixture.register(owner, Lifecycle.State.CREATED) + + navLeaseCoordinator.registerCandidate( + owner, + Lifecycle.State.RESUMED, + { fixture.published += "duplicate" }, + { fixture.cleared += "duplicate" }, + ) + navLeaseCoordinator.onBackStackUpdate(owner, fixture.scope) + + assertThat(fixture.published).containsExactly(owner) + } + + @Test + fun `registering a destroyed owner does not make it eligible for the nav lease`() { + val fixture = Fixture.create() + val navLeaseCoordinator = fixture.navLeaseCoordinator + + fixture.register(owner, Lifecycle.State.DESTROYED) + navLeaseCoordinator.onBackStackUpdate(owner, fixture.scope) + + assertThat(navLeaseCoordinator.hasLease(owner)).isFalse() + assertThat(fixture.published).isEmpty() + } + + @Test + fun `equal but distinct owners do not share the nav lease`() { + val fixture = Fixture.create() + val navLeaseCoordinator = fixture.navLeaseCoordinator + val registeredOwner = String(charArrayOf('a')) + val equalOwner = String(charArrayOf('a')) + fixture.acquireLease(registeredOwner) + + val result = navLeaseCoordinator.hasLease(equalOwner) + + assertThat(result).isFalse() + assertThat(navLeaseCoordinator.hasLease(registeredOwner)).isTrue() + } + + @Test + fun `starting a created owner leaves it eligible to acquire the nav lease on its first back stack update`() { + val fixture = Fixture.create() + val navLeaseCoordinator = fixture.navLeaseCoordinator + fixture.register(owner, Lifecycle.State.CREATED) + + navLeaseCoordinator.startCandidate(owner) + navLeaseCoordinator.onBackStackUpdate(owner, fixture.scope) + + assertThat(navLeaseCoordinator.hasLease(owner)).isTrue() + } + + @Test + fun `restarting a stopped owner does not take the nav lease on its first back stack update`() { + val fixture = Fixture.create() + val navLeaseCoordinator = fixture.navLeaseCoordinator + fixture.register(owner, Lifecycle.State.CREATED) + navLeaseCoordinator.stopCandidate(owner, fixture.scope) + fixture.acquireLease(incumbent) + + navLeaseCoordinator.startCandidate(owner) + navLeaseCoordinator.onBackStackUpdate(owner, fixture.scope) + + assertThat(navLeaseCoordinator.hasLease(incumbent)).isTrue() + assertThat(fixture.published).containsExactly(incumbent) + } + + @Test + fun `resuming an owner before its first back stack update leaves the nav lease unassigned`() { + val fixture = Fixture.create() + val navLeaseCoordinator = fixture.navLeaseCoordinator + fixture.register(owner, Lifecycle.State.CREATED) + + navLeaseCoordinator.resumeCandidate(owner, fixture.scope) + + assertThat(navLeaseCoordinator.hasLease(owner)).isFalse() + assertThat(fixture.published).isEmpty() + } + + @Test + fun `resuming a stopped owner after its first back stack update transfers the nav lease`() { + val fixture = Fixture.create() + val navLeaseCoordinator = fixture.navLeaseCoordinator + fixture.acquireLease(incumbent) + fixture.register(owner, Lifecycle.State.CREATED) + navLeaseCoordinator.stopCandidate(owner, fixture.scope) + navLeaseCoordinator.onBackStackUpdate(owner, fixture.scope) + + navLeaseCoordinator.resumeCandidate(owner, fixture.scope) + + assertThat(navLeaseCoordinator.hasLease(owner)).isTrue() + assertThat(fixture.published).containsExactly(incumbent, owner).inOrder() + } + + @Test + fun `a paused owner cannot acquire the nav lease on its first back stack update`() { + val fixture = Fixture.create() + val navLeaseCoordinator = fixture.navLeaseCoordinator + fixture.register(owner, Lifecycle.State.RESUMED) + navLeaseCoordinator.pauseCandidate(owner, fixture.scope) + + navLeaseCoordinator.onBackStackUpdate(owner, fixture.scope) + + assertThat(navLeaseCoordinator.hasLease(owner)).isFalse() + assertThat(fixture.published).isEmpty() + } + + @Test + fun `an owner that isn't resumed doesn't take the nav lease from a resumed leaseholder`() { + val fixture = Fixture.create() + val navLeaseCoordinator = fixture.navLeaseCoordinator + fixture.acquireLease(incumbent) + + fixture.acquireLease(owner, state = Lifecycle.State.STARTED) + + assertThat(navLeaseCoordinator.hasLease(incumbent)).isTrue() + assertThat(fixture.published).containsExactly(incumbent) + } + + @Test + fun `an owner kept from the nav lease by a resumed leaseholder acquires it when it resumes`() { + val fixture = Fixture.create() + val navLeaseCoordinator = fixture.navLeaseCoordinator + fixture.acquireLease(incumbent) + fixture.acquireLease(owner, state = Lifecycle.State.STARTED) + + navLeaseCoordinator.resumeCandidate(owner, fixture.scope) + + assertThat(navLeaseCoordinator.hasLease(owner)).isTrue() + assertThat(fixture.published).containsExactly(incumbent, owner).inOrder() + } + + @Test + fun `an owner that isn't resumed takes the nav lease from a paused leaseholder`() { + val fixture = Fixture.create() + val navLeaseCoordinator = fixture.navLeaseCoordinator + fixture.acquireLease(incumbent) + navLeaseCoordinator.pauseCandidate(incumbent, fixture.scope) + + fixture.acquireLease(owner, state = Lifecycle.State.CREATED) + + assertThat(navLeaseCoordinator.hasLease(owner)).isTrue() + } + + @Test + fun `an owner that isn't resumed takes the nav lease from a stopped leaseholder`() { + val fixture = Fixture.create() + val navLeaseCoordinator = fixture.navLeaseCoordinator + fixture.acquireLease(incumbent) + navLeaseCoordinator.pauseCandidate(incumbent, fixture.scope) + navLeaseCoordinator.stopCandidate(incumbent, fixture.scope) + + fixture.acquireLease(owner, state = Lifecycle.State.CREATED) + + assertThat(navLeaseCoordinator.hasLease(owner)).isTrue() + assertThat(fixture.published).containsExactly(incumbent, owner).inOrder() + } + + @Test + fun `a resumed owner takes the nav lease from a resumed leaseholder`() { + val fixture = Fixture.create() + val navLeaseCoordinator = fixture.navLeaseCoordinator + fixture.acquireLease(incumbent) + + fixture.acquireLease(owner) + + assertThat(navLeaseCoordinator.hasLease(owner)).isTrue() + } + + @Test + fun `acquiring the nav lease clears the previous owner's published nav data before publishing`() { + val fixture = Fixture.create() + val navLeaseCoordinator = fixture.navLeaseCoordinator + val calls = mutableListOf() + navLeaseCoordinator.registerCandidate( + previous, + Lifecycle.State.RESUMED, + onPublishData = { calls += "publish previous" }, + onClearPublishedData = { calls += "clear previous" }, + ) + navLeaseCoordinator.onBackStackUpdate(previous, fixture.scope) + navLeaseCoordinator.registerCandidate( + incoming, + Lifecycle.State.RESUMED, + onPublishData = { calls += "publish incoming" }, + onClearPublishedData = { calls += "clear incoming" }, + ) + + navLeaseCoordinator.onBackStackUpdate(incoming, fixture.scope) + + assertThat(calls) + .containsExactly("publish previous", "clear previous", "publish incoming") + .inOrder() + } + + @Test + fun `regaining the nav lease doesn't clear the owner's own published nav data`() { + val fixture = Fixture.create() + val navLeaseCoordinator = fixture.navLeaseCoordinator + fixture.acquireLease(previous) + fixture.acquireLease(owner) + navLeaseCoordinator.pauseCandidate(owner, null) + fixture.cleared.clear() + + navLeaseCoordinator.resumeCandidate(owner, fixture.scope) + + assertThat(navLeaseCoordinator.hasLease(owner)).isTrue() + assertThat(fixture.published).containsExactly(previous, owner, owner).inOrder() + assertThat(fixture.cleared).isEmpty() + } + + @Test + fun `releasing the nav lease leaves the owner's published nav data in place`() { + val fixture = Fixture.create() + val navLeaseCoordinator = fixture.navLeaseCoordinator + fixture.acquireLease(previous) + fixture.acquireLease(owner) + fixture.cleared.clear() + + navLeaseCoordinator.pauseCandidate(owner, null) + + assertThat(navLeaseCoordinator.hasLease(owner)).isFalse() + assertThat(fixture.cleared).isEmpty() + } + + /** + * The coordinator records the incoming owner as the publisher before calling into the scope, so a + * clear callback that throws mid-handoff doesn't strand the incoming owner's nav data: the next + * handoff still clears it. + */ + @Test + fun `if a clear attempt throws during a handoff, the next handoff clears the new leaseholder's nav data`() { + val fixture = Fixture.create() + val navLeaseCoordinator = fixture.navLeaseCoordinator + val failure = IllegalStateException("clear failed") + var failClear = true + val firstOwner = previous + val secondOwner = incoming + val thirdOwner = owner + fixture.acquireLease( + firstOwner, + onClearPublishedData = { + if (failClear) { + failClear = false + throw failure + } + fixture.cleared += firstOwner + }, + ) + fixture.register(secondOwner, Lifecycle.State.RESUMED) + + val thrown = + assertThrows(IllegalStateException::class.java) { + navLeaseCoordinator.onBackStackUpdate(secondOwner, fixture.scope) + } + + assertThat(thrown).isSameInstanceAs(failure) + assertThat(navLeaseCoordinator.hasLease(secondOwner)).isTrue() + + fixture.acquireLease(thirdOwner) + + assertThat(fixture.cleared).containsExactly(secondOwner) + } + + @Test + fun `later back stack updates do not republish nav data or reclaim the nav lease`() { + val fixture = Fixture.create() + val navLeaseCoordinator = fixture.navLeaseCoordinator + val first = Any() + val second = Any() + fixture.acquireLease(first) + fixture.acquireLease(second, state = Lifecycle.State.RESUMED) + + navLeaseCoordinator.onBackStackUpdate(first, fixture.scope) + + assertThat(navLeaseCoordinator.hasLease(second)).isTrue() + assertThat(fixture.published).containsExactly(first, second).inOrder() + } + + @Test + fun `pausing the leaseholder hands the nav lease to the most recently resumed owner that's still resumed`() { + val fixture = Fixture.create() + val navLeaseCoordinator = fixture.navLeaseCoordinator + val older = Any() + val recent = Any() + fixture.acquireLease(older) + fixture.acquireLease(recent) + fixture.acquireLease(incoming, state = Lifecycle.State.RESUMED) + + navLeaseCoordinator.pauseCandidate(incoming, fixture.scope) + + assertThat(navLeaseCoordinator.hasLease(recent)).isTrue() + assertThat(fixture.published.last()).isSameInstanceAs(recent) + } + + @Test + fun `pausing the leaseholder keeps the nav lease when other eligible owners are only paused`() { + val fixture = Fixture.create() + val navLeaseCoordinator = fixture.navLeaseCoordinator + fixture.acquireLease(owner) + navLeaseCoordinator.pauseCandidate(owner, fixture.scope) + fixture.acquireLease(incoming) + + navLeaseCoordinator.pauseCandidate(incoming, fixture.scope) + + assertThat(navLeaseCoordinator.hasLease(incoming)).isTrue() + assertThat(fixture.published).containsExactly(owner, incoming).inOrder() + } + + @Test + fun `a displaced paused owner regains the nav lease when it resumes`() { + val fixture = Fixture.create() + val navLeaseCoordinator = fixture.navLeaseCoordinator + fixture.acquireLease(owner) + navLeaseCoordinator.pauseCandidate(owner, fixture.scope) + fixture.acquireLease(incoming) + navLeaseCoordinator.pauseCandidate(incoming, fixture.scope) + + navLeaseCoordinator.resumeCandidate(owner, fixture.scope) + + assertThat(navLeaseCoordinator.hasLease(owner)).isTrue() + assertThat(fixture.published).containsExactly(owner, incoming, owner).inOrder() + } + + @Test + fun `pausing the leaseholder skips a resumed owner without a back stack update`() { + val fixture = Fixture.create() + val navLeaseCoordinator = fixture.navLeaseCoordinator + val eligible = Any() + val uninitialized = Any() + fixture.acquireLease(eligible) + fixture.register(uninitialized, Lifecycle.State.RESUMED) + fixture.acquireLease(incoming, state = Lifecycle.State.RESUMED) + + navLeaseCoordinator.pauseCandidate(incoming, fixture.scope) + + assertThat(navLeaseCoordinator.hasLease(eligible)).isTrue() + assertThat(navLeaseCoordinator.hasLease(uninitialized)).isFalse() + } + + @Test + fun `pausing the only eligible owner keeps the nav lease`() { + val fixture = Fixture.create() + val navLeaseCoordinator = fixture.navLeaseCoordinator + fixture.acquireLease(owner) + + navLeaseCoordinator.pauseCandidate(owner, fixture.scope) + + assertThat(navLeaseCoordinator.hasLease(owner)).isTrue() + assertThat(fixture.published).containsExactly(owner) + } + + @Test + fun `pausing the only eligible owner without a scope keeps the nav lease`() { + val fixture = Fixture.create() + val navLeaseCoordinator = fixture.navLeaseCoordinator + fixture.acquireLease(owner) + + navLeaseCoordinator.pauseCandidate(owner, null) + + assertThat(navLeaseCoordinator.hasLease(owner)).isTrue() + } + + @Test + fun `resuming a paused leaseholder does not republish its nav data`() { + val fixture = Fixture.create() + val navLeaseCoordinator = fixture.navLeaseCoordinator + fixture.acquireLease(owner) + navLeaseCoordinator.pauseCandidate(owner, fixture.scope) + + navLeaseCoordinator.resumeCandidate(owner, fixture.scope) + + assertThat(navLeaseCoordinator.hasLease(owner)).isTrue() + assertThat(fixture.published).containsExactly(owner) + } + + @Test + fun `stopping the leaseholder restores a resumed owner`() { + val fixture = Fixture.create() + val navLeaseCoordinator = fixture.navLeaseCoordinator + fixture.acquireLease(previous) + fixture.acquireLease(owner, state = Lifecycle.State.RESUMED) + + navLeaseCoordinator.stopCandidate(owner, fixture.scope) + + assertThat(navLeaseCoordinator.hasLease(previous)).isTrue() + assertThat(fixture.published.last()).isSameInstanceAs(previous) + } + + @Test + fun `stopping the only eligible owner keeps the nav lease`() { + val fixture = Fixture.create() + val navLeaseCoordinator = fixture.navLeaseCoordinator + fixture.acquireLease(owner) + navLeaseCoordinator.pauseCandidate(owner, fixture.scope) + + navLeaseCoordinator.stopCandidate(owner, fixture.scope) + + assertThat(navLeaseCoordinator.hasLease(owner)).isTrue() + assertThat(fixture.published).containsExactly(owner) + } + + @Test + fun `stopping the only eligible owner without a scope keeps the nav lease`() { + val fixture = Fixture.create() + val navLeaseCoordinator = fixture.navLeaseCoordinator + fixture.acquireLease(owner) + navLeaseCoordinator.pauseCandidate(owner, null) + + navLeaseCoordinator.stopCandidate(owner, null) + + assertThat(navLeaseCoordinator.hasLease(owner)).isTrue() + } + + @Test + fun `stopping the leaseholder doesn't hand the nav lease to a paused owner`() { + val fixture = Fixture.create() + val navLeaseCoordinator = fixture.navLeaseCoordinator + fixture.acquireLease(previous) + navLeaseCoordinator.pauseCandidate(previous, fixture.scope) + fixture.acquireLease(owner) + navLeaseCoordinator.pauseCandidate(owner, fixture.scope) + + navLeaseCoordinator.stopCandidate(owner, fixture.scope) + + assertThat(navLeaseCoordinator.hasLease(owner)).isTrue() + assertThat(navLeaseCoordinator.hasLease(previous)).isFalse() + assertThat(fixture.published).containsExactly(previous, owner).inOrder() + assertThat(fixture.cleared).containsExactly(previous) + } + + @Test + fun `a paused owner passed over when the leaseholder stopped gets the nav lease when it resumes`() { + val fixture = Fixture.create() + val navLeaseCoordinator = fixture.navLeaseCoordinator + fixture.acquireLease(previous) + navLeaseCoordinator.pauseCandidate(previous, fixture.scope) + fixture.acquireLease(owner) + navLeaseCoordinator.pauseCandidate(owner, fixture.scope) + navLeaseCoordinator.stopCandidate(owner, fixture.scope) + + navLeaseCoordinator.resumeCandidate(previous, fixture.scope) + + assertThat(navLeaseCoordinator.hasLease(previous)).isTrue() + assertThat(fixture.published).containsExactly(previous, owner, previous).inOrder() + } + + @Test + fun `restarting a stopped leaseholder keeps the nav lease`() { + val fixture = Fixture.create() + val navLeaseCoordinator = fixture.navLeaseCoordinator + fixture.acquireLease(owner) + navLeaseCoordinator.pauseCandidate(owner, fixture.scope) + navLeaseCoordinator.stopCandidate(owner, fixture.scope) + + navLeaseCoordinator.startCandidate(owner) + + assertThat(navLeaseCoordinator.hasLease(owner)).isTrue() + assertThat(fixture.published).containsExactly(owner) + } + + @Test + fun `stopping an owner excludes it from later nav lease fallback`() { + val fixture = Fixture.create() + val navLeaseCoordinator = fixture.navLeaseCoordinator + fixture.acquireLease(previous) + fixture.acquireLease(owner) + navLeaseCoordinator.pauseCandidate(previous, fixture.scope) + navLeaseCoordinator.stopCandidate(previous, fixture.scope) + + navLeaseCoordinator.unregisterCandidate(owner, fixture.scope) + + assertThat(navLeaseCoordinator.hasLease(previous)).isFalse() + assertThat(fixture.published).containsExactly(previous, owner).inOrder() + } + + @Test + fun `resuming without a scope keeps the owner eligible for nav lease fallback`() { + val fixture = Fixture.create() + val navLeaseCoordinator = fixture.navLeaseCoordinator + fixture.register(owner, Lifecycle.State.CREATED) + navLeaseCoordinator.resumeCandidate(owner, null) + navLeaseCoordinator.onBackStackUpdate(owner, fixture.scope) + fixture.acquireLease(incoming, state = Lifecycle.State.CREATED) + + navLeaseCoordinator.pauseCandidate(incoming, fixture.scope) + + assertThat(navLeaseCoordinator.hasLease(owner)).isTrue() + } + + @Test + fun `resuming without a scope does not transfer the nav lease`() { + val fixture = Fixture.create() + val navLeaseCoordinator = fixture.navLeaseCoordinator + fixture.acquireLease(incumbent) + fixture.register(owner, Lifecycle.State.CREATED) + navLeaseCoordinator.stopCandidate(owner, fixture.scope) + navLeaseCoordinator.onBackStackUpdate(owner, fixture.scope) + + navLeaseCoordinator.resumeCandidate(owner, null) + + assertThat(navLeaseCoordinator.hasLease(incumbent)).isTrue() + assertThat(fixture.published).containsExactly(incumbent) + } + + @Test + fun `pausing the leaseholder without a scope releases the nav lease without publishing a fallback`() { + val fixture = Fixture.create() + val navLeaseCoordinator = fixture.navLeaseCoordinator + fixture.acquireLease(previous) + fixture.acquireLease(owner, state = Lifecycle.State.RESUMED) + + navLeaseCoordinator.pauseCandidate(owner, null) + + assertThat(navLeaseCoordinator.hasLease(owner)).isFalse() + assertThat(navLeaseCoordinator.hasLease(previous)).isFalse() + assertThat(fixture.published).containsExactly(previous, owner).inOrder() + } + + @Test + fun `stopping the leaseholder without a scope releases the nav lease without publishing a fallback`() { + val fixture = Fixture.create() + val navLeaseCoordinator = fixture.navLeaseCoordinator + fixture.acquireLease(previous) + fixture.acquireLease(owner, state = Lifecycle.State.RESUMED) + + navLeaseCoordinator.stopCandidate(owner, null) + + assertThat(navLeaseCoordinator.hasLease(owner)).isFalse() + assertThat(navLeaseCoordinator.hasLease(previous)).isFalse() + assertThat(fixture.published).containsExactly(previous, owner).inOrder() + } + + @Test + fun `an unregistered owner's nav transaction is not retained`() { + val fixture = Fixture.create() + val navLeaseCoordinator = fixture.navLeaseCoordinator + val other = Any() + fixture.acquireLease(owner) + + navLeaseCoordinator.retainTransaction(other, transaction) + navLeaseCoordinator.unregisterCandidate(owner, fixture.scope) + + verifyNoInteractions(transaction) + } + + @Test + fun `a nav transaction recorded without the lease is ignored`() { + val fixture = Fixture.create() + val navLeaseCoordinator = fixture.navLeaseCoordinator + fixture.acquireLease(owner) + fixture.acquireLease(incoming, state = Lifecycle.State.RESUMED) + + navLeaseCoordinator.retainTransaction(owner, transaction) + navLeaseCoordinator.unregisterCandidate(incoming, fixture.scope) + + assertThat(navLeaseCoordinator.hasLease(owner)).isTrue() + assertThat(navLeaseCoordinator.isNavTransaction(transaction)).isFalse() + assertThat(fixture.scope.transaction).isNull() + + navLeaseCoordinator.unregisterCandidate(owner, fixture.scope) + + verifyNoInteractions(transaction) + } + + @Test + fun `an unfinished nav transaction is restored when its owner regains the lease`() { + val fixture = Fixture.create() + val navLeaseCoordinator = fixture.navLeaseCoordinator + fixture.acquireLease(owner, transaction = transaction) + fixture.acquireLease(incoming, state = Lifecycle.State.RESUMED) + + assertThat(fixture.scope.transaction).isNull() + navLeaseCoordinator.pauseCandidate(incoming, fixture.scope) + + assertThat(fixture.scope.transaction).isSameInstanceAs(transaction) + assertThat(navLeaseCoordinator.hasLease(owner)).isTrue() + verify(transaction, never()).finish(any()) + } + + @Test + fun `restoring a nav transaction preserves an unrelated scope transaction`() { + val fixture = Fixture.create() + val navLeaseCoordinator = fixture.navLeaseCoordinator + fixture.acquireLease(owner, transaction = transaction) + fixture.acquireLease(incoming, state = Lifecycle.State.RESUMED) + fixture.scope.transaction = unrelatedTransaction + + navLeaseCoordinator.pauseCandidate(incoming, fixture.scope) + + assertThat(fixture.scope.transaction).isSameInstanceAs(unrelatedTransaction) + } + + @Test + fun `a finished nav transaction is discarded when its owner regains the lease`() { + val fixture = Fixture.create() + val navLeaseCoordinator = fixture.navLeaseCoordinator + fixture.acquireLease(owner, transaction = transaction) + fixture.acquireLease(incoming, state = Lifecycle.State.RESUMED) + whenever(transaction.isFinished).thenReturn(true) + + navLeaseCoordinator.pauseCandidate(incoming, fixture.scope) + + assertThat(fixture.scope.transaction).isNull() + assertThat(navLeaseCoordinator.hasLease(owner)).isTrue() + assertThat(navLeaseCoordinator.isNavTransaction(transaction)).isFalse() + } + + @Test + fun `a released unfinished nav transaction is not restored when its owner regains the lease`() { + val fixture = Fixture.create() + val navLeaseCoordinator = fixture.navLeaseCoordinator + fixture.acquireLease(owner, transaction = transaction) + fixture.acquireLease(incoming, state = Lifecycle.State.RESUMED) + + navLeaseCoordinator.releaseTransaction(owner, transaction) + navLeaseCoordinator.pauseCandidate(incoming, fixture.scope) + + assertThat(navLeaseCoordinator.hasLease(owner)).isTrue() + assertThat(fixture.scope.transaction).isNull() + assertThat(navLeaseCoordinator.isNavTransaction(transaction)).isFalse() + } + + @Test + fun `releasing a nav transaction other than the retained one keeps the retained one restorable`() { + val fixture = Fixture.create() + val navLeaseCoordinator = fixture.navLeaseCoordinator + fixture.acquireLease(owner, transaction = transaction) + fixture.acquireLease(incoming, state = Lifecycle.State.RESUMED) + + navLeaseCoordinator.releaseTransaction(owner, unrelatedTransaction) + navLeaseCoordinator.pauseCandidate(incoming, fixture.scope) + + assertThat(navLeaseCoordinator.hasLease(owner)).isTrue() + assertThat(fixture.scope.transaction).isSameInstanceAs(transaction) + } + + @Test + fun `a displaced owner's retained nav transaction remains identifiable until unregistering`() { + val fixture = Fixture.create() + val navLeaseCoordinator = fixture.navLeaseCoordinator + fixture.acquireLease(owner, transaction = transaction) + fixture.acquireLease(incoming, state = Lifecycle.State.RESUMED) + + assertThat(navLeaseCoordinator.isNavTransaction(transaction)).isTrue() + assertThat(navLeaseCoordinator.isNavTransaction(unrelatedTransaction)).isFalse() + + navLeaseCoordinator.unregisterCandidate(owner, fixture.scope) + + assertThat(navLeaseCoordinator.isNavTransaction(transaction)).isFalse() + } + + @Test + fun `acquiring the nav lease binds its retained transaction before publishing nav data`() { + val fixture = Fixture.create() + val navLeaseCoordinator = fixture.navLeaseCoordinator + val transactionAtPublication = mutableListOf() + navLeaseCoordinator.registerCandidate( + owner, + Lifecycle.State.RESUMED, + onPublishData = { transactionAtPublication += it.transaction }, + onClearPublishedData = {}, + ) + navLeaseCoordinator.onBackStackUpdate(owner, fixture.scope) + navLeaseCoordinator.retainTransaction(owner, transaction) + fixture.scope.transaction = transaction + fixture.acquireLease(incoming, state = Lifecycle.State.RESUMED) + + navLeaseCoordinator.pauseCandidate(incoming, fixture.scope) + + assertThat(navLeaseCoordinator.hasLease(owner)).isTrue() + assertThat(transactionAtPublication).containsExactly(null, transaction).inOrder() + } + + @Test + fun `unregistering the leaseholder finishes its transaction and clears its published nav data`() { + val fixture = Fixture.create() + val navLeaseCoordinator = fixture.navLeaseCoordinator + fixture.acquireLease(owner, transaction = transaction) + + navLeaseCoordinator.unregisterCandidate(owner, fixture.scope) + + verify(transaction).finish(SpanStatus.OK) + assertThat(fixture.scope.transaction).isNull() + assertThat(fixture.cleared).containsExactly(owner) + assertThat(navLeaseCoordinator.hasLease(owner)).isFalse() + } + + @Test + fun `unregistering a former leaseholder preserves the current owner's published nav data`() { + val fixture = Fixture.create() + val navLeaseCoordinator = fixture.navLeaseCoordinator + fixture.acquireLease(previous) + fixture.acquireLease(owner, state = Lifecycle.State.RESUMED) + navLeaseCoordinator.pauseCandidate(owner, fixture.scope) + fixture.cleared.clear() + + navLeaseCoordinator.unregisterCandidate(owner, fixture.scope) + + assertThat(navLeaseCoordinator.hasLease(previous)).isTrue() + assertThat(fixture.cleared).isEmpty() + } + + @Test + fun `unregistering the leaseholder hands the nav lease to the most recently resumed eligible owner`() { + val fixture = Fixture.create() + val navLeaseCoordinator = fixture.navLeaseCoordinator + val older = Any() + fixture.acquireLease(older) + navLeaseCoordinator.pauseCandidate(older, fixture.scope) + fixture.acquireLease(previous) + navLeaseCoordinator.pauseCandidate(previous, fixture.scope) + fixture.acquireLease(owner) + + navLeaseCoordinator.unregisterCandidate(owner, fixture.scope) + + assertThat(navLeaseCoordinator.hasLease(previous)).isTrue() + assertThat(fixture.published.last()).isSameInstanceAs(previous) + assertThat(fixture.cleared).containsExactly(older, previous, owner).inOrder() + } + + @Test + fun `unregistering the leaseholder without a scope releases the nav lease`() { + val fixture = Fixture.create() + val navLeaseCoordinator = fixture.navLeaseCoordinator + fixture.acquireLease(previous) + fixture.acquireLease(owner) + fixture.cleared.clear() + + navLeaseCoordinator.unregisterCandidate(owner, null) + + assertThat(navLeaseCoordinator.hasLease(owner)).isFalse() + assertThat(navLeaseCoordinator.hasLease(previous)).isFalse() + assertThat(fixture.published).containsExactly(previous, owner).inOrder() + assertThat(fixture.cleared).isEmpty() + } + + @Test + fun `unregistering the leaseholder without a scope doesn't finish its nav transaction`() { + val fixture = Fixture.create() + val navLeaseCoordinator = fixture.navLeaseCoordinator + fixture.acquireLease(owner, transaction = transaction) + + navLeaseCoordinator.unregisterCandidate(owner, null) + + verify(transaction, never()).finish(any()) + } + + @Test + fun `unregistering a former leaseholder finishes its retained transaction without clearing an unrelated one`() { + val fixture = Fixture.create() + val navLeaseCoordinator = fixture.navLeaseCoordinator + whenever(transaction.status).thenReturn(SpanStatus.CANCELLED) + fixture.acquireLease(owner, transaction = transaction) + fixture.acquireLease(incoming, state = Lifecycle.State.RESUMED) + fixture.scope.transaction = unrelatedTransaction + fixture.cleared.clear() + + navLeaseCoordinator.unregisterCandidate(owner, fixture.scope) + + verify(transaction).finish(SpanStatus.CANCELLED) + assertThat(fixture.scope.transaction).isSameInstanceAs(unrelatedTransaction) + assertThat(fixture.cleared).isEmpty() + } + + @Test + fun `unregistering the leaseholder forgets the owner when finishing its transaction throws`() { + val fixture = Fixture.create() + val navLeaseCoordinator = fixture.navLeaseCoordinator + val failure = IllegalStateException("finish failed") + fixture.acquireLease(owner, transaction = transaction) + doThrow(failure).whenever(transaction).finish(SpanStatus.OK) + + val thrown = + assertThrows(IllegalStateException::class.java) { + navLeaseCoordinator.unregisterCandidate(owner, fixture.scope) + } + + assertThat(thrown).isSameInstanceAs(failure) + assertThat(navLeaseCoordinator.hasLease(owner)).isFalse() + assertThat(navLeaseCoordinator.isNavTransaction(transaction)).isFalse() + + fixture.published.clear() + navLeaseCoordinator.resumeCandidate(owner, fixture.scope) + fixture.acquireLease(incoming) + + assertThat(fixture.published).containsExactly(incoming) + assertThat(fixture.cleared).isEmpty() + } + + private class Fixture { + val navLeaseCoordinator = NavLeaseCoordinator() + val scope = Scope(SentryOptions()) + val published = mutableListOf() + val cleared = mutableListOf() + + fun register( + owner: Any, + state: Lifecycle.State, + onClearPublishedData: (IScope) -> Unit = { cleared += owner }, + ) { + navLeaseCoordinator.registerCandidate( + owner, + state, + onPublishData = { published += owner }, + onClearPublishedData = onClearPublishedData, + ) + } + + fun acquireLease( + owner: Any, + state: Lifecycle.State = Lifecycle.State.RESUMED, + transaction: ITransaction? = null, + onClearPublishedData: (IScope) -> Unit = { cleared += owner }, + ) { + register(owner, state, onClearPublishedData) + navLeaseCoordinator.onBackStackUpdate(owner, scope) + if (transaction != null) { + navLeaseCoordinator.retainTransaction(owner, transaction) + scope.transaction = transaction + } + } + + companion object { + fun create(): Fixture = Fixture() + } + } +} diff --git a/sentry-android-navigation3/src/test/kotlin/io/sentry/compose/navigation3/lifecycle/NavScopeTest.kt b/sentry-android-navigation3/src/test/kotlin/io/sentry/compose/navigation3/lifecycle/NavScopeTest.kt new file mode 100644 index 00000000000..be66fb5b165 --- /dev/null +++ b/sentry-android-navigation3/src/test/kotlin/io/sentry/compose/navigation3/lifecycle/NavScopeTest.kt @@ -0,0 +1,321 @@ +package io.sentry.compose.navigation3.lifecycle + +import androidx.lifecycle.Lifecycle +import com.google.common.truth.Truth.assertThat +import io.sentry.ITransaction +import io.sentry.Scope +import io.sentry.ScopeObserverAdapter +import io.sentry.SentryOptions +import org.junit.Assert.assertThrows +import org.junit.Test +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever + +class NavScopeTest { + + @Test + fun `getScreen() - reads the real screen with the nav lease`() { + val fixture = Fixture.create(ownsLease = true) + val navScope = fixture.navScope + navScope.screen = "Staged" + fixture.realScope.screen = "Host" + + val screen = navScope.screen + + assertThat(screen).isEqualTo("Host") + } + + @Test + fun `getScreen() - reads the staged screen without the nav lease`() { + val fixture = Fixture.create() + val navScope = fixture.navScope + fixture.realScope.screen = "Host" + fixture.stagedNavData.stageScreen("Staged") + + val screen = navScope.screen + + assertThat(screen).isEqualTo("Staged") + } + + @Test + fun `setScreen() - publishes the screen with the nav lease`() { + val fixture = Fixture.create(ownsLease = true) + val navScope = fixture.navScope + + navScope.screen = "Profile" + + assertThat(fixture.realScope.screen).isEqualTo("Profile") + } + + @Test + fun `setScreen() - stages the screen without the nav lease`() { + val fixture = Fixture.create() + val navScope = fixture.navScope + fixture.realScope.screen = "Host" + + navScope.screen = "Profile" + + assertThat(fixture.realScope.screen).isEqualTo("Host") + assertThat(navScope.screen).isEqualTo("Profile") + } + + @Test + fun `setNavigationContext() - publishes the navigation context with the nav lease`() { + val fixture = Fixture.create(ownsLease = true) + val navScope = fixture.navScope + val navigationContext = mapOf("route" to "Profile") + + navScope.setNavigationContext(navigationContext) + + assertThat(fixture.realScope.contexts[NAVIGATION_CONTEXT_KEY]) + .isSameInstanceAs(navigationContext) + } + + @Test + fun `setNavigationContext() - stages the navigation context without the nav lease`() { + val fixture = Fixture.create() + val navScope = fixture.navScope + val hostContext = mapOf("route" to "Host") + val navigationContext = mapOf("route" to "Profile") + fixture.realScope.setContexts(NAVIGATION_CONTEXT_KEY, hostContext) + + navScope.setNavigationContext(navigationContext) + + assertThat(fixture.realScope.contexts[NAVIGATION_CONTEXT_KEY]).isSameInstanceAs(hostContext) + fixture.stagedNavData.publishTo(fixture.realScope) + assertThat(fixture.realScope.contexts[NAVIGATION_CONTEXT_KEY]) + .isSameInstanceAs(navigationContext) + } + + @Test + fun `rotatePropagationContext() - replaces the real context with the nav lease`() { + val fixture = Fixture.create(ownsLease = true) + val realContext = fixture.realScope.propagationContext + + fixture.navScope.rotatePropagationContext() + + assertThat(fixture.realScope.propagationContext).isNotSameInstanceAs(realContext) + } + + @Test + fun `rotatePropagationContext() - defers rotating the real context without the nav lease`() { + val fixture = Fixture.create() + val realContext = fixture.realScope.propagationContext + + fixture.navScope.rotatePropagationContext() + + assertThat(fixture.realScope.propagationContext).isSameInstanceAs(realContext) + fixture.stagedNavData.publishTo(fixture.realScope) + assertThat(fixture.realScope.propagationContext).isNotSameInstanceAs(realContext) + } + + @Test + fun `bindTransaction() - binds to a free scope with the nav lease`() { + val fixture = Fixture.create(ownsLease = true) + val transaction = mock() + + fixture.navScope.bindTransaction(transaction) + + assertThat(fixture.realScope.transaction).isSameInstanceAs(transaction) + } + + @Test + fun `bindTransaction() - preserves an already bound transaction`() { + val fixture = Fixture.create(ownsLease = true) + val bound = mock() + fixture.realScope.transaction = bound + + fixture.navScope.bindTransaction(mock()) + + assertThat(fixture.realScope.transaction).isSameInstanceAs(bound) + } + + @Test + fun `bindTransaction() - does nothing without the nav lease`() { + val fixture = Fixture.create() + + fixture.navScope.bindTransaction(mock()) + + assertThat(fixture.realScope.transaction).isNull() + } + + @Test + fun `unbindTransaction() - unbinds the given transaction without the nav lease`() { + val fixture = Fixture.create() + val transaction = mock() + fixture.realScope.transaction = transaction + + fixture.navScope.unbindTransaction(transaction) + + assertThat(fixture.realScope.transaction).isNull() + } + + @Test + fun `unbindTransaction() - releases the owner's retained nav transaction with the nav lease`() { + val fixture = Fixture.create(ownsLease = true) + val transaction = mock() + fixture.retainTransaction(transaction) + + fixture.navScope.unbindTransaction(transaction) + + assertThat(fixture.coordinator.isNavTransaction(transaction)).isFalse() + } + + @Test + fun `unbindTransaction() - releases the owner's retained nav transaction without the nav lease`() { + val fixture = Fixture.create(ownsLease = true) + val transaction = mock() + fixture.retainTransaction(transaction) + fixture.giveLeaseToAnotherOwner() + + fixture.navScope.unbindTransaction(transaction) + + assertThat(fixture.coordinator.isNavTransaction(transaction)).isFalse() + } + + @Test + fun `unbindTransaction() - releases the retained nav transaction when a scope observer throws`() { + val fixture = Fixture.create(ownsLease = true) + val transaction = mock() + fixture.retainTransaction(transaction) + fixture.realScope.transaction = transaction + // Registered last, so that only clearTransaction() reaches it. + fixture.realScope.options.addScopeObserver( + object : ScopeObserverAdapter() { + override fun setTransaction(transaction: String?) { + throw ThrowingScopeObserverException() + } + } + ) + + assertThrows(ThrowingScopeObserverException::class.java) { + fixture.navScope.unbindTransaction(transaction) + } + + assertThat(fixture.coordinator.isNavTransaction(transaction)).isFalse() + } + + @Test + fun `unbindTransaction() - preserves a different bound transaction`() { + val fixture = Fixture.create(ownsLease = true) + val bound = mock() + fixture.realScope.transaction = bound + + fixture.navScope.unbindTransaction(mock()) + + assertThat(fixture.realScope.transaction).isSameInstanceAs(bound) + } + + @Test + fun `clearFinishedTransaction() - unbinds a finished transaction with the nav lease`() { + val fixture = Fixture.create(ownsLease = true) + fixture.realScope.transaction = + mock().also { whenever(it.isFinished).thenReturn(true) } + + fixture.navScope.clearFinishedTransaction() + + assertThat(fixture.realScope.transaction).isNull() + } + + @Test + fun `clearFinishedTransaction() - does nothing without the nav lease`() { + val fixture = Fixture.create() + val finished = mock().also { whenever(it.isFinished).thenReturn(true) } + fixture.realScope.transaction = finished + + fixture.navScope.clearFinishedTransaction() + + assertThat(fixture.realScope.transaction).isSameInstanceAs(finished) + } + + @Test + fun `transaction - hides another owner's nav transaction without the nav lease`() { + val fixture = Fixture.create() + val other = Any() + val otherTransaction = mock() + fixture.coordinator.registerCandidate(other, Lifecycle.State.RESUMED, {}, {}) + fixture.coordinator.onBackStackUpdate(other, fixture.realScope) + fixture.coordinator.retainTransaction(other, otherTransaction) + fixture.realScope.transaction = otherTransaction + + val transaction = fixture.navScope.transaction + + assertThat(transaction).isNull() + } + + @Test + fun `transaction - reports a non-nav transaction without the nav lease`() { + val fixture = Fixture.create() + val hostTransaction = mock() + fixture.realScope.transaction = hostTransaction + + val transaction = fixture.navScope.transaction + + assertThat(transaction).isSameInstanceAs(hostTransaction) + } + + @Test + fun `transaction - reports the bound transaction with the nav lease`() { + val fixture = Fixture.create(ownsLease = true) + val boundTransaction = mock() + fixture.realScope.transaction = boundTransaction + + val transaction = fixture.navScope.transaction + + assertThat(transaction).isSameInstanceAs(boundTransaction) + } + + /** Thrown by a scope observer, to stand in for one the host app registered. */ + private class ThrowingScopeObserverException : RuntimeException() + + private class Fixture + private constructor( + val realScope: Scope, + val stagedNavData: StagedNavData, + val coordinator: NavLeaseCoordinator, + val navScope: NavScope, + private val owner: Any, + ) { + + /** Retains [transaction] as the owner's nav transaction; the owner must hold the nav lease. */ + fun retainTransaction(transaction: ITransaction) { + coordinator.retainTransaction(owner, transaction) + check(coordinator.isNavTransaction(transaction)) + } + + /** Lets another resumed owner take the nav lease from the owner. */ + fun giveLeaseToAnotherOwner() { + val other = Any() + coordinator.registerCandidate(other, Lifecycle.State.RESUMED, {}, {}) + coordinator.onBackStackUpdate(other, realScope) + check(!coordinator.hasLease(owner)) + } + + companion object { + + fun create(ownsLease: Boolean = false): Fixture { + val realScope = Scope(SentryOptions.empty()) + val stagedNavData = StagedNavData() + val coordinator = NavLeaseCoordinator() + val owner = Any() + coordinator.registerCandidate( + owner, + Lifecycle.State.RESUMED, + stagedNavData::publishTo, + stagedNavData::clearPublishedStateFrom, + ) + if (ownsLease) { + coordinator.onBackStackUpdate(owner, realScope) + } + + return Fixture( + realScope, + stagedNavData, + coordinator, + NavScope(realScope, stagedNavData, coordinator, owner), + owner, + ) + } + } + } +} diff --git a/sentry-android-navigation3/src/test/kotlin/io/sentry/compose/navigation3/lifecycle/NavScopesTest.kt b/sentry-android-navigation3/src/test/kotlin/io/sentry/compose/navigation3/lifecycle/NavScopesTest.kt new file mode 100644 index 00000000000..4470072b354 --- /dev/null +++ b/sentry-android-navigation3/src/test/kotlin/io/sentry/compose/navigation3/lifecycle/NavScopesTest.kt @@ -0,0 +1,208 @@ +package io.sentry.compose.navigation3.lifecycle + +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.LifecycleRegistry +import com.google.common.truth.Truth.assertThat +import io.sentry.Breadcrumb +import io.sentry.Hint +import io.sentry.IScopes +import io.sentry.ITransaction +import io.sentry.Scope +import io.sentry.ScopeCallback +import io.sentry.SentryOptions +import io.sentry.TransactionContext +import io.sentry.TransactionOptions +import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.doAnswer +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +class NavScopesTest { + + @Test + fun `attach() - ignores a second, different lifecycle`() { + val fixture = Fixture() + val navScopes = fixture.navScopes() + val first = TestLifecycleOwner() + val second = TestLifecycleOwner() + navScopes.attach(first.lifecycle) + + navScopes.attach(second.lifecycle) + + assertThat(first.lifecycle.observerCount).isEqualTo(1) + assertThat(second.lifecycle.observerCount).isEqualTo(0) + } + + @Test + fun `lifecycle callbacks are recorded while scope callbacks are skipped`() { + val fixture = Fixture() + val outgoing = fixture.navScopes() + val outgoingOwner = TestLifecycleOwner() + outgoing.attach(outgoingOwner.lifecycle) + fixture.isEnabled = false + outgoingOwner.lifecycle.currentState = Lifecycle.State.RESUMED + fixture.isEnabled = true + outgoing.applyFirstBackStackUpdate() + + val incoming = fixture.navScopes() + val incomingOwner = TestLifecycleOwner(Lifecycle.State.RESUMED) + incoming.attach(incomingOwner.lifecycle) + incoming.applyFirstBackStackUpdate() + assertThat(fixture.hasLease(incoming)).isTrue() + + incoming.dispose() + + assertThat(fixture.hasLease(outgoing)).isTrue() + } + + @Test + fun `onBackStackUpdate() - an update skipped by the scope does not count as the first update`() { + val fixture = Fixture() + val navScopes = fixture.navScopes() + val owner = TestLifecycleOwner() + navScopes.attach(owner.lifecycle) + fixture.isEnabled = false + navScopes.applyFirstBackStackUpdate() + fixture.isEnabled = true + + navScopes.applyFirstBackStackUpdate() + + assertThat(fixture.hasLease(navScopes)).isTrue() + } + + @Test + fun `addBreadcrumb() - records breadcrumbs immediately without the nav lease`() { + val fixture = Fixture() + val leaseholder = fixture.navScopes() + leaseholder.attach(TestLifecycleOwner().lifecycle) + leaseholder.applyFirstBackStackUpdate() + val other = fixture.navScopes() + other.attach(TestLifecycleOwner().lifecycle) + val breadcrumb = Breadcrumb() + val hint = Hint() + + other.addBreadcrumb(breadcrumb, hint) + + assertThat(fixture.hasLease(other)).isFalse() + verify(fixture.scopes).addBreadcrumb(breadcrumb, hint) + } + + @Test + fun `startTransaction() - returns a no-op transaction without the nav lease`() { + val fixture = Fixture() + val covered = fixture.navScopes() + covered.attach(TestLifecycleOwner(Lifecycle.State.RESUMED).lifecycle) + covered.applyFirstBackStackUpdate() + val incoming = fixture.navScopes() + incoming.attach(TestLifecycleOwner(Lifecycle.State.RESUMED).lifecycle) + incoming.applyFirstBackStackUpdate() + + val transaction = + covered.startTransaction(TransactionContext("name", "op"), TransactionOptions()) + + assertThat(fixture.hasLease(covered)).isFalse() + assertThat(transaction.isNoOp).isTrue() + assertThat(fixture.scope.transaction).isNull() + } + + @Test + fun `startTransaction() - declining without the nav lease defers a propagation context rotation`() { + val fixture = Fixture() + val covered = fixture.navScopes() + covered.attach(TestLifecycleOwner(Lifecycle.State.RESUMED).lifecycle) + covered.applyFirstBackStackUpdate() + val incoming = fixture.navScopes() + incoming.attach(TestLifecycleOwner(Lifecycle.State.RESUMED).lifecycle) + incoming.applyFirstBackStackUpdate() + val incomingContext = fixture.scope.propagationContext + + covered.startTransaction(TransactionContext("name", "op"), TransactionOptions()) + assertThat(fixture.scope.propagationContext).isSameInstanceAs(incomingContext) + + incoming.dispose() + + assertThat(fixture.hasLease(covered)).isTrue() + assertThat(fixture.scope.propagationContext).isNotSameInstanceAs(incomingContext) + } + + @Test + fun `startTransaction() - returns a no-op transaction after dispose`() { + val fixture = Fixture() + val navScopes = fixture.navScopes() + navScopes.attach(TestLifecycleOwner().lifecycle) + navScopes.applyFirstBackStackUpdate() + navScopes.dispose() + + val transaction = + navScopes.startTransaction(TransactionContext("name", "op"), TransactionOptions()) + + assertThat(transaction.isNoOp).isTrue() + } + + @Test + fun `rotatePropagationContext() - a rotation requested without the nav lease happens once the lease is acquired`() { + val fixture = Fixture() + val covered = fixture.navScopes() + covered.attach(TestLifecycleOwner(Lifecycle.State.RESUMED).lifecycle) + covered.applyFirstBackStackUpdate() + val incoming = fixture.navScopes() + incoming.attach(TestLifecycleOwner(Lifecycle.State.RESUMED).lifecycle) + incoming.applyFirstBackStackUpdate() + val incomingContext = fixture.scope.propagationContext + + covered.configureScope { it.rotatePropagationContext() } + assertThat(fixture.scope.propagationContext).isSameInstanceAs(incomingContext) + + incoming.dispose() + + assertThat(fixture.hasLease(covered)).isTrue() + assertThat(fixture.scope.propagationContext).isNotSameInstanceAs(incomingContext) + } + + private fun NavScopes.applyFirstBackStackUpdate() { + onBackStackUpdate() + } + + private class TestLifecycleOwner(initialState: Lifecycle.State = Lifecycle.State.CREATED) : + LifecycleOwner { + override val lifecycle: LifecycleRegistry = + LifecycleRegistry.createUnsafe(this).apply { currentState = initialState } + } + + private class Fixture { + var isEnabled = true + val scope = Scope(SentryOptions()) + val coordinator = NavLeaseCoordinator() + val scopes = + mock().also { scopes -> + whenever(scopes.options).thenReturn(SentryOptions()) + doAnswer { + if (isEnabled) { + (it.arguments[0] as ScopeCallback).run(scope) + } + null + } + .whenever(scopes) + .configureScope(any()) + whenever(scopes.startTransaction(any(), any())) + .thenAnswer { mock() } + } + + fun navScopes(): NavScopes = NavScopes(scopes, coordinator) + + private var leaseProbeCount = 0 + + /** + * Whether [navScopes] holds the nav lease, observed through whether a screen name it sets + * reaches the real [scope]. Overwrites the scope's screen. + */ + fun hasLease(navScopes: NavScopes): Boolean { + val probe = "lease probe ${++leaseProbeCount}" + navScopes.configureScope { it.screen = probe } + return scope.screen == probe + } + } +} diff --git a/sentry-android-navigation3/src/test/kotlin/io/sentry/compose/navigation3/lifecycle/StagedNavDataTest.kt b/sentry-android-navigation3/src/test/kotlin/io/sentry/compose/navigation3/lifecycle/StagedNavDataTest.kt new file mode 100644 index 00000000000..a5b74ff4a2c --- /dev/null +++ b/sentry-android-navigation3/src/test/kotlin/io/sentry/compose/navigation3/lifecycle/StagedNavDataTest.kt @@ -0,0 +1,221 @@ +package io.sentry.compose.navigation3.lifecycle + +import com.google.common.truth.Truth.assertThat +import io.sentry.Scope +import io.sentry.SentryOptions +import org.junit.Test + +class StagedNavDataTest { + + private val realScope = Scope(SentryOptions.empty()) + private val stagedNavData = StagedNavData() + + @Test + fun `stageScreen() - stages the screen without changing the real scope`() { + realScope.screen = "Host" + + stagedNavData.stageScreen("Profile") + + assertThat(stagedNavData.screen).isEqualTo("Profile") + assertThat(realScope.screen).isEqualTo("Host") + } + + @Test + fun `publishScreen() - publishes the staged screen`() { + stagedNavData.stageScreen("Profile") + + stagedNavData.publishScreen(realScope) + + assertThat(realScope.screen).isEqualTo("Profile") + } + + @Test + fun `stageNavigationContext() - leaves the real navigation context unchanged`() { + val hostContext = mapOf("route" to "Host") + realScope.setContexts(NAVIGATION_CONTEXT_KEY, hostContext) + + stagedNavData.stageNavigationContext(mapOf("route" to "Profile")) + + assertThat(realScope.contexts[NAVIGATION_CONTEXT_KEY]).isSameInstanceAs(hostContext) + } + + @Test + fun `publishNavigationContext() - publishes the staged navigation context`() { + val navigationContext = mapOf("route" to "Profile") + stagedNavData.stageNavigationContext(navigationContext) + + stagedNavData.publishNavigationContext(realScope) + + assertThat(realScope.contexts[NAVIGATION_CONTEXT_KEY]).isSameInstanceAs(navigationContext) + } + + @Test + fun `publishTo() - publishes the staged screen`() { + stagedNavData.stageScreen("Profile") + + stagedNavData.publishTo(realScope) + + assertThat(realScope.screen).isEqualTo("Profile") + } + + @Test + fun `publishTo() - republishes an already published screen that was since replaced`() { + stagedNavData.stageScreen("Profile") + stagedNavData.publishScreen(realScope) + realScope.screen = "Host" + + stagedNavData.publishTo(realScope) + + assertThat(realScope.screen).isEqualTo("Profile") + } + + @Test + fun `publishTo() - leaves the real screen alone when no screen was staged`() { + realScope.screen = "Host" + + stagedNavData.publishTo(realScope) + + assertThat(realScope.screen).isEqualTo("Host") + } + + @Test + fun `publishTo() - clears the real screen when the staged screen is null`() { + realScope.screen = "Previous" + stagedNavData.stageScreen(null) + + stagedNavData.publishTo(realScope) + + assertThat(realScope.screen).isNull() + } + + @Test + fun `publishTo() - publishes the staged navigation context`() { + val navigationContext = mapOf("route" to "Profile") + stagedNavData.stageNavigationContext(navigationContext) + + stagedNavData.publishTo(realScope) + + assertThat(realScope.contexts[NAVIGATION_CONTEXT_KEY]).isSameInstanceAs(navigationContext) + } + + @Test + fun `publishTo() - republishes an already published navigation context that was since replaced`() { + val navigationContext = mapOf("route" to "Profile") + stagedNavData.stageNavigationContext(navigationContext) + stagedNavData.publishNavigationContext(realScope) + realScope.setContexts(NAVIGATION_CONTEXT_KEY, mapOf("route" to "Host")) + + stagedNavData.publishTo(realScope) + + assertThat(realScope.contexts[NAVIGATION_CONTEXT_KEY]).isSameInstanceAs(navigationContext) + } + + @Test + fun `publishTo() - leaves the real navigation context alone when none was staged`() { + val hostContext = mapOf("route" to "Host") + realScope.setContexts(NAVIGATION_CONTEXT_KEY, hostContext) + + stagedNavData.publishTo(realScope) + + assertThat(realScope.contexts[NAVIGATION_CONTEXT_KEY]).isSameInstanceAs(hostContext) + } + + @Test + fun `publishTo() - clears the real navigation context when the staged context is null`() { + realScope.setContexts(NAVIGATION_CONTEXT_KEY, mapOf("route" to "Previous")) + stagedNavData.stageNavigationContext(null) + + stagedNavData.publishTo(realScope) + + assertThat(realScope.contexts[NAVIGATION_CONTEXT_KEY]).isNull() + } + + @Test + fun `clearPublishedStateFrom() - preserves a host screen set after publication without a staged screen`() { + stagedNavData.publishTo(realScope) + realScope.screen = "Host" + + stagedNavData.clearPublishedStateFrom(realScope) + + assertThat(realScope.screen).isEqualTo("Host") + } + + @Test + fun `clearPublishedStateFrom() - clears screen and navigation context published by this holder`() { + stagedNavData.stageScreen("Profile") + stagedNavData.stageNavigationContext(mapOf("route" to "Profile")) + stagedNavData.publishTo(realScope) + + stagedNavData.clearPublishedStateFrom(realScope) + + assertThat(realScope.screen).isNull() + assertThat(realScope.contexts[NAVIGATION_CONTEXT_KEY]).isNull() + } + + @Test + fun `clearPublishedStateFrom() - clears the published screen and navigation context after newer values are staged`() { + stagedNavData.stageScreen("Home") + stagedNavData.stageNavigationContext(mapOf("route" to "Home")) + stagedNavData.publishTo(realScope) + stagedNavData.stageScreen("Profile") + stagedNavData.stageNavigationContext(mapOf("route" to "Profile")) + + stagedNavData.clearPublishedStateFrom(realScope) + + assertThat(realScope.screen).isNull() + assertThat(realScope.contexts[NAVIGATION_CONTEXT_KEY]).isNull() + } + + @Test + fun `clearPublishedStateFrom() - preserves equal but distinct host replacements`() { + stagedNavData.stageScreen(String(charArrayOf('H', 'o', 'm', 'e'))) + stagedNavData.stageNavigationContext(mapOf("route" to "Home")) + stagedNavData.publishTo(realScope) + val hostScreen = String(charArrayOf('H', 'o', 'm', 'e')) + val hostContext = mapOf("route" to "Home") + realScope.screen = hostScreen + realScope.setContexts(NAVIGATION_CONTEXT_KEY, hostContext) + + stagedNavData.clearPublishedStateFrom(realScope) + + assertThat(realScope.screen).isSameInstanceAs(hostScreen) + assertThat(realScope.contexts[NAVIGATION_CONTEXT_KEY]).isSameInstanceAs(hostContext) + } + + @Test + fun `clearPublishedStateFrom() - preserves the real screen and navigation context before publication`() { + realScope.screen = "Host" + val hostContext = mapOf("route" to "Host") + realScope.setContexts(NAVIGATION_CONTEXT_KEY, hostContext) + stagedNavData.stageScreen("Staged") + stagedNavData.stageNavigationContext(mapOf("route" to "Staged")) + + stagedNavData.clearPublishedStateFrom(realScope) + + assertThat(realScope.screen).isEqualTo("Host") + assertThat(realScope.contexts[NAVIGATION_CONTEXT_KEY]).isSameInstanceAs(hostContext) + } + + @Test + fun `publishTo() - leaves the real propagation context alone when no rotation was deferred`() { + val realContext = realScope.propagationContext + + stagedNavData.publishTo(realScope) + + assertThat(realScope.propagationContext).isSameInstanceAs(realContext) + } + + @Test + fun `publishTo() - performs a deferred propagation context rotation once`() { + stagedNavData.deferPropagationContextRotation() + stagedNavData.deferPropagationContextRotation() + val realContext = realScope.propagationContext + + stagedNavData.publishTo(realScope) + val rotatedContext = realScope.propagationContext + stagedNavData.publishTo(realScope) + + assertThat(rotatedContext).isNotSameInstanceAs(realContext) + assertThat(realScope.propagationContext).isSameInstanceAs(rotatedContext) + } +} diff --git a/sentry-android-replay/detekt-baseline.xml b/sentry-android-replay/detekt-baseline.xml index 2adcb412344..8432d83f8dd 100644 --- a/sentry-android-replay/detekt-baseline.xml +++ b/sentry-android-replay/detekt-baseline.xml @@ -43,6 +43,7 @@ FunctionParameterNaming:PixelCopyStrategyTest.kt$DeferredWindowPixelCopyShadow.Companion$_dest: Bitmap FunctionParameterNaming:PixelCopyStrategyTest.kt$DeferredWindowPixelCopyShadow.Companion$_source: Window LargeClass:CanvasStrategy.kt$TextIgnoringDelegateCanvas : Canvas + LargeClass:ReplayCacheTest.kt$ReplayCacheTest LargeClass:ReplayIntegrationTest.kt$ReplayIntegrationTest LongMethod:CaptureStrategy.kt$CaptureStrategy.Companion$@Suppress("NewApi") 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<Breadcrumb>, events: Deque<RRWebEvent>, traceIds: List<String>, segmentNames: List<String>, ): ReplaySegment LongMethod:ComposeViewHierarchyNode.kt$ComposeViewHierarchyNode$private fun fromComposeNode( node: LayoutNode, parent: ViewHierarchyNode?, distance: Int, isComposeRoot: Boolean, options: SentryMaskingOptions, logger: ILogger, ): ViewHierarchyNode? @@ -158,6 +159,7 @@ TooManyFunctions:WindowRecorder.kt$WindowRecorder : RecorderOnRootViewsChangedListenerExecutorProvider UnusedPrivateProperty:DebugOverlayDrawable.kt$DebugOverlayDrawable$private val padding = 6f UseCheckOrError:ReplayShadowMediaCodec.kt$ReplayShadowMediaCodec$throw IllegalStateException("Simulated codec start failure") + UtilityClassWithPublicConstructor:CanvasStrategyTest.kt$DeferredSurfacePixelCopyShadow UtilityClassWithPublicConstructor:PixelCopyStrategyTest.kt$DeferredWindowPixelCopyShadow diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt index 21d8f88c7f8..64aec8a077c 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt @@ -139,16 +139,17 @@ public class ReplayCache(private val options: SentryOptions, private val replayI bitRate: Int, videoFile: File = File(replayCacheDir, "$segmentId.mp4"), ): GeneratedVideo? { - if (videoFile.exists() && videoFile.length() > 0) { - videoFile.delete() - } // Work on a snapshot of frames to avoid races with writers val framesSnapshot = framesLock.acquire().use { if (frames.isEmpty()) mutableListOf() else frames.toMutableList() } if (framesSnapshot.isEmpty()) { + // keep an existing video intact, e.g. one from a flush whose envelope is still queued options.logger.log(DEBUG, "No captured frames, skipping generating a video segment") return null } + if (videoFile.exists() && videoFile.length() > 0) { + videoFile.delete() + } encoder = SimpleVideoEncoder( diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt index 015e162afc7..4d6614a7832 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt @@ -59,6 +59,7 @@ import java.io.Closeable import java.io.File import java.util.Date import java.util.LinkedList +import java.util.concurrent.CopyOnWriteArraySet import java.util.concurrent.CountDownLatch import java.util.concurrent.Executors import java.util.concurrent.ThreadFactory @@ -85,6 +86,11 @@ public class ReplayIntegration( SentryIntegrationPackageStorage.getInstance() .addPackage("maven:io.sentry:sentry-android-replay", BuildConfig.VERSION_NAME) } + + // Replays recorded by this process. Their folders hold segment videos the transport may still + // read after the replay stopped, so the startup cleanup of previous replays must keep them. + // Shared across instances, because a second SDK init registers a new ReplayIntegration. + val replayIdsOfThisProcess = CopyOnWriteArraySet() } // needed for the Java's call site @@ -247,6 +253,9 @@ public class ReplayIntegration( recorder?.start() strategy.start() val replayId: SentryId? = strategy.currentReplayId + if (replayId != null && replayId != SentryId.EMPTY_ID) { + replayIdsOfThisProcess += replayId.toString() + } state.set( ReplayState( generation = current.generation + 1, @@ -340,23 +349,17 @@ public class ReplayIntegration( return } - var activeStrategy: CaptureStrategy = strategy + // Convert before capturing, so the callback knows which strategy continues the replay. + val activeStrategy = strategy.convert() capture(strategy) { newTimestamp -> - enqueueOnMainThread { - val latest = state.get() - // The flush completes asynchronously; ignore it if this replay was stopped, restarted, - // or handed to another strategy in the meantime. - if ( - latest.matches(expectedGeneration, expectedReplayId) && - latest.captureStrategy === activeStrategy - ) { - activeStrategy.currentSegment++ - activeStrategy.segmentTimestamp = newTimestamp - activeStrategy.isFlushed = true - } - } + // Runs on the replay executor, which the converted strategy shares. Advancing the segment + // here lands before any segment the converted strategy creates later (e.g. on stop), so the + // flushed segment id is not reused. A stale strategy (stopped or restarted replay) is not + // in the state anymore, so updating it has no effect. + activeStrategy.currentSegment++ + activeStrategy.segmentTimestamp = newTimestamp + activeStrategy.isFlushed = true } - activeStrategy = strategy.convert() val replayId: SentryId? = activeStrategy.currentReplayId state.set( current.copy( @@ -644,15 +647,13 @@ public class ReplayIntegration( } private fun cleanupReplays(unfinishedReplayId: String = "") { - // clean up old replays + // clean up old replays, but keep the replays of this process and the previous process's + // unfinished replay (its folder is cleaned up after its envelope is assembled) + val keptReplayIds = replayIdsOfThisProcess + listOfNotNull(unfinishedReplayId.ifBlank { null }) 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)) - ) { + if (name.startsWith("replay_") && keptReplayIds.none { name.contains(it) }) { FileUtils.deleteRecursively(file) } } @@ -669,7 +670,12 @@ public class ReplayIntegration( return@submitSafely } val previousReplayId = SentryId(previousReplayIdString) - if (previousReplayId == SentryId.EMPTY_ID) { + // when this task runs late, the persisted replay id can already be one this process recorded, + // which is not a previous replay to recover + if ( + previousReplayId == SentryId.EMPTY_ID || + previousReplayId.toString() in replayIdsOfThisProcess + ) { cleanupReplays() return@submitSafely } diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/screenshot/CanvasStrategy.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/screenshot/CanvasStrategy.kt index c3deefbf71b..6956782cc27 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/screenshot/CanvasStrategy.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/screenshot/CanvasStrategy.kt @@ -58,6 +58,9 @@ internal class CanvasStrategy( private val lastCaptureSuccessful = AtomicBoolean(false) private val textIgnoringCanvas = TextIgnoringDelegateCanvas() private val isClosed = AtomicBoolean(false) + // Only accessed on the background handler, which runs the render task, the PixelCopy callback and + // the cleanup, so it needs no synchronization + private var pixelCopyInFlight = false private val surfaceTexture = SurfaceTexture(false).apply { @@ -110,15 +113,19 @@ internal class CanvasStrategy( ) return@Runnable } + pixelCopyInFlight = true PixelCopy.request( surface, screenshot!!, { result -> + pixelCopyInFlight = false if (isClosed.get()) { options.logger.log( SentryLevel.DEBUG, "CanvasStrategy is closed, ignoring capture result", ) + // close() skipped the cleanup while this copy was still writing into the screenshot + releaseResources() return@request } if (result == PixelCopy.SUCCESS) { @@ -138,6 +145,8 @@ internal class CanvasStrategy( executor.getBackgroundHandler(), ) } catch (t: Throwable) { + // if the request itself threw, no callback will clear the flag + pixelCopyInFlight = false options.logger.log(SentryLevel.ERROR, "Canvas Strategy: picture render failed", t) lastCaptureSuccessful.set(false) } @@ -174,14 +183,22 @@ internal class CanvasStrategy( .getBackgroundHandler() .postSafely( ReplayRunnable("CanvasStrategy.close") { - screenshot?.let { synchronized(it) { if (!it.isRecycled) it.recycle() } } - surface.release() - surfaceTexture.release() + // PixelCopy writes into the screenshot on the RenderThread; recycling it mid-copy aborts + // the process, so an in-flight copy releases the resources from its callback instead + if (!pixelCopyInFlight) { + releaseResources() + } } ) unprocessedPictureRef.getAndSet(null) } + private fun releaseResources() { + screenshot?.let { synchronized(it) { if (!it.isRecycled) it.recycle() } } + surface.release() + surfaceTexture.release() + } + override fun lastCaptureSuccessful(): Boolean = lastCaptureSuccessful.get() override fun emitLastScreenshot() { diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleVideoEncoder.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleVideoEncoder.kt index dd9af1c24d6..b1e3cd20513 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleVideoEncoder.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleVideoEncoder.kt @@ -177,25 +177,8 @@ internal class SimpleVideoEncoder( } 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 - * --- - * Same for Motorola devices. - * --- - * As for the Spreadtrum/Unisoc chipset, it can be spread across various devices, so we have to - * check the SOC_MANUFACTURER property, as the manufacturer name might have changed. - * https://github.com/getsentry/sentry-android-gradle-plugin/issues/861#issuecomment-2867021256 - */ val canvas = - if ( - Build.MANUFACTURER.contains("xiaomi", ignoreCase = true) || - Build.MANUFACTURER.contains("motorola", ignoreCase = true) || - SystemProperties.get(SystemProperties.Property.SOC_MANUFACTURER) - .equals("spreadtrum", ignoreCase = true) || - SystemProperties.get(SystemProperties.Property.SOC_MANUFACTURER) - .equals("unisoc", ignoreCase = true) - ) { + if (shouldUseSoftwareCanvas()) { surface?.lockCanvas(null) } else { surface?.lockHardwareCanvas() @@ -338,6 +321,34 @@ internal class SimpleVideoEncoder( } } +/** + * Whether frames should be drawn onto the encoder's input surface with a software canvas + * ([Surface.lockCanvas]) instead of a hardware one ([Surface.lockHardwareCanvas]). + * + * It seems that Xiaomi devices have problems with hardware canvas, so we have to use lockCanvas + * instead https://stackoverflow.com/a/73520742 + * --- + * Same for Motorola devices. + * --- + * As for the Spreadtrum/Unisoc chipset, it can be spread across various devices, so we have to + * check the SOC_MANUFACTURER property, as the manufacturer name might have changed. + * https://github.com/getsentry/sentry-android-gradle-plugin/issues/861#issuecomment-2867021256 + * --- + * On the Android Emulator (goldfish/ranchu), a hardware canvas makes hwui own an EGL window surface + * on the input surface. The emulator's EGL driver keeps a dequeued buffer on it and cancels that + * buffer when the surface is destroyed, which can crash natively in eglDestroySurface once the + * encoder has been torn down. https://github.com/getsentry/sentry-java/issues/6279 + */ +internal fun shouldUseSoftwareCanvas(): Boolean = + Build.MANUFACTURER.contains("xiaomi", ignoreCase = true) || + Build.MANUFACTURER.contains("motorola", ignoreCase = true) || + SystemProperties.get(SystemProperties.Property.SOC_MANUFACTURER) + .equals("spreadtrum", ignoreCase = true) || + SystemProperties.get(SystemProperties.Property.SOC_MANUFACTURER) + .equals("unisoc", ignoreCase = true) || + Build.HARDWARE.contains("goldfish", ignoreCase = true) || + Build.HARDWARE.contains("ranchu", ignoreCase = true) + @SuppressLint("UseRequiresApi") @TargetApi(24) internal data class MuxerConfig( diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayCacheTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayCacheTest.kt index 5607ebd87c6..aeca0c868d1 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayCacheTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayCacheTest.kt @@ -107,6 +107,18 @@ class ReplayCacheTest { assertNull(video) } + @Test + fun `when no frames are provided, does not delete existing video`() { + val replayCache = fixture.getSut(tmpDir) + val existingVideo = + File(replayCache.replayCacheDir, "0.mp4").also { it.writeBytes(byteArrayOf(1, 2, 3)) } + + val video = replayCache.createVideoOf(5000L, 0, 0, 100, 200, 1, 20_000) + + assertNull(video) + assertEquals(3, existingVideo.length()) + } + @Test fun `releases the muxer when the encoder fails to start`() { ReplayShadowMediaCodec.throwOnStart = true diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationTest.kt index 4f4f7ff3c96..70e8426d124 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationTest.kt @@ -752,6 +752,49 @@ class ReplayIntegrationTest { assertThat(replay.replayId).isEqualTo(newReplayId) } + @Test + fun `flush followed by immediate stop advances the converted strategy segment`() { + val replayId = SentryId() + var onSegmentSent: ((Date) -> Unit)? = null + val bufferStrategy = mock() + whenever(bufferStrategy.currentReplayId).thenReturn(replayId) + doAnswer { + @Suppress("UNCHECKED_CAST") + onSegmentSent = it.arguments[0] as (Date) -> Unit + } + .whenever(bufferStrategy) + .flush(any()) + val sessionStrategy = mock() + whenever(sessionStrategy.currentReplayId).thenReturn(replayId) + whenever(sessionStrategy.currentSegment).thenReturn(0) + whenever(bufferStrategy.convert()).thenReturn(sessionStrategy) + val replay = + fixture.getSut( + context, + sessionSampleRate = 0.0, + onErrorSampleRate = 0.0, + replayCaptureStrategyProvider = { bufferStrategy }, + mainLooperHandler = MainLooperHandler(), + ) + replay.register(fixture.scopes, fixture.options) + replay.startBuffering() + shadowOf(Looper.getMainLooper()).idle() + + replay.flush() + replay.stop() + shadowOf(Looper.getMainLooper()).idle() + verify(sessionStrategy).stop() + + // the flushed segment completes on the replay executor after stop ran on the main thread, + // but before the stop segment of the converted strategy is created + val timestamp = Date() + onSegmentSent!!.invoke(timestamp) + + verify(sessionStrategy).currentSegment = 1 + verify(sessionStrategy).segmentTimestamp = timestamp + verify(sessionStrategy).isFlushed = true + } + @Test fun `pause does nothing when not recording`() { val captureStrategy = mock() @@ -1220,6 +1263,95 @@ class ReplayIntegrationTest { assertFalse(evenOlderReplay.exists()) } + @Test + fun `delayed startup cleanup keeps replays recorded by this process`() { + val replayId = SentryId() + fixture.options.cacheDirPath = tmpDir.newFolder().absolutePath + val olderReplay = + File(fixture.options.cacheDirPath, "replay_${SentryId()}").also { it.mkdirs() } + // hold back the startup work, like a busy executor does + val deferredTasks = mutableListOf() + fixture.options.executorService = mock { + doAnswer { deferredTasks += it.arguments[0] as Runnable } + .whenever(mock) + .submit(any()) + } + + val captureStrategy = mock { on { currentReplayId }.thenReturn(replayId) } + val replay = fixture.getSut(context, replayCaptureStrategyProvider = { captureStrategy }) + replay.register(fixture.scopes, fixture.options) + replay.startBuffering() + // a flushed segment video that is still waiting for the transport + val stoppedReplay = + File(fixture.options.cacheDirPath, "replay_$replayId").also { + it.mkdirs() + File(it, "0.mp4").writeBytes(byteArrayOf(1, 2, 3)) + } + replay.stop() + + deferredTasks.forEach { it.run() } + + assertTrue(File(stoppedReplay, "0.mp4").exists()) + assertFalse(olderReplay.exists()) + } + + @Test + fun `second SDK init keeps replays recorded by the first one`() { + val firstReplayId = SentryId() + fixture.options.cacheDirPath = tmpDir.newFolder().absolutePath + val firstStrategy = mock { on { currentReplayId }.thenReturn(firstReplayId) } + val first = fixture.getSut(context, replayCaptureStrategyProvider = { firstStrategy }) + first.register(fixture.scopes, fixture.options) + first.startBuffering() + val firstReplay = + File(fixture.options.cacheDirPath, "replay_$firstReplayId").also { + it.mkdirs() + File(it, "0.mp4").writeBytes(byteArrayOf(1, 2, 3)) + } + first.stop() + first.close() + + // a new Sentry.init registers a new integration, which runs the startup cleanup again + val secondStrategy = mock { on { currentReplayId }.thenReturn(SentryId()) } + val second = fixture.getSut(context, replayCaptureStrategyProvider = { secondStrategy }) + second.register(fixture.scopes, fixture.options) + + assertTrue(File(firstReplay, "0.mp4").exists()) + } + + @Test + fun `delayed startup task does not finalize a replay recorded by this process`() { + val replayId = SentryId() + fixture.options.cacheDirPath = tmpDir.newFolder().absolutePath + fixture.options.addScopeObserver(PersistingScopeObserver(fixture.options)) + val deferredTasks = mutableListOf() + fixture.options.executorService = mock { + doAnswer { deferredTasks += it.arguments[0] as Runnable } + .whenever(mock) + .submit(any()) + } + + val captureStrategy = mock { on { currentReplayId }.thenReturn(replayId) } + val replay = fixture.getSut(context, replayCaptureStrategyProvider = { captureStrategy }) + replay.register(fixture.scopes, fixture.options) + replay.startBuffering() + val currentReplay = + File(fixture.options.cacheDirPath, "replay_$replayId").also { + it.mkdirs() + File(it, "0.mp4").writeBytes(byteArrayOf(1, 2, 3)) + } + // the scope observer persisted this replay's id after a flush, before the startup task ran + File(fixture.options.cacheDirPath, PersistingScopeObserver.SCOPE_CACHE).also { + it.mkdirs() + File(it, PersistingScopeObserver.REPLAY_FILENAME).writeText("\"$replayId\"") + } + + deferredTasks.forEach { it.run() } + + assertTrue(File(currentReplay, "0.mp4").exists()) + verify(fixture.scopes, never()).captureReplay(any(), any()) + } + @Test fun `onScreenshotRecorded supplies screen from scope to replay cache`() { val captureStrategy = diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/screenshot/CanvasStrategyTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/screenshot/CanvasStrategyTest.kt new file mode 100644 index 00000000000..55137a59948 --- /dev/null +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/screenshot/CanvasStrategyTest.kt @@ -0,0 +1,125 @@ +package io.sentry.android.replay.screenshot + +import android.graphics.Bitmap +import android.os.Handler +import android.os.Looper +import android.view.PixelCopy +import android.view.Surface +import android.view.View +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.common.truth.Truth.assertThat +import io.sentry.SentryOptions +import io.sentry.android.replay.ExecutorProvider +import io.sentry.android.replay.ScreenshotRecorderConfig +import io.sentry.android.replay.util.MainLooperHandler +import java.util.concurrent.ScheduledExecutorService +import kotlin.test.BeforeTest +import kotlin.test.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.mock +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config +import org.robolectric.annotation.Implementation +import org.robolectric.annotation.Implements + +@RunWith(AndroidJUnit4::class) +@Config(sdk = [30], shadows = [DeferredSurfacePixelCopyShadow::class]) +class CanvasStrategyTest { + + private class Fixture { + val options = SentryOptions() + val config = ScreenshotRecorderConfig(100, 100, 1f, 1f, 1, 1000) + + fun getSut(): CanvasStrategy = + CanvasStrategy( + object : ExecutorProvider { + override fun getExecutor(): ScheduledExecutorService = mock() + + override fun getMainLooperHandler(): MainLooperHandler = MainLooperHandler() + + // runs the render task, the PixelCopy callback and the cleanup, like production + override fun getBackgroundHandler(): Handler = Handler(Looper.getMainLooper()) + }, + mock(), + options, + config, + ) + } + + private val fixture = Fixture() + + @BeforeTest + fun setup() { + DeferredSurfacePixelCopyShadow.reset() + } + + @Test + fun `close keeps the screenshot alive until the in-flight pixel copy finishes`() { + val strategy = fixture.getSut() + strategy.capture(View(ApplicationProvider.getApplicationContext())) + shadowOf(Looper.getMainLooper()).idle() + val screenshot = DeferredSurfacePixelCopyShadow.dest!! + + strategy.close() + shadowOf(Looper.getMainLooper()).idle() + + // the copy is still writing into the screenshot, recycling it now would abort the RenderThread + assertThat(screenshot.isRecycled).isFalse() + + DeferredSurfacePixelCopyShadow.flush() + shadowOf(Looper.getMainLooper()).idle() + + assertThat(screenshot.isRecycled).isTrue() + } + + @Test + fun `close recycles the screenshot right away when no pixel copy is in flight`() { + val strategy = fixture.getSut() + strategy.capture(View(ApplicationProvider.getApplicationContext())) + shadowOf(Looper.getMainLooper()).idle() + val screenshot = DeferredSurfacePixelCopyShadow.dest!! + DeferredSurfacePixelCopyShadow.flush() + shadowOf(Looper.getMainLooper()).idle() + + strategy.close() + shadowOf(Looper.getMainLooper()).idle() + + assertThat(screenshot.isRecycled).isTrue() + } +} + +/** Holds back the result of a [PixelCopy] from a [Surface] until [flush] is called. */ +@Implements(PixelCopy::class) +class DeferredSurfacePixelCopyShadow { + companion object { + private val pendingCallbacks = mutableListOf<() -> Unit>() + var dest: Bitmap? = null + + fun reset() { + pendingCallbacks.clear() + dest = null + } + + fun flush() { + val callbacks = pendingCallbacks.toList() + pendingCallbacks.clear() + callbacks.forEach { it.invoke() } + } + + @JvmStatic + @Implementation + @Suppress("UNUSED_PARAMETER") + fun request( + source: Surface, + dest: Bitmap, + listener: PixelCopy.OnPixelCopyFinishedListener, + listenerThread: Handler, + ) { + this.dest = dest + pendingCallbacks.add { + listenerThread.post { listener.onPixelCopyFinished(PixelCopy.SUCCESS) } + } + } + } +} diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/video/SimpleVideoEncoderCanvasTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/video/SimpleVideoEncoderCanvasTest.kt new file mode 100644 index 00000000000..01598df8ef8 --- /dev/null +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/video/SimpleVideoEncoderCanvasTest.kt @@ -0,0 +1,51 @@ +package io.sentry.android.replay.video + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.common.truth.Truth.assertThat +import kotlin.test.Test +import org.junit.runner.RunWith +import org.robolectric.annotation.Config +import org.robolectric.shadows.ShadowBuild + +@RunWith(AndroidJUnit4::class) +@Config(sdk = [30]) +class SimpleVideoEncoderCanvasTest { + + @Test + fun `uses hardware canvas on regular devices`() { + ShadowBuild.setManufacturer("Google") + ShadowBuild.setHardware("tensor") + + assertThat(shouldUseSoftwareCanvas()).isFalse() + } + + @Test + fun `uses software canvas on Xiaomi devices`() { + ShadowBuild.setManufacturer("Xiaomi") + + assertThat(shouldUseSoftwareCanvas()).isTrue() + } + + @Test + fun `uses software canvas on Motorola devices`() { + ShadowBuild.setManufacturer("motorola") + + assertThat(shouldUseSoftwareCanvas()).isTrue() + } + + @Test + fun `uses software canvas on ranchu emulators`() { + ShadowBuild.setManufacturer("Google") + ShadowBuild.setHardware("ranchu") + + assertThat(shouldUseSoftwareCanvas()).isTrue() + } + + @Test + fun `uses software canvas on goldfish emulators`() { + ShadowBuild.setManufacturer("unknown") + ShadowBuild.setHardware("goldfish") + + assertThat(shouldUseSoftwareCanvas()).isTrue() + } +} diff --git a/sentry-async-profiler/build.gradle.kts b/sentry-async-profiler/build.gradle.kts index 3bcdefd1d82..fbb4a8fc353 100644 --- a/sentry-async-profiler/build.gradle.kts +++ b/sentry-async-profiler/build.gradle.kts @@ -32,6 +32,7 @@ dependencies { // tests testImplementation(kotlin(Config.kotlinStdLib)) testImplementation(projects.sentryTestSupport) + testImplementation(libs.google.truth) testImplementation(libs.kotlin.test.junit) testImplementation(libs.mockito.kotlin) testImplementation(libs.mockito.core) diff --git a/sentry-async-profiler/src/test/java/io/sentry/asyncprofiler/ContinuousProfilerEndToEndTest.kt b/sentry-async-profiler/src/test/java/io/sentry/asyncprofiler/ContinuousProfilerEndToEndTest.kt new file mode 100644 index 00000000000..25f8d0b9940 --- /dev/null +++ b/sentry-async-profiler/src/test/java/io/sentry/asyncprofiler/ContinuousProfilerEndToEndTest.kt @@ -0,0 +1,48 @@ +package io.sentry.asyncprofiler + +import com.google.common.truth.Truth.assertThat +import io.sentry.ProfileLifecycle +import io.sentry.Sentry +import io.sentry.test.MockSentryServer +import io.sentry.test.initForTest +import io.sentry.test.profileChunk +import io.sentry.test.transaction +import kotlin.test.AfterTest +import kotlin.test.Test + +/** Profiles a transaction with the async profiler and sends both over the real HTTP transport. */ +class ContinuousProfilerEndToEndTest { + private val server = MockSentryServer() + + @AfterTest + fun teardown() { + Sentry.close() + server.close() + } + + @Test + fun `a transaction starts a profile whose chunk reaches Sentry`() { + initForTest { + it.dsn = server.dsn + it.tracesSampleRate = 1.0 + it.profileSessionSampleRate = 1.0 + it.profileLifecycle = ProfileLifecycle.TRACE + // The profiler rejects a traces path with a dot anywhere in it, which the module's absolute + // path can have. + it.profilingTracesDirPath = + "${System.getProperty("java.io.tmpdir")}/sentry-profiler-end-to-end-test" + } + + val transaction = Sentry.startTransaction("transaction name", "op") + val span = transaction.startChild("child") + Thread.sleep(100) + span.finish() + transaction.finish() + + val sent = + server.awaitEnvelope { it.transaction()?.transaction == "transaction name" }.transaction()!! + val profilerId = sent.contexts.profile?.profilerId + assertThat(profilerId).isNotNull() + server.awaitEnvelope(timeoutMillis = 30_000) { it.profileChunk()?.profilerId == profilerId } + } +} diff --git a/sentry-jul/build.gradle.kts b/sentry-jul/build.gradle.kts index 130b46ad5ed..451cbf1991b 100644 --- a/sentry-jul/build.gradle.kts +++ b/sentry-jul/build.gradle.kts @@ -28,6 +28,7 @@ dependencies { // tests testImplementation(projects.sentryTestSupport) testImplementation(kotlin(Config.kotlinStdLib)) + testImplementation(libs.google.truth) testImplementation(libs.kotlin.test.junit) testImplementation(libs.logback.classic) testImplementation(libs.mockito.kotlin) diff --git a/sentry-jul/src/test/kotlin/io/sentry/jul/SentryHandlerIntegrationTest.kt b/sentry-jul/src/test/kotlin/io/sentry/jul/SentryHandlerIntegrationTest.kt new file mode 100644 index 00000000000..7d1952486c7 --- /dev/null +++ b/sentry-jul/src/test/kotlin/io/sentry/jul/SentryHandlerIntegrationTest.kt @@ -0,0 +1,69 @@ +package io.sentry.jul + +import com.google.common.truth.Truth.assertThat +import io.sentry.Sentry +import io.sentry.SentryLevel +import io.sentry.test.MockSentryServer +import io.sentry.test.event +import io.sentry.test.hasFlag +import io.sentry.test.logs +import java.util.logging.Level +import java.util.logging.LogManager +import java.util.logging.Logger +import kotlin.test.AfterTest +import kotlin.test.Test + +class SentryHandlerIntegrationTest { + private val server = MockSentryServer() + + @AfterTest + fun teardown() { + // Back to the configuration the other tests read, which also closes this test's handler. + LogManager.getLogManager().readConfiguration() + Sentry.close() + server.close() + System.clearProperty("sentry.dsn") + } + + @Test + fun `handler configured from logging properties sends events, breadcrumbs and logs`() { + // The handler reads the DSN as external configuration, as an app's sentry.properties would + // set it. + System.setProperty("sentry.dsn", server.dsn) + javaClass.getResourceAsStream("/sentry-handler-integration-test.properties")!!.use { + LogManager.getLogManager().readConfiguration(it) + } + // Outside io.sentry, whose loggers the handler drops so it doesn't capture the SDK's own logs. + val logger = Logger.getLogger("com.example.App") + + logger.config("Hello Sentry!") + Sentry.setAttribute("user.type", "admin") + Sentry.setAttribute("feature.version", 2) + Sentry.setAttribute("debug.enabled", true) + Sentry.addFeatureFlag("my-feature-flag", true) + logger.warning("important warning") + logger.log(Level.INFO, "User has made a purchase of product: %d", 445) + logger.log(Level.SEVERE, "Something went wrong", RuntimeException("Invalid productId=445")) + Sentry.flush(5000) + + val warning = server.awaitEnvelope { it.event()?.level == SentryLevel.WARNING }.event()!! + assertThat(warning.message!!.message).isEqualTo("important warning") + assertThat(warning.hasFlag("my-feature-flag", true)).isTrue() + + val error = server.awaitEnvelope { it.event()?.level == SentryLevel.ERROR }.event()!! + assertThat(error.message!!.message).isEqualTo("Something went wrong") + assertThat(error.exceptions!!.map { it.type to it.value }) + .contains("RuntimeException" to "Invalid productId=445") + assertThat(error.breadcrumbs!!.map { it.message to it.level }) + .containsAtLeast( + "Hello Sentry!" to SentryLevel.DEBUG, + "User has made a purchase of product: 445" to SentryLevel.INFO, + ) + + val logs = server.awaitEnvelope { it.logs() != null }.logs()!! + assertThat(logs.items.map { it.body }).contains("User has made a purchase of product: 445") + val failureLog = logs.items.single { it.body == "Something went wrong" } + assertThat(failureLog.attributes!!.mapValues { it.value.value }) + .containsAtLeast("user.type", "admin", "feature.version", 2, "debug.enabled", true) + } +} diff --git a/sentry-jul/src/test/resources/sentry-handler-integration-test.properties b/sentry-jul/src/test/resources/sentry-handler-integration-test.properties new file mode 100644 index 00000000000..7769646be0b --- /dev/null +++ b/sentry-jul/src/test/resources/sentry-handler-integration-test.properties @@ -0,0 +1,8 @@ +handlers=io.sentry.jul.SentryHandler +.level=FINEST +io.sentry.jul.SentryHandler.level=FINEST +io.sentry.jul.SentryHandler.minimumEventLevel=INFO +io.sentry.jul.SentryHandler.minimumBreadcrumbLevel=CONFIG +io.sentry.jul.SentryHandler.minimumLevel=INFO +io.sentry.jul.SentryHandler.printfStyle=true +io.sentry.jul.SentryHandler.logsEnabled=true diff --git a/sentry-kafka/build.gradle.kts b/sentry-kafka/build.gradle.kts index 37240c8305e..eb293ada057 100644 --- a/sentry-kafka/build.gradle.kts +++ b/sentry-kafka/build.gradle.kts @@ -28,10 +28,13 @@ dependencies { // tests testImplementation(projects.sentryTestSupport) testImplementation(kotlin(Config.kotlinStdLib)) + testImplementation(libs.google.truth) testImplementation(libs.kotlin.test.junit) testImplementation(libs.mockito.kotlin) testImplementation(libs.mockito.core) testImplementation(libs.kafka.clients) + // For an in-JVM broker. It brings no Spring application into the tests. + testImplementation(libs.spring.kafka3.test) } tasks.withType().configureEach { diff --git a/sentry-kafka/src/test/kotlin/io/sentry/kafka/SentryKafkaEndToEndTest.kt b/sentry-kafka/src/test/kotlin/io/sentry/kafka/SentryKafkaEndToEndTest.kt new file mode 100644 index 00000000000..2c42817a4ec --- /dev/null +++ b/sentry-kafka/src/test/kotlin/io/sentry/kafka/SentryKafkaEndToEndTest.kt @@ -0,0 +1,103 @@ +package io.sentry.kafka + +import com.google.common.truth.Truth.assertThat +import io.sentry.Sentry +import io.sentry.test.MockSentryServer +import io.sentry.test.initForTest +import io.sentry.test.transaction +import java.time.Duration +import java.util.Properties +import kotlin.test.AfterTest +import kotlin.test.Test +import org.apache.kafka.clients.consumer.ConsumerConfig +import org.apache.kafka.clients.consumer.ConsumerRecord +import org.apache.kafka.clients.consumer.KafkaConsumer +import org.apache.kafka.clients.producer.KafkaProducer +import org.apache.kafka.clients.producer.ProducerConfig +import org.apache.kafka.clients.producer.ProducerRecord +import org.apache.kafka.common.serialization.StringDeserializer +import org.apache.kafka.common.serialization.StringSerializer +import org.springframework.kafka.test.EmbeddedKafkaKraftBroker + +/** + * Sends a message through the plain Kafka client to an in-JVM KRaft broker, so no Docker is needed, + * and checks that both sides reach Sentry over the real HTTP transport. + */ +class SentryKafkaEndToEndTest { + private val server = MockSentryServer() + private val kafka = EmbeddedKafkaKraftBroker(1, 1, TOPIC).apply { afterPropertiesSet() } + + @AfterTest + fun teardown() { + Sentry.close() + kafka.destroy() + server.close() + } + + @Test + fun `a message is traced where it's published and where it's processed`() { + initForTest { + it.dsn = server.dsn + it.tracesSampleRate = 1.0 + it.isEnableQueueTracing = true + } + + val transaction = Sentry.startTransaction("kafka-demo", "demo") + transaction.makeCurrent().use { + SentryKafkaProducer.wrap(KafkaProducer(producerProperties())).use { producer + -> + producer.send(ProducerRecord(TOPIC, "sentry-kafka message")).get() + } + } + transaction.finish() + + KafkaConsumer(consumerProperties()).use { consumer -> + consumer.subscribe(listOf(TOPIC)) + val record = pollOne(consumer) + SentryKafkaConsumerTracing.withTracing(record, Runnable {}) + } + + val producer = + server.awaitEnvelope { it.transaction()?.transaction == "kafka-demo" }.transaction()!! + assertThat(producer.spans.map { it.op }).contains("queue.publish") + + val consumer = + server + .awaitEnvelope { it.transaction()?.contexts?.trace?.operation == "queue.process" } + .transaction()!! + val trace = consumer.contexts.trace!! + assertThat(trace.origin).isEqualTo(SentryKafkaConsumerTracing.TRACE_ORIGIN) + assertThat(trace.data).containsEntry("messaging.system", "kafka") + // The producer's span puts the trace in the message's headers, and the consumer continues it. + assertThat(trace.traceId).isEqualTo(producer.contexts.trace!!.traceId) + } + + private fun pollOne(consumer: KafkaConsumer): ConsumerRecord { + repeat(20) { + consumer.poll(Duration.ofMillis(500)).firstOrNull()?.let { + return it + } + } + error("No message arrived") + } + + private fun producerProperties(): Properties = + Properties().apply { + put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, kafka.brokersAsString) + put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer::class.java.name) + put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, StringSerializer::class.java.name) + } + + private fun consumerProperties(): Properties = + Properties().apply { + put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, kafka.brokersAsString) + put(ConsumerConfig.GROUP_ID_CONFIG, "sentry-kafka-end-to-end-test") + put(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, "earliest") + put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer::class.java.name) + put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, StringDeserializer::class.java.name) + } + + private companion object { + const val TOPIC = "sentry-topic" + } +} diff --git a/sentry-log4j2/build.gradle.kts b/sentry-log4j2/build.gradle.kts index 2d2444a2395..68cdaa9b726 100644 --- a/sentry-log4j2/build.gradle.kts +++ b/sentry-log4j2/build.gradle.kts @@ -30,6 +30,7 @@ dependencies { // tests testImplementation(projects.sentryTestSupport) testImplementation(kotlin(Config.kotlinStdLib)) + testImplementation(libs.google.truth) testImplementation(libs.kotlin.test.junit) testImplementation(libs.log4j.api) testImplementation(libs.log4j.core) @@ -55,6 +56,16 @@ tasks.withType().configureEach { } } +// log4j's GraalVmProcessor warns (and fails the build under -Werror) without these options +tasks.named("compileJava") { + options.compilerArgs.addAll( + listOf( + "-Alog4j.graalvm.groupId=${project.group}", + "-Alog4j.graalvm.artifactId=${project.name}", + ) + ) +} + tasks.jar { manifest { attributes( 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 bc002e2f103..30113dd006c 100644 --- a/sentry-log4j2/src/main/java/io/sentry/log4j2/SentryAppender.java +++ b/sentry-log4j2/src/main/java/io/sentry/log4j2/SentryAppender.java @@ -218,7 +218,9 @@ public void append(final @NotNull LogEvent eventObject) { * @param loggingEvent the log4j2 event */ // for the Android compatibility we must use old Java Date class - @SuppressWarnings("JdkObsolete") + // Message.getFormat() is deprecated since log4j 2.24.0 without a replacement for reading the + // message template + @SuppressWarnings({"JdkObsolete", "deprecation"}) protected void captureLog(@NotNull LogEvent loggingEvent) { final @NotNull SentryLogLevel sentryLevel = toSentryLogLevel(loggingEvent.getLevel()); @@ -250,7 +252,9 @@ protected void captureLog(@NotNull LogEvent loggingEvent) { * @return the sentry event */ // for the Android compatibility we must use old Java Date class - @SuppressWarnings("JdkObsolete") + // Message.getFormat() is deprecated since log4j 2.24.0 without a replacement for reading the + // message template + @SuppressWarnings({"JdkObsolete", "deprecation"}) protected @NotNull SentryEvent createEvent(final @NotNull LogEvent loggingEvent) { final SentryEvent event = new SentryEvent(DateUtils.getDateTime(loggingEvent.getTimeMillis())); final Message message = new Message(); diff --git a/sentry-log4j2/src/test/kotlin/io/sentry/log4j2/SentryAppenderIntegrationTest.kt b/sentry-log4j2/src/test/kotlin/io/sentry/log4j2/SentryAppenderIntegrationTest.kt new file mode 100644 index 00000000000..cce2510e86e --- /dev/null +++ b/sentry-log4j2/src/test/kotlin/io/sentry/log4j2/SentryAppenderIntegrationTest.kt @@ -0,0 +1,73 @@ +package io.sentry.log4j2 + +import com.google.common.truth.Truth.assertThat +import io.sentry.Sentry +import io.sentry.SentryLevel +import io.sentry.test.MockSentryServer +import io.sentry.test.event +import io.sentry.test.hasFlag +import io.sentry.test.logs +import kotlin.test.AfterTest +import kotlin.test.Test +import org.apache.logging.log4j.ThreadContext +import org.apache.logging.log4j.core.LoggerContext + +class SentryAppenderIntegrationTest { + private val server = MockSentryServer() + + // A context of its own, so the other tests' context keeps its configuration. + private val loggerContext = + LoggerContext( + "sentry-appender-integration-test", + null, + javaClass.getResource("/sentry-appender-integration-test.xml")!!.toURI(), + ) + + @AfterTest + fun teardown() { + loggerContext.stop() + Sentry.close() + server.close() + ThreadContext.clearAll() + System.clearProperty("integrationTestDsn") + } + + @Test + fun `appender configured from XML sends events, breadcrumbs and logs`() { + System.setProperty("integrationTestDsn", server.dsn) + loggerContext.start() + val logger = loggerContext.getLogger(SentryAppenderIntegrationTest::class.java.name) + + logger.debug("Hello Sentry!") + ThreadContext.put("userId", "user-1") + Sentry.setAttribute("user.type", "admin") + Sentry.setAttribute("feature.version", 2) + Sentry.setAttribute("debug.enabled", true) + Sentry.addFeatureFlag("my-feature-flag", true) + logger.info("User has made a purchase of product: {}", 445) + logger.warn("Important warning") + logger.error("Something went wrong", RuntimeException("Invalid productId=445")) + Sentry.flush(5000) + + val warning = server.awaitEnvelope { it.event()?.level == SentryLevel.WARNING }.event()!! + assertThat(warning.message!!.message).isEqualTo("Important warning") + assertThat(warning.tags).containsEntry("userId", "user-1") + assertThat(warning.hasFlag("my-feature-flag", true)).isTrue() + + val error = server.awaitEnvelope { it.event()?.level == SentryLevel.ERROR }.event()!! + assertThat(error.message!!.formatted).isEqualTo("Something went wrong") + assertThat(error.exceptions!!.map { it.type to it.value }) + .contains("RuntimeException" to "Invalid productId=445") + assertThat(error.breadcrumbs!!.map { it.message to it.level }) + .containsAtLeast( + "Hello Sentry!" to SentryLevel.DEBUG, + "User has made a purchase of product: 445" to SentryLevel.INFO, + ) + + val logs = server.awaitEnvelope { it.logs() != null }.logs()!! + assertThat(logs.items.map { it.body }).contains("User has made a purchase of product: 445") + val failureLog = logs.items.single { it.body == "Something went wrong" } + assertThat(failureLog.attributes!!.mapValues { it.value.value }) + .containsAtLeast("user.type", "admin", "feature.version", 2, "debug.enabled", true) + } +} diff --git a/sentry-log4j2/src/test/resources/sentry-appender-integration-test.xml b/sentry-log4j2/src/test/resources/sentry-appender-integration-test.xml new file mode 100644 index 00000000000..c6ff2096302 --- /dev/null +++ b/sentry-log4j2/src/test/resources/sentry-appender-integration-test.xml @@ -0,0 +1,18 @@ + + + + + + + + + + + diff --git a/sentry-logback/src/test/kotlin/io/sentry/logback/SentryAppenderIntegrationTest.kt b/sentry-logback/src/test/kotlin/io/sentry/logback/SentryAppenderIntegrationTest.kt new file mode 100644 index 00000000000..8f654b3d038 --- /dev/null +++ b/sentry-logback/src/test/kotlin/io/sentry/logback/SentryAppenderIntegrationTest.kt @@ -0,0 +1,68 @@ +package io.sentry.logback + +import ch.qos.logback.classic.LoggerContext +import ch.qos.logback.classic.joran.JoranConfigurator +import com.google.common.truth.Truth.assertThat +import io.sentry.Sentry +import io.sentry.SentryLevel +import io.sentry.test.MockSentryServer +import io.sentry.test.event +import io.sentry.test.hasFlag +import io.sentry.test.logs +import kotlin.test.AfterTest +import kotlin.test.Test +import org.slf4j.MDC + +class SentryAppenderIntegrationTest { + private val server = MockSentryServer() + private val loggerContext = LoggerContext() + + @AfterTest + fun teardown() { + loggerContext.stop() + Sentry.close() + server.close() + MDC.clear() + } + + @Test + fun `appender configured from XML sends events, breadcrumbs and logs`() { + loggerContext.putProperty("dsn", server.dsn) + JoranConfigurator() + .apply { context = loggerContext } + .doConfigure(javaClass.getResource("/sentry-appender-integration-test.xml")) + val logger = loggerContext.getLogger(SentryAppenderIntegrationTest::class.java) + + logger.debug("Hello Sentry!") + MDC.put("userId", "user-1") + Sentry.setAttribute("user.type", "admin") + Sentry.setAttribute("feature.version", 2) + Sentry.setAttribute("debug.enabled", true) + Sentry.addFeatureFlag("my-feature-flag", true) + logger.warn("important warning") + logger.info("User has made a purchase of product: {}", 445) + logger.error("Something went wrong", RuntimeException("Invalid productId=445")) + Sentry.flush(5000) + + val warning = server.awaitEnvelope { it.event()?.level == SentryLevel.WARNING }.event()!! + assertThat(warning.message!!.message).isEqualTo("important warning") + assertThat(warning.tags).containsEntry("userId", "user-1") + assertThat(warning.hasFlag("my-feature-flag", true)).isTrue() + + val error = server.awaitEnvelope { it.event()?.level == SentryLevel.ERROR }.event()!! + assertThat(error.message!!.formatted).isEqualTo("Something went wrong") + assertThat(error.exceptions!!.map { it.type to it.value }) + .contains("RuntimeException" to "Invalid productId=445") + assertThat(error.breadcrumbs!!.map { it.message to it.level }) + .containsAtLeast( + "Hello Sentry!" to SentryLevel.DEBUG, + "User has made a purchase of product: 445" to SentryLevel.INFO, + ) + + val logs = server.awaitEnvelope { it.logs() != null }.logs()!! + assertThat(logs.items.map { it.body }).contains("User has made a purchase of product: 445") + val failureLog = logs.items.single { it.body == "Something went wrong" } + assertThat(failureLog.attributes!!.mapValues { it.value.value }) + .containsAtLeast("user.type", "admin", "feature.version", 2, "debug.enabled", true) + } +} diff --git a/sentry-logback/src/test/resources/sentry-appender-integration-test.xml b/sentry-logback/src/test/resources/sentry-appender-integration-test.xml new file mode 100644 index 00000000000..bfc3c4593b3 --- /dev/null +++ b/sentry-logback/src/test/resources/sentry-appender-integration-test.xml @@ -0,0 +1,16 @@ + + + + ${dsn} + userId + + WARN + DEBUG + INFO + true + + + + + + diff --git a/sentry-opentelemetry/sentry-opentelemetry-agent/api/sentry-opentelemetry-agent.api b/sentry-opentelemetry/sentry-opentelemetry-agent/api/sentry-opentelemetry-agent.api new file mode 100644 index 00000000000..eab018dee5d --- /dev/null +++ b/sentry-opentelemetry/sentry-opentelemetry-agent/api/sentry-opentelemetry-agent.api @@ -0,0 +1,4 @@ +public final class io/sentry/opentelemetry/agent/AgentMarker { + public fun ()V +} + diff --git a/sentry-opentelemetry/sentry-opentelemetry-agent/build.gradle.kts b/sentry-opentelemetry/sentry-opentelemetry-agent/build.gradle.kts index 054db790dc2..b4b1a8b840d 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-agent/build.gradle.kts +++ b/sentry-opentelemetry/sentry-opentelemetry-agent/build.gradle.kts @@ -1,12 +1,19 @@ import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar +import io.sentry.gradle.ChildJvmTestArguments +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile plugins { `java-library` id("io.sentry.animalsniffer") id("io.sentry.javadoc") + alias(libs.plugins.kotlin.jvm) alias(libs.plugins.shadow) } +tasks.named("compileKotlin") { + compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_1_8 +} + fun relocatePackages(shadowJar: ShadowJar) { // rewrite dependencies calling Logger.getLogger shadowJar.relocate("java.util.logging.Logger", "io.opentelemetry.javaagent.bootstrap.PatchLogger") @@ -76,6 +83,11 @@ dependencies { bootstrapLibs(projects.sentryOpentelemetry.sentryOpentelemetryBootstrap) javaagentLibs(projects.sentryOpentelemetry.sentryOpentelemetryAgentcustomization) upstreamAgent(libs.otel.javaagent) + + testImplementation(projects.sentryTestSupport) + testImplementation(kotlin(Config.kotlinStdLib)) + testImplementation(libs.kotlin.test.junit) + testImplementation(libs.google.truth) } fun isolateClasses(jars: Iterable): CopySpec = copySpec { @@ -197,6 +209,12 @@ tasks { } assemble { dependsOn(shadowJar) } + + test { + jvmArgumentProviders.add( + objects.newInstance().apply { agentJar.from(shadowJar) } + ) + } } tasks.named("distZip").configure { this.dependsOn("jar") } diff --git a/sentry-opentelemetry/sentry-opentelemetry-agent/src/test/java/io/sentry/opentelemetry/agent/fixture/HelloServer.java b/sentry-opentelemetry/sentry-opentelemetry-agent/src/test/java/io/sentry/opentelemetry/agent/fixture/HelloServer.java new file mode 100644 index 00000000000..b03287568d9 --- /dev/null +++ b/sentry-opentelemetry/sentry-opentelemetry-agent/src/test/java/io/sentry/opentelemetry/agent/fixture/HelloServer.java @@ -0,0 +1,34 @@ +package io.sentry.opentelemetry.agent.fixture; + +import com.sun.net.httpserver.HttpServer; +import java.io.IOException; +import java.io.OutputStream; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Paths; + +/** + * An app for the agent to instrument. It only uses the JDK, so a test can run it with nothing but + * this class on the classpath, and it writes its port to the file named by {@code PORTFILE}. + */ +public final class HelloServer { + public static void main(final String[] args) throws IOException { + final HttpServer server = + HttpServer.create(new InetSocketAddress(InetAddress.getByName("127.0.0.1"), 0), 0); + server.createContext( + "/hello", + exchange -> { + final byte[] body = "hello".getBytes(StandardCharsets.UTF_8); + exchange.sendResponseHeaders(200, body.length); + try (OutputStream out = exchange.getResponseBody()) { + out.write(body); + } + }); + server.start(); + Files.write( + Paths.get(System.getenv("PORTFILE")), + String.valueOf(server.getAddress().getPort()).getBytes(StandardCharsets.UTF_8)); + } +} diff --git a/sentry-opentelemetry/sentry-opentelemetry-agent/src/test/kotlin/io/sentry/opentelemetry/agent/AgentSmokeTest.kt b/sentry-opentelemetry/sentry-opentelemetry-agent/src/test/kotlin/io/sentry/opentelemetry/agent/AgentSmokeTest.kt new file mode 100644 index 00000000000..2764a7d183a --- /dev/null +++ b/sentry-opentelemetry/sentry-opentelemetry-agent/src/test/kotlin/io/sentry/opentelemetry/agent/AgentSmokeTest.kt @@ -0,0 +1,56 @@ +package io.sentry.opentelemetry.agent + +import com.google.common.truth.Truth.assertThat +import io.sentry.opentelemetry.agent.fixture.HelloServer +import io.sentry.test.ChildJvmApp +import io.sentry.test.MockSentryServer +import io.sentry.test.transaction +import java.io.File +import java.net.URL +import kotlin.test.AfterTest +import kotlin.test.Test + +/** + * Runs an app with the built agent attached, in a child JVM, so the test's own JVM stays + * uninstrumented. Instrumenting it would turn the test's HTTP calls and [MockSentryServer] itself + * into spans. + */ +class AgentSmokeTest { + private val server = MockSentryServer() + private var app: ChildJvmApp? = null + + @AfterTest + fun teardown() { + app?.close() + server.close() + } + + @Test + fun `agent sends a transaction for an incoming request`() { + val app = + ChildJvmApp.start( + mainClass = HelloServer::class.java.name, + classpath = + File(HelloServer::class.java.protectionDomain.codeSource.location.toURI()).path, + jvmArgs = listOf("-javaagent:${System.getProperty("sentry.test.agentJar")}"), + env = + mapOf( + "SENTRY_DSN" to server.dsn, + "SENTRY_TRACES_SAMPLE_RATE" to "1.0", + "OTEL_TRACES_EXPORTER" to "none", + "OTEL_METRICS_EXPORTER" to "none", + "OTEL_LOGS_EXPORTER" to "none", + ), + ) + .also { app = it } + + URL("http://127.0.0.1:${app.awaitPort()}/hello").readText() + + // The agent's BatchSpanProcessor exports every 5s. + val envelope = + server.awaitEnvelope(timeoutMillis = 30_000) { + it.transaction()?.contexts?.trace?.operation == "http.server" + } + assertThat(envelope.header.sdkVersion!!.name).isEqualTo("sentry.java.opentelemetry.agent") + } +} diff --git a/sentry-opentelemetry/sentry-opentelemetry-agentless/api/sentry-opentelemetry-agentless.api b/sentry-opentelemetry/sentry-opentelemetry-agentless/api/sentry-opentelemetry-agentless.api new file mode 100644 index 00000000000..45011c308e2 --- /dev/null +++ b/sentry-opentelemetry/sentry-opentelemetry-agentless/api/sentry-opentelemetry-agentless.api @@ -0,0 +1,9 @@ +public final class io/sentry/opentelemetry/agent/AgentlessMarker { + public fun ()V +} + +public final class io/sentry/opentelemetry/agentless/BuildConfig { + public static final field SENTRY_OPENTELEMETRY_AGENTLESS_SDK_NAME Ljava/lang/String; + public static final field VERSION_NAME Ljava/lang/String; +} + diff --git a/sentry-opentelemetry/sentry-opentelemetry-agentless/build.gradle.kts b/sentry-opentelemetry/sentry-opentelemetry-agentless/build.gradle.kts index 72508d73737..32d029746c1 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-agentless/build.gradle.kts +++ b/sentry-opentelemetry/sentry-opentelemetry-agentless/build.gradle.kts @@ -1,10 +1,17 @@ +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile + plugins { `java-library` id("io.sentry.javadoc") + alias(libs.plugins.kotlin.jvm) alias(libs.plugins.buildconfig) id("io.sentry.animalsniffer") } +tasks.named("compileKotlin") { + compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_1_8 +} + dependencies { api(projects.sentry) implementation(projects.sentryOpentelemetry.sentryOpentelemetryBootstrap) @@ -13,8 +20,17 @@ dependencies { api(libs.otel.semconv) api(libs.otel.semconv.incubating) api(libs.otel.extension.autoconfigure) + + testImplementation(projects.sentryAsyncProfiler) + testImplementation(projects.sentryTestSupport) + testImplementation(kotlin(Config.kotlinStdLib)) + testImplementation(libs.kotlin.test.junit) + testImplementation(libs.google.truth) } +// Sentry's OpenTelemetry customizer only initializes Sentry when the environment asks it to. +tasks.test { environment("SENTRY_AUTO_INIT", "true") } + buildConfig { useJavaOutput() packageName("io.sentry.opentelemetry.agentless") diff --git a/sentry-opentelemetry/sentry-opentelemetry-agentless/src/test/kotlin/io/sentry/opentelemetry/agentless/AgentlessEndToEndTest.kt b/sentry-opentelemetry/sentry-opentelemetry-agentless/src/test/kotlin/io/sentry/opentelemetry/agentless/AgentlessEndToEndTest.kt new file mode 100644 index 00000000000..8f9d5d0fb8a --- /dev/null +++ b/sentry-opentelemetry/sentry-opentelemetry-agentless/src/test/kotlin/io/sentry/opentelemetry/agentless/AgentlessEndToEndTest.kt @@ -0,0 +1,163 @@ +package io.sentry.opentelemetry.agentless + +import com.google.common.truth.Truth.assertThat +import io.opentelemetry.api.GlobalOpenTelemetry +import io.opentelemetry.sdk.OpenTelemetrySdk +import io.opentelemetry.sdk.autoconfigure.AutoConfiguredOpenTelemetrySdk +import io.sentry.Breadcrumb +import io.sentry.EventProcessor +import io.sentry.Hint +import io.sentry.Sentry +import io.sentry.SentryEvent +import io.sentry.SentryLevel +import io.sentry.protocol.Message +import io.sentry.test.MockSentryServer +import io.sentry.test.event +import io.sentry.test.hasFlag +import io.sentry.test.hasMetric +import io.sentry.test.hasMetricWithAttribute +import io.sentry.test.metrics +import io.sentry.test.profileChunk +import io.sentry.test.transaction +import java.util.concurrent.TimeUnit +import kotlin.test.AfterTest +import kotlin.test.Test + +/** + * Sets Sentry up the way an app without the agent does: the OpenTelemetry SDK's autoconfiguration + * loads Sentry's customizer, which initializes Sentry and has it trace through OpenTelemetry. + */ +class AgentlessEndToEndTest { + private val server = MockSentryServer() + private var openTelemetry: OpenTelemetrySdk? = null + + @AfterTest + fun teardown() { + openTelemetry?.close() + GlobalOpenTelemetry.resetForTest() + Sentry.close() + server.close() + SENTRY_PROPERTIES.forEach { System.clearProperty(it) } + } + + @Test + fun `Sentry set up by OpenTelemetry reports events, a profiled transaction and metrics`() { + // The customizer's Sentry.init reads these as external configuration. + System.setProperty("sentry.dsn", server.dsn) + System.setProperty("sentry.traces-sample-rate", "1.0") + System.setProperty("sentry.profile-session-sample-rate", "1.0") + System.setProperty("sentry.profile-lifecycle", "TRACE") + // The profiler rejects a traces path with a dot anywhere in it, which the module's absolute + // path can have. + System.setProperty( + "sentry.profiling-traces-dir-path", + "${System.getProperty("java.io.tmpdir")}/sentry-agentless-end-to-end-test", + ) + val openTelemetry = + AutoConfiguredOpenTelemetrySdk.builder() + .setResultAsGlobal() + .addPropertiesSupplier { + mapOf( + "otel.logs.exporter" to "none", + "otel.metrics.exporter" to "none", + "otel.traces.exporter" to "none", + ) + } + .build() + .openTelemetrySdk + this.openTelemetry = openTelemetry + + Sentry.configureScope { + it.addEventProcessor( + object : EventProcessor { + override fun process(event: SentryEvent, hint: Hint): SentryEvent = event.apply { + if ((level?.ordinal ?: 0) > SentryLevel.INFO.ordinal) { + addBreadcrumb(Breadcrumb("Processed by the event processor")) + } + } + } + ) + } + Sentry.withScope { + it.level = SentryLevel.FATAL + Sentry.captureMessage("Fatal message!") + } + Sentry.captureMessage("Some warning!", SentryLevel.WARNING) + Sentry.addFeatureFlag("my-feature-flag", true) + Sentry.setAttribute("user.type", "admin") + Sentry.setAttribute("feature.version", 2) + Sentry.metrics().count("countMetric") + Sentry.metrics().gauge("gaugeMetric", 5.0) + Sentry.metrics().distribution("distributionMetric", 7.0) + Sentry.captureException(RuntimeException("Some error!")) + Sentry.captureEvent( + SentryEvent().apply { + message = Message().apply { message = "Detailed event" } + fingerprints = listOf("NewClientDebug") + level = SentryLevel.DEBUG + } + ) + repeat(10) { i -> + Sentry.captureEvent( + SentryEvent().apply { message = Message().apply { message = "event $i" } } + ) + } + val transaction = Sentry.startTransaction("transaction name", "op") + val span = transaction.startChild("child") + Thread.sleep(100) + span.finish() + transaction.finish() + Sentry.flush(5000) + // Sentry exports OpenTelemetry spans in batches every 5 seconds. + openTelemetry.sdkTracerProvider.forceFlush().join(5, TimeUnit.SECONDS) + + val fatal = + server.awaitEnvelope { it.event()?.message?.formatted == "Fatal message!" }.event()!! + assertThat(fatal.level).isEqualTo(SentryLevel.FATAL) + assertThat(fatal.breadcrumbs!!.map { it.message }).contains("Processed by the event processor") + + val warning = + server.awaitEnvelope { it.event()?.message?.formatted == "Some warning!" }.event()!! + assertThat(warning.level).isEqualTo(SentryLevel.WARNING) + + val error = + server + .awaitEnvelope { it.event()?.exceptions?.any { e -> e.value == "Some error!" } == true } + .event()!! + assertThat(error.exceptions!!.map { it.type to it.value }) + .contains("RuntimeException" to "Some error!") + assertThat(error.hasFlag("my-feature-flag", true)).isTrue() + + val detailed = + server.awaitEnvelope { it.event()?.message?.message == "Detailed event" }.event()!! + assertThat(detailed.fingerprints).containsExactly("NewClientDebug") + assertThat(detailed.level).isEqualTo(SentryLevel.DEBUG) + + repeat(10) { i -> server.awaitEnvelope { it.event()?.message?.message == "event $i" } } + + val sent = + server.awaitEnvelope { it.transaction()?.transaction == "transaction name" }.transaction()!! + assertThat(sent.spans.map { it.op }).contains("child") + val profilerId = sent.contexts.profile?.profilerId + assertThat(profilerId).isNotNull() + server.awaitEnvelope(timeoutMillis = 30_000) { it.profileChunk()?.profilerId == profilerId } + + val metrics = server.awaitEnvelope { it.metrics() != null }.metrics()!! + assertThat(metrics.hasMetric("countMetric", "counter", 1.0)).isTrue() + assertThat(metrics.hasMetric("gaugeMetric", "gauge", 5.0)).isTrue() + assertThat(metrics.hasMetric("distributionMetric", "distribution", 7.0)).isTrue() + assertThat(metrics.hasMetricWithAttribute("countMetric", "user.type", "admin")).isTrue() + assertThat(metrics.hasMetricWithAttribute("countMetric", "feature.version", 2)).isTrue() + } + + private companion object { + val SENTRY_PROPERTIES = + listOf( + "sentry.dsn", + "sentry.traces-sample-rate", + "sentry.profile-session-sample-rate", + "sentry.profile-lifecycle", + "sentry.profiling-traces-dir-path", + ) + } +} diff --git a/sentry-opentelemetry/sentry-opentelemetry-otlp/build.gradle.kts b/sentry-opentelemetry/sentry-opentelemetry-otlp/build.gradle.kts index afab9ef180f..fb95d722d6f 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-otlp/build.gradle.kts +++ b/sentry-opentelemetry/sentry-opentelemetry-otlp/build.gradle.kts @@ -34,6 +34,7 @@ dependencies { testImplementation(projects.sentryTestSupport) testImplementation(kotlin(Config.kotlinStdLib)) testImplementation(libs.awaitility.kotlin) + testImplementation(libs.google.truth) testImplementation(libs.kotlin.test.junit) testImplementation(libs.mockito.kotlin) diff --git a/sentry-opentelemetry/sentry-opentelemetry-otlp/src/test/kotlin/OtlpEndToEndTest.kt b/sentry-opentelemetry/sentry-opentelemetry-otlp/src/test/kotlin/OtlpEndToEndTest.kt new file mode 100644 index 00000000000..ce66d5f9dae --- /dev/null +++ b/sentry-opentelemetry/sentry-opentelemetry-otlp/src/test/kotlin/OtlpEndToEndTest.kt @@ -0,0 +1,65 @@ +package io.sentry.opentelemetry.otlp + +import com.google.common.truth.Truth.assertThat +import io.opentelemetry.sdk.OpenTelemetrySdk +import io.opentelemetry.sdk.trace.SdkTracerProvider +import io.sentry.Sentry +import io.sentry.test.MockSentryServer +import io.sentry.test.event +import io.sentry.test.hasMetric +import io.sentry.test.initForTest +import io.sentry.test.logs +import io.sentry.test.metrics +import kotlin.test.AfterTest +import kotlin.test.Test + +/** + * An app that exports its OpenTelemetry spans over OTLP and reports everything else to Sentry, as + * the console OTLP sample does. + */ +class OtlpEndToEndTest { + private val server = MockSentryServer() + private val openTelemetry = + OpenTelemetrySdk.builder().setTracerProvider(SdkTracerProvider.builder().build()).build() + + @AfterTest + fun teardown() { + Sentry.close() + openTelemetry.close() + server.close() + } + + @Test + fun `an event inside an OpenTelemetry span is linked to it`() { + initForTest { + it.dsn = server.dsn + it.addEventProcessor(OpenTelemetryOtlpEventProcessor()) + } + + val span = openTelemetry.getTracer("otlp-end-to-end-test").spanBuilder("otelSpan").startSpan() + span.makeCurrent().use { + Sentry.captureMessage("this message is connected to the outerSpan") + Sentry.logger().error("Some error log message") + Sentry.metrics().count("invocations") + } + span.end() + Sentry.flush(5000) + + val event = + server + .awaitEnvelope { + it.event()?.message?.formatted == "this message is connected to the outerSpan" + } + .event()!! + // The span's trace goes over OTLP, so the event points to it rather than to a Sentry span. + val trace = event.contexts.trace!! + assertThat(trace.traceId.toString()).isEqualTo(span.spanContext.traceId) + assertThat(trace.spanId.toString()).isEqualTo(span.spanContext.spanId) + + val logs = server.awaitEnvelope { it.logs() != null }.logs()!! + assertThat(logs.items.map { it.body }).contains("Some error log message") + + val metrics = server.awaitEnvelope { it.metrics() != null }.metrics()!! + assertThat(metrics.hasMetric("invocations", "counter", 1.0)).isTrue() + } +} diff --git a/sentry-samples/sentry-samples-android/build.gradle.kts b/sentry-samples/sentry-samples-android/build.gradle.kts index cf4b054e354..a9d4ea8ac5f 100644 --- a/sentry-samples/sentry-samples-android/build.gradle.kts +++ b/sentry-samples/sentry-samples-android/build.gradle.kts @@ -208,6 +208,7 @@ dependencies { implementation(libs.androidx.navigation3.runtime) implementation(libs.androidx.navigation3.ui) implementation(libs.androidx.recyclerview) + implementation(libs.androidx.viewpager2) implementation(libs.androidx.browser) implementation(libs.androidx.room3.runtime) implementation(libs.bundles.androidx.room2) @@ -230,6 +231,9 @@ dependencies { debugImplementation(projects.sentryAndroidDistribution) debugImplementation(libs.leakcanary) + + testImplementation(libs.google.truth) + testImplementation(libs.kotlin.test.junit) } abstract class ToggleNativeLoggingTask : Exec() { diff --git a/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml b/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml index acd93384b1e..aef1b070a7b 100644 --- a/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml +++ b/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml @@ -123,6 +123,30 @@ android:screenOrientation="portrait" android:theme="@style/AppTheme.Main" /> + + + + + + + + + diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3Activity.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3Activity.kt index 5b935a01e01..e90e27573a9 100644 --- a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3Activity.kt +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3Activity.kt @@ -39,6 +39,8 @@ import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.testTagsAsResourceId import androidx.navigation3.runtime.entryProvider import androidx.navigation3.ui.NavDisplay import io.sentry.compose.navigation3.SentryBackStackEntry @@ -75,9 +77,6 @@ class Nav3Activity : ComponentActivity() { private var routeWorkOptions by mutableStateOf(setOf(RouteWorkOption.MANUAL_CHILD_SPAN)) - private var isTransactionHistoryActive = false - private val transactionHistory = - NavigationTransactionHistory(isActive = { isTransactionHistoryActive }) private var showTransactionHistorySheet by mutableStateOf(false) private var showActivityUiLoadTransactionDelayMessage = false @@ -99,14 +98,14 @@ class Nav3Activity : ComponentActivity() { cancelCurrentActivityUiLoadTransaction() } - transactionHistory.install() + NavigationTransactionHistory.install() setContent { Nav3SampleTheme { Nav3SampleApp( performanceState = performanceState, configuration = configuration, - transactions = transactionHistory.transactions, + transactions = NavigationTransactionHistory.transactions, showActivityUiLoadTransactionDelayMessage = showActivityUiLoadTransactionDelayMessage, routeWorkOptions = routeWorkOptions, showTransactionHistorySheet = showTransactionHistorySheet, @@ -124,13 +123,7 @@ class Nav3Activity : ComponentActivity() { } } - override fun onStart() { - super.onStart() - isTransactionHistoryActive = true - } - override fun onStop() { - isTransactionHistoryActive = false performanceState.stopAutomaticWork() super.onStop() } @@ -139,7 +132,6 @@ class Nav3Activity : ComponentActivity() { if (isFinishing) { previousConfig.applyToCurrentOptions() } - transactionHistory.uninstall() super.onDestroy() } @@ -360,8 +352,20 @@ private fun Nav3SampleApp( } } + val openFragmentScenario: (Nav3FragmentMode) -> Unit = { mode -> + activity?.let { host -> + host.startActivity( + Intent(host, Nav3FragmentsActivity::class.java) + .putExtra(Nav3FragmentsActivity.EXTRA_MODE, mode.name) + .putExtras(Bundle().apply { putNav3SampleConfiguration(configuration) }) + ) + } + } + Scaffold( - modifier = Modifier.fillMaxSize().safeDrawingPadding(), + // Expose test tags as resource IDs so adb/uiautomator can target them. + modifier = + Modifier.fillMaxSize().safeDrawingPadding().semantics { testTagsAsResourceId = true }, containerColor = MaterialTheme.colorScheme.background, topBar = { Nav3TopBar( @@ -464,6 +468,35 @@ private fun Nav3SampleApp( ) } } + entry { route -> + TracedNav3Route(route, selectedScenario) { + Nav3RouteWorkEffect(route, routeWorkOptions) + MultiActivityRoute { + activity?.startActivity( + Intent(activity, Nav3ProductsActivity::class.java) + .putExtras(Bundle().apply { putNav3SampleConfiguration(configuration) }) + ) + } + } + } + entry { route -> + TracedNav3Route(route, selectedScenario) { + Nav3RouteWorkEffect(route, routeWorkOptions) + FragmentScenarioRoute(route, Nav3FragmentMode.REPLACE, openFragmentScenario) + } + } + entry { route -> + TracedNav3Route(route, selectedScenario) { + Nav3RouteWorkEffect(route, routeWorkOptions) + FragmentScenarioRoute(route, Nav3FragmentMode.DIALOG, openFragmentScenario) + } + } + entry { route -> + TracedNav3Route(route, selectedScenario) { + Nav3RouteWorkEffect(route, routeWorkOptions) + FragmentScenarioRoute(route, Nav3FragmentMode.PAGER, openFragmentScenario) + } + } entry { route -> TracedNav3Route(route, selectedScenario) { Nav3RouteWorkEffect(route, routeWorkOptions) @@ -534,18 +567,6 @@ private fun Nav3SampleApp( } } - entry { route -> - TracedNav3Route(route, selectedScenario) { - Nav3RouteWorkEffect(route, routeWorkOptions) - FutureRoute(routeName = "Multipane", scenario = "multipane") - } - } - entry { route -> - TracedNav3Route(route, selectedScenario) { - Nav3RouteWorkEffect(route, routeWorkOptions) - FutureRoute(routeName = "Multiple Stacks", scenario = "multistack") - } - } entry { route -> TracedNav3Route(route, selectedScenario) { Nav3RouteWorkEffect(route, routeWorkOptions) diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3MultiActivity.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3MultiActivity.kt new file mode 100644 index 00000000000..5e6ca0b9d07 --- /dev/null +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3MultiActivity.kt @@ -0,0 +1,493 @@ +package io.sentry.samples.android.navigation.nav3 + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.util.Log +import android.widget.Toast +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawingPadding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.AccountTree +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshots.SnapshotStateList +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.platform.testTag +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.testTagsAsResourceId +import androidx.compose.ui.unit.dp +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.compose.currentStateAsState +import androidx.navigation3.runtime.entryProvider +import androidx.navigation3.ui.NavDisplay +import androidx.navigationevent.compose.LocalNavigationEventDispatcherOwner +import androidx.navigationevent.compose.rememberNavigationEventDispatcherOwner +import io.sentry.compose.SentryModifier.sentryTag +import io.sentry.compose.navigation3.SentryBackStackEntry +import io.sentry.compose.navigation3.SentryNavEffect +import io.sentry.compose.navigation3.SentryNavOptions +import io.sentry.samples.android.navigation.common.NavigationSampleConfig +import io.sentry.samples.android.navigation.common.RouteWorkOption +import io.sentry.samples.android.navigation.common.hasOnlyActivityUiLoadTransactions +import io.sentry.samples.android.navigation.common.showRouteWorkDialog +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +/* + * Activities that each own a Nav3 root (back stack, `SentryNavEffect`, and `NavDisplay`), which let us + * exercise how separate effects hand off during Activity transactions. + */ + +/** Keeps its navigation effect alive while checkout runs in another Activity. */ +class Nav3ProductsActivity : ComponentActivity() { + + private var backStack: SnapshotStateList? = null + + /** An order ID returned before [backStack] exists (e.g., after Activity recreation). */ + private var pendingOrderId: String? = null + + // Pushes a destination from the result callback itself, which runs before this Activity resumes, + // like an app that navigates in response to an Activity result. + private val checkoutForResult = + registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + val orderId = result.data?.getStringExtra(EXTRA_ORDER_ID) ?: return@registerForActivityResult + backStack?.add(Nav3Route.Confirmation(orderId)) ?: run { pendingOrderId = orderId } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val configuration = (intent.extras ?: Bundle()).nav3SampleConfiguration() + NavigationTransactionHistory.install() + if (!configuration.enableActivityUiLoadTransaction) { + cancelCurrentActivityUiLoadTransaction() + } + setContent { + val stack = rememberSaveableNav3BackStack(Nav3Route.ProductList) + SideEffect { + backStack = stack + pendingOrderId?.let { orderId -> + pendingOrderId = null + stack.add(Nav3Route.Confirmation(orderId)) + } + } + Nav3SampleTheme { + MultiRootNavDisplay( + activity = this, + title = "Products Activity", + initialRoute = Nav3Route.ProductList, + scenario = Nav3Scenario.MULTI_ACTIVITY, + configuration = configuration, + onFinish = { finish() }, + backStack = stack, + onCheckout = { productId -> startActivity(checkoutIntent(productId, configuration)) }, + productDetailActions = + listOf( + ProductDetailAction( + label = "Go to Translucent Checkout", + testTag = nav3TestTag("product_detail_go_to_translucent_checkout"), + onClick = { productId -> + startActivity( + checkoutIntent(productId, configuration) + .setClass(this, Nav3TranslucentCheckoutActivity::class.java) + ) + }, + ), + ProductDetailAction( + label = "Go to Checkout for Result", + testTag = nav3TestTag("product_detail_go_to_checkout_for_result"), + onClick = { productId -> + checkoutForResult.launch( + checkoutIntent(productId, configuration).putExtra(EXTRA_RETURN_RESULT, true) + ) + }, + ), + ProductDetailAction( + label = "Go to Checkout (Crash on First Composition)", + testTag = nav3TestTag("product_detail_go_to_checkout_crash"), + onClick = { productId -> + startActivity( + checkoutIntent(productId, configuration) + .putExtra(EXTRA_CRASH_ON_FIRST_COMPOSITION, true) + ) + }, + ), + ), + ) + } + } + } + + override fun onDestroy() { + backStack = null + super.onDestroy() + } + + private fun checkoutIntent(productId: String, configuration: NavigationSampleConfig): Intent = + Intent(this, Nav3CheckoutActivity::class.java) + .putExtra(EXTRA_PRODUCT_ID, productId) + .putExtras(Bundle().apply { putNav3SampleConfiguration(configuration) }) +} + +/** Owns the checkout back stack independently of the Products Activity. */ +open class Nav3CheckoutActivity : ComponentActivity() { + + /** Whether to show checkout as a sheet, leaving the Activity underneath partly visible. */ + protected open val showAsSheet: Boolean = false + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val configuration = (intent.extras ?: Bundle()).nav3SampleConfiguration() + val productId = intent.getStringExtra(EXTRA_PRODUCT_ID) ?: "42" + val returnsResult = intent.getBooleanExtra(EXTRA_RETURN_RESULT, false) + // Only crash on the first launch, not when the system restores this Activity. + val crashOnFirstComposition = + savedInstanceState == null && intent.getBooleanExtra(EXTRA_CRASH_ON_FIRST_COMPOSITION, false) + NavigationTransactionHistory.install() + if (!configuration.enableActivityUiLoadTransaction) { + cancelCurrentActivityUiLoadTransaction() + } + setContent { + Nav3SampleTheme { + if (showAsSheet) { + CheckoutSheet { + CheckoutRoot(productId, configuration, returnsResult, crashOnFirstComposition) + } + } else { + CheckoutRoot(productId, configuration, returnsResult, crashOnFirstComposition) + } + } + } + } + + @Composable + private fun CheckoutRoot( + productId: String, + configuration: NavigationSampleConfig, + returnsResult: Boolean, + crashOnFirstComposition: Boolean, + ) { + MultiRootNavDisplay( + activity = this, + title = if (showAsSheet) "Translucent Checkout Activity" else "Checkout Activity", + initialRoute = Nav3Route.Checkout(productId), + scenario = Nav3Scenario.MULTI_ACTIVITY, + configuration = configuration, + onFinish = { finish() }, + onCompleteOrder = + if (returnsResult) { + { orderId -> + setResult(RESULT_OK, Intent().putExtra(EXTRA_ORDER_ID, orderId)) + finish() + } + } else { + null + }, + crashOnFirstComposition = crashOnFirstComposition, + ) + } +} + +/** + * Checkout in a translucent Activity. The Products Activity underneath stays visible, so it's + * paused rather than stopped while checkout is open. + */ +class Nav3TranslucentCheckoutActivity : Nav3CheckoutActivity() { + override val showAsSheet: Boolean = true +} + +/** Shows [content] as a bottom sheet over a scrim, leaving the top of the screen see-through. */ +@Composable +private fun CheckoutSheet(content: @Composable () -> Unit) { + Box(modifier = Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.4f))) { + Box(modifier = Modifier.fillMaxWidth().fillMaxHeight(0.7f).align(Alignment.BottomCenter)) { + content() + } + } +} + +/** An extra Product Detail button, given the product's ID when clicked. */ +internal class ProductDetailAction( + val label: String, + val testTag: String, + val onClick: (productId: String) -> Unit, +) + +@Composable +internal fun MultiActivityRoute(onOpenProducts: () -> Unit) { + RouteScaffold( + routeSpec = Nav3Route.MultiActivity.routeSpec(), + testTagPrefix = nav3TestTag("route_multi_activity"), + cardContent = { + RouteButton( + "Open products Activity", + onClick = onOpenProducts, + testTag = nav3TestTag("multi_activity_open_products"), + ) + }, + ) +} + +/** + * A self-contained Nav3 root (back stack, [SentryNavEffect], and [NavDisplay]) shared by the + * Multi-Activity and Multi-Fragment samples, so each Activity or Fragment owns its own effect. + * + * @param backStack This root's back stack. Pass one to navigate it from outside the composition. + * @param onCheckout Opens checkout outside this root (e.g., another Activity or Fragment). When + * `null`, checkout is pushed onto this root's own back stack. + * @param productDetailActions Additional Product Detail buttons. Product Detail always offers a + * button that pushes a confirmation after a delay, so the push can happen while this root is + * covered by another one. + * @param onCompleteOrder Handles Checkout's "Complete Order" outside this root (e.g., by returning + * a result). When `null`, the confirmation is pushed onto this root's own back stack. + * @param crashOnFirstComposition Crashes when the Checkout destination's first composition is + * applied, so its navigation transaction and tags can attribute the crash to that screen. + */ +@OptIn(ExperimentalComposeUiApi::class) +@Composable +internal fun MultiRootNavDisplay( + activity: ComponentActivity, + title: String, + initialRoute: Nav3Route, + scenario: Nav3Scenario, + configuration: NavigationSampleConfig, + onFinish: () -> Unit, + backStack: SnapshotStateList = rememberSaveableNav3BackStack(initialRoute), + onCheckout: ((String) -> Unit)? = null, + productDetailActions: List = emptyList(), + onCompleteOrder: ((orderId: String) -> Unit)? = null, + crashOnFirstComposition: Boolean = false, +) { + val coroutineScope = rememberCoroutineScope() + var routeWorkOptions by remember { mutableStateOf(setOf(RouteWorkOption.MANUAL_CHILD_SPAN)) } + var showTransactionHistorySheet by remember { mutableStateOf(false) } + var showCrashConfirmation by remember { mutableStateOf(false) } + val options = + remember(configuration) { + SentryNavOptions { + enableNavigationBreadcrumbs = configuration.enableNavigationBreadcrumbs + enableNavigationTransactions = configuration.enableNavigationTransactions + captureBackStack = configuration.captureBackStack + maxCapturedBackStackEntries = configuration.maxCapturedBackStackEntries + } + } + SentryNavEffect( + backStack = backStack, + backStackEntryMapper = { route -> + SentryBackStackEntry(name = route.routeName, arguments = route.arguments) + }, + options = options, + ) + val onBack: () -> Unit = { + if (backStack.size > 1) { + backStack.removeLastOrNull() + } else { + onFinish() + } + } + Scaffold( + // Expose test tags as resource IDs so adb/uiautomator can target them. + modifier = + Modifier.fillMaxSize().safeDrawingPadding().semantics { testTagsAsResourceId = true }, + topBar = { + Column(modifier = Modifier.padding(horizontal = 24.dp, vertical = 12.dp)) { + Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Text( + title, + style = MaterialTheme.typography.titleLarge, + modifier = Modifier.weight(1f), + ) + IconButton( + onClick = { showTransactionHistorySheet = true }, + modifier = + Modifier.sentryTag(nav3InteractionTag("Recent Transactions")) + .testTag(nav3TestTag("top_bar_recent_transactions")), + ) { + Icon(Icons.Filled.AccountTree, contentDescription = "Recent transactions") + } + IconButton( + onClick = { + showRouteWorkDialog(activity, routeWorkOptions) { routeWorkOptions = it } + }, + modifier = + Modifier.sentryTag(nav3InteractionTag("Route Work Settings")) + .testTag(nav3TestTag("top_bar_route_work_settings")), + ) { + Icon(Icons.Filled.Settings, contentDescription = "Route work settings") + } + } + Text("Current route: ${backStack.last().displayRoute()}") + } + }, + bottomBar = { + SentryControls( + onCaptureException = { captureSampleException("Nav3 ${scenario.label}") }, + onCrashApp = { showCrashConfirmation = true }, + ) + }, + ) { padding -> + // Only handle back while this root is resumed. Otherwise, a root that's still composed but no + // longer current (e.g., a Fragment animating out, or a preloaded ViewPager2 page) could consume + // back presses meant for the current root, popping a back stack the user can't see. + val isResumed = + LocalLifecycleOwner.current.lifecycle + .currentStateAsState() + .value + .isAtLeast(Lifecycle.State.RESUMED) + // Pass the parent explicitly: it's null while this root's view is detached (e.g., a recycled + // ViewPager2 page that still recomposes), where the default parameter would throw. + val parentDispatcherOwner = LocalNavigationEventDispatcherOwner.current + CompositionLocalProvider( + LocalNavigationEventDispatcherOwner provides + rememberNavigationEventDispatcherOwner(enabled = isResumed, parent = parentDispatcherOwner) + ) { + NavDisplay( + backStack = backStack, + modifier = Modifier.fillMaxSize().padding(padding), + onBack = onBack, + entryProvider = + entryProvider { + entry { route -> + TracedNav3Route(route, scenario) { + Nav3RouteWorkEffect(route, routeWorkOptions) + ProductListRoute(backStack) + } + } + entry { route -> + TracedNav3Route(route, scenario) { + Nav3RouteWorkEffect(route, routeWorkOptions) + ProductDetailRoute( + route = route, + backStack = backStack, + onCheckout = { + onCheckout?.invoke(route.productId) + ?: backStack.add(Nav3Route.Checkout(route.productId)) + }, + showOverlays = false, + extraActions = { + RouteButton( + "Push Confirmation in $DELAYED_NAVIGATION_SECONDS s", + onClick = { + coroutineScope.launch { + delay(DELAYED_NAVIGATION_SECONDS * 1_000L) + backStack += Nav3Route.Confirmation("delayed-${route.productId}") + } + }, + testTag = nav3TestTag("product_detail_delayed_confirmation"), + ) + productDetailActions.forEach { action -> + RouteButton( + action.label, + onClick = { action.onClick(route.productId) }, + testTag = action.testTag, + ) + } + }, + ) + } + } + entry { route -> + TracedNav3Route(route, scenario) { + if (crashOnFirstComposition) { + SideEffect { + throw RuntimeException( + "Fatal Nav3 ${scenario.label} crash after first composition" + ) + } + } + Nav3RouteWorkEffect(route, routeWorkOptions) + CheckoutRoute( + route = route, + backStack = backStack, + onCompleteOrder = onCompleteOrder ?: { backStack += Nav3Route.Confirmation(it) }, + ) + } + } + entry { route -> + TracedNav3Route(route, scenario) { + Nav3RouteWorkEffect(route, routeWorkOptions) + ConfirmationRoute(route, backStack, rootRoute = initialRoute) + } + } + }, + ) + } + } + if (showCrashConfirmation) { + AlertDialog( + onDismissRequest = { showCrashConfirmation = false }, + title = { Text("Crash app?") }, + text = { Text("This will throw an uncaught exception and close the sample app.") }, + dismissButton = { + TextButton(onClick = { showCrashConfirmation = false }) { Text("Cancel") } + }, + confirmButton = { + TextButton( + onClick = { + showCrashConfirmation = false + crashSampleApp("Nav3 ${scenario.label}") + } + ) { + Text("Crash") + } + }, + ) + } + if (showTransactionHistorySheet) { + NavigationTransactionHistorySheet( + sampleName = "Nav3", + transactions = NavigationTransactionHistory.transactions, + showActivityUiLoadTransactionDelayMessage = configuration.hasOnlyActivityUiLoadTransactions, + onDismissRequest = { showTransactionHistorySheet = false }, + onOpenTransaction = { url -> + activity.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url))) + }, + onDumpTransactionUrl = { url -> + Log.i("Nav3MultiActivity", "Sentry transaction URL: $url") + Toast.makeText(activity, "Dumped transaction URL to logcat.", Toast.LENGTH_SHORT).show() + }, + onCopyTransactionUrl = { url -> + val clipboard = activity.getSystemService(ClipboardManager::class.java) + clipboard.setPrimaryClip(ClipData.newPlainText("Sentry transaction URL", url)) + Toast.makeText(activity, "Copied transaction URL to clipboard.", Toast.LENGTH_SHORT).show() + }, + ) + } +} + +private const val DELAYED_NAVIGATION_SECONDS = 5 +private const val EXTRA_PRODUCT_ID = "io.sentry.samples.android.navigation.nav3.product_id" +private const val EXTRA_RETURN_RESULT = "io.sentry.samples.android.navigation.nav3.return_result" +private const val EXTRA_ORDER_ID = "io.sentry.samples.android.navigation.nav3.order_id" +private const val EXTRA_CRASH_ON_FIRST_COMPOSITION = + "io.sentry.samples.android.navigation.nav3.crash_on_first_composition" diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3MultiFragment.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3MultiFragment.kt new file mode 100644 index 00000000000..846344103c6 --- /dev/null +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3MultiFragment.kt @@ -0,0 +1,453 @@ +package io.sentry.samples.android.navigation.nav3 + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.LinearLayout +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.testTagsAsResourceId +import androidx.compose.ui.unit.dp +import androidx.core.graphics.Insets +import androidx.core.os.bundleOf +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.fragment.app.DialogFragment +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentActivity +import androidx.fragment.app.FragmentContainerView +import androidx.fragment.app.commit +import androidx.fragment.app.commitNow +import androidx.viewpager2.adapter.FragmentStateAdapter +import androidx.viewpager2.widget.ViewPager2 +import io.sentry.samples.android.R +import io.sentry.samples.android.navigation.common.NavigationSampleConfig + +/** + * Hosts Fragments that each own a Nav3 root (back stack, `SentryNavEffect`, and `NavDisplay`), to + * exercise how separate effects hand off in three independently selectable scenarios: + * + * - `replace` + back stack (Products → Checkout), optionally with a slow enter animation; + * - a full-screen `DialogFragment` overlay whose host Fragment stays resumed; + * - a `ViewPager2` of Fragments, where neighboring pages are preloaded at STARTED. + */ +// FragmentActivity rather than AppCompatActivity: AppCompatActivity.setContentView doesn't install +// the NavigationEventDispatcherOwner that NavDisplay needs on the view tree. +class Nav3FragmentsActivity : FragmentActivity() { + + internal val mode: Nav3FragmentMode + get() = + Nav3FragmentMode.entries.firstOrNull { it.name == intent.getStringExtra(EXTRA_MODE) } + ?: Nav3FragmentMode.REPLACE + + internal companion object { + const val EXTRA_MODE = "io.sentry.samples.android.navigation.nav3.FRAGMENT_MODE" + } + + /** When true, Fragment `replace` uses a slow enter animation. */ + internal var slowTransitions by mutableStateOf(false) + + internal val configuration: NavigationSampleConfig + get() = (intent.extras ?: Bundle()).nav3SampleConfiguration() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + NavigationTransactionHistory.install() + if (!configuration.enableActivityUiLoadTransaction) { + cancelCurrentActivityUiLoadTransaction() + } + + val controls = + ComposeView(this).apply { + setContent { + Nav3SampleTheme { + MultiFragmentControls( + mode = mode, + slowTransitions = slowTransitions, + onSlowTransitionsChanged = { slowTransitions = it }, + ) + } + } + } + val container = FragmentContainerView(this).apply { id = R.id.nav3_multi_fragment_container } + // The controls above already pad for the status bar; keep Fragments from padding for it again. + ViewCompat.setOnApplyWindowInsetsListener(container) { _, insets -> + WindowInsetsCompat.Builder(insets) + .setInsets(WindowInsetsCompat.Type.statusBars(), Insets.NONE) + .setInsets(WindowInsetsCompat.Type.displayCutout(), Insets.NONE) + .build() + } + setContentView( + LinearLayout(this).apply { + orientation = LinearLayout.VERTICAL + addView(controls, LinearLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT)) + addView(container, LinearLayout.LayoutParams(MATCH_PARENT, 0, 1f)) + } + ) + + if (savedInstanceState == null) { + if (mode == Nav3FragmentMode.REPLACE) { + supportFragmentManager.commit { + setReorderingAllowed(true) + add(R.id.nav3_multi_fragment_container, Nav3RootFragment.products()) + } + } else { + // Keep Products beneath the overlay or pager for dismissal and Back handoffs. + supportFragmentManager.commitNow { + setReorderingAllowed(true) + add(R.id.nav3_multi_fragment_container, Nav3RootFragment.products()) + } + if (mode == Nav3FragmentMode.DIALOG) { + openCheckoutSheet() + } else { + openPager() + } + } + } + } + + internal fun openCheckoutFragment(productId: String) { + supportFragmentManager.commit { + setReorderingAllowed(true) + if (slowTransitions) { + setCustomAnimations( + R.anim.nav3_slow_fade_in, + R.anim.nav3_slow_hold, + R.anim.nav3_slow_fade_in, + R.anim.nav3_slow_hold, + ) + } else { + setCustomAnimations( + android.R.anim.fade_in, + android.R.anim.fade_out, + android.R.anim.fade_in, + android.R.anim.fade_out, + ) + } + replace(R.id.nav3_multi_fragment_container, Nav3RootFragment.checkout(productId)) + addToBackStack("checkout") + } + } + + private fun openPager() { + supportFragmentManager.commit { + setReorderingAllowed(true) + replace(R.id.nav3_multi_fragment_container, Nav3PagerFragment()) + addToBackStack("pager") + } + } + + private fun openCheckoutSheet() { + Nav3CheckoutSheetFragment().show(supportFragmentManager, "checkout_sheet") + } + + /** Pops this Activity's Fragment back stack, or finishes the Activity when it's empty. */ + internal fun finishRoot() { + if (supportFragmentManager.backStackEntryCount > 0) { + supportFragmentManager.popBackStack() + } else { + finish() + } + } +} + +internal enum class Nav3FragmentMode(val scenario: Nav3Scenario) { + REPLACE(Nav3Scenario.MULTI_FRAGMENT), + DIALOG(Nav3Scenario.FRAGMENT_OVERLAY), + PAGER(Nav3Scenario.VIEW_PAGER2), +} + +/** A Fragment that owns one Nav3 root. Pager pages are child Fragments of [Nav3PagerFragment]. */ +class Nav3RootFragment : Fragment() { + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View { + val args = requireArguments() + val host = requireNotNull(args.getString(ARG_HOST)) + val activity = requireActivity() as Nav3FragmentsActivity + val initialRoute = + when (args.getString(ARG_KIND)) { + KIND_CHECKOUT -> Nav3Route.Checkout(requireNotNull(args.getString(ARG_PRODUCT_ID))) + KIND_PAGE -> pageRoots[args.getInt(ARG_PAGE)] + else -> Nav3Route.ProductList + } + val opensCheckoutFragment = + args.getString(ARG_KIND) == KIND_PRODUCTS && activity.mode == Nav3FragmentMode.REPLACE + + return nav3RootComposeView( + activity = activity, + host = host, + initialRoute = initialRoute, + configuration = activity.configuration, + onFinish = activity::finishRoot, + onCheckout = if (opensCheckoutFragment) activity::openCheckoutFragment else null, + ) + } + + internal companion object { + private const val ARG_KIND = "kind" + private const val ARG_HOST = "host" + private const val ARG_PRODUCT_ID = "product_id" + private const val ARG_PAGE = "page" + private const val KIND_PRODUCTS = "products" + private const val KIND_CHECKOUT = "checkout" + private const val KIND_PAGE = "page" + + internal val pageRoots: List = + listOf(Nav3Route.ProductList, Nav3Route.Checkout("7"), Nav3Route.Confirmation("page-2")) + + fun products() = + Nav3RootFragment().apply { + arguments = bundleOf(ARG_KIND to KIND_PRODUCTS, ARG_HOST to "ProductsFragment") + } + + fun checkout(productId: String) = + Nav3RootFragment().apply { + arguments = + bundleOf( + ARG_KIND to KIND_CHECKOUT, + ARG_HOST to "CheckoutFragment", + ARG_PRODUCT_ID to productId, + ) + } + + fun page(index: Int) = + Nav3RootFragment().apply { + arguments = bundleOf(ARG_KIND to KIND_PAGE, ARG_HOST to "Page$index", ARG_PAGE to index) + } + } +} + +/** A full-screen dialog with its own Nav3 root. The Fragment underneath stays resumed. */ +class Nav3CheckoutSheetFragment : DialogFragment() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setStyle(STYLE_NO_TITLE, R.style.AppTheme_Main) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View { + val activity = requireActivity() as Nav3FragmentsActivity + return nav3RootComposeView( + activity = activity, + host = "CheckoutSheet", + title = "Checkout dialog", + initialRoute = Nav3Route.Checkout("42"), + configuration = activity.configuration, + onFinish = ::dismiss, + onCheckout = null, + ) + } + + override fun onStart() { + super.onStart() + dialog?.window?.setLayout(MATCH_PARENT, MATCH_PARENT) + } +} + +/** + * A `ViewPager2` whose pages each own a Nav3 root. With `offscreenPageLimit = 1`, the neighbors of + * the current page are created and capped at STARTED, while only the current page is RESUMED. + */ +class Nav3PagerFragment : Fragment() { + + private var currentPage by mutableIntStateOf(0) + private var pager: ViewPager2? = null + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View { + val viewPager = + ViewPager2(requireContext()).apply { + id = R.id.nav3_multi_fragment_pager + offscreenPageLimit = 1 + } + pager = viewPager + val header = + ComposeView(requireContext()).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + Nav3SampleTheme { + PagerHeader(currentPage = currentPage, onSelectPage = { viewPager.currentItem = it }) + } + } + } + return LinearLayout(requireContext()).apply { + orientation = LinearLayout.VERTICAL + addView(header, LinearLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT)) + addView(viewPager, LinearLayout.LayoutParams(MATCH_PARENT, 0, 1f)) + } + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + val viewPager = requireNotNull(pager) + viewPager.adapter = + object : FragmentStateAdapter(childFragmentManager, viewLifecycleOwner.lifecycle) { + override fun getItemCount(): Int = Nav3RootFragment.pageRoots.size + + override fun createFragment(position: Int): Fragment = Nav3RootFragment.page(position) + } + viewPager.registerOnPageChangeCallback( + object : ViewPager2.OnPageChangeCallback() { + override fun onPageSelected(position: Int) { + currentPage = position + } + } + ) + } + + override fun onDestroyView() { + pager = null + super.onDestroyView() + } +} + +@Composable +internal fun FragmentScenarioRoute( + route: Nav3Route, + mode: Nav3FragmentMode, + onOpenFragments: (Nav3FragmentMode) -> Unit, +) { + RouteScaffold( + routeSpec = route.routeSpec(), + testTagPrefix = nav3TestTag("route_${mode.name.lowercase()}_fragments"), + cardContent = { + RouteButton( + when (mode) { + Nav3FragmentMode.REPLACE -> "Open Products Fragment" + Nav3FragmentMode.DIALOG -> "Open Checkout dialog" + Nav3FragmentMode.PAGER -> "Open ViewPager2" + }, + onClick = { onOpenFragments(mode) }, + testTag = + nav3TestTag( + if (mode == Nav3FragmentMode.REPLACE) "multi_fragment_open_fragments" + else "${mode.name.lowercase()}_open_fragments" + ), + ) + }, + ) +} + +private fun Fragment.nav3RootComposeView( + activity: Nav3FragmentsActivity, + host: String, + title: String = host, + initialRoute: Nav3Route, + configuration: NavigationSampleConfig, + onFinish: () -> Unit, + onCheckout: ((String) -> Unit)?, +): ComposeView = + ComposeView(requireContext()).apply { + // Dispose with the Fragment's view, so the root's SentryNavEffect leaves the composition when + // the Fragment's view is destroyed (e.g., when it's replaced and put on the back stack). + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + Nav3SampleTheme { + CompositionLocalProvider(LocalNav3SampleHost provides host) { + MultiRootNavDisplay( + activity = activity, + title = title, + initialRoute = initialRoute, + scenario = activity.mode.scenario, + configuration = configuration, + onFinish = onFinish, + onCheckout = onCheckout, + ) + } + } + } + } + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +private fun MultiFragmentControls( + mode: Nav3FragmentMode, + slowTransitions: Boolean, + onSlowTransitionsChanged: (Boolean) -> Unit, +) { + Surface( + shadowElevation = 4.dp, + modifier = Modifier.statusBarsPadding().semantics { testTagsAsResourceId = true }, + ) { + Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp)) { + Text(mode.scenario.label, style = MaterialTheme.typography.titleMedium) + when (mode) { + Nav3FragmentMode.REPLACE -> { + Row(verticalAlignment = Alignment.CenterVertically) { + Text("Slow Fragment transitions", modifier = Modifier.weight(1f)) + Switch( + checked = slowTransitions, + onCheckedChange = onSlowTransitionsChanged, + modifier = Modifier.testTag(nav3TestTag("multi_fragment_slow_transitions")), + ) + } + } + Nav3FragmentMode.DIALOG, + Nav3FragmentMode.PAGER -> Unit + } + } + } +} + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +private fun PagerHeader(currentPage: Int, onSelectPage: (Int) -> Unit) { + Column( + modifier = + Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp).semantics { + testTagsAsResourceId = true + } + ) { + Text( + "ViewPager2 page $currentPage (neighbors preloaded at STARTED)", + modifier = Modifier.testTag(nav3TestTag("multi_fragment_current_page")), + ) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Nav3RootFragment.pageRoots.indices.forEach { index -> + OutlinedButton( + onClick = { onSelectPage(index) }, + modifier = Modifier.testTag(nav3TestTag("multi_fragment_page_$index")), + ) { + Text("Page $index") + } + } + } + } +} + +private const val MATCH_PARENT = ViewGroup.LayoutParams.MATCH_PARENT +private const val WRAP_CONTENT = ViewGroup.LayoutParams.WRAP_CONTENT diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3RouteScreens.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3RouteScreens.kt index 1d4b933fdc3..64e3ee41f39 100644 --- a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3RouteScreens.kt +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3RouteScreens.kt @@ -28,25 +28,30 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.SideEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshots.SnapshotStateList +import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.colorResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import io.sentry.ITransaction import io.sentry.Sentry import io.sentry.compose.SentryModifier.sentryTag import io.sentry.compose.SentryTraced import io.sentry.samples.android.R +import io.sentry.samples.android.getActivity import io.sentry.samples.android.navigation.common.RouteNames import io.sentry.samples.android.navigation.common.RouteSpec import io.sentry.samples.android.navigation.common.RouteSpecs @@ -58,6 +63,12 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import retrofit2.HttpException +/** + * Names the Activity or Fragment hosting the current Nav3 root, for the sample's transaction tags. + * Defaults to the Activity's class name when unset. + */ +internal val LocalNav3SampleHost = staticCompositionLocalOf { null } + @ExperimentalComposeUiApi @Composable internal fun TracedNav3Route( @@ -65,7 +76,10 @@ internal fun TracedNav3Route( scenario: Nav3Scenario, content: @Composable BoxScope.() -> Unit, ) { - tagCurrentNav3Scenario(scenario) + val activityName = LocalContext.current.getActivity().javaClass.simpleName + val hostName = LocalNav3SampleHost.current ?: activityName + // Tag after composition is applied so abandoned compositions don't tag anything. + SideEffect { tagCurrentNav3Scenario(route, scenario, hostName, activityName) } SentryTraced( tag = "Nav3 /${route.routeName}", enableUserInteractionTracing = false, @@ -73,9 +87,52 @@ internal fun TracedNav3Route( ) } -private fun tagCurrentNav3Scenario(scenario: Nav3Scenario) { - Sentry.getSpan()?.setTag("sample_nav3_scenario", scenario.label) - tagCurrentNavigationSampleScenario(scenario.label) +private fun tagCurrentNav3Scenario( + route: Nav3Route, + scenario: Nav3Scenario, + hostName: String, + activityName: String, +) { + val sessionId = NavigationTransactionHistory.sessionId + Sentry.configureScope { scope -> + scope.withTransaction { transaction -> + tagNavigationSampleTransaction( + transaction, + route, + scenario, + hostName, + activityName, + sessionId, + ) + } + } +} + +private fun tagNavigationSampleTransaction( + transaction: ITransaction?, + route: Nav3Route, + scenario: Nav3Scenario, + hostName: String, + activityName: String, + sessionId: String, +) { + // A covered root can remain composed and re-run this SideEffect after another root acquires the + // navigation lease. Only tag this destination's transaction, or an untagged ui.load transaction + // owned by its Activity. + val activeTransaction = transaction ?: return + val ownsNavigationTransaction = activeTransaction.name == "/${route.routeName}" + val ownsUiLoadTransaction = + activeTransaction.operation == ACTIVITY_UI_LOAD_OP && + activeTransaction.name == activityName && + activeTransaction.getTag(NAVIGATION_SAMPLE_ACTIVITY_TAG) == null + if (!ownsNavigationTransaction && !ownsUiLoadTransaction) { + return + } + + activeTransaction.setTag(NAV3_SCENARIO_TAG, scenario.label) + activeTransaction.setTag(NAVIGATION_SAMPLE_SCENARIO_TAG, scenario.label) + activeTransaction.setTag(NAVIGATION_SAMPLE_ACTIVITY_TAG, hostName) + activeTransaction.setTag(NAVIGATION_SAMPLE_SESSION_TAG, sessionId) } @Composable @@ -496,6 +553,9 @@ internal fun ProductListRoute(backStack: SnapshotStateList) { internal fun ProductDetailRoute( route: Nav3Route.ProductDetail, backStack: SnapshotStateList, + onCheckout: () -> Unit = { backStack.add(Nav3Route.Checkout(route.productId)) }, + showOverlays: Boolean = true, + extraActions: @Composable ColumnScope.() -> Unit = {}, ) { LaunchedEffect(route.productId, route.source, route.campaign) { recordSimulatedBackgroundSpan(RouteNames.PRODUCT_DETAIL) @@ -508,21 +568,24 @@ internal fun ProductDetailRoute( RouteSpecs.productDetail.displayArguments(route.arguments).forEach { (label, value) -> RouteInfo(label, value) } - RouteButton( - "Show Promo Dialog", - onClick = { backStack.add(Nav3Route.PromoDialog("detail-${route.productId}")) }, - testTag = nav3TestTag("product_detail_show_promo_dialog"), - ) - RouteButton( - "Open Share Sheet", - onClick = { backStack.add(Nav3Route.ShareSheet(route.productId)) }, - testTag = nav3TestTag("product_detail_open_share_sheet"), - ) + if (showOverlays) { + RouteButton( + "Show Promo Dialog", + onClick = { backStack.add(Nav3Route.PromoDialog("detail-${route.productId}")) }, + testTag = nav3TestTag("product_detail_show_promo_dialog"), + ) + RouteButton( + "Open Share Sheet", + onClick = { backStack.add(Nav3Route.ShareSheet(route.productId)) }, + testTag = nav3TestTag("product_detail_open_share_sheet"), + ) + } RouteButton( "Go to Checkout", - onClick = { backStack.add(Nav3Route.Checkout(route.productId)) }, + onClick = onCheckout, testTag = nav3TestTag("product_detail_go_to_checkout"), ) + extraActions() }, footerContent = { RouteButton( @@ -538,6 +601,7 @@ internal fun ProductDetailRoute( internal fun CheckoutRoute( route: Nav3Route.Checkout, backStack: SnapshotStateList, + onCompleteOrder: (orderId: String) -> Unit = { backStack.add(Nav3Route.Confirmation(it)) }, ) { RouteScaffold( routeSpec = RouteSpecs.checkout, @@ -548,7 +612,7 @@ internal fun CheckoutRoute( } RouteButton( "Complete Order", - onClick = { backStack.add(Nav3Route.Confirmation(orderId = "order-${route.productId}")) }, + onClick = { onCompleteOrder("order-${route.productId}") }, testTag = nav3TestTag("checkout_complete_order"), ) }, @@ -670,21 +734,6 @@ internal fun ShareSheetRoute( } } -@Composable -internal fun FutureRoute(routeName: String, scenario: String) { - RouteScaffold( - routeSpec = - RouteSpecs.get(routeName) - .copy( - title = "$routeName: WIP", - description = - "Reserved for a future milestone when SentryNavEffect supports $scenario navigation " + - "state.", - ), - testTagPrefix = nav3RouteTag(routeName), - ) -} - @OptIn(ExperimentalComposeUiApi::class) @Composable private fun Nav3ProductListItem(index: Int) { diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3Routes.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3Routes.kt index b9972a6cd76..70b5232c533 100644 --- a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3Routes.kt +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3Routes.kt @@ -26,6 +26,39 @@ internal fun Nav3Route.routeSpec(): RouteSpec = "Reuses the single-stack shopping flow while simulating a power user who starts their " + "own manual transactions.", ) + Nav3Route.MultiActivity -> + RouteSpec( + routeName = Nav3Route.MultiActivity.routeName, + title = "Multi-Activity", + description = + "Tests how SentryNavEffect uses NavScopes to coordinate state updates between " + + "SentryNavEffect instances. Each Activity owns its own SentryNavEffect and NavDisplay.", + ) + Nav3Route.MultiFragment -> + RouteSpec( + routeName = Nav3Route.MultiFragment.routeName, + title = "Multi-Fragment", + description = + "Replace a Products Fragment with a Checkout Fragment. Each Fragment owns its own " + + "SentryNavEffect and NavDisplay. Try a slow transition and press Back during it.", + ) + Nav3Route.FragmentOverlay -> + RouteSpec( + routeName = Nav3Route.FragmentOverlay.routeName, + title = "Fragment: Overlay", + description = + "Open a full-screen Checkout DialogFragment over a Products Fragment. Both own a " + + "SentryNavEffect and NavDisplay, and Products stays resumed underneath. Back dismisses " + + "the dialog and reveals Products.", + ) + Nav3Route.ViewPager2 -> + RouteSpec( + routeName = Nav3Route.ViewPager2.routeName, + title = "ViewPager2", + description = + "Each page is a Fragment with its own SentryNavEffect and NavDisplay. Neighboring " + + "pages are preloaded at STARTED. Back returns to the Products Fragment underneath.", + ) Nav3Route.DeepLink -> RouteSpec( routeName = Nav3Route.DeepLink.routeName, @@ -40,8 +73,6 @@ internal fun Nav3Route.routeSpec(): RouteSpec = is Nav3Route.Confirmation -> RouteSpecs.confirmation is Nav3Route.PromoDialog -> RouteSpecs.promoDialog is Nav3Route.ShareSheet -> RouteSpecs.shareSheet - Nav3Route.Multipane, - Nav3Route.Multistack, is Nav3Route.Performance -> RouteSpec(routeName = routeName, title = routeName) } @@ -69,6 +100,10 @@ private fun Nav3Route.toSavedState(): Bundle = Nav3Route.Landing -> putString("type", "landing") Nav3Route.SingleStack -> putString("type", "single_stack") Nav3Route.Custom -> putString("type", "custom") + Nav3Route.MultiActivity -> putString("type", "multi_activity") + Nav3Route.MultiFragment -> putString("type", "multi_fragment") + Nav3Route.FragmentOverlay -> putString("type", "fragment_overlay") + Nav3Route.ViewPager2 -> putString("type", "view_pager2") Nav3Route.DeepLink -> putString("type", "deep_link") Nav3Route.ProductList -> putString("type", "product_list") is Nav3Route.ProductDetail -> { @@ -93,8 +128,6 @@ private fun Nav3Route.toSavedState(): Bundle = putString("type", "share_sheet") putString("product_id", productId) } - Nav3Route.Multipane -> putString("type", "multipane") - Nav3Route.Multistack -> putString("type", "multistack") is Nav3Route.Performance -> { putString("type", "performance") putInt("index", index) @@ -108,6 +141,13 @@ private fun Bundle.toNav3Route(): Nav3Route { "landing" -> Nav3Route.Landing "single_stack" -> Nav3Route.SingleStack "custom" -> Nav3Route.Custom + "multi_activity" -> Nav3Route.MultiActivity + "multi_fragment", + "fragment_replace" -> Nav3Route.MultiFragment + "dialog_overlay", + "fragment_overlay" -> Nav3Route.FragmentOverlay + "view_pager_pages", + "view_pager2" -> Nav3Route.ViewPager2 "deep_link" -> Nav3Route.DeepLink "product_list" -> Nav3Route.ProductList "product_detail" -> { @@ -127,8 +167,6 @@ private fun Bundle.toNav3Route(): Nav3Route { Nav3Route.PromoDialog(promoId = getString("promo_id") ?: return Nav3Route.SingleStack) "share_sheet" -> Nav3Route.ShareSheet(productId = getString("product_id") ?: return Nav3Route.SingleStack) - "multipane" -> Nav3Route.Multipane - "multistack" -> Nav3Route.Multistack "performance" -> Nav3Route.Performance( index = getInt("index"), @@ -147,10 +185,12 @@ internal fun SnapshotStateList.openScenario(scenario: Nav3Scenario) { when (scenario) { Nav3Scenario.LANDING -> resetTo(Nav3Route.Landing) Nav3Scenario.SINGLE_STACK -> resetTo(Nav3Route.SingleStack) - Nav3Scenario.CUSTOM -> resetTo(Nav3Route.Custom) + Nav3Scenario.MULTI_ACTIVITY -> resetTo(Nav3Route.MultiActivity) + Nav3Scenario.MULTI_FRAGMENT -> resetTo(Nav3Route.MultiFragment) + Nav3Scenario.FRAGMENT_OVERLAY -> resetTo(Nav3Route.FragmentOverlay) + Nav3Scenario.VIEW_PAGER2 -> resetTo(Nav3Route.ViewPager2) Nav3Scenario.DEEP_LINK -> resetTo(Nav3Route.DeepLink) - Nav3Scenario.MULTIPANE -> resetTo(Nav3Route.Multipane) - Nav3Scenario.MULTIPLE_STACKS -> resetTo(Nav3Route.Multistack) + Nav3Scenario.CUSTOM -> resetTo(Nav3Route.Custom) Nav3Scenario.PERFORMANCE -> resetTo(Nav3Route.Performance(index = 0, generation = 0)) } } @@ -211,6 +251,22 @@ internal sealed interface Nav3Route { override val routeName: String = "Custom" } + data object MultiActivity : Nav3Route { + override val routeName: String = "MultiActivity" + } + + data object MultiFragment : Nav3Route { + override val routeName: String = "MultiFragment" + } + + data object FragmentOverlay : Nav3Route { + override val routeName: String = "FragmentOverlay" + } + + data object ViewPager2 : Nav3Route { + override val routeName: String = "ViewPager2" + } + data object DeepLink : Nav3Route { override val routeName: String = RouteNames.DEEP_LINK } @@ -259,16 +315,6 @@ internal sealed interface Nav3Route { override val previewName: String = "ShareSheet($productId)" } - data object Multipane : Nav3Route { - override val routeName: String = "Multipane" - override val arguments: Map = mapOf("scenario" to "multipane") - } - - data object Multistack : Nav3Route { - override val routeName: String = "Multistack" - override val arguments: Map = mapOf("scenario" to "multistack") - } - data class Performance(val index: Int, val generation: Int) : Nav3Route { override val routeName: String = "Performance" override val arguments: Map = mapOf("index" to index, "generation" to generation) @@ -281,9 +327,11 @@ internal enum class Nav3Scenario(val label: String, val showTab: Boolean = true) LANDING(RouteNames.LANDING, showTab = false), SINGLE_STACK("Single Stack"), DEEP_LINK("Deep Link"), + MULTI_ACTIVITY("Multi-Activity"), + MULTI_FRAGMENT("Multi-Fragment"), + FRAGMENT_OVERLAY("Fragment: Overlay"), + VIEW_PAGER2("ViewPager2"), CUSTOM("Custom"), - MULTIPANE("Multipane"), - MULTIPLE_STACKS("Multistack"), PERFORMANCE("Performance"), } @@ -292,9 +340,11 @@ internal val Nav3Scenario.initialRoute: Nav3Route when (this) { Nav3Scenario.LANDING -> Nav3Route.Landing Nav3Scenario.SINGLE_STACK -> Nav3Route.SingleStack - Nav3Scenario.CUSTOM -> Nav3Route.Custom + Nav3Scenario.MULTI_ACTIVITY -> Nav3Route.MultiActivity + Nav3Scenario.MULTI_FRAGMENT -> Nav3Route.MultiFragment + Nav3Scenario.FRAGMENT_OVERLAY -> Nav3Route.FragmentOverlay + Nav3Scenario.VIEW_PAGER2 -> Nav3Route.ViewPager2 Nav3Scenario.DEEP_LINK -> Nav3Route.DeepLink - Nav3Scenario.MULTIPANE -> Nav3Route.Multipane - Nav3Scenario.MULTIPLE_STACKS -> Nav3Route.Multistack + Nav3Scenario.CUSTOM -> Nav3Route.Custom Nav3Scenario.PERFORMANCE -> Nav3Route.Performance(index = 0, generation = 0) } diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3SampleTracing.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3SampleTracing.kt index 4a9b77227b1..a23ed37c3f6 100644 --- a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3SampleTracing.kt +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3SampleTracing.kt @@ -73,7 +73,9 @@ internal const val SENTRY_FLUSH_TIMEOUT_MILLIS = 5000L private const val BACKGROUND_WORK_MILLIS = 1000L private const val NAV2_SCENARIO_TAG = "sample_nav2_scenario" -private const val NAV3_SCENARIO_TAG = "sample_nav3_scenario" -private const val NAVIGATION_SAMPLE_SCENARIO_TAG = "sample_navigation_scenario" +internal const val NAV3_SCENARIO_TAG = "sample_nav3_scenario" +internal const val NAVIGATION_SAMPLE_ACTIVITY_TAG = "sample_navigation_activity" +internal const val NAVIGATION_SAMPLE_SESSION_TAG = "sample_navigation_session" +internal const val NAVIGATION_SAMPLE_SCENARIO_TAG = "sample_navigation_scenario" private const val UNKNOWN_NAVIGATION_SCENARIO_LABEL = "Unknown" -private const val ACTIVITY_UI_LOAD_OP = "ui.load" +internal const val ACTIVITY_UI_LOAD_OP = "ui.load" diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3SetupActivity.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3SetupActivity.kt index db945d45198..7b37bbeacab 100644 --- a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3SetupActivity.kt +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3SetupActivity.kt @@ -35,6 +35,7 @@ class Nav3SetupActivity : AppCompatActivity() { onLaunch = { val previousOptions = currentNavigationSampleConfigSnapshot() configuration.applyToCurrentOptions() + NavigationTransactionHistory.startSession() startActivity(nav3LaunchIntent(configuration, previousOptions)) }, ) diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3TransactionHistory.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3TransactionHistory.kt index 38cd6d2039b..21d99a50b2c 100644 --- a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3TransactionHistory.kt +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3TransactionHistory.kt @@ -8,12 +8,19 @@ import io.sentry.protocol.SentrySpan import io.sentry.protocol.SentryTransaction import io.sentry.samples.android.SampleBeforeSendTransactionHook -/** State holder backing the navigation sample transaction history sheet. */ -internal class NavigationTransactionHistory(private val isActive: () -> Boolean) { +/** Process-wide state holder backing every Nav3 transaction history sheet. */ +internal object NavigationTransactionHistory { - val transactions = mutableStateListOf() + private val store = NavigationTransactionHistoryStore() + @Volatile private var sessionGeneration = 0L + + val transactions: List + get() = store.transactions + + val sessionId: String + get() = sessionGeneration.toString() - private val mainHandler = Handler(Looper.getMainLooper()) + private val mainHandler by lazy { Handler(Looper.getMainLooper()) } private val transactionListener: (SentryTransaction, String?) -> Unit = { transaction, dsn -> add(transaction, dsn) } @@ -24,34 +31,56 @@ internal class NavigationTransactionHistory(private val isActive: () -> Boolean) SampleBeforeSendTransactionHook.addListener(transactionListener) } - fun uninstall() { + fun startSession() { + install() + sessionGeneration++ clear() - SampleBeforeSendTransactionHook.removeListener(transactionListener) } fun clear() { - transactions.clear() + store.clear() } private fun add(transaction: SentryTransaction, dsn: String?) { - if (!isActive()) { + val transactionSessionId = transaction.getTag(NAVIGATION_SAMPLE_SESSION_TAG) + if (transaction.getTag(NAV3_SCENARIO_TAG) == null || transactionSessionId != sessionId) { return } val trace = transaction.toNavigationTransactionTrace(dsn) if (Looper.myLooper() == Looper.getMainLooper()) { - transactions.addMostRecent(trace) + store.add(trace) } else { mainHandler.post { - if (isActive()) { - transactions.addMostRecent(trace) + // The session may have changed while this was queued. + if (transactionSessionId == sessionId) { + store.add(trace) } } } } } +internal class NavigationTransactionHistoryStore( + private val transactionLimit: Int = TRANSACTION_HISTORY_LIMIT +) { + + val transactions = mutableStateListOf() + + fun add(transaction: NavigationTransactionTrace) { + transactions.add(0, transaction) + while (transactions.size > transactionLimit) { + transactions.removeAt(transactions.lastIndex) + } + } + + fun clear() { + transactions.clear() + } +} + internal data class NavigationTransactionTrace( + val activity: String, val name: String, val operation: String, val eventId: String, @@ -73,7 +102,7 @@ internal data class NavigationTraceSpan( val children: List = emptyList(), ) -private fun SentryTransaction.toNavigationTransactionTrace( +internal fun SentryTransaction.toNavigationTransactionTrace( dsn: String? ): NavigationTransactionTrace { val trace = contexts.trace @@ -97,6 +126,7 @@ private fun SentryTransaction.toNavigationTransactionTrace( .sortedBy { span -> span.startOffsetMillis } return NavigationTransactionTrace( + activity = getTag(NAVIGATION_SAMPLE_ACTIVITY_TAG) ?: UNKNOWN_NAVIGATION_ACTIVITY, name = transaction ?: "", operation = trace?.operation ?: "transaction", eventId = eventId, @@ -109,15 +139,6 @@ private fun SentryTransaction.toNavigationTransactionTrace( ) } -private fun MutableList.addMostRecent( - transaction: NavigationTransactionTrace -) { - add(0, transaction) - while (size > TRANSACTION_HISTORY_LIMIT) { - removeAt(lastIndex) - } -} - private fun SentrySpan.toNavigationTraceSpan( transactionStartTimestamp: Double ): NavigationTraceSpan = @@ -172,5 +193,6 @@ private fun String.projectIdFromDsn(): String? = projectId.isNotEmpty() && projectId.all { it.isDigit() } } -private const val TRANSACTION_HISTORY_LIMIT = 10 +internal const val TRANSACTION_HISTORY_LIMIT = 25 private const val SENTRY_SAMPLE_ORG_SLUG = "sentry-sdks" +private const val UNKNOWN_NAVIGATION_ACTIVITY = "Unknown" diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3TransactionHistorySheet.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3TransactionHistorySheet.kt index 0e0038083ea..94a643ba7ca 100644 --- a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3TransactionHistorySheet.kt +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/navigation/nav3/Nav3TransactionHistorySheet.kt @@ -66,7 +66,8 @@ internal fun NavigationTransactionHistorySheet( modifier = Modifier.testTag(historyTag(sampleName, "title")), ) Text( - "Displays the last 10 finished transactions emitted by the SDK. Transactions with child work appear here after their work finishes.", + "Displays the last $TRANSACTION_HISTORY_LIMIT finished Nav3 transactions emitted by the SDK. " + + "Transactions with child work appear here after their work finishes.", style = MaterialTheme.typography.bodyMedium, ) Column( @@ -133,7 +134,11 @@ private fun TransactionCard( modifier = Modifier.padding(12.dp).testTag(historyTag(sampleName, "card_${index}_content")), verticalArrangement = Arrangement.spacedBy(10.dp), ) { - Row(verticalAlignment = Alignment.CenterVertically) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.Top, + ) { Column( modifier = Modifier.weight(1f).testTag(historyTag(sampleName, "card_${index}_summary")) ) { @@ -152,12 +157,20 @@ private fun TransactionCard( modifier = Modifier.testTag(historyTag(sampleName, "card_${index}_operation")), ) } - Text( - "Tab: ${transaction.tab}", - style = MaterialTheme.typography.labelLarge, - fontFamily = FontFamily.Monospace, - modifier = Modifier.testTag(historyTag(sampleName, "card_${index}_tab")), - ) + Column(horizontalAlignment = Alignment.End) { + Text( + transaction.activity, + style = MaterialTheme.typography.labelLarge, + fontFamily = FontFamily.Monospace, + modifier = Modifier.testTag(historyTag(sampleName, "card_${index}_activity")), + ) + Text( + "Tab: ${transaction.tab}", + style = MaterialTheme.typography.labelLarge, + fontFamily = FontFamily.Monospace, + modifier = Modifier.testTag(historyTag(sampleName, "card_${index}_tab")), + ) + } } TransactionWaterfall(transaction, historyTag(sampleName, "card_${index}_waterfall")) diff --git a/sentry-samples/sentry-samples-android/src/main/res/anim/nav3_slow_fade_in.xml b/sentry-samples/sentry-samples-android/src/main/res/anim/nav3_slow_fade_in.xml new file mode 100644 index 00000000000..a74b152b33c --- /dev/null +++ b/sentry-samples/sentry-samples-android/src/main/res/anim/nav3_slow_fade_in.xml @@ -0,0 +1,7 @@ + + + diff --git a/sentry-samples/sentry-samples-android/src/main/res/anim/nav3_slow_hold.xml b/sentry-samples/sentry-samples-android/src/main/res/anim/nav3_slow_hold.xml new file mode 100644 index 00000000000..8928cea2bf4 --- /dev/null +++ b/sentry-samples/sentry-samples-android/src/main/res/anim/nav3_slow_hold.xml @@ -0,0 +1,6 @@ + + + diff --git a/sentry-samples/sentry-samples-android/src/main/res/values/ids.xml b/sentry-samples/sentry-samples-android/src/main/res/values/ids.xml index 918cb756c54..a87670f6c0d 100644 --- a/sentry-samples/sentry-samples-android/src/main/res/values/ids.xml +++ b/sentry-samples/sentry-samples-android/src/main/res/values/ids.xml @@ -23,4 +23,7 @@ + + + diff --git a/sentry-samples/sentry-samples-android/src/main/res/values/styles.xml b/sentry-samples/sentry-samples-android/src/main/res/values/styles.xml index b01417199a8..73f92d1d263 100644 --- a/sentry-samples/sentry-samples-android/src/main/res/values/styles.xml +++ b/sentry-samples/sentry-samples-android/src/main/res/values/styles.xml @@ -20,6 +20,12 @@ + + +